From eb34aec1f17de1504b54ea5c3d7c4fa7654ce52c Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Thu, 23 Apr 2026 17:14:22 +0200 Subject: [PATCH] feat(config): template-config UI forms (configure sheet + editor) Introduces the TemplateConfigSheet form and its view models, plus the install-flow integration points: a new .awaitingConfig stage in TemplateInstallerViewModel, the configureView step in the install sheet, and the dashboard-header Configuration button wiring in ProjectsView. This is the schemaful-template v2.3 UI that every subsequent config commit builds on. Originally landed alongside scaffolding for an iOS target in b289a83; this is the split that keeps the template-config work and drops the iOS scaffolding (the real iOS port is on scarf-mobile-development). --- .../Projects/Views/ProjectsView.swift | 9 + .../TemplateConfigEditorViewModel.swift | 118 ++++++ .../ViewModels/TemplateConfigViewModel.swift | 198 +++++++++ .../TemplateInstallerViewModel.swift | 38 +- .../Templates/Views/TemplateConfigSheet.swift | 384 ++++++++++++++++++ .../Views/TemplateInstallSheet.swift | 82 ++++ 6 files changed, 825 insertions(+), 4 deletions(-) create mode 100644 scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift create mode 100644 scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift create mode 100644 scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index 5ba71c4..c01bf91 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -26,6 +26,7 @@ struct ProjectsView: View { @State private var showingInstallURLPrompt = false @State private var installURLInput = "" @State private var showingUninstallSheet = false + @State private var configEditorProject: ProjectEntry? private let uninstaller: ProjectTemplateUninstaller @@ -36,6 +37,14 @@ struct ProjectsView: View { self.uninstaller = ProjectTemplateUninstaller(context: context) } + /// True when the given project has a cached manifest (i.e. was + /// installed from a schemaful template). Cheap — just a file + /// existence check via the transport. + private func isConfigurable(_ project: ProjectEntry) -> Bool { + let path = ProjectConfigService.manifestCachePath(for: project) + return serverContext.makeTransport().fileExists(path) + } + @State private var selectedTab: DashboardTab = .dashboard var body: some View { diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift new file mode 100644 index 0000000..cec6bbf --- /dev/null +++ b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift @@ -0,0 +1,118 @@ +import Foundation +import Observation +import os + +/// Drives the post-install "Configuration" button on the project +/// dashboard. Loads `/.scarf/manifest.json` + `config.json`, +/// hands a `TemplateConfigViewModel` seeded with current values to the +/// sheet, then writes the edited values back on commit. +/// +/// Smaller surface than `TemplateInstallerViewModel` — no unzipping, +/// no parent-dir picking, no cron CLI. Just: read → edit → save. +@Observable +@MainActor +final class TemplateConfigEditorViewModel { + private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel") + + enum Stage: Sendable { + case idle + case loading + /// Manifest + config loaded; the sheet is displaying the form. + case editing + case saving + case succeeded + case failed(String) + /// Project wasn't installed from a schemaful template — no + /// manifest cache on disk. The dashboard button is hidden in + /// this case so we shouldn't hit this stage normally. + case notConfigurable + } + + let context: ServerContext + let project: ProjectEntry + private let configService: ProjectConfigService + + init(context: ServerContext, project: ProjectEntry) { + self.context = context + self.project = project + self.configService = ProjectConfigService(context: context) + } + + var stage: Stage = .idle + var manifest: ProjectTemplateManifest? + var currentValues: [String: TemplateConfigValue] = [:] + + /// Non-nil while `.editing`; used to construct the sheet's VM. + var formViewModel: TemplateConfigViewModel? + + /// Load the cached manifest + current config values, then move to + /// `.editing` so the sheet can render the form. + func begin() { + stage = .loading + let service = configService + let project = project + Task.detached { [weak self] in + do { + guard let cachedManifest = try service.loadCachedManifest(project: project), + let schema = cachedManifest.config, + !schema.isEmpty else { + await MainActor.run { [weak self] in + self?.stage = .notConfigurable + } + return + } + let configFile = try service.load(project: project) + await MainActor.run { [weak self] in + guard let self else { return } + self.manifest = cachedManifest + self.currentValues = configFile?.values ?? [:] + self.formViewModel = TemplateConfigViewModel( + schema: schema, + templateId: cachedManifest.id, + templateSlug: cachedManifest.slug, + initialValues: self.currentValues, + mode: .edit(project: project) + ) + self.stage = .editing + } + } catch { + Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + await MainActor.run { [weak self] in + self?.stage = .failed(error.localizedDescription) + } + } + } + } + + /// Called when the sheet's commit succeeded. Persists the edited + /// values to `/.scarf/config.json`. Secrets are already + /// in the Keychain — the VM's commit step wrote them. + func save(values: [String: TemplateConfigValue]) { + guard let manifest else { return } + stage = .saving + let service = configService + let project = project + Task.detached { [weak self] in + do { + try service.save( + project: project, + templateId: manifest.id, + values: values + ) + await MainActor.run { [weak self] in + self?.stage = .succeeded + } + } catch { + Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)") + await MainActor.run { [weak self] in + self?.stage = .failed(error.localizedDescription) + } + } + } + } + + func cancel() { + stage = .idle + formViewModel = nil + } +} diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift new file mode 100644 index 0000000..9a26bc9 --- /dev/null +++ b/scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift @@ -0,0 +1,198 @@ +import Foundation +import Observation +import os + +/// Drives the configure form for template install + post-install editing. +/// +/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes +/// in-memory (`pendingSecrets`) until the user clicks the commit button. +/// Only then does `commit()` push each secret through +/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI. +/// This means cancelling the sheet never leaves an orphan Keychain +/// entry behind — the form is transactional from the user's POV. +/// +/// **Validation.** Runs via `ProjectConfigService.validateValues` every +/// time the user attempts to commit. Per-field errors are tracked in +/// `errors` so the sheet can surface them inline with the offending field. +/// No live validation on every keystroke — that creates a messy +/// "error appears the moment you start typing" UX. +@Observable +@MainActor +final class TemplateConfigViewModel { + private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel") + + enum Mode: Sendable { + /// User is filling in values for the first time as part of the + /// install flow. Secrets will be written to the Keychain when + /// `commit` succeeds. + case install + /// User is editing values for an already-installed project. + /// Existing keychain refs are preserved for fields the user + /// doesn't touch; only secrets the user actually changes get + /// re-written to the Keychain. + case edit(project: ProjectEntry) + } + + let schema: TemplateConfigSchema + let templateId: String + let templateSlug: String + let mode: Mode + private let configService: ProjectConfigService + + /// Current form values, keyed by field key. Non-secret values live + /// here directly; secret fields either hold a `.keychainRef(...)` + /// (existing, untouched in edit mode) or nothing at all (user + /// hasn't entered a secret yet, or they just cleared it). + var values: [String: TemplateConfigValue] = [:] + + /// Raw secret bytes waiting to be written to the Keychain on + /// `commit()`. Indexed by field key. `values[key]` stays as its + /// current `.keychainRef(...)` (for edit mode) or missing (for + /// install mode) until commit swaps it for the freshly-written + /// ref URI. + var pendingSecrets: [String: Data] = [:] + + /// One error per field with a problem. Populated by `commit()` on + /// validation failure; the sheet surfaces the message inline below + /// the offending control. + var errors: [String: String] = [:] + + init( + schema: TemplateConfigSchema, + templateId: String, + templateSlug: String, + initialValues: [String: TemplateConfigValue] = [:], + mode: Mode, + configService: ProjectConfigService = ProjectConfigService() + ) { + self.schema = schema + self.templateId = templateId + self.templateSlug = templateSlug + self.mode = mode + self.configService = configService + self.values = Self.applyDefaults(schema: schema, initial: initialValues) + } + + // MARK: - Field setters (the sheet calls these as controls change) + + func setString(_ key: String, _ value: String) { + values[key] = .string(value) + errors.removeValue(forKey: key) + } + + func setNumber(_ key: String, _ value: Double) { + values[key] = .number(value) + errors.removeValue(forKey: key) + } + + func setBool(_ key: String, _ value: Bool) { + values[key] = .bool(value) + errors.removeValue(forKey: key) + } + + func setList(_ key: String, _ items: [String]) { + values[key] = .list(items) + errors.removeValue(forKey: key) + } + + /// Stage a new secret value. Doesn't hit the Keychain until + /// `commit()`. An empty `value` clears both the pending secret and + /// the field's stored keychainRef — only valid in edit mode, where + /// "empty" means "I want to remove this secret." + func setSecret(_ key: String, _ value: String) { + if value.isEmpty { + pendingSecrets.removeValue(forKey: key) + values.removeValue(forKey: key) + } else { + pendingSecrets[key] = Data(value.utf8) + // Keep any existing ref around; the sheet can display + // "(changed)" while the ref is still the old one. commit() + // overwrites on disk. + } + errors.removeValue(forKey: key) + } + + // MARK: - Commit + + /// Validate, persist secrets to the Keychain, and hand back the + /// final values dictionary. On validation failure, `errors` is + /// populated and the method returns `nil` without touching the + /// Keychain — the form is transactional. + /// + /// In install mode, `project` is required (secrets need a path + /// hash for their Keychain account). In edit mode it falls out of + /// the `.edit(project:)` associated value. + func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? { + // Build the value set we're about to validate. For secrets + // that have a pending update, we treat them as present (we'll + // write them in a moment); for secrets already stored as + // keychainRef, we treat them as present too. Only a completely + // empty secret field is "missing." + var candidate = values + for key in pendingSecrets.keys { + // The field is about to have a fresh keychainRef — for + // validation purposes, use a placeholder ref so the type + // check passes. The real ref replaces it below. + candidate[key] = .keychainRef("pending://\(key)") + } + let validationErrors = ProjectConfigService.validateValues(candidate, against: schema) + guard validationErrors.isEmpty else { + var byField: [String: String] = [:] + for err in validationErrors { + guard let key = err.fieldKey else { continue } + byField[key] = err.message + } + self.errors = byField + return nil + } + + // Validation passed — write the pending secrets to the Keychain. + let targetProject: ProjectEntry + switch mode { + case .install: + guard let project else { + Self.logger.error("commit(project:) called in install mode without a project") + return nil + } + targetProject = project + case .edit(let proj): + targetProject = proj + } + + for (key, secret) in pendingSecrets { + do { + let ref = try configService.storeSecret( + templateSlug: templateSlug, + fieldKey: key, + project: targetProject, + secret: secret + ) + values[key] = ref + } catch { + Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)") + errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)" + return nil + } + } + pendingSecrets.removeAll() + errors.removeAll() + return values + } + + // MARK: - Helpers + + /// Seed the form with any author-supplied defaults for fields that + /// don't already have an initial value (from a saved config.json). + nonisolated private static func applyDefaults( + schema: TemplateConfigSchema, + initial: [String: TemplateConfigValue] + ) -> [String: TemplateConfigValue] { + var out = initial + for field in schema.fields where out[field.key] == nil { + if let def = field.defaultValue { + out[field.key] = def + } + } + return out + } +} diff --git a/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift b/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift index 6995e04..4b2e476 100644 --- a/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift +++ b/scarf/scarf/Features/Templates/ViewModels/TemplateInstallerViewModel.swift @@ -18,6 +18,10 @@ final class TemplateInstallerViewModel { case fetching(sourceDescription: String) case inspecting case awaitingParentDirectory + /// Template declared a non-empty config schema; the sheet + /// presents `TemplateConfigSheet` before continuing to the + /// preview. Schema-less templates skip this stage entirely. + case awaitingConfig case planned case installing case succeeded(installed: ProjectEntry) @@ -139,14 +143,20 @@ final class TemplateInstallerViewModel { guard let inspection else { return } chosenParentDirectory = parentDir let service = templateService - let context = context Task.detached { [weak self] in do { let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir) - _ = context await MainActor.run { [weak self] in - self?.plan = plan - self?.stage = .planned + guard let self else { return } + self.plan = plan + // If the template declares a non-empty config + // schema, insert the configure step before the + // preview sheet. Otherwise go straight to .planned. + if let schema = plan.configSchema, !schema.isEmpty { + self.stage = .awaitingConfig + } else { + self.stage = .planned + } } } catch { await MainActor.run { [weak self] in @@ -156,6 +166,26 @@ final class TemplateInstallerViewModel { } } + /// Called by `TemplateInstallSheet` once the user has filled in + /// the configure form and `TemplateConfigViewModel.commit()` + /// succeeded. Stashes the values in the plan and advances to the + /// preview stage (`.planned`). Secrets in `values` are already + /// `.keychainRef(...)` — the VM's commit step wrote them to the + /// Keychain. + func submitConfig(values: [String: TemplateConfigValue]) { + guard var plan else { return } + plan.configValues = values + self.plan = plan + stage = .planned + } + + /// Called when the user cancels out of the configure step without + /// committing. Returns to `.awaitingParentDirectory` so they can + /// try again (or dismiss the whole sheet). + func cancelConfig() { + stage = .awaitingParentDirectory + } + func confirmInstall() { guard let plan else { return } stage = .installing diff --git a/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift new file mode 100644 index 0000000..105b9d9 --- /dev/null +++ b/scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift @@ -0,0 +1,384 @@ +import SwiftUI + +/// The configure form rendered for template install + post-install +/// editing. One row per schema field; controls dispatch by field type. +/// Commit button returns the finalized values via `onCommit` — in +/// install mode the caller stashes them in the install plan; in edit +/// mode the caller writes them straight to `/.scarf/config.json`. +struct TemplateConfigSheet: View { + @Environment(\.dismiss) private var dismiss + + @State var viewModel: TemplateConfigViewModel + let title: LocalizedStringKey + let commitLabel: LocalizedStringKey + /// In install mode the caller passes the planned `ProjectEntry` + /// (project dir path is the unique key for the Keychain secret). + /// In edit mode the VM already holds the project; pass `nil` here. + let project: ProjectEntry? + let onCommit: ([String: TemplateConfigValue]) -> Void + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 0) { + header + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if viewModel.schema.fields.isEmpty { + ContentUnavailableView( + "No fields", + systemImage: "slider.horizontal.3", + description: Text("This template has no configuration fields.") + ) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + ForEach(viewModel.schema.fields) { field in + fieldRow(field) + } + } + if let rec = viewModel.schema.modelRecommendation { + modelRecommendation(rec) + } + } + .padding(20) + } + Divider() + footer + } + .frame(minWidth: 560, minHeight: 480) + } + + // MARK: - Header / footer + + @ViewBuilder + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(title).font(.title2.bold()) + Text(viewModel.templateId) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(16) + } + + @ViewBuilder + private var footer: some View { + HStack { + Button("Cancel") { + onCancel() + dismiss() + } + .keyboardShortcut(.cancelAction) + Spacer() + Button(commitLabel) { + if let finalized = viewModel.commit(project: project) { + onCommit(finalized) + dismiss() + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + .padding(16) + } + + // MARK: - Field rows + + @ViewBuilder + private func fieldRow(_ field: TemplateConfigField) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(field.label).font(.headline) + if field.required { + Text("*") + .font(.headline) + .foregroundStyle(.red) + } + Spacer() + Text(field.type.rawValue) + .font(.caption2.monospaced()) + .foregroundStyle(.secondary) + } + if let description = field.description, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + control(for: field) + if let err = viewModel.errors[field.key] { + Label(err, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(.background.secondary) + ) + } + + @ViewBuilder + private func control(for field: TemplateConfigField) -> some View { + switch field.type { + case .string: + StringControl( + value: stringBinding(for: field), + placeholder: field.placeholder + ) + case .text: + TextControl(value: stringBinding(for: field)) + case .number: + NumberControl(value: numberBinding(for: field)) + case .bool: + BoolControl(label: field.label, value: boolBinding(for: field)) + case .enum: + EnumControl( + options: field.options ?? [], + value: stringBinding(for: field) + ) + case .list: + ListControl(items: listBinding(for: field)) + case .secret: + SecretControl( + fieldKey: field.key, + placeholder: field.placeholder, + viewModel: viewModel + ) + } + } + + // MARK: - Model recommendation panel + + private func modelRecommendation(_ rec: TemplateModelRecommendation) -> some View { + VStack(alignment: .leading, spacing: 6) { + Label("Recommended model", systemImage: "lightbulb") + .font(.caption.bold()) + .foregroundStyle(.secondary) + Text(rec.preferred).font(.body.monospaced()) + if let rationale = rec.rationale, !rationale.isEmpty { + Text(rationale) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let alts = rec.alternatives, !alts.isEmpty { + Text("Also works: \(alts.joined(separator: ", "))") + .font(.caption2) + .foregroundStyle(.secondary) + } + Text("Scarf doesn't auto-switch your active model. Change it in Settings if you'd like.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.accentColor.opacity(0.08)) + ) + } + + // MARK: - Binding helpers (threading the VM through typed lenses) + + private func stringBinding(for field: TemplateConfigField) -> Binding { + Binding( + get: { + if case .string(let s) = viewModel.values[field.key] { return s } + return "" + }, + set: { viewModel.setString(field.key, $0) } + ) + } + + private func numberBinding(for field: TemplateConfigField) -> Binding { + Binding( + get: { + if case .number(let n) = viewModel.values[field.key] { return n } + return 0 + }, + set: { viewModel.setNumber(field.key, $0) } + ) + } + + private func boolBinding(for field: TemplateConfigField) -> Binding { + Binding( + get: { + if case .bool(let b) = viewModel.values[field.key] { return b } + return false + }, + set: { viewModel.setBool(field.key, $0) } + ) + } + + private func listBinding(for field: TemplateConfigField) -> Binding<[String]> { + Binding( + get: { + if case .list(let items) = viewModel.values[field.key] { return items } + return [] + }, + set: { viewModel.setList(field.key, $0) } + ) + } +} + +// MARK: - Field controls + +private struct StringControl: View { + @Binding var value: String + let placeholder: String? + var body: some View { + TextField(placeholder ?? "", text: $value) + .textFieldStyle(.roundedBorder) + } +} + +private struct TextControl: View { + @Binding var value: String + var body: some View { + TextEditor(text: $value) + .font(.body.monospaced()) + .frame(minHeight: 80, maxHeight: 160) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(.secondary.opacity(0.3)) + ) + } +} + +private struct NumberControl: View { + @Binding var value: Double + var body: some View { + TextField("", value: $value, format: .number) + .textFieldStyle(.roundedBorder) + } +} + +private struct BoolControl: View { + let label: String + @Binding var value: Bool + var body: some View { + Toggle(isOn: $value) { + Text(value ? "Enabled" : "Disabled") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + +private struct EnumControl: View { + let options: [TemplateConfigField.EnumOption] + @Binding var value: String + var body: some View { + // Segmented for ≤ 4 options, dropdown otherwise — fits Scarf's + // existing settings UI. + if options.count <= 4 { + Picker("", selection: $value) { + ForEach(options) { opt in + Text(opt.label).tag(opt.value) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } else { + Picker("", selection: $value) { + ForEach(options) { opt in + Text(opt.label).tag(opt.value) + } + } + .labelsHidden() + } + } +} + +/// Variable-length list of string values. Each row is a text field +/// with an inline remove button; a + button adds a trailing row. +private struct ListControl: View { + @Binding var items: [String] + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(items.indices, id: \.self) { i in + HStack(spacing: 6) { + TextField("", text: Binding( + get: { i < items.count ? items[i] : "" }, + set: { newValue in + guard i < items.count else { return } + items[i] = newValue + } + )) + .textFieldStyle(.roundedBorder) + Button { + guard i < items.count else { return } + items.remove(at: i) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .disabled(items.count <= 1) + } + } + Button { + items.append("") + } label: { + Label("Add", systemImage: "plus.circle") + .font(.caption) + } + .buttonStyle(.borderless) + } + } +} + +/// Secret fields never echo the previously-stored value back. Instead +/// we render "(unchanged)" when a Keychain ref already exists and let +/// the user type over it if they want to replace. Empty input in edit +/// mode signals "remove this secret entirely." +private struct SecretControl: View { + let fieldKey: String + let placeholder: String? + @Bindable var viewModel: TemplateConfigViewModel + + @State private var typedValue: String = "" + @State private var isRevealed: Bool = false + + private var hasStoredRef: Bool { + if case .keychainRef = viewModel.values[fieldKey] { return true } + return false + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Group { + if isRevealed { + TextField(placeholder ?? "", text: $typedValue) + } else { + SecureField(placeholder ?? "", text: $typedValue) + } + } + .textFieldStyle(.roundedBorder) + .onChange(of: typedValue) { _, new in + viewModel.setSecret(fieldKey, new) + } + Button { + isRevealed.toggle() + } label: { + Image(systemName: isRevealed ? "eye.slash" : "eye") + } + .buttonStyle(.borderless) + .help(isRevealed ? "Hide" : "Show while typing") + } + if hasStoredRef && typedValue.isEmpty { + Text("Saved in Keychain — leave empty to keep the stored value.") + .font(.caption2) + .foregroundStyle(.secondary) + } else if !typedValue.isEmpty { + Text("Will be saved to the Keychain on commit.") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } +} diff --git a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift index eae2d13..98f9a64 100644 --- a/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift +++ b/scarf/scarf/Features/Templates/Views/TemplateInstallSheet.swift @@ -21,6 +21,8 @@ struct TemplateInstallSheet: View { progress("Inspecting template…") case .awaitingParentDirectory: pickParentView + case .awaitingConfig: + configureView case .planned: if let plan = viewModel.plan { plannedView(plan: plan) @@ -85,6 +87,39 @@ struct TemplateInstallSheet: View { } } + /// Configure step for schemaful templates. Inlines + /// `TemplateConfigSheet` into the install flow rather than pushing + /// a second sheet on top — keeps the user in one window. The + /// nested VM is created freshly each time `.awaitingConfig` is + /// entered so a Cancel + retry doesn't carry stale form state. + @ViewBuilder + private var configureView: some View { + if let plan = viewModel.plan, + let schema = plan.configSchema, + let manifest = viewModel.inspection?.manifest { + TemplateConfigSheet( + viewModel: TemplateConfigViewModel( + schema: schema, + templateId: manifest.id, + templateSlug: manifest.slug, + initialValues: plan.configValues, + mode: .install + ), + title: "Configure \(manifest.name)", + commitLabel: "Continue", + project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir), + onCommit: { values in + viewModel.submitConfig(values: values) + }, + onCancel: { + viewModel.cancelConfig() + } + ) + } else { + progress("Preparing…") + } + } + private func plannedView(plan: TemplateInstallPlan) -> some View { VStack(alignment: .leading, spacing: 0) { manifestHeader(plan.manifest) @@ -102,6 +137,9 @@ struct TemplateInstallSheet: View { if plan.memoryAppendix != nil { memorySection(plan: plan) } + if let schema = plan.configSchema, !schema.isEmpty { + configurationSection(plan: plan, schema: schema) + } readmeSection } .padding(.vertical) @@ -213,6 +251,50 @@ struct TemplateInstallSheet: View { } } + /// Configuration values the user entered in the configure step. + /// Secrets display masked so the preview never echoes a freshly + /// typed API key back on screen. + private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View { + section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") { + VStack(alignment: .leading, spacing: 4) { + ForEach(schema.fields) { field in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(field.key) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .frame(minWidth: 120, alignment: .leading) + Text(displayValue(for: field, in: plan.configValues)) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + } + } + + /// One-line display form for a value in the preview. Secrets are + /// always masked; lists show a count + first entry; strings are + /// truncated by `.lineLimit(1)` at the view level. + private func displayValue( + for field: TemplateConfigField, + in values: [String: TemplateConfigValue] + ) -> String { + switch field.type { + case .secret: + return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)" + case .list: + if case .list(let items) = values[field.key] { + if items.isEmpty { return "(none)" } + if items.count == 1 { return items[0] } + return "\(items[0]) + \(items.count - 1) more" + } + return "(none)" + default: + return values[field.key]?.displayString ?? "(not set)" + } + } + private var readmeSection: some View { Group { // The body is preloaded in the VM off MainActor when inspection