@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -61,7 +61,7 @@
61
61
  "generate-themes": "bun run scripts/generate-themes.ts"
62
62
  },
63
63
  "dependencies": {
64
- "@brimveyn/aimux-config": "0.4.0",
64
+ "@brimveyn/aimux-config": "0.4.2",
65
65
  "@opentui/core": "^0.1.90",
66
66
  "@opentui/react": "^0.1.90",
67
67
  "@xterm/headless": "^6.0.0",
@@ -471,6 +471,7 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
471
471
  mode: persistedGitPane?.mode ?? 'embedded',
472
472
  position: persistedGitPane?.position ?? 'bottom',
473
473
  ratio: persistedGitPane?.ratio ?? 0.5,
474
+ treeCompaction: persistedGitPane?.treeCompaction,
474
475
  visible: persistedGitPane?.visible ?? true,
475
476
  },
476
477
  })
@@ -487,6 +488,24 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
487
488
  mode: persistedGitPane?.mode ?? 'embedded',
488
489
  position: persistedGitPane?.position ?? 'bottom',
489
490
  ratio: persistedGitPane?.ratio ?? 0.5,
491
+ treeCompaction: persistedGitPane?.treeCompaction,
492
+ visible: persistedGitPane?.visible ?? true,
493
+ },
494
+ })
495
+ return
496
+ }
497
+ case 'persist-git-tree-compaction': {
498
+ const config = loadConfig()
499
+ const persistedGitPane = config.gitPane
500
+ saveConfig({
501
+ ...config,
502
+ gitPane: {
503
+ diffModeRatio: persistedGitPane?.diffModeRatio,
504
+ fileListMode: persistedGitPane?.fileListMode,
505
+ mode: persistedGitPane?.mode ?? 'embedded',
506
+ position: persistedGitPane?.position ?? 'bottom',
507
+ ratio: persistedGitPane?.ratio ?? 0.5,
508
+ treeCompaction: effect.enabled,
490
509
  visible: persistedGitPane?.visible ?? true,
491
510
  },
492
511
  })
package/src/app.tsx CHANGED
@@ -88,10 +88,14 @@ export function App({
88
88
  const userGitPane = resolvedConfig.gitPane
89
89
  const fileListMode = userGitPane?.fileListMode ?? json.gitPane?.fileListMode ?? 'tree'
90
90
  const diffModeRatio = userGitPane?.diffModeRatio ?? json.gitPane?.diffModeRatio ?? 0.35
91
+ const treeCompaction = userGitPane?.treeCompaction ?? json.gitPane?.treeCompaction ?? true
92
+ const prefetchRadius = userGitPane?.prefetchRadius ?? json.gitPane?.prefetchRadius ?? 5
91
93
  const gitPaneOverrides = {
92
94
  ...json.gitPane,
93
95
  diffModeRatio,
94
96
  fileListMode,
97
+ prefetchRadius,
98
+ treeCompaction,
95
99
  ...(userGitPane?.visible !== undefined ? { visible: userGitPane.visible } : {}),
96
100
  ...(userGitPane?.mode !== undefined ? { mode: userGitPane.mode } : {}),
97
101
  ...(userGitPane?.position !== undefined ? { position: userGitPane.position } : {}),
package/src/config.ts CHANGED
@@ -25,6 +25,8 @@ export const CONFIG_PATH = `${getProfileConfigDir()}/aimux.json`
25
25
  export interface PersistedGitPane {
26
26
  diffModeRatio?: number
27
27
  fileListMode?: GitFileListMode
28
+ treeCompaction?: boolean
29
+ prefetchRadius?: number
28
30
  visible: boolean
29
31
  mode: 'embedded' | 'pane'
30
32
  position: 'top' | 'bottom' | 'left' | 'right'
@@ -62,7 +64,23 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
62
64
  const visibleOk = typeof v.visible === 'boolean'
63
65
  const fileListModeOk =
64
66
  v.fileListMode === undefined || v.fileListMode === 'tree' || v.fileListMode === 'flat'
65
- if (!modeOk || !positionOk || !ratioOk || !diffModeRatioOk || !visibleOk || !fileListModeOk) {
67
+ const treeCompactionOk = v.treeCompaction === undefined || typeof v.treeCompaction === 'boolean'
68
+ const prefetchRadiusOk =
69
+ v.prefetchRadius === undefined ||
70
+ (typeof v.prefetchRadius === 'number' &&
71
+ Number.isFinite(v.prefetchRadius) &&
72
+ v.prefetchRadius >= 0 &&
73
+ v.prefetchRadius <= 50)
74
+ if (
75
+ !modeOk ||
76
+ !positionOk ||
77
+ !ratioOk ||
78
+ !diffModeRatioOk ||
79
+ !visibleOk ||
80
+ !fileListModeOk ||
81
+ !treeCompactionOk ||
82
+ !prefetchRadiusOk
83
+ ) {
66
84
  return false
67
85
  }
68
86
  // cross-field coherence: embedded => top|bottom; pane => left|right
@@ -0,0 +1,10 @@
1
+ // FNV-1a 32-bit hash. Fast, non-crypto, used to detect stale cached diff
2
+ // artefacts (parsed file, syntax highlights) when the raw diff text changes.
3
+ export function diffHash(input: string): string {
4
+ let h = 0x811c9dc5
5
+ for (let i = 0; i < input.length; i++) {
6
+ h ^= input.charCodeAt(i)
7
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0
8
+ }
9
+ return h.toString(16)
10
+ }
@@ -6,12 +6,14 @@ function resolveStatus(entry: GitFileEntry): { status: DiffFileStatus; oldPath?:
6
6
  if (entry.renamedFrom) return { oldPath: entry.renamedFrom, status: 'renamed' }
7
7
  if (entry.section === 'untracked' || entry.status === '?') return { status: 'new' }
8
8
  if (entry.status === 'D') return { status: 'deleted' }
9
- if (entry.status === 'A' && entry.section === 'staged') return { status: 'new' }
9
+ if (entry.status === 'A' && (entry.section === 'staged' || entry.section === 'historical')) {
10
+ return { status: 'new' }
11
+ }
10
12
  return { status: 'modified' }
11
13
  }
12
14
 
13
- async function isBinary(cwd: string, path: string): Promise<boolean> {
14
- const result = await $`git -C ${cwd} diff HEAD --numstat -- ${path}`.quiet().nothrow()
15
+ async function isBinary(cwd: string, ref: string, path: string): Promise<boolean> {
16
+ const result = await $`git -C ${cwd} diff ${ref} --numstat -- ${path}`.quiet().nothrow()
15
17
  if (result.exitCode !== 0) return false
16
18
  const text = result.text().trim()
17
19
  if (!text) return false
@@ -19,8 +21,8 @@ async function isBinary(cwd: string, path: string): Promise<boolean> {
19
21
  return first.startsWith('-\t-\t')
20
22
  }
21
23
 
22
- async function readHeadSize(cwd: string, path: string): Promise<number> {
23
- const result = await $`git -C ${cwd} show HEAD:${path}`.quiet().nothrow()
24
+ async function readHeadSize(cwd: string, ref: string, path: string): Promise<number> {
25
+ const result = await $`git -C ${cwd} show ${ref}:${path}`.quiet().nothrow()
24
26
  if (result.exitCode !== 0) return 0
25
27
  return result.text().length
26
28
  }
@@ -33,9 +35,14 @@ async function readWorkingSize(cwd: string, path: string): Promise<number> {
33
35
  return 0
34
36
  }
35
37
 
36
- async function rawUnifiedDiff(cwd: string, path: string, status: DiffFileStatus): Promise<string> {
38
+ async function rawUnifiedDiff(
39
+ cwd: string,
40
+ ref: string,
41
+ path: string,
42
+ status: DiffFileStatus
43
+ ): Promise<string> {
37
44
  if (status === 'new') {
38
- const result = await $`git -C ${cwd} diff HEAD --no-color --no-textconv -- ${path}`
45
+ const result = await $`git -C ${cwd} diff ${ref} --no-color --no-textconv -- ${path}`
39
46
  .quiet()
40
47
  .nothrow()
41
48
  if (result.exitCode === 0 && result.text().length > 0) return result.text()
@@ -47,19 +54,24 @@ async function rawUnifiedDiff(cwd: string, path: string, status: DiffFileStatus)
47
54
  }
48
55
 
49
56
  const result =
50
- await $`git -C ${cwd} diff HEAD --unified=99999 --no-color --no-textconv -- ${path}`
57
+ await $`git -C ${cwd} diff ${ref} --unified=99999 --no-color --no-textconv -- ${path}`
51
58
  .quiet()
52
59
  .nothrow()
53
60
  if (result.exitCode !== 0) return ''
54
61
  return result.text()
55
62
  }
56
63
 
57
- export async function fetchDiff(cwd: string, file: GitFileEntry): Promise<DiffData> {
64
+ export async function fetchDiff(
65
+ cwd: string,
66
+ file: GitFileEntry,
67
+ headOffset: number = 0
68
+ ): Promise<DiffData> {
58
69
  const { oldPath, status } = resolveStatus(file)
70
+ const ref = headOffset > 0 ? `HEAD~${headOffset}` : 'HEAD'
59
71
 
60
- if (await isBinary(cwd, file.path)) {
72
+ if (await isBinary(cwd, ref, file.path)) {
61
73
  const [binarySizeBefore, binarySizeAfter] = await Promise.all([
62
- readHeadSize(cwd, file.path),
74
+ readHeadSize(cwd, ref, file.path),
63
75
  readWorkingSize(cwd, file.path),
64
76
  ])
65
77
  return {
@@ -71,7 +83,7 @@ export async function fetchDiff(cwd: string, file: GitFileEntry): Promise<DiffDa
71
83
  }
72
84
  }
73
85
 
74
- const rawDiff = await rawUnifiedDiff(cwd, file.path, status)
86
+ const rawDiff = await rawUnifiedDiff(cwd, ref, file.path, status)
75
87
 
76
88
  const data: DiffData = {
77
89
  path: file.path,
@@ -9,9 +9,10 @@ const MAX_INTERVAL_MS = 30_000
9
9
  interface Options {
10
10
  enabled: boolean
11
11
  projectPath: string | undefined
12
+ headOffset: number
12
13
  }
13
14
 
14
- export function useGitPanelPolling({ enabled, projectPath }: Options): void {
15
+ export function useGitPanelPolling({ enabled, headOffset, projectPath }: Options): void {
15
16
  useEffect(() => {
16
17
  if (!enabled || !projectPath) return undefined
17
18
 
@@ -27,11 +28,18 @@ export function useGitPanelPolling({ enabled, projectPath }: Options): void {
27
28
  }
28
29
 
29
30
  const tick = async () => {
30
- const result = await collectGitStatus(projectPath)
31
+ const result = await collectGitStatus(projectPath, { headOffset })
31
32
  if (cancelled) return
32
33
  if (result.kind === 'ok') {
33
34
  dispatchGlobal({ payload: result.payload, type: 'git-refresh-success' })
34
35
  delay = BASE_INTERVAL_MS
36
+ } else if (result.kind === 'out-of-range') {
37
+ dispatchGlobal({ offset: result.maxOffset, type: 'git-mode-set-head-offset' })
38
+ dispatchGlobal({
39
+ message: `no older commit — clamped to HEAD~${result.maxOffset}`,
40
+ type: 'git-mode-set-message',
41
+ })
42
+ delay = BASE_INTERVAL_MS
35
43
  } else {
36
44
  dispatchGlobal({ kind: result.error, type: 'git-refresh-error' })
37
45
  delay = Math.min(delay * 2, MAX_INTERVAL_MS)
@@ -45,5 +53,5 @@ export function useGitPanelPolling({ enabled, projectPath }: Options): void {
45
53
  cancelled = true
46
54
  if (timer) clearTimeout(timer)
47
55
  }
48
- }, [enabled, projectPath])
56
+ }, [enabled, projectPath, headOffset])
49
57
  }
@@ -16,6 +16,7 @@ interface NumstatRow {
16
16
  export type GitCollectResult =
17
17
  | { kind: 'ok'; payload: GitRefreshPayload }
18
18
  | { kind: 'error'; error: GitPanelError }
19
+ | { kind: 'out-of-range'; maxOffset: number }
19
20
 
20
21
  const STATUS_CODES = new Set(['M', 'A', 'D', 'R', 'C', 'U', '?'])
21
22
 
@@ -189,26 +190,106 @@ async function annotateUntrackedCounts(cwd: string, files: GitFileEntry[]): Prom
189
190
  }
190
191
  }
191
192
 
192
- export async function collectGitStatus(cwd: string): Promise<GitCollectResult> {
193
- try {
194
- const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
195
- $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
196
- $`git -C ${cwd} -c core.quotePath=false diff --numstat`.quiet().nothrow(),
197
- $`git -C ${cwd} -c core.quotePath=false diff --cached --numstat`.quiet().nothrow(),
198
- ])
199
-
200
- if (statusResult.exitCode !== 0) {
201
- return { error: 'not-a-repo', kind: 'error' }
193
+ interface CollectOptions {
194
+ headOffset?: number
195
+ }
196
+
197
+ function parseNameStatus(
198
+ output: string
199
+ ): { path: string; status: GitFileStatus; renamedFrom?: string }[] {
200
+ const rows: { path: string; status: GitFileStatus; renamedFrom?: string }[] = []
201
+ for (const raw of output.split('\n')) {
202
+ if (!raw) continue
203
+ const parts = raw.split('\t')
204
+ const code = parts[0] ?? ''
205
+ const letter = code[0] ?? ''
206
+ const status = toStatus(letter)
207
+ if (!status) continue
208
+ if (letter === 'R' || letter === 'C') {
209
+ const from = parts[1]
210
+ const to = parts[2]
211
+ if (!from || !to) continue
212
+ rows.push({ path: to, renamedFrom: from, status })
213
+ } else {
214
+ const path = parts.slice(1).join('\t')
215
+ if (!path) continue
216
+ rows.push({ path, status })
202
217
  }
218
+ }
219
+ return rows
220
+ }
221
+
222
+ async function collectAgainstHead(cwd: string): Promise<GitCollectResult> {
223
+ const [statusResult, unstagedDiff, stagedDiff] = await Promise.all([
224
+ $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
225
+ $`git -C ${cwd} -c core.quotePath=false diff --numstat`.quiet().nothrow(),
226
+ $`git -C ${cwd} -c core.quotePath=false diff --cached --numstat`.quiet().nothrow(),
227
+ ])
203
228
 
204
- const statusText = statusResult.text()
205
- const { ahead, behind, branch } = parseBranchLines(statusText)
206
- const unstagedNumstat = parseNumstat(unstagedDiff.text())
207
- const stagedNumstat = parseNumstat(stagedDiff.text())
208
- const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
209
- await annotateUntrackedCounts(cwd, files)
229
+ if (statusResult.exitCode !== 0) {
230
+ return { error: 'not-a-repo', kind: 'error' }
231
+ }
232
+
233
+ const statusText = statusResult.text()
234
+ const { ahead, behind, branch } = parseBranchLines(statusText)
235
+ const unstagedNumstat = parseNumstat(unstagedDiff.text())
236
+ const stagedNumstat = parseNumstat(stagedDiff.text())
237
+ const files = parsePorcelainEntries(statusText, stagedNumstat, unstagedNumstat)
238
+ await annotateUntrackedCounts(cwd, files)
210
239
 
211
- return { kind: 'ok', payload: { ahead, behind, branch, files } }
240
+ return { kind: 'ok', payload: { ahead, behind, branch, files } }
241
+ }
242
+
243
+ async function collectAgainstHistorical(
244
+ cwd: string,
245
+ headOffset: number
246
+ ): Promise<GitCollectResult> {
247
+ const ref = `HEAD~${headOffset}`
248
+ const revParse = await $`git -C ${cwd} rev-parse ${ref}`.quiet().nothrow()
249
+ if (revParse.exitCode !== 0) {
250
+ const countResult = await $`git -C ${cwd} rev-list --count HEAD`.quiet().nothrow()
251
+ const count = countResult.exitCode === 0 ? Number.parseInt(countResult.text().trim(), 10) : NaN
252
+ const maxOffset = Number.isFinite(count) && count > 0 ? count - 1 : 0
253
+ return { kind: 'out-of-range', maxOffset }
254
+ }
255
+
256
+ const [statusResult, nameStatus, numstat, untrackedStatus] = await Promise.all([
257
+ $`git -C ${cwd} status --porcelain=v2 -b -z --untracked-files=all`.quiet().nothrow(),
258
+ $`git -C ${cwd} -c core.quotePath=false diff ${ref} --name-status`.quiet().nothrow(),
259
+ $`git -C ${cwd} -c core.quotePath=false diff ${ref} --numstat`.quiet().nothrow(),
260
+ $`git -C ${cwd} status --porcelain=v2 -z --untracked-files=all`.quiet().nothrow(),
261
+ ])
262
+
263
+ if (statusResult.exitCode !== 0) {
264
+ return { error: 'not-a-repo', kind: 'error' }
265
+ }
266
+
267
+ const { ahead, behind, branch } = parseBranchLines(statusResult.text())
268
+ const stats = parseNumstat(numstat.text())
269
+ const rows = parseNameStatus(nameStatus.text())
270
+ const files: GitFileEntry[] = rows.map((row) =>
271
+ buildEntry('historical', row.status, row.path, stats, row.renamedFrom)
272
+ )
273
+
274
+ // Untracked files live outside any commit — still surface them so `?` files
275
+ // don't vanish when the user walks history.
276
+ const porcelain = parsePorcelainEntries(untrackedStatus.text(), new Map(), new Map())
277
+ const untracked = porcelain.filter((f) => f.section === 'untracked')
278
+ await annotateUntrackedCounts(cwd, untracked)
279
+ files.push(...untracked)
280
+
281
+ return { kind: 'ok', payload: { ahead, behind, branch, files } }
282
+ }
283
+
284
+ export async function collectGitStatus(
285
+ cwd: string,
286
+ options: CollectOptions = {}
287
+ ): Promise<GitCollectResult> {
288
+ const headOffset = options.headOffset ?? 0
289
+ try {
290
+ return headOffset > 0
291
+ ? await collectAgainstHistorical(cwd, headOffset)
292
+ : await collectAgainstHead(cwd)
212
293
  } catch {
213
294
  return { error: 'unknown', kind: 'error' }
214
295
  }
@@ -49,6 +49,7 @@ export type SideEffect =
49
49
  | { type: 'scroll-git-diff'; delta: number }
50
50
  | { type: 'persist-git-diff-mode-ratio'; ratio: number }
51
51
  | { type: 'persist-git-file-list-mode'; mode: GitFileListMode }
52
+ | { type: 'persist-git-tree-compaction'; enabled: boolean }
52
53
  | { type: 'git-stage'; path: string }
53
54
  | { type: 'git-unstage'; path: string }
54
55
  | { type: 'git-restore'; path: string }
@@ -2,7 +2,7 @@ import type { Terminal } from '@xterm/headless'
2
2
 
3
3
  import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/types'
4
4
 
5
- import { theme } from '../ui/theme'
5
+ import { getCurrentTheme } from '../ui/theme'
6
6
 
7
7
  const ANSI_PALETTE = [
8
8
  '#000000',
@@ -113,15 +113,15 @@ function buildLine(
113
113
  let bg = getColorHex(current.getBgColor(), bgMode)
114
114
 
115
115
  if (current.isInverse()) {
116
- const resolvedFg = fg ?? theme.colors['editor.foreground']
117
- const resolvedBg = bg ?? theme.colors['editor.background']
116
+ const resolvedFg = fg ?? getCurrentTheme().colors['editor.foreground']
117
+ const resolvedBg = bg ?? getCurrentTheme().colors['editor.background']
118
118
  ;[fg, bg] = [resolvedBg, resolvedFg]
119
119
  }
120
120
 
121
121
  const isCursorCell = cursorVisible && cursorColumn === column
122
122
  if (isCursorCell) {
123
- const resolvedFg = fg ?? theme.colors['editor.foreground']
124
- const resolvedBg = bg ?? theme.colors['editor.background']
123
+ const resolvedFg = fg ?? getCurrentTheme().colors['editor.foreground']
124
+ const resolvedBg = bg ?? getCurrentTheme().colors['editor.background']
125
125
  ;[fg, bg] = [resolvedBg, resolvedFg]
126
126
  }
127
127
 
@@ -1,6 +1,6 @@
1
1
  import type { GitFileEntry, GitFileListMode, GitFileSection } from './types'
2
2
 
3
- const SECTION_ORDER: GitFileSection[] = ['staged', 'unstaged', 'untracked']
3
+ const SECTION_ORDER: GitFileSection[] = ['historical', 'staged', 'unstaged', 'untracked']
4
4
 
5
5
  interface GitTreeNode {
6
6
  files: GitFileEntry[]
@@ -53,7 +53,8 @@ export function gitFolderKey(section: GitFileSection, folderPath: string): strin
53
53
  export function buildGitTreeRows(
54
54
  files: GitFileEntry[],
55
55
  collapsedFolders: Record<string, true>,
56
- fileListMode: GitFileListMode = 'tree'
56
+ fileListMode: GitFileListMode = 'tree',
57
+ compact: boolean = false
57
58
  ): GitTreeRows {
58
59
  const sections = SECTION_ORDER.map((section) => {
59
60
  const sectionFiles = files.filter((file) => file.section === section)
@@ -68,7 +69,7 @@ export function buildGitTreeRows(
68
69
  kind: 'file' as const,
69
70
  section,
70
71
  }))
71
- : flattenSectionRows(sectionFiles, collapsedFolders),
72
+ : flattenSectionRows(sectionFiles, collapsedFolders, compact),
72
73
  section,
73
74
  }
74
75
  })
@@ -81,10 +82,16 @@ export function getSelectedGitRow(
81
82
  collapsedFolders: Record<string, true>
82
83
  fileListMode: GitFileListMode
83
84
  selectedEntryKey: string | null
85
+ compact?: boolean
84
86
  }
85
87
  ): GitTreeRow | null {
86
88
  if (!options.selectedEntryKey) return null
87
- const { visibleRows } = buildGitTreeRows(files, options.collapsedFolders, options.fileListMode)
89
+ const { visibleRows } = buildGitTreeRows(
90
+ files,
91
+ options.collapsedFolders,
92
+ options.fileListMode,
93
+ options.compact ?? false
94
+ )
88
95
  return visibleRows.find((row) => row.key === options.selectedEntryKey) ?? null
89
96
  }
90
97
 
@@ -94,6 +101,7 @@ export function getSelectedGitFile(
94
101
  collapsedFolders: Record<string, true>
95
102
  fileListMode: GitFileListMode
96
103
  selectedEntryKey: string | null
104
+ compact?: boolean
97
105
  }
98
106
  ): GitFileEntry | null {
99
107
  const row = getSelectedGitRow(files, options)
@@ -105,9 +113,10 @@ export function reconcileSelectedGitEntryKey(
105
113
  collapsedFolders: Record<string, true>,
106
114
  fileListMode: GitFileListMode,
107
115
  selectedEntryKey: string | null | undefined,
108
- preferredKeys: string[] = []
116
+ preferredKeys: string[] = [],
117
+ compact: boolean = false
109
118
  ): string | null {
110
- const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
119
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, compact)
111
120
  if (visibleRows.length === 0) return null
112
121
  const candidates = [...preferredKeys, selectedEntryKey ?? '']
113
122
  for (const key of candidates) {
@@ -122,9 +131,10 @@ export function moveGitSelection(
122
131
  collapsedFolders: Record<string, true>,
123
132
  fileListMode: GitFileListMode,
124
133
  selectedEntryKey: string | null,
125
- delta: -1 | 1
134
+ delta: -1 | 1,
135
+ compact: boolean = false
126
136
  ): string | null {
127
- const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
137
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, compact)
128
138
  const total = visibleRows.length
129
139
  if (total === 0) return null
130
140
  const current = visibleRows.findIndex((row) => row.key === selectedEntryKey)
@@ -137,9 +147,10 @@ export function moveGitFileSelection(
137
147
  collapsedFolders: Record<string, true>,
138
148
  fileListMode: GitFileListMode,
139
149
  selectedEntryKey: string | null,
140
- delta: -1 | 1
150
+ delta: -1 | 1,
151
+ compact: boolean = false
141
152
  ): string | null {
142
- const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode)
153
+ const { visibleRows } = buildGitTreeRows(files, collapsedFolders, fileListMode, compact)
143
154
  const fileRows = visibleRows.filter((row) => row.kind === 'file')
144
155
  const total = fileRows.length
145
156
  if (total === 0) return null
@@ -153,13 +164,14 @@ export function moveGitFileSelection(
153
164
 
154
165
  function flattenSectionRows(
155
166
  files: GitFileEntry[],
156
- collapsedFolders: Record<string, true>
167
+ collapsedFolders: Record<string, true>,
168
+ compact: boolean
157
169
  ): GitTreeRow[] {
158
170
  if (files.length === 0) return []
159
171
  const section = files[0]?.section
160
172
  if (!section) return []
161
173
  const root = buildSectionTree(files, section)
162
- return flattenTreeNode(root, collapsedFolders, 0)
174
+ return flattenTreeNode(root, collapsedFolders, 0, undefined, compact)
163
175
  }
164
176
 
165
177
  function buildSectionTree(files: GitFileEntry[], section: GitFileSection): GitTreeNode {
@@ -187,16 +199,28 @@ function flattenTreeNode(
187
199
  node: GitTreeNode,
188
200
  collapsedFolders: Record<string, true>,
189
201
  depth: number,
190
- parentKey?: string
202
+ parentKey: string | undefined,
203
+ compact: boolean
191
204
  ): GitTreeRow[] {
192
205
  const rows: GitTreeRow[] = []
193
206
  for (const child of node.folders.values()) {
194
- const name = child.path.split('/').pop() ?? child.path
195
- const key = gitFolderKey(node.section, child.path)
207
+ // Compaction: while a folder has exactly one child folder and no direct
208
+ // files, fold the chain into a single row ("src/keymap/keymap.ts" instead
209
+ // of three nested rows).
210
+ let current = child
211
+ const segments: string[] = [current.path.split('/').pop() ?? current.path]
212
+ while (compact && current.files.length === 0 && current.folders.size === 1) {
213
+ const next = current.folders.values().next().value
214
+ if (!next) break
215
+ segments.push(next.path.split('/').pop() ?? next.path)
216
+ current = next
217
+ }
218
+ const name = segments.join('/')
219
+ const key = gitFolderKey(node.section, current.path)
196
220
  const isCollapsed = key in collapsedFolders
197
221
  rows.push({
198
222
  depth,
199
- folderPath: child.path,
223
+ folderPath: current.path,
200
224
  isCollapsed,
201
225
  key,
202
226
  kind: 'folder',
@@ -204,7 +228,9 @@ function flattenTreeNode(
204
228
  parentKey,
205
229
  section: node.section,
206
230
  })
207
- if (!isCollapsed) rows.push(...flattenTreeNode(child, collapsedFolders, depth + 1, key))
231
+ if (!isCollapsed) {
232
+ rows.push(...flattenTreeNode(current, collapsedFolders, depth + 1, key, compact))
233
+ }
208
234
  }
209
235
  for (const file of node.files) {
210
236
  rows.push({
@@ -0,0 +1,64 @@
1
+ import type { AppState } from '../types'
2
+
3
+ export function clearDiffCacheForPath(state: AppState, path: string): AppState {
4
+ return clearDiffCacheForPaths(state, new Set([path]))
5
+ }
6
+
7
+ // Drops every cache slice derived from the given file paths: diffs, folds,
8
+ // loading flags, parsed patches, and per-theme highlight tokens. Called when
9
+ // a file's on-disk content shifts or a file disappears from git status.
10
+ export function clearDiffCacheForPaths(state: AppState, paths: Set<string>): AppState {
11
+ if (paths.size === 0) return state
12
+ const nextDiffs = { ...state.gitMode.diffs }
13
+ const nextFolds = { ...state.gitMode.folds }
14
+ const nextLoading = { ...state.gitMode.loading }
15
+ const nextParsed = { ...state.gitMode.parsedFiles }
16
+ const nextHighlights = { ...state.gitMode.highlights }
17
+ let changed = false
18
+ const keyMatches = (key: string): boolean => {
19
+ for (const path of paths) {
20
+ if (key.endsWith(`:${path}`) || key.startsWith(`${path}|`) || key.includes(`:${path}|`)) {
21
+ return true
22
+ }
23
+ }
24
+ return false
25
+ }
26
+ for (const key of Object.keys(nextDiffs)) {
27
+ const entry = nextDiffs[key]
28
+ if (!entry || !paths.has(entry.path)) continue
29
+ delete nextDiffs[key]
30
+ changed = true
31
+ }
32
+ for (const key of Object.keys(nextFolds)) {
33
+ if (!keyMatches(key)) continue
34
+ delete nextFolds[key]
35
+ changed = true
36
+ }
37
+ for (const key of Object.keys(nextLoading)) {
38
+ if (!keyMatches(key)) continue
39
+ delete nextLoading[key]
40
+ changed = true
41
+ }
42
+ for (const key of Object.keys(nextParsed)) {
43
+ if (!keyMatches(key)) continue
44
+ delete nextParsed[key]
45
+ changed = true
46
+ }
47
+ for (const key of Object.keys(nextHighlights)) {
48
+ if (!keyMatches(key)) continue
49
+ delete nextHighlights[key]
50
+ changed = true
51
+ }
52
+ if (!changed) return state
53
+ return {
54
+ ...state,
55
+ gitMode: {
56
+ ...state.gitMode,
57
+ diffs: nextDiffs,
58
+ folds: nextFolds,
59
+ highlights: nextHighlights,
60
+ loading: nextLoading,
61
+ parsedFiles: nextParsed,
62
+ },
63
+ }
64
+ }