From ea2fb4b986b7fb0283958ba1605ce8aa5fa66bf0 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 15 Jun 2026 09:59:52 -0500 Subject: [PATCH 1/3] Settings: tag/token list editor for repo/label fields (CROW-513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the six comma-delimited TextFields in Settings with a reusable TokenListEditor bound to Binding<[String]>. Entries render as removable gold chips; add via type/paste (commit on Return or comma, split pasted "a, b, c" into multiple chips); Backspace on empty input removes the last chip. Trims, de-dupes (case-sensitive), and ignores empty input. Chips wrap via a small FlowLayout (macOS 14 Layout protocol). Styling borrows LinkChip's capsule look. The model fields are already [String], so this is bound directly with no persistence or schema change — existing comma-separated configs decode into the arrays and render as chips. - AutomationSettingsView: excludeReviewRepos, ignoreReviewLabels, excludeTicketRepos (drop the @State CSV mirrors; persist via onChange). - WorkspaceFormView: alwaysInclude, autoReviewRepos, excludeReviewRepos (switch @State to [String]; drop the parseCSV glue). - Add TokenListEditor.adding unit suite. Closes #513 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 4CBC6396-0AE1-4F5C-B64B-E201DBDE2BF1 --- .../CrowUI/AutomationSettingsView.swift | 72 +++--- .../Sources/CrowUI/TokenListEditor.swift | 212 ++++++++++++++++++ .../Sources/CrowUI/WorkspaceFormView.swift | 57 +++-- .../Tests/CrowUITests/CrowUITests.swift | 36 +++ 4 files changed, 302 insertions(+), 75 deletions(-) create mode 100644 Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift diff --git a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift index 1babb012..50e8d85a 100644 --- a/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift +++ b/Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift @@ -16,9 +16,6 @@ public struct AutomationSettingsView: View { @Binding var autoRebaseWatcherEnabled: Bool var onSave: (() -> Void)? - @State private var excludeReviewReposText: String - @State private var ignoreReviewLabelsText: String - @State private var excludeTicketReposText: String @State private var managerGatewayBaseURL: String @State private var managerGatewayHeadersText: String @@ -44,9 +41,6 @@ public struct AutomationSettingsView: View { self._autoCreateWatcherEnabled = autoCreateWatcherEnabled self._autoRebaseWatcherEnabled = autoRebaseWatcherEnabled self.onSave = onSave - self._excludeReviewReposText = State(initialValue: defaults.wrappedValue.excludeReviewRepos.joined(separator: ", ")) - self._ignoreReviewLabelsText = State(initialValue: defaults.wrappedValue.ignoreReviewLabels.joined(separator: ", ")) - self._excludeTicketReposText = State(initialValue: defaults.wrappedValue.excludeTicketRepos.joined(separator: ", ")) self._managerGatewayBaseURL = State(initialValue: managerGateway.wrappedValue?.baseURL ?? "") self._managerGatewayHeadersText = State(initialValue: managerGateway.wrappedValue.map { WorkspaceGateway.headerLines(from: $0.customHeaders) @@ -81,49 +75,37 @@ public struct AutomationSettingsView: View { public var body: some View { Form { Section("Reviews") { - TextField("Excluded Repos", text: $excludeReviewReposText) - .textFieldStyle(.roundedBorder) - .onChange(of: excludeReviewReposText) { _, _ in - defaults.excludeReviewRepos = excludeReviewReposText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - onSave?() - } - Text("Comma-separated repos to hide from the review board. Supports wildcards (e.g., zarf-dev/*, bmlt-enabled/yap).") - .font(.caption) - .foregroundStyle(.secondary) - Text("Per-workspace auto-review opt-ins are configured in Workspaces → edit a workspace.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Excluded Repos") + TokenListEditor(tokens: $defaults.excludeReviewRepos, placeholder: "owner/repo") + .onChange(of: defaults.excludeReviewRepos) { _, _ in onSave?() } + Text("Repos to hide from the review board. Supports wildcards (e.g., zarf-dev/*, bmlt-enabled/yap).") + .font(.caption) + .foregroundStyle(.secondary) + Text("Per-workspace auto-review opt-ins are configured in Workspaces → edit a workspace.") + .font(.caption) + .foregroundStyle(.secondary) + } - TextField("Ignored Labels", text: $ignoreReviewLabelsText) - .textFieldStyle(.roundedBorder) - .onChange(of: ignoreReviewLabelsText) { _, _ in - defaults.ignoreReviewLabels = ignoreReviewLabelsText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - onSave?() - } - Text("Comma-separated labels to ignore from the review board (e.g., dependencies, renovate, automated).") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Ignored Labels") + TokenListEditor(tokens: $defaults.ignoreReviewLabels, placeholder: "label") + .onChange(of: defaults.ignoreReviewLabels) { _, _ in onSave?() } + Text("Labels to ignore from the review board (e.g., dependencies, renovate, automated).") + .font(.caption) + .foregroundStyle(.secondary) + } } Section("Tickets") { - TextField("Excluded Repos", text: $excludeTicketReposText) - .textFieldStyle(.roundedBorder) - .onChange(of: excludeTicketReposText) { _, _ in - defaults.excludeTicketRepos = excludeTicketReposText - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - onSave?() - } - Text("Comma-separated repos to hide from the ticket board. Supports wildcards (e.g., zarf-dev/*, bmlt-enabled/yap).") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Excluded Repos") + TokenListEditor(tokens: $defaults.excludeTicketRepos, placeholder: "owner/repo") + .onChange(of: defaults.excludeTicketRepos) { _, _ in onSave?() } + Text("Repos to hide from the ticket board. Supports wildcards (e.g., zarf-dev/*, bmlt-enabled/yap).") + .font(.caption) + .foregroundStyle(.secondary) + } } Section("Remote Control") { diff --git a/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift new file mode 100644 index 00000000..d88e29ce --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift @@ -0,0 +1,212 @@ +import SwiftUI +import CrowCore + +/// Reusable token / chip editor bound to a `[String]`. +/// +/// Existing values render as removable gold capsules (styled like ``LinkChip``); +/// a trailing text field adds new tokens. Tokens commit on Return, on a typed +/// comma, or when a comma-containing string is pasted and submitted. Input is +/// trimmed, empty entries are ignored, and exact duplicates (case-sensitive) are +/// dropped. Backspace in an empty field removes the last chip. +/// +/// Chips + field wrap across lines via ``FlowLayout``. Binding directly to the +/// model's existing `[String]` means there is no persistence or schema change — +/// existing comma-separated configs already decode into the array. +public struct TokenListEditor: View { + @Binding var tokens: [String] + private let placeholder: String + + @State private var input: String = "" + @FocusState private var fieldFocused: Bool + + /// - Parameters: + /// - tokens: The backing array of token strings. + /// - placeholder: Prompt shown in the input field when there are no tokens. + public init(tokens: Binding<[String]>, placeholder: String = "Add…") { + self._tokens = tokens + self.placeholder = placeholder + } + + public var body: some View { + FlowLayout(spacing: 6, lineSpacing: 6) { + ForEach(Array(tokens.enumerated()), id: \.offset) { index, token in + chip(token, at: index) + } + inputField + } + .padding(6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(CorveilTheme.bgCard) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(CorveilTheme.borderSubtle, lineWidth: 1) + ) + ) + .contentShape(Rectangle()) + .onTapGesture { fieldFocused = true } + } + + // MARK: Subviews + + private func chip(_ token: String, at index: Int) -> some View { + HStack(spacing: 4) { + Text(token) + .font(.caption) + .fontWeight(.medium) + Button { + remove(at: index) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(token)") + } + .foregroundStyle(CorveilTheme.gold) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(CorveilTheme.gold.opacity(0.1)) + .overlay( + Capsule().strokeBorder(CorveilTheme.goldDark.opacity(0.3), lineWidth: 1) + ) + .clipShape(Capsule()) + } + + private var inputField: some View { + TextField(tokens.isEmpty ? placeholder : "", text: $input) + .textFieldStyle(.plain) + .font(.caption) + .autocorrectionDisabled() + .frame(minWidth: 80) + .focused($fieldFocused) + .onSubmit { commit() } + .onExitCommand { fieldFocused = false } + // Catch a typed comma immediately; a paste of "a, b, c" carries no key + // event, so its commas are split in commit() on Return instead. + .onKeyPress(",") { + commit() + return .handled + } + // TextField doesn't report a backspace when already empty (no text + // change), so use onKeyPress to remove the last chip in that case. + .onKeyPress(.delete) { + if input.isEmpty, !tokens.isEmpty { + tokens.removeLast() + return .handled + } + return .ignored + } + } + + // MARK: Mutation + + private func commit() { + tokens = Self.adding(input, to: tokens) + input = "" + } + + private func remove(at index: Int) { + guard tokens.indices.contains(index) else { return } + tokens.remove(at: index) + } + + // MARK: Pure logic (unit-tested) + + /// Splits `input` on commas, trims whitespace, drops empties, and appends to + /// `existing`, skipping case-sensitive exact duplicates (against both the + /// existing array and tokens added earlier in the same call). Pure — never + /// mutates its arguments. + static func adding(_ input: String, to existing: [String]) -> [String] { + var result = existing + for piece in input.split(separator: ",", omittingEmptySubsequences: true) { + let trimmed = piece.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { continue } + guard !result.contains(trimmed) else { continue } + result.append(trimmed) + } + return result + } +} + +// MARK: - FlowLayout + +/// Wrapping flow layout: lays subviews left-to-right, wrapping to a new line when +/// the next subview would overflow the proposed width. macOS 14+ `Layout`. +struct FlowLayout: Layout { + var spacing: CGFloat = 6 + var lineSpacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + var rowWidth: CGFloat = 0 + var rowHeight: CGFloat = 0 + var totalHeight: CGFloat = 0 + var totalWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if rowWidth > 0, rowWidth + spacing + size.width > maxWidth { + totalHeight += rowHeight + lineSpacing + totalWidth = max(totalWidth, rowWidth) + rowWidth = size.width + rowHeight = size.height + } else { + rowWidth += (rowWidth > 0 ? spacing : 0) + size.width + rowHeight = max(rowHeight, size.height) + } + } + totalHeight += rowHeight + totalWidth = max(totalWidth, rowWidth) + + return CGSize(width: proposal.width ?? totalWidth, height: totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + let maxWidth = bounds.width + var x = bounds.minX + var y = bounds.minY + var rowHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > bounds.minX, x + size.width > bounds.minX + maxWidth { + x = bounds.minX + y += rowHeight + lineSpacing + rowHeight = 0 + } + subview.place( + at: CGPoint(x: x, y: y), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + spacing + rowHeight = max(rowHeight, size.height) + } + } +} + +#if DEBUG +private struct TokenListEditorPreviewHost: View { + @State private var repos = ["zarf-dev/*", "bmlt-enabled/yap", "radiusmethod/crow"] + @State private var empty: [String] = [] + + var body: some View { + Form { + Section("With values") { + TokenListEditor(tokens: $repos, placeholder: "owner/repo") + } + Section("Empty") { + TokenListEditor(tokens: $empty, placeholder: "Add a label") + } + } + .formStyle(.grouped) + .frame(width: 460, height: 360) + } +} + +#Preview("TokenListEditor") { + TokenListEditorPreviewHost() + .environment(\.colorScheme, .dark) +} +#endif diff --git a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift index 5e92a020..74b9c281 100644 --- a/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift +++ b/Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift @@ -20,9 +20,9 @@ public struct WorkspaceFormView: View { @State private var jiraProjectKey: String @State private var jiraJQL: String @State private var corveilHost: String - @State private var alwaysIncludeText: String - @State private var autoReviewReposText: String - @State private var excludeReviewReposText: String + @State private var alwaysInclude: [String] + @State private var autoReviewRepos: [String] + @State private var excludeReviewRepos: [String] @State private var customInstructionsText: String @State private var gatewayBaseURL: String @State private var gatewayHeadersText: String @@ -52,9 +52,9 @@ public struct WorkspaceFormView: View { self._jiraProjectKey = State(initialValue: workspace?.jiraProjectKey ?? "") self._jiraJQL = State(initialValue: workspace?.jiraJQL ?? "") self._corveilHost = State(initialValue: workspace?.corveilHost ?? "") - self._alwaysIncludeText = State(initialValue: workspace?.alwaysInclude.joined(separator: ", ") ?? "") - self._autoReviewReposText = State(initialValue: workspace?.autoReviewRepos.joined(separator: ", ") ?? "") - self._excludeReviewReposText = State(initialValue: workspace?.excludeReviewRepos.joined(separator: ", ") ?? "") + self._alwaysInclude = State(initialValue: workspace?.alwaysInclude ?? []) + self._autoReviewRepos = State(initialValue: workspace?.autoReviewRepos ?? []) + self._excludeReviewRepos = State(initialValue: workspace?.excludeReviewRepos ?? []) self._customInstructionsText = State(initialValue: workspace?.customInstructions ?? "") self._gatewayBaseURL = State(initialValue: workspace?.gateway?.baseURL ?? "") self._gatewayHeadersText = State(initialValue: workspace?.gateway.map { @@ -179,23 +179,29 @@ public struct WorkspaceFormView: View { } Section("Repos") { - TextField("Always Include Repos", text: $alwaysIncludeText) - .textFieldStyle(.roundedBorder) - Text("Comma-separated repo specs: owner/* lists all of an org's repos, or owner/repo for a single repo. Populates the Jobs repo picker.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Always Include Repos") + TokenListEditor(tokens: $alwaysInclude, placeholder: "owner/repo or owner/*") + Text("Repo specs: owner/* lists all of an org's repos, or owner/repo for a single repo. Populates the Jobs repo picker.") + .font(.caption) + .foregroundStyle(.secondary) + } - TextField("Auto-Review Repos", text: $autoReviewReposText) - .textFieldStyle(.roundedBorder) - Text("Comma-separated repos or patterns (e.g. org/repo, org/*). New review requests from matching repos will automatically create a review session.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Auto-Review Repos") + TokenListEditor(tokens: $autoReviewRepos, placeholder: "owner/repo or owner/*") + Text("Repos or patterns (e.g. org/repo, org/*). New review requests from matching repos will automatically create a review session.") + .font(.caption) + .foregroundStyle(.secondary) + } - TextField("Excluded Review Repos", text: $excludeReviewReposText) - .textFieldStyle(.roundedBorder) - Text("Comma-separated repos or patterns (e.g. org/repo, org/*). Review requests from matching repos are hidden from the review board and don't trigger notifications.") - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 4) { + Text("Excluded Review Repos") + TokenListEditor(tokens: $excludeReviewRepos, placeholder: "owner/repo or owner/*") + Text("Repos or patterns (e.g. org/repo, org/*). Review requests from matching repos are hidden from the review board and don't trigger notifications.") + .font(.caption) + .foregroundStyle(.secondary) + } } Section("Custom Instructions") { @@ -254,9 +260,6 @@ public struct WorkspaceFormView: View { } private func buildWorkspace() -> WorkspaceInfo { - let alwaysInclude = parseCSV(alwaysIncludeText) - let autoReviewRepos = parseCSV(autoReviewReposText) - let excludeReviewRepos = parseCSV(excludeReviewReposText) let trimmedInstructions = customInstructionsText.trimmingCharacters(in: .whitespacesAndNewlines) // Persist taskProvider only when it diverges from the code provider; @@ -284,12 +287,6 @@ public struct WorkspaceFormView: View { ) } - private func parseCSV(_ text: String) -> [String] { - text.split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } - } - private func nonEmpty(_ text: String) -> String? { let trimmed = text.trimmingCharacters(in: .whitespaces) return trimmed.isEmpty ? nil : trimmed diff --git a/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift b/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift index d9aed106..77523360 100644 --- a/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift +++ b/Packages/CrowUI/Tests/CrowUITests/CrowUITests.swift @@ -376,3 +376,39 @@ private let canonicalLabels: [(name: String, hex: String)] = [ #expect(hsl.s == 0) #expect(abs(hsl.l - 0.5) < 1e-9) } + +// MARK: - TokenListEditor.adding (CROW-513) + +@Suite("TokenListEditor.adding") +struct TokenListEditorAddingTests { + @Test func trimsWhitespace() { + #expect(TokenListEditor.adding(" foo ", to: []) == ["foo"]) + } + + @Test func ignoresEmptyAndWhitespaceOnly() { + #expect(TokenListEditor.adding(" ", to: []) == []) + #expect(TokenListEditor.adding("", to: ["a"]) == ["a"]) + #expect(TokenListEditor.adding(", ,", to: []) == []) + } + + @Test func splitsOnCommas() { + #expect(TokenListEditor.adding("a, b, c", to: []) == ["a", "b", "c"]) + } + + @Test func dedupesAgainstExistingCaseSensitive() { + #expect(TokenListEditor.adding("a", to: ["a"]) == ["a"]) + #expect(TokenListEditor.adding("A", to: ["a"]) == ["a", "A"]) + } + + @Test func dedupesWithinSameCommit() { + #expect(TokenListEditor.adding("x, x, y", to: []) == ["x", "y"]) + } + + @Test func appendsPreservingOrder() { + #expect(TokenListEditor.adding("b, c", to: ["a"]) == ["a", "b", "c"]) + } + + @Test func trailingCommaProducesOneToken() { + #expect(TokenListEditor.adding("foo,", to: []) == ["foo"]) + } +} From e81a9590b1e329433accea4f353286c6e3934634 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 15 Jun 2026 10:08:41 -0500 Subject: [PATCH 2/3] Fix CI: mark TokenListEditor.adding nonisolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TokenListEditor conforms to View, so the type is implicitly @MainActor; the static adding(_:to:) helper inherited that isolation. Under the package's Swift 6 language mode, the nonisolated test suite could not call it synchronously ("call to main actor-isolated static method in a synchronous nonisolated context"). Mark the pure helper nonisolated. 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 4CBC6396-0AE1-4F5C-B64B-E201DBDE2BF1 --- Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift index d88e29ce..debea3bb 100644 --- a/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift +++ b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift @@ -117,7 +117,10 @@ public struct TokenListEditor: View { /// `existing`, skipping case-sensitive exact duplicates (against both the /// existing array and tokens added earlier in the same call). Pure — never /// mutates its arguments. - static func adding(_ input: String, to existing: [String]) -> [String] { + /// + /// `nonisolated` so the (main-actor-isolated, via `View`) type's helper stays + /// callable from synchronous nonisolated contexts such as the test suite. + nonisolated static func adding(_ input: String, to existing: [String]) -> [String] { var result = existing for piece in input.split(separator: ",", omittingEmptySubsequences: true) { let trimmed = piece.trimmingCharacters(in: .whitespaces) From aa1f65565d3c2ef38ea85217516282b32d50b098 Mon Sep 17 00:00:00 2001 From: Dustin Hilgaertner Date: Mon, 15 Jun 2026 15:54:00 -0500 Subject: [PATCH 3/3] TokenListEditor: traditional input-row + tags-below layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous design embedded the text field inside the same wrapping flow as the chips, so accumulated chips crowded the typing area and made entry awkward. Switch to the conventional "add to a list" layout: a standard rounded-border TextField plus an Add button on top, with committed values listed as removable chips beneath. Commit on Return or Add; paste of "a, b, c" still splits into three chips; focus returns to the field after each add for rapid entry. Dropped the comma-key and backspace-to-remove interceptions that fought normal text editing (incl. paste). 🐦‍⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude Crow-Session: 4CBC6396-0AE1-4F5C-B64B-E201DBDE2BF1 --- .../Sources/CrowUI/TokenListEditor.swift | 86 ++++++++----------- 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift index debea3bb..eadc7321 100644 --- a/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift +++ b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift @@ -1,17 +1,20 @@ import SwiftUI import CrowCore -/// Reusable token / chip editor bound to a `[String]`. +/// Reusable list editor bound to a `[String]`. /// -/// Existing values render as removable gold capsules (styled like ``LinkChip``); -/// a trailing text field adds new tokens. Tokens commit on Return, on a typed -/// comma, or when a comma-containing string is pasted and submitted. Input is -/// trimmed, empty entries are ignored, and exact duplicates (case-sensitive) are -/// dropped. Backspace in an empty field removes the last chip. +/// A standard text field plus an **Add** button sit on top; committed values +/// accumulate as removable gold chips below (styled like ``LinkChip``). This is +/// the conventional "add to a list" layout — the typing field is kept separate +/// from the chips so existing entries never get in the way of new input. /// -/// Chips + field wrap across lines via ``FlowLayout``. Binding directly to the -/// model's existing `[String]` means there is no persistence or schema change — -/// existing comma-separated configs already decode into the array. +/// Add by pressing Return or the Add button. Input is split on commas (so a +/// pasted `a, b, c` becomes three chips), trimmed, de-duplicated (case-sensitive, +/// exact), and empty entries are ignored. Each chip's `x` removes that entry. +/// +/// Binding directly to the model's existing `[String]` means there is no +/// persistence or schema change — existing comma-separated configs already decode +/// into the array and render as chips. public struct TokenListEditor: View { @Binding var tokens: [String] private let placeholder: String @@ -21,30 +24,36 @@ public struct TokenListEditor: View { /// - Parameters: /// - tokens: The backing array of token strings. - /// - placeholder: Prompt shown in the input field when there are no tokens. + /// - placeholder: Prompt shown in the input field. public init(tokens: Binding<[String]>, placeholder: String = "Add…") { self._tokens = tokens self.placeholder = placeholder } + private var canAdd: Bool { + !input.trimmingCharacters(in: .whitespaces).isEmpty + } + public var body: some View { - FlowLayout(spacing: 6, lineSpacing: 6) { - ForEach(Array(tokens.enumerated()), id: \.offset) { index, token in - chip(token, at: index) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + TextField(placeholder, text: $input) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .focused($fieldFocused) + .onSubmit(commit) + Button("Add", action: commit) + .disabled(!canAdd) + } + + if !tokens.isEmpty { + FlowLayout(spacing: 6, lineSpacing: 6) { + ForEach(Array(tokens.enumerated()), id: \.offset) { index, token in + chip(token, at: index) + } + } } - inputField } - .padding(6) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(CorveilTheme.bgCard) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(CorveilTheme.borderSubtle, lineWidth: 1) - ) - ) - .contentShape(Rectangle()) - .onTapGesture { fieldFocused = true } } // MARK: Subviews @@ -73,37 +82,12 @@ public struct TokenListEditor: View { .clipShape(Capsule()) } - private var inputField: some View { - TextField(tokens.isEmpty ? placeholder : "", text: $input) - .textFieldStyle(.plain) - .font(.caption) - .autocorrectionDisabled() - .frame(minWidth: 80) - .focused($fieldFocused) - .onSubmit { commit() } - .onExitCommand { fieldFocused = false } - // Catch a typed comma immediately; a paste of "a, b, c" carries no key - // event, so its commas are split in commit() on Return instead. - .onKeyPress(",") { - commit() - return .handled - } - // TextField doesn't report a backspace when already empty (no text - // change), so use onKeyPress to remove the last chip in that case. - .onKeyPress(.delete) { - if input.isEmpty, !tokens.isEmpty { - tokens.removeLast() - return .handled - } - return .ignored - } - } - // MARK: Mutation private func commit() { tokens = Self.adding(input, to: tokens) input = "" + fieldFocused = true // keep focus for rapid entry } private func remove(at index: Int) {