native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
16 kB · 486 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487import AppKitimport SwiftUI
struct WorktreeRow: View { let name: String let worktreeName: String let info: WorktreeInfoEntry? /// Per-worktree open-tab count badge; `nil` hides the badge entirely /// (workspace child rows — tab attribution is per directory, not per child). let tabCountBadge: WorktreeTabCountBadge? let iconSystemName: String? let showsPullRequestInfo: Bool let isHovered: Bool let isPinned: Bool let isMainWorktree: Bool let isLoading: Bool let taskStatus: WorktreeTaskStatus? let hasBlockedAgent: Bool let isRunScriptRunning: Bool let showsNotificationIndicator: Bool let notifications: [WorktreeTerminalNotification] let onFocusNotification: (WorktreeTerminalNotification) -> Void let shortcutHint: String? let showsShortcutHint: Bool let pinAction: (() -> Void)? let isSelected: Bool let archiveAction: (() -> Void)? let onDiffTap: (() -> Void)? let onStopRunScript: (() -> Void)? @Environment(\.colorScheme) private var colorScheme @Environment(\.resolvedKeybindings) private var resolvedKeybindings
init( name: String, worktreeName: String, info: WorktreeInfoEntry?, tabCountBadge: WorktreeTabCountBadge? = nil, iconSystemName: String? = nil, showsPullRequestInfo: Bool, isHovered: Bool, isPinned: Bool, isMainWorktree: Bool, isLoading: Bool, taskStatus: WorktreeTaskStatus?, hasBlockedAgent: Bool = false, isRunScriptRunning: Bool, showsNotificationIndicator: Bool, notifications: [WorktreeTerminalNotification], onFocusNotification: @escaping (WorktreeTerminalNotification) -> Void, shortcutHint: String?, showsShortcutHint: Bool, pinAction: (() -> Void)?, isSelected: Bool, archiveAction: (() -> Void)?, onDiffTap: (() -> Void)?, onStopRunScript: (() -> Void)? ) { self.name = name self.worktreeName = worktreeName self.info = info self.tabCountBadge = tabCountBadge self.iconSystemName = iconSystemName self.showsPullRequestInfo = showsPullRequestInfo self.isHovered = isHovered self.isPinned = isPinned self.isMainWorktree = isMainWorktree self.isLoading = isLoading self.taskStatus = taskStatus self.hasBlockedAgent = hasBlockedAgent self.isRunScriptRunning = isRunScriptRunning self.showsNotificationIndicator = showsNotificationIndicator self.notifications = notifications self.onFocusNotification = onFocusNotification self.shortcutHint = shortcutHint self.showsShortcutHint = showsShortcutHint self.pinAction = pinAction self.isSelected = isSelected self.archiveAction = archiveAction self.onDiffTap = onDiffTap self.onStopRunScript = onStopRunScript }
var body: some View { // A blocked agent has stopped and is waiting on an answer, so it must not // wear the running spinner — that affordance tells you to wait, which is // the opposite of what a blocked agent needs. `isLoading` still wins: that // spinner describes the row's own create/archive/delete work, not an agent. let showsBlockedIndicator = hasBlockedAgent && !isLoading let showsSpinner = isLoading || (taskStatus == .running && !showsBlockedIndicator) let hidesBranchIcon = showsSpinner || showsBlockedIndicator let branchIconName = iconSystemName ?? (isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch")) let display = WorktreePullRequestDisplay( worktreeName: name, pullRequest: showsPullRequestInfo ? info?.pullRequest : nil ) let displayAddedLines = info?.addedLines let displayRemovedLines = info?.removedLines let lineChangePresentation: LineChangeBadgePresentation? = if let displayAddedLines, let displayRemovedLines { LineChangeBadgePresentation( addedLines: displayAddedLines, removedLines: displayRemovedLines, skippedUntrackedFileCount: info?.skippedUntrackedFileCount ?? 0 ) } else { nil } let mergeReadiness = pullRequestMergeReadiness(for: display.pullRequest) let isQueued = display.pullRequest.flatMap(PullRequestMergeQueueStatus.init(pullRequest:)) != nil let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil let nameColor = colorScheme == .dark ? Color.white : Color.primary let detailText = worktreeName.isEmpty ? name : worktreeName let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: 6) { ZStack { if showsNotificationIndicator { NotificationPopoverButton( notifications: notifications, onFocusNotification: onFocusNotification ) { Image(systemName: "bell.fill") .font(.caption) .foregroundStyle(.orange) .accessibilityLabel("Unread notifications") } .opacity(hidesBranchIcon ? 0 : 1) } else { Image(systemName: branchIconName) .font(.caption) .foregroundStyle(.secondary) .opacity(hidesBranchIcon ? 0 : 1) .accessibilityHidden(true) } if showsBlockedIndicator { Image(systemName: "exclamationmark.circle.fill") .font(.caption) .foregroundStyle(.red) .accessibilityLabel("Agent is waiting for your input") } else if showsSpinner { ProgressView() .controlSize(.small) } } .frame(width: 16, height: 16) .alignmentGuide(.firstTextBaseline) { _ in bodyFontAscender } Text(name) .font(.body) .foregroundStyle(nameColor) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) .help(name) if let tabCountBadge { tabCountBadge } Spacer(minLength: 4) if isHovered, pinAction != nil { Button { pinAction?() } label: { Image(systemName: isPinned ? "pin.slash" : "pin") .font(.caption) .contentTransition(.symbolEffect(.replace)) .accessibilityLabel(isPinned ? "Unpin worktree" : "Pin worktree") } .buttonStyle(.plain) .help(isPinned ? "Unpin" : "Pin to top") } if isHovered, archiveAction != nil { Button { archiveAction?() } label: { Image(systemName: "archivebox") .font(.caption) .accessibilityLabel("Archive worktree") } .buttonStyle(.plain) .help("Archive Worktree") } if isRunScriptRunning { RunScriptIndicator(onStop: onStopRunScript) } if let lineChangePresentation { if let onDiffTap { Button { onDiffTap() } label: { WorktreeRowChangeCountView( presentation: lineChangePresentation, isSelected: isSelected, ) } .buttonStyle(.plain) .help( [ AppShortcuts.helpText( title: "Show Diff", commandID: AppShortcuts.CommandID.showDiff, in: resolvedKeybindings ), lineChangePresentation.incompleteCountDescription, ] .compactMap { $0 } .joined(separator: "\n") ) } else { WorktreeRowChangeCountView( presentation: lineChangePresentation, isSelected: isSelected, ) .help(lineChangePresentation.incompleteCountDescription ?? "") } } } WorktreeRowInfoView( worktreeName: detailText, showsPullRequestTag: showsPullRequestTag, pullRequestNumber: display.pullRequest?.number, pullRequestURL: display.pullRequest.flatMap { URL(string: $0.url) }, pullRequestState: display.pullRequestState, mergeReadiness: mergeReadiness, isQueued: isQueued, shortcutHint: shortcutHint, showsShortcutHint: showsShortcutHint ) .padding(.leading, 22) } .padding(.horizontal, 2) .frame(maxWidth: .infinity, minHeight: worktreeRowHeight, alignment: .center) }
private func pullRequestMergeReadiness( for pullRequest: GithubPullRequest? ) -> PullRequestMergeReadiness? { guard let pullRequest, pullRequest.state.uppercased() == "OPEN" else { return nil } return PullRequestMergeReadiness(pullRequest: pullRequest) }
private var worktreeRowHeight: CGFloat { 36 }}
private struct RunScriptIndicator: View { let onStop: (() -> Void)? @State private var isHovering = false
var body: some View { Button { onStop?() } label: { Image(systemName: isHovering ? "stop.fill" : "play.fill") .font(.caption) .foregroundStyle(isHovering ? .red : .green) .contentTransition(.symbolEffect(.replace)) .accessibilityLabel(isHovering ? "Stop run script" : "Run script active") } .buttonStyle(.plain) .help(isHovering ? "Stop Script" : "Run script active") .disabled(onStop == nil) .onHover { isHovering = $0 } }}
private struct WorktreeRowInfoView: View { let worktreeName: String let showsPullRequestTag: Bool let pullRequestNumber: Int? let pullRequestURL: URL? let pullRequestState: String? let mergeReadiness: PullRequestMergeReadiness? let isQueued: Bool let shortcutHint: String? let showsShortcutHint: Bool
var body: some View { HStack(spacing: 4) { summaryText .lineLimit(1) .truncationMode(.tail) .layoutPriority(1) Spacer(minLength: 0) if let shortcutHint { ShortcutHintView(text: shortcutHint, color: .secondary) .opacity(showsShortcutHint ? 1 : 0) .accessibilityHidden(!showsShortcutHint) } } .font(.caption) .frame(minHeight: 14) .animation(.easeInOut(duration: 0.15), value: showsShortcutHint) }
private var summaryText: Text { var result = AttributedString() func appendSeparator() { if !result.characters.isEmpty { var sep = AttributedString(" \u{2022} ") sep.foregroundColor = .secondary result.append(sep) } } if !worktreeName.isEmpty { var segment = AttributedString(worktreeName) segment.foregroundColor = .secondary result.append(segment) } if showsPullRequestTag, let pullRequestNumber { appendSeparator() var segment = AttributedString("PR #\(pullRequestNumber)") segment.foregroundColor = .secondary // Clickable via `Text`'s built-in link handling; the rest of the row // keeps its tap-to-select behavior. segment.link = pullRequestURL result.append(segment) } if pullRequestState == "MERGED" { appendSeparator() var segment = AttributedString(String(localized: "Merged")) segment.foregroundColor = PullRequestBadgeStyle.mergedColor result.append(segment) } else if pullRequestState == "CLOSED" { appendSeparator() var segment = AttributedString(String(localized: "Closed")) segment.foregroundColor = PullRequestBadgeStyle.closedColor result.append(segment) } else if isQueued { // A queued PR is mid-merge, so the queue state takes priority over the // merge-readiness label. appendSeparator() var segment = AttributedString(String(localized: "Queued")) segment.foregroundColor = PullRequestBadgeStyle.queuedColor result.append(segment) } else if let mergeReadiness { appendSeparator() var segment = AttributedString(mergeReadiness.label) segment.foregroundColor = mergeStatusColor(mergeReadiness) result.append(segment) } return Text(result) }
private func mergeStatusColor(_ readiness: PullRequestMergeReadiness) -> Color { switch readiness.blockingReason { case .none: return .green case .mergeConflicts, .changesRequested, .checksFailed, .blocked: return .red case .checksPending: return .yellow } }}
// MARK: - Previews
@MainActorprivate struct WorktreeRowPreview: View { @State private var hoveredID: String?
var body: some View { List { row(id: "main", name: "main", worktreeName: "Default", isMainWorktree: true) row( id: "diff", name: "feature/sidebar-redesign", worktreeName: "sidebar-redesign", addedLines: 120, removedLines: 45 ) row( id: "long-diff", name: "feature/long-running-sidebar-layout-stress-case", worktreeName: "sidebar-layout-stress", addedLines: 632, removedLines: 344 ) row( id: "pinned", name: "feature/pinned-branch", worktreeName: "pinned-branch", isPinned: true) row(id: "running", name: "feature/auth-flow", worktreeName: "auth-flow", taskStatus: .running) row(id: "loading", name: "creating-worktree...", worktreeName: "Setting up", isLoading: true) row( id: "notif", name: "feature/notifications", worktreeName: "notifications", showsNotificationIndicator: true) row( id: "script", name: "feature/run-script", worktreeName: "run-script", isRunScriptRunning: true) row(id: "hint", name: "feature/shortcuts", worktreeName: "shortcuts", shortcutHint: "⌘1") row(id: "selected", name: "feature/selected", worktreeName: "selected", isSelected: true) } .listStyle(.sidebar) .scrollIndicators(.never) .frame(width: 280, height: 550) }
private func row( id: String, name: String, worktreeName: String, isPinned: Bool = false, isMainWorktree: Bool = false, isLoading: Bool = false, taskStatus: WorktreeTaskStatus? = nil, isRunScriptRunning: Bool = false, showsNotificationIndicator: Bool = false, addedLines: Int? = nil, removedLines: Int? = nil, isSelected: Bool = false, shortcutHint: String? = nil ) -> some View { let info: WorktreeInfoEntry? = if let addedLines, let removedLines { WorktreeInfoEntry(addedLines: addedLines, removedLines: removedLines, pullRequest: nil) } else { nil } let isHovered = hoveredID == id return WorktreeRow( name: name, worktreeName: worktreeName, info: info, showsPullRequestInfo: false, isHovered: isHovered, isPinned: isPinned, isMainWorktree: isMainWorktree, isLoading: isLoading, taskStatus: taskStatus, isRunScriptRunning: isRunScriptRunning, showsNotificationIndicator: showsNotificationIndicator, notifications: [], onFocusNotification: { _ in }, shortcutHint: shortcutHint, showsShortcutHint: shortcutHint != nil, pinAction: {}, isSelected: isSelected, archiveAction: {}, onDiffTap: addedLines != nil ? {} : nil, onStopRunScript: isRunScriptRunning ? {} : nil ) .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) .onHover { hovering in hoveredID = hovering ? id : nil } }}
#Preview("WorktreeRow") { WorktreeRowPreview()}
// MARK: - Subviews
private struct WorktreeRowChangeCountView: View { let presentation: LineChangeBadgePresentation let isSelected: Bool
var body: some View { HStack(spacing: 4) { Text(presentation.addedText) .foregroundStyle(.green) Text(presentation.removedText) .foregroundStyle(.red) .baselineOffset(-1) } .font(.caption) .lineLimit(1) .padding(.horizontal, 4) .padding(.vertical, 0) .fixedSize(horizontal: true, vertical: false) .accessibilityElement(children: .ignore) .accessibilityLabel(presentation.accessibilityLabel) .overlay { Capsule() .stroke( isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), lineWidth: 1) } .monospacedDigit() }}