@brimveyn/aimux 1.5.4 → 1.6.0

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 (65) 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/input/modes/bridge.ts +8 -0
  13. package/src/input/modes/transitions.ts +2 -1
  14. package/src/input/modes/types.ts +4 -1
  15. package/src/pty/terminal-snapshot.ts +4 -4
  16. package/src/state/git-tree.ts +220 -0
  17. package/src/state/reducers/git-mode-state.ts +232 -35
  18. package/src/state/reducers/git-panel-state.ts +29 -3
  19. package/src/state/reducers/modal-state.ts +43 -10
  20. package/src/state/store.ts +5 -1
  21. package/src/state/types.ts +41 -5
  22. package/src/state/workspace-save.ts +2 -0
  23. package/src/ui/components/create-session-modal.tsx +23 -7
  24. package/src/ui/components/diff-renderer/build-rows.ts +349 -0
  25. package/src/ui/components/diff-renderer/filetype.ts +29 -0
  26. package/src/ui/components/diff-renderer/fold-strip.tsx +84 -0
  27. package/src/ui/components/diff-renderer/highlight.ts +48 -0
  28. package/src/ui/components/diff-renderer/index.ts +1 -0
  29. package/src/ui/components/diff-renderer/pierre-diff.tsx +170 -0
  30. package/src/ui/components/diff-renderer/split-view.tsx +207 -0
  31. package/src/ui/components/diff-renderer/stacked-view.tsx +166 -0
  32. package/src/ui/components/git-commit-modal.tsx +14 -2
  33. package/src/ui/components/git-pane-widget.tsx +5 -0
  34. package/src/ui/components/git-panel.tsx +176 -79
  35. package/src/ui/components/git-view.tsx +89 -87
  36. package/src/ui/components/help-modal.tsx +43 -11
  37. package/src/ui/components/input-field.tsx +2 -2
  38. package/src/ui/components/list-item.tsx +9 -1
  39. package/src/ui/components/modal-filter-bar.tsx +1 -1
  40. package/src/ui/components/modal-keybinds-overlay.tsx +2 -2
  41. package/src/ui/components/modal-shell.tsx +3 -3
  42. package/src/ui/components/new-tab-modal.tsx +15 -3
  43. package/src/ui/components/pending-chord-overlay.tsx +3 -3
  44. package/src/ui/components/session-bar.tsx +10 -5
  45. package/src/ui/components/session-picker-modal.tsx +26 -9
  46. package/src/ui/components/sidebar.tsx +22 -10
  47. package/src/ui/components/snippet-editor-modal.tsx +16 -2
  48. package/src/ui/components/snippet-picker-modal.tsx +11 -3
  49. package/src/ui/components/split-layout.tsx +1 -1
  50. package/src/ui/components/status-bar.tsx +12 -9
  51. package/src/ui/components/surface.tsx +5 -5
  52. package/src/ui/components/tab-item.tsx +22 -16
  53. package/src/ui/components/terminal-pane.tsx +25 -15
  54. package/src/ui/components/theme-picker-modal.tsx +111 -16
  55. package/src/ui/components/update-available-modal.tsx +11 -1
  56. package/src/ui/filter-themes.ts +10 -0
  57. package/src/ui/house-themes.ts +313 -0
  58. package/src/ui/keymap-context.ts +10 -2
  59. package/src/ui/root.tsx +26 -6
  60. package/src/ui/shiki.ts +49 -0
  61. package/src/ui/status-bar-model.ts +19 -1
  62. package/src/ui/theme.ts +22 -10
  63. package/src/ui/themes.generated.ts +59794 -0
  64. package/src/ui/themes.ts +68 -274
  65. package/src/ui/syntax.ts +0 -102
@@ -5,11 +5,9 @@ import type { AppAction, AppState } from '../types'
5
5
  import { collectHelpEntries } from '../../input/keymap/help-entries'
6
6
  import { getActiveKeymap } from '../../input/keymap/keymap-ref'
7
7
  import { getAllAssistantOptions } from '../../pty/command-registry'
