native macOS codings agent orchestrator
5
fork

Configure Feed

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

Merge pull request #252 from onevcat/plan/sidebar-container-refactor

Refactor sidebar presentation drag handling

authored by

Wei Wang and committed by
GitHub
ba406099 c135c92c

+2139 -291
+617
doc-onevcat/plans/2026-05-03-sidebar-container-refactor-plan.md
··· 1 + # Sidebar Container Refactor Plan 2 + 3 + Status: planning 4 + Issue: [#249](https://github.com/onevcat/Prowl/issues/249) 5 + Related: [#222](https://github.com/onevcat/Prowl/issues/222) 6 + 7 + ## Goal 8 + 9 + Refactor the repository sidebar so each repository behaves as one stable visual and drag unit, while worktrees remain selectable, reorderable, and efficient to update. 10 + 11 + This should fix the structural mismatch where the app treats repositories as reorderable units but SwiftUI `List` sees repository headers and worktree rows as separate rows. That mismatch shows up as: 12 + 13 + - incorrect repository drag insertion indicators when dragging downward across expanded repositories 14 + - unstable bulk expand/collapse animations 15 + - potential sidebar flicker during drag when live terminal, notification, or ordering updates arrive 16 + 17 + ## Current Findings 18 + 19 + ### 1. Repository sections are not actual list rows 20 + 21 + `SidebarListView` renders repositories through an outer `ForEach(...).onMove`. 22 + 23 + `RepositorySectionView` then returns: 24 + 25 + ```swift 26 + Group { 27 + header 28 + .tag(SidebarSelection.repository(repository.id)) 29 + if isExpanded { 30 + WorktreeRowsView(...) 31 + } 32 + } 33 + ``` 34 + 35 + In practice, the outer data model says "repository row", but `List` receives separate rows: 36 + 37 + ```text 38 + Repository A header 39 + Repository A worktree 40 + Repository A worktree 41 + Repository B header 42 + Repository B worktree 43 + ``` 44 + 45 + This explains the observed downward-drag indicator bug: 46 + 47 + ```text 48 + Target Repo header 49 + o----------- 50 + Target Repo worktree 51 + ``` 52 + 53 + SwiftUI is placing the indicator between list rows. It does not know that the target repository header and its worktree rows should be treated as one repository-level drop zone. 54 + 55 + ### 2. `List(selection:)` is doing too much 56 + 57 + The current `List` carries several behaviors at once: 58 + 59 + - archived worktree selection and repository list header 60 + - repository row selection for plain folders 61 + - worktree multi-selection 62 + - repository expand/collapse 63 + - native repository reorder 64 + - native worktree reorder for pinned and unpinned groups 65 + - reveal-in-sidebar via `ScrollViewReader.scrollTo` 66 + - native sidebar styling and accessibility 67 + 68 + Canvas, Shelf, and the footer are not `List` rows today; they are safe-area inset chrome around the list. The refactor should preserve that boundary unless a later design intentionally moves them into the scroll content. 69 + 70 + ### 3. Live state still reaches rows during drag 71 + 72 + Some expensive state reads have already been isolated, such as moving repository tab-count reads into `RepoHeaderTabCountBadge`. 73 + 74 + Remaining drag-time churn sources include: 75 + 76 + - `WorktreeRowsView` animates changes to `rowIDs` 77 + - each worktree row reads terminal notification/task/run-script state 78 + - notification-driven reorder can call `withAnimation(.snappy)` and mutate `worktreeOrderByRepository` 79 + - row hover/action UI changes while a drag session is active 80 + 81 + These are not necessarily the root cause of the drop-indicator bug, but they are credible contributors to #222-style flicker. 82 + 83 + ## Revised Direction 84 + 85 + Use a custom sidebar scroll container rather than trying to keep the current flat `List` structure. 86 + 87 + Execution order matters: first stabilize the old `List` path with a reducer-level drag gate, then replace the visual structure. The drag gate is a hard prerequisite because it reduces #222 risk before the broader #249 rewrite starts. 88 + 89 + Recommended shape: 90 + 91 + ```text 92 + SidebarView chrome 93 + ├── top safeAreaInset buttons 94 + │ ├── Canvas 95 + │ └── Shelf 96 + ├── ScrollViewReader 97 + │ └── ScrollView 98 + │ └── LazyVStack or VStack 99 + │ ├── repository list header 100 + │ ├── RepositoryContainerRow 101 + │ │ ├── RepositoryHeaderRow 102 + │ │ └── WorktreeRows 103 + │ ├── FailedRepositoryRow 104 + │ └── ArchivedWorktreesRow 105 + └── bottom safeAreaInset footer 106 + ``` 107 + 108 + Key property: repository containers are the only repository-level siblings in the outer stack. Expanded worktrees are children inside the container, not siblings beside it. 109 + 110 + This aligns UI boundaries with model boundaries: 111 + 112 + - repository reorder indicators target repository containers 113 + - expand/collapse animates inside a container 114 + - worktree reorder indicators target worktree rows inside one container 115 + - live worktree updates do not change the outer repository list shape 116 + 117 + ## Options Considered 118 + 119 + ### Option A: Keep `List`, wrap worktrees inside one repository row 120 + 121 + Pros: 122 + 123 + - preserves some native sidebar styling 124 + - repository-level `onMove` might remain mostly native 125 + 126 + Cons: 127 + 128 + - nested selectable worktree rows inside a single `List` row no longer participate naturally in `List(selection:)` 129 + - worktree-level `onMove` becomes awkward inside a row 130 + - native selection and keyboard behavior still need replacement 131 + - likely keeps a hard-to-debug mix of native and custom drag logic 132 + 133 + This option reduces the indicator bug but does not cleanly solve the broader sidebar design. 134 + 135 + ### Option B: Move fully to `ScrollView` + explicit rows 136 + 137 + Pros: 138 + 139 + - model and visual structure match 140 + - repo and worktree drag/drop can be made explicit and testable 141 + - selection, focus, and reveal behavior are owned by our code instead of `List` side effects 142 + - easier to freeze drag-time updates intentionally 143 + - eliminates `List` cell reuse as a class of expand/collapse animation bugs 144 + 145 + Cons: 146 + 147 + - must replace native `List(selection:)` 148 + - must rebuild keyboard navigation, multi-selection, reorder, and accessibility affordances 149 + - more implementation work 150 + 151 + This remains the recommended route for #249 if the goal is "fix the sidebar design once" rather than patch one symptom. 152 + 153 + ### Option C: Short-term drag-time freeze only 154 + 155 + Pros: 156 + 157 + - small 158 + - may help #222 flicker 159 + 160 + Cons: 161 + 162 + - does not fix repository insertion indicator because row boundaries remain wrong 163 + - leaves the main structural mismatch in place 164 + 165 + This is now the mandatory M1 prerequisite for Option B, not a replacement for Option B. 166 + 167 + ## Hard Requirements 168 + 169 + The refactor must preserve these behavior and state contracts. 170 + 171 + ### Reducer actions and persistence 172 + 173 + - Keep the existing reducer actions and persistence paths for repository and worktree ordering unless a later implementation note explicitly proves a rename is worth it. 174 + - Preserve calls behind repository reorder, pinned worktree reorder, unpinned worktree reorder, and notification-driven reorder. 175 + - Treat failed repository reorder semantics as an explicit product decision: 176 + - either failed repository rows are reorderable and their order persists through the same root ordering path 177 + - or they are not reorderable and the UI gives consistent feedback with no insertion target around them 178 + 179 + ### Expanded and collapsed state 180 + 181 + - Preserve `@Shared` write-back semantics for collapsed repository IDs. 182 + - Preserve cleanup of invalid collapsed IDs when repository IDs change. 183 + - Ensure bulk expand/collapse and single expand/collapse share the same model path. 184 + 185 + ### Focused actions and selection synchronization 186 + 187 + - Preserve `SidebarView` focused values for `confirmWorktreeAction`, `archiveWorktreeAction`, `deleteWorktreeAction`, and `visibleHotkeyWorktreeRows`. 188 + - Preserve the `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization currently owned by `SidebarView`. 189 + - Do not regress menu commands or numbered worktree hotkeys when replacing `List(selection:)`. 190 + 191 + ### Existing row affordances 192 + 193 + - Preserve repository and worktree context menus. 194 + - Preserve drag previews. 195 + - Preserve current worktree row type-select behavior. Worktree rows currently use `.typeSelectEquivalent("")`; V1 should keep type-select effectively disabled for those rows. 196 + - Preserve root-level `dropDestination(for: URL.self)` on the sidebar container, including drops into blank sidebar space. 197 + 198 + ### Ordered roots 199 + 200 + - Converge the current `orderedRoots.isEmpty` fallback and non-empty custom-order path into one presentation path. 201 + - The empty ordered-roots case is a valid user state and must have tests. 202 + 203 + ## Proposed Architecture 204 + 205 + ### SidebarPresentation 206 + 207 + Introduce a pure presentation model that flattens current repository state into explicit sidebar units. 208 + 209 + Suggested model: 210 + 211 + ```swift 212 + struct SidebarPresentation: Equatable { 213 + var items: [SidebarItem] 214 + } 215 + 216 + enum SidebarItem: Equatable, Identifiable { 217 + case listHeader(SidebarListHeaderModel) 218 + case repository(SidebarRepositoryContainerModel) 219 + case failedRepository(FailedRepositoryModel) 220 + case archivedWorktrees(ArchivedWorktreesRowModel) 221 + } 222 + 223 + struct SidebarRepositoryContainerModel: Equatable, Identifiable { 224 + var repositoryID: Repository.ID 225 + var title: String 226 + var rootURL: URL 227 + var kind: Repository.Kind 228 + var isExpanded: Bool 229 + var isRemoving: Bool 230 + var worktreeSections: WorktreeRowSections 231 + } 232 + ``` 233 + 234 + Rules: 235 + 236 + - build `SidebarPresentation` from reducer/state-side pure functions or equivalent helpers 237 + - outer `items` contains one item per repository, not one item per row 238 + - worktree sections remain inside the repository container 239 + - presentation construction is pure and unit-tested 240 + - high-frequency terminal notification/task/run-script state stays in leaf views, not in broad presentation state 241 + - Canvas, Shelf, and footer chrome remain outside `SidebarPresentation` in V1 242 + 243 + ### Selection 244 + 245 + Replace `List(selection:)` with explicit selection handling. 246 + 247 + Keep `RepositoriesFeature.State.selection` and `sidebarSelectedWorktreeIDs` as the source of truth, but route clicks through helper functions. 248 + 249 + Compatibility matrix: 250 + 251 + | Interaction | State behavior | Focus behavior | 252 + | --- | --- | --- | 253 + | Canvas button | Selects Canvas and clears incompatible sidebar worktree selection. | Does not focus a terminal. | 254 + | Shelf button | Selects Shelf and clears incompatible sidebar worktree selection. | Does not focus a terminal. | 255 + | Archived worktrees row | Selects archived worktrees and clears incompatible worktree selection. | Does not focus a terminal. | 256 + | Git repository header click | Toggles expanded state by default. | Does not focus a terminal. | 257 + | Plain folder repository click | Selects the repository. | Does not focus a terminal unless current behavior already does. | 258 + | Worktree row normal click | Selects one worktree and updates sidebar selected worktree IDs to that one ID. | Focuses the terminal for the selected worktree. | 259 + | Worktree row Cmd-click | Toggles membership in sidebar selected worktree IDs, preserving multi-select priority. | Does not steal focus unless the resulting primary selection changes by existing rules. | 260 + | Empty sidebar selection | Clears sidebar selected worktree IDs. | Does not focus a terminal. | 261 + 262 + Selection visuals should be explicit in `RepositoryHeaderRow` and `WorktreeRow`, not inherited from `List`. 263 + 264 + ### Keyboard Navigation 265 + 266 + Preserve the existing command actions first: 267 + 268 + - `selectNextWorktree` 269 + - `selectPreviousWorktree` 270 + - `revealSelectedWorktreeInSidebar` 271 + - numbered hotkeys 272 + 273 + Do not try to rebuild full Finder-like keyboard navigation in the first pass unless it is currently user-visible and relied upon. 274 + 275 + Required V1 behavior: 276 + 277 + - command shortcuts still select worktrees 278 + - selected row is scrolled into view on reveal 279 + - focus returns to terminal after single worktree selection 280 + - sidebar focus does not accidentally forward text while Canvas, Shelf, or Archived rules say it should not 281 + 282 + ### Repository Reorder 283 + 284 + Replace `ForEach(...).onMove` with explicit repository drag/drop. 285 + 286 + Suggested approach: 287 + 288 + - make `RepositoryContainerRow` draggable with repository ID payload 289 + - render a custom repository insertion indicator between repository containers 290 + - compute drop destination as a repository index 291 + - dispatch existing repository-ordering actions or a new reducer action that delegates to the same persistence path 292 + 293 + The custom indicator should always render at repository container boundaries: 294 + 295 + ```text 296 + Target Repo header 297 + Target Repo worktree 298 + o----------- 299 + ``` 300 + 301 + This directly fixes the current downward-drag indicator bug. 302 + 303 + ### Worktree Reorder 304 + 305 + Keep worktree reorder scoped inside one repository container. 306 + 307 + Suggested approach: 308 + 309 + - worktree rows are draggable with worktree ID payload 310 + - pinned and unpinned sections keep separate drop zones 311 + - main and pending rows remain non-movable 312 + - drop destination maps to existing reducer actions: 313 + - `.pinnedWorktreesMoved(repositoryID, offsets, destination)` 314 + - `.unpinnedWorktreesMoved(repositoryID, offsets, destination)` 315 + 316 + Cross-repository worktree drag can stay out of scope. The current model does not appear to support moving worktrees between repositories. 317 + 318 + ### Drag-Time Freeze 319 + 320 + Add sidebar drag state at reducer level and use it in both the old and new sidebar paths. 321 + 322 + During any sidebar drag: 323 + 324 + - freeze hover-only row actions 325 + - hide pull request / notification popover affordances that resize rows 326 + - suppress row-ID animations caused by notification-driven reorder 327 + - defer "move notified worktree to top" until drag ends, or apply it without animation after drop 328 + 329 + Reducer behavior: 330 + 331 + - drag begin records that sidebar drag is active 332 + - `worktreeNotificationReceived` while drag is active records pending reorder IDs instead of mutating row order immediately 333 + - drag end flushes pending notification reorders in deterministic order, dropping stale worktree IDs 334 + - `moveNotifiedWorktreeToTop == false` remains a no-op 335 + 336 + This addresses #222 without requiring every live data read to stop. 337 + 338 + ### Expand / Collapse 339 + 340 + Move expand/collapse animation into `RepositoryContainerRow`. 341 + 342 + Rules: 343 + 344 + - outer repository container identity must not change when worktrees appear/disappear 345 + - single repo expand/collapse animates child rows inside the container 346 + - bulk expand/collapse updates many containers, but the outer stack still has stable repository items 347 + - avoid animating row identity and live status changes in the same transaction 348 + 349 + ### Reveal In Sidebar 350 + 351 + `ScrollViewReader.scrollTo` can still work, but scroll IDs must be explicit: 352 + 353 + - repository container: `SidebarScrollID.repository(repositoryID)` 354 + - worktree row: `SidebarScrollID.worktree(worktreeID)` 355 + - archived worktrees row: `SidebarScrollID.archivedWorktrees` 356 + 357 + When revealing a collapsed worktree: 358 + 359 + 1. expand its repository 360 + 2. wait for an event-driven row availability signal 361 + 3. scroll to `SidebarScrollID.worktree(worktreeID)` 362 + 4. consume pending reveal 363 + 364 + Do not rely on a fixed number of `Task.yield()` calls in the new architecture. The implementation can use a scroll target registry, preference key, or equivalent view materialization signal. 365 + 366 + ### Accessibility 367 + 368 + Minimum accessibility requirements: 369 + 370 + - repository headers expose button/row labels and expanded state 371 + - worktree rows expose selection state 372 + - drag handles or rows expose reorder affordance where AppKit/SwiftUI can support it 373 + - Canvas / Shelf / Archived rows keep meaningful labels 374 + 375 + If full native `List` accessibility cannot be matched in V1, document the gap and keep keyboard command coverage strong. 376 + 377 + ## Implementation Plan 378 + 379 + ### Phase 0: Baseline and Guardrails 380 + 381 + - Add a short manual repro checklist for: 382 + - repository drag up/down over expanded target 383 + - bulk expand/collapse with many repositories 384 + - worktree reorder in pinned/unpinned groups 385 + - sidebar multi-selection 386 + - reveal-in-sidebar 387 + - Add signposts around sidebar presentation build and drag state transitions if trace work is needed. 388 + - Establish `LazyVStack` vs `VStack` decision metrics before replacing the list: 389 + - expand/collapse latency for 10+ repositories 390 + - frame stability during repository drag 391 + - CPU peak during drag and bulk expand/collapse 392 + - body recomputation count for repository container and worktree row views 393 + - Keep current `List` code untouched until M1 and presentation tests exist. 394 + 395 + ### M1: Stabilize Old `List` Drag Behavior 396 + 397 + Files likely involved: 398 + 399 + - `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift` 400 + - `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift` 401 + - `supacode/Features/Repositories/Views/SidebarListView.swift` 402 + - `supacodeTests/RepositoriesFeatureTests.swift` 403 + 404 + Deliver: 405 + 406 + - reducer-level sidebar drag state 407 + - view action for drag begin/end from the old `List` path 408 + - delayed or no-animation handling for notification-driven reorder during drag 409 + - deterministic pending reorder flush on drag end 410 + 411 + Tests: 412 + 413 + - notification during sidebar drag does not mutate visible worktree order immediately 414 + - drag end applies the pending notification reorder in deterministic order 415 + - multiple notifications during one drag produce stable ordering 416 + - stale pending worktree IDs are ignored 417 + - `moveNotifiedWorktreeToTop == false` remains a no-op 418 + - persistence is called only when the reorder is actually applied 419 + 420 + ### Phase 1: Pure Presentation and Reorder Mapping 421 + 422 + Files likely involved: 423 + 424 + - `supacode/Features/Repositories/Models/SidebarPresentation.swift` (new) 425 + - `supacodeTests/SidebarPresentationTests.swift` (new) 426 + - existing reducer ordering tests 427 + 428 + Deliver: 429 + 430 + - pure sidebar presentation builder 431 + - stable scroll IDs 432 + - pure drop-destination mapping for repository and worktree reorder 433 + - one unified presentation path for empty and non-empty ordered roots 434 + - explicit failed repository row reorder semantics 435 + 436 + Tests: 437 + 438 + - expanded repository keeps one outer item with child rows 439 + - failed repositories preserve the chosen reorder semantics 440 + - plain folders produce repository containers with no worktree children 441 + - pinned/main/pending/unpinned sections are preserved 442 + - empty ordered roots and custom ordered roots produce equivalent presentation rules 443 + - repository drop destinations map to expected order 444 + - worktree drop destinations map within pinned/unpinned sections 445 + 446 + ### Phase 2: New Container Views Behind a Switch 447 + 448 + Files likely involved: 449 + 450 + - `SidebarListView.swift` 451 + - `RepositorySectionView.swift` 452 + - `WorktreeRowsView.swift` 453 + - new `SidebarContainerListView.swift` 454 + - new `RepositoryContainerRow.swift` 455 + 456 + Deliver: 457 + 458 + - render the new container sidebar behind a local compile-time or private runtime switch 459 + - no reducer changes except new presentation helpers if needed 460 + - preserve row styling visually before enabling custom drag/drop 461 + - preserve root-level URL drop for files dragged into blank sidebar space 462 + - preserve context menus and drag previews 463 + 464 + This phase should be screenshot/manual verified before deleting the old `List` path. 465 + 466 + ### Phase 3: Explicit Selection, Focus, and Reveal 467 + 468 + Deliver: 469 + 470 + - click handling for repository and worktree rows 471 + - explicit selection visuals 472 + - multi-selection behavior matching the compatibility matrix 473 + - `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization 474 + - focused actions and hotkey row values 475 + - reveal-in-sidebar via new scroll IDs and row availability events 476 + - focused terminal handoff after single worktree selection 477 + 478 + Tests: 479 + 480 + - pure selection helper tests 481 + - reducer tests for sidebar selected worktree synchronization 482 + - focused action manual checklist for confirm/archive/delete and numbered hotkeys 483 + 484 + ### Phase 4: Custom Repository Reorder 485 + 486 + Deliver: 487 + 488 + - repository drag payload 489 + - custom repo-level insertion indicator 490 + - drop handling that dispatches repository reorder through the existing persistence path 491 + - drag-time UI freeze for non-essential row affordances 492 + 493 + Manual verification: 494 + 495 + - dragging a repository upward shows indicator below the target repository container when appropriate 496 + - dragging a repository downward never shows the indicator between target header and target worktree rows 497 + - failed repository rows follow the documented reorder semantics 498 + 499 + ### Phase 5: Custom Worktree Reorder 500 + 501 + Deliver: 502 + 503 + - pinned/unpinned scoped worktree drop zones 504 + - custom worktree insertion indicator 505 + - main/pending rows stay non-movable 506 + - existing persistence paths remain unchanged 507 + 508 + Manual verification: 509 + 510 + - pinned worktree reorder persists 511 + - unpinned worktree reorder persists 512 + - dragging over main/pending rows does not create invalid moves 513 + 514 + ### Phase 6: Remove Old `List` Path and Polish 515 + 516 + Deliver: 517 + 518 + - delete old `List(selection:)` implementation 519 + - remove obsolete `RepositorySectionView` / `WorktreeRowsView` pieces or fold them into new components 520 + - final accessibility pass 521 + - final animation pass for bulk expand/collapse 522 + - update issue #249 with final implementation notes 523 + 524 + ## Verification Matrix 525 + 526 + Automated: 527 + 528 + - `SidebarPresentationTests` 529 + - reducer tests for sidebar drag gate and notification reorder concurrency 530 + - reducer tests for expanded/collapsed state write-back and invalid collapsed ID cleanup 531 + - reducer tests for sidebar selected worktree synchronization 532 + - existing `RepositoriesFeatureTests` ordering tests 533 + - existing `RepositorySectionViewTests` migrated or renamed 534 + - `make check` 535 + - `make build-app` 536 + 537 + Manual: 538 + 539 + 1. Select a plain folder repository row. 540 + 2. Click a git repository header and confirm it expands/collapses without selecting a worktree. 541 + 3. Select a git repository worktree row and confirm terminal focus. 542 + 4. Cmd-click multiple worktree rows and confirm bulk archive/delete commands still target selected rows. 543 + 5. Verify confirm/archive/delete menu commands target the same worktrees as before. 544 + 6. Verify numbered worktree hotkeys use visible sidebar rows. 545 + 7. Expand/collapse one repository. 546 + 8. Bulk expand/collapse at least 10 repositories. 547 + 9. Drag repository upward and downward across expanded repositories. 548 + 10. Drag pinned worktrees within a repository. 549 + 11. Drag unpinned worktrees within a repository. 550 + 12. Trigger reveal-in-sidebar from Canvas or command. 551 + 13. Verify Canvas / Shelf / Archived interactions remain correct. 552 + 14. Verify notification/task/run-script indicators update without moving rows during a drag. 553 + 15. Drop a repository URL onto a visible row and onto blank sidebar space. 554 + 16. Verify repository and worktree context menus. 555 + 17. Verify drag previews. 556 + 18. Verify worktree rows do not gain type-select behavior in V1. 557 + 558 + ## Risks 559 + 560 + ### Native `List` behavior loss 561 + 562 + Risk: custom scroll rows may lose some free AppKit sidebar behavior. 563 + 564 + Mitigation: 565 + 566 + - preserve command-based navigation first 567 + - add explicit accessibility labels/traits 568 + - keep manual keyboard/accessibility checklist 569 + 570 + ### Reorder implementation complexity 571 + 572 + Risk: custom drag/drop can become more complex than native `.onMove`. 573 + 574 + Mitigation: 575 + 576 + - keep pure drop-index mapping tested 577 + - keep repository reorder and worktree reorder separate 578 + - defer cross-repository worktree moves 579 + 580 + ### UI regressions from broad rewrite 581 + 582 + Risk: replacing the sidebar in one PR touches selection, animation, and drag. 583 + 584 + Mitigation: 585 + 586 + - stage behind a private switch until visual behavior is verified 587 + - land M1 and presentation model tests first 588 + - keep reducer actions and persistence shape stable 589 + 590 + ### Performance regressions 591 + 592 + Risk: replacing lazy `List` with `VStack` could render too much. 593 + 594 + Mitigation: 595 + 596 + - start with `LazyVStack` 597 + - switch only repository containers to non-lazy child stacks if expand/collapse animation needs it 598 + - decide using the Phase 0 metrics rather than visual impression alone 599 + 600 + ## Recommendation 601 + 602 + Proceed with Option B as the #249 plan: a custom `ScrollView` sidebar with repository containers as outer items. 603 + 604 + Do not attempt to fix the repository insertion indicator through reducer index changes. The indicator is a symptom of the current `List` row structure, not the persisted ordering logic. 605 + 606 + The safest execution path is: 607 + 608 + 1. baseline metrics and manual guardrails 609 + 2. M1 old `List` drag gate and reducer concurrency tests 610 + 3. pure presentation model and tests 611 + 4. render-only new sidebar path 612 + 5. explicit selection, focus, and reveal 613 + 6. custom repository reorder 614 + 7. custom worktree reorder 615 + 8. remove old `List` path 616 + 617 + This is larger than a tactical #222 fix, but it addresses the underlying sidebar design mismatch and gives future sidebar features a cleaner foundation.
+202
supacode/Features/Repositories/Models/SidebarPresentation.swift
··· 1 + import Foundation 2 + 3 + struct SidebarPresentation: Equatable { 4 + var items: [SidebarItem] 5 + 6 + static func showsListHeader(repositoryCount: Int) -> Bool { 7 + repositoryCount > 10 8 + } 9 + 10 + var repositoryOrderIDs: [Repository.ID] { 11 + items.compactMap(\.repositoryOrderID) 12 + } 13 + 14 + func repositoryOrderAfterMove( 15 + fromOffsets source: IndexSet, 16 + toOffset destination: Int 17 + ) -> [Repository.ID] { 18 + var orderedIDs = repositoryOrderIDs 19 + orderedIDs.moveElements(fromOffsets: source, toOffset: destination) 20 + return orderedIDs 21 + } 22 + } 23 + 24 + enum SidebarItem: Equatable, Identifiable { 25 + case listHeader(SidebarListHeaderModel) 26 + case repository(SidebarRepositoryContainerModel) 27 + case failedRepository(FailedRepositoryModel) 28 + case archivedWorktrees(ArchivedWorktreesRowModel) 29 + 30 + var id: SidebarPresentationItemID { 31 + switch self { 32 + case .listHeader: 33 + return .listHeader 34 + case .repository(let model): 35 + return .repository(model.id) 36 + case .failedRepository(let model): 37 + return .failedRepository(model.id) 38 + case .archivedWorktrees: 39 + return .archivedWorktrees 40 + } 41 + } 42 + 43 + var repositoryOrderID: Repository.ID? { 44 + switch self { 45 + case .repository(let model): 46 + return model.id 47 + case .failedRepository(let model) where model.isReorderable: 48 + return model.id 49 + case .listHeader, .failedRepository, .archivedWorktrees: 50 + return nil 51 + } 52 + } 53 + } 54 + 55 + enum SidebarPresentationItemID: Equatable, Hashable { 56 + case listHeader 57 + case repository(Repository.ID) 58 + case failedRepository(Repository.ID) 59 + case archivedWorktrees 60 + } 61 + 62 + enum SidebarScrollID: Equatable, Hashable { 63 + case repository(Repository.ID) 64 + case worktree(Worktree.ID) 65 + case archivedWorktrees 66 + } 67 + 68 + struct SidebarListHeaderModel: Equatable, Identifiable { 69 + let id = SidebarPresentationItemID.listHeader 70 + var repositoryCount: Int 71 + } 72 + 73 + struct SidebarRepositoryContainerModel: Equatable, Identifiable { 74 + var id: Repository.ID { repositoryID } 75 + 76 + var repositoryID: Repository.ID 77 + var title: String 78 + var rootURL: URL 79 + var kind: Repository.Kind 80 + var isExpanded: Bool 81 + var isRemoving: Bool 82 + var worktreeSections: WorktreeRowSections 83 + } 84 + 85 + struct FailedRepositoryModel: Equatable, Identifiable { 86 + var id: Repository.ID 87 + var name: String 88 + var path: String 89 + var failureMessage: String 90 + var isReorderable: Bool 91 + } 92 + 93 + struct ArchivedWorktreesRowModel: Equatable, Identifiable { 94 + let id = SidebarPresentationItemID.archivedWorktrees 95 + var count: Int 96 + } 97 + 98 + enum SidebarWorktreeSection: Equatable { 99 + case pinned 100 + case unpinned 101 + } 102 + 103 + struct SidebarWorktreeDropTarget: Equatable { 104 + var repositoryID: Repository.ID 105 + var section: SidebarWorktreeSection 106 + var source: IndexSet 107 + var destination: Int 108 + 109 + var action: RepositoriesFeature.WorktreeOrderingAction { 110 + switch section { 111 + case .pinned: 112 + return .pinnedWorktreesMoved(repositoryID: repositoryID, source, destination) 113 + case .unpinned: 114 + return .unpinnedWorktreesMoved(repositoryID: repositoryID, source, destination) 115 + } 116 + } 117 + } 118 + 119 + extension RepositoriesFeature.State { 120 + func sidebarPresentation( 121 + expandedRepositoryIDs: Set<Repository.ID>, 122 + includesArchivedWorktreesRow: Bool = false 123 + ) -> SidebarPresentation { 124 + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 125 + let roots = sidebarPresentationRoots() 126 + let repositoryCount = roots.count 127 + var items: [SidebarItem] = [] 128 + 129 + if SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) { 130 + items.append(.listHeader(SidebarListHeaderModel(repositoryCount: repositoryCount))) 131 + } 132 + 133 + for rootURL in roots { 134 + let standardizedRootURL = rootURL.standardizedFileURL 135 + let repositoryID = standardizedRootURL.path(percentEncoded: false) 136 + if let failureMessage = loadFailuresByID[repositoryID] { 137 + let path = standardizedRootURL.path(percentEncoded: false) 138 + items.append( 139 + .failedRepository( 140 + FailedRepositoryModel( 141 + id: repositoryID, 142 + name: Repository.name(for: standardizedRootURL), 143 + path: path, 144 + failureMessage: failureMessage, 145 + isReorderable: true 146 + ) 147 + ) 148 + ) 149 + } else if let repository = repositoriesByID[repositoryID] { 150 + let isExpanded = expandedRepositoryIDs.contains(repository.id) 151 + items.append( 152 + .repository( 153 + SidebarRepositoryContainerModel( 154 + repositoryID: repository.id, 155 + title: repository.name, 156 + rootURL: repository.rootURL, 157 + kind: repository.kind, 158 + isExpanded: isExpanded, 159 + isRemoving: isRemovingRepository(repository), 160 + worktreeSections: isExpanded ? worktreeRowSections(in: repository) : .empty 161 + ) 162 + ) 163 + ) 164 + } 165 + } 166 + 167 + if includesArchivedWorktreesRow, !archivedWorktrees.isEmpty { 168 + items.append(.archivedWorktrees(ArchivedWorktreesRowModel(count: archivedWorktrees.count))) 169 + } 170 + 171 + return SidebarPresentation(items: items) 172 + } 173 + 174 + private func sidebarPresentationRoots() -> [URL] { 175 + let orderedRoots = orderedRepositoryRoots() 176 + if !orderedRoots.isEmpty { 177 + return orderedRoots 178 + } 179 + return repositories.map(\.rootURL) 180 + } 181 + } 182 + 183 + extension WorktreeRowSections { 184 + static let empty = WorktreeRowSections( 185 + main: nil, 186 + pinned: [], 187 + pending: [], 188 + unpinned: [] 189 + ) 190 + } 191 + 192 + extension Array { 193 + fileprivate mutating func moveElements(fromOffsets source: IndexSet, toOffset destination: Int) { 194 + let sourceIndexes = source.sorted() 195 + let movedElements = sourceIndexes.map { self[$0] } 196 + for index in sourceIndexes.reversed() { 197 + remove(at: index) 198 + } 199 + let removedBeforeDestination = sourceIndexes.filter { $0 < destination }.count 200 + insert(contentsOf: movedElements, at: destination - removedBeforeDestination) 201 + } 202 + }
+78 -21
supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift
··· 142 142 return .merge(effects) 143 143 144 144 case .worktreeNotificationReceived(let worktreeID): 145 - guard let repositoryID = state.repositoryID(containing: worktreeID), 146 - let repository = state.repositories[id: repositoryID], 147 - let worktree = repository.worktrees[id: worktreeID] 148 - else { 145 + guard notificationReorderTarget(for: worktreeID, state: state) != nil else { 146 + return .none 147 + } 148 + if state.isSidebarDragActive { 149 + state.pendingSidebarNotifyReorderIDs.removeAll { $0 == worktreeID } 150 + state.pendingSidebarNotifyReorderIDs.append(worktreeID) 149 151 return .none 150 152 } 151 - if state.isWorktreeArchived(worktree.id) { 153 + guard applyNotificationReorder(for: worktreeID, state: &state, animated: true) else { 152 154 return .none 153 155 } 154 - if state.moveNotifiedWorktreeToTop, !state.isMainWorktree(worktree), !state.isWorktreePinned(worktree) { 155 - let reordered = reorderedUnpinnedWorktreeIDs( 156 - for: worktreeID, 157 - in: repository, 158 - state: state 159 - ) 160 - if state.worktreeOrderByRepository[repositoryID] != reordered { 161 - withAnimation(.snappy(duration: 0.2)) { 162 - state.worktreeOrderByRepository[repositoryID] = reordered 163 - } 164 - let worktreeOrderByRepository = state.worktreeOrderByRepository 165 - return .run { _ in 166 - await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 167 - } 168 - } 156 + let worktreeOrderByRepository = state.worktreeOrderByRepository 157 + return .run { _ in 158 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 159 + } 160 + 161 + case .setSidebarDragActive(let isActive): 162 + guard state.isSidebarDragActive != isActive else { 163 + return .none 164 + } 165 + state.isSidebarDragActive = isActive 166 + guard !isActive else { 167 + return .none 168 + } 169 + let pendingWorktreeIDs = state.pendingSidebarNotifyReorderIDs 170 + state.pendingSidebarNotifyReorderIDs = [] 171 + guard !pendingWorktreeIDs.isEmpty else { 172 + return .none 173 + } 174 + var didReorder = false 175 + for worktreeID in pendingWorktreeIDs { 176 + didReorder = applyNotificationReorder(for: worktreeID, state: &state, animated: false) || didReorder 177 + } 178 + guard didReorder else { 179 + return .none 180 + } 181 + let worktreeOrderByRepository = state.worktreeOrderByRepository 182 + return .run { _ in 183 + await repositoryPersistence.saveWorktreeOrderByRepository(worktreeOrderByRepository) 169 184 } 170 - return .none 171 185 172 186 case .setMoveNotifiedWorktreeToTop(let isEnabled): 173 187 state.moveNotifiedWorktreeToTop = isEnabled ··· 184 198 } 185 199 } 186 200 } 201 + 202 + private func notificationReorderTarget( 203 + for worktreeID: Worktree.ID, 204 + state: RepositoriesFeature.State 205 + ) -> (repositoryID: Repository.ID, repository: Repository)? { 206 + guard state.moveNotifiedWorktreeToTop, 207 + let repositoryID = state.repositoryID(containing: worktreeID), 208 + let repository = state.repositories[id: repositoryID], 209 + let worktree = repository.worktrees[id: worktreeID], 210 + !state.isWorktreeArchived(worktree.id), 211 + !state.isMainWorktree(worktree), 212 + !state.isWorktreePinned(worktree) 213 + else { 214 + return nil 215 + } 216 + return (repositoryID, repository) 217 + } 218 + 219 + private func applyNotificationReorder( 220 + for worktreeID: Worktree.ID, 221 + state: inout RepositoriesFeature.State, 222 + animated: Bool 223 + ) -> Bool { 224 + guard let target = notificationReorderTarget(for: worktreeID, state: state) else { 225 + return false 226 + } 227 + let reordered = reorderedUnpinnedWorktreeIDs( 228 + for: worktreeID, 229 + in: target.repository, 230 + state: state 231 + ) 232 + guard state.worktreeOrderByRepository[target.repositoryID] != reordered else { 233 + return false 234 + } 235 + if animated { 236 + withAnimation(.snappy(duration: 0.2)) { 237 + state.worktreeOrderByRepository[target.repositoryID] = reordered 238 + } 239 + } else { 240 + state.worktreeOrderByRepository[target.repositoryID] = reordered 241 + } 242 + return true 243 + }
+4 -1
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 146 146 case pinWorktree(Worktree.ID) 147 147 case unpinWorktree(Worktree.ID) 148 148 case worktreeNotificationReceived(Worktree.ID) 149 + case setSidebarDragActive(Bool) 149 150 case setMoveNotifiedWorktreeToTop(Bool) 150 151 } 151 152 ··· 235 236 var sidebarSelectedWorktreeIDs: Set<Worktree.ID> = [] 236 237 var nextPendingSidebarRevealID = 0 237 238 var pendingSidebarReveal: PendingSidebarReveal? 239 + var isSidebarDragActive = false 240 + var pendingSidebarNotifyReorderIDs: [Worktree.ID] = [] 238 241 @Shared(.appStorage("sidebarCollapsedRepositoryIDs")) var collapsedRepositoryIDs: [Repository.ID] = [] 239 242 @Presents var worktreeCreationPrompt: WorktreeCreationPromptFeature.State? 240 243 @Presents var alert: AlertState<Alert>? ··· 1977 1980 } 1978 1981 } 1979 1982 1980 - struct WorktreeRowSections { 1983 + struct WorktreeRowSections: Equatable { 1981 1984 let main: WorktreeRowModel? 1982 1985 let pinned: [WorktreeRowModel] 1983 1986 let pending: [WorktreeRowModel]
+2
supacode/Features/Repositories/Views/RepoDisplayName.swift
··· 16 16 17 17 var body: some View { 18 18 Text(customTitle ?? fallbackName) 19 + .lineLimit(1) 20 + .truncationMode(.tail) 19 21 .help(tooltip ?? "") 20 22 } 21 23 }
+17 -7
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 12 12 @Binding var expandedRepoIDs: Set<Repository.ID> 13 13 @Bindable var store: StoreOf<RepositoriesFeature> 14 14 let terminalManager: WorktreeTerminalManager 15 + let onRepositorySelected: () -> Void 15 16 @Environment(\.colorScheme) private var colorScheme 16 17 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 17 18 @State private var isHovering = false ··· 21 22 let state = store.state 22 23 let isExpanded = expandedRepoIDs.contains(repository.id) 23 24 let isRemovingRepository = state.isRemovingRepository(repository) 25 + let isSelected = state.selection == .repository(repository.id) 24 26 let openRepoSettings = { 25 27 _ = store.send(.repositoryManagement(.openRepositorySettings(repository.id))) 26 28 } ··· 34 36 } 35 37 } 36 38 } 37 - let isDragging = isDragActive 38 - 39 39 let appearance = repositoryAppearances[repository.id] ?? .empty 40 40 let header = HStack { 41 41 // Inner HStack groups the name row and the tab-count badge so they ··· 49 49 customTitle: store.repositoryCustomTitles[repository.id], 50 50 isRemoving: isRemovingRepository, 51 51 icon: appearance.icon, 52 - iconTint: appearance.color?.color, 52 + iconTint: appearance.color?.color ?? .accentColor, 53 53 repositoryRootURL: repository.rootURL, 54 54 nameTooltip: repository.capabilities.supportsWorktrees 55 55 ? (isExpanded ? "Collapse" : "Expand") ··· 71 71 } 72 72 } 73 73 } 74 - if isRemovingRepository && !isDragging { 74 + if isRemovingRepository { 75 75 ProgressView() 76 76 .controlSize(.small) 77 77 .background { ··· 92 92 .help(color.displayName) 93 93 .accessibilityLabel(Text("Repo color: \(color.displayName)")) 94 94 } 95 - if isHovering && !isDragging { 95 + if isHovering { 96 96 Menu { 97 97 Button("Repo Settings") { 98 98 openRepoSettings() ··· 180 180 } 181 181 } 182 182 .frame(maxWidth: .infinity, minHeight: headerCellHeight, maxHeight: .infinity, alignment: .center) 183 + .padding(.horizontal, 12) 183 184 .padding(.top, hasTopSpacing ? 4 : 0) 184 185 .padding(.bottom, hasTopSpacing && !repository.capabilities.supportsWorktrees ? 4 : 0) 185 186 .contentShape(.interaction, .rect) 186 187 .background { 187 - if Self.debugHeaderLayers { 188 + if isSelected { 189 + RoundedRectangle(cornerRadius: 5) 190 + .fill(Color.accentColor.opacity(0.18)) 191 + .padding(.horizontal, 6) 192 + } else if Self.debugHeaderLayers { 188 193 Rectangle() 189 194 .fill(.red.opacity(0.12)) 190 195 .overlay { ··· 194 199 } 195 200 } 196 201 .onHover { isHovering = $0 } 202 + .onTapGesture { 203 + onRepositorySelected() 204 + } 205 + .accessibilityAddTraits(.isButton) 197 206 .contentShape(.rect) 198 207 .contextMenu { 199 208 Button("Repo Settings") { ··· 211 220 .environment(\.colorScheme, colorScheme) 212 221 .preferredColorScheme(colorScheme) 213 222 214 - Group { 223 + VStack(spacing: 0) { 215 224 header 216 225 .tag(SidebarSelection.repository(repository.id)) 217 226 if isExpanded { ··· 225 234 ) 226 235 } 227 236 } 237 + .id(SidebarScrollID.repository(repository.id)) 228 238 } 229 239 230 240 private var headerCellHeight: CGFloat {
+385
supacode/Features/Repositories/Views/SidebarDragSupport.swift
··· 1 + import SwiftUI 2 + import UniformTypeIdentifiers 3 + 4 + extension UTType { 5 + nonisolated static let prowlSidebarDragPayload = UTType.plainText 6 + } 7 + 8 + enum SidebarDragProvider { 9 + private nonisolated static let repositoryPrefix = "prowl-sidebar-repository:" 10 + private nonisolated static let worktreePrefix = "prowl-sidebar-worktree:" 11 + 12 + nonisolated static func repository(id: Repository.ID) -> NSItemProvider { 13 + itemProvider(payload: repositoryPrefix + id) 14 + } 15 + 16 + nonisolated static func worktree(id: Worktree.ID) -> NSItemProvider { 17 + itemProvider(payload: worktreePrefix + id) 18 + } 19 + 20 + nonisolated static func repositoryID(from data: Data) -> Repository.ID? { 21 + payload(from: data, prefix: repositoryPrefix) 22 + } 23 + 24 + nonisolated static func worktreeID(from data: Data) -> Worktree.ID? { 25 + payload(from: data, prefix: worktreePrefix) 26 + } 27 + 28 + private nonisolated static func itemProvider(payload: String) -> NSItemProvider { 29 + let provider = NSItemProvider() 30 + let loadHandler: @Sendable (@escaping @Sendable (Data?, (any Error)?) -> Void) -> Progress? = { completion in 31 + completion(Data(payload.utf8), nil) 32 + return nil 33 + } 34 + provider.registerDataRepresentation( 35 + forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier, 36 + visibility: .all, 37 + loadHandler: loadHandler 38 + ) 39 + return provider 40 + } 41 + 42 + private nonisolated static func payload(from data: Data, prefix: String) -> String? { 43 + guard let payload = String(data: data, encoding: .utf8), 44 + payload.hasPrefix(prefix) 45 + else { 46 + return nil 47 + } 48 + return String(payload.dropFirst(prefix.count)) 49 + } 50 + } 51 + 52 + struct SidebarRepositoryDropDelegate: DropDelegate { 53 + let isEnabled: Bool 54 + let destination: (DropInfo) -> Int 55 + let repositoryOrderIDs: [Repository.ID] 56 + @Binding var targetedDestination: Int? 57 + let actions: SidebarDropTargetActions 58 + 59 + func dropEntered(info: DropInfo) { 60 + guard isEnabled else { 61 + targetedDestination = nil 62 + return 63 + } 64 + targetedDestination = destination(info) 65 + } 66 + 67 + func dropExited(info: DropInfo) { 68 + targetedDestination = nil 69 + } 70 + 71 + func dropUpdated(info: DropInfo) -> DropProposal? { 72 + guard isEnabled else { 73 + targetedDestination = nil 74 + return nil 75 + } 76 + targetedDestination = destination(info) 77 + return DropProposal(operation: .move) 78 + } 79 + 80 + func performDrop(info: DropInfo) -> Bool { 81 + guard isEnabled else { 82 + targetedDestination = nil 83 + return false 84 + } 85 + let dropDestination = destination(info) 86 + targetedDestination = nil 87 + if let repositoryID = actions.draggedItemID { 88 + return performDrop(repositoryID: repositoryID, dropDestination: dropDestination) 89 + } 90 + guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { 91 + actions.onDragEnded() 92 + return false 93 + } 94 + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier) { data, _ in 95 + guard let data, 96 + let repositoryID = SidebarDragProvider.repositoryID(from: data) 97 + else { 98 + Task { @MainActor in actions.onDragEnded() } 99 + return 100 + } 101 + Task { @MainActor in 102 + _ = performDrop(repositoryID: repositoryID, dropDestination: dropDestination) 103 + } 104 + } 105 + return true 106 + } 107 + 108 + @MainActor 109 + private func performDrop(repositoryID: Repository.ID, dropDestination: Int) -> Bool { 110 + guard let source = repositoryOrderIDs.firstIndex(of: repositoryID), 111 + source != dropDestination, 112 + source + 1 != dropDestination 113 + else { 114 + actions.onDragEnded() 115 + return false 116 + } 117 + actions.onDragEnded() 118 + actions.onDrop(IndexSet(integer: source), dropDestination) 119 + return true 120 + } 121 + } 122 + 123 + struct SidebarWorktreeDropDelegate: DropDelegate { 124 + let isEnabled: Bool 125 + let destination: (DropInfo) -> Int 126 + let sectionIDs: [Worktree.ID] 127 + @Binding var targetedDestination: Int? 128 + let actions: SidebarDropTargetActions 129 + 130 + func dropEntered(info: DropInfo) { 131 + guard isEnabled else { 132 + targetedDestination = nil 133 + return 134 + } 135 + targetedDestination = destination(info) 136 + } 137 + 138 + func dropExited(info: DropInfo) { 139 + targetedDestination = nil 140 + } 141 + 142 + func dropUpdated(info: DropInfo) -> DropProposal? { 143 + guard isEnabled else { 144 + targetedDestination = nil 145 + return nil 146 + } 147 + targetedDestination = destination(info) 148 + return DropProposal(operation: .move) 149 + } 150 + 151 + func performDrop(info: DropInfo) -> Bool { 152 + guard isEnabled else { 153 + targetedDestination = nil 154 + return false 155 + } 156 + let dropDestination = destination(info) 157 + targetedDestination = nil 158 + if let worktreeID = actions.draggedItemID { 159 + return performDrop(worktreeID: worktreeID, dropDestination: dropDestination) 160 + } 161 + guard let provider = info.itemProviders(for: [.prowlSidebarDragPayload]).first else { 162 + actions.onDragEnded() 163 + return false 164 + } 165 + provider.loadDataRepresentation(forTypeIdentifier: UTType.prowlSidebarDragPayload.identifier) { data, _ in 166 + guard let data, 167 + let worktreeID = SidebarDragProvider.worktreeID(from: data) 168 + else { 169 + Task { @MainActor in actions.onDragEnded() } 170 + return 171 + } 172 + Task { @MainActor in 173 + _ = performDrop(worktreeID: worktreeID, dropDestination: dropDestination) 174 + } 175 + } 176 + return true 177 + } 178 + 179 + @MainActor 180 + private func performDrop(worktreeID: Worktree.ID, dropDestination: Int) -> Bool { 181 + guard let source = sectionIDs.firstIndex(of: worktreeID), 182 + source != dropDestination, 183 + source + 1 != dropDestination 184 + else { 185 + actions.onDragEnded() 186 + return false 187 + } 188 + actions.onDragEnded() 189 + actions.onDrop(IndexSet(integer: source), dropDestination) 190 + return true 191 + } 192 + } 193 + 194 + struct SidebarDropIndicator: View { 195 + let isVisible: Bool 196 + var horizontalPadding: CGFloat = 12 197 + 198 + var body: some View { 199 + ZStack { 200 + if isVisible { 201 + Capsule() 202 + .fill(Color.accentColor) 203 + .frame(height: 2) 204 + .padding(.horizontal, horizontalPadding) 205 + .transition(.opacity) 206 + } 207 + } 208 + .frame(maxWidth: .infinity) 209 + .frame(height: 6) 210 + .accessibilityHidden(true) 211 + } 212 + } 213 + 214 + enum SidebarDropIndicatorEdge: Equatable { 215 + case none 216 + case top 217 + case bottom 218 + 219 + static func edge( 220 + targetedDestination: Int?, 221 + rowIndex: Int, 222 + rowCount: Int 223 + ) -> Self { 224 + guard let targetedDestination else { 225 + return .none 226 + } 227 + if targetedDestination == rowIndex { 228 + return .top 229 + } 230 + if rowIndex == rowCount - 1, targetedDestination == rowCount { 231 + return .bottom 232 + } 233 + return .none 234 + } 235 + } 236 + 237 + struct SidebarDropTargetActions { 238 + var draggedItemID: String? 239 + let onDrop: (IndexSet, Int) -> Void 240 + let onDragEnded: () -> Void 241 + } 242 + 243 + extension View { 244 + func repositoryDropTarget( 245 + index: Int, 246 + repositoryOrderIDs: [Repository.ID], 247 + isEnabled: Bool, 248 + targetedDestination: Binding<Int?>, 249 + actions: SidebarDropTargetActions 250 + ) -> some View { 251 + let edge = SidebarDropIndicatorEdge.edge( 252 + targetedDestination: targetedDestination.wrappedValue, 253 + rowIndex: index, 254 + rowCount: repositoryOrderIDs.count 255 + ) 256 + return 257 + self 258 + .overlay(alignment: .top) { 259 + SidebarDropIndicator(isVisible: edge == .top) 260 + } 261 + .overlay(alignment: .bottom) { 262 + SidebarDropIndicator(isVisible: edge == .bottom) 263 + } 264 + .modifier( 265 + SidebarRepositoryDropTargetModifier( 266 + isEnabled: isEnabled, 267 + index: index, 268 + repositoryOrderIDs: repositoryOrderIDs, 269 + targetedDestination: targetedDestination, 270 + actions: actions 271 + ) 272 + ) 273 + } 274 + 275 + func worktreeDropTarget( 276 + index: Int, 277 + rowIDs: [Worktree.ID], 278 + isEnabled: Bool, 279 + targetedDestination: Binding<Int?>, 280 + actions: SidebarDropTargetActions 281 + ) -> some View { 282 + let edge = SidebarDropIndicatorEdge.edge( 283 + targetedDestination: targetedDestination.wrappedValue, 284 + rowIndex: index, 285 + rowCount: rowIDs.count 286 + ) 287 + return 288 + self 289 + .overlay(alignment: .top) { 290 + SidebarDropIndicator(isVisible: edge == .top, horizontalPadding: 28) 291 + } 292 + .overlay(alignment: .bottom) { 293 + SidebarDropIndicator(isVisible: edge == .bottom, horizontalPadding: 28) 294 + } 295 + .modifier( 296 + SidebarWorktreeDropTargetModifier( 297 + isEnabled: isEnabled, 298 + index: index, 299 + rowIDs: rowIDs, 300 + targetedDestination: targetedDestination, 301 + actions: actions 302 + ) 303 + ) 304 + } 305 + 306 + @ViewBuilder 307 + func draggableRepository( 308 + id: Repository.ID, 309 + isEnabled: Bool, 310 + beginDrag: @escaping () -> Void 311 + ) -> some View { 312 + if isEnabled { 313 + self.onDrag { 314 + beginDrag() 315 + return SidebarDragProvider.repository(id: id) 316 + } 317 + } else { 318 + self 319 + } 320 + } 321 + 322 + @ViewBuilder 323 + func draggableWorktree( 324 + id: Worktree.ID, 325 + isEnabled: Bool, 326 + beginDrag: @escaping () -> Void 327 + ) -> some View { 328 + if isEnabled { 329 + self.onDrag { 330 + beginDrag() 331 + return SidebarDragProvider.worktree(id: id) 332 + } 333 + } else { 334 + self 335 + } 336 + } 337 + } 338 + 339 + private struct SidebarRepositoryDropTargetModifier: ViewModifier { 340 + let isEnabled: Bool 341 + let index: Int 342 + let repositoryOrderIDs: [Repository.ID] 343 + @Binding var targetedDestination: Int? 344 + let actions: SidebarDropTargetActions 345 + 346 + @ViewBuilder 347 + func body(content: Content) -> some View { 348 + content.onDrop( 349 + of: [.prowlSidebarDragPayload], 350 + delegate: SidebarRepositoryDropDelegate( 351 + isEnabled: isEnabled, 352 + destination: { info in 353 + info.location.y < 24 ? index : index + 1 354 + }, 355 + repositoryOrderIDs: repositoryOrderIDs, 356 + targetedDestination: $targetedDestination, 357 + actions: actions 358 + ) 359 + ) 360 + } 361 + } 362 + 363 + private struct SidebarWorktreeDropTargetModifier: ViewModifier { 364 + let isEnabled: Bool 365 + let index: Int 366 + let rowIDs: [Worktree.ID] 367 + @Binding var targetedDestination: Int? 368 + let actions: SidebarDropTargetActions 369 + 370 + @ViewBuilder 371 + func body(content: Content) -> some View { 372 + content.onDrop( 373 + of: [.prowlSidebarDragPayload], 374 + delegate: SidebarWorktreeDropDelegate( 375 + isEnabled: isEnabled, 376 + destination: { info in 377 + info.location.y < 18 ? index : index + 1 378 + }, 379 + sectionIDs: rowIDs, 380 + targetedDestination: $targetedDestination, 381 + actions: actions 382 + ) 383 + ) 384 + } 385 + }
+172 -174
supacode/Features/Repositories/Views/SidebarListView.swift
··· 35 35 let terminalManager: WorktreeTerminalManager 36 36 @FocusState private var isSidebarFocused: Bool 37 37 @State private var isDragActive = false 38 + @State private var draggingRepositoryID: Repository.ID? 39 + @State private var targetedRepositoryDropDestination: Int? 38 40 39 41 var body: some View { 40 42 let state = store.state 41 43 let hotkeyRows = state.orderedWorktreeRows(includingRepositoryIDs: expandedRepoIDs) 42 - let orderedRoots = state.orderedRepositoryRoots() 44 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: expandedRepoIDs) 43 45 let expandableRepositoryIDs = Self.expandableRepositoryIDs(in: state.repositories) 44 46 let repositoryListHeaderAction = Self.repositoryListHeaderAction( 45 47 expandedRepoIDs: expandedRepoIDs, 46 48 expandableRepositoryIDs: expandableRepositoryIDs 47 49 ) 48 - let visibleRepositoryCount = orderedRoots.isEmpty ? state.repositories.count : orderedRoots.count 49 - let showsRepositoryListHeader = Self.showsRepositoryListHeader(repositoryCount: visibleRepositoryCount) 50 - let selectedWorktreeIDs = Set(sidebarSelections.compactMap(\.worktreeID)) 51 - let selection = Binding<Set<SidebarSelection>>( 52 - get: { 53 - var nextSelections = sidebarSelections 54 - if state.isShowingCanvas { 55 - nextSelections = [.canvas] 56 - } else if state.isShowingArchivedWorktrees { 57 - nextSelections = [.archivedWorktrees] 58 - } else { 59 - nextSelections.remove(.archivedWorktrees) 60 - nextSelections.remove(.canvas) 61 - if let selectedRepository = state.selectedRepository, selectedRepository.kind == .plain { 62 - nextSelections = [.repository(selectedRepository.id)] 63 - } else if let selectedWorktreeID = state.selectedWorktreeID { 64 - nextSelections.insert(.worktree(selectedWorktreeID)) 65 - } 66 - } 67 - return nextSelections 68 - }, 69 - set: { newValue in 70 - let nextSelections = newValue 71 - let repositorySelections: [Repository.ID] = nextSelections.compactMap { selection in 72 - guard case .repository(let repositoryID) = selection else { return nil } 73 - return repositoryID 74 - } 75 - 76 - if nextSelections.contains(.canvas) { 77 - sidebarSelections = [.canvas] 78 - store.send(.selectCanvas) 79 - return 80 - } 81 - 82 - if nextSelections.contains(.archivedWorktrees) { 83 - sidebarSelections = [.archivedWorktrees] 84 - store.send(.selectArchivedWorktrees) 85 - return 86 - } 87 - 88 - if let repositoryID = repositorySelections.first { 89 - guard let repository = state.repositories[id: repositoryID] else { 90 - return 91 - } 92 - if repository.capabilities.supportsWorktrees { 93 - withAnimation(.easeOut(duration: 0.2)) { 94 - if expandedRepoIDs.contains(repositoryID) { 95 - expandedRepoIDs.remove(repositoryID) 96 - } else { 97 - expandedRepoIDs.insert(repositoryID) 98 - } 99 - } 100 - sidebarSelections = [] 101 - } else { 102 - sidebarSelections = [.repository(repositoryID)] 103 - store.send(.selectRepository(repositoryID)) 104 - focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) 105 - } 106 - return 107 - } 108 - 109 - let worktreeIDs = Set(nextSelections.compactMap(\.worktreeID)) 110 - guard !worktreeIDs.isEmpty else { 111 - sidebarSelections = [] 112 - store.send(.selectWorktree(nil)) 113 - return 114 - } 115 - let shouldFocusTerminal = worktreeIDs.count == 1 116 - sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) 117 - if let selectedWorktreeID = state.selectedWorktreeID, 118 - worktreeIDs.contains(selectedWorktreeID) 119 - { 120 - if shouldFocusTerminal { 121 - focusTerminalAfterSidebarSelection(worktreeID: selectedWorktreeID) 122 - } 123 - return 124 - } 125 - let nextPrimarySelection = 126 - hotkeyRows.map(\.id).first(where: worktreeIDs.contains) 127 - ?? worktreeIDs.first 128 - store.send(.selectWorktree(nextPrimarySelection, focusTerminal: shouldFocusTerminal)) 129 - if shouldFocusTerminal { 130 - focusTerminalAfterSidebarSelection(worktreeID: nextPrimarySelection) 131 - } 50 + let repositoryItems = presentation.items.filter(\.isRepositoryOrderItem) 51 + let showsRepositoryListHeader = presentation.items.contains { item in 52 + if case .listHeader = item { 53 + return true 132 54 } 133 - ) 134 - let repositoriesByID = Dictionary(uniqueKeysWithValues: store.repositories.map { ($0.id, $0) }) 55 + return false 56 + } 57 + let selectedWorktreeIDs = Self.selectedWorktreeIDs(in: state) 135 58 let pendingSidebarReveal = state.pendingSidebarReveal 136 59 137 60 ScrollViewReader { scrollProxy in 138 - List(selection: selection) { 139 - if showsRepositoryListHeader { 140 - repositoryListHeader( 141 - action: repositoryListHeaderAction, 142 - expandableRepositoryIDs: expandableRepositoryIDs 143 - ) 144 - .listRowInsets(EdgeInsets()) 145 - } 146 - 147 - if orderedRoots.isEmpty { 148 - let repositories = store.repositories 149 - ForEach(Array(repositories.enumerated()), id: \.element.id) { index, repository in 150 - RepositorySectionView( 151 - repository: repository, 152 - hasTopSpacing: index > 0, 153 - isDragActive: isDragActive, 154 - hotkeyRows: hotkeyRows, 155 - selectedWorktreeIDs: selectedWorktreeIDs, 156 - expandedRepoIDs: $expandedRepoIDs, 157 - store: store, 158 - terminalManager: terminalManager 61 + ScrollView { 62 + LazyVStack(spacing: 0) { 63 + if showsRepositoryListHeader { 64 + repositoryListHeader( 65 + action: repositoryListHeaderAction, 66 + expandableRepositoryIDs: expandableRepositoryIDs 159 67 ) 160 - .listRowInsets(EdgeInsets()) 161 68 } 162 - } else { 163 - let orderedRows = Array(orderedRoots.enumerated()).map { index, rootURL in 164 - ( 69 + 70 + ForEach(Array(repositoryItems.enumerated()), id: \.element.id) { index, item in 71 + repositoryItemView( 72 + item, 165 73 index: index, 166 - rootURL: rootURL, 167 - repositoryID: rootURL.standardizedFileURL.path(percentEncoded: false) 74 + repositoryOrderIDs: presentation.repositoryOrderIDs, 75 + hotkeyRows: hotkeyRows, 76 + selectedWorktreeIDs: selectedWorktreeIDs 168 77 ) 169 78 } 170 - ForEach(orderedRows, id: \.repositoryID) { row in 171 - let index = row.index 172 - let rootURL = row.rootURL 173 - let repositoryID = row.repositoryID 174 - if let failureMessage = state.loadFailuresByID[repositoryID] { 175 - let name = Repository.name(for: rootURL.standardizedFileURL) 176 - let path = rootURL.standardizedFileURL.path(percentEncoded: false) 177 - FailedRepositoryRow( 178 - name: name, 179 - path: path, 180 - showFailure: { 181 - let message = "\(path)\n\n\(failureMessage)" 182 - store.send(.presentAlert(title: "Unable to load \(name)", message: message)) 183 - }, 184 - removeRepository: { 185 - store.send(.repositoryManagement(.removeFailedRepository(repositoryID))) 186 - } 187 - ) 188 - .padding(.horizontal, 12) 189 - .overlay(alignment: .top) { 190 - if index > 0 { 191 - Rectangle() 192 - .fill(.secondary) 193 - .frame(height: 1) 194 - .frame(maxWidth: .infinity) 195 - .accessibilityHidden(true) 196 - } 197 - } 198 - .listRowInsets(EdgeInsets()) 199 - } else if let repository = repositoriesByID[repositoryID] { 200 - RepositorySectionView( 201 - repository: repository, 202 - hasTopSpacing: index > 0, 203 - isDragActive: isDragActive, 204 - hotkeyRows: hotkeyRows, 205 - selectedWorktreeIDs: selectedWorktreeIDs, 206 - expandedRepoIDs: $expandedRepoIDs, 207 - store: store, 208 - terminalManager: terminalManager 209 - ) 210 - .listRowInsets(EdgeInsets()) 211 - } 212 - } 213 - .onMove { offsets, destination in 214 - store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 215 - } 216 79 } 80 + .padding(.vertical, 2) 217 81 } 218 - .listStyle(.sidebar) 219 82 .scrollIndicators(.never) 220 83 .frame(minWidth: 220) 84 + .background(.bar) 221 85 .onDragSessionUpdated { session in 222 86 if case .ended = session.phase { 223 - if isDragActive { 224 - isDragActive = false 225 - } 87 + endSidebarDrag() 226 88 return 227 89 } 228 90 if case .dataTransferCompleted = session.phase { 229 - if isDragActive { 230 - isDragActive = false 231 - } 232 - return 233 - } 234 - if !isDragActive { 235 - isDragActive = true 91 + endSidebarDrag() 236 92 } 237 93 } 238 94 .safeAreaInset(edge: .top) { ··· 263 119 return true 264 120 } 265 121 .focused($isSidebarFocused) 122 + .onAppear { 123 + resetSidebarDrag() 124 + } 266 125 .task(id: pendingSidebarReveal?.id) { 267 126 await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) 268 127 } ··· 314 173 } 315 174 } 316 175 .frame(maxWidth: .infinity, minHeight: 26, alignment: .center) 317 - .padding(.top, 8) 176 + .padding(.leading, 12) 177 + .padding(.trailing, 7) 178 + .padding(.top, 2) 318 179 .padding(.bottom, 4) 319 180 } 320 181 182 + @ViewBuilder 183 + private func repositoryItemView( 184 + _ item: SidebarItem, 185 + index: Int, 186 + repositoryOrderIDs: [Repository.ID], 187 + hotkeyRows: [WorktreeRowModel], 188 + selectedWorktreeIDs: Set<Worktree.ID> 189 + ) -> some View { 190 + Group { 191 + switch item { 192 + case .repository(let model): 193 + if let repository = store.state.repositories[id: model.repositoryID] { 194 + RepositorySectionView( 195 + repository: repository, 196 + hasTopSpacing: index > 0, 197 + isDragActive: isDragActive, 198 + hotkeyRows: hotkeyRows, 199 + selectedWorktreeIDs: selectedWorktreeIDs, 200 + expandedRepoIDs: $expandedRepoIDs, 201 + store: store, 202 + terminalManager: terminalManager, 203 + onRepositorySelected: { 204 + selectRepository(repository) 205 + } 206 + ) 207 + .draggableRepository( 208 + id: model.repositoryID, 209 + isEnabled: !model.isRemoving, 210 + beginDrag: { 211 + beginSidebarDrag(repositoryID: model.repositoryID) 212 + } 213 + ) 214 + } 215 + 216 + case .failedRepository(let model): 217 + FailedRepositoryRow( 218 + name: model.name, 219 + path: model.path, 220 + showFailure: { 221 + let message = "\(model.path)\n\n\(model.failureMessage)" 222 + store.send(.presentAlert(title: "Unable to load \(model.name)", message: message)) 223 + }, 224 + removeRepository: { 225 + store.send(.repositoryManagement(.removeFailedRepository(model.id))) 226 + } 227 + ) 228 + .padding(.horizontal, 12) 229 + .overlay(alignment: .top) { 230 + if index > 0 { 231 + Rectangle() 232 + .fill(.secondary) 233 + .frame(height: 1) 234 + .frame(maxWidth: .infinity) 235 + .accessibilityHidden(true) 236 + } 237 + } 238 + .draggableRepository( 239 + id: model.id, 240 + isEnabled: model.isReorderable, 241 + beginDrag: { 242 + beginSidebarDrag(repositoryID: model.id) 243 + } 244 + ) 245 + 246 + case .listHeader, .archivedWorktrees: 247 + EmptyView() 248 + } 249 + } 250 + .repositoryDropTarget( 251 + index: index, 252 + repositoryOrderIDs: repositoryOrderIDs, 253 + isEnabled: isDragActive, 254 + targetedDestination: $targetedRepositoryDropDestination, 255 + actions: SidebarDropTargetActions( 256 + draggedItemID: draggingRepositoryID, 257 + onDrop: { offsets, destination in 258 + withAnimation(.easeOut(duration: 0.2)) { 259 + _ = store.send(.worktreeOrdering(.repositoriesMoved(offsets, destination))) 260 + } 261 + }, 262 + onDragEnded: endSidebarDrag 263 + ) 264 + ) 265 + } 266 + 267 + private func beginSidebarDrag(repositoryID: Repository.ID) { 268 + guard !isDragActive else { return } 269 + draggingRepositoryID = repositoryID 270 + isDragActive = true 271 + store.send(.worktreeOrdering(.setSidebarDragActive(true))) 272 + } 273 + 274 + private func endSidebarDrag() { 275 + targetedRepositoryDropDestination = nil 276 + draggingRepositoryID = nil 277 + isDragActive = false 278 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 279 + } 280 + 281 + private func resetSidebarDrag() { 282 + targetedRepositoryDropDestination = nil 283 + draggingRepositoryID = nil 284 + isDragActive = false 285 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 286 + } 287 + 288 + private func selectRepository(_ repository: Repository) { 289 + if repository.capabilities.supportsWorktrees { 290 + withAnimation(.easeOut(duration: 0.2)) { 291 + if expandedRepoIDs.contains(repository.id) { 292 + expandedRepoIDs.remove(repository.id) 293 + } else { 294 + expandedRepoIDs.insert(repository.id) 295 + } 296 + } 297 + sidebarSelections = [] 298 + } else { 299 + sidebarSelections = [.repository(repository.id)] 300 + store.send(.selectRepository(repository.id)) 301 + focusTerminalAfterSidebarSelection(worktreeID: store.state.selectedTerminalWorktree?.id) 302 + } 303 + } 304 + 321 305 @MainActor 322 306 private func revealPendingSidebarWorktree( 323 307 _ pendingSidebarReveal: PendingSidebarReveal?, ··· 329 313 await Task.yield() 330 314 isSidebarFocused = true 331 315 withAnimation(.easeOut(duration: 0.2)) { 332 - scrollProxy.scrollTo(pendingSidebarReveal.worktreeID, anchor: .center) 316 + scrollProxy.scrollTo(SidebarScrollID.worktree(pendingSidebarReveal.worktreeID), anchor: .center) 333 317 } 334 318 store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) 335 319 } ··· 354 338 } 355 339 356 340 static func showsRepositoryListHeader(repositoryCount: Int) -> Bool { 357 - repositoryCount > 10 341 + SidebarPresentation.showsListHeader(repositoryCount: repositoryCount) 342 + } 343 + 344 + static func selectedWorktreeIDs(in state: RepositoriesFeature.State) -> Set<Worktree.ID> { 345 + var selectedWorktreeIDs = state.sidebarSelectedWorktreeIDs 346 + if let selectedWorktreeID = state.selectedWorktreeID { 347 + selectedWorktreeIDs.insert(selectedWorktreeID) 348 + } 349 + return selectedWorktreeIDs 350 + } 351 + } 352 + 353 + extension SidebarItem { 354 + fileprivate var isRepositoryOrderItem: Bool { 355 + repositoryOrderID != nil 358 356 } 359 357 } 360 358
+1
supacode/Features/Repositories/Views/WorktreeRow.swift
··· 73 73 .font(.body) 74 74 .foregroundStyle(nameColor) 75 75 .lineLimit(1) 76 + .truncationMode(.tail) 76 77 Spacer(minLength: 4) 77 78 if isHovered, pinAction != nil { 78 79 Button {
+279 -88
supacode/Features/Repositories/Views/WorktreeRowsView.swift
··· 14 14 @Environment(\.resolvedKeybindings) private var resolvedKeybindings 15 15 @State private var draggingWorktreeIDs: Set<Worktree.ID> = [] 16 16 @State private var hoveredWorktreeID: Worktree.ID? 17 + @State private var contextMenuHighlightedWorktreeID: Worktree.ID? 18 + @State private var targetedPinnedDropDestination: Int? 19 + @State private var targetedUnpinnedDropDestination: Int? 17 20 18 21 var body: some View { 19 22 if isExpanded { ··· 25 28 let state = store.state 26 29 let sections = state.worktreeRowSections(in: repository) 27 30 let isRepositoryRemoving = state.isRemovingRepository(repository) 31 + let isSidebarDragActive = state.isSidebarDragActive 28 32 let showShortcutHints = commandKeyObserver.isPressed 29 33 let allRows = showShortcutHints ? hotkeyRows : [] 30 34 let shortcutIndexByID = Dictionary( ··· 34 38 return rowsGroup( 35 39 sections: sections, 36 40 isRepositoryRemoving: isRepositoryRemoving, 37 - showShortcutHints: showShortcutHints, 38 41 shortcutIndexByID: shortcutIndexByID 39 42 ) 40 - .animation(.easeOut(duration: 0.2), value: rowIDs) 43 + .animation(isSidebarDragActive ? nil : .easeOut(duration: 0.2), value: rowIDs) 44 + .onReceive(NotificationCenter.default.publisher(for: NSMenu.didEndTrackingNotification)) { _ in 45 + contextMenuHighlightedWorktreeID = nil 46 + } 41 47 } 42 48 43 49 @ViewBuilder 44 50 private func rowsGroup( 45 51 sections: WorktreeRowSections, 46 52 isRepositoryRemoving: Bool, 47 - showShortcutHints: Bool, 48 53 shortcutIndexByID: [Worktree.ID: Int] 49 54 ) -> some View { 50 55 if let row = sections.main { ··· 52 57 row, 53 58 isRepositoryRemoving: isRepositoryRemoving, 54 59 moveDisabled: true, 55 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 60 + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) 56 61 ) 57 62 } 58 - ForEach(sections.pinned) { row in 59 - rowView( 60 - row, 61 - isRepositoryRemoving: isRepositoryRemoving, 62 - moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 63 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 64 - ) 65 - } 66 - .onMove { offsets, destination in 67 - store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 68 - } 63 + movableRowsGroup( 64 + rows: sections.pinned, 65 + section: .pinned, 66 + targetedDestination: $targetedPinnedDropDestination, 67 + isRepositoryRemoving: isRepositoryRemoving, 68 + shortcutIndexByID: shortcutIndexByID 69 + ) 69 70 ForEach(sections.pending) { row in 70 71 rowView( 71 72 row, 72 73 isRepositoryRemoving: isRepositoryRemoving, 73 74 moveDisabled: true, 74 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 75 + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) 75 76 ) 76 77 } 77 - ForEach(sections.unpinned) { row in 78 + movableRowsGroup( 79 + rows: sections.unpinned, 80 + section: .unpinned, 81 + targetedDestination: $targetedUnpinnedDropDestination, 82 + isRepositoryRemoving: isRepositoryRemoving, 83 + shortcutIndexByID: shortcutIndexByID 84 + ) 85 + } 86 + 87 + @ViewBuilder 88 + private func movableRowsGroup( 89 + rows: [WorktreeRowModel], 90 + section: SidebarWorktreeSection, 91 + targetedDestination: Binding<Int?>, 92 + isRepositoryRemoving: Bool, 93 + shortcutIndexByID: [Worktree.ID: Int] 94 + ) -> some View { 95 + let rowIDs = rows.map(\.id) 96 + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty 97 + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in 78 98 rowView( 79 99 row, 80 100 isRepositoryRemoving: isRepositoryRemoving, 81 101 moveDisabled: isRepositoryRemoving || row.isDeleting || row.isArchiving, 82 - shortcutHint: showShortcutHints ? worktreeShortcutHint(for: shortcutIndexByID[row.id]) : nil 102 + shortcutHint: worktreeShortcutHint(for: shortcutIndexByID[row.id]) 83 103 ) 84 - } 85 - .onMove { offsets, destination in 86 - store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 104 + .worktreeDropTarget( 105 + index: index, 106 + rowIDs: rowIDs, 107 + isEnabled: isWorktreeDragActive, 108 + targetedDestination: targetedDestination, 109 + actions: SidebarDropTargetActions( 110 + draggedItemID: draggingWorktreeIDs.first, 111 + onDrop: { offsets, destination in 112 + moveWorktrees(section: section, offsets: offsets, destination: destination) 113 + }, 114 + onDragEnded: endWorktreeDrag 115 + ) 116 + ) 87 117 } 88 118 } 89 119 ··· 94 124 moveDisabled: Bool, 95 125 shortcutHint: String? 96 126 ) -> some View { 97 - let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 127 + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty 128 + let config = rowConfig( 129 + for: row, 130 + isRepositoryRemoving: isRepositoryRemoving, 131 + isWorktreeDragActive: isWorktreeDragActive, 132 + moveDisabled: moveDisabled, 133 + shortcutHint: shortcutHint 134 + ) 135 + let baseRow = worktreeRowView(row, config: config) 136 + .disabled(isRepositoryRemoving) 137 + .contentShape(.dragPreview, .rect) 138 + .contentShape(.interaction, .rect) 139 + .contentShape(Rectangle()) 140 + Group { 141 + if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 142 + baseRow 143 + .overlay { 144 + ContextMenuActivationOverlay { 145 + scheduleContextMenuHighlight(for: row.id) 146 + } 147 + } 148 + .contextMenu { 149 + rowContextMenu(worktree: worktree, row: row) 150 + } 151 + } else { 152 + baseRow 153 + } 154 + } 155 + .onTapGesture { 156 + selectWorktreeRow(row.id) 157 + } 158 + .accessibilityAddTraits(.isButton) 159 + .draggableWorktree( 160 + id: row.id, 161 + isEnabled: !moveDisabled, 162 + beginDrag: { 163 + draggingWorktreeIDs = [row.id] 164 + store.send(.worktreeOrdering(.setSidebarDragActive(true))) 165 + } 166 + ) 167 + .environment(\.colorScheme, colorScheme) 168 + .preferredColorScheme(colorScheme) 169 + .onHover { hovering in 170 + if hovering { 171 + hoveredWorktreeID = row.id 172 + } else if hoveredWorktreeID == row.id { 173 + hoveredWorktreeID = nil 174 + } 175 + } 176 + .onDragSessionUpdated { session in 177 + let didEnd = 178 + if case .ended = session.phase { 179 + true 180 + } else if case .dataTransferCompleted = session.phase { 181 + true 182 + } else { 183 + false 184 + } 185 + handleWorktreeDragSession( 186 + draggedIDs: Set(session.draggedItemIDs(for: Worktree.ID.self)), 187 + didEnd: didEnd 188 + ) 189 + } 190 + } 191 + 192 + private func rowConfig( 193 + for row: WorktreeRowModel, 194 + isRepositoryRemoving: Bool, 195 + isWorktreeDragActive: Bool, 196 + moveDisabled: Bool, 197 + shortcutHint: String? 198 + ) -> WorktreeRowViewConfig { 98 199 let displayName = 99 200 if row.isDeleting { 100 201 "\(row.name) (deleting...)" ··· 103 204 } else { 104 205 row.name 105 206 } 106 - let canShowRowActions = row.isRemovable && !isRepositoryRemoving 107 - let pinAction: (() -> Void)? = 108 - canShowRowActions && !row.isMainWorktree 109 - ? { togglePin(for: row.id, isPinned: row.isPinned) } 110 - : nil 111 - let archiveAction: (() -> Void)? = 112 - canShowRowActions && !row.isMainWorktree 113 - ? { archiveWorktree(row.id) } 114 - : nil 207 + let showsNotificationIndicator = terminalManager.hasUnseenNotifications(for: row.id) 115 208 let notifications = terminalManager.stateIfExists(for: row.id)?.notifications ?? [] 116 - let onFocusNotification: (WorktreeTerminalNotification) -> Void = { notification in 117 - guard let terminalState = terminalManager.stateIfExists(for: row.id) else { 209 + let canShowRowActions = row.isRemovable && !isRepositoryRemoving && !isWorktreeDragActive 210 + return WorktreeRowViewConfig( 211 + displayName: displayName, 212 + worktreeName: worktreeName(for: row), 213 + isHovered: !isWorktreeDragActive && hoveredWorktreeID == row.id, 214 + showsNotificationIndicator: !isWorktreeDragActive && showsNotificationIndicator, 215 + notifications: isWorktreeDragActive ? [] : notifications, 216 + onFocusNotification: focusNotificationHandler(for: row.id), 217 + shortcutHint: shortcutHint, 218 + pinAction: canShowRowActions && !row.isMainWorktree ? { togglePin(for: row.id, isPinned: row.isPinned) } : nil, 219 + archiveAction: canShowRowActions && !row.isMainWorktree ? { archiveWorktree(row.id) } : nil, 220 + onDiffTap: diffTapHandler(for: row.id), 221 + onStopRunScript: stopRunScriptHandler(for: row.id), 222 + moveDisabled: moveDisabled, 223 + ) 224 + } 225 + 226 + private func focusNotificationHandler(for worktreeID: Worktree.ID) -> (WorktreeTerminalNotification) -> Void { 227 + { notification in 228 + guard let terminalState = terminalManager.stateIfExists(for: worktreeID) else { 118 229 return 119 230 } 120 231 _ = terminalState.focusSurface(id: notification.surfaceId) 121 232 } 122 - let onDiffTap: (() -> Void)? = { 123 - guard let worktree = store.state.worktree(for: row.id) else { return } 233 + } 234 + 235 + private func diffTapHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { 236 + { 237 + guard let worktree = store.state.worktree(for: worktreeID) else { return } 124 238 DiffWindowManager.shared.show( 125 239 worktreeURL: worktree.workingDirectory, 126 240 branchName: worktree.name, 127 241 resolvedKeybindings: resolvedKeybindings 128 242 ) 129 243 } 130 - let onStopRunScript: (() -> Void)? = 131 - terminalManager.isRunScriptRunning(for: row.id) 132 - ? { _ = terminalManager.stateIfExists(for: row.id)?.stopRunScript() } 244 + } 245 + 246 + private func stopRunScriptHandler(for worktreeID: Worktree.ID) -> (() -> Void)? { 247 + terminalManager.isRunScriptRunning(for: worktreeID) 248 + ? { _ = terminalManager.stateIfExists(for: worktreeID)?.stopRunScript() } 133 249 : nil 134 - let config = WorktreeRowViewConfig( 135 - displayName: displayName, 136 - worktreeName: worktreeName(for: row), 137 - isHovered: hoveredWorktreeID == row.id, 138 - showsNotificationIndicator: showsNotificationIndicator, 139 - notifications: notifications, 140 - onFocusNotification: onFocusNotification, 141 - shortcutHint: shortcutHint, 142 - pinAction: pinAction, 143 - archiveAction: archiveAction, 144 - onDiffTap: onDiffTap, 145 - onStopRunScript: onStopRunScript, 146 - moveDisabled: moveDisabled, 147 - ) 148 - let baseRow = worktreeRowView(row, config: config) 149 - Group { 150 - if row.isRemovable, let worktree = store.state.worktree(for: row.id), !isRepositoryRemoving { 151 - baseRow.contextMenu { 152 - rowContextMenu(worktree: worktree, row: row) 153 - } 250 + } 251 + 252 + private func handleWorktreeDragSession( 253 + draggedIDs: Set<Worktree.ID>, 254 + didEnd: Bool 255 + ) { 256 + if didEnd { 257 + endWorktreeDrag() 258 + return 259 + } 260 + if !draggedIDs.isEmpty, draggedIDs != draggingWorktreeIDs { 261 + draggingWorktreeIDs = draggedIDs 262 + } 263 + } 264 + 265 + private func selectWorktreeRow(_ worktreeID: Worktree.ID) { 266 + if commandKeyObserver.isPressed { 267 + var nextSelection = selectedWorktreeIDs 268 + if nextSelection.contains(worktreeID) { 269 + nextSelection.remove(worktreeID) 154 270 } else { 155 - baseRow.disabled(isRepositoryRemoving) 271 + nextSelection.insert(worktreeID) 156 272 } 157 - } 158 - .contentShape(.dragPreview, .rect) 159 - .contentShape(.interaction, .rect) 160 - .environment(\.colorScheme, colorScheme) 161 - .preferredColorScheme(colorScheme) 162 - .onHover { hovering in 163 - if hovering { 164 - hoveredWorktreeID = row.id 165 - } else if hoveredWorktreeID == row.id { 166 - hoveredWorktreeID = nil 273 + guard !nextSelection.isEmpty else { 274 + store.send(.selectWorktree(nil)) 275 + return 167 276 } 277 + let primarySelection = 278 + hotkeyRows.map(\.id).first(where: nextSelection.contains) 279 + ?? nextSelection.first 280 + store.send(.selectWorktree(primarySelection, focusTerminal: false)) 281 + store.send(.setSidebarSelectedWorktreeIDs(nextSelection)) 282 + return 168 283 } 169 - .onDragSessionUpdated { session in 170 - let draggedIDs = Set(session.draggedItemIDs(for: Worktree.ID.self)) 171 - if case .ended = session.phase { 172 - if !draggingWorktreeIDs.isEmpty { 173 - draggingWorktreeIDs = [] 284 + 285 + store.send(.selectWorktree(worktreeID, focusTerminal: true)) 286 + focusTerminalAfterSelection(worktreeID: worktreeID) 287 + } 288 + 289 + private func focusTerminalAfterSelection(worktreeID: Worktree.ID) { 290 + Task { @MainActor [terminalManager] in 291 + for _ in 0..<4 { 292 + await Task.yield() 293 + if let terminalState = terminalManager.stateIfExists(for: worktreeID) { 294 + terminalState.focusSelectedTab() 295 + return 174 296 } 175 - return 176 297 } 177 - if case .dataTransferCompleted = session.phase { 178 - if !draggingWorktreeIDs.isEmpty { 179 - draggingWorktreeIDs = [] 180 - } 181 - return 182 - } 183 - if draggedIDs != draggingWorktreeIDs { 184 - draggingWorktreeIDs = draggedIDs 185 - } 298 + } 299 + } 300 + 301 + private func endWorktreeDrag() { 302 + draggingWorktreeIDs = [] 303 + targetedPinnedDropDestination = nil 304 + targetedUnpinnedDropDestination = nil 305 + store.send(.worktreeOrdering(.setSidebarDragActive(false))) 306 + } 307 + 308 + private func moveWorktrees( 309 + section: SidebarWorktreeSection, 310 + offsets: IndexSet, 311 + destination: Int 312 + ) { 313 + switch section { 314 + case .pinned: 315 + store.send(.worktreeOrdering(.pinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 316 + case .unpinned: 317 + store.send(.worktreeOrdering(.unpinnedWorktreesMoved(repositoryID: repository.id, offsets, destination))) 186 318 } 187 319 } 188 320 ··· 203 335 204 336 private func worktreeRowView(_ row: WorktreeRowModel, config: WorktreeRowViewConfig) -> some View { 205 337 let isSelected = selectedWorktreeIDs.contains(row.id) 338 + let showsContextMenuHighlight = contextMenuHighlightedWorktreeID == row.id && !isSelected 206 339 let taskStatus = terminalManager.taskStatus(for: row.id) 207 340 let isRunScriptRunning = terminalManager.isRunScriptRunning(for: row.id) 341 + let isWorktreeDragActive = !draggingWorktreeIDs.isEmpty 208 342 return WorktreeRow( 209 343 name: config.displayName, 210 344 worktreeName: config.worktreeName, 211 345 info: row.info, 212 - showsPullRequestInfo: !draggingWorktreeIDs.contains(row.id), 346 + showsPullRequestInfo: !isWorktreeDragActive, 213 347 isHovered: config.isHovered, 214 348 isPinned: row.isPinned, 215 349 isMainWorktree: row.isMainWorktree, ··· 227 361 onStopRunScript: config.onStopRunScript, 228 362 ) 229 363 .tag(SidebarSelection.worktree(row.id)) 230 - .id(row.id) 364 + .id(SidebarScrollID.worktree(row.id)) 231 365 .typeSelectEquivalent("") 232 - .listRowInsets(EdgeInsets()) 233 - .listRowSeparator(.hidden) 366 + .padding(.leading, 14) 367 + .padding(.trailing, 8) 368 + .background { 369 + if isSelected { 370 + RoundedRectangle(cornerRadius: 5) 371 + .fill(Color.accentColor.opacity(0.18)) 372 + .padding(.horizontal, 6) 373 + } 374 + } 375 + .overlay { 376 + if showsContextMenuHighlight { 377 + RoundedRectangle(cornerRadius: 5) 378 + .stroke(Color.accentColor, lineWidth: 2) 379 + .padding(.horizontal, 6) 380 + } 381 + } 234 382 .transition(.opacity) 235 383 .moveDisabled(config.moveDisabled) 384 + } 385 + 386 + private func scheduleContextMenuHighlight(for worktreeID: Worktree.ID) { 387 + guard !selectedWorktreeIDs.contains(worktreeID) else { return } 388 + Task { @MainActor in 389 + contextMenuHighlightedWorktreeID = worktreeID 390 + } 236 391 } 237 392 238 393 @ViewBuilder ··· 367 522 return row.name 368 523 } 369 524 } 525 + 526 + private struct ContextMenuActivationOverlay: NSViewRepresentable { 527 + let activate: () -> Void 528 + 529 + func makeNSView(context: Context) -> RightClickForwardingView { 530 + let view = RightClickForwardingView() 531 + view.activate = activate 532 + return view 533 + } 534 + 535 + func updateNSView(_ nsView: RightClickForwardingView, context: Context) { 536 + nsView.activate = activate 537 + } 538 + 539 + final class RightClickForwardingView: NSView { 540 + var activate: (() -> Void)? 541 + private var isForwardingRightClick = false 542 + 543 + override func hitTest(_ point: NSPoint) -> NSView? { 544 + guard !isForwardingRightClick, 545 + NSApp.currentEvent?.type == .rightMouseDown 546 + else { 547 + return nil 548 + } 549 + return bounds.contains(point) ? self : nil 550 + } 551 + 552 + override func rightMouseDown(with event: NSEvent) { 553 + activate?() 554 + guard let window else { return } 555 + isForwardingRightClick = true 556 + window.sendEvent(event) 557 + isForwardingRightClick = false 558 + } 559 + } 560 + }
+109
supacodeTests/RepositoriesFeatureTests.swift
··· 2577 2577 #expect(store.state.statusToast == nil) 2578 2578 } 2579 2579 2580 + @Test func worktreeNotificationDuringSidebarDragDefersReorder() async { 2581 + let repoRoot = "/tmp/repo" 2582 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2583 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2584 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2585 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 2586 + var state = makeState(repositories: [repository]) 2587 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2588 + let store = TestStore(initialState: state) { 2589 + RepositoriesFeature() 2590 + } 2591 + 2592 + await store.send(.worktreeOrdering(.setSidebarDragActive(true))) { 2593 + $0.isSidebarDragActive = true 2594 + } 2595 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { 2596 + $0.pendingSidebarNotifyReorderIDs = [featureB.id] 2597 + } 2598 + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id, featureB.id]) 2599 + } 2600 + 2601 + @Test func endingSidebarDragAppliesPendingNotificationReordersInOrder() async { 2602 + let repoRoot = "/tmp/repo" 2603 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2604 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2605 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2606 + let featureC = makeWorktree(id: "/tmp/repo/c", name: "c", repoRoot: repoRoot) 2607 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB, featureC]) 2608 + var state = makeState(repositories: [repository]) 2609 + state.isSidebarDragActive = true 2610 + state.pendingSidebarNotifyReorderIDs = [featureA.id, featureC.id, featureB.id] 2611 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id, featureC.id] 2612 + let store = TestStore(initialState: state) { 2613 + RepositoriesFeature() 2614 + } 2615 + 2616 + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { 2617 + $0.isSidebarDragActive = false 2618 + $0.pendingSidebarNotifyReorderIDs = [] 2619 + $0.worktreeOrderByRepository[repoRoot] = [featureB.id, featureC.id, featureA.id] 2620 + } 2621 + } 2622 + 2623 + @Test func repeatedNotificationDuringSidebarDragKeepsLatestPosition() async { 2624 + let repoRoot = "/tmp/repo" 2625 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2626 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2627 + let featureB = makeWorktree(id: "/tmp/repo/b", name: "b", repoRoot: repoRoot) 2628 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA, featureB]) 2629 + var state = makeState(repositories: [repository]) 2630 + state.isSidebarDragActive = true 2631 + state.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2632 + let store = TestStore(initialState: state) { 2633 + RepositoriesFeature() 2634 + } 2635 + 2636 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { 2637 + $0.pendingSidebarNotifyReorderIDs = [featureA.id] 2638 + } 2639 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureB.id))) { 2640 + $0.pendingSidebarNotifyReorderIDs = [featureA.id, featureB.id] 2641 + } 2642 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) { 2643 + $0.pendingSidebarNotifyReorderIDs = [featureB.id, featureA.id] 2644 + } 2645 + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { 2646 + $0.isSidebarDragActive = false 2647 + $0.pendingSidebarNotifyReorderIDs = [] 2648 + $0.worktreeOrderByRepository[repoRoot] = [featureA.id, featureB.id] 2649 + } 2650 + } 2651 + 2652 + @Test func stalePendingNotificationReordersAreIgnoredWhenDragEnds() async { 2653 + let repoRoot = "/tmp/repo" 2654 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2655 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2656 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) 2657 + var state = makeState(repositories: [repository]) 2658 + state.isSidebarDragActive = true 2659 + state.pendingSidebarNotifyReorderIDs = ["/tmp/repo/stale", featureA.id] 2660 + state.worktreeOrderByRepository[repoRoot] = [featureA.id] 2661 + let store = TestStore(initialState: state) { 2662 + RepositoriesFeature() 2663 + } 2664 + 2665 + await store.send(.worktreeOrdering(.setSidebarDragActive(false))) { 2666 + $0.isSidebarDragActive = false 2667 + $0.pendingSidebarNotifyReorderIDs = [] 2668 + } 2669 + } 2670 + 2671 + @Test func notificationDuringSidebarDragDoesNotRecordWhenMoveToTopDisabled() async { 2672 + let repoRoot = "/tmp/repo" 2673 + let mainWorktree = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 2674 + let featureA = makeWorktree(id: "/tmp/repo/a", name: "a", repoRoot: repoRoot) 2675 + let repository = makeRepository(id: repoRoot, worktrees: [mainWorktree, featureA]) 2676 + var state = makeState(repositories: [repository]) 2677 + state.isSidebarDragActive = true 2678 + state.moveNotifiedWorktreeToTop = false 2679 + state.worktreeOrderByRepository[repoRoot] = [featureA.id] 2680 + let store = TestStore(initialState: state) { 2681 + RepositoriesFeature() 2682 + } 2683 + 2684 + await store.send(.worktreeOrdering(.worktreeNotificationReceived(featureA.id))) 2685 + #expect(store.state.pendingSidebarNotifyReorderIDs.isEmpty) 2686 + #expect(store.state.worktreeOrderByRepository[repoRoot] == [featureA.id]) 2687 + } 2688 + 2580 2689 @Test func setMoveNotifiedWorktreeToTopUpdatesState() async { 2581 2690 var state = makeState(repositories: []) 2582 2691 state.moveNotifiedWorktreeToTop = true
+22
supacodeTests/RepositorySectionViewTests.swift
··· 69 69 #expect(SidebarListView.showsRepositoryListHeader(repositoryCount: 11)) 70 70 } 71 71 72 + @Test func explicitSelectionIncludesPrimarySelectedWorktree() { 73 + let worktree = Worktree( 74 + id: "/tmp/repo/wt", 75 + name: "wt", 76 + detail: "detail", 77 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt"), 78 + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") 79 + ) 80 + let repository = Repository( 81 + id: "/tmp/repo", 82 + rootURL: URL(fileURLWithPath: "/tmp/repo"), 83 + name: "repo", 84 + kind: .git, 85 + worktrees: [worktree] 86 + ) 87 + var state = RepositoriesFeature.State() 88 + state.repositories = [repository] 89 + state.selection = .worktree(worktree.id) 90 + 91 + #expect(SidebarListView.selectedWorktreeIDs(in: state) == [worktree.id]) 92 + } 93 + 72 94 @Test func openTabCountForGitRepositorySumsAllWorktrees() { 73 95 let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) 74 96 let repositoryRootURL = URL(fileURLWithPath: "/tmp/repo")
+54
supacodeTests/SidebarDragSupportTests.swift
··· 1 + import Testing 2 + 3 + @testable import supacode 4 + 5 + struct SidebarDragSupportTests { 6 + @Test func dropIndicatorOnlyDrawsOneEdgeForInteriorDestinations() { 7 + #expect( 8 + SidebarDropIndicatorEdge.edge( 9 + targetedDestination: 1, 10 + rowIndex: 0, 11 + rowCount: 3 12 + ) 13 + == .none 14 + ) 15 + #expect( 16 + SidebarDropIndicatorEdge.edge( 17 + targetedDestination: 1, 18 + rowIndex: 1, 19 + rowCount: 3 20 + ) 21 + == .top 22 + ) 23 + } 24 + 25 + @Test func dropIndicatorDrawsTopAndFinalBottomBoundaries() { 26 + #expect( 27 + SidebarDropIndicatorEdge.edge( 28 + targetedDestination: 0, 29 + rowIndex: 0, 30 + rowCount: 3 31 + ) 32 + == .top 33 + ) 34 + #expect( 35 + SidebarDropIndicatorEdge.edge( 36 + targetedDestination: 3, 37 + rowIndex: 2, 38 + rowCount: 3 39 + ) 40 + == .bottom 41 + ) 42 + } 43 + 44 + @Test func dropIndicatorHidesWithoutTarget() { 45 + #expect( 46 + SidebarDropIndicatorEdge.edge( 47 + targetedDestination: nil, 48 + rowIndex: 1, 49 + rowCount: 3 50 + ) 51 + == .none 52 + ) 53 + } 54 + }
+197
supacodeTests/SidebarPresentationTests.swift
··· 1 + import Foundation 2 + import IdentifiedCollections 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct SidebarPresentationTests { 9 + @Test func expandedRepositoryIsOneOuterItemWithChildRows() { 10 + let repoRoot = "/tmp/repo" 11 + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 12 + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) 13 + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) 14 + let state = makeState(repositories: [repository]) 15 + 16 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) 17 + 18 + #expect(presentation.items.count == 1) 19 + guard case .repository(let model) = presentation.items.first else { 20 + Issue.record("Expected repository container") 21 + return 22 + } 23 + #expect(model.id == repository.id) 24 + #expect(model.isExpanded) 25 + #expect(model.worktreeSections.allRows.map(\.id) == [main.id, feature.id]) 26 + } 27 + 28 + @Test func collapsedRepositoryKeepsContainerButHidesChildRows() { 29 + let repoRoot = "/tmp/repo" 30 + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 31 + let feature = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: repoRoot) 32 + let repository = makeRepository(id: repoRoot, worktrees: [main, feature]) 33 + let state = makeState(repositories: [repository]) 34 + 35 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: []) 36 + 37 + guard case .repository(let model) = presentation.items.first else { 38 + Issue.record("Expected repository container") 39 + return 40 + } 41 + #expect(!model.isExpanded) 42 + #expect(model.worktreeSections.allRows.isEmpty) 43 + } 44 + 45 + @Test func failedRepositoriesParticipateInRootOrder() { 46 + let repoA = makeRepository(id: "/tmp/a", worktrees: []) 47 + var state = makeState(repositories: [repoA]) 48 + state.repositoryRoots = [ 49 + URL(fileURLWithPath: "/tmp/missing"), 50 + repoA.rootURL, 51 + ] 52 + state.repositoryOrderIDs = ["/tmp/missing", repoA.id] 53 + state.loadFailuresByID["/tmp/missing"] = "missing" 54 + 55 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id]) 56 + 57 + #expect(presentation.repositoryOrderIDs == ["/tmp/missing", repoA.id]) 58 + #expect( 59 + presentation.repositoryOrderAfterMove(fromOffsets: IndexSet(integer: 0), toOffset: 2) == [ 60 + repoA.id, "/tmp/missing", 61 + ]) 62 + guard case .failedRepository(let failed) = presentation.items.first else { 63 + Issue.record("Expected failed repository first") 64 + return 65 + } 66 + #expect(failed.id == "/tmp/missing") 67 + #expect(failed.isReorderable) 68 + } 69 + 70 + @Test func plainFolderProducesContainerWithoutWorktreeChildren() { 71 + let repository = makeRepository(id: "/tmp/plain", kind: .plain, worktrees: []) 72 + let state = makeState(repositories: [repository]) 73 + 74 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) 75 + 76 + guard case .repository(let model) = presentation.items.first else { 77 + Issue.record("Expected repository container") 78 + return 79 + } 80 + #expect(model.kind == .plain) 81 + #expect(model.worktreeSections.allRows.isEmpty) 82 + } 83 + 84 + @Test func worktreeSectionsPreservePinnedMainPendingAndUnpinnedRows() { 85 + let repoRoot = "/tmp/repo" 86 + let main = makeWorktree(id: repoRoot, name: "main", repoRoot: repoRoot) 87 + let pinned = makeWorktree(id: "/tmp/repo/pinned", name: "pinned", repoRoot: repoRoot) 88 + let unpinned = makeWorktree(id: "/tmp/repo/unpinned", name: "unpinned", repoRoot: repoRoot) 89 + let repository = makeRepository(id: repoRoot, worktrees: [main, pinned, unpinned]) 90 + var state = makeState(repositories: [repository]) 91 + state.pinnedWorktreeIDs = [pinned.id] 92 + state.pendingWorktrees = [ 93 + PendingWorktree( 94 + id: "/tmp/repo/pending", 95 + repositoryID: repository.id, 96 + progress: WorktreeCreationProgress(stage: .choosingWorktreeName, worktreeName: "pending") 97 + ) 98 + ] 99 + state.worktreeOrderByRepository[repository.id] = [unpinned.id] 100 + 101 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repository.id]) 102 + 103 + guard case .repository(let model) = presentation.items.first else { 104 + Issue.record("Expected repository container") 105 + return 106 + } 107 + #expect(model.worktreeSections.main?.id == main.id) 108 + #expect(model.worktreeSections.pinned.map(\.id) == [pinned.id]) 109 + #expect(model.worktreeSections.pending.map(\.id) == ["/tmp/repo/pending"]) 110 + #expect(model.worktreeSections.unpinned.map(\.id) == [unpinned.id]) 111 + } 112 + 113 + @Test func emptyOrderedRootsStillBuildsRepositoryPresentation() { 114 + let repoA = makeRepository(id: "/tmp/a", worktrees: []) 115 + let repoB = makeRepository(id: "/tmp/b", worktrees: []) 116 + var state = RepositoriesFeature.State() 117 + state.repositories = [repoA, repoB] 118 + 119 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) 120 + 121 + #expect(presentation.repositoryOrderIDs == [repoA.id, repoB.id]) 122 + } 123 + 124 + @Test func customOrderedRootsUseSamePresentationRules() { 125 + let repoA = makeRepository(id: "/tmp/a", worktrees: []) 126 + let repoB = makeRepository(id: "/tmp/b", worktrees: []) 127 + var state = makeState(repositories: [repoA, repoB]) 128 + state.repositoryOrderIDs = [repoB.id, repoA.id] 129 + 130 + let presentation = state.sidebarPresentation(expandedRepositoryIDs: [repoA.id, repoB.id]) 131 + 132 + #expect(presentation.repositoryOrderIDs == [repoB.id, repoA.id]) 133 + } 134 + 135 + @Test func worktreeDropDestinationsMapToExistingOrderingActions() { 136 + let pinned = SidebarWorktreeDropTarget( 137 + repositoryID: "/tmp/repo", 138 + section: .pinned, 139 + source: IndexSet(integer: 1), 140 + destination: 0 141 + ) 142 + let unpinned = SidebarWorktreeDropTarget( 143 + repositoryID: "/tmp/repo", 144 + section: .unpinned, 145 + source: IndexSet(integer: 0), 146 + destination: 2 147 + ) 148 + 149 + #expect( 150 + pinned.action 151 + == RepositoriesFeature.WorktreeOrderingAction.pinnedWorktreesMoved( 152 + repositoryID: "/tmp/repo", 153 + IndexSet(integer: 1), 154 + 0 155 + ) 156 + ) 157 + #expect( 158 + unpinned.action 159 + == RepositoriesFeature.WorktreeOrderingAction.unpinnedWorktreesMoved( 160 + repositoryID: "/tmp/repo", 161 + IndexSet(integer: 0), 162 + 2 163 + ) 164 + ) 165 + } 166 + 167 + private func makeWorktree(id: String, name: String, repoRoot: String) -> Worktree { 168 + Worktree( 169 + id: id, 170 + name: name, 171 + detail: "detail", 172 + workingDirectory: URL(fileURLWithPath: id), 173 + repositoryRootURL: URL(fileURLWithPath: repoRoot) 174 + ) 175 + } 176 + 177 + private func makeRepository( 178 + id: String, 179 + kind: Repository.Kind = .git, 180 + worktrees: [Worktree] 181 + ) -> Repository { 182 + Repository( 183 + id: id, 184 + rootURL: URL(fileURLWithPath: id), 185 + name: URL(fileURLWithPath: id).lastPathComponent, 186 + kind: kind, 187 + worktrees: IdentifiedArray(uniqueElements: worktrees) 188 + ) 189 + } 190 + 191 + private func makeState(repositories: [Repository]) -> RepositoriesFeature.State { 192 + var state = RepositoriesFeature.State() 193 + state.repositories = IdentifiedArray(uniqueElements: repositories) 194 + state.repositoryRoots = repositories.map(\.rootURL) 195 + return state 196 + } 197 + }