@brimveyn/aimux 1.6.1 → 1.7.0

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 (71) hide show
  1. package/README.md +3 -0
  2. package/package.json +2 -2
  3. package/src/app-runtime/backend-attach-runtime.ts +6 -0
  4. package/src/app-runtime/backend-runtime-events.ts +21 -21
  5. package/src/app-runtime/side-effects.ts +43 -12
  6. package/src/app-runtime/use-backend-runtime.ts +2 -20
  7. package/src/app-runtime/use-directory-search.ts +6 -1
  8. package/src/app.tsx +6 -1
  9. package/src/config.ts +28 -1
  10. package/src/daemon/daemon.ts +197 -15
  11. package/src/daemon/session-manager.ts +9 -0
  12. package/src/daemon/session-registry.ts +5 -5
  13. package/src/git/diff-hash.ts +10 -0
  14. package/src/index.tsx +10 -2
  15. package/src/input/keymap/help-entries.ts +5 -5
  16. package/src/input/modes/bridge.ts +5 -8
  17. package/src/input/modes/transitions.ts +15 -23
  18. package/src/input/modes/types.ts +3 -5
  19. package/src/ipc/protocol.ts +49 -5
  20. package/src/platform/project-search.ts +45 -12
  21. package/src/pty/assistant-status-detection-loop.ts +192 -0
  22. package/src/pty/assistant-status-detector.ts +226 -0
  23. package/src/pty/pty-manager.ts +3 -37
  24. package/src/session-backend/bootstrap.ts +4 -1
  25. package/src/session-backend/local-session-backend.ts +43 -56
  26. package/src/session-backend/remote-session-backend.ts +15 -0
  27. package/src/session-backend/types.ts +10 -1
  28. package/src/state/git-tree.ts +42 -16
  29. package/src/state/reducers/diff-cache.ts +64 -0
  30. package/src/state/reducers/git-mode-state.ts +91 -33
  31. package/src/state/reducers/git-panel-state.ts +33 -2
  32. package/src/state/reducers/modal-state.ts +91 -134
  33. package/src/state/reducers/session-state.ts +13 -6
  34. package/src/state/reducers/tab-state.ts +0 -10
  35. package/src/state/selectors.ts +12 -0
  36. package/src/state/session-persistence.ts +20 -15
  37. package/src/state/store.ts +13 -3
  38. package/src/state/types.ts +87 -14
  39. package/src/ui/breaking-update-screen.tsx +31 -0
  40. package/src/ui/components/bare-input.tsx +44 -0
  41. package/src/ui/components/create-session-modal.tsx +16 -8
  42. package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
  43. package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
  44. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  45. package/src/ui/components/diff-renderer/split-view.tsx +35 -16
  46. package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
  47. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  48. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  49. package/src/ui/components/git-pane-widget.tsx +2 -0
  50. package/src/ui/components/git-panel.tsx +27 -10
  51. package/src/ui/components/git-view.tsx +23 -9
  52. package/src/ui/components/help-modal.tsx +45 -160
  53. package/src/ui/components/input-field.tsx +6 -2
  54. package/src/ui/components/list-item.tsx +36 -28
  55. package/src/ui/components/modal-shell.tsx +51 -13
  56. package/src/ui/components/new-tab-modal.tsx +78 -45
  57. package/src/ui/components/picker.tsx +179 -0
  58. package/src/ui/components/session-bar.tsx +47 -21
  59. package/src/ui/components/session-picker-modal.tsx +58 -56
  60. package/src/ui/components/sidebar.tsx +49 -22
  61. package/src/ui/components/snippet-picker-modal.tsx +43 -34
  62. package/src/ui/components/status-bar.tsx +3 -2
  63. package/src/ui/components/surface.tsx +11 -8
  64. package/src/ui/components/tab-item.tsx +38 -22
  65. package/src/ui/components/terminal-pane.tsx +8 -8
  66. package/src/ui/components/theme-picker-modal.tsx +51 -91
  67. package/src/ui/root.tsx +14 -23
  68. package/src/ui/status-bar-model.ts +5 -5
  69. package/src/ui/theme-store.ts +26 -2
  70. package/src/ui/theme.ts +9 -1
  71. package/src/ui/components/modal-filter-bar.tsx +0 -19
@@ -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'
@@ -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 { useTheme } from '../../theme'
16
+ import { useBg, useTheme, useTransparent } 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,7 +48,7 @@ export const SplitView = forwardRef<SplitViewHandle, Props>(function SplitView(
43
48
  { contentWidth, file, foldDispatch, folds, highlights },
44
49
  ref
45
50
  ) {
46
- const theme = useTheme()
51
+ const separatorBg = useBg('editor.background')
47
52
  const leftRef = useRef<ScrollBoxRenderable | null>(null)
48
53
  const rightRef = useRef<ScrollBoxRenderable | null>(null)
49
54
 
@@ -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
- {rows.map((row, i) => (
84
+ {displayRows.map((row, i) => (
78
85
  <SideRow
79
86
  key={i}
80
87
  cell={row.type === 'row' ? row.left : null}
@@ -85,8 +92,9 @@ 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
- <box width={1} backgroundColor={theme.colors['editor.background']} />
97
+ <box width={1} backgroundColor={separatorBg} />
90
98
  <scrollbox
91
99
  ref={rightRef}
92
100
  flexGrow={1}
@@ -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
- {rows.map((row, i) => (
106
+ {displayRows.map((row, i) => (
99
107
  <SideRow
100
108
  key={i}
101
109
  cell={row.type === 'row' ? row.right : null}
@@ -106,11 +114,24 @@ 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
+ const headerBg = useBg('sideBarSectionHeader.background')
126
+ return (
127
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
128
+ <text fg={theme.colors['editorWarning.foreground']}>
129
+ …diff truncated — {hidden} more rows hidden
130
+ </text>
131
+ </box>
132
+ )
133
+ }
134
+
114
135
  function SideRow({
115
136
  cell,
116
137
  foldDispatch,
@@ -134,13 +155,9 @@ function SideRow({
134
155
 
135
156
  function HunkHeaderRow({ row }: { row: Extract<SplitRowOrHeader, { type: 'hunk-header' }> }) {
136
157
  const theme = useTheme()
158
+ const headerBg = useBg('sideBarSectionHeader.background')
137
159
  return (
138
- <box
139
- flexDirection="row"
140
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
141
- paddingLeft={1}
142
- paddingRight={1}
143
- >
160
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
144
161
  <text fg={theme.colors['descriptionForeground']}>{row.spec}</text>
145
162
  {row.context ? (
146
163
  <text fg={theme.colors['editor.lineHighlightBackground']}> {row.context}</text>
@@ -161,8 +178,10 @@ function HalfRow({
161
178
  tokens: ThemedToken[][]
162
179
  }) {
163
180
  const theme = useTheme()
181
+ const headerBg = useBg('sideBarSectionHeader.background')
182
+ const transparent = useTransparent()
164
183
  if (cell.type === 'filler') {
165
- return <box backgroundColor={theme.colors['sideBarSectionHeader.background']} height={height} />
184
+ return <box backgroundColor={headerBg} height={height} />
166
185
  }
167
186
  let bg: string | undefined
168
187
  let sign = ' '
@@ -179,7 +198,7 @@ function HalfRow({
179
198
  const num = String(cell.lineNumber).padStart(gw, ' ')
180
199
  const lineTokens = tokens[cell.lineIdx]
181
200
  return (
182
- <box flexDirection="row" backgroundColor={bg} height={height}>
201
+ <box flexDirection="row" backgroundColor={transparent ? undefined : bg} height={height}>
183
202
  <text fg={theme.colors['descriptionForeground']}>{` ${num} `}</text>
184
203
  <text fg={signColor}>{`${sign} `}</text>
185
204
  <LineContent content={cell.content} tokens={lineTokens} />
@@ -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 { useTheme } from '../../theme'
16
+ import { useBg, useTheme, useTransparent } 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 = buildUnifiedRows(file, folds, contentWidth)
58
- const gw = gutterWidth(file)
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
- {rows.map((row, i) => (
77
+ {displayRows.map((row, i) => (
70
78
  <UnifiedRowRender
71
79
  key={i}
72
80
  foldDispatch={foldDispatch}
@@ -75,10 +83,23 @@ 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
+ const headerBg = useBg('sideBarSectionHeader.background')
94
+ return (
95
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
96
+ <text fg={theme.colors['editorWarning.foreground']}>
97
+ …diff truncated — {hidden} more rows hidden
98
+ </text>
99
+ </box>
100
+ )
101
+ }
102
+
82
103
  function UnifiedRowRender({
83
104
  foldDispatch,
84
105
  gw,
@@ -91,14 +112,11 @@ function UnifiedRowRender({
91
112
  row: UnifiedRowOrHeader
92
113
  }) {
93
114
  const theme = useTheme()
115
+ const headerBg = useBg('sideBarSectionHeader.background')
116
+ const transparent = useTransparent()
94
117
  if (row.type === 'hunk-header') {
95
118
  return (
96
- <box
97
- flexDirection="row"
98
- backgroundColor={theme.colors['sideBarSectionHeader.background']}
99
- paddingLeft={1}
100
- paddingRight={1}
101
- >
119
+ <box flexDirection="row" backgroundColor={headerBg} paddingLeft={1} paddingRight={1}>
102
120
  <text fg={theme.colors['descriptionForeground']}>{row.spec}</text>
103
121
  {row.context ? (
104
122
  <text fg={theme.colors['editor.lineHighlightBackground']}> {row.context}</text>
@@ -136,7 +154,7 @@ function UnifiedRowRender({
136
154
  const addNum = row.type === 'addition' ? row.lineNumber : undefined
137
155
  const tokens = row.type === 'addition' ? highlights.add[row.lineIdx] : highlights.del[row.lineIdx]
138
156
  return (
139
- <box flexDirection="row" backgroundColor={bg} height={row.height}>
157
+ <box flexDirection="row" backgroundColor={transparent ? undefined : bg} height={row.height}>
140
158
  <text fg={theme.colors['descriptionForeground']}>{` ${pad(delNum)} ${pad(addNum)} `}</text>
141
159
  <text fg={signColor}>{`${sign} `}</text>
142
160
  <LineContent content={row.content} tokens={tokens} />
@@ -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
+ }
@@ -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)
@@ -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}