···11+# Shelf Book-Switch Jank Investigation
22+33+Last updated: 2026-04-29
44+Status: Closed for now. The current branch keeps the fixes that improved trace data or UX without visible regressions, and drops the later experiments that introduced artifacts or reduced interaction quality.
55+66+## Summary
77+88+Switching books quickly in Shelf mode showed visible frame drops, especially when switching with keyboard shortcuts. The first investigation pass found a large SwiftUI invalidation storm in the sidebar. The second pass narrowed the keyboard-path cost and simplified the Shelf spine layout.
99+1010+The retained changes are:
1111+1212+- `RepositorySectionView` no longer reads `WorktreeTerminalManager.states` just to render the tab-count badge. The read moved into a leaf view so invalidation stops at the badge.
1313+- `orderedShelfBooks()` avoids per-body `Dictionary`, `Set`, and full `WorktreeRowModel` construction.
1414+- Shelf-originated book switches no longer send an extra TCA animation transaction on top of the view-level `openBookID` animation.
1515+- `CommandKeyObserver` only enters shortcut-hint mode for bare Command or bare Control, not every shortcut chord containing Command/Control.
1616+- The sidebar key-forwarding `.onKeyPress` modifier is not installed while Shelf is active. This removed the `EnvironmentWriter: KeyPressModifier` invalidation path from the Shelf switching trace.
1717+- Shelf spines are rendered from a single `ForEach`, removing the cross-list `matchedGeometryEffect` between left/right spine stacks.
1818+- The open-book opacity transition was removed. Trace improvement was small, but the user reported no visible UX regression, so it is kept as a low-risk cleanup.
1919+- Long-term signposts remain in place for future regressions.
2020+2121+Experiments that were tried and rejected:
2222+2323+- Removing `.id(worktree.id)` from `ShelfOpenBookView`. It was behaviorally safe but gave no measurable win.
2424+- Fully disabling or isolating the Shelf animation. It either only improved perception or caused visible terminal animation loss.
2525+- Rendering the terminal/open area in an overlay outside the spine layout animation. This created a black-edge artifact during spine movement and made several SwiftUI cause counters worse.
2626+- Removing context menus from closed-spine tab slots. This hurt interaction and did not improve hitches.
2727+2828+## Final Trace Shape
2929+3030+The final useful comparison line is run13/run15, after the keyboard-path and single-`ForEach` fixes. Hangs were treated as unreliable in this phase because they appeared and disappeared across similar runs; hitches and SwiftUI cause edges were more useful.
3131+3232+| Run | Main change | Selects | All hitches/select | Select-window hitches/select | Key SwiftUI result |
3333+| --- | --- | ---: | ---: | ---: | --- |
3434+| run10 | Rejected terminal animation isolation experiment | 25 | 296.5 ms | not used | UX regression; terminal animation disappeared |
3535+| run11 | Bare Command/Control hint mode | 25 | 235.7 ms | 186.7 ms | `@Observable CommandKeyObserver.(Bool)` disappeared; `KeyPressModifier` fell to 116,284 source edges |
3636+| run12 | Disable sidebar key forwarding in Shelf | 29 | 196.1 ms | 151.7 ms | `EnvironmentWriter: KeyPressModifier` fell to 0; `WorktreeRowsView.body [skipped]` fell sharply |
3737+| run13 | Single `ForEach`, no matched geometry | 29 | 176.4 ms | 144.4 ms | `Layout: MatchedFrame` fell to 0; `LayoutChildGeometries` source edges fell from 111,421 to 4,279 |
3838+| run14 | Rejected terminal overlay | 29 | 167.1 ms | 136.3 ms | Small hitch win, but `AnimatableFrameAttribute`, responders, and opacity renderer all worsened; visible black edge |
3939+| run15 | Remove open-book opacity transition | 28 | 250.6 ms | 140.3 ms | Small select-window win; outside-window hitches dominated all-hitch total |
4040+| run16 | Rejected context-menu scoping | 27 | 180.6 ms | 145.1 ms | Interaction regression; `View Responders` worsened |
4141+4242+`select-window hitches/select` is the preferred number for book-switch work because it only counts hitches between the first and last `reducer.selectWorktree` signpost. The "all hitches/select" number can be dominated by startup, recording, or unrelated post-workload spikes, as run15 shows.
4343+4444+## Problem
4545+4646+Quickly switching between Shelf books produced obvious frame drops. Initial Instruments captures showed:
4747+4848+- severe hangs in early traces, including one multi-second main-thread block
4949+- hundreds of thousands of SwiftUI update/cause edges in short recordings
5050+- visible animation breakage during the 0.2 s spine-flow animation
5151+5252+The problem was not one slow reducer or one slow `NSViewRepresentable`. The reducer and Ghostty bridge signposts consistently measured in sub-millisecond ranges. The heavy work was mostly SwiftUI invalidation, layout, responder, and display-list work between our signposts.
5353+5454+## Investigation Timeline
5555+5656+### 1. Static Suspicions
5757+5858+The first static pass identified several plausible sources:
5959+6060+1. `ShelfView.body` recomputes `orderedShelfBooks()` on every body call.
6161+2. `.id(worktree.id)` on `ShelfOpenBookView` might force expensive subtree teardown.
6262+3. Shelf book switching was applying animation twice: once in the TCA action send and once via `.animation(value: openBookID)`.
6363+4. Sidebar repository rows read `terminalManager.stateIfExists(...)` while rendering tab counts.
6464+5. The Shelf layout split spines across left/right `ForEach` lists and bridged identity with `matchedGeometryEffect`.
6565+6. Keyboard-based switching might be over-invalidating shortcut hint UI through `CommandKeyObserver` and `.onKeyPress`.
6666+6767+Most of these were real; only some moved user-visible performance.
6868+6969+### 2. Sidebar Tab-Count Subscription Storm
7070+7171+Early `swiftui-causes` data showed `RepositorySectionView.body` and `@Observable WorktreeTerminalManager.(Dictionary<String, WorktreeTerminalState>)` as top offenders.
7272+7373+The cause was:
7474+7575+```swift
7676+RepoHeaderRow(
7777+ ...,
7878+ tabCount: Self.openTabCount(for: repository, terminalManager: terminalManager),
7979+ ...
8080+)
8181+```
8282+8383+`openTabCount` iterated worktrees and called `terminalManager.stateIfExists(...)` from the parent sidebar row. That subscribed every repository section body to the global terminal state dictionary, so unrelated terminal activity fanned out through the sidebar.
8484+8585+Fixes landed in `0fe682cb`:
8686+8787+- Move tab-count reads into `RepoHeaderTabCountBadge`.
8888+- Rewrite `orderedShelfBooks()` to use direct repository/worktree ordering.
8989+- Remove the redundant Shelf-originated action animation.
9090+- Add `SupaLogger` signpost helpers and focused Shelf/Ghostty signposts.
9191+9292+Result:
9393+9494+| Metric | Before | After |
9595+| --- | ---: | ---: |
9696+| `RepositorySectionView.body` cause edges | 184,681 | 14,456 |
9797+| `WorktreeTerminalManager.states` cause edges | 196,968 | 14,456 |
9898+| Severe hangs from this storm | present | gone |
9999+100100+This was the largest unambiguous win.
101101+102102+### 3. `.id(worktree.id)` Experiment
103103+104104+Hypothesis: the residual cost came from `ShelfOpenBookView` teardown/remount on every book switch.
105105+106106+We tried migrating focus logic into `onChange(of: worktree.id, initial: true)` and removing the outer `.id(worktree.id)`. This behaved correctly, but trace data did not improve. The likely reason is that a deeper `.id(node.structuralIdentity)` in the terminal split tree already forces the relevant Ghostty surface wrapper lifecycle work.
107107+108108+Decision: reverted. The extra code was not worth keeping.
109109+110110+### 4. Animation Experiments
111111+112112+Disabling the root Shelf animation made the UI feel much smoother, but traces showed the work mostly remained. This clarified that animation was a perception amplifier: missed frames are much more visible when the spine is supposed to glide.
113113+114114+A later attempt to isolate book-switch animation around the spine stacks and disable it around the terminal area also failed product-wise. The terminal animation disappeared and the trace did not justify the UX loss.
115115+116116+Decision: keep the normal spine-flow animation.
117117+118118+### 5. Keyboard-Path Fixes
119119+120120+The user reproduced the problem primarily with shortcuts. run8/run10 showed high `@Observable CommandKeyObserver.(Bool)` and `EnvironmentWriter: KeyPressModifier` cost.
121121+122122+Two fixes were retained:
123123+124124+- `CommandKeyObserver.shouldShowShortcuts(for:)` now returns true only for bare Command or bare Control, not shortcut chords such as Control+number or Command+Shift.
125125+- `SidebarListView` does not install its sidebar-to-terminal `.onKeyPress` forwarding modifier while Shelf is active.
126126+127127+Results:
128128+129129+- run11 removed `@Observable CommandKeyObserver.(Bool)` from the hot SwiftUI causes.
130130+- run12 removed `EnvironmentWriter: KeyPressModifier` from the Shelf switching path entirely.
131131+- `WorktreeRowsView.body [skipped]` dropped from 26,466 in run11 to 2,853 in run12.
132132+- select-window hitches/select improved from 186.7 ms in run11 to 151.7 ms in run12.
133133+134134+The behavior trade-off is narrow: while Shelf is active and the sidebar has focus, ordinary typed characters are no longer forwarded into the selected terminal. Direct terminal input is unaffected.
135135+136136+### 6. Single-`ForEach` Shelf Layout
137137+138138+The original Shelf layout split spines into left and right stacks:
139139+140140+```swift
141141+left spines
142142+open book area
143143+right spines
144144+```
145145+146146+Because a book moved between the two `ForEach` subtrees when the open index changed, the code used `matchedGeometryEffect` to bridge identity.
147147+148148+The retained rewrite renders all spines from a single `ForEach(books)` and inserts the open book area after the open book. This lets normal SwiftUI diffing preserve spine identity without matched geometry.
149149+150150+run13 results:
151151+152152+| Metric | run12 | run13 |
153153+| --- | ---: | ---: |
154154+| select-window hitches/select | 151.7 ms | 144.4 ms |
155155+| SwiftUI cause rows | 1,303,129 | 1,026,467 |
156156+| `Layout: MatchedFrame` source | 18,030 | 0 |
157157+| `Layout: LayoutChildGeometries` source | 111,421 | 4,279 |
158158+| `View Responders` dest | 102,928 | 69,228 |
159159+| opacity renderer dest | 72,073 | 57,078 |
160160+161161+Decision: kept. The trace win was moderate and the user reported a clear subjective improvement.
162162+163163+### 7. Terminal Overlay Experiment
164164+165165+Hypothesis: keep spines animating, but remove the real terminal subtree from the animated `HStack`. Use a lightweight placeholder in the layout and render the terminal in an overlay at its final frame.
166166+167167+run14 showed a small hitch win, but the approach had two problems:
168168+169169+- Visual artifact: during spine movement the terminal overlay was already at the new position, while the old layout area exposed the window background, producing a black edge.
170170+- SwiftUI causes got worse:
171171+ - `AnimatableFrameAttribute` source increased from 135,581 to 164,827.
172172+ - `View Responders` dest increased from 69,228 to 75,033.
173173+ - opacity renderer dest increased from 57,078 to 70,036.
174174+175175+Decision: reverted. The small hitch improvement did not justify the artifact and added complexity.
176176+177177+### 8. Open-Book Opacity Transition Removal
178178+179179+Removing the explicit `.transition(.opacity)` from `openBookArea` produced only a small win:
180180+181181+| Metric | run13 | run15 |
182182+| --- | ---: | ---: |
183183+| select-window hitches/select | 144.4 ms | 140.3 ms |
184184+| select-window max hitch | 125.0 ms | 116.7 ms |
185185+| `AnimatableFrameAttribute` source/select | 4,675 | 4,588 |
186186+187187+Several opacity-related SwiftUI causes did not improve after normalization, so this is not a major root-cause fix. However, the user reported no visible behavior regression, so it was kept as a simple low-risk improvement.
188188+189189+### 9. Closed-Spine Tab Context Menu Experiment
190190+191191+Hypothesis: closed spines did not need a full tab context menu, and removing it might reduce `View Responders` and the `TerminalTabContextMenu` view-list cost.
192192+193193+Implementation: only the open spine retained full tab context menus.
194194+195195+Result: rejected.
196196+197197+- select-window hitches/select regressed from 140.3 ms to 145.1 ms.
198198+- `View Responders` dest/select worsened from 2,296 to 3,133.
199199+- The old `ModifiedContent<ShelfSpineTabSlot, TerminalTabContextMenu>` shape was replaced by `_ConditionalContent<ModifiedContent, ShelfSpineTabSlot>`, so SwiftUI still had view-list complexity.
200200+- Closed-spine right-click behavior got worse.
201201+202202+Decision: reset away. Do not optimize context menus by conditionally changing the child view type.
203203+204204+## What Worked vs What Did Not
205205+206206+| Change | Outcome |
207207+| --- | --- |
208208+| Sidebar tab-count leaf view | Large win. Removed the biggest invalidation storm. Kept. |
209209+| Faster `orderedShelfBooks()` | Small/free hot-path cleanup. Kept. |
210210+| Remove redundant action animation | Small/free cleanup. Kept. |
211211+| Signpost toolkit and focused signposts | Made every later trace tractable. Kept. |
212212+| Bare Command/Control shortcut-hint mode | Clear win for keyboard switching. Kept. |
213213+| Disable sidebar `.onKeyPress` while Shelf is active | Clear win; removed `KeyPressModifier` from Shelf switching trace. Kept. |
214214+| Single `ForEach` Shelf layout | Moderate trace win and good subjective win. Kept. |
215215+| Remove open-book opacity transition | Small trace win, no observed UX cost. Kept. |
216216+| Remove outer `.id(worktree.id)` | No measurable win. Reverted. |
217217+| Disable/isolate animation | Either perceptual-only or visual regression. Reverted. |
218218+| Terminal overlay outside layout animation | Small hitch win but black-edge artifact and worse causes. Reverted. |
219219+| Closed-spine context-menu scoping | Worse trace and worse UX. Reset away. |
220220+221221+## Current Conclusions
222222+223223+1. **The worst problem was not the terminal.** Reducer work, Ghostty `makeNSView`, focus, and sync signposts are all tiny relative to the hitch windows.
224224+225225+2. **The first major class was invalidation fan-out.** Sidebar tab counts, shortcut hints, and `.onKeyPress` environment machinery were multiplying work across unrelated views.
226226+227227+3. **The second major class is SwiftUI layout animation.** After invalidation storms were removed, the dominant costs became `AnimatableFrameAttribute`, `External: Time`, `View Responders`, and display-list renderer effects. These are consequences of animating a dense interactive SwiftUI tree.
228228+229229+4. **Hangs were too unstable to use as the deciding metric in later runs.** The same user-visible workload could show very different hang counts. Hitches and normalized SwiftUI cause edges were more reliable.
230230+231231+5. **Animation changes must be judged by both trace and eye.** Disabling animation can feel much better while barely moving work. Conversely, moving terminal rendering out of layout improved hitches slightly but created visible artifacts.
232232+233233+6. **Stable view shape matters.** The closed-spine context-menu experiment showed that replacing one expensive modifier with a conditional child shape can shift cost instead of reducing it.
234234+235235+## Methodology and Tooling Learned
236236+237237+### `xctrace` from the command line
238238+239239+`/usr/bin/xctrace` is a stub; use the Xcode-bundled tool. In this investigation the working path was:
240240+241241+```bash
242242+XCT="/Applications/Xcode-26.4.1.app/Contents/Developer/usr/bin/xctrace"
243243+244244+"$XCT" export --input run.trace --toc --output toc.xml
245245+246246+"$XCT" export --input run.trace \
247247+ --xpath '/trace-toc/run[@number="1"]/data/table[@schema="hitches"]' \
248248+ --output hitches.xml
249249+250250+"$XCT" export --input run.trace \
251251+ --xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' \
252252+ --output hangs.xml
253253+254254+"$XCT" export --input run.trace \
255255+ --xpath '/trace-toc/run[@number="1"]/data/table[@schema="os-signpost"][@category="PointsOfInterest"]' \
256256+ --output poi.xml
257257+258258+"$XCT" export --input run.trace \
259259+ --xpath '/trace-toc/run[@number="1"]/data/table[@schema="swiftui-causes"]' \
260260+ --output swiftui-causes.xml
261261+```
262262+263263+The XML export interns values with `id`/`ref`. Aggregation scripts must resolve refs; a streaming parser is necessary for large `swiftui-causes` and `swiftui-updates` exports.
264264+265265+### Signposts
266266+267267+`SupaLogger` emits signposts under subsystem `com.onevcat.prowl` and category `PointsOfInterest`. This category is visible in the stock Points of Interest instrument.
268268+269269+Reducer code that mutates `inout state` should use manual begin/end tokens:
270270+271271+```swift
272272+case .selectWorktree(let id, let focusTerminal):
273273+ let token = repositoriesLogger.beginInterval("reducer.selectWorktree")
274274+ defer { repositoriesLogger.endInterval(token) }
275275+ // mutate state
276276+```
277277+278278+SwiftUI bodies can use event signposts:
279279+280280+```swift
281281+var body: some View {
282282+ let _ = shelfLogger.event("ShelfView.body")
283283+ // view tree
284284+}
285285+```
286286+287287+### Hangs vs Hitches
288288+289289+- A **Hang** is a main-thread block longer than the threshold.
290290+- A **Hitch** is one or more missed frames.
291291+292292+They measure different failure modes. For this investigation, hitches normalized by `reducer.selectWorktree` count were the most useful metric.
293293+294294+## Future Work
295295+296296+The current result is probably good enough. If Shelf book switching becomes a product priority again, the remaining useful directions are more structural:
297297+298298+- **Custom spine layout instead of animated `HStack` frame interpolation.** Compute spine positions directly and animate transforms/offsets with stable child identity. This is more invasive but targets `AnimatableFrameAttribute` directly.
299299+- **Reduce per-spine interactivity in a stable way.** Avoid conditional child types; consider stable lightweight wrappers if responder cost becomes a proven bottleneck.
300300+- **Lazy or virtualized spines.** Useful only if real users commonly have many more visible/open books than the current test set.
301301+- **Audit the inner terminal split-tree `.id(node.structuralIdentity)`.** This may be risky because `GhosttySurfaceScrollView` currently stores its surface view as immutable state; changing it needs a careful lifecycle audit.
302302+- **Canvas-rendered spines.** Potentially large reduction in SwiftUI view-tree work, but high implementation and accessibility cost.
303303+304304+Do not re-try these unless a new trace suggests a different result:
305305+306306+- removing only the outer open-book `.id`
307307+- fully disabling Shelf animation
308308+- moving terminal content to a final-position overlay
309309+- conditionally removing closed-spine context menus
310310+311311+## Long-Term Observability Hooks
312312+313313+Permanent signposts retained for future Shelf debugging:
314314+315315+- Reducer paths: `reducer.selectWorktree`, `reducer.selectRepository`
316316+- Terminal lifecycle: `Ghostty.makeNSView`, `Ghostty.updateNSView`, `Ghostty.dismantleNSView`
317317+- Shelf focus/sync: `OpenBook.onAppear`, `OpenBook.onChange.selectedTabId`, `focusSelectedTab`, `syncFocus`, `applySurfaceActivity`, `OpenBook.onDisappear`
318318+- View counters: `ShelfView.body`, `ShelfSpineView.body`
319319+- User markers: `BookClick.SwitchBook`, `BookClick.TabSwitchSameBook`, `BookClick.NewTabSpine`
320320+321321+Recommended future workflow:
322322+323323+1. Record with Animation Hitches template.
324324+2. Add the Points of Interest instrument.
325325+3. Reproduce the workload.
326326+4. Export `hitches`, `os-signpost`, and `swiftui-causes`.
327327+5. Normalize hitches by `reducer.selectWorktree` count and inspect SwiftUI source/destination pairs.
···11import Foundation
22+import IdentifiedCollections
2334/// A book on the Shelf — the unified abstraction over a Git worktree or
45/// a plain folder repository.
···3839 /// while in Shelf mode adds its ID here, which causes its spine to
3940 /// materialize (with the standard spine-flow animation).
4041 func orderedShelfBooks() -> [ShelfBook] {
4141- let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) })
4242+ // `ShelfView.body` re-runs on every TCA state change, so this method
4343+ // is on the per-frame hot path. The previous implementation built a
4444+ // `Dictionary(uniqueKeysWithValues:)` per call and routed worktree
4545+ // ordering through `worktreeRowSections(in:)` — which constructs a
4646+ // full `WorktreeRowModel` per worktree (PR/info lookups, icon
4747+ // resolution, etc.) plus several intermediate `Set` allocations per
4848+ // repository. None of that detail is needed by the Shelf, which only
4949+ // consumes id/name/branch. Use direct `IdentifiedArray` lookup and
5050+ // `orderedWorktrees(in:)` for the lighter ordering path.
4251 var books: [ShelfBook] = []
4352 for repositoryID in orderedRepositoryIDs() {
4444- guard let repository = repositoriesByID[repositoryID] else { continue }
5353+ guard let repository = repositories[id: repositoryID] else { continue }
4554 if repository.kind == .plain {
4655 guard openedWorktreeIDs.contains(repository.id) else { continue }
4756 books.append(
···5564 ))
5665 continue
5766 }
5858- for row in worktreeRows(in: repository) {
5959- guard openedWorktreeIDs.contains(row.id) else { continue }
6767+ for worktree in orderedWorktrees(in: repository)
6868+ where openedWorktreeIDs.contains(worktree.id) {
6069 books.append(
6170 ShelfBook(
6262- id: row.id,
7171+ id: worktree.id,
6372 repositoryID: repositoryID,
6464- displayName: row.name,
7373+ displayName: worktree.name,
6574 projectName: repository.name,
6666- branchName: row.name,
7575+ branchName: worktree.name,
7676+ kind: .worktree
7777+ ))
7878+ }
7979+ // Preserve prior behavior of `worktreeRowSections` which also
8080+ // surfaced any pending (in-creation) worktrees that had been
8181+ // marked opened. The list is typically empty so the cost is
8282+ // negligible — the win is avoiding `makePendingWorktreeRow` which
8383+ // builds a full `WorktreeRowModel` per entry.
8484+ for pending in pendingWorktrees
8585+ where pending.repositoryID == repositoryID
8686+ && openedWorktreeIDs.contains(pending.id)
8787+ {
8888+ books.append(
8989+ ShelfBook(
9090+ id: pending.id,
9191+ repositoryID: repositoryID,
9292+ displayName: pending.progress.titleText,
9393+ projectName: repository.name,
9494+ branchName: pending.progress.titleText,
6795 kind: .worktree
6896 ))
6997 }
···11import Sharing
22import SwiftUI
3344+private let shelfLogger = SupaLogger("Shelf")
55+46/// Vertical spine rendering for a single book on the Shelf.
57///
68/// Phase 3 scope: header with book-level notification dot, a vertical
···4244 @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts
43454446 var body: some View {
4747+ // Body-invocation counter signpost. With ~10 spines visible during
4848+ // a book switch, the trace can multiply this event count by spine
4949+ // count to estimate per-click body work. Emitted as a no-arg event
5050+ // (instant timeline marker) so it imposes no work even when
5151+ // Instruments isn't attached.
5252+ let _ = shelfLogger.event("ShelfSpineView.body")
4553 VStack(spacing: 0) {
4654 headerButton
4755 tabList
+57-59
supacode/Features/Shelf/Views/ShelfView.swift
···11import ComposableArchitecture
22import SwiftUI
3344+private let shelfLogger = SupaLogger("Shelf")
55+46/// Root view for Shelf presentation mode.
57///
68/// Phase 3 layout: three horizontal segments — a left stack of passed
···1416 let terminalManager: WorktreeTerminalManager
1517 let createTab: () -> Void
16181717- /// Shared namespace so each spine's `matchedGeometryEffect` can bridge
1818- /// the left-stack ForEach and the right-stack ForEach without breaking
1919- /// visual identity while it moves between them.
2020- @Namespace private var spineNamespace
2121-2219 /// Mirrors the Ghostty `background-opacity` setting so the Shelf can
2320 /// honor the same window transparency as normal view mode. A previous
2421 /// plain `.background(.background)` defeated transparency entirely by
···2724 @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity
28252926 var body: some View {
2727+ // Body-invocation counter. The @ViewBuilder getter rules out a
2828+ // `defer`-based interval, but a fire-and-forget event marker is a
2929+ // simple expression and has no impact on the rendered tree. Each
3030+ // marker corresponds to one full body re-evaluation — useful for
3131+ // sanity-checking how often the root re-renders during animation.
3232+ let _ = shelfLogger.event("ShelfView.body")
3033 let state = store.state
3134 let books = state.orderedShelfBooks()
3235 let openBookID = state.openShelfBookID
···3538 }
36393740 HStack(spacing: 0) {
3838- if let openIndex {
3939- spineStack(books: Array(books[0...openIndex]), openIndex: openIndex, baseOffset: 0)
4040- openBookArea(for: books[openIndex], state: state)
4141- .transition(.opacity)
4242- let rightStart = openIndex + 1
4343- if rightStart < books.count {
4444- spineStack(
4545- books: Array(books[rightStart..<books.count]),
4646- openIndex: openIndex,
4747- baseOffset: rightStart
4848- )
4141+ ForEach(Array(books.enumerated()), id: \.element.id) { index, book in
4242+ spine(book: book, index: index, openIndex: openIndex)
4343+ if book.id == openBookID {
4444+ openBookArea(for: book, state: state)
4945 }
5050- } else {
5151- spineStack(books: books, openIndex: nil, baseOffset: 0)
4646+ }
4747+ if openBookID == nil {
5248 emptyOpenArea()
5349 }
5450 }
···6157 .animation(.easeInOut(duration: 0.2), value: openBookID)
6258 }
63596464- /// `baseOffset` is the index of `books.first` within the full ordered
6565- /// list, so we can reconstruct each spine's global index and compute
6666- /// its distance to `openIndex` without re-scanning the full list.
6760 @ViewBuilder
6868- private func spineStack(books: [ShelfBook], openIndex: Int?, baseOffset: Int) -> some View {
6969- HStack(spacing: 0) {
7070- ForEach(Array(books.enumerated()), id: \.element.id) { localIndex, book in
7171- let globalIndex = baseOffset + localIndex
7272- let distance = openIndex.map { abs(globalIndex - $0) }
7373- let open = globalIndex == openIndex
7474- ShelfSpineView(
7575- book: book,
7676- isOpen: open,
7777- distanceFromOpen: distance,
7878- terminalState: terminalManager.stateIfExists(for: book.id),
7979- onOpenBook: { openBook(book, selectingTab: nil) },
8080- onSelectTab: { tabID in openBook(book, selectingTab: tabID) },
8181- onNewTab: {
8282- // On a closed spine, `+` doubles as "pull this book out and
8383- // start a fresh tab". Sequencing is fine because TCA runs
8484- // reducers synchronously — `newTerminal` will observe the
8585- // new `selectedTerminalWorktree` set by `selectWorktree`.
8686- switchToBookIfNeeded(book)
8787- createTab()
8888- },
8989- onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil,
9090- onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil,
9191- closeMenuTitle: closeMenuTitle(for: book),
9292- onCloseBook: { closeBook(book) },
9393- onOpenRepositorySettings: {
9494- store.send(.repositoryManagement(.openRepositorySettings(book.repositoryID)))
9595- }
9696- )
9797- .matchedGeometryEffect(id: book.id, in: spineNamespace)
6161+ private func spine(book: ShelfBook, index: Int, openIndex: Int?) -> some View {
6262+ let distance = openIndex.map { abs(index - $0) }
6363+ let open = index == openIndex
6464+ ShelfSpineView(
6565+ book: book,
6666+ isOpen: open,
6767+ distanceFromOpen: distance,
6868+ terminalState: terminalManager.stateIfExists(for: book.id),
6969+ onOpenBook: { openBook(book, selectingTab: nil) },
7070+ onSelectTab: { tabID in openBook(book, selectingTab: tabID) },
7171+ onNewTab: {
7272+ // On a closed spine, `+` doubles as "pull this book out and
7373+ // start a fresh tab". Sequencing is fine because TCA runs
7474+ // reducers synchronously — `newTerminal` will observe the
7575+ // new `selectedTerminalWorktree` set by `selectWorktree`.
7676+ switchToBookIfNeeded(book)
7777+ createTab()
7878+ },
7979+ onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil,
8080+ onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil,
8181+ closeMenuTitle: closeMenuTitle(for: book),
8282+ onCloseBook: { closeBook(book) },
8383+ onOpenRepositorySettings: {
8484+ store.send(.repositoryManagement(.openRepositorySettings(book.repositoryID)))
9885 }
9999- }
8686+ )
10087 }
1018810289 /// Dispatch the open-book action only when `book` isn't already the open
10390 /// one — idempotent helper for taps that imply a book change.
9191+ ///
9292+ /// No `animation:` is passed to `store.send` because the visible
9393+ /// spine-flow animation is already driven by the view-level
9494+ /// `.animation(.easeInOut(duration: 0.2), value: openBookID)` modifier
9595+ /// on the root container — wrapping the dispatch in another animation
9696+ /// transaction would double-run layout / transition machinery for the
9797+ /// same change.
10498 private func switchToBookIfNeeded(_ book: ShelfBook) {
10599 guard !isOpen(book) else { return }
100100+ shelfLogger.event("BookClick.NewTabSpine")
106101 switch book.kind {
107102 case .worktree:
108108- store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2))
103103+ store.send(.selectWorktree(book.id, focusTerminal: true))
109104 case .plainFolder:
110110- store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2))
105105+ store.send(.selectRepository(book.repositoryID))
111106 }
112107 }
113108···190185 private func openBook(_ book: ShelfBook, selectingTab tabID: TerminalTabID?) {
191186 let isAlreadyOpen = store.state.openShelfBookID == book.id
192187 if let tabID, isAlreadyOpen, let state = terminalManager.stateIfExists(for: book.id) {
188188+ shelfLogger.event("BookClick.TabSwitchSameBook")
193189 state.tabManager.selectTab(tabID)
194190 return
195191 }
196196- // Animate the spine flow and terminal crossfade. The duration and
197197- // curve mirror the Shelf design doc: ~200ms ease-in-out, snappy but
198198- // legible so the user can read each spine's movement.
192192+ shelfLogger.event("BookClick.SwitchBook")
193193+ // The spine flow / terminal crossfade animation is already driven
194194+ // by the view-level `.animation(_:value: openBookID)` on the root
195195+ // container (~200ms ease-in-out per the Shelf design doc), so the
196196+ // dispatch itself does not pass an `animation:` argument here.
199197 switch book.kind {
200198 case .worktree:
201201- store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2))
199199+ store.send(.selectWorktree(book.id, focusTerminal: true))
202200 case .plainFolder:
203203- store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2))
201201+ store.send(.selectRepository(book.repositoryID))
204202 }
205203 if let tabID {
206204 // Apply tab selection eagerly; the target book's state already exists
···1111 }
12121313 func makeNSView(context: Context) -> GhosttySurfaceScrollView {
1414- let view = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: hostKind)
1515- view.pinnedSize = pinnedSize
1616- terminalHostLogger.info(
1717- "[CanvasExit] hostMake wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) "
1818- + "surface=\(surfaceView.debugIdentifierForLogging) "
1919- + "pinned=\(pinnedSize != nil)"
2020- )
2121- return view
1414+ // Wrap in a signpost interval so the cost of NSView construction
1515+ // (allocating the scroll view, attaching the Metal-backed surface)
1616+ // shows up on the Points of Interest timeline. Frequency tells us
1717+ // how often `.id(worktree.id)` on `ShelfOpenBookView` is forcing a
1818+ // wholesale teardown/recreate on book switching — total time tells
1919+ // us if AppKit/Metal initialization is on the hot path.
2020+ return terminalHostLogger.interval("Ghostty.makeNSView") {
2121+ let view = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: hostKind)
2222+ view.pinnedSize = pinnedSize
2323+ terminalHostLogger.info(
2424+ "[CanvasExit] hostMake wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) "
2525+ + "surface=\(surfaceView.debugIdentifierForLogging) "
2626+ + "pinned=\(pinnedSize != nil)"
2727+ )
2828+ return view
2929+ }
2230 }
23312432 func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) {
2525- view.pinnedSize = pinnedSize
2626- terminalHostLogger.info(
2727- "[CanvasExit] hostUpdate wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) "
2828- + "surface=\(surfaceView.debugIdentifierForLogging) "
2929- + "pinned=\(pinnedSize != nil) "
3030- + "attached=\(view.isSurfaceAttachedToDocumentView)"
3131- )
3232- view.ensureSurfaceAttached()
3333+ terminalHostLogger.interval("Ghostty.updateNSView") {
3434+ view.pinnedSize = pinnedSize
3535+ terminalHostLogger.info(
3636+ "[CanvasExit] hostUpdate wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) "
3737+ + "surface=\(surfaceView.debugIdentifierForLogging) "
3838+ + "pinned=\(pinnedSize != nil) "
3939+ + "attached=\(view.isSurfaceAttachedToDocumentView)"
4040+ )
4141+ view.ensureSurfaceAttached()
4242+ }
4343+ }
4444+4545+ static func dismantleNSView(_ view: GhosttySurfaceScrollView, coordinator: Void) {
4646+ // Pairs with `Ghostty.makeNSView` on the timeline so the interval
4747+ // count of `make` minus `dismantle` gives a live count of attached
4848+ // surface wrappers — and frequency confirms whether each book
4949+ // switch tears down the wrapper layer.
5050+ terminalHostLogger.event("Ghostty.dismantleNSView")
3351 }
3452}
+58-1
supacode/Support/SupaLogger.swift
···55 #if !DEBUG
66 private let logger: Logger
77 #endif
88+ /// Signposter for emitting `os_signpost` intervals/events visible in
99+ /// Instruments. Signposts are essentially zero-cost when no Instruments
1010+ /// session is attached (a single TLS read), so they are always live —
1111+ /// no DEBUG gating.
1212+ ///
1313+ /// The signposter uses the well-known `"PointsOfInterest"` category
1414+ /// regardless of the logger's own `category` so that intervals and
1515+ /// events automatically surface in Apple's **Points of Interest**
1616+ /// instrument (the discoverable, "just drag it in" track most people
1717+ /// will reach for). The signpost `name:` argument carries the actual
1818+ /// origin (e.g. `"OpenBook.onAppear"`, `"focusSelectedTab"`) so source
1919+ /// granularity is preserved — only the routing category differs from
2020+ /// the regular log channel.
2121+ let signposter: OSSignposter
822923 init(_ category: String) {
1024 self.category = category
2525+ let subsystem = Bundle.main.bundleIdentifier ?? "com.onevcat.prowl"
1126 #if !DEBUG
1212- self.logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: category)
2727+ self.logger = Logger(subsystem: subsystem, category: category)
1328 #endif
2929+ self.signposter = OSSignposter(subsystem: subsystem, category: "PointsOfInterest")
1430 }
15311632 func debug(_ message: String) {
···3652 logger.warning("\(message, privacy: .public)")
3753 #endif
3854 }
5555+5656+ /// Wraps `body` in an `os_signpost` interval named `name`. The
5757+ /// interval renders as a labeled bar on the Instruments timeline,
5858+ /// making it trivial to correlate hotspots with hangs/hitches without
5959+ /// post-processing the trace XML.
6060+ func interval<T>(_ name: StaticString, _ body: () throws -> T) rethrows -> T {
6161+ let id = signposter.makeSignpostID()
6262+ let state = signposter.beginInterval(name, id: id)
6363+ defer { signposter.endInterval(name, state) }
6464+ return try body()
6565+ }
6666+6767+ /// Manual begin/end pair for code paths that can't use the closure
6868+ /// form — e.g. inside a TCA reducer case where `inout state` cannot
6969+ /// be captured by a non-escaping closure. The returned `IntervalToken`
7070+ /// is opaque to callers, so they don't have to import `OSLog`
7171+ /// themselves.
7272+ func beginInterval(_ name: StaticString) -> IntervalToken {
7373+ let id = signposter.makeSignpostID()
7474+ let state = signposter.beginInterval(name, id: id)
7575+ return IntervalToken(name: name, state: state)
7676+ }
7777+7878+ func endInterval(_ token: IntervalToken) {
7979+ signposter.endInterval(token.name, token.state)
8080+ }
8181+8282+ /// Emits an instantaneous `os_signpost` event marker — useful for
8383+ /// marking discrete moments (e.g. "user clicked book") without an
8484+ /// associated duration.
8585+ func event(_ name: StaticString) {
8686+ signposter.emitEvent(name)
8787+ }
8888+}
8989+9090+/// Opaque token bundling a signpost name and its interval state so
9191+/// callers can `beginInterval` / `endInterval` without depending on
9292+/// `OSLog` themselves.
9393+struct IntervalToken {
9494+ fileprivate let name: StaticString
9595+ fileprivate let state: OSSignpostIntervalState
3996}