@brimveyn/aimux 1.5.4 → 1.6.1

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 (67) hide show
  1. package/README.md +14 -2
  2. package/package.json +5 -3
  3. package/src/app-runtime/side-effects.ts +41 -6
  4. package/src/app.tsx +17 -5
  5. package/src/config.ts +29 -4
  6. package/src/diff-parser/clean-last-newline.ts +5 -0
  7. package/src/diff-parser/constants.ts +13 -0
  8. package/src/diff-parser/index.ts +12 -0
  9. package/src/diff-parser/parse-line-type.ts +29 -0
  10. package/src/diff-parser/parse-patch-files.ts +369 -0
  11. package/src/diff-parser/types.ts +75 -0
  12. package/src/git/git-diff.ts +24 -12
  13. package/src/git/git-poller.ts +11 -3
  14. package/src/git/git-status.ts +98 -17
  15. package/src/input/modes/bridge.ts +8 -0
  16. package/src/input/modes/transitions.ts +2 -1
  17. package/src/input/modes/types.ts +4 -1
  18. package/src/pty/terminal-snapshot.ts +5 -5
  19. package/src/state/git-tree.ts +220 -0
  20. package/src/state/reducers/git-mode-state.ts +276 -36
  21. package/src/state/reducers/git-panel-state.ts +35 -4
  22. package/src/state/reducers/modal-state.ts +43 -10
  23. package/src/state/store.ts +5 -1
  24. package/src/state/types.ts +46 -6
  25. package/src/state/workspace-save.ts +2 -0
  26. package/src/ui/components/create-session-modal.tsx +25 -8
  27. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  28. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  29. package/src/ui/components/diff-renderer/fold-strip.tsx +86 -0
  30. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  31. package/src/ui/components/diff-renderer/index.ts +1 -0
  32. package/src/ui/components/diff-renderer/pierre-diff.tsx +171 -0
  33. package/src/ui/components/diff-renderer/split-view.tsx +211 -0
  34. package/src/ui/components/diff-renderer/stacked-view.tsx +168 -0
  35. package/src/ui/components/git-commit-modal.tsx +16 -3
  36. package/src/ui/components/git-pane-widget.tsx +6 -1
  37. package/src/ui/components/git-panel.tsx +215 -86
  38. package/src/ui/components/git-view.tsx +103 -90
  39. package/src/ui/components/help-modal.tsx +45 -12
  40. package/src/ui/components/input-field.tsx +4 -3
  41. package/src/ui/components/list-item.tsx +11 -2
  42. package/src/ui/components/modal-filter-bar.tsx +3 -2
  43. package/src/ui/components/modal-keybinds-overlay.tsx +4 -3
  44. package/src/ui/components/modal-shell.tsx +5 -4
  45. package/src/ui/components/new-tab-modal.tsx +17 -4
  46. package/src/ui/components/pending-chord-overlay.tsx +5 -4
  47. package/src/ui/components/session-bar.tsx +13 -6
  48. package/src/ui/components/session-picker-modal.tsx +28 -10
  49. package/src/ui/components/sidebar.tsx +26 -11
  50. package/src/ui/components/snippet-editor-modal.tsx +18 -3
  51. package/src/ui/components/snippet-picker-modal.tsx +13 -4
  52. package/src/ui/components/split-layout.tsx +3 -2
  53. package/src/ui/components/status-bar.tsx +15 -10
  54. package/src/ui/components/surface.tsx +6 -6
  55. package/src/ui/components/tab-item.tsx +28 -17
  56. package/src/ui/components/terminal-pane.tsx +28 -16
  57. package/src/ui/components/theme-picker-modal.tsx +113 -17
  58. package/src/ui/components/update-available-modal.tsx +13 -2
  59. package/src/ui/filter-themes.ts +15 -0
  60. package/src/ui/keymap-context.ts +10 -2
  61. package/src/ui/root.tsx +29 -7
  62. package/src/ui/shiki.ts +49 -0
  63. package/src/ui/status-bar-model.ts +32 -4
  64. package/src/ui/theme-store.ts +36 -0
  65. package/src/ui/theme.ts +4 -17
  66. package/src/ui/themes.ts +23 -277
  67. package/src/ui/syntax.ts +0 -102
