mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
5be67282d8
Closes the deferred Layer B install-drive that v2.7's smoke test
left as future work. The new test
(`testFullCatalogToInstallToDashboardJourney`) drives the full
install/uninstall pipeline end-to-end and validates 9 assertion
points along the way:
- Window surfaces under `--scarf-test-mode`
- Sidebar navigation to Projects
- Install sheet appears (URL handoff via launch arg)
- Parent-dir field accepts custom path + Continue
- Configure sheet renders + commit clicks
- Confirm Install runs the install pipeline
- Open Project advances to success view
- Project row appears in sidebar with uniquified name
- Right-click Uninstall + confirm Remove + Done removes the row
Runs in ~30s green on the dev Mac.
## What needed wiring up
**SwiftUI Menu / NSToolbarItem accessibility-bridging.** macOS
toolbar Menus don't propagate `.accessibilityIdentifier` through to
XCUITest — neither the menu trigger NOR the popup contents are
queryable by ID. Verified by tree-dump diagnostics. The test
sidesteps this entirely by routing the install URL through a new
`--scarf-test-install-url <https-url>` launch arg that calls
`TemplateURLRouter.shared.handle(scarf://install?url=...)` at App
init, gated on `TestModeFlags.shared.isTestMode`. Production
launches (no flag) untouched.
**Accessibility IDs added** on the new install/uninstall path:
- `templateConfig.commitButton`, `templateConfig.cancelButton`
- `projects.row.<name>`, `sidebar.section.<rawValue>`
- `projects.contextMenu.uninstallTemplate`
- `templateUninstall.confirmRemove`
- `templateInstall.success.openProject`
- `templateUninstall.success.done`
**Sandboxed-runner caveat.** The XCUITest runner's `/tmp` is
sandbox-protected (createDirectory throws EPERM); we use
`NSTemporaryDirectory()` which resolves to the runner's container
tmp (`~/Library/Containers/com.scarfUITests.xctrunner/Data/tmp/`),
which the unsandboxed Scarf app can read since it has full disk
access.
## Known cohabitation hazard (pre-existing uninstaller bug)
If the dev Mac already has a project from the same template
installed, the install pipeline uniquifies the new project's name
("HackerNews Daily Digest 2") but BOTH projects' cron jobs get
registered under the same `[tmpl:awizemann/hackernews-digest] Daily
HN digest` name. `ProjectTemplateUninstaller.loadUninstallPlan`
resolves cron jobs to remove by NAME and can target the wrong
project's job. The Layer B test surfaces this — manifests as: test
passes, the dev's real project's cron job disappears.
**Fix (separate work):** store cron-job IDs in
`<project>/.scarf/template.lock.json` at install time and resolve
by ID at uninstall time. Until then, the test docstring warns
about cohabitation; recovery is `hermes cron create` to recreate
the lost job.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
427 lines
16 KiB
Swift
427 lines
16 KiB
Swift
import ScarfCore
|
|
import ScarfDesign
|
|
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 {
|
|
// `.frame(maxWidth: .infinity, alignment: .leading)` is
|
|
// load-bearing: without it, SwiftUI resolves width
|
|
// bottom-up and an unbreakable token in a child (e.g. a
|
|
// raw URL inside a field description rendered via
|
|
// AttributedString markdown) sets the whole VStack's
|
|
// ideal width to that token's length. ScrollView's
|
|
// content then exceeds the sheet's viewport, the outer
|
|
// `.frame(minWidth: 560)` grows to content width, and
|
|
// the window clips the result with labels cut off on
|
|
// the left + URL spilling off the right. With the
|
|
// explicit maxWidth, the ScrollView's offered width
|
|
// propagates down and the description Text's
|
|
// `.fixedSize(horizontal: false, vertical: true)`
|
|
// wraps at whitespace boundaries as intended.
|
|
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)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.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") {
|
|
// Caller owns dismissal — this view is used both as a
|
|
// standalone sheet (ConfigEditorSheet, where the caller
|
|
// wants dismissal) AND inlined inside the install sheet
|
|
// (TemplateInstallSheet.configureView, where calling
|
|
// .dismiss here would tear down the OUTER install sheet
|
|
// and abort the flow before .planned is reached).
|
|
onCancel()
|
|
}
|
|
.keyboardShortcut(.cancelAction)
|
|
.accessibilityIdentifier("templateConfig.cancelButton")
|
|
Spacer()
|
|
Button(commitLabel) {
|
|
if let finalized = viewModel.commit(project: project) {
|
|
onCommit(finalized)
|
|
}
|
|
// Same dismissal-is-caller's-responsibility rule as
|
|
// Cancel — inside the install sheet, onCommit transitions
|
|
// stage to .planned and the outer view re-renders to
|
|
// show the preview. In the edit sheet, onCommit
|
|
// transitions the editor VM and its state machine
|
|
// handles dismissal via the success view's Done button.
|
|
}
|
|
.keyboardShortcut(.defaultAction)
|
|
.buttonStyle(ScarfPrimaryButton())
|
|
.accessibilityIdentifier("templateConfig.commitButton")
|
|
}
|
|
.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).scarfStyle(.headline)
|
|
if field.required {
|
|
Text("*")
|
|
.scarfStyle(.headline)
|
|
.foregroundStyle(.red)
|
|
}
|
|
Spacer()
|
|
Text(field.type.rawValue)
|
|
.font(.caption2.monospaced())
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let description = field.description, !description.isEmpty {
|
|
// Inline markdown so descriptions can include
|
|
// `[Create one](https://…)`-style links to token
|
|
// generation pages, **bold** emphasis on important
|
|
// prerequisites, etc. Raw URLs (not wrapped in
|
|
// markdown link syntax) will still render but can't
|
|
// word-break mid-token — keep the parent maxWidth
|
|
// constraint below so a rogue raw URL wraps cleanly
|
|
// instead of expanding the entire sheet.
|
|
TemplateMarkdown.inlineText(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)
|
|
}
|
|
}
|
|
// maxWidth: .infinity forces this row to span the column's
|
|
// full width so its internal description Text wraps instead
|
|
// of expanding the outer VStack when a description contains
|
|
// a long unbreakable token (raw URL, path, etc.). See the
|
|
// comment on the parent ScrollView's inner VStack.
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.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 {
|
|
// Always use the default Menu picker (dropdown). An earlier
|
|
// version switched to `.pickerStyle(.segmented)` when
|
|
// `options.count ≤ 4` for a more compact look, but on macOS
|
|
// segmented pickers size to the intrinsic width of all their
|
|
// labels concatenated — they refuse offered width constraints
|
|
// and refuse to wrap. A schema with three long labels like
|
|
// "Claude Opus 4 (Recommended - Most Capable)" produced a
|
|
// ~650pt picker that overflowed the 560pt sheet viewport,
|
|
// clipping the entire form. Menu pickers respect the fieldRow's
|
|
// offered width and show long labels in the popup list, so the
|
|
// sheet can't overflow regardless of label length.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|