@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.
Files changed (56) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +19 -0
  3. package/src/app.tsx +4 -0
  4. package/src/config.ts +19 -1
  5. package/src/git/diff-hash.ts +10 -0
  6. package/src/git/git-diff.ts +24 -12
  7. package/src/git/git-poller.ts +11 -3
  8. package/src/git/git-status.ts +98 -17
  9. package/src/input/modes/types.ts +1 -0
  10. package/src/pty/terminal-snapshot.ts +5 -5
  11. package/src/state/git-tree.ts +43 -17
  12. package/src/state/reducers/diff-cache.ts +64 -0
  13. package/src/state/reducers/git-mode-state.ts +135 -34
  14. package/src/state/reducers/git-panel-state.ts +39 -3
  15. package/src/state/store.ts +2 -0
  16. package/src/state/types.ts +63 -2
  17. package/src/ui/components/create-session-modal.tsx +5 -4
  18. package/src/ui/components/diff-renderer/fold-strip.tsx +3 -1
  19. package/src/ui/components/diff-renderer/pierre-diff.tsx +11 -32
  20. package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
  21. package/src/ui/components/diff-renderer/split-view.tsx +35 -6
  22. package/src/ui/components/diff-renderer/stacked-view.tsx +32 -5
  23. package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
  24. package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
  25. package/src/ui/components/git-commit-modal.tsx +2 -1
  26. package/src/ui/components/git-pane-widget.tsx +3 -1
  27. package/src/ui/components/git-panel.tsx +92 -50
  28. package/src/ui/components/git-view.tsx +34 -5
  29. package/src/ui/components/help-modal.tsx +2 -1
  30. package/src/ui/components/input-field.tsx +2 -1
  31. package/src/ui/components/list-item.tsx +2 -1
  32. package/src/ui/components/modal-filter-bar.tsx +2 -1
  33. package/src/ui/components/modal-keybinds-overlay.tsx +2 -1
  34. package/src/ui/components/modal-shell.tsx +2 -1
  35. package/src/ui/components/new-tab-modal.tsx +2 -1
  36. package/src/ui/components/pending-chord-overlay.tsx +2 -1
  37. package/src/ui/components/session-bar.tsx +3 -1
  38. package/src/ui/components/session-picker-modal.tsx +2 -1
  39. package/src/ui/components/sidebar.tsx +8 -5
  40. package/src/ui/components/snippet-editor-modal.tsx +2 -1
  41. package/src/ui/components/snippet-picker-modal.tsx +2 -1
  42. package/src/ui/components/split-layout.tsx +2 -1
  43. package/src/ui/components/status-bar.tsx +8 -6
  44. package/src/ui/components/surface.tsx +6 -6
  45. package/src/ui/components/tab-item.tsx +14 -9
  46. package/src/ui/components/terminal-pane.tsx +7 -5
  47. package/src/ui/components/theme-picker-modal.tsx +2 -1
  48. package/src/ui/components/update-available-modal.tsx +2 -1
  49. package/src/ui/filter-themes.ts +7 -2
  50. package/src/ui/root.tsx +3 -1
  51. package/src/ui/status-bar-model.ts +13 -3
  52. package/src/ui/theme-store.ts +36 -0
  53. package/src/ui/theme.ts +4 -29
  54. package/src/ui/themes.ts +15 -63
  55. package/src/ui/house-themes.ts +0 -313
  56. package/src/ui/themes.generated.ts +0 -59794
@@ -7,6 +7,7 @@ import {
7
7
  moveGitSelection,
8
8
  reconcileSelectedGitEntryKey,
9
9
  } from '../git-tree'
10
+ import { clearDiffCacheForPath, clearDiffCacheForPaths } from './diff-cache'
10
11
  import { sortFilesBySection } from './git-panel-state'
11
12
 
