@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
@@ -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,13 +146,15 @@ 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
  }
150
154
 
151
155
  export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
152
156
 
153
- export type GitFileSection = 'staged' | 'unstaged' | 'untracked'
157
+ export type GitFileSection = 'staged' | 'unstaged' | 'untracked' | 'historical'
154
158
 
155
159
  export interface GitFileEntry {
156
160
  path: string
@@ -183,12 +187,25 @@ 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>>
207
+ /** Working-tree-vs-HEAD~N offset. 0 = working tree vs HEAD (default). */
208
+ headOffset: number
192
209
  }
193
210
 
194
211
  interface ModalBase {
@@ -226,11 +243,13 @@ export interface ModalSnippetPicker extends ModalBase {
226
243
 
227
244
  export interface ModalThemePicker extends ModalBase {
228
245
  type: 'theme-picker'
246
+ entryCount: number
229
247
  }
230
248
 
231
249
  export interface ModalHelp extends ModalBase {
232
250
  type: 'help'
233
251
  entryCount: number
252
+ scope: ModeId | null
234
253
  }
235
254
 
236
255
  export interface ModalSplitPicker extends ModalBase {
@@ -323,7 +342,7 @@ export interface AppState {
323
342
  export type ModalAction =
324
343
  | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
325
344
  | { type: 'open-new-tab-modal' }
326
- | { type: 'open-help-modal' }
345
+ | { type: 'open-help-modal'; scope?: ModeId }
327
346
  | { type: 'open-split-picker'; direction: import('./layout-tree').SplitDirection }
328
347
  | { type: 'open-session-picker' }
329
348
  | { type: 'open-session-name-modal'; sessionTargetId?: string; initialName?: string }
@@ -343,7 +362,9 @@ export type ModalAction =
343
362
  | { type: 'open-snippet-editor'; snippetId?: string }
344
363
  | { type: 'begin-snippet-filter' }
345
364
  | { type: 'begin-help-filter' }
365
+ | { type: 'begin-theme-filter' }
346
366
  | { type: 'set-help-entry-count'; count: number }
367
+ | { type: 'set-theme-entry-count'; count: number }
347
368
  | { type: 'open-theme-picker' }
348
369
  | { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
349
370
 
@@ -421,6 +442,7 @@ export type UIAction =
421
442
  | { type: 'set-terminal-size'; cols: number; rows: number }
422
443
  | { type: 'toggle-git-pane' }
423
444
  | { type: 'resize-git-pane'; delta: number }
445
+ | { type: 'resize-git-diff-pane'; delta: number }
424
446
  | { type: 'set-git-pane-mode'; mode: GitPaneMode }
425
447
  | { type: 'set-git-pane-position'; position: GitPanePosition }
426
448
  | { type: 'set-pending-chords'; chords: string[] | null }
@@ -443,12 +465,30 @@ export type GitPanelAction =
443
465
  export type GitModeAction =
444
466
  | { type: 'enter-git-mode' }
445
467
  | { 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 }
468
+ | { type: 'git-mode-move-selection'; delta: -1 | 1 }
469
+ | { type: 'git-mode-move-file-selection'; delta: -1 | 1 }
470
+ | { type: 'git-mode-select-entry-by-key'; key: string }
471
+ | { type: 'git-mode-toggle-folder'; key: string }
472
+ | { type: 'git-mode-toggle-selected-folder' }
473
+ | { type: 'git-mode-collapse-selection' }
474
+ | { type: 'git-mode-expand-selection' }
475
+ | { type: 'git-mode-toggle-file-list-mode' }
476
+ | { type: 'git-mode-set-diff'; key: string; diff: DiffData }
477
+ | { type: 'git-mode-set-loading'; key: string; loading: boolean }
449
478
  | { type: 'git-mode-set-pending-delete'; path: string | null }
450
479
  | { type: 'git-mode-clear-diff-cache'; path: string }
451
480
  | { type: 'git-mode-set-message'; message: string | null }
481
+ | { type: 'git-mode-toggle-diff-view' }
482
+ | { type: 'git-mode-shift-head-offset'; delta: number }
483
+ | { type: 'git-mode-set-head-offset'; offset: number }
484
+ | {
485
+ type: 'git-mode-fold-adjust'
486
+ key: string
487
+ foldId: string
488
+ side: 'top' | 'bottom'
489
+ delta: number
490
+ }
491
+ | { type: 'git-mode-fold-set'; key: string; foldId: string; top: number; bottom: number }
452
492
  | {
453
493
  type: 'git-mode-optimistic-move'
454
494
  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,
@@ -1,7 +1,7 @@
1
1
  import type { DirectoryResult } from '../../state/types'
2
2
 
3
3
  import { abbreviatePath } from '../path-format'
4
- import { theme } from '../theme'
4
+ import { getCurrentTheme, useTheme } from '../theme'
5
5
  import { uiTokens } from '../ui-tokens'
6
6
  import { InputField } from './input-field'
7
7
  import { ListItem } from './list-item'
@@ -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 getCurrentTheme().colors['editorWarning.foreground']
25
25
  }
26
26
 
27
27
  if (result.type === 'workspace') {
28
- return theme.accentAlt
28
+ return getCurrentTheme().colors['terminal.ansiMagenta']
29
29
  }
30
30
 
31
- return theme.accent
31
+ return getCurrentTheme().colors['textLink.foreground']
32
32
  }
33
33
 
34
34
  interface CreateSessionModalProps {
@@ -48,6 +48,7 @@ export function CreateSessionModal({
48
48
  selectedIndex,
49
49
  sessionName,
50
50
  }: CreateSessionModalProps) {
51
+ const theme = useTheme()
51
52
  const dirActive = activeField === 'directory'
52
53
  const nameActive = activeField === 'name'
53
54
 
@@ -58,7 +59,11 @@ export function CreateSessionModal({
58
59
  width={uiTokens.modalWidth.xl}
59
60
  >
60
61
  <box flexDirection="column">
61
- <text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
62
+ <text
63
+ fg={dirActive ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
64
+ >
65
+ Search projects
66
+ </text>
62
67
  <InputField
63
68
  active={dirActive}
64
69
  value={
@@ -68,7 +73,7 @@ export function CreateSessionModal({
68
73
  </box>
69
74
 
70
75
  {dirActive && results.length === 0 && directoryQuery.length > 0 ? (
71
- <text fg={theme.textMuted}>No matches</text>
76
+ <text fg={theme.colors['descriptionForeground']}>No matches</text>
72
77
  ) : null}
73
78
 
74
79
  {dirActive
@@ -82,7 +87,13 @@ export function CreateSessionModal({
82
87
  <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>
83
88
  }
84
89
  title={
85
- <text fg={active ? theme.text : theme.textMuted}>
90
+ <text
91
+ fg={
92
+ active
93
+ ? theme.colors['editor.foreground']
94
+ : theme.colors['descriptionForeground']
95
+ }
96
+ >
86
97
  {abbreviatePath(result.path)}
87
98
  </text>
88
99
  }
@@ -92,7 +103,13 @@ export function CreateSessionModal({
92
103
  : null}
93
104
 
94
105
  <box flexDirection="column">
95
- <text fg={nameActive ? theme.text : theme.textMuted}>Session name</text>
106
+ <text
107
+ fg={
108
+ nameActive ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
109
+ }
110
+ >
111
+ Session name
112
+ </text>
96
113
  <InputField active={nameActive} value={sessionName} />
97
114
  </box>
98
115
  </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
+ }
@@ -0,0 +1,29 @@
1
+ const EXT_TO_SHIKI: Record<string, string> = {
2
+ cjs: 'javascript',
3
+ css: 'css',
4
+ go: 'go',
5
+ html: 'html',
6
+ js: 'javascript',
7
+ json: 'json',
8
+ jsx: 'jsx',
9
+ markdown: 'markdown',
10
+ md: 'markdown',
11
+ mdx: 'mdx',
12
+ mjs: 'javascript',
13
+ py: 'python',
14
+ rs: 'rust',
15
+ sh: 'shellscript',
16
+ toml: 'toml',
17
+ ts: 'typescript',
18
+ tsx: 'tsx',
19
+ yaml: 'yaml',
20
+ yml: 'yaml',
21
+ zig: 'zig',
22
+ }
23
+
24
+ export function filetypeFromPath(path: string): string | undefined {
25
+ const dot = path.lastIndexOf('.')
26
+ if (dot < 0) return undefined
27
+ const ext = path.slice(dot + 1).toLowerCase()
28
+ return EXT_TO_SHIKI[ext]
29
+ }
@@ -0,0 +1,86 @@
1
+ import { useTheme } from '../../theme'
2
+ import { FOLD_STEP, type FoldInfo } from './build-rows'
3
+ import { type FoldDispatch } from './pierre-diff'
4
+
5
+ interface Props {
6
+ fold: FoldInfo
7
+ dispatch: FoldDispatch
8
+ }
9
+
10
+ function Button({ label, onPress }: { label: string; onPress: () => void }) {
11
+ const theme = useTheme()
12
+ return (
13
+ <box
14
+ paddingLeft={1}
15
+ paddingRight={1}
16
+ backgroundColor={theme.colors['sideBar.background']}
17
+ onMouseDown={onPress}
18
+ >
19
+ <text fg={theme.colors['textLink.foreground']}>{label}</text>
20
+ </box>
21
+ )
22
+ }
23
+
24
+ function Spacer() {
25
+ return <text> </text>
26
+ }
27
+
28
+ export function FoldStrip({ dispatch, fold }: Props) {
29
+ const theme = useTheme()
30
+ const { bottomExpanded, foldId, hidden, topExpanded, total } = fold
31
+ const stepUp = Math.min(FOLD_STEP, hidden)
32
+ const stepDown = Math.min(FOLD_STEP, hidden)
33
+ const shrinkUp = Math.min(FOLD_STEP, topExpanded)
34
+ const shrinkDown = Math.min(FOLD_STEP, bottomExpanded)
35
+
36
+ const controls: React.ReactNode[] = []
37
+ if (hidden > 0) {
38
+ controls.push(
39
+ <Button
40
+ key="up"
41
+ label={`↑${stepUp}`}
42
+ onPress={() => dispatch.adjust(foldId, 'top', stepUp)}
43
+ />,
44
+ <Spacer key="sp1" />,
45
+ <Button
46
+ key="down"
47
+ label={`↓${stepDown}`}
48
+ onPress={() => dispatch.adjust(foldId, 'bottom', stepDown)}
49
+ />,
50
+ <Spacer key="sp2" />,
51
+ <Button key="all" label="⇅ all" onPress={() => dispatch.set(foldId, total, 0)} />
52
+ )
53
+ }
54
+ if (shrinkUp > 0) {
55
+ controls.push(
56
+ <Spacer key="sp3" />,
57
+ <Button
58
+ key="shrinkUp"
59
+ label={`−↑${shrinkUp}`}
60
+ onPress={() => dispatch.adjust(foldId, 'top', -shrinkUp)}
61
+ />
62
+ )
63
+ }
64
+ if (shrinkDown > 0) {
65
+ controls.push(
66
+ <Spacer key="sp4" />,
67
+ <Button
68
+ key="shrinkDown"
69
+ label={`−↓${shrinkDown}`}
70
+ onPress={() => dispatch.adjust(foldId, 'bottom', -shrinkDown)}
71
+ />
72
+ )
73
+ }
74
+
75
+ return (
76
+ <box
77
+ flexDirection="row"
78
+ backgroundColor={theme.colors['sideBarSectionHeader.background']}
79
+ paddingLeft={1}
80
+ paddingRight={1}
81
+ >
82
+ <text fg={theme.colors['descriptionForeground']}>{`⋯ ${hidden} hidden `}</text>
83
+ {controls}
84
+ </box>
85
+ )
86
+ }