@brimveyn/aimux 1.6.0 → 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/git/git-diff.ts +24 -12
- package/src/git/git-poller.ts +11 -3
- package/src/git/git-status.ts +98 -17
- package/src/input/modes/types.ts +1 -0
- package/src/pty/terminal-snapshot.ts +5 -5
- package/src/state/git-tree.ts +43 -17
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +135 -34
- package/src/state/reducers/git-panel-state.ts +39 -3
- package/src/state/store.ts +2 -0
- package/src/state/types.ts +63 -2
- package/src/ui/components/create-session-modal.tsx +5 -4
- package/src/ui/components/diff-renderer/fold-strip.tsx +3 -1
- package/src/ui/components/diff-renderer/pierre-diff.tsx +11 -32
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +35 -6
- package/src/ui/components/diff-renderer/stacked-view.tsx +32 -5
- 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-commit-modal.tsx +2 -1
- package/src/ui/components/git-pane-widget.tsx +3 -1
- package/src/ui/components/git-panel.tsx +92 -50
- package/src/ui/components/git-view.tsx +34 -5
- package/src/ui/components/help-modal.tsx +2 -1
- package/src/ui/components/input-field.tsx +2 -1
- package/src/ui/components/list-item.tsx +2 -1
- package/src/ui/components/modal-filter-bar.tsx +2 -1
- package/src/ui/components/modal-keybinds-overlay.tsx +2 -1
- package/src/ui/components/modal-shell.tsx +2 -1
- package/src/ui/components/new-tab-modal.tsx +2 -1
- package/src/ui/components/pending-chord-overlay.tsx +2 -1
- package/src/ui/components/session-bar.tsx +3 -1
- package/src/ui/components/session-picker-modal.tsx +2 -1
- package/src/ui/components/sidebar.tsx +8 -5
- package/src/ui/components/snippet-editor-modal.tsx +2 -1
- package/src/ui/components/snippet-picker-modal.tsx +2 -1
- package/src/ui/components/split-layout.tsx +2 -1
- package/src/ui/components/status-bar.tsx +8 -6
- package/src/ui/components/surface.tsx +6 -6
- package/src/ui/components/tab-item.tsx +14 -9
- package/src/ui/components/terminal-pane.tsx +7 -5
- package/src/ui/components/theme-picker-modal.tsx +2 -1
- package/src/ui/components/update-available-modal.tsx +2 -1
- package/src/ui/filter-themes.ts +7 -2
- package/src/ui/root.tsx +3 -1
- package/src/ui/status-bar-model.ts +13 -3
- package/src/ui/theme-store.ts +36 -0
- package/src/ui/theme.ts +4 -29
- package/src/ui/themes.ts +15 -63
- package/src/ui/house-themes.ts +0 -313
- package/src/ui/themes.generated.ts +0 -59794
|
@@ -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'
|
|
@@ -13,11 +13,16 @@ import type { DiffHighlights, FoldDispatch } from './pierre-diff'
|
|
|
13
13
|
|
|
14
14
|
import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
|
|
15
15
|
import { scrollGitDiff } from '../../git-view-controls'
|
|
16
|
-
import {
|
|
16
|
+
import { useTheme } from '../../theme'
|
|
17
17
|
import { buildSplitRows, gutterWidth, type SplitCell, type SplitRowOrHeader } from './build-rows'
|
|
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
|
|
@@ -43,6 +48,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
43
48
|
{ contentWidth, file, foldDispatch, folds, highlights },
|
|
44
49
|
ref
|
|
45
50
|
) {
|
|
51
|
+
const theme = useTheme()
|
|
46
52
|
const leftRef = useRef<ScrollBoxRenderable | null>(null)
|
|
47
53
|
const rightRef = useRef<ScrollBoxRenderable | null>(null)
|
|
48
54
|
|
|
@@ -59,8 +65,10 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
59
65
|
[]
|
|
60
66
|
)
|
|
61
67
|
|
|
62
|
-
const rows = buildSplitRows(file, folds, contentWidth)
|
|
63
|
-
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
|
|
64
72
|
|
|
65
73
|
return (
|
|
66
74
|
<box flexDirection="row" flexGrow={1} overflow="hidden" onMouseScroll={handleScroll}>
|
|
@@ -73,7 +81,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
73
81
|
verticalScrollbarOptions={{ visible: false }}
|
|
74
82
|
onMouseScroll={handleScroll}
|
|
75
83
|
>
|
|
76
|
-
{
|
|
84
|
+
{displayRows.map((row, i) => (
|
|
77
85
|
<SideRow
|
|
78
86
|
key={i}
|
|
79
87
|
cell={row.type === 'row' ? row.left : null}
|
|
@@ -84,6 +92,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
84
92
|
tokens={highlights.del}
|
|
85
93
|
/>
|
|
86
94
|
))}
|
|
95
|
+
{truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
|
|
87
96
|
</scrollbox>
|
|
88
97
|
<box width={1} backgroundColor={theme.colors['editor.background']} />
|
|
89
98
|
<scrollbox
|
|
@@ -94,7 +103,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
94
103
|
contentOptions={{ flexDirection: 'column', gap: 0 }}
|
|
95
104
|
onMouseScroll={handleScroll}
|
|
96
105
|
>
|
|
97
|
-
{
|
|
106
|
+
{displayRows.map((row, i) => (
|
|
98
107
|
<SideRow
|
|
99
108
|
key={i}
|
|
100
109
|
cell={row.type === 'row' ? row.right : null}
|
|
@@ -105,11 +114,28 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
|
|
|
105
114
|
tokens={highlights.add}
|
|
106
115
|
/>
|
|
107
116
|
))}
|
|
117
|
+
{truncated ? <TruncationNotice hidden={rows.length - displayRows.length} /> : null}
|
|
108
118
|
</scrollbox>
|
|
109
119
|
</box>
|
|
110
120
|
)
|
|
111
121
|
})
|
|
112
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
|
+
|
|
113
139
|
function SideRow({
|
|
114
140
|
cell,
|
|
115
141
|
foldDispatch,
|
|
@@ -132,6 +158,7 @@ function SideRow({
|
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
function HunkHeaderRow({ row }: { row: Extract<SplitRowOrHeader, { type: 'hunk-header' }> }) {
|
|
161
|
+
const theme = useTheme()
|
|
135
162
|
return (
|
|
136
163
|
<box
|
|
137
164
|
flexDirection="row"
|
|
@@ -158,6 +185,7 @@ function HalfRow({
|
|
|
158
185
|
height: number
|
|
159
186
|
tokens: ThemedToken[][]
|
|
160
187
|
}) {
|
|
188
|
+
const theme = useTheme()
|
|
161
189
|
if (cell.type === 'filler') {
|
|
162
190
|
return <box backgroundColor={theme.colors['sideBarSectionHeader.background']} height={height} />
|
|
163
191
|
}
|
|
@@ -185,6 +213,7 @@ function HalfRow({
|
|
|
185
213
|
}
|
|
186
214
|
|
|
187
215
|
function LineContent({ content, tokens }: { content: string; tokens: ThemedToken[] | undefined }) {
|
|
216
|
+
const theme = useTheme()
|
|
188
217
|
if (!tokens || tokens.length === 0) {
|
|
189
218
|
return <text fg={theme.colors['editor.foreground']}>{content}</text>
|
|
190
219
|
}
|
|
@@ -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'
|
|
@@ -13,11 +13,14 @@ import type { DiffHighlights, FoldDispatch } from './pierre-diff'
|
|
|
13
13
|
|
|
14
14
|
import { getScrollViewportDelta } from '../../../app-runtime/terminal-mouse-adapter'
|
|
15
15
|
import { scrollGitDiff } from '../../git-view-controls'
|
|
16
|
-
import {
|
|
16
|
+
import { useTheme } from '../../theme'
|
|
17
17
|
import { buildUnifiedRows, gutterWidth, type UnifiedRowOrHeader } from './build-rows'
|
|
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,
|
|
@@ -90,6 +115,7 @@ function UnifiedRowRender({
|
|
|
90
115
|
highlights: DiffHighlights
|
|
91
116
|
row: UnifiedRowOrHeader
|
|
92
117
|
}) {
|
|
118
|
+
const theme = useTheme()
|
|
93
119
|
if (row.type === 'hunk-header') {
|
|
94
120
|
return (
|
|
95
121
|
<box
|
|
@@ -144,6 +170,7 @@ function UnifiedRowRender({
|
|
|
144
170
|
}
|
|
145
171
|
|
|
146
172
|
function LineContent({ content, tokens }: { content: string; tokens: ThemedToken[] | undefined }) {
|
|
173
|
+
const theme = useTheme()
|
|
147
174
|
if (!tokens || tokens.length === 0) {
|
|
148
175
|
return <text fg={theme.colors['editor.foreground']}>{content}</text>
|
|
149
176
|
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useTheme } from '../theme'
|
|
2
2
|
import { uiTokens } from '../ui-tokens'
|
|
3
3
|
import { InputField } from './input-field'
|
|
4
4
|
import { ModalShell } from './modal-shell'
|
|
@@ -11,6 +11,7 @@ interface GitCommitModalProps {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function GitCommitModal({ activeField, body, cursorPos, title }: GitCommitModalProps) {
|
|
14
|
+
const theme = useTheme()
|
|
14
15
|
const titleActive = activeField === 'title'
|
|
15
16
|
const bodyActive = activeField === 'body'
|
|
16
17
|
|
|
@@ -14,6 +14,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: Git
|
|
|
14
14
|
const gitPanel = useAppStore((s) => s.gitPanel)
|
|
15
15
|
const gitMode = useAppStore((s) => s.gitMode)
|
|
16
16
|
const gitFileListMode = useAppStore((s) => s.gitPane.fileListMode)
|
|
17
|
+
const treeCompaction = useAppStore((s) => s.gitPane.treeCompaction)
|
|
17
18
|
const pathConfig = useAppStore((s) => s.gitPane.path)
|
|
18
19
|
const diffCountConfig = useAppStore((s) => s.gitPane.diffCount)
|
|
19
20
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
@@ -23,7 +24,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: Git
|
|
|
23
24
|
: undefined
|
|
24
25
|
const projectPath = currentSession?.projectPath
|
|
25
26
|
|
|
26
|
-
useGitPanelPolling({ enabled: pollingEnabled, projectPath })
|
|
27
|
+
useGitPanelPolling({ enabled: pollingEnabled, headOffset: 0, projectPath })
|
|
27
28
|
|
|
28
29
|
const lastGoodRef = useRef<GitPanelState | null>(null)
|
|
29
30
|
const prevProjectPathRef = useRef(projectPath)
|
|
@@ -40,6 +41,7 @@ export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: Git
|
|
|
40
41
|
return (
|
|
41
42
|
<GitPanel
|
|
42
43
|
collapsedFolders={gitMode.collapsedFolders}
|
|
44
|
+
compact={treeCompaction}
|
|
43
45
|
diffCountConfig={diffCountConfig}
|
|
44
46
|
fileListMode={gitFileListMode}
|
|
45
47
|
gitPanel={display}
|