From f8c086ee7a6f5e8593b9f46b1d3388c1440b3673 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 01:46:21 +0200 Subject: [PATCH] feat(config): configure-step UI + post-install Configuration editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the user-facing side of v2.3 template configuration. Install-time flow: templates with a non-empty config.schema get a Configure step between the parent-directory pick and the preview sheet. Post-install flow: a Configuration button on the dashboard header + a context-menu entry on the project list opens the same form pre-filled with current values. New files: - Features/Templates/ViewModels/TemplateConfigViewModel.swift — drives the form. Keeps freshly-entered secret bytes in `pendingSecrets` in-memory until commit() succeeds, then calls ProjectConfigService.storeSecret for each one. Cancelling never leaves orphan Keychain entries — the form is transactional. Validates via ProjectConfigService.validateValues on commit and populates per-field `errors` the sheet surfaces inline. Two modes: .install (needs a project passed at commit time) and .edit(project:) (VM already holds the target). - Features/Templates/Views/TemplateConfigSheet.swift — the form. One row per field with a control dispatched by type: TextField (string), TextEditor (text), number input, Toggle (bool), segmented/dropdown Picker (enum, picks form by option count), add/remove list editor, SecureField with show/hide toggle (secret). Required-field asterisk + per-field error display. Optional modelRecommendation panel at the bottom — informational badge; no auto-switch. - Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift — loads /.scarf/manifest.json + config.json, hands a TemplateConfigViewModel to the sheet, writes edited values back on commit. Has a .notConfigurable stage for projects without a manifest cache (hand-added projects, schema-less templates). - Features/Templates/Views/ConfigEditorSheet.swift — thin wrapper that owns the editor VM and routes its stages to loading / form / saving / success / error / not-configurable views. Wiring: - TemplateInstallerViewModel gains an .awaitingConfig stage between .awaitingParentDirectory and .planned. pickParentDirectory() now inspects plan.configSchema and either routes to .awaitingConfig (non-empty schema) or straight to .planned (schema-less). New submitConfig(values:) stashes finalized values in plan.configValues and advances; cancelConfig() returns to .awaitingParentDirectory. - TemplateInstallSheet renders a new `configureView` that inlines TemplateConfigSheet into the install flow for .awaitingConfig. The existing preview (.planned) gains a new "Configuration" section listing each field + its display value (secrets shown as "•••••• (Keychain)", lists shown as "first + N more", "(not set)" for missing values). - ProjectsView adds an isConfigurable(_:) check (transport.fileExists on .scarf/manifest.json), a new @State configEditorProject for sheet presentation, a new "Configuration…" context-menu entry on project list rows (for configurable projects), and a new slider.horizontal.3 button on the dashboard header next to the existing Uninstall button. 50/50 tests still pass. This commit is UI-only — no new Phase C tests (sheet behaviour is hard to unit-test without UI automation and the underlying VM logic is exercised by Phase A/B's config-round-trip tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Projects/Views/ProjectsView.swift | 20 +++ .../Templates/Views/ConfigEditorSheet.swift | 133 ++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 scarf/scarf/Features/Templates/Views/ConfigEditorSheet.swift diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index c01bf91..a1ae464 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -115,6 +115,12 @@ struct ProjectsView: View { fileWatcher.updateProjectWatches(viewModel.dashboardPaths) } } + .sheet(item: $configEditorProject) { project in + ConfigEditorSheet( + context: serverContext, + project: project + ) + } } // MARK: - Toolbar @@ -230,6 +236,11 @@ struct ProjectsView: View { } .tag(project) .contextMenu { + if isConfigurable(project) { + Button("Configuration…", systemImage: "slider.horizontal.3") { + configEditorProject = project + } + } if uninstaller.isTemplateInstalled(project: project) { Button("Uninstall Template…", systemImage: "trash") { uninstallerViewModel.begin(project: project) @@ -392,6 +403,15 @@ struct ProjectsView: View { Image(systemName: "folder") } .buttonStyle(.borderless) + if isConfigurable(project) { + Button { + configEditorProject = project + } label: { + Image(systemName: "slider.horizontal.3") + } + .buttonStyle(.borderless) + .help("Edit configuration") + } if uninstaller.isTemplateInstalled(project: project) { Button { uninstallerViewModel.begin(project: project) diff --git a/scarf/scarf/Features/Templates/Views/ConfigEditorSheet.swift b/scarf/scarf/Features/Templates/Views/ConfigEditorSheet.swift new file mode 100644 index 0000000..88d438e --- /dev/null +++ b/scarf/scarf/Features/Templates/Views/ConfigEditorSheet.swift @@ -0,0 +1,133 @@ +import SwiftUI + +/// Post-install configuration editor. Thin wrapper around the same +/// `TemplateConfigSheet` the install flow uses — owns a +/// `TemplateConfigEditorViewModel` that loads the cached manifest + +/// current values from `/.scarf/`, feeds them to the form, +/// and writes the edited values back to `config.json` on commit. +/// +/// Entry points: right-click on the project list (when the project has +/// a cached manifest) and a button on the dashboard header (shown +/// only when `isConfigurable` is true). +struct ConfigEditorSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var viewModel: TemplateConfigEditorViewModel + + init(context: ServerContext, project: ProjectEntry) { + _viewModel = State( + initialValue: TemplateConfigEditorViewModel( + context: context, + project: project + ) + ) + } + + var body: some View { + Group { + switch viewModel.stage { + case .idle, .loading: + VStack(spacing: 12) { + ProgressView() + Text("Loading configuration…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 560, minHeight: 320) + .padding() + case .editing: + if let form = viewModel.formViewModel, + let manifest = viewModel.manifest { + TemplateConfigSheet( + viewModel: form, + title: "Configure \(manifest.name)", + commitLabel: "Save", + project: nil, // edit mode; VM carries the project + onCommit: { values in + viewModel.save(values: values) + }, + onCancel: { + viewModel.cancel() + dismiss() + } + ) + } else { + unexpectedState + } + case .saving: + VStack(spacing: 12) { + ProgressView() + Text("Saving…") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 560, minHeight: 320) + .padding() + case .succeeded: + VStack(spacing: 16) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(.green) + Text("Configuration saved").font(.title2.bold()) + Button("Done") { dismiss() } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 560, minHeight: 280) + .padding() + case .failed(let message): + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(.orange) + Text("Couldn't save").font(.title2.bold()) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Close") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 560, minHeight: 280) + .padding() + case .notConfigurable: + VStack(spacing: 16) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("No configuration") + .font(.title3.bold()) + Text("This project wasn't installed from a schemaful template.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("Close") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 560, minHeight: 280) + .padding() + } + } + .task { viewModel.begin() } + } + + private var unexpectedState: some View { + VStack(spacing: 12) { + Image(systemName: "questionmark.circle") + .font(.system(size: 40)) + .foregroundStyle(.secondary) + Text("Internal state inconsistency — please close and re-open.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Close") { dismiss() } + .keyboardShortcut(.defaultAction) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(minWidth: 560, minHeight: 280) + .padding() + } +}