native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #230 from onevcat/feat/shelf-view

feat(shelf): Shelf — a new stacked-book presentation mode

authored by

Wei Wang and committed by
GitHub
c616c9f5 0165c26b

+2913 -27
+457
doc-onevcat/shelf-view.md
··· 1 + # Shelf View 2 + 3 + Last updated: 2026-04-21 4 + Status: Implemented (see **Implementation Decisions Journal** at the bottom for deviations taken during implementation) 5 + 6 + A new terminal presentation mode that sits alongside Canvas. Where Canvas spreads 7 + worktrees out as flat cards and weakens the worktree concept, Shelf preserves and 8 + strengthens it: each worktree (or plain folder) becomes a "book" with a vertical 9 + spine that doubles as its tab bar. Exactly one book is "open" at any time, 10 + occupying the space between a left stack of already-passed spines and a right 11 + stack of upcoming spines. 12 + 13 + --- 14 + 15 + ## Mode & Entry Point 16 + 17 + - Shelf is a terminal-region presentation mode, **mutually exclusive** with 18 + Canvas. The left navigation remains visible in Shelf mode (Shelf only occupies 19 + the terminal region to the right of the navigation). 20 + - The Shelf toggle lives next to the Canvas toggle in the same toolbar `HStack`, 21 + placed immediately to the **right of** (i.e. after) the Canvas entry. 22 + - Toggle hotkey: **`Cmd+Shift+Enter`** — symmetric with `Toggle Canvas`'s 23 + `Cmd+Option+Enter`. 24 + - **Exit Shelf**: only by re-clicking the Shelf toggle (or pressing the toggle 25 + hotkey). Clicking a different worktree in the left navigation does **not** 26 + exit Shelf — it merely changes which book is open. (This differs from Canvas, 27 + where left-nav clicks exit the mode, because Canvas weakens the worktree 28 + concept while Shelf treats `book = worktree` 1:1.) 29 + 30 + --- 31 + 32 + ## Concept Mapping 33 + 34 + | Shelf concept | Prowl model | 35 + |---|---| 36 + | Book | A worktree or a plain folder | 37 + | Spine | The book's vertical tab bar; also carries its identity (worktree/folder name + branch) | 38 + | Open book body | The terminal surface (with splits) of the book's currently active tab | 39 + 40 + **Order of books on the shelf** equals the order of worktrees / plain folders in 41 + the left navigation. Reordering happens through the left nav, not on the shelf. 42 + 43 + --- 44 + 45 + ## Layout Invariant 46 + 47 + The terminal region (everything to the right of the left navigation) is split 48 + into three horizontal segments: 49 + 50 + ``` 51 + [ left spine stack ] [ open book terminal area ] [ right spine stack ] 52 + ``` 53 + 54 + Let `N` be the index of the currently open book among all books `1…last`: 55 + 56 + - **Left stack** = spines of books `1…N`, in book order. Book `N`'s spine is the 57 + rightmost in the left stack and sits flush against the left edge of the 58 + terminal area. 59 + - **Terminal area** = the surface of book `N`'s currently active tab (with the 60 + existing split logic). 61 + - **Right stack** = spines of books `N+1…last`, in book order, flush against 62 + the window's right edge. 63 + 64 + **Initial state on entering Shelf**: `N` is the worktree currently identified by 65 + `WorktreeTerminalManager.selectedWorktreeID`; the open book's active tab is 66 + that worktree's currently active tab (no separate Shelf-only tab memory). 67 + 68 + **Book set = opened worktrees/folders only**: the spines shown on the Shelf 69 + are *not* the full list of worktrees + plain folders in the sidebar. The 70 + Shelf only includes books the user has interacted with at least once in 71 + the current session — i.e., those with an associated terminal state. A 72 + worktree that appears in the left navigation but has never been clicked (or 73 + touched by CLI / layout restore) does *not* get a spine. Clicking an 74 + as-yet-unopened worktree in the left navigation while Shelf is active is 75 + what makes its spine materialize — the normal spine-flow animation applies 76 + as the new spine slides into its sidebar-order position. 77 + 78 + --- 79 + 80 + ## Spine Specification 81 + 82 + ### Geometry 83 + 84 + - **Width**: one line of text (compact, fixed across all spines and across 85 + open/closed states). 86 + - **Identical structure and width whether the book is open or closed**; only 87 + the area to the spine's right changes (terminal surface vs. nothing). 88 + 89 + ### Header (top of spine) 90 + 91 + - Worktree name + branch name, rendered **rotated 90°** (vertical reading 92 + direction). 93 + - For **plain folders** (no branch): only the folder name is shown, with the 94 + branch line entirely omitted (consistent with how plain folders are presented 95 + in the left navigation today). 96 + - The header is **not** part of the scrollable area (see Tab List Overflow). 97 + 98 + ### Tab List (below header) 99 + 100 + - Each tab is rendered as **its icon only** (Prowl already supports per-tab 101 + custom icons). No label text in the slot. 102 + - Each slot is a uniform-sized clickable target. 103 + - **Hotkey overlay**: when the user holds **⌘ (Command)**, the icon in each 104 + slot is **replaced** by the tab's `Cmd+N` digit (1–9). Slot size and position 105 + do not change — there is zero layout shift. This matches Prowl's existing 106 + "hold ⌘ to reveal hotkeys" behavior. 107 + - For tabs at index ≥ 10 (no `Cmd+N` hotkey): when ⌘ is held, the slot continues 108 + to show the icon (optionally slightly dimmed to hint "no hotkey"); details left 109 + to implementation. 110 + 111 + ### Tab List Overflow 112 + 113 + - When the tab list does not fit the available spine height, the **tab list 114 + area scrolls vertically**. 115 + - The header (worktree/branch) stays **pinned** and does not scroll. 116 + - The bottom controls (see below) also stay pinned and do not scroll. 117 + 118 + ### Bottom Controls 119 + 120 + - A row of three buttons at the spine's bottom: **`+` / vertical split / 121 + horizontal split**, mirroring Prowl's standard tab bar. 122 + - These controls are **only shown on the spine of the currently open book**. 123 + Closed-book spines do not show them (acting on a non-open book first requires 124 + opening it). 125 + 126 + ### Per-Tab Visual States (must all be respected, simultaneously when applicable) 127 + 128 + - **Active tab highlight** — the book's currently selected tab. 129 + - **Notification highlight** — drives off the existing 130 + `WorktreeTerminalState.hasUnseenNotification(for:)`. Visual: **slot 131 + background tint**, using the same color/style as Canvas title-bar 132 + notification highlights (reuse the existing token / style for consistency). 133 + 134 + ### Book-Level Aggregated Notification 135 + 136 + - When **any** tab in a book has an unread notification, a **small dot badge** 137 + is shown on the spine **header** (next to the worktree/branch text). 138 + - Purpose: when a notifying tab is scrolled out of view in the spine's tab 139 + list, the user can still see at a glance "this book has activity". 140 + - No directional arrow / no "scroll up to see" hint — keep it minimal. 141 + 142 + --- 143 + 144 + ## Open Book Visual Distinction 145 + 146 + The open book's spine is visually distinguished from other spines through a 147 + **combination** of: 148 + 149 + - An **accent color / contrasting background tint** on the open book's spine, 150 + and 151 + - **Visual continuity** with the terminal area: the spine and terminal area 152 + share background color and/or border treatment so the spine reads as "the 153 + left edge of the open page" — reinforcing the book metaphor. 154 + 155 + (Active-tab highlight on the spine's currently-active tab slot is a separate, 156 + **tab-level** signal, independent of the **book-level** open-book signal. 157 + Both can be visible at once.) 158 + 159 + --- 160 + 161 + ## Interaction 162 + 163 + ### Book ↔ Left Navigation Sync (bidirectional) 164 + 165 + - **Shelf → Left nav**: clicking a different spine in Shelf updates 166 + `selectedWorktreeID` (and therefore the left-nav selection). 167 + - **Left nav → Shelf**: while in Shelf mode, clicking a worktree in the left 168 + navigation triggers the same spine-flow animation as clicking that book's 169 + spine directly. The Shelf does not exit. 170 + 171 + The single source of truth for "which book is open" is `selectedWorktreeID`. 172 + 173 + ### Switching Books (clicking a non-open book's spine) 174 + 175 + Clicking spine `M` (where `M ≠ N`): 176 + 177 + - If the click lands on a specific tab slot `T` on spine `M`: animate the 178 + spine flow (rules below), open book `M`, and set `M`'s active tab to `T`. 179 + - If the click lands on the spine **header** only: animate the spine flow, 180 + open book `M`, keep `M`'s previously active tab. 181 + 182 + **Spine flow rules:** 183 + 184 + - **`M > N` (forward)**: spines `N+1…M` slide from the right stack into the 185 + tail of the left stack. Spines `M+1…last` do not move. 186 + - **`M < N` (backward)**: spines `M+1…N` slide from the left stack back to the 187 + head of the right stack. Spines `1…M-1` do not move. 188 + 189 + In both cases the previously open book's spine ends up wherever the flow 190 + places it (no special case). 191 + 192 + ### Switching Tabs Within the Open Book 193 + 194 + Clicking a tab slot on the **currently open** book's own spine: 195 + 196 + - **No spine animation, no page-turn transition.** The spine layout is 197 + unchanged. 198 + - The terminal area is replaced with the newly selected tab's surface. 199 + 200 + ### Unified Click Rule 201 + 202 + Every tab slot on every spine is a click target meaning "switch to this book 203 + and this tab". Whether the click triggers spine-flow animation depends solely 204 + on whether the targeted book is already the open book. 205 + 206 + ### Creating Tabs / Splits 207 + 208 + - Use the **bottom controls** (`+` / vsplit / hsplit) on the **open book's** 209 + spine, or the existing keyboard shortcuts. 210 + - To add a tab to a non-open book: open it first by clicking its spine, then 211 + use the bottom controls. 212 + 213 + ### Closing Tabs 214 + 215 + Mirror Prowl's normal-mode tab close behavior: 216 + 217 + - **Hover X**: hovering a tab slot reveals a small X button to close it. 218 + - **Right-click menu**: right-clicking a tab slot opens a tab-level context 219 + menu containing Close (and any other existing tab actions). 220 + - **`Cmd+W`** keyboard shortcut continues to close the active tab. 221 + 222 + ### Closing the Last Tab in a Book 223 + 224 + Closing the last tab **retires the book from the Shelf**. Its spine disappears; 225 + if the closed book was the one currently open, Shelf auto-advances to the next 226 + remaining book (in Shelf order). The user can bring the book back by clicking 227 + its worktree in the left navigation, which re-opens it and re-adds its spine 228 + with the standard spine-flow animation. 229 + 230 + (Earlier drafts of this doc proposed keeping the book on the shelf with an 231 + empty-terminal placeholder. Reversed: a lingering empty book felt unnatural and 232 + doubled as dead weight. See the Implementation Decisions Journal for the switch.) 233 + 234 + ### Removing a Book from the Shelf 235 + 236 + A book is removed from the shelf only by: 237 + 238 + 1. **Closing/removing the worktree** through the left navigation (existing 239 + pathway), or 240 + 2. **Right-clicking the spine header** → context menu → **"Remove book"**. 241 + 242 + Right-click scoping: 243 + 244 + - Right-click on a **tab slot** → tab-level context menu (Close, etc.). 245 + - Right-click on the **spine header or its empty body area** → book-level 246 + context menu (Remove book, etc.). 247 + 248 + --- 249 + 250 + ## Animation Specification 251 + 252 + ### Axis 1 — Spine flow character 253 + 254 + - **Snappy**: ~200ms, ease-in-out. Crisp, minimal hang time. 255 + 256 + ### Axis 2 — Terminal area swap 257 + 258 + - Use SwiftUI **`matchedGeometryEffect`** (or the closest equivalent): the 259 + terminal area is treated as a piece of "openable book content" that 260 + geometrically transforms together with its spine. 261 + - During transitions, **two terminals may coexist briefly** in the terminal 262 + region: 263 + - **Forward (`M > N`, "pulling in")**: book `M`'s terminal slides in from 264 + the right alongside `M`'s spine. The previously open book `N`'s terminal 265 + stays in place and **fades out** as `M`'s terminal arrives, so the user 266 + never sees a half-clipped or partially-replaced surface. 267 + - **Backward (`M < N`, "pushing out")**: book `N`'s terminal slides out to 268 + the right alongside `N`'s spine and **fades out** during the slide. Book 269 + `M`'s terminal materializes at its destination (slide-in or fade-in, as 270 + looks best in implementation). 271 + - **Unified rule**: the "about to be invisible" terminal handles the fade; the 272 + "about to be visible" terminal stays opaque (slide-in) or fades in. This 273 + prevents surface views from popping in / out abruptly and avoids visual 274 + tears against the moving spines. 275 + 276 + --- 277 + 278 + ## Keyboard Shortcuts 279 + 280 + All Shelf-related shortcuts are **configurable** through Prowl's existing 281 + keybinding system (`scope = configurableAppAction`), exposed in 282 + `Settings → Shortcuts`. 283 + 284 + | Command | Default binding | Notes | 285 + |---|---|---| 286 + | `toggleShelf` | `Cmd+Shift+Enter` | New command. Symmetric with `toggleCanvas` (`Cmd+Option+Enter`). | 287 + | `selectTerminalTab1…9` | `Cmd+1..9` | **Existing** commands. In Shelf, they switch tabs within the open book. | 288 + | `selectPreviousTerminalTab` / `selectNextTerminalTab` | `Cmd+Shift+[` / `Cmd+Shift+]` | **Existing** — apply within the open book. | 289 + | `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. | 290 + | `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. | 291 + | `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. | 292 + 293 + ### Implementation note on multi-binding 294 + 295 + The current `KeybindingSchema` / `AppShortcut` / `Binding` model holds a single 296 + `shortcut` per command. The `Cmd+Ctrl+←/→` alias for 297 + `selectNext/PreviousWorktree` requires a non-trivial extension to support a 298 + collection of bindings per command (and to surface that in the settings UI). 299 + If this cost proves prohibitive, the fallback is to introduce wrapper commands 300 + (e.g. `selectNextBookAlias`) that invoke the same underlying action, at the 301 + cost of duplicating rows in the shortcuts settings list. 302 + 303 + --- 304 + 305 + ## Mapping to Existing Models 306 + 307 + - The ordered list of spines mirrors the ordered list of worktrees + plain 308 + folders tracked by `WorktreeTerminalManager`. 309 + - Each spine's tab list mirrors that worktree's `TerminalTabManager` tabs. 310 + - The terminal area renders the active tab's `GhosttySurfaceState` (and any 311 + splits) using the existing surface-rendering path. 312 + - `WorktreeTerminalManager.selectedWorktreeID` ↔ "the open book", driven by 313 + spine clicks and left-nav clicks alike (single source of truth). 314 + - Per-spine tab-slot notification highlights consume 315 + `WorktreeTerminalState.hasUnseenNotification(for:)`. 316 + - Per-book aggregated header dot consumes 317 + `WorktreeTerminalState.hasUnseenNotification` (book-wide). 318 + 319 + --- 320 + 321 + ## Open Implementation Questions (non-blocking) 322 + 323 + - Spine height budget per slot, and the exact dimming treatment for tabs ≥ 10 324 + when ⌘ is held. 325 + - Exact accent color / continuity treatment for the open book's spine + terminal 326 + area (decide during visual implementation; iterate if it looks off). 327 + - Whether the spine should auto-scroll to reveal a newly-arriving notification 328 + (vs. relying solely on the aggregated header dot). 329 + - Multi-binding architectural change (see Keyboard Shortcuts → Implementation 330 + note) — design before implementation. 331 + - Empty-state visuals for an empty Shelf (no books at all). 332 + - Animation behavior under user interruption (e.g. clicking a third spine while 333 + a transition is mid-flight). 334 + 335 + --- 336 + 337 + ## Implementation Decisions Journal 338 + 339 + Decisions made during implementation that deviate from — or add nuance to — 340 + the earlier design, recorded for review. 341 + 342 + ### Keyboard Shortcuts: wrapper commands over multi-binding 343 + 344 + **Design spec** had `Cmd+Ctrl+→` / `Cmd+Ctrl+←` as a second alias on the 345 + existing `selectNext/PreviousWorktree` commands, with a note that this 346 + requires a non-trivial extension to the keybinding schema (singular 347 + `shortcut` → collection). 348 + 349 + **Implemented** as distinct `selectNextShelfBook` / `selectPreviousShelfBook` 350 + commands. Reasons: 351 + 352 + - The Shelf-book ordering includes plain folders (interleaved per 353 + `orderedShelfBooks()`), so "next book on the Shelf" is not semantically 354 + equal to "next worktree" when plain folders exist. Aliasing would have 355 + skipped plain folders when a user pressed the arrow alias. 356 + - The wrapper-command approach keeps `AppShortcut.Binding.shortcut` singular, 357 + avoiding the schema change. 358 + - Both commands still live in `Settings → Shortcuts` so users can remap 359 + either set independently. 360 + 361 + ### Commands plumbing: merged into `SidebarCommands` 362 + 363 + Originally planned as a separate `ShelfCommands: Commands` struct. Moved into 364 + `SidebarCommands` because SwiftUI's `@CommandsBuilder` caps the number of 365 + direct children in a `.commands { }` block; adding a new top-level Commands 366 + struct pushed the builder past the cap and triggered a compile error on 367 + unrelated `CommandGroup`s. Merging keeps the external menu footprint the 368 + same (two visible toggles + one Worktrees menu). 369 + 370 + ### `isShelfActive` as a separate flag 371 + 372 + `RepositoriesFeature.State` gained a new `isShelfActive: Bool` flag instead 373 + of adding a `.shelf` case to `SidebarSelection`. Reason: Shelf is a 374 + presentation mode that still needs `selection` to track a worktree or plain 375 + folder (the open book). Using a dedicated flag decouples "is Shelf active" 376 + from "which book is open", which lets the bidirectional sync with the left 377 + navigation fall out for free. 378 + 379 + ### Auto-exit rules 380 + 381 + Entering Canvas or Archived Worktrees from any entry point clears 382 + `isShelfActive` — those two presentation modes are mutually exclusive with 383 + Shelf by design. Entering Shelf from Canvas / archived redirects selection 384 + to a compatible worktree / plain-folder before flipping the flag. 385 + 386 + ### Terminal rendering in the open area 387 + 388 + Rather than reusing `WorktreeTerminalTabsView` (which includes the horizontal 389 + tab bar), we introduced `ShelfOpenBookView` — a leaner view that renders only 390 + the terminal content stack + icon picker sheet + window focus observer. In 391 + Shelf, the tab bar lives on the spine, so duplicating it would violate the 392 + design. 393 + 394 + ### Plain folder spines 395 + 396 + `ShelfBook` uses `Worktree.ID` as its identity. For plain folders this is 397 + the repository ID, matching the synthetic worktree emitted by 398 + `RepositoriesFeature.State.selectedTerminalWorktree`. That way 399 + `openShelfBookID == selectedTerminalWorktree?.id` for both kinds without 400 + special-casing. 401 + 402 + ### Animation: `.animation(value:)` for both entry points 403 + 404 + To make left-nav-originated book switches animate identically to 405 + Shelf-originated taps, the root `HStack` carries an explicit 406 + `.animation(.easeInOut(duration: 0.2), value: openBookID)` modifier. 407 + Shelf-originated taps additionally pass the same animation to 408 + `store.send(_, animation:)` so the TCA-side mutation carries the transaction 409 + along. 410 + 411 + ### Close-last-tab behavior (revised) 412 + 413 + Reversed from the original decision. Closing the last tab now removes the 414 + book from the Shelf entirely. The implementation: 415 + 416 + - `TerminalClient.Event.tabClosed` gained a `remainingTabs: Int` payload so 417 + AppFeature can detect the last-tab case. When it sees `remainingTabs == 0`, 418 + it dispatches `.repositories(.markWorktreeClosed(id))`. 419 + - The `markWorktreeClosed` reducer handler removes the ID from 420 + `openedWorktreeIDs`, and — only when Shelf is active and the closed 421 + worktree was the open book — auto-advances selection to the next 422 + remaining book (via `shelfBookSelectionEffect`). In normal view, the 423 + selection is left alone so the user's current context isn't disturbed. 424 + - When the closed book was the last book on the Shelf, selection is kept 425 + as-is; `ShelfView` falls through to its "No book selected" empty state. 426 + 427 + ### Opened-worktrees set 428 + 429 + `RepositoriesFeature.State.openedWorktreeIDs: Set<Worktree.ID>` tracks 430 + which worktrees/plain folders are currently part of the Shelf's book list. 431 + It's updated by the reducer in four places: 432 + 433 + - `.selectWorktree(id, _)` handler inserts `id` (covers sidebar click, 434 + Shelf click, most CLI opens, layout restore of the *active* worktree) 435 + - `.selectRepository(id)` handler inserts `id` when the repository is a 436 + plain folder 437 + - `.toggleShelf` entry path inserts the currently selected ID when the 438 + selection is already compatible with Shelf (rare path, but guards 439 + against state set without going through the two actions above) 440 + - `.markWorktreeOpened(id)` — a dedicated action that AppFeature 441 + dispatches in response to `.terminalEvent(.tabCreated(worktreeID:))`. 442 + This is the *critical* catch-all for every path that sets 443 + `state.selection` directly without going through `.selectWorktree` — 444 + in particular, the cold-launch auto-selection that restores the last 445 + focused worktree (`state.selection = state.lastFocusedWorktreeID…` in 446 + `applyRepositories`) and the "first available after reload" fallback. 447 + Layout restore is a third such path. The common downstream signal in 448 + all of them is that the newly-focused worktree ends up materializing 449 + its first tab, emitting `.tabCreated`, which this forwarder converts 450 + into an `openedWorktreeIDs` insertion. 451 + 452 + `orderedShelfBooks()` filters against this set. The set is pure 453 + additive in this iteration — archived / removed worktrees still drop off 454 + the Shelf because the book iteration is anchored on the live 455 + `repositories` array, not on `openedWorktreeIDs`. That leaves a handful 456 + of stale IDs in the set but no visible spines; pruning can be layered in 457 + later if the set grows unbounded.
+139 -2
supacode/App/AppShortcuts.swift
··· 99 99 static let checkForUpdates = "check_for_updates" 100 100 static let showDiff = "show_diff" 101 101 static let toggleCanvas = "toggle_canvas" 102 + static let toggleShelf = "toggle_shelf" 103 + static let selectNextShelfBook = "select_next_shelf_book" 104 + static let selectPreviousShelfBook = "select_previous_shelf_book" 105 + static let selectShelfBook1 = "select_shelf_book_1" 106 + static let selectShelfBook2 = "select_shelf_book_2" 107 + static let selectShelfBook3 = "select_shelf_book_3" 108 + static let selectShelfBook4 = "select_shelf_book_4" 109 + static let selectShelfBook5 = "select_shelf_book_5" 110 + static let selectShelfBook6 = "select_shelf_book_6" 111 + static let selectShelfBook7 = "select_shelf_book_7" 112 + static let selectShelfBook8 = "select_shelf_book_8" 113 + static let selectShelfBook9 = "select_shelf_book_9" 102 114 static let revealInSidebar = "reveal_in_sidebar" 103 115 static let archivedWorktrees = "archived_worktrees" 104 116 static let selectNextWorktree = "select_next_worktree" ··· 175 187 static let toggleCanvas = AppShortcut( 176 188 keyEquivalent: .return, ghosttyKeyName: "return", modifiers: [.command, .option] 177 189 ) 190 + static let toggleShelf = AppShortcut( 191 + keyEquivalent: .return, ghosttyKeyName: "return", modifiers: [.command, .shift] 192 + ) 193 + static let selectNextShelfBook = AppShortcut( 194 + keyEquivalent: .rightArrow, ghosttyKeyName: "arrow_right", modifiers: [.command, .control] 195 + ) 196 + static let selectPreviousShelfBook = AppShortcut( 197 + keyEquivalent: .leftArrow, ghosttyKeyName: "arrow_left", modifiers: [.command, .control] 198 + ) 199 + static let selectShelfBook1 = AppShortcut(key: "1", modifiers: [.control, .option]) 200 + static let selectShelfBook2 = AppShortcut(key: "2", modifiers: [.control, .option]) 201 + static let selectShelfBook3 = AppShortcut(key: "3", modifiers: [.control, .option]) 202 + static let selectShelfBook4 = AppShortcut(key: "4", modifiers: [.control, .option]) 203 + static let selectShelfBook5 = AppShortcut(key: "5", modifiers: [.control, .option]) 204 + static let selectShelfBook6 = AppShortcut(key: "6", modifiers: [.control, .option]) 205 + static let selectShelfBook7 = AppShortcut(key: "7", modifiers: [.control, .option]) 206 + static let selectShelfBook8 = AppShortcut(key: "8", modifiers: [.control, .option]) 207 + static let selectShelfBook9 = AppShortcut(key: "9", modifiers: [.control, .option]) 208 + static let shelfBookSelection: [AppShortcut] = [ 209 + selectShelfBook1, 210 + selectShelfBook2, 211 + selectShelfBook3, 212 + selectShelfBook4, 213 + selectShelfBook5, 214 + selectShelfBook6, 215 + selectShelfBook7, 216 + selectShelfBook8, 217 + selectShelfBook9, 218 + ] 219 + 220 + static let shelfBookSelectionCommandIDs: [String] = [ 221 + CommandID.selectShelfBook1, 222 + CommandID.selectShelfBook2, 223 + CommandID.selectShelfBook3, 224 + CommandID.selectShelfBook4, 225 + CommandID.selectShelfBook5, 226 + CommandID.selectShelfBook6, 227 + CommandID.selectShelfBook7, 228 + CommandID.selectShelfBook8, 229 + CommandID.selectShelfBook9, 230 + ] 178 231 static let revealInSidebar = AppShortcut(key: "l", modifiers: [.command, .shift]) 179 232 static let archivedWorktrees = AppShortcut(key: "a", modifiers: [.command, .control]) 180 233 static let selectNextWorktree = AppShortcut( ··· 375 428 shortcut: toggleCanvas 376 429 ), 377 430 .init( 431 + id: CommandID.toggleShelf, 432 + title: "Toggle Shelf", 433 + scope: .configurableAppAction, 434 + shortcut: toggleShelf 435 + ), 436 + .init( 437 + id: CommandID.selectNextShelfBook, 438 + title: "Select Next Book", 439 + scope: .configurableAppAction, 440 + shortcut: selectNextShelfBook 441 + ), 442 + .init( 443 + id: CommandID.selectPreviousShelfBook, 444 + title: "Select Previous Book", 445 + scope: .configurableAppAction, 446 + shortcut: selectPreviousShelfBook 447 + ), 448 + .init( 449 + id: CommandID.selectShelfBook1, 450 + title: "Select Book 1", 451 + scope: .configurableAppAction, 452 + shortcut: selectShelfBook1 453 + ), 454 + .init( 455 + id: CommandID.selectShelfBook2, 456 + title: "Select Book 2", 457 + scope: .configurableAppAction, 458 + shortcut: selectShelfBook2 459 + ), 460 + .init( 461 + id: CommandID.selectShelfBook3, 462 + title: "Select Book 3", 463 + scope: .configurableAppAction, 464 + shortcut: selectShelfBook3 465 + ), 466 + .init( 467 + id: CommandID.selectShelfBook4, 468 + title: "Select Book 4", 469 + scope: .configurableAppAction, 470 + shortcut: selectShelfBook4 471 + ), 472 + .init( 473 + id: CommandID.selectShelfBook5, 474 + title: "Select Book 5", 475 + scope: .configurableAppAction, 476 + shortcut: selectShelfBook5 477 + ), 478 + .init( 479 + id: CommandID.selectShelfBook6, 480 + title: "Select Book 6", 481 + scope: .configurableAppAction, 482 + shortcut: selectShelfBook6 483 + ), 484 + .init( 485 + id: CommandID.selectShelfBook7, 486 + title: "Select Book 7", 487 + scope: .configurableAppAction, 488 + shortcut: selectShelfBook7 489 + ), 490 + .init( 491 + id: CommandID.selectShelfBook8, 492 + title: "Select Book 8", 493 + scope: .configurableAppAction, 494 + shortcut: selectShelfBook8 495 + ), 496 + .init( 497 + id: CommandID.selectShelfBook9, 498 + title: "Select Book 9", 499 + scope: .configurableAppAction, 500 + shortcut: selectShelfBook9 501 + ), 502 + .init( 378 503 id: CommandID.revealInSidebar, 379 504 title: "Reveal in Sidebar", 380 505 scope: .configurableAppAction, ··· 388 513 ), 389 514 .init( 390 515 id: CommandID.selectNextWorktree, 391 - title: "Select Next Worktree", 516 + title: "Select Next Worktree (Tab in Shelf View)", 392 517 scope: .configurableAppAction, 393 518 shortcut: selectNextWorktree 394 519 ), 395 520 .init( 396 521 id: CommandID.selectPreviousWorktree, 397 - title: "Select Previous Worktree", 522 + title: "Select Previous Worktree (Tab in Shelf View)", 398 523 scope: .configurableAppAction, 399 524 shortcut: selectPreviousWorktree 400 525 ), ··· 720 845 checkForUpdates, 721 846 showDiff, 722 847 toggleCanvas, 848 + toggleShelf, 849 + selectNextShelfBook, 850 + selectPreviousShelfBook, 851 + selectShelfBook1, 852 + selectShelfBook2, 853 + selectShelfBook3, 854 + selectShelfBook4, 855 + selectShelfBook5, 856 + selectShelfBook6, 857 + selectShelfBook7, 858 + selectShelfBook8, 859 + selectShelfBook9, 723 860 archivedWorktrees, 724 861 selectNextWorktree, 725 862 selectPreviousWorktree,
+1 -1
supacode/Clients/Terminal/TerminalClient.swift
··· 50 50 case notificationReceived(worktreeID: Worktree.ID, title: String, body: String) 51 51 case notificationIndicatorChanged(count: Int) 52 52 case tabCreated(worktreeID: Worktree.ID) 53 - case tabClosed(worktreeID: Worktree.ID) 53 + case tabClosed(worktreeID: Worktree.ID, remainingTabs: Int) 54 54 case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) 55 55 case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) 56 56 case runScriptStatusChanged(worktreeID: Worktree.ID, isRunning: Bool)
+34
supacode/Commands/SidebarCommands.swift
··· 26 26 } 27 27 .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleCanvas))) 28 28 .help(helpText(title: "Canvas", commandID: AppShortcuts.CommandID.toggleCanvas)) 29 + Button("Shelf") { 30 + store.send(.repositories(.toggleShelf)) 31 + } 32 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.toggleShelf))) 33 + .help(helpText(title: "Shelf", commandID: AppShortcuts.CommandID.toggleShelf)) 34 + Button("Select Next Book") { 35 + store.send(.repositories(.selectNextShelfBook)) 36 + } 37 + .modifier( 38 + KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.selectNextShelfBook)) 39 + ) 40 + .help(helpText(title: "Select Next Book", commandID: AppShortcuts.CommandID.selectNextShelfBook)) 41 + Button("Select Previous Book") { 42 + store.send(.repositories(.selectPreviousShelfBook)) 43 + } 44 + .modifier( 45 + KeyboardShortcutModifier( 46 + shortcut: keyboardShortcut(for: AppShortcuts.CommandID.selectPreviousShelfBook) 47 + ) 48 + ) 49 + .help(helpText(title: "Select Previous Book", commandID: AppShortcuts.CommandID.selectPreviousShelfBook)) 50 + shelfBookMenuButtons 29 51 Button("Show Diff") { 30 52 let repos = store.repositories 31 53 guard let worktreeID = repos.selectedWorktreeID, ··· 40 62 .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: AppShortcuts.CommandID.showDiff))) 41 63 .help(helpText(title: "Show Diff", commandID: AppShortcuts.CommandID.showDiff)) 42 64 .disabled(store.repositories.selectedWorktreeID == nil) 65 + } 66 + } 67 + 68 + @ViewBuilder 69 + private var shelfBookMenuButtons: some View { 70 + ForEach(Array(AppShortcuts.shelfBookSelectionCommandIDs.enumerated()), id: \.element) { index, commandID in 71 + let title = "Select Book \(index + 1)" 72 + Button(title) { 73 + store.send(.repositories(.selectShelfBook(index + 1))) 74 + } 75 + .modifier(KeyboardShortcutModifier(shortcut: keyboardShortcut(for: commandID))) 76 + .help(helpText(title: title, commandID: commandID)) 43 77 } 44 78 } 45 79
+31 -3
supacode/Features/App/Reducer/AppFeature.swift
··· 1009 1009 1010 1010 case .terminalEvent(.layoutRestored(let selectedWorktreeID)): 1011 1011 appLogger.info("[LayoutRestore] layoutRestored: selectedWorktreeID=\(selectedWorktreeID ?? "nil")") 1012 + // Once layout is restored the saved tabs have all been re-created 1013 + // (each emits `tabCreated` → `markWorktreeOpened`) and a valid 1014 + // active worktree is in hand — the right moment to honor the 1015 + // "Default View = Shelf" preference for Layout-Restore launches, 1016 + // which the `repositorySnapshotLoaded` hook intentionally 1017 + // deferred to avoid a selection flash. 1018 + @Shared(.settingsFile) var settingsFile 1019 + let shouldEnterShelf = 1020 + settingsFile.global.defaultViewMode == .shelf 1021 + && !state.repositories.isShelfActive 1022 + var effects: [Effect<Action>] = [] 1012 1023 if let selectedWorktreeID { 1013 1024 // Plain folders use .repository selection, not .worktree 1014 1025 if let repo = state.repositories.repositories[id: selectedWorktreeID], 1015 1026 repo.kind == .plain 1016 1027 { 1017 - return .send(.repositories(.selectRepository(selectedWorktreeID))) 1028 + effects.append(.send(.repositories(.selectRepository(selectedWorktreeID)))) 1029 + } else { 1030 + effects.append(.send(.repositories(.selectWorktree(selectedWorktreeID)))) 1018 1031 } 1019 - return .send(.repositories(.selectWorktree(selectedWorktreeID))) 1020 1032 } 1021 - return .none 1033 + if shouldEnterShelf { 1034 + effects.append(.send(.repositories(.toggleShelf))) 1035 + } 1036 + return effects.isEmpty ? .none : .merge(effects) 1022 1037 1023 1038 case .terminalEvent(.layoutRestoreFailed(let message)): 1024 1039 appLogger.warning("[LayoutRestore] layoutRestoreFailed: \(message)") 1025 1040 return .send(.repositories(.showToast(.warning(message)))) 1041 + 1042 + case .terminalEvent(.tabCreated(let worktreeID)): 1043 + // Every tab creation (user +, CLI open, layout restore, …) 1044 + // marks its worktree as Shelf-visible. Layout restore in 1045 + // particular only calls `selectWorktree` for the one active 1046 + // worktree; other restored worktrees only surface here. 1047 + return .send(.repositories(.markWorktreeOpened(worktreeID))) 1048 + 1049 + case .terminalEvent(.tabClosed(let worktreeID, let remainingTabs)): 1050 + // Closing the last tab retires the book from the Shelf. Other 1051 + // closes are routine and need no Reducer-side bookkeeping. 1052 + guard remainingTabs == 0 else { return .none } 1053 + return .send(.repositories(.markWorktreeClosed(worktreeID))) 1026 1054 1027 1055 case .terminalEvent: 1028 1056 return .none
+216
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 206 206 var lastFocusedWorktreeID: Worktree.ID? 207 207 var preCanvasWorktreeID: Worktree.ID? 208 208 var preCanvasTerminalTargetID: Worktree.ID? 209 + var isShelfActive: Bool = false 210 + /// IDs of worktrees (and plain-folder repositories) that have been 211 + /// "opened" at least once in this session — i.e., had their 212 + /// terminal state created by a user selection or CLI activation. 213 + /// The Shelf's book list is derived from this set so a sidebar 214 + /// worktree that's never been touched does not appear as a spine. 215 + var openedWorktreeIDs: Set<Worktree.ID> = [] 209 216 var launchRestoreMode: LaunchRestoreMode = .lastFocusedWorktree 210 217 var shouldRestoreLastFocusedWorktree = false 211 218 var shouldSelectFirstAfterReload = false ··· 272 279 case selectArchivedWorktrees 273 280 case selectCanvas 274 281 case toggleCanvas 282 + case toggleShelf 283 + case selectNextShelfBook 284 + case selectPreviousShelfBook 285 + case selectShelfBook(Int) 286 + case markWorktreeOpened(Worktree.ID) 287 + case markWorktreeClosed(Worktree.ID) 275 288 case setSidebarSelectedWorktreeIDs(Set<Worktree.ID>) 276 289 case selectRepository(Repository.ID?) 277 290 case selectWorktree(Worktree.ID?, focusTerminal: Bool = false) ··· 423 436 if selectionChanged { 424 437 allEffects.append(.send(.delegate(.selectedWorktreeChanged(selectedWorktree)))) 425 438 } 439 + // Apply "Default View = Shelf" preference once the initial 440 + // repository snapshot has landed — reuses `.toggleShelf`'s 441 + // guards (needs ≥1 book, falls back to `lastFocusedWorktreeID` 442 + // when no selection is set yet). `repositorySnapshotLoaded` 443 + // is only sent from `.task` at launch, so this won't re-enter 444 + // Shelf if the user has already exited to Normal in the same 445 + // session. When Layout Restore is about to run, defer to the 446 + // `.layoutRestored` event in AppFeature — otherwise Layout 447 + // Restore clears the selection we just set, and any books not 448 + // in the saved layout would linger as stray spines. 449 + @Shared(.settingsFile) var settingsFile 450 + if settingsFile.global.defaultViewMode == .shelf, 451 + state.launchRestoreMode != .restoreLayout, 452 + !state.isShelfActive 453 + { 454 + allEffects.append(.send(.toggleShelf)) 455 + } 426 456 return .merge(allEffects) 427 457 428 458 case .pinnedWorktreeIDsLoaded(let pinnedWorktreeIDs): ··· 611 641 return .none 612 642 613 643 case .selectArchivedWorktrees: 644 + state.isShelfActive = false 614 645 state.selection = .archivedWorktrees 615 646 state.sidebarSelectedWorktreeIDs = [] 616 647 return .send(.delegate(.selectedWorktreeChanged(nil))) ··· 619 650 // Remember the current worktree so toggleCanvas can restore it. 620 651 state.preCanvasWorktreeID = state.selectedWorktreeID 621 652 state.preCanvasTerminalTargetID = state.selectedTerminalWorktree?.id 653 + state.isShelfActive = false 622 654 state.selection = .canvas 623 655 state.sidebarSelectedWorktreeIDs = [] 624 656 return .run { _ in ··· 650 682 return .send(.selectCanvas) 651 683 } 652 684 685 + case .selectNextShelfBook: 686 + guard let book = shelfBook(atOffset: 1, state: state) else { return .none } 687 + return shelfBookSelectionEffect(for: book) 688 + 689 + case .selectPreviousShelfBook: 690 + guard let book = shelfBook(atOffset: -1, state: state) else { return .none } 691 + return shelfBookSelectionEffect(for: book) 692 + 693 + case .selectShelfBook(let index): 694 + let books = state.orderedShelfBooks() 695 + let zeroBased = index - 1 696 + guard books.indices.contains(zeroBased) else { return .none } 697 + return shelfBookSelectionEffect(for: books[zeroBased]) 698 + 699 + case .markWorktreeOpened(let worktreeID): 700 + state.openedWorktreeIDs.insert(worktreeID) 701 + return .none 702 + 703 + case .markWorktreeClosed(let worktreeID): 704 + // Closing the last tab of a book retires the book from the 705 + // Shelf. If this book was the one currently open on the 706 + // Shelf, move focus to the neighboring book — the one after 707 + // the closed book if there is one, otherwise the one before 708 + // — so the user lands close to where they were instead of 709 + // always snapping back to the first spine. 710 + let replacement = replacementBookAfterClosing( 711 + worktreeID: worktreeID, 712 + state: state 713 + ) 714 + state.openedWorktreeIDs.remove(worktreeID) 715 + if let replacement { 716 + return shelfBookSelectionEffect(for: replacement) 717 + } 718 + return .none 719 + 720 + case .toggleShelf: 721 + if state.isShelfActive { 722 + state.isShelfActive = false 723 + return .none 724 + } 725 + // Entering Shelf requires at least one book to render. 726 + guard !state.orderedWorktreeRows().isEmpty else { return .none } 727 + // Shelf is mutually exclusive with Canvas / archived views: when entering 728 + // Shelf we need a worktree- or repository-scoped selection. 729 + let needsRedirect: Bool 730 + switch state.selection { 731 + case .some(.worktree), .some(.repository): 732 + needsRedirect = false 733 + case .some(.canvas), .some(.archivedWorktrees), .none: 734 + needsRedirect = true 735 + } 736 + state.isShelfActive = true 737 + if !needsRedirect { 738 + // The current selection is the open book — make sure it's 739 + // registered as opened so the Shelf renders at least this 740 + // spine. Guards the case where `selection` was set without 741 + // going through `.selectWorktree` / `.selectRepository`. 742 + // 743 + // Also request terminal focus for this worktree so that 744 + // `ShelfOpenBookView.onAppear` forces focus onto the 745 + // surface (`forceAutoFocus: shouldFocusTerminal(for:)`). 746 + // Without this, entering Shelf via keyboard shortcut 747 + // leaves the first responder on the (now-dismissed) menu 748 + // path, and `applySurfaceActivity`'s "only refocus if the 749 + // current responder is a GhosttySurfaceView" guard skips 750 + // the surface — user can't type until a second 751 + // interaction (tab switch, etc.) forces focus through. 752 + switch state.selection { 753 + case .some(.worktree(let id)): 754 + state.openedWorktreeIDs.insert(id) 755 + state.pendingTerminalFocusWorktreeIDs.insert(id) 756 + case .some(.repository(let id)) 757 + where state.repositories[id: id]?.kind == .plain: 758 + state.openedWorktreeIDs.insert(id) 759 + state.pendingTerminalFocusWorktreeIDs.insert(id) 760 + default: 761 + break 762 + } 763 + return .none 764 + } 765 + // Same fallback chain as `toggleCanvas`'s exit path: prefer 766 + // the card the user was actively focused on in Canvas so a 767 + // Canvas → Shelf switch opens *that* card as the active book, 768 + // not whatever was selected before Canvas was entered. 769 + let targetID = 770 + terminalClient.canvasFocusedWorktreeID() 771 + ?? state.preCanvasTerminalTargetID 772 + ?? state.preCanvasWorktreeID 773 + ?? state.lastFocusedWorktreeID 774 + ?? state.orderedWorktreeRows().first?.id 775 + guard let targetID else { return .none } 776 + if state.worktree(for: targetID) == nil, 777 + let repository = state.repositories[id: targetID], 778 + repository.kind == .plain 779 + { 780 + state.pendingTerminalFocusWorktreeIDs.insert(targetID) 781 + return .send(.selectRepository(targetID)) 782 + } 783 + return .send(.selectWorktree(targetID, focusTerminal: true)) 784 + 653 785 case .setSidebarSelectedWorktreeIDs(let worktreeIDs): 654 786 let validWorktreeIDs = Set(state.orderedWorktreeRows().map(\.id)) 655 787 var nextWorktreeIDs = worktreeIDs.intersection(validWorktreeIDs) ··· 663 795 guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } 664 796 state.selection = .repository(repositoryID) 665 797 state.sidebarSelectedWorktreeIDs = [] 798 + if state.repositories[id: repositoryID]?.kind == .plain { 799 + // Plain folder selection opens the folder as a Shelf book. 800 + state.openedWorktreeIDs.insert(repositoryID) 801 + } 666 802 return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) 667 803 668 804 case .selectWorktree(let worktreeID, let focusTerminal): 669 805 setSingleWorktreeSelection(worktreeID, state: &state) 670 806 if focusTerminal, let worktreeID { 671 807 state.pendingTerminalFocusWorktreeIDs.insert(worktreeID) 808 + } 809 + if let worktreeID { 810 + state.openedWorktreeIDs.insert(worktreeID) 672 811 } 673 812 let selectedWorktree = state.worktree(for: worktreeID) 674 813 return .send(.delegate(.selectedWorktreeChanged(selectedWorktree))) 675 814 676 815 case .selectNextWorktree: 816 + // In Shelf, the vertical arrow pair maps to tab navigation 817 + // within the open book — horizontal (← / →) is already book 818 + // navigation, so the two axes match the Shelf layout. 819 + if state.isShelfActive, let worktree = state.selectedTerminalWorktree { 820 + return .run { _ in 821 + await terminalClient.send(.performBindingAction(worktree, action: "next_tab")) 822 + } 823 + } 677 824 guard let id = state.worktreeID(byOffset: 1) else { return .none } 678 825 return .send(.selectWorktree(id)) 679 826 680 827 case .selectPreviousWorktree: 828 + if state.isShelfActive, let worktree = state.selectedTerminalWorktree { 829 + return .run { _ in 830 + await terminalClient.send(.performBindingAction(worktree, action: "previous_tab")) 831 + } 832 + } 681 833 guard let id = state.worktreeID(byOffset: -1) else { return .none } 682 834 return .send(.selectWorktree(id)) 683 835 ··· 1385 1537 1386 1538 var isShowingCanvas: Bool { 1387 1539 selection == .canvas 1540 + } 1541 + 1542 + var isShowingShelf: Bool { 1543 + isShelfActive 1388 1544 } 1389 1545 1390 1546 var archivedWorktreeIDSet: Set<Worktree.ID> { ··· 2085 2241 state: RepositoriesFeature.State 2086 2242 ) -> Bool { 2087 2243 state.selectedRow(for: id) != nil 2244 + } 2245 + 2246 + /// Choose the next book to open after `worktreeID`'s book is retired. 2247 + /// Prefer the book immediately *after* the closed one in Shelf order; 2248 + /// fall back to the one immediately *before* it; return `nil` when 2249 + /// Shelf is inactive, when the closed book isn't the currently open 2250 + /// one, or when no other books remain. 2251 + func replacementBookAfterClosing( 2252 + worktreeID: Worktree.ID, 2253 + state: RepositoriesFeature.State 2254 + ) -> ShelfBook? { 2255 + guard state.isShelfActive, 2256 + state.selectedTerminalWorktree?.id == worktreeID 2257 + else { return nil } 2258 + let books = state.orderedShelfBooks() 2259 + guard let index = books.firstIndex(where: { $0.id == worktreeID }) else { 2260 + return nil 2261 + } 2262 + let remaining = books.enumerated().filter { $0.offset != index }.map(\.element) 2263 + guard !remaining.isEmpty else { return nil } 2264 + // After removing index `index`, the "next" book is now at position 2265 + // `index` in the reduced list (if it exists); otherwise the last one 2266 + // is the "previous" relative to what was closed. 2267 + if index < remaining.count { 2268 + return remaining[index] 2269 + } 2270 + return remaining.last 2271 + } 2272 + 2273 + /// Returns the Shelf book at `offset` positions from the currently open 2274 + /// book (wrapping around the book list). Returns nil if there are no 2275 + /// books. When there is no open book, offset > 0 picks the first book 2276 + /// and offset < 0 picks the last. 2277 + func shelfBook( 2278 + atOffset offset: Int, 2279 + state: RepositoriesFeature.State 2280 + ) -> ShelfBook? { 2281 + let books = state.orderedShelfBooks() 2282 + guard !books.isEmpty else { return nil } 2283 + if let currentID = state.openShelfBookID, 2284 + let currentIndex = books.firstIndex(where: { $0.id == currentID }) 2285 + { 2286 + let nextIndex = (currentIndex + offset + books.count) % books.count 2287 + return books[nextIndex] 2288 + } 2289 + return offset > 0 ? books.first : books.last 2290 + } 2291 + 2292 + /// Dispatches the right selection action for a book — a worktree vs. 2293 + /// a plain folder requires different Reducer actions even though the 2294 + /// Shelf treats them uniformly. 2295 + func shelfBookSelectionEffect( 2296 + for book: ShelfBook 2297 + ) -> Effect<RepositoriesFeature.Action> { 2298 + switch book.kind { 2299 + case .worktree: 2300 + return .send(.selectWorktree(book.id, focusTerminal: true)) 2301 + case .plainFolder: 2302 + return .send(.selectRepository(book.repositoryID)) 2303 + } 2088 2304 } 2089 2305 2090 2306 private func isSidebarSelectionValid(
+12 -5
supacode/Features/Repositories/Views/SidebarListView.swift
··· 184 184 } 185 185 } 186 186 .safeAreaInset(edge: .top) { 187 - CanvasSidebarButton( 188 - store: store, 189 - isSelected: state.isShowingCanvas 190 - ) 187 + HStack(spacing: 4) { 188 + CanvasSidebarButton( 189 + store: store, 190 + isSelected: state.isShowingCanvas 191 + ) 192 + ShelfSidebarButton( 193 + store: store, 194 + isSelected: state.isShowingShelf 195 + ) 196 + } 191 197 .padding(.top, 4) 198 + .padding(.horizontal, 4) 192 199 .background(.bar) 193 200 .overlay(alignment: .bottom) { 194 201 Divider() ··· 319 326 state.repositories = [repo1, repo2] 320 327 state.pinnedWorktreeIDs = ["/tmp/wt/auth"] 321 328 state.worktreeInfoByID = [ 322 - "/tmp/wt/sidebar": WorktreeInfoEntry(addedLines: 120, removedLines: 45, pullRequest: nil), 329 + "/tmp/wt/sidebar": WorktreeInfoEntry(addedLines: 120, removedLines: 45, pullRequest: nil) 323 330 ] 324 331 return state 325 332 }
+7
supacode/Features/Repositories/Views/WorktreeDetailView.swift
··· 224 224 onExitToTab: { 225 225 store.send(.repositories(.toggleCanvas)) 226 226 }) 227 + } else if repositories.isShowingShelf { 228 + ShelfView( 229 + store: store.scope(state: \.repositories, action: \.repositories), 230 + terminalManager: terminalManager, 231 + createTab: { store.send(.newTerminal) } 232 + ) 233 + .frame(maxWidth: .infinity, maxHeight: .infinity) 227 234 } else if repositories.isShowingArchivedWorktrees { 228 235 ArchivedWorktreesDetailView( 229 236 store: store.scope(state: \.repositories, action: \.repositories)
+19
supacode/Features/Settings/Models/DefaultViewMode.swift
··· 1 + /// Which presentation the app enters on launch. `normal` keeps the 2 + /// historical behavior (sidebar + terminal detail); `shelf` boots 3 + /// straight into Shelf so power users who live in Shelf don't have to 4 + /// toggle it every time they open Prowl. 5 + enum DefaultViewMode: String, CaseIterable, Identifiable, Codable, Sendable { 6 + case normal 7 + case shelf 8 + 9 + var id: String { rawValue } 10 + 11 + var title: String { 12 + switch self { 13 + case .normal: 14 + return "Normal View" 15 + case .shelf: 16 + return "Shelf View" 17 + } 18 + } 19 + }
+11 -2
supacode/Features/Settings/Models/GlobalSettings.swift
··· 26 26 var terminalFontSize: Float32? 27 27 var archivedAutoDeletePeriod: AutoDeletePeriod? 28 28 var keybindingUserOverrides: KeybindingUserOverrideStore 29 + var defaultViewMode: DefaultViewMode 29 30 30 31 static let `default` = GlobalSettings( 31 32 appearanceMode: .dark, ··· 54 55 restoreTerminalLayoutOnLaunch: false, 55 56 archivedAutoDeletePeriod: nil, 56 57 terminalFontSize: nil, 57 - keybindingUserOverrides: .empty 58 + keybindingUserOverrides: .empty, 59 + defaultViewMode: .normal 58 60 ) 59 61 60 62 init( ··· 84 86 restoreTerminalLayoutOnLaunch: Bool = false, 85 87 archivedAutoDeletePeriod: AutoDeletePeriod? = nil, 86 88 terminalFontSize: Float32? = nil, 87 - keybindingUserOverrides: KeybindingUserOverrideStore = .empty 89 + keybindingUserOverrides: KeybindingUserOverrideStore = .empty, 90 + defaultViewMode: DefaultViewMode = .normal 88 91 ) { 89 92 self.appearanceMode = appearanceMode 90 93 self.defaultEditorID = defaultEditorID ··· 113 116 self.archivedAutoDeletePeriod = archivedAutoDeletePeriod 114 117 self.terminalFontSize = terminalFontSize 115 118 self.keybindingUserOverrides = keybindingUserOverrides 119 + self.defaultViewMode = defaultViewMode 116 120 } 117 121 118 122 func encode(to encoder: any Encoder) throws { ··· 144 148 try container.encodeIfPresent(archivedAutoDeletePeriod?.rawValue, forKey: .archivedAutoDeletePeriod) 145 149 try container.encodeIfPresent(terminalFontSize, forKey: .terminalFontSize) 146 150 try container.encode(keybindingUserOverrides, forKey: .keybindingUserOverrides) 151 + try container.encode(defaultViewMode, forKey: .defaultViewMode) 147 152 } 148 153 149 154 private enum CodingKeys: String, CodingKey { ··· 174 179 case archivedAutoDeletePeriod 175 180 case terminalFontSize 176 181 case keybindingUserOverrides 182 + case defaultViewMode 177 183 // Legacy key for migration 178 184 case automaticallyArchiveMergedWorktrees 179 185 } ··· 263 269 keybindingUserOverrides = 264 270 try container.decodeIfPresent(KeybindingUserOverrideStore.self, forKey: .keybindingUserOverrides) 265 271 ?? Self.default.keybindingUserOverrides 272 + defaultViewMode = 273 + try container.decodeIfPresent(DefaultViewMode.self, forKey: .defaultViewMode) 274 + ?? Self.default.defaultViewMode 266 275 } 267 276 }
+5 -1
supacode/Features/Settings/Reducer/SettingsFeature.swift
··· 32 32 var restoreTerminalLayoutOnLaunch: Bool 33 33 var terminalFontSize: Float32? 34 34 var keybindingUserOverrides: KeybindingUserOverrideStore 35 + var defaultViewMode: DefaultViewMode 35 36 var cliInstallStatus: CLIInstallStatus = .notInstalled 36 37 var cliInstallShowAlert: Bool = true 37 38 var selection: SettingsSection? = .general ··· 68 69 restoreTerminalLayoutOnLaunch = settings.restoreTerminalLayoutOnLaunch 69 70 terminalFontSize = settings.terminalFontSize 70 71 keybindingUserOverrides = settings.keybindingUserOverrides 72 + defaultViewMode = settings.defaultViewMode 71 73 } 72 74 73 75 var globalSettings: GlobalSettings { ··· 100 102 restoreTerminalLayoutOnLaunch: restoreTerminalLayoutOnLaunch, 101 103 archivedAutoDeletePeriod: archivedAutoDeletePeriod, 102 104 terminalFontSize: terminalFontSize, 103 - keybindingUserOverrides: keybindingUserOverrides 105 + keybindingUserOverrides: keybindingUserOverrides, 106 + defaultViewMode: defaultViewMode 104 107 ) 105 108 } 106 109 } ··· 200 203 state.restoreTerminalLayoutOnLaunch = normalizedSettings.restoreTerminalLayoutOnLaunch 201 204 state.terminalFontSize = normalizedSettings.terminalFontSize 202 205 state.keybindingUserOverrides = normalizedSettings.keybindingUserOverrides 206 + state.defaultViewMode = normalizedSettings.defaultViewMode 203 207 state.syncGlobalDefaults(from: normalizedSettings) 204 208 return .send(.delegate(.settingsChanged(normalizedSettings))) 205 209
+8
supacode/Features/Settings/Views/AppearanceSettingsView.swift
··· 30 30 .foregroundStyle(.secondary) 31 31 .textSelection(.enabled) 32 32 } 33 + Section("Default View") { 34 + Picker("Launch in", selection: $store.defaultViewMode) { 35 + ForEach(DefaultViewMode.allCases) { mode in 36 + Text(mode.title).tag(mode) 37 + } 38 + } 39 + .help("View Prowl starts in on launch. Shelf requires at least one worktree or folder.") 40 + } 33 41 Section("Default Editor") { 34 42 Picker( 35 43 "Default editor",
+12
supacode/Features/Settings/Views/ShortcutsSettingsView.swift
··· 922 922 AppShortcuts.CommandID.stopScript, 923 923 AppShortcuts.CommandID.showDiff, 924 924 AppShortcuts.CommandID.toggleCanvas, 925 + AppShortcuts.CommandID.toggleShelf, 926 + AppShortcuts.CommandID.selectNextShelfBook, 927 + AppShortcuts.CommandID.selectPreviousShelfBook, 928 + AppShortcuts.CommandID.selectShelfBook1, 929 + AppShortcuts.CommandID.selectShelfBook2, 930 + AppShortcuts.CommandID.selectShelfBook3, 931 + AppShortcuts.CommandID.selectShelfBook4, 932 + AppShortcuts.CommandID.selectShelfBook5, 933 + AppShortcuts.CommandID.selectShelfBook6, 934 + AppShortcuts.CommandID.selectShelfBook7, 935 + AppShortcuts.CommandID.selectShelfBook8, 936 + AppShortcuts.CommandID.selectShelfBook9, 925 937 AppShortcuts.CommandID.selectAllCanvasCards, 926 938 AppShortcuts.CommandID.archivedWorktrees: 927 939 return .scripts
+80
supacode/Features/Shelf/Models/ShelfBook.swift
··· 1 + import Foundation 2 + 3 + /// A book on the Shelf — the unified abstraction over a Git worktree or 4 + /// a plain folder repository. 5 + /// 6 + /// For worktrees the `id` is the underlying `Worktree.ID`. For plain 7 + /// folders the `id` is the owning `Repository.ID`; plain folders are 8 + /// represented in the terminal system as synthetic worktrees sharing the 9 + /// repository's ID, so using the repository ID here keeps it consistent 10 + /// with `selectedTerminalWorktree?.id`. 11 + struct ShelfBook: Identifiable, Equatable, Hashable, Sendable { 12 + enum Kind: Equatable, Hashable, Sendable { 13 + case worktree 14 + case plainFolder 15 + } 16 + 17 + let id: Worktree.ID 18 + let repositoryID: Repository.ID 19 + let displayName: String 20 + /// Project/repository name shown as the primary part of the spine 21 + /// header. For plain folders this equals the folder name. 22 + let projectName: String 23 + let branchName: String? 24 + let kind: Kind 25 + 26 + var isPlainFolder: Bool { kind == .plainFolder } 27 + } 28 + 29 + extension RepositoriesFeature.State { 30 + /// Books rendered on the Shelf, in the same order the left navigation 31 + /// presents them (by repository, then by worktree rows within the 32 + /// repository). Plain folder repositories contribute a single book. 33 + /// 34 + /// The list is filtered to only books whose IDs are in 35 + /// `openedWorktreeIDs` — a worktree (or plain folder) appears on the 36 + /// Shelf only after the user has interacted with it at least once. 37 + /// Clicking a previously-unopened worktree in the left navigation 38 + /// while in Shelf mode adds its ID here, which causes its spine to 39 + /// materialize (with the standard spine-flow animation). 40 + func orderedShelfBooks() -> [ShelfBook] { 41 + let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 42 + var books: [ShelfBook] = [] 43 + for repositoryID in orderedRepositoryIDs() { 44 + guard let repository = repositoriesByID[repositoryID] else { continue } 45 + if repository.kind == .plain { 46 + guard openedWorktreeIDs.contains(repository.id) else { continue } 47 + books.append( 48 + ShelfBook( 49 + id: repository.id, 50 + repositoryID: repository.id, 51 + displayName: repository.name, 52 + projectName: repository.name, 53 + branchName: nil, 54 + kind: .plainFolder 55 + )) 56 + continue 57 + } 58 + for row in worktreeRows(in: repository) { 59 + guard openedWorktreeIDs.contains(row.id) else { continue } 60 + books.append( 61 + ShelfBook( 62 + id: row.id, 63 + repositoryID: repositoryID, 64 + displayName: row.name, 65 + projectName: repository.name, 66 + branchName: row.name, 67 + kind: .worktree 68 + )) 69 + } 70 + } 71 + return books 72 + } 73 + 74 + /// Identifier of the book currently open on the Shelf, derived from the 75 + /// active selection. Equal to `selectedTerminalWorktree?.id`, but kept as 76 + /// its own property so call sites read as shelf-aware. 77 + var openShelfBookID: Worktree.ID? { 78 + selectedTerminalWorktree?.id 79 + } 80 + }
+93
supacode/Features/Shelf/Views/ShelfOpenBookView.swift
··· 1 + import AppKit 2 + import SwiftUI 3 + 4 + /// Renders the terminal content for the currently open book. 5 + /// 6 + /// Mirrors the terminal-content slice of `WorktreeTerminalTabsView` without 7 + /// the horizontal tab bar: in Shelf the tab bar lives on the book's spine, 8 + /// so we only render the content stack (plus icon picker sheet + focus 9 + /// observer) here. Focus management (`ensureInitialTab`, `focusSelectedTab`, 10 + /// window-key syncing, and the `forceAutoFocus` on-change plumbing) is 11 + /// copied verbatim from `WorktreeTerminalTabsView` so that typing into the 12 + /// open book's surface works the same way it does in normal view. 13 + struct ShelfOpenBookView: View { 14 + let worktree: Worktree 15 + let manager: WorktreeTerminalManager 16 + let shouldRunSetupScript: Bool 17 + let forceAutoFocus: Bool 18 + 19 + @State private var windowActivity = WindowActivityState.inactive 20 + 21 + var body: some View { 22 + let state = manager.state(for: worktree) { shouldRunSetupScript } 23 + Group { 24 + if let selectedId = state.tabManager.selectedTabId { 25 + TerminalTabContentStack(tabs: state.tabManager.tabs, selectedTabId: selectedId) { tabId in 26 + TerminalSplitTreeAXContainer(tree: state.splitTree(for: tabId)) { operation in 27 + state.performSplitOperation(operation, in: tabId) 28 + } 29 + } 30 + } else { 31 + EmptyTerminalPaneView(message: "No terminals open") 32 + } 33 + } 34 + .sheet( 35 + item: Binding( 36 + get: { state.iconPickerTabId }, 37 + set: { state.iconPickerTabId = $0 } 38 + ) 39 + ) { tabId in 40 + let currentIcon = state.tabManager.tabs.first(where: { $0.id == tabId })?.icon 41 + TabIconPickerView( 42 + initialIcon: currentIcon, 43 + defaultIcon: state.defaultIcon(for: tabId), 44 + onApply: { newIcon in 45 + state.applyIconChange(tabId, icon: newIcon) 46 + state.dismissIconPicker() 47 + }, 48 + onCancel: { 49 + state.dismissIconPicker() 50 + } 51 + ) 52 + } 53 + .background( 54 + WindowFocusObserverView { activity in 55 + windowActivity = activity 56 + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 57 + } 58 + ) 59 + .onAppear { 60 + state.ensureInitialTab(focusing: false) 61 + if shouldAutoFocusTerminal { 62 + state.focusSelectedTab() 63 + } 64 + let activity = resolvedWindowActivity 65 + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 66 + } 67 + .onChange(of: state.tabManager.selectedTabId) { _, _ in 68 + if shouldAutoFocusTerminal { 69 + state.focusSelectedTab() 70 + } 71 + let activity = resolvedWindowActivity 72 + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 73 + } 74 + } 75 + 76 + private var shouldAutoFocusTerminal: Bool { 77 + if forceAutoFocus { 78 + return true 79 + } 80 + guard let responder = NSApp.keyWindow?.firstResponder else { return true } 81 + return !(responder is NSTableView) && !(responder is NSOutlineView) 82 + } 83 + 84 + private var resolvedWindowActivity: WindowActivityState { 85 + if let keyWindow = NSApp.keyWindow { 86 + return WindowActivityState( 87 + isKeyWindow: keyWindow.isKeyWindow, 88 + isVisible: keyWindow.occlusionState.contains(.visible) 89 + ) 90 + } 91 + return windowActivity 92 + } 93 + }
+37
supacode/Features/Shelf/Views/ShelfSidebarButton.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + struct ShelfSidebarButton: View { 5 + let store: StoreOf<RepositoriesFeature> 6 + let isSelected: Bool 7 + @Environment(CommandKeyObserver.self) private var commandKeyObserver 8 + @Environment(\.resolvedKeybindings) private var resolvedKeybindings 9 + 10 + var body: some View { 11 + Button { 12 + store.send(.toggleShelf) 13 + } label: { 14 + HStack(spacing: 6) { 15 + Label("Shelf", systemImage: "books.vertical") 16 + .font(.callout) 17 + .frame(maxWidth: .infinity, alignment: .leading) 18 + if commandKeyObserver.isPressed, 19 + let shortcut = AppShortcuts.display(for: AppShortcuts.CommandID.toggleShelf, in: resolvedKeybindings) 20 + { 21 + ShortcutHintView(text: shortcut, color: .secondary) 22 + } 23 + } 24 + .padding(.horizontal, 12) 25 + .padding(.vertical, 6) 26 + .contentShape(.rect) 27 + } 28 + .buttonStyle(.plain) 29 + .background(isSelected ? Color.accentColor.opacity(0.15) : .clear, in: .rect(cornerRadius: 6)) 30 + .help( 31 + AppShortcuts.helpText( 32 + title: "Shelf", 33 + commandID: AppShortcuts.CommandID.toggleShelf, 34 + in: resolvedKeybindings 35 + )) 36 + } 37 + }
+421
supacode/Features/Shelf/Views/ShelfSpineView.swift
··· 1 + import SwiftUI 2 + 3 + /// Vertical spine rendering for a single book on the Shelf. 4 + /// 5 + /// Phase 3 scope: header with book-level notification dot, a vertical 6 + /// scrollable tab list (icon-only slots), tap targets for header (opens 7 + /// the book with its current tab) and per-tab slot (opens the book with 8 + /// that tab). Animations, ⌘-held digit overlay, and bottom controls are 9 + /// layered in subsequent phases. 10 + struct ShelfSpineView: View { 11 + let book: ShelfBook 12 + let isOpen: Bool 13 + /// Absolute distance along the ordered book list between this spine and 14 + /// the currently open book (0 = this *is* the open book, 1 = immediate 15 + /// neighbor, …). Nil when no book is open. Drives the step-wise accent 16 + /// tint that fades outward from the open book, so proximity reads at a 17 + /// glance instead of every non-open spine looking identical. 18 + let distanceFromOpen: Int? 19 + let terminalState: WorktreeTerminalState? 20 + let onOpenBook: () -> Void 21 + let onSelectTab: (TerminalTabID) -> Void 22 + /// Bottom controls — provided only for the open book's spine. `nil` 23 + /// suppresses the trio entirely. 24 + let onNewTab: (() -> Void)? 25 + let onSplitVertical: (() -> Void)? 26 + let onSplitHorizontal: (() -> Void)? 27 + /// "Remove this book" — drives the book-level context menu entry on 28 + /// the spine header / empty body. Nil disables the menu. 29 + let onRemoveBook: (() -> Void)? 30 + 31 + @State private var isHovering = false 32 + 33 + var body: some View { 34 + VStack(spacing: 0) { 35 + headerButton 36 + tabList 37 + bottomControls 38 + } 39 + .frame(width: ShelfMetrics.spineWidth) 40 + // `maxHeight: .infinity` binds the spine to the parent Shelf's 41 + // available height (set by `ShelfView.frame(maxHeight: .infinity)`). 42 + // Without this, a long tab list would let the spine VStack grow to 43 + // its intrinsic size and push the entire window taller. 44 + .frame(maxHeight: .infinity, alignment: .top) 45 + .background( 46 + // Single `Rectangle` with a computed fill so the color change 47 + // interpolates in place as `distanceFromOpen` shifts, rather than 48 + // swapping one view for another (which the previous `@ViewBuilder` 49 + // if/else did). Fill is derived from a stepped accent-alpha ladder 50 + // so the open book glows strongest and neighbors fade outward. 51 + Rectangle().fill(spineBackgroundColor) 52 + ) 53 + // Whole-spine tap target. Inner Buttons (header, tab slots, controls) 54 + // absorb their own clicks; clicks that fall on empty areas (scroll 55 + // view negative space, gaps between tabs, etc.) bubble here and open 56 + // the book. Keeps the "books on a shelf" metaphor: grab anywhere on 57 + // the spine to pull the book out. 58 + .contentShape(.rect) 59 + .onTapGesture { onOpenBook() } 60 + .accessibilityAddTraits(.isButton) 61 + .contextMenu { bookContextMenu } 62 + .onHover { isHovering = $0 } 63 + .animation(.easeOut(duration: 0.12), value: isHovering) 64 + .overlay(alignment: .trailing) { 65 + if !isOpen { 66 + // Explicit 1pt vertical rule. `Divider()` used here before 67 + // rendered a *horizontal* hairline (no stack context → default 68 + // horizontal orientation) spanning the spine's full width at 69 + // its vertical center, lining up across every closed spine and 70 + // looking like a single white bar cutting through the Shelf. 71 + Rectangle() 72 + .fill(Color.secondary.opacity(0.1)) 73 + .frame(width: 1) 74 + } 75 + } 76 + .help(book.displayName) 77 + } 78 + 79 + /// Step-wise accent-alpha ladder keyed by `distanceFromOpen`. 100% 80 + /// (selected) → 50% → 30% → 20% → 10% → 5%; beyond the ladder the 81 + /// multiplier is 0 so the halo is bounded. The sharp drop at distance 82 + /// 1 keeps the open book clearly dominant rather than blending into 83 + /// its neighbors. Shared by the spine background and the per-tab 84 + /// active-highlight fill so they fade in lockstep. 85 + private var accentProximityMultiplier: Double { 86 + guard let distance = distanceFromOpen else { return 0 } 87 + let ladder: [Double] = [1.0, 0.5, 0.3, 0.2, 0.1, 0.05] 88 + return distance < ladder.count ? ladder[distance] : 0 89 + } 90 + 91 + /// When no book is open (empty shelf), fall back to the neutral gray 92 + /// used everywhere else so spines don't become invisible; otherwise 93 + /// derive from the proximity ladder. Hovering an unselected spine 94 + /// bumps its tint to 80% of the selected book's intensity — a clear 95 + /// "this is interactable" affordance that sits just below the open 96 + /// book and animates in/out smoothly. 97 + private var spineBackgroundColor: Color { 98 + guard distanceFromOpen != nil else { 99 + return Color.primary.opacity(0.06) 100 + } 101 + let multiplier = isHovering && !isOpen ? 0.8 : accentProximityMultiplier 102 + return Color.accentColor.opacity(0.20 * multiplier) 103 + } 104 + 105 + /// Active-tab highlight fades more gently than the spine background — 106 + /// the tab-selection indicator has to stay legible even on far-away 107 + /// books so users can still see which tab would open. Uses absolute 108 + /// alpha stops (0.20 / 0.15 / 0.10) rather than the spine's multiplier 109 + /// ladder; the two axes are tuned independently for their own roles. 110 + private var activeTabHighlightAlpha: Double { 111 + guard let distance = distanceFromOpen else { return 0.2 } 112 + switch distance { 113 + case 0: return 0.2 114 + case 1: return 0.15 115 + default: return 0.1 116 + } 117 + } 118 + 119 + @ViewBuilder 120 + private var bookContextMenu: some View { 121 + if let onRemoveBook { 122 + Button(role: .destructive) { 123 + onRemoveBook() 124 + } label: { 125 + Text("Remove Book") 126 + } 127 + } 128 + } 129 + 130 + @ViewBuilder 131 + private var bottomControls: some View { 132 + // `+` is shown on every spine, not just the open one: clicking it on a 133 + // closed book opens that book and creates a tab in one motion (the 134 + // caller sequences `selectWorktree` → `newTerminal`). Splits only 135 + // make sense against a focused surface, so they stay scoped to the 136 + // open book. 137 + if onNewTab != nil || onSplitVertical != nil || onSplitHorizontal != nil { 138 + VStack(spacing: ShelfMetrics.slotSpacing) { 139 + Divider().opacity(0.3) 140 + if let onNewTab { 141 + ShelfSpineControlButton( 142 + systemImage: "plus", 143 + label: "New Tab", 144 + action: onNewTab 145 + ) 146 + } 147 + if let onSplitVertical { 148 + ShelfSpineControlButton( 149 + systemImage: "square.split.2x1", 150 + label: "Split Vertically", 151 + action: onSplitVertical 152 + ) 153 + } 154 + if let onSplitHorizontal { 155 + ShelfSpineControlButton( 156 + systemImage: "square.split.1x2", 157 + label: "Split Horizontally", 158 + action: onSplitHorizontal 159 + ) 160 + } 161 + } 162 + .padding(.horizontal, ShelfMetrics.slotHorizontalPadding) 163 + .padding(.bottom, ShelfMetrics.slotSpacing) 164 + } 165 + } 166 + 167 + @ViewBuilder 168 + private var headerButton: some View { 169 + Button(action: onOpenBook) { 170 + ShelfSpineHeader( 171 + book: book, 172 + hasAggregatedNotification: terminalState?.hasUnseenNotification == true 173 + ) 174 + .frame(maxWidth: .infinity) 175 + .contentShape(.rect) 176 + } 177 + .buttonStyle(.plain) 178 + .contextMenu { bookContextMenu } 179 + } 180 + 181 + @ViewBuilder 182 + private var tabList: some View { 183 + if let terminalState { 184 + // Scroll the tab slots when they overflow the spine so the window 185 + // height stays capped instead of growing unbounded with tab count. 186 + // `.scrollIndicators(.never)` — stronger than `.hidden`, which 187 + // still shows scroll bars when the user has "Always show scroll 188 + // bars" enabled in System Settings. The 34pt-wide spine has no 189 + // room to donate to a scroll bar, so we always hide it. 190 + // `.scrollBounceBehavior(.basedOnSize)` keeps short lists static. 191 + // `.clipped()` eats any overdraw at the scroll-view edges so the 192 + // spine boundary stays crisp. 193 + ScrollView(.vertical) { 194 + tabListContent(state: terminalState) 195 + } 196 + .scrollIndicators(.never) 197 + .scrollBounceBehavior(.basedOnSize) 198 + .clipped() 199 + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) 200 + } 201 + } 202 + 203 + @ViewBuilder 204 + private func tabListContent(state terminalState: WorktreeTerminalState) -> some View { 205 + VStack(spacing: ShelfMetrics.slotSpacing) { 206 + ForEach(Array(terminalState.tabManager.tabs.enumerated()), id: \.element.id) { index, tab in 207 + // 1-based hotkey number that matches Cmd+1..9. Tabs at 208 + // positions 10+ intentionally have no hotkey: they keep 209 + // showing their icon even while ⌘ is held. 210 + let hotkeyIndex = index < 9 ? index + 1 : nil 211 + ShelfSpineTabSlot( 212 + tab: tab, 213 + hotkeyIndex: hotkeyIndex, 214 + isActive: terminalState.tabManager.selectedTabId == tab.id, 215 + hasUnseenNotification: terminalState.hasUnseenNotification(for: tab.id), 216 + activeHighlightAlpha: activeTabHighlightAlpha, 217 + onTap: { onSelectTab(tab.id) }, 218 + onClose: { terminalState.closeTab(tab.id) } 219 + ) 220 + .terminalTabContextMenu( 221 + tabId: tab.id, 222 + tabs: terminalState.tabManager.tabs, 223 + actions: TerminalTabContextMenuActions( 224 + changeTitle: { terminalState.promptChangeTabTitle($0) }, 225 + changeIcon: { terminalState.presentIconPicker(for: $0) }, 226 + closeTab: { terminalState.closeTab($0) }, 227 + closeOthers: { terminalState.closeOtherTabs(keeping: $0) }, 228 + closeToRight: { terminalState.closeTabsToRight(of: $0) }, 229 + closeAll: { terminalState.closeAllTabs() } 230 + ) 231 + ) 232 + } 233 + } 234 + .padding(.horizontal, ShelfMetrics.slotHorizontalPadding) 235 + .padding(.top, ShelfMetrics.slotSpacing) 236 + } 237 + 238 + } 239 + 240 + private struct ShelfSpineHeader: View { 241 + let book: ShelfBook 242 + let hasAggregatedNotification: Bool 243 + 244 + var body: some View { 245 + VStack(spacing: 6) { 246 + Circle() 247 + .fill(.orange) 248 + .frame(width: ShelfMetrics.aggregatedDotSize, height: ShelfMetrics.aggregatedDotSize) 249 + .opacity(hasAggregatedNotification ? 1 : 0) 250 + .accessibilityLabel("Unread notifications") 251 + .accessibilityHidden(!hasAggregatedNotification) 252 + .padding(.top, 6) 253 + rotatedTitle 254 + } 255 + } 256 + 257 + /// Composed title rendered vertically (top-to-bottom reading direction). 258 + /// Project name is primary; the `· branch` suffix is secondary so the 259 + /// user can scan the spine and pick out the repo at a glance even on 260 + /// repositories with many worktrees. 261 + @ViewBuilder 262 + private var rotatedTitle: some View { 263 + combinedTitle 264 + .font(.callout) 265 + .lineLimit(1) 266 + .truncationMode(.middle) 267 + .frame(width: ShelfMetrics.headerMaxLength, alignment: .leading) 268 + .rotationEffect(.degrees(90)) 269 + .frame(width: ShelfMetrics.spineWidth, height: ShelfMetrics.headerMaxLength) 270 + } 271 + 272 + /// Single composed `Text` (string-interpolation form) so middle- 273 + /// truncation can operate across project + branch as one string. 274 + /// `foregroundStyle` on each interpolated piece survives composition 275 + /// and drives the primary/secondary split. 276 + private var combinedTitle: Text { 277 + let project = Text(book.projectName) 278 + .font(.callout.weight(.semibold)) 279 + .foregroundStyle(.primary) 280 + guard let branch = book.branchName, !branch.isEmpty else { 281 + return project 282 + } 283 + let branchText = Text(" · \(branch)").foregroundStyle(.secondary) 284 + return Text("\(project)\(branchText)") 285 + } 286 + } 287 + 288 + private struct ShelfSpineTabSlot: View { 289 + let tab: TerminalTabItem 290 + let hotkeyIndex: Int? 291 + let isActive: Bool 292 + let hasUnseenNotification: Bool 293 + /// Absolute alpha for the active-tab accent fill, supplied by the 294 + /// enclosing spine so it can fade with proximity on its own curve 295 + /// (which decays more gently than the spine background — selection 296 + /// indicators must stay legible even on far books). Orange 297 + /// notification tint is left untouched so unread signals remain 298 + /// attention-grabbing regardless of distance. 299 + let activeHighlightAlpha: Double 300 + let onTap: () -> Void 301 + let onClose: () -> Void 302 + 303 + @Environment(CommandKeyObserver.self) private var commandKeyObserver 304 + @State private var isHovering = false 305 + 306 + var body: some View { 307 + Button(action: onTap) { 308 + ZStack { 309 + backgroundFill 310 + slotContent 311 + } 312 + .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) 313 + .contentShape(.rect) 314 + } 315 + .buttonStyle(.plain) 316 + .overlay(alignment: .topTrailing) { 317 + if isHovering && !commandKeyObserver.isPressed { 318 + Button(action: onClose) { 319 + Image(systemName: "xmark.circle.fill") 320 + .imageScale(.small) 321 + .foregroundStyle(.primary) 322 + .background(Circle().fill(.background)) 323 + .accessibilityLabel("Close Tab") 324 + } 325 + .buttonStyle(.plain) 326 + .offset(x: 3, y: -3) 327 + .help("Close Tab") 328 + } 329 + } 330 + .onHover { hovering in 331 + isHovering = hovering 332 + } 333 + .help(tab.title) 334 + } 335 + 336 + /// When ⌘ is held AND this tab has a `Cmd+N` hotkey, swap the icon 337 + /// for a compact `⌘N` glyph in-place. Slot frame stays the same either 338 + /// way so nothing reflows. 339 + @ViewBuilder 340 + private var slotContent: some View { 341 + let showsHotkey = commandKeyObserver.isPressed && hotkeyIndex != nil 342 + if let hotkeyIndex, showsHotkey { 343 + HStack(spacing: 1) { 344 + Image(systemName: "command") 345 + .font(.system(size: 8, weight: .semibold)) 346 + .foregroundStyle(foregroundTint) 347 + Text("\(hotkeyIndex)") 348 + .font(.callout.weight(.semibold).monospacedDigit()) 349 + .foregroundStyle(foregroundTint) 350 + } 351 + .accessibilityHidden(true) 352 + } else { 353 + Image(systemName: tab.icon ?? ShelfMetrics.defaultTabIcon) 354 + .imageScale(.medium) 355 + .foregroundStyle(foregroundTint) 356 + // Dim tabs without a hotkey when ⌘ is held, so the "this slot 357 + // can't be jumped to via Cmd+N" affordance is legible without 358 + // shifting any layout. 359 + .opacity(commandKeyObserver.isPressed && hotkeyIndex == nil ? 0.45 : 1) 360 + .accessibilityHidden(true) 361 + } 362 + } 363 + 364 + @ViewBuilder 365 + private var backgroundFill: some View { 366 + if hasUnseenNotification { 367 + // Same tint as Canvas title-bar notification highlight so Shelf's 368 + // per-tab unread indicator reads as "this tab" rather than a new 369 + // idiom. Wins over the active-tab highlight when both apply. 370 + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) 371 + .fill(Color.orange.opacity(0.3)) 372 + } else if isActive { 373 + RoundedRectangle(cornerRadius: ShelfMetrics.slotCornerRadius, style: .continuous) 374 + .fill(Color.accentColor.opacity(activeHighlightAlpha)) 375 + } else { 376 + Color.clear 377 + } 378 + } 379 + 380 + private var foregroundTint: Color { 381 + if hasUnseenNotification { return .primary } 382 + if isActive { return .primary } 383 + return .secondary 384 + } 385 + } 386 + 387 + private struct ShelfSpineControlButton: View { 388 + let systemImage: String 389 + let label: String 390 + let action: () -> Void 391 + 392 + var body: some View { 393 + Button(action: action) { 394 + Image(systemName: systemImage) 395 + .imageScale(.medium) 396 + .foregroundStyle(.secondary) 397 + .frame(width: ShelfMetrics.slotSize, height: ShelfMetrics.slotSize) 398 + .contentShape(.rect) 399 + .accessibilityHidden(true) 400 + } 401 + .buttonStyle(.plain) 402 + .help(label) 403 + } 404 + } 405 + 406 + /// Shared metrics for the Shelf layout so the three segments stay in sync. 407 + enum ShelfMetrics { 408 + /// Width of a single spine. Sized for comfortable one-line-of-text plus 409 + /// a bit of breathing room around the rotated title. 410 + static let spineWidth: CGFloat = 34 411 + static let slotSize: CGFloat = 28 412 + static let slotCornerRadius: CGFloat = 5 413 + static let slotSpacing: CGFloat = 3 414 + static let slotHorizontalPadding: CGFloat = 3 415 + static let aggregatedDotSize: CGFloat = 6 416 + /// Max pre-rotation width (i.e. visual height after 90° rotation) of the 417 + /// spine header title. Texts longer than this get middle-truncated. 418 + static let headerMaxLength: CGFloat = 160 419 + /// Fallback icon when a tab has no custom icon set. 420 + static let defaultTabIcon: String = "terminal" 421 + }
+198
supacode/Features/Shelf/Views/ShelfView.swift
··· 1 + import ComposableArchitecture 2 + import SwiftUI 3 + 4 + /// Root view for Shelf presentation mode. 5 + /// 6 + /// Phase 3 layout: three horizontal segments — a left stack of passed 7 + /// spines (each showing its book's tabs), the currently open book's 8 + /// terminal area, and a right stack of upcoming spines. Clicking a tab 9 + /// on any spine opens that book (when different) and selects that tab. 10 + /// Animations and the ⌘-held digit overlay are layered in subsequent 11 + /// phases. 12 + struct ShelfView: View { 13 + let store: StoreOf<RepositoriesFeature> 14 + let terminalManager: WorktreeTerminalManager 15 + let createTab: () -> Void 16 + 17 + /// Shared namespace so each spine's `matchedGeometryEffect` can bridge 18 + /// the left-stack ForEach and the right-stack ForEach without breaking 19 + /// visual identity while it moves between them. 20 + @Namespace private var spineNamespace 21 + 22 + /// Mirrors the Ghostty `background-opacity` setting so the Shelf can 23 + /// honor the same window transparency as normal view mode. A previous 24 + /// plain `.background(.background)` defeated transparency entirely by 25 + /// stamping an opaque layer behind every child — including the 26 + /// terminal surface and empty-state area. 27 + @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity 28 + 29 + var body: some View { 30 + let state = store.state 31 + let books = state.orderedShelfBooks() 32 + let openBookID = state.openShelfBookID 33 + let openIndex = openBookID.flatMap { id in 34 + books.firstIndex(where: { $0.id == id }) 35 + } 36 + 37 + HStack(spacing: 0) { 38 + if let openIndex { 39 + spineStack(books: Array(books[0...openIndex]), openIndex: openIndex, baseOffset: 0) 40 + openBookArea(for: books[openIndex], state: state) 41 + .transition(.opacity) 42 + let rightStart = openIndex + 1 43 + if rightStart < books.count { 44 + spineStack( 45 + books: Array(books[rightStart..<books.count]), 46 + openIndex: openIndex, 47 + baseOffset: rightStart 48 + ) 49 + } 50 + } else { 51 + spineStack(books: books, openIndex: nil, baseOffset: 0) 52 + emptyOpenArea() 53 + } 54 + } 55 + .frame(maxWidth: .infinity, maxHeight: .infinity) 56 + .background(Color(nsColor: .windowBackgroundColor).opacity(surfaceBackgroundOpacity)) 57 + // Animate on every openBookID change — covers both Shelf-originated 58 + // book switches (which also set their own TCA animation) and 59 + // left-nav-originated switches, so the spine flow is consistent 60 + // regardless of entry point. 61 + .animation(.easeInOut(duration: 0.2), value: openBookID) 62 + } 63 + 64 + /// `baseOffset` is the index of `books.first` within the full ordered 65 + /// list, so we can reconstruct each spine's global index and compute 66 + /// its distance to `openIndex` without re-scanning the full list. 67 + @ViewBuilder 68 + private func spineStack(books: [ShelfBook], openIndex: Int?, baseOffset: Int) -> some View { 69 + HStack(spacing: 0) { 70 + ForEach(Array(books.enumerated()), id: \.element.id) { localIndex, book in 71 + let globalIndex = baseOffset + localIndex 72 + let distance = openIndex.map { abs(globalIndex - $0) } 73 + let open = globalIndex == openIndex 74 + ShelfSpineView( 75 + book: book, 76 + isOpen: open, 77 + distanceFromOpen: distance, 78 + terminalState: terminalManager.stateIfExists(for: book.id), 79 + onOpenBook: { openBook(book, selectingTab: nil) }, 80 + onSelectTab: { tabID in openBook(book, selectingTab: tabID) }, 81 + onNewTab: { 82 + // On a closed spine, `+` doubles as "pull this book out and 83 + // start a fresh tab". Sequencing is fine because TCA runs 84 + // reducers synchronously — `newTerminal` will observe the 85 + // new `selectedTerminalWorktree` set by `selectWorktree`. 86 + switchToBookIfNeeded(book) 87 + createTab() 88 + }, 89 + onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil, 90 + onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil, 91 + onRemoveBook: { removeBook(book) } 92 + ) 93 + .matchedGeometryEffect(id: book.id, in: spineNamespace) 94 + } 95 + } 96 + } 97 + 98 + /// Dispatch the open-book action only when `book` isn't already the open 99 + /// one — idempotent helper for taps that imply a book change. 100 + private func switchToBookIfNeeded(_ book: ShelfBook) { 101 + guard !isOpen(book) else { return } 102 + switch book.kind { 103 + case .worktree: 104 + store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2)) 105 + case .plainFolder: 106 + store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2)) 107 + } 108 + } 109 + 110 + private func performSplit(direction: String) { 111 + guard let openID = store.state.openShelfBookID, 112 + let state = terminalManager.stateIfExists(for: openID) 113 + else { return } 114 + _ = state.performBindingActionOnFocusedSurface(direction) 115 + } 116 + 117 + /// "Remove Book" context action. Worktree books funnel through the 118 + /// existing archive flow (which shows confirmation + progress); plain 119 + /// folder books go through repository removal. Both pathways 120 + /// eventually drop the book off the Shelf via the same prune logic 121 + /// that drives the left navigation. 122 + private func removeBook(_ book: ShelfBook) { 123 + switch book.kind { 124 + case .worktree: 125 + store.send(.worktreeLifecycle(.requestArchiveWorktree(book.id, book.repositoryID))) 126 + case .plainFolder: 127 + store.send(.repositoryManagement(.requestRemoveRepository(book.repositoryID))) 128 + } 129 + } 130 + 131 + private func isOpen(_ book: ShelfBook) -> Bool { 132 + store.state.openShelfBookID == book.id 133 + } 134 + 135 + @ViewBuilder 136 + private func openBookArea(for book: ShelfBook, state: RepositoriesFeature.State) -> some View { 137 + if let worktree = state.selectedTerminalWorktree, worktree.id == book.id { 138 + let shouldFocus = state.shouldFocusTerminal(for: worktree.id) 139 + ShelfOpenBookView( 140 + worktree: worktree, 141 + manager: terminalManager, 142 + shouldRunSetupScript: state.pendingSetupScriptWorktreeIDs.contains(worktree.id), 143 + forceAutoFocus: shouldFocus 144 + ) 145 + .frame(maxWidth: .infinity, maxHeight: .infinity) 146 + .id(worktree.id) 147 + .onAppear { 148 + if shouldFocus { 149 + store.send(.worktreeCreation(.consumeTerminalFocus(worktree.id))) 150 + } 151 + } 152 + } else { 153 + emptyOpenArea() 154 + } 155 + } 156 + 157 + @ViewBuilder 158 + private func emptyOpenArea() -> some View { 159 + VStack(spacing: 10) { 160 + Image(systemName: "books.vertical") 161 + .font(.system(size: 40)) 162 + .foregroundStyle(.secondary) 163 + .accessibilityHidden(true) 164 + Text("No book selected") 165 + .font(.headline) 166 + Text("Click a spine to open a book.") 167 + .font(.callout) 168 + .foregroundStyle(.secondary) 169 + } 170 + .frame(maxWidth: .infinity, maxHeight: .infinity) 171 + } 172 + 173 + /// Open `book` and optionally select a specific tab on it. For the open 174 + /// book's own tab slots (no book change), this skips the worktree 175 + /// re-selection and just tells the tab manager to switch tab. 176 + private func openBook(_ book: ShelfBook, selectingTab tabID: TerminalTabID?) { 177 + let isAlreadyOpen = store.state.openShelfBookID == book.id 178 + if let tabID, isAlreadyOpen, let state = terminalManager.stateIfExists(for: book.id) { 179 + state.tabManager.selectTab(tabID) 180 + return 181 + } 182 + // Animate the spine flow and terminal crossfade. The duration and 183 + // curve mirror the Shelf design doc: ~200ms ease-in-out, snappy but 184 + // legible so the user can read each spine's movement. 185 + switch book.kind { 186 + case .worktree: 187 + store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2)) 188 + case .plainFolder: 189 + store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2)) 190 + } 191 + if let tabID { 192 + // Apply tab selection eagerly; the target book's state already exists 193 + // if the user has opened it before. For first-time opens the tab 194 + // manager seeds a default tab which we won't override. 195 + terminalManager.stateIfExists(for: book.id)?.tabManager.selectTab(tabID) 196 + } 197 + } 198 + }
+4 -2
supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift
··· 231 231 state.onTabCreated = { [weak self] in 232 232 self?.emit(.tabCreated(worktreeID: worktree.id)) 233 233 } 234 - state.onTabClosed = { [weak self] in 235 - self?.emit(.tabClosed(worktreeID: worktree.id)) 234 + state.onTabClosed = { [weak self, weak state] in 235 + guard let self else { return } 236 + let remaining = state?.tabManager.tabs.count ?? 0 237 + emit(.tabClosed(worktreeID: worktree.id, remainingTabs: remaining)) 236 238 } 237 239 state.onFocusChanged = { [weak self] surfaceID in 238 240 self?.emit(.focusChanged(worktreeID: worktree.id, surfaceID: surfaceID))
+16
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 840 840 lastEmittedFocusSurfaceId = nil 841 841 } 842 842 emitTaskStatusIfChanged() 843 + // Signal "this worktree now has tabs" so downstream Shelf 844 + // bookkeeping (`markWorktreeOpened` via `terminalEvent(.tabCreated)`) 845 + // adds the restored worktree to `openedWorktreeIDs`. Without this 846 + // emit, only the active worktree (which goes through 847 + // `.selectWorktree` on `.layoutRestored`) shows as a book on the 848 + // Shelf — every other restored worktree is missing, even though 849 + // the sidebar lists it and its terminal state is live. 850 + if !restoredTabs.isEmpty { 851 + onTabCreated?() 852 + } 843 853 terminalStateLogger.info( 844 854 "[LayoutRestore] applySnapshot: success, restored \(restoredTabs.count) tab(s)" 845 855 + " selectedTab=\(selectedTabID?.rawValue.uuidString ?? "nil")" ··· 1585 1595 if tabId == runScriptTabId { 1586 1596 setRunScriptTabId(nil) 1587 1597 } 1598 + // Mirror `state.closeTab(_:)`'s `onTabClosed` emit: this path 1599 + // fires when the shell process exits (ghostty-driven close) 1600 + // and historically skipped the callback, which meant the 1601 + // Shelf's "retire the book when its last tab closes" logic 1602 + // never saw this very common path. 1603 + onTabClosed?() 1588 1604 return 1589 1605 } 1590 1606 updateTree(newTree, for: tabId)
+10 -8
supacode/Features/Terminal/Views/WindowFocusObserverView.swift
··· 1 1 import AppKit 2 2 import SwiftUI 3 3 4 - private let windowFocusLogger = SupaLogger("WindowFocus") 5 - 6 4 struct WindowActivityState: Equatable { 7 5 let isKeyWindow: Bool 8 6 let isVisible: Bool ··· 51 49 clearObservers() 52 50 observedWindow = window 53 51 guard let window else { 54 - emitActivityIfNeeded(force: true) 52 + // View is being torn down from its window (e.g. a sibling view 53 + // swap in SwiftUI). The window itself is not going away — other 54 + // observers watching the same `WorktreeTerminalState` are still 55 + // live and reflect the real window activity. Emitting an 56 + // inactive signal here would poison the shared state's 57 + // `lastWindowIsKey`/`lastWindowIsVisible`, causing 58 + // `applySurfaceActivity` to demote focus even though the window 59 + // is still key. Just stop observing silently and let the 60 + // surviving observer drive state. This branch is covered by 61 + // `WindowFocusObserverViewTests.detachFromWindowEmitsNothingNew`. 55 62 return 56 63 } 57 64 let center = NotificationCenter.default ··· 94 101 return 95 102 } 96 103 lastEmittedActivity = activity 97 - windowFocusLogger.info( 98 - "[TerminalWake] activityChanged key=\(activity.isKeyWindow) " 99 - + "visible=\(activity.isVisible) force=\(force) " 100 - + "windowNumber=\(window?.windowNumber ?? -1)" 101 - ) 102 104 onWindowActivityChanged(activity) 103 105 } 104 106
+25 -3
supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift
··· 119 119 private var lastPerformKeyEvent: TimeInterval? 120 120 private var currentCursor: NSCursor = .iBeam 121 121 private var focused = false 122 + private var detachedFocusClearTask: Task<Void, Never>? 122 123 private var markedText = NSMutableAttributedString() 123 124 private var keyboardLayoutChangeKeyUpSuppression: KeyboardLayoutChangeKeyUpSuppression? 124 125 private var keyTextAccumulator: [String]? ··· 473 474 override func viewDidMoveToWindow() { 474 475 super.viewDidMoveToWindow() 475 476 if window == nil { 476 - // SwiftUI can temporarily detach a pane while rebuilding split/zoom layout. 477 - // If we keep the stale local focus bit, detached panes still intercept bindings. 478 - focusDidChange(false) 477 + // SwiftUI can temporarily detach a pane while rebuilding split/zoom 478 + // layout — or when another SwiftUI subtree (e.g. Shelf) takes over 479 + // hosting the same surface. Clearing the focused bit immediately 480 + // here is wrong for the re-attach case: AppKit silently resigns the 481 + // surface without a call path we can observe, and same-window 482 + // re-attach does not trigger `becomeFirstResponder`, so the focused 483 + // bit never recovers. Delay the clear so a prompt re-attach 484 + // cancels it; only when the surface truly stays detached past the 485 + // grace window do we flip the bit. 486 + detachedFocusClearTask?.cancel() 487 + detachedFocusClearTask = Task { @MainActor [weak self] in 488 + try? await ContinuousClock().sleep(for: .milliseconds(150)) 489 + guard !Task.isCancelled, let self, self.window == nil else { return } 490 + focusDidChange(false) 491 + } 492 + } else { 493 + detachedFocusClearTask?.cancel() 494 + detachedFocusClearTask = nil 479 495 } 480 496 updateScreenObservers() 481 497 updateContentScale() ··· 570 586 func focusDidChange(_ focused: Bool) { 571 587 guard surface != nil else { return } 572 588 guard self.focused != focused else { return } 589 + // Retained as the single diagnostic entry point for focus regressions. 590 + // Filter `make log-stream | grep '\[ShelfFocus\] focusDidChange'` to 591 + // trace every focused-bit transition across the app. 592 + SupaLogger("SurfaceFocus").info( 593 + "[ShelfFocus] focusDidChange surface=\(debugID) \(self.focused) -> \(focused)" 594 + ) 573 595 self.focused = focused 574 596 if focused { 575 597 bridge.state.bellCount = 0
+1
supacodeTests/AppFeaturePlainFolderTerminalTests.swift
··· 65 65 66 66 await store.send(.repositories(.selectRepository(repository.id))) { 67 67 $0.repositories.selection = .repository(repository.id) 68 + $0.repositories.openedWorktreeIDs = [repository.id] 68 69 } 69 70 await store.receive(\.repositories.delegate.selectedWorktreeChanged) 70 71 await store.receive(\.worktreeSettingsLoaded) {
+3
supacodeTests/AppFeatureTerminalSetupScriptTests.swift
··· 81 81 } 82 82 83 83 await store.send(.terminalEvent(.tabCreated(worktreeID: worktree.id))) 84 + await store.receive(\.repositories.markWorktreeOpened) { 85 + $0.repositories.openedWorktreeIDs = [worktree.id] 86 + } 84 87 #expect(store.state.repositories.pendingSetupScriptWorktreeIDs.contains(worktree.id)) 85 88 await store.finish() 86 89 }
+14
supacodeTests/RepositoriesFeatureTests.swift
··· 727 727 await store.send(.selectWorktree(worktree.id)) { 728 728 $0.selection = .worktree(worktree.id) 729 729 $0.sidebarSelectedWorktreeIDs = [worktree.id] 730 + $0.openedWorktreeIDs = [worktree.id] 730 731 } 731 732 await store.receive(\.delegate.selectedWorktreeChanged) 732 733 } ··· 746 747 await store.send(.selectWorktree(wt2.id)) { 747 748 $0.selection = .worktree(wt2.id) 748 749 $0.sidebarSelectedWorktreeIDs = [wt2.id] 750 + $0.openedWorktreeIDs = [wt2.id] 749 751 } 750 752 await store.receive(\.delegate.selectedWorktreeChanged) 751 753 } ··· 781 783 await store.send(.selectRepository(repository.id)) { 782 784 $0.selection = .repository(repository.id) 783 785 $0.sidebarSelectedWorktreeIDs = [] 786 + $0.openedWorktreeIDs = [repository.id] 784 787 } 785 788 #expect( 786 789 store.state.selectedTerminalWorktree ··· 816 819 await store.receive(\.selectRepository) { 817 820 $0.selection = .repository(repository.id) 818 821 $0.sidebarSelectedWorktreeIDs = [] 822 + $0.openedWorktreeIDs = [repository.id] 819 823 } 820 824 await store.receive(\.delegate.selectedWorktreeChanged) 821 825 } ··· 842 846 await store.receive(\.selectRepository) { 843 847 $0.selection = .repository(repository.id) 844 848 $0.sidebarSelectedWorktreeIDs = [] 849 + $0.openedWorktreeIDs = [repository.id] 845 850 } 846 851 await store.receive(\.delegate.selectedWorktreeChanged) 847 852 } ··· 3714 3719 await store.receive(\.selectWorktree) { 3715 3720 $0.selection = .worktree(wt1.id) 3716 3721 $0.sidebarSelectedWorktreeIDs = [wt1.id] 3722 + $0.openedWorktreeIDs = [wt1.id] 3717 3723 } 3718 3724 await store.receive(\.delegate.selectedWorktreeChanged) 3719 3725 } ··· 3732 3738 await store.receive(\.selectWorktree) { 3733 3739 $0.selection = .worktree(wt2.id) 3734 3740 $0.sidebarSelectedWorktreeIDs = [wt2.id] 3741 + $0.openedWorktreeIDs = [wt2.id] 3735 3742 } 3736 3743 await store.receive(\.delegate.selectedWorktreeChanged) 3737 3744 } ··· 3748 3755 await store.receive(\.selectWorktree) { 3749 3756 $0.selection = .worktree(wt1.id) 3750 3757 $0.sidebarSelectedWorktreeIDs = [wt1.id] 3758 + $0.openedWorktreeIDs = [wt1.id] 3751 3759 } 3752 3760 await store.receive(\.delegate.selectedWorktreeChanged) 3753 3761 } ··· 3768 3776 await store.receive(\.selectWorktree) { 3769 3777 $0.selection = .worktree(wt2.id) 3770 3778 $0.sidebarSelectedWorktreeIDs = [wt2.id] 3779 + $0.openedWorktreeIDs = [wt2.id] 3771 3780 } 3772 3781 await store.receive(\.delegate.selectedWorktreeChanged) 3773 3782 } ··· 3784 3793 await store.receive(\.selectWorktree) { 3785 3794 $0.selection = .worktree(wt2.id) 3786 3795 $0.sidebarSelectedWorktreeIDs = [wt2.id] 3796 + $0.openedWorktreeIDs = [wt2.id] 3787 3797 } 3788 3798 await store.receive(\.delegate.selectedWorktreeChanged) 3789 3799 } ··· 3809 3819 await store.receive(\.selectWorktree) { 3810 3820 $0.selection = .worktree(worktree.id) 3811 3821 $0.sidebarSelectedWorktreeIDs = [worktree.id] 3822 + $0.openedWorktreeIDs = [worktree.id] 3812 3823 } 3813 3824 await store.receive(\.delegate.selectedWorktreeChanged) 3814 3825 } ··· 3831 3842 await store.receive(\.selectWorktree) { 3832 3843 $0.selection = .worktree(wt3.id) 3833 3844 $0.sidebarSelectedWorktreeIDs = [wt3.id] 3845 + $0.openedWorktreeIDs = [wt3.id] 3834 3846 } 3835 3847 await store.receive(\.delegate.selectedWorktreeChanged) 3836 3848 } ··· 3853 3865 await store.receive(\.selectWorktree) { 3854 3866 $0.selection = .worktree(wt1.id) 3855 3867 $0.sidebarSelectedWorktreeIDs = [wt1.id] 3868 + $0.openedWorktreeIDs = [wt1.id] 3856 3869 } 3857 3870 await store.receive(\.delegate.selectedWorktreeChanged) 3858 3871 } ··· 3901 3914 await store.receive(\.selectWorktree) { 3902 3915 $0.selection = .worktree(wt1.id) 3903 3916 $0.sidebarSelectedWorktreeIDs = [wt1.id] 3917 + $0.openedWorktreeIDs = [wt1.id] 3904 3918 } 3905 3919 await store.receive(\.delegate.selectedWorktreeChanged) 3906 3920 }
+199
supacodeTests/ShelfBookOrderingTests.swift
··· 1 + import Foundation 2 + import IdentifiedCollections 3 + import Testing 4 + 5 + @testable import supacode 6 + 7 + @MainActor 8 + struct ShelfBookOrderingTests { 9 + @Test func emptyRepositoriesProduceNoBooks() { 10 + let state = RepositoriesFeature.State() 11 + #expect(state.orderedShelfBooks().isEmpty) 12 + } 13 + 14 + @Test func unopenedWorktreesDoNotAppearOnShelf() { 15 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 16 + let main = Worktree( 17 + id: "/tmp/repo", 18 + name: "main", 19 + detail: "", 20 + workingDirectory: rootURL, 21 + repositoryRootURL: rootURL 22 + ) 23 + let repository = Repository( 24 + id: rootURL.path(percentEncoded: false), 25 + rootURL: rootURL, 26 + name: "repo", 27 + worktrees: IdentifiedArray(uniqueElements: [main]) 28 + ) 29 + var state = RepositoriesFeature.State(repositories: [repository]) 30 + state.repositoryRoots = [rootURL] 31 + state.repositoryOrderIDs = [repository.id] 32 + // Empty `openedWorktreeIDs` → no spines even though the worktree 33 + // exists. The Shelf only shows interactive books. 34 + #expect(state.orderedShelfBooks().isEmpty) 35 + } 36 + 37 + @Test func singleWorktreeRepositoryProducesOneBookPerOpenedWorktree() { 38 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 39 + let main = Worktree( 40 + id: "/tmp/repo", 41 + name: "main", 42 + detail: "", 43 + workingDirectory: rootURL, 44 + repositoryRootURL: rootURL 45 + ) 46 + let feature = Worktree( 47 + id: "/tmp/repo/feature", 48 + name: "feature/login", 49 + detail: "", 50 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"), 51 + repositoryRootURL: rootURL 52 + ) 53 + let repository = Repository( 54 + id: rootURL.path(percentEncoded: false), 55 + rootURL: rootURL, 56 + name: "repo", 57 + worktrees: IdentifiedArray(uniqueElements: [main, feature]) 58 + ) 59 + var state = RepositoriesFeature.State(repositories: [repository]) 60 + state.repositoryRoots = [rootURL] 61 + state.repositoryOrderIDs = [repository.id] 62 + state.openedWorktreeIDs = [main.id, feature.id] 63 + 64 + let books = state.orderedShelfBooks() 65 + #expect(books.count == 2) 66 + #expect(books[0].id == main.id) 67 + #expect(books[0].kind == .worktree) 68 + #expect(books[0].displayName == "main") 69 + #expect(books[1].id == feature.id) 70 + #expect(books[1].kind == .worktree) 71 + #expect(books[1].displayName == "feature/login") 72 + } 73 + 74 + @Test func unopenedWorktreesInOpenedRepoAreFiltered() { 75 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 76 + let main = Worktree( 77 + id: "/tmp/repo", 78 + name: "main", 79 + detail: "", 80 + workingDirectory: rootURL, 81 + repositoryRootURL: rootURL 82 + ) 83 + let feature = Worktree( 84 + id: "/tmp/repo/feature", 85 + name: "feature/login", 86 + detail: "", 87 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"), 88 + repositoryRootURL: rootURL 89 + ) 90 + let repository = Repository( 91 + id: rootURL.path(percentEncoded: false), 92 + rootURL: rootURL, 93 + name: "repo", 94 + worktrees: IdentifiedArray(uniqueElements: [main, feature]) 95 + ) 96 + var state = RepositoriesFeature.State(repositories: [repository]) 97 + state.repositoryRoots = [rootURL] 98 + state.repositoryOrderIDs = [repository.id] 99 + state.openedWorktreeIDs = [feature.id] // only feature has been opened 100 + 101 + let books = state.orderedShelfBooks() 102 + #expect(books.count == 1) 103 + #expect(books[0].id == feature.id) 104 + } 105 + 106 + @Test func plainFolderRepositoryProducesOnePlainFolderBook() { 107 + let rootURL = URL(fileURLWithPath: "/tmp/folder") 108 + let repository = Repository( 109 + id: rootURL.path(percentEncoded: false), 110 + rootURL: rootURL, 111 + name: "folder", 112 + kind: .plain, 113 + worktrees: [] 114 + ) 115 + var state = RepositoriesFeature.State(repositories: [repository]) 116 + state.repositoryRoots = [rootURL] 117 + state.repositoryOrderIDs = [repository.id] 118 + state.openedWorktreeIDs = [repository.id] 119 + 120 + let books = state.orderedShelfBooks() 121 + #expect(books.count == 1) 122 + #expect(books[0].id == repository.id) 123 + #expect(books[0].kind == .plainFolder) 124 + #expect(books[0].branchName == nil) 125 + #expect(books[0].displayName == "folder") 126 + } 127 + 128 + @Test func mixedRepositoriesPreserveOrder() { 129 + let gitRootURL = URL(fileURLWithPath: "/tmp/git") 130 + let gitMain = Worktree( 131 + id: "/tmp/git", 132 + name: "main", 133 + detail: "", 134 + workingDirectory: gitRootURL, 135 + repositoryRootURL: gitRootURL 136 + ) 137 + let gitRepo = Repository( 138 + id: gitRootURL.path(percentEncoded: false), 139 + rootURL: gitRootURL, 140 + name: "git", 141 + worktrees: IdentifiedArray(uniqueElements: [gitMain]) 142 + ) 143 + let plainRootURL = URL(fileURLWithPath: "/tmp/plain") 144 + let plainRepo = Repository( 145 + id: plainRootURL.path(percentEncoded: false), 146 + rootURL: plainRootURL, 147 + name: "plain", 148 + kind: .plain, 149 + worktrees: [] 150 + ) 151 + var state = RepositoriesFeature.State(repositories: [gitRepo, plainRepo]) 152 + state.repositoryRoots = [gitRootURL, plainRootURL] 153 + state.repositoryOrderIDs = [gitRepo.id, plainRepo.id] 154 + state.openedWorktreeIDs = [gitMain.id, plainRepo.id] 155 + 156 + let books = state.orderedShelfBooks() 157 + #expect(books.count == 2) 158 + #expect(books[0].id == gitMain.id) 159 + #expect(books[0].kind == .worktree) 160 + #expect(books[1].id == plainRepo.id) 161 + #expect(books[1].kind == .plainFolder) 162 + } 163 + 164 + @Test func openShelfBookIDFollowsSelectedWorktree() { 165 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 166 + let worktree = Worktree( 167 + id: "/tmp/repo", 168 + name: "main", 169 + detail: "", 170 + workingDirectory: rootURL, 171 + repositoryRootURL: rootURL 172 + ) 173 + let repository = Repository( 174 + id: rootURL.path(percentEncoded: false), 175 + rootURL: rootURL, 176 + name: "repo", 177 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 178 + ) 179 + var state = RepositoriesFeature.State(repositories: [repository]) 180 + state.selection = .worktree(worktree.id) 181 + 182 + #expect(state.openShelfBookID == worktree.id) 183 + } 184 + 185 + @Test func openShelfBookIDResolvesPlainFolderViaRepositoryID() { 186 + let rootURL = URL(fileURLWithPath: "/tmp/plain") 187 + let repository = Repository( 188 + id: rootURL.path(percentEncoded: false), 189 + rootURL: rootURL, 190 + name: "plain", 191 + kind: .plain, 192 + worktrees: [] 193 + ) 194 + var state = RepositoriesFeature.State(repositories: [repository]) 195 + state.selection = .repository(repository.id) 196 + 197 + #expect(state.openShelfBookID == repository.id) 198 + } 199 + }
+814
supacodeTests/ShelfFeatureTests.swift
··· 1 + import ComposableArchitecture 2 + import DependenciesTestSupport 3 + import Foundation 4 + import IdentifiedCollections 5 + import Sharing 6 + import Testing 7 + 8 + @testable import supacode 9 + 10 + @MainActor 11 + struct ShelfFeatureTests { 12 + @Test(.dependencies) func toggleShelfFromWorktreeEntersShelfWithoutRedirecting() async { 13 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 14 + let worktree = Worktree( 15 + id: "/tmp/repo/wt1", 16 + name: "wt1", 17 + detail: "", 18 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 19 + repositoryRootURL: rootURL 20 + ) 21 + let repository = Repository( 22 + id: rootURL.path(percentEncoded: false), 23 + rootURL: rootURL, 24 + name: "repo", 25 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 26 + ) 27 + var state = RepositoriesFeature.State(repositories: [repository]) 28 + state.selection = .worktree(worktree.id) 29 + let store = TestStore(initialState: state) { 30 + RepositoriesFeature() 31 + } 32 + 33 + await store.send(.toggleShelf) { 34 + $0.isShelfActive = true 35 + $0.openedWorktreeIDs = [worktree.id] 36 + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] 37 + } 38 + await store.finish() 39 + } 40 + 41 + @Test(.dependencies) func toggleShelfWhileActiveExitsShelf() async { 42 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 43 + let worktree = Worktree( 44 + id: "/tmp/repo/wt1", 45 + name: "wt1", 46 + detail: "", 47 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 48 + repositoryRootURL: rootURL 49 + ) 50 + let repository = Repository( 51 + id: rootURL.path(percentEncoded: false), 52 + rootURL: rootURL, 53 + name: "repo", 54 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 55 + ) 56 + var state = RepositoriesFeature.State(repositories: [repository]) 57 + state.selection = .worktree(worktree.id) 58 + state.isShelfActive = true 59 + let store = TestStore(initialState: state) { 60 + RepositoriesFeature() 61 + } 62 + 63 + await store.send(.toggleShelf) { 64 + $0.isShelfActive = false 65 + } 66 + await store.finish() 67 + } 68 + 69 + @Test(.dependencies) func toggleShelfWithoutWorktreesIsNoOp() async { 70 + let store = TestStore(initialState: RepositoriesFeature.State()) { 71 + RepositoriesFeature() 72 + } 73 + 74 + await store.send(.toggleShelf) 75 + await store.finish() 76 + } 77 + 78 + @Test(.dependencies) func toggleShelfFromCanvasRedirectsToWorktreeAndEntersShelf() async { 79 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 80 + let worktree = Worktree( 81 + id: "/tmp/repo/wt1", 82 + name: "wt1", 83 + detail: "", 84 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 85 + repositoryRootURL: rootURL 86 + ) 87 + let repository = Repository( 88 + id: rootURL.path(percentEncoded: false), 89 + rootURL: rootURL, 90 + name: "repo", 91 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 92 + ) 93 + var state = RepositoriesFeature.State(repositories: [repository]) 94 + state.selection = .canvas 95 + state.lastFocusedWorktreeID = worktree.id 96 + let store = TestStore(initialState: state) { 97 + RepositoriesFeature() 98 + } 99 + 100 + await store.send(.toggleShelf) { 101 + $0.isShelfActive = true 102 + } 103 + await store.receive(\.selectWorktree) { 104 + $0.selection = .worktree(worktree.id) 105 + $0.sidebarSelectedWorktreeIDs = [worktree.id] 106 + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] 107 + $0.openedWorktreeIDs = [worktree.id] 108 + } 109 + await store.receive(\.delegate.selectedWorktreeChanged) 110 + await store.finish() 111 + } 112 + 113 + @Test(.dependencies) func toggleShelfFromCanvasPrefersCanvasFocusedCard() async { 114 + // Canvas can have a focused card distinct from `lastFocusedWorktreeID` 115 + // (which is only updated while `selection` is `.worktree`). A direct 116 + // Canvas → Shelf switch should open *that* card as the active book. 117 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 118 + let worktreeA = Worktree( 119 + id: "/tmp/repo/wt-a", 120 + name: "wt-a", 121 + detail: "", 122 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-a"), 123 + repositoryRootURL: rootURL 124 + ) 125 + let worktreeB = Worktree( 126 + id: "/tmp/repo/wt-b", 127 + name: "wt-b", 128 + detail: "", 129 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-b"), 130 + repositoryRootURL: rootURL 131 + ) 132 + let repository = Repository( 133 + id: rootURL.path(percentEncoded: false), 134 + rootURL: rootURL, 135 + name: "repo", 136 + worktrees: IdentifiedArray(uniqueElements: [worktreeA, worktreeB]) 137 + ) 138 + var state = RepositoriesFeature.State(repositories: [repository]) 139 + state.selection = .canvas 140 + state.lastFocusedWorktreeID = worktreeA.id 141 + let store = TestStore(initialState: state) { 142 + RepositoriesFeature() 143 + } withDependencies: { 144 + $0.terminalClient.canvasFocusedWorktreeID = { worktreeB.id } 145 + } 146 + 147 + await store.send(.toggleShelf) { 148 + $0.isShelfActive = true 149 + } 150 + await store.receive(\.selectWorktree) { 151 + $0.selection = .worktree(worktreeB.id) 152 + $0.sidebarSelectedWorktreeIDs = [worktreeB.id] 153 + $0.pendingTerminalFocusWorktreeIDs = [worktreeB.id] 154 + $0.openedWorktreeIDs = [worktreeB.id] 155 + } 156 + await store.receive(\.delegate.selectedWorktreeChanged) 157 + await store.finish() 158 + } 159 + 160 + @Test(.dependencies) func toggleShelfFromArchivedRedirectsToWorktreeAndEntersShelf() async { 161 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 162 + let worktree = Worktree( 163 + id: "/tmp/repo/wt1", 164 + name: "wt1", 165 + detail: "", 166 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 167 + repositoryRootURL: rootURL 168 + ) 169 + let repository = Repository( 170 + id: rootURL.path(percentEncoded: false), 171 + rootURL: rootURL, 172 + name: "repo", 173 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 174 + ) 175 + var state = RepositoriesFeature.State(repositories: [repository]) 176 + state.selection = .archivedWorktrees 177 + let store = TestStore(initialState: state) { 178 + RepositoriesFeature() 179 + } 180 + 181 + await store.send(.toggleShelf) { 182 + $0.isShelfActive = true 183 + } 184 + await store.receive(\.selectWorktree) { 185 + $0.selection = .worktree(worktree.id) 186 + $0.sidebarSelectedWorktreeIDs = [worktree.id] 187 + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] 188 + $0.openedWorktreeIDs = [worktree.id] 189 + } 190 + await store.receive(\.delegate.selectedWorktreeChanged) 191 + await store.finish() 192 + } 193 + 194 + @Test(.dependencies) func selectingADifferentWorktreeKeepsShelfActive() async { 195 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 196 + let first = Worktree( 197 + id: "/tmp/repo/wt1", 198 + name: "wt1", 199 + detail: "", 200 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 201 + repositoryRootURL: rootURL 202 + ) 203 + let second = Worktree( 204 + id: "/tmp/repo/wt2", 205 + name: "wt2", 206 + detail: "", 207 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt2"), 208 + repositoryRootURL: rootURL 209 + ) 210 + let repository = Repository( 211 + id: rootURL.path(percentEncoded: false), 212 + rootURL: rootURL, 213 + name: "repo", 214 + worktrees: IdentifiedArray(uniqueElements: [first, second]) 215 + ) 216 + var state = RepositoriesFeature.State(repositories: [repository]) 217 + state.selection = .worktree(first.id) 218 + state.isShelfActive = true 219 + let store = TestStore(initialState: state) { 220 + RepositoriesFeature() 221 + } 222 + 223 + // Mirrors "user clicks second worktree in the left navigation 224 + // while in Shelf mode": Shelf must not exit; only the open book 225 + // changes via the new `selectedWorktreeID`. 226 + await store.send(.selectWorktree(second.id, focusTerminal: true)) { 227 + $0.selection = .worktree(second.id) 228 + $0.sidebarSelectedWorktreeIDs = [second.id] 229 + $0.pendingTerminalFocusWorktreeIDs = [second.id] 230 + $0.openedWorktreeIDs = [second.id] 231 + } 232 + await store.receive(\.delegate.selectedWorktreeChanged) 233 + await store.finish() 234 + } 235 + 236 + @Test(.dependencies) func selectCanvasClearsShelfActiveFlag() async { 237 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 238 + let worktree = Worktree( 239 + id: "/tmp/repo/wt1", 240 + name: "wt1", 241 + detail: "", 242 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 243 + repositoryRootURL: rootURL 244 + ) 245 + let repository = Repository( 246 + id: rootURL.path(percentEncoded: false), 247 + rootURL: rootURL, 248 + name: "repo", 249 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 250 + ) 251 + var state = RepositoriesFeature.State(repositories: [repository]) 252 + state.selection = .worktree(worktree.id) 253 + state.isShelfActive = true 254 + let store = TestStore(initialState: state) { 255 + RepositoriesFeature() 256 + } withDependencies: { 257 + $0.terminalClient.send = { _ in } 258 + } 259 + 260 + await store.send(.selectCanvas) { 261 + $0.preCanvasWorktreeID = worktree.id 262 + $0.preCanvasTerminalTargetID = worktree.id 263 + $0.isShelfActive = false 264 + $0.selection = .canvas 265 + $0.sidebarSelectedWorktreeIDs = [] 266 + } 267 + await store.finish() 268 + } 269 + 270 + @Test(.dependencies) func selectShelfBookByIndexDispatchesWorktreeSelection() async { 271 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 272 + let wt1 = Worktree( 273 + id: "/tmp/repo", 274 + name: "main", 275 + detail: "", 276 + workingDirectory: rootURL, 277 + repositoryRootURL: rootURL 278 + ) 279 + let wt2 = Worktree( 280 + id: "/tmp/repo/feature", 281 + name: "feature", 282 + detail: "", 283 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"), 284 + repositoryRootURL: rootURL 285 + ) 286 + let repo = Repository( 287 + id: rootURL.path(percentEncoded: false), 288 + rootURL: rootURL, 289 + name: "repo", 290 + worktrees: IdentifiedArray(uniqueElements: [wt1, wt2]) 291 + ) 292 + var state = RepositoriesFeature.State(repositories: [repo]) 293 + state.repositoryRoots = [rootURL] 294 + state.repositoryOrderIDs = [repo.id] 295 + state.selection = .worktree(wt1.id) 296 + state.isShelfActive = true 297 + state.openedWorktreeIDs = [wt1.id, wt2.id] 298 + let store = TestStore(initialState: state) { 299 + RepositoriesFeature() 300 + } 301 + 302 + await store.send(.selectShelfBook(2)) 303 + await store.receive(\.selectWorktree) { 304 + $0.selection = .worktree(wt2.id) 305 + $0.sidebarSelectedWorktreeIDs = [wt2.id] 306 + $0.pendingTerminalFocusWorktreeIDs = [wt2.id] 307 + } 308 + await store.receive(\.delegate.selectedWorktreeChanged) 309 + await store.finish() 310 + } 311 + 312 + @Test(.dependencies) func selectShelfBookOutOfRangeIsNoOp() async { 313 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 314 + let wt1 = Worktree( 315 + id: "/tmp/repo", 316 + name: "main", 317 + detail: "", 318 + workingDirectory: rootURL, 319 + repositoryRootURL: rootURL 320 + ) 321 + let repo = Repository( 322 + id: rootURL.path(percentEncoded: false), 323 + rootURL: rootURL, 324 + name: "repo", 325 + worktrees: IdentifiedArray(uniqueElements: [wt1]) 326 + ) 327 + var state = RepositoriesFeature.State(repositories: [repo]) 328 + state.repositoryRoots = [rootURL] 329 + state.repositoryOrderIDs = [repo.id] 330 + state.selection = .worktree(wt1.id) 331 + let store = TestStore(initialState: state) { 332 + RepositoriesFeature() 333 + } 334 + 335 + await store.send(.selectShelfBook(5)) 336 + await store.finish() 337 + } 338 + 339 + @Test(.dependencies) func selectNextShelfBookWrapsAround() async { 340 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 341 + let wt1 = Worktree( 342 + id: "/tmp/repo", 343 + name: "main", 344 + detail: "", 345 + workingDirectory: rootURL, 346 + repositoryRootURL: rootURL 347 + ) 348 + let wt2 = Worktree( 349 + id: "/tmp/repo/feature", 350 + name: "feature", 351 + detail: "", 352 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/feature"), 353 + repositoryRootURL: rootURL 354 + ) 355 + let repo = Repository( 356 + id: rootURL.path(percentEncoded: false), 357 + rootURL: rootURL, 358 + name: "repo", 359 + worktrees: IdentifiedArray(uniqueElements: [wt1, wt2]) 360 + ) 361 + var state = RepositoriesFeature.State(repositories: [repo]) 362 + state.repositoryRoots = [rootURL] 363 + state.repositoryOrderIDs = [repo.id] 364 + state.selection = .worktree(wt2.id) // Currently on the last book. 365 + state.openedWorktreeIDs = [wt1.id, wt2.id] 366 + let store = TestStore(initialState: state) { 367 + RepositoriesFeature() 368 + } 369 + 370 + await store.send(.selectNextShelfBook) 371 + // Wrapping: next-after-last lands back on the first book. 372 + await store.receive(\.selectWorktree) { 373 + $0.selection = .worktree(wt1.id) 374 + $0.sidebarSelectedWorktreeIDs = [wt1.id] 375 + $0.pendingTerminalFocusWorktreeIDs = [wt1.id] 376 + } 377 + await store.receive(\.delegate.selectedWorktreeChanged) 378 + await store.finish() 379 + } 380 + 381 + @Test(.dependencies) func selectNextWorktreeRoutesToTabNavigationInShelf() async { 382 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 383 + let wt1 = Worktree( 384 + id: "/tmp/repo/wt1", 385 + name: "wt1", 386 + detail: "", 387 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 388 + repositoryRootURL: rootURL 389 + ) 390 + let repo = Repository( 391 + id: rootURL.path(percentEncoded: false), 392 + rootURL: rootURL, 393 + name: "repo", 394 + worktrees: IdentifiedArray(uniqueElements: [wt1]) 395 + ) 396 + var state = RepositoriesFeature.State(repositories: [repo]) 397 + state.selection = .worktree(wt1.id) 398 + state.isShelfActive = true 399 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 400 + let store = TestStore(initialState: state) { 401 + RepositoriesFeature() 402 + } withDependencies: { 403 + $0.terminalClient.send = { command in 404 + sentCommands.withValue { $0.append(command) } 405 + } 406 + } 407 + 408 + await store.send(.selectNextWorktree) 409 + await store.finish() 410 + #expect(sentCommands.value == [.performBindingAction(wt1, action: "next_tab")]) 411 + } 412 + 413 + @Test(.dependencies) func selectPreviousWorktreeRoutesToTabNavigationInShelf() async { 414 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 415 + let wt1 = Worktree( 416 + id: "/tmp/repo/wt1", 417 + name: "wt1", 418 + detail: "", 419 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 420 + repositoryRootURL: rootURL 421 + ) 422 + let repo = Repository( 423 + id: rootURL.path(percentEncoded: false), 424 + rootURL: rootURL, 425 + name: "repo", 426 + worktrees: IdentifiedArray(uniqueElements: [wt1]) 427 + ) 428 + var state = RepositoriesFeature.State(repositories: [repo]) 429 + state.selection = .worktree(wt1.id) 430 + state.isShelfActive = true 431 + let sentCommands = LockIsolated<[TerminalClient.Command]>([]) 432 + let store = TestStore(initialState: state) { 433 + RepositoriesFeature() 434 + } withDependencies: { 435 + $0.terminalClient.send = { command in 436 + sentCommands.withValue { $0.append(command) } 437 + } 438 + } 439 + 440 + await store.send(.selectPreviousWorktree) 441 + await store.finish() 442 + #expect(sentCommands.value == [.performBindingAction(wt1, action: "previous_tab")]) 443 + } 444 + 445 + @Test(.dependencies) func selectNextWorktreeOutsideShelfStillCyclesWorktrees() async { 446 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 447 + let wt1 = Worktree( 448 + id: "/tmp/repo/wt1", 449 + name: "wt1", 450 + detail: "", 451 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 452 + repositoryRootURL: rootURL 453 + ) 454 + let wt2 = Worktree( 455 + id: "/tmp/repo/wt2", 456 + name: "wt2", 457 + detail: "", 458 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt2"), 459 + repositoryRootURL: rootURL 460 + ) 461 + let repo = Repository( 462 + id: rootURL.path(percentEncoded: false), 463 + rootURL: rootURL, 464 + name: "repo", 465 + worktrees: IdentifiedArray(uniqueElements: [wt1, wt2]) 466 + ) 467 + var state = RepositoriesFeature.State(repositories: [repo]) 468 + state.selection = .worktree(wt1.id) 469 + // Shelf NOT active — existing worktree-cycling behavior must survive. 470 + state.isShelfActive = false 471 + let store = TestStore(initialState: state) { 472 + RepositoriesFeature() 473 + } 474 + 475 + await store.send(.selectNextWorktree) 476 + await store.receive(\.selectWorktree) { 477 + $0.selection = .worktree(wt2.id) 478 + $0.sidebarSelectedWorktreeIDs = [wt2.id] 479 + $0.openedWorktreeIDs = [wt2.id] 480 + } 481 + await store.receive(\.delegate.selectedWorktreeChanged) 482 + await store.finish() 483 + } 484 + 485 + @Test(.dependencies) func markWorktreeClosedRemovesFromOpenedSet() async { 486 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 487 + let wt1 = Worktree( 488 + id: "/tmp/repo/wt1", 489 + name: "wt1", 490 + detail: "", 491 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 492 + repositoryRootURL: rootURL 493 + ) 494 + let repo = Repository( 495 + id: rootURL.path(percentEncoded: false), 496 + rootURL: rootURL, 497 + name: "repo", 498 + worktrees: IdentifiedArray(uniqueElements: [wt1]) 499 + ) 500 + var state = RepositoriesFeature.State(repositories: [repo]) 501 + state.openedWorktreeIDs = [wt1.id] 502 + state.selection = nil // Not currently selected, no auto-next needed. 503 + let store = TestStore(initialState: state) { 504 + RepositoriesFeature() 505 + } 506 + 507 + await store.send(.markWorktreeClosed(wt1.id)) { 508 + $0.openedWorktreeIDs = [] 509 + } 510 + await store.finish() 511 + } 512 + 513 + @Test(.dependencies) func markWorktreeClosedAdvancesToNextBookWhenAvailable() async { 514 + let fixture = threeWorktreeFixture() 515 + var state = RepositoriesFeature.State(repositories: [fixture.repo]) 516 + state.repositoryRoots = [fixture.repo.rootURL] 517 + state.repositoryOrderIDs = [fixture.repo.id] 518 + state.selection = .worktree(fixture.worktrees[1].id) // Middle book open. 519 + state.isShelfActive = true 520 + state.openedWorktreeIDs = Set(fixture.worktrees.map(\.id)) 521 + let store = TestStore(initialState: state) { 522 + RepositoriesFeature() 523 + } 524 + 525 + // Closing the middle book → replacement is the one AFTER it (wt3), 526 + // not the first book (wt1). The user stays close to where they 527 + // were on the shelf. 528 + let closingID = fixture.worktrees[1].id 529 + let nextID = fixture.worktrees[2].id 530 + await store.send(.markWorktreeClosed(closingID)) { 531 + $0.openedWorktreeIDs = [fixture.worktrees[0].id, nextID] 532 + } 533 + await store.receive(\.selectWorktree) { 534 + $0.selection = .worktree(nextID) 535 + $0.sidebarSelectedWorktreeIDs = [nextID] 536 + $0.pendingTerminalFocusWorktreeIDs = [nextID] 537 + $0.openedWorktreeIDs = [fixture.worktrees[0].id, nextID] 538 + } 539 + await store.receive(\.delegate.selectedWorktreeChanged) 540 + await store.finish() 541 + } 542 + 543 + @Test(.dependencies) func markWorktreeClosedFallsBackToPreviousBookWhenClosingLast() async { 544 + let fixture = threeWorktreeFixture() 545 + var state = RepositoriesFeature.State(repositories: [fixture.repo]) 546 + state.repositoryRoots = [fixture.repo.rootURL] 547 + state.repositoryOrderIDs = [fixture.repo.id] 548 + state.selection = .worktree(fixture.worktrees[2].id) // Last book open. 549 + state.isShelfActive = true 550 + state.openedWorktreeIDs = Set(fixture.worktrees.map(\.id)) 551 + let store = TestStore(initialState: state) { 552 + RepositoriesFeature() 553 + } 554 + 555 + // Closing the last book → no book after it, so the replacement is 556 + // the book BEFORE it (wt2). 557 + let closingID = fixture.worktrees[2].id 558 + let prevID = fixture.worktrees[1].id 559 + await store.send(.markWorktreeClosed(closingID)) { 560 + $0.openedWorktreeIDs = [fixture.worktrees[0].id, prevID] 561 + } 562 + await store.receive(\.selectWorktree) { 563 + $0.selection = .worktree(prevID) 564 + $0.sidebarSelectedWorktreeIDs = [prevID] 565 + $0.pendingTerminalFocusWorktreeIDs = [prevID] 566 + $0.openedWorktreeIDs = [fixture.worktrees[0].id, prevID] 567 + } 568 + await store.receive(\.delegate.selectedWorktreeChanged) 569 + await store.finish() 570 + } 571 + 572 + private struct ThreeWorktreeFixture { 573 + let repo: Repository 574 + let worktrees: [Worktree] 575 + } 576 + 577 + private func threeWorktreeFixture() -> ThreeWorktreeFixture { 578 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 579 + let worktrees = (1...3).map { index in 580 + Worktree( 581 + id: "/tmp/repo/wt\(index)", 582 + name: "wt\(index)", 583 + detail: "", 584 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt\(index)"), 585 + repositoryRootURL: rootURL 586 + ) 587 + } 588 + let repo = Repository( 589 + id: rootURL.path(percentEncoded: false), 590 + rootURL: rootURL, 591 + name: "repo", 592 + worktrees: IdentifiedArray(uniqueElements: worktrees) 593 + ) 594 + return ThreeWorktreeFixture(repo: repo, worktrees: worktrees) 595 + } 596 + 597 + @Test(.dependencies) func markWorktreeClosedLeavesSelectionAloneInNormalView() async { 598 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 599 + let wt1 = Worktree( 600 + id: "/tmp/repo/wt1", 601 + name: "wt1", 602 + detail: "", 603 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 604 + repositoryRootURL: rootURL 605 + ) 606 + let wt2 = Worktree( 607 + id: "/tmp/repo/wt2", 608 + name: "wt2", 609 + detail: "", 610 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt2"), 611 + repositoryRootURL: rootURL 612 + ) 613 + let repo = Repository( 614 + id: rootURL.path(percentEncoded: false), 615 + rootURL: rootURL, 616 + name: "repo", 617 + worktrees: IdentifiedArray(uniqueElements: [wt1, wt2]) 618 + ) 619 + var state = RepositoriesFeature.State(repositories: [repo]) 620 + state.selection = .worktree(wt1.id) 621 + state.isShelfActive = false // Normal view. 622 + state.openedWorktreeIDs = [wt1.id, wt2.id] 623 + let store = TestStore(initialState: state) { 624 + RepositoriesFeature() 625 + } 626 + 627 + // In normal view, removing from the opened set must not also steal 628 + // selection away from the user — they are actively on wt1. 629 + await store.send(.markWorktreeClosed(wt1.id)) { 630 + $0.openedWorktreeIDs = [wt2.id] 631 + } 632 + await store.finish() 633 + } 634 + 635 + @Test(.dependencies) func markWorktreeOpenedAddsToOpenedSet() async { 636 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 637 + let wt1 = Worktree( 638 + id: "/tmp/repo/wt1", 639 + name: "wt1", 640 + detail: "", 641 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 642 + repositoryRootURL: rootURL 643 + ) 644 + let repo = Repository( 645 + id: rootURL.path(percentEncoded: false), 646 + rootURL: rootURL, 647 + name: "repo", 648 + worktrees: IdentifiedArray(uniqueElements: [wt1]) 649 + ) 650 + let store = TestStore(initialState: RepositoriesFeature.State(repositories: [repo])) { 651 + RepositoriesFeature() 652 + } 653 + 654 + // Mirrors the AppFeature forwarding `terminalEvent(.tabCreated)` → 655 + // `.markWorktreeOpened`. Any tab creation (including restored 656 + // layouts) adds its worktree to the Shelf's visible book set. 657 + await store.send(.markWorktreeOpened(wt1.id)) { 658 + $0.openedWorktreeIDs = [wt1.id] 659 + } 660 + await store.finish() 661 + } 662 + 663 + @Test(.dependencies) func selectArchivedWorktreesClearsShelfActiveFlag() async { 664 + let rootURL = URL(fileURLWithPath: "/tmp/repo") 665 + let worktree = Worktree( 666 + id: "/tmp/repo/wt1", 667 + name: "wt1", 668 + detail: "", 669 + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt1"), 670 + repositoryRootURL: rootURL 671 + ) 672 + let repository = Repository( 673 + id: rootURL.path(percentEncoded: false), 674 + rootURL: rootURL, 675 + name: "repo", 676 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 677 + ) 678 + var state = RepositoriesFeature.State(repositories: [repository]) 679 + state.selection = .worktree(worktree.id) 680 + state.isShelfActive = true 681 + let store = TestStore(initialState: state) { 682 + RepositoriesFeature() 683 + } 684 + 685 + await store.send(.selectArchivedWorktrees) { 686 + $0.isShelfActive = false 687 + $0.selection = .archivedWorktrees 688 + $0.sidebarSelectedWorktreeIDs = [] 689 + } 690 + await store.receive(\.delegate.selectedWorktreeChanged) 691 + await store.finish() 692 + } 693 + 694 + @Test(.dependencies) func defaultViewShelfPreferenceDispatchesToggleAfterSnapshot() async { 695 + let repoRoot = "/tmp/default-shelf-repo" 696 + let rootURL = URL(fileURLWithPath: repoRoot) 697 + let worktree = Worktree( 698 + id: "\(repoRoot)/main", 699 + name: "main", 700 + detail: "", 701 + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), 702 + repositoryRootURL: rootURL 703 + ) 704 + let repository = Repository( 705 + id: repoRoot, 706 + rootURL: rootURL, 707 + name: "repo", 708 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 709 + ) 710 + 711 + @Shared(.settingsFile) var settingsFile 712 + $settingsFile.withLock { 713 + var updated = $0.global 714 + updated.defaultViewMode = .shelf 715 + $0.global = updated 716 + } 717 + // Restore settings after the test so `@Shared` state doesn't leak 718 + // across parallel test runs in the same process. 719 + defer { 720 + $settingsFile.withLock { 721 + var updated = $0.global 722 + updated.defaultViewMode = .normal 723 + $0.global = updated 724 + } 725 + } 726 + 727 + // Drive only the reducer action under test, not the full `.task` 728 + // flow — the launch pipeline is covered elsewhere. This isolates 729 + // the new "default view shelf" hook from unrelated effects and 730 + // keeps the assertion surface minimal. 731 + var initialState = RepositoriesFeature.State() 732 + initialState.lastFocusedWorktreeID = worktree.id 733 + initialState.shouldRestoreLastFocusedWorktree = true 734 + initialState.snapshotPersistencePhase = .restoring 735 + let store = TestStore(initialState: initialState) { 736 + RepositoriesFeature() 737 + } 738 + 739 + await store.send(.repositorySnapshotLoaded([repository])) { 740 + $0.repositories = [repository] 741 + $0.repositoryRoots = [rootURL] 742 + $0.selection = .worktree(worktree.id) 743 + $0.shouldRestoreLastFocusedWorktree = false 744 + $0.isInitialLoadComplete = true 745 + } 746 + await store.receive(\.delegate.repositoriesChanged) 747 + await store.receive(\.delegate.selectedWorktreeChanged) 748 + await store.receive(\.toggleShelf) { 749 + $0.isShelfActive = true 750 + $0.openedWorktreeIDs = [worktree.id] 751 + $0.pendingTerminalFocusWorktreeIDs = [worktree.id] 752 + } 753 + await store.finish() 754 + } 755 + 756 + @Test(.dependencies) func defaultViewShelfDefersDuringLayoutRestore() async { 757 + // When Layout Restore is active the snapshot-load hook must stay 758 + // quiet: Layout Restore clears selection and replays tabs, so 759 + // entering Shelf here would flash an empty open area and leave a 760 + // stray spine if the restored active book differs from 761 + // `lastFocusedWorktreeID`. The AppFeature hook on `.layoutRestored` 762 + // takes over once Layout Restore has settled. 763 + let repoRoot = "/tmp/default-shelf-restore-repo" 764 + let rootURL = URL(fileURLWithPath: repoRoot) 765 + let worktree = Worktree( 766 + id: "\(repoRoot)/main", 767 + name: "main", 768 + detail: "", 769 + workingDirectory: URL(fileURLWithPath: "\(repoRoot)/main"), 770 + repositoryRootURL: rootURL 771 + ) 772 + let repository = Repository( 773 + id: repoRoot, 774 + rootURL: rootURL, 775 + name: "repo", 776 + worktrees: IdentifiedArray(uniqueElements: [worktree]) 777 + ) 778 + 779 + @Shared(.settingsFile) var settingsFile 780 + $settingsFile.withLock { 781 + var updated = $0.global 782 + updated.defaultViewMode = .shelf 783 + $0.global = updated 784 + } 785 + defer { 786 + $settingsFile.withLock { 787 + var updated = $0.global 788 + updated.defaultViewMode = .normal 789 + $0.global = updated 790 + } 791 + } 792 + 793 + var initialState = RepositoriesFeature.State() 794 + initialState.lastFocusedWorktreeID = worktree.id 795 + initialState.shouldRestoreLastFocusedWorktree = true 796 + initialState.snapshotPersistencePhase = .restoring 797 + initialState.launchRestoreMode = .restoreLayout 798 + let store = TestStore(initialState: initialState) { 799 + RepositoriesFeature() 800 + } 801 + 802 + await store.send(.repositorySnapshotLoaded([repository])) { 803 + $0.repositories = [repository] 804 + $0.repositoryRoots = [rootURL] 805 + $0.selection = .worktree(worktree.id) 806 + $0.shouldRestoreLastFocusedWorktree = false 807 + $0.isInitialLoadComplete = true 808 + } 809 + await store.receive(\.delegate.repositoriesChanged) 810 + await store.receive(\.delegate.selectedWorktreeChanged) 811 + // No `.toggleShelf` here — the Layout Restore path is responsible. 812 + await store.finish() 813 + } 814 + }
+46
supacodeTests/WindowFocusObserverViewTests.swift
··· 1 + import AppKit 2 + import Testing 3 + 4 + @testable import supacode 5 + 6 + /// Regression guard for the Shelf-entry focus bug: when a 7 + /// `WindowFocusObserverNSView` is torn off its host window (e.g. one 8 + /// SwiftUI subtree is swapped for another that continues to observe the 9 + /// same `WorktreeTerminalState`), the teardown must NOT fire an 10 + /// "inactive" activity callback. An inactive callback would overwrite 11 + /// the shared state's cached window-key flag and demote the surface's 12 + /// focused bit even though the window is still key. 13 + @MainActor 14 + struct WindowFocusObserverViewTests { 15 + @Test func detachFromWindowEmitsNothingNew() { 16 + let window = NSWindow( 17 + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), 18 + styleMask: [.titled], 19 + backing: .buffered, 20 + defer: true 21 + ) 22 + let observer = WindowFocusObserverNSView() 23 + var emits: [WindowActivityState] = [] 24 + observer.onWindowActivityChanged = { emits.append($0) } 25 + 26 + // Attach: the observer may emit the window's current activity state 27 + // once (headless test windows report key=false, visible=false — so 28 + // that initial emit is itself "inactive" here; that's fine). We 29 + // capture the count so the detach assertion compares against this 30 + // baseline instead of against an idealized non-inactive list. 31 + window.contentView?.addSubview(observer) 32 + let emitsAtAttach = emits.count 33 + 34 + // Detach: no new emit should fire, regardless of what the window's 35 + // current state is. Prior to the fix, `updateObservers` called 36 + // `emitActivityIfNeeded(force: true)` on detach, which always sent 37 + // a (key=false, visible=false) inactive signal and poisoned the 38 + // shared `WorktreeTerminalState`'s cached window-key flag. 39 + observer.removeFromSuperview() 40 + let emitsAfterDetach = emits.count 41 + #expect( 42 + emitsAfterDetach == emitsAtAttach, 43 + "Detach must not emit new activity. attach=\(emitsAtAttach), afterDetach=\(emitsAfterDetach)" 44 + ) 45 + } 46 + }