@brimveyn/aimux 1.8.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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +6 -1
- package/src/app.tsx +20 -16
- package/src/input/keymap/describe-bindings.ts +63 -2
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +1 -0
- package/src/pty/terminal-snapshot.ts +7 -5
- package/src/services/ai-usage/adapters/claude.ts +102 -26
- package/src/services/ai-usage/adapters/codex.ts +83 -32
- package/src/services/ai-usage/cache.ts +60 -0
- package/src/services/ai-usage/pace.ts +79 -0
- package/src/services/ai-usage/provider.ts +43 -20
- package/src/services/ai-usage/types.ts +31 -0
- package/src/state/ai-usage-store.ts +10 -3
- package/src/state/reducers/git-mode-state.ts +58 -0
- package/src/state/reducers/modal-state.ts +13 -0
- package/src/state/types.ts +18 -6
- package/src/ui/breaking-update-screen.tsx +2 -2
- package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
- package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
- package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
- package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
- package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
- package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
- package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
- package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
- package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
- package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
- package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
- package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
- package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
- package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
- package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
- package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
- package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
- package/src/ui/components/modals/app/ai-usage-modal.tsx +190 -0
- package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
- package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
- package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
- package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
- package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
- package/src/ui/components/modals/shared/form.tsx +164 -0
- package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
- package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
- package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
- package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
- package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
- package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
- package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +56 -24
- package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
- package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
- package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
- package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
- package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
- package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
- package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
- package/src/ui/root.tsx +26 -23
- package/src/ui/theme-store.ts +30 -26
- package/src/ui/themes.ts +1 -0
- package/src/ui/ai-usage/controller.ts +0 -35
- package/src/ui/components/ai-usage-popover.tsx +0 -152
- package/src/ui/components/create-session-modal.tsx +0 -96
- package/src/ui/components/diff-renderer/build-rows.ts +0 -349
- package/src/ui/components/diff-renderer/highlight.ts +0 -44
- package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
- package/src/ui/components/session-name-modal.tsx +0 -11
- package/src/ui/components/snippet-editor-modal.tsx +0 -40
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
- /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
- /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 {
|
|
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 '
|
|
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 '
|
|
15
|
-
import { scrollGitDiff } from '
|
|
16
|
-
import { useBg, useTokens, useTransparent } from '
|
|
17
|
-
import {
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 {
|
|
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 '
|
|
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 '
|
|
15
|
-
import { scrollGitDiff } from '
|
|
16
|
-
import { useBg, useTokens, useTransparent } from '
|
|
17
|
-
import {
|
|
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
|
|
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,
|
|
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
|
-
|
|
60
|
-
()
|
|
61
|
-
|
|
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
|
-
{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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 '
|
|
3
|
+
import type { GitFileEntry } from '../../../../state/types'
|
|
4
4
|
|
|
5
|
-
import { diffHash } from '
|
|
6
|
-
import { fetchDiff } from '
|
|
7
|
-
import { useAppStore } from '
|
|
8
|
-
import { dispatchGlobal } from '
|
|
9
|
-
import { buildGitTreeRows } from '
|
|
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
|
|
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.
|
|
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(() => {
|