Files
downterm/docs/workspace-architecture-spec.md
2026-03-14 02:58:59 +11:00

886 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
CommandNotch/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