Skip to content

refactor(settings): tab the settings window and cut what it did not need - #232

Merged
arzafran merged 13 commits into
mainfrom
refactor/settings-consolidation
Jul 31, 2026
Merged

refactor(settings): tab the settings window and cut what it did not need#232
arzafran merged 13 commits into
mainfrom
refactor/settings-consolidation

Conversation

@arzafran

Copy link
Copy Markdown
Member

What this does

Settings was one 2,340-line scroll of nine stacked sections, so finding anything meant knowing roughly how far down it lived. It is now six tabs, and several settings that existed only as taste knobs are gone rather than relocated.

Three toggles were deleted outright, not hidden. Each one's current default became the only behaviour, and the key, the schema entry, the read sites and the tests went with it. An existing settings.json that still sets a removed key is harmless: the store only reads keys it knows, so the entry is ignored rather than rejected.

Review order

The diff is large but repetitive. Start at 1 and 2; the rest is the same shape repeated.

  1. Sources/SettingsView.swift and Sources/SettingsModels.swift — the tab split and the new SettingsTab type
  2. Sources/ContentView.swift, Sources/TabManager.swift, Sources/Panels/MarkdownPanelView.swift — the behaviour that used to be conditional and now is not
  3. Sources/TerminalController*.swift, Sources/V2CommandCatalog.swift — one debug.* socket method removed
  4. tests_v2/, programaTests/ — tests repointed at single behaviours instead of toggles
  5. docs/testing-layout.md — new, explains why tests/ and tests_v2/ both exist

What was cut

Removed Now always
Sidebar tint: light hex, dark hex, opacity, reset the default tint (still tunable in the Debug window)
Workspace colour pickers and the inline palette editor settings.json manages named colours, as its own note already said
Keep Workspace Open When Closing Last Surface Cmd+W on the last surface closes the workspace
Focus Pane on First Click first click activates the window only
Rename Selects Existing Name rename opens with the name selected

Deleting the palette editor also killed an .onReceive(UserDefaults.didChangeNotification) that reloaded the whole workspace palette on every defaults write while Settings was open, and dropping first-click-focus removed a UserDefaults read from a mouse-down path.

Also here

  • Two App rows shipped bare English strings and would have stayed English in a Japanese UI. Now localized.
  • test_text_input_refresh_cost.py and test_workspace_churn_up_arrow_lag.py drive a running app over the socket but lived in tests/. Moved to tests_v2/ with their ci.yml invocations.

Test plan

  • xcodebuild -scheme programa builds
  • xcodebuild -scheme programa-unit build-for-testing builds
  • ci.yml re-validated as YAML after the path moves
  • CI: socket-integration-tests — the tests_v2 edits are unverified locally, since that suite needs a live app and is CI-only
  • CI: unit-tests — two test classes were removed with the types they covered, one repointed at the new invariant
  • Open Settings, Cmd+,, and check the six tabs fit and the first row of each clears the header

Risk

commandPaletteSearchesAllSurfaces was deliberately left alone. Unlike the three that went, both of its values are defensible, and it is threaded through ContentView in six places with its own test coverage.

arzafran added 10 commits July 31, 2026 12:02
Four of the six rows in Sidebar Appearance were per-pixel tuning of one
surface: light tint hex, dark tint hex, tint opacity, and a section-local
reset. The same four keys are already bound by the Debug window, so this
removes the copy that shipped to every user and keeps the one intended for
development.

The @AppStorage bindings stay on SettingsView because "Reset all settings"
still writes them, which is what restores anyone who set a tint while the
rows existed.

Sidebar Appearance goes from six rows to two: Match Terminal Background and
Show Claude Quota, both of which change behaviour rather than pixels.

Also drops the nine localization keys that went dead with the rows.
settings.sidebarAppearance.defaultLabel is deliberately kept -- the Workspace
Colors rows still use it.
Workspace Colors carried a full palette CRUD editor inside the settings
panel: a hex picker and Remove button per named color, plus a note telling
you to edit settings.json for the same thing. It also had selection-highlight
and notification-badge hex pickers, which are the same per-pixel tuning the
sidebar tints were.

All of it goes. settings.json stays the way to manage named colors, which is
what the note already said. "Reset all settings" still calls
WorkspaceTabColorSettings.reset() and nils both hex keys, so anything set
while these rows existed is recoverable.

The section keeps one row, Workspace Color Indicator, which changes what the
sidebar draws rather than what shade it draws it in.

Removing the editor made a whole chain dead: workspaceTabPaletteEntries and
the four helpers that maintained it, an onAppear refresh, and an onReceive on
UserDefaults.didChangeNotification that reloaded the palette on every defaults
write. That last one ran for every settings change while the window was open.

Also drops nine localization keys, including
settings.sidebarAppearance.defaultLabel, whose last two users were the hex
pickers removed here.
"Desktop Notifications" and "Notification Command" passed raw string
literals for title, subtitle and placeholder, so they stayed English in a
Japanese UI. Every other row in the file uses String(localized:).

Found while inventorying the App section for the settings cut.
Settings was one scroll of nine stacked sections, so finding anything meant
knowing roughly how far down it lived.

Sections that answer the same question are merged: Workspace Colors and
Sidebar Appearance become Appearance, Custom Commands joins Automation, and
Reset sits with the general app switches it resets. That gives General,
Appearance, Automation, Phone, Browser.

Keyboard Shortcuts stays on its own. It renders one row per
KeyboardShortcutSettings.Action, and there are 57 of them, so it would swamp
whatever it shared a tab with.

The tab strip lives in the header overlay rather than the scroll content, so
it stays put while a tab's rows scroll under the existing blur. The header
grows from 62pt to 96pt and the content inset moves with it.

SettingsNavigationRequest deep links still work: the handler selects the
owning tab before scrolling, because an anchor only exists while its tab is
selected. browserImport is the one that still needs the scroll, since it sits
mid-tab rather than at the top.
…ggle

Cmd+W on a workspace's last surface now always closes the workspace, which
was already the default. The opt-out is gone.

Removed end to end rather than just hidden: the row and its inverted binding
in SettingsView, the LastSurfaceCloseShortcutSettings enum in TabManager,
both the parse and emit sides of keepWorkspaceOpenWhenClosingLastSurface in
ProgramaSettingsFileStore, and three localization keys.

An existing settings.json that still sets keepWorkspaceOpenWhenClosingLastSurface
is harmless: the store only reads keys it knows about, so the entry is
ignored rather than rejected.

shouldCloseWorkspaceOnLastSurfaceShortcut keeps its other two conditions, so
the close button still routes through Workspace's explicit-close path.
Clicking into an inactive Programa window now always activates the window
without also focusing the pane under the pointer, which was already the
default. The opt-in is gone.

Every read site was gated on a flag that is now permanently false, so the
gates go with it:

- GhosttyNSView and ProgramaWebView lose their acceptsFirstMouse overrides
  entirely. NSView already returns false, so the overrides were restating the
  default and reading UserDefaults on a mouse-down path to do it.
- MarkdownPanelPointerObserverView keeps its hitTest override, now returning
  nil unconditionally. Deleting it would have fallen back to NSView's default
  hit-testing, which is the opposite of what the view did.
- Its acceptsFirstMouse override goes, and the dead branch in shouldHandle
  goes with it.

Also removes the PaneFirstClickFocusSettings enum, both schema sides of
focusPaneOnFirstClick, and three localization keys.

InactivePaneFirstClickFocusTests now pins the single behaviour instead of
exercising both sides of a toggle, including that writing the old defaults
key no longer re-enables click-through. That last one is the real regression
guard: a live read left behind would make the removal cosmetic for anyone
whose settings.json still carries the key.

Also drops LastSurfaceCloseShortcutSettingsTests, which covered the enum
deleted in the previous commit. That commit only built the app target and
missed it.
Command-palette rename now always opens with the existing name selected, so
typing replaces it. That was already the default.

This one reached further than the other cuts because the setting had a v2
socket method behind it so UI tests could flip it:

- Sources: the CommandPaletteRenameSelectionSettings enum, the @AppStorage in
  CommandPaletteController, a dead read/write proxy in ContentView, both
  schema sides of renameSelectsExistingName, and the settings row.
- Socket API: debug.command_palette.rename_input.select_all is gone, along
  with its catalog entry, its dispatch case, and the two client helpers in
  tests_v2/cmux.py. It is a debug.* method, not part of the stable surface.
