@brimveyn/aimux 1.9.0 → 1.9.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/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +6 -1
  3. package/src/app.tsx +20 -16
  4. package/src/input/keymap/describe-bindings.ts +63 -2
  5. package/src/pty/terminal-snapshot.ts +7 -5
  6. package/src/state/reducers/git-mode-state.ts +58 -0
  7. package/src/state/types.ts +11 -6
  8. package/src/ui/breaking-update-screen.tsx +2 -2
  9. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  10. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  11. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  12. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  13. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  14. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  15. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +8 -17
  17. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  18. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  19. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +9 -5
  20. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
  21. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  22. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +4 -4
  23. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  24. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  25. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  26. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  27. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  28. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  29. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  30. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  31. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  32. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  33. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  34. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  35. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  36. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  37. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  38. package/src/ui/components/modals/shared/form.tsx +164 -0
  39. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  40. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  41. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  42. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  43. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  44. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  45. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  46. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  47. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  48. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  49. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  50. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  51. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  52. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  53. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  54. package/src/ui/root.tsx +24 -22
  55. package/src/ui/theme-store.ts +30 -26
  56. package/src/ui/themes.ts +1 -0
  57. package/src/ui/components/create-session-modal.tsx +0 -96
  58. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  59. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  60. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  61. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  62. package/src/ui/components/session-name-modal.tsx +0 -11
  63. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  64. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  65. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  66. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  67. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -0,0 +1,467 @@
