@brimveyn/aimux 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +19 -0
- package/src/app.tsx +4 -0
- package/src/config.ts +19 -1
- package/src/git/diff-hash.ts +10 -0
- package/src/git/git-diff.ts +24 -12
- package/src/git/git-poller.ts +11 -3
- package/src/git/git-status.ts +98 -17
- package/src/input/modes/types.ts +1 -0
- package/src/pty/terminal-snapshot.ts +5 -5
- package/src/state/git-tree.ts +43 -17
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +135 -34
- package/src/state/reducers/git-panel-state.ts +39 -3
- package/src/state/store.ts +2 -0
- package/src/state/types.ts +63 -2
- package/src/ui/components/create-session-modal.tsx +5 -4
- package/src/ui/components/diff-renderer/fold-strip.tsx +3 -1
- package/src/ui/components/diff-renderer/pierre-diff.tsx +11 -32
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +35 -6
- package/src/ui/components/diff-renderer/stacked-view.tsx +32 -5
- package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
- package/src/ui/components/git-commit-modal.tsx +2 -1
- package/src/ui/components/git-pane-widget.tsx +3 -1
- package/src/ui/components/git-panel.tsx +92 -50
- package/src/ui/components/git-view.tsx +34 -5
- package/src/ui/components/help-modal.tsx +2 -1
- package/src/ui/components/input-field.tsx +2 -1
- package/src/ui/components/list-item.tsx +2 -1
- package/src/ui/components/modal-filter-bar.tsx +2 -1
- package/src/ui/components/modal-keybinds-overlay.tsx +2 -1
- package/src/ui/components/modal-shell.tsx +2 -1
- package/src/ui/components/new-tab-modal.tsx +2 -1
- package/src/ui/components/pending-chord-overlay.tsx +2 -1
- package/src/ui/components/session-bar.tsx +3 -1
- package/src/ui/components/session-picker-modal.tsx +2 -1
- package/src/ui/components/sidebar.tsx +8 -5
- package/src/ui/components/snippet-editor-modal.tsx +2 -1
- package/src/ui/components/snippet-picker-modal.tsx +2 -1
- package/src/ui/components/split-layout.tsx +2 -1
- package/src/ui/components/status-bar.tsx +8 -6
- package/src/ui/components/surface.tsx +6 -6
- package/src/ui/components/tab-item.tsx +14 -9
- package/src/ui/components/terminal-pane.tsx +7 -5
- package/src/ui/components/theme-picker-modal.tsx +2 -1
- package/src/ui/components/update-available-modal.tsx +2 -1
- package/src/ui/filter-themes.ts +7 -2
- package/src/ui/root.tsx +3 -1
- package/src/ui/status-bar-model.ts +13 -3
- package/src/ui/theme-store.ts +36 -0
- package/src/ui/theme.ts +4 -29
- package/src/ui/themes.ts +15 -63
- package/src/ui/house-themes.ts +0 -313
- package/src/ui/themes.generated.ts +0 -59794
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.6.
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|
package/src/git/git-diff.ts
CHANGED
|
@@ -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'
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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,
|
package/src/git/git-poller.ts
CHANGED
|
@@ -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
|
}
|
package/src/git/git-status.ts
CHANGED
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/input/modes/types.ts
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
117
|
-
const resolvedBg = bg ??
|
|
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 ??
|
|
124
|
-
const resolvedBg = bg ??
|
|
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
|
|
package/src/state/git-tree.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
195
|
-
|
|
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:
|
|
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)
|
|
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
|
+
}
|