···11+# Sidebar Container Refactor Plan
22+33+Status: planning
44+Issue: [#249](https://github.com/onevcat/Prowl/issues/249)
55+Related: [#222](https://github.com/onevcat/Prowl/issues/222)
66+77+## Goal
88+99+Refactor the repository sidebar so each repository behaves as one stable visual and drag unit, while worktrees remain selectable, reorderable, and efficient to update.
1010+1111+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:
1212+1313+- incorrect repository drag insertion indicators when dragging downward across expanded repositories
1414+- unstable bulk expand/collapse animations
1515+- potential sidebar flicker during drag when live terminal, notification, or ordering updates arrive
1616+1717+## Current Findings
1818+1919+### 1. Repository sections are not actual list rows
2020+2121+`SidebarListView` renders repositories through an outer `ForEach(...).onMove`.
2222+2323+`RepositorySectionView` then returns:
2424+2525+```swift
2626+Group {
2727+ header
2828+ .tag(SidebarSelection.repository(repository.id))
2929+ if isExpanded {
3030+ WorktreeRowsView(...)
3131+ }
3232+}
3333+```
3434+3535+In practice, the outer data model says "repository row", but `List` receives separate rows:
3636+3737+```text
3838+Repository A header
3939+ Repository A worktree
4040+ Repository A worktree
4141+Repository B header
4242+ Repository B worktree
4343+```
4444+4545+This explains the observed downward-drag indicator bug:
4646+4747+```text
4848+Target Repo header
4949+o-----------
5050+Target Repo worktree
5151+```
5252+5353+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.
5454+5555+### 2. `List(selection:)` is doing too much
5656+5757+The current `List` carries several behaviors at once:
5858+5959+- archived worktree selection and repository list header
6060+- repository row selection for plain folders
6161+- worktree multi-selection
6262+- repository expand/collapse
6363+- native repository reorder
6464+- native worktree reorder for pinned and unpinned groups
6565+- reveal-in-sidebar via `ScrollViewReader.scrollTo`
6666+- native sidebar styling and accessibility
6767+6868+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.
6969+7070+### 3. Live state still reaches rows during drag
7171+7272+Some expensive state reads have already been isolated, such as moving repository tab-count reads into `RepoHeaderTabCountBadge`.
7373+7474+Remaining drag-time churn sources include:
7575+7676+- `WorktreeRowsView` animates changes to `rowIDs`
7777+- each worktree row reads terminal notification/task/run-script state
7878+- notification-driven reorder can call `withAnimation(.snappy)` and mutate `worktreeOrderByRepository`
7979+- row hover/action UI changes while a drag session is active
8080+8181+These are not necessarily the root cause of the drop-indicator bug, but they are credible contributors to #222-style flicker.
8282+8383+## Revised Direction
8484+8585+Use a custom sidebar scroll container rather than trying to keep the current flat `List` structure.
8686+8787+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.
8888+8989+Recommended shape:
9090+9191+```text
9292+SidebarView chrome
9393+├── top safeAreaInset buttons
9494+│ ├── Canvas
9595+│ └── Shelf
9696+├── ScrollViewReader
9797+│ └── ScrollView
9898+│ └── LazyVStack or VStack
9999+│ ├── repository list header
100100+│ ├── RepositoryContainerRow
101101+│ │ ├── RepositoryHeaderRow
102102+│ │ └── WorktreeRows
103103+│ ├── FailedRepositoryRow
104104+│ └── ArchivedWorktreesRow
105105+└── bottom safeAreaInset footer
106106+```
107107+108108+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.
109109+110110+This aligns UI boundaries with model boundaries:
111111+112112+- repository reorder indicators target repository containers
113113+- expand/collapse animates inside a container
114114+- worktree reorder indicators target worktree rows inside one container
115115+- live worktree updates do not change the outer repository list shape
116116+117117+## Options Considered
118118+119119+### Option A: Keep `List`, wrap worktrees inside one repository row
120120+121121+Pros:
122122+123123+- preserves some native sidebar styling
124124+- repository-level `onMove` might remain mostly native
125125+126126+Cons:
127127+128128+- nested selectable worktree rows inside a single `List` row no longer participate naturally in `List(selection:)`
129129+- worktree-level `onMove` becomes awkward inside a row
130130+- native selection and keyboard behavior still need replacement
131131+- likely keeps a hard-to-debug mix of native and custom drag logic
132132+133133+This option reduces the indicator bug but does not cleanly solve the broader sidebar design.
134134+135135+### Option B: Move fully to `ScrollView` + explicit rows
136136+137137+Pros:
138138+139139+- model and visual structure match
140140+- repo and worktree drag/drop can be made explicit and testable
141141+- selection, focus, and reveal behavior are owned by our code instead of `List` side effects
142142+- easier to freeze drag-time updates intentionally
143143+- eliminates `List` cell reuse as a class of expand/collapse animation bugs
144144+145145+Cons:
146146+147147+- must replace native `List(selection:)`
148148+- must rebuild keyboard navigation, multi-selection, reorder, and accessibility affordances
149149+- more implementation work
150150+151151+This remains the recommended route for #249 if the goal is "fix the sidebar design once" rather than patch one symptom.
152152+153153+### Option C: Short-term drag-time freeze only
154154+155155+Pros:
156156+157157+- small
158158+- may help #222 flicker
159159+160160+Cons:
161161+162162+- does not fix repository insertion indicator because row boundaries remain wrong
163163+- leaves the main structural mismatch in place
164164+165165+This is now the mandatory M1 prerequisite for Option B, not a replacement for Option B.
166166+167167+## Hard Requirements
168168+169169+The refactor must preserve these behavior and state contracts.
170170+171171+### Reducer actions and persistence
172172+173173+- 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.
174174+- Preserve calls behind repository reorder, pinned worktree reorder, unpinned worktree reorder, and notification-driven reorder.
175175+- Treat failed repository reorder semantics as an explicit product decision:
176176+ - either failed repository rows are reorderable and their order persists through the same root ordering path
177177+ - or they are not reorderable and the UI gives consistent feedback with no insertion target around them
178178+179179+### Expanded and collapsed state
180180+181181+- Preserve `@Shared` write-back semantics for collapsed repository IDs.
182182+- Preserve cleanup of invalid collapsed IDs when repository IDs change.
183183+- Ensure bulk expand/collapse and single expand/collapse share the same model path.
184184+185185+### Focused actions and selection synchronization
186186+187187+- Preserve `SidebarView` focused values for `confirmWorktreeAction`, `archiveWorktreeAction`, `deleteWorktreeAction`, and `visibleHotkeyWorktreeRows`.
188188+- Preserve the `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization currently owned by `SidebarView`.
189189+- Do not regress menu commands or numbered worktree hotkeys when replacing `List(selection:)`.
190190+191191+### Existing row affordances
192192+193193+- Preserve repository and worktree context menus.
194194+- Preserve drag previews.
195195+- Preserve current worktree row type-select behavior. Worktree rows currently use `.typeSelectEquivalent("")`; V1 should keep type-select effectively disabled for those rows.
196196+- Preserve root-level `dropDestination(for: URL.self)` on the sidebar container, including drops into blank sidebar space.
197197+198198+### Ordered roots
199199+200200+- Converge the current `orderedRoots.isEmpty` fallback and non-empty custom-order path into one presentation path.
201201+- The empty ordered-roots case is a valid user state and must have tests.
202202+203203+## Proposed Architecture
204204+205205+### SidebarPresentation
206206+207207+Introduce a pure presentation model that flattens current repository state into explicit sidebar units.
208208+209209+Suggested model:
210210+211211+```swift
212212+struct SidebarPresentation: Equatable {
213213+ var items: [SidebarItem]
214214+}
215215+216216+enum SidebarItem: Equatable, Identifiable {
217217+ case listHeader(SidebarListHeaderModel)
218218+ case repository(SidebarRepositoryContainerModel)
219219+ case failedRepository(FailedRepositoryModel)
220220+ case archivedWorktrees(ArchivedWorktreesRowModel)
221221+}
222222+223223+struct SidebarRepositoryContainerModel: Equatable, Identifiable {
224224+ var repositoryID: Repository.ID
225225+ var title: String
226226+ var rootURL: URL
227227+ var kind: Repository.Kind
228228+ var isExpanded: Bool
229229+ var isRemoving: Bool
230230+ var worktreeSections: WorktreeRowSections
231231+}
232232+```
233233+234234+Rules:
235235+236236+- build `SidebarPresentation` from reducer/state-side pure functions or equivalent helpers
237237+- outer `items` contains one item per repository, not one item per row
238238+- worktree sections remain inside the repository container
239239+- presentation construction is pure and unit-tested
240240+- high-frequency terminal notification/task/run-script state stays in leaf views, not in broad presentation state
241241+- Canvas, Shelf, and footer chrome remain outside `SidebarPresentation` in V1
242242+243243+### Selection
244244+245245+Replace `List(selection:)` with explicit selection handling.
246246+247247+Keep `RepositoriesFeature.State.selection` and `sidebarSelectedWorktreeIDs` as the source of truth, but route clicks through helper functions.
248248+249249+Compatibility matrix:
250250+251251+| Interaction | State behavior | Focus behavior |
252252+| --- | --- | --- |
253253+| Canvas button | Selects Canvas and clears incompatible sidebar worktree selection. | Does not focus a terminal. |
254254+| Shelf button | Selects Shelf and clears incompatible sidebar worktree selection. | Does not focus a terminal. |
255255+| Archived worktrees row | Selects archived worktrees and clears incompatible worktree selection. | Does not focus a terminal. |
256256+| Git repository header click | Toggles expanded state by default. | Does not focus a terminal. |
257257+| Plain folder repository click | Selects the repository. | Does not focus a terminal unless current behavior already does. |
258258+| Worktree row normal click | Selects one worktree and updates sidebar selected worktree IDs to that one ID. | Focuses the terminal for the selected worktree. |
259259+| 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. |
260260+| Empty sidebar selection | Clears sidebar selected worktree IDs. | Does not focus a terminal. |
261261+262262+Selection visuals should be explicit in `RepositoryHeaderRow` and `WorktreeRow`, not inherited from `List`.
263263+264264+### Keyboard Navigation
265265+266266+Preserve the existing command actions first:
267267+268268+- `selectNextWorktree`
269269+- `selectPreviousWorktree`
270270+- `revealSelectedWorktreeInSidebar`
271271+- numbered hotkeys
272272+273273+Do not try to rebuild full Finder-like keyboard navigation in the first pass unless it is currently user-visible and relied upon.
274274+275275+Required V1 behavior:
276276+277277+- command shortcuts still select worktrees
278278+- selected row is scrolled into view on reveal
279279+- focus returns to terminal after single worktree selection
280280+- sidebar focus does not accidentally forward text while Canvas, Shelf, or Archived rules say it should not
281281+282282+### Repository Reorder
283283+284284+Replace `ForEach(...).onMove` with explicit repository drag/drop.
285285+286286+Suggested approach:
287287+288288+- make `RepositoryContainerRow` draggable with repository ID payload
289289+- render a custom repository insertion indicator between repository containers
290290+- compute drop destination as a repository index
291291+- dispatch existing repository-ordering actions or a new reducer action that delegates to the same persistence path
292292+293293+The custom indicator should always render at repository container boundaries:
294294+295295+```text
296296+Target Repo header
297297+ Target Repo worktree
298298+o-----------
299299+```
300300+301301+This directly fixes the current downward-drag indicator bug.
302302+303303+### Worktree Reorder
304304+305305+Keep worktree reorder scoped inside one repository container.
306306+307307+Suggested approach:
308308+309309+- worktree rows are draggable with worktree ID payload
310310+- pinned and unpinned sections keep separate drop zones
311311+- main and pending rows remain non-movable
312312+- drop destination maps to existing reducer actions:
313313+ - `.pinnedWorktreesMoved(repositoryID, offsets, destination)`
314314+ - `.unpinnedWorktreesMoved(repositoryID, offsets, destination)`
315315+316316+Cross-repository worktree drag can stay out of scope. The current model does not appear to support moving worktrees between repositories.
317317+318318+### Drag-Time Freeze
319319+320320+Add sidebar drag state at reducer level and use it in both the old and new sidebar paths.
321321+322322+During any sidebar drag:
323323+324324+- freeze hover-only row actions
325325+- hide pull request / notification popover affordances that resize rows
326326+- suppress row-ID animations caused by notification-driven reorder
327327+- defer "move notified worktree to top" until drag ends, or apply it without animation after drop
328328+329329+Reducer behavior:
330330+331331+- drag begin records that sidebar drag is active
332332+- `worktreeNotificationReceived` while drag is active records pending reorder IDs instead of mutating row order immediately
333333+- drag end flushes pending notification reorders in deterministic order, dropping stale worktree IDs
334334+- `moveNotifiedWorktreeToTop == false` remains a no-op
335335+336336+This addresses #222 without requiring every live data read to stop.
337337+338338+### Expand / Collapse
339339+340340+Move expand/collapse animation into `RepositoryContainerRow`.
341341+342342+Rules:
343343+344344+- outer repository container identity must not change when worktrees appear/disappear
345345+- single repo expand/collapse animates child rows inside the container
346346+- bulk expand/collapse updates many containers, but the outer stack still has stable repository items
347347+- avoid animating row identity and live status changes in the same transaction
348348+349349+### Reveal In Sidebar
350350+351351+`ScrollViewReader.scrollTo` can still work, but scroll IDs must be explicit:
352352+353353+- repository container: `SidebarScrollID.repository(repositoryID)`
354354+- worktree row: `SidebarScrollID.worktree(worktreeID)`
355355+- archived worktrees row: `SidebarScrollID.archivedWorktrees`
356356+357357+When revealing a collapsed worktree:
358358+359359+1. expand its repository
360360+2. wait for an event-driven row availability signal
361361+3. scroll to `SidebarScrollID.worktree(worktreeID)`
362362+4. consume pending reveal
363363+364364+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.
365365+366366+### Accessibility
367367+368368+Minimum accessibility requirements:
369369+370370+- repository headers expose button/row labels and expanded state
371371+- worktree rows expose selection state
372372+- drag handles or rows expose reorder affordance where AppKit/SwiftUI can support it
373373+- Canvas / Shelf / Archived rows keep meaningful labels
374374+375375+If full native `List` accessibility cannot be matched in V1, document the gap and keep keyboard command coverage strong.
376376+377377+## Implementation Plan
378378+379379+### Phase 0: Baseline and Guardrails
380380+381381+- Add a short manual repro checklist for:
382382+ - repository drag up/down over expanded target
383383+ - bulk expand/collapse with many repositories
384384+ - worktree reorder in pinned/unpinned groups
385385+ - sidebar multi-selection
386386+ - reveal-in-sidebar
387387+- Add signposts around sidebar presentation build and drag state transitions if trace work is needed.
388388+- Establish `LazyVStack` vs `VStack` decision metrics before replacing the list:
389389+ - expand/collapse latency for 10+ repositories
390390+ - frame stability during repository drag
391391+ - CPU peak during drag and bulk expand/collapse
392392+ - body recomputation count for repository container and worktree row views
393393+- Keep current `List` code untouched until M1 and presentation tests exist.
394394+395395+### M1: Stabilize Old `List` Drag Behavior
396396+397397+Files likely involved:
398398+399399+- `supacode/Features/Repositories/Reducer/RepositoriesFeature.swift`
400400+- `supacode/Features/Repositories/Reducer/RepositoriesFeature+WorktreeOrdering.swift`
401401+- `supacode/Features/Repositories/Views/SidebarListView.swift`
402402+- `supacodeTests/RepositoriesFeatureTests.swift`
403403+404404+Deliver:
405405+406406+- reducer-level sidebar drag state
407407+- view action for drag begin/end from the old `List` path
408408+- delayed or no-animation handling for notification-driven reorder during drag
409409+- deterministic pending reorder flush on drag end
410410+411411+Tests:
412412+413413+- notification during sidebar drag does not mutate visible worktree order immediately
414414+- drag end applies the pending notification reorder in deterministic order
415415+- multiple notifications during one drag produce stable ordering
416416+- stale pending worktree IDs are ignored
417417+- `moveNotifiedWorktreeToTop == false` remains a no-op
418418+- persistence is called only when the reorder is actually applied
419419+420420+### Phase 1: Pure Presentation and Reorder Mapping
421421+422422+Files likely involved:
423423+424424+- `supacode/Features/Repositories/Models/SidebarPresentation.swift` (new)
425425+- `supacodeTests/SidebarPresentationTests.swift` (new)
426426+- existing reducer ordering tests
427427+428428+Deliver:
429429+430430+- pure sidebar presentation builder
431431+- stable scroll IDs
432432+- pure drop-destination mapping for repository and worktree reorder
433433+- one unified presentation path for empty and non-empty ordered roots
434434+- explicit failed repository row reorder semantics
435435+436436+Tests:
437437+438438+- expanded repository keeps one outer item with child rows
439439+- failed repositories preserve the chosen reorder semantics
440440+- plain folders produce repository containers with no worktree children
441441+- pinned/main/pending/unpinned sections are preserved
442442+- empty ordered roots and custom ordered roots produce equivalent presentation rules
443443+- repository drop destinations map to expected order
444444+- worktree drop destinations map within pinned/unpinned sections
445445+446446+### Phase 2: New Container Views Behind a Switch
447447+448448+Files likely involved:
449449+450450+- `SidebarListView.swift`
451451+- `RepositorySectionView.swift`
452452+- `WorktreeRowsView.swift`
453453+- new `SidebarContainerListView.swift`
454454+- new `RepositoryContainerRow.swift`
455455+456456+Deliver:
457457+458458+- render the new container sidebar behind a local compile-time or private runtime switch
459459+- no reducer changes except new presentation helpers if needed
460460+- preserve row styling visually before enabling custom drag/drop
461461+- preserve root-level URL drop for files dragged into blank sidebar space
462462+- preserve context menus and drag previews
463463+464464+This phase should be screenshot/manual verified before deleting the old `List` path.
465465+466466+### Phase 3: Explicit Selection, Focus, and Reveal
467467+468468+Deliver:
469469+470470+- click handling for repository and worktree rows
471471+- explicit selection visuals
472472+- multi-selection behavior matching the compatibility matrix
473473+- `sidebarSelections -> setSidebarSelectedWorktreeIDs` synchronization
474474+- focused actions and hotkey row values
475475+- reveal-in-sidebar via new scroll IDs and row availability events
476476+- focused terminal handoff after single worktree selection
477477+478478+Tests:
479479+480480+- pure selection helper tests
481481+- reducer tests for sidebar selected worktree synchronization
482482+- focused action manual checklist for confirm/archive/delete and numbered hotkeys
483483+484484+### Phase 4: Custom Repository Reorder
485485+486486+Deliver:
487487+488488+- repository drag payload
489489+- custom repo-level insertion indicator
490490+- drop handling that dispatches repository reorder through the existing persistence path
491491+- drag-time UI freeze for non-essential row affordances
492492+493493+Manual verification:
494494+495495+- dragging a repository upward shows indicator below the target repository container when appropriate
496496+- dragging a repository downward never shows the indicator between target header and target worktree rows
497497+- failed repository rows follow the documented reorder semantics
498498+499499+### Phase 5: Custom Worktree Reorder
500500+501501+Deliver:
502502+503503+- pinned/unpinned scoped worktree drop zones
504504+- custom worktree insertion indicator
505505+- main/pending rows stay non-movable
506506+- existing persistence paths remain unchanged
507507+508508+Manual verification:
509509+510510+- pinned worktree reorder persists
511511+- unpinned worktree reorder persists
512512+- dragging over main/pending rows does not create invalid moves
513513+514514+### Phase 6: Remove Old `List` Path and Polish
515515+516516+Deliver:
517517+518518+- delete old `List(selection:)` implementation
519519+- remove obsolete `RepositorySectionView` / `WorktreeRowsView` pieces or fold them into new components
520520+- final accessibility pass
521521+- final animation pass for bulk expand/collapse
522522+- update issue #249 with final implementation notes
523523+524524+## Verification Matrix
525525+526526+Automated:
527527+528528+- `SidebarPresentationTests`
529529+- reducer tests for sidebar drag gate and notification reorder concurrency
530530+- reducer tests for expanded/collapsed state write-back and invalid collapsed ID cleanup
531531+- reducer tests for sidebar selected worktree synchronization
532532+- existing `RepositoriesFeatureTests` ordering tests
533533+- existing `RepositorySectionViewTests` migrated or renamed
534534+- `make check`
535535+- `make build-app`
536536+537537+Manual:
538538+539539+1. Select a plain folder repository row.
540540+2. Click a git repository header and confirm it expands/collapses without selecting a worktree.
541541+3. Select a git repository worktree row and confirm terminal focus.
542542+4. Cmd-click multiple worktree rows and confirm bulk archive/delete commands still target selected rows.
543543+5. Verify confirm/archive/delete menu commands target the same worktrees as before.
544544+6. Verify numbered worktree hotkeys use visible sidebar rows.
545545+7. Expand/collapse one repository.
546546+8. Bulk expand/collapse at least 10 repositories.
547547+9. Drag repository upward and downward across expanded repositories.
548548+10. Drag pinned worktrees within a repository.
549549+11. Drag unpinned worktrees within a repository.
550550+12. Trigger reveal-in-sidebar from Canvas or command.
551551+13. Verify Canvas / Shelf / Archived interactions remain correct.
552552+14. Verify notification/task/run-script indicators update without moving rows during a drag.
553553+15. Drop a repository URL onto a visible row and onto blank sidebar space.
554554+16. Verify repository and worktree context menus.
555555+17. Verify drag previews.
556556+18. Verify worktree rows do not gain type-select behavior in V1.
557557+558558+## Risks
559559+560560+### Native `List` behavior loss
561561+562562+Risk: custom scroll rows may lose some free AppKit sidebar behavior.
563563+564564+Mitigation:
565565+566566+- preserve command-based navigation first
567567+- add explicit accessibility labels/traits
568568+- keep manual keyboard/accessibility checklist
569569+570570+### Reorder implementation complexity
571571+572572+Risk: custom drag/drop can become more complex than native `.onMove`.
573573+574574+Mitigation:
575575+576576+- keep pure drop-index mapping tested
577577+- keep repository reorder and worktree reorder separate
578578+- defer cross-repository worktree moves
579579+580580+### UI regressions from broad rewrite
581581+582582+Risk: replacing the sidebar in one PR touches selection, animation, and drag.
583583+584584+Mitigation:
585585+586586+- stage behind a private switch until visual behavior is verified
587587+- land M1 and presentation model tests first
588588+- keep reducer actions and persistence shape stable
589589+590590+### Performance regressions
591591+592592+Risk: replacing lazy `List` with `VStack` could render too much.
593593+594594+Mitigation:
595595+596596+- start with `LazyVStack`
597597+- switch only repository containers to non-lazy child stacks if expand/collapse animation needs it
598598+- decide using the Phase 0 metrics rather than visual impression alone
599599+600600+## Recommendation
601601+602602+Proceed with Option B as the #249 plan: a custom `ScrollView` sidebar with repository containers as outer items.
603603+604604+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.
605605+606606+The safest execution path is:
607607+608608+1. baseline metrics and manual guardrails
609609+2. M1 old `List` drag gate and reducer concurrency tests
610610+3. pure presentation model and tests
611611+4. render-only new sidebar path
612612+5. explicit selection, focus, and reveal
613613+6. custom repository reorder
614614+7. custom worktree reorder
615615+8. remove old `List` path
616616+617617+This is larger than a tactical #222 fix, but it addresses the underlying sidebar design mismatch and gives future sidebar features a cleaner foundation.