• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / agterm / 30244261338

27 Jul 2026 06:54AM UTC coverage: 97.9% (+0.06%) from 97.837%
30244261338

push

github

web-flow
feat(sidebar): generalize workspace focus into a multi-workspace set (#297)

generalizes the sidebar's focus filter from one workspace to a set plus an on/off flag. `AppStore.focusedWorkspaceID: UUID?` becomes `focusedWorkspaceIDs: Set<UUID>` + `focusEnabled: Bool`, so the marked set survives turning the filter off and peeking at the whole tree costs one toggle instead of losing the selection.

everything downstream of `visibleWorkspaces` inherits the set unchanged: session nav, attention nav, Ctrl-Tab MRU, the ⌃P palette, close-reselection and the reconcile signal. Persistence migrates the legacy single-id snapshot.

**sidebar** - a marked workspace row draws the filled grid icon, the row context menu toggles membership, and the old focus pill is replaced by a workspace filter toggle in the bottom bar.

**control API** - new `workspace.filter` (`on|off|toggle`) for the flag, and `workspace.focus` gains an `add` mode next to `on|off|toggle` that marks without enabling the filter, so a set can be built member by member with the whole tree still on screen. Read-back is per-workspace `focused` on the tree node plus the top-level `workspaceFilter`, reported independently, so a script can record a working set and restore it.

**menu, palette, keymap** - `toggle_workspace_filter` joins the keyless builtin actions.

docs updated across README, the bundled agent skill, `site/docs.html`, `site/commands.html` and the engineering rules.

231 of 231 new or added lines in 14 files covered. (100.0%)

22 existing lines in 3 files now uncovered.

6153 of 6285 relevant lines covered (97.9%)

4783773.66 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.96
/agtermCore/Sources/agtermCore/WindowLibrary.swift
1
import Foundation
2
import Observation
3

4
/// Metadata for one window — a named bundle of workspaces + sessions rendered in its
5
/// own on-screen macOS window. Named `WindowInfo` (not `Window`) to avoid clashing with
6
/// SwiftUI/AppKit `Window` types in the app target.
7
public struct WindowInfo: Codable, Sendable, Identifiable, Equatable {
8
    public let id: UUID
9
    public var name: String
10

11
    public init(id: UUID = UUID(), name: String) {
172✔
12
        self.id = id
172✔
13
        self.name = name
172✔
14
    }
172✔
15

16
    /// Whether `name` is a user-set name rather than an auto-assigned default. The title bar shows
17
    /// the window name only when this is true, so default "window N" names stay hidden.
18
    public var hasCustomName: Bool { !Self.isAutoName(name) }
7✔
19

20
    /// Whether `name` matches the auto-assigned scheme `WindowLibrary.defaultWindowName` produces —
21
    /// the literal word "window" followed by a positive integer ("window 1", "window 2", …).
22
    public static func isAutoName(_ name: String) -> Bool {
10✔
23
        let parts = name.split(separator: " ", omittingEmptySubsequences: true)
10✔
24
        guard parts.count == 2, parts[0] == "window", let number = Int(parts[1]), number >= 1 else { return false }
10✔
25
        return true
5✔
26
    }
10✔
27
}
28

29
/// One entry in the persisted window index: identity, name, and whether the window was
30
/// open at quit (drives reopen-all on the next launch).
31
public struct WindowEntry: Codable, Sendable, Equatable {
32
    public var id: UUID
33
    public var name: String
34
    public var isOpen: Bool
35

36
    public init(id: UUID, name: String, isOpen: Bool) {
306✔
37
        self.id = id
306✔
38
        self.name = name
306✔
39
        self.isOpen = isOpen
306✔
40
    }
306✔
41
}
42

43
/// The persisted `windows.json` index: the ordered window list plus the frontmost id.
44
/// Carries its own `version`, deliberately independent of `Snapshot.version` (the
45
/// per-window file shape) so the two can evolve separately.
46
public struct WindowsIndex: Codable, Equatable, Sendable {
47
    /// Bumped when the index shape changes; a mismatch makes the loader treat it as absent.
48
    public static let currentVersion = 1
49

50
    public var version: Int
51
    public var frontmost: UUID?
52
    public var windows: [WindowEntry]
53

54
    public init(version: Int = WindowsIndex.currentVersion, frontmost: UUID? = nil, windows: [WindowEntry] = []) {
209✔
55
        self.version = version
209✔
56
        self.frontmost = frontmost
209✔
57
        self.windows = windows
209✔
58
    }
209✔
59
}
60

61
/// The app-global owner of the window set: the ordered window metadata, the lazily-loaded
62
/// per-window `AppStore`s, the open-set, and the frontmost id, plus per-window + index
63
/// persistence.
64
///
65
/// `@Observable @MainActor` like `AppStore` — SwiftUI observes the window list and frontmost
66
/// id, and all access is main-actor isolated. A window is "open" iff its `AppStore` is loaded
67
/// (`stores[id] != nil`); the persisted open-set in `windows.json` records which to reopen on
68
/// the next launch.
69
///
70
/// Recovery contract (never throws to the caller, mirrors `PersistenceStore.load()`): a
71
/// corrupt or version-mismatched `windows.json` is treated as absent → migrate from legacy
72
/// `workspaces.json` if present, else seed one window. A missing/corrupt per-window file opens
73
/// that window with an empty `Snapshot` (one default workspace + session). The app always
74
/// reaches a valid, non-empty window set.
75
@Observable
76
@MainActor
77
public final class WindowLibrary {
78
    /// The ordered window metadata, for the menu/palette.
79
    public private(set) var windows: [WindowInfo]
80

81
    /// App-wide recent closed sessions/workspaces, newest first. Reopening inserts the saved item into
82
    /// the active window; this list is independent of window reopen semantics.
83
    public private(set) var recentClosedItems: [RecentClosedItem]
84

85
    /// The id of the frontmost on-screen window, mirrored into the index on change.
86
    public var frontmostWindowID: UUID?
87

88
    /// Live per-window stores. A window is open iff it has a loaded store. `@ObservationIgnored`:
89
    /// read imperatively (scene/control), not by any SwiftUI view.
90
    @ObservationIgnored private var stores: [UUID: AppStore]
91

92
    /// The state directory (AGTERM_STATE_DIR-aware); the index lives here and per-window files in
93
    /// the `windows/` subdirectory.
94
    @ObservationIgnored private let directory: URL
95
    @ObservationIgnored private let recentClosedStore: RecentClosedStore
96
    /// One bounded run-identified ring shared by every window store for this library/app lifetime.
97
    @ObservationIgnored private let controlEventRing: ControlEventRing
98
    @ObservationIgnored private var treeEventDebouncers: [UUID: Debouncer]
99
    @ObservationIgnored private var isBootstrapping = true
106✔
100

101
    /// Set once the launch reopen-all has run, so the scene `.task` (which fires per window) drives
102
    /// it exactly once. `@ObservationIgnored`: a launch-flow latch, not view state.
103
    @ObservationIgnored public private(set) var hasReopened = false
106✔
104

105
    /// FIFO of window ids each freshly-appearing SwiftUI window claims as its own. The scene is a
106
    /// plain `WindowGroup` (which auto-opens one window at launch and one per `openWindow()`), so a
107
    /// window has no presented id — it pops the next id here on appear instead. Seeded with the open
108
    /// set (launch window first) by `consumeReopen()`. `@ObservationIgnored`: a launch-flow queue.
109
    @ObservationIgnored private var pendingClaim: [UUID] = []
106✔
110

111
    /// The launch id the launch window adopted via the pre-seed fallback (`adoptLaunchWindowID()`),
112
    /// when its `.onAppear` ran before the scene `.task` seeded the queue. `consumeReopen()` excludes
113
    /// it from the seeded queue so the first reopened window can't claim it a second time (the
114
    /// duplicate-store collision). `@ObservationIgnored`: a launch-flow latch.
115
    @ObservationIgnored private var adoptedLaunchID: UUID?
116

117
    /// Set at quit so the per-window `willClose` close-reporting becomes a no-op — the open-set must
118
    /// be preserved for the next launch's reopen-all, not zeroed as each window tears down on quit.
119
    @ObservationIgnored public var isTerminating = false
106✔
120

121
    private static let indexFileName = "windows.json"
122
    private static let windowsSubdirectory = "windows"
123
    private static let legacyFileName = "workspaces.json"
124

125
    private var indexURL: URL { directory.appendingPathComponent(Self.indexFileName) }
300✔
126
    private var windowsDirectory: URL { directory.appendingPathComponent(Self.windowsSubdirectory, isDirectory: true) }
262✔
127

128
    /// Creates the library rooted at `directory`, running migration/recovery so the resulting
129
    /// window set is always valid and non-empty.
130
    public init(directory: URL = PersistenceStore.defaultDirectory,
131
                controlEventRing: ControlEventRing? = nil) {
106✔
132
        self.directory = directory
106✔
133
        self.recentClosedStore = RecentClosedStore(directory: directory)
106✔
134
        self.controlEventRing = controlEventRing ?? ControlEventRing()
106✔
135
        self.treeEventDebouncers = [:]
106✔
136
        self.stores = [:]
106✔
137
        self.windows = []
106✔
138
        self.recentClosedItems = recentClosedStore.load()
106✔
139
        self.frontmostWindowID = nil
106✔
140
        bootstrap()
106✔
141
        isBootstrapping = false
106✔
142
    }
106✔
143

144
    // MARK: - Lookup
145

146
    /// The live store of an open window, or nil when the window is closed/unknown.
147
    public func store(for id: UUID?) -> AppStore? {
91✔
148
        guard let id else { return nil }
91✔
149
        return stores[id]
89✔
150
    }
91✔
151

152
    /// The frontmost open window's id, falling back to the first open window (in window order)
153
    /// when the frontmost id is unset/closed. The app-side seams that key off the window — the
154
    /// frontmost store and the frontmost quick terminal — resolve through this. Nil only in the
155
    /// degenerate all-windows-closed state (the app is quitting).
156
    public var activeWindowID: UUID? {
67✔
157
        if let frontmostWindowID, stores[frontmostWindowID] != nil { return frontmostWindowID }
67✔
158
        for id in windows.map(\.id) where stores[id] != nil { return id }
41✔
159
        return nil
×
160
    }
67✔
161

162
    /// The frontmost open window's store, falling back to the first open store (in window order)
163
    /// when the frontmost id is unset/closed. The app-side action/control/settings seams resolve
164
    /// the store to act on through this — it stays non-nil because the library is never windowless
165
    /// at launch. Nil only in the degenerate all-windows-closed state (the app is quitting).
166
    public var activeStore: AppStore? {
29✔
167
        store(for: activeWindowID)
29✔
168
    }
29✔
169

170
    /// Whether the window is currently open (its store is loaded).
171
    public func isOpen(_ id: UUID) -> Bool {
24✔
172
        stores[id] != nil
24✔
173
    }
24✔
174

175
    /// Auto-hide-inactive-sidebars driver: the frontmost open window shows its sidebar and every OTHER
176
    /// open window collapses its own. With a single open window it force-shows that (active) window's
177
    /// sidebar and collapses nothing. Host-free so it is unit-testable; the app-side caller gates it on the
178
    /// `autoHideSidebarInactiveWindows` setting and invokes it on every frontmost change (and once when the
179
    /// toggle flips on). `setSidebarVisible` no-ops a window already in its target state, so the
180
    /// write/persist/notify happens only for the windows that actually change.
181
    public func applyInactiveWindowSidebarHiding() {
4✔
182
        guard let active = activeWindowID else { return }
4✔
183
        for id in openIDs() {
9✔
184
            stores[id]?.setSidebarVisible(id == active)
9✔
185
        }
9✔
186
    }
4✔
187

188
    /// The window set projected into the `window.list` control payload (id/name + open/active flags),
189
    /// in window order.
190
    /// The `geometry` closure supplies each open window's live on-screen frame; it is app-side (the NSWindow
191
    /// handles live in `WindowRegistry`, not this host-free model), defaulting to nil so unit tests and any
192
    /// non-AppKit caller get a geometry-free list.
193
    public func controlWindowNodes(geometry: (WindowInfo.ID) -> ControlWindowFrame? = { _ in nil },
10✔
194
                                   flags: (WindowInfo.ID) -> (fullscreen: Bool, zoomed: Bool, minimized: Bool)? = { _ in nil })
10✔
195
        -> [ControlWindowNode] {
6✔
196
        let active = activeWindowID
6✔
197
        return windows.map {
12✔
198
            // reach each open store for its auto-follow timeout + sidebar visibility (per-window state); a
12✔
199
            // closed window has no store and reports nil for both. The frame + fullscreen/zoom/minimized
12✔
200
            // flags come from the app-side closures (nil for a closed window with no NSWindow).
12✔
201
            let live = flags($0.id)
12✔
202
            return ControlWindowNode(id: $0.id.uuidString, name: $0.name, open: isOpen($0.id), active: $0.id == active,
12✔
203
                                     autoFollowMs: stores[$0.id]?.autoFollowMs,
12✔
204
                                     sidebarVisible: stores[$0.id]?.sidebarVisible,
12✔
205
                                     geometry: geometry($0.id),
12✔
206
                                     fullscreen: live?.fullscreen, zoomed: live?.zoomed,
12✔
207
                                     minimized: live?.minimized)
12✔
208
        }
12✔
209
    }
6✔
210

211
    /// Reads the app-wide ring and maps both pages and cursor failures onto the stable control response.
212
    public func readEvents(_ options: ControlEventReadOptions) -> ControlResponse {
36✔
213
        switch controlEventRing.read(cursor: options.cursor, kinds: options.kinds, limit: options.limit) {
36✔
214
        case .batch(let batch):
36✔
215
            return ControlResponse(ok: true, result: ControlResult(events: batch))
35✔
216
        case .failure(let error, let anchor):
36✔
217
            return ControlResponse(ok: false, result: ControlResult(events: anchor), error: error.rawValue)
1✔
218
        }
36✔
219
    }
36✔
220

221
    /// Test/quit seam: synchronously fires every pending per-window structural invalidation,
222
    /// including one queued for a window that has just been removed from the catalog.
223
    func flushTreeEvents() {
9✔
224
        for debouncer in treeEventDebouncers.values { debouncer.flush() }
9✔
225
    }
9✔
226

227
    /// Resolve a control window target against the library's ordered window set. All known windows are
228
    /// candidates, including closed windows; `active` resolves to the frontmost open window, with the
229
    /// same fallback as `activeWindowID`.
230
    public func resolveWindow(_ target: String) -> TargetResolution {
7✔
231
        ControlResolve.resolve(target, candidates: windows.map(\.id), active: activeWindowID)
14✔
232
    }
7✔
233

234
    /// The persisted open-set in window order, for the launch reopen-all. A window is open iff
235
    /// its store is loaded.
236
    public func openIDs() -> [UUID] {
49✔
237
        windows.map(\.id).filter { stores[$0] != nil }
87✔
238
    }
49✔
239

240
    /// Every session across all open windows, flattened — the shared walk for the per-session sweeps
241
    /// (restore-running-command capture + `restore.clear`).
242
    public func allOpenSessions() -> [Session] {
5✔
243
        openIDs().compactMap { stores[$0] }.flatMap { $0.workspaces.flatMap(\.sessions) }
7✔
244
    }
5✔
245

246
    /// The total unseen-notification count across every session in every OPEN window — the number the
247
    /// Dock tile badge shows (`DockBadgeController`), the app-wide roll-up of the same `Session.unseenCount`
248
    /// the sidebar's red pills track. Reads the observable `windows` list, each open store's `workspaces`,
249
    /// and each session's `unseenCount`, so a `withObservationTracking` observer over this re-fires on a
250
    /// notification bump, a focus/select clear, and a session add/remove. A window CLOSE drops a store
251
    /// (`@ObservationIgnored stores`), which is NOT observable — the app refreshes the badge explicitly on
252
    /// the `willClose` teardown for that case.
253
    public var totalUnseenCount: Int {
3✔
254
        allOpenSessions().reduce(0) { $0 + $1.unseenCount }
5✔
255
    }
3✔
256

257
    /// The number of currently-open windows and the total number of sessions across them — the
258
    /// counts the quit confirmation reports. A window is open iff its store is loaded.
259
    public func openCounts() -> (windows: Int, sessions: Int) {
3✔
260
        let openStores = windows.map(\.id).compactMap { stores[$0] }
6✔
261
        let sessions = openStores.reduce(0) { total, store in
5✔
262
            total + store.workspaces.reduce(0) { $0 + $1.sessions.count }
5✔
263
        }
5✔
264
        return (openStores.count, sessions)
3✔
265
    }
3✔
266

267
    /// The id SwiftUI's auto-opened launch window claims: the frontmost open window, else the first.
268
    /// `nil` only in the degenerate all-windows-closed state. Guards the frontmost on openness (like
269
    /// `activeWindowID`) — a frontmost pointing at a closed window must fall through to the first open
270
    /// one, else `consumeReopen` seeds a closed id and undercounts the open set.
271
    private var launchWindowID: UUID? {
22✔
272
        if let frontmostWindowID, stores[frontmostWindowID] != nil { return frontmostWindowID }
22✔
273
        return openIDs().first
3✔
274
    }
22✔
275

276
    /// Latches the launch reopen-all so it runs once across the per-window scene `.task`s, seeding
277
    /// the claim queue with the open set and returning the *additional* `openWindow()` calls needed
278
    /// beyond the one window SwiftUI auto-opens at launch. So with N open windows it returns N-1 (each
279
    /// ≥0). Empty on every subsequent call.
280
    ///
281
    /// The launch window takes exactly one id: via the queue (when this runs before its `.onAppear`)
282
    /// or via `adoptLaunchWindowID()`'s fallback (when its `.onAppear` ran first). An already-adopted
283
    /// launch id is excluded from the queue so the first reopened window can't claim it a second time
284
    /// (two windows binding one store — the duplicate-store collision). The N-1 count is independent
285
    /// of which path the launch window took.
286
    public func consumeReopen() -> Int {
9✔
287
        guard !hasReopened else { return 0 }
9✔
288
        hasReopened = true
8✔
289
        let open = openIDs()
8✔
290
        // launch window first, then the rest in window order — minus any id the fallback already
8✔
291
        // handed the launch window (so it isn't claimed twice).
8✔
292
        let ordered = (launchWindowID.map { [$0] } ?? []) + open.filter { $0 != launchWindowID }
11✔
293
        pendingClaim = ordered.filter { $0 != adoptedLaunchID }
11✔
294
        return max(open.count - 1, 0)
8✔
295
    }
9✔
296

297
    /// Pops the next window id for a freshly-appearing SwiftUI window to render. Returns nil once the
298
    /// queue is drained (a window beyond the open set — e.g. a SwiftUI-restored extra — which the app
299
    /// dismisses).
300
    public func claimNextWindowID() -> UUID? {
22✔
301
        guard !pendingClaim.isEmpty else { return nil }
22✔
302
        return pendingClaim.removeFirst()
13✔
303
    }
22✔
304

305
    /// The launch window's fallback id when its `.onAppear` fires before the scene `.task` seeds the
306
    /// claim queue. Records the id as adopted so a later `consumeReopen()` excludes it from the queue
307
    /// — without this, `consumeReopen` re-seeds the launch id and the first reopened window claims it
308
    /// again, leaving two windows bound to one store. Idempotent-per-launch: only the FIRST caller gets
309
    /// the launch id; a second caller before `consumeReopen()` runs (SwiftUI restored more than one
310
    /// window, each hitting the empty-queue fallback) gets nil and dismisses itself, so two windows
311
    /// can't both bind the one launch store. Returns nil in the degenerate all-windows-closed state.
312
    public func adoptLaunchWindowID() -> UUID? {
4✔
313
        guard adoptedLaunchID == nil, let id = launchWindowID else { return nil }
4✔
314
        adoptedLaunchID = id
3✔
315
        return id
3✔
316
    }
4✔
317

318
    /// Enqueues a window id to be claimed by the next appearing window — used when the app opens a
319
    /// window (`newWindow` + `openWindow()`, or a `window.select` / reveal of a closed window), so the
320
    /// new SwiftUI window adopts that id. Pure queue-membership dedup: an id already pending (a repeated
321
    /// `window.select` of the same window before the first claim is consumed) is not appended again, so
322
    /// one bundle never spawns two windows. It does NOT skip an id whose store is loaded — `newWindow`
323
    /// pre-loads the store before enqueueing, so a store-loaded guard would silently drop the claim and
324
    /// the spawned window would self-dismiss. The "already on-screen, raise instead of spawn" check
325
    /// lives at the call site (`WindowRegistry.raise`), not here.
326
    public func enqueueClaim(_ id: UUID) {
6✔
327
        guard !pendingClaim.contains(id) else { return }
6✔
328
        pendingClaim.append(id)
4✔
329
    }
4✔
330

331
    /// The id of the open window that owns the given session, or nil when no open window has it.
332
    /// Searches only OPEN windows (closed windows aren't loaded).
333
    public func windowID(forSession sessionID: UUID) -> UUID? {
9✔
334
        for id in windows.map(\.id) where stores[id]?.session(withID: sessionID) != nil { return id }
2,147,483,647✔
335
        return nil
2,147,483,647✔
336
    }
9✔
337

338
    /// The open store that owns the given session, searching only OPEN windows. Backs cross-window
339
    /// session targeting (notification reveal + ControlServer).
340
    public func store(forSession sessionID: UUID) -> AppStore? {
5✔
341
        store(for: windowID(forSession: sessionID))
5✔
342
    }
5✔
343

344
    /// The id of the open window backed by `store` (by identity), or nil — the reverse of `store(for:)`,
345
    /// used to reach a window's per-window controllers (quick terminal / zoom / dashboard) from its store.
346
    public func windowID(for store: AppStore) -> UUID? {
×
347
        openIDs().first { stores[$0] === store }
×
UNCOV
348
    }
×
349

350
    /// The display name of the window with `id`, or "" when `id` is nil or no window has that id — the
351
    /// name half of the `{AGT_WINDOW_NAME}`/`$AGT_WINDOW_NAME` command context.
352
    public func windowName(for id: UUID?) -> String {
4✔
353
        guard let id else { return "" }
4✔
354
        return windows.first { $0.id == id }?.name ?? ""
5✔
355
    }
4✔
356

357
    /// The auto-generated name for the next new window (`window 1`, `window 2`, …).
358
    public var defaultWindowName: String {
89✔
359
        "window \(windows.count + 1)"
89✔
360
    }
89✔
361

362
    // MARK: - Mutation
363

364
    /// Creates a fresh window seeded with one default workspace ("workspace 1") and one session
365
    /// at $HOME (the seeding that used to live in the app's `restoredStore()`), opens it (loads
366
    /// its store), and persists the index. Defaults the name to "window N".
367
    @discardableResult
368
    public func newWindow(name: String? = nil) -> WindowInfo {
127✔
369
        let info = WindowInfo(name: name?.trimmedOrNil ?? defaultWindowName)
127✔
370
        let store = makeStore(for: info.id, persistence: persistenceStore(for: info.id))
127✔
371
        let workspace = store.addWorkspace(name: "workspace 1")
127✔
372
        store.addSession(toWorkspace: workspace.id, cwd: FileManager.default.homeDirectoryForCurrentUser.path)
127✔
373
        windows.append(info)
127✔
374
        stores[info.id] = store
127✔
375
        // a newly created window is the active one: mark it frontmost so the seams that key off the
127✔
376
        // window (the active store, the command palette, the quick terminal) target it immediately,
127✔
377
        // rather than waiting on the new on-screen window's first `didBecomeKey` (which loses to the
127✔
378
        // File-menu focus returning to the previous window after New Window).
127✔
379
        frontmostWindowID = info.id
127✔
380
        saveIndex()
127✔
381
        return info
127✔
382
    }
127✔
383

384
    /// Lazily builds (or returns the cached) `AppStore` for a window, loading its persisted
385
    /// `windows/<id>.json` (an empty `Snapshot` when missing/corrupt, per the recovery contract).
386
    /// No-op returning nil for an id with no index entry. Marks the window open and persists.
387
    ///
388
    /// `launchRestore` marks an APP-BOOTSTRAP load — passed only by `reopen`/`recoverOrphanedWindows`,
389
    /// and the only thing that arms a session's persisted `session.restore` override for this launch. It
390
    /// defaults to false because `ContentView.resolveStore()` calls this at RUNTIME when a closed window
391
    /// is reopened mid-process, which must not execute anything.
392
    @discardableResult
393
    public func loadStore(for id: UUID, launchRestore: Bool = false) -> AppStore? {
34✔
394
        guard windows.contains(where: { $0.id == id }) else { return nil }
46✔
395
        if let existing = stores[id] { return existing }
33✔
396
        let persistence = persistenceStore(for: id)
33✔
397
        let store = makeStore(for: id, persistence: persistence)
33✔
398
        store.restore(from: persistence.load(), launchRestore: launchRestore)
33✔
399
        stores[id] = store
33✔
400
        if !launchRestore {
33✔
401
            for workspace in store.workspaces {
4✔
402
                for session in workspace.sessions { store.emitSessionCreated(session, workspace: workspace.id) }
4✔
403
            }
4✔
404
            store.scheduleTreeChanged()
4✔
405
        }
4✔
406
        saveIndex()
33✔
407
        return store
33✔
408
    }
34✔
409

410
    @discardableResult
411
    public func reopenRecentClosed(_ itemID: UUID, into targetStore: AppStore? = nil) -> Bool {
6✔
412
        refreshRecentClosedItems()
6✔
413
        guard let item = recentClosedItems.first(where: { $0.id == itemID }),
7✔
414
              let store = targetStore ?? activeStore,
6✔
415
              store.restoreRecentClosed(item)
6✔
416
        else { return false }
6✔
417
        recentClosedStore.remove(itemID)
6✔
418
        refreshRecentClosedItems()
6✔
419
        return true
6✔
420
    }
6✔
421

422
    @discardableResult
423
    public func reopenLatestRecentClosed(into targetStore: AppStore? = nil) -> Bool {
×
424
        refreshRecentClosedItems()
×
425
        guard let item = recentClosedItems.first else { return false }
×
426
        return reopenRecentClosed(item.id, into: targetStore)
×
UNCOV
427
    }
×
428

429
    public func clearRecentClosedItems() {
×
430
        recentClosedStore.clear()
×
431
        refreshRecentClosedItems()
×
UNCOV
432
    }
×
433

434
    /// Closes a window: drops its store (marking it closed) and persists the index. The caller
435
    /// (app target) tears down the window's surfaces first — `WindowLibrary` only drops the store.
436
    /// No-op for an unknown/closed id, or while terminating (the open-set must survive for reopen-all).
437
    public func closeWindow(_ id: UUID) {
19✔
438
        guard !isTerminating else { return }
19✔
439
        // cancel any queued claim for this id so a window still attaching can't re-open it after a
18✔
440
        // close that raced its registration (window.new immediately followed by window.close).
18✔
441
        pendingClaim.removeAll { $0 == id }
18✔
442
        guard let store = stores[id] else { return }
18✔
443
        for workspace in store.workspaces {
17✔
444
            for session in workspace.sessions { store.emitSessionClosed(session, workspace: workspace.id) }
18✔
445
        }
17✔
446
        store.scheduleTreeChanged()
17✔
447
        stores[id] = nil
17✔
448
        if frontmostWindowID == id { frontmostWindowID = activeWindowID }
17✔
449
        saveIndex()
17✔
450
    }
17✔
451

452
    /// Renames a window (and its open store is unaffected — the name lives only in the index).
453
    /// An empty/whitespace-only name is ignored. Persists the index.
454
    public func renameWindow(_ id: UUID, to name: String) {
2✔
455
        guard let trimmed = name.trimmedOrNil, let index = windows.firstIndex(where: { $0.id == id }) else { return }
2✔
456
        guard windows[index].name != trimmed else { return }
1✔
457
        windows[index].name = trimmed
1✔
458
        scheduleTreeChanged(for: id)
1✔
459
        saveIndex()
1✔
460
    }
1✔
461

462
    /// Whether a window may be removed: one window is always kept, so removal is allowed only
463
    /// when more than one exists.
464
    public var canRemoveWindow: Bool { windows.count > 1 }
9✔
465

466
    /// Removes a window: drops its store, deletes its per-window file, removes the index entry,
467
    /// and persists. No-ops unless more than one window exists (the last one is kept). Clears
468
    /// `frontmostWindowID` if it pointed at the removed window.
469
    public func removeWindow(_ id: UUID) {
8✔
470
        guard canRemoveWindow, let index = windows.firstIndex(where: { $0.id == id }) else { return }
13✔
471
        if let store = stores[id] {
7✔
472
            for workspace in store.workspaces {
5✔
473
                for session in workspace.sessions { store.emitSessionClosed(session, workspace: workspace.id) }
5✔
474
            }
5✔
475
        }
5✔
476
        scheduleTreeChanged(for: id)
7✔
477
        // cancel the store's pending debounced save BEFORE deleting the file — a save scheduled by a
7✔
478
        // just-before-delete selectSession/setFontSize captures the store weakly and fires ~0.3 s out;
7✔
479
        // since the delete-path willClose teardown skips its own save() (the window is no longer open),
7✔
480
        // an un-cancelled pending save would fire after removeItem and re-create windows/<id>.json as an
7✔
481
        // orphan that a future index loss would resurrect via recoverOrphanedWindows().
7✔
482
        stores[id]?.cancelPendingSave()
7✔
483
        // sweep each session's rendered `.text` watermark PNG before dropping the store: deleting a window
7✔
484
        // permanently destroys its sessions (like closeSession/removeWorkspace), so their PNGs must go too —
7✔
485
        // window-CLOSE keeps the sessions and is handled elsewhere, but window-DELETE has no later sweep.
7✔
486
        // An OPEN window's session ids come from its live store; a CLOSED one has no store, so read them from
7✔
487
        // the persisted snapshot — else deleting a closed window orphans its PNGs in <stateDir>/watermarks/.
7✔
488
        // `directory` is the state-dir root WatermarkStorage resolves against (both default to
7✔
489
        // AGTERM_STATE_DIR else PersistenceStore.defaultDirectory), so passing it is production-identical
7✔
490
        // and lets a test sweep into an injected temp dir without touching process-global env.
7✔
491
        let sessionIDsToSweep: [UUID] = stores[id].map { $0.workspaces.flatMap(\.sessions).map(\.id) }
7✔
492
            ?? persistenceStore(for: id).load().workspaces.flatMap(\.sessions).map(\.id)
7✔
493
        for sessionID in sessionIDsToSweep {
7✔
494
            WatermarkStorage.removeRenderedText(sessionID: sessionID, stateDir: directory)
7✔
495
        }
7✔
496
        stores[id] = nil
7✔
497
        windows.remove(at: index)
7✔
498
        if frontmostWindowID == id { frontmostWindowID = nil }
7✔
499
        // best-effort: a missing/never-written per-window file is fine to "fail" to remove.
7✔
500
        try? FileManager.default.removeItem(at: windowFileURL(for: id))
7✔
501
        saveIndex()
7✔
502
    }
7✔
503

504
    /// Clears every session's per-window font-size override across ALL windows — open ones through
505
    /// their live store, closed ones by rewriting the persisted `windows/<id>.json` in place. A global
506
    /// font/appearance change resets every surface to the new default, so a closed window must drop its
507
    /// stale per-session sizes too, else it reopens later overriding the new default. No-ops a window
508
    /// (open or closed) whose snapshot has no overrides.
509
    public func resetSessionFontSizesAllWindows() {
1✔
510
        for info in windows {
2✔
511
            if let store = stores[info.id] {
2✔
512
                store.resetSessionFontSizes()
1✔
513
                continue
1✔
514
            }
1✔
515
            clearClosedWindowFontSizes(info.id)
1✔
516
        }
1✔
517
    }
1✔
518

519
    /// Loads a closed window's snapshot, strips every `fontSize` override, and rewrites the file only
520
    /// when something changed (so it doesn't churn untouched windows). Best-effort: a missing/corrupt
521
    /// file loads as empty (no overrides to clear) and a write failure is swallowed by the store.
522
    private func clearClosedWindowFontSizes(_ id: UUID) {
1✔
523
        let persistence = persistenceStore(for: id)
1✔
524
        var snapshot = persistence.load()
1✔
525
        var changed = false
1✔
526
        for w in snapshot.workspaces.indices {
1✔
527
            for s in snapshot.workspaces[w].sessions.indices where snapshot.workspaces[w].sessions[s].fontSize != nil {
1✔
528
                snapshot.workspaces[w].sessions[s].fontSize = nil
1✔
529
                changed = true
1✔
530
            }
1✔
531
        }
1✔
532
        guard changed else { return }
1✔
533
        try? persistence.save(snapshot)
1✔
534
    }
1✔
535

536
    // MARK: - Persistence
537

538
    /// Flushes every open window's store, so per-window cwd changes since the last structural
539
    /// mutation are persisted (the quit-time flush the app's terminate path drives).
540
    public func saveAllOpen() {
1✔
541
        for store in stores.values { store.save() }
2✔
542
    }
1✔
543

544
    /// Finalizes any grace-period session/workspace closes in open windows before a window/app teardown.
545
    public func finalizeAllPendingCloses() {
×
546
        for store in stores.values { store.finalizeAllPendingCloses() }
×
UNCOV
547
    }
×
548

549
    /// Writes `windows.json`: the ordered window list (with each window's open flag) and the
550
    /// frontmost id. A write failure is logged and swallowed.
551
    public func saveIndex() {
194✔
552
        let entries = windows.map { WindowEntry(id: $0.id, name: $0.name, isOpen: stores[$0.id] != nil) }
285✔
553
        let index = WindowsIndex(frontmost: frontmostWindowID, windows: entries)
194✔
554
        do {
194✔
555
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
194✔
556
            let encoder = JSONEncoder()
194✔
557
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
194✔
558
            try encoder.encode(index).write(to: indexURL, options: .atomic)
194✔
559
        } catch {
194✔
UNCOV
560
            log("saveIndex failed: \(error)")
×
561
        }
194✔
562
    }
194✔
563

564
    // MARK: - Bootstrap (migration + recovery)
565

566
    /// Resolves the window set on init: load `windows.json` if valid; else recover orphaned per-window
567
    /// `windows/<id>.json` files into a fresh index when any are present (so a future schema bump that
568
    /// invalidates the index doesn't lose the trees); else migrate from legacy `workspaces.json`; else
569
    /// seed one empty window. Reopens the persisted open-set (never windowless — falls back to the
570
    /// frontmost/first window).
571
    private func bootstrap() {
106✔
572
        if let index = loadIndex() {
106✔
573
            windows = index.windows.map { WindowInfo(id: $0.id, name: $0.name) }
30✔
574
            frontmostWindowID = index.frontmost
18✔
575
            reopen(index)
18✔
576
            return
18✔
577
        }
88✔
578
        // index unreadable but per-window files survive: recover them rather than discard the user's
88✔
579
        // sessions (a future schema bump that invalidates the index must not lose the trees).
88✔
580
        if recoverOrphanedWindows() { return }
88✔
581
        if migrateLegacy() { return }
85✔
582
        // no index, no orphans, and no legacy file: seed one empty default-named window ("window 1").
81✔
583
        newWindow()
81✔
584
    }
81✔
585

586
    /// Reads `windows.json`, treating a missing/corrupt/version-mismatched file as absent (nil),
587
    /// so the caller falls through to migration/seeding.
588
    private func loadIndex() -> WindowsIndex? {
106✔
589
        guard let data = try? Data(contentsOf: indexURL) else { return nil }
106✔
590
        guard let index = try? JSONDecoder().decode(WindowsIndex.self, from: data) else { return nil }
26✔
591
        guard index.version == WindowsIndex.currentVersion, !index.windows.isEmpty else { return nil }
21✔
592
        return index
18✔
593
    }
106✔
594

595
    /// Reopens the persisted open-set after loading the index: loads a store for each window
596
    /// marked open. If none were open, opens the frontmost (else the first) so the app is never
597
    /// windowless. The frontmost id is used only when it actually exists in `windows` — a stale
598
    /// frontmost (pointing at a deleted window) would otherwise no-op `loadStore` and leave the app
599
    /// windowless; in that case fall through to the first window.
600
    private func reopen(_ index: WindowsIndex) {
18✔
601
        for entry in index.windows where entry.isOpen { loadStore(for: entry.id, launchRestore: true) }
30✔
602
        guard openIDs().isEmpty else { return }
18✔
603
        let frontmostExists = index.frontmost.map { id in windows.contains { $0.id == id } } ?? false
3✔
604
        let fallback = (frontmostExists ? index.frontmost : nil) ?? windows.first?.id
3✔
605
        if let fallback { loadStore(for: fallback, launchRestore: true) }
3✔
606
    }
3✔
607

608
    /// When `windows.json` is unreadable/version-mismatched but per-window `windows/<id>.json` files
609
    /// survive, recovers them into a fresh index instead of falling through to legacy/seeding (which
610
    /// would discard the user's sessions). Each file whose name stem is a valid UUID becomes an OPEN
611
    /// window named "window N", numbered in filename order so the numbering and the frontmost pick are
612
    /// deterministic; the first recovered window becomes frontmost. Files with a non-UUID stem are
613
    /// skipped. Returns false when no recoverable per-window files exist (the caller then tries
614
    /// legacy migration, else seeds). Recovering every orphan as open means the launch reopen-all
615
    /// opens them all on screen at once — acceptable for this rare recovery path.
616
    @discardableResult
617
    private func recoverOrphanedWindows() -> Bool {
88✔
618
        let contents = (try? FileManager.default.contentsOfDirectory(at: windowsDirectory,
88✔
619
                                                                     includingPropertiesForKeys: nil)) ?? []
88✔
620
        // stable filename order so "window N" numbering and the frontmost pick are deterministic.
88✔
621
        let ids = contents
88✔
622
            .filter { $0.pathExtension == "json" }
88✔
623
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
88✔
624
            .compactMap { UUID(uuidString: $0.deletingPathExtension().lastPathComponent) }
88✔
625
        guard !ids.isEmpty else { return false }
88✔
626
        let infos = ids.enumerated().map { WindowInfo(id: $0.element, name: "window \($0.offset + 1)") }
4✔
627
        // append ALL infos FIRST — loadStore(for:) guards on `windows.contains(id)`, so loading a
3✔
628
        // store before the append would silently no-op.
3✔
629
        windows.append(contentsOf: infos)
3✔
630
        for info in infos { loadStore(for: info.id, launchRestore: true) }
4✔
631
        frontmostWindowID = infos.first?.id
3✔
632
        saveIndex()
3✔
633
        return true
3✔
634
    }
88✔
635

636
    /// If `windows.json` is absent but legacy `workspaces.json` exists, wraps it into one window
637
    /// ("window 1"): writes the loaded snapshot to `windows/<id>.json` + the index marking it
638
    /// open/frontmost, and opens it. Returns false (no migration) when no legacy file exists.
639
    @discardableResult
640
    private func migrateLegacy() -> Bool {
85✔
641
        let legacy = PersistenceStore(directory: directory, fileName: Self.legacyFileName)
85✔
642
        let snapshot = legacy.load()
85✔
643
        guard !snapshot.workspaces.isEmpty else { return false }
85✔
644
        // first window, so `defaultWindowName` yields "window 1" (windows is empty at this point).
4✔
645
        let info = WindowInfo(name: defaultWindowName)
4✔
646
        let store = makeStore(for: info.id, persistence: persistenceStore(for: info.id))
4✔
647
        store.restore(from: snapshot, launchRestore: true)
4✔
648
        store.save()
4✔
649
        windows = [info]
4✔
650
        stores[info.id] = store
4✔
651
        frontmostWindowID = info.id
4✔
652
        saveIndex()
4✔
653
        return true
4✔
654
    }
85✔
655

656
    // MARK: - Helpers
657

658
    /// The per-window persistence file `windows/<id>.json`.
659
    private func windowFileURL(for id: UUID) -> URL {
7✔
660
        windowsDirectory.appendingPathComponent("\(id.uuidString).json")
7✔
661
    }
7✔
662

663
    /// A `PersistenceStore` pointed at the window's `windows/<id>.json` file.
664
    private func persistenceStore(for id: UUID) -> PersistenceStore {
167✔
665
        PersistenceStore(directory: windowsDirectory, fileName: "\(id.uuidString).json")
167✔
666
    }
167✔
667

668
    private func makeStore(for windowID: UUID, persistence: PersistenceStore) -> AppStore {
164✔
669
        AppStore(
164✔
670
            persistence: persistence,
164✔
671
            recentClosedStore: recentClosedStore,
164✔
672
            recentClosedDidChange: { [weak self] in self?.refreshRecentClosedItems() },
164✔
673
            controlEventSink: { [weak self] draft in
606✔
674
                guard let self else { return }
606✔
675
                guard !self.isBootstrapping else { return }
606✔
676
                if draft.kind == .treeChanged {
363✔
677
                    self.scheduleTreeChanged(for: windowID)
214✔
678
                    return
214✔
679
                }
214✔
680
                self.controlEventRing.append(ControlEventDraft(
149✔
681
                    kind: draft.kind,
149✔
682
                    window: windowID.uuidString,
149✔
683
                    workspace: draft.workspace,
149✔
684
                    session: draft.session,
149✔
685
                    payload: draft.payload
149✔
686
                ))
149✔
687
            }
149✔
688
        )
164✔
689
    }
164✔
690

691
    private func scheduleTreeChanged(for windowID: UUID) {
222✔
692
        let debouncer = treeEventDebouncers[windowID] ?? Debouncer()
222✔
693
        treeEventDebouncers[windowID] = debouncer
222✔
694
        debouncer.schedule(after: 0.1) { [weak self] in
222✔
695
            self?.controlEventRing.append(ControlEventDraft(kind: .treeChanged, window: windowID.uuidString))
8✔
696
        }
8✔
697
    }
222✔
698

699
    private func refreshRecentClosedItems() {
40✔
700
        recentClosedItems = recentClosedStore.load()
40✔
701
    }
40✔
702

703
    private func log(_ message: @autoclosure () -> String) {
×
704
        NSLog("agterm: %@", message())
×
UNCOV
705
    }
×
706
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc