mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user