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

21 KiB
Raw Permalink Blame History

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

typealias WorkspaceID = UUID
typealias ScreenID = String
typealias TabID = UUID
typealias SessionID = UUID

App Settings

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

struct WorkspaceSummary: Equatable, Codable, Identifiable {
    var id: WorkspaceID
    var name: String
    var createdAt: Date
}

Workspace Assignment

struct ScreenWorkspaceAssignment: Equatable, Codable {
    var screenID: ScreenID
    var workspaceID: WorkspaceID
}

Screen UI State

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

struct WorkspaceState: Identifiable {
    var id: WorkspaceID
    var name: String
    var tabs: [TerminalTabState]
    var activeTabID: TabID?
}

Tab State

struct TerminalTabState: Identifiable {
    var id: TabID
    var sessionID: SessionID
    var title: String
}

Notch Lifecycle State

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:

@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:

@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:

@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:

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:

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

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:

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