@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
|
@@ -1,349 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { ThemedToken } from 'shiki'
|
|
2
|
-
|
|
3
|
-
import { ensureActiveShikiTheme, ensureShikiLang, getShikiHighlighter } from '../../shiki'
|
|
4
|
-
|
|
5
|
-
export interface HighlightSpan {
|
|
6
|
-
bold?: boolean
|
|
7
|
-
fg?: string
|
|
8
|
-
italic?: boolean
|
|
9
|
-
text: string
|
|
10
|
-
underline?: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Shiki FontStyle: 1 = italic, 2 = bold, 4 = underline.
|
|
14
|
-
export function tokenToSpan(token: ThemedToken): HighlightSpan {
|
|
15
|
-
const fs = token.fontStyle ?? 0
|
|
16
|
-
return {
|
|
17
|
-
bold: (fs & 2) !== 0 || undefined,
|
|
18
|
-
fg: token.color,
|
|
19
|
-
italic: (fs & 1) !== 0 || undefined,
|
|
20
|
-
text: token.content,
|
|
21
|
-
underline: (fs & 4) !== 0 || undefined,
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export async function tokenizeSide(lines: string[], lang: string): Promise<ThemedToken[][]> {
|
|
26
|
-
if (lines.length === 0) return []
|
|
27
|
-
const highlighter = await getShikiHighlighter()
|
|
28
|
-
const [langOk, themeName] = await Promise.all([
|
|
29
|
-
ensureShikiLang(highlighter, lang),
|
|
30
|
-
ensureActiveShikiTheme(highlighter),
|
|
31
|
-
])
|
|
32
|
-
if (!langOk) return []
|
|
33
|
-
try {
|
|
34
|
-
const result = highlighter.codeToTokens(lines.join(''), {
|
|
35
|
-
// eslint-disable-next-line typescript/no-explicit-any
|
|
36
|
-
lang: lang as any,
|
|
37
|
-
// eslint-disable-next-line typescript/no-explicit-any
|
|
38
|
-
theme: themeName as any,
|
|
39
|
-
})
|
|
40
|
-
return result.tokens
|
|
41
|
-
} catch {
|
|
42
|
-
return []
|
|
43
|
-
}
|
|
44
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import type { ThemedToken } from 'shiki'
|
|
2
|
-
|
|
3
|
-
import { type FileDiffMetadata, parsePatchFiles } from '../../../diff-parser'
|
|
4
|
-
import { diffHash } from '../../../git/diff-hash'
|
|
5
|
-
import { filetypeFromPath } from './filetype'
|
|
6
|
-
import { tokenizeSide } from './highlight'
|
|
7
|
-
|
|
8
|
-
export interface PreparedDiff {
|
|
9
|
-
hash: string
|
|
10
|
-
file: FileDiffMetadata | null
|
|
11
|
-
filetype: string | null
|
|
12
|
-
highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface PrepareOptions {
|
|
16
|
-
signal?: AbortSignal
|
|
17
|
-
/** Skip Shiki for big files to keep prepare under a few tens of ms. */
|
|
18
|
-
skipHighlightThreshold?: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const DEFAULT_SKIP_HIGHLIGHT = 2000
|
|
22
|
-
|
|
23
|
-
function yieldToEventLoop(): Promise<void> {
|
|
24
|
-
return new Promise((resolve) => {
|
|
25
|
-
setImmediate(resolve)
|
|
26
|
-
})
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function throwIfAborted(signal?: AbortSignal): void {
|
|
30
|
-
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Lifts the hot path (parse → tokenize) off the synchronous render tree. The
|
|
34
|
-
// parser still blocks briefly on very large diffs, but yielding between parse
|
|
35
|
-
// and each tokenize side keeps other UI updates responsive.
|
|
36
|
-
export async function prepareDiff(
|
|
37
|
-
diff: string,
|
|
38
|
-
path: string,
|
|
39
|
-
opts: PrepareOptions
|
|
40
|
-
): Promise<PreparedDiff> {
|
|
41
|
-
const hash = diffHash(diff)
|
|
42
|
-
throwIfAborted(opts.signal)
|
|
43
|
-
const patches = parsePatchFiles(diff)
|
|
44
|
-
const file = patches[0]?.files[0] ?? null
|
|
45
|
-
const filetype = file ? (filetypeFromPath(path) ?? null) : null
|
|
46
|
-
|
|
47
|
-
const skipThreshold = opts.skipHighlightThreshold ?? DEFAULT_SKIP_HIGHLIGHT
|
|
48
|
-
const totalLines = file ? file.additionLines.length + file.deletionLines.length : 0
|
|
49
|
-
const shouldHighlight = !!file && !!filetype && totalLines <= skipThreshold
|
|
50
|
-
|
|
51
|
-
if (!shouldHighlight) {
|
|
52
|
-
return { file, filetype, hash, highlights: { add: [], del: [] } }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
await yieldToEventLoop()
|
|
56
|
-
throwIfAborted(opts.signal)
|
|
57
|
-
const add = await tokenizeSide(file.additionLines, filetype)
|
|
58
|
-
throwIfAborted(opts.signal)
|
|
59
|
-
await yieldToEventLoop()
|
|
60
|
-
throwIfAborted(opts.signal)
|
|
61
|
-
const del = await tokenizeSide(file.deletionLines, filetype)
|
|
62
|
-
throwIfAborted(opts.signal)
|
|
63
|
-
|
|
64
|
-
return { file, filetype, hash, highlights: { add, del } }
|
|
65
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import type { ThemedToken } from 'shiki'
|
|
2
|
-
|
|
3
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
4
|
-
|
|
5
|
-
import type { FileDiffMetadata } from '../../../diff-parser'
|
|
6
|
-
|
|
7
|
-
import { useAppStore } from '../../../state/app-store'
|
|
8
|
-
import { dispatchGlobal } from '../../../state/dispatch-ref'
|
|
9
|
-
import { getHighlightsTokens, getParsedFile } from '../../../state/types'
|
|
10
|
-
import { prepareDiff } from './prepare-diff'
|
|
11
|
-
|
|
12
|
-
export interface DiffPreparation {
|
|
13
|
-
file: FileDiffMetadata | null
|
|
14
|
-
highlights: { add: ThemedToken[][]; del: ThemedToken[][] }
|
|
15
|
-
/** True while prepare is running for this cache key + hash. */
|
|
16
|
-
preparing: boolean
|
|
17
|
-
/** Available once either cache or prepare has produced a valid hash. */
|
|
18
|
-
ready: boolean
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const EMPTY_HIGHLIGHTS = { add: [] as ThemedToken[][], del: [] as ThemedToken[][] }
|
|
22
|
-
|
|
23
|
-
// Consults the parsed + highlight caches first, then falls back to an off-thread
|
|
24
|
-
// prepareDiff that dispatches results into the store. Deduplicates in-flight
|
|
25
|
-
// preparation per cacheKey+hash by hashing the diff string once.
|
|
26
|
-
export function useDiffPreparation(
|
|
27
|
-
cacheKey: string,
|
|
28
|
-
diff: string,
|
|
29
|
-
path: string,
|
|
30
|
-
themeId: string
|
|
31
|
-
): DiffPreparation {
|
|
32
|
-
const cachedParsed = useAppStore((s) => s.gitMode.parsedFiles[cacheKey])
|
|
33
|
-
const highlightKey = `${cacheKey}|${themeId}`
|
|
34
|
-
const cachedHighlights = useAppStore((s) => s.gitMode.highlights[highlightKey])
|
|
35
|
-
|
|
36
|
-
const [preparing, setPreparing] = useState(false)
|
|
37
|
-
const [localFile, setLocalFile] = useState<FileDiffMetadata | null>(null)
|
|
38
|
-
const [localHighlights, setLocalHighlights] = useState(EMPTY_HIGHLIGHTS)
|
|
39
|
-
const [localHash, setLocalHash] = useState<string | null>(null)
|
|
40
|
-
|
|
41
|
-
const { file, highlights, ready } = useMemo(() => {
|
|
42
|
-
const parsedFile = cachedParsed ? getParsedFile(cachedParsed) : null
|
|
43
|
-
const parsedMatches = !!cachedParsed
|
|
44
|
-
const tokens = cachedHighlights ? getHighlightsTokens(cachedHighlights) : null
|
|
45
|
-
const hlMatches = !!cachedHighlights && cachedHighlights.hash === cachedParsed?.hash
|
|
46
|
-
|
|
47
|
-
if (parsedMatches && hlMatches && tokens) {
|
|
48
|
-
return { file: parsedFile, highlights: tokens, ready: true }
|
|
49
|
-
}
|
|
50
|
-
if (parsedMatches && !hlMatches) {
|
|
51
|
-
return {
|
|
52
|
-
file: parsedFile,
|
|
53
|
-
highlights: tokens ?? EMPTY_HIGHLIGHTS,
|
|
54
|
-
ready: true,
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
if (localHash && localFile) {
|
|
58
|
-
return { file: localFile, highlights: localHighlights, ready: true }
|
|
59
|
-
}
|
|
60
|
-
return { file: null, highlights: EMPTY_HIGHLIGHTS, ready: false }
|
|
61
|
-
}, [cachedParsed, cachedHighlights, localFile, localHighlights, localHash])
|
|
62
|
-
|
|
63
|
-
useEffect(() => {
|
|
64
|
-
// If caches already cover both parsed and highlights for this theme, bail.
|
|
65
|
-
const parsedOk = !!cachedParsed
|
|
66
|
-
const hlOk = !!cachedHighlights && cachedParsed && cachedHighlights.hash === cachedParsed.hash
|
|
67
|
-
if (parsedOk && hlOk) return
|
|
68
|
-
|
|
69
|
-
const controller = new AbortController()
|
|
70
|
-
setPreparing(true)
|
|
71
|
-
void prepareDiff(diff, path, { signal: controller.signal })
|
|
72
|
-
.then((result) => {
|
|
73
|
-
if (controller.signal.aborted) return
|
|
74
|
-
setLocalFile(result.file)
|
|
75
|
-
setLocalHighlights(result.highlights)
|
|
76
|
-
setLocalHash(result.hash)
|
|
77
|
-
dispatchGlobal({
|
|
78
|
-
file: result.file,
|
|
79
|
-
hash: result.hash,
|
|
80
|
-
key: cacheKey,
|
|
81
|
-
type: 'git-mode-set-parsed',
|
|
82
|
-
})
|
|
83
|
-
dispatchGlobal({
|
|
84
|
-
add: result.highlights.add,
|
|
85
|
-
del: result.highlights.del,
|
|
86
|
-
hash: result.hash,
|
|
87
|
-
key: cacheKey,
|
|
88
|
-
themeId,
|
|
89
|
-
type: 'git-mode-set-highlights',
|
|
90
|
-
})
|
|
91
|
-
})
|
|
92
|
-
.catch((err: unknown) => {
|
|
93
|
-
if (err instanceof Error && err.name === 'AbortError') return
|
|
94
|
-
// Swallow — the UI falls back to "(could not parse diff)" when file stays null.
|
|
95
|
-
})
|
|
96
|
-
.finally(() => {
|
|
97
|
-
if (!controller.signal.aborted) setPreparing(false)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
return () => {
|
|
101
|
-
controller.abort()
|
|
102
|
-
}
|
|
103
|
-
}, [cacheKey, diff, path, themeId, cachedParsed, cachedHighlights])
|
|
104
|
-
|
|
105
|
-
return { file, highlights, preparing, ready }
|
|
106
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { uiTokens } from '../ui-tokens'
|
|
2
|
-
import { InputField } from './input-field'
|
|
3
|
-
import { ModalShell } from './modal-shell'
|
|
4
|
-
|
|
5
|
-
export function SessionNameModal({ title, value }: { title: string; value: string }) {
|
|
6
|
-
return (
|
|
7
|
-
<ModalShell title={title} keybindsModeId="modal.session-name" width={uiTokens.modalWidth.md}>
|
|
8
|
-
<InputField active value={value} />
|
|
9
|
-
</ModalShell>
|
|
10
|
-
)
|
|
11
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { useTokens } from '../theme'
|
|
2
|
-
import { uiTokens } from '../ui-tokens'
|
|
3
|
-
import { InputField } from './input-field'
|
|
4
|
-
import { ModalShell } from './modal-shell'
|
|
5
|
-
|
|
6
|
-
interface SnippetEditorModalProps {
|
|
7
|
-
activeField: 'name' | 'content'
|
|
8
|
-
snippetName: string
|
|
9
|
-
snippetContent: string
|
|
10
|
-
isEditing: boolean
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function SnippetEditorModal({
|
|
14
|
-
activeField,
|
|
15
|
-
isEditing,
|
|
16
|
-
snippetContent,
|
|
17
|
-
snippetName,
|
|
18
|
-
}: SnippetEditorModalProps) {
|
|
19
|
-
const t = useTokens()
|
|
20
|
-
const nameActive = activeField === 'name'
|
|
21
|
-
const contentActive = activeField === 'content'
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<ModalShell
|
|
25
|
-
title={isEditing ? 'Edit snippet' : 'Create snippet'}
|
|
26
|
-
keybindsModeId="modal.snippet-editor"
|
|
27
|
-
width={uiTokens.modalWidth.xl}
|
|
28
|
-
>
|
|
29
|
-
<box flexDirection="column">
|
|
30
|
-
<text fg={nameActive ? t.palette.ink : t.muted}>Name</text>
|
|
31
|
-
<InputField active={nameActive} value={snippetName} />
|
|
32
|
-
</box>
|
|
33
|
-
|
|
34
|
-
<box flexDirection="column">
|
|
35
|
-
<text fg={contentActive ? t.palette.ink : t.muted}>Content</text>
|
|
36
|
-
<InputField active={contentActive} value={snippetContent} />
|
|
37
|
-
</box>
|
|
38
|
-
</ModalShell>
|
|
39
|
-
)
|
|
40
|
-
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/src/ui/components/{use-sidebar-auto-scroll.ts → layout/sidebar/use-sidebar-auto-scroll.ts}
RENAMED
|
File without changes
|