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

umputun / agterm / 31037426633

05 Aug 2026 07:00PM UTC coverage: 97.816%. Remained the same
31037426633

Pull #370

github

web-flow
Merge 704e4b4d3 into 29c47114f
Pull Request #370: fix: capture running commands before window-close surface teardown

30 of 30 new or added lines in 2 files covered. (100.0%)

16 existing lines in 2 files now uncovered.

7301 of 7464 relevant lines covered (97.82%)

4028206.71 hits per line

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

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

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

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

15
    /// Whether `name` is user-set; the title bar shows it only when true, so "window N" stays hidden.
16
    public var hasCustomName: Bool { !Self.isAutoName(name) }
7✔
17

18
    /// Whether `name` matches `WindowLibrary.defaultWindowName`: "window" plus a positive integer.
19
    public static func isAutoName(_ name: String) -> Bool {
10✔
20
        let parts = name.split(separator: " ", omittingEmptySubsequences: true)
10✔
21
        guard parts.count == 2, parts[0] == "window", let number = Int(parts[1]), number >= 1 else { return false }
10✔
22
        return true
5✔
23
    }
10✔
24
}
25

26
/// One entry in the persisted window index: id, name, and open-at-quit, which drives reopen-all.
27
public struct WindowEntry: Codable, Sendable, Equatable {
28
    public var id: UUID
29
    public var name: String
30
    public var isOpen: Bool
31

32
    public init(id: UUID, name: String, isOpen: Bool) {
315✔
33
        self.id = id
315✔
34
        self.name = name
315✔
35
        self.isOpen = isOpen
315✔
36
    }
315✔
37
}
38

39
/// The persisted `windows.json` index: ordered window list plus frontmost id. `version` is independent of
40
/// `Snapshot.version` (the per-window file shape) so the two evolve separately.
41
public struct WindowsIndex: Codable, Equatable, Sendable {
42
    /// Bumped when the index shape changes; a mismatch makes the index count as absent.
43
    public static let currentVersion = 1
44

45
    public var version: Int
46
    public var frontmost: UUID?
47
    public var windows: [WindowEntry]
48

49
    public init(version: Int = WindowsIndex.currentVersion, frontmost: UUID? = nil, windows: [WindowEntry] = []) {
216✔
50
        self.version = version
216✔
51
        self.frontmost = frontmost
216✔
52
        self.windows = windows
216✔
53
    }
216✔
54
}
55

