native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #186 from supabitapp/revert-origin-main-41f5ff6

Revert "Redesign and refactor sidebar"

authored by

Stefano Bertagno and committed by
GitHub
8e6ef265 41f5ff66

+970 -1554
-1
.gitignore
··· 67 67 Resources/terminfo 68 68 *.profraw 69 69 .env 70 - .DS_Store
+2 -6
supacode/App/AppShortcuts.swift
··· 6 6 // Compile-time checkable shortcut identifier. 7 7 nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRepresentable { 8 8 case commandPalette, openSettings, checkForUpdates 9 - case toggleLeftSidebar, revealInSidebar 9 + case toggleLeftSidebar 10 10 case newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree 11 11 case deleteWorktree, confirmWorktreeAction 12 12 case selectNextWorktree, selectPreviousWorktree ··· 37 37 case .openSettings: "openSettings" 38 38 case .checkForUpdates: "checkForUpdates" 39 39 case .toggleLeftSidebar: "toggleLeftSidebar" 40 - case .revealInSidebar: "revealInSidebar" 41 40 case .newWorktree: "newWorktree" 42 41 case .refreshWorktrees: "refreshWorktrees" 43 42 case .archivedWorktrees: "archivedWorktrees" ··· 61 60 "openSettings": .openSettings, 62 61 "checkForUpdates": .checkForUpdates, 63 62 "toggleLeftSidebar": .toggleLeftSidebar, 64 - "revealInSidebar": .revealInSidebar, 65 63 "newWorktree": .newWorktree, 66 64 "refreshWorktrees": .refreshWorktrees, 67 65 "archivedWorktrees": .archivedWorktrees, ··· 96 94 case .openSettings: "Open Settings" 97 95 case .checkForUpdates: "Check For Updates" 98 96 case .toggleLeftSidebar: "Toggle Left Sidebar" 99 - case .revealInSidebar: "Reveal in Sidebar" 100 97 case .newWorktree: "New Worktree" 101 98 case .refreshWorktrees: "Refresh Worktrees" 102 99 case .archivedWorktrees: "Archived Worktrees" ··· 268 265 static let checkForUpdates = AppShortcut(id: .checkForUpdates, key: "u", modifiers: .command) 269 266 270 267 static let toggleLeftSidebar = AppShortcut(id: .toggleLeftSidebar, key: "[", modifiers: .command) 271 - static let revealInSidebar = AppShortcut(id: .revealInSidebar, key: "e", modifiers: [.command, .shift]) 272 268 273 269 static let newWorktree = AppShortcut(id: .newWorktree, key: "n", modifiers: .command) 274 270 static let refreshWorktrees = AppShortcut(id: .refreshWorktrees, key: "r", modifiers: [.command, .shift]) ··· 321 317 322 318 static let groups: [AppShortcutGroup] = [ 323 319 AppShortcutGroup(category: .general, shortcuts: [commandPalette, openSettings, checkForUpdates]), 324 - AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar, revealInSidebar]), 320 + AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar]), 325 321 AppShortcutGroup( 326 322 category: .worktrees, 327 323 shortcuts: [
-56
supacode/App/ContentView.swift
··· 5 5 // Created by khoi on 20/1/26. 6 6 // 7 7 8 - import AppKit 9 8 import ComposableArchitecture 10 9 import SwiftUI 11 10 import UniformTypeIdentifiers ··· 89 88 ) 90 89 } 91 90 .focusedSceneValue(\.toggleLeftSidebarAction, toggleLeftSidebar) 92 - .focusedSceneValue(\.revealInSidebarAction, revealInSidebarAction) 93 91 .overlay { 94 92 CommandPaletteOverlayView( 95 93 store: store.scope(state: \.commandPalette, action: \.commandPalette), ··· 106 104 withAnimation(.easeOut(duration: 0.2)) { 107 105 leftSidebarVisibility = leftSidebarVisibility == .detailOnly ? .all : .detailOnly 108 106 } 109 - } 110 - 111 - private var revealInSidebarAction: (() -> Void)? { 112 - guard store.repositories.selectedWorktreeID != nil else { return nil } 113 - return { revealInSidebar() } 114 - } 115 - 116 - private func revealInSidebar() { 117 - withAnimation(.easeOut(duration: 0.2)) { 118 - leftSidebarVisibility = .all 119 - } 120 - store.send(.repositories(.revealSelectedWorktreeInSidebar)) 121 - Task { @MainActor in 122 - await Self.focusSidebarOutlineViewWhenReady() 123 - } 124 - } 125 - 126 - private static func focusSidebarOutlineViewWhenReady(maxAttempts: Int = 8) async { 127 - for attempt in 0..<maxAttempts { 128 - guard !focusSidebarOutlineViewIfReady(), 129 - attempt < maxAttempts - 1 130 - else { 131 - return 132 - } 133 - await Task.yield() 134 - } 135 - } 136 - 137 - private static func focusSidebarOutlineViewIfReady() -> Bool { 138 - guard let window = NSApp.keyWindow, 139 - let outlineView = firstOutlineView(in: window.contentView) 140 - else { return false } 141 - outlineView.layoutSubtreeIfNeeded() 142 - outlineView.enclosingScrollView?.layoutSubtreeIfNeeded() 143 - window.makeFirstResponder(outlineView) 144 - let selectedRow = outlineView.selectedRow 145 - guard selectedRow >= 0 else { return false } 146 - let rowRect = outlineView.rect(ofRow: selectedRow) 147 - if let clipView = outlineView.enclosingScrollView?.contentView { 148 - let centeredY = rowRect.midY - clipView.bounds.height / 2 149 - let clampedY = max(0, min(centeredY, outlineView.bounds.height - clipView.bounds.height)) 150 - clipView.setBoundsOrigin(NSPoint(x: 0, y: clampedY)) 151 - } 152 - return true 153 - } 154 - 155 - private static func firstOutlineView(in view: NSView?) -> NSOutlineView? { 156 - guard let view else { return nil } 157 - if let outlineView = view as? NSOutlineView { return outlineView } 158 - for subview in view.subviews { 159 - guard let found = firstOutlineView(in: subview) else { continue } 160 - return found 161 - } 162 - return nil 163 107 } 164 108 165 109 }
-7
supacode/App/supacodeApp.swift
··· 216 216 } 217 217 .appKeyboardShortcut(settings) 218 218 } 219 - CommandGroup(replacing: .help) { 220 - Button("Submit GitHub Issue") { 221 - guard let url = URL(string: "https://github.com/supabitapp/supacode/issues/new") else { return } 222 - NSWorkspace.shared.open(url) 223 - } 224 - .help("Submit GitHub Issue") 225 - } 226 219 CommandGroup(replacing: .appTermination) { 227 220 Button("Quit Supacode") { 228 221 store.send(.requestQuit)
-16
supacode/Assets.xcassets/git-branch.imageset/Contents.json
··· 1 - { 2 - "images" : [ 3 - { 4 - "filename" : "git-branch.svg", 5 - "idiom" : "universal" 6 - } 7 - ], 8 - "info" : { 9 - "author" : "xcode", 10 - "version" : 1 11 - }, 12 - "properties" : { 13 - "preserves-vector-representation" : true, 14 - "template-rendering-intent" : "template" 15 - } 16 - }
-1
supacode/Assets.xcassets/git-branch.imageset/git-branch.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="black" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>
-16
supacode/Assets.xcassets/git-default.imageset/Contents.json
··· 1 - { 2 - "images" : [ 3 - { 4 - "filename" : "git-default.svg", 5 - "idiom" : "universal" 6 - } 7 - ], 8 - "info" : { 9 - "author" : "xcode", 10 - "version" : 1 11 - }, 12 - "properties" : { 13 - "preserves-vector-representation" : true, 14 - "template-rendering-intent" : "template" 15 - } 16 - }
-5
supacode/Assets.xcassets/git-default.imageset/git-default.svg
··· 1 - <?xml version="1.0" encoding="UTF-8"?> 2 - <!-- Generated by Pixelmator Pro 4.0 --> 3 - <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> 4 - <path id="Path" fill="#000000" stroke="none" d="M 9.5 12.751913 C 9.500345 13.896059 10.359278 14.857762 11.496118 14.986864 C 12.632957 15.115966 13.685661 14.371352 13.942553 13.256418 C 14.199444 12.141484 13.578712 11.011295 12.5 10.629913 L 12.5 10.001913 C 12.5 8.621201 11.380712 7.501913 10 7.501913 L 6 7.501913 C 5.447715 7.501913 5 7.054198 5 6.501913 L 5 5.373913 C 6.03374 5.008614 6.653928 3.951669 6.468612 2.871058 C 6.283296 1.790447 5.346385 1.000532 4.25 1.000532 C 3.153615 1.000532 2.216704 1.790447 2.031388 2.871058 C 1.846072 3.951669 2.46626 5.008614 3.5 5.373913 L 3.5 10.629913 C 2.46684 10.99519 1.847096 12.051662 2.032403 13.131713 C 2.21771 14.211763 3.154168 15.001233 4.25 15.001233 C 5.345832 15.001233 6.28229 14.211763 6.467597 13.131713 C 6.652904 12.051662 6.03316 10.99519 5 10.629913 L 5 8.793913 C 5.315405 8.93152 5.655884 9.002339 6 9.001913 L 10 9.001913 C 10.552285 9.001913 11 9.449629 11 10.001913 L 11 10.629913 C 10.100785 10.947834 9.499712 11.798152 9.5 12.751913 Z M 3.5 12.751913 C 3.5 12.3377 3.835786 12.001913 4.25 12.001913 C 4.664214 12.001913 5 12.3377 5 12.751913 C 5 13.166126 4.664214 13.501913 4.25 13.501913 C 3.835786 13.501913 3.5 13.166126 3.5 12.751913 Z M 11.75 13.501913 C 11.335787 13.501913 11 13.166126 11 12.751913 C 11 12.3377 11.335787 12.001913 11.75 12.001913 C 12.164213 12.001913 12.5 12.3377 12.5 12.751913 C 12.5 13.166126 12.164213 13.501913 11.75 13.501913 Z M 4.25 4.001913 C 3.835786 4.001913 3.5 3.666126 3.5 3.251913 C 3.5 2.8377 3.835786 2.501913 4.25 2.501913 C 4.664214 2.501913 5 2.8377 5 3.251913 C 5 3.666126 4.664214 4.001913 4.25 4.001913 Z"/> 5 - </svg>
-16
supacode/Assets.xcassets/git-merge.imageset/Contents.json
··· 1 - { 2 - "images" : [ 3 - { 4 - "filename" : "git-merge.svg", 5 - "idiom" : "universal" 6 - } 7 - ], 8 - "info" : { 9 - "author" : "xcode", 10 - "version" : 1 11 - }, 12 - "properties" : { 13 - "preserves-vector-representation" : true, 14 - "template-rendering-intent" : "template" 15 - } 16 - }
-1
supacode/Assets.xcassets/git-merge.imageset/git-merge.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="black" d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM3.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>
-16
supacode/Assets.xcassets/git-pull-request-closed.imageset/Contents.json
··· 1 - { 2 - "images" : [ 3 - { 4 - "filename" : "git-pull-request-closed.svg", 5 - "idiom" : "universal" 6 - } 7 - ], 8 - "info" : { 9 - "author" : "xcode", 10 - "version" : 1 11 - }, 12 - "properties" : { 13 - "preserves-vector-representation" : true, 14 - "template-rendering-intent" : "template" 15 - } 16 - }
-1
supacode/Assets.xcassets/git-pull-request-closed.imageset/git-pull-request-closed.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="black" d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.28a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042L10.56 3.5l1.22 1.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L9.5 4.56 8.28 5.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L8.44 3.5 7.22 2.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L9.5 2.44ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>
-16
supacode/Assets.xcassets/git-pull-request-draft.imageset/Contents.json
··· 1 - { 2 - "images" : [ 3 - { 4 - "filename" : "git-pull-request-draft.svg", 5 - "idiom" : "universal" 6 - } 7 - ], 8 - "info" : { 9 - "author" : "xcode", 10 - "version" : 1 11 - }, 12 - "properties" : { 13 - "preserves-vector-representation" : true, 14 - "template-rendering-intent" : "template" 15 - } 16 - }
-1
supacode/Assets.xcassets/git-pull-request-draft.imageset/git-pull-request-draft.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="black" d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 3.25 1Zm9.5 14a2.25 2.25 0 1 1 0-4.5 2.25 2.25 0 0 1 0 4.5ZM2.5 3.25a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0ZM3.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm9.5 0a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM14 6.5a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Zm0-4a1.25 1.25 0 1 1-2.5 0 1.25 1.25 0 0 1 2.5 0Z"/></svg>
-16
supacode/Assets.xcassets/git-pull-request.imageset/Contents.json
··· 1 - { 2 - "images" : [ 3 - { 4 - "filename" : "git-pull-request.svg", 5 - "idiom" : "universal" 6 - } 7 - ], 8 - "info" : { 9 - "author" : "xcode", 10 - "version" : 1 11 - }, 12 - "properties" : { 13 - "preserves-vector-representation" : true, 14 - "template-rendering-intent" : "template" 15 - } 16 - }
-1
supacode/Assets.xcassets/git-pull-request.imageset/git-pull-request.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill="black" d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z"/></svg>
+2 -30
supacode/Commands/SidebarCommands.swift
··· 3 3 4 4 struct SidebarCommands: Commands { 5 5 @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction 6 - @FocusedValue(\.revealInSidebarAction) private var revealInSidebarAction 7 6 @Shared(.settingsFile) private var settingsFile 8 - @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst 9 - @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true 10 7 11 8 var body: some Commands { 12 - let overrides = settingsFile.global.shortcutOverrides 13 - let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: overrides) 14 - let revealInSidebar = AppShortcuts.revealInSidebar.effective(from: overrides) 9 + let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: settingsFile.global.shortcutOverrides) 15 10 CommandGroup(replacing: .sidebar) { 16 - Button("Toggle Left Sidebar", systemImage: "sidebar.leading") { 11 + Button("Toggle Left Sidebar") { 17 12 toggleLeftSidebarAction?() 18 13 } 19 14 .appKeyboardShortcut(toggleLeftSidebar) 20 15 .help("Toggle Left Sidebar (\(toggleLeftSidebar?.display ?? "none"))") 21 16 .disabled(toggleLeftSidebarAction == nil) 22 - Button("Reveal in Sidebar") { 23 - revealInSidebarAction?() 24 - } 25 - .appKeyboardShortcut(revealInSidebar) 26 - .help("Reveal in Sidebar (\(revealInSidebar?.display ?? "none"))") 27 - .disabled(revealInSidebarAction == nil) 28 - Section { 29 - Picker("Title and Subtitle", systemImage: "textformat", selection: Binding($displayMode)) { 30 - ForEach(WorktreeRowDisplayMode.allCases) { mode in 31 - Text(mode.label).tag(mode) 32 - } 33 - } 34 - Toggle("Hide Subtitle on Match", isOn: Binding($hideSubtitleOnMatch)) 35 - } 36 17 } 37 18 } 38 19 } ··· 41 22 typealias Value = () -> Void 42 23 } 43 24 44 - private struct RevealInSidebarActionKey: FocusedValueKey { 45 - typealias Value = () -> Void 46 - } 47 - 48 25 extension FocusedValues { 49 26 var toggleLeftSidebarAction: (() -> Void)? { 50 27 get { self[ToggleLeftSidebarActionKey.self] } 51 28 set { self[ToggleLeftSidebarActionKey.self] = newValue } 52 - } 53 - 54 - var revealInSidebarAction: (() -> Void)? { 55 - get { self[RevealInSidebarActionKey.self] } 56 - set { self[RevealInSidebarActionKey.self] = newValue } 57 29 } 58 30 }
+1 -2
supacode/Commands/TerminalCommands.swift
··· 13 13 14 14 var body: some Commands { 15 15 CommandGroup(after: .newItem) { 16 - Divider() 17 - Button("New Terminal", systemImage: "apple.terminal") { 16 + Button("New Terminal") { 18 17 newTerminalAction?() 19 18 } 20 19 .modifier(KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "new_tab")))
+53 -63
supacode/Commands/WorktreeCommands.swift
··· 37 37 let run = AppShortcuts.runScript.effective(from: overrides) 38 38 let stop = AppShortcuts.stopRunScript.effective(from: overrides) 39 39 CommandMenu("Worktrees") { 40 - // Creation and opening. 41 - Button("New Worktree…", systemImage: "plus") { 42 - store.send(.repositories(.createRandomWorktree)) 40 + Button("Select Next Worktree") { 41 + store.send(.repositories(.selectNextWorktree)) 42 + } 43 + .appKeyboardShortcut(selectNext) 44 + .help("Select Next Worktree (\(selectNext?.display ?? "none"))") 45 + .disabled(orderedRows.isEmpty) 46 + Button("Select Previous Worktree") { 47 + store.send(.repositories(.selectPreviousWorktree)) 48 + } 49 + .appKeyboardShortcut(selectPrevious) 50 + .help("Select Previous Worktree (\(selectPrevious?.display ?? "none"))") 51 + .disabled(orderedRows.isEmpty) 52 + Divider() 53 + let worktreeShortcutsList = worktreeShortcuts(from: overrides) 54 + ForEach(worktreeShortcutsList.indices, id: \.self) { index in 55 + WorktreeShortcutButton( 56 + index: index, 57 + shortcut: worktreeShortcutsList[index], 58 + orderedRows: orderedRows, 59 + store: store 60 + ) 43 61 } 44 - .appKeyboardShortcut(newWt) 45 - .help("New Worktree (\(newWt?.display ?? "none"))") 46 - .disabled(!repositories.canCreateWorktree) 47 - Button("Open in Finder", systemImage: "folder") { 62 + } 63 + CommandGroup(replacing: .newItem) { 64 + Button("Open Repository...", systemImage: "folder") { 65 + store.send(.repositories(.setOpenPanelPresented(true))) 66 + } 67 + .appKeyboardShortcut(openRepo) 68 + .help("Open Repository (\(openRepo?.display ?? "none"))") 69 + Button("Open Worktree") { 48 70 openSelectedWorktreeAction?() 49 71 } 50 72 .appKeyboardShortcut(openWorktree) 51 - .help("Open in Finder (\(openWorktree?.display ?? "none"))") 73 + .help("Open Worktree (\(openWorktree?.display ?? "none"))") 52 74 .disabled(openSelectedWorktreeAction == nil) 53 - Button("Open Pull Request", systemImage: "arrow.up.forward") { 75 + Button("Open Pull Request on GitHub") { 54 76 if let pullRequestURL { 55 77 NSWorkspace.shared.open(pullRequestURL) 56 78 } 57 79 } 58 80 .appKeyboardShortcut(openPR) 59 - .help("Open Pull Request (\(openPR?.display ?? "none"))") 81 + .help("Open Pull Request on GitHub (\(openPR?.display ?? "none"))") 60 82 .disabled(pullRequestURL == nil || !githubIntegrationEnabled) 61 - Divider() 62 - // Lifecycle. 63 - Button("Refresh Worktrees", systemImage: "arrow.clockwise") { 64 - store.send(.repositories(.refreshWorktrees)) 83 + Button("New Worktree", systemImage: "plus") { 84 + store.send(.repositories(.createRandomWorktree)) 65 85 } 66 - .appKeyboardShortcut(refresh) 67 - .help("Refresh (\(refresh?.display ?? "none"))") 68 - Button("Archived Worktrees", systemImage: "archivebox") { 86 + .appKeyboardShortcut(newWt) 87 + .help("New Worktree (\(newWt?.display ?? "none"))") 88 + .disabled(!repositories.canCreateWorktree) 89 + Button("Archived Worktrees") { 69 90 store.send(.repositories(.selectArchivedWorktrees)) 70 91 } 71 92 .appKeyboardShortcut(archived) 72 93 .help("Archived Worktrees (\(archived?.display ?? "none"))") 73 - Divider() 74 - // Commands. 75 - Button("Archive Worktree…", systemImage: "archivebox") { 94 + Button("Archive Worktree") { 76 95 archiveWorktreeAction?() 77 96 } 78 97 .appKeyboardShortcut(archive) 79 98 .help("Archive Worktree (\(archive?.display ?? "none"))") 80 99 .disabled(archiveWorktreeAction == nil) 81 - Button("Delete Worktree…", systemImage: "trash") { 100 + Button("Delete Worktree") { 82 101 deleteWorktreeAction?() 83 102 } 84 103 .appKeyboardShortcut(deleteWt) 85 104 .help("Delete Worktree (\(deleteWt?.display ?? "none"))") 86 105 .disabled(deleteWorktreeAction == nil) 106 + Button("Confirm Worktree Action") { 107 + confirmWorktreeAction?() 108 + } 109 + .appKeyboardShortcut(confirm) 110 + .help("Confirm Worktree Action (\(confirm?.display ?? "none"))") 111 + .disabled(confirmWorktreeAction == nil) 112 + Button("Refresh Worktrees") { 113 + store.send(.repositories(.refreshWorktrees)) 114 + } 115 + .appKeyboardShortcut(refresh) 116 + .help("Refresh Worktrees (\(refresh?.display ?? "none"))") 87 117 Divider() 88 - // Scripts. 89 - Button("Run Script", systemImage: "play") { 118 + Button("Run Script") { 90 119 runScriptAction?() 91 120 } 92 121 .appKeyboardShortcut(run) 93 122 .help("Run Script (\(run?.display ?? "none"))") 94 123 .disabled(runScriptAction == nil) 95 - Button("Stop Script", systemImage: "stop") { 124 + Button("Stop Script") { 96 125 stopRunScriptAction?() 97 126 } 98 127 .appKeyboardShortcut(stop) 99 128 .help("Stop Script (\(stop?.display ?? "none"))") 100 129 .disabled(stopRunScriptAction == nil) 101 - Divider() 102 - // Navigation. 103 - Button("Select Next", systemImage: "chevron.down") { 104 - store.send(.repositories(.selectNextWorktree)) 105 - } 106 - .appKeyboardShortcut(selectNext) 107 - .help("Select Next (\(selectNext?.display ?? "none"))") 108 - .disabled(orderedRows.isEmpty) 109 - Button("Select Previous", systemImage: "chevron.up") { 110 - store.send(.repositories(.selectPreviousWorktree)) 111 - } 112 - .appKeyboardShortcut(selectPrevious) 113 - .help("Select Previous (\(selectPrevious?.display ?? "none"))") 114 - .disabled(orderedRows.isEmpty) 115 - // Direct worktree shortcuts. 116 - let worktreeShortcutsList = worktreeShortcuts(from: overrides) 117 - Menu("Select Worktree") { 118 - ForEach(worktreeShortcutsList.indices, id: \.self) { index in 119 - WorktreeShortcutButton( 120 - index: index, 121 - shortcut: worktreeShortcutsList[index], 122 - orderedRows: orderedRows, 123 - store: store 124 - ) 125 - } 126 - } 127 - } 128 - CommandGroup(replacing: .newItem) { 129 - Button("Add Repository...", systemImage: "folder.badge.plus") { 130 - store.send(.repositories(.setOpenPanelPresented(true))) 131 - } 132 - .appKeyboardShortcut(openRepo) 133 - .help("Add Repository (\(openRepo?.display ?? "none"))") 134 - Button("Confirm Action") { 135 - confirmWorktreeAction?() 136 - } 137 - .appKeyboardShortcut(confirm) 138 - .help("Confirm Action (\(confirm?.display ?? "none"))") 139 - .disabled(confirmWorktreeAction == nil) 140 130 } 141 131 } 142 132
+17 -135
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 129 129 case refreshWorktrees 130 130 case reloadRepositories(animated: Bool) 131 131 case repositoriesLoaded([Repository], failures: [LoadFailure], roots: [URL], animated: Bool) 132 - case selectionChanged(Set<SidebarSelection>, focusTerminal: Bool = false) 133 - case repositoryExpansionChanged(Repository.ID, isExpanded: Bool) 134 132 case selectArchivedWorktrees 135 133 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 136 134 case openRepositories([URL]) ··· 143 141 case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) 144 142 case selectNextWorktree 145 143 case selectPreviousWorktree 146 - case revealSelectedWorktreeInSidebar 147 144 case requestRenameBranch(Worktree.ID, String) 148 145 case createRandomWorktree 149 146 case createRandomWorktreeInRepository(Repository.ID) ··· 254 251 255 252 private struct ApplyRepositoriesResult { 256 253 let didPrunePinned: Bool 257 - // Auto-persisted via `@Shared`; tracked for consistency but not consumed in effect dispatch. 258 - let didPruneCollapsedRepositoryIDs: Bool 259 254 let didPruneRepositoryOrder: Bool 260 255 let didPruneWorktreeOrder: Bool 261 256 let didPruneArchivedWorktreeIDs: Bool ··· 542 537 } 543 538 return .merge(allEffects) 544 539 545 - case .selectionChanged(let selections, let focusTerminal): 546 - return reduceSelectionChanged( 547 - into: &state, 548 - selections: selections, 549 - focusTerminal: focusTerminal 550 - ) 551 - 552 - case .repositoryExpansionChanged(let repositoryID, let isExpanded): 553 - state.$collapsedRepositoryIDs.withLock { collapsedRepositoryIDs in 554 - if isExpanded { 555 - collapsedRepositoryIDs.removeAll { $0 == repositoryID } 556 - } else if !collapsedRepositoryIDs.contains(repositoryID) { 557 - collapsedRepositoryIDs.append(repositoryID) 558 - } 559 - collapsedRepositoryIDs.sort() 560 - } 561 - return .none 562 - 563 540 case .selectArchivedWorktrees: 564 541 state.selection = .archivedWorktrees 565 542 state.sidebarSelectedWorktreeIDs = [] ··· 589 566 case .selectPreviousWorktree: 590 567 guard let id = state.worktreeID(byOffset: -1) else { return .none } 591 568 return .send(.selectWorktree(id)) 592 - 593 - case .revealSelectedWorktreeInSidebar: 594 - guard let worktreeID = state.selectedWorktreeID, 595 - let repositoryID = state.repositoryID(containing: worktreeID) 596 - else { return .none } 597 - state.$collapsedRepositoryIDs.withLock { 598 - $0.removeAll { $0 == repositoryID } 599 - } 600 - return .none 601 569 602 570 case .requestRenameBranch(let worktreeID, let branchName): 603 571 guard let worktree = state.worktree(for: worktreeID) else { return .none } ··· 2771 2739 state.worktreeInfoByID = filteredWorktreeInfo 2772 2740 } 2773 2741 let didPrunePinned = prunePinnedWorktreeIDs(state: &state) 2774 - let didPruneCollapsedRepositoryIDs = pruneCollapsedRepositoryIDs(state: &state) 2775 2742 let didPruneRepositoryOrder = pruneRepositoryOrderIDs(roots: roots, state: &state) 2776 2743 let didPruneWorktreeOrder = pruneWorktreeOrderByRepository(roots: roots, state: &state) 2777 2744 let didPruneArchivedWorktreeIDs = ··· 2796 2763 } 2797 2764 return ApplyRepositoriesResult( 2798 2765 didPrunePinned: didPrunePinned, 2799 - didPruneCollapsedRepositoryIDs: didPruneCollapsedRepositoryIDs, 2800 2766 didPruneRepositoryOrder: didPruneRepositoryOrder, 2801 2767 didPruneWorktreeOrder: didPruneWorktreeOrder, 2802 2768 didPruneArchivedWorktreeIDs: didPruneArchivedWorktreeIDs ··· 2839 2805 } 2840 2806 } 2841 2807 2808 + private func selectionDidChange( 2809 + previousSelectionID: Worktree.ID?, 2810 + previousSelectedWorktree: Worktree?, 2811 + selectedWorktreeID: Worktree.ID?, 2812 + selectedWorktree: Worktree? 2813 + ) -> Bool { 2814 + if previousSelectionID != selectedWorktreeID { 2815 + return true 2816 + } 2817 + if previousSelectedWorktree?.workingDirectory != selectedWorktree?.workingDirectory { 2818 + return true 2819 + } 2820 + if previousSelectedWorktree?.repositoryRootURL != selectedWorktree?.repositoryRootURL { 2821 + return true 2822 + } 2823 + return false 2824 + } 2842 2825 } 2843 2826 2844 2827 extension RepositoriesFeature.State { ··· 2846 2829 selection?.worktreeID 2847 2830 } 2848 2831 2849 - var effectiveSidebarSelectedRows: [WorktreeRowModel] { 2850 - let selectedRows = orderedWorktreeRows().filter { sidebarSelectedWorktreeIDs.contains($0.id) } 2851 - return selectedRows.isEmpty ? (selectedRow(for: selectedWorktreeID).map { [$0] } ?? []) : selectedRows 2852 - } 2853 - 2854 2832 var expandedRepositoryIDs: Set<Repository.ID> { 2855 2833 let repositoryIDs = Set(repositories.map(\.id)) 2856 2834 let collapsedSet = Set(collapsedRepositoryIDs).intersection(repositoryIDs) 2857 2835 let pendingRepositoryIDs = Set(pendingWorktrees.map(\.repositoryID)) 2858 2836 return repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) 2859 - } 2860 - 2861 - func isRepositoryExpanded(_ repositoryID: Repository.ID) -> Bool { 2862 - expandedRepositoryIDs.contains(repositoryID) 2863 - } 2864 - 2865 - var sidebarSelections: Set<SidebarSelection> { 2866 - guard !isShowingArchivedWorktrees else { 2867 - return [.archivedWorktrees] 2868 - } 2869 - var selections = Set(sidebarSelectedWorktreeIDs.map(SidebarSelection.worktree)) 2870 - if let selectedWorktreeID { 2871 - selections.insert(.worktree(selectedWorktreeID)) 2872 - } 2873 - return selections 2874 2837 } 2875 2838 2876 2839 func worktreeID(byOffset offset: Int) -> Worktree.ID? { ··· 3623 3586 } 3624 3587 } 3625 3588 3626 - private func reduceSelectionChanged( 3627 - into state: inout RepositoriesFeature.State, 3628 - selections: Set<SidebarSelection>, 3629 - focusTerminal: Bool 3630 - ) -> Effect<RepositoriesFeature.Action> { 3631 - let previousSelection = state.selectedWorktreeID 3632 - let previousSelectedWorktree = state.worktree(for: previousSelection) 3633 - 3634 - guard !selections.contains(.archivedWorktrees) else { 3635 - state.selection = .archivedWorktrees 3636 - state.sidebarSelectedWorktreeIDs = [] 3637 - return .send(.delegate(.selectedWorktreeChanged(nil))) 3638 - } 3639 - 3640 - let orderedRows = state.orderedWorktreeRows() 3641 - let orderedWorktreeIDs = orderedRows.map(\.id) 3642 - let allWorktreeIDs = Set(orderedWorktreeIDs) 3643 - let requestedWorktreeIDs = Set(selections.compactMap(\.worktreeID)) 3644 - let nextSidebarSelectedWorktreeIDs = requestedWorktreeIDs.intersection(allWorktreeIDs) 3645 - let droppedIDs = requestedWorktreeIDs.subtracting(nextSidebarSelectedWorktreeIDs) 3646 - if !droppedIDs.isEmpty { 3647 - repositoriesLogger.debug("Selection dropped unknown worktree IDs: \(droppedIDs).") 3648 - } 3649 - 3650 - guard !nextSidebarSelectedWorktreeIDs.isEmpty else { 3651 - setSingleWorktreeSelection(nil, state: &state) 3652 - return .send(.delegate(.selectedWorktreeChanged(nil))) 3653 - } 3654 - 3655 - let nextSelectedWorktreeID = 3656 - if let selectedWorktreeID = state.selectedWorktreeID, 3657 - nextSidebarSelectedWorktreeIDs.contains(selectedWorktreeID) 3658 - { 3659 - selectedWorktreeID 3660 - } else { 3661 - orderedWorktreeIDs.first(where: nextSidebarSelectedWorktreeIDs.contains) 3662 - ?? nextSidebarSelectedWorktreeIDs.first 3663 - } 3664 - 3665 - state.selection = nextSelectedWorktreeID.map(SidebarSelection.worktree) 3666 - state.sidebarSelectedWorktreeIDs = nextSidebarSelectedWorktreeIDs 3667 - if focusTerminal, 3668 - let nextSelectedWorktreeID, 3669 - previousSelection != nextSelectedWorktreeID 3670 - { 3671 - state.pendingTerminalFocusWorktreeIDs.insert(nextSelectedWorktreeID) 3672 - } 3673 - 3674 - let selectedWorktree = state.worktree(for: nextSelectedWorktreeID) 3675 - let selectionChanged = selectionDidChange( 3676 - previousSelectionID: previousSelection, 3677 - previousSelectedWorktree: previousSelectedWorktree, 3678 - selectedWorktreeID: nextSelectedWorktreeID, 3679 - selectedWorktree: selectedWorktree 3680 - ) 3681 - return selectionChanged ? .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) : .none 3682 - } 3683 - 3684 - private func selectionDidChange( 3685 - previousSelectionID: Worktree.ID?, 3686 - previousSelectedWorktree: Worktree?, 3687 - selectedWorktreeID: Worktree.ID?, 3688 - selectedWorktree: Worktree? 3689 - ) -> Bool { 3690 - previousSelectionID != selectedWorktreeID 3691 - || previousSelectedWorktree?.workingDirectory != selectedWorktree?.workingDirectory 3692 - || previousSelectedWorktree?.repositoryRootURL != selectedWorktree?.repositoryRootURL 3693 - } 3694 - 3695 3589 private func repositoryForWorktreeCreation( 3696 3590 _ state: RepositoriesFeature.State 3697 3591 ) -> Repository? { ··· 3728 3622 return true 3729 3623 } 3730 3624 return false 3731 - } 3732 - 3733 - private func pruneCollapsedRepositoryIDs(state: inout RepositoriesFeature.State) -> Bool { 3734 - let repositoryIDs = Set(state.repositories.map(\.id)) 3735 - var didChange = false 3736 - state.$collapsedRepositoryIDs.withLock { collapsedRepositoryIDs in 3737 - let pruned = collapsedRepositoryIDs.filter { repositoryIDs.contains($0) } 3738 - didChange = pruned != collapsedRepositoryIDs 3739 - guard didChange else { return } 3740 - collapsedRepositoryIDs = pruned 3741 - } 3742 - return didChange 3743 3625 } 3744 3626 3745 3627 private func pruneRepositoryOrderIDs(
+2 -1
supacode/Features/Repositories/Views/NotificationPopoverButton.swift
··· 2 2 3 3 struct NotificationPopoverButton<Label: View>: View { 4 4 let notifications: [WorktreeTerminalNotification] 5 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 5 6 @ViewBuilder let label: () -> Label 6 7 @State private var isPresented = false 7 8 @State private var isHoveringButton = false ··· 23 24 updatePresentation() 24 25 } 25 26 .popover(isPresented: $isPresented) { 26 - NotificationPopoverView(notifications: notifications) 27 + NotificationPopoverView(notifications: notifications, onFocusNotification: onFocusNotification) 27 28 .onHover { hovering in 28 29 isHoveringPopover = hovering 29 30 updatePresentation()
+2 -2
supacode/Features/Repositories/Views/NotificationPopoverView.swift
··· 2 2 3 3 struct NotificationPopoverView: View { 4 4 let notifications: [WorktreeTerminalNotification] 5 - @Environment(\.focusNotificationAction) private var focusNotificationAction: (WorktreeTerminalNotification) -> Void 5 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 6 6 7 7 var body: some View { 8 8 let count = notifications.count ··· 17 17 Divider() 18 18 ForEach(notifications) { notification in 19 19 Button { 20 - focusNotificationAction(notification) 20 + onFocusNotification(notification) 21 21 } label: { 22 22 HStack(alignment: .top) { 23 23 Image(systemName: "bell")
+17
supacode/Features/Repositories/Views/RepoHeaderRow.swift
··· 1 + import SwiftUI 2 + 3 + struct RepoHeaderRow: View { 4 + let name: String 5 + let isRemoving: Bool 6 + var body: some View { 7 + HStack { 8 + Text(name) 9 + .foregroundStyle(.secondary) 10 + if isRemoving { 11 + Text("Removing...") 12 + .font(.caption) 13 + .foregroundStyle(.tertiary) 14 + } 15 + } 16 + } 17 + }
-17
supacode/Features/Repositories/Views/RepoSectionHeaderView.swift
··· 1 - import SwiftUI 2 - 3 - struct RepoSectionHeaderView: View { 4 - let name: String 5 - let isRemoving: Bool 6 - 7 - var body: some View { 8 - HStack { 9 - Text(name) 10 - if isRemoving { 11 - ProgressView() 12 - .controlSize(.small) 13 - .accessibilityLabel("Removing repository") 14 - } 15 - } 16 - } 17 - }
+146
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 1 + import ComposableArchitecture 2 + import Sharing 3 + import SwiftUI 4 + 5 + struct RepositorySectionView: View { 6 + let repository: Repository 7 + let showsTopSeparator: Bool 8 + let isDragActive: Bool 9 + let hotkeyRows: [WorktreeRowModel] 10 + let selectedWorktreeIDs: Set<Worktree.ID> 11 + @Binding var expandedRepoIDs: Set<Repository.ID> 12 + @Bindable var store: StoreOf<RepositoriesFeature> 13 + let terminalManager: WorktreeTerminalManager 14 + @Environment(\.colorScheme) private var colorScheme 15 + @State private var isHovering = false 16 + @Shared(.settingsFile) private var settingsFile 17 + 18 + var body: some View { 19 + let state = store.state 20 + let isExpanded = expandedRepoIDs.contains(repository.id) 21 + let isRemovingRepository = state.isRemovingRepository(repository) 22 + let openRepoSettings = { 23 + _ = store.send(.openRepositorySettings(repository.id)) 24 + } 25 + let toggleExpanded = { 26 + guard !isRemovingRepository else { return } 27 + withAnimation(.easeOut(duration: 0.2)) { 28 + if isExpanded { 29 + expandedRepoIDs.remove(repository.id) 30 + } else { 31 + expandedRepoIDs.insert(repository.id) 32 + } 33 + } 34 + } 35 + let isDragging = isDragActive 36 + 37 + Group { 38 + HStack { 39 + RepoHeaderRow( 40 + name: repository.name, 41 + isRemoving: isRemovingRepository 42 + ) 43 + .frame(maxWidth: .infinity, alignment: .leading) 44 + if isRemovingRepository && !isDragging { 45 + ProgressView() 46 + .controlSize(.small) 47 + } 48 + if isHovering && !isDragging { 49 + Menu { 50 + Button("Repo Settings") { 51 + openRepoSettings() 52 + } 53 + .help("Repo Settings ") 54 + Button("Remove Repository") { 55 + store.send(.requestRemoveRepository(repository.id)) 56 + } 57 + .help("Remove repository ") 58 + .disabled(isRemovingRepository) 59 + } label: { 60 + Label("Repository options", systemImage: "ellipsis") 61 + .labelStyle(.iconOnly) 62 + .frame(maxHeight: .infinity) 63 + .contentShape(Rectangle()) 64 + } 65 + .buttonStyle(.plain) 66 + .foregroundStyle(.secondary) 67 + .help("Repository options ") 68 + .disabled(isRemovingRepository) 69 + Button { 70 + store.send(.createRandomWorktreeInRepository(repository.id)) 71 + } label: { 72 + Label("New Worktree", systemImage: "plus") 73 + .labelStyle(.iconOnly) 74 + .frame(maxHeight: .infinity) 75 + .contentShape(Rectangle()) 76 + } 77 + .buttonStyle(.plain) 78 + .foregroundStyle(.secondary) 79 + .help( 80 + { 81 + let display = AppShortcuts.newWorktree.effective(from: settingsFile.global.shortcutOverrides)?.display 82 + return "New Worktree (\(display ?? "none"))" 83 + }() 84 + ) 85 + .disabled(isRemovingRepository) 86 + Button { 87 + toggleExpanded() 88 + } label: { 89 + Image(systemName: "chevron.right") 90 + .rotationEffect(.degrees(isExpanded ? 90 : 0)) 91 + .frame(maxHeight: .infinity) 92 + .contentShape(Rectangle()) 93 + .accessibilityLabel(isExpanded ? "Collapse" : "Expand") 94 + } 95 + .buttonStyle(.plain) 96 + .foregroundStyle(.secondary) 97 + .help(isExpanded ? "Collapse" : "Expand") 98 + } 99 + } 100 + .frame(maxWidth: .infinity) 101 + .frame(height: headerCellHeight, alignment: .center) 102 + .overlay(alignment: .top) { 103 + if showsTopSeparator { 104 + Rectangle() 105 + .fill(.secondary) 106 + .frame(height: 1) 107 + .frame(maxWidth: .infinity) 108 + .accessibilityHidden(true) 109 + } 110 + } 111 + .onHover { isHovering = $0 } 112 + .contentShape(.rect) 113 + .contextMenu { 114 + Button("Repo Settings") { 115 + openRepoSettings() 116 + } 117 + .help("Repo Settings ") 118 + Button("Remove Repository") { 119 + store.send(.requestRemoveRepository(repository.id)) 120 + } 121 + .help("Remove repository ") 122 + .disabled(isRemovingRepository) 123 + } 124 + .contentShape(.dragPreview, .rect) 125 + .tag(SidebarSelection.repository(repository.id)) 126 + .listRowBackground(Color.clear) 127 + .environment(\.colorScheme, colorScheme) 128 + .preferredColorScheme(colorScheme) 129 + 130 + if isExpanded { 131 + WorktreeRowsView( 132 + repository: repository, 133 + isExpanded: isExpanded, 134 + hotkeyRows: hotkeyRows, 135 + selectedWorktreeIDs: selectedWorktreeIDs, 136 + store: store, 137 + terminalManager: terminalManager 138 + ) 139 + } 140 + } 141 + } 142 + 143 + private var headerCellHeight: CGFloat { 144 + 34 145 + } 146 + }
+77
supacode/Features/Repositories/Views/SidebarFooterView.swift
··· 1 + import ComposableArchitecture 2 + import Sharing 3 + import SwiftUI 4 + 5 + struct SidebarFooterView: View { 6 + let store: StoreOf<RepositoriesFeature> 7 + @Environment(\.surfaceBottomChromeBackgroundOpacity) private var surfaceBottomChromeBackgroundOpacity 8 + @Environment(\.openURL) private var openURL 9 + @Environment(CommandKeyObserver.self) private var commandKeyObserver 10 + @Shared(.settingsFile) private var settingsFile 11 + 12 + var body: some View { 13 + let overrides = settingsFile.global.shortcutOverrides 14 + let openRepo = AppShortcuts.openRepository.effective(from: overrides) 15 + let refresh = AppShortcuts.refreshWorktrees.effective(from: overrides) 16 + let archived = AppShortcuts.archivedWorktrees.effective(from: overrides) 17 + let settings = AppShortcuts.openSettings.effective(from: overrides) 18 + HStack { 19 + Button { 20 + store.send(.setOpenPanelPresented(true)) 21 + } label: { 22 + HStack(spacing: 6) { 23 + Label("Add Repository", systemImage: "folder.badge.plus") 24 + .font(.callout) 25 + if commandKeyObserver.isPressed { 26 + ShortcutHintView(text: openRepo?.display ?? "", color: .secondary) 27 + } 28 + } 29 + } 30 + .help("Add Repository (\(openRepo?.display ?? "none"))") 31 + Spacer() 32 + Menu { 33 + Button("Submit GitHub issue", systemImage: "exclamationmark.bubble") { 34 + if let url = URL(string: "https://github.com/supabitapp/supacode/issues/new") { 35 + openURL(url) 36 + } 37 + } 38 + .help("Submit GitHub issue") 39 + } label: { 40 + Label("Help", systemImage: "questionmark.circle") 41 + .labelStyle(.iconOnly) 42 + } 43 + .menuIndicator(.hidden) 44 + .help("Help") 45 + Button { 46 + store.send(.refreshWorktrees) 47 + } label: { 48 + Image(systemName: "arrow.clockwise") 49 + .symbolEffect(.rotate, options: .repeating, isActive: store.state.isRefreshingWorktrees) 50 + .accessibilityLabel("Refresh Worktrees") 51 + } 52 + .help("Refresh Worktrees (\(refresh?.display ?? "none"))") 53 + .disabled(store.state.repositoryRoots.isEmpty && !store.state.isRefreshingWorktrees) 54 + Button { 55 + store.send(.selectArchivedWorktrees) 56 + } label: { 57 + Image(systemName: "archivebox") 58 + .accessibilityLabel("Archived Worktrees") 59 + } 60 + .help("Archived Worktrees (\(archived?.display ?? "none"))") 61 + Button("Settings", systemImage: "gearshape") { 62 + SettingsWindowManager.shared.show() 63 + } 64 + .labelStyle(.iconOnly) 65 + .help("Settings (\(settings?.display ?? "none"))") 66 + } 67 + .buttonStyle(.plain) 68 + .font(.callout) 69 + .padding(.horizontal, 12) 70 + .padding(.vertical, 8) 71 + .frame(maxWidth: .infinity, alignment: .leading) 72 + .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBottomChromeBackgroundOpacity)) 73 + .overlay(alignment: .top) { 74 + Divider() 75 + } 76 + } 77 + }
+137 -137
supacode/Features/Repositories/Views/SidebarListView.swift
··· 3 3 4 4 struct SidebarListView: View { 5 5 @Bindable var store: StoreOf<RepositoriesFeature> 6 + @Binding var expandedRepoIDs: Set<Repository.ID> 7 + @Binding var sidebarSelections: Set<SidebarSelection> 6 8 let terminalManager: WorktreeTerminalManager 9 + @State private var isDragActive = false 10 + 7 11 var body: some View { 8 12 let state = store.state 9 - let expandedRepoIDs = state.expandedRepositoryIDs 10 13 let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 11 14 let orderedRoots = state.orderedRepositoryRoots() 12 - let selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 13 - let currentSelections = state.sidebarSelections 15 + let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) 14 16 let selection = Binding<Set<SidebarSelection>>( 15 - get: { currentSelections }, 17 + get: { 18 + var nextSelections = sidebarSelections 19 + if state.isShowingArchivedWorktrees { 20 + nextSelections = [.archivedWorktrees] 21 + } else { 22 + nextSelections.remove(.archivedWorktrees) 23 + if let selectedWorktreeID = state.selectedWorktreeID { 24 + nextSelections.insert(.worktree(selectedWorktreeID)) 25 + } 26 + } 27 + return nextSelections 28 + }, 16 29 set: { newValue in 17 - guard newValue != currentSelections else { return } 18 - store.send(.selectionChanged(newValue)) 30 + var nextSelections = newValue 31 + let repositorySelections: [Repository.ID] = nextSelections.compactMap { selection in 32 + guard case .repository(let repositoryID) = selection else { return nil } 33 + return repositoryID 34 + } 35 + if !repositorySelections.isEmpty { 36 + withAnimation(.easeOut(duration: 0.2)) { 37 + for repositoryID in repositorySelections { 38 + guard let repository = store.state.repositories[id: repositoryID], 39 + !store.state.isRemovingRepository(repository) 40 + else { 41 + continue 42 + } 43 + if expandedRepoIDs.contains(repositoryID) { 44 + expandedRepoIDs.remove(repositoryID) 45 + } else { 46 + expandedRepoIDs.insert(repositoryID) 47 + } 48 + } 49 + } 50 + nextSelections = Set( 51 + nextSelections.filter { 52 + if case .repository = $0 { 53 + return false 54 + } 55 + return true 56 + }) 57 + } 58 + 59 + if nextSelections.contains(.archivedWorktrees) { 60 + sidebarSelections = [.archivedWorktrees] 61 + store.send(.selectArchivedWorktrees) 62 + return 63 + } 64 + 65 + let worktreeIDs = Set(nextSelections.compactMap(\.worktreeID)) 66 + guard !worktreeIDs.isEmpty else { 67 + if !repositorySelections.isEmpty { 68 + return 69 + } 70 + sidebarSelections = [] 71 + store.send(.selectWorktree(nil)) 72 + return 73 + } 74 + sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) 75 + if let selectedWorktreeID = state.selectedWorktreeID, worktreeIDs.contains(selectedWorktreeID) { 76 + return 77 + } 78 + let nextPrimarySelection = 79 + hotkeyRows.map(\.id).first(where: worktreeIDs.contains) 80 + ?? worktreeIDs.first 81 + store.send(.selectWorktree(nextPrimarySelection, focusTerminal: true)) 19 82 } 20 83 ) 21 84 let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 22 85 List(selection: selection) { 23 86 if orderedRoots.isEmpty { 24 - ForEach(store.repositories) { repository in 25 - SidebarRepositorySectionView( 87 + let repositories = store.repositories 88 + ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in 89 + RepositorySectionView( 26 90 repository: repository, 91 + showsTopSeparator: index > 0, 92 + isDragActive: isDragActive, 27 93 hotkeyRows: hotkeyRows, 28 94 selectedWorktreeIDs: selectedWorktreeIDs, 95 + expandedRepoIDs: $expandedRepoIDs, 29 96 store: store, 30 97 terminalManager: terminalManager 31 98 ) 99 + .listRowInsets(EdgeInsets()) 32 100 } 33 101 } else { 34 - ForEach(sidebarRootRows(from: orderedRoots), id: \.repositoryID) { row in 35 - if let failureMessage = state.loadFailuresByID[row.repositoryID] { 36 - SidebarFailedRepositoryRow( 37 - rootURL: row.rootURL, 38 - failureMessage: failureMessage, 39 - store: store 102 + let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in 103 + ( 104 + index: index, 105 + rootURL: rootURL, 106 + repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 107 + ) 108 + } 109 + ForEach(orderedRows, id: \.repositoryID) { row in 110 + let index = row.index 111 + let rootURL = row.rootURL 112 + let repositoryID = row.repositoryID 113 + if let failureMessage = state.loadFailuresByID[repositoryID] { 114 + let name = Repository.name(for: rootURL.standardizedFileURL) 115 + let path = rootURL.standardizedFileURL.path(percentEncoded: false) 116 + FailedRepositoryRow( 117 + name: name, 118 + path: path, 119 + showFailure: { 120 + let message = "\(path)\n\n\(failureMessage)" 121 + store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 122 + }, 123 + removeRepository: { 124 + store.send(.removeFailedRepository(repositoryID)) 125 + } 40 126 ) 41 - } else if let repository = repositoriesByID[row.repositoryID] { 42 - SidebarRepositorySectionView( 127 + .padding(.horizontal, 12) 128 + .overlay(alignment: .top) { 129 + if index > 0 { 130 + Rectangle() 131 + .fill(.secondary) 132 + .frame(height: 1) 133 + .frame(maxWidth: .infinity) 134 + .accessibilityHidden(true) 135 + } 136 + } 137 + .listRowInsets(EdgeInsets()) 138 + } else if let repository = repositoriesByID[repositoryID] { 139 + RepositorySectionView( 43 140 repository: repository, 141 + showsTopSeparator: index > 0, 142 + isDragActive: isDragActive, 44 143 hotkeyRows: hotkeyRows, 45 144 selectedWorktreeIDs: selectedWorktreeIDs, 145 + expandedRepoIDs: $expandedRepoIDs, 46 146 store: store, 47 147 terminalManager: terminalManager 48 148 ) 149 + .listRowInsets(EdgeInsets()) 49 150 } 50 151 } 51 152 .onMove { offsets, destination in ··· 56 157 .listStyle(.sidebar) 57 158 .scrollIndicators(.never) 58 159 .frame(minWidth: 220) 160 + .onDragSessionUpdated { session in 161 + if case .ended = session.phase { 162 + if isDragActive { 163 + isDragActive = false 164 + } 165 + return 166 + } 167 + if case .dataTransferCompleted = session.phase { 168 + if isDragActive { 169 + isDragActive = false 170 + } 171 + return 172 + } 173 + if !isDragActive { 174 + isDragActive = true 175 + } 176 + } 177 + .safeAreaInset(edge: .bottom) { 178 + SidebarFooterView(store: store) 179 + } 59 180 .dropDestination(for: URL.self) { urls, _ in 60 181 let fileURLs = urls.filter(\.isFileURL) 61 182 guard !fileURLs.isEmpty else { return false } ··· 84 205 terminalState.focusAndInsertText(keyPress.characters) 85 206 return .handled 86 207 } 87 - } 88 - 89 - private func sidebarRootRows( 90 - from orderedRoots: [URL] 91 - ) -> [(rootURL: URL, repositoryID: Repository.ID)] { 92 - orderedRoots.map { rootURL in 93 - ( 94 - rootURL: rootURL, 95 - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 96 - ) 97 - } 98 - } 99 - } 100 - 101 - private struct SidebarRepositorySectionView: View { 102 - let repository: Repository 103 - let hotkeyRows: [WorktreeRowModel] 104 - let selectedWorktreeIDs: Set<Worktree.ID> 105 - @Bindable var store: StoreOf<RepositoriesFeature> 106 - let terminalManager: WorktreeTerminalManager 107 - var body: some View { 108 - let isRemovingRepository = store.state.isRemovingRepository(repository) 109 - Section(isExpanded: repositoryExpansionBinding) { 110 - WorktreeRowsView( 111 - repository: repository, 112 - hotkeyRows: hotkeyRows, 113 - selectedWorktreeIDs: selectedWorktreeIDs, 114 - store: store, 115 - terminalManager: terminalManager 116 - ) 117 - } header: { 118 - RepoSectionHeaderView( 119 - name: repository.name, 120 - isRemoving: isRemovingRepository 121 - ) 122 - } 123 - .sectionActions { 124 - SidebarRepositorySectionActionsView( 125 - repositoryID: repository.id, 126 - isRemovingRepository: isRemovingRepository, 127 - store: store 128 - ) 129 - } 130 - } 131 - 132 - private var repositoryExpansionBinding: Binding<Bool> { 133 - Binding( 134 - get: { store.state.isRepositoryExpanded(repository.id) }, 135 - set: { isExpanded in 136 - store.send(.repositoryExpansionChanged(repository.id, isExpanded: isExpanded)) 137 - } 138 - ) 139 - } 140 - } 141 - 142 - private struct SidebarRepositorySectionActionsView: View { 143 - let repositoryID: Repository.ID 144 - let isRemovingRepository: Bool 145 - let store: StoreOf<RepositoriesFeature> 146 - 147 - var body: some View { 148 - Menu { 149 - Button("Repository Settings…", systemImage: "gear") { 150 - store.send(.openRepositorySettings(repositoryID)) 151 - } 152 - .help("Repository Settings") 153 - Divider() 154 - Button("Remove Repository…", systemImage: "folder.badge.minus", role: .destructive) { 155 - store.send(.requestRemoveRepository(repositoryID)) 156 - } 157 - .help("Remove Repository") 158 - .disabled(isRemovingRepository) 159 - } label: { 160 - Image(systemName: "ellipsis") 161 - .accessibilityLabel("Options") 162 - .frame(maxHeight: .infinity) 163 - .contentShape(Rectangle()) 164 - } 165 - .menuStyle(.button) 166 - .menuIndicator(.hidden) 167 - .buttonStyle(.plain) 168 - .foregroundStyle(.secondary) 169 - 170 - Button { 171 - store.send(.createRandomWorktreeInRepository(repositoryID)) 172 - } label: { 173 - Image(systemName: "plus") 174 - .accessibilityLabel("New Worktree") 175 - .frame(maxHeight: .infinity) 176 - .contentShape(Rectangle()) 177 - } 178 - .buttonStyle(.plain) 179 - .disabled(isRemovingRepository) 180 - .foregroundStyle(.secondary) 181 - .help("New Worktree") 182 - .padding(.trailing, 4) 183 - } 184 - } 185 - 186 - private struct SidebarFailedRepositoryRow: View { 187 - let rootURL: URL 188 - let failureMessage: String 189 - let store: StoreOf<RepositoriesFeature> 190 - 191 - var body: some View { 192 - let standardizedRootURL = rootURL.standardizedFileURL 193 - let name = Repository.name(for: standardizedRootURL) 194 - let path = standardizedRootURL.path(percentEncoded: false) 195 - 196 - FailedRepositoryRow( 197 - name: name, 198 - path: path, 199 - showFailure: { 200 - let message = "\(path)\n\n\(failureMessage)" 201 - store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 202 - }, 203 - removeRepository: { 204 - store.send(.removeFailedRepository(path)) 205 - } 206 - ) 207 - .padding(.horizontal, 12) 208 208 } 209 209 }
+2 -1
supacode/Features/Repositories/Views/SidebarSelection.swift
··· 1 1 enum SidebarSelection: Hashable { 2 2 case worktree(Worktree.ID) 3 3 case archivedWorktrees 4 + case repository(Repository.ID) 4 5 5 6 var worktreeID: Worktree.ID? { 6 7 switch self { 7 8 case .worktree(let id): 8 9 return id 9 - case .archivedWorktrees: 10 + case .archivedWorktrees, .repository: 10 11 return nil 11 12 } 12 13 }
+84 -16
supacode/Features/Repositories/Views/SidebarView.swift
··· 5 5 struct SidebarView: View { 6 6 @Bindable var store: StoreOf<RepositoriesFeature> 7 7 let terminalManager: WorktreeTerminalManager 8 - @Shared(.settingsFile) private var settingsFile 8 + @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) private var collapsedRepositoryIDs: [Repository.ID] = [] 9 + @State private var sidebarSelections: Set<SidebarSelection> = [] 9 10 10 11 var body: some View { 11 12 let state = store.state 12 - let visibleHotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: state.expandedRepositoryIDs) 13 - let effectiveSelectedRows = state.effectiveSidebarSelectedRows 13 + let repositoryIDs = Set(state.repositories.map(\.id)) 14 + let expandedRepoIDs = state.expandedRepositoryIDs 15 + let expandedRepoIDsBinding = expandedRepoIDsBinding( 16 + repositoryIDs: repositoryIDs, 17 + expandedRepoIDs: expandedRepoIDs 18 + ) 19 + let visibleHotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 20 + let visibleWorktreeIDs = Set(visibleHotkeyRows.map(\.id)) 21 + let effectiveSelectedRows = selectedRows(state: state) 14 22 let confirmWorktreeAction = makeConfirmWorktreeAction(state: state) 15 23 let archiveWorktreeAction = makeArchiveWorktreeAction(rows: effectiveSelectedRows) 16 24 let deleteWorktreeAction = makeDeleteWorktreeAction(rows: effectiveSelectedRows) 17 - let openRepo = AppShortcuts.openRepository.effective(from: settingsFile.global.shortcutOverrides) 18 25 19 26 return SidebarListView( 20 27 store: store, 28 + expandedRepoIDs: expandedRepoIDsBinding, 29 + sidebarSelections: $sidebarSelections, 21 30 terminalManager: terminalManager 22 31 ) 23 - .toolbar { 24 - ToolbarItem(placement: .primaryAction) { 25 - Button { 26 - store.send(.setOpenPanelPresented(true)) 27 - } label: { 28 - Image(systemName: "folder.badge.plus") 29 - .offset(y: -1) 30 - .accessibilityLabel("Add Repository") 31 - } 32 - .help("Add Repository (\(openRepo?.display ?? "none"))") 33 - } 34 - } 35 32 .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) 36 33 .focusedValue(\.archiveWorktreeAction, archiveWorktreeAction) 37 34 .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) 38 35 .focusedSceneValue(\.visibleHotkeyWorktreeRows, visibleHotkeyRows) 36 + .onAppear { syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) } 37 + .onChange(of: state.selection) { _, _ in 38 + syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 39 + } 40 + .onChange(of: visibleHotkeyRows.map(\.id)) { _, _ in 41 + syncSidebarSelections(state: state, visibleWorktreeIDs: visibleWorktreeIDs) 42 + } 43 + .onChange(of: sidebarSelections) { _, newValue in 44 + store.send(.setSidebarSelectedWorktreeIDs(selectedWorktreeIDs(from: newValue))) 45 + } 46 + .onChange(of: repositoryIDs) { _, newValue in 47 + let collapsed = Set(collapsedRepositoryIDs).intersection(newValue) 48 + $collapsedRepositoryIDs.withLock { 49 + $0 = Array(collapsed).sorted() 50 + } 51 + } 52 + } 53 + 54 + private func expandedRepoIDsBinding( 55 + repositoryIDs: Set<Repository.ID>, 56 + expandedRepoIDs: Set<Repository.ID> 57 + ) -> Binding<Set<Repository.ID>> { 58 + Binding<Set<Repository.ID>>( 59 + get: { expandedRepoIDs }, 60 + set: { newValue in 61 + let collapsed = repositoryIDs.subtracting(newValue) 62 + $collapsedRepositoryIDs.withLock { 63 + $0 = Array(collapsed).sorted() 64 + } 65 + } 66 + ) 67 + } 68 + 69 + private func selectedRows(state: RepositoriesFeature.State) -> [WorktreeRowModel] { 70 + let selectedRow = state.selectedRow(for: state.selectedWorktreeID) 71 + let selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 72 + let selectedRows = state.orderedWorktreeRows().filter { selectedWorktreeIDs.contains($0.id) } 73 + return selectedRows.isEmpty ? (selectedRow.map { [$0] } ?? []) : selectedRows 39 74 } 40 75 41 76 private func makeConfirmWorktreeAction( ··· 89 124 store.send(.requestDeleteWorktrees(targets)) 90 125 } 91 126 } 127 + } 128 + 129 + private func syncSidebarSelections( 130 + state: RepositoriesFeature.State, 131 + visibleWorktreeIDs: Set<Worktree.ID> 132 + ) { 133 + sidebarSelections = normalizedSidebarSelections( 134 + state: state, 135 + visibleWorktreeIDs: visibleWorktreeIDs 136 + ) 137 + store.send(.setSidebarSelectedWorktreeIDs(selectedWorktreeIDs(from: sidebarSelections))) 138 + } 139 + 140 + private func normalizedSidebarSelections( 141 + state: RepositoriesFeature.State, 142 + visibleWorktreeIDs: Set<Worktree.ID> 143 + ) -> Set<SidebarSelection> { 144 + if state.isShowingArchivedWorktrees { 145 + return [.archivedWorktrees] 146 + } 147 + var normalized = Set( 148 + state.sidebarSelectedWorktreeIDs 149 + .intersection(visibleWorktreeIDs) 150 + .map(SidebarSelection.worktree) 151 + ) 152 + if let selectedWorktreeID = state.selectedWorktreeID { 153 + normalized.insert(.worktree(selectedWorktreeID)) 154 + } 155 + return normalized 156 + } 157 + 158 + private func selectedWorktreeIDs(from selections: Set<SidebarSelection>) -> Set<Worktree.ID> { 159 + Set(selections.compactMap(\.worktreeID)) 92 160 } 93 161 }
+177 -369
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 1 + import AppKit 1 2 import SwiftUI 2 3 3 4 struct WorktreeRow: View { 4 5 let name: String 5 - let subtitle: String? 6 - let worktreeColor: WorktreeColor 7 - let isBusy: Bool 8 - let gitIconName: String 9 - let gitIconColor: Color 10 - let isArchiving: Bool 11 - let isDeleting: Bool 6 + let worktreeName: String 12 7 let info: WorktreeInfoEntry? 13 - let pullRequestBadgeText: String? 14 8 let showsPullRequestInfo: Bool 9 + let isHovered: Bool 10 + let isPinned: Bool 11 + let isMainWorktree: Bool 12 + let isLoading: Bool 13 + let taskStatus: WorktreeTaskStatus? 15 14 let isRunScriptRunning: Bool 16 15 let showsNotificationIndicator: Bool 17 16 let notifications: [WorktreeTerminalNotification] 17 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 18 18 let shortcutHint: String? 19 + let pinAction: (() -> Void)? 20 + let isSelected: Bool 21 + let archiveAction: (() -> Void)? 22 + @Environment(\.colorScheme) private var colorScheme 19 23 20 - enum WorktreeColor { 21 - case `default` 22 - case main 23 - case pinned 24 - } 25 - 26 - init( 27 - row: WorktreeRowModel, 28 - displayMode: WorktreeRowDisplayMode, 29 - hideSubtitle: Bool, 30 - hideSubtitleOnMatch: Bool, 31 - showsPullRequestInfo: Bool, 32 - isRunScriptRunning: Bool, 33 - showsNotificationIndicator: Bool, 34 - notifications: [WorktreeTerminalNotification], 35 - shortcutHint: String? 36 - ) { 37 - self.isArchiving = row.isArchiving 38 - self.isDeleting = row.isDeleting 39 - self.info = row.info 40 - self.showsPullRequestInfo = showsPullRequestInfo 41 - self.isRunScriptRunning = isRunScriptRunning 42 - self.showsNotificationIndicator = showsNotificationIndicator 43 - self.notifications = notifications 44 - self.shortcutHint = shortcutHint 45 - self.isBusy = row.isArchiving || row.isDeleting || row.isPending 46 - 47 - // Worktree color. 48 - self.worktreeColor = 49 - if row.isMainWorktree { .main } else if row.isPinned { .pinned } else { .default } 50 - 51 - // Title and subtitle based on display mode. 52 - let branchName = row.name 53 - let worktreeName = Self.worktreeName(for: row) 54 - let effectiveWorktreeName = worktreeName.isEmpty ? branchName : worktreeName 55 - switch displayMode { 56 - case .branchFirst: 57 - self.name = branchName 58 - case .worktreeFirst: 59 - self.name = effectiveWorktreeName 60 - } 61 - 62 - // Hide subtitle when the worktree name matches the branch name's last path component. 63 - let branchLastComponent = branchName.split(separator: "/").last.map(String.init) ?? branchName 64 - let isMatch = effectiveWorktreeName == branchLastComponent 65 - let rawSubtitle = displayMode == .branchFirst ? effectiveWorktreeName : branchName 66 - if hideSubtitle || (hideSubtitleOnMatch && isMatch) { 67 - self.subtitle = nil 68 - } else { 69 - self.subtitle = rawSubtitle 70 - } 71 - 72 - // Pull request display. 73 - let prDisplay = WorktreePullRequestDisplay( 74 - worktreeName: row.name, 75 - pullRequest: showsPullRequestInfo ? row.info?.pullRequest : nil 24 + var body: some View { 25 + let showsSpinner = isLoading || taskStatus == .running 26 + let branchIconName = isMainWorktree ? "star.fill" : (isPinned ? "pin.fill" : "arrow.triangle.branch") 27 + let display = WorktreePullRequestDisplay( 28 + worktreeName: name, 29 + pullRequest: showsPullRequestInfo ? info?.pullRequest : nil 76 30 ) 77 - self.pullRequestBadgeText = prDisplay.pullRequestBadgeStyle?.text 78 - if let pullRequest = prDisplay.pullRequest { 79 - switch pullRequest.state.uppercased() { 80 - case "MERGED": 81 - self.gitIconName = "git-merge" 82 - self.gitIconColor = .purple 83 - case "CLOSED": 84 - self.gitIconName = "git-pull-request-closed" 85 - self.gitIconColor = .red 86 - case "OPEN" where pullRequest.isDraft: 87 - self.gitIconName = "git-pull-request-draft" 88 - self.gitIconColor = .secondary 89 - case "OPEN": 90 - self.gitIconName = "git-pull-request" 91 - let readiness = PullRequestMergeReadiness(pullRequest: pullRequest) 92 - self.gitIconColor = readiness.isBlocking ? .red : .green 93 - default: 94 - self.gitIconName = "git-branch" 95 - self.gitIconColor = .secondary 96 - } 97 - } else { 98 - self.gitIconName = "git-branch" 99 - self.gitIconColor = .secondary 100 - } 101 - } 102 - 103 - private static func worktreeName(for row: WorktreeRowModel) -> String { 104 - guard !row.isMainWorktree else { return "Default" } 105 - guard !row.isPending else { return row.detail } 106 - if row.id.contains("/") { 107 - let pathName = URL(fileURLWithPath: row.id).lastPathComponent 108 - guard pathName.isEmpty else { return pathName } 109 - } 110 - if !row.detail.isEmpty, row.detail != "." { 111 - let detailName = URL(fileURLWithPath: row.detail).lastPathComponent 112 - guard detailName.isEmpty || detailName == "." else { return detailName } 113 - } 114 - return row.name 115 - } 116 - 117 - var body: some View { 118 - Label { 119 - HStack(spacing: 6) { 120 - TitleView( 121 - name: name, 122 - subtitle: subtitle, 123 - worktreeColor: worktreeColor, 124 - isBusy: isBusy 125 - ) 126 - Spacer(minLength: 0) 127 - TrailingView( 128 - shortcutHint: shortcutHint, 129 - info: info, 130 - isBusy: isBusy, 131 - showsPullRequestInfo: showsPullRequestInfo, 132 - pullRequestBadgeText: pullRequestBadgeText, 133 - isRunScriptRunning: isRunScriptRunning, 134 - showsNotificationIndicator: showsNotificationIndicator, 135 - notifications: notifications 136 - ) 31 + let displayAddedLines = info?.addedLines 32 + let displayRemovedLines = info?.removedLines 33 + let mergeReadiness = pullRequestMergeReadiness(for: display.pullRequest) 34 + let hasChangeCounts = displayAddedLines != nil && displayRemovedLines != nil 35 + let archiveShortcut = KeyboardShortcut(.delete, modifiers: .command).display 36 + let showsPullRequestTag = display.pullRequest != nil && display.pullRequestBadgeStyle != nil 37 + let nameColor = colorScheme == .dark ? Color.white : Color.primary 38 + let detailText = worktreeName.isEmpty ? name : worktreeName 39 + let bodyFontAscender = NSFont.preferredFont(forTextStyle: .body).ascender 40 + VStack(alignment: .leading, spacing: 2) { 41 + HStack(alignment: .firstTextBaseline, spacing: 6) { 42 + ZStack { 43 + if showsNotificationIndicator { 44 + NotificationPopoverButton( 45 + notifications: notifications, 46 + onFocusNotification: onFocusNotification 47 + ) { 48 + Image(systemName: "bell.fill") 49 + .font(.caption) 50 + .foregroundStyle(.orange) 51 + .accessibilityLabel("Unread notifications") 52 + } 53 + .opacity(showsSpinner ? 0 : 1) 54 + } else { 55 + Image(systemName: branchIconName) 56 + .font(.caption) 57 + .foregroundStyle(.secondary) 58 + .opacity(showsSpinner ? 0 : 1) 59 + .accessibilityHidden(true) 60 + } 61 + if showsSpinner { 62 + ProgressView() 63 + .controlSize(.small) 64 + } 65 + } 66 + .frame(width: 16, height: 16) 67 + .alignmentGuide(.firstTextBaseline) { _ in 68 + bodyFontAscender 69 + } 70 + Text(name) 71 + .font(.body) 72 + .foregroundStyle(nameColor) 73 + .lineLimit(1) 74 + Spacer(minLength: 4) 75 + if isRunScriptRunning { 76 + Image(systemName: "play.fill") 77 + .font(.caption) 78 + .foregroundStyle(.green) 79 + .help("Run script active") 80 + .accessibilityLabel("Run script active") 81 + } 82 + if hasChangeCounts, let displayAddedLines, let displayRemovedLines { 83 + WorktreeRowChangeCountView( 84 + addedLines: displayAddedLines, 85 + removedLines: displayRemovedLines, 86 + isSelected: isSelected 87 + ) 88 + } 89 + if isHovered { 90 + Button { 91 + pinAction?() 92 + } label: { 93 + Image(systemName: isPinned ? "pin.slash" : "pin") 94 + .contentTransition(.symbolEffect(.replace)) 95 + .accessibilityLabel(isPinned ? "Unpin worktree" : "Pin worktree") 96 + } 97 + .buttonStyle(.plain) 98 + .help(isPinned ? "Unpin" : "Pin to top") 99 + .disabled(pinAction == nil) 100 + Button { 101 + archiveAction?() 102 + } label: { 103 + Image(systemName: "archivebox") 104 + .accessibilityLabel("Archive worktree") 105 + } 106 + .buttonStyle(.plain) 107 + .help("Archive Worktree (\(archiveShortcut))") 108 + .disabled(archiveAction == nil) 109 + } 137 110 } 138 - } icon: { 139 - IconView( 140 - isArchiving: isArchiving, 141 - isDeleting: isDeleting, 142 - gitIconName: gitIconName, 143 - gitIconColor: gitIconColor 111 + WorktreeRowInfoView( 112 + worktreeName: detailText, 113 + showsPullRequestTag: showsPullRequestTag, 114 + pullRequestNumber: display.pullRequest?.number, 115 + pullRequestState: display.pullRequestState, 116 + mergeReadiness: mergeReadiness, 117 + shortcutHint: shortcutHint 144 118 ) 145 - } 146 - .labelStyle(.verticallyCentered) 147 - .listRowInsets(.trailing, 4) 148 - .listRowInsets(.vertical, 6) 149 - } 150 - } 151 - 152 - // MARK: - Title. 153 - 154 - private struct TitleView: View { 155 - let name: String 156 - let subtitle: String? 157 - let worktreeColor: WorktreeRow.WorktreeColor 158 - let isBusy: Bool 159 - @Environment(\.backgroundProminence) private var backgroundProminence 160 - 161 - private var resolvedWorktreeColor: AnyShapeStyle { 162 - guard backgroundProminence != .increased else { return AnyShapeStyle(.secondary) } 163 - return switch worktreeColor { 164 - case .main: AnyShapeStyle(.yellow) 165 - case .pinned: AnyShapeStyle(.orange) 166 - case .default: AnyShapeStyle(.tertiary) 119 + .padding(.leading, 22) 167 120 } 121 + .padding(.horizontal, 2) 122 + .frame(maxWidth: .infinity, minHeight: worktreeRowHeight, alignment: .center) 168 123 } 169 124 170 - var body: some View { 171 - VStack(alignment: .leading, spacing: 0) { 172 - Text(name) 173 - .font(.body) 174 - .lineLimit(1) 175 - .shimmer(isActive: isBusy) 176 - if let subtitle { 177 - Text(subtitle) 178 - .font(.footnote) 179 - .foregroundStyle(resolvedWorktreeColor) 180 - .lineLimit(1) 181 - } 125 + private func pullRequestMergeReadiness( 126 + for pullRequest: GithubPullRequest? 127 + ) -> PullRequestMergeReadiness? { 128 + guard let pullRequest, pullRequest.state.uppercased() == "OPEN" else { 129 + return nil 182 130 } 131 + return PullRequestMergeReadiness(pullRequest: pullRequest) 183 132 } 184 - } 185 133 186 - // MARK: - Icon. 187 - 188 - private struct IconView: View { 189 - let isArchiving: Bool 190 - let isDeleting: Bool 191 - let gitIconName: String 192 - let gitIconColor: Color 193 - @Environment(\.backgroundProminence) private var backgroundProminence 194 - 195 - private var isEmphasized: Bool { 196 - backgroundProminence == .increased 197 - } 198 - 199 - private var resolvedName: String { 200 - if isArchiving { return "archivebox" } 201 - if isDeleting { return "trash" } 202 - return gitIconName 203 - } 204 - 205 - private var resolvedColor: AnyShapeStyle { 206 - guard !isEmphasized else { return AnyShapeStyle(.secondary) } 207 - if isArchiving { return AnyShapeStyle(.orange) } 208 - if isDeleting { return AnyShapeStyle(.red) } 209 - return AnyShapeStyle(gitIconColor) 210 - } 211 - 212 - private var isSystemImage: Bool { 213 - isArchiving || isDeleting 214 - } 215 - 216 - private var accessibilityLabel: String? { 217 - if isArchiving { return "Archiving" } 218 - if isDeleting { return "Deleting" } 219 - return nil 220 - } 221 - 222 - var body: some View { 223 - Group { 224 - if isSystemImage { 225 - Image(systemName: resolvedName) 226 - } else { 227 - Image(resolvedName) 228 - .renderingMode(.template) 229 - } 230 - } 231 - .foregroundStyle(resolvedColor) 232 - .accessibilityLabel(accessibilityLabel ?? "") 233 - .accessibilityHidden(accessibilityLabel == nil) 134 + private var worktreeRowHeight: CGFloat { 135 + 42 234 136 } 235 137 } 236 138 237 - // MARK: - Trailing. 238 - 239 - private struct TrailingView: View { 139 + private struct WorktreeRowInfoView: View { 140 + let worktreeName: String 141 + let showsPullRequestTag: Bool 142 + let pullRequestNumber: Int? 143 + let pullRequestState: String? 144 + let mergeReadiness: PullRequestMergeReadiness? 240 145 let shortcutHint: String? 241 - let info: WorktreeInfoEntry? 242 - let isBusy: Bool 243 - let showsPullRequestInfo: Bool 244 - let pullRequestBadgeText: String? 245 - let isRunScriptRunning: Bool 246 - let showsNotificationIndicator: Bool 247 - let notifications: [WorktreeTerminalNotification] 248 146 249 147 var body: some View { 250 - if let shortcutHint { 251 - Text(shortcutHint) 252 - .font(.caption) 253 - .foregroundStyle(.secondary) 254 - } else { 255 - HStack(spacing: 6) { 256 - if !isBusy { 257 - DiffStatsView(info: info) 258 - if showsPullRequestInfo, let pullRequestBadgeText { 259 - Text(pullRequestBadgeText) 260 - .font(.caption) 261 - .foregroundStyle(.secondary) 262 - .transition(.blurReplace) 263 - } 264 - } 265 - StatusIndicator( 266 - isRunScriptRunning: isRunScriptRunning, 267 - showsNotificationIndicator: showsNotificationIndicator, 268 - notifications: notifications 269 - ) 148 + HStack(spacing: 4) { 149 + summaryText 150 + .lineLimit(1) 151 + .truncationMode(.tail) 152 + .layoutPriority(1) 153 + Spacer(minLength: 0) 154 + if let shortcutHint { 155 + ShortcutHintView(text: shortcutHint, color: .secondary) 270 156 } 271 157 } 158 + .font(.caption) 159 + .frame(minHeight: 14) 272 160 } 273 - } 274 161 275 - // MARK: - Diff stats. 276 - 277 - private struct DiffStatsView: View { 278 - let info: WorktreeInfoEntry? 279 - @Environment(\.backgroundProminence) private var backgroundProminence 280 - 281 - var body: some View { 282 - let isEmphasized = backgroundProminence == .increased 283 - if let added = info?.addedLines, let removed = info?.removedLines, added + removed > 0 { 284 - HStack(spacing: 2) { 285 - Text("+\(added)") 286 - .foregroundStyle(isEmphasized ? AnyShapeStyle(.secondary) : AnyShapeStyle(.green)) 287 - Text("-\(removed)") 288 - .foregroundStyle(isEmphasized ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red)) 162 + private var summaryText: Text { 163 + var result = AttributedString() 164 + func appendSeparator() { 165 + if !result.characters.isEmpty { 166 + var sep = AttributedString(" \u{2022} ") 167 + sep.foregroundColor = .secondary 168 + result.append(sep) 289 169 } 290 - .font(.caption) 291 - .monospacedDigit() 292 - .transition(.blurReplace) 170 + } 171 + if !worktreeName.isEmpty { 172 + var segment = AttributedString(worktreeName) 173 + segment.foregroundColor = .secondary 174 + result.append(segment) 293 175 } 294 - } 295 - } 296 - 297 - // MARK: - Status indicator. 298 - 299 - private struct StatusIndicator: View { 300 - let isRunScriptRunning: Bool 301 - let showsNotificationIndicator: Bool 302 - let notifications: [WorktreeTerminalNotification] 303 - @Environment(\.backgroundProminence) private var backgroundProminence 304 - @Environment(\.focusNotificationAction) private var focusNotificationAction: (WorktreeTerminalNotification) -> Void 305 - 306 - var body: some View { 307 - let isEmphasized = backgroundProminence == .increased 308 - if isRunScriptRunning || showsNotificationIndicator { 309 - ZStack { 310 - if isRunScriptRunning { 311 - PingDot( 312 - style: isEmphasized ? AnyShapeStyle(.primary) : AnyShapeStyle(.green), 313 - size: 6, 314 - showsSolidCenter: !showsNotificationIndicator 315 - ) 316 - } 317 - if showsNotificationIndicator { 318 - NotificationPopoverButton(notifications: notifications) { 319 - Circle() 320 - .fill(.red) 321 - .frame(width: 6, height: 6) 322 - .accessibilityLabel("Unread notifications") 323 - } 324 - .zIndex(1) 325 - } 326 - } 327 - .transition(.blurReplace) 176 + if showsPullRequestTag, let pullRequestNumber { 177 + appendSeparator() 178 + var segment = AttributedString("PR #\(pullRequestNumber)") 179 + segment.foregroundColor = .secondary 180 + result.append(segment) 328 181 } 329 - } 330 - } 331 - 332 - // MARK: - Vertically centered label style. 333 - 334 - private struct VerticallyCenteredLabelStyle: LabelStyle { 335 - func makeBody(configuration: Configuration) -> some View { 336 - HStack(spacing: 6) { 337 - configuration.icon 338 - configuration.title 182 + if pullRequestState == "MERGED" { 183 + appendSeparator() 184 + var segment = AttributedString("Merged") 185 + segment.foregroundColor = PullRequestBadgeStyle.mergedColor 186 + result.append(segment) 187 + } else if let mergeReadiness { 188 + appendSeparator() 189 + var segment = AttributedString(mergeReadiness.label) 190 + segment.foregroundColor = mergeReadiness.isBlocking ? .red : .green 191 + result.append(segment) 339 192 } 340 - } 341 - } 342 - 343 - extension LabelStyle where Self == VerticallyCenteredLabelStyle { 344 - static var verticallyCentered: VerticallyCenteredLabelStyle { .init() } 345 - } 346 - 347 - // MARK: - Shimmer effect. 348 - 349 - private struct ShimmerModifier: ViewModifier { 350 - let isActive: Bool 351 - @State private var phase = false 352 - 353 - func body(content: Content) -> some View { 354 - content 355 - .mask( 356 - LinearGradient( 357 - colors: isActive ? [.black.opacity(0.6), .black, .black.opacity(0.6)] : [.black], 358 - startPoint: phase ? UnitPoint(x: 1, y: 1) : UnitPoint(x: -0.5, y: -0.5), 359 - endPoint: phase ? UnitPoint(x: 1.5, y: 1.5) : UnitPoint(x: 0, y: 0) 360 - ) 361 - ) 362 - .animation( 363 - isActive ? .linear(duration: 1.5).delay(0.25).repeatForever(autoreverses: false) : nil, 364 - value: phase 365 - ) 366 - .task(id: isActive) { phase = isActive } 367 - } 368 - } 369 - 370 - extension View { 371 - func shimmer(isActive: Bool) -> some View { 372 - modifier(ShimmerModifier(isActive: isActive)) 193 + return Text(result) 373 194 } 374 195 } 375 196 376 - // MARK: - Pulsing dot. 377 - 378 - private struct PingDot<S: ShapeStyle>: View { 379 - let style: S 380 - let size: CGFloat 381 - let showsSolidCenter: Bool 382 - @State private var isPinging = false 197 + private struct WorktreeRowChangeCountView: View { 198 + let addedLines: Int 199 + let removedLines: Int 200 + let isSelected: Bool 383 201 384 202 var body: some View { 385 - ZStack { 386 - Circle() 387 - .stroke(style, lineWidth: 1) 388 - .frame(width: size, height: size) 389 - .scaleEffect(isPinging ? 2 : 1) 390 - .opacity(isPinging ? 0 : 0.6) 391 - .animation(.easeOut(duration: 1).repeatForever(autoreverses: false), value: isPinging) 392 - if showsSolidCenter { 393 - Circle() 394 - .fill(style) 395 - .frame(width: size, height: size) 396 - } 203 + HStack(spacing: 4) { 204 + Text("+\(addedLines)") 205 + .foregroundStyle(.green) 206 + Text("-\(removedLines)") 207 + .foregroundStyle(.red) 397 208 } 398 - .accessibilityLabel("Run script active") 399 - .task { isPinging = true } 400 - } 401 - } 402 - 403 - // MARK: - Focus notification environment. 404 - 405 - private nonisolated let notificationEnvironmentLogger = SupaLogger("Notifications") 406 - 407 - extension EnvironmentValues { 408 - @Entry var focusNotificationAction: (WorktreeTerminalNotification) -> Void = { _ in 409 - notificationEnvironmentLogger.warning("focusNotificationAction called but was never set in the environment.") 209 + .font(.caption) 210 + .lineLimit(1) 211 + .padding(.horizontal, 4) 212 + .padding(.vertical, 0) 213 + .overlay { 214 + RoundedRectangle(cornerRadius: 4, style: .continuous) 215 + .stroke(isSelected ? AnyShapeStyle(.secondary.opacity(0.3)) : AnyShapeStyle(.tertiary), lineWidth: 1) 216 + } 217 + .monospacedDigit() 410 218 } 411 219 }
-13
supacode/Features/Repositories/Views/WorktreeRowDisplayMode.swift
··· 1 - enum WorktreeRowDisplayMode: String, CaseIterable, Identifiable, Codable, Sendable { 2 - case branchFirst 3 - case worktreeFirst 4 - 5 - var id: String { rawValue } 6 - 7 - var label: String { 8 - switch self { 9 - case .branchFirst: "Branch Name First" 10 - case .worktreeFirst: "Worktree Name First" 11 - } 12 - } 13 - }
+251 -225
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 3 3 import Sharing 4 4 import SwiftUI 5 5 6 - private nonisolated let notificationLogger = SupaLogger("Notifications") 7 - 8 6 struct WorktreeRowsView: View { 9 - private struct GroupConfiguration: Identifiable { 10 - let id: String 11 - let rows: [WorktreeRowModel] 12 - let hideSubtitle: Bool 13 - let moveBehavior: WorktreeRowGroupView.MoveBehavior 14 - } 15 - 16 7 let repository: Repository 8 + let isExpanded: Bool 17 9 let hotkeyRows: [WorktreeRowModel] 18 10 let selectedWorktreeIDs: Set<Worktree.ID> 19 11 @Bindable var store: StoreOf<RepositoriesFeature> 20 12 let terminalManager: WorktreeTerminalManager 21 13 @Environment(CommandKeyObserver.self) private var commandKeyObserver 14 + @Environment(\.colorScheme) private var colorScheme 22 15 @State private var draggingWorktreeIDs: Set<Worktree.ID> = [] 16 + @State private var hoveredWorktreeID: Worktree.ID? 23 17 24 18 var body: some View { 19 + if isExpanded { 20 + expandedRowsView 21 + } 22 + } 23 + 24 + private var expandedRowsView: some View { 25 25 let state = store.state 26 26 let sections = state.worktreeRowSections(in: repository) 27 - let isSoleDefaultWorktree = sections.allRows.count == 1 && sections.main != nil 28 27 let isRepositoryRemoving = state.isRemovingRepository(repository) 29 28 let showShortcutHints = commandKeyObserver.isPressed 30 - let shortcutIndexByID: [Worktree.ID: Int] = 31 - showShortcutHints 32 - ? Dictionary(uniqueKeysWithValues: hotkeyRows.enumerated().map { ($0.element.id, $0.offset) }) 33 - : [:] 34 - let groupConfigurations = [ 35 - GroupConfiguration( 36 - id: "main", 37 - rows: sections.main.map { [$0] } ?? [], 38 - hideSubtitle: isSoleDefaultWorktree, 39 - moveBehavior: .disabled 40 - ), 41 - GroupConfiguration( 42 - id: "pinned", 43 - rows: sections.pinned, 44 - hideSubtitle: false, 45 - moveBehavior: .pinned(repository.id) 46 - ), 47 - GroupConfiguration( 48 - id: "pending", 49 - rows: sections.pending, 50 - hideSubtitle: false, 51 - moveBehavior: .disabled 52 - ), 53 - GroupConfiguration( 54 - id: "unpinned", 55 - rows: sections.unpinned, 56 - hideSubtitle: false, 57 - moveBehavior: .unpinned(repository.id) 58 - ), 59 - ] 29 + let allRows = showShortcutHints ? hotkeyRows : [] 30 + let shortcutIndexByID = Dictionary( 31 + uniqueKeysWithValues: allRows.enumerated().map { ($0.element.id, $0.offset) } 32 + ) 33 + let rowIDs = sections.allRows.map(\.id) 34 + return rowsGroup( 35 + sections: sections, 36 + isRepositoryRemoving: isRepositoryRemoving, 37 + showShortcutHints: showShortcutHints, 38 + shortcutIndexByID: shortcutIndexByID 39 + ) 40 + .animation(.easeOut(duration: 0.2), value: rowIDs) 41 + } 60 42 61 - ForEach(groupConfigurations) { groupConfiguration in 62 - WorktreeRowGroupView( 63 - rows: groupConfiguration.rows, 64 - selectedWorktreeIDs: selectedWorktreeIDs, 65 - store: store, 66 - terminalManager: terminalManager, 67 - draggingWorktreeIDs: $draggingWorktreeIDs, 43 + @ViewBuilder 44 + private func rowsGroup( 45 + sections: WorktreeRowSections, 46 + isRepositoryRemoving: Bool, 47 + showShortcutHints: Bool, 48 + shortcutIndexByID: [Worktree.ID: Int] 49 + ) -> some View { 50 + if let row = sections.main { 51 + rowView( 52 + row, 68 53 isRepositoryRemoving: isRepositoryRemoving, 69 - hideSubtitle: groupConfiguration.hideSubtitle, 70 - moveBehavior: groupConfiguration.moveBehavior, 71 - shortcutIndexByID: shortcutIndexByID 54 + moveDisabled: true, 55 + shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 72 56 ) 73 57 } 74 - } 75 - 76 - } 77 - 78 - private struct WorktreeRowGroupView: View { 79 - enum MoveBehavior: Hashable { 80 - case disabled 81 - case pinned(Repository.ID) 82 - case unpinned(Repository.ID) 83 - } 84 - 85 - let rows: [WorktreeRowModel] 86 - let selectedWorktreeIDs: Set<Worktree.ID> 87 - @Bindable var store: StoreOf<RepositoriesFeature> 88 - let terminalManager: WorktreeTerminalManager 89 - @Binding var draggingWorktreeIDs: Set<Worktree.ID> 90 - let isRepositoryRemoving: Bool 91 - let hideSubtitle: Bool 92 - let moveBehavior: MoveBehavior 93 - let shortcutIndexByID: [Worktree.ID: Int] 94 - 95 - var body: some View { 96 - ForEach(rows) { row in 97 - WorktreeRowContainer( 98 - row: row, 99 - store: store, 100 - terminalManager: terminalManager, 101 - selectedWorktreeIDs: selectedWorktreeIDs, 102 - draggingWorktreeIDs: $draggingWorktreeIDs, 58 + ForEach(sections.pinned) { row in 59 + rowView( 60 + row, 103 61 isRepositoryRemoving: isRepositoryRemoving, 104 - hideSubtitle: hideSubtitle, 105 - moveDisabled: moveDisabled(for: row), 106 - shortcutHint: shortcutHint(for: shortcutIndexByID[row.id]) 62 + moveDisabled: isRepositoryRemoving || row.isLoading, 63 + shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 107 64 ) 108 65 } 109 - .onMove(perform: moveRows) 110 - } 111 - 112 - private func moveDisabled(for row: WorktreeRowModel) -> Bool { 113 - switch moveBehavior { 114 - case .disabled: 115 - true 116 - case .pinned, .unpinned: 117 - isRepositoryRemoving || row.isDeleting || row.isArchiving 66 + .onMove { offsets, destination in 67 + store.send(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination)) 118 68 } 119 - } 120 - 121 - @Shared(.settingsFile) private var settingsFile 122 - 123 - private func shortcutHint(for index: Int?) -> String? { 124 - guard let index, AppShortcuts.worktreeSelection.indices.contains(index) else { return nil } 125 - let overrides = settingsFile.global.shortcutOverrides 126 - return AppShortcuts.worktreeSelection[index].effective(from: overrides)?.display 127 - } 128 - 129 - private func moveRows(_ offsets: IndexSet, _ destination: Int) { 130 - switch moveBehavior { 131 - case .disabled: 132 - break 133 - case .pinned(let repositoryID): 134 - store.send(.pinnedWorktreesMoved(repositoryID: repositoryID, offsets, destination)) 135 - case .unpinned(let repositoryID): 136 - store.send(.unpinnedWorktreesMoved(repositoryID: repositoryID, offsets, destination)) 69 + ForEach(sections.pending) { row in 70 + rowView( 71 + row, 72 + isRepositoryRemoving: isRepositoryRemoving, 73 + moveDisabled: true, 74 + shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 75 + ) 76 + } 77 + ForEach(sections.unpinned) { row in 78 + rowView( 79 + row, 80 + isRepositoryRemoving: isRepositoryRemoving, 81 + moveDisabled: isRepositoryRemoving || row.isLoading, 82 + shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 83 + ) 84 + } 85 + .onMove { offsets, destination in 86 + store.send(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination)) 137 87 } 138 88 } 139 - } 140 89 141 - // MARK: - Row container. 142 - 143 - private struct WorktreeRowContainer: View { 144 - let row: WorktreeRowModel 145 - @Bindable var store: StoreOf<RepositoriesFeature> 146 - let terminalManager: WorktreeTerminalManager 147 - let selectedWorktreeIDs: Set<Worktree.ID> 148 - @Binding var draggingWorktreeIDs: Set<Worktree.ID> 149 - let isRepositoryRemoving: Bool 150 - let hideSubtitle: Bool 151 - let moveDisabled: Bool 152 - let shortcutHint: String? 153 - @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst 154 - @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true 155 - 156 - var body: some View { 157 - WorktreeRow( 158 - row: row, 159 - displayMode: displayMode, 160 - hideSubtitle: hideSubtitle, 161 - hideSubtitleOnMatch: hideSubtitleOnMatch, 162 - showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 163 - isRunScriptRunning: terminalManager.isRunScriptRunning(for: row.id), 164 - showsNotificationIndicator: terminalManager.hasUnseenNotifications(for: row.id), 165 - notifications: terminalManager.stateIfExists(for: row.id)?.notifications ?? [], 166 - shortcutHint: shortcutHint 167 - ) 168 - .environment(\.focusNotificationAction) { notification in 90 + @ViewBuilder 91 + private func rowView( 92 + _ row: WorktreeRowModel, 93 + isRepositoryRemoving: Bool, 94 + moveDisabled: Bool, 95 + shortcutHint: String? 96 + ) -> some View { 97 + let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 98 + let displayName: String = 99 + switch row.status { 100 + case .deleting: "\(row.name) (deleting...)" 101 + case .archiving: "\(row.name) (archiving...)" 102 + case .idle, .pending: row.name 103 + } 104 + let canShowRowActions = row.isRemovable && !isRepositoryRemoving 105 + let pinAction: (() -> Void)? = 106 + canShowRowActions && !row.isMainWorktree 107 + ? { togglePin(for: row.id, isPinned: row.isPinned) } 108 + : nil 109 + let archiveAction: (() -> Void)? = 110 + canShowRowActions && !row.isMainWorktree && !row.isLoading 111 + ? { archiveWorktree(row.id) } 112 + : nil 113 + let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] 114 + let onFocusNotification: (WorktreeTerminalNotification) -> Void = { notification in 169 115 guard let terminalState = terminalManager.stateIfExists(for: row.id) else { 170 - notificationLogger.warning( 171 - "No terminal state for worktree \(row.id) when focusing notification \(notification.surfaceId).") 172 116 return 173 117 } 174 - if !terminalState.focusSurface(id: notification.surfaceId) { 175 - notificationLogger.warning("Failed to focus surface \(notification.surfaceId) for worktree \(row.id).") 176 - } 118 + _ = terminalState.focusSurface(id: notification.surfaceId) 177 119 } 178 - .tag(SidebarSelection.worktree(row.id)) 179 - .typeSelectEquivalent("") 180 - .moveDisabled(moveDisabled) 181 - .contextMenu { 120 + let config = WorktreeRowViewConfig( 121 + displayName: displayName, 122 + worktreeName: worktreeName(for: row), 123 + isHovered: hoveredWorktreeID == row.id, 124 + showsNotificationIndicator: showsNotificationIndicator, 125 + notifications: notifications, 126 + onFocusNotification: onFocusNotification, 127 + shortcutHint: shortcutHint, 128 + pinAction: pinAction, 129 + archiveAction: archiveAction, 130 + moveDisabled: moveDisabled 131 + ) 132 + let baseRow = worktreeRowView(row, config: config) 133 + Group { 182 134 if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 183 - WorktreeContextMenu( 184 - worktree: worktree, 185 - row: row, 186 - store: store, 187 - selectedWorktreeIDs: selectedWorktreeIDs 188 - ) 135 + baseRow.contextMenu { 136 + rowContextMenu(worktree: worktree, row: row) 137 + } 138 + } else { 139 + baseRow.disabled(isRepositoryRemoving) 189 140 } 190 141 } 191 - .disabled(!row.isRemovable && isRepositoryRemoving) 192 142 .contentShape(.dragPreview, .rect) 193 143 .contentShape(.interaction, .rect) 144 + .environment(\.colorScheme, colorScheme) 145 + .preferredColorScheme(colorScheme) 146 + .onHover { hovering in 147 + if hovering { 148 + hoveredWorktreeID = row.id 149 + } else if hoveredWorktreeID == row.id { 150 + hoveredWorktreeID = nil 151 + } 152 + } 194 153 .onDragSessionUpdated { session in 195 154 let draggedIDs = Set(session.draggedItemIDs(for: Worktree.ID.self)) 196 155 if case .ended = session.phase { ··· 211 170 } 212 171 } 213 172 214 - } 173 + private struct WorktreeRowViewConfig { 174 + let displayName: String 175 + let worktreeName: String 176 + let isHovered: Bool 177 + let showsNotificationIndicator: Bool 178 + let notifications: [WorktreeTerminalNotification] 179 + let onFocusNotification: (WorktreeTerminalNotification) -> Void 180 + let shortcutHint: String? 181 + let pinAction: (() -> Void)? 182 + let archiveAction: (() -> Void)? 183 + let moveDisabled: Bool 184 + } 215 185 216 - // MARK: - Context menu. 217 - 218 - private struct WorktreeContextMenu: View { 219 - let worktree: Worktree 220 - let row: WorktreeRowModel 221 - @Bindable var store: StoreOf<RepositoriesFeature> 222 - let selectedWorktreeIDs: Set<Worktree.ID> 223 - @Shared(.settingsFile) private var settingsFile 224 - 225 - private var contextRows: [WorktreeRowModel] { 226 - guard selectedWorktreeIDs.count > 1, selectedWorktreeIDs.contains(row.id) else { 227 - return [row] 228 - } 229 - let rows = selectedWorktreeIDs.compactMap { store.state.selectedRow(for: $0) } 230 - return rows.isEmpty ? [row] : rows 186 + private func worktreeRowView(_ row: WorktreeRowModel, config: WorktreeRowViewConfig) -> some View { 187 + let isSelected = selectedWorktreeIDs.contains(row.id) 188 + let taskStatus = terminalManager.taskStatus(for: row.id) 189 + let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) 190 + return WorktreeRow( 191 + name: config.displayName, 192 + worktreeName: config.worktreeName, 193 + info: row.info, 194 + showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 195 + isHovered: config.isHovered, 196 + isPinned: row.isPinned, 197 + isMainWorktree: row.isMainWorktree, 198 + isLoading: row.isLoading, 199 + taskStatus: taskStatus, 200 + isRunScriptRunning: isRunScriptRunning, 201 + showsNotificationIndicator: config.showsNotificationIndicator, 202 + notifications: config.notifications, 203 + onFocusNotification: config.onFocusNotification, 204 + shortcutHint: config.shortcutHint, 205 + pinAction: config.pinAction, 206 + isSelected: isSelected, 207 + archiveAction: config.archiveAction 208 + ) 209 + .tag(SidebarSelection.worktree(row.id)) 210 + .typeSelectEquivalent("") 211 + .listRowInsets(EdgeInsets()) 212 + .listRowSeparator(.hidden) 213 + .transition(.opacity) 214 + .moveDisabled(config.moveDisabled) 231 215 } 232 216 233 - var body: some View { 234 - let contextRows = contextRows 217 + @ViewBuilder 218 + private func rowContextMenu(worktree: Worktree, row: WorktreeRowModel) -> some View { 219 + let archiveShortcut = KeyboardShortcut(.delete, modifiers: .command).display 220 + let deleteShortcut = KeyboardShortcut(.delete, modifiers: [.command, .shift]).display 221 + let contextRows = contextActionRows(for: row) 235 222 let isBulkSelection = contextRows.count > 1 236 - let overrides = settingsFile.global.shortcutOverrides 237 - let archiveShortcut = AppShortcuts.archiveWorktree.effective(from: overrides) 238 - let deleteShortcut = AppShortcuts.deleteWorktree.effective(from: overrides) 239 - 240 - let pinnableRows = contextRows.filter { !$0.isMainWorktree } 241 - if !pinnableRows.isEmpty { 242 - let allPinned = pinnableRows.allSatisfy(\.isPinned) 243 - if allPinned { 244 - let label = isBulkSelection ? "Unpin Worktrees" : "Unpin Worktree" 245 - Button(label, systemImage: "pin.slash") { 246 - for pinnableRow in pinnableRows { 247 - togglePin(for: pinnableRow.id, isPinned: true) 248 - } 249 - } 250 - } else { 251 - let label = isBulkSelection ? "Pin Worktrees" : "Pin Worktree" 252 - Button(label, systemImage: "pin") { 253 - for pinnableRow in pinnableRows where !pinnableRow.isPinned { 254 - togglePin(for: pinnableRow.id, isPinned: false) 255 - } 256 - } 257 - } 258 - Divider() 259 - } 260 - 261 - if !isBulkSelection { 262 - Button("Copy as Pathname", systemImage: "doc.on.doc") { 263 - NSPasteboard.general.clearContents() 264 - NSPasteboard.general.setString(worktree.workingDirectory.path, forType: .string) 265 - } 266 - Button("Copy as Branch Name") { 267 - NSPasteboard.general.clearContents() 268 - NSPasteboard.general.setString(worktree.name, forType: .string) 269 - } 270 - Divider() 271 - } 272 - 273 223 let archiveTargets = 274 224 contextRows 275 225 .filter { !$0.isMainWorktree && !$0.isLoading } ··· 285 235 repositoryID: $0.repositoryID 286 236 ) 287 237 } 288 - 289 - let archiveLabel = isBulkSelection ? "Archive Worktrees…" : "Archive Worktree…" 290 - Button(archiveLabel, systemImage: "archivebox") { 291 - if archiveTargets.count == 1, let target = archiveTargets.first { 292 - store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 238 + let archiveTitle = 239 + isBulkSelection 240 + ? "Archive Selected Worktrees (\(archiveShortcut))" 241 + : "Archive Worktree (\(archiveShortcut))" 242 + let deleteTitle = 243 + isBulkSelection 244 + ? "Delete Selected Worktrees (\(deleteShortcut))" 245 + : "Delete Worktree (\(deleteShortcut))" 246 + if !row.isMainWorktree { 247 + if row.isPinned { 248 + Button("Unpin") { 249 + togglePin(for: worktree.id, isPinned: true) 250 + } 251 + .help("Unpin") 293 252 } else { 294 - store.send(.requestArchiveWorktrees(archiveTargets)) 253 + Button("Pin to top") { 254 + togglePin(for: worktree.id, isPinned: false) 255 + } 256 + .help("Pin to top") 295 257 } 296 258 } 297 - .appKeyboardShortcut(archiveShortcut) 259 + Button("Copy Path") { 260 + NSPasteboard.general.clearContents() 261 + NSPasteboard.general.setString(worktree.workingDirectory.path, forType: .string) 262 + } 263 + Button(archiveTitle) { 264 + archiveWorktrees(archiveTargets) 265 + } 266 + .help( 267 + archiveTargets.isEmpty 268 + ? "Main worktree can't be archived" 269 + : archiveTitle 270 + ) 298 271 .disabled(archiveTargets.isEmpty) 272 + Button(deleteTitle, role: .destructive) { 273 + deleteWorktrees(deleteTargets) 274 + } 275 + .help(deleteTitle) 276 + } 299 277 300 - let deleteLabel = isBulkSelection ? "Delete Worktrees…" : "Delete Worktree…" 301 - Button(deleteLabel, systemImage: "trash", role: .destructive) { 302 - if deleteTargets.count == 1, let target = deleteTargets.first { 303 - store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 304 - } else { 305 - store.send(.requestDeleteWorktrees(deleteTargets)) 306 - } 307 - } 308 - .appKeyboardShortcut(deleteShortcut) 278 + private func worktreeShortcutHint(for index: Int?) -> String? { 279 + guard let index, AppShortcuts.worktreeSelection.indices.contains(index) else { return nil } 280 + @Shared(.settingsFile) var settingsFile 281 + let overrides = settingsFile.global.shortcutOverrides 282 + return AppShortcuts.worktreeSelection[index].effective(from: overrides)?.display 309 283 } 310 284 311 285 private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) { ··· 316 290 store.send(.pinWorktree(worktreeID)) 317 291 } 318 292 } 293 + } 294 + 295 + private func archiveWorktree(_ worktreeID: Worktree.ID) { 296 + store.send(.requestArchiveWorktree(worktreeID, repository.id)) 297 + } 298 + 299 + private func contextActionRows(for row: WorktreeRowModel) -> [WorktreeRowModel] { 300 + guard selectedWorktreeIDs.count > 1, selectedWorktreeIDs.contains(row.id) else { 301 + return [row] 302 + } 303 + let rows = selectedWorktreeIDs.compactMap { store.state.selectedRow(for: $0) } 304 + return rows.isEmpty ? [row] : rows 305 + } 306 + 307 + private func archiveWorktrees(_ targets: [RepositoriesFeature.ArchiveWorktreeTarget]) { 308 + guard !targets.isEmpty else { return } 309 + if targets.count == 1, let target = targets.first { 310 + store.send(.requestArchiveWorktree(target.worktreeID, target.repositoryID)) 311 + } else { 312 + store.send(.requestArchiveWorktrees(targets)) 313 + } 314 + } 315 + 316 + private func deleteWorktrees(_ targets: [RepositoriesFeature.DeleteWorktreeTarget]) { 317 + guard !targets.isEmpty else { return } 318 + if targets.count == 1, let target = targets.first { 319 + store.send(.requestDeleteWorktree(target.worktreeID, target.repositoryID)) 320 + } else { 321 + store.send(.requestDeleteWorktrees(targets)) 322 + } 323 + } 324 + 325 + private func worktreeName(for row: WorktreeRowModel) -> String { 326 + if row.isMainWorktree { 327 + return "Default" 328 + } 329 + if row.isPending { 330 + return row.detail 331 + } 332 + if row.id.contains("/") { 333 + let pathName = URL(fileURLWithPath: row.id).lastPathComponent 334 + if !pathName.isEmpty { 335 + return pathName 336 + } 337 + } 338 + if !row.detail.isEmpty, row.detail != "." { 339 + let detailName = URL(fileURLWithPath: row.detail).lastPathComponent 340 + if !detailName.isEmpty, detailName != "." { 341 + return detailName 342 + } 343 + } 344 + return row.name 319 345 } 320 346 }
-367
supacodeTests/RepositoriesFeatureTests.swift
··· 135 135 await store.receive(\.delegate.selectedWorktreeChanged) 136 136 } 137 137 138 - @Test func sidebarSelectionChangedChoosesFirstVisibleWorktreeAndFocusesTerminal() async { 139 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 140 - let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 141 - let wt3 = makeWorktree(id: "/tmp/repo/wt3", name: "wt3", repoRoot: "/tmp/repo") 142 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2, wt3]) 143 - var initialState = makeState(repositories: [repository]) 144 - initialState.selection = .worktree(wt1.id) 145 - let store = TestStore(initialState: initialState) { 146 - RepositoriesFeature() 147 - } 148 - 149 - await store.send( 150 - .selectionChanged( 151 - [.worktree(wt3.id), .worktree(wt2.id)], 152 - focusTerminal: true 153 - ) 154 - ) { 155 - $0.selection = .worktree(wt2.id) 156 - $0.sidebarSelectedWorktreeIDs = [wt2.id, wt3.id] 157 - $0.pendingTerminalFocusWorktreeIDs = [wt2.id] 158 - } 159 - await store.receive(\.delegate.selectedWorktreeChanged) 160 - } 161 - 162 - @Test func sidebarSelectionChangedClearsSelectionWhenEmpty() async { 163 - let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 164 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 165 - var initialState = makeState(repositories: [repository]) 166 - initialState.selection = .worktree(worktree.id) 167 - initialState.sidebarSelectedWorktreeIDs = [worktree.id] 168 - let store = TestStore(initialState: initialState) { 169 - RepositoriesFeature() 170 - } 171 - 172 - await store.send(.selectionChanged([])) { 173 - $0.selection = nil 174 - $0.sidebarSelectedWorktreeIDs = [] 175 - } 176 - await store.receive(\.delegate.selectedWorktreeChanged) 177 - } 178 - 179 - @Test func sidebarSelectionChangedArchivesAndClearsSidebarSelection() async { 180 - let worktree1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 181 - let worktree2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 182 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree1, worktree2]) 183 - var initialState = makeState(repositories: [repository]) 184 - initialState.selection = .worktree(worktree1.id) 185 - initialState.sidebarSelectedWorktreeIDs = [worktree1.id, worktree2.id] 186 - let store = TestStore(initialState: initialState) { 187 - RepositoriesFeature() 188 - } 189 - 190 - await store.send(.selectionChanged([.archivedWorktrees])) { 191 - $0.selection = .archivedWorktrees 192 - $0.sidebarSelectedWorktreeIDs = [] 193 - } 194 - await store.receive(\.delegate.selectedWorktreeChanged) 195 - } 196 - 197 - @Test func sidebarRepositoryExpansionChangedUpdatesCollapsedRepositoryIDs() async { 198 - let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 199 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 200 - let store = TestStore(initialState: makeState(repositories: [repository])) { 201 - RepositoriesFeature() 202 - } 203 - 204 - await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) { 205 - $0.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 206 - } 207 - 208 - await store.send(.repositoryExpansionChanged(repository.id, isExpanded: true)) { 209 - $0.$collapsedRepositoryIDs.withLock { $0 = [] } 210 - } 211 - } 212 - 213 - @Test func repositoryExpansionChangedIsIdempotent() async { 214 - let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 215 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 216 - let store = TestStore(initialState: makeState(repositories: [repository])) { 217 - RepositoriesFeature() 218 - } 219 - 220 - await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) { 221 - $0.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 222 - } 223 - 224 - // Collapsing again should be a no-op. 225 - await store.send(.repositoryExpansionChanged(repository.id, isExpanded: false)) 226 - } 227 - 228 - @Test func sidebarSelectionChangedWithoutFocusTerminalDoesNotInsertPendingFocus() async { 229 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 230 - let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 231 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) 232 - var initialState = makeState(repositories: [repository]) 233 - initialState.selection = .worktree(wt1.id) 234 - let store = TestStore(initialState: initialState) { 235 - RepositoriesFeature() 236 - } 237 - 238 - await store.send(.selectionChanged([.worktree(wt2.id)])) { 239 - $0.selection = .worktree(wt2.id) 240 - $0.sidebarSelectedWorktreeIDs = [wt2.id] 241 - } 242 - await store.receive(\.delegate.selectedWorktreeChanged) 243 - #expect(store.state.pendingTerminalFocusWorktreeIDs.isEmpty) 244 - } 245 - 246 - @Test func sidebarSelectionChangedKeepsCurrentSelectionDuringMultiSelect() async { 247 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 248 - let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 249 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) 250 - var initialState = makeState(repositories: [repository]) 251 - initialState.selection = .worktree(wt1.id) 252 - initialState.sidebarSelectedWorktreeIDs = [wt1.id] 253 - let store = TestStore(initialState: initialState) { 254 - RepositoriesFeature() 255 - } 256 - 257 - await store.send( 258 - .selectionChanged([.worktree(wt1.id), .worktree(wt2.id)], focusTerminal: true) 259 - ) { 260 - $0.selection = .worktree(wt1.id) 261 - $0.sidebarSelectedWorktreeIDs = [wt1.id, wt2.id] 262 - } 263 - #expect(store.state.pendingTerminalFocusWorktreeIDs.isEmpty) 264 - } 265 - 266 - @Test func repositoriesLoadedPrunesCollapsedRepositoryIDs() async { 267 - let repoAID = "/tmp/repo-a" 268 - let repoBID = "/tmp/repo-b" 269 - let repoA = makeRepository( 270 - id: repoAID, 271 - worktrees: [makeWorktree(id: "\(repoAID)/wt1", name: "wt1", repoRoot: repoAID)] 272 - ) 273 - let repoB = makeRepository( 274 - id: repoBID, 275 - worktrees: [makeWorktree(id: "\(repoBID)/wt1", name: "wt1", repoRoot: repoBID)] 276 - ) 277 - var initialState = makeState(repositories: [repoA, repoB]) 278 - initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id, "/tmp/missing"] } 279 - let store = TestStore(initialState: initialState) { 280 - RepositoriesFeature() 281 - } 282 - 283 - await store.send( 284 - .repositoriesLoaded( 285 - [repoA], 286 - failures: [], 287 - roots: [repoA.rootURL], 288 - animated: false 289 - ) 290 - ) { 291 - $0.repositories = [repoA] 292 - $0.repositoryRoots = [repoA.rootURL] 293 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoA.id] } 294 - $0.isInitialLoadComplete = true 295 - } 296 - await store.receive(\.delegate.repositoriesChanged) 297 - } 298 - 299 - @Test func sidebarSelectionChangedWithAllUnknownWorktreeIDsClearsSelection() async { 300 - let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 301 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 302 - var initialState = makeState(repositories: [repository]) 303 - initialState.selection = .worktree(worktree.id) 304 - initialState.sidebarSelectedWorktreeIDs = [worktree.id] 305 - let store = TestStore(initialState: initialState) { 306 - RepositoriesFeature() 307 - } 308 - 309 - await store.send(.selectionChanged([.worktree("/tmp/unknown")])) { 310 - $0.selection = nil 311 - $0.sidebarSelectedWorktreeIDs = [] 312 - } 313 - await store.receive(\.delegate.selectedWorktreeChanged) 314 - } 315 - 316 - @Test func sidebarSelectionChangedWithMixedArchivedAndWorktreeSelectsArchived() async { 317 - let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 318 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 319 - var initialState = makeState(repositories: [repository]) 320 - initialState.selection = .worktree(worktree.id) 321 - initialState.sidebarSelectedWorktreeIDs = [worktree.id] 322 - let store = TestStore(initialState: initialState) { 323 - RepositoriesFeature() 324 - } 325 - 326 - await store.send(.selectionChanged([.archivedWorktrees, .worktree(worktree.id)])) { 327 - $0.selection = .archivedWorktrees 328 - $0.sidebarSelectedWorktreeIDs = [] 329 - } 330 - await store.receive(\.delegate.selectedWorktreeChanged) 331 - } 332 - 333 - @Test func repositoryExpansionChangedMultipleRepositoriesKeepsSortedOrder() async { 334 - let repoA = makeRepository( 335 - id: "/tmp/repo-a", 336 - worktrees: [makeWorktree(id: "/tmp/repo-a/wt1", name: "wt1", repoRoot: "/tmp/repo-a")], 337 - ) 338 - let repoB = makeRepository( 339 - id: "/tmp/repo-b", 340 - worktrees: [makeWorktree(id: "/tmp/repo-b/wt1", name: "wt1", repoRoot: "/tmp/repo-b")], 341 - ) 342 - let store = TestStore(initialState: makeState(repositories: [repoA, repoB])) { 343 - RepositoriesFeature() 344 - } 345 - 346 - // Collapse B first, then A. 347 - await store.send(.repositoryExpansionChanged(repoB.id, isExpanded: false)) { 348 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } 349 - } 350 - await store.send(.repositoryExpansionChanged(repoA.id, isExpanded: false)) { 351 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } 352 - } 353 - } 354 - 355 - @Test func sidebarSelectionChangedSameWorktreeSuppressesDelegateAndFocus() async { 356 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 357 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1]) 358 - var initialState = makeState(repositories: [repository]) 359 - initialState.selection = .worktree(wt1.id) 360 - initialState.sidebarSelectedWorktreeIDs = [wt1.id] 361 - let store = TestStore(initialState: initialState) { 362 - RepositoriesFeature() 363 - } 364 - 365 - // Re-selecting the same worktree should not fire delegate or insert pending focus. 366 - await store.send(.selectionChanged([.worktree(wt1.id)], focusTerminal: true)) 367 - #expect(store.state.pendingTerminalFocusWorktreeIDs.isEmpty) 368 - } 369 - 370 - @Test func repositoriesLoadedFiresDelegateWhenWorktreePropertiesChange() async { 371 - let repoRoot = "/tmp/repo" 372 - let worktree = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: repoRoot) 373 - let repository = makeRepository(id: repoRoot, worktrees: [worktree]) 374 - var initialState = makeState(repositories: [repository]) 375 - initialState.selection = .worktree(worktree.id) 376 - let store = TestStore(initialState: initialState) { 377 - RepositoriesFeature() 378 - } 379 - 380 - // Same worktree ID but different workingDirectory triggers delegate. 381 - let movedWorktree = Worktree( 382 - id: worktree.id, 383 - name: worktree.name, 384 - detail: "detail", 385 - workingDirectory: URL(fileURLWithPath: "/tmp/repo/moved-wt1"), 386 - repositoryRootURL: worktree.repositoryRootURL, 387 - ) 388 - let updatedRepository = makeRepository(id: repoRoot, worktrees: [movedWorktree]) 389 - await store.send( 390 - .repositoriesLoaded( 391 - [updatedRepository], 392 - failures: [], 393 - roots: [repository.rootURL], 394 - animated: false, 395 - ) 396 - ) { 397 - $0.repositories = [updatedRepository] 398 - $0.isInitialLoadComplete = true 399 - } 400 - await store.receive(\.delegate.repositoriesChanged) 401 - await store.receive(\.delegate.selectedWorktreeChanged) 402 - } 403 - 404 - @Test func sidebarSelectionChangedWithMixedValidAndInvalidIDsKeepsValidOnly() async { 405 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 406 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1]) 407 - var initialState = makeState(repositories: [repository]) 408 - initialState.selection = .worktree(wt1.id) 409 - initialState.sidebarSelectedWorktreeIDs = [wt1.id] 410 - let store = TestStore(initialState: initialState) { 411 - RepositoriesFeature() 412 - } 413 - 414 - // Valid ID kept, unknown ID silently dropped. 415 - await store.send(.selectionChanged([.worktree(wt1.id), .worktree("/tmp/unknown")])) 416 - #expect(store.state.sidebarSelectedWorktreeIDs == [wt1.id]) 417 - } 418 - 419 - @Test func sidebarSelectionsComputedPropertyReflectsState() { 420 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 421 - let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 422 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) 423 - var state = makeState(repositories: [repository]) 424 - 425 - // No selection. 426 - #expect(state.sidebarSelections.isEmpty) 427 - 428 - // Single selection. 429 - state.selection = .worktree(wt1.id) 430 - #expect(state.sidebarSelections == [.worktree(wt1.id)]) 431 - 432 - // Multi-selection includes selectedWorktreeID. 433 - state.sidebarSelectedWorktreeIDs = [wt2.id] 434 - #expect(state.sidebarSelections == [.worktree(wt1.id), .worktree(wt2.id)]) 435 - 436 - // Archived overrides everything. 437 - state.selection = .archivedWorktrees 438 - #expect(state.sidebarSelections == [.archivedWorktrees]) 439 - } 440 - 441 - @Test func effectiveSidebarSelectedRowsFallsBackToSelectedWorktreeID() { 442 - let wt1 = makeWorktree(id: "/tmp/repo/wt1", name: "wt1", repoRoot: "/tmp/repo") 443 - let wt2 = makeWorktree(id: "/tmp/repo/wt2", name: "wt2", repoRoot: "/tmp/repo") 444 - let repository = makeRepository(id: "/tmp/repo", worktrees: [wt1, wt2]) 445 - var state = makeState(repositories: [repository]) 446 - state.selection = .worktree(wt1.id) 447 - state.sidebarSelectedWorktreeIDs = [] 448 - 449 - // Falls back to selectedWorktreeID. 450 - let fallbackRows = state.effectiveSidebarSelectedRows 451 - #expect(fallbackRows.count == 1) 452 - #expect(fallbackRows.first?.id == wt1.id) 453 - 454 - // Primary path: sidebarSelectedWorktreeIDs non-empty. 455 - state.sidebarSelectedWorktreeIDs = [wt1.id, wt2.id] 456 - let primaryRows = state.effectiveSidebarSelectedRows 457 - #expect(primaryRows.count == 2) 458 - } 459 - 460 - @Test func revealInSidebarExpandsCollapsedRepository() async { 461 - let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 462 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 463 - var initialState = makeState(repositories: [repository]) 464 - initialState.selection = .worktree(worktree.id) 465 - initialState.sidebarSelectedWorktreeIDs = [worktree.id] 466 - initialState.$collapsedRepositoryIDs.withLock { $0 = [repository.id] } 467 - let store = TestStore(initialState: initialState) { 468 - RepositoriesFeature() 469 - } 470 - 471 - await store.send(.revealSelectedWorktreeInSidebar) { 472 - $0.$collapsedRepositoryIDs.withLock { $0 = [] } 473 - } 474 - } 475 - 476 - @Test func revealInSidebarWithNoSelectionIsNoOp() async { 477 - let worktree = makeWorktree(id: "/tmp/repo/wt", name: "wt") 478 - let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) 479 - let initialState = makeState(repositories: [repository]) 480 - let store = TestStore(initialState: initialState) { 481 - RepositoriesFeature() 482 - } 483 - 484 - await store.send(.revealSelectedWorktreeInSidebar) 485 - } 486 - 487 - @Test func revealInSidebarKeepsOtherRepositoriesCollapsed() async { 488 - let worktree1 = makeWorktree(id: "/tmp/repo-a/wt", name: "wt", repoRoot: "/tmp/repo-a") 489 - let worktree2 = makeWorktree(id: "/tmp/repo-b/wt", name: "wt", repoRoot: "/tmp/repo-b") 490 - let repoA = makeRepository(id: "/tmp/repo-a", worktrees: [worktree1]) 491 - let repoB = makeRepository(id: "/tmp/repo-b", worktrees: [worktree2]) 492 - var initialState = makeState(repositories: [repoA, repoB]) 493 - initialState.selection = .worktree(worktree1.id) 494 - initialState.sidebarSelectedWorktreeIDs = [worktree1.id] 495 - initialState.$collapsedRepositoryIDs.withLock { $0 = [repoA.id, repoB.id] } 496 - let store = TestStore(initialState: initialState) { 497 - RepositoriesFeature() 498 - } 499 - 500 - await store.send(.revealSelectedWorktreeInSidebar) { 501 - $0.$collapsedRepositoryIDs.withLock { $0 = [repoB.id] } 502 - } 503 - } 504 - 505 138 @Test func createRandomWorktreeWithoutRepositoriesShowsAlert() async { 506 139 let store = TestStore(initialState: RepositoriesFeature.State()) { 507 140 RepositoriesFeature()