12
13
  function applyFold(state: AppState, key: string, foldId: string, next: FoldState): AppState {
@@ -28,33 +29,6 @@ function applyFold(state: AppState, key: string, foldId: string, next: FoldState
28
29
  return { ...state, gitMode: { ...state.gitMode, folds: nextFolds } }
29
30
  }
30
31
 
31
- function clearDiffCacheForPath(state: AppState, path: string): AppState {
32
- const nextDiffs = { ...state.gitMode.diffs }
33
- const nextFolds = { ...state.gitMode.folds }
34
- const nextLoading = { ...state.gitMode.loading }
35
- let changed = false
36
- for (const key of Object.keys(nextDiffs)) {
37
- if (nextDiffs[key]?.path !== path) continue
38
- delete nextDiffs[key]
39
- changed = true
40
- }
41
- for (const key of Object.keys(nextFolds)) {
42
- if (!key.endsWith(`:${path}`)) continue
43
- delete nextFolds[key]
44
- changed = true
45
- }
46
- for (const key of Object.keys(nextLoading)) {
47
- if (!key.endsWith(`:${path}`)) continue
48
- delete nextLoading[key]
49
- changed = true
50
- }
51
- if (!changed) return state
52
- return {
53
- ...state,
54
- gitMode: { ...state.gitMode, diffs: nextDiffs, folds: nextFolds, loading: nextLoading },
55
- }
56
- }
57
-
58
32
  export function emptyGitMode(): GitModeState {
59
33
  return {
60
34
  actionMessage: null,
@@ -62,7 +36,10 @@ export function emptyGitMode(): GitModeState {
62
36
  diffs: {},
63
37
  diffView: 'split',
64
38
  folds: {},
39
+ headOffset: 0,
40
+ highlights: {},
65
41
  loading: {},
42
+ parsedFiles: {},
66
43
  pendingDeletePath: null,
67
44
  selectedEntryKey: null,
68
45
  }
@@ -83,14 +60,29 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
83
60
  state.gitPanel.files,
84
61
  state.gitMode.collapsedFolders,
85
62
  state.gitPane.fileListMode,
86
- null
63
+ null,
64
+ [],
65
+ state.gitPane.treeCompaction
87
66
  ),
88
67
  },
89
68
  }
90
69
  }
91
70
  case 'exit-git-mode': {
92
71
  if (state.focusMode !== 'git') return state
93
- return { ...state, focusMode: 'navigation' }
72
+ return {
73
+ ...state,
74
+ focusMode: 'navigation',
75
+ gitMode: {
76
+ ...state.gitMode,
77
+ actionMessage: null,
78
+ diffs: {},
79
+ folds: {},
80
+ headOffset: 0,
81
+ highlights: {},
82
+ loading: {},
83
+ parsedFiles: {},
84
+ },
85
+ }
94
86
  }
