Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions graphcode/Sources/Features/Settings/SettingsTemplateEntry.swift
Original file line number Diff line number Diff line change
@@ -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<URL> = [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<URL>()
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))
}
}
}
58 changes: 25 additions & 33 deletions graphcode/Sources/Features/Settings/TemplatesSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,22 @@ 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…."
)
.font(.caption2)
.foregroundStyle(.secondary)
} else {
ForEach(templates) { template in
row(template)
ForEach(entries) { entry in
row(entry)
}
}
} header: {
Expand All @@ -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
Expand All @@ -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()
}
Expand All @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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")
Expand All @@ -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: " — ")
}
Expand Down Expand Up @@ -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<TemplateOrigin>()
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)
}
}

Expand Down
151 changes: 151 additions & 0 deletions graphcode/Tests/SettingsTemplateEntryTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading