Skip to content

feat(ui): redesign the interface around native macOS chrome - #13

Merged
Yan123-tech merged 23 commits into
mainfrom
feature/ui-redesign
Oct 10, 2026
Merged

Yan123-tech merged 23 commits into
mainfrom
feature/ui-redesign

Conversation

@Yan123-tech

Copy link
Copy Markdown
Owner

Summary

Rebuilds the window around native macOS chrome and moves every visual decision out of the views and into a design system. The two defects behind the "looks like a prototype" impression are fixed first: isDark derived appearance from colour arithmetic that returns false for any neutral background, so Dark and High Contrast forced light system chrome; and the tab strip swapped the close button for the dirty dot, making modified tabs impossible to close without saving.

Finishes before features, deliberately — the find panel is upstream AppKit, so dropping it into an unfinished shell would have made the gap more obvious, not less.

Type

  • Bug fix
  • New feature
  • Refactor
  • Docs / build / CI

What changed

Foundations — Theme gains colorScheme, a Chrome group and a Semantic group; the accent was previously Theme.keyword, so the tab underline, folder icons, terminal prompt and terminal errors all changed meaning with the syntax palette. Metrics (4pt grid, radii, chrome heights) lands in CodeEditorCore with the grid enforced by a test. Typography maps roles to semantic styles, so chrome scales with system text size while code stays fixed-size SF Mono.

Finish — unified toolbar with breadcrumb replacing the truncated No Fold...Opened header; tab strip whose active tab merges into the canvas; one adaptive empty state instead of two competing ones; status bar that reports encoding and language instead of a byte count; materials the sidebar had been overriding.

Features — ⌘F via the source editor's own panel, ⌘P quick open, drag-resized terminal, session restore.

Extraction — FileOutline, QuickOpenMatcher, QuickOpenIndex, SessionStore into Foundation-only Core so the view layer's new logic is testable. 35 → 68 tests.

Testing

  • swift build passes
  • swift test passes — 68 tests in 16 suites
  • swift-format lint --strict clean
  • Manually verified in the app

