Show Snippet edit times on iOS cards
This commit is contained in:
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user