mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
3bd95de8f4
Two bugs chained into the observed "install completed but project didn't show up" report. Either one would have been enough on its own; both are here so both are fixed. Primary bug: TemplateConfigSheet's Cancel + Continue buttons each called `@Environment(\.dismiss)` after their state-update callbacks. That was fine when the sheet is presented standalone (the post-install Configuration button uses it this way and wants dismissal), but Phase C also INLINED the same view inside TemplateInstallSheet.configureView for the install flow's .awaitingConfig stage — there's no intermediate .sheet() presenter there, so `dismiss()` resolved to the OUTER install sheet. Clicking Continue → configure form's `onCommit` fired `installerViewModel.submitConfig(values:)` which advanced stage to .planned, then the dismiss() closed the whole install sheet before the preview ever rendered. install() was never called. Fix: remove both dismiss() calls from TemplateConfigSheet. Dismissal is now the caller's responsibility. ConfigEditorSheet (standalone mode) already calls `dismiss()` inside its own onCancel closure and lets the .succeeded state's Done button handle commit-dismissal, so nothing breaks there. The install flow's state machine advances to the preview stage where the existing Install/Cancel buttons drive everything from there. Secondary bug (latent, same class): ProjectDashboardService.saveRegistry swallowed both directory-creation and file-write errors with `try?`. If the `~/.hermes/scarf/` dir creation or projects.json write ever failed for any reason (permissions, readonly filesystem, sandbox), the installer's registerProject returned a valid-looking ProjectEntry while the registry on disk never received the row. Same symptom surface as the primary bug: install "succeeds," project invisible. Fix: saveRegistry now throws. Updated all four callers: - ProjectTemplateInstaller.registerProject: `try` — a registry write failure aborts install with a user-visible failure screen. This is the critical path; silent success on a destructive op is the exact failure mode we want to eliminate. - ProjectTemplateUninstaller: `do/catch` + logger.warning — we're at the final step of uninstall after every other side effect has already completed (files removed, skills removed, cron removed, memory stripped, Keychain cleared). Leaving a stale registry row pointing at a deleted project is cosmetic and easy to fix from the sidebar minus button. - ProjectsViewModel.addProject + removeProject: `do/catch` + logger.error. The VM doesn't currently have a surface for user-visible errors (no toast/alert on this view), but the failure now at least lands in the unified log instead of disappearing. Proper in-UI error surface is tracked as follow-up. - ProjectDashboardService.loadRegistry: switched its stale `print` to `logger.error` while I was in the file. Tests: added TemplateInstallerViewModelTests suite (3 tests) covering the install VM's configure-step state transitions: - submitConfigStashesValuesAndTransitionsToPlanned — .awaitingConfig → .planned + configValues stash on the plan. The exact transition that the dismiss() bug tore down mid-flight. - cancelConfigReturnsToAwaitingParentDirectory — back-button behaviour with plan preserved so re-entry doesn't re-run buildPlan. - submitConfigNoOpWhenPlanIsNil — defensive guard. These won't catch a view-level regression (Swift Testing doesn't do UI tests in this project), but they lock in the VM state-machine contract so the next refactor can't silently break submitConfig or cancelConfig without failing CI. 53/53 Swift tests + 24/24 Python tests + catalog validator clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
2.9 KiB
Swift
78 lines
2.9 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
struct ProjectDashboardService: Sendable {
|
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
|
|
|
let context: ServerContext
|
|
let transport: any ServerTransport
|
|
|
|
nonisolated init(context: ServerContext = .local) {
|
|
self.context = context
|
|
self.transport = context.makeTransport()
|
|
}
|
|
|
|
// MARK: - Registry
|
|
|
|
func loadRegistry() -> ProjectRegistry {
|
|
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
|
return ProjectRegistry(projects: [])
|
|
}
|
|
do {
|
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
|
} catch {
|
|
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
|
return ProjectRegistry(projects: [])
|
|
}
|
|
}
|
|
|
|
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
|
///
|
|
/// **Throws** on every non-success path — the previous version of
|
|
/// this method silently swallowed `createDirectory` and `writeFile`
|
|
/// failures with `try?`, which meant the installer could return a
|
|
/// valid-looking `ProjectEntry` while the registry on disk never
|
|
/// received the new row (project would complete install, show a
|
|
/// success screen, then be invisible in the sidebar). Callers that
|
|
/// want fire-and-forget behaviour can still use `try?`, but the
|
|
/// choice is now theirs.
|
|
func saveRegistry(_ registry: ProjectRegistry) throws {
|
|
let dir = context.paths.scarfDir
|
|
if !transport.fileExists(dir) {
|
|
try transport.createDirectory(dir)
|
|
}
|
|
let data = try JSONEncoder().encode(registry)
|
|
// Pretty-print for readability (agents may read this file).
|
|
let writeData: Data
|
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
|
writeData = formatted
|
|
} else {
|
|
writeData = data
|
|
}
|
|
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
|
}
|
|
|
|
// MARK: - Dashboard
|
|
|
|
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
|
guard let data = try? transport.readFile(project.dashboardPath) else {
|
|
return nil
|
|
}
|
|
do {
|
|
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
|
} catch {
|
|
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func dashboardExists(for project: ProjectEntry) -> Bool {
|
|
transport.fileExists(project.dashboardPath)
|
|
}
|
|
|
|
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
|
transport.stat(project.dashboardPath)?.mtime
|
|
}
|
|
}
|