Yep. AI rewrote the whole thing.

This commit is contained in:
2026-03-13 03:24:24 +11:00
parent e4719cb9f4
commit fe6c7d8c12
47 changed files with 5348 additions and 1182 deletions

View File

@@ -0,0 +1,885 @@
# Workspace Architecture Spec
## Purpose
This document defines the target architecture for refactoring CommandNotch into a maintainable, testable macOS app with:
- explicit runtime state ownership
- virtual workspaces shared across screens
- typed settings instead of `UserDefaults` as the event bus
- reduced singleton coupling
- a realistic testing strategy for logic, integration, and UI
This is the implementation reference for the refactor.
## Goals
- Preserve the current product behavior where possible.
- Support multiple virtual workspaces.
- Allow multiple screens to point at the same workspace.
- Allow screens to switch workspaces independently.
- Keep screen-specific UI state local to each screen.
- Make business logic unit testable without real `NSWindow`, `NSScreen`, or `UserDefaults`.
- Keep AppKit/SwiftUI at the edges.
## Non-Goals
- Multi-window rendering of the same live terminal instance at the same time.
- Full state restoration of shell process contents across launches.
- Perfect backward compatibility for internal architecture.
- Solving all visual polish issues during the refactor.
## Constraints
- A `TerminalView` cannot safely belong to more than one window/view hierarchy at once.
- A `LocalProcess`-backed session is inherently stateful and UI-coupled through SwiftTerm.
- The app is macOS-only and already depends on AppKit + SwiftUI + SwiftTerm.
## Target Mental Model
There are three layers of state:
1. Global app state
Global settings, hotkeys, launch behavior, workspace metadata, screen assignments.
2. Workspace state
Tabs, active tab, terminal sessions, workspace title, workspace-local behavior.
3. Screen state
Which workspace a screen is viewing, notch geometry, open/closed state, hover state, focus state.
The current app blurs these boundaries. The refactor makes them explicit.
## High-Level Architecture
### Composition Root
`AppDelegate` becomes the composition root. It wires concrete implementations together and passes them into coordinators/controllers. It should be the only place that knows about most concrete types.
### Core Modules
- `AppSettingsStore`
Loads and saves global settings. Publishes typed changes.
- `WorkspaceRegistry`
Owns all workspaces and workspace metadata.
- `ScreenRegistry`
Tracks connected screens and their screen-local state.
- `NotchOrchestrator`
Coordinates notch lifecycle per screen.
- `WindowCoordinator`
Owns AppKit windows/panels and binds them to screen models.
- `HotkeyService`
Registers global and local hotkeys and emits typed intents.
- `SettingsCoordinator`
Presents settings UI.
- `PopoutCoordinator`
Presents detached terminal windows.
### UI Boundaries
- SwiftUI views render state and emit intents only.
- AppKit controllers own `NSWindow`, `NSPanel`, `NSHostingView`.
- SwiftTerm integration is behind a terminal session abstraction.
## Ownership Model
### AppSettings
Owns:
- app-wide appearance settings
- hotkey bindings
- launch-at-login
- menu bar visibility
- default workspace behavior
- workspace assignment persistence
Does not own:
- transient window geometry
- active terminal sessions
- per-screen hover/open state
### Workspace
Owns:
- `workspaceID`
- display name
- ordered tabs
- active tab selection
- detached tab metadata if retained
- workspace-local terminal state
Does not own:
- screen assignment
- notch geometry
- whether a screen is open or closed
### ScreenContext
Owns:
- `screenID`
- assigned `workspaceID`
- notch presentation state
- current notch frame/size
- hover state
- local focus state
- local transition state
Does not own:
- tabs
- terminal session collection
- app-wide settings
## Required Domain Types
### Identifiers
```swift
typealias WorkspaceID = UUID
typealias ScreenID = String
typealias TabID = UUID
typealias SessionID = UUID
```
### App Settings
```swift
struct AppSettings: Equatable, Codable {
var showMenuBarIcon: Bool
var showOnAllDisplays: Bool
var launchAtLogin: Bool
var appearance: AppearanceSettings
var animation: AnimationSettings
var terminal: TerminalSettings
var hotkeys: HotkeySettings
}
```
### Workspace Summary
```swift
struct WorkspaceSummary: Equatable, Codable, Identifiable {
var id: WorkspaceID
var name: String
var createdAt: Date
}
```
### Workspace Assignment
```swift
struct ScreenWorkspaceAssignment: Equatable, Codable {
var screenID: ScreenID
var workspaceID: WorkspaceID
}
```
### Screen UI State
```swift
struct ScreenUIState: Equatable {
var screenID: ScreenID
var workspaceID: WorkspaceID
var notchState: NotchPresentationState
var notchSize: CGSize
var closedNotchSize: CGSize
var isHovering: Bool
var isFocused: Bool
var transitionState: NotchTransitionState
}
```
### Workspace State
```swift
struct WorkspaceState: Identifiable {
var id: WorkspaceID
var name: String
var tabs: [TerminalTabState]
var activeTabID: TabID?
}
```
### Tab State
```swift
struct TerminalTabState: Identifiable {
var id: TabID
var sessionID: SessionID
var title: String
}
```
### Notch Lifecycle State
```swift
enum NotchPresentationState: Equatable {
case closed
case open
}
enum NotchTransitionState: Equatable {
case idle
case opening
case closing
case resizingUser
case resizingPreset
}
```
## Target Runtime Objects
### `AppController`
Top-level coordinator created by `AppDelegate`.
Responsibilities:
- boot app services
- respond to lifecycle events
- connect hotkey intents to workspace/screen actions
- own references to main long-lived services
### `WorkspaceRegistry`
Responsibilities:
- create workspace
- delete workspace
- rename workspace
- fetch workspace by id
- publish workspace list changes
- ensure at least one workspace exists
Suggested API:
```swift
@MainActor
protocol WorkspaceRegistryType: AnyObject {
var workspaceSummariesPublisher: AnyPublisher<[WorkspaceSummary], Never> { get }
func allWorkspaceSummaries() -> [WorkspaceSummary]
func workspaceController(for id: WorkspaceID) -> WorkspaceControllerType?
func createWorkspace(named: String?) -> WorkspaceID
func deleteWorkspace(id: WorkspaceID)
func renameWorkspace(id: WorkspaceID, name: String)
func ensureWorkspaceExists() -> WorkspaceID
}
```
### `WorkspaceController`
Replaces most of `TerminalManager`.
Responsibilities:
- own tabs for a single workspace
- create and close tabs
- detach tabs
- switch active tab
- update session appearance when settings change
- publish workspace state
Suggested API:
```swift
@MainActor
protocol WorkspaceControllerType: ObservableObject {
var state: WorkspaceState { get }
func newTab()
func closeTab(id: TabID)
func closeActiveTab()
func switchToTab(id: TabID)
func switchToTab(index: Int)
func nextTab()
func previousTab()
func detachActiveTab() -> TerminalSessionType?
func updateTheme(_ theme: TerminalTheme)
func updateFontSize(_ size: CGFloat)
}
```
### `ScreenRegistry`
Responsibilities:
- discover connected screens
- maintain `ScreenContext` for each screen
- maintain screen-to-workspace assignment
- rebuild state on screen changes
Suggested API:
```swift
@MainActor
protocol ScreenRegistryType: AnyObject {
var screenContextsPublisher: AnyPublisher<[ScreenContext], Never> { get }
func allScreens() -> [ScreenContext]
func screenContext(for id: ScreenID) -> ScreenContext?
func assignWorkspace(_ workspaceID: WorkspaceID, to screenID: ScreenID)
func activeScreenID() -> ScreenID?
func refreshConnectedScreens()
}
```
### `ScreenContext`
Observable object for one physical display.
Responsibilities:
- store local UI state
- expose a current `workspaceID`
- emit user intents for local notch behavior
This should replace todays overloaded `NotchViewModel`.
### `NotchOrchestrator`
Responsibilities:
- open/close notch for a screen
- coordinate focus, hover, suppression, and transitions
- enforce transition rules
- drive frame updates indirectly through window coordination
The orchestrator should own the state machine, not `ContentView`.
### `WindowCoordinator`
Responsibilities:
- create/destroy one `NotchWindow` per screen
- bind `ScreenContext` to window content
- update window frames
- respond to `resignKey`
- focus the correct terminal view
This keeps AppKit-specific code out of registry/controller classes.
### `HotkeyService`
Responsibilities:
- register global hotkey bindings
- observe only hotkey-related settings changes
- emit typed commands
Suggested intent enum:
```swift
enum AppCommand {
case toggleNotch(screenID: ScreenID?)
case newTab(workspaceID: WorkspaceID)
case closeTab(workspaceID: WorkspaceID)
case nextTab(workspaceID: WorkspaceID)
case previousTab(workspaceID: WorkspaceID)
case switchToTab(workspaceID: WorkspaceID, index: Int)
case detachTab(workspaceID: WorkspaceID)
case applySizePreset(screenID: ScreenID, presetID: UUID)
case switchWorkspace(screenID: ScreenID, workspaceID: WorkspaceID)
case createWorkspace(screenID: ScreenID)
}
```
## Terminal Session Design
### Current Problem
`TerminalSession` mixes:
- process lifecycle
- SwiftTerm view ownership
- delegate callbacks
- some UI-facing published state
This makes reuse difficult.
### Target Split
Introduce:
- `TerminalSession`
Process + session metadata + delegate event translation
- `TerminalViewHost`
Owns a `TerminalView` for one active screen/window
Minimal first-step compromise:
- Keep `TerminalSession` owning one `TerminalView`
- Enforce that only one screen at a time can actively render a given workspace
- Make that rule explicit in the architecture
Recommended v1 rule:
- many screens may point at the same workspace
- only one screen may have that workspace open and focused at a time
This avoids trying to mirror one live terminal view into multiple windows.
## Settings Design
### Current Problem
`@AppStorage` is scattered across many views and managers, and `UserDefaults` changes trigger broad runtime work.
### Target Design
Introduce:
- `AppSettingsStore`
Persistence boundary
- `AppSettingsController`
In-memory observable runtime settings
Suggested pattern:
```swift
protocol AppSettingsStoreType {
func load() -> AppSettings
func save(_ settings: AppSettings)
}
@MainActor
final class AppSettingsController: ObservableObject {
@Published private(set) var settings: AppSettings
func update(_ update: (inout AppSettings) -> Void)
}
```
Views bind to `AppSettingsController`.
Services subscribe to precise sub-slices of settings.
## Persistence Design
Persist only durable data:
- app settings
- workspace summaries
- screen-to-workspace assignments
- optional workspace-local preferences
- size presets
Do not persist:
- live `LocalProcess`
- active shell buffer contents
- transient hover/focus/transition state
Suggested storage keys:
- `appSettings`
- `workspaceSummaries`
- `screenAssignments`
- `terminalSizePresets`
Prefer a single encoded settings object per concern over many independent keys.
## UI Structure
### Root Views
- `NotchRootView`
- `ClosedNotchView`
- `OpenWorkspaceView`
- `TabStripView`
- `WorkspacePickerView`
- `SettingsRootView`
### View Rules
- Views may read state from observable objects.
- Views may emit intents via closures or controller methods.
- Views do not call global singletons.
- Views do not own delayed business logic unless it is purely visual.
## Workspace UX Rules
- Every screen always has an assigned workspace.
- A new screen defaults to:
- the app default workspace strategy, or
- the first existing workspace
- Users can:
- switch a screen to another workspace
- create a new workspace from a screen
- rename a workspace
- Deleting a workspace:
- reassigns affected screens to a fallback workspace
- is disallowed if it is the last workspace
## Screen UX Rules
- A screen can be open/closed independently of other screens.
- Two screens may point at the same workspace.
- If a workspace is already open on another screen:
- opening it on this screen should either transfer focus, or
- close it on the other screen first
Recommended v1 behavior:
- one active open screen per workspace
- opening workspace `W` on screen `B` closes `W` on screen `A`
That is simple, deterministic, and compatible with single `TerminalView` ownership.
## Migration Plan
### Phase 0: Hygiene
- remove dead settings and unused properties
- fix version drift
- fix stale comments
- add test targets
- isolate root build artifacts from source tree if desired
### Phase 1: Settings Layer
- create `AppSettings`, `AppSettingsStore`, `AppSettingsController`
- move scattered `@AppStorage` usage into typed settings reads/writes
- make `HotkeyService` observe only hotkey settings
- make window sizing observe only relevant settings
Exit criteria:
- no runtime logic depends on `UserDefaults.didChangeNotification`
### Phase 2: Workspace Core
- add `WorkspaceSummary`, `WorkspaceRegistry`, `WorkspaceController`
- migrate current `TerminalManager` logic into `WorkspaceController`
- ensure one default workspace exists
Exit criteria:
- tabs exist inside a workspace object, not globally
### Phase 3: Screen Core
- add `ScreenContext`, `ScreenRegistry`
- migrate current `NotchViewModel` responsibilities into `ScreenContext`
- persist screen-to-workspace assignments
Exit criteria:
- screen-local state is independent from workspace state
### Phase 4: Notch Lifecycle Orchestrator
- move hover/open/close/transition logic out of `ContentView`
- add explicit transition states
- centralize focus/open/close sequencing
Exit criteria:
- `ContentView` becomes mostly rendering + view intents
### Phase 5: Window Coordination
- extract AppKit window creation and frame management into `WindowCoordinator`
- keep `ScreenManager` as thin glue or replace it entirely
Exit criteria:
- no window-specific behavior in workspace logic
### Phase 6: Workspace Switching UX
- add workspace picker UI
- support create/switch/rename/delete workspaces
- enforce one-open-screen-per-workspace behavior
Exit criteria:
- a user can assign the same workspace to multiple screens
### Phase 7: Popout and Session Cleanup
- formalize detached tab ownership
- remove session observer leaks
- remove app-global current-directory mutation
Exit criteria:
- session lifetime is explicit and testable
### Phase 8: Test Expansion
- add unit coverage for all core models/controllers
- add integration tests for orchestrators
- add XCUITests for key user flows
## Proposed File Layout
```text
Downterm/CommandNotch/
App/
CommandNotchApp.swift
AppDelegate.swift
AppController.swift
Core/
Settings/
AppSettings.swift
AppSettingsController.swift
AppSettingsStore.swift
UserDefaultsAppSettingsStore.swift
Workspaces/
WorkspaceSummary.swift
WorkspaceState.swift
WorkspaceRegistry.swift
WorkspaceController.swift
WorkspaceStore.swift
Screens/
ScreenContext.swift
ScreenRegistry.swift
ScreenAssignmentStore.swift
Notch/
NotchOrchestrator.swift
NotchStateMachine.swift
NotchFrameCalculator.swift
Terminal/
TerminalSession.swift
TerminalSessionFactory.swift
TerminalTheme.swift
TerminalTabState.swift
Hotkeys/
HotkeyService.swift
HotkeyBinding.swift
AppCommand.swift
UI/
Notch/
NotchRootView.swift
ClosedNotchView.swift
OpenWorkspaceView.swift
TabStripView.swift
WorkspacePickerView.swift
Settings/
SettingsRootView.swift
GeneralSettingsView.swift
AppearanceSettingsView.swift
AnimationSettingsView.swift
TerminalSettingsView.swift
HotkeySettingsView.swift
WorkspaceSettingsView.swift
Components/
SwiftTermView.swift
HotkeyRecorderView.swift
NotchShape.swift
AppKit/
WindowCoordinator.swift
NotchWindow.swift
SettingsCoordinator.swift
PopoutCoordinator.swift
ScreenProvider.swift
Persistence/
CodableStore.swift
Extensions/
NSScreen+Extensions.swift
```
## Protocol Boundaries Required For Tests
Create protocols around:
- settings store
- workspace store
- screen provider
- window factory
- hotkey registrar
- launch-at-login service
- terminal session factory
- clock/scheduler for delayed hover/open/close logic
Examples:
```swift
protocol ScreenProviderType {
var screens: [ScreenDescriptor] { get }
}
protocol WindowCoordinatorType {
func showWindow(for screenID: ScreenID)
func hideWindow(for screenID: ScreenID)
func updateFrame(for screenID: ScreenID, frame: CGRect)
func focusTerminal(for screenID: ScreenID)
}
protocol SchedulerType {
func schedule(after interval: TimeInterval, _ action: @escaping () -> Void) -> Cancellable
}
```
## Testing Strategy
## 1. Unit Tests
Add a `CommandNotchTests` target.
Focus on pure logic:
- `WorkspaceRegistryTests`
- creates default workspace
- cannot delete last workspace
- rename/delete/create behavior
- `WorkspaceControllerTests`
- new tab
- close active tab
- detach active tab
- active tab index/id updates correctly
- `ScreenRegistryTests`
- new screen gets valid assignment
- assignment changes persist
- missing workspace reassigns to fallback
- `NotchStateMachineTests`
- closed -> opening -> open
- close suppression while hovering
- resizing transitions
- `NotchFrameCalculatorTests`
- clamping
- per-screen max bounds
- closed-notch size behavior
- `HotkeyBindingTests`
- event matching
- preset digit mapping
- `AppSettingsControllerTests`
- only targeted changes publish
## 2. Integration Tests
Use fakes for AppKit boundaries.
- `NotchOrchestratorIntegrationTests`
- opening a workspace on one screen closes it on another screen if shared
- focus transfer rules
- preset resize behavior
- `AppControllerIntegrationTests`
- hotkey command routes to active screen/workspace
- settings changes update workspaces without broad side effects
## 3. UI Tests
Add a `CommandNotchUITests` target with XCUITest.
Recommended initial flows:
- launch app and open settings
- create workspace from settings or notch UI
- assign workspace to another screen context if testable
- create tab
- switch tabs
- apply size preset
- detach active tab
UI tests should validate behavior and existence, not exact pixel values.
## 4. Optional Snapshot Tests
Only add after state is injectable.
Snapshot candidates:
- closed notch
- open notch with one tab
- workspace picker
- settings panes
Do not start here.
## Testing SwiftUI/AppKit UI
### What to test directly
- view model/controller behavior
- user intents emitted by the view
- XCUITest end-to-end flows
### What not to over-invest in early
- exact animation curves
- fragile layout pixel assertions
- direct testing of `NSWindow` internals unless wrapped behind protocols
### Practical answer
For this app, “testing UI” mostly means testing the logic that drives the UI, plus a thin XCUITest layer that proves the main flows work.
## Current Issues Mapped To Fixes
- Global singleton tab state
Fix with `WorkspaceRegistry` + `WorkspaceController`
- `UserDefaults` as runtime bus
Fix with typed settings controller and targeted subscriptions
- Dead gesture settings
Remove or implement later behind a dedicated feature
- Observer leakage in tab creation
Store cancellables per session or derive titles from workspace state
- App-global cwd mutation
Remove from session startup; set process cwd explicitly if supported
- Monolithic settings file
Split into feature files bound to typed settings
- Timing-based UI behavior spread across layers
Centralize in `NotchOrchestrator`
- No tests
Add unit + integration + XCUITest targets
## Implementation Checklist
- [ ] Add `CommandNotchTests`
- [ ] Add `CommandNotchUITests`
- [ ] Create `AppSettings` model
- [ ] Create `AppSettingsStore`
- [ ] Replace broad defaults observation
- [ ] Create `WorkspaceRegistry`
- [ ] Create `WorkspaceController`
- [ ] Move tab logic out of `TerminalManager`
- [ ] Create `ScreenContext`
- [ ] Create `ScreenRegistry`
- [ ] Persist screen assignments
- [ ] Create `NotchOrchestrator`
- [ ] Create `NotchFrameCalculator`
- [ ] Extract `WindowCoordinator`
- [ ] Add workspace picker UI
- [ ] Add create/switch/rename/delete workspace flows
- [ ] Enforce one-open-screen-per-workspace
- [ ] Clean up detached session ownership
- [ ] Split settings views into separate files
- [ ] Remove dead settings and unused code
- [ ] Add unit tests for registry/controller/state machine/frame calculator
- [ ] Add initial XCUITest flows
## First Concrete Refactor Slice
If implementing incrementally, the best first slice is:
1. add tests target
2. add `AppSettings`
3. add `WorkspaceRegistry` with a single default workspace
4. migrate current `TerminalManager` into a first `WorkspaceController`
5. change the app so all current behavior still uses one workspace
That gives immediate architectural improvement without changing UX yet.
## Definition Of Done
The refactor is successful when:
- there is no critical runtime logic driven by broad `UserDefaults` notifications
- tabs/sessions live under workspaces, not globally
- screens are explicitly assigned to workspaces
- the same workspace can be selected on multiple screens
- only one open/focused screen owns a workspaces live terminal at a time
- window behavior is separated from domain logic
- unit tests cover the core state machines and registries
- XCUITests cover main user flows