From 231be54e08cf87ed5f8443103c697d2a087948d0 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 6 Oct 2026 14:42:51 +0800 Subject: [PATCH] feat(history): refine recent history and restore closed windows --- .../ConnectionHistoryMenuTests.swift | 58 +++++--- .../HistoryRenderingSnapshotTests.swift | 5 +- FrameworkTests/RecentlyClosedStoreTests.swift | 62 +++++++++ Frameworks/Resources/Localizable.xcstrings | 102 +++++++++++--- .../ConnectionHistoryMenuSnapshot.swift | 13 +- .../ConnectionHistoryModel.swift | 4 - .../Workspaces/RecentlyClosedRecord.swift | 18 +++ .../RecentlyClosedStore.swift | 60 +++++++++ .../HistoryMenuContent.swift | 13 +- .../TermindPresentation/Strings/History.swift | 8 +- .../Strings/Workspace.swift | 3 + TFI/App/MobileAppDependencies.swift | 2 + TFI/App/MobileAppModel.swift | 25 ++++ TFI/App/MobileCommands.swift | 40 +++++- TFI/App/MobileRecentlyClosedWorkspaces.swift | 86 ++++++++++++ TFI/App/MobileStartupView.swift | 6 + TFI/App/MobileWorkspaceSessionModel.swift | 71 +++++----- TFI/Info.plist | 1 + TFI/Infrastructure/MobileWorkspaceStore.swift | 4 + .../MobileHomeScreenQuickActions.swift | 10 ++ .../MobileWorkspaceSceneRouting.swift | 16 +++ TFITests/MobileWorkspaceSessionTests.swift | 87 +++++++++++- TFM/App/AppModel.swift | 16 +++ TFM/App/RecentlyClosedWorkspaces.swift | 62 +++++++++ TFM/App/TermindCommands.swift | 62 +++++++-- .../Workspaces/ClosedWorkspaceRecord.swift | 53 ++++++++ TFM/Features/Workspaces/WindowModel.swift | 22 +++- TFM/Platform/MainWindowCoordinator.swift | 14 +- TFMTests/ClosedWorkspaceRestoreTests.swift | 124 ++++++++++++++++++ TFMTests/HistoryMenuTests.swift | 28 +++- TFMTests/WindowStateTests.swift | 6 +- 31 files changed, 955 insertions(+), 126 deletions(-) create mode 100644 FrameworkTests/RecentlyClosedStoreTests.swift create mode 100644 Frameworks/TermindDomain/Workspaces/RecentlyClosedRecord.swift create mode 100644 Frameworks/TermindInfrastructure/RecentlyClosedStore.swift create mode 100644 TFI/App/MobileRecentlyClosedWorkspaces.swift create mode 100644 TFM/App/RecentlyClosedWorkspaces.swift create mode 100644 TFM/Features/Workspaces/ClosedWorkspaceRecord.swift create mode 100644 TFMTests/ClosedWorkspaceRestoreTests.swift diff --git a/FrameworkTests/ConnectionHistoryMenuTests.swift b/FrameworkTests/ConnectionHistoryMenuTests.swift index a8da3dd7..1ece420a 100644 --- a/FrameworkTests/ConnectionHistoryMenuTests.swift +++ b/FrameworkTests/ConnectionHistoryMenuTests.swift @@ -14,7 +14,7 @@ struct ConnectionHistoryMenuTests { return value } - @Test func launchRecordsExcludeSyncedEventsAndResetOnRestart() throws { + @Test func todayIncludesSyncedEventsAndDoesNotChangeOnRestart() throws { let host = Host(name: "Server", host: "server.example") let local = UUID() let repository = InMemoryConnectionHistoryRepository() @@ -30,16 +30,34 @@ struct ConnectionHistoryMenuTests { let menu = ConnectionHistoryMenuSnapshot( history: history, library: library, devices: devices, now: now, calendar: calendar) - #expect(menu.currentLaunch.map(\.id) == [second.id, first.id]) - #expect(menu.days.count == 1) - #expect(menu.days.first?.isToday == true) - #expect(menu.days.first?.rows.map(\.id) == [synced.id]) + #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) - #expect(afterRestart.currentLaunch.isEmpty) - #expect(afterRestart.days.first?.rows.map(\.id) == [synced.id, second.id, first.id]) + #expect(afterRestart.recentToday.map(\.id) == [synced.id, second.id, first.id]) + #expect(afterRestart.days.isEmpty) + } + + @Test func todayOverflowMovesIntoDateGroupsWithoutLosingOrDuplicatingRecords() throws { + let host = Host(name: "Server", host: "server.example") + let history = ConnectionHistoryModel() + let today = calendar.startOfDay(for: now) + let yesterday = try #require(history.record(kind: .ssh, for: host.id, at: today.addingTimeInterval(-60))) + let entries = try (0..<18).map { index in + 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(), + now: today.addingTimeInterval(3600), calendar: calendar) + #expect(menu.recentToday.map(\.id) == entries.suffix(15).reversed().map(\.id)) + #expect(menu.days.map(\.isToday) == [true, false]) + #expect(menu.days.first?.rows.map(\.id) == entries.prefix(3).reversed().map(\.id)) + #expect(menu.days.last?.rows.map(\.id) == [yesterday.id]) + let displayed = menu.recentToday + menu.days.flatMap(\.rows) + #expect(displayed.count == history.entries().count) + #expect(Set(displayed.map(\.id)).count == displayed.count) } @Test func dateGroupsFollowLocalDaysAndOmitMissingHosts() { @@ -54,36 +72,40 @@ struct ConnectionHistoryMenuTests { let library = HostLibraryModel(hosts: [host]) let menu = ConnectionHistoryMenuSnapshot( history: history, library: library, devices: devices, now: now, calendar: calendar) - #expect(menu.days.map(\.isToday) == [true, false]) - #expect(menu.days.flatMap(\.rows).map(\.id) == [recent.id, earlier.id]) + #expect(menu.recentToday.map(\.id) == [recent.id]) + #expect(menu.days.map(\.isToday) == [false]) + #expect(menu.days.flatMap(\.rows).map(\.id) == [earlier.id]) var shifted = calendar shifted.timeZone = TimeZone(secondsFromGMT: 8 * 3600)! let localMenu = ConnectionHistoryMenuSnapshot( history: history, library: library, devices: devices, now: today.addingTimeInterval(120), calendar: shifted) - #expect(localMenu.days.count == 1) - #expect(localMenu.days.first?.rows.map(\.id) == [recent.id, earlier.id]) + #expect(localMenu.days.isEmpty) + #expect(localMenu.recentToday.map(\.id) == [recent.id, earlier.id]) let tomorrow = ConnectionHistoryMenuSnapshot( history: history, library: library, devices: devices, now: today.addingTimeInterval(86_400), calendar: calendar) + #expect(tomorrow.recentToday.isEmpty) #expect(tomorrow.days.allSatisfy { !$0.isToday }) } @Test func unavailableProtocolsStayVisibleAndClearRemovesBothSections() throws { var host = Host(name: "Server", host: "server.example") host.sshEnabled = false - let old = ConnectionHistoryEntry(hostID: host.id, kind: .sftp, connectedAt: now) + let old = ConnectionHistoryEntry( + 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)) - let before = ConnectionHistoryMenuSnapshot(history: history, library: library, devices: devices) - #expect(before.currentLaunch.first?.canOpen == false) + _ = try #require(history.record(kind: .ssh, for: host.id, at: now)) + let before = ConnectionHistoryMenuSnapshot( + history: history, library: library, devices: devices, 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) - #expect(after.currentLaunch.isEmpty && after.days.isEmpty) - #expect(history.currentLaunchEntryIDs.isEmpty) + let after = ConnectionHistoryMenuSnapshot( + history: history, library: library, devices: devices, now: now, calendar: calendar) + #expect(after.recentToday.isEmpty && after.days.isEmpty) #expect(library.host(host.id) != nil) } } diff --git a/FrameworkTests/HistoryRenderingSnapshotTests.swift b/FrameworkTests/HistoryRenderingSnapshotTests.swift index c9eb7589..3ddbd44f 100644 --- a/FrameworkTests/HistoryRenderingSnapshotTests.swift +++ b/FrameworkTests/HistoryRenderingSnapshotTests.swift @@ -6,18 +6,17 @@ import Testing @Suite @MainActor struct HistoryRenderingSnapshotTests { - @Test func failedClearKeepsLaunchRecordsReadable() throws { + @Test func failedClearKeepsHistoryReadable() throws { let repository = HistoryRepository() let model = ConnectionHistoryModel(repository: repository) let entry = try #require(model.record(kind: .ssh, for: UUID())) repository.failsClear = true model.clearAll() #expect(model.entries().map(\.id) == [entry.id]) - #expect(model.currentLaunchEntryIDs.contains(entry.id)) #expect(model.storageError != nil) repository.failsClear = false model.clearAll() - #expect(model.entries().isEmpty && model.currentLaunchEntryIDs.isEmpty) + #expect(model.entries().isEmpty) #expect(model.storageError == nil) } diff --git a/FrameworkTests/RecentlyClosedStoreTests.swift b/FrameworkTests/RecentlyClosedStoreTests.swift new file mode 100644 index 00000000..a5d626a5 --- /dev/null +++ b/FrameworkTests/RecentlyClosedStoreTests.swift @@ -0,0 +1,62 @@ +import Foundation +import TermindInfrastructure +import Testing + +@Suite @MainActor +struct RecentlyClosedStoreTests { + private struct Entry: Codable, Identifiable { + let id: UUID + let title: String + } + + @Test func recordsAcrossLaunchesShareOneBoundedListAndConsumedRecordsDoNotReturn() throws { + let name = "RecentlyClosed.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let first = RecentlyClosedStore(defaults: defaults, key: "records") + let entries = (0..<17).map { Entry(id: UUID(), title: "\($0)") } + for entry in entries { first.record(entry) } + #expect(first.entries.count == 15) + let restarted = RecentlyClosedStore(defaults: defaults, key: "records") + #expect(restarted.entries.map(\.id) == entries.suffix(15).reversed().map(\.id)) + restarted.remove(entries[16].id) + let current = Entry(id: UUID(), title: "Current") + restarted.record(current) + #expect(restarted.entries.first?.id == current.id) + let nextLaunch = RecentlyClosedStore(defaults: defaults, key: "records") + #expect(nextLaunch.entries.first?.id == current.id) + #expect(!nextLaunch.entries.contains { $0.id == entries[16].id }) + } + + @Test func unreadableStoreIsNotOverwritten() throws { + let name = "RecentlyClosed.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let invalid = Data("invalid".utf8) + defaults.set(invalid, forKey: "records") + let store = RecentlyClosedStore(defaults: defaults, key: "records") + store.record(Entry(id: UUID(), title: "New")) + #expect(store.storageError != nil) + #expect(defaults.data(forKey: "records") == invalid) + } + + @Test func launchesWithoutClosedRecordsPreserveTheLastSavedRecords() throws { + let name = "RecentlyClosed.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let entry = Entry(id: UUID(), title: "Server") + RecentlyClosedStore(defaults: defaults, key: "records").record(entry) + let saved = try #require(defaults.data(forKey: "records")) + + for _ in 0..<3 { + let launch = RecentlyClosedStore(defaults: defaults, key: "records") + #expect(launch.entries.map(\.id) == [entry.id]) + } + #expect(defaults.data(forKey: "records") == saved) + + let next = RecentlyClosedStore(defaults: defaults, key: "records") + next.remove(entry.id) + let afterReopen = RecentlyClosedStore(defaults: defaults, key: "records") + #expect(afterReopen.entries.isEmpty) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index a1d93a4f..a9632a79 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -135,6 +135,40 @@ } } }, + "%@ (and %lld more workspaces)" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(以及另外%2$lld个工作区)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(以及另外%2$lld個工作區)" + } + } + } + }, + "%@ (and 1 more workspace)" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@(以及另外1个工作区)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@(以及另外1個工作區)" + } + } + } + }, "%@ (default)" : { "extractionState" : "manual", "localizations" : { @@ -21680,23 +21714,6 @@ } } }, - "Recent Closed" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "最近关闭" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "最近關閉" - } - } - } - }, "Recent Workspaces" : { "extractionState" : "manual", "localizations" : { @@ -21714,6 +21731,23 @@ } } }, + "Recently Closed" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近关闭" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近關閉" + } + } + } + }, "Recents" : { "extractionState" : "manual", "localizations" : { @@ -22431,6 +22465,23 @@ } } }, + "Reopen Last Closed Window" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新打开上次关闭的窗口" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新開啟上次關閉的視窗" + } + } + } + }, "Reopen Last Closed Workspace" : { "extractionState" : "manual", "localizations" : { @@ -22448,6 +22499,23 @@ } } }, + "Reopen Window" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新打开窗口" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新開啟視窗" + } + } + } + }, "Repeated tool action failed: %lld %@" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift index 4266f515..adb59873 100644 --- a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift +++ b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryMenuSnapshot.swift @@ -23,9 +23,9 @@ public struct ConnectionHistoryMenuDay: Identifiable { public var id: Date { date } } -/// A read-only projection. Synced events never become events from this launch. +/// The latest records from today, with overflow and earlier records grouped by local calendar day. public struct ConnectionHistoryMenuSnapshot { - public let currentLaunch: [ConnectionHistoryMenuRow] + public let recentToday: [ConnectionHistoryMenuRow] public let days: [ConnectionHistoryMenuDay] @MainActor @@ -42,12 +42,15 @@ public struct ConnectionHistoryMenuSnapshot { entry: entry, host: host, deviceName: history.isLocal(entry) ? nil : devices.name(for: entry.sourceDeviceID)) } - currentLaunch = rows.filter { history.currentLaunchEntryIDs.contains($0.id) } - let older = rows.filter { !history.currentLaunchEntryIDs.contains($0.id) } + let referenceDate = now ?? history.menuReferenceDate + recentToday = Array( + rows.filter { calendar.isDate($0.entry.connectedAt, inSameDayAs: referenceDate) }.prefix(15)) + let displayed = Set(recentToday.map(\.id)) + let older = rows.filter { !displayed.contains($0.id) } let grouped = Dictionary(grouping: older) { calendar.startOfDay(for: $0.entry.connectedAt) } days = grouped.keys.sorted(by: >).map { date in ConnectionHistoryMenuDay( - date: date, isToday: calendar.isDate(date, inSameDayAs: now ?? history.menuReferenceDate), + date: date, isToday: calendar.isDate(date, inSameDayAs: referenceDate), rows: grouped[date] ?? []) } } diff --git a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift index e5c406ee..c616df9b 100644 --- a/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/ConnectionHistoryModel.swift @@ -21,7 +21,6 @@ public final class ConnectionHistoryModel { public let sourceDeviceID: UUID private var cachedEntries: [ConnectionHistoryEntry] = [] public private(set) var revision: UInt64 = 0 - public private(set) var currentLaunchEntryIDs: Set = [] public private(set) var storageError: String? public init( @@ -96,7 +95,6 @@ public final class ConnectionHistoryModel { ) do { try repository.insert(entry, limit: Self.limit) - currentLaunchEntryIDs.insert(entry.id) reload() return entry } catch { @@ -117,7 +115,6 @@ public final class ConnectionHistoryModel { public func reload() { do { cachedEntries = try repository.entries() - currentLaunchEntryIDs.formIntersection(cachedEntries.map(\.id)) storageError = nil } catch { cachedEntries = [] @@ -144,7 +141,6 @@ public final class ConnectionHistoryModel { do { try repository.clearAll() cachedEntries = [] - currentLaunchEntryIDs.removeAll() storageError = nil revision &+= 1 } catch { storageError = error.localizedDescription } diff --git a/Frameworks/TermindDomain/Workspaces/RecentlyClosedRecord.swift b/Frameworks/TermindDomain/Workspaces/RecentlyClosedRecord.swift new file mode 100644 index 00000000..15ae1dfe --- /dev/null +++ b/Frameworks/TermindDomain/Workspaces/RecentlyClosedRecord.swift @@ -0,0 +1,18 @@ +import Foundation + +/// One close operation. A window and all of its workspaces occupy one history slot. +public struct RecentlyClosedRecord: Codable, Identifiable where Item.ID == UUID { + public let id: UUID + public let closedAt: Date + public let isWindow: Bool + public var items: [Item] + public let selectedItemID: UUID? + + public init(items: [Item], isWindow: Bool = false, selectedItemID: UUID? = nil, closedAt: Date = Date()) { + id = isWindow ? UUID() : items.first?.id ?? UUID() + self.closedAt = closedAt + self.isWindow = isWindow + self.items = items + self.selectedItemID = selectedItemID + } +} diff --git a/Frameworks/TermindInfrastructure/RecentlyClosedStore.swift b/Frameworks/TermindInfrastructure/RecentlyClosedStore.swift new file mode 100644 index 00000000..e2867e83 --- /dev/null +++ b/Frameworks/TermindInfrastructure/RecentlyClosedStore.swift @@ -0,0 +1,60 @@ +import Foundation +import Observation + +/// Local restoration metadata only. Each app owns one store across its windows. +@MainActor @Observable +public final class RecentlyClosedStore where Entry.ID == UUID { + public private(set) var entries: [Entry] + public private(set) var storageError: String? + private var persistentIDs: Set + private let defaults: UserDefaults? + private let key: String + private var canSave = true + + public init(defaults: UserDefaults?, key: String) { + self.defaults = defaults + self.key = key + do { + let entries = try defaults?.data(forKey: key).map { try JSONDecoder().decode([Entry].self, from: $0) } ?? [] + self.entries = Array(entries.prefix(15)) + persistentIDs = Set(entries.prefix(15).map(\.id)) + } catch { + entries = [] + persistentIDs = [] + storageError = error.localizedDescription + canSave = false + } + } + + public func record(_ entry: Entry, persist: Bool = true) { + entries.removeAll { $0.id == entry.id } + entries.insert(entry, at: 0) + entries = Array(entries.prefix(15)) + if persist { persistentIDs.insert(entry.id) } else { persistentIDs.remove(entry.id) } + persistentIDs.formIntersection(entries.map(\.id)) + save() + } + + public func update(_ entry: Entry) { + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return } + entries[index] = entry + save() + } + + public func remove(_ id: UUID) { + entries.removeAll { $0.id == id } + persistentIDs.remove(id) + save() + } + + private func save() { + guard canSave else { return } + do { + let data = try JSONEncoder().encode(entries.filter { persistentIDs.contains($0.id) }) + defaults?.set(data, forKey: key) + storageError = nil + } catch { + storageError = error.localizedDescription + } + } +} diff --git a/Frameworks/TermindPresentation/HistoryMenuContent.swift b/Frameworks/TermindPresentation/HistoryMenuContent.swift index 1f302c53..a6692ea4 100644 --- a/Frameworks/TermindPresentation/HistoryMenuContent.swift +++ b/Frameworks/TermindPresentation/HistoryMenuContent.swift @@ -55,9 +55,9 @@ public struct HistoryMenuRecords: View { } public var body: some View { - if !snapshot.currentLaunch.isEmpty { + if !snapshot.recentToday.isEmpty { Divider() - rows(snapshot.currentLaunch) + rows(snapshot.recentToday) } if !snapshot.days.isEmpty { Divider() @@ -92,12 +92,5 @@ public struct HistoryMenuRecords: View { } } - private func title(_ row: ConnectionHistoryMenuRow) -> String { - var parts = [ - row.host.name, row.entry.kind.label, - row.entry.connectedAt.formatted(.dateTime.hour().minute().locale(locale)), - ] - if let device = row.deviceName { parts.append(device) } - return parts.joined(separator: " · ") - } + private func title(_ row: ConnectionHistoryMenuRow) -> String { row.host.name } } diff --git a/Frameworks/TermindPresentation/Strings/History.swift b/Frameworks/TermindPresentation/Strings/History.swift index ac98cc7f..72febd58 100644 --- a/Frameworks/TermindPresentation/Strings/History.swift +++ b/Frameworks/TermindPresentation/Strings/History.swift @@ -3,7 +3,13 @@ import Foundation extension AppStrings { public nonisolated enum History { public static var showHistory: LocalizedStringResource { "Show History" } - public static var recentClosed: LocalizedStringResource { "Recent Closed" } + public static var recentClosed: LocalizedStringResource { "Recently Closed" } + public static var reopenWindow: LocalizedStringResource { "Reopen Window" } + public static func closedWindow(_ title: String, count: Int) -> LocalizedStringResource { + if count <= 1 { return LocalizedStringResource("\(title)") } + if count == 2 { return "\(title) (and 1 more workspace)" } + return "\(title) (and \(count - 1) more workspaces)" + } public static var earlierToday: LocalizedStringResource { "Earlier Today" } } } diff --git a/Frameworks/TermindPresentation/Strings/Workspace.swift b/Frameworks/TermindPresentation/Strings/Workspace.swift index b099cd33..f410b8b1 100644 --- a/Frameworks/TermindPresentation/Strings/Workspace.swift +++ b/Frameworks/TermindPresentation/Strings/Workspace.swift @@ -369,6 +369,9 @@ extension AppStrings { public static var newWindow: LocalizedStringResource { "New Window" } + public static var reopenClosedWindow: LocalizedStringResource { + "Reopen Last Closed Window" + } public static var reopenClosedWorkspace: LocalizedStringResource { "Reopen Last Closed Workspace" } diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index b8c690c3..2d9bbbba 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -20,6 +20,8 @@ struct MobileAppDependencies { var container: ModelContainer? = nil var workspaceStore: MobileWorkspaceStore? = nil var liveActivities: MobileLiveActivityController? = nil + var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void = + MobileWorkspaceSceneRouting.createWindow var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void = MobileWorkspaceSceneRouting.activate var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void = diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 9170b30a..0d23b12d 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -141,6 +141,9 @@ final class MobileAppModel { // precisely the workspaces recovered from that window, even if another window already received them. private var recoveredWorkspaceIDs: [String: Set] = [:] private var discardedSessionIDs: Set = [] + var closedWorkspaceStates: [UUID: [MobileWorkspace.ReopenState]] = [:] + var pendingClosedWindows: Set = [] + var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void var openWorkspaceSessionIDs: @MainActor () -> Set @@ -175,6 +178,7 @@ final class MobileAppModel { let workspaceConnectionServices = dependencies.workspaceConnections let connectionCoordinator = dependencies.connectionCoordinator keyExportServices = connectionCoordinator.keyExportServices() + createWorkspaceWindow = dependencies.createWorkspaceWindow activateWorkspaceSession = dependencies.activateWorkspaceSession closeWorkspaceSession = dependencies.closeWorkspaceSession openWorkspaceSessionIDs = dependencies.openWorkspaceSessionIDs @@ -739,6 +743,19 @@ final class MobileAppModel { discardedSessionIDs.formUnion(ids) orphanedSessionIDs.subtract(ids) for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) } + // A native discard can arrive after reconciliation moved the disappearing window's workspaces. + for id in ids { + if let recovered = recoveredWorkspaceIDs[id], !recovered.isEmpty { + let states = workspaceSessions.flatMap(\.orderedWorkspaceItems) + .filter { recovered.contains($0.id) } + .map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) } + rememberClosedWorkspaces(states, isWindow: true) + } else if hasBoundWorkspaceSession, !workspaceSessions.contains(where: { $0.id == id }), + let descriptors = workspaceSnapshot.sessions[id] + { + rememberClosedWorkspaces(descriptors.map { .init(descriptor: $0) }, isWindow: true) + } + } let recoveredIDs = ids.reduce(into: Set()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) } for session in workspaceSessions { for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false) } @@ -754,6 +771,14 @@ final class MobileAppModel { } } + func containsWorkspace(_ id: UUID) -> Bool { + workspaceSessions.contains { session in session.orderedWorkspaceItems.contains { $0.id == id } } + } + + var recentlyClosedStore: RecentlyClosedStore> { + workspaceStore.recentlyClosed + } + func saveWorkspaceDescriptors(_ descriptors: [MobileWorkspaceDescriptor], for id: String) { guard !discardedSessionIDs.contains(id) else { return } workspaceSnapshot.sessions[id] = descriptors diff --git a/TFI/App/MobileCommands.swift b/TFI/App/MobileCommands.swift index c4c01c64..a293f6be 100644 --- a/TFI/App/MobileCommands.swift +++ b/TFI/App/MobileCommands.swift @@ -137,12 +137,35 @@ struct MobileCommands: Commands { Divider() Menu { - ForEach(scene?.workspaceSession.recentlyClosedWorkspaces ?? []) { descriptor in - Button { - scene?.reopenClosedWorkspace(descriptor.id) - } label: { - HistoryMenuLabel( - verbatim: descriptor.title, symbol: descriptor.kind.symbol) + ForEach(scene?.workspaceSession.recentlyClosedRecords ?? []) { record in + if record.isWindow { + Menu { + Button(AppStrings.History.reopenWindow.swiftUIKey, systemImage: "macwindow") { + scene?.workspaceSession.requestClosedWindow(record.id) + } + Divider() + ForEach(record.items) { item in + Button { + guard let scene, + let id = scene.workspaceSession.reopenClosedItem(item.id, in: record.id) + else { return } + scene.selectWorkspace(id) + } label: { + HistoryMenuLabel(verbatim: item.title, symbol: item.kind.symbol) + } + } + } label: { + HistoryMenuLabel( + AppStrings.History.closedWindow( + record.items.first?.title ?? "", count: record.items.count), + symbol: record.items.first?.kind.symbol ?? "macwindow") + } + } else if let descriptor = record.items.first { + Button { + scene?.reopenClosedWorkspace(record.id) + } label: { + HistoryMenuLabel(verbatim: descriptor.title, symbol: descriptor.kind.symbol) + } } } } label: { @@ -153,7 +176,10 @@ struct MobileCommands: Commands { Button { scene?.reopenLastClosedWorkspace() } label: { - HistoryMenuLabel(AppStrings.Workspace.reopenClosedWorkspace, symbol: "arrow.uturn.backward") + HistoryMenuLabel( + scene?.workspaceSession.reopensWindow == true + ? AppStrings.Workspace.reopenClosedWindow : AppStrings.Workspace.reopenClosedWorkspace, + symbol: "arrow.uturn.backward") } .keyboardShortcut(MobileShortcut.reopenWorkspace) .disabled(scene?.workspaceSession.canReopen != true) diff --git a/TFI/App/MobileRecentlyClosedWorkspaces.swift b/TFI/App/MobileRecentlyClosedWorkspaces.swift new file mode 100644 index 00000000..4793acb1 --- /dev/null +++ b/TFI/App/MobileRecentlyClosedWorkspaces.swift @@ -0,0 +1,86 @@ +import Foundation +import TermindDomain +import TermindPresentation + +extension MobileAppModel { + var closedWorkspaceRecords: [RecentlyClosedRecord] { + recentlyClosedStore.entries.filter { record in + !record.items.isEmpty + && record.items.allSatisfy { item in + guard !containsWorkspace(item.id) else { return false } + if closedWorkspaceStates[record.id]?.contains(where: { + $0.descriptor.id == item.id && !$0.connectionOrigin.allowsPersistence + }) == true { + return true + } + guard let host = library.host(item.hostID) else { return false } + return MobileConnectionHistoryActionPolicy.isAvailable(item.kind, for: host) + } + } + } + + func rememberClosedWorkspaces(_ states: [MobileWorkspace.ReopenState], isWindow: Bool = false) { + guard !states.isEmpty else { return } + let record = RecentlyClosedRecord(items: states.map(\.descriptor), isWindow: isWindow) + closedWorkspaceStates[record.id] = states + recentlyClosedStore.record( + record, + persist: states.allSatisfy { + $0.connectionOrigin.allowsPersistence && library.host($0.descriptor.hostID) != nil + }) + let retained = Set(recentlyClosedStore.entries.map(\.id)) + closedWorkspaceStates = closedWorkspaceStates.filter { retained.contains($0.key) } + } + + func forgetClosedWorkspaces(for hostID: UUID) { + for var record in recentlyClosedStore.entries { + record.items.removeAll { $0.hostID == hostID } + closedWorkspaceStates[record.id]?.removeAll { $0.descriptor.hostID == hostID } + if record.items.isEmpty { + recentlyClosedStore.remove(record.id) + closedWorkspaceStates[record.id] = nil + } else { + recentlyClosedStore.update(record) + } + } + } + + func requestClosedWindow(_ id: UUID) { + guard closedWorkspaceRecords.contains(where: { $0.id == id && $0.isWindow }), + pendingClosedWindows.insert(id).inserted + else { return } + createWorkspaceWindow(id) { [weak self] in + self?.pendingClosedWindows.remove(id) + self?.error = String(localized: AppStrings.Workspace.restoreWorkspacesFailure) + } + } + + func restoreClosedWindow(_ id: UUID, into session: MobileWorkspaceSessionModel) -> UUID? { + guard pendingClosedWindows.remove(id) != nil, + closedWorkspaceRecords.contains(where: { $0.id == id && $0.isWindow }) + else { return nil } + return restoreClosedRecord(id, into: session) + } + + func restoreClosedRecord(_ id: UUID, into session: MobileWorkspaceSessionModel, itemID: UUID? = nil) -> UUID? { + guard !session.isDiscarded, var record = closedWorkspaceRecords.first(where: { $0.id == id }) else { + return nil + } + let items = record.items.filter { itemID == nil || $0.id == itemID } + guard !items.isEmpty else { return nil } + let states = items.map { item in + closedWorkspaceStates[id]?.first { $0.descriptor.id == item.id } ?? .init(descriptor: item) + } + session.restoreClosedStates(states) + let removed = Set(items.map(\.id)) + record.items.removeAll { removed.contains($0.id) } + closedWorkspaceStates[id]?.removeAll { removed.contains($0.descriptor.id) } + if record.items.isEmpty { + recentlyClosedStore.remove(id) + closedWorkspaceStates[id] = nil + } else { + recentlyClosedStore.update(record) + } + return items.first { $0.id == record.selectedItemID }?.id ?? items.first?.id + } +} diff --git a/TFI/App/MobileStartupView.swift b/TFI/App/MobileStartupView.swift index d7ac90c4..51c0e114 100644 --- a/TFI/App/MobileStartupView.swift +++ b/TFI/App/MobileStartupView.swift @@ -69,6 +69,12 @@ struct MobileBoundSceneRoot: View { consumePendingQuickAction: delegate.consumePendingQuickAction ) .onAppear { delegate.bind(to: app) } + .task(id: scenePhase == .active ? delegate.pendingClosedWindowID : nil) { + guard scenePhase == .active, let id = delegate.consumePendingClosedWindowID() else { return } + if let workspaceID = app.restoreClosedWindow(id, into: scene.workspaceSession) { + scene.selectWorkspace(workspaceID) + } + } .task(id: scenePhase == .active ? delegate.pendingWorkspaceID : nil) { guard scenePhase == .active, delegate.pendingWorkspaceID != nil else { return } await Task.yield() diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index f0ded3d3..9dc1afa5 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -52,11 +52,12 @@ final class MobileWorkspaceSessionModel { } private(set) var savedWorkspaceDescriptors: [MobileWorkspaceDescriptor] private var workspaceDisplayOrder: [MobileWorkspace.ID] - private var reopenStack: [MobileWorkspace.ReopenState] = [] - var canReopen: Bool { !recentlyClosedWorkspaces.isEmpty } + 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 reopenStack.reversed().filter(canReopen).map(\.descriptor) + return app.closedWorkspaceRecords.filter { !$0.isWindow }.flatMap(\.items) } private(set) var isDiscarded = false @ObservationIgnored private unowned let app: MobileAppModel @@ -82,7 +83,6 @@ final class MobileWorkspaceSessionModel { } func releaseForRecovery() { - reopenStack.removeAll() manuallyUnreadIDs.removeAll() workspaces.removeAll() savedWorkspaceDescriptors.removeAll() @@ -100,8 +100,10 @@ final class MobileWorkspaceSessionModel { } func discard() { - closeAll() - reopenStack.removeAll() + guard !isDiscarded else { return } + let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) } + app.rememberClosedWorkspaces(states, isWindow: true) + closeAll(rememberForReopen: false) isDiscarded = true } @@ -237,7 +239,6 @@ final class MobileWorkspaceSessionModel { } func close(_ id: UUID, rememberForReopen: Bool = true) { - if !rememberForReopen { reopenStack.removeAll { $0.descriptor.id == id } } manuallyUnreadIDs.remove(id) let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id }) guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return } @@ -277,10 +278,10 @@ final class MobileWorkspaceSessionModel { } } - func closeAll() { + func closeAll(rememberForReopen: Bool = true) { manuallyUnreadIDs.removeAll() guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return } - rememberClosedWorkspaces(Set(workspaceDisplayOrder)) + if rememberForReopen { rememberClosedWorkspaces(Set(workspaceDisplayOrder)) } let closing = workspaces workspaces.removeAll() savedWorkspaceDescriptors.removeAll() @@ -296,38 +297,46 @@ final class MobileWorkspaceSessionModel { private func rememberClosedWorkspaces(_ ids: Set) { for item in orderedWorkspaceItems where ids.contains(item.id) { - reopenStack.append(item.workspace?.reopenState ?? .init(descriptor: item.descriptor)) + app.rememberClosedWorkspaces([item.workspace?.reopenState ?? .init(descriptor: item.descriptor)]) } - if reopenStack.count > 10 { reopenStack.removeFirst(reopenStack.count - 10) } } func forgetClosedWorkspaces(for hostID: Host.ID) { - reopenStack.removeAll { $0.descriptor.hostID == hostID } - } - - private func canReopen(_ state: MobileWorkspace.ReopenState) -> Bool { - !state.connectionOrigin.allowsPersistence || app.library.host(state.descriptor.hostID) != nil + app.forgetClosedWorkspaces(for: hostID) } func reopenLastClosed() -> MobileWorkspace.ID? { - guard let id = recentlyClosedWorkspaces.first?.id else { return nil } - return reopenClosedWorkspace(id) + guard !isDiscarded, let record = app.closedWorkspaceRecords.first else { return nil } + if record.isWindow { + app.requestClosedWindow(record.id) + return nil + } + return reopenClosedWorkspace(record.id) } func reopenClosedWorkspace(_ id: MobileWorkspace.ID) -> MobileWorkspace.ID? { - guard !isDiscarded, - let index = reopenStack.firstIndex(where: { $0.descriptor.id == id && canReopen($0) }) - else { return nil } - let state = reopenStack.remove(at: index) - let workspace = MobileWorkspace( - descriptor: state.descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, - terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices, - transientHost: state.transientHost, connectionRoute: state.connectionRoute, - usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin, - externalSFTPResourcePath: state.externalSFTPResourcePath, - externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory) - addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence) - return workspace.id + guard !isDiscarded else { return nil } + return app.restoreClosedRecord(id, into: self) + } + + func reopenClosedItem(_ itemID: UUID, in recordID: UUID) -> UUID? { + guard !isDiscarded else { return nil } + return app.restoreClosedRecord(recordID, into: self, itemID: itemID) + } + + func requestClosedWindow(_ id: UUID) { app.requestClosedWindow(id) } + + func restoreClosedStates(_ states: [MobileWorkspace.ReopenState]) { + for state in states { + let workspace = MobileWorkspace( + descriptor: state.descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, + terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices, + transientHost: state.transientHost, connectionRoute: state.connectionRoute, + usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin, + externalSFTPResourcePath: state.externalSFTPResourcePath, + externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory) + addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence) + } } /// Publish before connecting so repeated selection in this window shares one initial attempt. diff --git a/TFI/Info.plist b/TFI/Info.plist index 59a0b719..cd57a7c8 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -41,6 +41,7 @@ NSUserActivityTypes com.akinokaede.termind.open-workspace + com.akinokaede.termind.reopen-window TermindCloudKitEnvironment $(ICLOUD_CONTAINER_ENVIRONMENT) diff --git a/TFI/Infrastructure/MobileWorkspaceStore.swift b/TFI/Infrastructure/MobileWorkspaceStore.swift index d92b638d..b4052dd3 100644 --- a/TFI/Infrastructure/MobileWorkspaceStore.swift +++ b/TFI/Infrastructure/MobileWorkspaceStore.swift @@ -1,4 +1,6 @@ import Foundation +import TermindDomain +import TermindInfrastructure /// Device-local connection intent only. Credentials, commands and live session state never enter this store. /// One app-owned store writes the complete snapshot, so saves from different windows cannot overwrite each other. @@ -20,11 +22,13 @@ final class MobileWorkspaceStore { } private enum StoreError: Error { case invalidSnapshot } + let recentlyClosed: RecentlyClosedStore> private let defaults: UserDefaults? private var canSave = true init(defaults: UserDefaults?) { self.defaults = defaults + recentlyClosed = RecentlyClosedStore(defaults: defaults, key: "mobile.recentlyClosed.v2") } func load() throws -> Snapshot { diff --git a/TFI/Platform/MobileHomeScreenQuickActions.swift b/TFI/Platform/MobileHomeScreenQuickActions.swift index d0862c95..59c9ecca 100644 --- a/TFI/Platform/MobileHomeScreenQuickActions.swift +++ b/TFI/Platform/MobileHomeScreenQuickActions.swift @@ -89,6 +89,7 @@ enum MobileHomeScreenQuickActions { final class MobileSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject { @Published private(set) var sessionID: String? @Published private(set) var pendingWorkspaceID: UUID? + @Published private(set) var pendingClosedWindowID: UUID? private(set) weak var app: MobileAppModel? @Published private(set) var pendingQuickAction: MobileHomeScreenQuickAction? @@ -132,10 +133,19 @@ final class MobileSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObje } func receiveWorkspaceActivity(_ activity: NSUserActivity) { + if let id = MobileWorkspaceSceneRouting.closedRecordID(from: activity) { + pendingClosedWindowID = id + return + } guard let id = MobileWorkspaceSceneRouting.workspaceID(from: activity) else { return } pendingWorkspaceID = id } + func consumePendingClosedWindowID() -> UUID? { + defer { pendingClosedWindowID = nil } + return pendingClosedWindowID + } + func consumePendingWorkspaceID() -> UUID? { defer { pendingWorkspaceID = nil } return pendingWorkspaceID diff --git a/TFI/Platform/MobileWorkspaceSceneRouting.swift b/TFI/Platform/MobileWorkspaceSceneRouting.swift index ed4884fa..1ba01c55 100644 --- a/TFI/Platform/MobileWorkspaceSceneRouting.swift +++ b/TFI/Platform/MobileWorkspaceSceneRouting.swift @@ -9,6 +9,7 @@ enum MobileWorkspaceSceneRouting { } static let activityType = "com.akinokaede.termind.open-workspace" + static let closedWindowActivityType = "com.akinokaede.termind.reopen-window" private static let workspaceIDKey = "workspaceID" static func openSessionIDs() -> Set { @@ -31,6 +32,21 @@ enum MobileWorkspaceSceneRouting { return UUID(uuidString: value) } + static func createWindow(_ recordID: UUID, failed: @escaping @MainActor () -> Void) { + let activity = NSUserActivity(activityType: closedWindowActivityType) + activity.userInfo = ["closedRecordID": recordID.uuidString] + UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil) { _ in + MainActor.assumeIsolated { failed() } + } + } + + static func closedRecordID(from activity: NSUserActivity) -> UUID? { + guard activity.activityType == closedWindowActivityType, + let value = activity.userInfo?["closedRecordID"] as? String + else { return nil } + return UUID(uuidString: value) + } + static func reconcile(app: MobileAppModel, activeSessionID: String?) { app.reconcileWorkspaceSessions( openSessionIDs: Set(UIApplication.shared.openSessions.map(\.persistentIdentifier)), diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 0cea306c..6ba83f7f 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -13,6 +13,85 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileWorkspaceSessionTests { + @Test func closedRecordsAcrossLaunchesShareOneListAndAreConsumedAcrossWindows() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let firstApp = try fixture.makeApp() + let owner = MobileSceneModel(app: firstApp, sessionID: "first") + owner.open(hostID: fixture.host.id, kind: .sftp) + owner.activatePendingWorkspace() + let closed = try #require(owner.activeWorkspace) + owner.closeActiveWorkspace() + + 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]) + 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]) + window.reopenLastClosedWorkspace() + #expect(window.workspaceSession.recentlyClosedWorkspaces.map(\.id) == [closed.id]) + window.reopenLastClosedWorkspace() + window.activatePendingWorkspace() + #expect(window.activeWorkspace?.descriptor.kind == .sftp) + #expect(window.activeWorkspace !== closed) + #expect(!other.workspaceSession.canReopen) + #expect(!window.workspaceSession.canReopen) + let thirdApp = try fixture.makeApp() + #expect(thirdApp.closedWorkspaceRecords.isEmpty) + } + + @Test func discardedWindowsAreGroupedAndReopenedInANewSession() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let source = MobileSceneModel(app: app, sessionID: "source") + source.open(hostID: fixture.host.id, kind: .ssh) + source.open(hostID: fixture.host.id, kind: .sftp) + let ids = source.workspaceSession.orderedWorkspaceItems.map(\.id) + app.discardWorkspaceSessions(["source"]) + let record = try #require(app.closedWorkspaceRecords.first) + #expect(record.isWindow) + #expect(record.items.map(\.id) == ids) + #expect(app.closedWorkspaceRecords.count == 1) + let current = MobileSceneModel(app: app, sessionID: "current") + var requests: [UUID] = [] + app.createWorkspaceWindow = { id, _ in requests.append(id) } + current.reopenLastClosedWorkspace() + current.reopenLastClosedWorkspace() + #expect(requests == [record.id]) + #expect(current.workspaceSession.orderedWorkspaceItems.isEmpty) + #expect(app.closedWorkspaceRecords.count == 1) + let destination = MobileSceneModel(app: app, sessionID: "destination") + let selected = app.restoreClosedWindow(record.id, into: destination.workspaceSession) + #expect(selected == ids.first) + #expect(destination.workspaceSession.orderedWorkspaceItems.map(\.id) == ids) + #expect(app.closedWorkspaceRecords.isEmpty) + #expect(app.restoreClosedWindow(record.id, into: destination.workspaceSession) == nil) + } + + @Test func windowChildRestoreAndFailedNewWindowRequestsPreserveRemainingRecords() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let source = MobileSceneModel(app: app, sessionID: "source") + source.open(hostID: fixture.host.id, kind: .ssh) + source.open(hostID: fixture.host.id, kind: .sftp) + app.discardWorkspaceSessions(["source"]) + let record = try #require(app.closedWorkspaceRecords.first) + let current = MobileSceneModel(app: app, sessionID: "current") + let first = try #require(record.items.first) + #expect(current.workspaceSession.reopenClosedItem(first.id, in: record.id) == first.id) + #expect(app.closedWorkspaceRecords.first?.items.count == 1) + app.createWorkspaceWindow = { _, failed in failed() } + current.reopenLastClosedWorkspace() + #expect(app.closedWorkspaceRecords.first?.items.count == 1) + #expect(app.pendingClosedWindows.isEmpty) + } + @Test func recentClosedCanRestoreAnOlderItemWithoutConsumingTheNewest() throws { let fixture = try Fixture() defer { fixture.cleanUp() } @@ -46,7 +125,7 @@ struct MobileWorkspaceSessionTests { owner.closeActiveWorkspace() #expect(owner.workspaceSession.canReopen) - #expect(!other.workspaceSession.canReopen) + #expect(other.workspaceSession.canReopen) #expect(try fixture.store.load().sessions["owner", default: []].isEmpty) owner.openSettings() owner.reopenLastClosedWorkspace() @@ -120,14 +199,14 @@ struct MobileWorkspaceSessionTests { let app = try fixture.makeApp() let session = app.workspaceSession(for: "owner") var closed: [UUID] = [] - for _ in 0..<12 { + for _ in 0..<17 { let id = try #require(session.open(hostID: fixture.host.id, kind: .ssh)) session.close(id) closed.append(id) } var reopened: [UUID] = [] while let id = session.reopenLastClosed() { reopened.append(id) } - #expect(reopened == Array(closed.suffix(10).reversed())) + #expect(reopened == Array(closed.suffix(15).reversed())) session.closeAll() #expect(session.canReopen) app.discardWorkspaceSessions(["owner"]) @@ -713,6 +792,8 @@ struct MobileWorkspaceSessionTests { #expect(first.workspaceSession.workspaces.count == 1) #expect(first.workspaceSession.workspaces.first?.id != original.id) #expect(app.workspaceOwner(original.id) == nil) + #expect(app.closedWorkspaceRecords.first?.isWindow == true) + #expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [original.id]) #expect(second.workspaceSession.open(hostID: fixture.host.id, kind: .ssh) == nil) } diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 5e384ed8..5bed854e 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -147,6 +147,8 @@ final class AppModel { private let agentBackgroundCommands: AgentBackgroundCommands private let outboxRunner: OutboxRunner? private let snippetTimestampFallback: Date + var closedWorkspaceInstances: [UUID: [Workspace]] = [:] + let recentlyClosedStore: RecentlyClosedStore> private let settingsDefaults: UserDefaults private let shutdownDependencies: AppShutdownDependencies? private var windows: [ObjectIdentifier: WeakWindow] = [:] @@ -206,6 +208,8 @@ final class AppModel { self.windowSizeStore = windowSizeStore let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups) settingsDefaults = dependencies.settingsDefaults + recentlyClosedStore = RecentlyClosedStore( + defaults: dependencies.settingsDefaults, key: "desktop.recentlyClosed.v2") snippetTimestampFallback = SnippetTimestamp.fallback(in: dependencies.settingsDefaults) assistantConversations = dependencies.assistantConversations ?? AssistantConversationStore() shutdownDependencies = dependencies.shutdown @@ -705,6 +709,12 @@ 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) @@ -715,6 +725,12 @@ 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 new file mode 100644 index 00000000..c157fd37 --- /dev/null +++ b/TFM/App/RecentlyClosedWorkspaces.swift @@ -0,0 +1,62 @@ +import Foundation +import TermindDomain + +extension AppModel { + var closedWorkspaceRecords: [RecentlyClosedRecord] { + recentlyClosedStore.entries.filter { record in + !record.items.isEmpty + && record.items.allSatisfy { item in + closedWorkspaceInstances[record.id]?.contains { $0.id == item.id } == true + || item.canRestore(in: hostLibrary) + } + } + } + + func rememberClosedWorkspaces(_ workspaces: [Workspace], isWindow: Bool = false, selectedID: UUID? = nil) { + guard !workspaces.isEmpty else { return } + let record = RecentlyClosedRecord( + items: workspaces.map { ClosedWorkspaceRecord($0, hosts: hostLibrary) }, + isWindow: isWindow, selectedItemID: selectedID) + closedWorkspaceInstances[record.id] = workspaces + recentlyClosedStore.record(record, persist: record.items.allSatisfy { $0.layout != nil }) + let retained = Set(recentlyClosedStore.entries.map(\.id)) + closedWorkspaceInstances = closedWorkspaceInstances.filter { retained.contains($0.key) } + } + + func reopenClosedRecord(_ id: UUID, in window: WindowModel?) { + guard let record = closedWorkspaceRecords.first(where: { $0.id == id }) else { return } + if record.isWindow || window == nil { + mainWindows.reopenClosedWindow(id, from: window) + } else if let window { + restoreClosedRecord(id, into: window) + } + } + + /// Consume only after a destination exists; failed scene creation leaves history intact. + func restoreClosedRecord(_ id: UUID, into window: WindowModel, itemID: UUID? = nil) { + guard !window.isClosed, var record = closedWorkspaceRecords.first(where: { $0.id == id }) else { return } + let items = record.items.filter { itemID == nil || $0.id == itemID } + guard !items.isEmpty else { return } + let restored = items.compactMap { item in + closedWorkspaceInstances[id]?.first { $0.id == item.id } ?? item.restore(in: hostLibrary) + } + guard restored.count == items.count else { return } + for workspace in restored { + workspace.prepareForReopen() + window.open(workspace) + } + if itemID == nil, let selected = items.firstIndex(where: { $0.id == record.selectedItemID }) { + 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 { + recentlyClosedStore.remove(id) + closedWorkspaceInstances[id] = nil + } else { + recentlyClosedStore.update(record) + } + } +} diff --git a/TFM/App/TermindCommands.swift b/TFM/App/TermindCommands.swift index 79c19bea..5102e302 100644 --- a/TFM/App/TermindCommands.swift +++ b/TFM/App/TermindCommands.swift @@ -233,29 +233,67 @@ struct TermindCommands: Commands { Divider() Menu { - ForEach(window?.recentlyClosedWorkspaces ?? []) { workspace in - Button { - window?.reopenClosedWorkspace(workspace.id) - } label: { - Label { - Text(verbatim: workspace.title(in: app.hostLibrary)) - } icon: { - Image(nsImage: MenuImageVisibilityController.markedImage(systemName: workspace.symbol)) + ForEach(app.closedWorkspaceRecords) { record in + if record.isWindow { + Menu { + Button { + app.reopenClosedRecord(record.id, in: window) + } label: { + HistoryMenuLabel(AppStrings.History.reopenWindow, symbol: "macwindow") + } + Divider() + ForEach(record.items) { item in + Button { + guard let window else { return } + app.restoreClosedRecord(record.id, into: window, itemID: item.id) + } label: { + Label { + Text(verbatim: item.title) + } icon: { + Image( + nsImage: MenuImageVisibilityController.markedImage(systemName: item.symbol)) + } + } + .disabled(window == nil) + } + } label: { + Label { + Text( + AppStrings.History.closedWindow( + record.items.first?.title ?? "", count: record.items.count)) + } icon: { + Image( + nsImage: MenuImageVisibilityController.markedImage( + systemName: record.items.first?.symbol ?? "macwindow")) + } + } + } else if let item = record.items.first { + Button { + app.reopenClosedRecord(record.id, in: window) + } label: { + Label { + Text(verbatim: item.title) + } icon: { + Image(nsImage: MenuImageVisibilityController.markedImage(systemName: item.symbol)) + } } } } } label: { HistoryMenuLabel(AppStrings.History.recentClosed, symbol: "clock.arrow.circlepath") } - .disabled(window?.canReopen != true) + .disabled(app.closedWorkspaceRecords.isEmpty) Button { - window?.reopenLastClosed() + if let record = app.closedWorkspaceRecords.first { app.reopenClosedRecord(record.id, in: window) } } label: { - HistoryMenuLabel(AppStrings.Workspace.reopenClosedWorkspace, symbol: MenuSymbol.reopenWorkspace) + HistoryMenuLabel( + app.closedWorkspaceRecords.first?.isWindow == true + ? AppStrings.Workspace.reopenClosedWindow : AppStrings.Workspace.reopenClosedWorkspace, + symbol: MenuSymbol.reopenWorkspace) } .termindShortcut("t", modifiers: [.command, .shift]) - .disabled(window?.canReopen != true) + .disabled(app.closedWorkspaceRecords.isEmpty) HistoryMenuRecords( snapshot: ConnectionHistoryMenuSnapshot( diff --git a/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift b/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift new file mode 100644 index 00000000..f4b44351 --- /dev/null +++ b/TFM/Features/Workspaces/ClosedWorkspaceRecord.swift @@ -0,0 +1,53 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// Only saved-host references and presentation state survive an app restart. +struct ClosedWorkspaceRecord: Codable, Identifiable { + let id: UUID + let title: String + let symbol: String + let layout: WorkspaceTemplateLayout? + let paneNames: [String?] + let focusedPaneIndex: Int + + @MainActor + init(_ workspace: Workspace, hosts: HostLibraryModel) { + let layout = + workspace.panes.contains { $0.sshSession?.isExternalTemporaryConnection == true } + ? nil : workspace.workspaceTemplateLayout(in: hosts) + self.id = workspace.id + self.title = workspace.title(in: hosts) + self.symbol = workspace.symbol + self.layout = layout + self.paneNames = workspace.panes.map(\.customTitle) + self.focusedPaneIndex = workspace.panes.firstIndex { $0.id == workspace.focusedPaneID } ?? 0 + } + + @MainActor + func canRestore(in hosts: HostLibraryModel) -> Bool { + guard let layout else { return false } + return layout.isValid + && layout.panes.allSatisfy { pane in + switch pane { + case .empty: true + case .ssh(let id), .sftp(let id): hosts.host(id)?.sshEnabled == true + case .telnet(let id): hosts.host(id)?.telnetEnabled == true + } + } + } + + @MainActor + func restore(in hosts: HostLibraryModel) -> Workspace? { + guard canRestore(in: hosts), let layout else { return nil } + let node = layout.makeSplitNode(using: hosts).node + let workspace = Workspace(pane: node.firstPane) + workspace.layout = node + for (pane, name) in zip(workspace.panes, paneNames) { pane.customTitle = name } + if workspace.isSplit { workspace.customTitle = title } else { workspace.automaticTitle = title } + if workspace.panes.indices.contains(focusedPaneIndex) { + workspace.focusedPaneID = workspace.panes[focusedPaneIndex].id + } + return workspace + } +} diff --git a/TFM/Features/Workspaces/WindowModel.swift b/TFM/Features/Workspaces/WindowModel.swift index 12d198fd..81ab532e 100644 --- a/TFM/Features/Workspaces/WindowModel.swift +++ b/TFM/Features/Workspaces/WindowModel.swift @@ -545,8 +545,17 @@ final class WindowModel { } } - var canReopen: Bool { !reopenStack.isEmpty } + 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? @@ -1381,6 +1390,7 @@ final class WindowModel { func closeForWindow() { guard !isClosed else { return } + rememberClosedWindow?(workspaces, selectedWorkspaceID) isClosed = true for workspace in workspaces { workspace.disconnectConnections() @@ -1482,7 +1492,8 @@ final class WindowModel { private func rememberForReopen(_ workspace: Workspace) { reopenStack.append(workspace) - if reopenStack.count > 10 { reopenStack.removeFirst(reopenStack.count - 10) } + rememberClosedWorkspace?(workspace) + if reopenStack.count > 15 { reopenStack.removeFirst(reopenStack.count - 15) } } func closeWorkspaces(for hostID: Host.ID) { @@ -1499,11 +1510,16 @@ final class WindowModel { } func reopenLastClosed() { - guard let id = reopenStack.last?.id else { return } + 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() diff --git a/TFM/Platform/MainWindowCoordinator.swift b/TFM/Platform/MainWindowCoordinator.swift index 9843926e..2010af56 100644 --- a/TFM/Platform/MainWindowCoordinator.swift +++ b/TFM/Platform/MainWindowCoordinator.swift @@ -39,17 +39,19 @@ final class MainWindowCoordinator { private final class Request { weak var source: WindowModel? let move: Move? + let closedRecordID: UUID? let showsMetrics: Bool let metricsHostID: UUID? let layout: Layout let screenPoint: CGPoint? init( - source: WindowModel? = nil, move: Move? = nil, + source: WindowModel? = nil, move: Move? = nil, closedRecordID: UUID? = nil, showsMetrics: Bool = false, metricsHostID: UUID? = nil, layout: Layout, screenPoint: CGPoint? = nil ) { self.source = source self.move = move + self.closedRecordID = closedRecordID self.showsMetrics = showsMetrics self.metricsHostID = metricsHostID self.layout = layout @@ -216,6 +218,12 @@ final class MainWindowCoordinator { request(Request(showsMetrics: showsMetrics, layout: sourceLayout(from: source))) } + @discardableResult + func reopenClosedWindow(_ id: UUID, from source: WindowModel?) -> UUID? { + if let pending = requests.first(where: { $0.value.closedRecordID == id }) { return pending.key } + return request(Request(closedRecordID: id, layout: sourceLayout(from: source))) + } + @discardableResult func moveWorkspace(_ id: UUID, from source: WindowModel, at screenPoint: CGPoint? = nil) -> UUID? { guard !source.isClosed, source.workspaces.contains(where: { $0.id == id }) else { return nil } @@ -310,7 +318,9 @@ final class MainWindowCoordinator { // A tear-off should appear at the release point immediately, without // another document-opening animation after the drag completes. if request.screenPoint != nil { native.animationBehavior = .none } - if let workspace = request.takeWorkspace() { + if let closedRecordID = request.closedRecordID { + app?.restoreClosedRecord(closedRecordID, into: model) + } else if let workspace = request.takeWorkspace() { model.open(workspace) model.refreshSessionContext() model.reconcileSidebarMode() diff --git a/TFMTests/ClosedWorkspaceRestoreTests.swift b/TFMTests/ClosedWorkspaceRestoreTests.swift new file mode 100644 index 00000000..b6b2fe01 --- /dev/null +++ b/TFMTests/ClosedWorkspaceRestoreTests.swift @@ -0,0 +1,124 @@ +import AppKit +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import Testing + +import struct TermindDomain.Host + +@testable import Termind + +@Suite @MainActor +struct ClosedWorkspaceRestoreTests { + @Test func restartKeepsClosedSplitsInOneRecentList() throws { + let name = "ClosedWorkspace.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let host = Host(name: "Server", host: "server.example") + let hosts = HostLibraryModel(hosts: [host]) + let dependencies = AppDependencies( + hostRepository: AppDependencies.inMemory(hosts: [host], groups: []).hostRepository, outboxRunner: nil, + settingsDefaults: defaults) + let firstApp = AppModel(hosts: hosts, dependencies: dependencies) + let workspace = Workspace.sshSession(host: host) + workspace.focusedPane.customTitle = "Shell" + workspace.splitFocusedPane(axis: .vertical, with: .sftpChannel(host: host)) + workspace.focusedPane.customTitle = "Files" + workspace.customTitle = "Operations" + let original = WindowModel(workspaces: [workspace]) + firstApp.registerWindow(original) + original.close(workspace.id) + original.closeForWindow() + + 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) + let current = Workspace.sshSession(host: host) + window.open(current) + window.close(current.id) + #expect(window.closedHistory().count == 2) + window.reopenLastClosed() + #expect(window.selectedWorkspace === current) + window.reopenLastClosed() + 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) + let thirdApp = AppModel(hosts: hosts, dependencies: dependencies) + let thirdWindow = WindowModel() + thirdApp.registerWindow(thirdWindow) + #expect(!thirdWindow.canReopen) + } + + @Test func closingAWindowCreatesOneGroupAndReopenWaitsForANewWindow() throws { + let host = Host(name: "Server", host: "server.example") + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + let first = Workspace.sshSession(host: host) + first.splitFocusedPane(axis: .vertical, with: .sftpChannel(host: host)) + let second = Workspace(pane: .sftpChannel(host: host)) + let source = WindowModel(workspaces: [first, second]) + source.select(first.id) + app.registerWindow(source) + source.closeForWindow() + let record = try #require(app.closedWorkspaceRecords.first) + #expect(record.isWindow) + #expect(record.items.map(\.id) == [first.id, second.id]) + #expect(app.closedWorkspaceRecords.count == 1) + let current = WindowModel() + app.registerWindow(current) + #expect(current.reopensWindow) + var requests: [UUID] = [] + app.mainWindows.createScene = { requests.append($0) } + current.reopenLastClosed() + current.reopenLastClosed() + #expect(requests.count == 1) + #expect(current.workspaces.isEmpty) + #expect(app.closedWorkspaceRecords.count == 1) + let destination = WindowModel() + let native = NSWindow(contentRect: .zero, styleMask: [.titled], backing: .buffered, defer: true) + native.isReleasedWhenClosed = false + defer { native.close() } + let requestID = try #require(requests.first) + app.mainWindows.register(native, model: destination, requestID: requestID) + #expect(destination.workspaces.map(\.id) == [first.id, second.id]) + #expect(destination.selectedWorkspace === first) + #expect(first.panes.count == 2) + #expect(app.closedWorkspaceRecords.isEmpty) + } + + @Test func selectingAWindowChildRestoresOnlyThatWorkspaceAndKeepsTheRest() throws { + let host = Host(name: "Server", host: "server.example") + let app = AppModel(hosts: HostLibraryModel(hosts: [host])) + let first = Workspace.sshSession(host: host) + let second = Workspace(pane: .sftpChannel(host: host)) + let source = WindowModel(workspaces: [first, second]) + app.registerWindow(source) + source.closeForWindow() + let id = try #require(app.closedWorkspaceRecords.first?.id) + let destination = WindowModel() + app.registerWindow(destination) + app.restoreClosedRecord(id, into: destination, itemID: first.id) + #expect(destination.selectedWorkspace === first) + #expect(app.closedWorkspaceRecords.first?.items.map(\.id) == [second.id]) + #expect(destination.reopensWindow) + } + + @Test func missingHostsAreNotOfferedAfterRestart() throws { + let host = Host(name: "Server", host: "server.example") + let record = ClosedWorkspaceRecord(.sshSession(host: host), hosts: HostLibraryModel(hosts: [host])) + let app = AppModel(hosts: HostLibraryModel()) + let window = WindowModel() + app.registerWindow(window) + app.recentlyClosedStore.record(RecentlyClosedRecord(items: [record])) + #expect(!window.canReopen) + window.reopenClosedWorkspace(record.id) + #expect(window.workspaces.isEmpty) + } +} diff --git a/TFMTests/HistoryMenuTests.swift b/TFMTests/HistoryMenuTests.swift index abd02f3c..18ba98d6 100644 --- a/TFMTests/HistoryMenuTests.swift +++ b/TFMTests/HistoryMenuTests.swift @@ -56,17 +56,31 @@ struct HistoryMenuTests { @Suite @MainActor struct HistoryMenuRecordTests { + @Test func closedWindowLabelsUseTheFirstNameAndCorrectPlural() { + func title(_ count: Int) -> String { + var value = AppStrings.History.closedWindow("Server", count: count) + value.locale = Locale(identifier: "en") + return String(localized: value) + } + #expect(title(1) == "Server") + #expect(title(2) == "Server (and 1 more workspace)") + #expect(title(3) == "Server (and 2 more workspaces)") + } + @Test(arguments: ["en", "zh-Hans", "zh-Hant"]) func nativeRecordMenusKeepProtocolImagesAndOpenTheChosenEvent(language: String) throws { let host = Host(name: "Server", host: "server.example") - let now = Date() + let now = Calendar.current.startOfDay(for: Date()).addingTimeInterval(43_200) let today = Calendar.current.startOfDay(for: now) let earlier = ConnectionHistoryEntry(hostID: host.id, kind: .sftp, connectedAt: today.addingTimeInterval(1)) let yesterday = ConnectionHistoryEntry(hostID: host.id, kind: .ssh, connectedAt: today.addingTimeInterval(-1)) - let history = ConnectionHistoryModel(entries: [earlier, yesterday]) + let recent = (1...14).map { + ConnectionHistoryEntry(hostID: host.id, kind: .ssh, connectedAt: today.addingTimeInterval(Double($0 * 60))) + } + 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()) + history: history, library: HostLibraryModel(hosts: [host]), devices: DeviceDirectoryModel(), now: now) var opened: [UUID] = [] let menu = NSHostingMenu( rootView: HistoryMenuRecords( @@ -77,15 +91,15 @@ struct HistoryMenuRecordTests { menu.update() MenuImageVisibilityController.apply(to: menu) let items = menu.items.filter { !$0.isSeparatorItem } - try #require(items.count == 3) + try #require(items.count == 17) let currentItem = try #require(items.first) - #expect(currentItem.title.contains("SSH")) + #expect(currentItem.title == host.name) #expect(currentItem.image != nil) - let todayMenu = try #require(items[1].submenu) + let todayMenu = try #require(items[15].submenu) todayMenu.update() MenuImageVisibilityController.apply(to: todayMenu) let sftpItem = try #require(todayMenu.items.first) - #expect(sftpItem.title.contains("SFTP")) + #expect(sftpItem.title == host.name) #expect(sftpItem.image != nil) #expect(currentItem.image?.name() != sftpItem.image?.name()) if #available(macOS 27.0, *) { diff --git a/TFMTests/WindowStateTests.swift b/TFMTests/WindowStateTests.swift index 0f349bdf..2250a1d0 100644 --- a/TFMTests/WindowStateTests.swift +++ b/TFMTests/WindowStateTests.swift @@ -1367,11 +1367,11 @@ struct WindowStateTests { } @Test - func individuallyClosedPanesShareTheTenItemReopenLimit() { + func individuallyClosedPanesShareTheFifteenItemReopenLimit() { let survivor = Pane(.empty) let workspace = Workspace(pane: survivor) let window = WindowModel(workspaces: [workspace]) - let closedPanes = (0..<11).map { _ in Pane(.empty) } + let closedPanes = (0..<16).map { _ in Pane(.empty) } for pane in closedPanes { workspace.splitFocusedPane(axis: .horizontal, with: pane) window.closePane(pane.id) @@ -1382,7 +1382,7 @@ struct WindowStateTests { window.reopenLastClosed() if let pane = window.selectedWorkspace?.focusedPane { reopenedIDs.append(pane.id) } } - #expect(reopenedIDs == closedPanes.suffix(10).reversed().map(\.id)) + #expect(reopenedIDs == closedPanes.suffix(15).reversed().map(\.id)) #expect(workspace.panes.map(\.id) == [survivor.id]) window.closePane(reopenedIDs[0])