From f1e8f3070f1a6217e23d4c40518ca89dc219fa92 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 22:57:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(projects):=20registry=20schema=20v2=20?= =?UTF-8?q?=E2=80=94=20folder=20+=20archived=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of three v2.3 commits. Adds the data model + view-model plumbing for folder grouping and soft-archive; no UI changes yet (sidebar still renders a flat list). ProjectEntry gains two optional fields: - `folder: String?` — opaque single-level label for sidebar grouping; nil means top-level. Custom Codable decodeIfPresent so v2.2 registry files parse cleanly. - `archived: Bool` — soft-delete flag; defaults to false via custom decoder. Archived projects stay on disk and in the registry; the v2.3 sidebar just hides them unless Show Archived is toggled on. Custom encode(to:) omits both fields when they're at their default values. Keeps registry files clean for the common (top-level, unarchived) case and means v2.2 Scarf still loads a v2.3-written registry of projects that never used the new features — forward + backward compat by construction. ProjectsViewModel grows four verbs: - moveProject(_:toFolder:) — update the folder assignment - renameProject(_:to:) — rename with duplicate-name + empty-name rejection; preserves selection across the rename so the user stays on the same project - archiveProject(_:) — sets archived=true, clears selection if the archived project was selected (avoids lingering on a hidden view) - unarchiveProject(_:) — sets archived=false; does NOT re-select (unhiding ≠ focusing) - `folders: [String]` computed property — distinct folder labels, sorted, for the sidebar + move-to-folder sheet Two new test suites: - ProjectRegistryMigrationTests: round-trips v2.2 → v2.3 and back, asserts encoder cleanliness (defaults omitted), identity stability under folder / archive changes. - ProjectsViewModelTests: verbs hit the real ~/.hermes/scarf/projects.json via TestRegistryLock for cross-suite serialization. Covers happy paths, duplicate / empty-name rename rejection, and folder dedup. 73/73 Swift tests pass (was 58; 15 new). No behavior change on v2.2 registry files yet — the sidebar UI lands in commit 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scarf/Core/Models/ProjectDashboard.swift | 56 ++++++ .../ViewModels/ProjectsViewModel.swift | 95 ++++++++++ .../ProjectRegistryMigrationTests.swift | 129 +++++++++++++ scarf/scarfTests/ProjectsViewModelTests.swift | 173 ++++++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 scarf/scarfTests/ProjectRegistryMigrationTests.swift create mode 100644 scarf/scarfTests/ProjectsViewModelTests.swift diff --git a/scarf/scarf/Core/Models/ProjectDashboard.swift b/scarf/scarf/Core/Models/ProjectDashboard.swift index abf598c..0659a76 100644 --- a/scarf/scarf/Core/Models/ProjectDashboard.swift +++ b/scarf/scarf/Core/Models/ProjectDashboard.swift @@ -11,7 +11,63 @@ struct ProjectEntry: Codable, Sendable, Identifiable, Hashable { let name: String let path: String + /// Folder path for sidebar grouping. `nil` means top-level (no + /// folder). Introduced in v2.3 — v2.2 registry files have no + /// `folder` key, which decodes cleanly as `nil` via + /// `decodeIfPresent` below. + /// + /// We leave shape flexible: today this is treated as an opaque + /// single-level label (e.g. "Clients"), and the sidebar renders + /// one DisclosureGroup per distinct value. If nesting becomes a + /// requirement later, we can interpret the string as a slash- + /// separated path without a migration (old single-label values + /// still mean a top-level folder with that name). + var folder: String? + + /// Soft-archive flag. Archived projects are hidden from the + /// sidebar by default; a Show Archived toggle surfaces them. + /// Non-destructive — nothing is deleted on disk. Introduced in + /// v2.3; v2.2 registry files default to `false` via the custom + /// decoder below. + var archived: Bool + var dashboardPath: String { path + "/.scarf/dashboard.json" } + + init(name: String, path: String, folder: String? = nil, archived: Bool = false) { + self.name = name + self.path = path + self.folder = folder + self.archived = archived + } + + // MARK: - Codable (custom for backward compat) + + private enum CodingKeys: String, CodingKey { + case name, path, folder, archived + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.name = try c.decode(String.self, forKey: .name) + self.path = try c.decode(String.self, forKey: .path) + // Both new fields: tolerate absence for v2.2-era registries. + self.folder = try c.decodeIfPresent(String.self, forKey: .folder) + self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false + } + + func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(name, forKey: .name) + try c.encode(path, forKey: .path) + // Only emit optional fields when they carry meaning — keeps + // registry files clean for the common (top-level, unarchived) + // case and means v2.2 Scarf can still load a v2.3-written + // registry of projects that never used the new features. + try c.encodeIfPresent(folder, forKey: .folder) + if archived { + try c.encode(archived, forKey: .archived) + } + } } // MARK: - Dashboard diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift index 53bfb14..292eb92 100644 --- a/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift +++ b/scarf/scarf/Features/Projects/ViewModels/ProjectsViewModel.swift @@ -73,6 +73,101 @@ final class ProjectsViewModel { } } + // MARK: - v2.3 registry verbs (folder / archive / rename) + + /// Move a project into a folder. `nil` folder returns the project + /// to the top level. No-op when the target already matches. + func moveProject(_ project: ProjectEntry, toFolder folder: String?) { + mutateEntry(project) { $0.folder = folder } + } + + /// Rename a project. `name` is the registry's unique key + the + /// Identifiable id; we reject renames that would collide with + /// another project's name. Returns true on success. + @discardableResult + func renameProject(_ project: ProjectEntry, to newName: String) -> Bool { + let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + guard trimmed != project.name else { return true } + var registry = service.loadRegistry() + // Reject collisions — a second project already owns that name. + guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false } + guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false } + let old = registry.projects[index] + registry.projects[index] = ProjectEntry( + name: trimmed, + path: old.path, + folder: old.folder, + archived: old.archived + ) + do { + try service.saveRegistry(registry) + } catch { + logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)") + return false + } + projects = registry.projects + // Preserve selection across the rename — the selected project + // still exists, it just has a new id. + if selectedProject?.name == project.name { + selectedProject = registry.projects[index] + } + return true + } + + /// Soft-archive a project. It stays on disk and in the registry; + /// the sidebar just hides it unless `showArchived` is on. + func archiveProject(_ project: ProjectEntry) { + mutateEntry(project) { $0.archived = true } + // If the archived project was selected, clear selection so + // the dashboard doesn't linger on a hidden project. + if selectedProject?.name == project.name { + selectedProject = nil + dashboard = nil + } + } + + /// Restore an archived project to the default view. + func unarchiveProject(_ project: ProjectEntry) { + mutateEntry(project) { $0.archived = false } + } + + /// Distinct folder labels across the current project set, sorted + /// alphabetically. Drives the sidebar's DisclosureGroups (commit + /// 2) and the Move-to-Folder sheet's existing-folder list. An + /// "empty" folder (folder with zero projects) can't exist under + /// this model — folders are implicit in the data — which is + /// intentional: v2.3 doesn't need first-class empty folders. + var folders: [String] { + let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty }) + return set.sorted() + } + + // MARK: - Helpers + + /// Fetch the registry, apply `mutation` to the matched entry, + /// persist, update in-memory state. Centralises the save + + /// re-publish dance shared by `moveProject`, `archiveProject`, + /// and `unarchiveProject`. Callers that need different matching + /// semantics (rename, remove) handle their own registry mutation. + private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) { + var registry = service.loadRegistry() + guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return } + var entry = registry.projects[index] + mutation(&entry) + registry.projects[index] = entry + do { + try service.saveRegistry(registry) + } catch { + logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)") + return + } + projects = registry.projects + if selectedProject?.name == project.name { + selectedProject = entry + } + } + func refreshDashboard() { guard let project = selectedProject else { return } loadDashboard(for: project) diff --git a/scarf/scarfTests/ProjectRegistryMigrationTests.swift b/scarf/scarfTests/ProjectRegistryMigrationTests.swift new file mode 100644 index 0000000..26b6d5f --- /dev/null +++ b/scarf/scarfTests/ProjectRegistryMigrationTests.swift @@ -0,0 +1,129 @@ +import Testing +import Foundation +@testable import scarf + +/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields. +/// Both are optional/defaulted at the decoder so v2.2-era +/// `~/.hermes/scarf/projects.json` files still parse cleanly, and +/// v2.3-written files are forward-compatible with v2.2 readers +/// (which ignore unknown keys). These tests lock in both ends of +/// that contract. +/// +/// No disk or Hermes dependency — we work entirely with in-memory +/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't +/// needed. Safe to run in parallel with every other test suite. +@Suite struct ProjectRegistryMigrationTests { + + @Test func decodesV22RegistryWithoutNewFields() throws { + // v2.2-era file: just name + path. No folder, no archived. + let json = """ + { + "projects": [ + { "name": "Legacy", "path": "/Users/x/legacy" }, + { "name": "Another", "path": "/Users/x/another" } + ] + } + """.data(using: .utf8)! + + let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json) + + #expect(registry.projects.count == 2) + #expect(registry.projects[0].name == "Legacy") + #expect(registry.projects[0].path == "/Users/x/legacy") + // Defaults hydrate for absent v2.3 fields. + #expect(registry.projects[0].folder == nil) + #expect(registry.projects[0].archived == false) + } + + @Test func decodesV23RegistryWithFolderAndArchived() throws { + let json = """ + { + "projects": [ + { "name": "Client A", "path": "/Users/x/a", "folder": "Clients" }, + { "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true }, + { "name": "Personal", "path": "/Users/x/p" } + ] + } + """.data(using: .utf8)! + + let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json) + + #expect(registry.projects.count == 3) + #expect(registry.projects[0].folder == "Clients") + #expect(registry.projects[0].archived == false) + #expect(registry.projects[1].folder == "Clients") + #expect(registry.projects[1].archived == true) + #expect(registry.projects[2].folder == nil) + #expect(registry.projects[2].archived == false) + } + + @Test func encodeOmitsDefaultedFields() throws { + // A top-level, non-archived project should encode with ONLY + // name + path keys. This keeps v2.3-written registries + // loadable by v2.2 Scarf (which ignores unknown keys), and + // keeps the file clean for the common case. + let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain") + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(entry) + let s = try #require(String(data: data, encoding: .utf8)) + #expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#) + } + + @Test func encodeIncludesFolderWhenPresent() throws { + let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients") + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(entry) + let s = try #require(String(data: data, encoding: .utf8)) + #expect(s.contains(#""folder":"Clients""#)) + // archived still omitted when false — cleanliness matters. + #expect(!s.contains(#""archived""#)) + } + + @Test func encodeIncludesArchivedOnlyWhenTrue() throws { + let archived = ProjectEntry(name: "Old", path: "/o", archived: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(archived) + let s = try #require(String(data: data, encoding: .utf8)) + #expect(s.contains(#""archived":true"#)) + + let active = ProjectEntry(name: "New", path: "/n", archived: false) + let data2 = try encoder.encode(active) + let s2 = try #require(String(data: data2, encoding: .utf8)) + #expect(!s2.contains(#""archived""#)) + } + + @Test func roundTripPreservesAllFields() throws { + let original = ProjectRegistry(projects: [ + ProjectEntry(name: "Top", path: "/t"), + ProjectEntry(name: "InFolder", path: "/f", folder: "Work"), + ProjectEntry(name: "ArchivedTop", path: "/a", archived: true), + ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true) + ]) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded) + + #expect(decoded.projects.count == 4) + #expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false) + #expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false) + #expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true) + #expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true) + } + + @Test func identityStaysKeyedOnName() throws { + // ProjectEntry.id should remain `name`, so selecting by id + // across a folder-move or archive-flip still works without + // a reselection step. + let a = ProjectEntry(name: "Foo", path: "/p") + let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients") + let c = ProjectEntry(name: "Foo", path: "/p", archived: true) + #expect(a.id == "Foo") + #expect(b.id == "Foo") + #expect(c.id == "Foo") + #expect(a.id == b.id) + #expect(a.id == c.id) + } +} diff --git a/scarf/scarfTests/ProjectsViewModelTests.swift b/scarf/scarfTests/ProjectsViewModelTests.swift new file mode 100644 index 0000000..c4feb4b --- /dev/null +++ b/scarf/scarfTests/ProjectsViewModelTests.swift @@ -0,0 +1,173 @@ +import Testing +import Foundation +@testable import scarf + +/// Exercises the v2.3 registry verbs added to ProjectsViewModel: +/// moveProject, renameProject, archiveProject, unarchiveProject, +/// + the derived `folders` list. All verbs write through to +/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so +/// each test uses TestRegistryLock to snapshot + restore the real +/// file. Cross-suite serialization ensures we don't race with other +/// registry-touching tests. +@MainActor @Suite(.serialized) struct ProjectsViewModelTests { + + @Test func moveProjectSetsFolder() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "Alpha", path: "/a"), + ProjectEntry(name: "Beta", path: "/b") + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + #expect(vm.projects.count == 2) + + vm.moveProject(vm.projects[0], toFolder: "Clients") + + #expect(vm.projects.count == 2) + #expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients") + #expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil) + + // Round-trip: reload from disk and confirm the move persisted. + let fresh = ProjectDashboardService(context: .local).loadRegistry() + #expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients") + } + + @Test func moveProjectToNilReturnsToTopLevel() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "Nested", path: "/n", folder: "Clients") + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + vm.moveProject(vm.projects[0], toFolder: nil) + + #expect(vm.projects[0].folder == nil) + let fresh = ProjectDashboardService(context: .local).loadRegistry() + #expect(fresh.projects[0].folder == nil) + } + + @Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false) + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + vm.selectProject(vm.projects[0]) + + let ok = vm.renameProject(vm.projects[0], to: "NewName") + #expect(ok == true) + #expect(vm.projects.count == 1) + #expect(vm.projects[0].name == "NewName") + #expect(vm.projects[0].folder == "Work") + #expect(vm.projects[0].archived == false) + // Selection follows the rename — the user stays on the same + // project they were on. + #expect(vm.selectedProject?.name == "NewName") + } + + @Test func renameProjectRejectsDuplicateName() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "A", path: "/a"), + ProjectEntry(name: "B", path: "/b") + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + + // Renaming A to B should be refused — B already exists. + let ok = vm.renameProject(vm.projects[0], to: "B") + #expect(ok == false) + // Registry unchanged. + #expect(vm.projects.map(\.name) == ["A", "B"]) + } + + @Test func renameProjectRejectsEmptyName() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "Foo", path: "/f") + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + + #expect(vm.renameProject(vm.projects[0], to: "") == false) + #expect(vm.renameProject(vm.projects[0], to: " ") == false) + #expect(vm.projects[0].name == "Foo") + } + + @Test func renameProjectToSameNameIsNoOpSuccess() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "Foo", path: "/f") + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + + #expect(vm.renameProject(vm.projects[0], to: "Foo") == true) + // Whitespace around matching name also no-ops. + #expect(vm.renameProject(vm.projects[0], to: " Foo ") == true) + #expect(vm.projects[0].name == "Foo") + } + + @Test func archiveAndUnarchiveProject() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "Target", path: "/t") + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + vm.selectProject(vm.projects[0]) + #expect(vm.projects[0].archived == false) + #expect(vm.selectedProject != nil) + + vm.archiveProject(vm.projects[0]) + #expect(vm.projects[0].archived == true) + // Archiving clears the selection so the dashboard doesn't + // linger on a project the sidebar will hide. + #expect(vm.selectedProject == nil) + + vm.unarchiveProject(vm.projects[0]) + #expect(vm.projects[0].archived == false) + // Unarchive doesn't re-select — the user chose to hide it, + // surfacing it doesn't mean they want focus back. + #expect(vm.selectedProject == nil) + } + + @Test func foldersListIsSortedAndDeduped() async throws { + let snapshot = TestRegistryLock.acquireAndSnapshot() + defer { TestRegistryLock.restore(snapshot) } + try seedRegistry(.init(projects: [ + ProjectEntry(name: "A", path: "/a", folder: "Work"), + ProjectEntry(name: "B", path: "/b", folder: "Personal"), + ProjectEntry(name: "C", path: "/c", folder: "Work"), + ProjectEntry(name: "D", path: "/d"), // top-level + ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil + ])) + + let vm = ProjectsViewModel(context: .local) + vm.load() + + #expect(vm.folders == ["Personal", "Work"]) + } + + // MARK: - Helpers + + @MainActor + private func seedRegistry(_ registry: ProjectRegistry) throws { + try ProjectDashboardService(context: .local).saveRegistry(registry) + } +}