1
+ import type { FileDiffMetadata, HunkContent } from '../../../../diff-parser'
2
+ import type { FoldState } from '../../../../state/types'
3
+
4
+ export const KEEP_CONTEXT = 3
5
+ export const MIN_FOLD_SIZE = 3
6
+ export const FOLD_STEP = 5
7
+ export const CONTEXT_SEGMENT_CHUNK_LINES = 64
8
+ export const CHANGE_SEGMENT_CHUNK_LINES = 64
9
+
10
+ export interface FoldInfo {
11
+ foldId: string
12
+ total: number
13
+ hidden: number
14
+ topExpanded: number
15
+ bottomExpanded: number
16
+ }
17
+
18
+ export type SplitCell =
19
+ | { content: string; lineIdx: number; lineNumber: number; type: 'context' }
20
+ | { content: string; lineIdx: number; lineNumber: number; type: 'addition' | 'deletion' }
21
+ | { fold: FoldInfo; type: 'fold' }
22
+ | { type: 'filler' }
23
+
24
+ export interface SplitRow {
25
+ type: 'row'
26
+ left: SplitCell
27
+ right: SplitCell
28
+ height: number
29
+ }
30
+
31
+ export type UnifiedRow =
32
+ | {
33
+ addLineNumber: number
34
+ content: string
35
+ lineIdx: number
36
+ delLineNumber: number
37
+ type: 'context'
38
+ height: number
39
+ }
40
+ | {
41
+ content: string
42
+ lineIdx: number
43
+ lineNumber: number
44
+ type: 'addition' | 'deletion'
45
+ height: number
46
+ }
47
+ | { fold: FoldInfo; type: 'fold' }
48
+
49
+ export interface HunkHeader {
50
+ context?: string
51
+ spec: string
52
+ type: 'hunk-header'
53
+ }
54
+
55
+ export type SplitRowOrHeader = HunkHeader | SplitRow
56
+ export type UnifiedRowOrHeader = HunkHeader | UnifiedRow
57
+
58
+ export type FoldMap = Record<string, FoldState>
59
+
60
+ interface ContextSlice {
61
+ leadingVisible: number
62
+ fold: { length: number; offset: number } | null
63
+ trailingVisible: number
64
+ }
65
+
66
+ interface SegmentBase {
67
+ estimatedHeight: number
68
+ id: string
69
+ offset: number
70
+ }
71
+
72
+ export interface HunkHeaderSegment extends SegmentBase {
73
+ kind: 'hunk-header'
74
+ context?: string
75
+ spec: string
76
+ }
77
+
78
+ export interface FoldSegment extends SegmentBase {
79
+ kind: 'fold'
80
+ fold: FoldInfo
81
+ }
82
+
83
+ export interface ContextSegment extends SegmentBase {
84
+ kind: 'context'
85
+ addLineNumberStart: number
86
+ addStart: number
87
+ count: number
88
+ delLineNumberStart: number
89
+ delStart: number
90
+ }
91
+
92
+ export interface ChangeSegment extends SegmentBase {
93
+ kind: 'change'
94
+ addLineNumberStart: number
95
+ addStart: number
96
+ additions: number
97
+ delLineNumberStart: number
98
+ delStart: number
99
+ deletions: number
100
+ }
101
+
102
+ export type DiffSegment = HunkHeaderSegment | FoldSegment | ContextSegment | ChangeSegment
103
+
104
+ export interface DiffSegmentBuild {
105
+ firstChangeOffset: number
106
+ segments: DiffSegment[]
107
+ }
108
+
109
+ function wrapCount(content: string, width: number): number {
110
+ if (width <= 0) return 1
111
+ return Math.max(1, Math.ceil(content.length / width))
112
+ }
113
+
114
+ function cellWraps(content: string, width: number): number {
115
+ return wrapCount(content, width)
116
+ }
117
+
118
+ function pairHeight(l: SplitCell, r: SplitCell, contentWidth: number): number {
119
+ const lh =
120
+ l.type === 'context' || l.type === 'addition' || l.type === 'deletion'
121
+ ? cellWraps(l.content, contentWidth)
122
+ : 1
123
+ const rh =
124
+ r.type === 'context' || r.type === 'addition' || r.type === 'deletion'
125
+ ? cellWraps(r.content, contentWidth)
126
+ : 1
127
+ return Math.max(lh, rh)
128
+ }
129
+
130
+ function sliceContext(
131
+ totalLines: number,
132
+ hasChangeBefore: boolean,
133
+ hasChangeAfter: boolean,
134
+ fold: FoldState
135
+ ): ContextSlice {
136
+ const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, totalLines) : 0
137
+ const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, totalLines - preKeep) : 0
138
+ const middle = totalLines - preKeep - postKeep
139
+ if (middle < MIN_FOLD_SIZE) {
140
+ return { fold: null, leadingVisible: totalLines, trailingVisible: 0 }
141
+ }
142
+ const top = Math.min(fold.top, middle)
143
+ const bottom = Math.min(fold.bottom, middle - top)
144
+ const hidden = middle - top - bottom
145
+ if (hidden <= 0) {
146
+ return { fold: null, leadingVisible: totalLines, trailingVisible: 0 }
147
+ }
148
+ return {
149
+ fold: { length: hidden, offset: preKeep + top },
150
+ leadingVisible: preKeep + top,
151
+ trailingVisible: postKeep + bottom,
152
+ }
153
+ }
154
+
155
+ function makeFoldInfo(foldId: string, middle: number, fold: FoldState, hidden: number): FoldInfo {
156
+ const topExpanded = Math.min(fold.top, middle)
157
+ const bottomExpanded = Math.min(fold.bottom, middle - topExpanded)
158
+ return { bottomExpanded, foldId, hidden, topExpanded, total: middle }
159
+ }
160
+
161
+ function contextNeighbors(
162
+ contents: HunkContent[],
163
+ index: number
164
+ ): { hasChangeBefore: boolean; hasChangeAfter: boolean } {
165
+ let hasChangeBefore = false
166
+ for (let i = index - 1; i >= 0; i--) {
167
+ const c = contents[i]
168
+ if (c && c.type !== 'context') {
169
+ hasChangeBefore = true
170
+ break
171
+ }
172
+ }
173
+ let hasChangeAfter = false
174
+ for (let i = index + 1; i < contents.length; i++) {
175
+ const c = contents[i]
176
+ if (c && c.type !== 'context') {
177
+ hasChangeAfter = true
178
+ break
179
+ }
180
+ }
181
+ return { hasChangeAfter, hasChangeBefore }
182
+ }
183
+
184
+ export function buildDiffSegments(file: FileDiffMetadata, folds: FoldMap = {}): DiffSegmentBuild {
185
+ const segments: DiffSegment[] = []
186
+ let offset = 0
187
+ let firstChangeOffset = -1
188
+ for (const [hIdx, hunk] of file.hunks.entries()) {
189
+ segments.push({
190
+ context: hunk.hunkContext,
191
+ estimatedHeight: 1,
192
+ id: `${hIdx}:header`,
193
+ kind: 'hunk-header',
194
+ offset,
195
+ spec: `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`,
196
+ })
197
+ offset += 1
198
+ let delLine = hunk.deletionStart
199
+ let addLine = hunk.additionStart
200
+ for (const [cIdx, content] of hunk.hunkContent.entries()) {
201
+ if (content.type === 'context') {
202
+ const foldId = `${hIdx}:${cIdx}`
203
+ const { hasChangeAfter, hasChangeBefore } = contextNeighbors(hunk.hunkContent, cIdx)
204
+ const foldState = folds[foldId] ?? { bottom: 0, top: 0 }
205
+ const slice = sliceContext(content.lines, hasChangeBefore, hasChangeAfter, foldState)
206
+ if (slice.leadingVisible > 0) {
207
+ let consumed = 0
208
+ while (consumed < slice.leadingVisible) {
209
+ const count = Math.min(CONTEXT_SEGMENT_CHUNK_LINES, slice.leadingVisible - consumed)
210
+ segments.push({
211
+ addLineNumberStart: addLine + consumed,
212
+ addStart: content.additionLineIndex + consumed,
213
+ count,
214
+ delLineNumberStart: delLine + consumed,
215
+ delStart: content.deletionLineIndex + consumed,
216
+ estimatedHeight: count,
217
+ id: `${hIdx}:${cIdx}:context:${consumed}:${count}`,
218
+ kind: 'context',
219
+ offset: offset + consumed,
220
+ })
221
+ consumed += count
222
+ }
223
+ addLine += slice.leadingVisible
224
+ delLine += slice.leadingVisible
225
+ offset += slice.leadingVisible
226
+ }
227
+ if (slice.fold) {
228
+ const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, content.lines) : 0
229
+ const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, content.lines - preKeep) : 0
230
+ const middle = content.lines - preKeep - postKeep
231
+ const info = makeFoldInfo(foldId, middle, foldState, slice.fold.length)
232
+ segments.push({
233
+ estimatedHeight: 1,
234
+ fold: info,
235
+ id: `${hIdx}:${cIdx}:fold`,
236
+ kind: 'fold',
237
+ offset,
238
+ })
239
+ addLine += slice.fold.length
240
+ delLine += slice.fold.length
241
+ offset += 1
242
+ if (slice.trailingVisible > 0) {
243
+ const start = slice.fold.offset + slice.fold.length
244
+ let consumed = 0
245
+ while (consumed < slice.trailingVisible) {
246
+ const count = Math.min(CONTEXT_SEGMENT_CHUNK_LINES, slice.trailingVisible - consumed)
247
+ segments.push({
248
+ addLineNumberStart: addLine + consumed,
249
+ addStart: content.additionLineIndex + start + consumed,
250
+ count,
251
+ delLineNumberStart: delLine + consumed,
252
+ delStart: content.deletionLineIndex + start + consumed,
253
+ estimatedHeight: count,
254
+ id: `${hIdx}:${cIdx}:context:${start + consumed}:${count}`,
255
+ kind: 'context',
256
+ offset: offset + consumed,
257
+ })
258
+ consumed += count
259
+ }
260
+ addLine += slice.trailingVisible
261
+ delLine += slice.trailingVisible
262
+ offset += slice.trailingVisible
263
+ }
264
+ }
265
+ } else {
266
+ const totalHeight = Math.max(content.additions, content.deletions)
267
+ if (firstChangeOffset < 0) firstChangeOffset = offset
268
+ let consumedHeight = 0
269
+ let chunkIdx = 0
270
+ while (consumedHeight < totalHeight) {
271
+ const take = Math.min(CHANGE_SEGMENT_CHUNK_LINES, totalHeight - consumedHeight)
272
+ const chunkAdditions = Math.min(take, Math.max(0, content.additions - consumedHeight))
273
+ const chunkDeletions = Math.min(take, Math.max(0, content.deletions - consumedHeight))
274
+ segments.push({
275
+ additions: chunkAdditions,
276
+ addLineNumberStart: addLine + consumedHeight,
277
+ addStart: content.additionLineIndex + consumedHeight,
278
+ deletions: chunkDeletions,
279
+ delLineNumberStart: delLine + consumedHeight,
280
+ delStart: content.deletionLineIndex + consumedHeight,
281
+ estimatedHeight: take,
282
+ id: `${hIdx}:${cIdx}:change:${chunkIdx}`,
283
+ kind: 'change',
284
+ offset: offset + consumedHeight,
285
+ })
286
+ consumedHeight += take
287
+ chunkIdx += 1
288
+ }
289
+ addLine += content.additions
290
+ delLine += content.deletions
291
+ offset += totalHeight
292
+ }
293
+ }
294
+ }
295
+ return { firstChangeOffset, segments }
296
+ }
297
+
298
+ export function expandSplitSegment(
299
+ file: FileDiffMetadata,
300
+ segment: DiffSegment,
301
+ contentWidth = 0
302
+ ): SplitRowOrHeader[] {
303
+ if (segment.kind === 'hunk-header') {
304
+ return [{ context: segment.context, spec: segment.spec, type: 'hunk-header' }]
305
+ }
306
+ if (segment.kind === 'fold') {
307
+ return [
308
+ {
309
+ height: 1,
310
+ left: { fold: segment.fold, type: 'fold' },
311
+ right: { fold: segment.fold, type: 'fold' },
312
+ type: 'row',
313
+ },
314
+ ]
315
+ }
316
+ if (segment.kind === 'context') {
317
+ const rows: SplitRowOrHeader[] = []
318
+ for (let i = 0; i < segment.count; i++) {
319
+ const addIdx = segment.addStart + i
320
+ const delIdx = segment.delStart + i
321
+ const text = stripNewline(file.additionLines[addIdx] ?? '')
322
+ const left: SplitCell = {
323
+ content: text,
324
+ lineIdx: delIdx,
325
+ lineNumber: segment.delLineNumberStart + i,
326
+ type: 'context',
327
+ }
328
+ const right: SplitCell = {
329
+ content: text,
330
+ lineIdx: addIdx,
331
+ lineNumber: segment.addLineNumberStart + i,
332
+ type: 'context',
333
+ }
334
+ rows.push({ height: pairHeight(left, right, contentWidth), left, right, type: 'row' })
335
+ }
336
+ return rows
337
+ }
338
+ const rows: SplitRowOrHeader[] = []
339
+ const max = Math.max(segment.additions, segment.deletions)
340
+ for (let i = 0; i < max; i++) {
341
+ const delIdx = segment.delStart + i
342
+ const addIdx = segment.addStart + i
343
+ const left: SplitCell =
344
+ i < segment.deletions
345
+ ? {
346
+ content: stripNewline(file.deletionLines[delIdx] ?? ''),
347
+ lineIdx: delIdx,
348
+ lineNumber: segment.delLineNumberStart + i,
349
+ type: 'deletion',
350
+ }
351
+ : { type: 'filler' }
352
+ const right: SplitCell =
353
+ i < segment.additions
354
+ ? {
355
+ content: stripNewline(file.additionLines[addIdx] ?? ''),
356
+ lineIdx: addIdx,
357
+ lineNumber: segment.addLineNumberStart + i,
358
+ type: 'addition',
359
+ }
360
+ : { type: 'filler' }
361
+ rows.push({ height: pairHeight(left, right, contentWidth), left, right, type: 'row' })
362
+ }
363
+ return rows
364
+ }
365
+
366
+ export function expandUnifiedSegment(
367
+ file: FileDiffMetadata,
368
+ segment: DiffSegment,
369
+ contentWidth = 0
370
+ ): UnifiedRowOrHeader[] {
371
+ if (segment.kind === 'hunk-header') {
372
+ return [{ context: segment.context, spec: segment.spec, type: 'hunk-header' }]
373
+ }
374
+ if (segment.kind === 'fold') return [{ fold: segment.fold, type: 'fold' }]
375
+ if (segment.kind === 'context') {
376
+ const rows: UnifiedRowOrHeader[] = []
377
+ for (let i = 0; i < segment.count; i++) {
378
+ const addIdx = segment.addStart + i
379
+ const text = stripNewline(file.additionLines[addIdx] ?? '')
380
+ rows.push({
381
+ addLineNumber: segment.addLineNumberStart + i,
382
+ content: text,
383
+ delLineNumber: segment.delLineNumberStart + i,
384
+ height: cellWraps(text, contentWidth),
385
+ lineIdx: addIdx,
386
+ type: 'context',
387
+ })
388
+ }
389
+ return rows
390
+ }
391
+ const rows: UnifiedRowOrHeader[] = []
392
+ for (let i = 0; i < segment.deletions; i++) {
393
+ const delIdx = segment.delStart + i
394
+ const text = stripNewline(file.deletionLines[delIdx] ?? '')
395
+ rows.push({
396
+ content: text,
397
+ height: cellWraps(text, contentWidth),
398
+ lineIdx: delIdx,
399
+ lineNumber: segment.delLineNumberStart + i,
400
+ type: 'deletion',
401
+ })
402
+ }
403
+ for (let i = 0; i < segment.additions; i++) {
404
+ const addIdx = segment.addStart + i
405
+ const text = stripNewline(file.additionLines[addIdx] ?? '')
406
+ rows.push({
407
+ content: text,
408
+ height: cellWraps(text, contentWidth),
409
+ lineIdx: addIdx,
410
+ lineNumber: segment.addLineNumberStart + i,
411
+ type: 'addition',
412
+ })
413
+ }
414
+ return rows
415
+ }
416
+
417
+ function rowHeight(row: SplitRowOrHeader | UnifiedRowOrHeader): number {
418
+ if (row.type === 'hunk-header') return 1
419
+ if (row.type === 'row') return row.height
420
+ if (row.type === 'fold') return 1
421
+ return row.height
422
+ }
423
+
424
+ function stripNewline(s: string): string {
425
+ if (s.endsWith('\r\n')) return s.slice(0, -2)
426
+ if (s.endsWith('\n')) return s.slice(0, -1)
427
+ return s
428
+ }
429
+
430
+ export function estimatedSegmentHeight(segment: DiffSegment, view: 'split' | 'stacked'): number {
431
+ if (segment.kind === 'hunk-header' || segment.kind === 'fold') return 1
432
+ if (segment.kind === 'context') return segment.count
433
+ return view === 'split'
434
+ ? Math.max(segment.additions, segment.deletions)
435
+ : segment.additions + segment.deletions
436
+ }
437
+
438
+ export function firstChangeSegmentOffset(
439
+ file: FileDiffMetadata,
440
+ folds: FoldMap,
441
+ contentWidth: number,
442
+ view: 'split' | 'stacked'
443
+ ): number {
444
+ const { segments } = buildDiffSegments(file, folds)
445
+ let offset = 0
446
+ for (const segment of segments) {
447
+ if (segment.kind === 'change') return offset
448
+ const rows =
449
+ view === 'split'
450
+ ? expandSplitSegment(file, segment, contentWidth)
451
+ : expandUnifiedSegment(file, segment, contentWidth)
452
+ for (const row of rows) offset += rowHeight(row)
453
+ }
454
+ return -1
455
+ }
456
+
457
+ export function gutterWidth(file: FileDiffMetadata): number {
458
+ let max = 1
459
+ for (const h of file.hunks) {
460
+ max = Math.max(
461
+ max,
462
+ h.deletionStart + h.deletionCount - 1,
463
+ h.additionStart + h.additionCount - 1
464
+ )
465
+ }
466
+ return Math.max(2, String(max).length)
467
+ }
@@ -1,4 +1,4 @@
1
- import { useBg, useTokens } from '../../theme'
1
+ import { useBg, useTokens } from '../../../theme'
2
2
  import { FOLD_STEP, type FoldInfo } from './build-rows'
