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

umputun / agterm / 28959094125

08 Jul 2026 04:33PM UTC coverage: 98.36%. First build
28959094125

Pull #174

github

web-flow
Merge fec95c35f into afe185280
Pull Request #174: feat(sessions): reopen recently closed items

346 of 376 new or added lines in 7 files covered. (92.02%)

4139 of 4208 relevant lines covered (98.36%)

7144818.75 hits per line

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

94.79
/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) {
132✔
12
        self.id = id
132✔
13
        self.name = name
132✔
14
    }
132✔
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) {
239✔
37
        self.id = id
239✔
38
        self.name = name
239✔
39
        self.isOpen = isOpen
239✔
40
    }
239✔
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] = []) {
158✔
55
        self.version = version
158✔
56
        self.frontmost = frontmost
158✔
57
        self.windows = windows
158✔
58
    }
158✔
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

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

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

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

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

117
    private static let indexFileName = "windows.json"
118
    private static let windowsSubdirectory = "windows"
119
    private static let legacyFileName = "workspaces.json"
120

121
    private var indexURL: URL { directory.appendingPathComponent(Self.indexFileName) }
220✔
122
    private var windowsDirectory: URL { directory.appendingPathComponent(Self.windowsSubdirectory, isDirectory: true) }
187✔
123

124
    /// Creates the library rooted at `directory`, running migration/recovery so the resulting
125
    /// window set is always valid and non-empty.
126
    public init(directory: URL = PersistenceStore.defaultDirectory) {
76✔
127
        self.directory = directory
76✔
128
        self.recentClosedStore = RecentClosedStore(directory: directory)
76✔
129
        self.stores = [:]
76✔
130
        self.windows = []
76✔
131
        self.recentClosedItems = recentClosedStore.load()
76✔
132
        self.frontmostWindowID = nil
76✔
133
        bootstrap()
76✔
134
    }
76✔
135

136
    // MARK: - Lookup
137

138
    /// The live store of an open window, or nil when the window is closed/unknown.
139
    public func store(for id: UUID?) -> AppStore? {
52✔
140
        guard let id else { return nil }
52✔
141
        return stores[id]
50✔
142
    }
52✔
143

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

154
    /// The frontmost open window's store, falling back to the first open store (in window order)
155
    /// when the frontmost id is unset/closed. The app-side action/control/settings seams resolve
156
    /// the store to act on through this — it stays non-nil because the library is never windowless
157
    /// at launch. Nil only in the degenerate all-windows-closed state (the app is quitting).
158
    public var activeStore: AppStore? {
11✔
159
        store(for: activeWindowID)
11✔
160
    }
11✔
161

162
    /// Whether the window is currently open (its store is loaded).
163
    public func isOpen(_ id: UUID) -> Bool {
24✔
164
        stores[id] != nil
24✔
165
    }
24✔
166

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

189
    /// Resolve a control window target against the library's ordered window set. All known windows are
190
    /// candidates, including closed windows; `active` resolves to the frontmost open window, with the
191
    /// same fallback as `activeWindowID`.
192
    public func resolveWindow(_ target: String) -> TargetResolution {
7✔
193
        ControlResolve.resolve(target, candidates: windows.map(\.id), active: activeWindowID)
14✔
194
    }
7✔
195

196
    /// The persisted open-set in window order, for the launch reopen-all. A window is open iff
197
    /// its store is loaded.
198
    public func openIDs() -> [UUID] {
44✔
199
        windows.map(\.id).filter { stores[$0] != nil }
77✔
200
    }
44✔
201

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

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

219
    /// The number of currently-open windows and the total number of sessions across them — the
220
    /// counts the quit confirmation reports. A window is open iff its store is loaded.
221
    public func openCounts() -> (windows: Int, sessions: Int) {
3✔
222
        let openStores = windows.map(\.id).compactMap { stores[$0] }
6✔
223
        let sessions = openStores.reduce(0) { total, store in
5✔
224
            total + store.workspaces.reduce(0) { $0 + $1.sessions.count }
5✔
225
        }
5✔
226
        return (openStores.count, sessions)
3✔
227
    }
3✔
228

229
    /// The id SwiftUI's auto-opened launch window claims: the frontmost open window, else the first.
