feat(history): refine recent history and restore closed windows

This commit is contained in:
Kaede Akino committed 2026-10-06 14:42:51 +08:00
1 parent b5e022419a
commit 231be54e08
31 files changed
+955 -126

No files matched your search

+40 -18
View File
@@ -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)
}
}
+85 -17
View File
@@ -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"
}
+2
View File
@@ -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 =
+25
View File
@@ -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
+33 -7
View File
@@ -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
}
}
+6
View File
@@ -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()
+40 -31
View File
@@ -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.
+1
View File
@@ -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)),
+84 -3
View File
@@ -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)
}
+16
View File
@@ -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)
+62
View File
@@ -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)
}
}
}
+50 -12
View File
@@ -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
}
}
+19 -3
View File
@@ -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()
+12 -2
View File
@@ -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()
+124
View File
@@ -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)
}
}
+21 -7
View File
@@ -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, *) {
+3 -3
View File
@@ -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])