8
- import { THEME_IDS } from '../../ui/themes'
8
+ import { filterThemeIds } from '../../ui/filter-themes'
9
9
  import { filterSessions, filterSnippets } from '../selectors'
10
10
 
11
- const THEME_COUNT = THEME_IDS.length
12
-
13
11
  function emptyModal() {
14
12
  return {
15
13
  cursorPos: 0,
@@ -42,14 +40,16 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
42
40
  }
43
41
  case 'open-help-modal': {
44
42
  const keymap = getActiveKeymap()
45
- const entryCount = keymap ? collectHelpEntries(keymap).length : 0
43
+ const scope = action.scope ?? null
44
+ const allEntries = keymap ? collectHelpEntries(keymap) : []
45
+ const scopedEntries = scope ? allEntries.filter((e) => e.mode === scope) : allEntries
46
46
  return {
47
47
  ...state,
48
- focusMode: 'modal',
49
48
  modal: {
50
49
  cursorPos: 0,
51
50
  editBuffer: null,
52
- entryCount,
51
+ entryCount: scopedEntries.length,
52
+ scope,
53
53
  selectedIndex: 0,
54
54
  sessionTargetId: null,
55
55
  type: 'help',
@@ -162,6 +162,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
162
162
  modal: {
163
163
  cursorPos: 0,
164
164
  editBuffer: null,
165
+ entryCount: 0,
165
166
  selectedIndex: 0,
166
167
  sessionTargetId: null,
167
168
  type: 'theme-picker',
@@ -225,6 +226,27 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
225
226
  return state
226
227
  }
227
228
  const buf = state.modal.editBuffer ?? ''
229
+ return {
230
+ ...state,
231
+ modal: { ...state.modal, cursorPos: buf.length, editBuffer: buf },
232
+ }
233
+ }
234
+ case 'set-theme-entry-count': {
235
+ if (state.modal.type !== 'theme-picker') return state
236
+ const clamped = Math.min(state.modal.selectedIndex, Math.max(0, action.count - 1))
237
+ if (state.modal.entryCount === action.count && clamped === state.modal.selectedIndex) {
238
+ return state
239
+ }
240
+ return {
241
+ ...state,
242
+ modal: { ...state.modal, entryCount: action.count, selectedIndex: clamped },
243
+ }
244
+ }
245
+ case 'begin-theme-filter': {
246
+ if (state.modal.type !== 'theme-picker') {
247
+ return state
248
+ }
249
+ const buf = state.modal.editBuffer ?? ''
228
250
  return {
229
251
  ...state,
230
252
  focusMode: 'command-edit',
@@ -232,8 +254,12 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
232
254
  }
233
255
  }
234
256
  case 'close-modal': {
235
- const nextFocus: AppState['focusMode'] =
236
- state.modal.type === 'git-commit' ? 'git' : 'navigation'
257
+ const closingType = state.modal.type
258
+ // Help is a pure overlay — it never flipped focusMode, so leave it alone.
259
+ if (closingType === 'help') {
260
+ return { ...state, modal: emptyModal() }
261
+ }
262
+ const nextFocus: AppState['focusMode'] = closingType === 'git-commit' ? 'git' : 'navigation'
237
263
  return { ...state, focusMode: nextFocus, modal: emptyModal() }
238
264
  }
239
265
  case 'move-modal-selection': {
@@ -268,7 +294,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
268
294
  const filtered = filterSnippets(state.snippets, state.modal.editBuffer)
269
295
  optionCount = filtered.length
270
296
  } else if (state.modal.type === 'theme-picker') {
271
- optionCount = THEME_COUNT
297
+ optionCount = filterThemeIds(state.modal.editBuffer).length
272
298
  } else if (state.modal.type === 'update-available') {
273
299
  optionCount = 2
274
300
  } else {
@@ -334,6 +360,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
334
360
  const resetIndex =
335
361
  state.modal.type === 'session-picker' ||
336
362
  state.modal.type === 'snippet-picker' ||
363
+ state.modal.type === 'theme-picker' ||
337
364
  state.modal.type === 'help'
338
365
  ? 0
339
366
  : state.modal.selectedIndex
@@ -401,10 +428,16 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
401
428
  if (state.modal.type === 'create-session' || state.modal.type === 'snippet-editor') {
402
429
  return { ...state, focusMode: 'navigation', modal: emptyModal() }
403
430
  }
431
+ if (state.modal.type === 'help') {
432
+ return {
433
+ ...state,
434
+ modal: { ...state.modal, cursorPos: 0, editBuffer: null, selectedIndex: 0 },
435
+ }
436
+ }
404
437
  if (
405
438
  state.modal.type === 'session-picker' ||
406
439
  state.modal.type === 'snippet-picker' ||
407
- state.modal.type === 'help'
440
+ state.modal.type === 'theme-picker'
408
441
  ) {
409
442
  return {
410
443
  ...state,
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AppAction,
3
3
  AppState,
4
+ GitModeState,
4
5
  GitPaneMode,
5
6
  GitPanePosition,
6
7
  GitPaneState,
@@ -24,6 +25,7 @@ const DEFAULT_TERMINAL_COLS = 80
24
25
  const DEFAULT_TERMINAL_ROWS = 24
25
26
 
26
27
  export interface InitialStateOverrides {
28
+ gitMode?: Partial<GitModeState>
27
29
  gitPane?: Partial<GitPaneState>
28
30
  sessionBarVisible?: boolean
29
31
  sessionBarPosition?: SessionBarPosition
@@ -31,6 +33,8 @@ export interface InitialStateOverrides {
31
33
 
32
34
  const DEFAULT_GIT_PANE: GitPaneState = {
33
35
  diffCount: { enabled: true },
36
+ diffModeRatio: 0.35,
37
+ fileListMode: 'tree',
34
38
  mode: 'embedded',
35
39
  path: { enabled: true },
36
40
  position: 'bottom',
@@ -62,7 +66,7 @@ export function createInitialState(
62
66
  currentSessionId: null,
63
67
  customCommands,
64
68
  focusMode: showSessionPicker ? 'modal' : 'navigation',
65
- gitMode: emptyGitMode(),
69
+ gitMode: { ...emptyGitMode(), ...overrides.gitMode },
66
70
  gitPane: {
67
71
  ...DEFAULT_GIT_PANE,
68
72
  ...overrides.gitPane,
@@ -1,3 +1,5 @@
1
+ import type { ModeId } from '@brimveyn/aimux-config'
2
+
1
3
  export type BuiltinAssistantId = 'claude' | 'codex' | 'opencode' | 'terminal'
2
4
 
3
5
  export type AssistantId = BuiltinAssistantId | (string & {})
@@ -144,6 +146,8 @@ export interface GitPaneState {
144
146
  mode: GitPaneMode
145
147
  position: GitPanePosition
146
148
  ratio: number
149
+ diffModeRatio: number
150
+ fileListMode: GitFileListMode
147
151
  path: GitPanePathConfig
148
152
  diffCount: GitPaneDiffCountConfig
149
153
  }
@@ -183,12 +187,23 @@ export interface DiffData {
183
187
  errorMessage?: string
184
188
  }
185
189
 
190
+ export type GitDiffView = 'split' | 'stacked'
191
+ export type GitFileListMode = 'tree' | 'flat'
192
+
193
+ export interface FoldState {
194
+ top: number
195
+ bottom: number
196
+ }
197
+
186
198
  export interface GitModeState {
187
- selectedFileIndex: number
199
+ selectedEntryKey: string | null
200
+ collapsedFolders: Record<string, true>
188
201
  diffs: Record<string, DiffData>
189
202
  loading: Record<string, boolean>
190
203
  pendingDeletePath: string | null
191
204
  actionMessage: string | null
205
+ diffView: GitDiffView
206
+ folds: Record<string, Record<string, FoldState>>
192
207
  }
193
208
 
194
209
  interface ModalBase {
@@ -226,11 +241,13 @@ export interface ModalSnippetPicker extends ModalBase {
226
241
 
227
242
  export interface ModalThemePicker extends ModalBase {
228
243
  type: 'theme-picker'
244
+ entryCount: number
229
245
  }
230
246
 
231
247
  export interface ModalHelp extends ModalBase {
232
248
  type: 'help'
233
249
  entryCount: number
250
+ scope: ModeId | null
234
251
  }
235
252
 
236
253
  export interface ModalSplitPicker extends ModalBase {
@@ -323,7 +340,7 @@ export interface AppState {
323
340
  export type ModalAction =
324
341
  | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
325
342
  | { type: 'open-new-tab-modal' }
326
- | { type: 'open-help-modal' }
343
+ | { type: 'open-help-modal'; scope?: ModeId }
327
344
  | { type: 'open-split-picker'; direction: import('./layout-tree').SplitDirection }
328
345
  | { type: 'open-session-picker' }
329
346
  | { type: 'open-session-name-modal'; sessionTargetId?: string; initialName?: string }
@@ -343,7 +360,9 @@ export type ModalAction =
343
360
  | { type: 'open-snippet-editor'; snippetId?: string }
344
361
  | { type: 'begin-snippet-filter' }
345
362
  | { type: 'begin-help-filter' }
363
+ | { type: 'begin-theme-filter' }
346
364
  | { type: 'set-help-entry-count'; count: number }
365
+ | { type: 'set-theme-entry-count'; count: number }
347
366
  | { type: 'open-theme-picker' }
348
367
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
349
368
 
@@ -421,6 +440,7 @@ export type UIAction =
421
440
  | { type: 'set-terminal-size'; cols: number; rows: number }
422
441
  | { type: 'toggle-git-pane' }
423
442
  | { type: 'resize-git-pane'; delta: number }
443
+ | { type: 'resize-git-diff-pane'; delta: number }
424
444
  | { type: 'set-git-pane-mode'; mode: GitPaneMode }
425
445
  | { type: 'set-git-pane-position'; position: GitPanePosition }
426
446
  | { type: 'set-pending-chords'; chords: string[] | null }
@@ -443,12 +463,28 @@ export type GitPanelAction =
443
463
  export type GitModeAction =
444
464
  | { type: 'enter-git-mode' }
445
465
  | { type: 'exit-git-mode' }
446
- | { type: 'git-mode-select-file'; delta: -1 | 1 }
447
- | { type: 'git-mode-set-diff'; path: string; diff: DiffData }
448
- | { type: 'git-mode-set-loading'; path: string; loading: boolean }
466
+ | { type: 'git-mode-move-selection'; delta: -1 | 1 }
467
+ | { type: 'git-mode-move-file-selection'; delta: -1 | 1 }
468
+ | { type: 'git-mode-select-entry-by-key'; key: string }
469
+ | { type: 'git-mode-toggle-folder'; key: string }
470
+ | { type: 'git-mode-toggle-selected-folder' }
471
+ | { type: 'git-mode-collapse-selection' }
472
+ | { type: 'git-mode-expand-selection' }
473
+ | { type: 'git-mode-toggle-file-list-mode' }
474
+ | { type: 'git-mode-set-diff'; key: string; diff: DiffData }
475
+ | { type: 'git-mode-set-loading'; key: string; loading: boolean }
449
476
  | { type: 'git-mode-set-pending-delete'; path: string | null }
450
477
  | { type: 'git-mode-clear-diff-cache'; path: string }
451
478
  | { type: 'git-mode-set-message'; message: string | null }
479
+ | { type: 'git-mode-toggle-diff-view' }
480
+ | {
481
+ type: 'git-mode-fold-adjust'
482
+ key: string
483
+ foldId: string
484
+ side: 'top' | 'bottom'
485
+ delta: number
486
+ }
487
+ | { type: 'git-mode-fold-set'; key: string; foldId: string; top: number; bottom: number }
452
488
  | {
453
489
  type: 'git-mode-optimistic-move'
454
490
  path: string
@@ -25,6 +25,8 @@ export function saveCurrentWorkspace(state: AppState): void {
25
25
  ...loadConfig(),
26
26
  customCommands: state.customCommands,
27
27
  gitPane: {
28
+ diffModeRatio: state.gitPane.diffModeRatio,
29
+ fileListMode: state.gitPane.fileListMode,
28
30
  mode: state.gitPane.mode,
29
31
  position: state.gitPane.position,
30
32
  ratio: state.gitPane.ratio,
@@ -21,14 +21,14 @@ function getDirectoryResultIcon(result: DirectoryResult): string {
21
21
 
22
22
  function getDirectoryResultColor(result: DirectoryResult): string {
23
23
  if (result.type === 'worktree') {
24
- return theme.warning
24
+ return theme.colors['editorWarning.foreground']
25
25
  }
26
26
 
27
27
  if (result.type === 'workspace') {
28
- return theme.accentAlt
28
+ return theme.colors['terminal.ansiMagenta']
29
29
  }
30
30
 
31
- return theme.accent
31
+ return theme.colors['textLink.foreground']
32
32
  }
33
33
 
34
34
  interface CreateSessionModalProps {
@@ -58,7 +58,11 @@ export function CreateSessionModal({
58
58
  width={uiTokens.modalWidth.xl}
59
59
  >
60
60
  <box flexDirection="column">
61
- <text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
61
+ <text
62
+ fg={dirActive ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
63
+ >
64
+ Search projects
65
+ </text>
62
66
  <InputField
63
67
  active={dirActive}
64
68
  value={
@@ -68,7 +72,7 @@ export function CreateSessionModal({
68
72
  </box>
69
73
 
70
74
  {dirActive && results.length === 0 && directoryQuery.length > 0 ? (
71
- <text fg={theme.textMuted}>No matches</text>
75
+ <text fg={theme.colors['descriptionForeground']}>No matches</text>
72
76
  ) : null}
73
77
 
74
78
  {dirActive
@@ -82,7 +86,13 @@ export function CreateSessionModal({
82
86
  <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>
83
87
  }
84
88
  title={
85
- <text fg={active ? theme.text : theme.textMuted}>
89
+ <text
90
+ fg={
91
+ active
92
+ ? theme.colors['editor.foreground']
93
+ : theme.colors['descriptionForeground']
94
+ }
95
+ >
86
96
  {abbreviatePath(result.path)}
87
97
  </text>
88
98
  }
@@ -92,7 +102,13 @@ export function CreateSessionModal({
92
102
  : null}
93
103
 
94
104
  <box flexDirection="column">
95
- <text fg={nameActive ? theme.text : theme.textMuted}>Session name</text>
105
+ <text
106
+ fg={
107
+ nameActive ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
108
+ }
109
+ >
110
+ Session name
111
+ </text>
96
112
  <InputField active={nameActive} value={sessionName} />
97
113
  </box>
98
114
  </ModalShell>
@@ -0,0 +1,349 @@
1
+ import type { FileDiffMetadata, Hunk, 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
+
8
+ export interface FoldInfo {
9
+ foldId: string
10
+ total: number
11
+ hidden: number
12
+ topExpanded: number
13
+ bottomExpanded: number
14
+ }
15
+
16
+ export type SplitCell =
17
+ | { content: string; lineIdx: number; lineNumber: number; type: 'context' }
18
+ | { content: string; lineIdx: number; lineNumber: number; type: 'addition' | 'deletion' }
19
+ | { fold: FoldInfo; type: 'fold' }
20
+ | { type: 'filler' }
21
+
22
+ export interface SplitRow {
23
+ type: 'row'
24
+ left: SplitCell
25
+ right: SplitCell
26
+ height: number
27
+ }
28
+
29
+ export type UnifiedRow =
30
+ | {
31
+ addLineNumber: number
32
+ content: string
33
+ lineIdx: number
34
+ delLineNumber: number
35
+ type: 'context'
36
+ height: number
37
+ }
38
+ | {
39
+ content: string
40
+ lineIdx: number
41
+ lineNumber: number
42
+ type: 'addition' | 'deletion'
43
+ height: number
44
+ }
45
+ | { fold: FoldInfo; type: 'fold' }
46
+
47
+ export interface HunkHeader {
48
+ context?: string
49
+ spec: string
50
+ type: 'hunk-header'
51
+ }
52
+
53
+ export type SplitRowOrHeader = HunkHeader | SplitRow
54
+ export type UnifiedRowOrHeader = HunkHeader | UnifiedRow
55
+
56
+ export type FoldMap = Record<string, FoldState>
57
+
58
+ function wrapCount(content: string, width: number): number {
59
+ if (width <= 0) return 1
60
+ return Math.max(1, Math.ceil(content.length / width))
61
+ }
62
+
63
+ function cellWraps(content: string, width: number): number {
64
+ return wrapCount(content, width)
65
+ }
66
+
67
+ interface ContextSlice {
68
+ // which file-line indices (relative to content start) to show, and which to fold
69
+ leadingVisible: number
70
+ fold: { length: number; offset: number } | null
71
+ trailingVisible: number
72
+ }
73
+
74
+ function sliceContext(
75
+ totalLines: number,
76
+ hasChangeBefore: boolean,
77
+ hasChangeAfter: boolean,
78
+ fold: FoldState
79
+ ): ContextSlice {
80
+ const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, totalLines) : 0
81
+ const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, totalLines - preKeep) : 0
82
+ const middle = totalLines - preKeep - postKeep
83
+ if (middle < MIN_FOLD_SIZE) {
84
+ return { fold: null, leadingVisible: totalLines, trailingVisible: 0 }
85
+ }
86
+ const top = Math.min(fold.top, middle)
87
+ const bottom = Math.min(fold.bottom, middle - top)
88
+ const hidden = middle - top - bottom
89
+ if (hidden <= 0) {
90
+ return { fold: null, leadingVisible: totalLines, trailingVisible: 0 }
91
+ }
92
+ return {
93
+ fold: { length: hidden, offset: preKeep + top },
94
+ leadingVisible: preKeep + top,
95
+ trailingVisible: postKeep + bottom,
96
+ }
97
+ }
98
+
99
+ function makeFoldInfo(foldId: string, middle: number, fold: FoldState, hidden: number): FoldInfo {
100
+ const topExpanded = Math.min(fold.top, middle)
101
+ const bottomExpanded = Math.min(fold.bottom, middle - topExpanded)
102
+ return { bottomExpanded, foldId, hidden, topExpanded, total: middle }
103
+ }
104
+
105
+ function contextNeighbors(
106
+ contents: HunkContent[],
107
+ index: number
108
+ ): { hasChangeBefore: boolean; hasChangeAfter: boolean } {
109
+ let hasChangeBefore = false
110
+ for (let i = index - 1; i >= 0; i--) {
111
+ const c = contents[i]
112
+ if (c && c.type !== 'context') {
113
+ hasChangeBefore = true
114
+ break
115
+ }
116
+ }
117
+ let hasChangeAfter = false
118
+ for (let i = index + 1; i < contents.length; i++) {
119
+ const c = contents[i]
120
+ if (c && c.type !== 'context') {
121
+ hasChangeAfter = true
122
+ break
123
+ }
124
+ }
125
+ return { hasChangeAfter, hasChangeBefore }
126
+ }
127
+
128
+ export function buildSplitRows(
129
+ file: FileDiffMetadata,
130
+ folds: FoldMap = {},
131
+ contentWidth = 0
132
+ ): SplitRowOrHeader[] {
133
+ const rows: SplitRowOrHeader[] = []
134
+ const pairHeight = (l: SplitCell, r: SplitCell): number => {
135
+ const lh =
136
+ l.type === 'context' || l.type === 'addition' || l.type === 'deletion'
137
+ ? cellWraps(l.content, contentWidth)
138
+ : 1
139
+ const rh =
140
+ r.type === 'context' || r.type === 'addition' || r.type === 'deletion'
141
+ ? cellWraps(r.content, contentWidth)
142
+ : 1
143
+ return Math.max(lh, rh)
144
+ }
145
+ for (const [hIdx, hunk] of file.hunks.entries()) {
146
+ rows.push(makeHeader(hunk))
147
+ let delLine = hunk.deletionStart
148
+ let addLine = hunk.additionStart
149
+ for (const [cIdx, content] of hunk.hunkContent.entries()) {
150
+ if (content.type === 'context') {
151
+ const foldId = `${hIdx}:${cIdx}`
152
+ const { hasChangeAfter, hasChangeBefore } = contextNeighbors(hunk.hunkContent, cIdx)
153
+ const foldState = folds[foldId] ?? { bottom: 0, top: 0 }
154
+ const slice = sliceContext(content.lines, hasChangeBefore, hasChangeAfter, foldState)
155
+
156
+ const pushContext = (start: number, count: number): void => {
157
+ for (let i = 0; i < count; i++) {
158
+ const offset = start + i
159
+ const addIdx = content.additionLineIndex + offset
160
+ const delIdx = content.deletionLineIndex + offset
161
+ const text = stripNewline(file.additionLines[addIdx] ?? '')
162
+ const left: SplitCell = {
163
+ content: text,
164
+ lineIdx: delIdx,
165
+ lineNumber: delLine++,
166
+ type: 'context',
167
+ }
168
+ const right: SplitCell = {
169
+ content: text,
170
+ lineIdx: addIdx,
171
+ lineNumber: addLine++,
172
+ type: 'context',
173
+ }
174
+ rows.push({ height: pairHeight(left, right), left, right, type: 'row' })
175
+ }
176
+ }
177
+
178
+ pushContext(0, slice.leadingVisible)
179
+ if (slice.fold) {
180
+ const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, content.lines) : 0
181
+ const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, content.lines - preKeep) : 0
182
+ const middle = content.lines - preKeep - postKeep
183
+ const info = makeFoldInfo(foldId, middle, foldState, slice.fold.length)
184
+ rows.push({
185
+ height: 1,
186
+ left: { fold: info, type: 'fold' },
187
+ right: { fold: info, type: 'fold' },
188
+ type: 'row',
189
+ })
190
+ delLine += slice.fold.length
191
+ addLine += slice.fold.length
192
+ pushContext(slice.fold.offset + slice.fold.length, slice.trailingVisible)
193
+ }
194
+ } else {
195
+ const max = Math.max(content.additions, content.deletions)
196
+ for (let i = 0; i < max; i++) {
197
+ const delIdx = content.deletionLineIndex + i
198
+ const addIdx = content.additionLineIndex + i
199
+ const left: SplitCell =
200
+ i < content.deletions
201
+ ? {
202
+ content: stripNewline(file.deletionLines[delIdx] ?? ''),
203
+ lineIdx: delIdx,
204
+ lineNumber: delLine++,
205
+ type: 'deletion',
206
+ }
207
+ : { type: 'filler' }
208
+ const right: SplitCell =
209
+ i < content.additions
210
+ ? {
211
+ content: stripNewline(file.additionLines[addIdx] ?? ''),
212
+ lineIdx: addIdx,
213
+ lineNumber: addLine++,
214
+ type: 'addition',
215
+ }
216
+ : { type: 'filler' }
217
+ rows.push({ height: pairHeight(left, right), left, right, type: 'row' })
218
+ }
219
+ }
220
+ }
221
+ }
222
+ return rows
223
+ }
224
+
225
+ export function buildUnifiedRows(
226
+ file: FileDiffMetadata,
227
+ folds: FoldMap = {},
228
+ contentWidth = 0
229
+ ): UnifiedRowOrHeader[] {
230
+ const rows: UnifiedRowOrHeader[] = []
231
+ for (const [hIdx, hunk] of file.hunks.entries()) {
232
+ rows.push(makeHeader(hunk))
233
+ let delLine = hunk.deletionStart
234
+ let addLine = hunk.additionStart
235
+ for (const [cIdx, content] of hunk.hunkContent.entries()) {
236
+ if (content.type === 'context') {
237
+ const foldId = `${hIdx}:${cIdx}`
238
+ const { hasChangeAfter, hasChangeBefore } = contextNeighbors(hunk.hunkContent, cIdx)
239
+ const foldState = folds[foldId] ?? { bottom: 0, top: 0 }
240
+ const slice = sliceContext(content.lines, hasChangeBefore, hasChangeAfter, foldState)
241
+
242
+ const pushContext = (start: number, count: number): void => {
243
+ for (let i = 0; i < count; i++) {
244
+ const offset = start + i
245
+ const addIdx = content.additionLineIndex + offset
246
+ const text = stripNewline(file.additionLines[addIdx] ?? '')
247
+ rows.push({
248
+ addLineNumber: addLine++,
249
+ content: text,
250
+ delLineNumber: delLine++,
251
+ height: cellWraps(text, contentWidth),
252
+ lineIdx: addIdx,
253
+ type: 'context',
254
+ })
255
+ }
256
+ }
257
+
258
+ pushContext(0, slice.leadingVisible)
259
+ if (slice.fold) {
260
+ const preKeep = hasChangeBefore ? Math.min(KEEP_CONTEXT, content.lines) : 0
261
+ const postKeep = hasChangeAfter ? Math.min(KEEP_CONTEXT, content.lines - preKeep) : 0
262
+ const middle = content.lines - preKeep - postKeep
263
+ const info = makeFoldInfo(foldId, middle, foldState, slice.fold.length)
264
+ rows.push({ fold: info, type: 'fold' })
265
+ delLine += slice.fold.length
266
+ addLine += slice.fold.length
267
+ pushContext(slice.fold.offset + slice.fold.length, slice.trailingVisible)
268
+ }
269
+ } else {
270
+ for (let i = 0; i < content.deletions; i++) {
271
+ const delIdx = content.deletionLineIndex + i
272
+ const text = stripNewline(file.deletionLines[delIdx] ?? '')
273
+ rows.push({
274
+ content: text,
275
+ height: cellWraps(text, contentWidth),
276
+ lineIdx: delIdx,
277
+ lineNumber: delLine++,
278
+ type: 'deletion',
279
+ })
280
+ }
281
+ for (let i = 0; i < content.additions; i++) {
282
+ const addIdx = content.additionLineIndex + i
283
+ const text = stripNewline(file.additionLines[addIdx] ?? '')
284
+ rows.push({
285
+ content: text,
286
+ height: cellWraps(text, contentWidth),
287
+ lineIdx: addIdx,
288
+ lineNumber: addLine++,
289
+ type: 'addition',
290
+ })
291
+ }
292
+ }
293
+ }
294
+ }
295
+ return rows
296
+ }
297
+
298
+ function rowHeight(row: SplitRowOrHeader | UnifiedRowOrHeader): number {
299
+ if (row.type === 'hunk-header') return 1
300
+ if (row.type === 'row') return row.height
301
+ if (row.type === 'fold') return 1
302
+ return row.height
303
+ }
304
+
305
+ function isChangeRow(row: SplitRowOrHeader | UnifiedRowOrHeader): boolean {
306
+ if (row.type === 'row') {
307
+ return (
308
+ row.left.type === 'addition' ||
309
+ row.left.type === 'deletion' ||
310
+ row.right.type === 'addition' ||
311
+ row.right.type === 'deletion'
312
+ )
313
+ }
314
+ return row.type === 'addition' || row.type === 'deletion'
315
+ }
316
+
317
+ export function firstChangeRowOffset(
318
+ rows: readonly (SplitRowOrHeader | UnifiedRowOrHeader)[]
319
+ ): number {
320
+ let offset = 0
321
+ for (const row of rows) {
322
+ if (isChangeRow(row)) return offset
323
+ offset += rowHeight(row)
324
+ }
325
+ return -1
326
+ }
327
+
328
+ function makeHeader(hunk: Hunk): HunkHeader {
329
+ const spec = `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`
330
+ return { context: hunk.hunkContext, spec, type: 'hunk-header' }
331
+ }
332
+
333
+ function stripNewline(s: string): string {
334
+ if (s.endsWith('\r\n')) return s.slice(0, -2)
335
+ if (s.endsWith('\n')) return s.slice(0, -1)
336
+ return s
337
+ }
338
+
339
+ export function gutterWidth(file: FileDiffMetadata): number {
340
+ let max = 1
341
+ for (const h of file.hunks) {
342
+ max = Math.max(
343
+ max,
344
+ h.deletionStart + h.deletionCount - 1,
345
+ h.additionStart + h.additionCount - 1
346
+ )
347
+ }
348
+ return Math.max(2, String(max).length)
349
+ }