3
3
  import { type FoldDispatch } from './pierre-diff'
4
4
 
@@ -0,0 +1,102 @@
1
+ import type { ThemedToken } from 'shiki'
2
+
3
+ import type { FileDiffMetadata } from '../../../../diff-parser'
4
+ import type { ChangeSegment, ContextSegment, DiffSegment } from './build-rows'
5
+
6
+ import { ensureActiveShikiTheme, ensureShikiLang, getShikiHighlighter } from '../../../shiki'
7
+
8
+ export interface HighlightSpan {
9
+ bold?: boolean
10
+ fg?: string
11
+ italic?: boolean
12
+ text: string
13
+ underline?: boolean
14
+ }
15
+
16
+ // Shiki FontStyle: 1 = italic, 2 = bold, 4 = underline.
17
+ export function tokenToSpan(token: ThemedToken): HighlightSpan {
18
+ const fs = token.fontStyle ?? 0
19
+ return {
20
+ bold: (fs & 2) !== 0 || undefined,
21
+ fg: token.color,
22
+ italic: (fs & 1) !== 0 || undefined,
23
+ text: token.content,
24
+ underline: (fs & 4) !== 0 || undefined,
25
+ }
26
+ }
27
+
28
+ export async function tokenizeSide(lines: string[], lang: string): Promise<ThemedToken[][]> {
29
+ if (lines.length === 0) return []
30
+ const highlighter = await getShikiHighlighter()
31
+ const [langOk, themeName] = await Promise.all([
32
+ ensureShikiLang(highlighter, lang),
33
+ ensureActiveShikiTheme(highlighter),
34
+ ])
35
+ if (!langOk) return []
36
+ try {
37
+ const result = highlighter.codeToTokens(lines.join(''), {
38
+ // eslint-disable-next-line typescript/no-explicit-any
39
+ lang: lang as any,
40
+ // eslint-disable-next-line typescript/no-explicit-any
41
+ theme: themeName as any,
42
+ })
43
+ return result.tokens
44
+ } catch {
45
+ return []
46
+ }
47
+ }
48
+
49
+ export interface HighlightPatch {
50
+ start: number
51
+ tokens: ThemedToken[][]
52
+ }
53
+
54
+ export interface SegmentHighlightPatches {
55
+ add: HighlightPatch[]
56
+ del: HighlightPatch[]
57
+ }
58
+
59
+ function range(lines: string[], start: number, count: number): string[] {
60
+ return count > 0 ? lines.slice(start, start + count) : []
61
+ }
62
+
63
+ async function tokenizeContextSegment(
64
+ file: FileDiffMetadata,
65
+ segment: ContextSegment,
66
+ lang: string
67
+ ): Promise<SegmentHighlightPatches> {
68
+ const tokens = await tokenizeSide(
69
+ range(file.additionLines, segment.addStart, segment.count),
70
+ lang
71
+ )
72
+ if (tokens.length === 0) return { add: [], del: [] }
73
+ return {
74
+ add: [{ start: segment.addStart, tokens }],
75
+ del: [{ start: segment.delStart, tokens }],
76
+ }
77
+ }
78
+
79
+ async function tokenizeChangeSegment(
80
+ file: FileDiffMetadata,
81
+ segment: ChangeSegment,
82
+ lang: string
83
+ ): Promise<SegmentHighlightPatches> {
84
+ const [add, del] = await Promise.all([
85
+ tokenizeSide(range(file.additionLines, segment.addStart, segment.additions), lang),
86
+ tokenizeSide(range(file.deletionLines, segment.delStart, segment.deletions), lang),
87
+ ])
88
+ return {
89
+ add: add.length > 0 ? [{ start: segment.addStart, tokens: add }] : [],
90
+ del: del.length > 0 ? [{ start: segment.delStart, tokens: del }] : [],
91
+ }
92
+ }
93
+
94
+ export async function tokenizeSegment(
95
+ file: FileDiffMetadata,
96
+ segment: DiffSegment,
97
+ lang: string
98
+ ): Promise<SegmentHighlightPatches> {
99
+ if (segment.kind === 'context') return tokenizeContextSegment(file, segment, lang)
100
+ if (segment.kind === 'change') return tokenizeChangeSegment(file, segment, lang)
101
+ return { add: [], del: [] }
102
+ }
@@ -3,13 +3,13 @@ import type { ThemedToken } from 'shiki'
3
3
 
