mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
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).
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// Drives the post-install "Configuration" button on the project
|
||||
/// dashboard. Loads `<project>/.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 `<project>/.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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<project>/.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<String> {
|
||||
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<Double> {
|
||||
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<Bool> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user