- ContentView's rename focus policy now asks for .selectAll directly. Other
  focus policies still use .caretAtEnd, so that case stays.

Test changes, rather than deletions, where the coverage was still worth
having. test_command_palette_rename_select_all.py kept its eight stress
cycles proving the selection survives interaction with the field, and lost
only the half that toggled the setting off.
test_command_palette_backspace_go_back.py no longer pins the setting to get a
deterministic starting state, because the behaviour is now guaranteed.
CommandPaletteRenameSelectionSettingsTests is gone with the enum it covered.

Both the app and the unit-test target build. tests_v2 is CI-only and unrun
here.
tests/ and tests_v2/ both held python, which made the split look like
versioning. It is not: tests_v2 is named for the v2 socket API, and every
file there drives a running app over the control socket.

Two files were on the wrong side of that line.
test_text_input_refresh_cost.py and test_workspace_churn_up_arrow_lag.py both
connect to a live instance, so they move to tests_v2 and their ci.yml
invocations move with them. The three python files left in tests/ drive the
programa CLI binary as a subprocess and need no app, which is what tests/ is
for alongside the shell script guards.

Adds docs/testing-layout.md explaining the four harnesses, what _v2 actually
refers to, and which directory a new test belongs in. That question came up
because nothing in the repo answered it.
CI caught three tests that still exercised the preference deleted earlier in
this branch. They compiled because they write the raw defaults key rather
than referencing the enum, so only running them surfaced it:

- TabManagerCloseCurrentPanelTests.testCloseCurrentPanelKeepsWorkspaceOpen...
  failed outright. It set the key and expected the workspace to survive.
- testClosePanelButtonStillClosesWorkspaceWhenKeepWorkspaceOpen... still
  passed, but only by duplicating the plain close-button test now that the
  preference does nothing.
- AppDelegateShortcutRoutingTests.testCmdWKeepsLastSurfaceWorkspaceOpen...
  had the same premise.

Replaced with one guard modelled on the two-workspace close test that already
passes, writing the removed key first and asserting it changes nothing. That
covers what actually matters: a stale settings.json entry must not bring the
old behaviour back.

The earlier cut verified against Sources/ and CLI/ but not programaTests/,
which is how these survived.
The Browser section rendered a mock of the blank-tab import hint, reusing the
hint's own strings. That included its footnote, "You can always find this in
Settings > Browser", displayed to someone already looking at Settings.

The Choose and Refresh buttons directly below it already offer the action the
mock was advertising, so the mock only added noise. The real card is
unchanged in BrowserToolbarViews, and both strings are still used there.

browserImportSubtitle went with it, since the mock was its only caller.
arzafran added 2 commits July 31, 2026 13:14
…owser-cuts

refactor(settings): stop duplicating the import hint inside Settings
Pairing offered three entry points and two buttons for a single action, and
the two devices did not agree on what pairing even looked like.

The phone had: scan a QR, paste a combined code with a "Use This Code"
button, and an Advanced section with separate ticket and token fields behind
its own "Connect" button. Two buttons that both connect, one of which only
worked with the fields nobody was meant to use. Its own doc comment called
the split fields legacy.

The Mac had: a QR, the combined code shown as selectable text with a Copy
button, and below that a "Can't scan? Paste the payload and token manually"
disclosure repeating the same secret split in two.

Both legacy paths are gone. The Mac shows the QR and the one combined code
that the QR encodes; copying that is the fallback for anyone who cannot scan.
The phone scans or pastes that code, and its single button is now called
Connect because connecting is what it does.

The ticket and token drafts stay on AppStore, since applyPairingCode still
fills them and connectManually still reads them. They are just no longer
bound to fields. The Connect button also picks up the isConnecting guard the
removed button used to own.

Both apps build. Nine localization keys dropped across the two catalogs, and
pairing.connect.useCodeButton retranslated for its new label.
…anup

refactor(pairing): one way in on each side instead of three
@arzafran
arzafran merged commit 749dd96 into main Jul 31, 2026
11 checks passed
@arzafran
arzafran deleted the refactor/settings-consolidation branch July 31, 2026 18:04
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