@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.
- package/README.md +14 -2
- package/package.json +5 -3
- package/src/app-runtime/side-effects.ts +41 -6
- package/src/app.tsx +17 -5
- package/src/config.ts +29 -4
- package/src/diff-parser/clean-last-newline.ts +5 -0
- package/src/diff-parser/constants.ts +13 -0
- package/src/diff-parser/index.ts +12 -0
- package/src/diff-parser/parse-line-type.ts +29 -0
- package/src/diff-parser/parse-patch-files.ts +369 -0
- package/src/diff-parser/types.ts +75 -0
- package/src/input/modes/bridge.ts +8 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +4 -1
- package/src/pty/terminal-snapshot.ts +4 -4
- package/src/state/git-tree.ts +220 -0
- package/src/state/reducers/git-mode-state.ts +232 -35
- package/src/state/reducers/git-panel-state.ts +29 -3
- package/src/state/reducers/modal-state.ts +43 -10
- package/src/state/store.ts +5 -1
- package/src/state/types.ts +41 -5
- package/src/state/workspace-save.ts +2 -0
- package/src/ui/components/create-session-modal.tsx +23 -7
- package/src/ui/components/diff-renderer/build-rows.ts +349 -0
- package/src/ui/components/diff-renderer/filetype.ts +29 -0
- package/src/ui/components/diff-renderer/fold-strip.tsx +84 -0
- package/src/ui/components/diff-renderer/highlight.ts +48 -0
- package/src/ui/components/diff-renderer/index.ts +1 -0
- package/src/ui/components/diff-renderer/pierre-diff.tsx +170 -0
- package/src/ui/components/diff-renderer/split-view.tsx +207 -0
- package/src/ui/components/diff-renderer/stacked-view.tsx +166 -0
- package/src/ui/components/git-commit-modal.tsx +14 -2
- package/src/ui/components/git-pane-widget.tsx +5 -0
- package/src/ui/components/git-panel.tsx +176 -79
- package/src/ui/components/git-view.tsx +89 -87
- package/src/ui/components/help-modal.tsx +43 -11
- package/src/ui/components/input-field.tsx +2 -2
- package/src/ui/components/list-item.tsx +9 -1
- package/src/ui/components/modal-filter-bar.tsx +1 -1
- package/src/ui/components/modal-keybinds-overlay.tsx +2 -2
- package/src/ui/components/modal-shell.tsx +3 -3
- package/src/ui/components/new-tab-modal.tsx +15 -3
- package/src/ui/components/pending-chord-overlay.tsx +3 -3
- package/src/ui/components/session-bar.tsx +10 -5
- package/src/ui/components/session-picker-modal.tsx +26 -9
- package/src/ui/components/sidebar.tsx +22 -10
- package/src/ui/components/snippet-editor-modal.tsx +16 -2
- package/src/ui/components/snippet-picker-modal.tsx +11 -3
- package/src/ui/components/split-layout.tsx +1 -1
- package/src/ui/components/status-bar.tsx +12 -9
- package/src/ui/components/surface.tsx +5 -5
- package/src/ui/components/tab-item.tsx +22 -16
- package/src/ui/components/terminal-pane.tsx +25 -15
- package/src/ui/components/theme-picker-modal.tsx +111 -16
- package/src/ui/components/update-available-modal.tsx +11 -1
- package/src/ui/filter-themes.ts +10 -0
- package/src/ui/house-themes.ts +313 -0
- package/src/ui/keymap-context.ts +10 -2
- package/src/ui/root.tsx +26 -6
- package/src/ui/shiki.ts +49 -0
- package/src/ui/status-bar-model.ts +19 -1
- package/src/ui/theme.ts +22 -10
- package/src/ui/themes.generated.ts +59794 -0
- package/src/ui/themes.ts +68 -274
- 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 {
|
|
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
|
|
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
|
|
236
|
-
|
|
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 =
|
|
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 === '
|
|
440
|
+
state.modal.type === 'theme-picker'
|
|
408
441
|
) {
|
|
409
442
|
return {
|
|
410
443
|
...state,
|
package/src/state/store.ts
CHANGED
|
@@ -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,
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
447
|
-
| { type: 'git-mode-
|
|
448
|
-
| { type: 'git-mode-
|
|
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.
|
|
24
|
+
return theme.colors['editorWarning.foreground']
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
if (result.type === 'workspace') {
|
|
28
|
-
return theme.
|
|
28
|
+
return theme.colors['terminal.ansiMagenta']
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
return theme.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|