@brimveyn/aimux 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.6.1",
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.1",
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
+ }
@@ -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 }
@@ -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
+ }
@@ -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,
@@ -63,7 +37,9 @@ export function emptyGitMode(): GitModeState {
63
37
  diffView: 'split',
64
38
  folds: {},
65
39
  headOffset: 0,
40
+ highlights: {},
66
41
  loading: {},
42
+ parsedFiles: {},
67
43
  pendingDeletePath: null,
68
44
  selectedEntryKey: null,
69
45
  }
@@ -84,7 +60,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
84
60
  state.gitPanel.files,
85
61
  state.gitMode.collapsedFolders,
86
62
  state.gitPane.fileListMode,
87
- null
63
+ null,
64
+ [],
65
+ state.gitPane.treeCompaction
88
66
  ),
89
67
  },
90
68
  }
@@ -100,7 +78,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
100
78
  diffs: {},
101
79
  folds: {},
102
80
  headOffset: 0,
81
+ highlights: {},
103
82
  loading: {},
83
+ parsedFiles: {},
104
84
  },
105
85
  }
106
86
  }
@@ -110,7 +90,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
110
90
  state.gitMode.collapsedFolders,
111
91
  state.gitPane.fileListMode,
112
92
  state.gitMode.selectedEntryKey,
113
- action.delta
93
+ action.delta,
94
+ state.gitPane.treeCompaction
114
95
  )
115
96
  if (!next || next === state.gitMode.selectedEntryKey) return state
116
97
  return {
@@ -128,7 +109,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
128
109
  state.gitMode.collapsedFolders,
129
110
  state.gitPane.fileListMode,
130
111
  state.gitMode.selectedEntryKey,
131
- action.delta
112
+ action.delta,
113
+ state.gitPane.treeCompaction
132
114
  )
133
115
  if (!next || next === state.gitMode.selectedEntryKey) return state
134
116
  return {
@@ -146,7 +128,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
146
128
  state.gitMode.collapsedFolders,
147
129
  state.gitPane.fileListMode,
148
130
  state.gitMode.selectedEntryKey,
149
- [action.key]
131
+ [action.key],
132
+ state.gitPane.treeCompaction
150
133
  )
151
134
  if (!next || next !== action.key || next === state.gitMode.selectedEntryKey) return state
152
135
  return {
@@ -161,6 +144,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
161
144
  case 'git-mode-toggle-folder': {
162
145
  const row = getSelectedGitRow(state.gitPanel.files, {
163
146
  collapsedFolders: state.gitMode.collapsedFolders,
147
+ compact: state.gitPane.treeCompaction,
164
148
  fileListMode: state.gitPane.fileListMode,
165
149
  selectedEntryKey: action.key,
166
150
  })
@@ -176,6 +160,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
176
160
  case 'git-mode-toggle-selected-folder': {
177
161
  const row = getSelectedGitRow(state.gitPanel.files, {
178
162
  collapsedFolders: state.gitMode.collapsedFolders,
163
+ compact: state.gitPane.treeCompaction,
179
164
  fileListMode: state.gitPane.fileListMode,
180
165
  selectedEntryKey: state.gitMode.selectedEntryKey,
181
166
  })
@@ -191,6 +176,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
191
176
  case 'git-mode-collapse-selection': {
192
177
  const row = getSelectedGitRow(state.gitPanel.files, {
193
178
  collapsedFolders: state.gitMode.collapsedFolders,
179
+ compact: state.gitPane.treeCompaction,
194
180
  fileListMode: state.gitPane.fileListMode,
195
181
  selectedEntryKey: state.gitMode.selectedEntryKey,
196
182
  })
@@ -217,6 +203,7 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
217
203
  case 'git-mode-expand-selection': {
218
204
  const row = getSelectedGitRow(state.gitPanel.files, {
219
205
  collapsedFolders: state.gitMode.collapsedFolders,
206
+ compact: state.gitPane.treeCompaction,
220
207
  fileListMode: state.gitPane.fileListMode,
221
208
  selectedEntryKey: state.gitMode.selectedEntryKey,
222
209
  })
@@ -236,24 +223,90 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
236
223
  state.gitPanel.files,
237
224
  state.gitMode.collapsedFolders,
238
225
  fileListMode,
239
- state.gitMode.selectedEntryKey
226
+ state.gitMode.selectedEntryKey,
227
+ [],
228
+ state.gitPane.treeCompaction
240
229
  ),
241
230
  },
242
231
  gitPane: { ...state.gitPane, fileListMode },
243
232
  }
244
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
+ }
245
253
  case 'git-mode-set-diff': {
246
254
  const nextLoading = { ...state.gitMode.loading }
247
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
+ }
248
265
  return {
249
266
  ...state,
250
267
  gitMode: {
251
268
  ...state.gitMode,
252
269
  diffs: { ...state.gitMode.diffs, [action.key]: action.diff },
270
+ highlights: nextHighlights,
253
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
+ },
254
303
  },
255
304
  }
