diff --git a/graphcode/Sources/Features/Settings/SettingsTemplateEntry.swift b/graphcode/Sources/Features/Settings/SettingsTemplateEntry.swift new file mode 100644 index 00000000..54444aa8 --- /dev/null +++ b/graphcode/Sources/Features/Settings/SettingsTemplateEntry.swift @@ -0,0 +1,41 @@ +import Foundation +import GraphcodeKit + +/// Settings manages files, not just template UUIDs: copies in different projects +/// keep their UUID so following loops can still resolve them. +struct SettingsTemplateEntry: Identifiable { + let id: URL + let template: PromptTemplate + let usage: TemplateUsage + + static func load( + storage: TemplateStorage, projectPaths: [String], graphs: [LoopGraph] + ) -> [Self] { + // Opening the home folder as a project must not relabel its personal templates. + let home = storage.homeDirectory.standardizedFileURL.resolvingSymlinksInPath() + var seenDirectories: Set = [home] + var found: [PromptTemplate] = [] + for path in projectPaths { + let directory = storage.projectDirectory(path).standardizedFileURL.resolvingSymlinksInPath() + guard seenDirectories.insert(directory).inserted else { continue } + found += storage.load(projectPath: path).filter(\.origin.isProject) + } + found += storage.load(projectPath: nil) + + var seenFiles = Set() + return TemplateLibraryClient.overlayUseCounts(found).compactMap { template in + let directory: URL + switch template.origin { + case .home: directory = storage.homeDirectory + case .project(let path): directory = storage.projectDirectory(path) + } + let id = directory.appendingPathComponent(template.fileName) + .standardizedFileURL.resolvingSymlinksInPath() + guard seenFiles.insert(id).inserted else { return nil } + return Self( + id: id, + template: template, + usage: TemplateUsage.of(template.id, in: graphs)) + } + } +} diff --git a/graphcode/Sources/Features/Settings/TemplatesSettings.swift b/graphcode/Sources/Features/Settings/TemplatesSettings.swift index 1dc92bab..10100cc5 100644 --- a/graphcode/Sources/Features/Settings/TemplatesSettings.swift +++ b/graphcode/Sources/Features/Settings/TemplatesSettings.swift @@ -14,14 +14,13 @@ import SwiftUI /// because a committed edit to a project template changes what runs on a teammate's /// machine. struct TemplatesSettingsSection: View { - @State private var templates: [PromptTemplate] = [] - @State private var usage: [UUID: TemplateUsage] = [:] - @State private var pendingDeletion: PromptTemplate? - @State private var editing: PromptTemplate? + @State private var entries: [SettingsTemplateEntry] = [] + @State private var pendingDeletion: SettingsTemplateEntry? + @State private var editing: SettingsTemplateEntry? var body: some View { Section { - if templates.isEmpty { + if entries.isEmpty { Text( "No templates yet. Save one from the New Node dialog (⌘T), or right-click a " + "loop that worked and choose Save as Template…." @@ -29,8 +28,8 @@ struct TemplatesSettingsSection: View { .font(.caption2) .foregroundStyle(.secondary) } else { - ForEach(templates) { template in - row(template) + ForEach(entries) { entry in + row(entry) } } } header: { @@ -47,10 +46,11 @@ struct TemplatesSettingsSection: View { .foregroundStyle(.secondary) } .onAppear(perform: reload) - .sheet(item: $editing) { template in + .sheet(item: $editing) { entry in + let template = entry.template TemplateEditorView( template: template, - usage: usage[template.id] ?? TemplateUsage(), + usage: entry.usage, onSave: { edited in _ = try? TemplateStorage.shared.update(edited, replacing: template) editing = nil @@ -61,13 +61,13 @@ struct TemplatesSettingsSection: View { // A template is a file, and a project one is a file in somebody's checkout. The // list is the only place they can be deleted, so the click asks first. .confirmationDialog( - "Delete “\(pendingDeletion?.name ?? "")”?", + "Delete “\(pendingDeletion?.template.name ?? "")”?", isPresented: Binding( get: { pendingDeletion != nil }, set: { if !$0 { pendingDeletion = nil } }) ) { Button("Delete template", role: .destructive) { - if let template = pendingDeletion { try? TemplateStorage.shared.delete(template) } + if let entry = pendingDeletion { try? TemplateStorage.shared.delete(entry.template) } pendingDeletion = nil reload() } @@ -77,8 +77,9 @@ struct TemplatesSettingsSection: View { } } - private func row(_ template: PromptTemplate) -> some View { - HStack(spacing: 8) { + private func row(_ entry: SettingsTemplateEntry) -> some View { + let template = entry.template + return HStack(spacing: 8) { RoundedRectangle(cornerRadius: 2) .fill((template.shape ?? .sketch).accent) .frame(width: 9, height: 9) @@ -94,19 +95,19 @@ struct TemplatesSettingsSection: View { .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 3)) } } - Text(subtitle(template)) + Text(subtitle(entry)) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 8) - Button("Edit") { editing = template } + Button("Edit") { editing = entry } .buttonStyle(.link) .font(.caption) Button("Reveal") { reveal(template) } .buttonStyle(.link) .font(.caption) - Button("Delete", role: .destructive) { pendingDeletion = template } + Button("Delete", role: .destructive) { pendingDeletion = entry } .buttonStyle(.link) .font(.caption) } @@ -117,9 +118,9 @@ struct TemplatesSettingsSection: View { /// their last-known snapshot and warn — so the dialog says so rather than letting /// someone guess that deleting is a way to stop a nightly run. private var deletionWarning: String { - guard let template = pendingDeletion else { return "" } - var text = "This removes \(TemplateSavePath.display(of: template))." - let following = usage[template.id]?.following ?? 0 + guard let entry = pendingDeletion else { return "" } + var text = "This removes \(TemplateSavePath.display(of: entry.template))." + let following = entry.usage.following if following > 0 { text += following == 1 @@ -129,7 +130,8 @@ struct TemplatesSettingsSection: View { return text } - private func subtitle(_ template: PromptTemplate) -> String { + private func subtitle(_ entry: SettingsTemplateEntry) -> String { + let template = entry.template var parts: [String] = [] switch template.shape { case .sketch, nil: parts.append("Main") @@ -141,7 +143,7 @@ struct TemplatesSettingsSection: View { case .composite: parts.append("Composite") } if template.useCount > 0 { parts.append("used \(template.useCount)×") } - if let following = usage[template.id]?.followingLine { parts.append(following) } + if let following = entry.usage.followingLine { parts.append(following) } parts.append(template.summaryLine) return parts.joined(separator: " — ") } @@ -169,19 +171,9 @@ struct TemplatesSettingsSection: View { private func reload() { let persistence = ProjectPersistence(baseDirectory: SupportDirectory.url) let projects = persistence.loadRecentProjects() - let storage = TemplateStorage.shared - - var seen = Set() - var found: [PromptTemplate] = [] - for path in projects.map(\.path) where seen.insert(.project(path)).inserted { - found += storage.load(projectPath: path).filter(\.origin.isProject) - } - found += storage.load(projectPath: nil) - let graphs = projects.compactMap { persistence.loadGraph(path: $0.path) } - templates = TemplateLibraryClient.overlayUseCounts(found) - usage = Dictionary( - uniqueKeysWithValues: templates.map { ($0.id, TemplateUsage.of($0.id, in: graphs)) }) + entries = SettingsTemplateEntry.load( + storage: .shared, projectPaths: projects.map(\.path), graphs: graphs) } } diff --git a/graphcode/Tests/SettingsTemplateEntryTests.swift b/graphcode/Tests/SettingsTemplateEntryTests.swift new file mode 100644 index 00000000..a425f83b --- /dev/null +++ b/graphcode/Tests/SettingsTemplateEntryTests.swift @@ -0,0 +1,151 @@ +import Foundation +import GraphcodeKit +import IdentifiedCollections +import Testing + +@testable import graphcode + +@Suite +struct SettingsTemplateEntryTests { + private func withStorage(_ body: (URL, TemplateStorage) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("settings-templates-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let storage = TemplateStorage( + homeDirectory: root.appendingPathComponent(".graphcode/templates", isDirectory: true)) + try body(root, storage) + } + + @Test + func homeDirectoryAsARecentProjectLoadsEachFileOnce() throws { + try withStorage { root, storage in + let template = PromptTemplate(name: "Home", body: "A personal brief.") + try storage.save(template, to: .home, projectPath: nil) + + let entries = SettingsTemplateEntry.load( + storage: storage, projectPaths: [root.path], graphs: []) + + #expect(entries.count == 1) + #expect(entries.first?.template.id == template.id) + #expect(entries.first?.template.origin == .home) + } + } + + @Test + func repeatedAndSymlinkedProjectsLoadEachFileOnce() throws { + try withStorage { root, storage in + let project = root.appendingPathComponent("repo", isDirectory: true) + let alias = root.appendingPathComponent("alias", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: project) + let template = PromptTemplate(name: "Project", body: "A project brief.") + try storage.save(template, to: .project(project.path), projectPath: project.path) + + let entries = SettingsTemplateEntry.load( + storage: storage, projectPaths: [project.path, project.path, alias.path], graphs: []) + let aliased = SettingsTemplateEntry.load( + storage: storage, projectPaths: [alias.path], graphs: []) + + #expect(entries.count == 1) + #expect(entries.map(\.id) == aliased.map(\.id)) + } + } + + @Test + func symlinkedHomeProjectRetainsThePersonalOrigin() throws { + try withStorage { root, storage in + let alias = root.appendingPathComponent("home-alias", isDirectory: true) + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: root) + try storage.save( + PromptTemplate(name: "Home", body: "A personal brief."), to: .home, projectPath: nil) + + let entries = SettingsTemplateEntry.load( + storage: storage, projectPaths: [alias.path], graphs: []) + + #expect(entries.count == 1) + #expect(entries.first?.template.origin == .home) + } + } + + @Test + func separateFilesWithTheSameUUIDRemainIndividuallyAddressable() throws { + try withStorage { _, storage in + let first = PromptTemplate(name: "First", body: "The first brief.") + let second = PromptTemplate(id: first.id, name: "Second", body: "The second brief.") + try storage.save(first, to: .home, projectPath: nil) + try storage.save(second, to: .home, projectPath: nil) + + let entries = SettingsTemplateEntry.load(storage: storage, projectPaths: [], graphs: []) + + #expect(entries.map(\.template.fileName) == ["first.md", "second.md"]) + #expect(entries.map(\.template.id) == [first.id, first.id]) + #expect(Set(entries.map(\.id)).count == 2) + let selected = try #require(entries.last) + var edited = selected.template + edited.body = "Changed only the second file." + try storage.update(edited, replacing: selected.template) + #expect(storage.load(projectPath: nil).map(\.body) == [first.body, edited.body]) + try storage.delete(selected.template) + #expect(storage.load(projectPath: nil).map(\.fileName) == ["first.md"]) + } + } + + @Test + func sharedUUIDsAcrossProjectsAndHomeKeepEveryFileAndUsage() throws { + try withStorage { root, storage in + let first = root.appendingPathComponent("first", isDirectory: true) + let second = root.appendingPathComponent("second", isDirectory: true) + let template = PromptTemplate(name: "Shared", body: "A shared brief.") + try storage.save(template, to: .home, projectPath: nil) + for project in [first, second] { + try storage.save(template, to: .project(project.path), projectPath: project.path) + } + let graph = LoopGraph( + project: ProjectRef(path: first.path, name: "First"), + nodes: IdentifiedArray(uniqueElements: [ + LoopNode( + title: "Follower", loopType: .timeBased, + templateFollow: TemplateFollow(id: template.id, name: template.name)), + LoopNode(title: "Snapshot", createdFromTemplateID: template.id), + ])) + + let entries = SettingsTemplateEntry.load( + storage: storage, projectPaths: [first.path, second.path], graphs: [graph]) + + #expect( + entries.map(\.template.origin) == [.project(first.path), .project(second.path), .home]) + #expect(entries.map(\.template.id) == [template.id, template.id, template.id]) + #expect(Set(entries.map(\.id)).count == 3) + #expect(entries.allSatisfy { $0.usage == TemplateUsage(following: 1, snapshots: 1) }) + } + } + + @Test + func fileSymlinksDoNotAddAnotherRow() throws { + try withStorage { _, storage in + try storage.save( + PromptTemplate(name: "Original", body: "One file."), to: .home, projectPath: nil) + let original = storage.homeDirectory.appendingPathComponent("original.md") + try FileManager.default.createSymbolicLink( + at: storage.homeDirectory.appendingPathComponent("linked.md"), + withDestinationURL: original) + + let entries = SettingsTemplateEntry.load(storage: storage, projectPaths: [], graphs: []) + + #expect(entries.count == 1) + #expect(entries.first?.id == original.standardizedFileURL.resolvingSymlinksInPath()) + } + } + + @Test + func missingTemplateDirectoriesAreAnEmptyLibrary() throws { + try withStorage { root, storage in + #expect( + SettingsTemplateEntry.load( + storage: storage, projectPaths: [root.appendingPathComponent("missing").path], graphs: [] + ) + .isEmpty) + } + } +}