95
87
  case 'git-mode-move-selection': {
96
88
  const next = moveGitSelection(
@@ -98,7 +90,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
98
90
  state.gitMode.collapsedFolders,
99
91
  state.gitPane.fileListMode,
100
92
  state.gitMode.selectedEntryKey,
101
- action.delta
93
+ action.delta,
94
+ state.gitPane.treeCompaction
102
95
  )
103
96
  if (!next || next === state.gitMode.selectedEntryKey) return state
104
97
  return {
@@ -116,7 +109,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
116
109
  state.gitMode.collapsedFolders,
117
110
  state.gitPane.fileListMode,
118
111
  state.gitMode.selectedEntryKey,
119
- action.delta
112
+ action.delta,
113
+ state.gitPane.treeCompaction
120
114
  )
121
115
  if (!next || next === state.gitMode.selectedEntryKey) return state
122
116
  return {
@@ -134,7 +128,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
134
128
  state.gitMode.collapsedFolders,
135
129
  state.gitPane.fileListMode,
136
130
  state.gitMode.selectedEntryKey,
137
- [action.key]
131
+ [action.key],
132
+ state.gitPane.treeCompaction
138
133
  )
139
134
  if (!next || next !== action.key || next === state.gitMode.selectedEntryKey) return state
140
135
  return {
@@ -149,6 +144,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
149
144
  case 'git-mode-toggle-folder': {
150
145
  const row = getSelectedGitRow(state.gitPanel.files, {
151
146
  collapsedFolders: state.gitMode.collapsedFolders,
147
+ compact: state.gitPane.treeCompaction,
152
148
  fileListMode: state.gitPane.fileListMode,
153
149
  selectedEntryKey: action.key,
154
150
  })
@@ -164,6 +160,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
164
160
  case 'git-mode-toggle-selected-folder': {
165
161
  const row = getSelectedGitRow(state.gitPanel.files, {
166
162
  collapsedFolders: state.gitMode.collapsedFolders,
163
+ compact: state.gitPane.treeCompaction,
167
164
  fileListMode: state.gitPane.fileListMode,
168
165
  selectedEntryKey: state.gitMode.selectedEntryKey,
169
166
  })
@@ -179,6 +176,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
179
176
  case 'git-mode-collapse-selection': {
180
177
  const row = getSelectedGitRow(state.gitPanel.files, {
181
178
  collapsedFolders: state.gitMode.collapsedFolders,
179
+ compact: state.gitPane.treeCompaction,
182
180
  fileListMode: state.gitPane.fileListMode,
183
181
  selectedEntryKey: state.gitMode.selectedEntryKey,
184
182
  })
@@ -205,6 +203,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
205
203
  case 'git-mode-expand-selection': {
206
204
  const row = getSelectedGitRow(state.gitPanel.files, {
207
205
  collapsedFolders: state.gitMode.collapsedFolders,
206
+ compact: state.gitPane.treeCompaction,
208
207
  fileListMode: state.gitPane.fileListMode,
209
208
  selectedEntryKey: state.gitMode.selectedEntryKey,
210
209
  })
@@ -224,24 +223,90 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
224
223
  state.gitPanel.files,
225
224
  state.gitMode.collapsedFolders,
226
225
  fileListMode,
227
- state.gitMode.selectedEntryKey
226
+ state.gitMode.selectedEntryKey,
227
+ [],
228
+ state.gitPane.treeCompaction
228
229
  ),
229
230
  },
230
231
  gitPane: { ...state.gitPane, fileListMode },
231
232
  }
232
233
  }
234
+ case 'git-mode-toggle-tree-compaction': {
235
+ const treeCompaction = !state.gitPane.treeCompaction
236
+ return {
237
+ ...state,
238
+ gitMode: {
239
+ ...state.gitMode,
240
+ pendingDeletePath: null,
241
+ selectedEntryKey: reconcileSelectedGitEntryKey(
242
+ state.gitPanel.files,
243
+ state.gitMode.collapsedFolders,
244
+ state.gitPane.fileListMode,
245
+ state.gitMode.selectedEntryKey,
246
+ [],
247
+ treeCompaction
248
+ ),
249
+ },
250
+ gitPane: { ...state.gitPane, treeCompaction },
251
+ }
252
+ }
233
253
  case 'git-mode-set-diff': {
234
254
  const nextLoading = { ...state.gitMode.loading }
235
255
  delete nextLoading[action.key]
256
+ // Drop stale derived entries (parsed + highlights) whose hash no longer matches.
257
+ const nextParsed = { ...state.gitMode.parsedFiles }
258
+ const prevParsed = nextParsed[action.key]
259
+ if (prevParsed && prevParsed.hash !== action.hash) delete nextParsed[action.key]
260
+ const nextHighlights = { ...state.gitMode.highlights }
261
+ for (const key of Object.keys(nextHighlights)) {
262
+ if (!key.startsWith(`${action.key}|`)) continue
263
+ if (nextHighlights[key]?.hash !== action.hash) delete nextHighlights[key]
264
+ }
236
265
  return {
237
266
  ...state,
238
267
  gitMode: {
239
268
  ...state.gitMode,
240
269
  diffs: { ...state.gitMode.diffs, [action.key]: action.diff },
270
+ highlights: nextHighlights,
241
271
  loading: nextLoading,
272
+ parsedFiles: nextParsed,
273
+ },
274
+ }
275
+ }
276
+ case 'git-mode-set-parsed': {
277
+ return {
278
+ ...state,
279
+ gitMode: {
280
+ ...state.gitMode,
281
+ parsedFiles: {
282
+ ...state.gitMode.parsedFiles,
283
+ [action.key]: { file: action.file, hash: action.hash },
284
+ },
285
+ },
286
+ }
287
+ }
288
+ case 'git-mode-set-highlights': {
289
+ const k = `${action.key}|${action.themeId}`
290
+ return {
291
+ ...state,
292
+ gitMode: {
293
+ ...state.gitMode,
294
+ highlights: {
295
+ ...state.gitMode.highlights,
296
+ [k]: {
297
+ add: action.add,
298
+ del: action.del,
299
+ hash: action.hash,
300
+ themeId: action.themeId,
301
+ },
302
+ },
242
303
  },
243
304
  }
244
305
  }
306
+ case 'git-mode-invalidate-diffs': {
307
+ if (action.paths.length === 0) return state
308
+ return clearDiffCacheForPaths(state, new Set(action.paths))
309
+ }
245
310
  case 'git-mode-set-loading': {
246
311
  const nextLoading = { ...state.gitMode.loading }
247
312
  if (action.loading) {
@@ -266,6 +331,41 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
266
331
  const next = state.gitMode.diffView === 'split' ? 'stacked' : 'split'
267
332
  return { ...state, gitMode: { ...state.gitMode, diffView: next } }
268
333
  }
334
+ case 'git-mode-shift-head-offset': {
335
+ const next = Math.max(0, state.gitMode.headOffset + action.delta)
336
+ if (next === state.gitMode.headOffset) return state
337
+ return {
338
+ ...state,
339
+ gitMode: {
340
+ ...state.gitMode,
341
+ actionMessage: null,
342
+ diffs: {},
343
+ folds: {},
344
+ headOffset: next,
345
+ highlights: {},
346
+ loading: {},
347
+ parsedFiles: {},
348
+ pendingDeletePath: null,
349
+ },
350
+ }
351
+ }
352
+ case 'git-mode-set-head-offset': {
353
+ const next = Math.max(0, action.offset)
354
+ if (next === state.gitMode.headOffset) return state
355
+ return {
356
+ ...state,
357
+ gitMode: {
358
+ ...state.gitMode,
359
+ diffs: {},
360
+ folds: {},
361
+ headOffset: next,
362
+ highlights: {},
363
+ loading: {},
364
+ parsedFiles: {},
365
+ pendingDeletePath: null,
366
+ },
367
+ }
368
+ }
269
369
  case 'git-mode-fold-adjust': {
270
370
  const perPath = state.gitMode.folds[action.key] ?? {}
271
371
  const prev = perPath[action.foldId] ?? { bottom: 0, top: 0 }
@@ -314,7 +414,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
314
414
  state.gitMode.collapsedFolders,
315
415
  state.gitPane.fileListMode,
316
416
  currentKey,
317
- preferredSelection
417
+ preferredSelection,
418
+ state.gitPane.treeCompaction
318
419
  )
319
420
 
320
421
  return {
@@ -1,11 +1,17 @@
1
1
  import type { AppAction, AppState, GitFileEntry, GitFileSection, GitPanelState } from '../types'
2
2
 
3
3
  import { reconcileSelectedGitEntryKey } from '../git-tree'
4
+ import { clearDiffCacheForPaths } from './diff-cache'
4
5
 
5
6
  export const GIT_PANEL_MIN_RATIO = 0.2
6
7
  export const GIT_PANEL_MAX_RATIO = 0.8
7
8
 
8
- const SECTION_RANK: Record<GitFileSection, number> = { staged: 0, unstaged: 1, untracked: 2 }
9
+ const SECTION_RANK: Record<GitFileSection, number> = {
10
+ historical: 0,
11
+ staged: 1,
12
+ unstaged: 2,
13
+ untracked: 3,
14
+ }
9
15
 
10
16
  export function sortFilesBySection(files: GitFileEntry[]): GitFileEntry[] {
11
17
  return [...files].sort((a, b) => {
@@ -30,6 +36,32 @@ export function emptyGitPanel(): GitPanelState {
30
36
  }
31
37
  }
32
38
 
39
+ function fileSignature(f: GitFileEntry): string {
40
+ return `${f.status}|${f.added ?? '-'}|${f.removed ?? '-'}|${f.renamedFrom ?? ''}`
41
+ }
42
+
43
+ // Paths whose content/status shifted between two snapshots — used to drop
44
+ // stale cached diffs so we re-fetch instead of serving pre-edit data.
45
+ function diffChangedPaths(prev: GitFileEntry[], next: GitFileEntry[]): Set<string> {
46
+ const prevByKey = new Map<string, string>()
47
+ for (const f of prev) prevByKey.set(`${f.section}:${f.path}`, fileSignature(f))
48
+ const changed = new Set<string>()
49
+ const nextKeys = new Set<string>()
50
+ for (const f of next) {
51
+ const key = `${f.section}:${f.path}`
52
+ nextKeys.add(key)
53
+ const prevSig = prevByKey.get(key)
54
+ if (prevSig !== fileSignature(f)) changed.add(f.path)
55
+ }
56
+ for (const [key, _sig] of prevByKey) {
57
+ if (nextKeys.has(key)) continue
58
+ const [, ...rest] = key.split(':')
59
+ const path = rest.join(':')
60
+ if (path) changed.add(path)
61
+ }
62
+ return changed
63
+ }
64
+
33
65
  function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
34
66
  if (a.length !== b.length) return false
35
67
  for (let i = 0; i < a.length; i++) {
@@ -104,7 +136,9 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
104
136
  sortedNext,
105
137
  state.gitMode.collapsedFolders,
106
138
  state.gitPane.fileListMode,
107
- state.gitMode.selectedEntryKey
139
+ state.gitMode.selectedEntryKey,
140
+ [],
141
+ state.gitPane.treeCompaction
108
142
  )
109
143
  if (
110
144
  prev.branch === next.branch &&
@@ -116,7 +150,8 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
116
150
  ) {
117
151
  return state
118
152
  }
119
- return {
153
+ const changedPaths = diffChangedPaths(prev.files, sortedNext)
154
+ const base: AppState = {
120
155
  ...state,
121
156
  gitMode: { ...state.gitMode, selectedEntryKey: nextSelectedEntryKey },
122
157
  gitPanel: {
@@ -128,6 +163,7 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
128
163
  files: sortedNext,
129
164
  },
130
165
  }
166
+ return changedPaths.size === 0 ? base : clearDiffCacheForPaths(base, changedPaths)
131
167
  }
132
168
  case 'git-refresh-error': {
133
169
  const prev = state.gitPanel
@@ -38,7 +38,9 @@ const DEFAULT_GIT_PANE: GitPaneState = {
38
38
  mode: 'embedded',
39
39
  path: { enabled: true },
40
40
  position: 'bottom',
41
+ prefetchRadius: 5,
41
42
  ratio: 0.5,
43
+ treeCompaction: true,
42
44
  visible: true,
43
45
  }
44
46
 
@@ -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,13 +151,16 @@ 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' | '?'
156
162
 
157
- export type GitFileSection = 'staged' | 'unstaged' | 'untracked'
163
+ export type GitFileSection = 'staged' | 'unstaged' | 'untracked' | 'historical'
158
164
 
159
165
  export interface GitFileEntry {
160
166
  path: string
@@ -195,15 +201,52 @@ 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
205
246
  diffView: GitDiffView
206
247
  folds: Record<string, Record<string, FoldState>>
248
+ /** Working-tree-vs-HEAD~N offset. 0 = working tree vs HEAD (default). */
249
+ headOffset: number
207
250
  }
208
251
 
209
252
  interface ModalBase {
@@ -471,12 +514,30 @@ export type GitModeAction =
471
514
  | { type: 'git-mode-collapse-selection' }
472
515
  | { type: 'git-mode-expand-selection' }
473
516
  | { type: 'git-mode-toggle-file-list-mode' }
474
- | { type: 'git-mode-set-diff'; key: string; diff: DiffData }
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[] }
475
534
  | { type: 'git-mode-set-loading'; key: string; loading: boolean }
476
535
  | { type: 'git-mode-set-pending-delete'; path: string | null }
477
536
  | { type: 'git-mode-clear-diff-cache'; path: string }
478
537
  | { type: 'git-mode-set-message'; message: string | null }
479
538
  | { type: 'git-mode-toggle-diff-view' }
539
+ | { type: 'git-mode-shift-head-offset'; delta: number }
540
+ | { type: 'git-mode-set-head-offset'; offset: number }
480
541
  | {
481
542
  type: 'git-mode-fold-adjust'
482
543
  key: string
@@ -1,7 +1,7 @@
1
1
  import type { DirectoryResult } from '../../state/types'
2
2
 
3
3
  import { abbreviatePath } from '../path-format'
4
- import { theme } from '../theme'
4
+ import { getCurrentTheme, useTheme } from '../theme'
5
5
  import { uiTokens } from '../ui-tokens'
6
6
  import { InputField } from './input-field'
7
7
  import { ListItem } from './list-item'
@@ -21,14 +21,14 @@ function getDirectoryResultIcon(result: DirectoryResult): string {
21
21
 
22
22
  function getDirectoryResultColor(result: DirectoryResult): string {
23
23
  if (result.type === 'worktree') {
24
- return theme.colors['editorWarning.foreground']
24
+ return getCurrentTheme().colors['editorWarning.foreground']
25
25
  }
26
26
 
27
27
  if (result.type === 'workspace') {
28
- return theme.colors['terminal.ansiMagenta']
28
+ return getCurrentTheme().colors['terminal.ansiMagenta']
29
29
  }
30
30
 
31
- return theme.colors['textLink.foreground']
31
+ return getCurrentTheme().colors['textLink.foreground']
32
32
  }
33
33
 
34
34
  interface CreateSessionModalProps {
@@ -48,6 +48,7 @@ export function CreateSessionModal({
48
48
  selectedIndex,
49
49
  sessionName,
50
50
  }: CreateSessionModalProps) {
51
+ const theme = useTheme()
51
52
  const dirActive = activeField === 'directory'
52
53
  const nameActive = activeField === 'name'
53
54
 
@@ -1,4 +1,4 @@
1
- import { theme } from '../../theme'
1
+ import { useTheme } from '../../theme'
2
2
  import { FOLD_STEP, type FoldInfo } from './build-rows'
3
3
  import { type FoldDispatch } from './pierre-diff'
4
4
 
@@ -8,6 +8,7 @@ interface Props {
8
8
  }
9
9
 
10
10
  function Button({ label, onPress }: { label: string; onPress: () => void }) {
11
+ const theme = useTheme()
11
12
  return (
12
13
  <box
13
14
  paddingLeft={1}
@@ -25,6 +26,7 @@ function Spacer() {
25
26
  }
26
27
 
27
28
  export function FoldStrip({ dispatch, fold }: Props) {
29
+ const theme = useTheme()
28
30
  const { bottomExpanded, foldId, hidden, topExpanded, total } = fold
29
31
  const stepUp = Math.min(FOLD_STEP, hidden)
30
32
  const stepDown = Math.min(FOLD_STEP, hidden)
@@ -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, useState } from 'react'
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'
12
- import { theme } from '../../theme'
10
+ import { type FoldState } from '../../../state/types'
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,19 +39,16 @@ 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(
48
45
  { cacheKey, diff, path, themeId, view },
49
46
  ref
50
47
  ) {
51
- const file = useMemo(() => {
52
- const patches = parsePatchFiles(diff)
53
- return patches[0]?.files[0]
54
- }, [diff])
55
-
56
- const filetype = useMemo(() => filetypeFromPath(path), [path])
48
+ const theme = useTheme()
49
+ const preparation = useDiffPreparation(cacheKey, diff, path, themeId)
50
+ const file = preparation.file ?? undefined
51
+ const highlights: DiffHighlights = preparation.highlights
57
52
 
58
53
  const terminalCols = useAppStore((s) => s.layout.terminalCols)
59
54
  const sidebarWidth = useAppStore((s) => s.sidebar.width)
@@ -77,24 +72,6 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
77
72
  [cacheKey]
78
73
  )
79
74
 
80
- const [highlights, setHighlights] = useState<DiffHighlights>(EMPTY_HIGHLIGHTS)
81
-
82
- useEffect(() => {
83
- setHighlights(EMPTY_HIGHLIGHTS)
84
- if (!file || !filetype) return
85
- let cancelled = false
86
- void Promise.all([
87
- tokenizeSide(file.additionLines, filetype, themeId),
88
- tokenizeSide(file.deletionLines, filetype, themeId),
89
- ]).then(([add, del]) => {
90
- if (cancelled) return
91
- setHighlights({ add, del })
92
- })
93
- return () => {
94
- cancelled = true
95
- }
96
- }, [file, filetype, themeId])
97
-
98
75
  const splitRef = useRef<SplitViewHandle | null>(null)
99
76
  const stackedRef = useRef<StackedViewHandle | null>(null)
100
77
 
@@ -140,7 +117,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
140
117
  if (!file) {
141
118
  return (
142
119
  <box flexGrow={1} padding={1}>
143
- <text fg={theme.colors['descriptionForeground']}>(could not parse diff)</text>
120
+ <text fg={theme.colors['descriptionForeground']}>
121
+ {preparation.preparing ? 'Preparing diff…' : '(could not parse diff)'}
122
+ </text>
144
123
  </box>
145
124
  )
146
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
+ }