Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 11 additions & 178 deletions graphcode/Sources/Features/Project/NodeDraftForm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,120 +113,6 @@ struct NodeDraftForm: View {
.buttonStyle(.plain)
.keyboardShortcut("t", modifiers: .command)
}

/// The applied template, stated — the chip, the plain-words shape sentence, the
/// tokens still to fill. The dialog must never gain a shape silently.
@ViewBuilder
private var appliedState: some View {
if let applied = store.templates.applied {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
HStack(spacing: 6) {
Text(applied.name)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(Color(red: 0.706, green: 0.843, blue: 1.0).opacity(0.95))
.lineLimit(1)
Button {
store.send(.templateChipRemoved)
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.white.opacity(0.7))
}
.buttonStyle(.plain)
.help("Remove the template — everything it contributed goes")
}
.padding(.horizontal, 9)
.frame(height: 24)
.background(
Theme.paneFocusTint.opacity(0.16), in: RoundedRectangle(cornerRadius: 6)
)
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(Theme.paneFocusTint.opacity(0.45), lineWidth: 1)
}
Spacer(minLength: 8)
}
if applied.carriesShape {
HStack(alignment: .top, spacing: 4) {
Text(shapeSentence(applied))
.font(.system(size: 11.5))
.foregroundStyle(.white.opacity(0.75))
.fixedSize(horizontal: false, vertical: true)
Button("Undo the shape") { store.send(.templateShapeUndone) }
.buttonStyle(.plain)
.font(.system(size: 11.5, weight: .semibold))
.foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9))
Text("to keep just the prompt.")
.font(.system(size: 11.5))
.foregroundStyle(.white.opacity(0.75))
}
}
if let prompt = store.unfilledTokenPrompt {
HStack(spacing: 5) {
Text(prompt)
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.55))
ForEach(store.unfilledTokens, id: \.self) { token in
Text("{\(token)}")
.font(.system(size: 11.5, design: .monospaced))
.foregroundStyle(Color(red: 0.812, green: 0.902, blue: 1.0))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.18),
in: RoundedRectangle(cornerRadius: 5)
)
.overlay {
RoundedRectangle(cornerRadius: 5)
.stroke(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.4), lineWidth: 1)
}
}
}
}
}
}
}

/// The shape in words, exactly the strip's promise: "This template makes it a
/// **Goal** loop and sets a done check and a worktree." One shared verb — the
/// design's sentence says "sets a done check and a worktree", not "sets … and
/// sets …".
private func shapeSentence(_ applied: ProjectFeature.AppliedTemplate) -> AttributedString {
var text = AttributedString("This template")
if applied.setFields.contains(.shape) || applied.shape != nil {
text += AttributedString(" makes it a \((applied.shape ?? .sketch).displayName) loop")
}
let sets: [String] = [
applied.setFields.contains(.doneCheck) ? "a done check" : nil,
applied.setFields.contains(.cadence) ? "a cadence" : nil,
applied.setFields.contains(.branch) ? "a worktree" : nil,
applied.setFields.contains(.metric) ? "a metric" : nil,
applied.setFields.contains(.backend) ? "the agent" : nil,
applied.setFields.contains(.subGraph) ? "its loops" : nil,
].compactMap { $0 }
var clauses: [String] = []
if !sets.isEmpty { clauses.append("sets " + Self.list(sets)) }
// The one that isn't a thing being set — it is a rhythm, so it keeps its own verb.
if applied.setFields.contains(.pausesBeforeWritesOnly) {
clauses.append("pauses only before writes")
}
if !clauses.isEmpty {
if text.characters.count > "This template".count { text += AttributedString(" and") }
text += AttributedString(" " + Self.list(clauses))
}
text += AttributedString(".")
return text
}

/// "a", "a and b", "a, b and c" — the spec's sentence reads as a sentence, and
/// three settings joined with two "and"s does not.
static func list(_ parts: [String]) -> String {
guard let last = parts.last else { return "" }
guard parts.count > 1 else { return last }
return parts.dropLast().joined(separator: ", ") + " and " + last
}

@ViewBuilder
private var typeFields: some View {
switch store.draftLoopType {
Expand Down Expand Up @@ -300,69 +186,14 @@ struct NodeDraftForm: View {
}
}

private var footer: some View {
HStack(spacing: 12) {
Button("Cancel") { store.send(.cancelNewNodeForm) }
.buttonStyle(.plain)
.font(.system(size: 12.5))
.foregroundStyle(.white.opacity(0.7))
if hasBriefToSave {
Button("Save as template…") { store.send(.saveTemplateTapped) }
.buttonStyle(.plain)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9))
.help("Save this brief as a template you can start from next time")
}
Spacer(minLength: 8)
// `draft.isValid` already knows why the button is off. Saying it beats a disabled
// control with no explanation, which is the version people file bugs about.
if let notice = store.templates.saveNotice {
HStack(spacing: 4) {
Text(noticePath(notice))
.font(.system(size: 10.5, design: .monospaced))
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
.truncationMode(.middle)
Button(notice.otherOffer) { store.send(.templateRelocationTapped) }
.buttonStyle(.plain)
.font(.system(size: 10.5, weight: .semibold))
.foregroundStyle(Color(red: 0.549, green: 0.773, blue: 1.0).opacity(0.9))
// The line is quiet, not permanent: it shares this slot with the reason
// Create is disabled, and staying forever would mean the dialog never
// explains itself again.
Button {
store.send(.templateSaveNoticeDismissed)
} label: {
Image(systemName: "xmark")
.font(.system(size: 8.5, weight: .semibold))
.foregroundStyle(.white.opacity(0.45))
}
.buttonStyle(.plain)
}
} else if let reason = disabledReason {
Text(reason)
.font(.system(size: 11.5))
.foregroundStyle(.white.opacity(0.62))
.lineLimit(1)
}
createButton
}
}

/// A prompt written is a template waiting to happen — the design's second save
/// entry point (the first being a loop's context menu, the third a composite's).
private var hasBriefToSave: Bool {
!store.currentBriefText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| store.draftLoopType == .composite
}

/// "~/.graphcode/templates/review-diff.md" or the project's own — the quiet
/// line states the path so the human knows where the file went.
private func noticePath(_ notice: ProjectFeature.TemplateSaveNotice) -> String {
"Saved to " + TemplateSavePath.display(of: notice.template)
}

private var createButton: some View {
/// Two rows, not one.
///
/// The save notice used to sit in this row between the Spacer and the primary
/// button, and there is not enough width for it: the path truncated to
/// "Saved…op.md", "Put it in the project instead" wrapped onto three lines, and the
/// primary button itself wrapped to two. A dialog's primary action must never wrap,
/// so the notice gets a line of its own above the buttons and the row below holds
var createButton: some View {
Button {
store.send(.createNodeConfirmed)
} label: {
Expand All @@ -371,6 +202,8 @@ struct NodeDraftForm: View {
.font(.system(size: 13, weight: .semibold))
Text("⏎").font(.system(size: 11, design: .monospaced)).opacity(0.7)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.foregroundStyle(isCreateEnabled ? .white : .white.opacity(0.42))
.padding(.horizontal, 14)
.frame(height: 32)
Expand Down Expand Up @@ -404,7 +237,7 @@ struct NodeDraftForm: View {
}

/// Which field is missing, in the words of the thing that is missing.
private var disabledReason: String? {
var disabledReason: String? {
guard !store.draft.isValid || store.draftBlocksOnTokens else { return nil }
if let first = store.unfilledTokens.first {
return "Fill in {\(first)} to continue"
Expand Down
Loading
Loading