@@ -0,0 +1,369 @@
1
+ // Vendored from @pierre/diffs v1.1.15 — parser only. See AIMUX-14.
2
+ /* eslint-disable eqeqeq, no-console, typescript/strict-boolean-expressions, no-else-return, unicorn/no-array-for-each */
3
+
4
+ import type {
5
+ ChangeContent,
6
+ ContextContent,
7
+ FileContents,
8
+ FileDiffMetadata,
9
+ HunkContent,
10
+ ParsedPatch,
11
+ } from './types'
12
+
13
+ import { cleanLastNewline } from './clean-last-newline'
14
+ import {
15
+ ALTERNATE_FILE_NAMES_GIT,
16
+ COMMIT_METADATA_SPLIT,
17
+ FILE_CONTEXT_BLOB,
18
+ FILENAME_HEADER_REGEX,
19
+ FILENAME_HEADER_REGEX_GIT,
20
+ GIT_DIFF_FILE_BREAK_REGEX,
21
+ HUNK_HEADER,
22
+ INDEX_LINE_METADATA,
23
+ SPLIT_WITH_NEWLINES,
24
+ UNIFIED_DIFF_FILE_BREAK_REGEX,
25
+ } from './constants'
26
+ import { parseLineType } from './parse-line-type'
27
+
28
+ interface ProcessFileOptions {
29
+ cacheKey?: string
30
+ isGitDiff?: boolean
31
+ oldFile?: FileContents
32
+ newFile?: FileContents
33
+ throwOnError?: boolean
34
+ }
35
+
36
+ function createContentGroup(
37
+ type: 'change' | 'context',
38
+ deletionLineIndex: number,
39
+ additionLineIndex: number
40
+ ): HunkContent {
41
+ if (type === 'change') {
42
+ return { additionLineIndex, additions: 0, deletionLineIndex, deletions: 0, type: 'change' }
43
+ }
44
+ return { additionLineIndex, deletionLineIndex, lines: 0, type: 'context' }
45
+ }
46
+
47
+ export function processFile(
48
+ fileDiffString: string,
49
+ {
50
+ cacheKey,
51
+ isGitDiff = GIT_DIFF_FILE_BREAK_REGEX.test(fileDiffString),
52
+ newFile,
53
+ oldFile,
54
+ throwOnError = false,
55
+ }: ProcessFileOptions = {}
56
+ ): FileDiffMetadata | undefined {
57
+ let lastHunkEnd = 0
58
+ const hunks = fileDiffString.split(FILE_CONTEXT_BLOB)
59
+ let currentFile: FileDiffMetadata | undefined
60
+ const isPartial = oldFile == null || newFile == null
61
+ let deletionLineIndex = 0
62
+ let additionLineIndex = 0
63
+ for (const hunk of hunks) {
64
+ const lines = hunk.split(SPLIT_WITH_NEWLINES)
65
+ const firstLine = lines.shift()
66
+ if (firstLine == null) {
67
+ if (throwOnError) throw Error('parsePatchContent: invalid hunk')
68
+ else console.error('parsePatchContent: invalid hunk', hunk)
69
+ continue
70
+ }
71
+ const fileHeaderMatch = firstLine.match(HUNK_HEADER)
72
+ let additionLines = 0
73
+ let deletionLines = 0
74
+ if (fileHeaderMatch == null || currentFile == null) {
75
+ if (currentFile != null) {
76
+ if (throwOnError) throw Error('parsePatchContent: Invalid hunk')
77
+ else console.error('parsePatchContent: Invalid hunk', hunk)
78
+ continue
79
+ }
80
+ currentFile = {
81
+ additionLines:
82
+ !isPartial && oldFile != null && newFile != null
83
+ ? newFile.contents.split(SPLIT_WITH_NEWLINES)
84
+ : [],
85
+ cacheKey,
86
+ deletionLines:
87
+ !isPartial && oldFile != null && newFile != null
88
+ ? oldFile.contents.split(SPLIT_WITH_NEWLINES)
89
+ : [],
90
+ hunks: [],
91
+ isPartial,
92
+ name: '',
93
+ splitLineCount: 0,
94
+ type: 'change',
95
+ unifiedLineCount: 0,
96
+ }
97
+ if (currentFile.additionLines.length === 1 && newFile?.contents === '') {
98
+ currentFile.additionLines.length = 0
99
+ }
100
+ if (currentFile.deletionLines.length === 1 && oldFile?.contents === '') {
101
+ currentFile.deletionLines.length = 0
102
+ }
103
+ lines.unshift(firstLine)
104
+ for (const line of lines) {
105
+ const filenameMatch = line.match(
106
+ isGitDiff ? FILENAME_HEADER_REGEX_GIT : FILENAME_HEADER_REGEX
107
+ )
108
+ if (line.startsWith('diff --git')) {
109
+ const match = line.trim().match(ALTERNATE_FILE_NAMES_GIT)
110
+ if (match) {
111
+ const prevName = match[1] ?? match[2]
112
+ const name = match[3] ?? match[4]
113
+ if (name != null) {
114
+ currentFile.name = name.trim()
115
+ if (prevName != null && prevName !== name) currentFile.prevName = prevName.trim()
116
+ }
117
+ }
118
+ } else if (filenameMatch != null) {
119
+ const [, type, fileName] = filenameMatch
120
+ if (fileName != null && fileName !== '/dev/null') {
121
+ if (type === '---') {
122
+ currentFile.prevName = fileName.trim()
123
+ currentFile.name = fileName.trim()
124
+ } else if (type === '+++') {
125
+ currentFile.name = fileName.trim()
126
+ }
127
+ }
128
+ } else if (isGitDiff) {
129
+ if (line.startsWith('new mode ')) currentFile.mode = line.replace('new mode', '').trim()
130
+ if (line.startsWith('old mode ')) {
131
+ currentFile.prevMode = line.replace('old mode', '').trim()
132
+ }
133
+ if (line.startsWith('new file mode')) {
134
+ currentFile.type = 'new'
135
+ currentFile.mode = line.replace('new file mode', '').trim()
136
+ }
137
+ if (line.startsWith('deleted file mode')) {
138
+ currentFile.type = 'deleted'
139
+ currentFile.mode = line.replace('deleted file mode', '').trim()
140
+ }
141
+ if (line.startsWith('similarity index')) {
142
+ if (line.startsWith('similarity index 100%')) currentFile.type = 'rename-pure'
143
+ else currentFile.type = 'rename-changed'
144
+ }
145
+ if (line.startsWith('index ')) {
146
+ const [, prevObjectId, newObjectId, mode] = line.trim().match(INDEX_LINE_METADATA) ?? []
147
+ if (prevObjectId != null) currentFile.prevObjectId = prevObjectId
148
+ if (newObjectId != null) currentFile.newObjectId = newObjectId
149
+ if (mode != null) currentFile.mode = mode
150
+ }
151
+ if (line.startsWith('rename from ')) {
152
+ currentFile.prevName = line.replace('rename from ', '').trim()
153
+ }
154
+ if (line.startsWith('rename to ')) {
155
+ currentFile.name = line.replace('rename to ', '').trim()
156
+ }
157
+ }
158
+ }
159
+ continue
160
+ }
161
+ let currentContent: HunkContent | undefined
162
+ let lastLineType: 'addition' | 'deletion' | 'context' | undefined
163
+ while (
164
+ lines.length > 0 &&
165
+ (lines[lines.length - 1] === '\n' ||
166
+ lines[lines.length - 1] === '\r' ||
167
+ lines[lines.length - 1] === '\r\n' ||
168
+ lines[lines.length - 1] === '')
169
+ ) {
170
+ lines.pop()
171
+ }
172
+ const additionStart = parseInt(fileHeaderMatch[3] ?? '')
173
+ const deletionStart = parseInt(fileHeaderMatch[1] ?? '')
174
+ deletionLineIndex = isPartial ? deletionLineIndex : deletionStart - 1
175
+ additionLineIndex = isPartial ? additionLineIndex : additionStart - 1
176
+ const hunkData = {
177
+ additionCount: parseInt(fileHeaderMatch[4] ?? '1'),
178
+ additionLineIndex,
179
+ additionLines,
180
+ additionStart,
181
+ collapsedBefore: 0,
182
+ deletionCount: parseInt(fileHeaderMatch[2] ?? '1'),
183
+ deletionLineIndex,
184
+ deletionLines,
185
+ deletionStart,
186
+ hunkContent: [] as HunkContent[],
187
+ hunkContext: fileHeaderMatch[5],
188
+ hunkSpecs: firstLine,
189
+ noEOFCRAdditions: false,
190
+ noEOFCRDeletions: false,
191
+ splitLineCount: 0,
192
+ splitLineStart: 0,
193
+ unifiedLineCount: 0,
194
+ unifiedLineStart: 0,
195
+ }
196
+ if (
197
+ isNaN(hunkData.additionCount) ||
198
+ isNaN(hunkData.deletionCount) ||
199
+ isNaN(hunkData.additionStart) ||
200
+ isNaN(hunkData.deletionStart)
201
+ ) {
202
+ if (throwOnError) throw Error('parsePatchContent: invalid hunk metadata')
203
+ else console.error('parsePatchContent: invalid hunk metadata', hunkData)
204
+ continue
205
+ }
206
+ for (const rawLine of lines) {
207
+ const parsedLine = parseLineType(rawLine)
208
+ if (parsedLine == null) {
209
+ console.error('processFile: invalid rawLine:', rawLine)
210
+ continue
211
+ }
212
+ const { line, type } = parsedLine
213
+ if (type === 'addition') {
214
+ if (currentContent == null || currentContent.type !== 'change') {
215
+ currentContent = createContentGroup('change', deletionLineIndex, additionLineIndex)
216
+ hunkData.hunkContent.push(currentContent)
217
+ }
218
+ additionLineIndex++
219
+ if (isPartial) currentFile.additionLines.push(line)
220
+ ;(currentContent as ChangeContent).additions++
221
+ additionLines++
222
+ lastLineType = 'addition'
223
+ } else if (type === 'deletion') {
224
+ if (currentContent == null || currentContent.type !== 'change') {
225
+ currentContent = createContentGroup('change', deletionLineIndex, additionLineIndex)
226
+ hunkData.hunkContent.push(currentContent)
227
+ }
228
+ deletionLineIndex++
229
+ if (isPartial) currentFile.deletionLines.push(line)
230
+ ;(currentContent as ChangeContent).deletions++
231
+ deletionLines++
232
+ lastLineType = 'deletion'
233
+ } else if (type === 'context') {
234
+ if (currentContent == null || currentContent.type !== 'context') {
235
+ currentContent = createContentGroup('context', deletionLineIndex, additionLineIndex)
236
+ hunkData.hunkContent.push(currentContent)
237
+ }
238
+ additionLineIndex++
239
+ deletionLineIndex++
240
+ if (isPartial) {
241
+ currentFile.deletionLines.push(line)
242
+ currentFile.additionLines.push(line)
243
+ }
244
+ ;(currentContent as ContextContent).lines++
245
+ lastLineType = 'context'
246
+ } else if (type === 'metadata' && currentContent != null) {
247
+ if (currentContent.type === 'context') {
248
+ hunkData.noEOFCRAdditions = true
249
+ hunkData.noEOFCRDeletions = true
250
+ } else if (lastLineType === 'deletion') hunkData.noEOFCRDeletions = true
251
+ else if (lastLineType === 'addition') hunkData.noEOFCRAdditions = true
252
+ if (isPartial && (lastLineType === 'addition' || lastLineType === 'context')) {
253
+ const lastIndex = currentFile.additionLines.length - 1
254
+ const last = currentFile.additionLines[lastIndex]
255
+ if (lastIndex >= 0 && last != null) {
256
+ currentFile.additionLines[lastIndex] = cleanLastNewline(last)
257
+ }
258
+ }
259
+ if (isPartial && (lastLineType === 'deletion' || lastLineType === 'context')) {
260
+ const lastIndex = currentFile.deletionLines.length - 1
261
+ const last = currentFile.deletionLines[lastIndex]
262
+ if (lastIndex >= 0 && last != null) {
263
+ currentFile.deletionLines[lastIndex] = cleanLastNewline(last)
264
+ }
265
+ }
266
+ }
267
+ }
268
+ hunkData.additionLines = additionLines
269
+ hunkData.deletionLines = deletionLines
270
+ hunkData.collapsedBefore = Math.max(hunkData.additionStart - 1 - lastHunkEnd, 0)
271
+ currentFile.hunks.push(hunkData)
272
+ lastHunkEnd = hunkData.additionStart + hunkData.additionCount - 1
273
+ for (const content of hunkData.hunkContent) {
274
+ if (content.type === 'context') {
275
+ hunkData.splitLineCount += content.lines
276
+ hunkData.unifiedLineCount += content.lines
277
+ } else {
278
+ hunkData.splitLineCount += Math.max(content.additions, content.deletions)
279
+ hunkData.unifiedLineCount += content.deletions + content.additions
280
+ }
281
+ }
282
+ hunkData.splitLineStart = currentFile.splitLineCount + hunkData.collapsedBefore
283
+ hunkData.unifiedLineStart = currentFile.unifiedLineCount + hunkData.collapsedBefore
284
+ currentFile.splitLineCount += hunkData.collapsedBefore + hunkData.splitLineCount
285
+ currentFile.unifiedLineCount += hunkData.collapsedBefore + hunkData.unifiedLineCount
286
+ }
287
+ if (currentFile == null) return
288
+ const lastHunk = currentFile.hunks[currentFile.hunks.length - 1]
289
+ if (
290
+ lastHunk != null &&
291
+ !isPartial &&
292
+ currentFile.additionLines.length > 0 &&
293
+ currentFile.deletionLines.length > 0
294
+ ) {
295
+ const lastHunkEnd$1 = lastHunk.additionStart + lastHunk.additionCount - 1
296
+ const totalFileLines = currentFile.additionLines.length
297
+ const collapsedAfter = Math.max(totalFileLines - lastHunkEnd$1, 0)
298
+ currentFile.splitLineCount += collapsedAfter
299
+ currentFile.unifiedLineCount += collapsedAfter
300
+ }
301
+ if (!isGitDiff) {
302
+ if (currentFile.prevName != null && currentFile.name !== currentFile.prevName) {
303
+ if (currentFile.hunks.length > 0) currentFile.type = 'rename-changed'
304
+ else currentFile.type = 'rename-pure'
305
+ } else if (newFile != null && newFile.contents === '') currentFile.type = 'deleted'
306
+ else if (oldFile != null && oldFile.contents === '') currentFile.type = 'new'
307
+ }
308
+ if (currentFile.type !== 'rename-pure' && currentFile.type !== 'rename-changed') {
309
+ currentFile.prevName = undefined
310
+ }
311
+ return currentFile
312
+ }
313
+
314
+ export function processPatch(
315
+ data: string,
316
+ cacheKeyPrefix?: string,
317
+ throwOnError = false
318
+ ): ParsedPatch {
319
+ const isGitDiff = GIT_DIFF_FILE_BREAK_REGEX.test(data)
320
+ const rawFiles = data.split(isGitDiff ? GIT_DIFF_FILE_BREAK_REGEX : UNIFIED_DIFF_FILE_BREAK_REGEX)
321
+ let patchMetadata: string | undefined
322
+ const files: FileDiffMetadata[] = []
323
+ for (const fileOrPatchMetadata of rawFiles) {
324
+ if (isGitDiff && !GIT_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) {
325
+ if (patchMetadata == null) patchMetadata = fileOrPatchMetadata
326
+ else if (throwOnError) throw Error('parsePatchContent: unknown file blob')
327
+ else console.error('parsePatchContent: unknown file blob:', fileOrPatchMetadata)
328
+ continue
329
+ } else if (!isGitDiff && !UNIFIED_DIFF_FILE_BREAK_REGEX.test(fileOrPatchMetadata)) {
330
+ if (patchMetadata == null) patchMetadata = fileOrPatchMetadata
331
+ else if (throwOnError) throw Error('parsePatchContent: unknown file blob')
332
+ else console.error('parsePatchContent: unknown file blob:', fileOrPatchMetadata)
333
+ continue
334
+ }
335
+ const currentFile = processFile(fileOrPatchMetadata, {
336
+ cacheKey: cacheKeyPrefix != null ? `${cacheKeyPrefix}-${files.length}` : undefined,
337
+ isGitDiff,
338
+ throwOnError,
339
+ })
340
+ if (currentFile != null) files.push(currentFile)
341
+ }
342
+ return { files, patchMetadata }
343
+ }
344
+
345
+ /**
346
+ * Parses a patch file string into an array of parsed patches.
347
+ */
348
+ export function parsePatchFiles(
349
+ data: string,
350
+ cacheKeyPrefix?: string,
351
+ throwOnError = false
352
+ ): ParsedPatch[] {
353
+ const patches: ParsedPatch[] = []
354
+ for (const patch of data.split(COMMIT_METADATA_SPLIT)) {
355
+ try {
356
+ patches.push(
357
+ processPatch(
358
+ patch,
359
+ cacheKeyPrefix != null ? `${cacheKeyPrefix}-${patches.length}` : undefined,
360
+ throwOnError
361
+ )
362
+ )
363
+ } catch (error) {
364
+ if (throwOnError) throw error
365
+ else console.error(error)
366
+ }
367
+ }
368
+ return patches
369
+ }
@@ -0,0 +1,75 @@
1
+ // Vendored from @pierre/diffs v1.1.15 — parser only. See AIMUX-14.
2
+ // Parser-only subset of the library's types. Renderer/shiki/hast types removed.
3
+
4
+ export type ChangeTypes = 'change' | 'rename-pure' | 'rename-changed' | 'new' | 'deleted'
5
+
6
+ export type HunkLineType = 'context' | 'expanded' | 'addition' | 'deletion' | 'metadata'
7
+
8
+ export interface ContextContent {
9
+ type: 'context'
10
+ lines: number
11
+ additionLineIndex: number
12
+ deletionLineIndex: number
13
+ }
14
+
15
+ export interface ChangeContent {
16
+ type: 'change'
17
+ deletions: number
18
+ deletionLineIndex: number
19
+ additions: number
20
+ additionLineIndex: number
21
+ }
22
+
23
+ export type HunkContent = ContextContent | ChangeContent
24
+
25
+ export interface Hunk {
26
+ collapsedBefore: number
27
+ additionStart: number
28
+ additionCount: number
29
+ additionLines: number
30
+ additionLineIndex: number
31
+ deletionStart: number
32
+ deletionCount: number
33
+ deletionLines: number
34
+ deletionLineIndex: number
35
+ hunkContent: HunkContent[]
36
+ hunkContext?: string
37
+ hunkSpecs?: string
38
+ splitLineStart: number
39
+ splitLineCount: number
40
+ unifiedLineStart: number
41
+ unifiedLineCount: number
42
+ noEOFCRDeletions: boolean
43
+ noEOFCRAdditions: boolean
44
+ }
45
+
46
+ export interface FileDiffMetadata {
47
+ name: string
48
+ prevName?: string
49
+ lang?: string
50
+ newObjectId?: string
51
+ prevObjectId?: string
52
+ mode?: string
53
+ prevMode?: string
54
+ type: ChangeTypes
55
+ hunks: Hunk[]
56
+ splitLineCount: number
57
+ unifiedLineCount: number
58
+ isPartial: boolean
59
+ deletionLines: string[]
60
+ additionLines: string[]
61
+ cacheKey?: string
62
+ }
63
+
64
+ export interface ParsedPatch {
65
+ patchMetadata?: string
66
+ files: FileDiffMetadata[]
67
+ }
68
+
69
+ export interface FileContents {
70
+ name: string
71
+ contents: string
72
+ lang?: string
73
+ header?: string
74
+ cacheKey?: string
75
+ }
@@ -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
  }