native macOS codings agent orchestrator
5
fork

Configure Feed

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

Merge pull request #246 from onevcat/perf/shelf-jank-fixes

Improve Shelf book switch performance

authored by

Wei Wang and committed by
GitHub
0a0a5569 47768c6a

+678 -152
+327
doc-onevcat/shelf-jank-investigation.md
··· 1 + # Shelf Book-Switch Jank Investigation 2 + 3 + Last updated: 2026-04-29 4 + 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. 5 + 6 + ## Summary 7 + 8 + 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. 9 + 10 + The retained changes are: 11 + 12 + - `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. 13 + - `orderedShelfBooks()` avoids per-body `Dictionary`, `Set`, and full `WorktreeRowModel` construction. 14 + - Shelf-originated book switches no longer send an extra TCA animation transaction on top of the view-level `openBookID` animation. 15 + - `CommandKeyObserver` only enters shortcut-hint mode for bare Command or bare Control, not every shortcut chord containing Command/Control. 16 + - 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. 17 + - Shelf spines are rendered from a single `ForEach`, removing the cross-list `matchedGeometryEffect` between left/right spine stacks. 18 + - 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. 19 + - Long-term signposts remain in place for future regressions. 20 + 21 + Experiments that were tried and rejected: 22 + 23 + - Removing `.id(worktree.id)` from `ShelfOpenBookView`. It was behaviorally safe but gave no measurable win. 24 + - Fully disabling or isolating the Shelf animation. It either only improved perception or caused visible terminal animation loss. 25 + - 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. 26 + - Removing context menus from closed-spine tab slots. This hurt interaction and did not improve hitches. 27 + 28 + ## Final Trace Shape 29 + 30 + 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. 31 + 32 + | Run | Main change | Selects | All hitches/select | Select-window hitches/select | Key SwiftUI result | 33 + | --- | --- | ---: | ---: | ---: | --- | 34 + | run10 | Rejected terminal animation isolation experiment | 25 | 296.5 ms | not used | UX regression; terminal animation disappeared | 35 + | run11 | Bare Command/Control hint mode | 25 | 235.7 ms | 186.7 ms | `@Observable CommandKeyObserver.(Bool)` disappeared; `KeyPressModifier` fell to 116,284 source edges | 36 + | run12 | Disable sidebar key forwarding in Shelf | 29 | 196.1 ms | 151.7 ms | `EnvironmentWriter: KeyPressModifier` fell to 0; `WorktreeRowsView.body [skipped]` fell sharply | 37 + | 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 | 38 + | 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 | 39 + | run15 | Remove open-book opacity transition | 28 | 250.6 ms | 140.3 ms | Small select-window win; outside-window hitches dominated all-hitch total | 40 + | run16 | Rejected context-menu scoping | 27 | 180.6 ms | 145.1 ms | Interaction regression; `View Responders` worsened | 41 + 42 + `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. 43 + 44 + ## Problem 45 + 46 + Quickly switching between Shelf books produced obvious frame drops. Initial Instruments captures showed: 47 + 48 + - severe hangs in early traces, including one multi-second main-thread block 49 + - hundreds of thousands of SwiftUI update/cause edges in short recordings 50 + - visible animation breakage during the 0.2 s spine-flow animation 51 + 52 + 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. 53 + 54 + ## Investigation Timeline 55 + 56 + ### 1. Static Suspicions 57 + 58 + The first static pass identified several plausible sources: 59 + 60 + 1. `ShelfView.body` recomputes `orderedShelfBooks()` on every body call. 61 + 2. `.id(worktree.id)` on `ShelfOpenBookView` might force expensive subtree teardown. 62 + 3. Shelf book switching was applying animation twice: once in the TCA action send and once via `.animation(value: openBookID)`. 63 + 4. Sidebar repository rows read `terminalManager.stateIfExists(...)` while rendering tab counts. 64 + 5. The Shelf layout split spines across left/right `ForEach` lists and bridged identity with `matchedGeometryEffect`. 65 + 6. Keyboard-based switching might be over-invalidating shortcut hint UI through `CommandKeyObserver` and `.onKeyPress`. 66 + 67 + Most of these were real; only some moved user-visible performance. 68 + 69 + ### 2. Sidebar Tab-Count Subscription Storm 70 + 71 + Early `swiftui-causes` data showed `RepositorySectionView.body` and `@Observable WorktreeTerminalManager.(Dictionary<String, WorktreeTerminalState>)` as top offenders. 72 + 73 + The cause was: 74 + 75 + ```swift 76 + RepoHeaderRow( 77 + ..., 78 + tabCount: Self.openTabCount(for: repository, terminalManager: terminalManager), 79 + ... 80 + ) 81 + ``` 82 + 83 + `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. 84 + 85 + Fixes landed in `0fe682cb`: 86 + 87 + - Move tab-count reads into `RepoHeaderTabCountBadge`. 88 + - Rewrite `orderedShelfBooks()` to use direct repository/worktree ordering. 89 + - Remove the redundant Shelf-originated action animation. 90 + - Add `SupaLogger` signpost helpers and focused Shelf/Ghostty signposts. 91 + 92 + Result: 93 + 94 + | Metric | Before | After | 95 + | --- | ---: | ---: | 96 + | `RepositorySectionView.body` cause edges | 184,681 | 14,456 | 97 + | `WorktreeTerminalManager.states` cause edges | 196,968 | 14,456 | 98 + | Severe hangs from this storm | present | gone | 99 + 100 + This was the largest unambiguous win. 101 + 102 + ### 3. `.id(worktree.id)` Experiment 103 + 104 + Hypothesis: the residual cost came from `ShelfOpenBookView` teardown/remount on every book switch. 105 + 106 + 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. 107 + 108 + Decision: reverted. The extra code was not worth keeping. 109 + 110 + ### 4. Animation Experiments 111 + 112 + 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. 113 + 114 + 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. 115 + 116 + Decision: keep the normal spine-flow animation. 117 + 118 + ### 5. Keyboard-Path Fixes 119 + 120 + The user reproduced the problem primarily with shortcuts. run8/run10 showed high `@Observable CommandKeyObserver.(Bool)` and `EnvironmentWriter: KeyPressModifier` cost. 121 + 122 + Two fixes were retained: 123 + 124 + - `CommandKeyObserver.shouldShowShortcuts(for:)` now returns true only for bare Command or bare Control, not shortcut chords such as Control+number or Command+Shift. 125 + - `SidebarListView` does not install its sidebar-to-terminal `.onKeyPress` forwarding modifier while Shelf is active. 126 + 127 + Results: 128 + 129 + - run11 removed `@Observable CommandKeyObserver.(Bool)` from the hot SwiftUI causes. 130 + - run12 removed `EnvironmentWriter: KeyPressModifier` from the Shelf switching path entirely. 131 + - `WorktreeRowsView.body [skipped]` dropped from 26,466 in run11 to 2,853 in run12. 132 + - select-window hitches/select improved from 186.7 ms in run11 to 151.7 ms in run12. 133 + 134 + 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. 135 + 136 + ### 6. Single-`ForEach` Shelf Layout 137 + 138 + The original Shelf layout split spines into left and right stacks: 139 + 140 + ```swift 141 + left spines 142 + open book area 143 + right spines 144 + ``` 145 + 146 + Because a book moved between the two `ForEach` subtrees when the open index changed, the code used `matchedGeometryEffect` to bridge identity. 147 + 148 + 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. 149 + 150 + run13 results: 151 + 152 + | Metric | run12 | run13 | 153 + | --- | ---: | ---: | 154 + | select-window hitches/select | 151.7 ms | 144.4 ms | 155 + | SwiftUI cause rows | 1,303,129 | 1,026,467 | 156 + | `Layout: MatchedFrame` source | 18,030 | 0 | 157 + | `Layout: LayoutChildGeometries` source | 111,421 | 4,279 | 158 + | `View Responders` dest | 102,928 | 69,228 | 159 + | opacity renderer dest | 72,073 | 57,078 | 160 + 161 + Decision: kept. The trace win was moderate and the user reported a clear subjective improvement. 162 + 163 + ### 7. Terminal Overlay Experiment 164 + 165 + 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. 166 + 167 + run14 showed a small hitch win, but the approach had two problems: 168 + 169 + - 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. 170 + - SwiftUI causes got worse: 171 + - `AnimatableFrameAttribute` source increased from 135,581 to 164,827. 172 + - `View Responders` dest increased from 69,228 to 75,033. 173 + - opacity renderer dest increased from 57,078 to 70,036. 174 + 175 + Decision: reverted. The small hitch improvement did not justify the artifact and added complexity. 176 + 177 + ### 8. Open-Book Opacity Transition Removal 178 + 179 + Removing the explicit `.transition(.opacity)` from `openBookArea` produced only a small win: 180 + 181 + | Metric | run13 | run15 | 182 + | --- | ---: | ---: | 183 + | select-window hitches/select | 144.4 ms | 140.3 ms | 184 + | select-window max hitch | 125.0 ms | 116.7 ms | 185 + | `AnimatableFrameAttribute` source/select | 4,675 | 4,588 | 186 + 187 + 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. 188 + 189 + ### 9. Closed-Spine Tab Context Menu Experiment 190 + 191 + Hypothesis: closed spines did not need a full tab context menu, and removing it might reduce `View Responders` and the `TerminalTabContextMenu` view-list cost. 192 + 193 + Implementation: only the open spine retained full tab context menus. 194 + 195 + Result: rejected. 196 + 197 + - select-window hitches/select regressed from 140.3 ms to 145.1 ms. 198 + - `View Responders` dest/select worsened from 2,296 to 3,133. 199 + - The old `ModifiedContent<ShelfSpineTabSlot, TerminalTabContextMenu>` shape was replaced by `_ConditionalContent<ModifiedContent, ShelfSpineTabSlot>`, so SwiftUI still had view-list complexity. 200 + - Closed-spine right-click behavior got worse. 201 + 202 + Decision: reset away. Do not optimize context menus by conditionally changing the child view type. 203 + 204 + ## What Worked vs What Did Not 205 + 206 + | Change | Outcome | 207 + | --- | --- | 208 + | Sidebar tab-count leaf view | Large win. Removed the biggest invalidation storm. Kept. | 209 + | Faster `orderedShelfBooks()` | Small/free hot-path cleanup. Kept. | 210 + | Remove redundant action animation | Small/free cleanup. Kept. | 211 + | Signpost toolkit and focused signposts | Made every later trace tractable. Kept. | 212 + | Bare Command/Control shortcut-hint mode | Clear win for keyboard switching. Kept. | 213 + | Disable sidebar `.onKeyPress` while Shelf is active | Clear win; removed `KeyPressModifier` from Shelf switching trace. Kept. | 214 + | Single `ForEach` Shelf layout | Moderate trace win and good subjective win. Kept. | 215 + | Remove open-book opacity transition | Small trace win, no observed UX cost. Kept. | 216 + | Remove outer `.id(worktree.id)` | No measurable win. Reverted. | 217 + | Disable/isolate animation | Either perceptual-only or visual regression. Reverted. | 218 + | Terminal overlay outside layout animation | Small hitch win but black-edge artifact and worse causes. Reverted. | 219 + | Closed-spine context-menu scoping | Worse trace and worse UX. Reset away. | 220 + 221 + ## Current Conclusions 222 + 223 + 1. **The worst problem was not the terminal.** Reducer work, Ghostty `makeNSView`, focus, and sync signposts are all tiny relative to the hitch windows. 224 + 225 + 2. **The first major class was invalidation fan-out.** Sidebar tab counts, shortcut hints, and `.onKeyPress` environment machinery were multiplying work across unrelated views. 226 + 227 + 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. 228 + 229 + 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. 230 + 231 + 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. 232 + 233 + 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. 234 + 235 + ## Methodology and Tooling Learned 236 + 237 + ### `xctrace` from the command line 238 + 239 + `/usr/bin/xctrace` is a stub; use the Xcode-bundled tool. In this investigation the working path was: 240 + 241 + ```bash 242 + XCT="/Applications/Xcode-26.4.1.app/Contents/Developer/usr/bin/xctrace" 243 + 244 + "$XCT" export --input run.trace --toc --output toc.xml 245 + 246 + "$XCT" export --input run.trace \ 247 + --xpath '/trace-toc/run[@number="1"]/data/table[@schema="hitches"]' \ 248 + --output hitches.xml 249 + 250 + "$XCT" export --input run.trace \ 251 + --xpath '/trace-toc/run[@number="1"]/data/table[@schema="potential-hangs"]' \ 252 + --output hangs.xml 253 + 254 + "$XCT" export --input run.trace \ 255 + --xpath '/trace-toc/run[@number="1"]/data/table[@schema="os-signpost"][@category="PointsOfInterest"]' \ 256 + --output poi.xml 257 + 258 + "$XCT" export --input run.trace \ 259 + --xpath '/trace-toc/run[@number="1"]/data/table[@schema="swiftui-causes"]' \ 260 + --output swiftui-causes.xml 261 + ``` 262 + 263 + 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. 264 + 265 + ### Signposts 266 + 267 + `SupaLogger` emits signposts under subsystem `com.onevcat.prowl` and category `PointsOfInterest`. This category is visible in the stock Points of Interest instrument. 268 + 269 + Reducer code that mutates `inout state` should use manual begin/end tokens: 270 + 271 + ```swift 272 + case .selectWorktree(let id, let focusTerminal): 273 + let token = repositoriesLogger.beginInterval("reducer.selectWorktree") 274 + defer { repositoriesLogger.endInterval(token) } 275 + // mutate state 276 + ``` 277 + 278 + SwiftUI bodies can use event signposts: 279 + 280 + ```swift 281 + var body: some View { 282 + let _ = shelfLogger.event("ShelfView.body") 283 + // view tree 284 + } 285 + ``` 286 + 287 + ### Hangs vs Hitches 288 + 289 + - A **Hang** is a main-thread block longer than the threshold. 290 + - A **Hitch** is one or more missed frames. 291 + 292 + They measure different failure modes. For this investigation, hitches normalized by `reducer.selectWorktree` count were the most useful metric. 293 + 294 + ## Future Work 295 + 296 + The current result is probably good enough. If Shelf book switching becomes a product priority again, the remaining useful directions are more structural: 297 + 298 + - **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. 299 + - **Reduce per-spine interactivity in a stable way.** Avoid conditional child types; consider stable lightweight wrappers if responder cost becomes a proven bottleneck. 300 + - **Lazy or virtualized spines.** Useful only if real users commonly have many more visible/open books than the current test set. 301 + - **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. 302 + - **Canvas-rendered spines.** Potentially large reduction in SwiftUI view-tree work, but high implementation and accessibility cost. 303 + 304 + Do not re-try these unless a new trace suggests a different result: 305 + 306 + - removing only the outer open-book `.id` 307 + - fully disabling Shelf animation 308 + - moving terminal content to a final-position overlay 309 + - conditionally removing closed-spine context menus 310 + 311 + ## Long-Term Observability Hooks 312 + 313 + Permanent signposts retained for future Shelf debugging: 314 + 315 + - Reducer paths: `reducer.selectWorktree`, `reducer.selectRepository` 316 + - Terminal lifecycle: `Ghostty.makeNSView`, `Ghostty.updateNSView`, `Ghostty.dismantleNSView` 317 + - Shelf focus/sync: `OpenBook.onAppear`, `OpenBook.onChange.selectedTabId`, `focusSelectedTab`, `syncFocus`, `applySurfaceActivity`, `OpenBook.onDisappear` 318 + - View counters: `ShelfView.body`, `ShelfSpineView.body` 319 + - User markers: `BookClick.SwitchBook`, `BookClick.TabSwitchSameBook`, `BookClick.NewTabSpine` 320 + 321 + Recommended future workflow: 322 + 323 + 1. Record with Animation Hitches template. 324 + 2. Add the Points of Interest instrument. 325 + 3. Reproduce the workload. 326 + 4. Export `hitches`, `os-signpost`, and `swiftui-causes`. 327 + 5. Normalize hitches by `reducer.selectWorktree` count and inspect SwiftUI source/destination pairs.
+2 -1
supacode/App/CommandKeyObserver.swift
··· 50 50 } 51 51 52 52 nonisolated static func shouldShowShortcuts(for modifierFlags: NSEvent.ModifierFlags) -> Bool { 53 - modifierFlags.contains(.command) || modifierFlags.contains(.control) 53 + let modifiers = modifierFlags.intersection(.deviceIndependentFlagsMask) 54 + return modifiers == .command || modifiers == .control 54 55 } 55 56 56 57 private func handleCommandKeyChange(isDown: Bool) {
+7
supacode/Features/Repositories/Reducer/RepositoriesFeature.swift
··· 10 10 nonisolated let worktreeCreationProgressUpdateStride = 20 11 11 nonisolated let archiveScriptProgressLineLimit = 200 12 12 private let secondsPerDay: Double = 86_400 13 + private let repositoriesLogger = SupaLogger("RepositoriesFeature") 13 14 14 15 nonisolated struct WorktreeCreationProgressUpdateThrottle { 15 16 private let stride: Int ··· 792 793 return .none 793 794 794 795 case .selectRepository(let repositoryID): 796 + // `inout state` cannot be captured by a closure, so use the 797 + // begin/end token API rather than the `interval` helper. 798 + let selectRepoToken = repositoriesLogger.beginInterval("reducer.selectRepository") 799 + defer { repositoriesLogger.endInterval(selectRepoToken) } 795 800 guard let repositoryID, state.repositories[id: repositoryID] != nil else { return .none } 796 801 state.selection = .repository(repositoryID) 797 802 state.sidebarSelectedWorktreeIDs = [] ··· 802 807 return .send(.delegate(.selectedWorktreeChanged(state.selectedTerminalWorktree))) 803 808 804 809 case .selectWorktree(let worktreeID, let focusTerminal): 810 + let selectWtToken = repositoriesLogger.beginInterval("reducer.selectWorktree") 811 + defer { repositoriesLogger.endInterval(selectWtToken) } 805 812 setSingleWorktreeSelection(worktreeID, state: &state) 806 813 if focusTerminal, let worktreeID { 807 814 state.pendingTerminalFocusWorktreeIDs.insert(worktreeID)
+31 -14
supacode/Features/Repositories/Views/RepoHeaderRow.swift
··· 4 4 private static let debugHeaderLayers = false 5 5 let name: String 6 6 let isRemoving: Bool 7 - let tabCount: Int 8 7 /// User-pinned icon, when set. Renders before the repo name. 9 8 /// `nil` keeps the historical text-only layout intact. 10 9 let icon: RepositoryIconSource? ··· 34 33 .font(.caption) 35 34 .foregroundStyle(.tertiary) 36 35 } 37 - if tabCount > 0 { 38 - Text("\(tabCount)") 39 - .font(.caption2) 40 - .monospacedDigit() 41 - .foregroundStyle(.secondary) 42 - .padding(.horizontal, 5) 43 - .padding(.vertical, 1) 44 - .background(.quaternary, in: .capsule) 45 - .help("\(tabCount) active \(tabCount == 1 ? "tab" : "tabs")") 46 - } 47 36 } 48 37 .background { 49 38 if Self.debugHeaderLayers { ··· 58 47 } 59 48 } 60 49 50 + /// Leaf view that renders the open-tab count badge for a repository. 51 + /// 52 + /// Lives in its own `View` so the read of `terminalManager` (an 53 + /// `@Observable` whose `states` dictionary churns whenever terminal 54 + /// activity happens) is isolated to this subtree. Without this split, 55 + /// `RepositorySectionView.body` would subscribe to every change in 56 + /// `terminalManager.states` on every re-evaluation — which under heavy 57 + /// terminal activity caused tens of thousands of body invocations per 58 + /// second across the sidebar. 59 + struct RepoHeaderTabCountBadge: View { 60 + let repository: Repository 61 + let terminalManager: WorktreeTerminalManager 62 + 63 + var body: some View { 64 + let count = RepositorySectionView.openTabCount( 65 + for: repository, 66 + terminalManager: terminalManager 67 + ) 68 + if count > 0 { 69 + Text("\(count)") 70 + .font(.caption2) 71 + .monospacedDigit() 72 + .foregroundStyle(.secondary) 73 + .padding(.horizontal, 5) 74 + .padding(.vertical, 1) 75 + .background(.quaternary, in: .capsule) 76 + .help("\(count) active \(count == 1 ? "tab" : "tabs")") 77 + } 78 + } 79 + } 80 + 61 81 // MARK: - Previews 62 82 63 83 #Preview("RepoHeaderRow") { ··· 65 85 RepoHeaderRow( 66 86 name: "supacode", 67 87 isRemoving: false, 68 - tabCount: 3, 69 88 icon: nil, 70 89 iconTint: nil, 71 90 repositoryRootURL: nil ··· 73 92 RepoHeaderRow( 74 93 name: "ghostty", 75 94 isRemoving: false, 76 - tabCount: 0, 77 95 icon: .sfSymbol("folder.fill"), 78 96 iconTint: .blue, 79 97 repositoryRootURL: URL(fileURLWithPath: "/tmp/ghostty") ··· 81 99 RepoHeaderRow( 82 100 name: "removing-repo", 83 101 isRemoving: true, 84 - tabCount: 1, 85 102 icon: .sfSymbol("hammer.fill"), 86 103 iconTint: .orange, 87 104 repositoryRootURL: URL(fileURLWithPath: "/tmp/removing")
+20 -13
supacode/Features/Repositories/Views/RepositorySectionView.swift
··· 38 38 39 39 let appearance = repositoryAppearances[repository.id] ?? .empty 40 40 let header = HStack { 41 - RepoHeaderRow( 42 - name: repository.name, 43 - isRemoving: isRemovingRepository, 44 - tabCount: Self.openTabCount( 45 - for: repository, 41 + // Inner HStack groups the name row and the tab-count badge so they 42 + // share the leading-aligned region of the outer header. Crucially 43 + // the badge is its own leaf view (`RepoHeaderTabCountBadge`) — it 44 + // owns the `terminalManager` read so this view never subscribes 45 + // to the manager-wide states dictionary. 46 + HStack { 47 + RepoHeaderRow( 48 + name: repository.name, 49 + isRemoving: isRemovingRepository, 50 + icon: appearance.icon, 51 + iconTint: appearance.color?.color, 52 + repositoryRootURL: repository.rootURL, 53 + nameTooltip: repository.capabilities.supportsWorktrees 54 + ? (isExpanded ? "Collapse" : "Expand") 55 + : "Open terminal in folder" 56 + ) 57 + RepoHeaderTabCountBadge( 58 + repository: repository, 46 59 terminalManager: terminalManager 47 - ), 48 - icon: appearance.icon, 49 - iconTint: appearance.color?.color, 50 - repositoryRootURL: repository.rootURL, 51 - nameTooltip: repository.capabilities.supportsWorktrees 52 - ? (isExpanded ? "Collapse" : "Expand") 53 - : "Open terminal in folder" 54 - ) 60 + ) 61 + } 55 62 .frame(maxWidth: .infinity, alignment: .leading) 56 63 .background { 57 64 if Self.debugHeaderLayers {
+53 -23
supacode/Features/Repositories/Views/SidebarListView.swift
··· 78 78 return 79 79 } 80 80 sidebarSelections = Set(worktreeIDs.map(SidebarSelection.worktree)) 81 - if let selectedWorktreeID = state.selectedWorktreeID, worktreeIDs.contains(selectedWorktreeID) { 81 + if let selectedWorktreeID = state.selectedWorktreeID, 82 + worktreeIDs.contains(selectedWorktreeID) 83 + { 82 84 return 83 85 } 84 86 let nextPrimarySelection = ··· 210 212 store.send(.repositoryManagement(.openRepositories(fileURLs))) 211 213 return true 212 214 } 213 - .onKeyPress { keyPress in 214 - guard !keyPress.characters.isEmpty else { return .ignored } 215 - let isNavigationKey = 216 - keyPress.key == .upArrow 217 - || keyPress.key == .downArrow 218 - || keyPress.key == .leftArrow 219 - || keyPress.key == .rightArrow 220 - || keyPress.key == .home 221 - || keyPress.key == .end 222 - || keyPress.key == .pageUp 223 - || keyPress.key == .pageDown 224 - if isNavigationKey { return .ignored } 225 - let hasCommandModifier = keyPress.modifiers.contains(.command) 226 - if hasCommandModifier { return .ignored } 227 - guard let worktreeID = store.selectedWorktreeID, 228 - state.sidebarSelectedWorktreeIDs.count == 1, 229 - state.sidebarSelectedWorktreeIDs.contains(worktreeID), 230 - let terminalState = terminalManager.stateIfExists(for: worktreeID) 231 - else { return .ignored } 232 - terminalState.focusAndInsertText(keyPress.characters) 233 - return .handled 234 - } 215 + .modifier( 216 + SidebarKeyForwardingModifier( 217 + isEnabled: !state.isShelfActive, 218 + selectedWorktreeID: state.selectedWorktreeID, 219 + sidebarSelectedWorktreeIDs: state.sidebarSelectedWorktreeIDs, 220 + terminalManager: terminalManager 221 + ) 222 + ) 235 223 .focused($isSidebarFocused) 236 224 .task(id: pendingSidebarReveal?.id) { 237 225 await revealPendingSidebarWorktree(pendingSidebarReveal, with: scrollProxy) ··· 253 241 scrollProxy.scrollTo(pendingSidebarReveal.worktreeID, anchor: .center) 254 242 } 255 243 store.send(.consumePendingSidebarReveal(pendingSidebarReveal.id)) 244 + } 245 + } 246 + 247 + private struct SidebarKeyForwardingModifier: ViewModifier { 248 + let isEnabled: Bool 249 + let selectedWorktreeID: Worktree.ID? 250 + let sidebarSelectedWorktreeIDs: Set<Worktree.ID> 251 + let terminalManager: WorktreeTerminalManager 252 + 253 + @ViewBuilder 254 + func body(content: Content) -> some View { 255 + if isEnabled { 256 + content 257 + .onKeyPress { keyPress in 258 + handleKeyPress(keyPress) 259 + } 260 + } else { 261 + content 262 + } 263 + } 264 + 265 + private func handleKeyPress(_ keyPress: KeyPress) -> KeyPress.Result { 266 + guard !keyPress.characters.isEmpty else { return .ignored } 267 + let isNavigationKey = 268 + keyPress.key == .upArrow 269 + || keyPress.key == .downArrow 270 + || keyPress.key == .leftArrow 271 + || keyPress.key == .rightArrow 272 + || keyPress.key == .home 273 + || keyPress.key == .end 274 + || keyPress.key == .pageUp 275 + || keyPress.key == .pageDown 276 + if isNavigationKey { return .ignored } 277 + let hasCommandModifier = keyPress.modifiers.contains(.command) 278 + if hasCommandModifier { return .ignored } 279 + guard let worktreeID = selectedWorktreeID, 280 + sidebarSelectedWorktreeIDs.count == 1, 281 + sidebarSelectedWorktreeIDs.contains(worktreeID), 282 + let terminalState = terminalManager.stateIfExists(for: worktreeID) 283 + else { return .ignored } 284 + terminalState.focusAndInsertText(keyPress.characters) 285 + return .handled 256 286 } 257 287 } 258 288
+35 -7
supacode/Features/Shelf/Models/ShelfBook.swift
··· 1 1 import Foundation 2 + import IdentifiedCollections 2 3 3 4 /// A book on the Shelf — the unified abstraction over a Git worktree or 4 5 /// a plain folder repository. ··· 38 39 /// while in Shelf mode adds its ID here, which causes its spine to 39 40 /// materialize (with the standard spine-flow animation). 40 41 func orderedShelfBooks() -> [ShelfBook] { 41 - let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) 42 + // `ShelfView.body` re-runs on every TCA state change, so this method 43 + // is on the per-frame hot path. The previous implementation built a 44 + // `Dictionary(uniqueKeysWithValues:)` per call and routed worktree 45 + // ordering through `worktreeRowSections(in:)` — which constructs a 46 + // full `WorktreeRowModel` per worktree (PR/info lookups, icon 47 + // resolution, etc.) plus several intermediate `Set` allocations per 48 + // repository. None of that detail is needed by the Shelf, which only 49 + // consumes id/name/branch. Use direct `IdentifiedArray` lookup and 50 + // `orderedWorktrees(in:)` for the lighter ordering path. 42 51 var books: [ShelfBook] = [] 43 52 for repositoryID in orderedRepositoryIDs() { 44 - guard let repository = repositoriesByID[repositoryID] else { continue } 53 + guard let repository = repositories[id: repositoryID] else { continue } 45 54 if repository.kind == .plain { 46 55 guard openedWorktreeIDs.contains(repository.id) else { continue } 47 56 books.append( ··· 55 64 )) 56 65 continue 57 66 } 58 - for row in worktreeRows(in: repository) { 59 - guard openedWorktreeIDs.contains(row.id) else { continue } 67 + for worktree in orderedWorktrees(in: repository) 68 + where openedWorktreeIDs.contains(worktree.id) { 60 69 books.append( 61 70 ShelfBook( 62 - id: row.id, 71 + id: worktree.id, 63 72 repositoryID: repositoryID, 64 - displayName: row.name, 73 + displayName: worktree.name, 65 74 projectName: repository.name, 66 - branchName: row.name, 75 + branchName: worktree.name, 76 + kind: .worktree 77 + )) 78 + } 79 + // Preserve prior behavior of `worktreeRowSections` which also 80 + // surfaced any pending (in-creation) worktrees that had been 81 + // marked opened. The list is typically empty so the cost is 82 + // negligible — the win is avoiding `makePendingWorktreeRow` which 83 + // builds a full `WorktreeRowModel` per entry. 84 + for pending in pendingWorktrees 85 + where pending.repositoryID == repositoryID 86 + && openedWorktreeIDs.contains(pending.id) 87 + { 88 + books.append( 89 + ShelfBook( 90 + id: pending.id, 91 + repositoryID: repositoryID, 92 + displayName: pending.progress.titleText, 93 + projectName: repository.name, 94 + branchName: pending.progress.titleText, 67 95 kind: .worktree 68 96 )) 69 97 }
+22 -9
supacode/Features/Shelf/Views/ShelfOpenBookView.swift
··· 1 1 import AppKit 2 2 import SwiftUI 3 3 4 + private let shelfLogger = SupaLogger("Shelf") 5 + 4 6 /// Renders the terminal content for the currently open book. 5 7 /// 6 8 /// Mirrors the terminal-content slice of `WorktreeTerminalTabsView` without ··· 57 59 } 58 60 ) 59 61 .onAppear { 60 - state.ensureInitialTab(focusing: false) 61 - if shouldAutoFocusTerminal { 62 - state.focusSelectedTab() 62 + shelfLogger.interval("OpenBook.onAppear") { 63 + state.ensureInitialTab(focusing: false) 64 + if shouldAutoFocusTerminal { 65 + state.focusSelectedTab() 66 + } 67 + let activity = resolvedWindowActivity 68 + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 63 69 } 64 - let activity = resolvedWindowActivity 65 - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 70 + } 71 + .onDisappear { 72 + // Long-term diagnostic — pairs with `OpenBook.onAppear` so that 73 + // any future regression in the per-book-switch teardown/remount 74 + // cadence shows up as a count delta on the Points of Interest 75 + // timeline. 76 + shelfLogger.event("OpenBook.onDisappear") 66 77 } 67 78 .onChange(of: state.tabManager.selectedTabId) { _, _ in 68 - if shouldAutoFocusTerminal { 69 - state.focusSelectedTab() 79 + shelfLogger.interval("OpenBook.onChange.selectedTabId") { 80 + if shouldAutoFocusTerminal { 81 + state.focusSelectedTab() 82 + } 83 + let activity = resolvedWindowActivity 84 + state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 70 85 } 71 - let activity = resolvedWindowActivity 72 - state.syncFocus(windowIsKey: activity.isKeyWindow, windowIsVisible: activity.isVisible) 73 86 } 74 87 } 75 88
+8
supacode/Features/Shelf/Views/ShelfSpineView.swift
··· 1 1 import Sharing 2 2 import SwiftUI 3 3 4 + private let shelfLogger = SupaLogger("Shelf") 5 + 4 6 /// Vertical spine rendering for a single book on the Shelf. 5 7 /// 6 8 /// Phase 3 scope: header with book-level notification dot, a vertical ··· 42 44 @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts 43 45 44 46 var body: some View { 47 + // Body-invocation counter signpost. With ~10 spines visible during 48 + // a book switch, the trace can multiply this event count by spine 49 + // count to estimate per-click body work. Emitted as a no-arg event 50 + // (instant timeline marker) so it imposes no work even when 51 + // Instruments isn't attached. 52 + let _ = shelfLogger.event("ShelfSpineView.body") 45 53 VStack(spacing: 0) { 46 54 headerButton 47 55 tabList
+57 -59
supacode/Features/Shelf/Views/ShelfView.swift
··· 1 1 import ComposableArchitecture 2 2 import SwiftUI 3 3 4 + private let shelfLogger = SupaLogger("Shelf") 5 + 4 6 /// Root view for Shelf presentation mode. 5 7 /// 6 8 /// Phase 3 layout: three horizontal segments — a left stack of passed ··· 14 16 let terminalManager: WorktreeTerminalManager 15 17 let createTab: () -> Void 16 18 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 19 /// Mirrors the Ghostty `background-opacity` setting so the Shelf can 23 20 /// honor the same window transparency as normal view mode. A previous 24 21 /// plain `.background(.background)` defeated transparency entirely by ··· 27 24 @Environment(\.surfaceBackgroundOpacity) private var surfaceBackgroundOpacity 28 25 29 26 var body: some View { 27 + // Body-invocation counter. The @ViewBuilder getter rules out a 28 + // `defer`-based interval, but a fire-and-forget event marker is a 29 + // simple expression and has no impact on the rendered tree. Each 30 + // marker corresponds to one full body re-evaluation — useful for 31 + // sanity-checking how often the root re-renders during animation. 32 + let _ = shelfLogger.event("ShelfView.body") 30 33 let state = store.state 31 34 let books = state.orderedShelfBooks() 32 35 let openBookID = state.openShelfBookID ··· 35 38 } 36 39 37 40 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 - ) 41 + ForEach(Array(books.enumerated()), id: \.element.id) { index, book in 42 + spine(book: book, index: index, openIndex: openIndex) 43 + if book.id == openBookID { 44 + openBookArea(for: book, state: state) 49 45 } 50 - } else { 51 - spineStack(books: books, openIndex: nil, baseOffset: 0) 46 + } 47 + if openBookID == nil { 52 48 emptyOpenArea() 53 49 } 54 50 } ··· 61 57 .animation(.easeInOut(duration: 0.2), value: openBookID) 62 58 } 63 59 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 60 @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 - closeMenuTitle: closeMenuTitle(for: book), 92 - onCloseBook: { closeBook(book) }, 93 - onOpenRepositorySettings: { 94 - store.send(.repositoryManagement(.openRepositorySettings(book.repositoryID))) 95 - } 96 - ) 97 - .matchedGeometryEffect(id: book.id, in: spineNamespace) 61 + private func spine(book: ShelfBook, index: Int, openIndex: Int?) -> some View { 62 + let distance = openIndex.map { abs(index - $0) } 63 + let open = index == openIndex 64 + ShelfSpineView( 65 + book: book, 66 + isOpen: open, 67 + distanceFromOpen: distance, 68 + terminalState: terminalManager.stateIfExists(for: book.id), 69 + onOpenBook: { openBook(book, selectingTab: nil) }, 70 + onSelectTab: { tabID in openBook(book, selectingTab: tabID) }, 71 + onNewTab: { 72 + // On a closed spine, `+` doubles as "pull this book out and 73 + // start a fresh tab". Sequencing is fine because TCA runs 74 + // reducers synchronously — `newTerminal` will observe the 75 + // new `selectedTerminalWorktree` set by `selectWorktree`. 76 + switchToBookIfNeeded(book) 77 + createTab() 78 + }, 79 + onSplitVertical: open ? { performSplit(direction: "new_split:right") } : nil, 80 + onSplitHorizontal: open ? { performSplit(direction: "new_split:down") } : nil, 81 + closeMenuTitle: closeMenuTitle(for: book), 82 + onCloseBook: { closeBook(book) }, 83 + onOpenRepositorySettings: { 84 + store.send(.repositoryManagement(.openRepositorySettings(book.repositoryID))) 98 85 } 99 - } 86 + ) 100 87 } 101 88 102 89 /// Dispatch the open-book action only when `book` isn't already the open 103 90 /// one — idempotent helper for taps that imply a book change. 91 + /// 92 + /// No `animation:` is passed to `store.send` because the visible 93 + /// spine-flow animation is already driven by the view-level 94 + /// `.animation(.easeInOut(duration: 0.2), value: openBookID)` modifier 95 + /// on the root container — wrapping the dispatch in another animation 96 + /// transaction would double-run layout / transition machinery for the 97 + /// same change. 104 98 private func switchToBookIfNeeded(_ book: ShelfBook) { 105 99 guard !isOpen(book) else { return } 100 + shelfLogger.event("BookClick.NewTabSpine") 106 101 switch book.kind { 107 102 case .worktree: 108 - store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2)) 103 + store.send(.selectWorktree(book.id, focusTerminal: true)) 109 104 case .plainFolder: 110 - store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2)) 105 + store.send(.selectRepository(book.repositoryID)) 111 106 } 112 107 } 113 108 ··· 190 185 private func openBook(_ book: ShelfBook, selectingTab tabID: TerminalTabID?) { 191 186 let isAlreadyOpen = store.state.openShelfBookID == book.id 192 187 if let tabID, isAlreadyOpen, let state = terminalManager.stateIfExists(for: book.id) { 188 + shelfLogger.event("BookClick.TabSwitchSameBook") 193 189 state.tabManager.selectTab(tabID) 194 190 return 195 191 } 196 - // Animate the spine flow and terminal crossfade. The duration and 197 - // curve mirror the Shelf design doc: ~200ms ease-in-out, snappy but 198 - // legible so the user can read each spine's movement. 192 + shelfLogger.event("BookClick.SwitchBook") 193 + // The spine flow / terminal crossfade animation is already driven 194 + // by the view-level `.animation(_:value: openBookID)` on the root 195 + // container (~200ms ease-in-out per the Shelf design doc), so the 196 + // dispatch itself does not pass an `animation:` argument here. 199 197 switch book.kind { 200 198 case .worktree: 201 - store.send(.selectWorktree(book.id, focusTerminal: true), animation: .easeInOut(duration: 0.2)) 199 + store.send(.selectWorktree(book.id, focusTerminal: true)) 202 200 case .plainFolder: 203 - store.send(.selectRepository(book.repositoryID), animation: .easeInOut(duration: 0.2)) 201 + store.send(.selectRepository(book.repositoryID)) 204 202 } 205 203 if let tabID { 206 204 // Apply tab selection eagerly; the target book's state already exists
+15 -5
supacode/Features/Terminal/Models/WorktreeTerminalState.swift
··· 315 315 } 316 316 317 317 func focusSelectedTab() { 318 - guard let tabId = tabManager.selectedTabId else { return } 319 - focusSurface(in: tabId) 318 + terminalStateLogger.interval("focusSelectedTab") { 319 + guard let tabId = tabManager.selectedTabId else { return } 320 + focusSurface(in: tabId) 321 + } 320 322 } 321 323 322 324 @discardableResult ··· 345 347 } 346 348 347 349 func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { 348 - lastWindowIsKey = windowIsKey 349 - lastWindowIsVisible = windowIsVisible 350 - applySurfaceActivity() 350 + terminalStateLogger.interval("syncFocus") { 351 + lastWindowIsKey = windowIsKey 352 + lastWindowIsVisible = windowIsVisible 353 + applySurfaceActivity() 354 + } 351 355 } 352 356 353 357 private func applySurfaceActivity() { 358 + terminalStateLogger.interval("applySurfaceActivity") { 359 + applySurfaceActivityImpl() 360 + } 361 + } 362 + 363 + private func applySurfaceActivityImpl() { 354 364 let selectedTabId = tabManager.selectedTabId 355 365 var surfaceToFocus: GhosttySurfaceView? 356 366 for (tabId, tree) in trees {
+34 -16
supacode/Infrastructure/Ghostty/GhosttyTerminalView.swift
··· 11 11 } 12 12 13 13 func makeNSView(context: Context) -> GhosttySurfaceScrollView { 14 - let view = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: hostKind) 15 - view.pinnedSize = pinnedSize 16 - terminalHostLogger.info( 17 - "[CanvasExit] hostMake wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " 18 - + "surface=\(surfaceView.debugIdentifierForLogging) " 19 - + "pinned=\(pinnedSize != nil)" 20 - ) 21 - return view 14 + // Wrap in a signpost interval so the cost of NSView construction 15 + // (allocating the scroll view, attaching the Metal-backed surface) 16 + // shows up on the Points of Interest timeline. Frequency tells us 17 + // how often `.id(worktree.id)` on `ShelfOpenBookView` is forcing a 18 + // wholesale teardown/recreate on book switching — total time tells 19 + // us if AppKit/Metal initialization is on the hot path. 20 + return terminalHostLogger.interval("Ghostty.makeNSView") { 21 + let view = GhosttySurfaceScrollView(surfaceView: surfaceView, hostKind: hostKind) 22 + view.pinnedSize = pinnedSize 23 + terminalHostLogger.info( 24 + "[CanvasExit] hostMake wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " 25 + + "surface=\(surfaceView.debugIdentifierForLogging) " 26 + + "pinned=\(pinnedSize != nil)" 27 + ) 28 + return view 29 + } 22 30 } 23 31 24 32 func updateNSView(_ view: GhosttySurfaceScrollView, context: Context) { 25 - view.pinnedSize = pinnedSize 26 - terminalHostLogger.info( 27 - "[CanvasExit] hostUpdate wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " 28 - + "surface=\(surfaceView.debugIdentifierForLogging) " 29 - + "pinned=\(pinnedSize != nil) " 30 - + "attached=\(view.isSurfaceAttachedToDocumentView)" 31 - ) 32 - view.ensureSurfaceAttached() 33 + terminalHostLogger.interval("Ghostty.updateNSView") { 34 + view.pinnedSize = pinnedSize 35 + terminalHostLogger.info( 36 + "[CanvasExit] hostUpdate wrapper=\(view.debugIdentifier) host=\(hostKind.rawValue) " 37 + + "surface=\(surfaceView.debugIdentifierForLogging) " 38 + + "pinned=\(pinnedSize != nil) " 39 + + "attached=\(view.isSurfaceAttachedToDocumentView)" 40 + ) 41 + view.ensureSurfaceAttached() 42 + } 43 + } 44 + 45 + static func dismantleNSView(_ view: GhosttySurfaceScrollView, coordinator: Void) { 46 + // Pairs with `Ghostty.makeNSView` on the timeline so the interval 47 + // count of `make` minus `dismantle` gives a live count of attached 48 + // surface wrappers — and frequency confirms whether each book 49 + // switch tears down the wrapper layer. 50 + terminalHostLogger.event("Ghostty.dismantleNSView") 33 51 } 34 52 }
+58 -1
supacode/Support/SupaLogger.swift
··· 5 5 #if !DEBUG 6 6 private let logger: Logger 7 7 #endif 8 + /// Signposter for emitting `os_signpost` intervals/events visible in 9 + /// Instruments. Signposts are essentially zero-cost when no Instruments 10 + /// session is attached (a single TLS read), so they are always live — 11 + /// no DEBUG gating. 12 + /// 13 + /// The signposter uses the well-known `"PointsOfInterest"` category 14 + /// regardless of the logger's own `category` so that intervals and 15 + /// events automatically surface in Apple's **Points of Interest** 16 + /// instrument (the discoverable, "just drag it in" track most people 17 + /// will reach for). The signpost `name:` argument carries the actual 18 + /// origin (e.g. `"OpenBook.onAppear"`, `"focusSelectedTab"`) so source 19 + /// granularity is preserved — only the routing category differs from 20 + /// the regular log channel. 21 + let signposter: OSSignposter 8 22 9 23 init(_ category: String) { 10 24 self.category = category 25 + let subsystem = Bundle.main.bundleIdentifier ?? "com.onevcat.prowl" 11 26 #if !DEBUG 12 - self.logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: category) 27 + self.logger = Logger(subsystem: subsystem, category: category) 13 28 #endif 29 + self.signposter = OSSignposter(subsystem: subsystem, category: "PointsOfInterest") 14 30 } 15 31 16 32 func debug(_ message: String) { ··· 36 52 logger.warning("\(message, privacy: .public)") 37 53 #endif 38 54 } 55 + 56 + /// Wraps `body` in an `os_signpost` interval named `name`. The 57 + /// interval renders as a labeled bar on the Instruments timeline, 58 + /// making it trivial to correlate hotspots with hangs/hitches without 59 + /// post-processing the trace XML. 60 + func interval<T>(_ name: StaticString, _ body: () throws -> T) rethrows -> T { 61 + let id = signposter.makeSignpostID() 62 + let state = signposter.beginInterval(name, id: id) 63 + defer { signposter.endInterval(name, state) } 64 + return try body() 65 + } 66 + 67 + /// Manual begin/end pair for code paths that can't use the closure 68 + /// form — e.g. inside a TCA reducer case where `inout state` cannot 69 + /// be captured by a non-escaping closure. The returned `IntervalToken` 70 + /// is opaque to callers, so they don't have to import `OSLog` 71 + /// themselves. 72 + func beginInterval(_ name: StaticString) -> IntervalToken { 73 + let id = signposter.makeSignpostID() 74 + let state = signposter.beginInterval(name, id: id) 75 + return IntervalToken(name: name, state: state) 76 + } 77 + 78 + func endInterval(_ token: IntervalToken) { 79 + signposter.endInterval(token.name, token.state) 80 + } 81 + 82 + /// Emits an instantaneous `os_signpost` event marker — useful for 83 + /// marking discrete moments (e.g. "user clicked book") without an 84 + /// associated duration. 85 + func event(_ name: StaticString) { 86 + signposter.emitEvent(name) 87 + } 88 + } 89 + 90 + /// Opaque token bundling a signpost name and its interval state so 91 + /// callers can `beginInterval` / `endInterval` without depending on 92 + /// `OSLog` themselves. 93 + struct IntervalToken { 94 + fileprivate let name: StaticString 95 + fileprivate let state: OSSignpostIntervalState 39 96 }
+9 -4
supacodeTests/CommandKeyObserverTests.swift
··· 4 4 @testable import supacode 5 5 6 6 struct CommandKeyObserverTests { 7 - @Test func shouldShowShortcutsForCommandOrControl() { 7 + @Test func shouldShowShortcutsForBareCommandOrControl() { 8 8 #expect(CommandKeyObserver.shouldShowShortcuts(for: [.command])) 9 9 #expect(CommandKeyObserver.shouldShowShortcuts(for: [.control])) 10 - #expect(CommandKeyObserver.shouldShowShortcuts(for: [.command, .shift])) 11 - #expect(CommandKeyObserver.shouldShowShortcuts(for: [.control, .option])) 10 + } 11 + 12 + @Test func shouldNotShowShortcutsForShortcutCombinations() { 13 + #expect(CommandKeyObserver.shouldShowShortcuts(for: [.command, .shift]) == false) 14 + #expect(CommandKeyObserver.shouldShowShortcuts(for: [.control, .option]) == false) 15 + #expect(CommandKeyObserver.shouldShowShortcuts(for: [.command, .control]) == false) 16 + #expect(CommandKeyObserver.shouldShowShortcuts(for: [.command, .control, .shift]) == false) 12 17 } 13 18 14 - @Test func shouldNotShowShortcutsForOtherModifiers() { 19 + @Test func shouldNotShowShortcutsForNonHintModifiers() { 15 20 #expect(CommandKeyObserver.shouldShowShortcuts(for: []) == false) 16 21 #expect(CommandKeyObserver.shouldShowShortcuts(for: [.shift]) == false) 17 22 #expect(CommandKeyObserver.shouldShowShortcuts(for: [.option]) == false)