From 59fbd11030ccc78d14856c7da371d1de050ffcae Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 6 Oct 2026 15:16:19 +0800 Subject: [PATCH] refactor(history): remove redundant closed workspace state --- .../ConnectionHistoryMenuTests.swift | 19 +++--- .../ConnectionHistoryMenuSnapshot.swift | 7 +-- .../HistoryMenuContent.swift | 4 +- TFI/App/MobileAppModel.swift | 2 +- TFI/App/MobileSceneModel.swift | 2 +- TFI/App/MobileWorkspaceSessionModel.swift | 8 --- TFITests/MobileWorkspaceSessionTests.swift | 26 ++++++-- TFM/App/AppModel.swift | 8 --- TFM/App/RecentlyClosedWorkspaces.swift | 1 - TFM/App/TermindCommands.swift | 2 +- TFM/Features/Workspaces/WindowModel.swift | 50 ++------------- TFM/Features/Workspaces/WindowState.swift | 6 +- TFMTests/ClosedWorkspaceRestoreTests.swift | 52 +++++++++++----- TFMTests/CommandNotificationTests.swift | 2 +- TFMTests/HistoryMenuTests.swift | 2 +- TFMTests/MultiWindowTests.swift | 10 +-- TFMTests/WindowStateTests.swift | 62 +++++++++++-------- TFMTests/WorkspaceWindowMoveTests.swift | 10 +-- 18 files changed, 126 insertions(+), 147 deletions(-) diff --git a/FrameworkTests/ConnectionHistoryMenuTests.swift b/FrameworkTests/ConnectionHistoryMenuTests.swift index 1ece420a..8f010a9f 100644 --- a/FrameworkTests/ConnectionHistoryMenuTests.swift +++ b/FrameworkTests/ConnectionHistoryMenuTests.swift @@ -19,7 +19,6 @@ struct ConnectionHistoryMenuTests { let local = UUID() let repository = InMemoryConnectionHistoryRepository() let history = ConnectionHistoryModel(repository: repository, sourceDeviceID: local) - let devices = DeviceDirectoryModel(currentDeviceID: local) let library = HostLibraryModel(hosts: [host]) let first = try #require(history.record(kind: .ssh, for: host.id, at: now)) let second = try #require(history.record(kind: .sftp, for: host.id, at: now.addingTimeInterval(1))) @@ -29,13 +28,13 @@ struct ConnectionHistoryMenuTests { history.reload() let menu = ConnectionHistoryMenuSnapshot( - history: history, library: library, devices: devices, now: now, calendar: calendar) + history: history, library: library, now: now, calendar: calendar) #expect(menu.recentToday.map(\.id) == [synced.id, second.id, first.id]) #expect(menu.days.isEmpty) let restarted = ConnectionHistoryModel(repository: repository, sourceDeviceID: local) let afterRestart = ConnectionHistoryMenuSnapshot( - history: restarted, library: library, devices: devices, now: now, calendar: calendar) + history: restarted, library: library, now: now, calendar: calendar) #expect(afterRestart.recentToday.map(\.id) == [synced.id, second.id, first.id]) #expect(afterRestart.days.isEmpty) } @@ -49,7 +48,7 @@ struct ConnectionHistoryMenuTests { try #require(history.record(kind: .ssh, for: host.id, at: today.addingTimeInterval(Double(index + 1)))) } let menu = ConnectionHistoryMenuSnapshot( - history: history, library: HostLibraryModel(hosts: [host]), devices: DeviceDirectoryModel(), + history: history, library: HostLibraryModel(hosts: [host]), now: today.addingTimeInterval(3600), calendar: calendar) #expect(menu.recentToday.map(\.id) == entries.suffix(15).reversed().map(\.id)) #expect(menu.days.map(\.isToday) == [true, false]) @@ -68,10 +67,9 @@ struct ConnectionHistoryMenuTests { let missing = ConnectionHistoryEntry( hostID: UUID(), kind: .ssh, connectedAt: today.addingTimeInterval(-200_000)) let history = ConnectionHistoryModel(entries: [earlier, missing, recent]) - let devices = DeviceDirectoryModel() let library = HostLibraryModel(hosts: [host]) let menu = ConnectionHistoryMenuSnapshot( - history: history, library: library, devices: devices, now: now, calendar: calendar) + history: history, library: library, now: now, calendar: calendar) #expect(menu.recentToday.map(\.id) == [recent.id]) #expect(menu.days.map(\.isToday) == [false]) #expect(menu.days.flatMap(\.rows).map(\.id) == [earlier.id]) @@ -79,11 +77,11 @@ struct ConnectionHistoryMenuTests { var shifted = calendar shifted.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! let localMenu = ConnectionHistoryMenuSnapshot( - history: history, library: library, devices: devices, now: today.addingTimeInterval(120), calendar: shifted) + history: history, library: library, now: today.addingTimeInterval(120), calendar: shifted) #expect(localMenu.days.isEmpty) #expect(localMenu.recentToday.map(\.id) == [recent.id, earlier.id]) let tomorrow = ConnectionHistoryMenuSnapshot( - history: history, library: library, devices: devices, + history: history, library: library, now: today.addingTimeInterval(86_400), calendar: calendar) #expect(tomorrow.recentToday.isEmpty) #expect(tomorrow.days.allSatisfy { !$0.isToday }) @@ -96,15 +94,14 @@ struct ConnectionHistoryMenuTests { hostID: host.id, kind: .sftp, connectedAt: calendar.startOfDay(for: now).addingTimeInterval(-60)) let history = ConnectionHistoryModel(entries: [old]) let library = HostLibraryModel(hosts: [host]) - let devices = DeviceDirectoryModel() _ = try #require(history.record(kind: .ssh, for: host.id, at: now)) let before = ConnectionHistoryMenuSnapshot( - history: history, library: library, devices: devices, now: now, calendar: calendar) + history: history, library: library, now: now, calendar: calendar) #expect(before.recentToday.first?.canOpen == false) #expect(before.days.first?.rows.first?.canOpen == false) history.clearAll() let after = ConnectionHistoryMenuSnapshot( - history: history, library: library, devices: devices, now: now, calendar: calendar) + history: history, library: library, now: now, calendar: calendar) #expect(after.recentToday.isEmpty && after.days.isEmpty) #expect(library.host(host.id) != nil) } diff --git a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift index adb59873..46636f13 100644 --- a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift +++ b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift @@ -6,7 +6,6 @@ import struct TermindDomain.Host public struct ConnectionHistoryMenuRow: Identifiable { public let entry: ConnectionHistoryEntry public let host: Host - public let deviceName: String? public var id: UUID { entry.id } public var canOpen: Bool { switch entry.kind { @@ -30,7 +29,7 @@ public struct ConnectionHistoryMenuSnapshot { @MainActor public init( - history: ConnectionHistoryModel, library: HostLibraryModel, devices: DeviceDirectoryModel, + history: ConnectionHistoryModel, library: HostLibraryModel, now: Date? = nil, calendar: Calendar = .current ) { let rows = history.entries().sorted { @@ -38,9 +37,7 @@ public struct ConnectionHistoryMenuSnapshot { return $0.id.uuidString < $1.id.uuidString }.compactMap { entry -> ConnectionHistoryMenuRow? in guard let host = library.host(entry.hostID) else { return nil } - return ConnectionHistoryMenuRow( - entry: entry, host: host, - deviceName: history.isLocal(entry) ? nil : devices.name(for: entry.sourceDeviceID)) + return ConnectionHistoryMenuRow(entry: entry, host: host) } let referenceDate = now ?? history.menuReferenceDate recentToday = Array( diff --git a/Frameworks/TermindPresentation/HistoryMenuContent.swift b/Frameworks/TermindPresentation/HistoryMenuContent.swift index a6692ea4..d118be09 100644 --- a/Frameworks/TermindPresentation/HistoryMenuContent.swift +++ b/Frameworks/TermindPresentation/HistoryMenuContent.swift @@ -83,7 +83,7 @@ public struct HistoryMenuRecords: View { open(row) } label: { Label { - Text(verbatim: title(row)) + Text(verbatim: row.host.name) } icon: { image(row.entry.kind.symbol) } @@ -91,6 +91,4 @@ public struct HistoryMenuRecords: View { .disabled(!row.canOpen) } } - - private func title(_ row: ConnectionHistoryMenuRow) -> String { row.host.name } } diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 0d23b12d..05ce42dd 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -468,8 +468,8 @@ final class MobileAppModel { } private func closeWorkspaces(for hostID: UUID) { + forgetClosedWorkspaces(for: hostID) for session in workspaceSessions { - session.forgetClosedWorkspaces(for: hostID) for item in session.orderedWorkspaceItems where item.descriptor.hostID == hostID { session.close(item.id, rememberForReopen: false) } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 98b49962..fc3514b1 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -69,7 +69,7 @@ final class MobileSceneModel { var confirmsClearConnectionHistory = false var historyMenuSnapshot: ConnectionHistoryMenuSnapshot { - ConnectionHistoryMenuSnapshot(history: app.connectionHistory, library: app.library, devices: app.devices) + ConnectionHistoryMenuSnapshot(history: app.connectionHistory, library: app.library) } var hasConnectionHistory: Bool { !app.connectionHistory.entries().isEmpty } diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index 9dc1afa5..bb100e7a 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -55,10 +55,6 @@ final class MobileWorkspaceSessionModel { var canReopen: Bool { !isDiscarded && !app.closedWorkspaceRecords.isEmpty } var reopensWindow: Bool { app.closedWorkspaceRecords.first?.isWindow == true } var recentlyClosedRecords: [RecentlyClosedRecord] { app.closedWorkspaceRecords } - var recentlyClosedWorkspaces: [MobileWorkspaceDescriptor] { - guard !isDiscarded else { return [] } - return app.closedWorkspaceRecords.filter { !$0.isWindow }.flatMap(\.items) - } private(set) var isDiscarded = false @ObservationIgnored private unowned let app: MobileAppModel @@ -301,10 +297,6 @@ final class MobileWorkspaceSessionModel { } } - func forgetClosedWorkspaces(for hostID: Host.ID) { - app.forgetClosedWorkspaces(for: hostID) - } - func reopenLastClosed() -> MobileWorkspace.ID? { guard !isDiscarded, let record = app.closedWorkspaceRecords.first else { return nil } if record.isWindow { diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 6ba83f7f..7ede86e6 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -26,14 +26,14 @@ struct MobileWorkspaceSessionTests { let nextApp = try fixture.makeApp() let window = MobileSceneModel(app: nextApp, sessionID: "second") let other = MobileSceneModel(app: nextApp, sessionID: "other") - #expect(window.workspaceSession.recentlyClosedWorkspaces.map(\.id) == [closed.id]) + #expect(window.workspaceSession.recentlyClosedRecords.flatMap(\.items).map(\.id) == [closed.id]) window.open(hostID: fixture.host.id, kind: .ssh) window.activatePendingWorkspace() let current = try #require(window.activeWorkspace) window.closeActiveWorkspace() - #expect(window.workspaceSession.recentlyClosedWorkspaces.map(\.id) == [current.id, closed.id]) + #expect(window.workspaceSession.recentlyClosedRecords.flatMap(\.items).map(\.id) == [current.id, closed.id]) window.reopenLastClosedWorkspace() - #expect(window.workspaceSession.recentlyClosedWorkspaces.map(\.id) == [closed.id]) + #expect(window.workspaceSession.recentlyClosedRecords.flatMap(\.items).map(\.id) == [closed.id]) window.reopenLastClosedWorkspace() window.activatePendingWorkspace() #expect(window.activeWorkspace?.descriptor.kind == .sftp) @@ -102,9 +102,9 @@ struct MobileWorkspaceSessionTests { let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") session.closeAll() - #expect(session.recentlyClosedWorkspaces.map(\.id) == records.reversed().map(\.id)) + #expect(session.recentlyClosedRecords.flatMap(\.items).map(\.id) == records.reversed().map(\.id)) #expect(session.reopenClosedWorkspace(records[0].id) == records[0].id) - #expect(session.recentlyClosedWorkspaces.map(\.id) == [records[2].id, records[1].id]) + #expect(session.recentlyClosedRecords.flatMap(\.items).map(\.id) == [records[2].id, records[1].id]) #expect(session.reopenClosedWorkspace(records[0].id) == nil) #expect(session.reopenLastClosed() == records[2].id) } @@ -231,6 +231,22 @@ struct MobileWorkspaceSessionTests { #expect(session.workspaces.isEmpty) } + @Test func deletingAHostBeforeBindingAWindowRemovesPersistedClosedWorkspaces() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let firstApp = try fixture.makeApp() + let session = firstApp.workspaceSession(for: "owner") + let id = try #require(session.open(hostID: fixture.host.id, kind: .ssh)) + session.close(id) + + let nextApp = try fixture.makeApp() + #expect(nextApp.recentlyClosedStore.entries.flatMap(\.items).map(\.id) == [id]) + #expect(nextApp.deleteHost(fixture.host.id)) + #expect(nextApp.recentlyClosedStore.entries.isEmpty) + let thirdApp = try fixture.makeApp() + #expect(thirdApp.recentlyClosedStore.entries.isEmpty) + } + @Test func agentForwardingStaysWithTheConversationScene() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 5bed854e..2998019e 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -709,12 +709,6 @@ final class AppModel { AssistantExecutionController.titleContext(for: conversation) } - func forgetClosedWorkspaces(_ ids: Set) { - for window in windows.values.compactMap(\.value) { - for id in ids { window.forgetClosedWorkspace(id) } - } - } - func unregisterWindow(_ window: WindowModel) { windows[ObjectIdentifier(window)] = nil onboarding.windowClosed(window) @@ -725,12 +719,10 @@ final class AppModel { configureConnectionHistory(in: window) window.connectionCoordinator = connections window.hostStore = hosts - window.closedHistory = { [weak self] in self?.closedWorkspaceRecords ?? [] } window.rememberClosedWorkspace = { [weak self] in self?.rememberClosedWorkspaces([$0]) } window.rememberClosedWindow = { [weak self] workspaces, selected in self?.rememberClosedWorkspaces(workspaces, isWindow: true, selectedID: selected) } - window.restoreClosedRecord = { [weak self, weak window] id in self?.reopenClosedRecord(id, in: window) } window.metricsModel = serverMetrics windows = windows.filter { $0.value.value != nil } windows[ObjectIdentifier(window)] = WeakWindow(window) diff --git a/TFM/App/RecentlyClosedWorkspaces.swift b/TFM/App/RecentlyClosedWorkspaces.swift index c157fd37..13f9cf7d 100644 --- a/TFM/App/RecentlyClosedWorkspaces.swift +++ b/TFM/App/RecentlyClosedWorkspaces.swift @@ -49,7 +49,6 @@ extension AppModel { window.select(restored[selected].id) } let removed = Set(items.map(\.id)) - forgetClosedWorkspaces(removed) record.items.removeAll { removed.contains($0.id) } closedWorkspaceInstances[id]?.removeAll { removed.contains($0.id) } if record.items.isEmpty { diff --git a/TFM/App/TermindCommands.swift b/TFM/App/TermindCommands.swift index 5102e302..701760e6 100644 --- a/TFM/App/TermindCommands.swift +++ b/TFM/App/TermindCommands.swift @@ -297,7 +297,7 @@ struct TermindCommands: Commands { HistoryMenuRecords( snapshot: ConnectionHistoryMenuSnapshot( - history: app.connectionHistory, library: app.hostLibrary, devices: app.devices), + history: app.connectionHistory, library: app.hostLibrary), image: { Image(nsImage: MenuImageVisibilityController.markedImage(systemName: $0)) } ) { row in guard let window, let host = app.hostLibrary.host(row.host.id) else { return } diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 81ab532e..5ac9d414 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -277,15 +277,6 @@ final class WindowModel { } } - /// Closed workspaces and individually closed panes, most recent last. A closed - /// pane is kept as a single-pane Workspace. ⇧⌘T pops from here. The Workspace retains - /// its layout, names, and conversation, while `prepareForReopen` replaces - /// terminal models because a dead SSH session cannot be revived. - private var reopenStack: [Workspace] { - get { workspaceDeck.reopenStack } - set { workspaceDeck.reopenStack = newValue } - } - var commandFocusChanged: (() -> Void)? var configureCommandNotifications: ((Pane) -> Void)? @@ -545,17 +536,8 @@ final class WindowModel { } } - var closedHistory: () -> [RecentlyClosedRecord] = { [] } var rememberClosedWorkspace: ((Workspace) -> Void)? var rememberClosedWindow: (([Workspace], UUID?) -> Void)? - var restoreClosedRecord: ((UUID) -> Void)? - var canReopen: Bool { - !isClosed && (restoreClosedRecord == nil ? !reopenStack.isEmpty : !closedHistory().isEmpty) - } - var reopensWindow: Bool { closedHistory().first?.isWindow == true } - var recentlyClosedWorkspaces: [Workspace] { Array(reopenStack.reversed()) } - - func forgetClosedWorkspace(_ id: UUID) { reopenStack.removeAll { $0.id == id } } var confirmsClearConnectionHistory = false var historyOperationError: String? @@ -1397,7 +1379,6 @@ final class WindowModel { workspace.lifecycleRouter = nil } workspaces.removeAll() - reopenStack.removeAll() dragging = nil presentations.route = nil transcriptCache.removeAll() @@ -1412,7 +1393,7 @@ final class WindowModel { guard let index = workspaces.firstIndex(where: { $0.id == id }) else { return } let removed = workspaces.remove(at: index) removed.disconnectConnections() - rememberForReopen(removed) + rememberClosedWorkspace?(removed) if selectedWorkspaceID == id { // Select the neighbour that visually takes its place. @@ -1436,7 +1417,7 @@ final class WindowModel { func closeOthers(than id: Workspace.ID) { for workspace in workspaces where workspace.id != id { workspace.disconnectConnections() - rememberForReopen(workspace) + rememberClosedWorkspace?(workspace) } workspaces.removeAll { $0.id != id } selectedWorkspaceID = id @@ -1445,7 +1426,7 @@ final class WindowModel { func closeAll() { for workspace in workspaces { workspace.disconnectConnections() - rememberForReopen(workspace) + rememberClosedWorkspace?(workspace) } workspaces.removeAll() promoteVisibleSidebarConversationOrSelectVault() @@ -1487,13 +1468,7 @@ final class WindowModel { // session. prepareForReopen restores routing when it becomes live again. pane.sshSession?.inputRouter = nil pane.sftpChannel?.navigationRouter = nil - rememberForReopen(closedWorkspace) - } - - private func rememberForReopen(_ workspace: Workspace) { - reopenStack.append(workspace) - rememberClosedWorkspace?(workspace) - if reopenStack.count > 15 { reopenStack.removeFirst(reopenStack.count - 15) } + rememberClosedWorkspace?(closedWorkspace) } func closeWorkspaces(for hostID: Host.ID) { @@ -1509,23 +1484,6 @@ final class WindowModel { } } - func reopenLastClosed() { - let candidate = restoreClosedRecord == nil ? reopenStack.last?.id : closedHistory().first?.id - guard let id = candidate else { return } - reopenClosedWorkspace(id) - } - - func reopenClosedWorkspace(_ id: Workspace.ID) { - if let restoreClosedRecord { - restoreClosedRecord(id) - return - } - guard let index = reopenStack.firstIndex(where: { $0.id == id }) else { return } - let workspace = reopenStack.remove(at: index) - workspace.prepareForReopen() - open(workspace) - } - // MARK: - Reordering /// Drag-reorder in the Navigator: puts `id` at `index` in the Workspace diff --git a/TFM/Features/Workspaces/WindowState.swift b/TFM/Features/Workspaces/WindowState.swift index 7293d97a..a044a3e1 100644 --- a/TFM/Features/Workspaces/WindowState.swift +++ b/TFM/Features/Workspaces/WindowState.swift @@ -3,13 +3,11 @@ import TermindApplication import TermindDomain import TermindPresentation -/// Window-scoped ownership for the dynamic workspace collection and its -/// recoverable close history. Navigation remains in `WindowModel`; workspace -/// lifetime no longer shares storage with Assistant and Vault state. +/// Window-scoped ownership for the dynamic workspace collection. +/// Navigation remains in `WindowModel`; recoverable close history belongs to the app. @Observable final class WorkspaceDeckModel { var workspaces: [Workspace] - var reopenStack: [Workspace] = [] init(workspaces: [Workspace] = []) { self.workspaces = workspaces diff --git a/TFMTests/ClosedWorkspaceRestoreTests.swift b/TFMTests/ClosedWorkspaceRestoreTests.swift index b6b2fe01..8c39dc04 100644 --- a/TFMTests/ClosedWorkspaceRestoreTests.swift +++ b/TFMTests/ClosedWorkspaceRestoreTests.swift @@ -11,6 +11,29 @@ import struct TermindDomain.Host @Suite @MainActor struct ClosedWorkspaceRestoreTests { + @Test func evictedHistoryReleasesWorkspacesAcrossWindows() { + let app = AppModel(hosts: HostLibraryModel()) + let firstWindow = WindowModel() + let secondWindow = WindowModel() + app.registerWindow(firstWindow) + app.registerWindow(secondWindow) + weak var evicted: Workspace? + do { + let workspace = Workspace(pane: Pane(.empty)) + evicted = workspace + firstWindow.open(workspace) + firstWindow.close(workspace.id) + } + #expect(evicted != nil) + for _ in 0..<15 { + let workspace = Workspace(pane: Pane(.empty)) + secondWindow.open(workspace) + secondWindow.close(workspace.id) + } + #expect(app.closedWorkspaceRecords.count == 15) + #expect(evicted == nil) + } + @Test func restartKeepsClosedSplitsInOneRecentList() throws { let name = "ClosedWorkspace.\(UUID())" let defaults = try #require(UserDefaults(suiteName: name)) @@ -34,27 +57,24 @@ struct ClosedWorkspaceRestoreTests { let nextApp = AppModel(hosts: hosts, dependencies: dependencies) let window = WindowModel() nextApp.registerWindow(window) - #expect(window.recentlyClosedWorkspaces.isEmpty) - #expect(window.closedHistory().flatMap(\.items).map(\.title) == ["Operations"]) - #expect(window.canReopen) + #expect(nextApp.closedWorkspaceRecords.flatMap(\.items).map(\.title) == ["Operations"]) + #expect(!nextApp.closedWorkspaceRecords.isEmpty) let current = Workspace.sshSession(host: host) window.open(current) window.close(current.id) - #expect(window.closedHistory().count == 2) - window.reopenLastClosed() + #expect(nextApp.closedWorkspaceRecords.count == 2) + nextApp.reopenClosedRecord(try #require(nextApp.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === current) - window.reopenLastClosed() + nextApp.reopenClosedRecord(try #require(nextApp.closedWorkspaceRecords.first?.id), in: window) let restored = try #require(window.selectedWorkspace) #expect(restored !== workspace) #expect(restored.panes.map(\.kind) == [.sshSession, .sftpChannel]) #expect(restored.panes.map(\.customTitle) == ["Shell", "Files"]) #expect(restored.customTitle == "Operations") #expect(restored.focusedPane.kind == .sftpChannel) - #expect(!window.canReopen) + #expect(nextApp.closedWorkspaceRecords.isEmpty) let thirdApp = AppModel(hosts: hosts, dependencies: dependencies) - let thirdWindow = WindowModel() - thirdApp.registerWindow(thirdWindow) - #expect(!thirdWindow.canReopen) + #expect(thirdApp.closedWorkspaceRecords.isEmpty) } @Test func closingAWindowCreatesOneGroupAndReopenWaitsForANewWindow() throws { @@ -73,11 +93,11 @@ struct ClosedWorkspaceRestoreTests { #expect(app.closedWorkspaceRecords.count == 1) let current = WindowModel() app.registerWindow(current) - #expect(current.reopensWindow) + #expect(app.closedWorkspaceRecords.first?.isWindow == true) var requests: [UUID] = [] app.mainWindows.createScene = { requests.append($0) } - current.reopenLastClosed() - current.reopenLastClosed() + app.reopenClosedRecord(record.id, in: current) + app.reopenClosedRecord(record.id, in: current) #expect(requests.count == 1) #expect(current.workspaces.isEmpty) #expect(app.closedWorkspaceRecords.count == 1) @@ -107,7 +127,7 @@ struct ClosedWorkspaceRestoreTests { app.restoreClosedRecord(id, into: destination, itemID: first.id) #expect(destination.selectedWorkspace === first) #expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [second.id]) - #expect(destination.reopensWindow) + #expect(app.closedWorkspaceRecords.first?.isWindow == true) } @Test func missingHostsAreNotOfferedAfterRestart() throws { @@ -117,8 +137,8 @@ struct ClosedWorkspaceRestoreTests { let window = WindowModel() app.registerWindow(window) app.recentlyClosedStore.record(RecentlyClosedRecord(items: [record])) - #expect(!window.canReopen) - window.reopenClosedWorkspace(record.id) + #expect(app.closedWorkspaceRecords.isEmpty) + app.reopenClosedRecord(record.id, in: window) #expect(window.workspaces.isEmpty) } } diff --git a/TFMTests/CommandNotificationTests.swift b/TFMTests/CommandNotificationTests.swift index be10c993..d2146b07 100644 --- a/TFMTests/CommandNotificationTests.swift +++ b/TFMTests/CommandNotificationTests.swift @@ -206,7 +206,7 @@ struct CommandNotificationTests { other.close(moved.id) #expect(app.commandNotificationTarget(firstID) == nil) #expect(app.selectCommandNotificationTarget(firstID) == nil) - other.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: other) #expect(app.commandNotificationTarget(firstID) == nil) #expect(first.commandSessionID != firstID) } diff --git a/TFMTests/HistoryMenuTests.swift b/TFMTests/HistoryMenuTests.swift index 18ba98d6..cabf1145 100644 --- a/TFMTests/HistoryMenuTests.swift +++ b/TFMTests/HistoryMenuTests.swift @@ -80,7 +80,7 @@ struct HistoryMenuRecordTests { let history = ConnectionHistoryModel(entries: [earlier, yesterday] + recent) let current = try #require(history.record(kind: .ssh, for: host.id, at: now)) let snapshot = ConnectionHistoryMenuSnapshot( - history: history, library: HostLibraryModel(hosts: [host]), devices: DeviceDirectoryModel(), now: now) + history: history, library: HostLibraryModel(hosts: [host]), now: now) var opened: [UUID] = [] let menu = NSHostingMenu( rootView: HistoryMenuRecords( diff --git a/TFMTests/MultiWindowTests.swift b/TFMTests/MultiWindowTests.swift index 4e4a8ea4..06c7c69a 100644 --- a/TFMTests/MultiWindowTests.swift +++ b/TFMTests/MultiWindowTests.swift @@ -42,7 +42,7 @@ struct MultiWindowTests { app.mainWindows.register(targetNative, model: target, requestID: request) #expect(source.workspaces.isEmpty) - #expect(!source.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) #expect(!source.isClosed) #expect(source.vaultDestination == .welcome) #expect(target.selectedWorkspace === workspace) @@ -122,7 +122,7 @@ struct MultiWindowTests { #expect(session.status == .disconnected) app.mainWindows.register(second, model: target, requestID: request) #expect(target.workspaces.isEmpty) - #expect(!source.canReopen) + #expect(app.closedWorkspaceRecords.first?.isWindow == true) source.closeForWindow() session.status = .connecting let phase = session.connectionJourney.phase @@ -276,14 +276,14 @@ struct MultiWindowTests { app.mainWindows.closeSelectionOrWindow(in: model) #expect(model.workspaces.isEmpty) - #expect(model.canReopen) + #expect(app.closedWorkspaceRecords.count == 2) #expect(firstSession.status == .disconnected) #expect(!model.isClosed) app.mainWindows.closeSelectionOrWindow(in: model) #expect(model.isClosed) - #expect(!model.canReopen) + #expect(app.closedWorkspaceRecords.count == 2) } @Test(arguments: [WindowDestination.vault, .settings, .assistant, .metrics]) @@ -310,7 +310,7 @@ struct MultiWindowTests { #expect(model.selectedDestination == destination) #expect(model.workspaces.first === workspace) #expect(session.status == .connected) - #expect(!model.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [false, true]) diff --git a/TFMTests/WindowStateTests.swift b/TFMTests/WindowStateTests.swift index 2250a1d0..72f69430 100644 --- a/TFMTests/WindowStateTests.swift +++ b/TFMTests/WindowStateTests.swift @@ -33,17 +33,19 @@ struct WindowStateTests { let first = Workspace(pane: Pane(.empty)) let second = Workspace(pane: Pane(.empty)) let window = WindowModel(workspaces: [first, second]) + let app = AppModel(hosts: HostLibraryModel()) + app.registerWindow(window) window.close(first.id) window.close(second.id) - #expect(window.recentlyClosedWorkspaces.map(\.id) == [second.id, first.id]) - window.reopenClosedWorkspace(first.id) + #expect(app.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [second.id, first.id]) + app.reopenClosedRecord(first.id, in: window) #expect(window.selectedWorkspace === first) - #expect(window.recentlyClosedWorkspaces.map(\.id) == [second.id]) - window.reopenClosedWorkspace(first.id) + #expect(app.closedWorkspaceRecords.flatMap(\.items).map(\.id) == [second.id]) + app.reopenClosedRecord(first.id, in: window) #expect(window.workspaces.count == 1) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === second) - #expect(!window.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [WindowDestination.vault, .metrics, .settings, .assistant]) @@ -1277,12 +1279,14 @@ struct WindowStateTests { pane.customTitle = "Logs" let closedSession = try #require(pane.sshSession) let window = WindowModel(workspaces: [workspace]) + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + app.registerWindow(window) window.close(workspace.id) #expect(closedSession.status == .disconnected) - #expect(window.canReopen) + #expect(!app.closedWorkspaceRecords.isEmpty) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) let reopenedPane = try #require(window.selectedWorkspace?.focusedPane) let reopenedSession = try #require(reopenedPane.sshSession) @@ -1291,7 +1295,7 @@ struct WindowStateTests { #expect(reopenedSession.status == .connecting) #expect(reopenedPane.customTitle == "Logs") #expect(window.selectedWorkspace === workspace) - #expect(!window.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [false, true]) @@ -1308,6 +1312,8 @@ struct WindowStateTests { workspace.splitFocusedPane(axis: .vertical, with: closedPane) workspace.broadcastsInput = true let window = WindowModel(workspaces: [workspace]) + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + app.registerWindow(window) if closeSelected { window.closeSelected() @@ -1315,11 +1321,11 @@ struct WindowStateTests { window.closePane(closedPane.id) } - #expect(window.canReopen) + #expect(!app.closedWorkspaceRecords.isEmpty) #expect(closedSession.status == .disconnected) #expect(workspace.panes.map(\.id) == [survivor.id]) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) let reopened = try #require(window.selectedWorkspace) let reopenedSession = try #require(reopened.focusedPane.sshSession) @@ -1335,7 +1341,7 @@ struct WindowStateTests { #expect(survivorSession.status == .connected) #expect(survivorSession.inputRouter === workspace) #expect(!workspace.broadcastsInput && !reopened.broadcastsInput) - #expect(!window.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test @@ -1347,30 +1353,34 @@ struct WindowStateTests { workspace.splitFocusedPane(axis: .horizontal, with: second) let other = Workspace.sshSession(host: host) let window = WindowModel(workspaces: [workspace, other]) + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + app.registerWindow(window) window.closePane(second.id) window.close(other.id) window.closePane(first.id) #expect(window.workspaces.isEmpty) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === workspace) #expect(workspace.panes.map(\.id) == [first.id]) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace === other) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) let reopened = try #require(window.selectedWorkspace) #expect(reopened.panes.map(\.id) == [second.id]) #expect(reopened.focusedPane.sftpChannel?.path == "/var/log") #expect(window.workspaces.count == 3) - #expect(!window.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test - func individuallyClosedPanesShareTheFifteenItemReopenLimit() { + func individuallyClosedPanesShareTheFifteenItemReopenLimit() throws { let survivor = Pane(.empty) let workspace = Workspace(pane: survivor) let window = WindowModel(workspaces: [workspace]) + let app = AppModel(hosts: HostLibraryModel()) + app.registerWindow(window) let closedPanes = (0..<16).map { _ in Pane(.empty) } for pane in closedPanes { workspace.splitFocusedPane(axis: .horizontal, with: pane) @@ -1378,17 +1388,17 @@ struct WindowStateTests { } var reopenedIDs: [Pane.ID] = [] - while window.canReopen { - window.reopenLastClosed() + while !app.closedWorkspaceRecords.isEmpty { + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) if let pane = window.selectedWorkspace?.focusedPane { reopenedIDs.append(pane.id) } } #expect(reopenedIDs == closedPanes.suffix(15).reversed().map(\.id)) #expect(workspace.panes.map(\.id) == [survivor.id]) window.closePane(reopenedIDs[0]) - #expect(window.canReopen) + #expect(!app.closedWorkspaceRecords.isEmpty) window.closeForWindow() - #expect(!window.canReopen) + #expect(app.closedWorkspaceRecords.first?.isWindow == true) } @Test @@ -1480,7 +1490,7 @@ struct WindowStateTests { } @Test - func normalRemoteExitClosesOnlyItsSplitPane() { + func normalRemoteExitClosesOnlyItsSplitPane() throws { let firstHost = Host(name: "First", host: "first.example") let secondHost = Host(name: "Second", host: "second.example") let workspace = Workspace.sshSession(host: firstHost) @@ -1491,6 +1501,8 @@ struct WindowStateTests { workspace.focus(firstPane.id) workspace.broadcastsInput = true let window = WindowModel(workspaces: [workspace]) + let app = AppModel(hosts: HostLibraryModel(hosts: [firstHost, secondHost])) + app.registerWindow(window) firstSSHSession.handleRemoteExit(.success(0)) @@ -1500,13 +1512,13 @@ struct WindowStateTests { #expect(workspace.focusedPaneID == secondPane.id) #expect(!workspace.broadcastsInput) #expect(firstSSHSession.inputRouter == nil) - #expect(window.canReopen) + #expect(!app.closedWorkspaceRecords.isEmpty) - window.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: window) #expect(window.selectedWorkspace?.focusedPane.id == firstPane.id) #expect(window.selectedWorkspace?.focusedPane.sshSession !== firstSSHSession) - #expect(!window.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test diff --git a/TFMTests/WorkspaceWindowMoveTests.swift b/TFMTests/WorkspaceWindowMoveTests.swift index 436117e3..57551ae6 100644 --- a/TFMTests/WorkspaceWindowMoveTests.swift +++ b/TFMTests/WorkspaceWindowMoveTests.swift @@ -55,7 +55,7 @@ struct WorkspaceWindowMoveTests { #expect(workspace.lifecycleRouter === source) #expect(source.sidebar.sessionContext.workingDirectory == "/srv") #expect(target.sidebar.sessionContext.workingDirectory == nil) - #expect(!source.canReopen && !target.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) #expect(ssh.status == .connected && sftp.status == .connected) #expect(sftp.path == "/srv") #expect(Set(history.entries().map(\.id)) == historyIDs) @@ -100,7 +100,7 @@ struct WorkspaceWindowMoveTests { #expect(source.workspaces.isEmpty) #expect(source.selectedDestination == .vault) #expect(source.vaultDestination == .welcome) - #expect(!source.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [false, true]) @@ -134,10 +134,10 @@ struct WorkspaceWindowMoveTests { #expect(session.status == .disconnected) #expect(target.workspaces.isEmpty) #expect(source.pane(moved.id) == nil) - #expect(source.canReopen == !closesWindow) + #expect(app.closedWorkspaceRecords.first?.isWindow == closesWindow) #expect(app.mainWindows.movePane(moved.id, from: source) == nil) if !closesWindow { - source.reopenLastClosed() + app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: source) #expect(source.selectedWorkspace?.focusedPane.id == moved.id) #expect(source.selectedWorkspace?.focusedPane.sshSession !== session) #expect(target.workspaces.isEmpty) @@ -197,7 +197,7 @@ struct WorkspaceWindowMoveTests { #expect(first.selectedWorkspace?.focusedPane === firstPane) #expect(second.selectedWorkspace?.focusedPane === secondPane) #expect(source.workspaces.isEmpty) - #expect(!source.canReopen) + #expect(app.closedWorkspaceRecords.isEmpty) } @Test(arguments: [false, true])