230
    /// `nil` only in the degenerate all-windows-closed state. Guards the frontmost on openness (like
231
    /// `activeWindowID`) — a frontmost pointing at a closed window must fall through to the first open
232
    /// one, else `consumeReopen` seeds a closed id and undercounts the open set.
233
    private var launchWindowID: UUID? {
22✔
234
        if let frontmostWindowID, stores[frontmostWindowID] != nil { return frontmostWindowID }
22✔
235
        return openIDs().first
3✔
236
    }
22✔
237

238
    /// Latches the launch reopen-all so it runs once across the per-window scene `.task`s, seeding
239
    /// the claim queue with the open set and returning the *additional* `openWindow()` calls needed
240
    /// beyond the one window SwiftUI auto-opens at launch. So with N open windows it returns N-1 (each
241
    /// ≥0). Empty on every subsequent call.
242
    ///
243
    /// The launch window takes exactly one id: via the queue (when this runs before its `.onAppear`)
244
    /// or via `adoptLaunchWindowID()`'s fallback (when its `.onAppear` ran first). An already-adopted
245
    /// launch id is excluded from the queue so the first reopened window can't claim it a second time
246
    /// (two windows binding one store — the duplicate-store collision). The N-1 count is independent
247
    /// of which path the launch window took.
248
    public func consumeReopen() -> Int {
9✔
249
        guard !hasReopened else { return 0 }
9✔
250
        hasReopened = true
8✔
251
        let open = openIDs()
8✔
252
        // launch window first, then the rest in window order — minus any id the fallback already
8✔
253
        // handed the launch window (so it isn't claimed twice).
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. Returns nil once the
260
    /// queue is drained (a window beyond the open set — e.g. a SwiftUI-restored extra — which the app
261
    /// dismisses).
262
    public func claimNextWindowID() -> UUID? {
22✔
263
        guard !pendingClaim.isEmpty else { return nil }
22✔
264
        return pendingClaim.removeFirst()
13✔
265
    }
22✔
266

267
    /// The launch window's fallback id when its `.onAppear` fires before the scene `.task` seeds the
268
    /// claim queue. Records the id as adopted so a later `consumeReopen()` excludes it from the queue
269
    /// — without this, `consumeReopen` re-seeds the launch id and the first reopened window claims it
270
    /// again, leaving two windows bound to one store. Idempotent-per-launch: only the FIRST caller gets
271
    /// the launch id; a second caller before `consumeReopen()` runs (SwiftUI restored more than one
272
    /// window, each hitting the empty-queue fallback) gets nil and dismisses itself, so two windows
273
    /// can't both bind the one launch store. Returns nil in the degenerate all-windows-closed state.
274
    public func adoptLaunchWindowID() -> UUID? {
4✔
275
        guard adoptedLaunchID == nil, let id = launchWindowID else { return nil }
4✔
276
        adoptedLaunchID = id
3✔
277
        return id
3✔
278
    }
4✔
279

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

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

300
    /// The open store that owns the given session, searching only OPEN windows. Backs cross-window
301
    /// session targeting (notification reveal + ControlServer).
302
    public func store(forSession sessionID: UUID) -> AppStore? {
5✔
303
        store(for: windowID(forSession: sessionID))
5✔
304
    }
5✔
305

306
    /// The auto-generated name for the next new window (`window 1`, `window 2`, …).
307
    public var defaultWindowName: String {
61✔
308
        "window \(windows.count + 1)"
61✔
309
    }
61✔
310

311
    // MARK: - Mutation
312

313
    /// Creates a fresh window seeded with one default workspace ("workspace 1") and one session
314
    /// at $HOME (the seeding that used to live in the app's `restoredStore()`), opens it (loads
315
    /// its store), and persists the index. Defaults the name to "window N".
316
    @discardableResult
317
    public func newWindow(name: String? = nil) -> WindowInfo {
90✔
318
        let info = WindowInfo(name: name?.trimmedOrNil ?? defaultWindowName)
90✔
319
        let store = makeStore(persistence: persistenceStore(for: info.id))
90✔
320
        let workspace = store.addWorkspace(name: "workspace 1")
90✔
321
        store.addSession(toWorkspace: workspace.id, cwd: FileManager.default.homeDirectoryForCurrentUser.path)
90✔
322
        windows.append(info)
90✔
323
        stores[info.id] = store
90✔
324
        // a newly created window is the active one: mark it frontmost so the seams that key off the
90✔
325
        // window (the active store, the command palette, the quick terminal) target it immediately,
90✔
326
        // rather than waiting on the new on-screen window's first `didBecomeKey` (which loses to the
90✔
327
        // File-menu focus returning to the previous window after New Window).
90✔
328
        frontmostWindowID = info.id
90✔
329
        saveIndex()
90✔
330
        return info
90✔
331
    }
90✔
332

333
    /// Lazily builds (or returns the cached) `AppStore` for a window, loading its persisted
334
    /// `windows/<id>.json` (an empty `Snapshot` when missing/corrupt, per the recovery contract).
335
    /// No-op returning nil for an id with no index entry. Marks the window open and persists.
336
    @discardableResult
337
    public func loadStore(for id: UUID) -> AppStore? {
29✔
338
        guard windows.contains(where: { $0.id == id }) else { return nil }
40✔
339
        if let existing = stores[id] { return existing }
28✔
340
        let persistence = persistenceStore(for: id)
28✔
341
        let store = makeStore(persistence: persistence)
28✔
342
        store.restore(from: persistence.load())
28✔
343
        stores[id] = store
28✔
344
        saveIndex()
28✔
345
        return store
28✔
346
    }
29✔
347

348
    @discardableResult
349
    public func reopenRecentClosed(_ itemID: UUID, into targetStore: AppStore? = nil) -> Bool {
3✔
350
        refreshRecentClosedItems()
3✔
351
        guard let item = recentClosedItems.first(where: { $0.id == itemID }),
4✔
352
              let store = targetStore ?? activeStore,
3✔
353
              store.restoreRecentClosed(item)
3✔
354
        else { return false }
3✔
355
        recentClosedStore.remove(itemID)
3✔
356
        refreshRecentClosedItems()
3✔
357
        return true
3✔
358
    }
3✔
359

360
    @discardableResult
NEW
361
    public func reopenLatestRecentClosed(into targetStore: AppStore? = nil) -> Bool {
×
NEW
362
        refreshRecentClosedItems()
×
NEW
363
        guard let item = recentClosedItems.first else { return false }
×
NEW
364
        return reopenRecentClosed(item.id, into: targetStore)
×
NEW
365
    }
×
366

NEW
367
    public func clearRecentClosedItems() {
×
NEW
368
        recentClosedStore.clear()
×
NEW
369
        refreshRecentClosedItems()
×
NEW
370
    }
×
371

372
    /// Closes a window: drops its store (marking it closed) and persists the index. The caller
373
    /// (app target) tears down the window's surfaces first — `WindowLibrary` only drops the store.
374
    /// No-op for an unknown/closed id, or while terminating (the open-set must survive for reopen-all).
375
    public func closeWindow(_ id: UUID) {
15✔
376
        guard !isTerminating else { return }
15✔
377
        // cancel any queued claim for this id so a window still attaching can't re-open it after a
14✔
378
        // close that raced its registration (window.new immediately followed by window.close).
14✔
379
        pendingClaim.removeAll { $0 == id }
14✔
380
        guard stores[id] != nil else { return }
14✔
381
        stores[id] = nil
13✔
382
        if frontmostWindowID == id { frontmostWindowID = activeWindowID }
13✔
383
        saveIndex()
13✔
384
    }
13✔
385

386
    /// Renames a window (and its open store is unaffected — the name lives only in the index).
387
    /// An empty/whitespace-only name is ignored. Persists the index.
388
    public func renameWindow(_ id: UUID, to name: String) {
2✔
389
        guard let trimmed = name.trimmedOrNil, let index = windows.firstIndex(where: { $0.id == id }) else { return }
2✔
390
        windows[index].name = trimmed
1✔
391
        saveIndex()
1✔
392
    }
1✔
393

394
    /// Whether a window may be removed: one window is always kept, so removal is allowed only
395
    /// when more than one exists.
396
    public var canRemoveWindow: Bool { windows.count > 1 }
7✔
397

398
    /// Removes a window: drops its store, deletes its per-window file, removes the index entry,
399
    /// and persists. No-ops unless more than one window exists (the last one is kept). Clears
400
    /// `frontmostWindowID` if it pointed at the removed window.
401
    public func removeWindow(_ id: UUID) {
6✔
402
        guard canRemoveWindow, let index = windows.firstIndex(where: { $0.id == id }) else { return }
9✔
403
        // cancel the store's pending debounced save BEFORE deleting the file — a save scheduled by a
5✔
404
        // just-before-delete selectSession/setFontSize captures the store weakly and fires ~0.3 s out;
5✔
405
        // since the delete-path willClose teardown skips its own save() (the window is no longer open),
5✔
406
        // an un-cancelled pending save would fire after removeItem and re-create windows/<id>.json as an
5✔
407
        // orphan that a future index loss would resurrect via recoverOrphanedWindows().
5✔
408
        stores[id]?.cancelPendingSave()
5✔
409
        // sweep each session's rendered `.text` watermark PNG before dropping the store: deleting a window
5✔
410
        // permanently destroys its sessions (like closeSession/removeWorkspace), so their PNGs must go too —
5✔
411
        // window-CLOSE keeps the sessions and is handled elsewhere, but window-DELETE has no later sweep.
5✔
412
        // An OPEN window's session ids come from its live store; a CLOSED one has no store, so read them from
5✔
413
        // the persisted snapshot — else deleting a closed window orphans its PNGs in <stateDir>/watermarks/.
5✔
414
        // `directory` is the state-dir root WatermarkStorage resolves against (both default to
5✔
415
        // AGTERM_STATE_DIR else PersistenceStore.defaultDirectory), so passing it is production-identical
5✔
416
        // and lets a test sweep into an injected temp dir without touching process-global env.
5✔
417
        let sessionIDsToSweep: [UUID] = stores[id].map { $0.workspaces.flatMap(\.sessions).map(\.id) }
5✔
418
            ?? persistenceStore(for: id).load().workspaces.flatMap(\.sessions).map(\.id)
5✔
419
        for sessionID in sessionIDsToSweep {
5✔
420
            WatermarkStorage.removeRenderedText(sessionID: sessionID, stateDir: directory)
5✔
421
        }
5✔
422
        stores[id] = nil
5✔
423
        windows.remove(at: index)
5✔
424
        if frontmostWindowID == id { frontmostWindowID = nil }
5✔
425
        // best-effort: a missing/never-written per-window file is fine to "fail" to remove.
5✔
426
        try? FileManager.default.removeItem(at: windowFileURL(for: id))
5✔
427
        saveIndex()
5✔
428
    }
5✔
429

430
    /// Clears every session's per-window font-size override across ALL windows — open ones through
431
    /// their live store, closed ones by rewriting the persisted `windows/<id>.json` in place. A global
432
    /// font/appearance change resets every surface to the new default, so a closed window must drop its
433
    /// stale per-session sizes too, else it reopens later overriding the new default. No-ops a window
434
    /// (open or closed) whose snapshot has no overrides.
435
    public func resetSessionFontSizesAllWindows() {
1✔
436
        for info in windows {
2✔
437
            if let store = stores[info.id] {
2✔
438
                store.resetSessionFontSizes()
1✔
439
                continue
1✔
440
            }
1✔
441
            clearClosedWindowFontSizes(info.id)
1✔
442
        }
1✔
443
    }
1✔
444

445
    /// Loads a closed window's snapshot, strips every `fontSize` override, and rewrites the file only
446
    /// when something changed (so it doesn't churn untouched windows). Best-effort: a missing/corrupt
447
    /// file loads as empty (no overrides to clear) and a write failure is swallowed by the store.
448
    private func clearClosedWindowFontSizes(_ id: UUID) {
1✔
449
        let persistence = persistenceStore(for: id)
1✔
450
        var snapshot = persistence.load()
1✔
451
        var changed = false
1✔
452
        for w in snapshot.workspaces.indices {
1✔
453
            for s in snapshot.workspaces[w].sessions.indices where snapshot.workspaces[w].sessions[s].fontSize != nil {
1✔
454
                snapshot.workspaces[w].sessions[s].fontSize = nil
1✔
455
                changed = true
1✔
456
            }
1✔
457
        }
1✔
458
        guard changed else { return }
1✔
459
        try? persistence.save(snapshot)
1✔
460
    }
1✔
461

462
    // MARK: - Persistence
463

464
    /// Flushes every open window's store, so per-window cwd changes since the last structural
465
    /// mutation are persisted (the quit-time flush the app's terminate path drives).
466
    public func saveAllOpen() {
1✔
467
        for store in stores.values { store.save() }
2✔
468
    }
1✔
469

470
    /// Finalizes any grace-period session/workspace closes in open windows before a window/app teardown.
NEW
471
    public func finalizeAllPendingCloses() {
×
NEW
472
        for store in stores.values { store.finalizeAllPendingCloses() }
×
NEW
473
    }
×
474

475
    /// Writes `windows.json`: the ordered window list (with each window's open flag) and the
476
    /// frontmost id. A write failure is logged and swallowed.
477
    public func saveIndex() {
144✔
478
        let entries = windows.map { WindowEntry(id: $0.id, name: $0.name, isOpen: stores[$0.id] != nil) }
219✔
479
        let index = WindowsIndex(frontmost: frontmostWindowID, windows: entries)
144✔
480
        do {
144✔
481
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
144✔
482
            let encoder = JSONEncoder()
144✔
483
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
144✔
484
            try encoder.encode(index).write(to: indexURL, options: .atomic)
144✔
485
        } catch {
144✔
486
            log("saveIndex failed: \(error)")
×
487
        }
144✔
488
    }
144✔
489

490
    // MARK: - Bootstrap (migration + recovery)
491

492
    /// Resolves the window set on init: load `windows.json` if valid; else recover orphaned per-window
493
    /// `windows/<id>.json` files into a fresh index when any are present (so a future schema bump that
494
    /// invalidates the index doesn't lose the trees); else migrate from legacy `workspaces.json`; else
495
    /// seed one empty window. Reopens the persisted open-set (never windowless — falls back to the
496
    /// frontmost/first window).
497
    private func bootstrap() {
76✔
498
        if let index = loadIndex() {
76✔
499
            windows = index.windows.map { WindowInfo(id: $0.id, name: $0.name) }
29✔
500
            frontmostWindowID = index.frontmost
17✔
501
            reopen(index)
17✔
502
            return
17✔
503
        }
59✔
504
        // index unreadable but per-window files survive: recover them rather than discard the user's
59✔
505
        // sessions (a future schema bump that invalidates the index must not lose the trees).
59✔
506
        if recoverOrphanedWindows() { return }
59✔
507
        if migrateLegacy() { return }
57✔
508
        // no index, no orphans, and no legacy file: seed one empty default-named window ("window 1").
54✔
509
        newWindow()
54✔
510
    }
54✔
511

512
    /// Reads `windows.json`, treating a missing/corrupt/version-mismatched file as absent (nil),
513
    /// so the caller falls through to migration/seeding.
514
    private func loadIndex() -> WindowsIndex? {
76✔
515
        guard let data = try? Data(contentsOf: indexURL) else { return nil }
76✔
516
        guard let index = try? JSONDecoder().decode(WindowsIndex.self, from: data) else { return nil }
24✔
517
        guard index.version == WindowsIndex.currentVersion, !index.windows.isEmpty else { return nil }
20✔
518
        return index
17✔
519
    }
76✔
520

521
    /// Reopens the persisted open-set after loading the index: loads a store for each window
522
    /// marked open. If none were open, opens the frontmost (else the first) so the app is never
523
    /// windowless. The frontmost id is used only when it actually exists in `windows` — a stale
524
    /// frontmost (pointing at a deleted window) would otherwise no-op `loadStore` and leave the app
525
    /// windowless; in that case fall through to the first window.
526
    private func reopen(_ index: WindowsIndex) {
17✔
527
        for entry in index.windows where entry.isOpen { loadStore(for: entry.id) }
29✔
528
        guard openIDs().isEmpty else { return }
17✔
529
        let frontmostExists = index.frontmost.map { id in windows.contains { $0.id == id } } ?? false
3✔
530
        let fallback = (frontmostExists ? index.frontmost : nil) ?? windows.first?.id
3✔
531
        if let fallback { loadStore(for: fallback) }
3✔
532
    }
3✔
533

534
    /// When `windows.json` is unreadable/version-mismatched but per-window `windows/<id>.json` files
535
    /// survive, recovers them into a fresh index instead of falling through to legacy/seeding (which
536
    /// would discard the user's sessions). Each file whose name stem is a valid UUID becomes an OPEN
537
    /// window named "window N", numbered in filename order so the numbering and the frontmost pick are
538
    /// deterministic; the first recovered window becomes frontmost. Files with a non-UUID stem are
539
    /// skipped. Returns false when no recoverable per-window files exist (the caller then tries
540
    /// legacy migration, else seeds). Recovering every orphan as open means the launch reopen-all
541
    /// opens them all on screen at once — acceptable for this rare recovery path.
542
    @discardableResult
543
    private func recoverOrphanedWindows() -> Bool {
59✔
544
        let contents = (try? FileManager.default.contentsOfDirectory(at: windowsDirectory,
59✔
545
                                                                     includingPropertiesForKeys: nil)) ?? []
59✔
546
        // stable filename order so "window N" numbering and the frontmost pick are deterministic.
59✔
547
        let ids = contents
59✔
548
            .filter { $0.pathExtension == "json" }
59✔
549
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
59✔
550
            .compactMap { UUID(uuidString: $0.deletingPathExtension().lastPathComponent) }
59✔
551
        guard !ids.isEmpty else { return false }
59✔
552
        let infos = ids.enumerated().map { WindowInfo(id: $0.element, name: "window \($0.offset + 1)") }
3✔
553
        // append ALL infos FIRST — loadStore(for:) guards on `windows.contains(id)`, so loading a
2✔
554
        // store before the append would silently no-op.
2✔
555
        windows.append(contentsOf: infos)
2✔
556
        for info in infos { loadStore(for: info.id) }
3✔
557
        frontmostWindowID = infos.first?.id
2✔
558
        saveIndex()
2✔
559
        return true
2✔
560
    }
59✔
561

562
    /// If `windows.json` is absent but legacy `workspaces.json` exists, wraps it into one window
563
    /// ("window 1"): writes the loaded snapshot to `windows/<id>.json` + the index marking it
564
    /// open/frontmost, and opens it. Returns false (no migration) when no legacy file exists.
565
    @discardableResult
566
    private func migrateLegacy() -> Bool {
57✔
567
        let legacy = PersistenceStore(directory: directory, fileName: Self.legacyFileName)
57✔
568
        let snapshot = legacy.load()
57✔
569
        guard !snapshot.workspaces.isEmpty else { return false }
57✔
570
        // first window, so `defaultWindowName` yields "window 1" (windows is empty at this point).
3✔
571
        let info = WindowInfo(name: defaultWindowName)
3✔
572
        let store = makeStore(persistence: persistenceStore(for: info.id))
3✔
573
        store.restore(from: snapshot)
3✔
574
        store.save()
3✔
575
        windows = [info]
3✔
576
        stores[info.id] = store
3✔
577
        frontmostWindowID = info.id
3✔
578
        saveIndex()
3✔
579
        return true
3✔
580
    }
57✔
581

582
    // MARK: - Helpers
583

584
    /// The per-window persistence file `windows/<id>.json`.
585
    private func windowFileURL(for id: UUID) -> URL {
5✔
586
        windowsDirectory.appendingPathComponent("\(id.uuidString).json")
5✔
587
    }
5✔
588

589
    /// A `PersistenceStore` pointed at the window's `windows/<id>.json` file.
590
    private func persistenceStore(for id: UUID) -> PersistenceStore {
123✔
591
        PersistenceStore(directory: windowsDirectory, fileName: "\(id.uuidString).json")
123✔
592
    }
123✔
593

594
    private func makeStore(persistence: PersistenceStore) -> AppStore {
121✔
595
        AppStore(persistence: persistence, recentClosedStore: recentClosedStore) { [weak self] in
121✔
596
            self?.refreshRecentClosedItems()
5✔
597
        }
5✔
598
    }
121✔
599

600
    private func refreshRecentClosedItems() {
11✔
601
        recentClosedItems = recentClosedStore.load()
11✔
602
    }
11✔
603

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

© 2026 Coveralls, Inc