Show Snippet edit times on iOS cards

This commit is contained in:
Kaede Akino committed 2026-09-12 10:38:01 +08:00
1 parent 97248d17f2
commit 01ca95e95f
12 files changed
+155 -4

No files matched your search

@@ -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 }
}
@@ -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) }
@@ -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
}
}
@@ -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] { [:] }
}
@@ -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<PersistentVaultSyncEntry>()) {
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()
@@ -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<PersistentHost>()), with: snapshot.hosts,
+4
View File
@@ -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
+23
View File
@@ -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
+18 -2
View File
@@ -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: {
+12 -1
View File
@@ -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) }
}
+35
View File
@@ -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")
+22
View File
@@ -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")