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..eadc7321 --- /dev/null +++ b/Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift @@ -0,0 +1,199 @@ +import SwiftUI +import CrowCore + +/// Reusable list editor bound to a `[String]`. +/// +/// 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. +/// +/// 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 + + @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. + 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 { + 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) + } + } + } + } + } + + // 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()) + } + + // 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) { + 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. + /// + /// `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) + 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"]) + } +}