@brimveyn/aimux 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +13 -3
  3. package/src/app.tsx +26 -17
  4. package/src/git/git-poller.ts +56 -3
  5. package/src/git/repo-discovery.ts +95 -0
  6. package/src/git/use-repo-discovery.ts +28 -0
  7. package/src/input/keymap/describe-bindings.ts +63 -2
  8. package/src/pty/terminal-snapshot.ts +7 -5
  9. package/src/state/reducers/git-mode-state.ts +58 -0
  10. package/src/state/reducers/git-panel-state.ts +3 -0
  11. package/src/state/reducers/multi-repo-state.ts +60 -0
  12. package/src/state/store.ts +6 -0
  13. package/src/state/types.ts +38 -6
  14. package/src/ui/breaking-update-screen.tsx +2 -2
  15. package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
  16. package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
  17. package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
  18. package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
  19. package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
  20. package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
  21. package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
  22. package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
  23. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
  24. package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
  25. package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
  26. package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
  27. package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
  28. package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
  29. package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
  30. package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
  31. package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
  32. package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
  33. package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
  34. package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
  35. package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
  36. package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
  37. package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
  38. package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
  39. package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
  40. package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
  41. package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
  42. package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
  43. package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
  44. package/src/ui/components/modals/shared/form.tsx +164 -0
  45. package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
  46. package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
  47. package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
  48. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
  49. package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
  50. package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
  51. package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
  52. package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
  53. package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
  54. package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
  55. package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
  56. package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
  57. package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
  58. package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
  59. package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
  60. package/src/ui/root.tsx +24 -22
  61. package/src/ui/theme-store.ts +30 -26
  62. package/src/ui/themes.ts +1 -0
  63. package/src/ui/components/create-session-modal.tsx +0 -96
  64. package/src/ui/components/diff-renderer/build-rows.ts +0 -349
  65. package/src/ui/components/diff-renderer/highlight.ts +0 -44
  66. package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
  67. package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
  68. package/src/ui/components/session-name-modal.tsx +0 -11
  69. package/src/ui/components/snippet-editor-modal.tsx +0 -40
  70. /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
  71. /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
  72. /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
  73. /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
@@ -1,8 +1,6 @@
1
1
  import type { ModeId } from '@brimveyn/aimux-config'
2
2
  import type { ThemedToken } from 'shiki'
3
3
 
4
- import type { FileDiffMetadata } from '../diff-parser'
5
-
6
4
  export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
7
5
 
8
6
  export type AssistantId = BuiltinAssistantId | (string & {})
@@ -190,6 +188,8 @@ export interface GitFileEntry {
190
188
  status: GitFileStatus
191
189
  added: number | null
192
190
  removed: number | null
191
+ /** Absolute path to the originating git repo. Set when the entry comes from a sub-repo. */
192
+ repoPath?: string
193
193
  }
194
194
 
195
195
  export type GitPanelError = 'not-a-repo' | 'unknown'
@@ -224,8 +224,7 @@ export interface FoldState {
224
224
 
225
225
  export interface ParsedDiffEntry {
226
226
  hash: string
227
- // Stored as unknown at the state boundary; concrete type is FileDiffMetadata
228
- // | null, narrowed at read sites via getParsedFile().
227
+ // Stored as unknown at the state boundary and narrowed at read sites.
229
228
  file: unknown
230
229
  }
231
230
 
@@ -237,9 +236,9 @@ export interface HighlightsEntry {
237
236
  del: unknown
238
237
  }
239
238
 
240
- export function getParsedFile(entry: ParsedDiffEntry | undefined): FileDiffMetadata | null {
239
+ export function getParsedPayload<T>(entry: ParsedDiffEntry | undefined): T | null {
241
240
  if (!entry) return null
242
- return entry.file as FileDiffMetadata | null
241
+ return entry.file as T | null
243
242
  }
244
243
 
245
244
  export function getHighlightsTokens(entry: HighlightsEntry | undefined): {
@@ -387,6 +386,24 @@ export interface SnippetRecord {
387
386
  content: string
388
387
  }
389
388
 
389
+ export interface DiscoveredRepo {
390
+ /** Absolute path to the repo. */
391
+ path: string
392
+ /** Label shown in UI (relative to the workspace root or repo basename). */
393
+ name: string
394
+ /** True when the repo is the session's projectPath itself. */
395
+ isRoot: boolean
396
+ }
397
+
398
+ export interface MultiRepoState {
399
+ /** Discovered sub-repos, ordered so root (if any) comes first. */
400
+ repos: DiscoveredRepo[]
401
+ /** Precomputed disambiguating prefix per repo path — empty string for the root repo. */
402
+ prefixes: Record<string, string>
403
+ }
404
+
405
+ export const EMPTY_MULTI_REPO_STATE: MultiRepoState = { prefixes: {}, repos: [] }
406
+
390
407
  export interface AppState {
391
408
  tabs: TabSession[]
392
409
  activeTabId: string | null
@@ -406,6 +423,7 @@ export interface AppState {
406
423
  gitPanel: GitPanelState
407
424
  gitMode: GitModeState
408
425
  autoCommit: AutoCommitState
426
+ multiRepo: MultiRepoState
409
427
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
410
428
  pendingChords: string[] | null
411
429
  }
@@ -608,6 +626,14 @@ export type GitModeAction =
608
626
  add: unknown
609
627
  del: unknown
610
628
  }
629
+ | {
630
+ type: 'git-mode-merge-highlights'
631
+ key: string
632
+ hash: string
633
+ themeId: string
634
+ add: Array<{ start: number; tokens: unknown }>
635
+ del: Array<{ start: number; tokens: unknown }>
636
+ }
611
637
  | { type: 'git-mode-invalidate-diffs'; paths: string[] }
612
638
  | { type: 'git-mode-set-loading'; key: string; loading: boolean }
613
639
  | { type: 'git-mode-set-pending-delete'; path: string | null }
@@ -643,6 +669,11 @@ export type DataAction =
643
669
  | { type: 'delete-snippet'; snippetId: string }
644
670
  | { type: 'set-custom-commands'; customCommands: Record<AssistantId, string> }
645
671
 
672
+ // -- Multi-repo actions --
673
+ export type MultiRepoAction =
674
+ | { type: 'multi-repo-set-repos'; repos: DiscoveredRepo[] }
675
+ | { type: 'multi-repo-clear' }
676
+
646
677
  export type AppAction =
647
678
  | ModalAction
648
679
  | SessionAction
@@ -653,3 +684,4 @@ export type AppAction =
653
684
  | GitPanelAction
654
685
  | GitModeAction
655
686
  | AutoCommitAction
687
+ | MultiRepoAction
@@ -1,7 +1,7 @@
1
1
  import { useKeyboard } from '@opentui/react'
2
2
 
3
- import { ListItem } from './components/list-item'
4
- import { ModalShell } from './components/modal-shell'
3
+ import { ModalShell } from './components/modals/shared/modal-shell'
4
+ import { ListItem } from './components/primitives/list-item'
5
5
  import { uiTokens } from './ui-tokens'
6
6
 
7
7
  interface BreakingUpdateScreenProps {
@@ -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
+ }