native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #183 from supabitapp/sbertix/sidebar-ui

authored by

khoi and committed by
GitHub
da412c7b 8e6ef265

+1418 -967
+1
.gitignore
··· 67 67 Resources/terminfo 68 68 *.profraw 69 69 .env 70 + .DS_Store
+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 + } 219 226 CommandGroup(replacing: .appTermination) { 220 227 Button("Quit Supacode") { 221 228 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>
+11 -1
supacode/Commands/SidebarCommands.swift
··· 4 4 struct SidebarCommands: Commands { 5 5 @FocusedValue(\.toggleLeftSidebarAction) private var toggleLeftSidebarAction 6 6 @Shared(.settingsFile) private var settingsFile 7 + @Shared(.appStorage("worktreeRowDisplayMode")) private var displayMode: WorktreeRowDisplayMode = .branchFirst 8 + @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true 7 9 8 10 var body: some Commands { 9 11 let toggleLeftSidebar = AppShortcuts.toggleLeftSidebar.effective(from: settingsFile.global.shortcutOverrides) 10 12 CommandGroup(replacing: .sidebar) { 11 - Button("Toggle Left Sidebar") { 13 + Button("Toggle Left Sidebar", systemImage: "sidebar.leading") { 12 14 toggleLeftSidebarAction?() 13 15 } 14 16 .appKeyboardShortcut(toggleLeftSidebar) 15 17 .help("Toggle Left Sidebar (\(toggleLeftSidebar?.display ?? "none"))") 16 18 .disabled(toggleLeftSidebarAction == nil) 19 + Section { 20 + Picker("Title and Subtitle", systemImage: "textformat", selection: Binding($displayMode)) { 21 + ForEach(WorktreeRowDisplayMode.allCases) { mode in 22 + Text(mode.label).tag(mode) 23 + } 24 + } 25 + Toggle("Hide Subtitle on Match", isOn: Binding($hideSubtitleOnMatch)) 26 + } 17 27 } 18 28 } 19 29 }
+2 -1
supacode/Commands/TerminalCommands.swift
··· 13 13 14 14 var body: some Commands { 15 15 CommandGroup(after: .newItem) { 16 - Button("New Terminal") { 16 + Divider() 17 + Button("New Terminal", systemImage: "apple.terminal") { 17 18 newTerminalAction?() 18 19 } 19 20 .modifier(KeyboardShortcutModifier(shortcut: ghosttyShortcuts.keyboardShortcut(for: "new_tab")))
+63 -53
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 - 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 - ) 61 - } 62 - } 63 - CommandGroup(replacing: .newItem) { 64 - Button("Open Repository...", systemImage: "folder") { 65 - store.send(.repositories(.setOpenPanelPresented(true))) 40 + // Creation and opening. 41 + Button("New Worktree…", systemImage: "plus") { 42 + store.send(.repositories(.createRandomWorktree)) 66 43 } 67 - .appKeyboardShortcut(openRepo) 68 - .help("Open Repository (\(openRepo?.display ?? "none"))") 69 - Button("Open Worktree") { 44 + .appKeyboardShortcut(newWt) 45 + .help("New Worktree (\(newWt?.display ?? "none"))") 46 + .disabled(!repositories.canCreateWorktree) 47 + Button("Open in Finder", systemImage: "folder") { 70 48 openSelectedWorktreeAction?() 71 49 } 72 50 .appKeyboardShortcut(openWorktree) 73 - .help("Open Worktree (\(openWorktree?.display ?? "none"))") 51 + .help("Open in Finder (\(openWorktree?.display ?? "none"))") 74 52 .disabled(openSelectedWorktreeAction == nil) 75 - Button("Open Pull Request on GitHub") { 53 + Button("Open Pull Request", systemImage: "arrow.up.forward") { 76 54 if let pullRequestURL { 77 55 NSWorkspace.shared.open(pullRequestURL) 78 56 } 79 57 } 80 58 .appKeyboardShortcut(openPR) 81 - .help("Open Pull Request on GitHub (\(openPR?.display ?? "none"))") 59 + .help("Open Pull Request (\(openPR?.display ?? "none"))") 82 60 .disabled(pullRequestURL == nil || !githubIntegrationEnabled) 83 - Button("New Worktree", systemImage: "plus") { 84 - store.send(.repositories(.createRandomWorktree)) 61 + Divider() 62 + // Lifecycle. 63 + Button("Refresh Worktrees", systemImage: "arrow.clockwise") { 64 + store.send(.repositories(.refreshWorktrees)) 85 65 } 86 - .appKeyboardShortcut(newWt) 87 - .help("New Worktree (\(newWt?.display ?? "none"))") 88 - .disabled(!repositories.canCreateWorktree) 89 - Button("Archived Worktrees") { 66 + .appKeyboardShortcut(refresh) 67 + .help("Refresh (\(refresh?.display ?? "none"))") 68 + Button("Archived Worktrees", systemImage: "archivebox") { 90 69 store.send(.repositories(.selectArchivedWorktrees)) 91 70 } 92 71 .appKeyboardShortcut(archived) 93 72 .help("Archived Worktrees (\(archived?.display ?? "none"))") 94 - Button("Archive Worktree") { 73 + Divider() 74 + // Commands. 75 + Button("Archive Worktree…", systemImage: "archivebox") { 95 76 archiveWorktreeAction?() 96 77 } 97 78 .appKeyboardShortcut(archive) 98 79 .help("Archive Worktree (\(archive?.display ?? "none"))") 99 80 .disabled(archiveWorktreeAction == nil) 100 - Button("Delete Worktree") { 81 + Button("Delete Worktree…", systemImage: "trash") { 101 82 deleteWorktreeAction?() 102 83 } 103 84 .appKeyboardShortcut(deleteWt) 104 85 .help("Delete Worktree (\(deleteWt?.display ?? "none"))") 105 86 .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"))") 117 87 Divider() 118 - Button("Run Script") { 88 + // Scripts. 89 + Button("Run Script", systemImage: "play") { 119 90 runScriptAction?() 120 91 } 121 92 .appKeyboardShortcut(run) 122 93 .help("Run Script (\(run?.display ?? "none"))") 123 94 .disabled(runScriptAction == nil) 124 - Button("Stop Script") { 95 + Button("Stop Script", systemImage: "stop") { 125 96 stopRunScriptAction?() 126 97 } 127 98 .appKeyboardShortcut(stop) 128 99 .help("Stop Script (\(stop?.display ?? "none"))") 129 100 .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) 130 140 } 131 141 } 132 142
+125 -17
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) 132 134 case selectArchivedWorktrees 133 135 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 134 136 case openRepositories([URL]) ··· 251 253 252 254 private struct ApplyRepositoriesResult { 253 255 let didPrunePinned: Bool 256 + // Auto-persisted via `@Shared`; tracked for consistency but not consumed in effect dispatch. 257 + let didPruneCollapsedRepositoryIDs: Bool 254 258 let didPruneRepositoryOrder: Bool 255 259 let didPruneWorktreeOrder: Bool 256 260 let didPruneArchivedWorktreeIDs: Bool ··· 536 540 ) 537 541 } 538 542 return .merge(allEffects) 543 + 544 + case .selectionChanged(let selections, let focusTerminal): 545 + return reduceSelectionChanged( 546 + into: &state, 547 + selections: selections, 548 + focusTerminal: focusTerminal 549 + ) 550 + 551 + case .repositoryExpansionChanged(let repositoryID, let isExpanded): 552 + state.$collapsedRepositoryIDs.withLock { collapsedRepositoryIDs in 553 + if isExpanded { 554 + collapsedRepositoryIDs.removeAll { $0 == repositoryID } 555 + } else if !collapsedRepositoryIDs.contains(repositoryID) { 556 + collapsedRepositoryIDs.append(repositoryID) 557 + } 558 + collapsedRepositoryIDs.sort() 559 + } 560 + return .none 539 561 540 562 case .selectArchivedWorktrees: 541 563 state.selection = .archivedWorktrees ··· 2739 2761 state.worktreeInfoByID = filteredWorktreeInfo 2740 2762 } 2741 2763 let didPrunePinned = prunePinnedWorktreeIDs(state: &state) 2764 + let didPruneCollapsedRepositoryIDs = pruneCollapsedRepositoryIDs(state: &state) 2742 2765 let didPruneRepositoryOrder = pruneRepositoryOrderIDs(roots: roots, state: &state) 2743 2766 let didPruneWorktreeOrder = pruneWorktreeOrderByRepository(roots: roots, state: &state) 2744 2767 let didPruneArchivedWorktreeIDs = ··· 2763 2786 } 2764 2787 return ApplyRepositoriesResult( 2765 2788 didPrunePinned: didPrunePinned, 2789 + didPruneCollapsedRepositoryIDs: didPruneCollapsedRepositoryIDs, 2766 2790 didPruneRepositoryOrder: didPruneRepositoryOrder, 2767 2791 didPruneWorktreeOrder: didPruneWorktreeOrder, 2768 2792 didPruneArchivedWorktreeIDs: didPruneArchivedWorktreeIDs ··· 2805 2829 } 2806 2830 } 2807 2831 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 - } 2825 2832 } 2826 2833 2827 2834 extension RepositoriesFeature.State { ··· 2829 2836 selection?.worktreeID 2830 2837 } 2831 2838 2839 + var effectiveSidebarSelectedRows: [WorktreeRowModel] { 2840 + let selectedRows = orderedWorktreeRows().filter { sidebarSelectedWorktreeIDs.contains($0.id) } 2841 + return selectedRows.isEmpty ? (selectedRow(for: selectedWorktreeID).map { [$0] } ?? []) : selectedRows 2842 + } 2843 + 2832 2844 var expandedRepositoryIDs: Set<Repository.ID> { 2833 2845 let repositoryIDs = Set(repositories.map(\.id)) 2834 2846 let collapsedSet = Set(collapsedRepositoryIDs).intersection(repositoryIDs) 2835 2847 let pendingRepositoryIDs = Set(pendingWorktrees.map(\.repositoryID)) 2836 2848 return repositoryIDs.subtracting(collapsedSet).union(pendingRepositoryIDs) 2849 + } 2850 + 2851 + func isRepositoryExpanded(_ repositoryID: Repository.ID) -> Bool { 2852 + expandedRepositoryIDs.contains(repositoryID) 2853 + } 2854 + 2855 + var sidebarSelections: Set<SidebarSelection> { 2856 + guard !isShowingArchivedWorktrees else { 2857 + return [.archivedWorktrees] 2858 + } 2859 + var selections = Set(sidebarSelectedWorktreeIDs.map(SidebarSelection.worktree)) 2860 + if let selectedWorktreeID { 2861 + selections.insert(.worktree(selectedWorktreeID)) 2862 + } 2863 + return selections 2837 2864 } 2838 2865 2839 2866 func worktreeID(byOffset offset: Int) -> Worktree.ID? { ··· 3586 3613 } 3587 3614 } 3588 3615 3616 + private func reduceSelectionChanged( 3617 + into state: inout RepositoriesFeature.State, 3618 + selections: Set<SidebarSelection>, 3619 + focusTerminal: Bool 3620 + ) -> Effect<RepositoriesFeature.Action> { 3621 + let previousSelection = state.selectedWorktreeID 3622 + let previousSelectedWorktree = state.worktree(for: previousSelection) 3623 + 3624 + guard !selections.contains(.archivedWorktrees) else { 3625 + state.selection = .archivedWorktrees 3626 + state.sidebarSelectedWorktreeIDs = [] 3627 + return .send(.delegate(.selectedWorktreeChanged(nil))) 3628 + } 3629 + 3630 + let orderedRows = state.orderedWorktreeRows() 3631 + let orderedWorktreeIDs = orderedRows.map(\.id) 3632 + let allWorktreeIDs = Set(orderedWorktreeIDs) 3633 + let requestedWorktreeIDs = Set(selections.compactMap(\.worktreeID)) 3634 + let nextSidebarSelectedWorktreeIDs = requestedWorktreeIDs.intersection(allWorktreeIDs) 3635 + let droppedIDs = requestedWorktreeIDs.subtracting(nextSidebarSelectedWorktreeIDs) 3636 + if !droppedIDs.isEmpty { 3637 + repositoriesLogger.debug("Selection dropped unknown worktree IDs: \(droppedIDs).") 3638 + } 3639 + 3640 + guard !nextSidebarSelectedWorktreeIDs.isEmpty else { 3641 + setSingleWorktreeSelection(nil, state: &state) 3642 + return .send(.delegate(.selectedWorktreeChanged(nil))) 3643 + } 3644 + 3645 + let nextSelectedWorktreeID = 3646 + if let selectedWorktreeID = state.selectedWorktreeID, 3647 + nextSidebarSelectedWorktreeIDs.contains(selectedWorktreeID) 3648 + { 3649 + selectedWorktreeID 3650 + } else { 3651 + orderedWorktreeIDs.first(where: nextSidebarSelectedWorktreeIDs.contains) 3652 + ?? nextSidebarSelectedWorktreeIDs.first 3653 + } 3654 + 3655 + state.selection = nextSelectedWorktreeID.map(SidebarSelection.worktree) 3656 + state.sidebarSelectedWorktreeIDs = nextSidebarSelectedWorktreeIDs 3657 + if focusTerminal, 3658 + let nextSelectedWorktreeID, 3659 + previousSelection != nextSelectedWorktreeID 3660 + { 3661 + state.pendingTerminalFocusWorktreeIDs.insert(nextSelectedWorktreeID) 3662 + } 3663 + 3664 + let selectedWorktree = state.worktree(for: nextSelectedWorktreeID) 3665 + let selectionChanged = selectionDidChange( 3666 + previousSelectionID: previousSelection, 3667 + previousSelectedWorktree: previousSelectedWorktree, 3668 + selectedWorktreeID: nextSelectedWorktreeID, 3669 + selectedWorktree: selectedWorktree 3670 + ) 3671 + return selectionChanged ? .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) : .none 3672 + } 3673 + 3674 + private func selectionDidChange( 3675 + previousSelectionID: Worktree.ID?, 3676 + previousSelectedWorktree: Worktree?, 3677 + selectedWorktreeID: Worktree.ID?, 3678 + selectedWorktree: Worktree? 3679 + ) -> Bool { 3680 + previousSelectionID != selectedWorktreeID 3681 + || previousSelectedWorktree?.workingDirectory != selectedWorktree?.workingDirectory 3682 + || previousSelectedWorktree?.repositoryRootURL != selectedWorktree?.repositoryRootURL 3683 + } 3684 + 3589 3685 private func repositoryForWorktreeCreation( 3590 3686 _ state: RepositoriesFeature.State 3591 3687 ) -> Repository? { ··· 3622 3718 return true 3623 3719 } 3624 3720 return false 3721 + } 3722 + 3723 + private func pruneCollapsedRepositoryIDs(state: inout RepositoriesFeature.State) -> Bool { 3724 + let repositoryIDs = Set(state.repositories.map(\.id)) 3725 + var didChange = false 3726 + state.$collapsedRepositoryIDs.withLock { collapsedRepositoryIDs in 3727 + let pruned = collapsedRepositoryIDs.filter { repositoryIDs.contains($0) } 3728 + didChange = pruned != collapsedRepositoryIDs 3729 + guard didChange else { return } 3730 + collapsedRepositoryIDs = pruned 3731 + } 3732 + return didChange 3625 3733 } 3626 3734 3627 3735 private func pruneRepositoryOrderIDs(
+1 -2
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 6 5 @ViewBuilder let label: () -> Label 7 6 @State private var isPresented = false 8 7 @State private var isHoveringButton = false ··· 24 23 updatePresentation() 25 24 } 26 25 .popover(isPresented: $isPresented) { 27 - NotificationPopoverView(notifications: notifications, onFocusNotification: onFocusNotification) 26 + NotificationPopoverView(notifications: notifications) 28 27 .onHover { hovering in 29 28 isHoveringPopover = hovering 30 29 updatePresentation()
+2 -2
supacode/Features/Repositories/Views/NotificationPopoverView.swift
··· 2 2 3 3 struct NotificationPopoverView: View { 4 4 let notifications: [WorktreeTerminalNotification] 5 - let onFocusNotification: (WorktreeTerminalNotification) -> Void 5 + @Environment(\.focusNotificationAction) private var focusNotificationAction: (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 - onFocusNotification(notification) 20 + focusNotificationAction(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> 8 6 let terminalManager: WorktreeTerminalManager 9 - @State private var isDragActive = false 10 - 11 7 var body: some View { 12 8 let state = store.state 9 + let expandedRepoIDs = state.expandedRepositoryIDs 13 10 let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 14 11 let orderedRoots = state.orderedRepositoryRoots() 15 - let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) 12 + let selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 13 + let currentSelections = state.sidebarSelections 16 14 let selection = Binding<Set<SidebarSelection>>( 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 - }, 15 + get: { currentSelections }, 29 16 set: { newValue in 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)) 17 + guard newValue != currentSelections else { return } 18 + store.send(.selectionChanged(newValue)) 82 19 } 83 20 ) 84 21 let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 85 22 List(selection: selection) { 86 23 if orderedRoots.isEmpty { 87 - let repositories = store.repositories 88 - ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in 89 - RepositorySectionView( 24 + ForEach(store.repositories) { repository in 25 + SidebarRepositorySectionView( 90 26 repository: repository, 91 - showsTopSeparator: index > 0, 92 - isDragActive: isDragActive, 93 27 hotkeyRows: hotkeyRows, 94 28 selectedWorktreeIDs: selectedWorktreeIDs, 95 - expandedRepoIDs: $expandedRepoIDs, 96 29 store: store, 97 30 terminalManager: terminalManager 98 31 ) 99 - .listRowInsets(EdgeInsets()) 100 32 } 101 33 } else { 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 - } 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 126 40 ) 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( 41 + } else if let repository = repositoriesByID[row.repositoryID] { 42 + SidebarRepositorySectionView( 140 43 repository: repository, 141 - showsTopSeparator: index > 0, 142 - isDragActive: isDragActive, 143 44 hotkeyRows: hotkeyRows, 144 45 selectedWorktreeIDs: selectedWorktreeIDs, 145 - expandedRepoIDs: $expandedRepoIDs, 146 46 store: store, 147 47 terminalManager: terminalManager 148 48 ) 149 - .listRowInsets(EdgeInsets()) 150 49 } 151 50 } 152 51 .onMove { offsets, destination in ··· 157 56 .listStyle(.sidebar) 158 57 .scrollIndicators(.never) 159 58 .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 - } 180 59 .dropDestination(for: URL.self) { urls, _ in 181 60 let fileURLs = urls.filter(\.isFileURL) 182 61 guard !fileURLs.isEmpty else { return false } ··· 205 84 terminalState.focusAndInsertText(keyPress.characters) 206 85 return .handled 207 86 } 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 }
+1 -2
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) 5 4 6 5 var worktreeID: Worktree.ID? { 7 6 switch self { 8 7 case .worktree(let id): 9 8 return id 10 - case .archivedWorktrees, .repository: 9 + case .archivedWorktrees: 11 10 return nil 12 11 } 13 12 }
+16 -84
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(.appStorage("sidebarCollapsedRepositoryIDs")) private var collapsedRepositoryIDs: [Repository.ID] = [] 9 - @State private var sidebarSelections: Set<SidebarSelection> = [] 8 + @Shared(.settingsFile) private var settingsFile 10 9 11 10 var body: some View { 12 11 let state = store.state 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) 12 + let visibleHotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: state.expandedRepositoryIDs) 13 + let effectiveSelectedRows = state.effectiveSidebarSelectedRows 22 14 let confirmWorktreeAction = makeConfirmWorktreeAction(state: state) 23 15 let archiveWorktreeAction = makeArchiveWorktreeAction(rows: effectiveSelectedRows) 24 16 let deleteWorktreeAction = makeDeleteWorktreeAction(rows: effectiveSelectedRows) 17 + let openRepo = AppShortcuts.openRepository.effective(from: settingsFile.global.shortcutOverrides) 25 18 26 19 return SidebarListView( 27 20 store: store, 28 - expandedRepoIDs: expandedRepoIDsBinding, 29 - sidebarSelections: $sidebarSelections, 30 21 terminalManager: terminalManager 31 22 ) 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 + } 32 35 .focusedSceneValue(\.confirmWorktreeAction, confirmWorktreeAction) 33 36 .focusedValue(\.archiveWorktreeAction, archiveWorktreeAction) 34 37 .focusedValue(\.deleteWorktreeAction, deleteWorktreeAction) 35 38 .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 74 39 } 75 40 76 41 private func makeConfirmWorktreeAction( ··· 124 89 store.send(.requestDeleteWorktrees(targets)) 125 90 } 126 91 } 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)) 160 92 } 161 93 }
+369 -177
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 1 - import AppKit 2 1 import SwiftUI 3 2 4 3 struct WorktreeRow: View { 5 4 let name: String 6 - let worktreeName: 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 7 12 let info: WorktreeInfoEntry? 13 + let pullRequestBadgeText: String? 8 14 let showsPullRequestInfo: Bool 9 - let isHovered: Bool 10 - let isPinned: Bool 11 - let isMainWorktree: Bool 12 - let isLoading: Bool 13 - let taskStatus: WorktreeTaskStatus? 14 15 let isRunScriptRunning: Bool 15 16 let showsNotificationIndicator: Bool 16 17 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 + 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 + } 23 71 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 72 + // Pull request display. 73 + let prDisplay = WorktreePullRequestDisplay( 74 + worktreeName: row.name, 75 + pullRequest: showsPullRequestInfo ? row.info?.pullRequest : nil 30 76 ) 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 - } 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 110 96 } 111 - WorktreeRowInfoView( 112 - worktreeName: detailText, 113 - showsPullRequestTag: showsPullRequestTag, 114 - pullRequestNumber: display.pullRequest?.number, 115 - pullRequestState: display.pullRequestState, 116 - mergeReadiness: mergeReadiness, 117 - shortcutHint: shortcutHint 118 - ) 119 - .padding(.leading, 22) 97 + } else { 98 + self.gitIconName = "git-branch" 99 + self.gitIconColor = .secondary 120 100 } 121 - .padding(.horizontal, 2) 122 - .frame(maxWidth: .infinity, minHeight: worktreeRowHeight, alignment: .center) 123 101 } 124 102 125 - private func pullRequestMergeReadiness( 126 - for pullRequest: GithubPullRequest? 127 - ) -> PullRequestMergeReadiness? { 128 - guard let pullRequest, pullRequest.state.uppercased() == "OPEN" else { 129 - return nil 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 } 130 109 } 131 - return PullRequestMergeReadiness(pullRequest: pullRequest) 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 132 115 } 133 116 134 - private var worktreeRowHeight: CGFloat { 135 - 42 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 + ) 137 + } 138 + } icon: { 139 + IconView( 140 + isArchiving: isArchiving, 141 + isDeleting: isDeleting, 142 + gitIconName: gitIconName, 143 + gitIconColor: gitIconColor 144 + ) 145 + } 146 + .labelStyle(.verticallyCentered) 147 + .listRowInsets(.trailing, 4) 148 + .listRowInsets(.vertical, 6) 136 149 } 137 150 } 138 151 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? 145 - let shortcutHint: String? 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) 167 + } 168 + } 146 169 147 170 var body: some View { 148 - HStack(spacing: 4) { 149 - summaryText 171 + VStack(alignment: .leading, spacing: 0) { 172 + Text(name) 173 + .font(.body) 150 174 .lineLimit(1) 151 - .truncationMode(.tail) 152 - .layoutPriority(1) 153 - Spacer(minLength: 0) 154 - if let shortcutHint { 155 - ShortcutHintView(text: shortcutHint, color: .secondary) 175 + .shimmer(isActive: isBusy) 176 + if let subtitle { 177 + Text(subtitle) 178 + .font(.footnote) 179 + .foregroundStyle(resolvedWorktreeColor) 180 + .lineLimit(1) 181 + } 182 + } 183 + } 184 + } 185 + 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) 156 229 } 157 230 } 158 - .font(.caption) 159 - .frame(minHeight: 14) 231 + .foregroundStyle(resolvedColor) 232 + .accessibilityLabel(accessibilityLabel ?? "") 233 + .accessibilityHidden(accessibilityLabel == nil) 160 234 } 235 + } 161 236 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) 237 + // MARK: - Trailing. 238 + 239 + private struct TrailingView: View { 240 + 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 + 249 + 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 + ) 169 270 } 170 271 } 171 - if !worktreeName.isEmpty { 172 - var segment = AttributedString(worktreeName) 173 - segment.foregroundColor = .secondary 174 - result.append(segment) 272 + } 273 + } 274 + 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)) 289 + } 290 + .font(.caption) 291 + .monospacedDigit() 292 + .transition(.blurReplace) 175 293 } 176 - if showsPullRequestTag, let pullRequestNumber { 177 - appendSeparator() 178 - var segment = AttributedString("PR #\(pullRequestNumber)") 179 - segment.foregroundColor = .secondary 180 - result.append(segment) 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) 181 328 } 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) 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 192 339 } 193 - return Text(result) 194 340 } 195 341 } 196 342 197 - private struct WorktreeRowChangeCountView: View { 198 - let addedLines: Int 199 - let removedLines: Int 200 - let isSelected: Bool 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)) 373 + } 374 + } 375 + 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 201 383 202 384 var body: some View { 203 - HStack(spacing: 4) { 204 - Text("+\(addedLines)") 205 - .foregroundStyle(.green) 206 - Text("-\(removedLines)") 207 - .foregroundStyle(.red) 208 - } 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) 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 + } 216 397 } 217 - .monospacedDigit() 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.") 218 410 } 219 411 }
+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 + }
+225 -251
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 3 3 import Sharing 4 4 import SwiftUI 5 5 6 + private nonisolated let notificationLogger = SupaLogger("Notifications") 7 + 6 8 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 + 7 16 let repository: Repository 8 - let isExpanded: Bool 9 17 let hotkeyRows: [WorktreeRowModel] 10 18 let selectedWorktreeIDs: Set<Worktree.ID> 11 19 @Bindable var store: StoreOf<RepositoriesFeature> 12 20 let terminalManager: WorktreeTerminalManager 13 21 @Environment(CommandKeyObserver.self) private var commandKeyObserver 14 - @Environment(\.colorScheme) private var colorScheme 15 22 @State private var draggingWorktreeIDs: Set<Worktree.ID> = [] 16 - @State private var hoveredWorktreeID: Worktree.ID? 17 23 18 24 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 27 28 let isRepositoryRemoving = state.isRemovingRepository(repository) 28 29 let showShortcutHints = commandKeyObserver.isPressed 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 - } 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 + ] 42 60 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, 61 + ForEach(groupConfigurations) { groupConfiguration in 62 + WorktreeRowGroupView( 63 + rows: groupConfiguration.rows, 64 + selectedWorktreeIDs: selectedWorktreeIDs, 65 + store: store, 66 + terminalManager: terminalManager, 67 + draggingWorktreeIDs: $draggingWorktreeIDs, 53 68 isRepositoryRemoving: isRepositoryRemoving, 54 - moveDisabled: true, 55 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 69 + hideSubtitle: groupConfiguration.hideSubtitle, 70 + moveBehavior: groupConfiguration.moveBehavior, 71 + shortcutIndexByID: shortcutIndexByID 56 72 ) 57 73 } 58 - ForEach(sections.pinned) { row in 59 - rowView( 60 - row, 61 - isRepositoryRemoving: isRepositoryRemoving, 62 - moveDisabled: isRepositoryRemoving || row.isLoading, 63 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 64 - ) 65 - } 66 - .onMove { offsets, destination in 67 - store.send(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination)) 68 - } 69 - ForEach(sections.pending) { row in 70 - rowView( 71 - row, 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, 72 103 isRepositoryRemoving: isRepositoryRemoving, 73 - moveDisabled: true, 74 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 104 + hideSubtitle: hideSubtitle, 105 + moveDisabled: moveDisabled(for: row), 106 + shortcutHint: shortcutHint(for: shortcutIndexByID[row.id]) 75 107 ) 76 108 } 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 - ) 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 84 118 } 85 - .onMove { offsets, destination in 86 - store.send(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination)) 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)) 87 137 } 88 138 } 139 + } 89 140 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 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 115 169 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).") 116 172 return 117 173 } 118 - _ = terminalState.focusSurface(id: notification.surfaceId) 174 + if !terminalState.focusSurface(id: notification.surfaceId) { 175 + notificationLogger.warning("Failed to focus surface \(notification.surfaceId) for worktree \(row.id).") 176 + } 119 177 } 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 { 178 + .tag(SidebarSelection.worktree(row.id)) 179 + .typeSelectEquivalent("") 180 + .moveDisabled(moveDisabled) 181 + .contextMenu { 134 182 if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 135 - baseRow.contextMenu { 136 - rowContextMenu(worktree: worktree, row: row) 137 - } 138 - } else { 139 - baseRow.disabled(isRepositoryRemoving) 183 + WorktreeContextMenu( 184 + worktree: worktree, 185 + row: row, 186 + store: store, 187 + selectedWorktreeIDs: selectedWorktreeIDs 188 + ) 140 189 } 141 190 } 191 + .disabled(!row.isRemovable && isRepositoryRemoving) 142 192 .contentShape(.dragPreview, .rect) 143 193 .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 - } 153 194 .onDragSessionUpdated { session in 154 195 let draggedIDs = Set(session.draggedItemIDs(for: Worktree.ID.self)) 155 196 if case .ended = session.phase { ··· 170 211 } 171 212 } 172 213 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 - } 214 + } 185 215 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) 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 215 231 } 216 232 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) 233 + var body: some View { 234 + let contextRows = contextRows 222 235 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 + 223 273 let archiveTargets = 224 274 contextRows 225 275 .filter { !$0.isMainWorktree && !$0.isLoading } ··· 235 285 repositoryID: $0.repositoryID 236 286 ) 237 287 } 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") 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)) 252 293 } else { 253 - Button("Pin to top") { 254 - togglePin(for: worktree.id, isPinned: false) 255 - } 256 - .help("Pin to top") 294 + store.send(.requestArchiveWorktrees(archiveTargets)) 257 295 } 258 296 } 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 - ) 297 + .appKeyboardShortcut(archiveShortcut) 271 298 .disabled(archiveTargets.isEmpty) 272 - Button(deleteTitle, role: .destructive) { 273 - deleteWorktrees(deleteTargets) 299 + 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 + } 274 307 } 275 - .help(deleteTitle) 276 - } 277 - 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 308 + .appKeyboardShortcut(deleteShortcut) 283 309 } 284 310 285 311 private func togglePin(for worktreeID: Worktree.ID, isPinned: Bool) { ··· 290 316 store.send(.pinWorktree(worktreeID)) 291 317 } 292 318 } 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 345 319 } 346 320 }
+322
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 + 138 460 @Test func createRandomWorktreeWithoutRepositoriesShowsAlert() async { 139 461 let store = TestStore(initialState: RepositoriesFeature.State()) { 140 462 RepositoriesFeature()