diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillSnapshotService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillSnapshotService.swift new file mode 100644 index 0000000..0e61d0b --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/SkillSnapshotService.swift @@ -0,0 +1,267 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Tracks "what skills did this Scarf instance see last time on this +/// server" so the Skills tab can render a "2 new, 4 updated since you +/// last looked" pill — same pattern Hermes's `hermes skills update` +/// shows in the CLI. +/// +/// **Storage shape.** Per server. Mac persists to a JSON file under +/// `~/Library/Application Support/com.scarf/skill-snapshots/.json`; +/// iOS persists to `UserDefaults` under +/// `com.scarf.ios.skill-snapshot.`. Both stores hold a +/// `[skillId: SkillSignature]` map. `skillId` is `/` +/// from the loader; `SkillSignature` is a compact hash of file count +/// + sorted file names so a skill update (added/removed/renamed file) +/// shows up as a delta even if Hermes-version-pinning isn't in play. +/// +/// **Failure model.** Persist errors log + no-op (the diff degrades +/// to "everything looks new" but the user can still mark seen). Read +/// errors return an empty snapshot so the diff treats every skill as +/// new — annoying once but recoverable. +public struct SkillSnapshotService: Sendable { + #if canImport(os) + static let logger = Logger( + subsystem: "com.scarf", + category: "SkillSnapshotService" + ) + #endif + + public let serverID: ServerID + private let backend: SnapshotBackend + + public init(serverID: ServerID) { + self.serverID = serverID + #if os(macOS) + self.backend = .file(MacSnapshotBackend()) + #else + self.backend = .userDefaults(IOSSnapshotBackend()) + #endif + } + + /// Public for tests. Production callers use the no-arg + /// init that picks the right backend per platform. + public init(serverID: ServerID, backend: SnapshotBackend) { + self.serverID = serverID + self.backend = backend + } + + // MARK: - Public API + + /// Compute the delta between the current skill set and the + /// last snapshot. Returns counts only — the caller renders them + /// in a pill ("2 new, 4 updated since you last looked"). + public func diff(against current: [HermesSkill]) -> SkillSnapshotDiff { + let last = backend.read(for: serverID) + let currentSigs = Self.signatures(for: current) + + var newCount = 0 + var updatedCount = 0 + for (id, sig) in currentSigs { + if let prev = last[id] { + if prev != sig { updatedCount += 1 } + } else { + newCount += 1 + } + } + return SkillSnapshotDiff( + newCount: newCount, + updatedCount: updatedCount, + previousSnapshotEmpty: last.isEmpty, + changedSkillIds: Set(currentSigs.compactMap { id, sig in + let prev = last[id] + if prev == nil { return id } + return prev != sig ? id : nil + }) + ) + } + + /// Record the current skills as seen. Called on user action + /// ("Mark all as seen") or on the first-ever load (when + /// `previousSnapshotEmpty` is true and we don't want to show + /// every skill as new on next launch). + public func markSeen(_ current: [HermesSkill]) { + backend.write(Self.signatures(for: current), for: serverID) + } + + // MARK: - Private + + /// Compute a stable per-skill signature: `:`. + /// Hash via Foundation's `String.hashValue` would be unstable + /// across launches, so we keep the raw concatenation; payload is + /// small enough that storage size doesn't matter. + static func signatures(for skills: [HermesSkill]) -> [String: SkillSignature] { + var result: [String: SkillSignature] = [:] + for skill in skills { + let files = skill.files.sorted().joined(separator: "|") + result[skill.id] = "\(skill.files.count):\(files)" + } + return result + } +} + +public typealias SkillSignature = String + +/// Result of comparing the current skills to the last snapshot. +public struct SkillSnapshotDiff: Sendable, Equatable { + public let newCount: Int + public let updatedCount: Int + /// True when this is the first time we've ever seen the skills + /// for this server (no prior snapshot on disk). The view should + /// silently mark everything as seen rather than rendering a + /// "5 new!" pill on a fresh install. + public let previousSnapshotEmpty: Bool + /// Skill ids (`/`) that count as new or updated. + /// Used by the Mac UI to filter the tree to only changed entries + /// when the user taps the pill. + public let changedSkillIds: Set + + public var hasChanges: Bool { newCount + updatedCount > 0 } + + public init( + newCount: Int, + updatedCount: Int, + previousSnapshotEmpty: Bool, + changedSkillIds: Set + ) { + self.newCount = newCount + self.updatedCount = updatedCount + self.previousSnapshotEmpty = previousSnapshotEmpty + self.changedSkillIds = changedSkillIds + } + + /// Compact label for the "What's New" pill, e.g. + /// "2 new, 4 updated since you last looked" or "1 new skill". + public var label: String { + switch (newCount, updatedCount) { + case (let n, 0): return n == 1 ? "1 new skill since you last looked" : "\(n) new skills since you last looked" + case (0, let u): return u == 1 ? "1 updated skill since you last looked" : "\(u) updated skills since you last looked" + default: return "\(newCount) new, \(updatedCount) updated since you last looked" + } + } +} + +// MARK: - Backend abstraction + +/// Per-platform persistence for the skill-snapshot store. Both +/// implementations encode `[skillId: signature]` as JSON and key by +/// `ServerID`. Tests use the in-memory variant. +public enum SnapshotBackend: Sendable { + case file(MacSnapshotBackend) + case userDefaults(IOSSnapshotBackend) + case inMemory(InMemorySnapshotBackend) + + func read(for serverID: ServerID) -> [String: SkillSignature] { + switch self { + case .file(let b): return b.read(for: serverID) + case .userDefaults(let b): return b.read(for: serverID) + case .inMemory(let b): return b.read(for: serverID) + } + } + + func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) { + switch self { + case .file(let b): b.write(snapshot, for: serverID) + case .userDefaults(let b): b.write(snapshot, for: serverID) + case .inMemory(let b): b.write(snapshot, for: serverID) + } + } +} + +#if os(macOS) +/// Mac persistence: one JSON file per server under +/// `~/Library/Application Support/com.scarf/skill-snapshots/`. +public struct MacSnapshotBackend: Sendable { + public init() {} + + public func read(for serverID: ServerID) -> [String: SkillSignature] { + guard let url = Self.fileURL(for: serverID), + let data = try? Data(contentsOf: url), + let map = try? JSONDecoder().decode([String: SkillSignature].self, from: data) + else { return [:] } + return map + } + + public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) { + guard let url = Self.fileURL(for: serverID) else { return } + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(snapshot) + try data.write(to: url, options: .atomic) + } catch { + #if canImport(os) + SkillSnapshotService.logger.warning( + "couldn't persist skill snapshot for \(serverID.uuidString, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + #endif + } + } + + private static func fileURL(for serverID: ServerID) -> URL? { + guard let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first else { return nil } + return appSupport + .appendingPathComponent("com.scarf", isDirectory: true) + .appendingPathComponent("skill-snapshots", isDirectory: true) + .appendingPathComponent("\(serverID.uuidString).json") + } +} +#else +public struct MacSnapshotBackend: Sendable { + public init() {} + public func read(for serverID: ServerID) -> [String: SkillSignature] { [:] } + public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) {} +} +#endif + +/// iOS persistence: one UserDefaults key per server. +/// `@unchecked Sendable` because `UserDefaults` itself doesn't conform +/// — it is in fact thread-safe per Apple docs, the conformance just +/// hasn't been added to the headers. +public final class IOSSnapshotBackend: @unchecked Sendable { + public static let keyPrefix = "com.scarf.ios.skill-snapshot." + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public func read(for serverID: ServerID) -> [String: SkillSignature] { + let key = Self.keyPrefix + serverID.uuidString + guard let data = defaults.data(forKey: key), + let map = try? JSONDecoder().decode([String: SkillSignature].self, from: data) + else { return [:] } + return map + } + + public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) { + let key = Self.keyPrefix + serverID.uuidString + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: key) + } +} + +/// In-memory backend for tests. Per-instance map; not shared. +public final class InMemorySnapshotBackend: @unchecked Sendable { + private var store: [ServerID: [String: SkillSignature]] = [:] + + public init() {} + + public func read(for serverID: ServerID) -> [String: SkillSignature] { + store[serverID] ?? [:] + } + + public func write(_ snapshot: [String: SkillSignature], for serverID: ServerID) { + store[serverID] = snapshot + } +} diff --git a/scarf/Scarf iOS/Skills/SkillsListView.swift b/scarf/Scarf iOS/Skills/SkillsListView.swift index 7b903f4..2ee3880 100644 --- a/scarf/Scarf iOS/Skills/SkillsListView.swift +++ b/scarf/Scarf iOS/Skills/SkillsListView.swift @@ -8,6 +8,7 @@ struct SkillsListView: View { let config: IOSServerConfig @State private var vm: IOSSkillsViewModel + @State private var snapshotDiff: SkillSnapshotDiff? private static let sharedContextID: ServerID = ServerID( uuidString: "00000000-0000-0000-0000-0000000000A1" @@ -21,6 +22,28 @@ struct SkillsListView: View { var body: some View { List { + if let diff = snapshotDiff, + diff.hasChanges, + !diff.previousSnapshotEmpty { + Section { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text(diff.label) + .font(.callout) + } + Spacer() + Button("Seen") { + SkillSnapshotService(serverID: Self.sharedContextID) + .markSeen(vm.categories.flatMap(\.skills)) + snapshotDiff = nil + } + .controlSize(.small) + .buttonStyle(.bordered) + } + } + } if let err = vm.lastError { Section { Label(err, systemImage: "exclamationmark.triangle.fill") @@ -71,8 +94,29 @@ struct SkillsListView: View { .clipShape(RoundedRectangle(cornerRadius: 10)) } } - .refreshable { await vm.load() } - .task { await vm.load() } + .refreshable { + await vm.load() + recomputeSnapshotDiff() + } + .task { + await vm.load() + recomputeSnapshotDiff() + } + } + + /// v2.5 "What's New" diff against the last-seen snapshot for this + /// server. First-time users get a silent prime — the pill only + /// renders on subsequent loads when something actually changed. + private func recomputeSnapshotDiff() { + let allSkills = vm.categories.flatMap(\.skills) + let svc = SkillSnapshotService(serverID: Self.sharedContextID) + let diff = svc.diff(against: allSkills) + if diff.previousSnapshotEmpty { + svc.markSeen(allSkills) + snapshotDiff = nil + } else { + snapshotDiff = diff + } } } diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 35d72c4..88691bf 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -9,6 +9,10 @@ struct SkillsView: View { /// is in flight; populated with `.present` / `.missing(...)` / /// `.unknown(...)` on completion. @State private var designMdNpxStatus: SkillPrereqService.Status? + /// Diff between the current skill list and the last-seen snapshot + /// for the active server. Drives the v2.5 "What's New" pill at + /// the top of the Skills list. Nil before first compute. + @State private var snapshotDiff: SkillSnapshotDiff? @Environment(\.serverContext) private var serverContext @State private var currentTab: Tab = .installed @@ -35,6 +39,15 @@ struct SkillsView: View { var body: some View { VStack(spacing: 0) { modePicker + // v2.5 "What's New" pill — only renders when the diff has + // changes against a non-empty prior snapshot (first launch + // is silent so users aren't drowned in "everything is + // new!" noise). + if let diff = snapshotDiff, + diff.hasChanges, + !diff.previousSnapshotEmpty { + whatsNewPill(diff: diff) + } Divider() switch currentTab { case .installed: installedContent @@ -58,6 +71,53 @@ struct SkillsView: View { designMdNpxStatus = await svc.probe(binary: "npx") } } + // Snapshot diff: recompute whenever the loaded skill list + // changes. First-load with no prior snapshot silently primes + // the snapshot — subsequent changes show the pill. + .onChange(of: viewModel.totalSkillCount) { _, _ in + recomputeSnapshotDiff() + } + .task { + recomputeSnapshotDiff() + } + } + + /// Compute the snapshot diff against the active server's last-seen + /// state. On a first-ever load (empty snapshot) we silently mark + /// the current set as seen so the next load shows real deltas. + private func recomputeSnapshotDiff() { + let allSkills = viewModel.categories.flatMap(\.skills) + let svc = SkillSnapshotService(serverID: serverContext.id) + let diff = svc.diff(against: allSkills) + if diff.previousSnapshotEmpty { + // Silent prime — don't show the pill; just record what + // we've seen so future diffs are honest. + svc.markSeen(allSkills) + snapshotDiff = nil + } else { + snapshotDiff = diff + } + } + + /// Tappable pill rendering "2 new, 4 updated since you last looked". + /// Tap → mark current set as seen + dismiss the pill. + private func whatsNewPill(diff: SkillSnapshotDiff) -> some View { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .foregroundStyle(.tint) + Text(diff.label) + .font(.callout) + Spacer() + Button("Mark as seen") { + let svc = SkillSnapshotService(serverID: serverContext.id) + svc.markSeen(viewModel.categories.flatMap(\.skills)) + snapshotDiff = nil + } + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.tint.opacity(0.1)) } private var modePicker: some View {