native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
32 kB · 852 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853import ComposableArchitectureimport Sharingimport SwiftUI
// Uses LazyVStack rather than List for repository drag precision; keyboard// worktree navigation goes through Cmd+Ctrl+↑/↓ (`selectNextWorktree`).struct SidebarListView: View { enum RepositoryListHeaderAction: Equatable { case expandAll case expandActive case collapseAll
var title: String { switch self { case .expandAll: return String(localized: "Expand All") case .expandActive: return String(localized: "Expand Active") case .collapseAll: return String(localized: "Collapse All") } }
var helpText: String { switch self { case .expandActive: return String(localized: "Expand repositories with open tabs") case .expandAll, .collapseAll: return title } }
var systemImageName: String { switch self { case .expandActive: return "chevron.right.2" case .expandAll, .collapseAll: return "chevron.right" } }
var rotation: Angle { switch self { case .expandAll, .expandActive: return .zero case .collapseAll: return .degrees(90) } } }
@Bindable var store: StoreOf<RepositoriesFeature> @Binding var expandedRepoIDs: Set<Repository.ID> @Binding var sidebarSelections: Set<SidebarSelection> let terminalManager: WorktreeTerminalManager @State private var isDragActive = false @State private var draggingRepositoryID: Repository.ID? @State private var targetedRepositoryDropDestination: Int? @State private var sidebarHeight = 0.0 @State private var sidebarFooterHeight = 0.0 @State private var resizingPanelHeight: Double? @State private var isAddChoicePresented = false @Namespace private var topSegmentNamespace @Environment(\.resolvedKeybindings) private var resolvedKeybindings
var body: some View { let state = store.state let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) let pendingSidebarReveal = state.pendingSidebarReveal
let maximumPanelHeight = sidebarHeight > 0 ? ActiveAgentsFeature.maximumPanelHeight(forContainerHeight: sidebarHeight) : ActiveAgentsFeature.maximumPanelHeight // The Active Agents panel and its `entries` read live in // `SidebarActiveAgentsOverlay` so that agent-state churn re-evaluates only // that overlay, not this body and the repository list. This body must not // read `state.activeAgents.entries`. let panelHeight = min(resizingPanelHeight ?? state.activeAgents.panelHeight, maximumPanelHeight) let panelOffset = state.activeAgents.isPanelHidden ? panelHeight : 0 let activeAgentsPanelTopGap = 4.0 let listBottomPadding = state.activeAgents.isPanelHidden ? 0 : panelHeight + activeAgentsPanelTopGap
ScrollViewReader { scrollProxy in ScrollView { // Avoid LazyVStack here: after collapsing and expanding large sections, // SwiftUI's lazy placement cache can spin on the main thread while scrolling. VStack(spacing: 0) { RemoteMirrorSidebar() // When there are no repositories the sidebar stays empty — the // detail pane's `EmptyStateView` ("Open a repository or folder") // carries the prompt and the Add button instead. if !repositoryItems.isEmpty { repositoryListHeader( expandableRepositoryIDs: expandableRepositoryIDs ) } ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in repositoryItemView( item, index: index, repositoryOrderIDs: presentation.repositoryOrderIDs, hotkeyRows: hotkeyRows, selectedWorktreeIDs: selectedWorktreeIDs ) } } .padding(.vertical, 2) .padding(.bottom, listBottomPadding) } .scrollIndicators(.never) .frame(minWidth: 220) .clipped() .onGeometryChange(for: Double.self) { proxy in Double(proxy.size.height) } action: { newHeight in sidebarHeight = newHeight } .onDragSessionUpdated { session in // Data transfer can finish before the drag becomes active on macOS. if case .ended = session.phase { endSidebarDrag() } } .safeAreaInset(edge: .top, spacing: 0) { topSegmentBar } .safeAreaInset(edge: .bottom, spacing: 0) { SidebarFooterView(store: store) .onGeometryChange(for: Double.self) { proxy in Double(proxy.size.height) } action: { newHeight in sidebarFooterHeight = newHeight } .padding(.vertical, 4) } .overlay { if repositoryItems.isEmpty { Text("Repositories you add will appear here") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 24) // Sit above dead-center for better visual balance in the tall panel. .offset(y: -40) .accessibilityAddTraits(.isStaticText) } } .overlay(alignment: .bottom) { SidebarActiveAgentsOverlay( store: store, terminalManager: terminalManager, panelHeight: panelHeight, maximumPanelHeight: maximumPanelHeight, panelOffset: panelOffset, isPanelHidden: state.activeAgents.isPanelHidden, sidebarFooterHeight: sidebarFooterHeight, onHeightChanged: { height in resizingPanelHeight = height }, onHeightChangeEnded: { height in resizingPanelHeight = nil store.send(.activeAgents(.panelHeightChanged(height))) } ) } .dropDestination(for: URL.self) { urls, _ in let fileURLs = urls.filter(\.isFileURL) guard !fileURLs.isEmpty else { return false } store.send(.repositoryManagement(.openRepositories(fileURLs))) return true } .onAppear { resetSidebarDrag() } .task(id: pendingSidebarReveal?.id) { await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) } .toolbar { ToolbarItem(placement: .automatic) { Button { isAddChoicePresented = true } label: { Label("Add...", systemImage: "folder.badge.plus") } .help("Add Repository or Workspace") .persistentPopover(isPresented: $isAddChoicePresented) { AddToProwlView( dismiss: { isAddChoicePresented = false }, onBrowse: { store.send(.setOpenPanelPresented(true)) }, onCloneCompleted: { url in store.send(.repositoryManagement(.openRepositories([url]))) }, onWorkspace: { store.send(.workspaceCreation(.promptRequested)) }, onDrop: { urls in store.send(.repositoryManagement(.openRepositories(urls))) } ) } } } } // ScrollViewReader }
// Fixed, opaque top bar (Xcode-navigator style): stays put while the list // scrolls underneath, so it neither bounces with the scroll nor lets repo // rows show through. Custom segmented control because the system .segmented // Picker only renders bare icon/text and ignores option layout, so it can't // be widened to fill; equal-width buttons inside a glass capsule track give // the macOS 26 look while truly filling the width. private var topSegmentBar: some View { HStack(spacing: 4) { topSegmentButton( .tabbed, systemImage: "checklist.unchecked", title: "Default", accessibilityIdentifier: "sidebar-view-mode-default" ) topSegmentButton( .canvas, systemImage: "square.grid.2x2", title: "Canvas", accessibilityIdentifier: "sidebar-view-mode-canvas", shortcutCommandID: AppShortcuts.CommandID.toggleCanvas, requiresRepository: true ) topSegmentButton( .shelf, systemImage: "distribute.horizontal.fill", title: "Shelf", accessibilityIdentifier: "sidebar-view-mode-shelf", shortcutCommandID: AppShortcuts.CommandID.toggleShelf, requiresRepository: true ) } .background { // Glass track, brightened by the same fill the terminal tab bar's capsule // uses so the inactive (unselected) segments read at the same level as the // tab bar instead of sitting darker on the bare material. Capsule() .fill(.thinMaterial) .overlay(Capsule().fill(TerminalTabBarColors.barBackground)) } .padding(.horizontal, 8) .padding(.vertical, 2) }
private func topSegmentButton( _ segment: TopSegment, systemImage: String, title: LocalizedStringResource, accessibilityIdentifier: String, shortcutCommandID: String? = nil, requiresRepository: Bool = false ) -> some View { let isSelected = store.topSegment == segment // Canvas and Shelf need at least one repository; with none, only Normal // (Default) is available, so disable them. let isDisabled = segment == .shelf ? !store.canEnterShelf : requiresRepository && store.repositories.isEmpty let localizedTitle = String(localized: title) let helpText = isDisabled ? (segment == .shelf ? String(localized: "Shelf — open a worktree or folder first") : String(localized: "\(localizedTitle) — add a repository first")) : shortcutCommandID.map { AppShortcuts.helpText(title: title, commandID: $0, in: resolvedKeybindings) } ?? localizedTitle return Button { store.send(.setTopSegment(segment)) } label: { Image(systemName: systemImage) .accessibilityHidden(true) .frame(maxWidth: .infinity) .frame(height: 28) .foregroundStyle(isSelected ? AnyShapeStyle(Color.white) : AnyShapeStyle(.secondary)) .background { if isSelected { Capsule() .fill(Color.accentColor) .matchedGeometryEffect(id: "topSegmentPill", in: topSegmentNamespace) } } .contentShape(.capsule) .opacity(isDisabled ? 0.35 : 1) } .buttonStyle(.plain) .disabled(isDisabled) .help(helpText) .accessibilityLabel(Text(localizedTitle)) .accessibilityIdentifier(accessibilityIdentifier) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) }
private func focusTerminalAfterSidebarSelection(worktreeID: Worktree.ID?) { guard let worktreeID else { return } Task { @MainActor [terminalManager] in for _ in 0..<4 { await Task.yield() if let terminalState = terminalManager.stateIfExists(for: worktreeID) { terminalState.focusSelectedTab() return } } } }
private func repositoryListHeader( expandableRepositoryIDs: Set<Repository.ID> ) -> some View { HStack(spacing: 4) { Text("Repositories") .font(.caption) .foregroundStyle(.tertiary) .frame(maxWidth: .infinity, alignment: .leading) if !expandableRepositoryIDs.isEmpty { RepositoryListHeaderToggle( repositories: store.state.repositories, expandableRepositoryIDs: expandableRepositoryIDs, expandedRepoIDs: $expandedRepoIDs, terminalManager: terminalManager ) } } .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) .padding(.leading, 12) .padding(.trailing, 7) .padding(.top, 2) .padding(.bottom, 4) }
@ViewBuilder private func repositoryItemView( _ item: SidebarItem, index: Int, repositoryOrderIDs: [Repository.ID], hotkeyRows: [WorktreeRowModel], selectedWorktreeIDs: Set<Worktree.ID> ) -> some View { Group { switch item { case .repository(let model): if let repository = store.state.repositories[id: model.repositoryID] { RepositorySectionView( repository: repository, hasTopSpacing: index > 0, isDragActive: isDragActive, hotkeyRows: hotkeyRows, selectedWorktreeIDs: selectedWorktreeIDs, expandedRepoIDs: $expandedRepoIDs, store: store, terminalManager: terminalManager, onRepositorySelected: { selectRepository(repository) } ) .draggableRepository( id: model.repositoryID, isEnabled: !model.isRemoving, beginDrag: { beginSidebarDrag(repositoryID: model.repositoryID) } ) }
case .failedRepository(let model): FailedRepositoryRow( name: model.name, path: model.path, isGitUnavailable: model.isGitUnavailable, retry: { store.send(.refreshWorktrees) }, showFailure: { store.send(.showRepositoryLoadFailure(model.id)) }, removeRepository: { store.send(.repositoryManagement(.removeFailedRepository(model.id))) } ) .padding(.horizontal, 12) .overlay(alignment: .top) { if index > 0 { Rectangle() .fill(.secondary) .frame(height: 1) .frame(maxWidth: .infinity) .accessibilityHidden(true) } } .draggableRepository( id: model.id, isEnabled: model.isReorderable, beginDrag: { beginSidebarDrag(repositoryID: model.id) } )
case .listHeader, .archivedWorktrees: EmptyView() } } .repositoryDropTarget( index: index, repositoryOrderIDs: repositoryOrderIDs, isEnabled: isDragActive, targetedDestination: $targetedRepositoryDropDestination, actions: SidebarDropTargetActions( draggedItemID: draggingRepositoryID, onDrop: { offsets, destination in withAnimation(.easeOut(duration: 0.2)) { _ = store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) } }, onDragEnded: endSidebarDrag ) ) }
private func beginSidebarDrag(repositoryID: Repository.ID) { guard !isDragActive else { return } draggingRepositoryID = repositoryID isDragActive = true store.send(.worktreeOrdering(.setSidebarDragActive(true))) }
private func endSidebarDrag() { targetedRepositoryDropDestination = nil draggingRepositoryID = nil isDragActive = false store.send(.worktreeOrdering(.setSidebarDragActive(false))) }
private func resetSidebarDrag() { targetedRepositoryDropDestination = nil draggingRepositoryID = nil isDragActive = false store.send(.worktreeOrdering(.setSidebarDragActive(false))) }
private func selectRepository(_ repository: Repository) { if repository.capabilities.supportsWorktrees { withAnimation(.easeOut(duration: 0.2)) { if expandedRepoIDs.contains(repository.id) { expandedRepoIDs.remove(repository.id) } else { expandedRepoIDs.insert(repository.id) } } sidebarSelections = [] } else { sidebarSelections = [.repository(repository.id)] if store.state.isShowingCanvas, Self.repositoryHeaderOpensCanvasTarget(repository) { store.send(.focusCanvasRepository(repository.id)) } else { store.send(.selectRepository(repository.id)) focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) } } }
@MainActor private func revealPendingSidebarWorktree( _ pendingSidebarReveal: PendingSidebarReveal?, with scrollProxy: ScrollViewProxy ) async { guard let pendingSidebarReveal else { return } // Give SwiftUI time to materialize newly expanded section rows before scrolling. await Task.yield() await Task.yield() withAnimation(.easeOut(duration: 0.2)) { scrollProxy.scrollTo( SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) } store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) }
static func expandableRepositoryIDs<Repositories: Sequence>( in repositories: Repositories ) -> Set<Repository.ID> where Repositories.Element == Repository { Set( repositories .filter { $0.capabilities.supportsWorktrees || $0.isWorkspace } .map(\.id) ) }
/// Expandable repositories/workspaces with at least one open terminal tab. static func activeRepositoryIDs<Repositories: Sequence>( in repositories: Repositories, expandableRepositoryIDs: Set<Repository.ID>, terminalManager: WorktreeTerminalManager ) -> Set<Repository.ID> where Repositories.Element == Repository { Set( repositories .filter { expandableRepositoryIDs.contains($0.id) } .filter { RepositorySectionView.openTabCount(for: $0, terminalManager: terminalManager) > 0 } .map(\.id) ) }
/// Next action offered by the header toggle, derived purely from the /// current expansion state (no stored mode). Cycle from fully collapsed: /// Expand Active → Expand All → Collapse All. Expand Active is skipped /// when the active set is empty or covers every expandable repository, /// and any manually mixed state falls back to Collapse All. static func repositoryListHeaderAction( expandedRepoIDs: Set<Repository.ID>, expandableRepositoryIDs: Set<Repository.ID>, activeRepositoryIDs: Set<Repository.ID> ) -> RepositoryListHeaderAction { let expanded = expandedRepoIDs.intersection(expandableRepositoryIDs) let active = activeRepositoryIDs.intersection(expandableRepositoryIDs) let activeIsProperSubset = !active.isEmpty && active != expandableRepositoryIDs if expanded.isEmpty { return activeIsProperSubset ? .expandActive : .expandAll } if activeIsProperSubset, expanded == active { return .expandAll } return .collapseAll }
static func selectedWorktreeIDs(in state: RepositoriesFeature.State) -> Set<Worktree.ID> { var selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs if let selectedWorktreeID = state.selectedWorktreeID { selectedWorktreeIDs.insert(selectedWorktreeID) } return selectedWorktreeIDs }
static func repositoryHeaderOpensCanvasTarget(_ repository: Repository) -> Bool { repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees }
static func activeAgentWorktreeMetadata( repositories: IdentifiedArrayOf<Repository>, customTitles: [Repository.ID: String], repositoryAppearances: [Repository.ID: RepositoryAppearance] = [:] ) -> ActiveAgentWorktreeMetadata { var repositoryNamesByWorktreeID: [Worktree.ID: String] = [:] var branchNamesByWorktreeID: [Worktree.ID: String] = [:] var repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice] = [:]
for repository in repositories { let repositoryName = customTitles[repository.id] ?? repository.name let repositoryColor = repositoryAppearances[repository.id]?.color if repository.capabilities.supportsRunnableFolderActions && !repository.capabilities.supportsWorktrees { repositoryNamesByWorktreeID[repository.id] = repositoryName branchNamesByWorktreeID[repository.id] = repository.name if let repositoryColor { repositoryColorsByWorktreeID[repository.id] = repositoryColor } } for worktree in repository.worktrees { repositoryNamesByWorktreeID[worktree.id] = repositoryName branchNamesByWorktreeID[worktree.id] = worktree.name if let repositoryColor { repositoryColorsByWorktreeID[worktree.id] = repositoryColor } } }
return ActiveAgentWorktreeMetadata( repositoryNamesByWorktreeID: repositoryNamesByWorktreeID, branchNamesByWorktreeID: branchNamesByWorktreeID, repositoryColorsByWorktreeID: repositoryColorsByWorktreeID ) }
/// Resolves the repository/branch label shown for each active agent from the directory the /// agent actually runs in, rather than the tab's owning worktree. static func activeAgentRowDisplays( entries: IdentifiedArrayOf<ActiveAgentEntry>, repositories: IdentifiedArrayOf<Repository>, metadata: ActiveAgentWorktreeMetadata ) -> [ActiveAgentEntry.ID: ActiveAgentRowDisplay] { // Resolutions are memoized across renders, not just shared within this batch. The index made // the *build* side cheap; the lookup still normalizes the queried directory, and that // normalization is a filesystem round-trip. This view re-runs whenever an agent's state // changes, so re-resolving unchanged directories put N `stat`s plus N symlink walks on the // main thread per frame. let resolvedWorktreeIDs = WorktreeDirectoryIndexCache.worktreeIDs( forWorkingDirectories: entries.compactMap(\.workingDirectory), in: repositories ) var displays: [ActiveAgentEntry.ID: ActiveAgentRowDisplay] = [:] for entry in entries { let resolvedWorktreeID = entry.workingDirectory.flatMap { resolvedWorktreeIDs[$0] } displays[entry.id] = activeAgentRowDisplay( for: entry, repositories: repositories, metadata: metadata, resolvedWorktreeID: resolvedWorktreeID ) } return displays }
/// Three-tier resolution for the displayed name/branch of a single agent: /// 1. `workingDirectory` falls inside a known repo/worktree → use it, so the label tracks live /// branch renames through `metadata`. /// 2. `workingDirectory` is known but outside every repo → derive a name from its last path /// component (same logic as adding a repository). /// 3. `workingDirectory` is unknown → fall back to the surface's owning worktree (legacy behavior). /// `directoryIndex` resolves the entry's working directory; passing `nil` builds a throwaway /// index for the single lookup. `activeAgentRowDisplays` does not come through here — it resolves /// through the memo in `WorktreeDirectoryIndexCache` and calls the `resolvedWorktreeID` overload. static func activeAgentRowDisplay( for entry: ActiveAgentEntry, repositories: IdentifiedArrayOf<Repository>, metadata: ActiveAgentWorktreeMetadata, directoryIndex: WorktreeDirectoryIndex? = nil ) -> ActiveAgentRowDisplay { let resolvedWorktreeID = entry.workingDirectory.flatMap { workingDirectory in (directoryIndex ?? WorktreeDirectoryIndex(repositories: repositories)) .worktreeID(forWorkingDirectory: workingDirectory) } return activeAgentRowDisplay( for: entry, repositories: repositories, metadata: metadata, resolvedWorktreeID: resolvedWorktreeID ) }
/// Builds the display from an already-resolved owning worktree, so a caller that resolves in /// bulk pays the directory normalization once per directory instead of once per row. static func activeAgentRowDisplay( for entry: ActiveAgentEntry, repositories: IdentifiedArrayOf<Repository>, metadata: ActiveAgentWorktreeMetadata, resolvedWorktreeID: Worktree.ID? ) -> ActiveAgentRowDisplay { if let workingDirectory = entry.workingDirectory { if let key = resolvedWorktreeID { let fallbackName = workingDirectory.lastPathComponent return ActiveAgentRowDisplay( repositoryName: metadata.repositoryNamesByWorktreeID[key] ?? fallbackName, branchName: metadata.branchNamesByWorktreeID[key] ?? fallbackName, color: metadata.repositoryColorsByWorktreeID[key], directory: workingDirectory ) } let name = Repository.name(for: workingDirectory) return ActiveAgentRowDisplay( repositoryName: name, branchName: name, color: nil, directory: workingDirectory ) } return ActiveAgentRowDisplay( repositoryName: metadata.repositoryNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName, branchName: metadata.branchNamesByWorktreeID[entry.worktreeID] ?? entry.worktreeName, color: metadata.repositoryColorsByWorktreeID[entry.worktreeID], directory: directory(forWorktreeID: entry.worktreeID, in: repositories) ) }
/// Finds the most specific repo/worktree whose directory contains `workingDirectory`. Plain /// folders are keyed by their repository id (matching `activeAgentWorktreeMetadata`); git repos /// are matched through their worktrees (the main worktree covers the repo root). When nested /// directories both match (e.g. a worktree inside a repo), the deepest one wins. static func resolveWorktreeID( forWorkingDirectory workingDirectory: URL, in repositories: IdentifiedArrayOf<Repository> ) -> Worktree.ID? { WorktreeDirectoryIndex(repositories: repositories) .worktreeID(forWorkingDirectory: workingDirectory) }
/// Directory of the surface's owning worktree, used when the agent hasn't /// reported a working directory. Plain folders are keyed by repository id. static func directory( forWorktreeID worktreeID: Worktree.ID, in repositories: IdentifiedArrayOf<Repository> ) -> URL? { for repository in repositories { if let worktree = repository.worktrees[id: worktreeID] { return worktree.workingDirectory } } guard let repository = repositories[id: worktreeID], repository.capabilities.supportsRunnableFolderActions else { return nil } return repository.rootURL }}
/// Header expand/collapse toggle as its own leaf view: deriving the offered/// action needs per-repository open-tab counts, and this split keeps the/// `WorktreeTerminalManager` read out of `SidebarListView.body` (same/// isolation rationale as `RepoHeaderTabCountBadge`).private struct RepositoryListHeaderToggle: View { let repositories: IdentifiedArrayOf<Repository> let expandableRepositoryIDs: Set<Repository.ID> @Binding var expandedRepoIDs: Set<Repository.ID> let terminalManager: WorktreeTerminalManager
var body: some View { let activeRepositoryIDs = SidebarListView.activeRepositoryIDs( in: repositories, expandableRepositoryIDs: expandableRepositoryIDs, terminalManager: terminalManager ) let action = SidebarListView.repositoryListHeaderAction( expandedRepoIDs: expandedRepoIDs, expandableRepositoryIDs: expandableRepositoryIDs, activeRepositoryIDs: activeRepositoryIDs ) Button { withAnimation(.easeOut(duration: 0.2)) { switch action { case .expandAll: expandedRepoIDs.formUnion(expandableRepositoryIDs) case .expandActive: var next = expandedRepoIDs next.subtract(expandableRepositoryIDs) next.formUnion(activeRepositoryIDs) expandedRepoIDs = next case .collapseAll: expandedRepoIDs.subtract(expandableRepositoryIDs) } } } label: { Label(action.title, systemImage: action.systemImageName) .labelStyle(.iconOnly) .frame(width: 20, height: 20) .rotationEffect(action.rotation) .contentShape(.rect) } .buttonStyle(.plain) .foregroundStyle(.secondary) .help(action.helpText) }}
struct ActiveAgentWorktreeMetadata: Equatable { let repositoryNamesByWorktreeID: [Worktree.ID: String] let branchNamesByWorktreeID: [Worktree.ID: String] let repositoryColorsByWorktreeID: [Worktree.ID: RepositoryColorChoice]}
struct ActiveAgentRowDisplay: Equatable { let repositoryName: String let branchName: String let color: RepositoryColorChoice? /// The directory the agent runs in (or its owning worktree's directory as a /// fallback); drives the context menu's Copy Path / Reveal in Finder. let directory: URL?}
extension SidebarItem { fileprivate var isRepositoryOrderItem: Bool { repositoryOrderID != nil }}
// MARK: - Previews
#if DEBUG @MainActor private struct SidebarLayoutPreview: View { @State private var expandedRepoIDs: Set<Repository.ID> @State private var sidebarSelections: Set<SidebarSelection> = [] private let store: StoreOf<RepositoriesFeature> private let terminalManager: WorktreeTerminalManager = .preview
init() { let state = Self.mockState _expandedRepoIDs = State(initialValue: Set(state.repositories.map(\.id))) store = Store(initialState: state) { EmptyReducer() } }
var body: some View { SidebarListView( store: store, expandedRepoIDs: $expandedRepoIDs, sidebarSelections: $sidebarSelections, terminalManager: terminalManager ) .environment(CommandKeyObserver()) .frame(width: 320, height: 500) }
private static var mockState: RepositoriesFeature.State { let repo1Root = URL(fileURLWithPath: "/tmp/supacode") let repo1Worktrees: IdentifiedArrayOf<Worktree> = [ Worktree( id: repo1Root.path, name: "main", detail: ".", workingDirectory: repo1Root, repositoryRootURL: repo1Root ), Worktree( id: "/tmp/wt/sidebar", name: "feature/sidebar-redesign", detail: "/tmp/wt/sidebar", workingDirectory: URL(fileURLWithPath: "/tmp/wt/sidebar"), repositoryRootURL: repo1Root ), Worktree( id: "/tmp/wt/auth", name: "feature/auth", detail: "/tmp/wt/auth", workingDirectory: URL(fileURLWithPath: "/tmp/wt/auth"), repositoryRootURL: repo1Root ), Worktree( id: "/tmp/wt/crash", name: "fix/crash", detail: "/tmp/wt/crash", workingDirectory: URL(fileURLWithPath: "/tmp/wt/crash"), repositoryRootURL: repo1Root ), ] let repo1 = Repository( id: repo1Root.path, rootURL: repo1Root, name: "supacode", worktrees: repo1Worktrees )
let repo2Root = URL(fileURLWithPath: "/tmp/ghostty") let repo2Worktrees: IdentifiedArrayOf<Worktree> = [ Worktree( id: repo2Root.path, name: "main", detail: ".", workingDirectory: repo2Root, repositoryRootURL: repo2Root ), Worktree( id: "/tmp/wt/renderer", name: "feature/renderer", detail: "/tmp/wt/renderer", workingDirectory: URL(fileURLWithPath: "/tmp/wt/renderer"), repositoryRootURL: repo2Root ), ] let repo2 = Repository( id: repo2Root.path, rootURL: repo2Root, name: "ghostty", worktrees: repo2Worktrees )
var state = RepositoriesFeature.State() state.repositories = [repo1, repo2] state.pinnedWorktreeIDs = ["/tmp/wt/auth"] state.worktreeInfoByID = [ "/tmp/wt/sidebar": WorktreeInfoEntry(addedLines: 120, removedLines: 45, pullRequest: nil) ] return state } }
#Preview("Sidebar Layout") { SidebarLayoutPreview() }#endif