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
72 changes: 27 additions & 45 deletions Packages/CrowUI/Sources/CrowUI/AutomationSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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") {
Expand Down
199 changes: 199 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/TokenListEditor.swift
Original file line number Diff line number Diff line change
@@ -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
57 changes: 27 additions & 30 deletions Packages/CrowUI/Sources/CrowUI/WorkspaceFormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading