@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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +13 -3
- package/src/app.tsx +26 -17
- package/src/git/git-poller.ts +56 -3
- package/src/git/repo-discovery.ts +95 -0
- package/src/git/use-repo-discovery.ts +28 -0
- package/src/input/keymap/describe-bindings.ts +63 -2
- package/src/pty/terminal-snapshot.ts +7 -5
- package/src/state/reducers/git-mode-state.ts +58 -0
- package/src/state/reducers/git-panel-state.ts +3 -0
- package/src/state/reducers/multi-repo-state.ts +60 -0
- package/src/state/store.ts +6 -0
- package/src/state/types.ts +38 -6
- package/src/ui/breaking-update-screen.tsx +2 -2
- package/src/ui/components/git/diff-renderer/build-rows.ts +467 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/fold-strip.tsx +1 -1
- package/src/ui/components/git/diff-renderer/highlight.ts +102 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/pierre-diff.tsx +23 -15
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +62 -0
- package/src/ui/components/{diff-renderer → git/diff-renderer}/split-view.tsx +122 -47
- package/src/ui/components/{diff-renderer → git/diff-renderer}/stacked-view.tsx +106 -35
- package/src/ui/components/{diff-renderer → git/diff-renderer}/use-diff-prefetch.ts +9 -18
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +240 -0
- package/src/ui/components/git/diff-renderer/use-segment-virtualization.ts +95 -0
- package/src/ui/components/{git-panel.tsx → git/git-panel.tsx} +32 -10
- package/src/ui/components/{git-view.tsx → git/git-view.tsx} +73 -38
- package/src/ui/components/{git-pane-context-menu.ts → git/pane/git-pane-context-menu.ts} +2 -2
- package/src/ui/components/{git-pane-widget.tsx → git/pane/git-pane-widget.tsx} +6 -4
- package/src/ui/components/{session-bar.tsx → layout/session-bar.tsx} +36 -13
- package/src/ui/components/{sidebar-group-metadata.ts → layout/sidebar/sidebar-group-metadata.ts} +2 -2
- package/src/ui/components/{sidebar.tsx → layout/sidebar/sidebar.tsx} +6 -6
- package/src/ui/components/{tab-item.tsx → layout/sidebar/tab-item.tsx} +5 -5
- package/src/ui/components/{use-sidebar-branch.ts → layout/sidebar/use-sidebar-branch.ts} +1 -1
- package/src/ui/components/{split-layout.tsx → layout/split-layout.tsx} +5 -5
- package/src/ui/components/{status-bar.tsx → layout/status-bar.tsx} +7 -7
- package/src/ui/components/{terminal-pane.tsx → layout/terminal-pane.tsx} +8 -8
- package/src/ui/components/{ai-usage-modal.tsx → modals/app/ai-usage-modal.tsx} +10 -6
- package/src/ui/components/{help-modal.tsx → modals/app/help-modal.tsx} +6 -6
- package/src/ui/components/{update-available-modal.tsx → modals/app/update-available-modal.tsx} +4 -4
- package/src/ui/components/{git-commit-modal.tsx → modals/git/git-commit-modal.tsx} +8 -8
- package/src/ui/components/modals/sessions/create-session-modal.tsx +79 -0
- package/src/ui/components/modals/sessions/session-name-modal.tsx +10 -0
- package/src/ui/components/{session-picker-modal.tsx → modals/sessions/session-picker-modal.tsx} +8 -8
- package/src/ui/components/modals/shared/form.tsx +164 -0
- package/src/ui/components/{modal-keybinds-overlay.tsx → modals/shared/modal-keybinds-overlay.tsx} +7 -7
- package/src/ui/components/{modal-shell.tsx → modals/shared/modal-shell.tsx} +1 -1
- package/src/ui/components/{picker.tsx → modals/shared/picker.tsx} +3 -3
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +30 -0
- package/src/ui/components/{snippet-picker-modal.tsx → modals/snippets/snippet-picker-modal.tsx} +6 -6
- package/src/ui/components/{new-tab-modal.tsx → modals/tabs/new-tab-modal.tsx} +17 -20
- package/src/ui/components/{theme-picker-modal.tsx → modals/themes/theme-picker-modal.tsx} +6 -6
- package/src/ui/components/{ai-usage-indicator.tsx → overlays/ai-usage/ai-usage-indicator.tsx} +3 -3
- package/src/ui/components/{context-menu-box.tsx → overlays/context-menu/context-menu-box.tsx} +1 -1
- package/src/ui/components/{context-menu-overlay.tsx → overlays/context-menu/context-menu-overlay.tsx} +3 -3
- package/src/ui/components/{pending-chord-overlay.tsx → overlays/pending-chord-overlay.tsx} +6 -6
- package/src/ui/components/{bare-input.tsx → primitives/bare-input.tsx} +2 -2
- package/src/ui/components/{input-field.tsx → primitives/input-field.tsx} +2 -2
- package/src/ui/components/{list-item.tsx → primitives/list-item.tsx} +1 -1
- package/src/ui/components/{surface.tsx → primitives/surface.tsx} +1 -1
- package/src/ui/root.tsx +24 -22
- package/src/ui/theme-store.ts +30 -26
- package/src/ui/themes.ts +1 -0
- package/src/ui/components/create-session-modal.tsx +0 -96
- package/src/ui/components/diff-renderer/build-rows.ts +0 -349
- package/src/ui/components/diff-renderer/highlight.ts +0 -44
- package/src/ui/components/diff-renderer/prepare-diff.ts +0 -65
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +0 -106
- package/src/ui/components/session-name-modal.tsx +0 -11
- package/src/ui/components/snippet-editor-modal.tsx +0 -40
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/filetype.ts +0 -0
- /package/src/ui/components/{diff-renderer → git/diff-renderer}/index.ts +0 -0
- /package/src/ui/components/{sidebar-scroll.ts → layout/sidebar/sidebar-scroll.ts} +0 -0
- /package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts} +0 -0
package/src/state/types.ts
CHANGED
|
@@ -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
|
|
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
|
|
239
|
+
export function getParsedPayload<T>(entry: ParsedDiffEntry | undefined): T | null {
|
|
241
240
|
if (!entry) return null
|
|
242
|
-
return entry.file as
|
|
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 {
|
|
4
|
-
import {
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|