Repository navigation
feat(ui): redesign the interface around native macOS chrome - #13
Merged
Merged
Conversation
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.
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.
8 of 9 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
isDarkderived 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
What changed
Foundations —
ThemegainscolorScheme, aChromegroup and aSemanticgroup; the accent was previouslyTheme.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 inCodeEditorCorewith the grid enforced by a test.Typographymaps 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...Openedheader; 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,SessionStoreinto Foundation-only Core so the view layer's new logic is testable. 35 → 68 tests.Testing
swift buildpassesswift testpasses — 68 tests in 16 suitesswift-format lint --strictcleanManually 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
feature/…,fix/…,chore/…,docs/…ortest/…