feat(history): refine recent history and restore closed windows
This commit is contained in:
1 parent
b5e022419a
commit
231be54e08
31 files changed
+955
-126
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Entry>(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<Entry>(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<Entry>(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<Entry>(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<Entry>(defaults: defaults, key: "records").record(entry)
|
||||
let saved = try #require(defaults.data(forKey: "records"))
|
||||
|
||||
for _ in 0..<3 {
|
||||
let launch = RecentlyClosedStore<Entry>(defaults: defaults, key: "records")
|
||||
#expect(launch.entries.map(\.id) == [entry.id])
|
||||
}
|
||||
#expect(defaults.data(forKey: "records") == saved)
|
||||
|
||||
let next = RecentlyClosedStore<Entry>(defaults: defaults, key: "records")
|
||||
next.remove(entry.id)
|
||||
let afterReopen = RecentlyClosedStore<Entry>(defaults: defaults, key: "records")
|
||||
#expect(afterReopen.entries.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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] ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ConnectionHistoryEntry.ID> = []
|
||||
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 }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
/// One close operation. A window and all of its workspaces occupy one history slot.
|
||||
public struct RecentlyClosedRecord<Item: Codable & Identifiable>: 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
|
||||
}
|
||||
}
|
||||
@@ -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<Entry: Codable & Identifiable> where Entry.ID == UUID {
|
||||
public private(set) var entries: [Entry]
|
||||
public private(set) var storageError: String?
|
||||
private var persistentIDs: Set<UUID>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<UUID>] = [:]
|
||||
private var discardedSessionIDs: Set<String> = []
|
||||
var closedWorkspaceStates: [UUID: [MobileWorkspace.ReopenState]] = [:]
|
||||
var pendingClosedWindows: Set<UUID> = []
|
||||
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<String>
|
||||
@@ -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<UUID>()) { $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<RecentlyClosedRecord<MobileWorkspaceDescriptor>> {
|
||||
workspaceStore.recentlyClosed
|
||||
}
|
||||
|
||||
func saveWorkspaceDescriptors(_ descriptors: [MobileWorkspaceDescriptor], for id: String) {
|
||||
guard !discardedSessionIDs.contains(id) else { return }
|
||||
workspaceSnapshot.sessions[id] = descriptors
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
import TermindPresentation
|
||||
|
||||
extension MobileAppModel {
|
||||
var closedWorkspaceRecords: [RecentlyClosedRecord<MobileWorkspaceDescriptor>] {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<MobileWorkspaceDescriptor>] { 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<UUID>) {
|
||||
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.
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>com.akinokaede.termind.open-workspace</string>
|
||||
<string>com.akinokaede.termind.reopen-window</string>
|
||||
</array>
|
||||
<key>TermindCloudKitEnvironment</key>
|
||||
<string>$(ICLOUD_CONTAINER_ENVIRONMENT)</string>
|
||||
|
||||
@@ -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<RecentlyClosedRecord<MobileWorkspaceDescriptor>>
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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)),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RecentlyClosedRecord<ClosedWorkspaceRecord>>
|
||||
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<UUID>) {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
import TermindDomain
|
||||
|
||||
extension AppModel {
|
||||
var closedWorkspaceRecords: [RecentlyClosedRecord<ClosedWorkspaceRecord>] {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -545,8 +545,17 @@ final class WindowModel {
|
||||
}
|
||||
}
|
||||
|
||||
var canReopen: Bool { !reopenStack.isEmpty }
|
||||
var closedHistory: () -> [RecentlyClosedRecord<ClosedWorkspaceRecord>] = { [] }
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, *) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user