@young1lin/dsh-ui-gitworkbench 0.1.15 → 0.1.17
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/CHANGELOG.md +26 -0
- package/CHANGELOG_EN.md +26 -0
- package/README.md +30 -5
- package/README_EN.md +1 -1
- package/lib/client.js +1600 -519
- package/lib/dir-listing.js +34 -0
- package/lib/fs-remove.js +5 -36
- package/lib/index.js +233 -52
- package/lib/path-lock.js +54 -0
- package/lib/worktree.js +133 -0
- package/lib/write-checked.js +1 -1
- package/package.json +1 -1
- package/src/client/ChromeGlyph.tsx +5 -0
- package/src/client/CodeEditor.tsx +19 -1
- package/src/client/DiffViews.tsx +122 -123
- package/src/client/FileBrowser.tsx +196 -23
- package/src/client/GitWorkbenchPanel.module.css +1 -0
- package/src/client/GitWorkbenchPanel.tsx +114 -12
- package/src/client/SideRails.tsx +106 -0
- package/src/client/diff-cells.tsx +147 -0
- package/src/client/diff-model.ts +20 -0
- package/src/client/diff-nav.ts +4 -1
- package/src/client/dir-tree.ts +31 -1
- package/src/client/file-rows.ts +40 -0
- package/src/client/h-rail.ts +70 -0
- package/src/client/ignored-cache.ts +193 -0
- package/src/client/index.ts +22 -3
- package/src/client/locales.ts +18 -4
- package/src/client/row-heights.ts +225 -0
- package/src/client/styles/changes.css +33 -2
- package/src/client/styles/controls.css +5 -0
- package/src/client/styles/files.css +5 -0
- package/src/client/styles/rails.css +72 -0
- package/src/client/use-row-window.ts +7 -3
- package/src/client/use-variable-row-window.ts +210 -0
- package/src/dir-listing.ts +47 -0
- package/src/fs-remove.ts +5 -36
- package/src/index.ts +257 -55
- package/src/path-lock.ts +56 -0
- package/src/types/dsh-shim.d.ts +12 -2
- package/src/worktree.ts +153 -0
- package/src/write-checked.ts +1 -1
package/src/client/DiffViews.tsx
CHANGED
|
@@ -15,10 +15,13 @@ import {
|
|
|
15
15
|
type EditState, type WriteResult,
|
|
16
16
|
} from './side-edit.ts'
|
|
17
17
|
import { PaneDivider } from './PaneDivider.tsx'
|
|
18
|
+
import { SideRails } from './SideRails.tsx'
|
|
18
19
|
import { CodeEditor, type PaintFn } from './CodeEditor.tsx'
|
|
19
20
|
import { detectIndent } from './indent.ts'
|
|
20
21
|
import { grammarLoadCount, highlightForRowsWindow, highlightRange, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
21
22
|
import { useRowWindow } from './use-row-window.ts'
|
|
23
|
+
import { rowMark, useVariableRowWindow } from './use-variable-row-window.ts'
|
|
24
|
+
import { RowSpacer, SideCells } from './diff-cells.tsx'
|
|
22
25
|
import type { BlockAsk, BlockMode, FileSides, GitOpResult, SideLayer, Translate } from './git-workbench-types.ts'
|
|
23
26
|
import css from './GitWorkbenchPanel.module.css'
|
|
24
27
|
|
|
@@ -63,11 +66,15 @@ function NavGlyph({ of }: { of: keyof typeof NAV_GLYPH }): ReactNode {
|
|
|
63
66
|
* a block child of a scroller is only ever as wide as the scrollport. A header
|
|
64
67
|
* outside the scrolled box has neither problem.
|
|
65
68
|
*/
|
|
66
|
-
export function DiffView({ segment, path, palette, t }: {
|
|
69
|
+
export function DiffView({ segment, path, palette, t, wrap }: {
|
|
67
70
|
segment: string
|
|
68
71
|
path: string
|
|
69
72
|
palette: string
|
|
70
73
|
t: Translate
|
|
74
|
+
/** Soft wrap. Changes the pane's height model, not just its white-space:
|
|
75
|
+
* the window below is exact arithmetic while every row is one line tall,
|
|
76
|
+
* and measured once they are not. */
|
|
77
|
+
wrap: boolean
|
|
71
78
|
}): ReactNode {
|
|
72
79
|
const lang = shikiLangOf(path)
|
|
73
80
|
const shikiTheme = shikiThemeOf(palette)
|
|
@@ -75,10 +82,15 @@ export function DiffView({ segment, path, palette, t }: {
|
|
|
75
82
|
const rowsWithWords = useMemo(() => attachWordRanges(parseRows(segment)), [segment])
|
|
76
83
|
const sides = useMemo(() => gutterSides(rowsWithWords), [rowsWithWords])
|
|
77
84
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
85
|
+
const preRef = useRef<HTMLPreElement>(null)
|
|
78
86
|
// Windowed for the same reason the side-by-side pane is: a unified diff of a
|
|
79
87
|
// long file put every row in the DOM and re-lexed every one of them, so
|
|
80
|
-
// opening one froze the pane in exactly the same way.
|
|
81
|
-
|
|
88
|
+
// opening one froze the pane in exactly the same way. Two models, one live
|
|
89
|
+
// at a time: exact `i * 20px` while nothing wraps, measured once it does.
|
|
90
|
+
const fixed = useRowWindow(scrollRef, rowsWithWords.length, path, !wrap)
|
|
91
|
+
const texts = useMemo(() => rowsWithWords.map(row => row.text), [rowsWithWords])
|
|
92
|
+
const flow = useVariableRowWindow({ scrollRef, rowsRef: preRef, texts, mountKey: path, scope: 'u', enabled: wrap })
|
|
93
|
+
const win = wrap ? flow.win : fixed
|
|
82
94
|
const syntax = useMemo(
|
|
83
95
|
() => highlightForRowsWindow(rowsWithWords, lang, shikiTheme, win.start, win.end),
|
|
84
96
|
[rowsWithWords, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
@@ -89,9 +101,16 @@ export function DiffView({ segment, path, palette, t }: {
|
|
|
89
101
|
// the block being walked to.
|
|
90
102
|
const blocksForNav = useRef<readonly number[]>(blocks)
|
|
91
103
|
blocksForNav.current = blocks
|
|
104
|
+
// Same reason the blocks are held in a ref: the walk is built once, and by
|
|
105
|
+
// the time it runs the pane may have been wrapped, unwrapped or re-measured.
|
|
106
|
+
const placeRow = useRef<((index: number) => number) | undefined>(undefined)
|
|
107
|
+
placeRow.current = wrap ? flow.rowTop : undefined
|
|
92
108
|
const { goToChange } = useChangeNav(
|
|
93
109
|
scrollRef,
|
|
94
|
-
useCallback(
|
|
110
|
+
useCallback(
|
|
111
|
+
() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP, placeRow.current),
|
|
112
|
+
[],
|
|
113
|
+
),
|
|
95
114
|
)
|
|
96
115
|
// Read by the key listener below, which is attached once. `goToChange` only
|
|
97
116
|
// ever touches refs, but pinning it here says so rather than relying on it.
|
|
@@ -137,12 +156,12 @@ export function DiffView({ segment, path, palette, t }: {
|
|
|
137
156
|
</div>
|
|
138
157
|
) : null}
|
|
139
158
|
<div ref={scrollRef} className={css.diffScroll} tabIndex={-1}>
|
|
140
|
-
<pre className={css.diffPre}>
|
|
159
|
+
<pre ref={preRef} className={wrap ? `${css.diffPre} ${css.diffPreWrap}` : css.diffPre}>
|
|
141
160
|
{win.padTop > 0 ? <div className={css.diffSpacer} style={{ height: `${win.padTop}px` }} aria-hidden="true" /> : null}
|
|
142
161
|
{rowsWithWords.slice(win.start, win.end).map((row, k) => {
|
|
143
162
|
const i = win.start + k
|
|
144
163
|
return (
|
|
145
|
-
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined}>
|
|
164
|
+
<div key={i} className={`${css.line} ${rowClass(row.kind)}`} data-block={blocks[i]! >= 0 ? blocks[i] : undefined} {...rowMark('u', i)}>
|
|
146
165
|
{sides.old ? <span className={css.lnOld}>{row.kind === 'add' || row.kind === 'hunk' ? '' : row.oldL}</span> : null}
|
|
147
166
|
{sides.new ? <span className={css.lnNew}>{row.kind === 'del' || row.kind === 'hunk' ? '' : row.newL}</span> : null}
|
|
148
167
|
<span className={`${css.gutter} ${row.kind === 'add' ? css.signAdd : row.kind === 'del' ? css.signDel : ''}`}>
|
|
@@ -222,10 +241,14 @@ function renderCode(row: RowWithRanges, tokens: readonly HighlightRun[]): ReactN
|
|
|
222
241
|
* (history and compare keep it unconditionally), with a notice — a silently
|
|
223
242
|
* different view reads as a broken one, not a guarded one.
|
|
224
243
|
*/
|
|
225
|
-
export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, onBlockAction, onSaved, onDirtyChange }: {
|
|
244
|
+
export function SideBySideView({ t, path, palette, wrap, statsPath, fetchSides, writeChecked, scopeKey, gen, fallbackSegment, fallbackLoading, phantomListed, onBlockAction, onSaved, onDirtyChange }: {
|
|
226
245
|
t: Translate
|
|
227
246
|
path: string
|
|
228
247
|
palette: string
|
|
248
|
+
/** Soft wrap. Passed through to the editor and the unified fallback; the two
|
|
249
|
+
* aligned columns are the case it costs the most, since a row's height is
|
|
250
|
+
* whichever side wrapped further. */
|
|
251
|
+
wrap: boolean
|
|
229
252
|
statsPath: string | undefined
|
|
230
253
|
fetchSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
231
254
|
/** Save the editor buffer; the host refuses a stale sha and nothing is written. */
|
|
@@ -239,6 +262,10 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
239
262
|
* refresh generations — this is how the poll reaches a dirty buffer. */
|
|
240
263
|
fallbackSegment: string
|
|
241
264
|
fallbackLoading: boolean
|
|
265
|
+
/** The open file is listed modified while its whole-file diff is empty —
|
|
266
|
+
* the line-ending phantom. The empty states then explain themselves
|
|
267
|
+
* instead of showing the generic "no text changes" a broken pane shows. */
|
|
268
|
+
phantomListed: boolean
|
|
242
269
|
/** Run one block action; a discard routes to the drawer's confirmation. */
|
|
243
270
|
onBlockAction: (mode: BlockMode, ask: BlockAsk) => Promise<GitOpResult>
|
|
244
271
|
/** After a successful save: refresh the tree and the pane together. */
|
|
@@ -390,7 +417,31 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
390
417
|
// line by line. Declared here because both the render and the highlighting
|
|
391
418
|
// below are bounded by it.
|
|
392
419
|
const rowWindowKey = `${scopeKey}\x1f${path}\x1f${layer}\x1f${sides?.diffSha ?? ''}`
|
|
393
|
-
const
|
|
420
|
+
const alignedGridRef = useRef<HTMLDivElement>(null)
|
|
421
|
+
const denseGridRef = useRef<HTMLDivElement>(null)
|
|
422
|
+
// The two columns themselves, for the rails that scroll them — see
|
|
423
|
+
// SideRails.tsx: each column's own scrollbar is drawn at the bottom of the
|
|
424
|
+
// FILE, which is not a place a scrollbar can be used from.
|
|
425
|
+
const leftColRef = useRef<HTMLDivElement>(null)
|
|
426
|
+
const rightColRef = useRef<HTMLDivElement>(null)
|
|
427
|
+
const fixedWin = useRowWindow(scrollRef, rows.length, rowWindowKey, !wrap)
|
|
428
|
+
// The taller of a row's two sides is what the row is worth, so the estimate
|
|
429
|
+
// is fed the longer of the two texts.
|
|
430
|
+
const rowTexts = useMemo(
|
|
431
|
+
() => rows.map(row => {
|
|
432
|
+
const left = row.left?.text ?? ''
|
|
433
|
+
const right = row.right?.text ?? ''
|
|
434
|
+
return left.length >= right.length ? left : right
|
|
435
|
+
}),
|
|
436
|
+
[rows],
|
|
437
|
+
)
|
|
438
|
+
// Both columns, because a row is as tall as its taller side; the width is
|
|
439
|
+
// read from one column, because that is what a line wraps inside.
|
|
440
|
+
const flow = useVariableRowWindow({
|
|
441
|
+
scrollRef, rowsRef: colsRef, widthRef: alignedGridRef,
|
|
442
|
+
texts: rowTexts, mountKey: rowWindowKey, scope: 'a', enabled: wrap,
|
|
443
|
+
})
|
|
444
|
+
const win = wrap ? flow.win : fixedWin
|
|
394
445
|
//
|
|
395
446
|
// Two passes with two lifetimes. The whole-file pass runs once per file and
|
|
396
447
|
// is what knows about block comments and template literals; the per-line
|
|
@@ -469,7 +520,17 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
469
520
|
// two columns render two different row lists while the editor is armed: the
|
|
470
521
|
// right side is a buffer, and the left side is then the index side DENSE,
|
|
471
522
|
// one row per index line rather than one per aligned row.
|
|
472
|
-
const
|
|
523
|
+
const fixedLeftWin = useRowWindow(scrollRef, leftRows.length, rowWindowKey, !wrap)
|
|
524
|
+
const leftTexts = useMemo(() => leftRows.map(entry => entry.row.left?.text ?? ''), [leftRows])
|
|
525
|
+
// Its own height model: the dense column's rows are the INDEX side's lines,
|
|
526
|
+
// a different list from the aligned rows, so it cannot share theirs. Nothing
|
|
527
|
+
// has to line up with it — the other column is a CodeMirror buffer that wraps
|
|
528
|
+
// on its own — but its spacers still have to add up to what it renders.
|
|
529
|
+
const leftFlow = useVariableRowWindow({
|
|
530
|
+
scrollRef, rowsRef: denseGridRef,
|
|
531
|
+
texts: leftTexts, mountKey: rowWindowKey, scope: 'd', enabled: wrap,
|
|
532
|
+
})
|
|
533
|
+
const leftWin = wrap ? leftFlow.win : fixedLeftWin
|
|
473
534
|
|
|
474
535
|
// Arming drops the caret straight into the buffer: the click that armed the
|
|
475
536
|
// editor said "I want to type here", and a second click to focus is a tax.
|
|
@@ -666,8 +727,8 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
666
727
|
|
|
667
728
|
/** The pane the drawer had before this view existed, notice included. */
|
|
668
729
|
const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
|
|
669
|
-
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
|
|
670
|
-
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
|
|
730
|
+
? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} wrap={wrap} />
|
|
731
|
+
: <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : phantomListed ? t('phantomNotice') : t('noTextDiff')}</div>
|
|
671
732
|
|
|
672
733
|
if (failed) return unifiedFallback()
|
|
673
734
|
if (sides === null) return <div className={css.empty}>{t('loadingDiff')}</div>
|
|
@@ -866,8 +927,9 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
866
927
|
layout only decides how much width each side gets. */}
|
|
867
928
|
<div ref={scrollRef} className={css.sideScroll}>
|
|
868
929
|
{bodyState.kind === 'empty' ? (
|
|
869
|
-
<div className={css.empty}>{t('noTextDiff')}</div>
|
|
930
|
+
<div className={css.empty}>{phantomListed ? t('phantomNotice') : t('noTextDiff')}</div>
|
|
870
931
|
) : (
|
|
932
|
+
<>
|
|
871
933
|
<div
|
|
872
934
|
ref={colsRef}
|
|
873
935
|
className={css.sideCols}
|
|
@@ -875,8 +937,11 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
875
937
|
onMouseDown={onBodySelect}
|
|
876
938
|
onMouseLeave={() => { setHotBlock(null) }}
|
|
877
939
|
>
|
|
878
|
-
<div className={css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
|
|
879
|
-
<div
|
|
940
|
+
<div ref={leftColRef} className={wrap ? `${css.sideCol} ${css.sideColWrap}` : css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
|
|
941
|
+
<div
|
|
942
|
+
ref={bodyState.kind === 'editor' ? denseGridRef : alignedGridRef}
|
|
943
|
+
className={wrap ? `${css.sideColGrid} ${css.sideColGridWrap}` : css.sideColGrid}
|
|
944
|
+
>
|
|
880
945
|
{bodyState.kind === 'editor' ? (
|
|
881
946
|
/* While armed the left column renders the index side DENSE —
|
|
882
947
|
one row per index line, no diff holes — because the right
|
|
@@ -888,16 +953,18 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
888
953
|
const k = leftWin.start + kk
|
|
889
954
|
const { row, i } = entry
|
|
890
955
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
891
|
-
const current = row.block >= 0 && row.block === currentBlock
|
|
892
|
-
const hotClass = blockHotClass(rows, i, 'left', current)
|
|
893
956
|
return (
|
|
894
|
-
<
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
957
|
+
<SideCells
|
|
958
|
+
key={`l${i}`}
|
|
959
|
+
row={row} side="left" index={i} rows={rows}
|
|
960
|
+
current={row.block >= 0 && row.block === currentBlock}
|
|
961
|
+
tokens={leftSyntax?.[i]}
|
|
962
|
+
// Marked by its DENSE index: this column's rows are the
|
|
963
|
+
// index side's lines, not the aligned ones. Nothing to
|
|
964
|
+
// impose a height for — the buffer beside it wraps itself.
|
|
965
|
+
mark={wrap ? rowMark('d', k) : undefined}
|
|
966
|
+
bar={hot && k === hotFirstLeft ? blockBar(row.block) : null}
|
|
967
|
+
/>
|
|
901
968
|
)
|
|
902
969
|
})}
|
|
903
970
|
<RowSpacer height={leftWin.padBottom} />
|
|
@@ -908,20 +975,19 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
908
975
|
{rows.slice(win.start, win.end).map((row, k) => {
|
|
909
976
|
const i = win.start + k
|
|
910
977
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
911
|
-
const current = row.block >= 0 && row.block === currentBlock
|
|
912
|
-
const hotClass = blockHotClass(rows, i, 'left', current)
|
|
913
|
-
// The block's action bar rides in this column only for a row
|
|
914
|
-
// with no right-hand side — a pure deletion, where the right
|
|
915
|
-
// column has no cell to hang it on.
|
|
916
|
-
const bar = hot && i === hotFirst && row.right === null ? blockBar(row.block) : null
|
|
917
978
|
return (
|
|
918
|
-
<
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
979
|
+
<SideCells
|
|
980
|
+
key={i}
|
|
981
|
+
row={row} side="left" index={i} rows={rows}
|
|
982
|
+
current={row.block >= 0 && row.block === currentBlock}
|
|
983
|
+
tokens={leftSyntax?.[i]}
|
|
984
|
+
mark={wrap ? rowMark('a', i) : undefined}
|
|
985
|
+
minHeight={wrap ? flow.rowHeight(i) : undefined}
|
|
986
|
+
// The block's action bar rides in this column only for a
|
|
987
|
+
// row with no right-hand side — a pure deletion, where the
|
|
988
|
+
// right column has no cell to hang it on.
|
|
989
|
+
bar={hot && i === hotFirst && row.right === null ? blockBar(row.block) : null}
|
|
990
|
+
/>
|
|
925
991
|
)
|
|
926
992
|
})}
|
|
927
993
|
<RowSpacer height={win.padBottom} />
|
|
@@ -930,7 +996,11 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
930
996
|
</div>
|
|
931
997
|
</div>
|
|
932
998
|
<PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
|
|
933
|
-
<div
|
|
999
|
+
<div
|
|
1000
|
+
ref={rightColRef}
|
|
1001
|
+
className={wrap ? `${css.sideCol} ${css.sideColRight} ${css.sideColWrap}` : `${css.sideCol} ${css.sideColRight}`}
|
|
1002
|
+
style={{ paddingTop: blockBarClearance }}
|
|
1003
|
+
>
|
|
934
1004
|
{bodyState.kind === 'editor' ? (
|
|
935
1005
|
<CodeEditor
|
|
936
1006
|
value={edit.buffer}
|
|
@@ -940,28 +1010,26 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
940
1010
|
indent={indentOfBuffer}
|
|
941
1011
|
ariaLabel={path}
|
|
942
1012
|
onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
|
|
1013
|
+
wrap={wrap}
|
|
943
1014
|
/>
|
|
944
1015
|
) : (
|
|
945
|
-
<div className={css.sideColGrid}>
|
|
1016
|
+
<div className={wrap ? `${css.sideColGrid} ${css.sideColGridWrap}` : css.sideColGrid}>
|
|
946
1017
|
<RowSpacer height={win.padTop} />
|
|
947
1018
|
{rows.slice(win.start, win.end).map((row, k) => {
|
|
948
1019
|
const i = win.start + k
|
|
949
1020
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
950
|
-
const current = row.block >= 0 && row.block === currentBlock
|
|
951
|
-
const hotClass = blockHotClass(rows, i, 'right', current)
|
|
952
|
-
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
953
1021
|
return (
|
|
954
|
-
<
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1022
|
+
<SideCells
|
|
1023
|
+
key={i}
|
|
1024
|
+
row={row} side="right" index={i} rows={rows}
|
|
1025
|
+
current={row.block >= 0 && row.block === currentBlock}
|
|
1026
|
+
tokens={rightSyntax?.[i]}
|
|
1027
|
+
mark={wrap ? rowMark('a', i) : undefined}
|
|
1028
|
+
minHeight={wrap ? flow.rowHeight(i) : undefined}
|
|
1029
|
+
bar={hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null}
|
|
1030
|
+
armable={layer === 'unstaged' && armable}
|
|
1031
|
+
onArm={layer === 'unstaged' && armable ? armFromCell : undefined}
|
|
1032
|
+
/>
|
|
965
1033
|
)
|
|
966
1034
|
})}
|
|
967
1035
|
<RowSpacer height={win.padBottom} />
|
|
@@ -969,6 +1037,8 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
|
|
|
969
1037
|
)}
|
|
970
1038
|
</div>
|
|
971
1039
|
</div>
|
|
1040
|
+
<SideRails leftRef={leftColRef} rightRef={rightColRef} split={split} />
|
|
1041
|
+
</>
|
|
972
1042
|
)}
|
|
973
1043
|
</div>
|
|
974
1044
|
{pendingLayer !== null ? (
|
|
@@ -1026,74 +1096,3 @@ export function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
|
|
|
1026
1096
|
</div>
|
|
1027
1097
|
)
|
|
1028
1098
|
}
|
|
1029
|
-
|
|
1030
|
-
/** Classes that paint only a block's OUTER perimeter. Internal rows carry the
|
|
1031
|
-
* vertical edges but no top/bottom line, avoiding the blue ladder a large
|
|
1032
|
-
* addition block used to draw. Absent side-cells return no class at all. */
|
|
1033
|
-
function blockHotClass(rows: readonly SideRow[], index: number, side: 'left' | 'right', hot: boolean): string {
|
|
1034
|
-
if (!hot) return ''
|
|
1035
|
-
const edge = blockEdge(rows, index, side)
|
|
1036
|
-
if (edge === null) return ''
|
|
1037
|
-
const first = edge === 'first' || edge === 'single' ? ` ${css.sideBlockHotFirst}` : ''
|
|
1038
|
-
const last = edge === 'last' || edge === 'single' ? ` ${css.sideBlockHotLast}` : ''
|
|
1039
|
-
return ` ${css.sideBlockHot}${first}${last}`
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
/** Line-number cell class: a PRESENT cell of a changed row carries its side's
|
|
1043
|
-
* tint into the gutter; an absent one stays blank, the way a split diff shows
|
|
1044
|
-
* a one-sided change with an empty opposite pane rather than a tinted void. */
|
|
1045
|
-
function sideNumClass(row: SideRow, side: 'left' | 'right'): string {
|
|
1046
|
-
const cell = side === 'left' ? row.left : row.right
|
|
1047
|
-
if (cell === null || row.kind === 'same') return css.sideNum
|
|
1048
|
-
return `${css.sideNum} ${side === 'left' ? css.sideNumDel : css.sideNumAdd}`
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
/** Code cell class: deletions tint left, additions right, context stays quiet. */
|
|
1052
|
-
function sideCodeClass(row: SideRow, side: 'left' | 'right'): string {
|
|
1053
|
-
const cell = side === 'left' ? row.left : row.right
|
|
1054
|
-
if (cell === null || row.kind === 'same') return css.sideCodeSame
|
|
1055
|
-
return `${side === 'left' ? css.sideCodeDel : css.sideCodeAdd} ${css.sideCellBlock}`
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
/** One text with every carriage return drawn as the CR glyph. No CR means
|
|
1059
|
-
* the text comes back untouched — the common line, on both sides, costs one
|
|
1060
|
-
* `includes`. The glyph spans are aria-hidden and unselectable, so copying a
|
|
1061
|
-
* line copies code, not markers. */
|
|
1062
|
-
function renderWithCrMarks(text: string): ReactNode {
|
|
1063
|
-
const parts = splitOnCr(text)
|
|
1064
|
-
if (parts.length === 1) return text
|
|
1065
|
-
const out: ReactNode[] = [parts[0]!]
|
|
1066
|
-
for (let i = 1; i < parts.length; i += 1) {
|
|
1067
|
-
out.push(<span key={`cr${i}`} className={css.crMark} aria-hidden="true">{CR_GLYPH}</span>)
|
|
1068
|
-
out.push(parts[i]!)
|
|
1069
|
-
}
|
|
1070
|
-
return out
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/** One cell's Shiki runs, or its plain text when no tokens exist; either way
|
|
1074
|
-
* each carriage return in the cell is drawn, so a line whose only change is
|
|
1075
|
-
* its ending shows the difference instead of two identical-looking cells. */
|
|
1076
|
-
function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] | undefined): ReactNode {
|
|
1077
|
-
if (cell === null) return ''
|
|
1078
|
-
if (tokens === undefined || tokens.length === 0) return renderWithCrMarks(cell.text)
|
|
1079
|
-
if (tokens.length === 1 && tokens[0]!.color === undefined && !tokens[0]!.italic) return renderWithCrMarks(cell.text)
|
|
1080
|
-
return tokens.map((tok, i) => (
|
|
1081
|
-
<span
|
|
1082
|
-
key={i}
|
|
1083
|
-
style={tok.color === undefined && !tok.italic ? undefined : { color: tok.color, fontStyle: tok.italic ? 'italic' : undefined }}
|
|
1084
|
-
>{renderWithCrMarks(tok.text)}</span>
|
|
1085
|
-
))
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* The spacer standing in for the rows above or below the window.
|
|
1092
|
-
*
|
|
1093
|
-
* It spans every column of the grid, so a blame gutter does not change it.
|
|
1094
|
-
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
1095
|
-
*/
|
|
1096
|
-
function RowSpacer({ height }: { height: number }): ReactNode {
|
|
1097
|
-
if (height <= 0) return null
|
|
1098
|
-
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
1099
|
-
}
|