@brimveyn/aimux 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/pty/terminal-snapshot.ts +7 -5
  6. package/src/state/reducers/git-mode-state.ts +58 -0
  7. package/src/state/types.ts +11 -6
  8. package/src/ui/breaking-update-screen.tsx +2 -2
  9. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  10. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  11. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  12. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  13. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  14. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  15. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  17. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  18. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  19. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  20. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  21. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  22. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  23. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  24. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  25. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  26. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  27. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  28. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  29. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  30. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  31. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  32. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  33. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  34. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  35. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  36. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  37. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  38. package/src/ui/components/modals/shared/form.tsx +164 -0
  39. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  40. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  41. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  42. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  43. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  44. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  45. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  46. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  47. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  48. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  49. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  50. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  51. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  52. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  53. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  54. package/src/ui/root.tsx +24 -22
  55. package/src/ui/theme-store.ts +30 -26
  56. package/src/ui/themes.ts +1 -0
  57. package/src/ui/components/create-session-modal.tsx +0 -96
  58. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  59. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  60. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  61. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  62. package/src/ui/components/session-name-modal.tsx +0 -11
  63. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  64. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  65. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  66. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  67. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -0,0 +1,62 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import { type FileDiffMetadata, parsePatchFiles } from '../../../../diff-parser'
4
+ import { diffHash } from '../../../../git/diff-hash'
5
+ import { buildDiffSegments, type DiffSegment } from './build-rows'
6
+ import { filetypeFromPath } from './filetype'
7
+
8
+ export interface PreparedParsedDiff {
9
+ file: FileDiffMetadata | null
10
+ filetype: string | null
11
+ firstChangeOffset: number
12
+ segments: DiffSegment[]
13
+ }
14
+
15
+ export interface PreparedDiff {
16
+ hash: string
17
+ parsed: PreparedParsedDiff
18
+ highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
19
+ }
20
+
21
+ interface PrepareOptions {
22
+ signal?: AbortSignal
23
+ }
24
+
25
+ function yieldToEventLoop(): Promise<void> {
26
+ return new Promise((resolve) => {
27
+ setImmediate(resolve)
28
+ })
29
+ }
30
+
31
+ function throwIfAborted(signal?: AbortSignal): void {
32
+ if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
33
+ }
34
+
35
+ // Lifts diff parsing off the synchronous render tree and returns a segmented
36
+ // model immediately. Syntax highlighting is requested later for visible
37
+ // segments so first paint is not blocked on full-file tokenization.
38
+ export async function prepareDiff(
39
+ diff: string,
40
+ path: string,
41
+ opts: PrepareOptions
42
+ ): Promise<PreparedDiff> {
43
+ const hash = diffHash(diff)
44
+ throwIfAborted(opts.signal)
45
+ await yieldToEventLoop()
46
+ throwIfAborted(opts.signal)
47
+ const patches = parsePatchFiles(diff)
48
+ const files = patches.flatMap((patch) => patch.files)
49
+ const file = files.length === 1 ? (files[0] ?? null) : null
50
+ const filetype = file ? (filetypeFromPath(path) ?? null) : null
51
+ const segmented = file ? buildDiffSegments(file) : { firstChangeOffset: -1, segments: [] }
52
+ return {
53
+ hash,
54
+ highlights: { add: [], del: [] },
55
+ parsed: {
56
+ file,
57
+ filetype,
58
+ firstChangeOffset: segmented.firstChangeOffset,
59
+ segments: segmented.segments,
60
+ },
61
+ }
62
+ }
@@ -5,20 +5,35 @@ import {
5
5
  type ScrollBoxRenderable,
6
6
  TextAttributes,
7
7
  } from '@opentui/core'
