886 lines
21 KiB
Markdown
886 lines
21 KiB
Markdown
# 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 today’s 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 workspace’s 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
|
||
|