@young1lin/dsh-ui-gitworkbench 0.1.11 → 0.1.13
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/AGENTS.md +1 -1
- package/CHANGELOG.md +28 -0
- package/CHANGELOG_EN.md +28 -0
- package/README.md +100 -50
- package/README_EN.md +2 -1
- package/lib/client.js +6902 -6256
- package/package.json +1 -1
- package/src/client/ChangesFileTree.tsx +550 -0
- package/src/client/ChromeGlyph.tsx +27 -0
- package/src/client/CodeEditor.tsx +129 -3
- package/src/client/CommitHistory.tsx +1001 -0
- package/src/client/DiffViews.tsx +1070 -0
- package/src/client/GitWorkbenchPanel.module.css +15 -2513
- package/src/client/GitWorkbenchPanel.tsx +37 -3959
- package/src/client/PaneDivider.tsx +74 -0
- package/src/client/WorkbenchControls.tsx +1047 -0
- package/src/client/WorktreeGlyph.tsx +24 -0
- package/src/client/cm-search-theme.ts +250 -0
- package/src/client/diff-nav.ts +59 -0
- package/src/client/git-workbench-types.ts +252 -0
- package/src/client/locales.ts +14 -8
- package/src/client/row-window.ts +23 -0
- package/src/client/search-count.ts +125 -0
- package/src/client/side-rows.ts +66 -0
- package/src/client/styles/changes.css +505 -0
- package/src/client/styles/controls.css +236 -0
- package/src/client/styles/environment.css +89 -0
- package/src/client/styles/files.css +113 -0
- package/src/client/styles/history-filters.css +400 -0
- package/src/client/styles/history.css +276 -0
- package/src/client/styles/image.css +67 -0
- package/src/client/styles/operations.css +179 -0
- package/src/client/styles/shell.css +431 -0
- package/src/client/styles/themes.css +224 -0
- package/src/client/use-change-nav.ts +6 -5
- package/src/client/use-row-window.ts +55 -0
|
@@ -0,0 +1,1070 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Fragment, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore,
|
|
3
|
+
type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode,
|
|
4
|
+
} from 'react'
|
|
5
|
+
|
|
6
|
+
import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
|
|
7
|
+
import { parsePatch } from '../patch-model.ts'
|
|
8
|
+
import { alignRows, allBlockLines, allBlockTally, blockActionsDisabled, blockCount, blockEdge, blockIsWholeFile, blockLines, blockTally, currentActionBlock, needsFirstBlockClearance, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
|
|
9
|
+
import { anchorFor, blockNearestTo, blockTopsFromRows, blockTopsFromSideRows, countBlocks, scrollTopFor, stepBlockIndex, unifiedBlocks } from './diff-nav.ts'
|
|
10
|
+
import { DIFF_GRID_PAD_TOP, DIFF_ROW_H } from './row-window.ts'
|
|
11
|
+
import { useChangeNav } from './use-change-nav.ts'
|
|
12
|
+
import {
|
|
13
|
+
applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, isDirty, markConflict, reloadSides, resetSides,
|
|
14
|
+
type EditState, type WriteResult,
|
|
15
|
+
} from './side-edit.ts'
|
|
16
|
+
import { PaneDivider } from './PaneDivider.tsx'
|
|
17
|
+
import { CodeEditor, type PaintFn } from './CodeEditor.tsx'
|
|
18
|
+
import { detectIndent } from './indent.ts'
|
|
19
|
+
import { grammarLoadCount, highlightForRowsWindow, highlightRange, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
20
|
+
import { useRowWindow } from './use-row-window.ts'
|
|
21
|
+
import type { BlockAsk, BlockMode, FileSides, GitOpResult, SideLayer, Translate } from './git-workbench-types.ts'
|
|
22
|
+
import css from './GitWorkbenchPanel.module.css'
|
|
23
|
+
|
|
24
|
+
const SPLIT_MIN = 0.15
|
|
25
|
+
const SPLIT_MAX = 0.85
|
|
26
|
+
const BLOCK_BAR_CLEARANCE = 21
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Change-to-change navigation, as two chevrons.
|
|
30
|
+
*
|
|
31
|
+
* Bootstrap Icons again, at the same 16 viewBox — a pair of arrows is what
|
|
32
|
+
* every editor spells this with, and the words would be longer than the
|
|
33
|
+
* controls beside them.
|
|
34
|
+
*/
|
|
35
|
+
const NAV_GLYPH = {
|
|
36
|
+
prev: 'M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6z',
|
|
37
|
+
next: 'M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z',
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
|
|
41
|
+
return (
|
|
42
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
43
|
+
<path d={NAV_GLYPH[of]} />
|
|
44
|
+
</svg>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ---------- diff rendering: rows, word-level ranges, syntax pass ---------- */
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render one file's unified-diff segment with word-level highlights and Shiki.
|
|
52
|
+
*
|
|
53
|
+
* This is what History and Compare show, and — unlike the side-by-side pane —
|
|
54
|
+
* it has no row model carrying block ids, because nothing here acts on a block:
|
|
55
|
+
* a commit's contents were decided long ago, so there is no staging and no
|
|
56
|
+
* roll-back. The walk still needs them, so the runs are read off the row kinds
|
|
57
|
+
* by `unifiedBlocks` and marked on the rows that scroll.
|
|
58
|
+
*
|
|
59
|
+
* The scroller is this component's own rather than the pane's. A bar that
|
|
60
|
+
* scrolls away is not a control, and the pane scrolls in BOTH directions —
|
|
61
|
+
* `sticky` fixes the vertical half and nothing fixes the horizontal one, since
|
|
62
|
+
* a block child of a scroller is only ever as wide as the scrollport. A header
|
|
63
|
+
* outside the scrolled box has neither problem.
|
|
64
|
+
*/
|
|
65
|
+
export function DiffView({ segment, path, palette, t }: {
|
|
66
|
+
segment: string
|
|
67
|
+
path: string
|
|
68
|
+
palette: string
|
|
69
|
+
t: Translate
|
|
70
|
+
}): ReactNode {
|
|
71
|
+
const lang = shikiLangOf(path)
|
|
72
|
+
const shikiTheme = shikiThemeOf(palette)
|
|
73
|
+
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
74
|
+
const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
|
|
75
|
+
const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
|
|
76
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
77
|
+
// Windowed for the same reason the side-by-side pane is: a unified diff of a
|
|
78
|
+
// long file put every row in the DOM and re-lexed every one of them, so
|
|
79
|
+
// opening one froze the pane in exactly the same way.
|
|
80
|
+
const win = useRowWindow(scrollRef, rowsWithWords.length, path)
|
|
81
|
+
const syntax = useMemo(
|
|
82
|
+
() => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
|
|
83
|
+
[rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
84
|
+
)
|
|
85
|
+
const blocks = useMemo(() => unifiedBlocks(rowsWithWords.map(row => row.kind)), [rowsWithWords])
|
|
86
|
+
const changes = useMemo(() => countBlocks(blocks), [blocks])
|
|
87
|
+
// Derived rather than measured, because a windowed pane has no element for
|
|
88
|
+
// the block being walked to.
|
|
89
|
+
const blocksForNav = useRef<readonly number[]>(blocks)
|
|
90
|
+
blocksForNav.current = blocks
|
|
91
|
+
const { goToChange } = useChangeNav(
|
|
92
|
+
scrollRef,
|
|
93
|
+
useCallback(() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
|
|
94
|
+
)
|
|
95
|
+
// Read by the key listener below, which is attached once. `goToChange` only
|
|
96
|
+
// ever touches refs, but pinning it here says so rather than relying on it.
|
|
97
|
+
const walk = useRef(goToChange)
|
|
98
|
+
walk.current = goToChange
|
|
99
|
+
|
|
100
|
+
// F7 / Shift+F7, the spelling IDEA's diff viewer taught, on the same element
|
|
101
|
+
// that scrolls — so the key and the buttons cannot disagree about which pane
|
|
102
|
+
// they move. `tabIndex` is what makes it able to receive the key at all:
|
|
103
|
+
// diff text is not focusable, and a click on it would otherwise leave focus
|
|
104
|
+
// on the document body.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const scroller = scrollRef.current
|
|
107
|
+
if (scroller === null) return
|
|
108
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
109
|
+
if (event.key !== 'F7') return
|
|
110
|
+
event.preventDefault()
|
|
111
|
+
walk.current(event.shiftKey ? -1 : 1)
|
|
112
|
+
}
|
|
113
|
+
scroller.addEventListener('keydown', onKey)
|
|
114
|
+
return () => { scroller.removeEventListener('keydown', onKey) }
|
|
115
|
+
}, [])
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className={css.diffWrap}>
|
|
119
|
+
{changes > 0 ? (
|
|
120
|
+
<div className={css.diffNav}>
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
className={css.blockBtn}
|
|
124
|
+
title={t('prevChangeHint')}
|
|
125
|
+
aria-label={t('prevChange')}
|
|
126
|
+
onClick={() => { goToChange(-1) }}
|
|
127
|
+
><NavGlyph of="prev" /></button>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
className={css.blockBtn}
|
|
131
|
+
title={t('nextChangeHint')}
|
|
132
|
+
aria-label={t('nextChange')}
|
|
133
|
+
onClick={() => { goToChange(1) }}
|
|
134
|
+
><NavGlyph of="next" /></button>
|
|
135
|
+
<span className={css.sideNavCount}>{t('changeCount', { n: changes })}</span>
|
|
136
|
+
</div>
|
|
137
|
+
) : null}
|
|
138
|
+
<div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
|
|
139
|
+
<pre className={css.diffPre}>
|
|
140
|
+
{win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
|
|
141
|
+
{rowsWithWords.slice(win.start, win.end).map((row, k) => {
|
|
142
|
+
const i = win.start + k
|
|
143
|
+
return (
|
|
144
|
+
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
|
|
145
|
+
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
146
|
+
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
147
|
+
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
148
|
+
{row.kind === 'add' ? '+' : row.kind === 'del' ? '−' : ''}
|
|
149
|
+
</span>
|
|
150
|
+
<span className={css.code}>{renderCode(row, syntax[i] ?? [])}</span>
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
})}
|
|
154
|
+
{win.padBottom > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padBottom}px` }} aria-hidden="true" /> : null}
|
|
155
|
+
</pre>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rowClass(kind: Row['kind']): string {
|
|
162
|
+
switch (kind) {
|
|
163
|
+
case 'add': return css.lineAdd
|
|
164
|
+
case 'del': return css.lineDel
|
|
165
|
+
case 'hunk': return css.lineHunk
|
|
166
|
+
default: return css.lineContext
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactNode {
|
|
171
|
+
if (row.kind === 'hunk') return row.text
|
|
172
|
+
const painted = overlayRanges(tokens.length > 0 ? tokens : [{ text: row.text }], row.ranges ?? [])
|
|
173
|
+
if (painted.length === 1 && painted[0]!.color === undefined && !painted[0]!.mark) return row.text
|
|
174
|
+
return painted.map((tok, i) => (
|
|
175
|
+
<span
|
|
176
|
+
key={i}
|
|
177
|
+
className={tok.mark ? (row.kind === 'add' ? css.wordAdd : css.wordDel) : undefined}
|
|
178
|
+
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
179
|
+
>{tok.text}</span>
|
|
180
|
+
))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ---------- side-by-side diff rendering (working tree only) ---------- */
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The working tree's per-file diff as IDEA shows it: one tab per layer of the
|
|
187
|
+
* index, two columns with the whole file, aligned row by row.
|
|
188
|
+
*
|
|
189
|
+
* The rows come from `side-rows.ts` over the layer's full-context diff, so the
|
|
190
|
+
* alignment is read off the diff rather than computed. A change block — a
|
|
191
|
+
* maximal run of changed rows — carries its own actions: hovering any of its
|
|
192
|
+
* cells outlines the whole block and floats its buttons (stage + roll back on
|
|
193
|
+
* the unstaged tab, unstage on the staged one). The click carries the block's
|
|
194
|
+
* hunk-line indices and the rendered diff's sha, so the host can prove the
|
|
195
|
+
* file has not changed since the pane drew it.
|
|
196
|
+
*
|
|
197
|
+
* The unstaged tab's right column is also EDITABLE (the staged one is not, by
|
|
198
|
+
* design: editing the index would mean writing a blob with no file behind it).
|
|
199
|
+
* Editing arms explicitly — never per keystroke — and the buffer's whole life
|
|
200
|
+
* against the file and the poll is `side-edit.ts`'s to decide: a refresh over
|
|
201
|
+
* a dirty buffer keeps the buffer, a file that moved underneath raises the
|
|
202
|
+
* reload-or-overwrite banner, and the one save path carries the sha the buffer
|
|
203
|
+
* is based on so the host can refuse a stale write. While editing, the layout
|
|
204
|
+
* trades the diff's hole-aligned grid for a dense editor column (same
|
|
205
|
+
* metrics, same gutter rhythm): one grid cannot stay diff-aligned AND hold a
|
|
206
|
+
* dense buffer whenever deletions outrun additions, and re-diffing per
|
|
207
|
+
* keystroke is exactly the editor-library work the first cut declines.
|
|
208
|
+
*
|
|
209
|
+
* `tooLarge` and `binary` fall back to the unified view the pane already had
|
|
210
|
+
* (history and compare keep it unconditionally), with a notice — a silently
|
|
211
|
+
* different view reads as a broken one, not a guarded one.
|
|
212
|
+
*/
|
|
213
|
+
export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
214
|
+
t: Translate
|
|
215
|
+
path: string
|
|
216
|
+
palette: string
|
|
217
|
+
statsPath: string | undefined
|
|
218
|
+
fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
219
|
+
/** Save the editor buffer; the host refuses a stale sha and nothing is written. */
|
|
220
|
+
writeChecked: (worktreePath: string | undefined, path: string, text: string, expectedSha: string, signal: AbortSignal) => Promise<WriteResult | null>
|
|
221
|
+
/** Names the view the fetch belongs to, as `viewKey` does for the diff cache. */
|
|
222
|
+
scopeKey: string
|
|
223
|
+
/** Refresh generation: a new one means the tree was re-read, so refetch. */
|
|
224
|
+
gen: number
|
|
225
|
+
/** The drawer's polled view of this file's HEAD-diff; a CHANGE in it means
|
|
226
|
+
* the drawer noticed the file move, so the pane refetches even between
|
|
227
|
+
* refresh generations — this is how the poll reaches a dirty buffer. */
|
|
228
|
+
fallbackSegment: string
|
|
229
|
+
fallbackLoading: boolean
|
|
230
|
+
/** Run one block action; a discard routes to the drawer's confirmation. */
|
|
231
|
+
onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
|
|
232
|
+
/** After a successful save: refresh the tree and the pane together. */
|
|
233
|
+
onSaved: () => void
|
|
234
|
+
/** Reports the buffer's dirty flag outward: the drawer guards every
|
|
235
|
+
* gesture that would drop the buffer (file selection, close, main tab)
|
|
236
|
+
* on it, so it must live where those gestures are handled. */
|
|
237
|
+
onDirtyChange: (dirty: boolean) => void
|
|
238
|
+
}): ReactNode {
|
|
239
|
+
const [layer, setLayer] = useState<SideLayer>('unstaged')
|
|
240
|
+
// How much of the pane the left column gets. Lives here rather than in the
|
|
241
|
+
// drawer so it is one setting for the pane, and survives a file switch —
|
|
242
|
+
// the reader sized the columns for how they read, not for one file.
|
|
243
|
+
const [split, setSplit] = useState(0.5)
|
|
244
|
+
const colsRef = useRef<HTMLDivElement>(null)
|
|
245
|
+
/** The pane's one vertical scroller — what "next change" moves. */
|
|
246
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
247
|
+
/** The block currently addressed by the fixed editor toolbar. Navigation and
|
|
248
|
+
* a direct click both update it; a refreshed diff is normalized by
|
|
249
|
+
* currentActionBlock before any Git action may use it. */
|
|
250
|
+
const [blockSelection, setBlockSelection] = useState({ key: '', block: 0 })
|
|
251
|
+
/** Side-pane navigation follows an explicit current hunk: the fixed action
|
|
252
|
+
* buttons and the counter must target the same block even after wheel
|
|
253
|
+
* scrolling. Read mode uses aligned-row geometry; Edit uses dense right-side
|
|
254
|
+
* line geometry. Both are memoized below and read only on a navigation key. */
|
|
255
|
+
const goToChange = (direction: 1 | -1): void => {
|
|
256
|
+
const current = blockSelection.key === rowWindowKey ? blockSelection.block : 0
|
|
257
|
+
const block = stepBlockIndex(totalBlocks, current, direction)
|
|
258
|
+
if (block === null) return
|
|
259
|
+
const tops = layer === 'unstaged' && edit.armed ? editorBlockTops : alignedBlockTops
|
|
260
|
+
const target = tops[block]
|
|
261
|
+
if (target !== undefined && scrollRef.current !== null) scrollRef.current.scrollTop = scrollTopFor(target.top)
|
|
262
|
+
setBlockSelection({ key: rowWindowKey, block })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const [sides, setSides] = useState<FileSides | null>(null)
|
|
266
|
+
// Set when the RPC itself failed — most plausibly a host half older than
|
|
267
|
+
// this client (the two halves reload on different cycles). The unified view
|
|
268
|
+
// still renders, so an old host costs the new pane, not the diff.
|
|
269
|
+
const [failed, setFailed] = useState(false)
|
|
270
|
+
// The block under the pointer, or null over context rows and gutters. Hover
|
|
271
|
+
// names the BLOCK, not the cell: the outline and the buttons belong to a
|
|
272
|
+
// whole run of rows, and a per-cell affordance would scatter them.
|
|
273
|
+
const [hotBlock, setHotBlock] = useState<number | null>(null)
|
|
274
|
+
// The block whose stage/unstage call is in flight, disabling its buttons.
|
|
275
|
+
const [pendingBlock, setPendingBlock] = useState<number | null>(null)
|
|
276
|
+
// The editable right column's state and its one save path. `saving` disables
|
|
277
|
+
// the controls for the call's duration; `saveFailed` carries a non-stale
|
|
278
|
+
// failure's sentence (the stale case is `edit.conflict`'s banner instead).
|
|
279
|
+
const [edit, setEdit] = useState<EditState>(DISARMED)
|
|
280
|
+
const [saving, setSaving] = useState(false)
|
|
281
|
+
const [saveFailed, setSaveFailed] = useState<{ title: string; detail: string } | null>(null)
|
|
282
|
+
// The layer a dirty-buffer tab switch is waiting on the reader to confirm.
|
|
283
|
+
const [pendingLayer, setPendingLayer] = useState<SideLayer | null>(null)
|
|
284
|
+
// Internal fetch generation: a stale save or the banner's reload refetch
|
|
285
|
+
// without waiting for the drawer's next refresh.
|
|
286
|
+
const [refetch, setRefetch] = useState(0)
|
|
287
|
+
// Which edit session the fetched payload belongs to, and what a landing
|
|
288
|
+
// payload may do with the buffer. The ref is read inside the fetch callback
|
|
289
|
+
// (which closes over a render that may be several states old by the time the
|
|
290
|
+
// answer arrives), and the adopt mode is consumed once by the next run.
|
|
291
|
+
const idRef = useRef('')
|
|
292
|
+
const adoptRef = useRef<'auto' | 'reload'>('auto')
|
|
293
|
+
const editRef = useRef(edit)
|
|
294
|
+
editRef.current = edit
|
|
295
|
+
|
|
296
|
+
// Switching tabs refetches: the two layers are different diffs of the same
|
|
297
|
+
// file, and neither is a transform of the other client-side. A change in the
|
|
298
|
+
// drawer's polled segment for this file refetches too — the poll's way of
|
|
299
|
+
// saying the file moved — which is what lets a change under a DIRTY buffer
|
|
300
|
+
// raise the banner within one poll interval instead of at the next refresh.
|
|
301
|
+
//
|
|
302
|
+
// Only a NEW file/layer/scope may blank the pane and disarm the editor; a
|
|
303
|
+
// refetch of the same identity keeps the current payload on screen until the
|
|
304
|
+
// answer lands, because blanking it would unmount the editor mid-keystroke.
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
const id = `${scopeKey}\x1f${path}\x1f${layer}`
|
|
307
|
+
const identityChanged = idRef.current !== id
|
|
308
|
+
if (identityChanged) idRef.current = id
|
|
309
|
+
const adopt = identityChanged ? 'reset' : adoptRef.current
|
|
310
|
+
adoptRef.current = 'auto'
|
|
311
|
+
const ctrl = new AbortController()
|
|
312
|
+
let alive = true
|
|
313
|
+
if (identityChanged) {
|
|
314
|
+
setSides(null)
|
|
315
|
+
setSaveFailed(null)
|
|
316
|
+
setPendingLayer(null)
|
|
317
|
+
}
|
|
318
|
+
if (identityChanged || !editRef.current.armed) setFailed(false)
|
|
319
|
+
fetchSides(statsPath, path, layer, ctrl.signal)
|
|
320
|
+
.then(value => {
|
|
321
|
+
if (!alive) return
|
|
322
|
+
// While armed, a failed background refetch keeps the pane as it is:
|
|
323
|
+
// dropping the editor over a transient RPC failure would cost the
|
|
324
|
+
// buffer's DOM (focus, IME composition) for no reader benefit.
|
|
325
|
+
if (value === null) {
|
|
326
|
+
if (!editRef.current.armed) setFailed(true)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
setSides(value)
|
|
330
|
+
setEdit(prev => adopt === 'reset' ? resetSides(prev, value)
|
|
331
|
+
: adopt === 'reload' ? reloadSides(prev, value)
|
|
332
|
+
: applySides(prev, value))
|
|
333
|
+
})
|
|
334
|
+
.catch(() => { if (alive && !editRef.current.armed) setFailed(true) })
|
|
335
|
+
return () => { alive = false; ctrl.abort() }
|
|
336
|
+
}, [fetchSides, statsPath, path, layer, scopeKey, gen, fallbackSegment, refetch])
|
|
337
|
+
|
|
338
|
+
const lang = shikiLangOf(path)
|
|
339
|
+
const shikiTheme = shikiThemeOf(palette)
|
|
340
|
+
const grammarGen = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)
|
|
341
|
+
const rows = useMemo(() => {
|
|
342
|
+
if (sides === null || sides.diff.length === 0) return []
|
|
343
|
+
const file = parsePatch(sides.diff)
|
|
344
|
+
// A diff with no hunk (mode-only change, or text patch-model cannot parse)
|
|
345
|
+
// has no rows to align; the no-change treatment below is the honest view.
|
|
346
|
+
return file === null ? [] : alignRows(file)
|
|
347
|
+
}, [sides])
|
|
348
|
+
/**
|
|
349
|
+
* How many separate places this file changed — the count the nav walks.
|
|
350
|
+
* Rows change when a diff is fetched, not when the editor buffer changes, so
|
|
351
|
+
* caching here keeps the document-wide scan off the keystroke path.
|
|
352
|
+
*/
|
|
353
|
+
const totalBlocks = useMemo(() => blockCount(rows), [rows])
|
|
354
|
+
// A toolbar rises above its block's first row. Horizontally scrolling columns
|
|
355
|
+
// clip overflow on both axes, so a file whose first row changed must reserve
|
|
356
|
+
// that space INSIDE each column. This depends on row shape, not hover, so the
|
|
357
|
+
// pointer cannot trigger a layout jump.
|
|
358
|
+
const blockBarClearance = needsFirstBlockClearance(rows) ? BLOCK_BAR_CLEARANCE : 0
|
|
359
|
+
const navOffset = DIFF_GRID_PAD_TOP + blockBarClearance
|
|
360
|
+
const alignedBlockTops = useMemo(
|
|
361
|
+
() => blockTopsFromRows(rows.map(row => row.block), DIFF_ROW_H, navOffset),
|
|
362
|
+
[rows, navOffset],
|
|
363
|
+
)
|
|
364
|
+
const editorBlockTops = useMemo(
|
|
365
|
+
() => blockTopsFromSideRows(rows, 'right', DIFF_ROW_H, navOffset),
|
|
366
|
+
[rows, navOffset],
|
|
367
|
+
)
|
|
368
|
+
// Highlight each column as one file — a row is not a program, and lexing
|
|
369
|
+
// fragments is what made the unified view paint keywords as plain text.
|
|
370
|
+
//
|
|
371
|
+
// These are UNDEFINED until a lazy grammar loads, and stay undefined for a
|
|
372
|
+
// file whose extension has no grammar at all (`go.mod`, `Dockerfile`), so
|
|
373
|
+
// every read below is optional-chained. `renderSideCode` already takes
|
|
374
|
+
// `undefined` and renders the plain text for it; what crashes is indexing
|
|
375
|
+
// the array itself, and `strict` is off in tsconfig, so the compiler will
|
|
376
|
+
// not say so.
|
|
377
|
+
// Only the rows the reader can see reach the DOM, and only they are re-lexed
|
|
378
|
+
// line by line. Declared here because both the render and the highlighting
|
|
379
|
+
// below are bounded by it.
|
|
380
|
+
const rowWindowKey = `${scopeKey}\x1f${path}\x1f${layer}\x1f${sides?.diffSha ?? ''}`
|
|
381
|
+
const win = useRowWindow(scrollRef, rows.length, rowWindowKey)
|
|
382
|
+
//
|
|
383
|
+
// Two passes with two lifetimes. The whole-file pass runs once per file and
|
|
384
|
+
// is what knows about block comments and template literals; the per-line
|
|
385
|
+
// re-lex — one Shiki call each, and the reason a 4,000-line file froze the
|
|
386
|
+
// pane for 2.8 seconds — runs only over the rows in the window, and so again
|
|
387
|
+
// whenever the reader scrolls.
|
|
388
|
+
const leftLines = useMemo(() => rows.map(row => row.left === null ? '' : row.left.text), [rows])
|
|
389
|
+
const rightLines = useMemo(() => rows.map(row => row.right === null ? '' : row.right.text), [rows])
|
|
390
|
+
const leftSyntax = useMemo(
|
|
391
|
+
() => highlightWindow(leftLines, lang, shikiTheme, win.start, win.end),
|
|
392
|
+
[leftLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
393
|
+
)
|
|
394
|
+
const rightSyntax = useMemo(
|
|
395
|
+
() => highlightWindow(rightLines, lang, shikiTheme, win.start, win.end),
|
|
396
|
+
[rightLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
/** The editor half of the pane, present only on the unstaged layer. */
|
|
400
|
+
const editable = layer === 'unstaged' && edit.armed
|
|
401
|
+
const dirty = isDirty(edit)
|
|
402
|
+
// Whether this payload may enter the editor at all: text carrying \r would
|
|
403
|
+
// be normalised to \n by the textarea the moment it landed, and the next
|
|
404
|
+
// save would rewrite every line ending in the file. The gate lives in
|
|
405
|
+
// `side-edit.ts` with the rest of the buffer's rules.
|
|
406
|
+
const armable = sides !== null && editableSides(sides)
|
|
407
|
+
// Which sentence the withheld editor gets: CRLF and a non-UTF-8 encoding are
|
|
408
|
+
// different problems, and one message for both leaves the reader guessing
|
|
409
|
+
// whether converting line endings would help.
|
|
410
|
+
const refusal = sides === null ? null : armRefusal(sides)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
// The drawer guards every gesture that would drop the buffer — selecting
|
|
414
|
+
// another file, closing, switching the main tab — so it needs the flag as
|
|
415
|
+
// it changes, not at click time from a stale render. Reported on the FLAG
|
|
416
|
+
// (not the buffer) so it fires on the transitions that matter; the cleanup
|
|
417
|
+
// clears it when this pane unmounts, so no orphaned flag prompts later.
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
onDirtyChange(dirty)
|
|
420
|
+
return () => { onDirtyChange(false) }
|
|
421
|
+
}, [dirty, onDirtyChange])
|
|
422
|
+
// The buffer's lines and their highlight, for the editor's underlay: the
|
|
423
|
+
// visible text under the transparent textarea, which is what keeps syntax
|
|
424
|
+
// coloring and the caret on the same grid while typing.
|
|
425
|
+
const bufferLines = useMemo(() => edit.buffer.split('\n'), [edit.buffer])
|
|
426
|
+
// What Tab inserts, learned from the file rather than configured. Keyed on
|
|
427
|
+
// the BASE text, not the buffer: re-detecting mid-edit would let a couple of
|
|
428
|
+
// freshly typed lines redefine the unit under the reader's hands.
|
|
429
|
+
const indentOfBuffer = useMemo(() => detectIndent(edit.baseText), [edit.baseText])
|
|
430
|
+
// The index side as one text, which is what the editor tints against while
|
|
431
|
+
// the reader types. It is the diff's own left column joined back up — every
|
|
432
|
+
// row of a full-context diff carries a left cell unless the line is an
|
|
433
|
+
// addition, which by definition is not on that side.
|
|
434
|
+
const indexText = useMemo(() => {
|
|
435
|
+
const left = rows.filter(row => row.left !== null).map(row => row.left!.text)
|
|
436
|
+
return left.length === 0 ? '' : left.join('\n') + '\n'
|
|
437
|
+
}, [rows])
|
|
438
|
+
// The editor's buffer is a whole file, so it takes the file pass rather than
|
|
439
|
+
// the diff's per-line re-lex — but only over the lines it is showing. The
|
|
440
|
+
// editor asks for a range as it scrolls, and `token-cache.ts` remembers what
|
|
441
|
+
// came back; a pass over the whole buffer was 1,637ms at 1,837 lines, paid
|
|
442
|
+
// again every time the reader stopped typing.
|
|
443
|
+
const editPaint = useMemo<PaintFn | null>(() => {
|
|
444
|
+
if (lang === undefined) return null
|
|
445
|
+
const key = 'buffer:' + statsPath + ':' + path
|
|
446
|
+
return (lines, from, to) => highlightRange(key, lines, lang, shikiTheme, from, to)
|
|
447
|
+
// `grammarGen` changes nothing computed here; the new identity is what
|
|
448
|
+
// makes the editor repaint once a lazy grammar has landed.
|
|
449
|
+
}, [lang, shikiTheme, statsPath, path, grammarGen])
|
|
450
|
+
// The left column while editing renders dense — one row per INDEX line, no
|
|
451
|
+
// holes — because the right column is now the dense buffer; a hole-aligned
|
|
452
|
+
// left beside a dense right is the alignment the diff view owes, not the
|
|
453
|
+
// editor. Each entry keeps its index into `rows` for its syntax tokens.
|
|
454
|
+
const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
|
|
455
|
+
|
|
456
|
+
// Only the rows the reader can see reach the DOM. Two windows because the
|
|
457
|
+
// two columns render two different row lists while the editor is armed: the
|
|
458
|
+
// right side is a buffer, and the left side is then the index side DENSE,
|
|
459
|
+
// one row per index line rather than one per aligned row.
|
|
460
|
+
const leftWin = useRowWindow(scrollRef, leftRows.length, rowWindowKey)
|
|
461
|
+
|
|
462
|
+
// Arming drops the caret straight into the buffer: the click that armed the
|
|
463
|
+
// editor said "I want to type here", and a second click to focus is a tax.
|
|
464
|
+
|
|
465
|
+
/** Arm the editor from the payload on screen; the unstaged tab, and only
|
|
466
|
+
* for a payload `editableSides` accepts — armEdit itself refuses the rest,
|
|
467
|
+
* so even a stray call cannot put CRLF text into the buffer. */
|
|
468
|
+
const arm = (block?: number): void => {
|
|
469
|
+
if (sides === null || layer !== 'unstaged' || !editableSides(sides)) return
|
|
470
|
+
const viewport = scrollRef.current === null ? 0 : anchorFor(scrollRef.current.scrollTop)
|
|
471
|
+
setBlockSelection({ key: rowWindowKey, block: block ?? blockNearestTo(alignedBlockTops, viewport)?.block ?? 0 })
|
|
472
|
+
setEdit(prev => armEdit(prev, sides))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The one save path, shared by the Save button, Ctrl/Cmd+S and the banner's
|
|
477
|
+
* overwrite action — they differ only in WHICH sha the host is asked to
|
|
478
|
+
* check: the buffer's basis for a save, the file as it stands NOW for an
|
|
479
|
+
* explicit overwrite of a concurrent writer's version.
|
|
480
|
+
*
|
|
481
|
+
* On success the basis moves to the sha the host read back and the drawer
|
|
482
|
+
* refreshes (tree and pane together). On `stale` the banner goes up and the
|
|
483
|
+
* pane refetches WITHOUT touching the buffer, so the banner's reload and
|
|
484
|
+
* overwrite actions read the file's true current state. Everything else is
|
|
485
|
+
* a failed save with a sentence.
|
|
486
|
+
*/
|
|
487
|
+
const runSave = async (expectedSha: string): Promise<void> => {
|
|
488
|
+
if (sides === null || !dirty || saving) return
|
|
489
|
+
const savedText = edit.buffer
|
|
490
|
+
// The edit session this save belongs to. A slow RPC can outlive a file or
|
|
491
|
+
// layer switch, and applying THIS save's outcome to the NEXT file's edit
|
|
492
|
+
// state would re-base that buffer onto text it never held — so every
|
|
493
|
+
// pane-local effect below is gated on the session still being current.
|
|
494
|
+
// The tree refresh on success is not: the file on disk did move.
|
|
495
|
+
const session = idRef.current
|
|
496
|
+
setSaving(true)
|
|
497
|
+
try {
|
|
498
|
+
const result = await writeChecked(statsPath, path, savedText, expectedSha, new AbortController().signal)
|
|
499
|
+
const stillHere = idRef.current === session
|
|
500
|
+
if (result === null) {
|
|
501
|
+
if (stillHere) setSaveFailed({ title: t('saveUnavailable'), detail: '' })
|
|
502
|
+
} else if (result.ok) {
|
|
503
|
+
if (stillHere) {
|
|
504
|
+
setSaveFailed(null)
|
|
505
|
+
setEdit(prev => applySaveOk(prev, savedText, result.sha ?? ''))
|
|
506
|
+
}
|
|
507
|
+
onSaved()
|
|
508
|
+
} else if (result.failure === 'stale') {
|
|
509
|
+
if (stillHere) {
|
|
510
|
+
setEdit(prev => markConflict(prev))
|
|
511
|
+
setRefetch(n => n + 1)
|
|
512
|
+
}
|
|
513
|
+
} else {
|
|
514
|
+
if (stillHere) setSaveFailed({ title: t('saveFailed'), detail: (result.error ?? '').trim() })
|
|
515
|
+
}
|
|
516
|
+
} finally {
|
|
517
|
+
setSaving(false)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The banner's two answers to a file that moved underneath. Overwrite may
|
|
523
|
+
* only run once the post-refusal refetch has landed (the fresh targetSha is
|
|
524
|
+
* what the host checks the overwrite against); until then the button waits,
|
|
525
|
+
* because re-sending the refused sha would just refuse again.
|
|
526
|
+
*/
|
|
527
|
+
const canOverwrite = dirty && edit.conflict && sides !== null && sides.targetSha !== edit.baseSha
|
|
528
|
+
const overwrite = (): Promise<void> => sides === null ? Promise.resolve() : runSave(sides.targetSha)
|
|
529
|
+
/** Reload: the reader chose the file over the buffer; drop the edits. */
|
|
530
|
+
const reload = (): void => {
|
|
531
|
+
setSaveFailed(null)
|
|
532
|
+
adoptRef.current = 'reload'
|
|
533
|
+
setRefetch(n => n + 1)
|
|
534
|
+
}
|
|
535
|
+
/** Revert the buffer to its basis, in place; the conflict flag stands. */
|
|
536
|
+
const revert = (): void => {
|
|
537
|
+
setSaveFailed(null)
|
|
538
|
+
setEdit(prev => ({ ...prev, buffer: prev.baseText }))
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* A layer tab is one click away from dropping the buffer: with unsaved
|
|
543
|
+
* edits the click asks first, and only the dialog's answer switches.
|
|
544
|
+
*/
|
|
545
|
+
const switchLayer = (next: SideLayer): void => {
|
|
546
|
+
if (next === layer) return
|
|
547
|
+
if (dirty) {
|
|
548
|
+
setPendingLayer(next)
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
setLayer(next)
|
|
552
|
+
}
|
|
553
|
+
const confirmLeave = (): void => {
|
|
554
|
+
const next = pendingLayer
|
|
555
|
+
setPendingLayer(null)
|
|
556
|
+
if (next !== null) setLayer(next)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Ctrl/Cmd+S inside the pane: the editor's other save affordance. */
|
|
560
|
+
const onPaneKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>): void => {
|
|
561
|
+
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
|
|
562
|
+
event.preventDefault()
|
|
563
|
+
if (dirty && !saving) void runSave(edit.baseSha)
|
|
564
|
+
}
|
|
565
|
+
// F7 and Shift+F7, the spelling IDEA's diff viewer taught. Chosen over
|
|
566
|
+
// Alt+Arrow because CodeMirror's default keymap binds those to moving a
|
|
567
|
+
// line, and the armed editor lives inside this same pane.
|
|
568
|
+
if (event.key === 'F7') {
|
|
569
|
+
event.preventDefault()
|
|
570
|
+
goToChange(event.shiftKey ? -1 : 1)
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* One bubbling hover listener turns the cell under the pointer into its
|
|
576
|
+
* block id: every changed row's code cells carry `data-block`, so `closest`
|
|
577
|
+
* reads the block off whatever the pointer is over — no handler per cell,
|
|
578
|
+
* and a pointer over context or a gutter simply clears the hot block.
|
|
579
|
+
*/
|
|
580
|
+
/**
|
|
581
|
+
* The divider: a ratio, not a pixel width, so the columns keep their
|
|
582
|
+
* proportion when the drawer itself is resized.
|
|
583
|
+
*
|
|
584
|
+
* Clamped well short of either edge — a column dragged to nothing looks
|
|
585
|
+
* like a broken pane, and there is no affordance to drag it back out of.
|
|
586
|
+
*/
|
|
587
|
+
const onSplitDrag = (clientX: number): void => {
|
|
588
|
+
const box = colsRef.current?.getBoundingClientRect()
|
|
589
|
+
if (box === undefined || box.width === 0) return
|
|
590
|
+
const ratio = (clientX - box.left) / box.width
|
|
591
|
+
setSplit(Math.min(SPLIT_MAX, Math.max(SPLIT_MIN, ratio)))
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const onBodyHover = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
|
595
|
+
const hit = (event.target as Element).closest('[data-block]')
|
|
596
|
+
const id = hit === null ? null : Number(hit.getAttribute('data-block'))
|
|
597
|
+
setHotBlock(prev => (prev === id ? prev : id))
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const onBodySelect = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
|
601
|
+
const hit = (event.target as Element).closest('[data-block]')
|
|
602
|
+
if (hit === null) return
|
|
603
|
+
const block = Number(hit.getAttribute('data-block'))
|
|
604
|
+
if (Number.isInteger(block) && block >= 0) setBlockSelection({ key: rowWindowKey, block })
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Run one block action with the coordinates of the diff on screen.
|
|
609
|
+
*
|
|
610
|
+
* Discard never acts from the click — the drawer opens the confirmation,
|
|
611
|
+
* and the confirmed call carries this same snapshot, so a file that moved
|
|
612
|
+
* underneath the dialog is refused host-side rather than re-derived from
|
|
613
|
+
* whatever the poll has fetched since. Stage and unstage run now; the
|
|
614
|
+
* clicked block's buttons stay disabled until the answer lands, and the
|
|
615
|
+
* drawer's op lock refuses any other block click meanwhile.
|
|
616
|
+
*/
|
|
617
|
+
const runBlock = async (mode: BlockMode, block: number): Promise<void> => {
|
|
618
|
+
if (sides === null) return
|
|
619
|
+
const ask: BlockAsk = {
|
|
620
|
+
path, layer, diffSha: sides.diffSha,
|
|
621
|
+
lines: blockLines(rows, block),
|
|
622
|
+
...blockTally(rows, block),
|
|
623
|
+
wholeFile: blockIsWholeFile(rows, block),
|
|
624
|
+
}
|
|
625
|
+
if (mode === 'discard') {
|
|
626
|
+
void onBlockAction(mode, ask)
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
setPendingBlock(block)
|
|
630
|
+
try {
|
|
631
|
+
await onBlockAction(mode, ask)
|
|
632
|
+
} finally {
|
|
633
|
+
setPendingBlock(null)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Unstage the complete staged layer with the same stale-sha checked patch
|
|
638
|
+
* path as a hunk action. The scan happens only on this explicit click. */
|
|
639
|
+
const runAllBlocks = async (mode: 'unstage'): Promise<void> => {
|
|
640
|
+
if (sides === null) return
|
|
641
|
+
const ask: BlockAsk = {
|
|
642
|
+
path, layer, diffSha: sides.diffSha,
|
|
643
|
+
lines: allBlockLines(rows),
|
|
644
|
+
...allBlockTally(rows),
|
|
645
|
+
wholeFile: false,
|
|
646
|
+
}
|
|
647
|
+
setPendingBlock(-1)
|
|
648
|
+
try {
|
|
649
|
+
await onBlockAction(mode, ask)
|
|
650
|
+
} finally {
|
|
651
|
+
setPendingBlock(null)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** The pane the drawer had before this view existed, notice included. */
|
|
656
|
+
const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
|
|
657
|
+
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
|
|
658
|
+
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
|
|
659
|
+
|
|
660
|
+
if (failed) return unifiedFallback()
|
|
661
|
+
if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
|
|
662
|
+
if (sides.binary) return <div className={css.empty}>{t('binaryFile')}</div>
|
|
663
|
+
if (sides.tooLarge) {
|
|
664
|
+
return (
|
|
665
|
+
<>
|
|
666
|
+
<div className={css.sideNotice}>{t('diffTooLarge')}</div>
|
|
667
|
+
{unifiedFallback()}
|
|
668
|
+
</>
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
// The tabs are the pane's, not one layer's: an empty diff here (a fully
|
|
672
|
+
// staged file's unstaged side, a file with nothing staged) is one click from
|
|
673
|
+
// the other layer, and the Edit button still arms — the working tree is the
|
|
674
|
+
// edit target even when every change in it is already staged. So the
|
|
675
|
+
// no-change treatment below is a state of the BODY (`sideBodyState`), never
|
|
676
|
+
// an early return for the pane: returning here is what used to blank the
|
|
677
|
+
// tabs for exactly these files.
|
|
678
|
+
const bodyState = sideBodyState(rows, editable)
|
|
679
|
+
// The fixed toolbar exists for both layers: unstaged offers Stage/Revert,
|
|
680
|
+
// staged offers Unstage. Edit changes geometry and disabled state, not the
|
|
681
|
+
// existence of the escape route.
|
|
682
|
+
const changes = bodyState.kind === 'empty' ? 0 : totalBlocks
|
|
683
|
+
const selectedBlock = blockSelection.key === rowWindowKey ? blockSelection.block : 0
|
|
684
|
+
const currentBlock = currentActionBlock(totalBlocks, bodyState.kind !== 'empty', selectedBlock)
|
|
685
|
+
// The hovered block's first row hosts the action bar; a del-only block has
|
|
686
|
+
// no right cell, so its bar rides the left one instead. In the editor
|
|
687
|
+
// layout the left column is dense, so the bar rides its first left row.
|
|
688
|
+
const hotFirst = hotBlock === null ? -1 : rows.findIndex(row => row.block === hotBlock)
|
|
689
|
+
const hotFirstLeft = hotBlock === null ? -1 : leftRows.findIndex(entry => entry.row.block === hotBlock)
|
|
690
|
+
// Dirty buffer, disabled block actions: a patch computed from the loaded
|
|
691
|
+
// diff would land on top of edits the patch knows nothing about.
|
|
692
|
+
const barDisabled = blockActionsDisabled(dirty, pendingBlock)
|
|
693
|
+
const blockButtons = (block: number): ReactNode => layer === 'staged' ? (
|
|
694
|
+
<button
|
|
695
|
+
type="button"
|
|
696
|
+
className={css.blockBtn}
|
|
697
|
+
disabled={barDisabled}
|
|
698
|
+
title={dirty ? t('blockActionsDirty') : undefined}
|
|
699
|
+
onClick={() => { void runBlock('unstage', block) }}
|
|
700
|
+
>{t('blockUnstage')}</button>
|
|
701
|
+
) : (
|
|
702
|
+
<>
|
|
703
|
+
<button
|
|
704
|
+
type="button"
|
|
705
|
+
className={css.blockBtn}
|
|
706
|
+
disabled={barDisabled}
|
|
707
|
+
title={dirty ? t('blockActionsDirty') : undefined}
|
|
708
|
+
onClick={() => { void runBlock('stage', block) }}
|
|
709
|
+
>{t('blockStage')}</button>
|
|
710
|
+
<button
|
|
711
|
+
type="button"
|
|
712
|
+
className={`${css.blockBtn} ${css.blockBtnDanger}`}
|
|
713
|
+
disabled={barDisabled}
|
|
714
|
+
title={dirty ? t('blockActionsDirty') : undefined}
|
|
715
|
+
onClick={() => { void runBlock('discard', block) }}
|
|
716
|
+
>{t('blockDiscard')}</button>
|
|
717
|
+
</>
|
|
718
|
+
)
|
|
719
|
+
const blockBar = (block: number): ReactNode => (
|
|
720
|
+
<span className={css.blockBar} style={{ top: -BLOCK_BAR_CLEARANCE }}>
|
|
721
|
+
{blockButtons(block)}
|
|
722
|
+
</span>
|
|
723
|
+
)
|
|
724
|
+
/** Clicking the working-tree column is the arm gesture readers will try
|
|
725
|
+
* first — unless the click was really a text selection, or landed on a
|
|
726
|
+
* block button, in which case it keeps its own meaning. */
|
|
727
|
+
const armFromCell = (event: ReactMouseEvent<HTMLSpanElement>): void => {
|
|
728
|
+
if (edit.armed) return
|
|
729
|
+
if ((event.target as Element).closest('button') !== null) return
|
|
730
|
+
const selection = window.getSelection()
|
|
731
|
+
if (selection !== null && !selection.isCollapsed) return
|
|
732
|
+
const block = Number(event.currentTarget.dataset.block)
|
|
733
|
+
arm(Number.isInteger(block) && block >= 0 ? block : undefined)
|
|
734
|
+
}
|
|
735
|
+
return (
|
|
736
|
+
/* `tabIndex={-1}` is what makes F7 reachable. The handler below is on
|
|
737
|
+
this element, so it only sees keys whose target is inside it — and
|
|
738
|
+
clicking diff text, which is not focusable, otherwise leaves focus on
|
|
739
|
+
the document body and the key never arrives. A negative index keeps the
|
|
740
|
+
pane out of the tab order while letting a click land focus here. */
|
|
741
|
+
<div className={css.sidePane} tabIndex={-1} onKeyDown={onPaneKeyDown}>
|
|
742
|
+
<div className={css.sideTabs}>
|
|
743
|
+
<button
|
|
744
|
+
type="button"
|
|
745
|
+
aria-pressed={layer === 'unstaged'}
|
|
746
|
+
className={layer === 'unstaged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
|
|
747
|
+
onClick={() => switchLayer('unstaged')}
|
|
748
|
+
>{t('tabUnstaged')}</button>
|
|
749
|
+
<button
|
|
750
|
+
type="button"
|
|
751
|
+
aria-pressed={layer === 'staged'}
|
|
752
|
+
className={layer === 'staged' ? `${css.sideTab} ${css.sideTabActive}` : css.sideTab}
|
|
753
|
+
onClick={() => switchLayer('staged')}
|
|
754
|
+
>{t('tabStaged')}</button>
|
|
755
|
+
{/* A file whose whole delta is one line is unfindable by scrolling:
|
|
756
|
+
the tint only shows once you are already looking at it. The row
|
|
757
|
+
model knows where every change is, so these two say so. The count
|
|
758
|
+
is the other half of the answer — "there is one place to look" is
|
|
759
|
+
what stops the hunt. */}
|
|
760
|
+
{changes > 0 ? (
|
|
761
|
+
<span className={css.sideNav}>
|
|
762
|
+
<button
|
|
763
|
+
type="button"
|
|
764
|
+
className={css.blockBtn}
|
|
765
|
+
title={t('prevChangeHint')}
|
|
766
|
+
aria-label={t('prevChange')}
|
|
767
|
+
onClick={() => { goToChange(-1) }}
|
|
768
|
+
><NavGlyph of="prev" /></button>
|
|
769
|
+
<button
|
|
770
|
+
type="button"
|
|
771
|
+
className={css.blockBtn}
|
|
772
|
+
title={t('nextChangeHint')}
|
|
773
|
+
aria-label={t('nextChange')}
|
|
774
|
+
onClick={() => { goToChange(1) }}
|
|
775
|
+
><NavGlyph of="next" /></button>
|
|
776
|
+
<span className={css.sideNavCount}>{currentBlock === null
|
|
777
|
+
? t('changeCount', { n: changes })
|
|
778
|
+
: t('changePosition', { current: currentBlock + 1, total: changes })}</span>
|
|
779
|
+
</span>
|
|
780
|
+
) : null}
|
|
781
|
+
{currentBlock !== null ? (
|
|
782
|
+
<span className={css.sideCurrentBlockActions}>
|
|
783
|
+
{layer !== 'staged' || totalBlocks > 1 ? blockButtons(currentBlock) : null}
|
|
784
|
+
{layer === 'staged' ? (
|
|
785
|
+
<button
|
|
786
|
+
type="button"
|
|
787
|
+
className={css.blockBtn}
|
|
788
|
+
disabled={barDisabled}
|
|
789
|
+
onClick={() => { void runAllBlocks('unstage') }}
|
|
790
|
+
>{t('fileUnstage')}</button>
|
|
791
|
+
) : null}
|
|
792
|
+
</span>
|
|
793
|
+
) : null}
|
|
794
|
+
{/* Editing arms explicitly and saves explicitly — the two halves of
|
|
795
|
+
"never per keystroke". Save enables only while dirty; Revert drops
|
|
796
|
+
the buffer back onto its basis without touching the file. A
|
|
797
|
+
payload the CRLF gate refuses offers no Edit button at all — the
|
|
798
|
+
notice below says why rather than leaving a button that does
|
|
799
|
+
nothing. */}
|
|
800
|
+
{layer === 'unstaged' ? (
|
|
801
|
+
<span className={`${css.sideActions}${currentBlock !== null ? ` ${css.sideActionsAdjacent}` : ''}`}>
|
|
802
|
+
{edit.armed ? (
|
|
803
|
+
<>
|
|
804
|
+
<button
|
|
805
|
+
type="button"
|
|
806
|
+
className={`${css.blockBtn}${dirty ? ` ${css.sideSaveReady}` : ''}`}
|
|
807
|
+
disabled={!dirty || saving}
|
|
808
|
+
onClick={() => { void runSave(edit.baseSha) }}
|
|
809
|
+
>{t('fileSave')}</button>
|
|
810
|
+
<button
|
|
811
|
+
type="button"
|
|
812
|
+
className={css.blockBtn}
|
|
813
|
+
disabled={!dirty || saving}
|
|
814
|
+
onClick={revert}
|
|
815
|
+
>{t('fileRevert')}</button>
|
|
816
|
+
</>
|
|
817
|
+
) : armable ? (
|
|
818
|
+
<button type="button" className={css.blockBtn} onClick={() => arm()}>{t('editFile')}</button>
|
|
819
|
+
) : null}
|
|
820
|
+
</span>
|
|
821
|
+
) : null}
|
|
822
|
+
</div>
|
|
823
|
+
{dirty ? <div className={css.sideNotice}>{t('editingNotice')}</div> : null}
|
|
824
|
+
{layer === 'unstaged' && refusal !== null && !edit.armed ? <div className={css.sideNotice}>{t(refusal === 'encoding' ? 'encodingNotice' : 'crlfNotice')}</div> : null}
|
|
825
|
+
{/* §4's row: the file moved underneath a dirty buffer — by the poll's
|
|
826
|
+
notice or by a refused save — and the reader chooses which version
|
|
827
|
+
survives. Overwrite waits for the refetch the refusal triggered, so
|
|
828
|
+
it is checked against the file as it truly stands. */}
|
|
829
|
+
{dirty && edit.conflict ? (
|
|
830
|
+
<div className={css.sideBanner} role="alert">
|
|
831
|
+
<span className={css.sideBannerTitle}>{t('staleTitle')}</span>
|
|
832
|
+
<span>{t('staleBody')}</span>
|
|
833
|
+
<span className={css.sideBannerActs}>
|
|
834
|
+
<button type="button" className={`${css.blockBtn} ${css.blockBtnDanger}`} disabled={saving} onClick={reload}>{t('staleReload')}</button>
|
|
835
|
+
<button type="button" className={css.blockBtn} disabled={!canOverwrite || saving} onClick={() => { void overwrite() }}>{t('staleOverwrite')}</button>
|
|
836
|
+
</span>
|
|
837
|
+
</div>
|
|
838
|
+
) : null}
|
|
839
|
+
{saveFailed !== null ? (
|
|
840
|
+
<div className={css.sideBanner} role="alert">
|
|
841
|
+
<span className={css.sideBannerTitle}>{saveFailed.title}</span>
|
|
842
|
+
{saveFailed.detail.length > 0 ? <span>{saveFailed.detail}</span> : null}
|
|
843
|
+
<span className={css.sideBannerActs}>
|
|
844
|
+
<button type="button" className={css.blockBtn} disabled={!dirty || saving} onClick={() => { void runSave(edit.baseSha) }}>{t('saveRetry')}</button>
|
|
845
|
+
</span>
|
|
846
|
+
</div>
|
|
847
|
+
) : null}
|
|
848
|
+
{/* Two columns that scroll sideways independently, with a divider the
|
|
849
|
+
reader can drag. One grid spanning both sides could not do this: its
|
|
850
|
+
tracks are sized by the widest line in the file, so a drag moved
|
|
851
|
+
nothing on exactly the wide files where the space matters. Vertical
|
|
852
|
+
alignment survives the split because both columns render one row per
|
|
853
|
+
aligned row at the same line height — the diff decides the rows, the
|
|
854
|
+
layout only decides how much width each side gets. */}
|
|
855
|
+
<div ref={scrollRef} className={css.sideScroll}>
|
|
856
|
+
{bodyState.kind === 'empty' ? (
|
|
857
|
+
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
858
|
+
) : (
|
|
859
|
+
<div
|
|
860
|
+
ref={colsRef}
|
|
861
|
+
className={css.sideCols}
|
|
862
|
+
onMouseOver={onBodyHover}
|
|
863
|
+
onMouseDown={onBodySelect}
|
|
864
|
+
onMouseLeave={() => { setHotBlock(null) }}
|
|
865
|
+
>
|
|
866
|
+
<div className={css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
|
|
867
|
+
<div className={css.sideColGrid}>
|
|
868
|
+
{bodyState.kind === 'editor' ? (
|
|
869
|
+
/* While armed the left column renders the index side DENSE —
|
|
870
|
+
one row per index line, no diff holes — because the right
|
|
871
|
+
column is a buffer whose line count diverges from the diff
|
|
872
|
+
the moment a keystroke lands. */
|
|
873
|
+
<>
|
|
874
|
+
<RowSpacer height={leftWin.padTop} />
|
|
875
|
+
{leftRows.slice(leftWin.start, leftWin.end).map((entry, kk) => {
|
|
876
|
+
const k = leftWin.start + kk
|
|
877
|
+
const { row, i } = entry
|
|
878
|
+
const hot = hotBlock !== null && row.block === hotBlock
|
|
879
|
+
const current = row.block >= 0 && row.block === currentBlock
|
|
880
|
+
const hotClass = blockHotClass(rows, i, 'left', current)
|
|
881
|
+
return (
|
|
882
|
+
<Fragment key={`l${i}`}>
|
|
883
|
+
<span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
|
|
884
|
+
<span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.left !== null && row.block >= 0 ? row.block : undefined}>
|
|
885
|
+
{renderSideCode(row.left, leftSyntax?.[i])}
|
|
886
|
+
{hot && k === hotFirstLeft ? blockBar(row.block) : null}
|
|
887
|
+
</span>
|
|
888
|
+
</Fragment>
|
|
889
|
+
)
|
|
890
|
+
})}
|
|
891
|
+
<RowSpacer height={leftWin.padBottom} />
|
|
892
|
+
</>
|
|
893
|
+
) : (
|
|
894
|
+
<>
|
|
895
|
+
<RowSpacer height={win.padTop} />
|
|
896
|
+
{rows.slice(win.start, win.end).map((row, k) => {
|
|
897
|
+
const i = win.start + k
|
|
898
|
+
const hot = hotBlock !== null && row.block === hotBlock
|
|
899
|
+
const current = row.block >= 0 && row.block === currentBlock
|
|
900
|
+
const hotClass = blockHotClass(rows, i, 'left', current)
|
|
901
|
+
// The block's action bar rides in this column only for a row
|
|
902
|
+
// with no right-hand side — a pure deletion, where the right
|
|
903
|
+
// column has no cell to hang it on.
|
|
904
|
+
const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
|
|
905
|
+
return (
|
|
906
|
+
<Fragment key={i}>
|
|
907
|
+
<span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
|
|
908
|
+
<span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.left !== null && row.block >= 0 ? row.block : undefined}>
|
|
909
|
+
{renderSideCode(row.left, leftSyntax?.[i])}
|
|
910
|
+
{bar}
|
|
911
|
+
</span>
|
|
912
|
+
</Fragment>
|
|
913
|
+
)
|
|
914
|
+
})}
|
|
915
|
+
<RowSpacer height={win.padBottom} />
|
|
916
|
+
</>
|
|
917
|
+
)}
|
|
918
|
+
</div>
|
|
919
|
+
</div>
|
|
920
|
+
<PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
|
|
921
|
+
<div className={`${css.sideCol} ${css.sideColRight}`} style={{ paddingTop: blockBarClearance }}>
|
|
922
|
+
{bodyState.kind === 'editor' ? (
|
|
923
|
+
<CodeEditor
|
|
924
|
+
value={edit.buffer}
|
|
925
|
+
original={indexText}
|
|
926
|
+
onChange={next => { setEdit(prev => ({ ...prev, buffer: next })) }}
|
|
927
|
+
paint={editPaint}
|
|
928
|
+
indent={indentOfBuffer}
|
|
929
|
+
ariaLabel={path}
|
|
930
|
+
onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
|
|
931
|
+
/>
|
|
932
|
+
) : (
|
|
933
|
+
<div className={css.sideColGrid}>
|
|
934
|
+
<RowSpacer height={win.padTop} />
|
|
935
|
+
{rows.slice(win.start, win.end).map((row, k) => {
|
|
936
|
+
const i = win.start + k
|
|
937
|
+
const hot = hotBlock !== null && row.block === hotBlock
|
|
938
|
+
const current = row.block >= 0 && row.block === currentBlock
|
|
939
|
+
const hotClass = blockHotClass(rows, i, 'right', current)
|
|
940
|
+
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
941
|
+
return (
|
|
942
|
+
<Fragment key={i}>
|
|
943
|
+
<span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
|
|
944
|
+
<span
|
|
945
|
+
className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
|
|
946
|
+
data-block={row.right !== null && row.block >= 0 ? row.block : undefined}
|
|
947
|
+
onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
|
|
948
|
+
>
|
|
949
|
+
{renderSideCode(row.right, rightSyntax?.[i])}
|
|
950
|
+
{bar}
|
|
951
|
+
</span>
|
|
952
|
+
</Fragment>
|
|
953
|
+
)
|
|
954
|
+
})}
|
|
955
|
+
<RowSpacer height={win.padBottom} />
|
|
956
|
+
</div>
|
|
957
|
+
)}
|
|
958
|
+
</div>
|
|
959
|
+
</div>
|
|
960
|
+
)}
|
|
961
|
+
</div>
|
|
962
|
+
{pendingLayer !== null ? (
|
|
963
|
+
<LeaveEditsConfirm t={t} path={path} onCancel={() => { setPendingLayer(null) }} onConfirm={confirmLeave} />
|
|
964
|
+
) : null}
|
|
965
|
+
</div>
|
|
966
|
+
)
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* The unsaved-edits guard, rendered at both sites that defer a gesture on the
|
|
971
|
+
* buffer's answer: the pane's layer-tab switch, and the drawer level (file
|
|
972
|
+
* selection, main tab, source switch, close) for every gesture that would
|
|
973
|
+
* drop the buffer. Same reason as the roll-back confirmation — the click it
|
|
974
|
+
* answers to is one gesture away from losing work. Cancel holds the initial
|
|
975
|
+
* focus and Escape closes, because the default answer to losing edits is no.
|
|
976
|
+
*/
|
|
977
|
+
export function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
|
|
978
|
+
t: Translate
|
|
979
|
+
path: string
|
|
980
|
+
onCancel: () => void
|
|
981
|
+
onConfirm: () => void
|
|
982
|
+
}): ReactNode {
|
|
983
|
+
const stayRef = useRef<HTMLButtonElement>(null)
|
|
984
|
+
useEffect(() => { stayRef.current?.focus() }, [])
|
|
985
|
+
useEffect(() => {
|
|
986
|
+
// Capture phase, like the roll-back dialog: while a question about edits
|
|
987
|
+
// is open, Escape answers it and nothing else — consumed here, before it
|
|
988
|
+
// can reach the page's other Escape handlers (an open picker's dismiss,
|
|
989
|
+
// the commit box's undo).
|
|
990
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
991
|
+
if (event.key !== 'Escape') return
|
|
992
|
+
event.stopPropagation()
|
|
993
|
+
onCancel()
|
|
994
|
+
}
|
|
995
|
+
window.addEventListener('keydown', onKey, true)
|
|
996
|
+
return () => { window.removeEventListener('keydown', onKey, true) }
|
|
997
|
+
}, [onCancel])
|
|
998
|
+
return (
|
|
999
|
+
<div className={css.confirmScrim} onClick={onCancel}>
|
|
1000
|
+
<div
|
|
1001
|
+
className={css.confirmBox}
|
|
1002
|
+
role="alertdialog"
|
|
1003
|
+
aria-modal="true"
|
|
1004
|
+
aria-label={t('unsavedTitle')}
|
|
1005
|
+
onClick={event => event.stopPropagation()}
|
|
1006
|
+
>
|
|
1007
|
+
<div className={css.confirmTitle}>{t('unsavedTitle')}</div>
|
|
1008
|
+
<div className={css.confirmBody}>{t('unsavedBody', { path })}</div>
|
|
1009
|
+
<div className={css.confirmActions}>
|
|
1010
|
+
<button ref={stayRef} type="button" className={css.btn} onClick={onCancel}>{t('unsavedStay')}</button>
|
|
1011
|
+
<button type="button" className={`${css.btn} ${css.btnDanger}`} onClick={onConfirm}>{t('unsavedLeave')}</button>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
)
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** Classes that paint only a block's OUTER perimeter. Internal rows carry the
|
|
1019
|
+
* vertical edges but no top/bottom line, avoiding the blue ladder a large
|
|
1020
|
+
* addition block used to draw. Absent side-cells return no class at all. */
|
|
1021
|
+
function blockHotClass(rows: readonly SideRow[], index: number, side: 'left' | 'right', hot: boolean): string {
|
|
1022
|
+
if (!hot) return ''
|
|
1023
|
+
const edge = blockEdge(rows, index, side)
|
|
1024
|
+
if (edge === null) return ''
|
|
1025
|
+
const first = edge === 'first' || edge === 'single' ? ` ${css.sideBlockHotFirst}` : ''
|
|
1026
|
+
const last = edge === 'last' || edge === 'single' ? ` ${css.sideBlockHotLast}` : ''
|
|
1027
|
+
return ` ${css.sideBlockHot}${first}${last}`
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/** Line-number cell class: a PRESENT cell of a changed row carries its side's
|
|
1031
|
+
* tint into the gutter; an absent one stays blank, the way a split diff shows
|
|
1032
|
+
* a one-sided change with an empty opposite pane rather than a tinted void. */
|
|
1033
|
+
function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
|
|
1034
|
+
const cell = side === 'left' ? row.left : row.right
|
|
1035
|
+
if (cell === null || row.kind === 'same') return css.sideNum
|
|
1036
|
+
return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Code cell class: deletions tint left, additions right, context stays quiet. */
|
|
1040
|
+
function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
1041
|
+
const cell = side === 'left' ? row.left : row.right
|
|
1042
|
+
if (cell === null || row.kind === 'same') return css.sideCodeSame
|
|
1043
|
+
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** One cell's Shiki runs, or its plain text when no tokens exist. */
|
|
1047
|
+
function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
1048
|
+
if (cell === null) return ''
|
|
1049
|
+
if (tokens === undefined || tokens.length === 0) return cell.text
|
|
1050
|
+
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return cell.text
|
|
1051
|
+
return tokens.map((tok, i) => (
|
|
1052
|
+
<span
|
|
1053
|
+
key={i}
|
|
1054
|
+
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
1055
|
+
>{tok.text}</span>
|
|
1056
|
+
))
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* The spacer standing in for the rows above or below the window.
|
|
1063
|
+
*
|
|
1064
|
+
* It spans every column of the grid, so a blame gutter does not change it.
|
|
1065
|
+
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
1066
|
+
*/
|
|
1067
|
+
function RowSpacer({ height }: { height: number }): ReactNode {
|
|
1068
|
+
if (height <= 0) return null
|
|
1069
|
+
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
1070
|
+
}
|