@brimveyn/aimux 1.6.1 → 1.6.2
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 +19 -0
- package/src/app.tsx +4 -0
- package/src/config.ts +19 -1
- package/src/git/diff-hash.ts +10 -0
- package/src/input/modes/types.ts +1 -0
- package/src/state/git-tree.ts +42 -16
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +91 -33
- package/src/state/reducers/git-panel-state.ts +33 -2
- package/src/state/store.ts +2 -0
- package/src/state/types.ts +58 -1
- package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +30 -5
- package/src/ui/components/diff-renderer/stacked-view.tsx +29 -4
- package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
- package/src/ui/components/git-pane-widget.tsx +2 -0
- package/src/ui/components/git-panel.tsx +17 -7
- package/src/ui/components/git-view.tsx +19 -1
package/src/state/types.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
+
import type { ThemedToken } from 'shiki'
|
|
3
|
+
|
|
4
|
+
import type { FileDiffMetadata } from '../diff-parser'
|
|
2
5
|
|
|
3
6
|
export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
|
|
4
7
|
|
|
@@ -148,8 +151,11 @@ export interface GitPaneState {
|
|
|
148
151
|
ratio: number
|
|
149
152
|
diffModeRatio: number
|
|
150
153
|
fileListMode: GitFileListMode
|
|
154
|
+
treeCompaction: boolean
|
|
151
155
|
path: GitPanePathConfig
|
|
152
156
|
diffCount: GitPaneDiffCountConfig
|
|
157
|
+
/** Prefetch this many neighbours around the selection. 0 disables prefetch. */
|
|
158
|
+
prefetchRadius: number
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
|
|
@@ -195,10 +201,45 @@ export interface FoldState {
|
|
|
195
201
|
bottom: number
|
|
196
202
|
}
|
|
197
203
|
|
|
204
|
+
export interface ParsedDiffEntry {
|
|
205
|
+
hash: string
|
|
206
|
+
// Stored as unknown at the state boundary; concrete type is FileDiffMetadata
|
|
207
|
+
// | null, narrowed at read sites via getParsedFile().
|
|
208
|
+
file: unknown
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface HighlightsEntry {
|
|
212
|
+
hash: string
|
|
213
|
+
themeId: string
|
|
214
|
+
// See ParsedDiffEntry note — narrowed via getHighlights().
|
|
215
|
+
add: unknown
|
|
216
|
+
del: unknown
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getParsedFile(entry: ParsedDiffEntry | undefined): FileDiffMetadata | null {
|
|
220
|
+
if (!entry) return null
|
|
221
|
+
return entry.file as FileDiffMetadata | null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function getHighlightsTokens(entry: HighlightsEntry | undefined): {
|
|
225
|
+
add: ThemedToken[][]
|
|
226
|
+
del: ThemedToken[][]
|
|
227
|
+
} | null {
|
|
228
|
+
if (!entry) return null
|
|
229
|
+
return {
|
|
230
|
+
add: entry.add as ThemedToken[][],
|
|
231
|
+
del: entry.del as ThemedToken[][],
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
198
235
|
export interface GitModeState {
|
|
199
236
|
selectedEntryKey: string | null
|
|
200
237
|
collapsedFolders: Record<string, true>
|
|
201
238
|
diffs: Record<string, DiffData>
|
|
239
|
+
/** Parsed diff keyed by fileKey. Invalidated on file change or head offset shift. */
|
|
240
|
+
parsedFiles: Record<string, ParsedDiffEntry>
|
|
241
|
+
/** Tokenised highlights keyed by `${fileKey}|${themeId}`. */
|
|
242
|
+
highlights: Record<string, HighlightsEntry>
|
|
202
243
|
loading: Record<string, boolean>
|
|
203
244
|
pendingDeletePath: string | null
|
|
204
245
|
actionMessage: string | null
|
|
@@ -473,7 +514,23 @@ export type GitModeAction =
|
|
|
473
514
|
| { type: 'git-mode-collapse-selection' }
|
|
474
515
|
| { type: 'git-mode-expand-selection' }
|
|
475
516
|
| { type: 'git-mode-toggle-file-list-mode' }
|
|
476
|
-
| { type: 'git-mode-
|
|
517
|
+
| { type: 'git-mode-toggle-tree-compaction' }
|
|
518
|
+
| { type: 'git-mode-set-diff'; key: string; diff: DiffData; hash: string }
|
|
519
|
+
| {
|
|
520
|
+
type: 'git-mode-set-parsed'
|
|
521
|
+
key: string
|
|
522
|
+
hash: string
|
|
523
|
+
file: unknown
|
|
524
|
+
}
|
|
525
|
+
| {
|
|
526
|
+
type: 'git-mode-set-highlights'
|
|
527
|
+
key: string
|
|
528
|
+
hash: string
|
|
529
|
+
themeId: string
|
|
530
|
+
add: unknown
|
|
531
|
+
del: unknown
|
|
532
|
+
}
|
|
533
|
+
| { type: 'git-mode-invalidate-diffs'; paths: string[] }
|
|
477
534
|
| { type: 'git-mode-set-loading'; key: string; loading: boolean }
|
|
478
535
|
| { type: 'git-mode-set-pending-delete'; path: string | null }
|
|
479
536
|
| { type: 'git-mode-clear-diff-cache'; path: string }
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from '@opentui/core'
|
|
2
2
|
import type { ThemedToken } from 'shiki'
|
|
3
3
|
|
|
4
|
-
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef
|
|
4
|
+
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
|
5
5
|
|
|
6
|
-
import type { FoldState } from '../../../state/types'
|
|
7
6
|
import type { ThemeId } from '../../themes'
|
|
8
7
|
|
|
9
|
-
import { parsePatchFiles } from '../../../diff-parser'
|
|
10
8
|
import { useAppStore } from '../../../state/app-store'
|
|
11
9
|
import { dispatchGlobal } from '../../../state/dispatch-ref'
|
|
10
|
+
import { type FoldState } from '../../../state/types'
|
|
12
11
|
import { useTheme } from '../../theme'
|
|
13
12
|
import { buildSplitRows, buildUnifiedRows, firstChangeRowOffset, gutterWidth } from './build-rows'
|
|
14
|
-
import { filetypeFromPath } from './filetype'
|
|
15
|
-
import { tokenizeSide } from './highlight'
|
|
16
13
|
import { SplitView, type SplitViewHandle } from './split-view'
|
|
17
14
|
import { StackedView, type StackedViewHandle } from './stacked-view'
|
|
15
|
+
import { useDiffPreparation } from './use-diff-preparation'
|
|
18
16
|
|
|
19
17
|
export type DiffView = 'split' | 'stacked'
|
|
20
18
|
|
|
@@ -41,7 +39,6 @@ interface Props {
|
|
|
41
39
|
view: DiffView
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
const EMPTY_HIGHLIGHTS: DiffHighlights = { add: [], del: [] }
|
|
45
42
|
const EMPTY_FOLDS: Record<string, FoldState> = {}
|
|
46
43
|
|
|
47
44
|
export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDiff(
|
|
@@ -49,12 +46,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
49
46
|
ref
|
|
50
47
|
) {
|
|
51
48
|
const theme = useTheme()
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}, [diff])
|
|
56
|
-
|
|
57
|
-
const filetype = useMemo(() => filetypeFromPath(path), [path])
|
|
49
|
+
const preparation = useDiffPreparation(cacheKey, diff, path, themeId)
|
|
50
|
+
const file = preparation.file ?? undefined
|
|
51
|
+
const highlights: DiffHighlights = preparation.highlights
|
|
58
52
|
|
|
59
53
|
const terminalCols = useAppStore((s) => s.layout.terminalCols)
|
|
60
54
|
const sidebarWidth = useAppStore((s) => s.sidebar.width)
|
|
@@ -78,24 +72,6 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
78
72
|
[cacheKey]
|
|
79
73
|
)
|
|
80
74
|
|
|
81
|
-
const [highlights, setHighlights] = useState<DiffHighlights>(EMPTY_HIGHLIGHTS)
|
|
82
|
-
|
|
83
|
-
useEffect(() => {
|
|
84
|
-
setHighlights(EMPTY_HIGHLIGHTS)
|
|
85
|
-
if (!file || !filetype) return
|
|
86
|
-
let cancelled = false
|
|
87
|
-
void Promise.all([
|
|
88
|
-
tokenizeSide(file.additionLines, filetype, themeId),
|
|
89
|
-
tokenizeSide(file.deletionLines, filetype, themeId),
|
|
90
|
-
]).then(([add, del]) => {
|
|
91
|
-
if (cancelled) return
|
|
92
|
-
setHighlights({ add, del })
|
|
93
|
-
})
|
|
94
|
-
return () => {
|
|
95
|
-
cancelled = true
|
|
96
|
-
}
|
|
97
|
-
}, [file, filetype, themeId])
|
|
98
|
-
|
|
99
75
|
const splitRef = useRef<SplitViewHandle | null>(null)
|
|
100
76
|
const stackedRef = useRef<StackedViewHandle | null>(null)
|
|
101
77
|
|
|
@@ -141,7 +117,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
141
117
|
if (!file) {
|
|
142
118
|
return (
|
|
143
119
|
<box flexGrow={1} padding={1}>
|
|
144
|
-
<text fg={theme.colors['descriptionForeground']}>
|
|
120
|
+
<text fg={theme.colors['descriptionForeground']}>
|
|
121
|
+
{preparation.preparing ? 'Preparing diff…' : '(could not parse diff)'}
|
|
122
|
+
</text>
|
|
145
123
|
</box>
|
|
146
124
|
)
|
|
147
125
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ThemedToken } from 'shiki'
|
|
2
|
+
|
|
3
|
+
import { type FileDiffMetadata, parsePatchFiles } from '../../../diff-parser'
|
|
4
|
+
import { diffHash } from '../../../git/diff-hash'
|
|
5
|
+
import { filetypeFromPath } from './filetype'
|
|
6
|
+
import { tokenizeSide } from './highlight'
|
|
7
|
+
|
|
8
|
+
export interface PreparedDiff {
|
|
9
|
+
hash: string
|
|
10
|
+
file: FileDiffMetadata | null
|
|
11
|
+
filetype: string | null
|
|
12
|
+
highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface PrepareOptions {
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
themeId: string
|
|
18
|
+
/** Skip Shiki for big files to keep prepare under a few tens of ms. */
|
|
19
|
+
skipHighlightThreshold?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SKIP_HIGHLIGHT = 2000
|
|
23
|
+
|
|
24
|
+
function yieldToEventLoop(): Promise<void> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
setImmediate(resolve)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
31
|
+
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Lifts the hot path (parse → tokenize) off the synchronous render tree. The
|
|
35
|
+
// parser still blocks briefly on very large diffs, but yielding between parse
|
|
36
|
+
// and each tokenize side keeps other UI updates responsive.
|
|
37
|
+
export async function prepareDiff(
|
|
38
|
+
diff: string,
|
|
39
|
+
path: string,
|
|
40
|
+
opts: PrepareOptions
|
|
41
|
+
): Promise<PreparedDiff> {
|
|
42
|
+
const hash = diffHash(diff)
|
|
43
|
+
throwIfAborted(opts.signal)
|
|
44
|
+
const patches = parsePatchFiles(diff)
|
|
45
|
+
const file = patches[0]?.files[0] ?? null
|
|
46
|
+
const filetype = file ? (filetypeFromPath(path) ?? null) : null
|
|
47
|
+
|
|
48
|
+
const skipThreshold = opts.skipHighlightThreshold ?? DEFAULT_SKIP_HIGHLIGHT
|
|
49
|
+
const totalLines = file ? file.additionLines.length + file.deletionLines.length : 0
|
|
50
|
+
const shouldHighlight = !!file && !!filetype && totalLines <= skipThreshold
|
|
51
|
+
|
|
52
|
+
if (!shouldHighlight) {
|
|
53
|
+
return { file, filetype, hash, highlights: { add: [], del: [] } }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
await yieldToEventLoop()
|
|
57
|
+
throwIfAborted(opts.signal)
|
|
58
|
+
const add = await tokenizeSide(file.additionLines, filetype, opts.themeId)
|
|
59
|
+
throwIfAborted(opts.signal)
|
|
60
|
+
await yieldToEventLoop()
|
|
61
|
+
throwIfAborted(opts.signal)
|
|
62
|
+
const del = await tokenizeSide(file.deletionLines, filetype, opts.themeId)
|
|
63
|
+
throwIfAborted(opts.signal)
|
|
64
|
+
|
|
65
|
+
return { file, filetype, hash, highlights: { add, del } }
|
|
66
|
+
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ScrollBoxRenderable,
|
|
6
6
|
TextAttributes,
|
|
7
7
|
} from '@opentui/core'
|
|
8
|
-
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
|
8
|
+
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
|
|
9
9
|
|
|
10
10
|
import type { FileDiffMetadata } from '../../../diff-parser'
|
|
11
11
|
import type { FoldState } from '../../../state/types'
|
|
@@ -18,6 +18,11 @@ import { buildSplitRows, gutterWidth, type SplitCell, type SplitRowOrHeader } fr
|
|
|
18
18
|
import { FoldStrip } from './fold-strip'
|
|
19
19
|
import { tokenToSpan } from './highlight'
|
|
20
20
|
|
|
21
|
+
// Cap DOM size for extreme diffs. Rows beyond this are hidden behind a banner;
|
|
22
|
+
// users scroll within the visible window. Shiki highlighting is already skipped
|
|
23
|
+
// upstream in prepare-diff for similarly large diffs.
|
|
24
|
+
const LARGE_ROW_CAP = 5000
|
|
25
|
+
|
|
21
26
|
export interface SplitViewHandle {
|
|
22
27
|
leftScroll: ScrollBoxRenderable | null
|
|
23
28
|
rightScroll: ScrollBoxRenderable | null
|
|
@@ -60,8 +65,10 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
60
65
|
[]
|
|
61
66
|
)
|
|
62
67
|
|
|
63
|
-
const rows = buildSplitRows(file, folds, contentWidth)
|
|
64
|
-
const gw = gutterWidth(file)
|
|
68
|
+
const rows = useMemo(() => buildSplitRows(file, folds, contentWidth), [file, folds, contentWidth])
|
|
69
|
+
const gw = useMemo(() => gutterWidth(file), [file])
|
|
70
|
+
const truncated = rows.length > LARGE_ROW_CAP
|
|
71
|
+
const displayRows = truncated ? rows.slice(0, LARGE_ROW_CAP) : rows
|
|
65
72
|
|
|
66
73
|
return (
|
|
67
74
|
<box flexDirection="row" flexGrow={1} overflow="hidden" onMouseScroll={handleScroll}>
|
|
@@ -74,7 +81,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
74
81
|
verticalScrollbarOptions={{ visible: false }}
|
|
75
82
|
onMouseScroll={handleScroll}
|
|
76
83
|
>
|
|
77
|
-
{
|
|
84
|
+
{displayRows.map((row, i) => (
|
|
78
85
|
<SideRow
|
|
79
86
|
key={i}
|
|
80
87
|
cell={row.type === 'row' ? row.left : null}
|
|
@@ -85,6 +92,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
85
92
|
tokens={highlights.del}
|
|
86
93
|
/>
|
|
87
94
|
))}
|
|
95
|
+
{truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
|
|
88
96
|
</scrollbox>
|
|
89
97
|
<box width={1} backgroundColor={theme.colors['editor.background']} />
|
|
90
98
|
<scrollbox
|
|
@@ -95,7 +103,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
95
103
|
contentOptions={{ flexDirection: 'column', gap: 0 }}
|
|
96
104
|
onMouseScroll={handleScroll}
|
|
97
105
|
>
|
|
98
|
-
{
|
|
106
|
+
{displayRows.map((row, i) => (
|
|
99
107
|
<SideRow
|
|
100
108
|
key={i}
|
|
101
109
|
cell={row.type === 'row' ? row.right : null}
|
|
@@ -106,11 +114,28 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
106
114
|
tokens={highlights.add}
|
|
107
115
|
/>
|
|
108
116
|
))}
|
|
117
|
+
{truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
|
|
109
118
|
</scrollbox>
|
|
110
119
|
</box>
|
|
111
120
|
)
|
|
112
121
|
})
|
|
113
122
|
|
|
123
|
+
function TruncationNotice({ hidden }: { hidden: number }) {
|
|
124
|
+
const theme = useTheme()
|
|
125
|
+
return (
|
|
126
|
+
<box
|
|
127
|
+
flexDirection="row"
|
|
128
|
+
backgroundColor={theme.colors['sideBarSectionHeader.background']}
|
|
129
|
+
paddingLeft={1}
|
|
130
|
+
paddingRight={1}
|
|
131
|
+
>
|
|
132
|
+
<text fg={theme.colors['editorWarning.foreground']}>
|
|
133
|
+
…diff truncated — {hidden} more rows hidden
|
|
134
|
+
</text>
|
|
135
|
+
</box>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
114
139
|
function SideRow({
|
|
115
140
|
cell,
|
|
116
141
|
foldDispatch,
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
type ScrollBoxRenderable,
|
|
6
6
|
TextAttributes,
|
|
7
7
|
} from '@opentui/core'
|
|
8
|
-
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
|
8
|
+
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'
|
|
9
9
|
|
|
10
10
|
import type { FileDiffMetadata } from '../../../diff-parser'
|
|
11
11
|
import type { FoldState } from '../../../state/types'
|
|
@@ -18,6 +18,9 @@ import { buildUnifiedRows, gutterWidth, type UnifiedRowOrHeader } from './build-
|
|
|
18
18
|
import { FoldStrip } from './fold-strip'
|
|
19
19
|
import { tokenToSpan } from './highlight'
|
|
20
20
|
|
|
21
|
+
// Same cap as split-view — keeps the React child count bounded.
|
|
22
|
+
const LARGE_ROW_CAP = 5000
|
|
23
|
+
|
|
21
24
|
export interface StackedViewHandle {
|
|
22
25
|
scroll: ScrollBoxRenderable | null
|
|
23
26
|
}
|
|
@@ -54,8 +57,13 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
|
|
|
54
57
|
[]
|
|
55
58
|
)
|
|
56
59
|
|
|
57
|
-
const rows =
|
|
58
|
-
|
|
60
|
+
const rows = useMemo(
|
|
61
|
+
() => buildUnifiedRows(file, folds, contentWidth),
|
|
62
|
+
[file, folds, contentWidth]
|
|
63
|
+
)
|
|
64
|
+
const gw = useMemo(() => gutterWidth(file), [file])
|
|
65
|
+
const truncated = rows.length > LARGE_ROW_CAP
|
|
66
|
+
const displayRows = truncated ? rows.slice(0, LARGE_ROW_CAP) : rows
|
|
59
67
|
|
|
60
68
|
return (
|
|
61
69
|
<scrollbox
|
|
@@ -66,7 +74,7 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
|
|
|
66
74
|
contentOptions={{ flexDirection: 'column', gap: 0 }}
|
|
67
75
|
onMouseScroll={handleScroll}
|
|
68
76
|
>
|
|
69
|
-
{
|
|
77
|
+
{displayRows.map((row, i) => (
|
|
70
78
|
<UnifiedRowRender
|
|
71
79
|
key={i}
|
|
72
80
|
foldDispatch={foldDispatch}
|
|
@@ -75,10 +83,27 @@ export const StackedView = forwardRef<StackedViewHandle, Props>(function Stacked
|
|
|
75
83
|
row={row}
|
|
76
84
|
/>
|
|
77
85
|
))}
|
|
86
|
+
{truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
|
|
78
87
|
</scrollbox>
|
|
79
88
|
)
|
|
80
89
|
})
|
|
81
90
|
|
|
91
|
+
function TruncationNotice({ hidden }: { hidden: number }) {
|
|
92
|
+
const theme = useTheme()
|
|
93
|
+
return (
|
|
94
|
+
<box
|
|
95
|
+
flexDirection="row"
|
|
96
|
+
backgroundColor={theme.colors['sideBarSectionHeader.background']}
|
|
97
|
+
paddingLeft={1}
|
|
98
|
+
paddingRight={1}
|
|
99
|
+
>
|
|
100
|
+
<text fg={theme.colors['editorWarning.foreground']}>
|
|
101
|
+
…diff truncated — {hidden} more rows hidden
|
|
102
|
+
</text>
|
|
103
|
+
</box>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
82
107
|
function UnifiedRowRender({
|
|
83
108
|
foldDispatch,
|
|
84
109
|
gw,
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { GitFileEntry } from '../../../state/types'
|
|
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'
|
|
10
|
+
import { prepareDiff } from './prepare-diff'
|
|
11
|
+
|
|
12
|
+
interface PrefetchOptions {
|
|
13
|
+
projectPath: string | undefined
|
|
14
|
+
themeId: string
|
|
15
|
+
headOffset: number
|
|
16
|
+
enabled: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_CONCURRENCY = 3
|
|
20
|
+
|
|
21
|
+
interface Task {
|
|
22
|
+
key: string
|
|
23
|
+
file: GitFileEntry
|
|
24
|
+
distance: number
|
|
25
|
+
controller: AbortController
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class PrefetchQueue {
|
|
29
|
+
private inflight = new Map<string, AbortController>()
|
|
30
|
+
private queue: Task[] = []
|
|
31
|
+
private running = 0
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
private readonly execute: (task: Task) => Promise<void>,
|
|
35
|
+
private readonly maxConcurrency: number
|
|
36
|
+
) {}
|
|
37
|
+
|
|
38
|
+
schedule(tasks: Task[]): void {
|
|
39
|
+
const keepKeys = new Set(tasks.map((t) => t.key))
|
|
40
|
+
// Cancel inflight + queued tasks that no longer sit inside the prefetch window.
|
|
41
|
+
for (const [key, controller] of this.inflight) {
|
|
42
|
+
if (!keepKeys.has(key)) {
|
|
43
|
+
controller.abort()
|
|
44
|
+
this.inflight.delete(key)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.queue = this.queue.filter((t) => keepKeys.has(t.key))
|
|
48
|
+
for (const task of tasks) {
|
|
49
|
+
if (this.inflight.has(task.key)) continue
|
|
50
|
+
if (this.queue.some((t) => t.key === task.key)) continue
|
|
51
|
+
this.queue.push(task)
|
|
52
|
+
}
|
|
53
|
+
this.queue.sort((a, b) => a.distance - b.distance)
|
|
54
|
+
this.pump()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cancelAll(): void {
|
|
58
|
+
for (const [, controller] of this.inflight) controller.abort()
|
|
59
|
+
this.inflight.clear()
|
|
60
|
+
this.queue = []
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private pump(): void {
|
|
64
|
+
while (this.running < this.maxConcurrency && this.queue.length > 0) {
|
|
65
|
+
const task = this.queue.shift()
|
|
66
|
+
if (!task) break
|
|
67
|
+
this.inflight.set(task.key, task.controller)
|
|
68
|
+
this.running += 1
|
|
69
|
+
void this.execute(task).finally(() => {
|
|
70
|
+
this.inflight.delete(task.key)
|
|
71
|
+
this.running -= 1
|
|
72
|
+
this.pump()
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Prefetches ±radius neighbours around the selected file so j/k-style navigation
|
|
79
|
+
// hits the cache. Runs through the full pipeline (git → parse → tokenize) off
|
|
80
|
+
// the main thread, bounded to MAX_CONCURRENCY to stay below a crowd of git
|
|
81
|
+
// subprocesses. Cancels tasks that leave the window on each selection change.
|
|
82
|
+
export function useDiffPrefetch(
|
|
83
|
+
selectedEntryKey: string | null,
|
|
84
|
+
radius: number,
|
|
85
|
+
opts: PrefetchOptions
|
|
86
|
+
): void {
|
|
87
|
+
const files = useAppStore((s) => s.gitPanel.files)
|
|
88
|
+
const diffs = useAppStore((s) => s.gitMode.diffs)
|
|
89
|
+
const parsed = useAppStore((s) => s.gitMode.parsedFiles)
|
|
90
|
+
const loading = useAppStore((s) => s.gitMode.loading)
|
|
91
|
+
const collapsedFolders = useAppStore((s) => s.gitMode.collapsedFolders)
|
|
92
|
+
const fileListMode = useAppStore((s) => s.gitPane.fileListMode)
|
|
93
|
+
const treeCompaction = useAppStore((s) => s.gitPane.treeCompaction)
|
|
94
|
+
|
|
95
|
+
const queueRef = useRef<PrefetchQueue | null>(null)
|
|
96
|
+
|
|
97
|
+
const { enabled, headOffset, projectPath, themeId } = opts
|
|
98
|
+
const runTaskRef = useRef<(task: Task) => Promise<void>>(async () => {})
|
|
99
|
+
|
|
100
|
+
// Keep a ref to the execute function so the queue keeps the latest closure
|
|
101
|
+
// without needing to recreate the queue itself.
|
|
102
|
+
runTaskRef.current = async (task: Task) => {
|
|
103
|
+
if (!projectPath) return
|
|
104
|
+
try {
|
|
105
|
+
const diff = await fetchDiff(projectPath, task.file, headOffset)
|
|
106
|
+
if (task.controller.signal.aborted) return
|
|
107
|
+
const hash = diffHash(diff.rawDiff)
|
|
108
|
+
dispatchGlobal({ diff, hash, key: task.key, type: 'git-mode-set-diff' })
|
|
109
|
+
const prep = await prepareDiff(diff.rawDiff, task.file.path, {
|
|
110
|
+
signal: task.controller.signal,
|
|
111
|
+
themeId,
|
|
112
|
+
})
|
|
113
|
+
if (task.controller.signal.aborted) return
|
|
114
|
+
dispatchGlobal({
|
|
115
|
+
file: prep.file,
|
|
116
|
+
hash: prep.hash,
|
|
117
|
+
key: task.key,
|
|
118
|
+
type: 'git-mode-set-parsed',
|
|
119
|
+
})
|
|
120
|
+
dispatchGlobal({
|
|
121
|
+
add: prep.highlights.add,
|
|
122
|
+
del: prep.highlights.del,
|
|
123
|
+
hash: prep.hash,
|
|
124
|
+
key: task.key,
|
|
125
|
+
themeId,
|
|
126
|
+
type: 'git-mode-set-highlights',
|
|
127
|
+
})
|
|
128
|
+
} catch {
|
|
129
|
+
// Prefetch errors are non-fatal; the foreground fetch will retry on focus.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!queueRef.current) {
|
|
134
|
+
queueRef.current = new PrefetchQueue((task) => runTaskRef.current(task), MAX_CONCURRENCY)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const queue = queueRef.current
|
|
139
|
+
if (!queue) return
|
|
140
|
+
if (!enabled || radius <= 0 || !projectPath || !selectedEntryKey) {
|
|
141
|
+
queue.cancelAll()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, treeCompaction)
|
|
145
|
+
const fileRows = visibleRows.filter((r) => r.kind === 'file')
|
|
146
|
+
const selectedIdx = fileRows.findIndex((r) => r.key === selectedEntryKey)
|
|
147
|
+
if (selectedIdx < 0) {
|
|
148
|
+
queue.cancelAll()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
const start = Math.max(0, selectedIdx - radius)
|
|
152
|
+
const end = Math.min(fileRows.length, selectedIdx + radius + 1)
|
|
153
|
+
const tasks: Task[] = []
|
|
154
|
+
for (let i = start; i < end; i++) {
|
|
155
|
+
if (i === selectedIdx) continue
|
|
156
|
+
const row = fileRows[i]
|
|
157
|
+
if (!row || row.kind !== 'file') continue
|
|
158
|
+
const key = row.key
|
|
159
|
+
if (diffs[key]) continue
|
|
160
|
+
if (parsed[key]) continue
|
|
161
|
+
if (loading[key]) continue
|
|
162
|
+
tasks.push({
|
|
163
|
+
controller: new AbortController(),
|
|
164
|
+
distance: Math.abs(i - selectedIdx),
|
|
165
|
+
file: row.file,
|
|
166
|
+
key,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
queue.schedule(tasks)
|
|
170
|
+
}, [
|
|
171
|
+
enabled,
|
|
172
|
+
radius,
|
|
173
|
+
projectPath,
|
|
174
|
+
selectedEntryKey,
|
|
175
|
+
files,
|
|
176
|
+
collapsedFolders,
|
|
177
|
+
fileListMode,
|
|
178
|
+
treeCompaction,
|
|
179
|
+
diffs,
|
|
180
|
+
parsed,
|
|
181
|
+
loading,
|
|
182
|
+
headOffset,
|
|
183
|
+
themeId,
|
|
184
|
+
])
|
|
185
|
+
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
return () => {
|
|
188
|
+
queueRef.current?.cancelAll()
|
|
189
|
+
}
|
|
190
|
+
}, [])
|
|
191
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { ThemedToken } from 'shiki'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { FileDiffMetadata } from '../../../diff-parser'
|
|
6
|
+
|
|
7
|
+
import { useAppStore } from '../../../state/app-store'
|
|
8
|
+
import { dispatchGlobal } from '../../../state/dispatch-ref'
|
|
9
|
+
import { getHighlightsTokens, getParsedFile } from '../../../state/types'
|
|
10
|
+
import { prepareDiff } from './prepare-diff'
|
|
11
|
+
|
|
12
|
+
export interface DiffPreparation {
|
|
13
|
+
file: FileDiffMetadata | null
|
|
14
|
+
highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
|
|
15
|
+
/** True while prepare is running for this cache key + hash. */
|
|
16
|
+
preparing: boolean
|
|
17
|
+
/** Available once either cache or prepare has produced a valid hash. */
|
|
18
|
+
ready: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const EMPTY_HIGHLIGHTS = { add: [] as ThemedToken[][], del: [] as ThemedToken[][] }
|
|
22
|
+
|
|
23
|
+
// Consults the parsed + highlight caches first, then falls back to an off-thread
|
|
24
|
+
// prepareDiff that dispatches results into the store. Deduplicates in-flight
|
|
25
|
+
// preparation per cacheKey+hash by hashing the diff string once.
|
|
26
|
+
export function useDiffPreparation(
|
|
27
|
+
cacheKey: string,
|
|
28
|
+
diff: string,
|
|
29
|
+
path: string,
|
|
30
|
+
themeId: string
|
|
31
|
+
): DiffPreparation {
|
|
32
|
+
const cachedParsed = useAppStore((s) => s.gitMode.parsedFiles[cacheKey])
|
|
33
|
+
const highlightKey = `${cacheKey}|${themeId}`
|
|
34
|
+
const cachedHighlights = useAppStore((s) => s.gitMode.highlights[highlightKey])
|
|
35
|
+
|
|
36
|
+
const [preparing, setPreparing] = useState(false)
|
|
37
|
+
const [localFile, setLocalFile] = useState<FileDiffMetadata | null>(null)
|
|
38
|
+
const [localHighlights, setLocalHighlights] = useState(EMPTY_HIGHLIGHTS)
|
|
39
|
+
const [localHash, setLocalHash] = useState<string | null>(null)
|
|
40
|
+
|
|
41
|
+
const { file, highlights, ready } = useMemo(() => {
|
|
42
|
+
const parsedFile = cachedParsed ? getParsedFile(cachedParsed) : null
|
|
43
|
+
const parsedMatches = !!cachedParsed
|
|
44
|
+
const tokens = cachedHighlights ? getHighlightsTokens(cachedHighlights) : null
|
|
45
|
+
const hlMatches = !!cachedHighlights && cachedHighlights.hash === cachedParsed?.hash
|
|
46
|
+
|
|
47
|
+
if (parsedMatches && hlMatches && tokens) {
|
|
48
|
+
return { file: parsedFile, highlights: tokens, ready: true }
|
|
49
|
+
}
|
|
50
|
+
if (parsedMatches && !hlMatches) {
|
|
51
|
+
return {
|
|
52
|
+
file: parsedFile,
|
|
53
|
+
highlights: tokens ?? EMPTY_HIGHLIGHTS,
|
|
54
|
+
ready: true,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (localHash && localFile) {
|
|
58
|
+
return { file: localFile, highlights: localHighlights, ready: true }
|
|
59
|
+
}
|
|
60
|
+
return { file: null, highlights: EMPTY_HIGHLIGHTS, ready: false }
|
|
61
|
+
}, [cachedParsed, cachedHighlights, localFile, localHighlights, localHash])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
// If caches already cover both parsed and highlights for this theme, bail.
|
|
65
|
+
const parsedOk = !!cachedParsed
|
|
66
|
+
const hlOk = !!cachedHighlights && cachedParsed && cachedHighlights.hash === cachedParsed.hash
|
|
67
|
+
if (parsedOk && hlOk) return
|
|
68
|
+
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
setPreparing(true)
|
|
71
|
+
void prepareDiff(diff, path, { signal: controller.signal, themeId })
|
|
72
|
+
.then((result) => {
|
|
73
|
+
if (controller.signal.aborted) return
|
|
74
|
+
setLocalFile(result.file)
|
|
75
|
+
setLocalHighlights(result.highlights)
|
|
76
|
+
setLocalHash(result.hash)
|
|
77
|
+
dispatchGlobal({
|
|
78
|
+
file: result.file,
|
|
79
|
+
hash: result.hash,
|
|
80
|
+
key: cacheKey,
|
|
81
|
+
type: 'git-mode-set-parsed',
|
|
82
|
+
})
|
|
83
|
+
dispatchGlobal({
|
|
84
|
+
add: result.highlights.add,
|
|
85
|
+
del: result.highlights.del,
|
|
86
|
+
hash: result.hash,
|
|
87
|
+
key: cacheKey,
|
|
88
|
+
themeId,
|
|
89
|
+
type: 'git-mode-set-highlights',
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
.catch((err: unknown) => {
|
|
93
|
+
if (err instanceof Error && err.name === 'AbortError') return
|
|
94
|
+
// Swallow — the UI falls back to "(could not parse diff)" when file stays null.
|
|
95
|
+
})
|
|
96
|
+
.finally(() => {
|
|
97
|
+
if (!controller.signal.aborted) setPreparing(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
controller.abort()
|
|
102
|
+
}
|
|
103
|
+
}, [cacheKey, diff, path, themeId, cachedParsed, cachedHighlights])
|
|
104
|
+
|
|
105
|
+
return { file, highlights, preparing, ready }
|
|
106
|
+
}
|