feat(config): configure-step UI + post-install Configuration editor

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 <project>/.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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 01:46:21 +02:00
parent eb34aec1f1
commit f8c086ee7a
2 changed files with 153 additions and 0 deletions
@@ -115,6 +115,12 @@ struct ProjectsView: View {
fileWatcher.updateProjectWatches(viewModel.dashboardPaths) fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
} }
} }
.sheet(item: $configEditorProject) { project in
ConfigEditorSheet(
context: serverContext,
project: project
)
}
} }
// MARK: - Toolbar // MARK: - Toolbar
@@ -230,6 +236,11 @@ struct ProjectsView: View {
} }
.tag(project) .tag(project)
.contextMenu { .contextMenu {
if isConfigurable(project) {
Button("Configuration…", systemImage: "slider.horizontal.3") {
configEditorProject = project
}
}
if uninstaller.isTemplateInstalled(project: project) { if uninstaller.isTemplateInstalled(project: project) {
Button("Uninstall Template…", systemImage: "trash") { Button("Uninstall Template…", systemImage: "trash") {
uninstallerViewModel.begin(project: project) uninstallerViewModel.begin(project: project)
@@ -392,6 +403,15 @@ struct ProjectsView: View {
Image(systemName: "folder") Image(systemName: "folder")
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
if isConfigurable(project) {
Button {
configEditorProject = project
} label: {
Image(systemName: "slider.horizontal.3")
}
.buttonStyle(.borderless)
.help("Edit configuration")
}
if uninstaller.isTemplateInstalled(project: project) { if uninstaller.isTemplateInstalled(project: project) {
Button { Button {
uninstallerViewModel.begin(project: project) uninstallerViewModel.begin(project: project)
@@ -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 `<project>/.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()
}
}