@brimveyn/aimux 1.9.0 → 1.9.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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +6 -1
- package/src/app.tsx +20 -16
- 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/types.ts +11 -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 +8 -17
- 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} +9 -5
- package/src/ui/components/{git-view.tsx → git/git-view.tsx} +70 -37
- 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} +4 -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
|
@@ -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
|
+
}
|
|
@@ -3,13 +3,13 @@ import type { ThemedToken } from 'shiki'
|
|
|
3
3
|
|
|
4
4
|
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
|
5
5
|
|
|
6
|
-
import type { ThemeId } from '
|
|
6
|
+
import type { ThemeId } from '../../../themes'
|
|
7
7
|
|
|
8
|
-
import { useAppStore } from '
|
|
9
|
-
import { dispatchGlobal } from '
|
|
10
|
-
import { type FoldState } from '
|
|
11
|
-
import { useTokens } from '
|
|
12
|
-
import {
|
|
8
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
9
|
+
import { dispatchGlobal } from '../../../../state/dispatch-ref'
|
|
10
|
+
import { type FoldState } from '../../../../state/types'
|
|
11
|
+
import { useTokens } from '../../../theme'
|
|
12
|
+
import { buildDiffSegments, firstChangeSegmentOffset, gutterWidth } from './build-rows'
|
|
13
13
|
import { SplitView, type SplitViewHandle } from './split-view'
|
|
14
14
|
import { StackedView, type StackedViewHandle } from './stacked-view'
|
|
15
15
|
import { useDiffPreparation } from './use-diff-preparation'
|
|
@@ -62,6 +62,10 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
62
62
|
}, [file, view, terminalCols, sidebarWidth])
|
|
63
63
|
|
|
64
64
|
const folds = useAppStore((s) => s.gitMode.folds[cacheKey]) ?? EMPTY_FOLDS
|
|
65
|
+
const segments = useMemo(
|
|
66
|
+
() => (file ? buildDiffSegments(file, folds).segments : []),
|
|
67
|
+
[file, folds]
|
|
68
|
+
)
|
|
65
69
|
const foldDispatch = useMemo<FoldDispatch>(
|
|
66
70
|
() => ({
|
|
67
71
|
adjust: (foldId, side, delta) =>
|
|
@@ -74,6 +78,7 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
74
78
|
|
|
75
79
|
const splitRef = useRef<SplitViewHandle | null>(null)
|
|
76
80
|
const stackedRef = useRef<StackedViewHandle | null>(null)
|
|
81
|
+
const autoScrollKeyRef = useRef<string | null>(null)
|
|
77
82
|
|
|
78
83
|
useImperativeHandle(
|
|
79
84
|
ref,
|
|
@@ -92,11 +97,10 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
92
97
|
|
|
93
98
|
useEffect(() => {
|
|
94
99
|
if (!file) return
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const offset = firstChangeRowOffset(rows)
|
|
100
|
+
const autoScrollKey = `${cacheKey}:${view}`
|
|
101
|
+
if (autoScrollKeyRef.current === autoScrollKey) return
|
|
102
|
+
autoScrollKeyRef.current = autoScrollKey
|
|
103
|
+
const offset = firstChangeSegmentOffset(file, EMPTY_FOLDS, contentWidth, view)
|
|
100
104
|
if (offset < 0) return
|
|
101
105
|
const target = Math.max(0, offset - 2)
|
|
102
106
|
const apply = (): void => {
|
|
@@ -110,9 +114,11 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
110
114
|
if (node) node.scrollTop = target
|
|
111
115
|
}
|
|
112
116
|
}
|
|
113
|
-
|
|
117
|
+
let raf = requestAnimationFrame(() => {
|
|
118
|
+
raf = requestAnimationFrame(apply)
|
|
119
|
+
})
|
|
114
120
|
return () => cancelAnimationFrame(raf)
|
|
115
|
-
}, [cacheKey, file, view
|
|
121
|
+
}, [cacheKey, contentWidth, file, view])
|
|
116
122
|
|
|
117
123
|
if (!file) {
|
|
118
124
|
return (
|
|
@@ -131,8 +137,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
131
137
|
contentWidth={contentWidth}
|
|
132
138
|
file={file}
|
|
133
139
|
foldDispatch={foldDispatch}
|
|
134
|
-
folds={folds}
|
|
135
140
|
highlights={highlights}
|
|
141
|
+
requestSegmentHighlights={preparation.requestSegmentHighlights}
|
|
142
|
+
segments={segments}
|
|
136
143
|
/>
|
|
137
144
|
)
|
|
138
145
|
}
|
|
@@ -142,8 +149,9 @@ export const PierreDiff = forwardRef<PierreDiffHandle, Props>(function PierreDif
|
|
|
142
149
|
contentWidth={contentWidth}
|
|
143
150
|
file={file}
|
|
144
151
|
foldDispatch={foldDispatch}
|
|
145
|
-
folds={folds}
|
|
146
152
|
highlights={highlights}
|
|
153
|
+
requestSegmentHighlights={preparation.requestSegmentHighlights}
|
|
154
|
+
segments={segments}
|
|
147
155
|
/>
|
|
148
156
|
)
|
|
149
157
|
})
|