8
- import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
8
+ import {
9
+ forwardRef,
10
+ useCallback,
11
+ useEffect,
12
+ useImperativeHandle,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react'
9
17
 
10
- import type { FileDiffMetadata } from '../../../diff-parser'
11
- import type { FoldState } from '../../../state/types'
18
+ import type { FileDiffMetadata } from '../../../../diff-parser'
12
19
  import type { DiffHighlights, FoldDispatch } from './pierre-diff'
13
20
 
14
- import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
15
- import { scrollGitDiff } from '../../git-view-controls'
16
- import { useBg, useTokens, useTransparent } from '../../theme'
17
- import { buildSplitRows, gutterWidth, type SplitCell, type SplitRowOrHeader } from './build-rows'
21
+ import { getScrollViewportDelta } from '../../../../app-runtime/terminal-mouse-adapter'
22
+ import { scrollGitDiff } from '../../../git-view-controls'
23
+ import { useBg, useTokens, useTransparent } from '../../../theme'
24
+ import {
25
+ type DiffSegment,
26
+ estimatedSegmentHeight,
27
+ expandSplitSegment,
28
+ gutterWidth,
29
+ type SplitCell,
30
+ type SplitRowOrHeader,
31
+ } from './build-rows'
18
32
  import { FoldStrip } from './fold-strip'
19
33
  import { tokenToSpan } from './highlight'
34
+ import { useSegmentVirtualization } from './use-segment-virtualization'
20
35
 
21
- const LARGE_ROW_CAP = 5000
36
+ const OVERSCAN = 24
22
37
 
23
38
  export interface SplitViewHandle {
24
39
  leftScroll: ScrollBoxRenderable | null
@@ -28,9 +43,16 @@ export interface SplitViewHandle {
28
43
  interface Props {
29
44
  file: FileDiffMetadata
30
45
  highlights: DiffHighlights
31
- folds: Record<string, FoldState>
32
46
  foldDispatch: FoldDispatch
33
47
  contentWidth: number
48
+ requestSegmentHighlights: (segments: readonly DiffSegment[]) => void
49
+ segments: DiffSegment[]
50
+ }
51
+
52
+ interface RenderedSegment {
53
+ exactHeight: number
54
+ rows: SplitRowOrHeader[]
55
+ segment: DiffSegment
34
56
  }
35
57
 
36
58
  function handleScroll(e: OtuiMouseEvent): void {
@@ -42,12 +64,15 @@ function handleScroll(e: OtuiMouseEvent): void {
42
64
  }
43
65
 
44
66
  export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
45
- { contentWidth, file, foldDispatch, folds, highlights },
67
+ { contentWidth, file, foldDispatch, highlights, requestSegmentHighlights, segments },
46
68
  ref
47
69
  ) {
48
70
  const separatorBg = useBg('base')
49
71
  const leftRef = useRef<ScrollBoxRenderable | null>(null)
50
72
  const rightRef = useRef<ScrollBoxRenderable | null>(null)
73
+ const measuredHeightsRef = useRef<Record<string, number>>({})
74
+ const commitFrameRef = useRef(0)
75
+ const [measurementVersion, setMeasurementVersion] = useState(0)
51
76
 
52
77
  useImperativeHandle(
53
78
  ref,
@@ -62,10 +87,64 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
62
87
  []
63
88
  )
64
89
 
65
- const rows = useMemo(() => buildSplitRows(file, folds, contentWidth), [file, folds, contentWidth])
90
+ useEffect(() => {
91
+ cancelAnimationFrame(commitFrameRef.current)
92
+ if (Object.keys(measuredHeightsRef.current).length === 0) return
93
+ measuredHeightsRef.current = {}
94
+ setMeasurementVersion((version) => version + 1)
95
+ }, [contentWidth, file])
96
+
97
+ useEffect(() => {
98
+ return () => cancelAnimationFrame(commitFrameRef.current)
99
+ }, [])
100
+
101
+ const estimateHeight = useCallback(
102
+ (segment: DiffSegment) =>
103
+ measuredHeightsRef.current[segment.id] ?? estimatedSegmentHeight(segment, 'split'),
104
+ []
105
+ )
106
+
107
+ const visibleWindow = useSegmentVirtualization({
108
+ estimateHeight,
109
+ overscan: OVERSCAN,
110
+ scrollRef: leftRef,
111
+ segments,
112
+ version: measurementVersion,
113
+ })
114
+
115
+ const renderedSegments = useMemo<RenderedSegment[]>(() => {
116
+ return visibleWindow.visible.map((segment) => {
117
+ const rows = expandSplitSegment(file, segment, contentWidth)
118
+ return {
119
+ exactHeight: rows.reduce((sum, row) => sum + (row.type === 'row' ? row.height : 1), 0),
120
+ rows,
121
+ segment,
122
+ }
123
+ })
124
+ }, [contentWidth, file, visibleWindow.visible])
125
+
126
+ useEffect(() => {
127
+ requestSegmentHighlights(visibleWindow.visible)
128
+ }, [requestSegmentHighlights, visibleWindow.visible])
129
+
130
+ useEffect(() => {
131
+ if (renderedSegments.length === 0) return
132
+ let changed = false
133
+ const next = { ...measuredHeightsRef.current }
134
+ for (const rendered of renderedSegments) {
135
+ if (next[rendered.segment.id] === rendered.exactHeight) continue
136
+ next[rendered.segment.id] = rendered.exactHeight
137
+ changed = true
138
+ }
139
+ if (!changed) return
140
+ measuredHeightsRef.current = next
141
+ cancelAnimationFrame(commitFrameRef.current)
142
+ commitFrameRef.current = requestAnimationFrame(() => {
143
+ setMeasurementVersion((version) => version + 1)
144
+ })
145
+ }, [renderedSegments])
146
+
66
147
  const gw = useMemo(() => gutterWidth(file), [file])
67
- const truncated = rows.length > LARGE_ROW_CAP
68
- const displayRows = truncated ? rows.slice(0, LARGE_ROW_CAP) : rows
69
148
 
70
149
  return (
71
150
  <box flexDirection="row" flexGrow={1} overflow="hidden" onMouseScroll={handleScroll}>
@@ -78,18 +157,21 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
78
157
  verticalScrollbarOptions={{ visible: false }}
79
158
  onMouseScroll={handleScroll}
80
159
  >
81
- {displayRows.map((row, i) => (
82
- <SideRow
83
- key={i}
84
- cell={row.type === 'row' ? row.left : null}
85
- foldDispatch={foldDispatch}
86
- gw={gw}
87
- header={row.type === 'hunk-header' ? row : null}
88
- rowHeight={row.type === 'row' ? row.height : 1}
89
- tokens={highlights.del}
90
- />
91
- ))}
92
- {truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
160
+ {visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
161
+ {renderedSegments.map((rendered) =>
162
+ rendered.rows.map((row, i) => (
163
+ <SideRow
164
+ key={`${rendered.segment.id}:left:${i}`}
165
+ cell={row.type === 'row' ? row.left : null}
166
+ foldDispatch={foldDispatch}
167
+ gw={gw}
168
+ header={row.type === 'hunk-header' ? row : null}
169
+ rowHeight={row.type === 'row' ? row.height : 1}
170
+ tokens={highlights.del}
171
+ />
172
+ ))
173
+ )}
174
+ {visibleWindow.bottomSpacer > 0 ? <box height={visibleWindow.bottomSpacer} /> : null}
93
175
  </scrollbox>
94
176
  <box width={1} backgroundColor={separatorBg} />
95
177
  <scrollbox
@@ -100,33 +182,26 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
100
182
  contentOptions={{ flexDirection: 'column', gap: 0 }}
101
183
  onMouseScroll={handleScroll}
102
184
  >
103
- {displayRows.map((row, i) => (
104
- <SideRow
105
- key={i}
106
- cell={row.type === 'row' ? row.right : null}
107
- foldDispatch={foldDispatch}
108
- gw={gw}
109
- header={row.type === 'hunk-header' ? row : null}
110
- rowHeight={row.type === 'row' ? row.height : 1}
111
- tokens={highlights.add}
112
- />
113
- ))}
114
- {truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
185
+ {visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
186
+ {renderedSegments.map((rendered) =>
187
+ rendered.rows.map((row, i) => (
188
+ <SideRow
189
+ key={`${rendered.segment.id}:right:${i}`}
190
+ cell={row.type === 'row' ? row.right : null}
191
+ foldDispatch={foldDispatch}
192
+ gw={gw}
193
+ header={row.type === 'hunk-header' ? row : null}
194
+ rowHeight={row.type === 'row' ? row.height : 1}
195
+ tokens={highlights.add}
196
+ />
197
+ ))
198
+ )}
199
+ {visibleWindow.bottomSpacer > 0 ? <box height={visibleWindow.bottomSpacer} /> : null}
115
200
  </scrollbox>
116
201
  </box>
117
202
  )
118
203
  })
119
204
 
120
- function TruncationNotice({ hidden }: { hidden: number }) {
121
- const t = useTokens()
122
- const headerBg = useBg('elevated')
123
- return (
124
- <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
125
- <text fg={t.palette.warning}>…diff truncated — {hidden} more rows hidden</text>
126
- </box>
127
- )
128
- }
129
-
130
205
  function SideRow({
131
206
  cell,
132
207
  foldDispatch,
@@ -5,20 +5,34 @@ import {
5
5
  type ScrollBoxRenderable,
6
6
  TextAttributes,
7
7
  } from '@opentui/core'
8
- import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
8
+ import {
9
+ forwardRef,
10
+ useCallback,
11
+ useEffect,
12
+ useImperativeHandle,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from 'react'
9
17
 
10
- import type { FileDiffMetadata } from '../../../diff-parser'
11
- import type { FoldState } from '../../../state/types'
18
+ import type { FileDiffMetadata } from '../../../../diff-parser'
12
19
  import type { DiffHighlights, FoldDispatch } from './pierre-diff'
13
20
 
14
- import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
15
- import { scrollGitDiff } from '../../git-view-controls'
16
- import { useBg, useTokens, useTransparent } from '../../theme'
17
- import { buildUnifiedRows, gutterWidth, type UnifiedRowOrHeader } from './build-rows'
21
+ import { getScrollViewportDelta } from '../../../../app-runtime/terminal-mouse-adapter'
22
+ import { scrollGitDiff } from '../../../git-view-controls'
23
+ import { useBg, useTokens, useTransparent } from '../../../theme'
24
+ import {
25
+ type DiffSegment,
26
+ estimatedSegmentHeight,
27
+ expandUnifiedSegment,
28
+ gutterWidth,
29
+ type UnifiedRowOrHeader,
30
+ } from './build-rows'
18
31
  import { FoldStrip } from './fold-strip'
19
32
  import { tokenToSpan } from './highlight'
33
+ import { useSegmentVirtualization } from './use-segment-virtualization'
20
34
 
21
- const LARGE_ROW_CAP = 5000
35
+ const OVERSCAN = 24
22
36
 
23
37
  export interface StackedViewHandle {
24
38
  scroll: ScrollBoxRenderable | null
@@ -27,9 +41,16 @@ export interface StackedViewHandle {
27
41
  interface Props {
28
42
  file: FileDiffMetadata
29
43
  highlights: DiffHighlights
30
- folds: Record<string, FoldState>
31
44
  foldDispatch: FoldDispatch
32
45
  contentWidth: number
46
+ requestSegmentHighlights: (segments: readonly DiffSegment[]) => void
47
+ segments: DiffSegment[]
48
+ }
49
+
50
+ interface RenderedSegment {
51
+ exactHeight: number
52
+ rows: UnifiedRowOrHeader[]
53
+ segment: DiffSegment
33
54
  }
34
55
 
35
56
  function handleScroll(e: OtuiMouseEvent): void {
@@ -41,10 +62,13 @@ function handleScroll(e: OtuiMouseEvent): void {
41
62
  }
42
63
 
43
64
  export const StackedView = forwardRef<StackedViewHandle, Props>(function StackedView(
44
- { contentWidth, file, foldDispatch, folds, highlights },
65
+ { contentWidth, file, foldDispatch, highlights, requestSegmentHighlights, segments },
45
66
  ref
46
67
  ) {
47
68
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
69
+ const measuredHeightsRef = useRef<Record<string, number>>({})
70
+ const commitFrameRef = useRef(0)
71
+ const [measurementVersion, setMeasurementVersion] = useState(0)
48
72
 
49
73
  useImperativeHandle(
50
74
  ref,
@@ -56,13 +80,67 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
56
80
  []
57
81
  )
58
82
 
59
- const rows = useMemo(
60
- () => buildUnifiedRows(file, folds, contentWidth),
61
- [file, folds, contentWidth]
83
+ useEffect(() => {
84
+ cancelAnimationFrame(commitFrameRef.current)
85
+ if (Object.keys(measuredHeightsRef.current).length === 0) return
86
+ measuredHeightsRef.current = {}
87
+ setMeasurementVersion((version) => version + 1)
88
+ }, [contentWidth, file])
89
+
90
+ useEffect(() => {
91
+ return () => cancelAnimationFrame(commitFrameRef.current)
92
+ }, [])
93
+
94
+ const estimateHeight = useCallback(
95
+ (segment: DiffSegment) =>
96
+ measuredHeightsRef.current[segment.id] ?? estimatedSegmentHeight(segment, 'stacked'),
97
+ []
62
98
  )
99
+
100
+ const visibleWindow = useSegmentVirtualization({
101
+ estimateHeight,
102
+ overscan: OVERSCAN,
103
+ scrollRef,
104
+ segments,
105
+ version: measurementVersion,
106
+ })
107
+
108
+ const renderedSegments = useMemo<RenderedSegment[]>(() => {
109
+ return visibleWindow.visible.map((segment) => {
110
+ const rows = expandUnifiedSegment(file, segment, contentWidth)
111
+ return {
112
+ exactHeight: rows.reduce(
113
+ (sum, row) => sum + (row.type === 'hunk-header' || row.type === 'fold' ? 1 : row.height),
114
+ 0
115
+ ),
116
+ rows,
117
+ segment,
118
+ }
119
+ })
120
+ }, [contentWidth, file, visibleWindow.visible])
121
+
122
+ useEffect(() => {
123
+ requestSegmentHighlights(visibleWindow.visible)
124
+ }, [requestSegmentHighlights, visibleWindow.visible])
125
+
126
+ useEffect(() => {
127
+ if (renderedSegments.length === 0) return
128
+ let changed = false
129
+ const next = { ...measuredHeightsRef.current }
130
+ for (const rendered of renderedSegments) {
131
+ if (next[rendered.segment.id] === rendered.exactHeight) continue
132
+ next[rendered.segment.id] = rendered.exactHeight
133
+ changed = true
134
+ }
135
+ if (!changed) return
136
+ measuredHeightsRef.current = next
137
+ cancelAnimationFrame(commitFrameRef.current)
138
+ commitFrameRef.current = requestAnimationFrame(() => {
139
+ setMeasurementVersion((version) => version + 1)
140
+ })
141
+ }, [renderedSegments])
142
+
63
143
  const gw = useMemo(() => gutterWidth(file), [file])
64
- const truncated = rows.length > LARGE_ROW_CAP
65
- const displayRows = truncated ? rows.slice(0, LARGE_ROW_CAP) : rows
66
144
 
67
145
  return (
68
146
  <scrollbox
@@ -73,30 +151,23 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
73
151
  contentOptions={{ flexDirection: 'column', gap: 0 }}
74
152
  onMouseScroll={handleScroll}
75
153
  >
76
- {displayRows.map((row, i) => (
77
- <UnifiedRowRender
78
- key={i}
79
- foldDispatch={foldDispatch}
80
- gw={gw}
81
- highlights={highlights}
82
- row={row}
83
- />
84
- ))}
85
- {truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
154
+ {visibleWindow.topSpacer > 0 ? <box height={visibleWindow.topSpacer} /> : null}
155
+ {renderedSegments.map((rendered) =>
156
+ rendered.rows.map((row, i) => (
157
+ <UnifiedRowRender
158
+ key={`${rendered.segment.id}:${i}`}
159
+ foldDispatch={foldDispatch}
160
+ gw={gw}
161
+ highlights={highlights}
162
+ row={row}
163
+ />
164
+ ))
165
+ )}
166
+ {visibleWindow.bottomSpacer > 0 ? <box height={visibleWindow.bottomSpacer} /> : null}
86
167
  </scrollbox>
87
168
  )
88
169
  })
89
170
 
90
- function TruncationNotice({ hidden }: { hidden: number }) {
91
- const t = useTokens()
92
- const headerBg = useBg('elevated')
93
- return (
94
- <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
95
- <text fg={t.palette.warning}>…diff truncated — {hidden} more rows hidden</text>
96
- </box>
97
- )
98
- }
99
-
100
171
  function UnifiedRowRender({
101
172
  foldDispatch,
102
173
  gw,
@@ -1,12 +1,12 @@
1
1
  import { useEffect, useRef } from 'react'
2
2
 
3
- import type { GitFileEntry } from '../../../state/types'
3
+ import type { GitFileEntry } from '../../../../state/types'
4
4
 
5
- import { diffHash } from '../../../git/diff-hash'
6
- import { fetchDiff } from '../../../git/git-diff'
7
- import { useAppStore } from '../../../state/app-store'
8
- import { dispatchGlobal } from '../../../state/dispatch-ref'
9
- import { buildGitTreeRows } from '../../../state/git-tree'
5
+ import { diffHash } from '../../../../git/diff-hash'
6
+ import { fetchDiff } from '../../../../git/git-diff'
7
+ import { useAppStore } from '../../../../state/app-store'
8
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
9
+ import { buildGitTreeRows } from '../../../../state/git-tree'
10
10
  import { prepareDiff } from './prepare-diff'
11
11
 
12
12
  interface PrefetchOptions {
@@ -94,7 +94,7 @@ export function useDiffPrefetch(
94
94
 
95
95
  const queueRef = useRef<PrefetchQueue | null>(null)
96
96
 
97
- const { enabled, headOffset, projectPath, themeId } = opts
97
+ const { enabled, headOffset, projectPath } = opts
98
98
  const runTaskRef = useRef<(task: Task) => Promise<void>>(async () => {})
99
99
 
100
100
  // Keep a ref to the execute function so the queue keeps the latest closure
@@ -111,19 +111,11 @@ export function useDiffPrefetch(
111
111
  })
112
112
  if (task.controller.signal.aborted) return
113
113
  dispatchGlobal({
114
- file: prep.file,
114
+ file: prep.parsed,
115
115
  hash: prep.hash,
116
116
  key: task.key,
117
117
  type: 'git-mode-set-parsed',
118
118
  })
119
- dispatchGlobal({
120
- add: prep.highlights.add,
121
- del: prep.highlights.del,
122
- hash: prep.hash,
123
- key: task.key,
124
- themeId,
125
- type: 'git-mode-set-highlights',
126
- })
127
119
  } catch {
128
120
  // Prefetch errors are non-fatal; the foreground fetch will retry on focus.
129
121
  }
@@ -179,7 +171,6 @@ export function useDiffPrefetch(
179
171
  parsed,
180
172
  loading,
181
173
  headOffset,
182
- themeId,
183
174
  ])
184
175
 
185
176
  useEffect(() => {