Manually verified by driving the running build and inspecting the accessibility tree: session restore reopens the workspace and tabs; folder expansion, toolbar, breadcrumb, status bar values and the tab close button are present; ⌘F opens the panel (the editor's scroll area shifts by the panel height).

What I did not verify: CI does not build this package (MEMORY.md #1) — nothing here was checked by CI, only by the local gate. I also could not visually review the design: screenshots capture fine but this session could not read images, so spacing, materials, contrast and the tab/canvas merge are unconfirmed by eye. Synthetic accessibility events do not reliably reach SwiftUI tree rows or the text view, so mouse clicks on rows and the positive selection case were verified indirectly (arrow keys share the selection path; the selection arithmetic is covered in Core) rather than directly.

Known limitations

Recorded in CURRENT_STATE.md: the quick-open index is a filesystem snapshot, not an FSEvents stream; session restore stores plain paths and is only correct unsandboxed; untitled documents do not restore; the app is single-window.

Checklist

  • Commits follow Conventional Commits
  • Branch is named feature/…, fix/…, chore/…, docs/… or test/…
  • New public APIs have doc comments
  • No unrelated changes bundled in

yan added 17 commits October 9, 2026 20:17
SwiftPM produces a bare Mach-O binary. macOS needs the bundle
layout before the process gets a Dock tile, a menu bar and an
open-panel identity. build-app.sh lays out Contents/{MacOS,Resources},
embeds the SwiftPM resource bundles Bundle.module resolves through
Bundle.main.resourceURL, renders AppIcon.icns from make-icon.swift
and ad-hoc signs the result.

Verified: Scripts/build-app.sh produces a valid, launching 105 MB
bundle from a clean release build.
isDark compared background.red < background.blue, which is false for
any neutral background: #1e1e1e is 30 < 30. Dark and High Contrast
therefore forced .preferredColorScheme(.light), so every system-drawn
control — sidebar rows, sheets, buttons, open panels — rendered light
against a dark canvas.

Theme now declares colorScheme per palette and isDark reads it. Adds
CodeEditorThemes to the test target; the suite fails against the old
comparison.
Theme gains a Chrome group (accent, sidebar, rows, bars, panels,
overlays, borders, usesMaterials) and a Semantic group (danger,
success, warning). statusBarBackground and separator move into
chrome.barBackground and chrome.border.

The accent was previously theme.keyword, so the tab underline, folder
icons, terminal prompt and terminal errors all changed meaning with
the syntax palette. Each of those now reads a chrome or semantic token
that means one thing. The terminal connection dot uses semantic
success/danger instead of raw Color.green/.red.

CodeEditorThemes splits into Theme, Chrome, EditorColor and a slimmer
ThemeManager, one type per file.
…ghts

The chrome previously used paddings of 2, 3, 4, 5, 6, 8 and 10 in
seven combinations, fixed heights of 28 and 20, and zero corner radii
anywhere. Metrics centralises the replacements: a 4pt spacing scale,
radii keyed by surface, and the chrome heights. The grid itself is
enforced by test.
Thirteen hardcoded .system(size:) calls in the chrome are replaced by
named roles over semantic styles, so sidebar, tabs, bars and overlays
scale with the user's text size. The code font stays fixed-size and
owned by EditorState; code reflowing under Dynamic Type mid-edit is
worse than the accessibility it would buy.
reveal(url:) expands every directory between the root and a file so
the sidebar can show the document that just opened, without re-reading
ancestors that are already expanded. clearError() exists because the
sidebar's alert binding only cleared the view's own error copy, so
dismissing it left the manager's error set and the alert re-presented
immediately.
Selection drives behaviour: clicking a file opens it and clicking a
folder selects it, the Xcode navigator model, replacing the reversed
scheme where a single click set an invisible create-target and only a
double-click expanded a folder. The disclosure triangle is the only
thing that toggles expansion. The create-target concept no longer has
any UI.

The hand-rolled header — a bold 12pt label that truncated 'No Folder
Opened' to 'No Fold...Opened' next to two unstyled buttons — is gone;
the toolbar owns the project identity now. The sidebar stops
overriding the system material with scrollContentBackground(.hidden)
plus a flat fill, so .sidebar renders its own vibrancy.

The active document is revealed by expanding its ancestors, shown
bold with an accent icon, and drives List selection. Delete gains a
confirmation dialog. Error alerts clear both error copies, fixing the
re-present loop. Sheets use grouped forms on the metrics grid.
…state

The window gains a real toolbar: breadcrumb in the principal slot,
create menu and terminal toggle trailing. The sidebar's hand-rolled
header is gone. One create menu replaces two icon buttons; new items
land beside the active document, the Xcode rule.

The tab strip moves from 28pt of hardcoded sizes to the metrics grid:
32pt, active tab filled with the canvas colour and rounded so it reads
as the top of the editor, hover wash on inactive tabs, dirty dot beside
the title, and a close button that is always present — swapping × for
a dot had made modified tabs impossible to close without saving.

The status bar drops the byte count and gains encoding, line ending
and language, grouped with hairline separators.

One empty state, in the canvas, adapting to what is missing: with a
workspace it points at the sidebar; without one it offers Open Folder
as the single prominent action. Previously both panes carried their
own empty message in different capitalisations with duplicate buttons.

Closing a modified document now routes through a SwiftUI confirmation
dialog instead of NSAlert.runModal(), which blocked the main thread
and could not share styling with the rest of the chrome.
FileOutline extracts the sidebar's tree flattening behind closures so
the traversal — depth cap, expansion recursion — is testable without a
window server. QuickOpenMatcher is a greedy-earliest subsequence scorer
with weights for filename over directory matches, segment starts and
consecutive runs; highlight ranges are UTF-16 offsets into the original
string. QuickOpenIndex walks the workspace off the main actor in
batches, skips .git/.build/node_modules/DerivedData/Pods, caps at
20k entries, and drops results from a superseded walk via a generation
counter.

The index is a snapshot, not an FSEvents stream: it refreshes on
reindex, so new files are not findable until then. Recorded as a
limitation on the type.
Find and replace reuses CodeEdit's own panel: the upstream coordinator
calls showFindPanel() when SourceEditorState.findPanelVisible
disagrees with the panel, so ⌘F sets a flag and the panel keeps itself
the authority on visibility. The editor view syncs both directions.

The editor now sets its peripherals explicitly. They were previously
left at upstream defaults, which silently enabled the minimap and the
folding ribbon. Minimap is off by default with a toggle; invisibles
and the folding ribbon are toggles too; line height moves to 1.35 from
1.2; bracket emphasis becomes a persistent underline; smart quotes and
invisible spaces are flagged as warnings.

Quick open (⌘P) scores a bounded filesystem index and shows matched
characters emphasised over the dimmed path. It is a snapshot, not an
FSEvents stream, so files created after the last walk are not
findable until the next reindex — recorded on the index type.

The terminal panel is drag-resized between 120 and 600 points and
persists, replacing a fixed 220. Its header drops the raw red/green
dot and the text Clear/Restart buttons for a semantic status dot and
icon buttons. Output selection is enabled so text can be copied.

The window becomes a single Window rather than a WindowGroup; a second
window would have shared one AppState, one document set and one
sidebar. On launch the workspace root, open tabs and active tab are
restored from UserDefaults. Untitled documents are not restored — they
have no stable identity across launches.
Selecting a folder row now expands it, the Xcode rule; the
disclosure triangle stops being the only way in. Activation moved off
the selection change onto an explicit row tap, because driving
expansion from selection meant arrowing down the tree toggled every
folder on the way. Arrow keys now collapse and expand the selected
folder and Return opens files, the outline-view convention.
A row tap gesture inside a selection List is swallowed by the List's
own gesture handling: clicking a folder selected it without expanding
anything. Activation now hangs off the selection change, which is what
List actually delivers — and which is the Xcode navigator's behaviour
anyway, since it also expands folders as you arrow through them.

The disclosure chevron becomes decorative. With the whole row
activating, a second tap target would toggle expansion twice.

Verified in the running app: arrowing onto a folder expands it.
CURRENT_STATE gains a description of what the window is now, and states
the four honest limitations: the quick-open index is a snapshot rather
than an FSEvents stream, session restore stores plain paths and is only
valid unsandboxed, untitled documents do not restore, and the app is
single-window. Test counts and module sizes re-measured against the
tree.

MEMORY.md gains five traps this work cost: never derive appearance from
colour arithmetic (#11), a tap gesture inside a selection List is
swallowed so row behaviour must hang off selection (#12), a split-view
column is sized in exactly one place (#13), SessionStore's plain paths
are only valid unsandboxed (#14), and .prominent is iOS (#15).

AGENTS.md gains the two design rules that are now conventions: chrome
never borrows a syntax token, and no magic numbers in chrome. README
documents ⌘F, ⌘P, session restore and where the tokens live.
ARCHITECTURE records the design-system split between Themes (colours,
type) and Core (metrics), so Core keeps its Foundation-only rule.
…t selects

build-app.sh passed --show-bin-path to both of its build commands,
which locates the output rather than producing it. It therefore only
worked when something else had already run swift build — and failed on
a clean checkout with 'No such file or directory' pointing at a binary
that had never been built. Found by running it from a clean state, which
is the only way it ever fails.

Document.selectedLines(for:) returns nothing for a collapsed selection.
EditorState derived selectedLines from start.line...end.line, which for
a caret is one line, so the status bar read '1 line selected' on every
freshly opened file. The line-count arithmetic moves to Core where it
is testable; EditorState now just reads it.
MEMORY.md gains #16 (a build step that passes while doing nothing, and
how to actually exercise it) and #17 (a caret is not a selection, and a
user-facing summary is a claim about state rather than about a range).
Test counts and module sizes re-measured: 68 tests, Core 1451 lines.
Three claims had drifted from the code, and a stale document is worse
than a missing one because it is trusted.

MEMORY #8 and STACK.md both said the CodeEditSourceEditor exposure was
contained to two files. Three import it: EditorState.swift has imported
it for IndentOption all along. Counted, corrected, and the rule is now
'keep it at three' with the third named and the reason it is thin.

STACK.md and ARCHITECTURE.md both described a UI-to-App notification
seam. There was no seam left. The sidebar lost its Open Folder button
when the empty state moved to the canvas, which left
.codeEditorOpenFolder with a subscriber and no publisher; and
.codeEditorOpenFiles posted and observed inside CodeEditorApp, a round
trip to nowhere. Both names are deleted and presentOpenFilePanel calls
AppState directly.

Session restore was described as restoring 'the workspace and tabs',
which reads as resuming work. It persists paths and never buffer
content, so quitting with unsaved changes loses them silently while the
tab still reopens. Stated plainly in CURRENT_STATE, README and the type's
own MEMORY entry.

Also: PROCESS and PROJECT_PLAN still claimed 35 tests, README's module
table predated the Core and Themes additions, ARCHITECTURE had no
account of the find-panel sync, session restore or quick open flows, and
two anchors in AGENTS.md pointed at headings that do not exist.
yan added 6 commits October 10, 2026 00:14
Issue #14 tracks the redesign. CURRENT_STATE's Next section no longer
lists the six issues in numeric order, because the redesign changed
which are cheap: it recommends guarding unsaved work on quit first
(the only remaining path to data loss, and not an issue yet), then #1
because SourceEditor already takes a completionDelegate and the
suggestion UI ships upstream, then #6, then #2 and #3 together since
they share the coordinator #1 proves.

It also says what is deliberately not next — more themes, multi-window,
Finder file associations — because a recommendation that only says what
to do leaves the obvious options open.

The gap itself is stated in Not working rather than only in the
roadmap: there is no applicationShouldTerminate handler, so the app
does not ask before losing unsaved edits.

PROJECT_PLAN gains #14, two unchecked gates for the data-loss guard and
the visual review, and two risks this round earned: a build script that
passes while doing nothing, and a document that outlives its code.
Every tab showed the same file. SourceEditor pushes text into its text
view exactly once, in makeNSViewController; updateNSViewController
diffs language, configuration and highlight providers, never the text
behind the Binding<String>. Switching tabs therefore kept the previous
document on screen. DocumentTextCoordinator captures the controller in
prepareCoordinator — the one hook that hands it out — and CodeEditorView
reloads it when the document changes. Verified in the running app:
switching tabs now changes the editor from README content to index.html
content.

A folder's chevron stopped working once the folder was selected.
Expansion hangs off the selection change, and clicking an already
selected row changes nothing, so no toggle fired. The chevron is a real
Button again; it selects the row first so the two paths cannot cancel.

The line-number gutter bled onto the tab labels while rubber-band
scrolling. The strip sits flush against the editor and a translucent
material lets an overscroll NSScrollView draw through it. Both bars now
carry an opaque canvas base under the material.

Also: the tab label was rendering both a dirty dot and the " *" that
Document.title appends. One state, one indicator.
#15 every tab showed the first file opened — upstream does not diff text
on update, and the coordinator that works around it looks redundant
enough that the next person would delete it without reading why.
MEMORY.md #18 exists for that, with the upstream code quoted and the
.id(document.id) fallback recorded.

#16 a selected folder's chevron did nothing — the direct consequence of
driving expansion off the selection change. Cross-linked from #12, which
explains why the tap gesture was removed in the first place.

#17 the gutter drew through the translucent tab strip. Marked honestly as
a hypothesis rather than a diagnosis: it cannot be reproduced
synthetically, and the competing explanation is the editor's content
insets, with the next thing to try written down.

CURRENT_STATE now records that the visual review happened and found
three things, none of which were visible without someone looking at the
running app. ARCHITECTURE accounts for the fourth upstream-touching file.
#15, #16 and #17 are confirmed fixed in the running app, so the caveats
about them not being drivable synthetically come out of CURRENT_STATE.

The real lesson is worth writing down: none of the three was caught by
68 passing tests. The tab bug lives in upstream's view-update logic, not
in anything CodeEditorCore owns, so there was nothing to assert. PROCESS
now requires opening two files and switching between them by hand when
the editor binding is touched.

MEMORY #18 records the same fact. A gate that only runs what is
automatable is not a gate.
applicationShouldTerminate returns .terminateCancel while modified
documents exist, so quitting no longer discards edits silently — the
only remaining path to data loss in the app.

The decision is DocumentManager.saveAllModified(save:) in Core, with
the save action injected, because saving properly needs UI (an untitled
document requires a location panel) while the decision does not. It
returns whether every document actually saved, judged by whether it is
still modified afterwards — which catches a cancelled panel and a failed
write alike, without the caller knowing why.

NSAlert rather than a SwiftUI alert here, deliberately: the delegate
must answer synchronously and .terminateCancel is what stops the quit,
neither of which a view-bound alert can do. A re-entrancy flag lets the
second applicationShouldTerminate pass once the user has decided.

Five tests cover it, including the dangerous case — a save that does not
take must report failure, because the caller is about to discard the
buffer. Two of those tests failed while being written and both times the
test was wrong, not the code: a fresh Document is clean, and setContent
cannot stand in for save() because it sets isModified rather than
clearing it.

Verified in the running app: a dirty document blocks the quit and Cancel
returns to the editor; a clean document quits freely.
CURRENT_STATE moves the data-loss gap out of 'Not working' and into
'Working', but keeps the part that is still true: force-quit and
crashes still lose buffers, because nothing persists unsaved content.
The roadmap loses its top item and the remaining five renumber.

Sizes and test counts re-measured: 73 tests, Core 1478, UI 1904, App 559.
@Yan123-tech
Yan123-tech merged commit c64d08e into main Oct 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant