mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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 showingInstallURLPrompt = false
|
||||||
@State private var installURLInput = ""
|
@State private var installURLInput = ""
|
||||||
@State private var showingUninstallSheet = false
|
@State private var showingUninstallSheet = false
|
||||||
|
@State private var configEditorProject: ProjectEntry?
|
||||||
|
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -36,6 +37,14 @@ struct ProjectsView: View {
|
|||||||
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
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
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
var body: some View {
|
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 fetching(sourceDescription: String)
|
||||||
case inspecting
|
case inspecting
|
||||||
case awaitingParentDirectory
|
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 planned
|
||||||
case installing
|
case installing
|
||||||
case succeeded(installed: ProjectEntry)
|
case succeeded(installed: ProjectEntry)
|
||||||
@@ -139,14 +143,20 @@ final class TemplateInstallerViewModel {
|
|||||||
guard let inspection else { return }
|
guard let inspection else { return }
|
||||||
chosenParentDirectory = parentDir
|
chosenParentDirectory = parentDir
|
||||||
let service = templateService
|
let service = templateService
|
||||||
let context = context
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
_ = context
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
self?.plan = plan
|
guard let self else { return }
|
||||||
self?.stage = .planned
|
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 {
|
} catch {
|
||||||
await MainActor.run { [weak self] in
|
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() {
|
func confirmInstall() {
|
||||||
guard let plan else { return }
|
guard let plan else { return }
|
||||||
stage = .installing
|
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…")
|
progress("Inspecting template…")
|
||||||
case .awaitingParentDirectory:
|
case .awaitingParentDirectory:
|
||||||
pickParentView
|
pickParentView
|
||||||
|
case .awaitingConfig:
|
||||||
|
configureView
|
||||||
case .planned:
|
case .planned:
|
||||||
if let plan = viewModel.plan {
|
if let plan = viewModel.plan {
|
||||||
plannedView(plan: 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 {
|
private func plannedView(plan: TemplateInstallPlan) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
manifestHeader(plan.manifest)
|
manifestHeader(plan.manifest)
|
||||||
@@ -102,6 +137,9 @@ struct TemplateInstallSheet: View {
|
|||||||
if plan.memoryAppendix != nil {
|
if plan.memoryAppendix != nil {
|
||||||
memorySection(plan: plan)
|
memorySection(plan: plan)
|
||||||
}
|
}
|
||||||
|
if let schema = plan.configSchema, !schema.isEmpty {
|
||||||
|
configurationSection(plan: plan, schema: schema)
|
||||||
|
}
|
||||||
readmeSection
|
readmeSection
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
.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 {
|
private var readmeSection: some View {
|
||||||
Group {
|
Group {
|
||||||
// The body is preloaded in the VM off MainActor when inspection
|
// The body is preloaded in the VM off MainActor when inspection
|
||||||
|
|||||||
Reference in New Issue
Block a user