4
4
  import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
5
5
 
6
- import type { ThemeId } from '../../themes'
6
+ import type { ThemeId } from '../../../themes'
7
7
 
8
- import { useAppStore } from '../../../state/app-store'
9
- import { dispatchGlobal } from '../../../state/dispatch-ref'
10
- import { type FoldState } from '../../../state/types'
11
- import { useTokens } from '../../theme'
12
- import { buildSplitRows, buildUnifiedRows, firstChangeRowOffset, gutterWidth } from './build-rows'
8
+ import { useAppStore } from '../../../../state/app-store'
9
+ import { dispatchGlobal } from '../../../../state/dispatch-ref'
10
+ import { type FoldState } from '../../../../state/types'
11
+ import { useTokens } from '../../../theme'
12
+ import { buildDiffSegments, firstChangeSegmentOffset, gutterWidth } from './build-rows'
13
13
  import { SplitView, type SplitViewHandle } from './split-view'
14
14
  import { StackedView, type StackedViewHandle } from './stacked-view'
15
15
  import { useDiffPreparation } from './use-diff-preparation'
@@ -62,6 +62,10 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
62
62
  }, [file, view, terminalCols, sidebarWidth])
63
63
 
64
64
  const folds = useAppStore((s) => s.gitMode.folds[cacheKey]) ?? EMPTY_FOLDS