256
305
  }
306
+ case 'git-mode-invalidate-diffs': {
307
+ if (action.paths.length === 0) return state
308
+ return clearDiffCacheForPaths(state, new Set(action.paths))
309
+ }
257
310
  case 'git-mode-set-loading': {
258
311
  const nextLoading = { ...state.gitMode.loading }
259
312
  if (action.loading) {
@@ -289,7 +342,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
289
342
  diffs: {},
290
343
  folds: {},
291
344
  headOffset: next,
345
+ highlights: {},
292
346
  loading: {},
347
+ parsedFiles: {},
293
348
  pendingDeletePath: null,
294
349
  },
295
350
  }
@@ -304,7 +359,9 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
304
359
  diffs: {},
305
360
  folds: {},
306
361
  headOffset: next,
362
+ highlights: {},
307
363
  loading: {},
364
+ parsedFiles: {},
308
365
  pendingDeletePath: null,
309
366
  },
310
367
  }
@@ -357,7 +414,8 @@ export function reduceGitModeState(state: AppState, action: AppAction): AppState
357
414
  state.gitMode.collapsedFolders,
358
415
  state.gitPane.fileListMode,
359
416
  currentKey,
360
- preferredSelection
417
+ preferredSelection,
418
+ state.gitPane.treeCompaction
361
419
  )
362
420
 
363
421
  return {
@@ -1,6 +1,7 @@
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
@@ -35,6 +36,32 @@ export function emptyGitPanel(): GitPanelState {
35
36
  }
36
37
  }
37
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
+
38
65
  function sameFiles(a: GitFileEntry[], b: GitFileEntry[]): boolean {
39
66
  if (a.length !== b.length) return false
40
67
  for (let i = 0; i < a.length; i++) {
@@ -109,7 +136,9 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
109
136
  sortedNext,
110
137
  state.gitMode.collapsedFolders,
111
138
  state.gitPane.fileListMode,
112
- state.gitMode.selectedEntryKey
139
+ state.gitMode.selectedEntryKey,
140
+ [],
141
+ state.gitPane.treeCompaction
113
142
  )
114
143
  if (
115
144
  prev.branch === next.branch &&
@@ -121,7 +150,8 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
121
150
  ) {
122
151
  return state
123
152
  }
124
- return {
153
+ const changedPaths = diffChangedPaths(prev.files, sortedNext)
154
+ const base: AppState = {
125
155
  ...state,
126
156
  gitMode: { ...state.gitMode, selectedEntryKey: nextSelectedEntryKey },
127
157
  gitPanel: {
@@ -133,6 +163,7 @@ export function reduceGitPanelState(state: AppState, action: AppAction): AppStat
133
163
  files: sortedNext,
134
164
  },
135
165
  }
166
+ return changedPaths.size === 0 ? base : clearDiffCacheForPaths(base, changedPaths)
136
167
  }
137
168
  case 'git-refresh-error': {
138
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