56
/// The app-global owner of the window set: ordered window metadata, lazily-loaded per-window `AppStore`s,
57
/// the open-set, the frontmost id, and per-window + index persistence. `@Observable` so SwiftUI tracks the
58
/// window list + frontmost id; all access is main-actor isolated. A window is "open" iff its `AppStore` is
59
/// loaded; the persisted open-set in `windows.json` records which to reopen on the next launch.
60
///
61
/// Recovery contract (never throws, mirrors `PersistenceStore.load()`): a corrupt or version-mismatched
62
/// `windows.json` counts as absent → migrate legacy `workspaces.json` if present, else seed one window; a
63
/// missing/corrupt per-window file opens with an empty `Snapshot` (one default workspace + session), so the
64
/// window set is always valid and non-empty.
65
@Observable
66
@MainActor
67
public final class WindowLibrary {
68
    /// The ordered window metadata, for the menu/palette.
69
    public private(set) var windows: [WindowInfo]
70

71
    /// App-wide recent closed sessions/workspaces, newest first. Reopening inserts into the active window;
72
    /// independent of window reopen semantics.
73
    public private(set) var recentClosedItems: [RecentClosedItem]
74

75
    /// The id of the frontmost on-screen window, mirrored into the index on change.
76
    public var frontmostWindowID: UUID?
77

78
    /// Live per-window stores. `@ObservationIgnored`: read imperatively (scene/control), never by a view.
79
    @ObservationIgnored private var stores: [UUID: AppStore]
80

81
    /// The state directory (AGTERM_STATE_DIR-aware): the index here, per-window files in `windows/`.
82
    @ObservationIgnored private let directory: URL
83
    @ObservationIgnored private let recentClosedStore: RecentClosedStore
84
    /// One bounded run-identified ring shared by every window store for this library/app lifetime.
85
    @ObservationIgnored private let controlEventRing: ControlEventRing
86
    @ObservationIgnored private var treeEventDebouncers: [UUID: Debouncer]
87
    @ObservationIgnored private var isBootstrapping = true
109✔
88

89
    /// Set once the launch reopen-all has run, so the per-window scene `.task` drives it exactly once.
90
    @ObservationIgnored public private(set) var hasReopened = false
109✔
91

92
    /// FIFO of window ids each freshly-appearing SwiftUI window pops on appear: the plain `WindowGroup`
93
    /// (one window at launch, one per `openWindow()`) gives a window no presented id. Seeded with the open
94
    /// set, launch window first, by `consumeReopen()`.
95
    @ObservationIgnored private var pendingClaim: [UUID] = []
109✔
96

97
    /// The launch id already adopted via `adoptLaunchWindowID()`'s fallback; `consumeReopen()` excludes it
98
    /// from the seeded queue so the first reopened window can't claim it twice.
99
    @ObservationIgnored private var adoptedLaunchID: UUID?
100

101
    /// Set at quit so per-window `willClose` close-reporting no-ops — the open-set must survive for the
102
    /// next launch's reopen-all instead of being zeroed as each window tears down.
103
    @ObservationIgnored public var isTerminating = false
109✔
104

105
    private static let indexFileName = "windows.json"
106
    private static let windowsSubdirectory = "windows"
107
    private static let legacyFileName = "workspaces.json"
108

109
    private var indexURL: URL { directory.appendingPathComponent(Self.indexFileName) }
310✔
110
    private var windowsDirectory: URL { directory.appendingPathComponent(Self.windowsSubdirectory, isDirectory: true) }
271✔
111

112
    /// Creates the library rooted at `directory`, running migration/recovery per the recovery contract.
113
    public init(directory: URL = PersistenceStore.defaultDirectory,
114
                controlEventRing: ControlEventRing? = nil) {
109✔
115
        self.directory = directory
109✔
116
        self.recentClosedStore = RecentClosedStore(directory: directory)
109✔
117
        self.controlEventRing = controlEventRing ?? ControlEventRing()
109✔
118
        self.treeEventDebouncers = [:]
109✔
119
        self.stores = [:]
109✔
120
        self.windows = []
109✔
121
        self.recentClosedItems = recentClosedStore.load()
109✔
122
        self.frontmostWindowID = nil
109✔
123
        bootstrap()
109✔
124
        isBootstrapping = false
109✔
125
    }
109✔
126

127
    // MARK: - Lookup
128

129
    public func store(for id: UUID?) -> AppStore? {
91✔
130
        guard let id else { return nil }
91✔
131
        return stores[id]
89✔
132
    }
91✔
133

134
    /// The frontmost open window's id, falling back to the first open window when the frontmost is
135
    /// unset/closed — the resolution every window-keyed seam uses. Nil only when all windows are closed
136
    /// (the app is quitting).
137
    public var activeWindowID: UUID? {
67✔
138
        if let frontmostWindowID, stores[frontmostWindowID] != nil { return frontmostWindowID }
67✔
139
        for id in windows.map(\.id) where stores[id] != nil { return id }
41✔
140
        return nil
×
141
    }
67✔
142

143
    /// The store for `activeWindowID` — how the action/control/settings seams resolve what to act on.
144
    /// Non-nil in practice (never windowless at launch); nil only when all windows are closed.
145
    public var activeStore: AppStore? {
29✔
146
        store(for: activeWindowID)
29✔
147
    }
29✔
148

149
    public func isOpen(_ id: UUID) -> Bool {
24✔
150
        stores[id] != nil
24✔
151
    }
24✔
152

153
    /// Auto-hide-inactive-sidebars driver: the frontmost open window shows its sidebar, every OTHER open one
154
    /// collapses (a lone window is force-shown). The caller gates on `autoHideSidebarInactiveWindows` and
155
    /// calls it on every frontmost change plus once when the toggle flips on. `setSidebarVisible` no-ops an
156
    /// already-correct window, so only changed windows write/persist/notify.
157
    public func applyInactiveWindowSidebarHiding() {
4✔
158
        guard let active = activeWindowID else { return }
4✔
159
        for id in openIDs() {
9✔
160
            stores[id]?.setSidebarVisible(id == active)
9✔
161
        }
9✔
162
    }
4✔
163

164
    /// The window set projected into the `window.list` payload, in window order. The `geometry` closure
165
    /// supplies each open window's live frame app-side (NSWindow handles live in `WindowRegistry`, not this
166
    /// host-free model); nil by default so tests and non-AppKit callers get a geometry-free list.
167
    public func controlWindowNodes(geometry: (WindowInfo.ID) -> ControlWindowFrame? = { _ in nil },
10✔
168
                                   flags: (WindowInfo.ID) -> (fullscreen: Bool, zoomed: Bool, minimized: Bool)? = { _ in nil })
10✔
169
        -> [ControlWindowNode] {
6✔
170
        let active = activeWindowID
6✔
171
        return windows.map {
12✔
172
            // auto-follow timeout + sidebar visibility come from the per-window store, the frame +
12✔
173
            // fullscreen/zoom/minimized flags from the app-side closures; both nil for a closed window.
12✔
174
            let live = flags($0.id)
12✔
175
            return ControlWindowNode(id: $0.id.uuidString, name: $0.name, open: isOpen($0.id), active: $0.id == active,
12✔
176
                                     autoFollowMs: stores[$0.id]?.autoFollowMs,
12✔
177
                                     sidebarVisible: stores[$0.id]?.sidebarVisible,
12✔
178
                                     geometry: geometry($0.id),
12✔
179
                                     fullscreen: live?.fullscreen, zoomed: live?.zoomed,
12✔
180
                                     minimized: live?.minimized)
12✔
181
        }
12✔
182
    }
6✔
183

184
    /// Reads the app-wide ring and maps both pages and cursor failures onto the stable control response.
185
    public func readEvents(_ options: ControlEventReadOptions) -> ControlResponse {
36✔
186
        switch controlEventRing.read(cursor: options.cursor, kinds: options.kinds, limit: options.limit) {
36✔
187
        case .batch(let batch):
36✔
188
            return ControlResponse(ok: true, result: ControlResult(events: batch))
35✔
189
        case .failure(let error, let anchor):
36✔
190
            return ControlResponse(ok: false, result: ControlResult(events: anchor), error: error.rawValue)
1✔
191
        }
36✔
192
    }
36✔
193

194
    /// Test/quit seam: fires every pending structural invalidation synchronously, even one queued for a
195
    /// window already removed from the catalog.
196
    func flushTreeEvents() {
9✔
197
        for debouncer in treeEventDebouncers.values { debouncer.flush() }
9✔
198
    }
9✔
199

200
    /// Resolve a control window target against the ordered window set. All known windows are candidates,
201
    /// closed ones included; `active` resolves like `activeWindowID`.
202
    public func resolveWindow(_ target: String) -> TargetResolution {
7✔
203
        ControlResolve.resolve(target, candidates: windows.map(\.id), active: activeWindowID)
14✔
204
    }
7✔
205

206
    /// The persisted open-set in window order, for the launch reopen-all.
207
    public func openIDs() -> [UUID] {
49✔
208
        windows.map(\.id).filter { stores[$0] != nil }
87✔
209
    }
49✔
210

211
    /// Every session across all open windows, flattened — the walk the per-session sweeps share
212
    /// (restore-running-command capture + `restore.clear`).
213
    public func allOpenSessions() -> [Session] {
5✔
214
        openIDs().compactMap { stores[$0] }.flatMap { $0.workspaces.flatMap(\.sessions) }
7✔
215
    }
5✔
216

217
    /// The total unseen count across every session in every OPEN window — what the Dock badge shows,
218
    /// rolling up the `Session.unseenCount` the sidebar's red pills track. Reads only observable state
219
    /// (`windows`, each store's `workspaces`, `unseenCount`), so a `withObservationTracking` observer
220
    /// re-fires on a bump, a focus/select clear, and a session add/remove. A window CLOSE drops a store,
221
    /// which is NOT observable — the app refreshes the badge explicitly in the `willClose` teardown.
222
    public var totalUnseenCount: Int {
3✔
223
        allOpenSessions().reduce(0) { $0 + $1.unseenCount }
5✔
224
    }
3✔
225

226
    /// Open-window count plus total sessions across them — the counts the quit confirmation reports.
227
    public func openCounts() -> (windows: Int, sessions: Int) {
3✔
228
        let openStores = windows.map(\.id).compactMap { stores[$0] }
6✔
229
        let sessions = openStores.reduce(0) { total, store in
5✔
230
            total + store.workspaces.reduce(0) { $0 + $1.sessions.count }
5✔
231
        }
5✔
232
        return (openStores.count, sessions)
3✔
233
    }
3✔
234

235
    /// The id SwiftUI's auto-opened launch window claims: the frontmost open window, else the first, nil
236
    /// when all are closed. Guards the frontmost on openness — one pointing at a closed window must fall
237
    /// through, else `consumeReopen` seeds a closed id and undercounts the open set.
238
    private var launchWindowID: UUID? {
22✔
239
        if let frontmostWindowID, stores[frontmostWindowID] != nil { return frontmostWindowID }
22✔
240
        return openIDs().first
3✔
241
    }
22✔
242

243
    /// Latches the launch reopen-all so it runs once across the per-window scene `.task`s, seeds the claim
244
    /// queue with the open set, and returns the ADDITIONAL `openWindow()` calls needed beyond SwiftUI's
245
    /// auto-opened launch window — N-1 for N open windows (≥0), 0 on every later call.
246
    ///
247
    /// The launch window takes exactly one id: from the queue (this ran before its `.onAppear`) or from
248
    /// `adoptLaunchWindowID()`'s fallback (its `.onAppear` ran first); an already-adopted id is excluded so
249
    /// the first reopened window can't claim it twice. The N-1 count is the same either way.
250
    public func consumeReopen() -> Int {
9✔
251
        guard !hasReopened else { return 0 }
9✔
252
        hasReopened = true
8✔
253
        let open = openIDs()
8✔
254
        let ordered = (launchWindowID.map { [$0] } ?? []) + open.filter { $0 != launchWindowID }
11✔
255
        pendingClaim = ordered.filter { $0 != adoptedLaunchID }
11✔
256
        return max(open.count - 1, 0)
8✔
257
    }
9✔
258

259
    /// Pops the next window id for a freshly-appearing SwiftUI window to render. Nil once the queue is
260
    /// drained — a window beyond the open set (e.g. a SwiftUI-restored extra), which the app dismisses.
261
    public func claimNextWindowID() -> UUID? {
22✔
262
        guard !pendingClaim.isEmpty else { return nil }
22✔
263
        return pendingClaim.removeFirst()
13✔
264
    }
22✔
265

266
    /// The launch window's fallback id when its `.onAppear` beats the scene `.task`'s queue seeding. Records
267
    /// it as adopted so a later `consumeReopen()` excludes it — else the first reopened window claims it
268
    /// again and two windows bind one store. Only the FIRST caller gets an id; a second (several restored
269
    /// windows all hitting the empty-queue fallback) gets nil and dismisses itself, as does an all-closed set.
270
    public func adoptLaunchWindowID() -> UUID? {
4✔
271
        guard adoptedLaunchID == nil, let id = launchWindowID else { return nil }
4✔
272
        adoptedLaunchID = id
3✔
273
        return id
3✔
274
    }
4✔
275

276
    /// Enqueues a window id for the next appearing SwiftUI window to adopt (`newWindow` + `openWindow()`, or
277
    /// a `window.select`/reveal of a closed window). Dedups on queue MEMBERSHIP only, so a repeated
278
    /// `window.select` before the first claim is consumed never spawns two windows; it must NOT also skip an
279
    /// id whose store is loaded, since `newWindow` pre-loads the store and the window would self-dismiss.
280
    /// The "already on-screen, raise instead of spawn" check lives in `WindowRegistry.raise`.
281
    public func enqueueClaim(_ id: UUID) {
6✔
282
        guard !pendingClaim.contains(id) else { return }
6✔
283
        pendingClaim.append(id)
4✔
284
    }
4✔
285

286
    /// The id of the OPEN window owning the given session, or nil — closed windows aren't loaded/searched.
287
    public func windowID(forSession sessionID: UUID) -> UUID? {
9✔
288
        for id in windows.map(\.id) where stores[id]?.session(withID: sessionID) != nil { return id }
2,147,483,647✔
289
        return nil
2,147,483,647✔
290
    }
9✔
291

292
    /// The open store owning the given session — backs cross-window targeting (reveal + ControlServer).
293
    public func store(forSession sessionID: UUID) -> AppStore? {
5✔
294
        store(for: windowID(forSession: sessionID))
5✔
295
    }
5✔
296

297
    /// The id of the open window backed by `store` (by identity) — the reverse of `store(for:)`, for
298
    /// reaching a window's per-window controllers (quick terminal / zoom / dashboard).
299
    public func windowID(for store: AppStore) -> UUID? {
×
300
        openIDs().first { stores[$0] === store }
×
301
    }
×
302

303
    /// The window's display name, "" for a nil or unknown id — the name half of the
304
    /// `{AGT_WINDOW_NAME}`/`$AGT_WINDOW_NAME` command context.
305
    public func windowName(for id: UUID?) -> String {
4✔
306
        guard let id else { return "" }
4✔
307
        return windows.first { $0.id == id }?.name ?? ""
5✔
308
    }
4✔
309

310
    public var defaultWindowName: String {
92✔
311
        "window \(windows.count + 1)"
92✔
312
    }
92✔
313

314
    // MARK: - Mutation
315

316
    /// Creates a window seeded with "workspace 1" and one $HOME session, opens it, and persists the index.
317
    /// Defaults the name to "window N".
318
    @discardableResult
319
    public func newWindow(name: String? = nil) -> WindowInfo {
132✔
320
        // the name feeds {AGT_WINDOW_NAME}; see TerminalText.
132✔
321
        let info = WindowInfo(name: name.map(TerminalText.sanitized)?.trimmedOrNil ?? defaultWindowName)
132✔
322
        let store = makeStore(for: info.id, persistence: persistenceStore(for: info.id))
132✔
323
        let workspace = store.addWorkspace(name: "workspace 1")
132✔
324
        store.addSession(toWorkspace: workspace.id, cwd: FileManager.default.homeDirectoryForCurrentUser.path)
132✔
325
        windows.append(info)
132✔
326
        stores[info.id] = store
132✔
327
        // mark frontmost now so the window-keyed seams target it immediately instead of waiting on its
132✔
328
        // first `didBecomeKey` — which loses to the File-menu focus returning to the previous window.
132✔
329
        frontmostWindowID = info.id
132✔
330
        saveIndex()
132✔
331
        return info
132✔
332
    }
132✔
333

334
    /// Lazily builds (or returns the cached) `AppStore` from `windows/<id>.json`, marks the window open, and
335
    /// persists. Nil for an id with no index entry.
336
    ///
337
    /// `launchRestore` marks an APP-BOOTSTRAP load — passed only by `reopen`/`recoverOrphanedWindows`, and
338
    /// the only thing that arms a session's persisted `session.restore` override. False by default because
339
    /// `ContentView.resolveStore()` calls this at RUNTIME for a mid-process reopen, which must not execute.
340
    @discardableResult
341
    public func loadStore(for id: UUID, launchRestore: Bool = false) -> AppStore? {
34✔
342
        guard windows.contains(where: { $0.id == id }) else { return nil }
46✔
343
        if let existing = stores[id] { return existing }
33✔
344
        let persistence = persistenceStore(for: id)
33✔
345
        let store = makeStore(for: id, persistence: persistence)
33✔
346
        store.restore(from: persistence.load(), launchRestore: launchRestore)
33✔
347
        stores[id] = store
33✔
348
        if !launchRestore {
33✔
349
            for workspace in store.workspaces {
4✔
350
                for session in workspace.sessions { store.emitSessionCreated(session, workspace: workspace.id) }
4✔
351
            }
4✔
352
            store.scheduleTreeChanged()
4✔
353
        }
4✔
354
        saveIndex()
33✔
355
        return store
33✔
356
    }
34✔
357

358
    @discardableResult
359
    public func reopenRecentClosed(_ itemID: UUID, into targetStore: AppStore? = nil) -> Bool {
6✔
360
        refreshRecentClosedItems()
6✔
361
        guard let item = recentClosedItems.first(where: { $0.id == itemID }),
7✔
362
              let store = targetStore ?? activeStore,
6✔
363
              store.restoreRecentClosed(item)
6✔
364
        else { return false }
6✔
365
        recentClosedStore.remove(itemID)
6✔
366
        refreshRecentClosedItems()
6✔
367
        return true
6✔
368
    }
6✔
369

370
    @discardableResult
UNCOV
371
    public func reopenLatestRecentClosed(into targetStore: AppStore? = nil) -> Bool {
×
UNCOV
372
        refreshRecentClosedItems()
×
UNCOV
373
        guard let item = recentClosedItems.first else { return false }
×
UNCOV
374
        return reopenRecentClosed(item.id, into: targetStore)
×
UNCOV
375
    }
×
376

UNCOV
377
    public func clearRecentClosedItems() {
×
UNCOV
378
        recentClosedStore.clear()
×
UNCOV
379
        refreshRecentClosedItems()
×
UNCOV
380
    }
×
381

382
    /// Closes a window: drops its store and persists the index. The app-target caller tears down the
383
    /// window's surfaces first. No-op for an unknown/closed id, or while terminating (see `isTerminating`).
384
    public func closeWindow(_ id: UUID) {
19✔
385
        guard !isTerminating else { return }
19✔
386
        // cancel any queued claim so a window still attaching can't re-open it after a close that raced
18✔
387
        // its registration (window.new immediately followed by window.close).
18✔
388
        pendingClaim.removeAll { $0 == id }
18✔
389
        guard let store = stores[id] else { return }
18✔
390
        for workspace in store.workspaces {
17✔
391
            for session in workspace.sessions { store.emitSessionClosed(session, workspace: workspace.id) }
18✔
392
        }
17✔
393
        store.scheduleTreeChanged()
17✔
394
        stores[id] = nil
17✔
395
        if frontmostWindowID == id { frontmostWindowID = activeWindowID }
17✔
396
        saveIndex()
17✔
397
    }
17✔
398

399
    /// Renames a window; the name lives only in the index. An empty/whitespace-only name is ignored.
400
    public func renameWindow(_ id: UUID, to name: String) {
3✔
401
        // the name feeds {AGT_WINDOW_NAME}; see TerminalText.
3✔
402
        guard let trimmed = TerminalText.sanitized(name).trimmedOrNil, let index = windows.firstIndex(where: { $0.id == id }) else { return }
3✔
403
        guard windows[index].name != trimmed else { return }
2✔
404
        windows[index].name = trimmed
2✔
405
        scheduleTreeChanged(for: id)
2✔
406
        saveIndex()
2✔
407
    }
2✔
408

409
    /// Whether a window may be removed — one window is always kept.
410
    public var canRemoveWindow: Bool { windows.count > 1 }
10✔
411

412
    /// Removes a window: drops its store, deletes its per-window file, removes the index entry, and
413
    /// persists. No-ops on the last window. Clears `frontmostWindowID` if it pointed at the removed one.
414
    public func removeWindow(_ id: UUID) {
9✔
415
        guard canRemoveWindow, let index = windows.firstIndex(where: { $0.id == id }) else { return }
15✔
416
        if let store = stores[id] {
8✔
417
            for workspace in store.workspaces {
6✔
418
                for session in workspace.sessions { store.emitSessionClosed(session, workspace: workspace.id) }
6✔
419
            }
6✔
420
        }
6✔
421
        scheduleTreeChanged(for: id)
8✔
422
        // cancel the pending debounced save BEFORE deleting the file: a ~0.3 s save from a just-before-delete
8✔
423
        // selectSession/setFontSize outlives the delete-path willClose (which skips its own save, the window
8✔
424
        // being closed) and would land after removeItem, re-creating windows/<id>.json as an orphan that a
8✔
425
        // future index loss resurrects via recoverOrphanedWindows().
8✔
426
        stores[id]?.cancelPendingSave()
8✔
427
        // sweep each session's rendered `.text` watermark PNG before dropping the store: window-DELETE
8✔
428
        // destroys its sessions permanently and has no later sweep, unlike window-CLOSE. Ids come from the
8✔
429
        // live store when open, the persisted snapshot when closed — else a closed window's PNGs orphan in
8✔
430
        // <stateDir>/watermarks/. `directory` is the same root WatermarkStorage resolves against, so passing
8✔
431
        // it is production-identical and lets a test sweep into an injected temp dir.
8✔
432
        let sessionIDsToSweep: [UUID] = stores[id].map { $0.workspaces.flatMap(\.sessions).map(\.id) }
8✔
433
            ?? persistenceStore(for: id).load().workspaces.flatMap(\.sessions).map(\.id)
8✔
434
        for sessionID in sessionIDsToSweep {
8✔
435
            WatermarkStorage.removeRenderedText(sessionID: sessionID, stateDir: directory)
8✔
436
        }
8✔
437
        stores[id] = nil
8✔
438
        windows.remove(at: index)
8✔
439
        if frontmostWindowID == id { frontmostWindowID = nil }
8✔
440
        // best-effort: a missing/never-written per-window file is fine to "fail" to remove.
8✔
441
        try? FileManager.default.removeItem(at: windowFileURL(for: id))
8✔
442
        saveIndex()
8✔
443
    }
8✔
444

445
    /// Clears every session's font-size override across ALL windows — open ones through their live store,
446
    /// closed ones by rewriting `windows/<id>.json`. A closed window must drop its stale sizes too, else it
447
    /// reopens overriding the new global default. No-ops a window with no overrides.
448
    public func resetSessionFontSizesAllWindows() {
1✔
449
        for info in windows {
2✔
450
            if let store = stores[info.id] {
2✔
451
                store.resetSessionFontSizes()
1✔
452
                continue
1✔
453
            }
1✔
454
            clearClosedWindowFontSizes(info.id)
1✔
455
        }
1✔
456
    }
1✔
457

458
    /// Strips every `fontSize` override from a closed window's snapshot, rewriting only when something
459
    /// changed so untouched windows don't churn. Best-effort: a missing/corrupt file loads as empty (no
460
    /// overrides to clear) and a write failure is swallowed.
461
    private func clearClosedWindowFontSizes(_ id: UUID) {
1✔
462
        let persistence = persistenceStore(for: id)
1✔
463
        var snapshot = persistence.load()
1✔
464
        var changed = false
1✔
465
        for w in snapshot.workspaces.indices {
1✔
466
            for s in snapshot.workspaces[w].sessions.indices where snapshot.workspaces[w].sessions[s].fontSize != nil {
1✔
467
                snapshot.workspaces[w].sessions[s].fontSize = nil
1✔
468
                changed = true
1✔
469
            }
1✔
470
        }
1✔
471
        guard changed else { return }
1✔
472
        try? persistence.save(snapshot)
1✔
473
    }
1✔
474

475
    // MARK: - Persistence
476

477
    /// Flushes every open window's store — the quit-time flush persisting cwd changes made since the last
478
    /// structural mutation.
479
    public func saveAllOpen() {
1✔
480
        for store in stores.values { store.save() }
2✔
481
    }
1✔
482

483
    /// Finalizes any grace-period session/workspace closes in open windows before a window/app teardown.
UNCOV
484
    public func finalizeAllPendingCloses() {
×
UNCOV
485
        for store in stores.values { store.finalizeAllPendingCloses() }
×
UNCOV
486
    }
×
487

488
    /// Writes `windows.json`: ordered window list with open flags, plus the frontmost id. A write failure
489
    /// is logged and swallowed.
490
    public func saveIndex() {
201✔
491
        let entries = windows.map { WindowEntry(id: $0.id, name: $0.name, isOpen: stores[$0.id] != nil) }
294✔
492
        let index = WindowsIndex(frontmost: frontmostWindowID, windows: entries)
201✔
493
        do {
201✔
494
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
201✔
495
            let encoder = JSONEncoder()
201✔
496
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
201✔
497
            try encoder.encode(index).write(to: indexURL, options: .atomic)
201✔
498
        } catch {
201✔
499
            log("saveIndex failed: \(error)")
×
500
        }
201✔
501
    }
201✔
502

503
    // MARK: - Bootstrap (migration + recovery)
504

505
    /// Resolves the window set on init: a valid `windows.json`; else recover orphaned `windows/<id>.json`
506
    /// files into a fresh index (so a schema bump invalidating the index doesn't lose the trees); else
507
    /// migrate legacy `workspaces.json`; else seed one window. Reopens the persisted open-set, never
508
    /// windowless — falls back to the frontmost/first window.
509
    private func bootstrap() {
109✔
510
        if let index = loadIndex() {
109✔
511
            windows = index.windows.map { WindowInfo(id: $0.id, name: $0.name) }
30✔
512
            frontmostWindowID = index.frontmost
18✔
513
            reopen(index)
18✔
514
            return
18✔
515
        }
91✔
516
        if recoverOrphanedWindows() { return }
91✔
517
        if migrateLegacy() { return }
88✔
518
        newWindow()
84✔
519
    }
84✔
520

521
    /// Reads `windows.json`; a missing/corrupt/version-mismatched file reads as nil, so the caller falls
522
    /// through to recovery/migration/seeding.
523
    private func loadIndex() -> WindowsIndex? {
109✔
524
        guard let data = try? Data(contentsOf: indexURL) else { return nil }
109✔
525
        guard let index = try? JSONDecoder().decode(WindowsIndex.self, from: data) else { return nil }
26✔
526
        guard index.version == WindowsIndex.currentVersion, !index.windows.isEmpty else { return nil }
21✔
527
        return index
18✔
528
    }
109✔
529

530
    /// Reopens the persisted open-set, falling back to the frontmost (else the first) so the app is never
531
    /// windowless. The frontmost is used only when it still exists in `windows` — a stale id (a deleted
532
    /// window) would no-op `loadStore` and leave the app windowless.
533
    private func reopen(_ index: WindowsIndex) {
18✔
534
        for entry in index.windows where entry.isOpen { loadStore(for: entry.id, launchRestore: true) }
30✔
535
        guard openIDs().isEmpty else { return }
18✔
536
        let frontmostExists = index.frontmost.map { id in windows.contains { $0.id == id } } ?? false
3✔
537
        let fallback = (frontmostExists ? index.frontmost : nil) ?? windows.first?.id
3✔
538
        if let fallback { loadStore(for: fallback, launchRestore: true) }
3✔
539
    }
3✔
540

541
    /// Recovers surviving `windows/<id>.json` files into a fresh index rather than falling through to
542
    /// legacy/seeding, which would discard the user's sessions. Each UUID-stemmed file becomes an OPEN
543
    /// window named "window N" in filename order, so numbering and the frontmost pick (the first) are
544
    /// deterministic; non-UUID stems are skipped. False when nothing is recoverable. All orphans open means
545
    /// the launch reopen-all puts them on screen at once — acceptable for this rare path.
546
    @discardableResult
547
    private func recoverOrphanedWindows() -> Bool {
91✔
548
        let contents = (try? FileManager.default.contentsOfDirectory(at: windowsDirectory,
91✔
549
                                                                     includingPropertiesForKeys: nil)) ?? []
91✔
550
        let ids = contents
91✔
551
            .filter { $0.pathExtension == "json" }
91✔
552
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
91✔
553
            .compactMap { UUID(uuidString: $0.deletingPathExtension().lastPathComponent) }
91✔
554
        guard !ids.isEmpty else { return false }
91✔
555
        let infos = ids.enumerated().map { WindowInfo(id: $0.element, name: "window \($0.offset + 1)") }
4✔
556
        // append ALL infos FIRST — `loadStore` guards on `windows.contains(id)` and would silently no-op.
3✔
557
        windows.append(contentsOf: infos)
3✔
558
        for info in infos { loadStore(for: info.id, launchRestore: true) }
4✔
559
        frontmostWindowID = infos.first?.id
3✔
560
        saveIndex()
3✔
561
        return true
3✔
562
    }
91✔
563

564
    /// Wraps a legacy `workspaces.json` into one window ("window 1"): writes its snapshot to
565
    /// `windows/<id>.json` + an index marking it open/frontmost, and opens it. False when no legacy file.
566
    @discardableResult
567
    private func migrateLegacy() -> Bool {
88✔
568
        let legacy = PersistenceStore(directory: directory, fileName: Self.legacyFileName)
88✔
569
        let snapshot = legacy.load()
88✔
570
        guard !snapshot.workspaces.isEmpty else { return false }
88✔
571
        // windows is empty here, so `defaultWindowName` yields "window 1".
4✔
572
        let info = WindowInfo(name: defaultWindowName)
4✔
573
        let store = makeStore(for: info.id, persistence: persistenceStore(for: info.id))
4✔
574
        store.restore(from: snapshot, launchRestore: true)
4✔
575
        store.save()
4✔
576
        windows = [info]
4✔
577
        stores[info.id] = store
4✔
578
        frontmostWindowID = info.id
4✔
579
        saveIndex()
4✔
580
        return true
4✔
581
    }
88✔
582

583
    // MARK: - Helpers
584

585
    private func windowFileURL(for id: UUID) -> URL {
8✔
586
        windowsDirectory.appendingPathComponent("\(id.uuidString).json")
8✔
587
    }
8✔
588

589
    private func persistenceStore(for id: UUID) -> PersistenceStore {
172✔
590
        PersistenceStore(directory: windowsDirectory, fileName: "\(id.uuidString).json")
172✔
591
    }
172✔
592

593
    private func makeStore(for windowID: UUID, persistence: PersistenceStore) -> AppStore {
169✔
594
        AppStore(
169✔
595
            persistence: persistence,
169✔
596
            recentClosedStore: recentClosedStore,
169✔
597
            recentClosedDidChange: { [weak self] in self?.refreshRecentClosedItems() },
169✔
598
            controlEventSink: { [weak self] draft in
623✔
599
                guard let self else { return }
623✔
600
                guard !self.isBootstrapping else { return }
623✔
601
                if draft.kind == .treeChanged {
371✔
602
                    self.scheduleTreeChanged(for: windowID)
219✔
603
                    return
219✔
604
                }
219✔
605
                self.controlEventRing.append(ControlEventDraft(
152✔
606
                    kind: draft.kind,
152✔
607
                    window: windowID.uuidString,
152✔
608
                    workspace: draft.workspace,
152✔
609
                    session: draft.session,
152✔
610
                    payload: draft.payload
152✔
611
                ))
152✔
612
            }
152✔
613
        )
169✔
614
    }
169✔
615

616
    private func scheduleTreeChanged(for windowID: UUID) {
229✔
617
        let debouncer = treeEventDebouncers[windowID] ?? Debouncer()
229✔
618
        treeEventDebouncers[windowID] = debouncer
229✔
619
        debouncer.schedule(after: 0.1) { [weak self] in
229✔
620
            self?.controlEventRing.append(ControlEventDraft(kind: .treeChanged, window: windowID.uuidString))
8✔
621
        }
8✔
622
    }
229✔
623

624
    private func refreshRecentClosedItems() {
40✔
625
        recentClosedItems = recentClosedStore.load()
40✔
626
    }
40✔
627

UNCOV
628
    private func log(_ message: @autoclosure () -> String) {
×
UNCOV
629
        NSLog("agterm: %@", message())
×
UNCOV
630
    }
×
631
}
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