65
+ const segments = useMemo(
66
+ () => (file ? buildDiffSegments(file, folds).segments : []),
67
+ [file, folds]
68
+ )
65
69
  const foldDispatch = useMemo<FoldDispatch>(
66
70
  () => ({
67
71
  adjust: (foldId, side, delta) =>
@@ -74,6 +78,7 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
74
78
 
75
79
  const splitRef = useRef<SplitViewHandle | null>(null)
76
80
  const stackedRef = useRef<StackedViewHandle | null>(null)
81
+ const autoScrollKeyRef = useRef<string | null>(null)
77
82
 
78
83
  useImperativeHandle(
79
84
  ref,
@@ -92,11 +97,10 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
92
97
 
93
98
  useEffect(() => {
94
99
  if (!file) return
95
- const rows =
96
- view === 'split'
97
- ? buildSplitRows(file, EMPTY_FOLDS, contentWidth)
98
- : buildUnifiedRows(file, EMPTY_FOLDS, contentWidth)
99
- const offset = firstChangeRowOffset(rows)
100
+ const autoScrollKey = `${cacheKey}:${view}`
101
+ if (autoScrollKeyRef.current === autoScrollKey) return
102
+ autoScrollKeyRef.current = autoScrollKey
103
+ const offset = firstChangeSegmentOffset(file, EMPTY_FOLDS, contentWidth, view)
100
104
  if (offset < 0) return
101
105
  const target = Math.max(0, offset - 2)
102
106
  const apply = (): void => {
@@ -110,9 +114,11 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
110
114
  if (node) node.scrollTop = target
111
115
  }
112
116
  }
113
- const raf = requestAnimationFrame(apply)
117
+ let raf = requestAnimationFrame(() => {
118
+ raf = requestAnimationFrame(apply)
119
+ })
114
120
  return () => cancelAnimationFrame(raf)
115
- }, [cacheKey, file, view, contentWidth])
121
+ }, [cacheKey, contentWidth, file, view])
116
122
 
117
123
  if (!file) {
118
124
  return (
@@ -131,8 +137,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
131
137
  contentWidth={contentWidth}
132
138
  file={file}
133
139
  foldDispatch={foldDispatch}
134
- folds={folds}
135
140
  highlights={highlights}
141
+ requestSegmentHighlights={preparation.requestSegmentHighlights}
142
+ segments={segments}
136
143
  />
137
144
  )
138
145
  }
@@ -142,8 +149,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
142
149
  contentWidth={contentWidth}
143
150
  file={file}
144
151
  foldDispatch={foldDispatch}
145
- folds={folds}
146
152
  highlights={highlights}
153
+ requestSegmentHighlights={preparation.requestSegmentHighlights}
154
+ segments={segments}
147
155
  />
148
156
  )
149
157
  })