···11+# Shelf View
22+33+Last updated: 2026-04-21
44+Status: Implemented (see **Implementation Decisions Journal** at the bottom for deviations taken during implementation)
55+66+A new terminal presentation mode that sits alongside Canvas. Where Canvas spreads
77+worktrees out as flat cards and weakens the worktree concept, Shelf preserves and
88+strengthens it: each worktree (or plain folder) becomes a "book" with a vertical
99+spine that doubles as its tab bar. Exactly one book is "open" at any time,
1010+occupying the space between a left stack of already-passed spines and a right
1111+stack of upcoming spines.
1212+1313+---
1414+1515+## Mode & Entry Point
1616+1717+- Shelf is a terminal-region presentation mode, **mutually exclusive** with
1818+ Canvas. The left navigation remains visible in Shelf mode (Shelf only occupies
1919+ the terminal region to the right of the navigation).
2020+- The Shelf toggle lives next to the Canvas toggle in the same toolbar `HStack`,
2121+ placed immediately to the **right of** (i.e. after) the Canvas entry.
2222+- Toggle hotkey: **`Cmd+Shift+Enter`** — symmetric with `Toggle Canvas`'s
2323+ `Cmd+Option+Enter`.
2424+- **Exit Shelf**: only by re-clicking the Shelf toggle (or pressing the toggle
2525+ hotkey). Clicking a different worktree in the left navigation does **not**
2626+ exit Shelf — it merely changes which book is open. (This differs from Canvas,
2727+ where left-nav clicks exit the mode, because Canvas weakens the worktree
2828+ concept while Shelf treats `book = worktree` 1:1.)
2929+3030+---
3131+3232+## Concept Mapping
3333+3434+| Shelf concept | Prowl model |
3535+|---|---|
3636+| Book | A worktree or a plain folder |
3737+| Spine | The book's vertical tab bar; also carries its identity (worktree/folder name + branch) |
3838+| Open book body | The terminal surface (with splits) of the book's currently active tab |
3939+4040+**Order of books on the shelf** equals the order of worktrees / plain folders in
4141+the left navigation. Reordering happens through the left nav, not on the shelf.
4242+4343+---
4444+4545+## Layout Invariant
4646+4747+The terminal region (everything to the right of the left navigation) is split
4848+into three horizontal segments:
4949+5050+```
5151+[ left spine stack ] [ open book terminal area ] [ right spine stack ]
5252+```
5353+5454+Let `N` be the index of the currently open book among all books `1…last`:
5555+5656+- **Left stack** = spines of books `1…N`, in book order. Book `N`'s spine is the
5757+ rightmost in the left stack and sits flush against the left edge of the
5858+ terminal area.
5959+- **Terminal area** = the surface of book `N`'s currently active tab (with the
6060+ existing split logic).
6161+- **Right stack** = spines of books `N+1…last`, in book order, flush against
6262+ the window's right edge.
6363+6464+**Initial state on entering Shelf**: `N` is the worktree currently identified by
6565+`WorktreeTerminalManager.selectedWorktreeID`; the open book's active tab is
6666+that worktree's currently active tab (no separate Shelf-only tab memory).
6767+6868+**Book set = opened worktrees/folders only**: the spines shown on the Shelf
6969+are *not* the full list of worktrees + plain folders in the sidebar. The
7070+Shelf only includes books the user has interacted with at least once in
7171+the current session — i.e., those with an associated terminal state. A
7272+worktree that appears in the left navigation but has never been clicked (or
7373+touched by CLI / layout restore) does *not* get a spine. Clicking an
7474+as-yet-unopened worktree in the left navigation while Shelf is active is
7575+what makes its spine materialize — the normal spine-flow animation applies
7676+as the new spine slides into its sidebar-order position.
7777+7878+---
7979+8080+## Spine Specification
8181+8282+### Geometry
8383+8484+- **Width**: one line of text (compact, fixed across all spines and across
8585+ open/closed states).
8686+- **Identical structure and width whether the book is open or closed**; only
8787+ the area to the spine's right changes (terminal surface vs. nothing).
8888+8989+### Header (top of spine)
9090+9191+- Worktree name + branch name, rendered **rotated 90°** (vertical reading
9292+ direction).
9393+- For **plain folders** (no branch): only the folder name is shown, with the
9494+ branch line entirely omitted (consistent with how plain folders are presented
9595+ in the left navigation today).
9696+- The header is **not** part of the scrollable area (see Tab List Overflow).
9797+9898+### Tab List (below header)
9999+100100+- Each tab is rendered as **its icon only** (Prowl already supports per-tab
101101+ custom icons). No label text in the slot.
102102+- Each slot is a uniform-sized clickable target.
103103+- **Hotkey overlay**: when the user holds **⌘ (Command)**, the icon in each
104104+ slot is **replaced** by the tab's `Cmd+N` digit (1–9). Slot size and position
105105+ do not change — there is zero layout shift. This matches Prowl's existing
106106+ "hold ⌘ to reveal hotkeys" behavior.
107107+- For tabs at index ≥ 10 (no `Cmd+N` hotkey): when ⌘ is held, the slot continues
108108+ to show the icon (optionally slightly dimmed to hint "no hotkey"); details left
109109+ to implementation.
110110+111111+### Tab List Overflow
112112+113113+- When the tab list does not fit the available spine height, the **tab list
114114+ area scrolls vertically**.
115115+- The header (worktree/branch) stays **pinned** and does not scroll.
116116+- The bottom controls (see below) also stay pinned and do not scroll.
117117+118118+### Bottom Controls
119119+120120+- A row of three buttons at the spine's bottom: **`+` / vertical split /
121121+ horizontal split**, mirroring Prowl's standard tab bar.
122122+- These controls are **only shown on the spine of the currently open book**.
123123+ Closed-book spines do not show them (acting on a non-open book first requires
124124+ opening it).
125125+126126+### Per-Tab Visual States (must all be respected, simultaneously when applicable)
127127+128128+- **Active tab highlight** — the book's currently selected tab.
129129+- **Notification highlight** — drives off the existing
130130+ `WorktreeTerminalState.hasUnseenNotification(for:)`. Visual: **slot
131131+ background tint**, using the same color/style as Canvas title-bar
132132+ notification highlights (reuse the existing token / style for consistency).
133133+134134+### Book-Level Aggregated Notification
135135+136136+- When **any** tab in a book has an unread notification, a **small dot badge**
137137+ is shown on the spine **header** (next to the worktree/branch text).
138138+- Purpose: when a notifying tab is scrolled out of view in the spine's tab
139139+ list, the user can still see at a glance "this book has activity".
140140+- No directional arrow / no "scroll up to see" hint — keep it minimal.
141141+142142+---
143143+144144+## Open Book Visual Distinction
145145+146146+The open book's spine is visually distinguished from other spines through a
147147+**combination** of:
148148+149149+- An **accent color / contrasting background tint** on the open book's spine,
150150+ and
151151+- **Visual continuity** with the terminal area: the spine and terminal area
152152+ share background color and/or border treatment so the spine reads as "the
153153+ left edge of the open page" — reinforcing the book metaphor.
154154+155155+(Active-tab highlight on the spine's currently-active tab slot is a separate,
156156+**tab-level** signal, independent of the **book-level** open-book signal.
157157+Both can be visible at once.)
158158+159159+---
160160+161161+## Interaction
162162+163163+### Book ↔ Left Navigation Sync (bidirectional)
164164+165165+- **Shelf → Left nav**: clicking a different spine in Shelf updates
166166+ `selectedWorktreeID` (and therefore the left-nav selection).
167167+- **Left nav → Shelf**: while in Shelf mode, clicking a worktree in the left
168168+ navigation triggers the same spine-flow animation as clicking that book's
169169+ spine directly. The Shelf does not exit.
170170+171171+The single source of truth for "which book is open" is `selectedWorktreeID`.
172172+173173+### Switching Books (clicking a non-open book's spine)
174174+175175+Clicking spine `M` (where `M ≠ N`):
176176+177177+- If the click lands on a specific tab slot `T` on spine `M`: animate the
178178+ spine flow (rules below), open book `M`, and set `M`'s active tab to `T`.
179179+- If the click lands on the spine **header** only: animate the spine flow,
180180+ open book `M`, keep `M`'s previously active tab.
181181+182182+**Spine flow rules:**
183183+184184+- **`M > N` (forward)**: spines `N+1…M` slide from the right stack into the
185185+ tail of the left stack. Spines `M+1…last` do not move.
186186+- **`M < N` (backward)**: spines `M+1…N` slide from the left stack back to the
187187+ head of the right stack. Spines `1…M-1` do not move.
188188+189189+In both cases the previously open book's spine ends up wherever the flow
190190+places it (no special case).
191191+192192+### Switching Tabs Within the Open Book
193193+194194+Clicking a tab slot on the **currently open** book's own spine:
195195+196196+- **No spine animation, no page-turn transition.** The spine layout is
197197+ unchanged.
198198+- The terminal area is replaced with the newly selected tab's surface.
199199+200200+### Unified Click Rule
201201+202202+Every tab slot on every spine is a click target meaning "switch to this book
203203+and this tab". Whether the click triggers spine-flow animation depends solely
204204+on whether the targeted book is already the open book.
205205+206206+### Creating Tabs / Splits
207207+208208+- Use the **bottom controls** (`+` / vsplit / hsplit) on the **open book's**
209209+ spine, or the existing keyboard shortcuts.
210210+- To add a tab to a non-open book: open it first by clicking its spine, then
211211+ use the bottom controls.
212212+213213+### Closing Tabs
214214+215215+Mirror Prowl's normal-mode tab close behavior:
216216+217217+- **Hover X**: hovering a tab slot reveals a small X button to close it.
218218+- **Right-click menu**: right-clicking a tab slot opens a tab-level context
219219+ menu containing Close (and any other existing tab actions).
220220+- **`Cmd+W`** keyboard shortcut continues to close the active tab.
221221+222222+### Closing the Last Tab in a Book
223223+224224+Closing the last tab **retires the book from the Shelf**. Its spine disappears;
225225+if the closed book was the one currently open, Shelf auto-advances to the next
226226+remaining book (in Shelf order). The user can bring the book back by clicking
227227+its worktree in the left navigation, which re-opens it and re-adds its spine
228228+with the standard spine-flow animation.
229229+230230+(Earlier drafts of this doc proposed keeping the book on the shelf with an
231231+empty-terminal placeholder. Reversed: a lingering empty book felt unnatural and
232232+doubled as dead weight. See the Implementation Decisions Journal for the switch.)
233233+234234+### Removing a Book from the Shelf
235235+236236+A book is removed from the shelf only by:
237237+238238+1. **Closing/removing the worktree** through the left navigation (existing
239239+ pathway), or
240240+2. **Right-clicking the spine header** → context menu → **"Remove book"**.
241241+242242+Right-click scoping:
243243+244244+- Right-click on a **tab slot** → tab-level context menu (Close, etc.).
245245+- Right-click on the **spine header or its empty body area** → book-level
246246+ context menu (Remove book, etc.).
247247+248248+---
249249+250250+## Animation Specification
251251+252252+### Axis 1 — Spine flow character
253253+254254+- **Snappy**: ~200ms, ease-in-out. Crisp, minimal hang time.
255255+256256+### Axis 2 — Terminal area swap
257257+258258+- Use SwiftUI **`matchedGeometryEffect`** (or the closest equivalent): the
259259+ terminal area is treated as a piece of "openable book content" that
260260+ geometrically transforms together with its spine.
261261+- During transitions, **two terminals may coexist briefly** in the terminal
262262+ region:
263263+ - **Forward (`M > N`, "pulling in")**: book `M`'s terminal slides in from
264264+ the right alongside `M`'s spine. The previously open book `N`'s terminal
265265+ stays in place and **fades out** as `M`'s terminal arrives, so the user
266266+ never sees a half-clipped or partially-replaced surface.
267267+ - **Backward (`M < N`, "pushing out")**: book `N`'s terminal slides out to
268268+ the right alongside `N`'s spine and **fades out** during the slide. Book
269269+ `M`'s terminal materializes at its destination (slide-in or fade-in, as
270270+ looks best in implementation).
271271+- **Unified rule**: the "about to be invisible" terminal handles the fade; the
272272+ "about to be visible" terminal stays opaque (slide-in) or fades in. This
273273+ prevents surface views from popping in / out abruptly and avoids visual
274274+ tears against the moving spines.
275275+276276+---
277277+278278+## Keyboard Shortcuts
279279+280280+All Shelf-related shortcuts are **configurable** through Prowl's existing
281281+keybinding system (`scope = configurableAppAction`), exposed in
282282+`Settings → Shortcuts`.
283283+284284+| Command | Default binding | Notes |
285285+|---|---|---|
286286+| `toggleShelf` | `Cmd+Shift+Enter` | New command. Symmetric with `toggleCanvas` (`Cmd+Option+Enter`). |
287287+| `selectTerminalTab1…9` | `Cmd+1..9` | **Existing** commands. In Shelf, they switch tabs within the open book. |
288288+| `selectPreviousTerminalTab` / `selectNextTerminalTab` | `Cmd+Shift+[` / `Cmd+Shift+]` | **Existing** — apply within the open book. |
289289+| `selectNextWorktree` / `selectPreviousWorktree` | `Cmd+Ctrl+↓` / `Cmd+Ctrl+↑` | Mode-aware: outside Shelf, cycles worktrees (unchanged). Inside Shelf, reroutes to tab navigation within the open book — vertical arrows step through tabs on the spine, horizontal arrows step through books, matching the Shelf's two-axis layout. See `selectNext/PreviousShelfBook` below for the `Cmd+Ctrl+→` / `Cmd+Ctrl+←` bindings. |
290290+| `selectNextShelfBook` / `selectPreviousShelfBook` | `Cmd+Ctrl+→` / `Cmd+Ctrl+←` | **New commands**. Operate on the ordered Shelf-book list (worktrees + plain folders), which can diverge from the worktree list if plain folders are interleaved. See the Implementation Decisions Journal for why we took this over a two-binding alias on the worktree commands. |
291291+| `selectShelfBook1…9` | `Ctrl+Option+1..9` | **New commands**, deliberately distinct from `selectWorktree1..9` (`Ctrl+1..9`). Books and worktrees are not 1:1 in numbering: "books on the shelf" can diverge from "items in the left navigation" (e.g. presence/absence on the shelf, plain-folder ordering). Shelf-specific. |
292292+293293+### Implementation note on multi-binding
294294+295295+The current `KeybindingSchema` / `AppShortcut` / `Binding` model holds a single
296296+`shortcut` per command. The `Cmd+Ctrl+←/→` alias for
297297+`selectNext/PreviousWorktree` requires a non-trivial extension to support a
298298+collection of bindings per command (and to surface that in the settings UI).
299299+If this cost proves prohibitive, the fallback is to introduce wrapper commands
300300+(e.g. `selectNextBookAlias`) that invoke the same underlying action, at the
301301+cost of duplicating rows in the shortcuts settings list.
302302+303303+---
304304+305305+## Mapping to Existing Models
306306+307307+- The ordered list of spines mirrors the ordered list of worktrees + plain
308308+ folders tracked by `WorktreeTerminalManager`.
309309+- Each spine's tab list mirrors that worktree's `TerminalTabManager` tabs.
310310+- The terminal area renders the active tab's `GhosttySurfaceState` (and any
311311+ splits) using the existing surface-rendering path.
312312+- `WorktreeTerminalManager.selectedWorktreeID` ↔ "the open book", driven by
313313+ spine clicks and left-nav clicks alike (single source of truth).
314314+- Per-spine tab-slot notification highlights consume
315315+ `WorktreeTerminalState.hasUnseenNotification(for:)`.
316316+- Per-book aggregated header dot consumes
317317+ `WorktreeTerminalState.hasUnseenNotification` (book-wide).
318318+319319+---
320320+321321+## Open Implementation Questions (non-blocking)
322322+323323+- Spine height budget per slot, and the exact dimming treatment for tabs ≥ 10
324324+ when ⌘ is held.
325325+- Exact accent color / continuity treatment for the open book's spine + terminal
326326+ area (decide during visual implementation; iterate if it looks off).
327327+- Whether the spine should auto-scroll to reveal a newly-arriving notification
328328+ (vs. relying solely on the aggregated header dot).
329329+- Multi-binding architectural change (see Keyboard Shortcuts → Implementation
330330+ note) — design before implementation.
331331+- Empty-state visuals for an empty Shelf (no books at all).
332332+- Animation behavior under user interruption (e.g. clicking a third spine while
333333+ a transition is mid-flight).
334334+335335+---
336336+337337+## Implementation Decisions Journal
338338+339339+Decisions made during implementation that deviate from — or add nuance to —
340340+the earlier design, recorded for review.
341341+342342+### Keyboard Shortcuts: wrapper commands over multi-binding
343343+344344+**Design spec** had `Cmd+Ctrl+→` / `Cmd+Ctrl+←` as a second alias on the
345345+existing `selectNext/PreviousWorktree` commands, with a note that this
346346+requires a non-trivial extension to the keybinding schema (singular
347347+`shortcut` → collection).
348348+349349+**Implemented** as distinct `selectNextShelfBook` / `selectPreviousShelfBook`
350350+commands. Reasons:
351351+352352+- The Shelf-book ordering includes plain folders (interleaved per
353353+ `orderedShelfBooks()`), so "next book on the Shelf" is not semantically
354354+ equal to "next worktree" when plain folders exist. Aliasing would have
355355+ skipped plain folders when a user pressed the arrow alias.
356356+- The wrapper-command approach keeps `AppShortcut.Binding.shortcut` singular,
357357+ avoiding the schema change.
358358+- Both commands still live in `Settings → Shortcuts` so users can remap
359359+ either set independently.
360360+361361+### Commands plumbing: merged into `SidebarCommands`
362362+363363+Originally planned as a separate `ShelfCommands: Commands` struct. Moved into
364364+`SidebarCommands` because SwiftUI's `@CommandsBuilder` caps the number of
365365+direct children in a `.commands { }` block; adding a new top-level Commands
366366+struct pushed the builder past the cap and triggered a compile error on
367367+unrelated `CommandGroup`s. Merging keeps the external menu footprint the
368368+same (two visible toggles + one Worktrees menu).
369369+370370+### `isShelfActive` as a separate flag
371371+372372+`RepositoriesFeature.State` gained a new `isShelfActive: Bool` flag instead
373373+of adding a `.shelf` case to `SidebarSelection`. Reason: Shelf is a
374374+presentation mode that still needs `selection` to track a worktree or plain
375375+folder (the open book). Using a dedicated flag decouples "is Shelf active"
376376+from "which book is open", which lets the bidirectional sync with the left
377377+navigation fall out for free.
378378+379379+### Auto-exit rules
380380+381381+Entering Canvas or Archived Worktrees from any entry point clears
382382+`isShelfActive` — those two presentation modes are mutually exclusive with
383383+Shelf by design. Entering Shelf from Canvas / archived redirects selection
384384+to a compatible worktree / plain-folder before flipping the flag.
385385+386386+### Terminal rendering in the open area
387387+388388+Rather than reusing `WorktreeTerminalTabsView` (which includes the horizontal
389389+tab bar), we introduced `ShelfOpenBookView` — a leaner view that renders only
390390+the terminal content stack + icon picker sheet + window focus observer. In
391391+Shelf, the tab bar lives on the spine, so duplicating it would violate the
392392+design.
393393+394394+### Plain folder spines
395395+396396+`ShelfBook` uses `Worktree.ID` as its identity. For plain folders this is
397397+the repository ID, matching the synthetic worktree emitted by
398398+`RepositoriesFeature.State.selectedTerminalWorktree`. That way
399399+`openShelfBookID == selectedTerminalWorktree?.id` for both kinds without
400400+special-casing.
401401+402402+### Animation: `.animation(value:)` for both entry points
403403+404404+To make left-nav-originated book switches animate identically to
405405+Shelf-originated taps, the root `HStack` carries an explicit
406406+`.animation(.easeInOut(duration: 0.2), value: openBookID)` modifier.
407407+Shelf-originated taps additionally pass the same animation to
408408+`store.send(_, animation:)` so the TCA-side mutation carries the transaction
409409+along.
410410+411411+### Close-last-tab behavior (revised)
412412+413413+Reversed from the original decision. Closing the last tab now removes the
414414+book from the Shelf entirely. The implementation:
415415+416416+- `TerminalClient.Event.tabClosed` gained a `remainingTabs: Int` payload so
417417+ AppFeature can detect the last-tab case. When it sees `remainingTabs == 0`,
418418+ it dispatches `.repositories(.markWorktreeClosed(id))`.
419419+- The `markWorktreeClosed` reducer handler removes the ID from
420420+ `openedWorktreeIDs`, and — only when Shelf is active and the closed
421421+ worktree was the open book — auto-advances selection to the next
422422+ remaining book (via `shelfBookSelectionEffect`). In normal view, the
423423+ selection is left alone so the user's current context isn't disturbed.
424424+- When the closed book was the last book on the Shelf, selection is kept
425425+ as-is; `ShelfView` falls through to its "No book selected" empty state.
426426+427427+### Opened-worktrees set
428428+429429+`RepositoriesFeature.State.openedWorktreeIDs: Set<Worktree.ID>` tracks
430430+which worktrees/plain folders are currently part of the Shelf's book list.
431431+It's updated by the reducer in four places:
432432+433433+- `.selectWorktree(id, _)` handler inserts `id` (covers sidebar click,
434434+ Shelf click, most CLI opens, layout restore of the *active* worktree)
435435+- `.selectRepository(id)` handler inserts `id` when the repository is a
436436+ plain folder
437437+- `.toggleShelf` entry path inserts the currently selected ID when the
438438+ selection is already compatible with Shelf (rare path, but guards
439439+ against state set without going through the two actions above)
440440+- `.markWorktreeOpened(id)` — a dedicated action that AppFeature
441441+ dispatches in response to `.terminalEvent(.tabCreated(worktreeID:))`.
442442+ This is the *critical* catch-all for every path that sets
443443+ `state.selection` directly without going through `.selectWorktree` —
444444+ in particular, the cold-launch auto-selection that restores the last
445445+ focused worktree (`state.selection = state.lastFocusedWorktreeID…` in
446446+ `applyRepositories`) and the "first available after reload" fallback.
447447+ Layout restore is a third such path. The common downstream signal in
448448+ all of them is that the newly-focused worktree ends up materializing
449449+ its first tab, emitting `.tabCreated`, which this forwarder converts
450450+ into an `openedWorktreeIDs` insertion.
451451+452452+`orderedShelfBooks()` filters against this set. The set is pure
453453+additive in this iteration — archived / removed worktrees still drop off
454454+the Shelf because the book iteration is anchored on the live
455455+`repositories` array, not on `openedWorktreeIDs`. That leaves a handful
456456+of stale IDs in the set but no visible spines; pruning can be layered in
457457+later if the set grows unbounded.
···1009100910101010 case .terminalEvent(.layoutRestored(let selectedWorktreeID)):
10111011 appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")")
10121012+ // Once layout is restored the saved tabs have all been re-created
10131013+ // (each emits `tabCreated` → `markWorktreeOpened`) and a valid
10141014+ // active worktree is in hand — the right moment to honor the
10151015+ // "Default View = Shelf" preference for Layout-Restore launches,
10161016+ // which the `repositorySnapshotLoaded` hook intentionally
10171017+ // deferred to avoid a selection flash.
10181018+ @Shared(.settingsFile) var settingsFile
10191019+ let shouldEnterShelf =
10201020+ settingsFile.global.defaultViewMode == .shelf
10211021+ && !state.repositories.isShelfActive
10221022+ var effects: [Effect<Action>] = []
10121023 if let selectedWorktreeID {
10131024 // Plain folders use .repository selection, not .worktree
10141025 if let repo = state.repositories.repositories[id: selectedWorktreeID],
10151026 repo.kind == .plain
10161027 {
10171017- return .send(.repositories(.selectRepository(selectedWorktreeID)))
10281028+ effects.append(.send(.repositories(.selectRepository(selectedWorktreeID))))
10291029+ } else {
10301030+ effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID))))
10181031 }
10191019- return .send(.repositories(.selectWorktree(selectedWorktreeID)))
10201032 }
10211021- return .none
10331033+ if shouldEnterShelf {
10341034+ effects.append(.send(.repositories(.toggleShelf)))
10351035+ }
10361036+ return effects.isEmpty ? .none : .merge(effects)
1022103710231038 case .terminalEvent(.layoutRestoreFailed(let message)):
10241039 appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)")
10251040 return .send(.repositories(.showToast(.warning(message))))
10411041+10421042+ case .terminalEvent(.tabCreated(let worktreeID)):
10431043+ // Every tab creation (user +, CLI open, layout restore, …)
10441044+ // marks its worktree as Shelf-visible. Layout restore in
10451045+ // particular only calls `selectWorktree` for the one active
10461046+ // worktree; other restored worktrees only surface here.
10471047+ return .send(.repositories(.markWorktreeOpened(worktreeID)))
10481048+10491049+ case .terminalEvent(.tabClosed(let worktreeID, let remainingTabs)):
10501050+ // Closing the last tab retires the book from the Shelf. Other
10511051+ // closes are routine and need no Reducer-side bookkeeping.
10521052+ guard remainingTabs == 0 else { return .none }
10531053+ return .send(.repositories(.markWorktreeClosed(worktreeID)))
1026105410271055 case .terminalEvent:
10281056 return .none
···206206 var lastFocusedWorktreeID: Worktree.ID?
207207 var preCanvasWorktreeID: Worktree.ID?
208208 var preCanvasTerminalTargetID: Worktree.ID?
209209+ var isShelfActive: Bool = false
210210+ /// IDs of worktrees (and plain-folder repositories) that have been
211211+ /// "opened" at least once in this session — i.e., had their
212212+ /// terminal state created by a user selection or CLI activation.
213213+ /// The Shelf's book list is derived from this set so a sidebar
214214+ /// worktree that's never been touched does not appear as a spine.
215215+ var openedWorktreeIDs: Set<Worktree.ID> = []
209216 var launchRestoreMode: LaunchRestoreMode = .lastFocusedWorktree
210217 var shouldRestoreLastFocusedWorktree = false
211218 var shouldSelectFirstAfterReload = false
···272279 case selectArchivedWorktrees
273280 case selectCanvas
274281 case toggleCanvas
282282+ case toggleShelf
283283+ case selectNextShelfBook
284284+ case selectPreviousShelfBook
285285+ case selectShelfBook(Int)
286286+ case markWorktreeOpened(Worktree.ID)
287287+ case markWorktreeClosed(Worktree.ID)
275288 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>)
276289 case selectRepository(Repository.ID?)
277290 case selectWorktree(Worktree.ID?, focusTerminal: Bool = false)
···423436 if selectionChanged {
424437 allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree))))
425438 }
439439+ // Apply "Default View = Shelf" preference once the initial
440440+ // repository snapshot has landed — reuses `.toggleShelf`'s
441441+ // guards (needs ≥1 book, falls back to `lastFocusedWorktreeID`
442442+ // when no selection is set yet). `repositorySnapshotLoaded`
443443+ // is only sent from `.task` at launch, so this won't re-enter
444444+ // Shelf if the user has already exited to Normal in the same
445445+ // session. When Layout Restore is about to run, defer to the
446446+ // `.layoutRestored` event in AppFeature — otherwise Layout
447447+ // Restore clears the selection we just set, and any books not
448448+ // in the saved layout would linger as stray spines.
449449+ @Shared(.settingsFile) var settingsFile
450450+ if settingsFile.global.defaultViewMode == .shelf,
451451+ state.launchRestoreMode != .restoreLayout,
452452+ !state.isShelfActive
453453+ {
454454+ allEffects.append(.send(.toggleShelf))
455455+ }
426456 return .merge(allEffects)
427457428458 case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs):
···611641 return .none
612642613643 case .selectArchivedWorktrees:
644644+ state.isShelfActive = false
614645 state.selection = .archivedWorktrees
615646 state.sidebarSelectedWorktreeIDs = []
616647 return .send(.delegate(.selectedWorktreeChanged(nil)))
···619650 // Remember the current worktree so toggleCanvas can restore it.
620651 state.preCanvasWorktreeID = state.selectedWorktreeID
621652 state.preCanvasTerminalTargetID = state.selectedTerminalWorktree?.id
653653+ state.isShelfActive = false
622654 state.selection = .canvas
623655 state.sidebarSelectedWorktreeIDs = []
624656 return .run { _ in
···650682 return .send(.selectCanvas)
651683 }
652684685685+ case .selectNextShelfBook:
686686+ guard let book = shelfBook(atOffset: 1, state: state) else { return .none }
687687+ return shelfBookSelectionEffect(for: book)
688688+689689+ case .selectPreviousShelfBook:
690690+ guard let book = shelfBook(atOffset: -1, state: state) else { return .none }
691691+ return shelfBookSelectionEffect(for: book)
692692+693693+ case .selectShelfBook(let index):
694694+ let books = state.orderedShelfBooks()
695695+ let zeroBased = index - 1
696696+ guard books.indices.contains(zeroBased) else { return .none }
697697+ return shelfBookSelectionEffect(for: books[zeroBased])
698698+699699+ case .markWorktreeOpened(let worktreeID):
700700+ state.openedWorktreeIDs.insert(worktreeID)
701701+ return .none
702702+703703+ case .markWorktreeClosed(let worktreeID):
704704+ // Closing the last tab of a book retires the book from the
705705+ // Shelf. If this book was the one currently open on the
706706+ // Shelf, move focus to the neighboring book — the one after
707707+ // the closed book if there is one, otherwise the one before
708708+ // — so the user lands close to where they were instead of
709709+ // always snapping back to the first spine.
710710+ let replacement = replacementBookAfterClosing(
711711+ worktreeID: worktreeID,
712712+ state: state
713713+ )
714714+ state.openedWorktreeIDs.remove(worktreeID)
715715+ if let replacement {
716716+ return shelfBookSelectionEffect(for: replacement)
717717+ }
718718+ return .none
719719+720720+ case .toggleShelf:
721721+ if state.isShelfActive {
722722+ state.isShelfActive = false
723723+ return .none
724724+ }
725725+ // Entering Shelf requires at least one book to render.
726726+ guard !state.orderedWorktreeRows().isEmpty else { return .none }
727727+ // Shelf is mutually exclusive with Canvas / archived views: when entering
728728+ // Shelf we need a worktree- or repository-scoped selection.
729729+ let needsRedirect: Bool
730730+ switch state.selection {
731731+ case .some(.worktree), .some(.repository):
732732+ needsRedirect = false
733733+ case .some(.canvas), .some(.archivedWorktrees), .none:
734734+ needsRedirect = true
735735+ }
736736+ state.isShelfActive = true
737737+ if !needsRedirect {
738738+ // The current selection is the open book — make sure it's
739739+ // registered as opened so the Shelf renders at least this
740740+ // spine. Guards the case where `selection` was set without
741741+ // going through `.selectWorktree` / `.selectRepository`.
742742+ //
743743+ // Also request terminal focus for this worktree so that
744744+ // `ShelfOpenBookView.onAppear` forces focus onto the
745745+ // surface (`forceAutoFocus: shouldFocusTerminal(for:)`).
746746+ // Without this, entering Shelf via keyboard shortcut
747747+ // leaves the first responder on the (now-dismissed) menu
748748+ // path, and `applySurfaceActivity`'s "only refocus if the
749749+ // current responder is a GhosttySurfaceView" guard skips
750750+ // the surface — user can't type until a second
751751+ // interaction (tab switch, etc.) forces focus through.
752752+ switch state.selection {
753753+ case .some(.worktree(let id)):
754754+ state.openedWorktreeIDs.insert(id)
755755+ state.pendingTerminalFocusWorktreeIDs.insert(id)
756756+ case .some(.repository(let id))
757757+ where state.repositories[id: id]?.kind == .plain:
758758+ state.openedWorktreeIDs.insert(id)
759759+ state.pendingTerminalFocusWorktreeIDs.insert(id)
760760+ default:
761761+ break
762762+ }
763763+ return .none
764764+ }
765765+ // Same fallback chain as `toggleCanvas`'s exit path: prefer
766766+ // the card the user was actively focused on in Canvas so a
767767+ // Canvas → Shelf switch opens *that* card as the active book,
768768+ // not whatever was selected before Canvas was entered.
769769+ let targetID =
770770+ terminalClient.canvasFocusedWorktreeID()
771771+ ?? state.preCanvasTerminalTargetID
772772+ ?? state.preCanvasWorktreeID
773773+ ?? state.lastFocusedWorktreeID
774774+ ?? state.orderedWorktreeRows().first?.id
775775+ guard let targetID else { return .none }
776776+ if state.worktree(for: targetID) == nil,
777777+ let repository = state.repositories[id: targetID],
778778+ repository.kind == .plain
779779+ {
780780+ state.pendingTerminalFocusWorktreeIDs.insert(targetID)
781781+ return .send(.selectRepository(targetID))
782782+ }
783783+ return .send(.selectWorktree(targetID, focusTerminal: true))
784784+653785 case .setSidebarSelectedWorktreeIDs(let worktreeIDs):
654786 let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id))
655787 var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs)
···663795 guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none }
664796 state.selection = .repository(repositoryID)
665797 state.sidebarSelectedWorktreeIDs = []
798798+ if state.repositories[id: repositoryID]?.kind == .plain {
799799+ // Plain folder selection opens the folder as a Shelf book.
800800+ state.openedWorktreeIDs.insert(repositoryID)
801801+ }
666802 return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree)))
667803668804 case .selectWorktree(let worktreeID, let focusTerminal):
669805 setSingleWorktreeSelection(worktreeID, state: &state)
670806 if focusTerminal, let worktreeID {
671807 state.pendingTerminalFocusWorktreeIDs.insert(worktreeID)
808808+ }
809809+ if let worktreeID {
810810+ state.openedWorktreeIDs.insert(worktreeID)
672811 }
673812 let selectedWorktree = state.worktree(for: worktreeID)
674813 return .send(.delegate(.selectedWorktreeChanged(selectedWorktree)))
675814676815 case .selectNextWorktree:
816816+ // In Shelf, the vertical arrow pair maps to tab navigation
817817+ // within the open book — horizontal (← / →) is already book
818818+ // navigation, so the two axes match the Shelf layout.
819819+ if state.isShelfActive, let worktree = state.selectedTerminalWorktree {
820820+ return .run { _ in
821821+ await terminalClient.send(.performBindingAction(worktree, action: "next_tab"))
822822+ }
823823+ }
677824 guard let id = state.worktreeID(byOffset: 1) else { return .none }
678825 return .send(.selectWorktree(id))
679826680827 case .selectPreviousWorktree:
828828+ if state.isShelfActive, let worktree = state.selectedTerminalWorktree {
829829+ return .run { _ in
830830+ await terminalClient.send(.performBindingAction(worktree, action: "previous_tab"))
831831+ }
832832+ }
681833 guard let id = state.worktreeID(byOffset: -1) else { return .none }
682834 return .send(.selectWorktree(id))
683835···1385153713861538 var isShowingCanvas: Bool {
13871539 selection == .canvas
15401540+ }
15411541+15421542+ var isShowingShelf: Bool {
15431543+ isShelfActive
13881544 }
1389154513901546 var archivedWorktreeIDSet: Set<Worktree.ID> {
···20852241 state: RepositoriesFeature.State
20862242) -> Bool {
20872243 state.selectedRow(for: id) != nil
22442244+}
22452245+22462246+/// Choose the next book to open after `worktreeID`'s book is retired.
22472247+/// Prefer the book immediately *after* the closed one in Shelf order;
22482248+/// fall back to the one immediately *before* it; return `nil` when
22492249+/// Shelf is inactive, when the closed book isn't the currently open
22502250+/// one, or when no other books remain.
22512251+func replacementBookAfterClosing(
22522252+ worktreeID: Worktree.ID,
22532253+ state: RepositoriesFeature.State
22542254+) -> ShelfBook? {
22552255+ guard state.isShelfActive,
22562256+ state.selectedTerminalWorktree?.id == worktreeID
22572257+ else { return nil }
22582258+ let books = state.orderedShelfBooks()
22592259+ guard let index = books.firstIndex(where: { $0.id == worktreeID }) else {
22602260+ return nil
22612261+ }
22622262+ let remaining = books.enumerated().filter { $0.offset != index }.map(\.element)
22632263+ guard !remaining.isEmpty else { return nil }
22642264+ // After removing index `index`, the "next" book is now at position
22652265+ // `index` in the reduced list (if it exists); otherwise the last one
22662266+ // is the "previous" relative to what was closed.
22672267+ if index < remaining.count {
22682268+ return remaining[index]
22692269+ }
22702270+ return remaining.last
22712271+}
22722272+22732273+/// Returns the Shelf book at `offset` positions from the currently open
22742274+/// book (wrapping around the book list). Returns nil if there are no
22752275+/// books. When there is no open book, offset > 0 picks the first book
22762276+/// and offset < 0 picks the last.
22772277+func shelfBook(
22782278+ atOffset offset: Int,
22792279+ state: RepositoriesFeature.State
22802280+) -> ShelfBook? {
22812281+ let books = state.orderedShelfBooks()
22822282+ guard !books.isEmpty else { return nil }
22832283+ if let currentID = state.openShelfBookID,
22842284+ let currentIndex = books.firstIndex(where: { $0.id == currentID })
22852285+ {
22862286+ let nextIndex = (currentIndex + offset + books.count) % books.count
22872287+ return books[nextIndex]
22882288+ }
22892289+ return offset > 0 ? books.first : books.last
22902290+}
22912291+22922292+/// Dispatches the right selection action for a book — a worktree vs.
22932293+/// a plain folder requires different Reducer actions even though the
22942294+/// Shelf treats them uniformly.
22952295+func shelfBookSelectionEffect(
22962296+ for book: ShelfBook
22972297+) -> Effect<RepositoriesFeature.Action> {
22982298+ switch book.kind {
22992299+ case .worktree:
23002300+ return .send(.selectWorktree(book.id, focusTerminal: true))
23012301+ case .plainFolder:
23022302+ return .send(.selectRepository(book.repositoryID))
23032303+ }
20882304}
2089230520902306private func isSidebarSelectionValid(
···11+/// Which presentation the app enters on launch. `normal` keeps the
22+/// historical behavior (sidebar + terminal detail); `shelf` boots
33+/// straight into Shelf so power users who live in Shelf don't have to
44+/// toggle it every time they open Prowl.
55+enum DefaultViewMode: String, CaseIterable, Identifiable, Codable, Sendable {
66+ case normal
77+ case shelf
88+99+ var id: String { rawValue }
1010+1111+ var title: String {
1212+ switch self {
1313+ case .normal:
1414+ return "Normal View"
1515+ case .shelf:
1616+ return "Shelf View"
1717+ }
1818+ }
1919+}
···11+import Foundation
22+33+/// A book on the Shelf — the unified abstraction over a Git worktree or
44+/// a plain folder repository.
55+///
66+/// For worktrees the `id` is the underlying `Worktree.ID`. For plain
77+/// folders the `id` is the owning `Repository.ID`; plain folders are
88+/// represented in the terminal system as synthetic worktrees sharing the
99+/// repository's ID, so using the repository ID here keeps it consistent
1010+/// with `selectedTerminalWorktree?.id`.
1111+struct ShelfBook: Identifiable, Equatable, Hashable, Sendable {
1212+ enum Kind: Equatable, Hashable, Sendable {
1313+ case worktree
1414+ case plainFolder
1515+ }
1616+1717+ let id: Worktree.ID
1818+ let repositoryID: Repository.ID
1919+ let displayName: String
2020+ /// Project/repository name shown as the primary part of the spine
2121+ /// header. For plain folders this equals the folder name.
2222+ let projectName: String
2323+ let branchName: String?
2424+ let kind: Kind
2525+2626+ var isPlainFolder: Bool { kind == .plainFolder }
2727+}
2828+2929+extension RepositoriesFeature.State {
3030+ /// Books rendered on the Shelf, in the same order the left navigation
3131+ /// presents them (by repository, then by worktree rows within the
3232+ /// repository). Plain folder repositories contribute a single book.
3333+ ///
3434+ /// The list is filtered to only books whose IDs are in
3535+ /// `openedWorktreeIDs` — a worktree (or plain folder) appears on the
3636+ /// Shelf only after the user has interacted with it at least once.
3737+ /// Clicking a previously-unopened worktree in the left navigation
3838+ /// while in Shelf mode adds its ID here, which causes its spine to
3939+ /// materialize (with the standard spine-flow animation).
4040+ func orderedShelfBooks() -> [ShelfBook] {
4141+ let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) })
4242+ var books: [ShelfBook] = []
4343+ for repositoryID in orderedRepositoryIDs() {
4444+ guard let repository = repositoriesByID[repositoryID] else { continue }
4545+ if repository.kind == .plain {
4646+ guard openedWorktreeIDs.contains(repository.id) else { continue }
4747+ books.append(
4848+ ShelfBook(
4949+ id: repository.id,
5050+ repositoryID: repository.id,
5151+ displayName: repository.name,
5252+ projectName: repository.name,
5353+ branchName: nil,
5454+ kind: .plainFolder
5555+ ))
5656+ continue
5757+ }
5858+ for row in worktreeRows(in: repository) {
5959+ guard openedWorktreeIDs.contains(row.id) else { continue }
6060+ books.append(
6161+ ShelfBook(
6262+ id: row.id,
6363+ repositoryID: repositoryID,
6464+ displayName: row.name,
6565+ projectName: repository.name,
6666+ branchName: row.name,
6767+ kind: .worktree
6868+ ))
6969+ }
7070+ }
7171+ return books
7272+ }
7373+7474+ /// Identifier of the book currently open on the Shelf, derived from the
7575+ /// active selection. Equal to `selectedTerminalWorktree?.id`, but kept as
7676+ /// its own property so call sites read as shelf-aware.
7777+ var openShelfBookID: Worktree.ID? {
7878+ selectedTerminalWorktree?.id
7979+ }
8080+}
···11+import SwiftUI
22+33+/// Vertical spine rendering for a single book on the Shelf.
44+///
55+/// Phase 3 scope: header with book-level notification dot, a vertical
66+/// scrollable tab list (icon-only slots), tap targets for header (opens
77+/// the book with its current tab) and per-tab slot (opens the book with
88+/// that tab). Animations, ⌘-held digit overlay, and bottom controls are
99+/// layered in subsequent phases.
1010+struct ShelfSpineView: View {
1111+ let book: ShelfBook
1212+ let isOpen: Bool
1313+ /// Absolute distance along the ordered book list between this spine and
1414+ /// the currently open book (0 = this *is* the open book, 1 = immediate
1515+ /// neighbor, …). Nil when no book is open. Drives the step-wise accent
1616+ /// tint that fades outward from the open book, so proximity reads at a
1717+ /// glance instead of every non-open spine looking identical.
1818+ let distanceFromOpen: Int?
1919+ let terminalState: WorktreeTerminalState?
2020+ let onOpenBook: () -> Void
2121+ let onSelectTab: (TerminalTabID) -> Void
2222+ /// Bottom controls — provided only for the open book's spine. `nil`
2323+ /// suppresses the trio entirely.
2424+ let onNewTab: (() -> Void)?
2525+ let onSplitVertical: (() -> Void)?
2626+ let onSplitHorizontal: (() -> Void)?
2727+ /// "Remove this book" — drives the book-level context menu entry on
2828+ /// the spine header / empty body. Nil disables the menu.
2929+ let onRemoveBook: (() -> Void)?
3030+3131+ @State private var isHovering = false
3232+3333+ var body: some View {
3434+ VStack(spacing: 0) {
3535+ headerButton
3636+ tabList
3737+ bottomControls
3838+ }
3939+ .frame(width: ShelfMetrics.spineWidth)
4040+ // `maxHeight: .infinity` binds the spine to the parent Shelf's
4141+ // available height (set by `ShelfView.frame(maxHeight: .infinity)`).
4242+ // Without this, a long tab list would let the spine VStack grow to
4343+ // its intrinsic size and push the entire window taller.
4444+ .frame(maxHeight: .infinity, alignment: .top)
4545+ .background(
4646+ // Single `Rectangle` with a computed fill so the color change
4747+ // interpolates in place as `distanceFromOpen` shifts, rather than
4848+ // swapping one view for another (which the previous `@ViewBuilder`
4949+ // if/else did). Fill is derived from a stepped accent-alpha ladder
5050+ // so the open book glows strongest and neighbors fade outward.
5151+ Rectangle().fill(spineBackgroundColor)
5252+ )
5353+ // Whole-spine tap target. Inner Buttons (header, tab slots, controls)
5454+ // absorb their own clicks; clicks that fall on empty areas (scroll
5555+ // view negative space, gaps between tabs, etc.) bubble here and open
5656+ // the book. Keeps the "books on a shelf" metaphor: grab anywhere on
5757+ // the spine to pull the book out.
5858+ .contentShape(.rect)
5959+ .onTapGesture { onOpenBook() }
6060+ .accessibilityAddTraits(.isButton)
6161+ .contextMenu { bookContextMenu }
6262+ .onHover { isHovering = $0 }
6363+ .animation(.easeOut(duration: 0.12), value: isHovering)
6464+ .overlay(alignment: .trailing) {
6565+ if !isOpen {
6666+ // Explicit 1pt vertical rule. `Divider()` used here before
6767+ // rendered a *horizontal* hairline (no stack context → default
6868+ // horizontal orientation) spanning the spine's full width at
6969+ // its vertical center, lining up across every closed spine and
7070+ // looking like a single white bar cutting through the Shelf.
7171+ Rectangle()
7272+ .fill(Color.secondary.opacity(0.1))
7373+ .frame(width: 1)
7474+ }
7575+ }
7676+ .help(book.displayName)
7777+ }
7878+7979+ /// Step-wise accent-alpha ladder keyed by `distanceFromOpen`. 100%
8080+ /// (selected) → 50% → 30% → 20% → 10% → 5%; beyond the ladder the
8181+ /// multiplier is 0 so the halo is bounded. The sharp drop at distance
8282+ /// 1 keeps the open book clearly dominant rather than blending into
8383+ /// its neighbors. Shared by the spine background and the per-tab
8484+ /// active-highlight fill so they fade in lockstep.
8585+ private var accentProximityMultiplier: Double {
8686+ guard let distance = distanceFromOpen else { return 0 }
8787+ let ladder: [Double] = [1.0, 0.5, 0.3, 0.2, 0.1, 0.05]
8888+ return distance < ladder.count ? ladder[distance] : 0
8989+ }
9090+9191+ /// When no book is open (empty shelf), fall back to the neutral gray
9292+ /// used everywhere else so spines don't become invisible; otherwise
9393+ /// derive from the proximity ladder. Hovering an unselected spine
9494+ /// bumps its tint to 80% of the selected book's intensity — a clear
9595+ /// "this is interactable" affordance that sits just below the open
9696+ /// book and animates in/out smoothly.
9797+ private var spineBackgroundColor: Color {
9898+ guard distanceFromOpen != nil else {
9999+ return Color.primary.opacity(0.06)
100100+ }
101101+ let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier
102102+ return Color.accentColor.opacity(0.20 * multiplier)
103103+ }
104104+105105+ /// Active-tab highlight fades more gently than the spine background —
106106+ /// the tab-selection indicator has to stay legible even on far-away
107107+ /// books so users can still see which tab would open. Uses absolute
108108+ /// alpha stops (0.20 / 0.15 / 0.10) rather than the spine's multiplier
109109+ /// ladder; the two axes are tuned independently for their own roles.
110110+ private var activeTabHighlightAlpha: Double {
111111+ guard let distance = distanceFromOpen else { return 0.2 }
112112+ switch distance {
113113+ case 0: return 0.2
114114+ case 1: return 0.15
115115+ default: return 0.1
116116+ }
117117+ }
118118+119119+ @ViewBuilder
120120+ private var bookContextMenu: some View {
121121+ if let onRemoveBook {
122122+ Button(role: .destructive) {
123123+ onRemoveBook()
124124+ } label: {
125125+ Text("Remove Book")
126126+ }
127127+ }
128128+ }
129129+130130+ @ViewBuilder
131131+ private var bottomControls: some View {
132132+ // `+` is shown on every spine, not just the open one: clicking it on a
133133+ // closed book opens that book and creates a tab in one motion (the
134134+ // caller sequences `selectWorktree` → `newTerminal`). Splits only
135135+ // make sense against a focused surface, so they stay scoped to the
136136+ // open book.
137137+ if onNewTab != nil || onSplitVertical != nil || onSplitHorizontal != nil {
138138+ VStack(spacing: ShelfMetrics.slotSpacing) {
139139+ Divider().opacity(0.3)
140140+ if let onNewTab {
141141+ ShelfSpineControlButton(
142142+ systemImage: "plus",
143143+ label: "New Tab",
144144+ action: onNewTab
145145+ )
146146+ }
147147+ if let onSplitVertical {
148148+ ShelfSpineControlButton(
149149+ systemImage: "square.split.2x1",
150150+ label: "Split Vertically",
151151+ action: onSplitVertical
152152+ )
153153+ }
154154+ if let onSplitHorizontal {
155155+ ShelfSpineControlButton(
156156+ systemImage: "square.split.1x2",
157157+ label: "Split Horizontally",
158158+ action: onSplitHorizontal
159159+ )
160160+ }
161161+ }
162162+ .padding(.horizontal, ShelfMetrics.slotHorizontalPadding)
163163+ .padding(.bottom, ShelfMetrics.slotSpacing)
164164+ }
165165+ }
166166+167167+ @ViewBuilder
168168+ private var headerButton: some View {
169169+ Button(action: onOpenBook) {
170170+ ShelfSpineHeader(
171171+ book: book,
172172+ hasAggregatedNotification: terminalState?.hasUnseenNotification == true
173173+ )
174174+ .frame(maxWidth: .infinity)
175175+ .contentShape(.rect)
176176+ }
177177+ .buttonStyle(.plain)
178178+ .contextMenu { bookContextMenu }
179179+ }
180180+181181+ @ViewBuilder
182182+ private var tabList: some View {
183183+ if let terminalState {
184184+ // Scroll the tab slots when they overflow the spine so the window
185185+ // height stays capped instead of growing unbounded with tab count.
186186+ // `.scrollIndicators(.never)` — stronger than `.hidden`, which
187187+ // still shows scroll bars when the user has "Always show scroll
188188+ // bars" enabled in System Settings. The 34pt-wide spine has no
189189+ // room to donate to a scroll bar, so we always hide it.
190190+ // `.scrollBounceBehavior(.basedOnSize)` keeps short lists static.
191191+ // `.clipped()` eats any overdraw at the scroll-view edges so the
192192+ // spine boundary stays crisp.
193193+ ScrollView(.vertical) {
194194+ tabListContent(state: terminalState)
195195+ }
196196+ .scrollIndicators(.never)
197197+ .scrollBounceBehavior(.basedOnSize)
198198+ .clipped()
199199+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
200200+ }
201201+ }
202202+203203+ @ViewBuilder
204204+ private func tabListContent(state terminalState: WorktreeTerminalState) -> some View {
205205+ VStack(spacing: ShelfMetrics.slotSpacing) {
206206+ ForEach(Array(terminalState.tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
207207+ // 1-based hotkey number that matches Cmd+1..9. Tabs at
208208+ // positions 10+ intentionally have no hotkey: they keep
209209+ // showing their icon even while ⌘ is held.
210210+ let hotkeyIndex = index < 9 ? index + 1 : nil
211211+ ShelfSpineTabSlot(
212212+ tab: tab,
213213+ hotkeyIndex: hotkeyIndex,
214214+ isActive: terminalState.tabManager.selectedTabId == tab.id,
215215+ hasUnseenNotification: terminalState.hasUnseenNotification(for: tab.id),
216216+ activeHighlightAlpha: activeTabHighlightAlpha,
217217+ onTap: { onSelectTab(tab.id) },
218218+ onClose: { terminalState.closeTab(tab.id) }
219219+ )
220220+ .terminalTabContextMenu(
221221+ tabId: tab.id,
222222+ tabs: terminalState.tabManager.tabs,
223223+ actions: TerminalTabContextMenuActions(
224224+ changeTitle: { terminalState.promptChangeTabTitle($0) },
225225+ changeIcon: { terminalState.presentIconPicker(for: $0) },
226226+ closeTab: { terminalState.closeTab($0) },
227227+ closeOthers: { terminalState.closeOtherTabs(keeping: $0) },
228228+ closeToRight: { terminalState.closeTabsToRight(of: $0) },
229229+ closeAll: { terminalState.closeAllTabs() }
230230+ )
231231+ )
232232+ }
233233+ }
234234+ .padding(.horizontal, ShelfMetrics.slotHorizontalPadding)
235235+ .padding(.top, ShelfMetrics.slotSpacing)
236236+ }
237237+238238+}
239239+240240+private struct ShelfSpineHeader: View {
241241+ let book: ShelfBook
242242+ let hasAggregatedNotification: Bool
243243+244244+ var body: some View {
245245+ VStack(spacing: 6) {
246246+ Circle()
247247+ .fill(.orange)
248248+ .frame(width: ShelfMetrics.aggregatedDotSize, height: ShelfMetrics.aggregatedDotSize)
249249+ .opacity(hasAggregatedNotification ? 1 : 0)
250250+ .accessibilityLabel("Unread notifications")
251251+ .accessibilityHidden(!hasAggregatedNotification)
252252+ .padding(.top, 6)
253253+ rotatedTitle
254254+ }
255255+ }
256256+257257+ /// Composed title rendered vertically (top-to-bottom reading direction).
258258+ /// Project name is primary; the `· branch` suffix is secondary so the
259259+ /// user can scan the spine and pick out the repo at a glance even on
260260+ /// repositories with many worktrees.
261261+ @ViewBuilder
262262+ private var rotatedTitle: some View {
263263+ combinedTitle
264264+ .font(.callout)
265265+ .lineLimit(1)
266266+ .truncationMode(.middle)
267267+ .frame(width: ShelfMetrics.headerMaxLength, alignment: .leading)
268268+ .rotationEffect(.degrees(90))
269269+ .frame(width: ShelfMetrics.spineWidth, height: ShelfMetrics.headerMaxLength)
270270+ }
271271+272272+ /// Single composed `Text` (string-interpolation form) so middle-
273273+ /// truncation can operate across project + branch as one string.
274274+ /// `foregroundStyle` on each interpolated piece survives composition
275275+ /// and drives the primary/secondary split.
276276+ private var combinedTitle: Text {
277277+ let project = Text(book.projectName)
278278+ .font(.callout.weight(.semibold))
279279+ .foregroundStyle(.primary)
280280+ guard let branch = book.branchName, !branch.isEmpty else {
281281+ return project
282282+ }
283283+ let branchText = Text(" · \(branch)").foregroundStyle(.secondary)
284284+ return Text("\(project)\(branchText)")
285285+ }
286286+}
287287+288288+private struct ShelfSpineTabSlot: View {
289289+ let tab: TerminalTabItem
290290+ let hotkeyIndex: Int?
291291+ let isActive: Bool
292292+ let hasUnseenNotification: Bool
293293+ /// Absolute alpha for the active-tab accent fill, supplied by the
294294+ /// enclosing spine so it can fade with proximity on its own curve
295295+ /// (which decays more gently than the spine background — selection
296296+ /// indicators must stay legible even on far books). Orange
297297+ /// notification tint is left untouched so unread signals remain
298298+ /// attention-grabbing regardless of distance.
299299+ let activeHighlightAlpha: Double
300300+ let onTap: () -> Void
301301+ let onClose: () -> Void
302302+303303+ @Environment(CommandKeyObserver.self) private var commandKeyObserver
304304+ @State private var isHovering = false
305305+306306+ var body: some View {
307307+ Button(action: onTap) {
308308+ ZStack {
309309+ backgroundFill
310310+ slotContent
311311+ }
312312+ .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize)
313313+ .contentShape(.rect)
314314+ }
315315+ .buttonStyle(.plain)
316316+ .overlay(alignment: .topTrailing) {
317317+ if isHovering && !commandKeyObserver.isPressed {
318318+ Button(action: onClose) {
319319+ Image(systemName: "xmark.circle.fill")
320320+ .imageScale(.small)
321321+ .foregroundStyle(.primary)
322322+ .background(Circle().fill(.background))
323323+ .accessibilityLabel("Close Tab")
324324+ }
325325+ .buttonStyle(.plain)
326326+ .offset(x: 3, y: -3)
327327+ .help("Close Tab")
328328+ }
329329+ }
330330+ .onHover { hovering in
331331+ isHovering = hovering
332332+ }
333333+ .help(tab.title)
334334+ }
335335+336336+ /// When ⌘ is held AND this tab has a `Cmd+N` hotkey, swap the icon
337337+ /// for a compact `⌘N` glyph in-place. Slot frame stays the same either
338338+ /// way so nothing reflows.
339339+ @ViewBuilder
340340+ private var slotContent: some View {
341341+ let showsHotkey = commandKeyObserver.isPressed && hotkeyIndex != nil
342342+ if let hotkeyIndex, showsHotkey {
343343+ HStack(spacing: 1) {
344344+ Image(systemName: "command")
345345+ .font(.system(size: 8, weight: .semibold))
346346+ .foregroundStyle(foregroundTint)
347347+ Text("\(hotkeyIndex)")
348348+ .font(.callout.weight(.semibold).monospacedDigit())
349349+ .foregroundStyle(foregroundTint)
350350+ }
351351+ .accessibilityHidden(true)
352352+ } else {
353353+ Image(systemName: tab.icon ?? ShelfMetrics.defaultTabIcon)
354354+ .imageScale(.medium)
355355+ .foregroundStyle(foregroundTint)
356356+ // Dim tabs without a hotkey when ⌘ is held, so the "this slot
357357+ // can't be jumped to via Cmd+N" affordance is legible without
358358+ // shifting any layout.
359359+ .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil ? 0.45 : 1)
360360+ .accessibilityHidden(true)
361361+ }
362362+ }
363363+364364+ @ViewBuilder
365365+ private var backgroundFill: some View {
366366+ if hasUnseenNotification {
367367+ // Same tint as Canvas title-bar notification highlight so Shelf's
368368+ // per-tab unread indicator reads as "this tab" rather than a new
369369+ // idiom. Wins over the active-tab highlight when both apply.
370370+ RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous)
371371+ .fill(Color.orange.opacity(0.3))
372372+ } else if isActive {
373373+ RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous)
374374+ .fill(Color.accentColor.opacity(activeHighlightAlpha))
375375+ } else {
376376+ Color.clear
377377+ }
378378+ }
379379+380380+ private var foregroundTint: Color {
381381+ if hasUnseenNotification { return .primary }
382382+ if isActive { return .primary }
383383+ return .secondary
384384+ }
385385+}
386386+387387+private struct ShelfSpineControlButton: View {
388388+ let systemImage: String
389389+ let label: String
390390+ let action: () -> Void
391391+392392+ var body: some View {
393393+ Button(action: action) {
394394+ Image(systemName: systemImage)
395395+ .imageScale(.medium)
396396+ .foregroundStyle(.secondary)
397397+ .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize)
398398+ .contentShape(.rect)
399399+ .accessibilityHidden(true)
400400+ }
401401+ .buttonStyle(.plain)
402402+ .help(label)
403403+ }
404404+}
405405+406406+/// Shared metrics for the Shelf layout so the three segments stay in sync.
407407+enum ShelfMetrics {
408408+ /// Width of a single spine. Sized for comfortable one-line-of-text plus
409409+ /// a bit of breathing room around the rotated title.
410410+ static let spineWidth: CGFloat = 34
411411+ static let slotSize: CGFloat = 28
412412+ static let slotCornerRadius: CGFloat = 5
413413+ static let slotSpacing: CGFloat = 3
414414+ static let slotHorizontalPadding: CGFloat = 3
415415+ static let aggregatedDotSize: CGFloat = 6
416416+ /// Max pre-rotation width (i.e. visual height after 90° rotation) of the
417417+ /// spine header title. Texts longer than this get middle-truncated.
418418+ static let headerMaxLength: CGFloat = 160
419419+ /// Fallback icon when a tab has no custom icon set.
420420+ static let defaultTabIcon: String = "terminal"
421421+}
+198
supacode/Features/Shelf/Views/ShelfView.swift
···11+import ComposableArchitecture
22+import SwiftUI
33+44+/// Root view for Shelf presentation mode.
55+///
66+/// Phase 3 layout: three horizontal segments — a left stack of passed
77+/// spines (each showing its book's tabs), the currently open book's
88+/// terminal area, and a right stack of upcoming spines. Clicking a tab
99+/// on any spine opens that book (when different) and selects that tab.
1010+/// Animations and the ⌘-held digit overlay are layered in subsequent
1111+/// phases.
1212+struct ShelfView: View {
1313+ let store: StoreOf<RepositoriesFeature>
1414+ let terminalManager: WorktreeTerminalManager
1515+ let createTab: () -> Void
1616+1717+ /// Shared namespace so each spine's `matchedGeometryEffect` can bridge
1818+ /// the left-stack ForEach and the right-stack ForEach without breaking
1919+ /// visual identity while it moves between them.
2020+ @Namespace private var spineNamespace
2121+2222+ /// Mirrors the Ghostty `background-opacity` setting so the Shelf can
2323+ /// honor the same window transparency as normal view mode. A previous
2424+ /// plain `.background(.background)` defeated transparency entirely by
2525+ /// stamping an opaque layer behind every child — including the
2626+ /// terminal surface and empty-state area.
2727+ @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity
2828+2929+ var body: some View {
3030+ let state = store.state
3131+ let books = state.orderedShelfBooks()
3232+ let openBookID = state.openShelfBookID
3333+ let openIndex = openBookID.flatMap { id in
3434+ books.firstIndex(where: { $0.id == id })
3535+ }
3636+3737+ HStack(spacing: 0) {
3838+ if let openIndex {
3939+ spineStack(books: Array(books[0...openIndex]), openIndex: openIndex, baseOffset: 0)
4040+ openBookArea(for: books[openIndex], state: state)
4141+ .transition(.opacity)
4242+ let rightStart = openIndex + 1
4343+ if rightStart < books.count {
4444+ spineStack(
4545+ books: Array(books[rightStart..<books.count]),
4646+ openIndex: openIndex,
4747+ baseOffset: rightStart
4848+ )
4949+ }
5050+ } else {
5151+ spineStack(books: books, openIndex: nil, baseOffset: 0)
5252+ emptyOpenArea()
5353+ }
5454+ }
5555+ .frame(maxWidth: .infinity, maxHeight: .infinity)
5656+ .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBackgroundOpacity))
5757+ // Animate on every openBookID change — covers both Shelf-originated
5858+ // book switches (which also set their own TCA animation) and
5959+ // left-nav-originated switches, so the spine flow is consistent
6060+ // regardless of entry point.
6161+ .animation(.easeInOut(duration: 0.2), value: openBookID)
6262+ }
6363+6464+ /// `baseOffset` is the index of `books.first` within the full ordered
6565+ /// list, so we can reconstruct each spine's global index and compute
6666+ /// its distance to `openIndex` without re-scanning the full list.
6767+ @ViewBuilder
6868+ private func spineStack(books: [ShelfBook], openIndex: Int?, baseOffset: Int) -> some View {
6969+ HStack(spacing: 0) {
7070+ ForEach(Array(books.enumerated()), id: \.element.id) { localIndex, book in
7171+ let globalIndex = baseOffset + localIndex
7272+ let distance = openIndex.map { abs(globalIndex - $0) }
7373+ let open = globalIndex == openIndex
7474+ ShelfSpineView(
7575+ book: book,
7676+ isOpen: open,
7777+ distanceFromOpen: distance,
7878+ terminalState: terminalManager.stateIfExists(for: book.id),
7979+ onOpenBook: { openBook(book, selectingTab: nil) },
8080+ onSelectTab: { tabID in openBook(book, selectingTab: tabID) },
8181+ onNewTab: {
8282+ // On a closed spine, `+` doubles as "pull this book out and
8383+ // start a fresh tab". Sequencing is fine because TCA runs
8484+ // reducers synchronously — `newTerminal` will observe the
8585+ // new `selectedTerminalWorktree` set by `selectWorktree`.
8686+ switchToBookIfNeeded(book)
8787+ createTab()
8888+ },
8989+ onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil,
9090+ onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil,
9191+ onRemoveBook: { removeBook(book) }
9292+ )
9393+ .matchedGeometryEffect(id: book.id, in: spineNamespace)
9494+ }
9595+ }
9696+ }
9797+9898+ /// Dispatch the open-book action only when `book` isn't already the open
9999+ /// one — idempotent helper for taps that imply a book change.
100100+ private func switchToBookIfNeeded(_ book: ShelfBook) {
101101+ guard !isOpen(book) else { return }
102102+ switch book.kind {
103103+ case .worktree:
104104+ store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2))
105105+ case .plainFolder:
106106+ store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2))
107107+ }
108108+ }
109109+110110+ private func performSplit(direction: String) {
111111+ guard let openID = store.state.openShelfBookID,
112112+ let state = terminalManager.stateIfExists(for: openID)
113113+ else { return }
114114+ _ = state.performBindingActionOnFocusedSurface(direction)
115115+ }
116116+117117+ /// "Remove Book" context action. Worktree books funnel through the
118118+ /// existing archive flow (which shows confirmation + progress); plain
119119+ /// folder books go through repository removal. Both pathways
120120+ /// eventually drop the book off the Shelf via the same prune logic
121121+ /// that drives the left navigation.
122122+ private func removeBook(_ book: ShelfBook) {
123123+ switch book.kind {
124124+ case .worktree:
125125+ store.send(.worktreeLifecycle(.requestArchiveWorktree(book.id, book.repositoryID)))
126126+ case .plainFolder:
127127+ store.send(.repositoryManagement(.requestRemoveRepository(book.repositoryID)))
128128+ }
129129+ }
130130+131131+ private func isOpen(_ book: ShelfBook) -> Bool {
132132+ store.state.openShelfBookID == book.id
133133+ }
134134+135135+ @ViewBuilder
136136+ private func openBookArea(for book: ShelfBook, state: RepositoriesFeature.State) -> some View {
137137+ if let worktree = state.selectedTerminalWorktree, worktree.id == book.id {
138138+ let shouldFocus = state.shouldFocusTerminal(for: worktree.id)
139139+ ShelfOpenBookView(
140140+ worktree: worktree,
141141+ manager: terminalManager,
142142+ shouldRunSetupScript: state.pendingSetupScriptWorktreeIDs.contains(worktree.id),
143143+ forceAutoFocus: shouldFocus
144144+ )
145145+ .frame(maxWidth: .infinity, maxHeight: .infinity)
146146+ .id(worktree.id)
147147+ .onAppear {
148148+ if shouldFocus {
149149+ store.send(.worktreeCreation(.consumeTerminalFocus(worktree.id)))
150150+ }
151151+ }
152152+ } else {
153153+ emptyOpenArea()
154154+ }
155155+ }
156156+157157+ @ViewBuilder
158158+ private func emptyOpenArea() -> some View {
159159+ VStack(spacing: 10) {
160160+ Image(systemName: "books.vertical")
161161+ .font(.system(size: 40))
162162+ .foregroundStyle(.secondary)
163163+ .accessibilityHidden(true)
164164+ Text("No book selected")
165165+ .font(.headline)
166166+ Text("Click a spine to open a book.")
167167+ .font(.callout)
168168+ .foregroundStyle(.secondary)
169169+ }
170170+ .frame(maxWidth: .infinity, maxHeight: .infinity)
171171+ }
172172+173173+ /// Open `book` and optionally select a specific tab on it. For the open
174174+ /// book's own tab slots (no book change), this skips the worktree
175175+ /// re-selection and just tells the tab manager to switch tab.
176176+ private func openBook(_ book: ShelfBook, selectingTab tabID: TerminalTabID?) {
177177+ let isAlreadyOpen = store.state.openShelfBookID == book.id
178178+ if let tabID, isAlreadyOpen, let state = terminalManager.stateIfExists(for: book.id) {
179179+ state.tabManager.selectTab(tabID)
180180+ return
181181+ }
182182+ // Animate the spine flow and terminal crossfade. The duration and
183183+ // curve mirror the Shelf design doc: ~200ms ease-in-out, snappy but
184184+ // legible so the user can read each spine's movement.
185185+ switch book.kind {
186186+ case .worktree:
187187+ store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2))
188188+ case .plainFolder:
189189+ store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2))
190190+ }
191191+ if let tabID {
192192+ // Apply tab selection eagerly; the target book's state already exists
193193+ // if the user has opened it before. For first-time opens the tab
194194+ // manager seeds a default tab which we won't override.
195195+ terminalManager.stateIfExists(for: book.id)?.tabManager.selectTab(tabID)
196196+ }
197197+ }
198198+}
···840840 lastEmittedFocusSurfaceId = nil
841841 }
842842 emitTaskStatusIfChanged()
843843+ // Signal "this worktree now has tabs" so downstream Shelf
844844+ // bookkeeping (`markWorktreeOpened` via `terminalEvent(.tabCreated)`)
845845+ // adds the restored worktree to `openedWorktreeIDs`. Without this
846846+ // emit, only the active worktree (which goes through
847847+ // `.selectWorktree` on `.layoutRestored`) shows as a book on the
848848+ // Shelf — every other restored worktree is missing, even though
849849+ // the sidebar lists it and its terminal state is live.
850850+ if !restoredTabs.isEmpty {
851851+ onTabCreated?()
852852+ }
843853 terminalStateLogger.info(
844854 "[LayoutRestore] applySnapshot: success, restored \(restoredTabs.count) tab(s)"
845855 + " selectedTab=\(selectedTabID?.rawValue.uuidString ?? "nil")"
···15851595 if tabId == runScriptTabId {
15861596 setRunScriptTabId(nil)
15871597 }
15981598+ // Mirror `state.closeTab(_:)`'s `onTabClosed` emit: this path
15991599+ // fires when the shell process exits (ghostty-driven close)
16001600+ // and historically skipped the callback, which meant the
16011601+ // Shelf's "retire the book when its last tab closes" logic
16021602+ // never saw this very common path.
16031603+ onTabClosed?()
15881604 return
15891605 }
15901606 updateTree(newTree, for: tabId)
···11import AppKit
22import SwiftUI
3344-private let windowFocusLogger = SupaLogger("WindowFocus")
55-64struct WindowActivityState: Equatable {
75 let isKeyWindow: Bool
86 let isVisible: Bool
···5149 clearObservers()
5250 observedWindow = window
5351 guard let window else {
5454- emitActivityIfNeeded(force: true)
5252+ // View is being torn down from its window (e.g. a sibling view
5353+ // swap in SwiftUI). The window itself is not going away — other
5454+ // observers watching the same `WorktreeTerminalState` are still
5555+ // live and reflect the real window activity. Emitting an
5656+ // inactive signal here would poison the shared state's
5757+ // `lastWindowIsKey`/`lastWindowIsVisible`, causing
5858+ // `applySurfaceActivity` to demote focus even though the window
5959+ // is still key. Just stop observing silently and let the
6060+ // surviving observer drive state. This branch is covered by
6161+ // `WindowFocusObserverViewTests.detachFromWindowEmitsNothingNew`.
5562 return
5663 }
5764 let center = NotificationCenter.default
···94101 return
95102 }
96103 lastEmittedActivity = activity
9797- windowFocusLogger.info(
9898- "[TerminalWake] activityChanged key=\(activity.isKeyWindow) "
9999- + "visible=\(activity.isVisible) force=\(force) "
100100- + "windowNumber=\(window?.windowNumber ?? -1)"
101101- )
102104 onWindowActivityChanged(activity)
103105 }
104106
···119119 private var lastPerformKeyEvent: TimeInterval?
120120 private var currentCursor: NSCursor = .iBeam
121121 private var focused = false
122122+ private var detachedFocusClearTask: Task<Void, Never>?
122123 private var markedText = NSMutableAttributedString()
123124 private var keyboardLayoutChangeKeyUpSuppression: KeyboardLayoutChangeKeyUpSuppression?
124125 private var keyTextAccumulator: [String]?
···473474 override func viewDidMoveToWindow() {
474475 super.viewDidMoveToWindow()
475476 if window == nil {
476476- // SwiftUI can temporarily detach a pane while rebuilding split/zoom layout.
477477- // If we keep the stale local focus bit, detached panes still intercept bindings.
478478- focusDidChange(false)
477477+ // SwiftUI can temporarily detach a pane while rebuilding split/zoom
478478+ // layout — or when another SwiftUI subtree (e.g. Shelf) takes over
479479+ // hosting the same surface. Clearing the focused bit immediately
480480+ // here is wrong for the re-attach case: AppKit silently resigns the
481481+ // surface without a call path we can observe, and same-window
482482+ // re-attach does not trigger `becomeFirstResponder`, so the focused
483483+ // bit never recovers. Delay the clear so a prompt re-attach
484484+ // cancels it; only when the surface truly stays detached past the
485485+ // grace window do we flip the bit.
486486+ detachedFocusClearTask?.cancel()
487487+ detachedFocusClearTask = Task { @MainActor [weak self] in
488488+ try? await ContinuousClock().sleep(for: .milliseconds(150))
489489+ guard !Task.isCancelled, let self, self.window == nil else { return }
490490+ focusDidChange(false)
491491+ }
492492+ } else {
493493+ detachedFocusClearTask?.cancel()
494494+ detachedFocusClearTask = nil
479495 }
480496 updateScreenObservers()
481497 updateContentScale()
···570586 func focusDidChange(_ focused: Bool) {
571587 guard surface != nil else { return }
572588 guard self.focused != focused else { return }
589589+ // Retained as the single diagnostic entry point for focus regressions.
590590+ // Filter `make log-stream | grep '\[ShelfFocus\] focusDidChange'` to
591591+ // trace every focused-bit transition across the app.
592592+ SupaLogger("SurfaceFocus").info(
593593+ "[ShelfFocus] focusDidChange surface=\(debugID) \(self.focused) -> \(focused)"
594594+ )
573595 self.focused = focused
574596 if focused {
575597 bridge.state.bellCount = 0
···11+import ComposableArchitecture
22+import DependenciesTestSupport
33+import Foundation
44+import IdentifiedCollections
55+import Sharing
66+import Testing
77+88+@testable import supacode
99+1010+@MainActor
1111+struct ShelfFeatureTests {
1212+ @Test(.dependencies) func toggleShelfFromWorktreeEntersShelfWithoutRedirecting() async {
1313+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
1414+ let worktree = Worktree(
1515+ id: "/tmp/repo/wt1",
1616+ name: "wt1",
1717+ detail: "",
1818+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
1919+ repositoryRootURL: rootURL
2020+ )
2121+ let repository = Repository(
2222+ id: rootURL.path(percentEncoded: false),
2323+ rootURL: rootURL,
2424+ name: "repo",
2525+ worktrees: IdentifiedArray(uniqueElements: [worktree])
2626+ )
2727+ var state = RepositoriesFeature.State(repositories: [repository])
2828+ state.selection = .worktree(worktree.id)
2929+ let store = TestStore(initialState: state) {
3030+ RepositoriesFeature()
3131+ }
3232+3333+ await store.send(.toggleShelf) {
3434+ $0.isShelfActive = true
3535+ $0.openedWorktreeIDs = [worktree.id]
3636+ $0.pendingTerminalFocusWorktreeIDs = [worktree.id]
3737+ }
3838+ await store.finish()
3939+ }
4040+4141+ @Test(.dependencies) func toggleShelfWhileActiveExitsShelf() async {
4242+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
4343+ let worktree = Worktree(
4444+ id: "/tmp/repo/wt1",
4545+ name: "wt1",
4646+ detail: "",
4747+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
4848+ repositoryRootURL: rootURL
4949+ )
5050+ let repository = Repository(
5151+ id: rootURL.path(percentEncoded: false),
5252+ rootURL: rootURL,
5353+ name: "repo",
5454+ worktrees: IdentifiedArray(uniqueElements: [worktree])
5555+ )
5656+ var state = RepositoriesFeature.State(repositories: [repository])
5757+ state.selection = .worktree(worktree.id)
5858+ state.isShelfActive = true
5959+ let store = TestStore(initialState: state) {
6060+ RepositoriesFeature()
6161+ }
6262+6363+ await store.send(.toggleShelf) {
6464+ $0.isShelfActive = false
6565+ }
6666+ await store.finish()
6767+ }
6868+6969+ @Test(.dependencies) func toggleShelfWithoutWorktreesIsNoOp() async {
7070+ let store = TestStore(initialState: RepositoriesFeature.State()) {
7171+ RepositoriesFeature()
7272+ }
7373+7474+ await store.send(.toggleShelf)
7575+ await store.finish()
7676+ }
7777+7878+ @Test(.dependencies) func toggleShelfFromCanvasRedirectsToWorktreeAndEntersShelf() async {
7979+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
8080+ let worktree = Worktree(
8181+ id: "/tmp/repo/wt1",
8282+ name: "wt1",
8383+ detail: "",
8484+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
8585+ repositoryRootURL: rootURL
8686+ )
8787+ let repository = Repository(
8888+ id: rootURL.path(percentEncoded: false),
8989+ rootURL: rootURL,
9090+ name: "repo",
9191+ worktrees: IdentifiedArray(uniqueElements: [worktree])
9292+ )
9393+ var state = RepositoriesFeature.State(repositories: [repository])
9494+ state.selection = .canvas
9595+ state.lastFocusedWorktreeID = worktree.id
9696+ let store = TestStore(initialState: state) {
9797+ RepositoriesFeature()
9898+ }
9999+100100+ await store.send(.toggleShelf) {
101101+ $0.isShelfActive = true
102102+ }
103103+ await store.receive(\.selectWorktree) {
104104+ $0.selection = .worktree(worktree.id)
105105+ $0.sidebarSelectedWorktreeIDs = [worktree.id]
106106+ $0.pendingTerminalFocusWorktreeIDs = [worktree.id]
107107+ $0.openedWorktreeIDs = [worktree.id]
108108+ }
109109+ await store.receive(\.delegate.selectedWorktreeChanged)
110110+ await store.finish()
111111+ }
112112+113113+ @Test(.dependencies) func toggleShelfFromCanvasPrefersCanvasFocusedCard() async {
114114+ // Canvas can have a focused card distinct from `lastFocusedWorktreeID`
115115+ // (which is only updated while `selection` is `.worktree`). A direct
116116+ // Canvas → Shelf switch should open *that* card as the active book.
117117+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
118118+ let worktreeA = Worktree(
119119+ id: "/tmp/repo/wt-a",
120120+ name: "wt-a",
121121+ detail: "",
122122+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-a"),
123123+ repositoryRootURL: rootURL
124124+ )
125125+ let worktreeB = Worktree(
126126+ id: "/tmp/repo/wt-b",
127127+ name: "wt-b",
128128+ detail: "",
129129+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-b"),
130130+ repositoryRootURL: rootURL
131131+ )
132132+ let repository = Repository(
133133+ id: rootURL.path(percentEncoded: false),
134134+ rootURL: rootURL,
135135+ name: "repo",
136136+ worktrees: IdentifiedArray(uniqueElements: [worktreeA, worktreeB])
137137+ )
138138+ var state = RepositoriesFeature.State(repositories: [repository])
139139+ state.selection = .canvas
140140+ state.lastFocusedWorktreeID = worktreeA.id
141141+ let store = TestStore(initialState: state) {
142142+ RepositoriesFeature()
143143+ } withDependencies: {
144144+ $0.terminalClient.canvasFocusedWorktreeID = { worktreeB.id }
145145+ }
146146+147147+ await store.send(.toggleShelf) {
148148+ $0.isShelfActive = true
149149+ }
150150+ await store.receive(\.selectWorktree) {
151151+ $0.selection = .worktree(worktreeB.id)
152152+ $0.sidebarSelectedWorktreeIDs = [worktreeB.id]
153153+ $0.pendingTerminalFocusWorktreeIDs = [worktreeB.id]
154154+ $0.openedWorktreeIDs = [worktreeB.id]
155155+ }
156156+ await store.receive(\.delegate.selectedWorktreeChanged)
157157+ await store.finish()
158158+ }
159159+160160+ @Test(.dependencies) func toggleShelfFromArchivedRedirectsToWorktreeAndEntersShelf() async {
161161+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
162162+ let worktree = Worktree(
163163+ id: "/tmp/repo/wt1",
164164+ name: "wt1",
165165+ detail: "",
166166+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
167167+ repositoryRootURL: rootURL
168168+ )
169169+ let repository = Repository(
170170+ id: rootURL.path(percentEncoded: false),
171171+ rootURL: rootURL,
172172+ name: "repo",
173173+ worktrees: IdentifiedArray(uniqueElements: [worktree])
174174+ )
175175+ var state = RepositoriesFeature.State(repositories: [repository])
176176+ state.selection = .archivedWorktrees
177177+ let store = TestStore(initialState: state) {
178178+ RepositoriesFeature()
179179+ }
180180+181181+ await store.send(.toggleShelf) {
182182+ $0.isShelfActive = true
183183+ }
184184+ await store.receive(\.selectWorktree) {
185185+ $0.selection = .worktree(worktree.id)
186186+ $0.sidebarSelectedWorktreeIDs = [worktree.id]
187187+ $0.pendingTerminalFocusWorktreeIDs = [worktree.id]
188188+ $0.openedWorktreeIDs = [worktree.id]
189189+ }
190190+ await store.receive(\.delegate.selectedWorktreeChanged)
191191+ await store.finish()
192192+ }
193193+194194+ @Test(.dependencies) func selectingADifferentWorktreeKeepsShelfActive() async {
195195+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
196196+ let first = Worktree(
197197+ id: "/tmp/repo/wt1",
198198+ name: "wt1",
199199+ detail: "",
200200+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
201201+ repositoryRootURL: rootURL
202202+ )
203203+ let second = Worktree(
204204+ id: "/tmp/repo/wt2",
205205+ name: "wt2",
206206+ detail: "",
207207+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt2"),
208208+ repositoryRootURL: rootURL
209209+ )
210210+ let repository = Repository(
211211+ id: rootURL.path(percentEncoded: false),
212212+ rootURL: rootURL,
213213+ name: "repo",
214214+ worktrees: IdentifiedArray(uniqueElements: [first, second])
215215+ )
216216+ var state = RepositoriesFeature.State(repositories: [repository])
217217+ state.selection = .worktree(first.id)
218218+ state.isShelfActive = true
219219+ let store = TestStore(initialState: state) {
220220+ RepositoriesFeature()
221221+ }
222222+223223+ // Mirrors "user clicks second worktree in the left navigation
224224+ // while in Shelf mode": Shelf must not exit; only the open book
225225+ // changes via the new `selectedWorktreeID`.
226226+ await store.send(.selectWorktree(second.id, focusTerminal: true)) {
227227+ $0.selection = .worktree(second.id)
228228+ $0.sidebarSelectedWorktreeIDs = [second.id]
229229+ $0.pendingTerminalFocusWorktreeIDs = [second.id]
230230+ $0.openedWorktreeIDs = [second.id]
231231+ }
232232+ await store.receive(\.delegate.selectedWorktreeChanged)
233233+ await store.finish()
234234+ }
235235+236236+ @Test(.dependencies) func selectCanvasClearsShelfActiveFlag() async {
237237+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
238238+ let worktree = Worktree(
239239+ id: "/tmp/repo/wt1",
240240+ name: "wt1",
241241+ detail: "",
242242+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
243243+ repositoryRootURL: rootURL
244244+ )
245245+ let repository = Repository(
246246+ id: rootURL.path(percentEncoded: false),
247247+ rootURL: rootURL,
248248+ name: "repo",
249249+ worktrees: IdentifiedArray(uniqueElements: [worktree])
250250+ )
251251+ var state = RepositoriesFeature.State(repositories: [repository])
252252+ state.selection = .worktree(worktree.id)
253253+ state.isShelfActive = true
254254+ let store = TestStore(initialState: state) {
255255+ RepositoriesFeature()
256256+ } withDependencies: {
257257+ $0.terminalClient.send = { _ in }
258258+ }
259259+260260+ await store.send(.selectCanvas) {
261261+ $0.preCanvasWorktreeID = worktree.id
262262+ $0.preCanvasTerminalTargetID = worktree.id
263263+ $0.isShelfActive = false
264264+ $0.selection = .canvas
265265+ $0.sidebarSelectedWorktreeIDs = []
266266+ }
267267+ await store.finish()
268268+ }
269269+270270+ @Test(.dependencies) func selectShelfBookByIndexDispatchesWorktreeSelection() async {
271271+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
272272+ let wt1 = Worktree(
273273+ id: "/tmp/repo",
274274+ name: "main",
275275+ detail: "",
276276+ workingDirectory: rootURL,
277277+ repositoryRootURL: rootURL
278278+ )
279279+ let wt2 = Worktree(
280280+ id: "/tmp/repo/feature",
281281+ name: "feature",
282282+ detail: "",
283283+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"),
284284+ repositoryRootURL: rootURL
285285+ )
286286+ let repo = Repository(
287287+ id: rootURL.path(percentEncoded: false),
288288+ rootURL: rootURL,
289289+ name: "repo",
290290+ worktrees: IdentifiedArray(uniqueElements: [wt1, wt2])
291291+ )
292292+ var state = RepositoriesFeature.State(repositories: [repo])
293293+ state.repositoryRoots = [rootURL]
294294+ state.repositoryOrderIDs = [repo.id]
295295+ state.selection = .worktree(wt1.id)
296296+ state.isShelfActive = true
297297+ state.openedWorktreeIDs = [wt1.id, wt2.id]
298298+ let store = TestStore(initialState: state) {
299299+ RepositoriesFeature()
300300+ }
301301+302302+ await store.send(.selectShelfBook(2))
303303+ await store.receive(\.selectWorktree) {
304304+ $0.selection = .worktree(wt2.id)
305305+ $0.sidebarSelectedWorktreeIDs = [wt2.id]
306306+ $0.pendingTerminalFocusWorktreeIDs = [wt2.id]
307307+ }
308308+ await store.receive(\.delegate.selectedWorktreeChanged)
309309+ await store.finish()
310310+ }
311311+312312+ @Test(.dependencies) func selectShelfBookOutOfRangeIsNoOp() async {
313313+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
314314+ let wt1 = Worktree(
315315+ id: "/tmp/repo",
316316+ name: "main",
317317+ detail: "",
318318+ workingDirectory: rootURL,
319319+ repositoryRootURL: rootURL
320320+ )
321321+ let repo = Repository(
322322+ id: rootURL.path(percentEncoded: false),
323323+ rootURL: rootURL,
324324+ name: "repo",
325325+ worktrees: IdentifiedArray(uniqueElements: [wt1])
326326+ )
327327+ var state = RepositoriesFeature.State(repositories: [repo])
328328+ state.repositoryRoots = [rootURL]
329329+ state.repositoryOrderIDs = [repo.id]
330330+ state.selection = .worktree(wt1.id)
331331+ let store = TestStore(initialState: state) {
332332+ RepositoriesFeature()
333333+ }
334334+335335+ await store.send(.selectShelfBook(5))
336336+ await store.finish()
337337+ }
338338+339339+ @Test(.dependencies) func selectNextShelfBookWrapsAround() async {
340340+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
341341+ let wt1 = Worktree(
342342+ id: "/tmp/repo",
343343+ name: "main",
344344+ detail: "",
345345+ workingDirectory: rootURL,
346346+ repositoryRootURL: rootURL
347347+ )
348348+ let wt2 = Worktree(
349349+ id: "/tmp/repo/feature",
350350+ name: "feature",
351351+ detail: "",
352352+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"),
353353+ repositoryRootURL: rootURL
354354+ )
355355+ let repo = Repository(
356356+ id: rootURL.path(percentEncoded: false),
357357+ rootURL: rootURL,
358358+ name: "repo",
359359+ worktrees: IdentifiedArray(uniqueElements: [wt1, wt2])
360360+ )
361361+ var state = RepositoriesFeature.State(repositories: [repo])
362362+ state.repositoryRoots = [rootURL]
363363+ state.repositoryOrderIDs = [repo.id]
364364+ state.selection = .worktree(wt2.id) // Currently on the last book.
365365+ state.openedWorktreeIDs = [wt1.id, wt2.id]
366366+ let store = TestStore(initialState: state) {
367367+ RepositoriesFeature()
368368+ }
369369+370370+ await store.send(.selectNextShelfBook)
371371+ // Wrapping: next-after-last lands back on the first book.
372372+ await store.receive(\.selectWorktree) {
373373+ $0.selection = .worktree(wt1.id)
374374+ $0.sidebarSelectedWorktreeIDs = [wt1.id]
375375+ $0.pendingTerminalFocusWorktreeIDs = [wt1.id]
376376+ }
377377+ await store.receive(\.delegate.selectedWorktreeChanged)
378378+ await store.finish()
379379+ }
380380+381381+ @Test(.dependencies) func selectNextWorktreeRoutesToTabNavigationInShelf() async {
382382+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
383383+ let wt1 = Worktree(
384384+ id: "/tmp/repo/wt1",
385385+ name: "wt1",
386386+ detail: "",
387387+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
388388+ repositoryRootURL: rootURL
389389+ )
390390+ let repo = Repository(
391391+ id: rootURL.path(percentEncoded: false),
392392+ rootURL: rootURL,
393393+ name: "repo",
394394+ worktrees: IdentifiedArray(uniqueElements: [wt1])
395395+ )
396396+ var state = RepositoriesFeature.State(repositories: [repo])
397397+ state.selection = .worktree(wt1.id)
398398+ state.isShelfActive = true
399399+ let sentCommands = LockIsolated<[TerminalClient.Command]>([])
400400+ let store = TestStore(initialState: state) {
401401+ RepositoriesFeature()
402402+ } withDependencies: {
403403+ $0.terminalClient.send = { command in
404404+ sentCommands.withValue { $0.append(command) }
405405+ }
406406+ }
407407+408408+ await store.send(.selectNextWorktree)
409409+ await store.finish()
410410+ #expect(sentCommands.value == [.performBindingAction(wt1, action: "next_tab")])
411411+ }
412412+413413+ @Test(.dependencies) func selectPreviousWorktreeRoutesToTabNavigationInShelf() async {
414414+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
415415+ let wt1 = Worktree(
416416+ id: "/tmp/repo/wt1",
417417+ name: "wt1",
418418+ detail: "",
419419+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
420420+ repositoryRootURL: rootURL
421421+ )
422422+ let repo = Repository(
423423+ id: rootURL.path(percentEncoded: false),
424424+ rootURL: rootURL,
425425+ name: "repo",
426426+ worktrees: IdentifiedArray(uniqueElements: [wt1])
427427+ )
428428+ var state = RepositoriesFeature.State(repositories: [repo])
429429+ state.selection = .worktree(wt1.id)
430430+ state.isShelfActive = true
431431+ let sentCommands = LockIsolated<[TerminalClient.Command]>([])
432432+ let store = TestStore(initialState: state) {
433433+ RepositoriesFeature()
434434+ } withDependencies: {
435435+ $0.terminalClient.send = { command in
436436+ sentCommands.withValue { $0.append(command) }
437437+ }
438438+ }
439439+440440+ await store.send(.selectPreviousWorktree)
441441+ await store.finish()
442442+ #expect(sentCommands.value == [.performBindingAction(wt1, action: "previous_tab")])
443443+ }
444444+445445+ @Test(.dependencies) func selectNextWorktreeOutsideShelfStillCyclesWorktrees() async {
446446+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
447447+ let wt1 = Worktree(
448448+ id: "/tmp/repo/wt1",
449449+ name: "wt1",
450450+ detail: "",
451451+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
452452+ repositoryRootURL: rootURL
453453+ )
454454+ let wt2 = Worktree(
455455+ id: "/tmp/repo/wt2",
456456+ name: "wt2",
457457+ detail: "",
458458+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt2"),
459459+ repositoryRootURL: rootURL
460460+ )
461461+ let repo = Repository(
462462+ id: rootURL.path(percentEncoded: false),
463463+ rootURL: rootURL,
464464+ name: "repo",
465465+ worktrees: IdentifiedArray(uniqueElements: [wt1, wt2])
466466+ )
467467+ var state = RepositoriesFeature.State(repositories: [repo])
468468+ state.selection = .worktree(wt1.id)
469469+ // Shelf NOT active — existing worktree-cycling behavior must survive.
470470+ state.isShelfActive = false
471471+ let store = TestStore(initialState: state) {
472472+ RepositoriesFeature()
473473+ }
474474+475475+ await store.send(.selectNextWorktree)
476476+ await store.receive(\.selectWorktree) {
477477+ $0.selection = .worktree(wt2.id)
478478+ $0.sidebarSelectedWorktreeIDs = [wt2.id]
479479+ $0.openedWorktreeIDs = [wt2.id]
480480+ }
481481+ await store.receive(\.delegate.selectedWorktreeChanged)
482482+ await store.finish()
483483+ }
484484+485485+ @Test(.dependencies) func markWorktreeClosedRemovesFromOpenedSet() async {
486486+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
487487+ let wt1 = Worktree(
488488+ id: "/tmp/repo/wt1",
489489+ name: "wt1",
490490+ detail: "",
491491+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
492492+ repositoryRootURL: rootURL
493493+ )
494494+ let repo = Repository(
495495+ id: rootURL.path(percentEncoded: false),
496496+ rootURL: rootURL,
497497+ name: "repo",
498498+ worktrees: IdentifiedArray(uniqueElements: [wt1])
499499+ )
500500+ var state = RepositoriesFeature.State(repositories: [repo])
501501+ state.openedWorktreeIDs = [wt1.id]
502502+ state.selection = nil // Not currently selected, no auto-next needed.
503503+ let store = TestStore(initialState: state) {
504504+ RepositoriesFeature()
505505+ }
506506+507507+ await store.send(.markWorktreeClosed(wt1.id)) {
508508+ $0.openedWorktreeIDs = []
509509+ }
510510+ await store.finish()
511511+ }
512512+513513+ @Test(.dependencies) func markWorktreeClosedAdvancesToNextBookWhenAvailable() async {
514514+ let fixture = threeWorktreeFixture()
515515+ var state = RepositoriesFeature.State(repositories: [fixture.repo])
516516+ state.repositoryRoots = [fixture.repo.rootURL]
517517+ state.repositoryOrderIDs = [fixture.repo.id]
518518+ state.selection = .worktree(fixture.worktrees[1].id) // Middle book open.
519519+ state.isShelfActive = true
520520+ state.openedWorktreeIDs = Set(fixture.worktrees.map(\.id))
521521+ let store = TestStore(initialState: state) {
522522+ RepositoriesFeature()
523523+ }
524524+525525+ // Closing the middle book → replacement is the one AFTER it (wt3),
526526+ // not the first book (wt1). The user stays close to where they
527527+ // were on the shelf.
528528+ let closingID = fixture.worktrees[1].id
529529+ let nextID = fixture.worktrees[2].id
530530+ await store.send(.markWorktreeClosed(closingID)) {
531531+ $0.openedWorktreeIDs = [fixture.worktrees[0].id, nextID]
532532+ }
533533+ await store.receive(\.selectWorktree) {
534534+ $0.selection = .worktree(nextID)
535535+ $0.sidebarSelectedWorktreeIDs = [nextID]
536536+ $0.pendingTerminalFocusWorktreeIDs = [nextID]
537537+ $0.openedWorktreeIDs = [fixture.worktrees[0].id, nextID]
538538+ }
539539+ await store.receive(\.delegate.selectedWorktreeChanged)
540540+ await store.finish()
541541+ }
542542+543543+ @Test(.dependencies) func markWorktreeClosedFallsBackToPreviousBookWhenClosingLast() async {
544544+ let fixture = threeWorktreeFixture()
545545+ var state = RepositoriesFeature.State(repositories: [fixture.repo])
546546+ state.repositoryRoots = [fixture.repo.rootURL]
547547+ state.repositoryOrderIDs = [fixture.repo.id]
548548+ state.selection = .worktree(fixture.worktrees[2].id) // Last book open.
549549+ state.isShelfActive = true
550550+ state.openedWorktreeIDs = Set(fixture.worktrees.map(\.id))
551551+ let store = TestStore(initialState: state) {
552552+ RepositoriesFeature()
553553+ }
554554+555555+ // Closing the last book → no book after it, so the replacement is
556556+ // the book BEFORE it (wt2).
557557+ let closingID = fixture.worktrees[2].id
558558+ let prevID = fixture.worktrees[1].id
559559+ await store.send(.markWorktreeClosed(closingID)) {
560560+ $0.openedWorktreeIDs = [fixture.worktrees[0].id, prevID]
561561+ }
562562+ await store.receive(\.selectWorktree) {
563563+ $0.selection = .worktree(prevID)
564564+ $0.sidebarSelectedWorktreeIDs = [prevID]
565565+ $0.pendingTerminalFocusWorktreeIDs = [prevID]
566566+ $0.openedWorktreeIDs = [fixture.worktrees[0].id, prevID]
567567+ }
568568+ await store.receive(\.delegate.selectedWorktreeChanged)
569569+ await store.finish()
570570+ }
571571+572572+ private struct ThreeWorktreeFixture {
573573+ let repo: Repository
574574+ let worktrees: [Worktree]
575575+ }
576576+577577+ private func threeWorktreeFixture() -> ThreeWorktreeFixture {
578578+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
579579+ let worktrees = (1...3).map { index in
580580+ Worktree(
581581+ id: "/tmp/repo/wt\(index)",
582582+ name: "wt\(index)",
583583+ detail: "",
584584+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt\(index)"),
585585+ repositoryRootURL: rootURL
586586+ )
587587+ }
588588+ let repo = Repository(
589589+ id: rootURL.path(percentEncoded: false),
590590+ rootURL: rootURL,
591591+ name: "repo",
592592+ worktrees: IdentifiedArray(uniqueElements: worktrees)
593593+ )
594594+ return ThreeWorktreeFixture(repo: repo, worktrees: worktrees)
595595+ }
596596+597597+ @Test(.dependencies) func markWorktreeClosedLeavesSelectionAloneInNormalView() async {
598598+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
599599+ let wt1 = Worktree(
600600+ id: "/tmp/repo/wt1",
601601+ name: "wt1",
602602+ detail: "",
603603+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
604604+ repositoryRootURL: rootURL
605605+ )
606606+ let wt2 = Worktree(
607607+ id: "/tmp/repo/wt2",
608608+ name: "wt2",
609609+ detail: "",
610610+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt2"),
611611+ repositoryRootURL: rootURL
612612+ )
613613+ let repo = Repository(
614614+ id: rootURL.path(percentEncoded: false),
615615+ rootURL: rootURL,
616616+ name: "repo",
617617+ worktrees: IdentifiedArray(uniqueElements: [wt1, wt2])
618618+ )
619619+ var state = RepositoriesFeature.State(repositories: [repo])
620620+ state.selection = .worktree(wt1.id)
621621+ state.isShelfActive = false // Normal view.
622622+ state.openedWorktreeIDs = [wt1.id, wt2.id]
623623+ let store = TestStore(initialState: state) {
624624+ RepositoriesFeature()
625625+ }
626626+627627+ // In normal view, removing from the opened set must not also steal
628628+ // selection away from the user — they are actively on wt1.
629629+ await store.send(.markWorktreeClosed(wt1.id)) {
630630+ $0.openedWorktreeIDs = [wt2.id]
631631+ }
632632+ await store.finish()
633633+ }
634634+635635+ @Test(.dependencies) func markWorktreeOpenedAddsToOpenedSet() async {
636636+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
637637+ let wt1 = Worktree(
638638+ id: "/tmp/repo/wt1",
639639+ name: "wt1",
640640+ detail: "",
641641+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
642642+ repositoryRootURL: rootURL
643643+ )
644644+ let repo = Repository(
645645+ id: rootURL.path(percentEncoded: false),
646646+ rootURL: rootURL,
647647+ name: "repo",
648648+ worktrees: IdentifiedArray(uniqueElements: [wt1])
649649+ )
650650+ let store = TestStore(initialState: RepositoriesFeature.State(repositories: [repo])) {
651651+ RepositoriesFeature()
652652+ }
653653+654654+ // Mirrors the AppFeature forwarding `terminalEvent(.tabCreated)` →
655655+ // `.markWorktreeOpened`. Any tab creation (including restored
656656+ // layouts) adds its worktree to the Shelf's visible book set.
657657+ await store.send(.markWorktreeOpened(wt1.id)) {
658658+ $0.openedWorktreeIDs = [wt1.id]
659659+ }
660660+ await store.finish()
661661+ }
662662+663663+ @Test(.dependencies) func selectArchivedWorktreesClearsShelfActiveFlag() async {
664664+ let rootURL = URL(fileURLWithPath: "/tmp/repo")
665665+ let worktree = Worktree(
666666+ id: "/tmp/repo/wt1",
667667+ name: "wt1",
668668+ detail: "",
669669+ workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"),
670670+ repositoryRootURL: rootURL
671671+ )
672672+ let repository = Repository(
673673+ id: rootURL.path(percentEncoded: false),
674674+ rootURL: rootURL,
675675+ name: "repo",
676676+ worktrees: IdentifiedArray(uniqueElements: [worktree])
677677+ )
678678+ var state = RepositoriesFeature.State(repositories: [repository])
679679+ state.selection = .worktree(worktree.id)
680680+ state.isShelfActive = true
681681+ let store = TestStore(initialState: state) {
682682+ RepositoriesFeature()
683683+ }
684684+685685+ await store.send(.selectArchivedWorktrees) {
686686+ $0.isShelfActive = false
687687+ $0.selection = .archivedWorktrees
688688+ $0.sidebarSelectedWorktreeIDs = []
689689+ }
690690+ await store.receive(\.delegate.selectedWorktreeChanged)
691691+ await store.finish()
692692+ }
693693+694694+ @Test(.dependencies) func defaultViewShelfPreferenceDispatchesToggleAfterSnapshot() async {
695695+ let repoRoot = "/tmp/default-shelf-repo"
696696+ let rootURL = URL(fileURLWithPath: repoRoot)
697697+ let worktree = Worktree(
698698+ id: "\(repoRoot)/main",
699699+ name: "main",
700700+ detail: "",
701701+ workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"),
702702+ repositoryRootURL: rootURL
703703+ )
704704+ let repository = Repository(
705705+ id: repoRoot,
706706+ rootURL: rootURL,
707707+ name: "repo",
708708+ worktrees: IdentifiedArray(uniqueElements: [worktree])
709709+ )
710710+711711+ @Shared(.settingsFile) var settingsFile
712712+ $settingsFile.withLock {
713713+ var updated = $0.global
714714+ updated.defaultViewMode = .shelf
715715+ $0.global = updated
716716+ }
717717+ // Restore settings after the test so `@Shared` state doesn't leak
718718+ // across parallel test runs in the same process.
719719+ defer {
720720+ $settingsFile.withLock {
721721+ var updated = $0.global
722722+ updated.defaultViewMode = .normal
723723+ $0.global = updated
724724+ }
725725+ }
726726+727727+ // Drive only the reducer action under test, not the full `.task`
728728+ // flow — the launch pipeline is covered elsewhere. This isolates
729729+ // the new "default view shelf" hook from unrelated effects and
730730+ // keeps the assertion surface minimal.
731731+ var initialState = RepositoriesFeature.State()
732732+ initialState.lastFocusedWorktreeID = worktree.id
733733+ initialState.shouldRestoreLastFocusedWorktree = true
734734+ initialState.snapshotPersistencePhase = .restoring
735735+ let store = TestStore(initialState: initialState) {
736736+ RepositoriesFeature()
737737+ }
738738+739739+ await store.send(.repositorySnapshotLoaded([repository])) {
740740+ $0.repositories = [repository]
741741+ $0.repositoryRoots = [rootURL]
742742+ $0.selection = .worktree(worktree.id)
743743+ $0.shouldRestoreLastFocusedWorktree = false
744744+ $0.isInitialLoadComplete = true
745745+ }
746746+ await store.receive(\.delegate.repositoriesChanged)
747747+ await store.receive(\.delegate.selectedWorktreeChanged)
748748+ await store.receive(\.toggleShelf) {
749749+ $0.isShelfActive = true
750750+ $0.openedWorktreeIDs = [worktree.id]
751751+ $0.pendingTerminalFocusWorktreeIDs = [worktree.id]
752752+ }
753753+ await store.finish()
754754+ }
755755+756756+ @Test(.dependencies) func defaultViewShelfDefersDuringLayoutRestore() async {
757757+ // When Layout Restore is active the snapshot-load hook must stay
758758+ // quiet: Layout Restore clears selection and replays tabs, so
759759+ // entering Shelf here would flash an empty open area and leave a
760760+ // stray spine if the restored active book differs from
761761+ // `lastFocusedWorktreeID`. The AppFeature hook on `.layoutRestored`
762762+ // takes over once Layout Restore has settled.
763763+ let repoRoot = "/tmp/default-shelf-restore-repo"
764764+ let rootURL = URL(fileURLWithPath: repoRoot)
765765+ let worktree = Worktree(
766766+ id: "\(repoRoot)/main",
767767+ name: "main",
768768+ detail: "",
769769+ workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"),
770770+ repositoryRootURL: rootURL
771771+ )
772772+ let repository = Repository(
773773+ id: repoRoot,
774774+ rootURL: rootURL,
775775+ name: "repo",
776776+ worktrees: IdentifiedArray(uniqueElements: [worktree])
777777+ )
778778+779779+ @Shared(.settingsFile) var settingsFile
780780+ $settingsFile.withLock {
781781+ var updated = $0.global
782782+ updated.defaultViewMode = .shelf
783783+ $0.global = updated
784784+ }
785785+ defer {
786786+ $settingsFile.withLock {
787787+ var updated = $0.global
788788+ updated.defaultViewMode = .normal
789789+ $0.global = updated
790790+ }
791791+ }
792792+793793+ var initialState = RepositoriesFeature.State()
794794+ initialState.lastFocusedWorktreeID = worktree.id
795795+ initialState.shouldRestoreLastFocusedWorktree = true
796796+ initialState.snapshotPersistencePhase = .restoring
797797+ initialState.launchRestoreMode = .restoreLayout
798798+ let store = TestStore(initialState: initialState) {
799799+ RepositoriesFeature()
800800+ }
801801+802802+ await store.send(.repositorySnapshotLoaded([repository])) {
803803+ $0.repositories = [repository]
804804+ $0.repositoryRoots = [rootURL]
805805+ $0.selection = .worktree(worktree.id)
806806+ $0.shouldRestoreLastFocusedWorktree = false
807807+ $0.isInitialLoadComplete = true
808808+ }
809809+ await store.receive(\.delegate.repositoriesChanged)
810810+ await store.receive(\.delegate.selectedWorktreeChanged)
811811+ // No `.toggleShelf` here — the Layout Restore path is responsible.
812812+ await store.finish()
813813+ }
814814+}
+46
supacodeTests/WindowFocusObserverViewTests.swift
···11+import AppKit
22+import Testing
33+44+@testable import supacode
55+66+/// Regression guard for the Shelf-entry focus bug: when a
77+/// `WindowFocusObserverNSView` is torn off its host window (e.g. one
88+/// SwiftUI subtree is swapped for another that continues to observe the
99+/// same `WorktreeTerminalState`), the teardown must NOT fire an
1010+/// "inactive" activity callback. An inactive callback would overwrite
1111+/// the shared state's cached window-key flag and demote the surface's
1212+/// focused bit even though the window is still key.
1313+@MainActor
1414+struct WindowFocusObserverViewTests {
1515+ @Test func detachFromWindowEmitsNothingNew() {
1616+ let window = NSWindow(
1717+ contentRect: NSRect(x: 0, y: 0, width: 200, height: 200),
1818+ styleMask: [.titled],
1919+ backing: .buffered,
2020+ defer: true
2121+ )
2222+ let observer = WindowFocusObserverNSView()
2323+ var emits: [WindowActivityState] = []
2424+ observer.onWindowActivityChanged = { emits.append($0) }
2525+2626+ // Attach: the observer may emit the window's current activity state
2727+ // once (headless test windows report key=false, visible=false — so
2828+ // that initial emit is itself "inactive" here; that's fine). We
2929+ // capture the count so the detach assertion compares against this
3030+ // baseline instead of against an idealized non-inactive list.
3131+ window.contentView?.addSubview(observer)
3232+ let emitsAtAttach = emits.count
3333+3434+ // Detach: no new emit should fire, regardless of what the window's
3535+ // current state is. Prior to the fix, `updateObservers` called
3636+ // `emitActivityIfNeeded(force: true)` on detach, which always sent
3737+ // a (key=false, visible=false) inactive signal and poisoned the
3838+ // shared `WorktreeTerminalState`'s cached window-key flag.
3939+ observer.removeFromSuperview()
4040+ let emitsAfterDetach = emits.count
4141+ #expect(
4242+ emitsAfterDetach == emitsAtAttach,
4343+ "Detach must not emit new activity. attach=\(emitsAtAttach), afterDetach=\(emitsAfterDetach)"
4444+ )
4545+ }
4646+}