native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413import AppKitimport ComposableArchitectureimport Sharingimport SwiftUI
private let shelfLogger = ProwlLogger("Shelf")
/// Root view for Shelf presentation mode.////// Phase 3 layout: three horizontal segments — a left stack of passed/// spines (each showing its book's tabs), the currently open book's/// terminal area, and a right stack of upcoming spines. Clicking a tab/// on any spine opens that book (when different) and selects that tab./// Animations and the ⌘-held digit overlay are layered in subsequent/// phases.struct ShelfView: View { let store: StoreOf<RepositoriesFeature> let terminalManager: WorktreeTerminalManager let createTab: () -> Void
/// Mirrors the Ghostty `background-opacity` setting so the Shelf can /// honor the same window transparency as normal view mode. A previous /// plain `.background(.background)` defeated transparency entirely by /// stamping an opaque layer behind every child — including the /// terminal surface and empty-state area. @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity @Shared(.repositoryAppearances) private var repositoryAppearances /// Drives the chrome tint mode / custom color and the Shelf spine tint /// preferences. @Shared(.settingsFile) private var settingsFile
var body: some View { // Body-invocation counter. The @ViewBuilder getter rules out a // `defer`-based interval, but a fire-and-forget event marker is a // simple expression and has no impact on the rendered tree. Each // marker corresponds to one full body re-evaluation — useful for // sanity-checking how often the root re-renders during animation. let _ = shelfLogger.event("ShelfView.body") let state = store.state let books = state.orderedShelfBooks(customTitles: state.repositoryCustomTitles) let openBook = state.openShelfBook(in: books) let openBookID = openBook?.id let openIndex = openBook.flatMap { book in books.firstIndex(where: { $0.id == book.id }) } let activeAgentEntriesByWorktreeID = settingsFile.global.showActiveAgentStatusInShelf ? Dictionary(grouping: state.activeAgents.entries) { entry in entry.worktreeID } : [:] // Color identity of the open book's repo (nil ⇒ neutral surface). Shared // by the spine fill and the toolbar band so they read as one "L". let openColor = openBook.flatMap { repositoryAppearances[$0.repositoryID]?.color } // Chrome band fill for the toolbar (top) and nav (leading), honoring the // user's window tint mode. Only shown when a book is open; an empty // shelf keeps its bare chrome. let chromeFill = openBook == nil ? nil : WindowChromeTint.fill( mode: settingsFile.global.windowTintMode, customColor: settingsFile.global.windowTintCustomColor.color, repositoryColor: openColor )
HStack(spacing: 0) { ForEach(Array(books.enumerated()), id: \.element.id) { index, book in spine( book: book, index: index, openIndex: openIndex, activeAgentEntries: activeAgentEntriesByWorktreeID[book.id] ?? [] ) if book.id == openBookID { openBookArea(for: book, state: state) } } if openBook == nil { emptyOpenArea() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBackgroundOpacity)) // Tint the toolbar (top) and nav (leading) chrome. The bands bleed up // under the titlebar and left under the floating glass sidebar, so once // the glass blends the leading band in, the nav panel, the open spine, // and the toolbar all read as one continuous color. .windowChromeTint(chromeFill, edges: [.top, .leading]) .overlay { ShelfSwipeEventMonitor(isEnabled: !books.isEmpty) { action in switch action { case .nextBook: store.send(.selectNextShelfBook) case .previousBook: store.send(.selectPreviousShelfBook) case .nextTab: store.send(.selectNextWorktree) case .previousTab: store.send(.selectPreviousWorktree) } } .accessibilityHidden(true) .allowsHitTesting(false) .frame(maxWidth: .infinity, maxHeight: .infinity) } // Animate on every openBookID change — covers both Shelf-originated // book switches (which also set their own TCA animation) and // left-nav-originated switches, so the spine flow is consistent // regardless of entry point. .animation(.easeInOut(duration: 0.2), value: openBookID) }
@ViewBuilder private func spine( book: ShelfBook, index: Int, openIndex: Int?, activeAgentEntries: [ActiveAgentEntry] ) -> some View { let distance = openIndex.map { abs(index - $0) } let open = index == openIndex ShelfSpineView( book: book, isOpen: open, distanceFromOpen: distance, terminalState: terminalManager.stateIfExists(for: book.id), activeAgentEntries: activeAgentEntries, tintFallback: settingsFile.global.shelfSpineTintFallback, followsRepositoryColor: settingsFile.global.shelfSpineTintFollowsRepositoryColor, onOpenBook: { openBook(book, selectingTab: nil) }, onSelectTab: { tabID in openBook(book, selectingTab: tabID) }, onSelectAgent: { agentID in store.send(.activeAgents(.entryTapped(agentID))) }, onNewTab: { // On a closed spine, `+` doubles as "pull this book out and // start a fresh tab". Sequencing is fine because TCA runs // reducers synchronously — `newTerminal` will observe the // new `selectedTerminalWorktree` set by `selectWorktree`. switchToBookIfNeeded(book) createTab() }, onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil, onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil, closeMenuTitle: closeMenuTitle(for: book), onCloseBook: { closeBook(book) }, onOpenRepositorySettings: { store.send(.repositoryManagement(.openRepositorySettings(book.repositoryID))) } ) }
/// Dispatch the open-book action only when `book` isn't already the open /// one — idempotent helper for taps that imply a book change. /// /// No `animation:` is passed to `store.send` because the visible /// spine-flow animation is already driven by the view-level /// `.animation(.easeInOut(duration: 0.2), value: openBookID)` modifier /// on the root container — wrapping the dispatch in another animation /// transaction would double-run layout / transition machinery for the /// same change. private func switchToBookIfNeeded(_ book: ShelfBook) { guard !isOpen(book) else { return } shelfLogger.event("BookClick.NewTabSpine") switch book.kind { case .worktree: store.send(.selectWorktree(book.id, focusTerminal: true)) case .plainFolder: store.send(.selectRepository(book.repositoryID)) } }
private func performSplit(direction: String) { guard let openID = store.state.openShelfBookID, let state = terminalManager.stateIfExists(for: openID) else { return } _ = state.performBindingActionOnFocusedSurface(direction) }
/// "Close Worktree / Close Folder" context action. Equivalent to /// closing the last tab on this book: tears down all of its terminal /// tabs, which lets the existing `tabClosed(remainingTabs: 0)` → /// `markWorktreeClosed` pipeline retire the book from the Shelf and /// auto-advance selection. Intentionally does *not* archive the /// worktree or remove the repository — Shelf removal is a view-state /// concern, not a destructive resource operation. private func closeBook(_ book: ShelfBook) { if let state = terminalManager.stateIfExists(for: book.id), !state.tabManager.tabs.isEmpty { state.closeAllTabs() } else { // No live tabs to fall through the closeAllTabs → tabClosed // pipeline — drive the Shelf removal directly. store.send(.markWorktreeClosed(book.id)) } }
private func closeMenuTitle(for book: ShelfBook) -> String { switch book.kind { case .worktree: String(localized: "Close Worktree") case .plainFolder: String(localized: "Close Folder") } }
private func isOpen(_ book: ShelfBook) -> Bool { store.state.openShelfBookID == book.id }
@ViewBuilder private func openBookArea(for book: ShelfBook, state: RepositoriesFeature.State) -> some View { if let worktree = state.selectedTerminalWorktree, worktree.id == book.id { let shouldFocus = state.shouldFocusTerminal(for: worktree.id) ShelfOpenBookView( worktree: worktree, manager: terminalManager, shouldRunSetupScript: state.pendingSetupScriptWorktreeIDs.contains(worktree.id), forceAutoFocus: shouldFocus ) .frame(maxWidth: .infinity, maxHeight: .infinity) .id(worktree.id) .onAppear { if shouldFocus { store.send(.worktreeCreation(.consumeTerminalFocus(worktree.id))) } } } else { emptyOpenArea() } }
@ViewBuilder private func emptyOpenArea() -> some View { ContentUnavailableView( "No worktree selected", systemImage: "books.vertical", description: Text("Click a worktree to open it.") ) .frame(maxWidth: .infinity, maxHeight: .infinity) }
/// Open `book` and optionally select a specific tab on it. For the open /// book's own tab slots (no book change), this skips the worktree /// re-selection and just tells the tab manager to switch tab. private func openBook(_ book: ShelfBook, selectingTab tabID: TerminalTabID?) { let isAlreadyOpen = store.state.openShelfBookID == book.id if let tabID, isAlreadyOpen, let state = terminalManager.stateIfExists(for: book.id) { shelfLogger.event("BookClick.TabSwitchSameBook") state.tabManager.selectTab(tabID) return } shelfLogger.event("BookClick.SwitchBook") // The spine flow / terminal crossfade animation is already driven // by the view-level `.animation(_:value: openBookID)` on the root // container (~200ms ease-in-out per the Shelf design doc), so the // dispatch itself does not pass an `animation:` argument here. switch book.kind { case .worktree: store.send(.selectWorktree(book.id, focusTerminal: true)) case .plainFolder: store.send(.selectRepository(book.repositoryID)) } if let tabID { // Apply tab selection eagerly; the target book's state already exists // if the user has opened it before. For first-time opens the tab // manager seeds a default tab which we won't override. terminalManager.stateIfExists(for: book.id)?.tabManager.selectTab(tabID) } }}
enum ShelfSwipeNavigationAction: Equatable { case nextBook case previousBook case nextTab case previousTab}
struct ShelfSwipeGestureClassifier { static let swipeThreshold: CGFloat = 80 static let axisDominanceRatio: CGFloat = 1.6
static func action( accumulatedDeltaX: CGFloat, accumulatedDeltaY: CGFloat ) -> ShelfSwipeNavigationAction? { let absX = abs(accumulatedDeltaX) let absY = abs(accumulatedDeltaY) if absX >= swipeThreshold, absX > absY * axisDominanceRatio { return accumulatedDeltaX > 0 ? .previousBook : .nextBook } if absY >= swipeThreshold, absY > absX * axisDominanceRatio { return accumulatedDeltaY > 0 ? .previousTab : .nextTab } return nil }}
private struct ShelfSwipeEventMonitor: NSViewRepresentable { let isEnabled: Bool let onSwipe: (ShelfSwipeNavigationAction) -> Void
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> NSView { let view = NSView() context.coordinator.view = view context.coordinator.installIfNeeded() return view }
func updateNSView(_ nsView: NSView, context: Context) { context.coordinator.view = nsView context.coordinator.isEnabled = isEnabled context.coordinator.onSwipe = onSwipe context.coordinator.installIfNeeded() }
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.removeMonitor() }
final class Coordinator { weak var view: NSView? var isEnabled = false var onSwipe: (ShelfSwipeNavigationAction) -> Void = { _ in }
private var monitor: Any? private var accumulatedDeltaX: CGFloat = 0 private var accumulatedDeltaY: CGFloat = 0 private var lastEventTimestamp: TimeInterval = 0 private var didTriggerCurrentGesture = false
private let eventGapResetInterval: TimeInterval = 0.25
func installIfNeeded() { guard monitor == nil else { return } monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in self?.handle(event) ?? event } }
func removeMonitor() { guard let monitor else { return } NSEvent.removeMonitor(monitor) self.monitor = nil }
private func handle(_ event: NSEvent) -> NSEvent? { guard isEnabled, let view, let window = view.window, event.window === window, view.bounds.contains(view.convert(event.locationInWindow, from: nil)) else { resetGesture() return event }
guard event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command) else { resetGesture() return event }
if event.phase.contains(.began) || event.timestamp - lastEventTimestamp > eventGapResetInterval { resetGesture() } lastEventTimestamp = event.timestamp
if didTriggerCurrentGesture { if event.phase.contains(.ended) || event.phase.contains(.cancelled) || event.momentumPhase.contains(.ended) || event.momentumPhase.contains(.cancelled) { resetGesture() } return nil }
guard event.momentumPhase.isEmpty else { return nil }
accumulatedDeltaX += event.scrollingDeltaX accumulatedDeltaY += event.scrollingDeltaY
guard let action = ShelfSwipeGestureClassifier.action( accumulatedDeltaX: accumulatedDeltaX, accumulatedDeltaY: accumulatedDeltaY ) else { return nil }
resetAccumulatedDeltas() didTriggerCurrentGesture = true onSwipe(action) return nil }
private func resetGesture() { resetAccumulatedDeltas() didTriggerCurrentGesture = false }
private func resetAccumulatedDeltas() { accumulatedDeltaX = 0 accumulatedDeltaY = 0 } }}