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
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,42 @@ jobs:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
with:
submodules: true

- uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "16"

- uses: mlugg/setup-zig@v2
with:
version: 0.15.2

- name: Get Ghostty submodule SHA
id: ghostty-sha
run: echo "sha=$(git -C vendor/ghostty rev-parse HEAD)" >> "$GITHUB_OUTPUT"

- name: Cache Ghostty framework
id: ghostty-cache
uses: actions/cache@v4
with:
path: Frameworks
key: ghostty-${{ runner.os }}-${{ runner.arch }}-${{ steps.ghostty-sha.outputs.sha }}

- name: Build Ghostty
if: steps.ghostty-cache.outputs.cache-hit != 'true'
run: |
unset ZIG_LOCAL_CACHE_DIR ZIG_GLOBAL_CACHE_DIR
make ghostty

- name: Run tests
run: swift test --package-path Packages/CrowCore
run: |
for pkg in Packages/*/; do
if [ -d "$pkg/Tests" ]; then
echo "Testing $pkg..."
swift test --package-path "$pkg"
fi
done

build:
name: Build
Expand Down
1 change: 1 addition & 0 deletions Packages/CrowUI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ let package = Package(
],
targets: [
.target(name: "CrowUI", dependencies: ["CrowCore", "CrowTerminal"]),
.testTarget(name: "CrowUITests", dependencies: ["CrowUI"]),
]
)
38 changes: 4 additions & 34 deletions Packages/CrowUI/Sources/CrowUI/AllowListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,7 @@ public struct AllowListView: View {

private var toolbar: some View {
HStack(spacing: 10) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
.foregroundStyle(CorveilTheme.textMuted)
.font(.caption)
TextField("Filter patterns…", text: $searchText)
.textFieldStyle(.plain)
.font(.system(size: 12))
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(CorveilTheme.bgCard)
.clipShape(RoundedRectangle(cornerRadius: 6))
SearchField("Filter patterns\u{2026}", text: $searchText)

Toggle("Hide Global", isOn: $hideGlobal)
.toggleStyle(.checkbox)
Expand Down Expand Up @@ -136,6 +125,7 @@ public struct AllowListView: View {

// MARK: - Entry Row

/// Row displaying a single allow-list entry with source badges.
struct AllowEntryRow: View {
let entry: AllowEntry

Expand All @@ -147,10 +137,10 @@ struct AllowEntryRow: View {

HStack(spacing: 6) {
if entry.isInGlobal {
SourceBadge(label: "Global", color: .green)
CapsuleBadge("Global", color: .green)
}
ForEach(entry.worktreeSessionNames, id: \.self) { name in
SourceBadge(label: name, color: .blue)
CapsuleBadge(name, color: .blue)
}
}
}
Expand All @@ -159,23 +149,3 @@ struct AllowEntryRow: View {
}
}

// MARK: - Source Badge

struct SourceBadge: View {
let label: String
let color: Color

var body: some View {
Text(label)
.font(.caption2)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.15))
.foregroundStyle(color)
.overlay(
Capsule().strokeBorder(color.opacity(0.3), lineWidth: 0.5)
)
.clipShape(Capsule())
}
}
141 changes: 141 additions & 0 deletions Packages/CrowUI/Sources/CrowUI/CorveilTheme.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import SwiftUI
import AppKit
import CrowCore

/// Corveil design tokens translated from corveil.com/styles.css
public enum CorveilTheme {
// Backgrounds
public static let bgDeep = Color(hex: 0x121416)
public static let bgSurface = Color(hex: 0x1A1D20)
public static let bgCard = Color(hex: 0x22262A)
/// Tinted background for "done" / linked-session cards.
public static let bgDone = Color(red: 0.15, green: 0.22, blue: 0.16)

// Gold palette
public static let gold = Color(hex: 0xDDC482)
Expand All @@ -31,3 +35,140 @@ extension Color {
)
}
}

// MARK: - Status Color Extensions

extension TicketStatus {
/// Canonical UI color for each pipeline stage.
public var color: Color {
switch self {
case .backlog: .gray
case .ready: .blue
case .inProgress: .orange
case .inReview: .purple
case .done: .green
case .unknown: .secondary
}
}
}

extension SessionStatus {
/// Human-readable display name (handles multi-word statuses like "In Review").
public var displayName: String {
switch self {
case .active: "Active"
case .paused: "Paused"
case .inReview: "In Review"
case .completed: "Completed"
case .archived: "Archived"
}
}
}

// MARK: - Brandmark Image

/// Loads the Corveil brandmark from app bundles. Decorative — hidden from VoiceOver.
public struct BrandmarkImage: View {
public init() {}

public var body: some View {
if let image = Self.load() {
Image(nsImage: image)
.resizable()
.scaledToFit()
.accessibilityHidden(true)
}
}

static func load() -> NSImage? {
for bundle in Bundle.allBundles {
if let url = bundle.url(forResource: "CorveilBrandmark", withExtension: "png"),
let image = NSImage(contentsOf: url) {
return image
}
}
return nil
}
}

// MARK: - Branch Formatting

/// Shortens a git branch name by stripping common prefixes.
public func shortenBranch(_ branch: String) -> String {
branch
.replacingOccurrences(of: "refs/heads/", with: "")
.replacingOccurrences(of: "feature/", with: "")
}

// MARK: - Search Field

/// Themed search field with magnifying glass icon and clear button.
public struct SearchField: View {
let placeholder: String
@Binding var text: String

public init(_ placeholder: String, text: Binding<String>) {
self.placeholder = placeholder
self._text = text
}

public var body: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12))
.foregroundStyle(CorveilTheme.textMuted)
TextField(placeholder, text: $text)
.textFieldStyle(.plain)
.font(.system(size: 13))
.foregroundStyle(CorveilTheme.textPrimary)
if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 12))
.foregroundStyle(CorveilTheme.textMuted)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear search")
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(CorveilTheme.bgCard)
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(CorveilTheme.borderSubtle, lineWidth: 1)
)
)
}
}

// MARK: - Capsule Badge

/// Reusable capsule badge with colored background and border.
public struct CapsuleBadge: View {
let label: String
let color: Color

public init(_ label: String, color: Color) {
self.label = label
self.color = color
}

public var body: some View {
Text(label)
.font(.caption2)
.fontWeight(.medium)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.15))
.foregroundStyle(color)
.overlay(
Capsule().strokeBorder(color.opacity(0.3), lineWidth: 0.5)
)
.clipShape(Capsule())
}
}
50 changes: 39 additions & 11 deletions Packages/CrowUI/Sources/CrowUI/DeleteSessionAlert.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,13 @@ struct DeleteSessionAlert: ViewModifier {
Button("Cancel", role: .cancel) { sessionToDelete = nil }
Button(buttonLabel, role: .destructive) {
if let session = sessionToDelete {
Task { try? await appState.onDeleteSession?(session.id) }
Task {
do {
try await appState.onDeleteSession?(session.id)
} catch {
NSLog("Failed to delete session \(session.name): \(error)")
}
}
sessionToDelete = nil
}
}
Expand All @@ -30,19 +36,47 @@ struct DeleteSessionAlert: ViewModifier {
guard let session = sessionToDelete else { return "Delete" }
let wts = appState.worktrees(for: session.id)
let hasRealWorktrees = wts.contains { !WorktreeClassification.isMainCheckout($0) }
return hasRealWorktrees ? "Delete Everything" : "Remove Session"
return DeleteSessionMessageBuilder.buttonLabel(hasRealWorktrees: hasRealWorktrees)
}

private var messageText: String {
guard let session = sessionToDelete else { return "" }
let wts = appState.worktrees(for: session.id)
let realWorktrees = wts.filter { !WorktreeClassification.isMainCheckout($0) }
let mainCheckouts = wts.filter { WorktreeClassification.isMainCheckout($0) }
return DeleteSessionMessageBuilder.buildMessage(
sessionName: session.name,
realWorktrees: realWorktrees,
mainCheckouts: mainCheckouts
)
}
}

extension View {
func deleteSessionAlert(session: Binding<Session?>, appState: AppState) -> some View {
modifier(DeleteSessionAlert(sessionToDelete: session, appState: appState))
}
}

// MARK: - Delete Session Message Builder

if wts.isEmpty {
return "This will remove the session \"\(session.name)\"."
/// Testable logic for generating delete-session confirmation messages.
enum DeleteSessionMessageBuilder {
static func buttonLabel(hasRealWorktrees: Bool) -> String {
hasRealWorktrees ? "Delete Everything" : "Remove Session"
}

static func buildMessage(
sessionName: String,
realWorktrees: [SessionWorktree],
mainCheckouts: [SessionWorktree]
) -> String {
let hasWorktrees = !realWorktrees.isEmpty || !mainCheckouts.isEmpty

if !hasWorktrees {
return "This will remove the session \"\(sessionName)\"."
} else if realWorktrees.isEmpty {
return "This will remove the session \"\(session.name)\".\n\nThe repository folder and branch (\(mainCheckouts.map(\.branch).joined(separator: ", "))) will not be affected."
return "This will remove the session \"\(sessionName)\".\n\nThe repository folder and branch (\(mainCheckouts.map(\.branch).joined(separator: ", "))) will not be affected."
} else if mainCheckouts.isEmpty {
return "This will delete:\n\n" +
realWorktrees.map { " \u{2022} Worktree: \($0.worktreePath)\n \u{2022} Branch: \($0.branch)" }
Expand All @@ -57,12 +91,6 @@ struct DeleteSessionAlert: ViewModifier {
}
}

extension View {
func deleteSessionAlert(session: Binding<Session?>, appState: AppState) -> some View {
modifier(DeleteSessionAlert(sessionToDelete: session, appState: appState))
}
}

// MARK: - Worktree Classification

/// Shared logic for classifying worktrees as main checkouts vs real worktrees.
Expand Down
Loading
Loading