From 01ca95e95f444273c56e97ac87201ed30d3ef7a8 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 10:05:47 +0800 Subject: [PATCH] Show Snippet edit times on iOS cards --- .../FeatureModels/HostLibraryModel.swift | 4 +++ .../FeatureModels/SnippetLibraryModel.swift | 3 ++ .../Repositories/VaultStore.swift | 16 ++++++++- .../TermindApplication/VaultPersisting.swift | 6 ++++ .../SwiftDataVaultJournal.swift | 9 +++++ .../SwiftDataVaultStore.swift | 4 +++ Memory/ARCHITECTURE.md | 4 +++ TFI/App/MobileAppModel.swift | 23 ++++++++++++ .../Snippets/MobileSnippetsView.swift | 20 +++++++++-- TFITests/MobileCardRenderingTests.swift | 13 ++++++- TFITests/MobileSnippetTests.swift | 35 +++++++++++++++++++ TFMTests/VaultSyncTests.swift | 22 ++++++++++++ 12 files changed, 155 insertions(+), 4 deletions(-) diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index bbd9716e..748b2d89 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -124,6 +124,10 @@ public final class HostLibraryModel { snippets.first { $0.id == id } } + public func snippetModifiedAt(_ id: Snippet.ID) -> Date? { + vault.modifiedAt(for: VaultRecordKey(kind: .snippet, id: id)) + } + public func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? { snippetPackages.first { $0.id == id } } diff --git a/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift index 44940901..ef6898be 100644 --- a/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift @@ -31,6 +31,9 @@ public final class SnippetLibraryModel { public func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? { library.snippetPackagePath(packageID) } + public func modifiedAt(for snippetID: Snippet.ID) -> Date? { + library.snippetModifiedAt(snippetID) + } public func searchSnippets(_ query: String) -> [Snippet] { library.searchSnippets(query) } public func searchHosts(_ query: String) -> [Host] { library.searchHosts(query) } public func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } diff --git a/Frameworks/TermindApplication/Repositories/VaultStore.swift b/Frameworks/TermindApplication/Repositories/VaultStore.swift index f6393ca4..2c3d0ee3 100644 --- a/Frameworks/TermindApplication/Repositories/VaultStore.swift +++ b/Frameworks/TermindApplication/Repositories/VaultStore.swift @@ -7,6 +7,7 @@ import TermindDomain @MainActor public final class VaultStore { private let persistence: (any VaultPersisting)? + private var modificationDates: [VaultRecordKey: Date] = [:] public var didPersist: (() -> Void)? public var didRecordChanges: ((VaultMutationSet) -> Void)? @@ -15,20 +16,33 @@ public final class VaultStore { } public func load(fallback: VaultSnapshot) throws -> VaultSnapshot { - try persistence?.load() ?? fallback + let snapshot = try persistence?.load() ?? fallback + refreshModificationDates() + return snapshot } public func save(_ snapshot: VaultSnapshot) throws { if let persistence { let mutations = try persistence.save(snapshot) + refreshModificationDates() didRecordChanges?(mutations) } didPersist?() } + public func modifiedAt(for key: VaultRecordKey) -> Date? { + modificationDates[key] + } + /// A remote projection is already persisted by the sync engine. Aggregate /// models only need to refresh repository projections after accepting it. public func didApplyRemoteSnapshot() { + refreshModificationDates() didPersist?() } + + private func refreshModificationDates() { + guard let dates = try? persistence?.modificationDates() else { return } + modificationDates = dates + } } diff --git a/Frameworks/TermindApplication/VaultPersisting.swift b/Frameworks/TermindApplication/VaultPersisting.swift index e236d858..c54bfcdd 100644 --- a/Frameworks/TermindApplication/VaultPersisting.swift +++ b/Frameworks/TermindApplication/VaultPersisting.swift @@ -1,3 +1,4 @@ +import Foundation import TermindDomain @MainActor @@ -5,4 +6,9 @@ public protocol VaultPersisting: AnyObject { func load() throws -> VaultSnapshot func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult + func modificationDates() throws -> [VaultRecordKey: Date] +} + +extension VaultPersisting { + public func modificationDates() throws -> [VaultRecordKey: Date] { [:] } } diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift index 152b2781..70621446 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -197,6 +197,15 @@ public final class VaultSyncJournal { try entry(recordName: key.recordName)?.envelope } + public func modificationDates() throws -> [VaultRecordKey: Date] { + var result: [VaultRecordKey: Date] = [:] + for row in try context.fetch(FetchDescriptor()) { + guard let envelope = row.envelope, !envelope.isDeleted else { continue } + result[envelope.key] = envelope.version.modifiedAt + } + return result + } + public func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge { var result = VaultRemoteMerge() let entries = try entriesByName() diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index 64e9e5a0..4bcfea06 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -79,6 +79,10 @@ public final class HostPersistence { return try journal.recordCurrentSnapshot(snapshot) } + public func modificationDates() throws -> [VaultRecordKey: Date] { + try VaultSyncJournal(context: context).modificationDates() + } + private func persist(_ snapshot: VaultSnapshot) throws { try reconcile( try context.fetch(FetchDescriptor()), with: snapshot.hosts, diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 2237b11a..adf832d4 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -688,6 +688,10 @@ foreground loop. New port forwarding rules never start merely because they were Mobile Snippets retain the shared Vault records, nested packages, ordering and target host references. A card opens a read-only detail; execution is always an explicit action. +On iOS, each card's lower-right timestamp is the Snippet record's Vault journal +`modifiedAt`: the first save is its added time and later persisted changes replace it. +Pre-journal baseline records use a stable, device-local migration timestamp for display +without rewriting the record or creating a sync conflict. Each resolved target opens an independent SSH Workspace, including free multi-host runs, because mobile never composes panes. The initial command is live Workspace state only: it is submitted once after the first successful authentication/trust flow, then consumed. It diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index b1e8acb6..db6b0969 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -16,6 +16,22 @@ private enum MobileWorkspaceStartup { case directory(String) } +enum MobileSnippetTimestamp { + static let fallbackKey = "mobile.snippets.baselineModifiedAtFallback.v1" + + static func fallback(in defaults: UserDefaults?) -> Date { + if let existing = defaults?.object(forKey: fallbackKey) as? Date { return existing } + let date = Date() + defaults?.set(date, forKey: fallbackKey) + return date + } + + static func resolve(modifiedAt: Date?, fallback: Date) -> Date { + guard let modifiedAt, modifiedAt > VaultVersion.baselineDate else { return fallback } + return modifiedAt + } +} + @MainActor @Observable final class MobileAppModel { @@ -44,6 +60,7 @@ final class MobileAppModel { let liveActivities: MobileLiveActivityController @ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = [] private let defaults: UserDefaults? + private let snippetTimestampFallback: Date private let background: any MobileBackgroundTaskManaging private let workspaceConnectionServices: MobileWorkspaceConnectionServices let outbox: OutboxRunner @@ -84,6 +101,7 @@ final class MobileAppModel { self.hosts = hosts ?? SwiftDataHostRepository(context: vaultContext) outbox = OutboxRunner(store: SwiftDataOutboxStore(context: ModelContext(container))) self.defaults = inMemory || hosts != nil ? nil : defaults + snippetTimestampFallback = MobileSnippetTimestamp.fallback(in: self.defaults) liveActivities = MobileLiveActivityController(enabled: !inMemory && hosts == nil) pro = inMemory || hosts != nil ? .unrestrictedForTesting() : .live() let persistence = HostPersistence(context: vaultContext) @@ -253,6 +271,11 @@ final class MobileAppModel { connectionHistory.mostRecentDate(for: hostID) } + func snippetModifiedAt(for snippetID: Snippet.ID) -> Date { + MobileSnippetTimestamp.resolve( + modifiedAt: snippets.modifiedAt(for: snippetID), fallback: snippetTimestampFallback) + } + func sceneChanged(_ id: UUID, phase: ScenePhase) { let wasBackground = !scenePhases.isEmpty && scenePhases.values.allSatisfy { $0 == .background } scenePhases[id] = phase diff --git a/TFI/Features/Snippets/MobileSnippetsView.swift b/TFI/Features/Snippets/MobileSnippetsView.swift index af286983..15bbaadb 100644 --- a/TFI/Features/Snippets/MobileSnippetsView.swift +++ b/TFI/Features/Snippets/MobileSnippetsView.swift @@ -30,6 +30,8 @@ nonisolated enum MobileSnippetOperations { } struct MobileSnippetsView: View { + @Environment(\.locale) private var locale + private enum SectionID: Hashable { case results case package(SnippetPackage.ID) @@ -322,8 +324,22 @@ struct MobileSnippetsView: View { assessment: CommandRiskClassifier.assess(snippet.body)) } } - if showsPackage, let path = app.snippets.snippetPackagePath(snippet.packageID) { - Label(path, systemImage: "folder").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 6) { + if showsPackage, let path = app.snippets.snippetPackagePath(snippet.packageID) { + Label(path, systemImage: "folder") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 6) + Text( + app.snippetModifiedAt(for: snippet.id) + .formatted(.relative(presentation: .numeric).locale(locale)) + ) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } } actions: { diff --git a/TFITests/MobileCardRenderingTests.swift b/TFITests/MobileCardRenderingTests.swift index 102b8144..2783091e 100644 --- a/TFITests/MobileCardRenderingTests.swift +++ b/TFITests/MobileCardRenderingTests.swift @@ -72,7 +72,12 @@ struct MobileCardRenderingTests { SSHKeyRef( label: "Missing Private Key / 私钥缺失", algorithm: .ecdsa384, fingerprint: "SHA256:missing", credentialRef: "termind.key.\(UUID())", storage: .iCloud)) - app.snippets.upsert(Snippet(title: "Inspect disk usage / 检查磁盘", body: "df -h\ndu -sh /var/log")) + let snippetPackage = SnippetPackage(name: "Operations / 运维") + app.snippets.upsert(snippetPackage) + app.snippets.upsert( + Snippet( + title: "Inspect disk usage / 检查磁盘", body: "df -h\ndu -sh /var/log", + packageID: snippetPackage.id)) let server = ServerMetric(selection: MetricsSelection(id: UUID()), name: "Production Gateway") server.isConnecting = false server.status = "" @@ -93,6 +98,12 @@ struct MobileCardRenderingTests { try await capture("vault", language: language, scheme: scheme) { NavigationStack { MobileVaultView(app: app, open: { _, _ in }) } } + try await capture("snippets", language: language, scheme: scheme) { + NavigationStack { MobileSnippetsView(app: app) } + } + try await capture("snippets-search", language: language, scheme: scheme) { + NavigationStack { MobileSnippetsView(app: app, initialQuery: "Inspect") } + } try await capture("keychain", language: language, scheme: scheme) { NavigationStack { MobileCredentialsView(app: app) } } diff --git a/TFITests/MobileSnippetTests.swift b/TFITests/MobileSnippetTests.swift index ca539ba1..cca548d5 100644 --- a/TFITests/MobileSnippetTests.swift +++ b/TFITests/MobileSnippetTests.swift @@ -11,6 +11,41 @@ import struct TermindDomain.Host @Suite @MainActor struct MobileSnippetTests { + @Test func legacyTimestampFallbackIsStableUntilARealEditExists() throws { + let suiteName = "termind.snippet-timestamp-tests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let fallback = MobileSnippetTimestamp.fallback(in: defaults) + #expect(MobileSnippetTimestamp.fallback(in: defaults) == fallback) + #expect( + MobileSnippetTimestamp.resolve(modifiedAt: VaultVersion.baselineDate, fallback: fallback) + == fallback) + + let editedAt = fallback.addingTimeInterval(60) + #expect(MobileSnippetTimestamp.resolve(modifiedAt: editedAt, fallback: fallback) == editedAt) + } + + @Test func snippetCardsReadCreationAndEditTimesFromTheVaultJournal() async throws { + let app = try MobileAppModel(inMemory: true) + var snippet = Snippet(title: "Status", body: "uptime") + + app.snippets.upsert(snippet) + let createdAt = try #require(app.snippets.modifiedAt(for: snippet.id)) + #expect(createdAt > VaultVersion.baselineDate) + #expect(app.snippetModifiedAt(for: snippet.id) == createdAt) + + app.snippets.upsert(snippet) + #expect(app.snippets.modifiedAt(for: snippet.id) == createdAt) + + try await Task.sleep(for: .milliseconds(10)) + snippet.body = "uptime -p" + app.snippets.upsert(snippet) + let editedAt = try #require(app.snippets.modifiedAt(for: snippet.id)) + #expect(editedAt > createdAt) + #expect(app.snippetModifiedAt(for: snippet.id) == editedAt) + } + @Test func packageManagementSearchAndPersistenceUseTheSharedVault() throws { let app = try MobileAppModel(inMemory: true) let root = SnippetPackage(name: "Operations") diff --git a/TFMTests/VaultSyncTests.swift b/TFMTests/VaultSyncTests.swift index c8adc9ee..df39a7a6 100644 --- a/TFMTests/VaultSyncTests.swift +++ b/TFMTests/VaultSyncTests.swift @@ -16,6 +16,28 @@ import struct TermindDomain.Host @Suite @MainActor struct VaultSyncTests { + @Test + func vaultModificationDatesTrackOnlyPersistedRecordChanges() async throws { + let container = try makeContainer("ModificationDates") + let persistence = HostPersistence(context: container.mainContext) + var snippet = Snippet(title: "Uptime", body: "uptime") + let initial = VaultSnapshot(snippets: [snippet]) + + _ = try persistence.save(initial) + let key = VaultRecordKey(kind: .snippet, id: snippet.id) + let createdAt = try #require(try persistence.modificationDates()[key]) + #expect(createdAt > VaultVersion.baselineDate) + + _ = try persistence.save(initial) + #expect(try persistence.modificationDates()[key] == createdAt) + + try await Task.sleep(for: .milliseconds(10)) + snippet.body = "uptime -p" + _ = try persistence.save(VaultSnapshot(snippets: [snippet])) + let editedAt = try #require(try persistence.modificationDates()[key]) + #expect(editedAt > createdAt) + } + @Test func allVaultPayloadsRoundTripWithStableRecordNames() throws { let host = Host(name: "Production", host: "prod.example")