@young1lin/dsh-ui-gitworkbench 0.1.14 → 0.1.16

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CHANGELOG_EN.md +23 -0
  3. package/README.md +29 -3
  4. package/README_EN.md +1 -1
  5. package/lib/client.js +1512 -470
  6. package/lib/dir-listing.js +34 -0
  7. package/lib/fs-remove.js +5 -36
  8. package/lib/index.js +346 -117
  9. package/lib/path-lock.js +54 -0
  10. package/lib/repo-root.js +60 -0
  11. package/lib/worktree.js +83 -0
  12. package/lib/write-checked.js +1 -1
  13. package/package.json +5 -5
  14. package/src/client/ChromeGlyph.tsx +5 -0
  15. package/src/client/CodeEditor.tsx +19 -1
  16. package/src/client/DiffViews.tsx +116 -121
  17. package/src/client/FileBrowser.tsx +196 -23
  18. package/src/client/GitWorkbenchPanel.module.css +1 -0
  19. package/src/client/GitWorkbenchPanel.tsx +100 -12
  20. package/src/client/SideRails.tsx +106 -0
  21. package/src/client/diff-cells.tsx +147 -0
  22. package/src/client/diff-nav.ts +4 -1
  23. package/src/client/dir-tree.ts +31 -1
  24. package/src/client/file-rows.ts +40 -0
  25. package/src/client/h-rail.ts +70 -0
  26. package/src/client/ignored-cache.ts +193 -0
  27. package/src/client/index.ts +22 -3
  28. package/src/client/locales.ts +12 -2
  29. package/src/client/row-heights.ts +225 -0
  30. package/src/client/styles/changes.css +33 -2
  31. package/src/client/styles/controls.css +5 -0
  32. package/src/client/styles/files.css +5 -0
  33. package/src/client/styles/rails.css +72 -0
  34. package/src/client/use-row-window.ts +7 -3
  35. package/src/client/use-variable-row-window.ts +210 -0
  36. package/src/dir-listing.ts +47 -0
  37. package/src/fs-remove.ts +5 -36
  38. package/src/index.ts +372 -118
  39. package/src/path-lock.ts +56 -0
  40. package/src/repo-root.ts +66 -0
  41. package/src/types/dsh-shim.d.ts +12 -2
  42. package/src/worktree.ts +97 -0
  43. package/src/write-checked.ts +1 -1
@@ -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
- const win = useRowWindow(scrollRef, rowsWithWords.length, path)
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(() => blockTopsFromRows(blocksForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
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, 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. */
@@ -390,7 +413,31 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
390
413
  // line by line. Declared here because both the render and the highlighting
391
414
  // below are bounded by it.
392
415
  const rowWindowKey = `${scopeKey}\x1f${path}\x1f${layer}\x1f${sides?.diffSha ?? ''}`
393
- const win = useRowWindow(scrollRef, rows.length, rowWindowKey)
416
+ const alignedGridRef = useRef<HTMLDivElement>(null)
417
+ const denseGridRef = useRef<HTMLDivElement>(null)
418
+ // The two columns themselves, for the rails that scroll them — see
419
+ // SideRails.tsx: each column's own scrollbar is drawn at the bottom of the
420
+ // FILE, which is not a place a scrollbar can be used from.
421
+ const leftColRef = useRef<HTMLDivElement>(null)
422
+ const rightColRef = useRef<HTMLDivElement>(null)
423
+ const fixedWin = useRowWindow(scrollRef, rows.length, rowWindowKey, !wrap)
424
+ // The taller of a row's two sides is what the row is worth, so the estimate
425
+ // is fed the longer of the two texts.
426
+ const rowTexts = useMemo(
427
+ () => rows.map(row => {
428
+ const left = row.left?.text ?? ''
429
+ const right = row.right?.text ?? ''
430
+ return left.length >= right.length ? left : right
431
+ }),
432
+ [rows],
433
+ )
434
+ // Both columns, because a row is as tall as its taller side; the width is
435
+ // read from one column, because that is what a line wraps inside.
436
+ const flow = useVariableRowWindow({
437
+ scrollRef, rowsRef: colsRef, widthRef: alignedGridRef,
438
+ texts: rowTexts, mountKey: rowWindowKey, scope: 'a', enabled: wrap,
439
+ })
440
+ const win = wrap ? flow.win : fixedWin
394
441
  //
395
442
  // Two passes with two lifetimes. The whole-file pass runs once per file and
396
443
  // is what knows about block comments and template literals; the per-line
@@ -469,7 +516,17 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
469
516
  // two columns render two different row lists while the editor is armed: the
470
517
  // right side is a buffer, and the left side is then the index side DENSE,
471
518
  // one row per index line rather than one per aligned row.
472
- const leftWin = useRowWindow(scrollRef, leftRows.length, rowWindowKey)
519
+ const fixedLeftWin = useRowWindow(scrollRef, leftRows.length, rowWindowKey, !wrap)
520
+ const leftTexts = useMemo(() => leftRows.map(entry => entry.row.left?.text ?? ''), [leftRows])
521
+ // Its own height model: the dense column's rows are the INDEX side's lines,
522
+ // a different list from the aligned rows, so it cannot share theirs. Nothing
523
+ // has to line up with it — the other column is a CodeMirror buffer that wraps
524
+ // on its own — but its spacers still have to add up to what it renders.
525
+ const leftFlow = useVariableRowWindow({
526
+ scrollRef, rowsRef: denseGridRef,
527
+ texts: leftTexts, mountKey: rowWindowKey, scope: 'd', enabled: wrap,
528
+ })
529
+ const leftWin = wrap ? leftFlow.win : fixedLeftWin
473
530
 
474
531
  // Arming drops the caret straight into the buffer: the click that armed the
475
532
  // editor said "I want to type here", and a second click to focus is a tax.
@@ -666,7 +723,7 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
666
723
 
667
724
  /** The pane the drawer had before this view existed, notice included. */
668
725
  const unifiedFallback = (): ReactNode => fallbackSegment.length > 0
669
- ? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} />
726
+ ? <DiffView segment={fallbackSegment} path={path} palette={palette} t={t} wrap={wrap} />
670
727
  : <div className={css.empty}>{fallbackLoading ? t('loadingDiff') : t('noTextDiff')}</div>
671
728
 
672
729
  if (failed) return unifiedFallback()
@@ -868,6 +925,7 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
868
925
  {bodyState.kind === 'empty' ? (
869
926
  <div className={css.empty}>{t('noTextDiff')}</div>
870
927
  ) : (
928
+ <>
871
929
  <div
872
930
  ref={colsRef}
873
931
  className={css.sideCols}
@@ -875,8 +933,11 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
875
933
  onMouseDown={onBodySelect}
876
934
  onMouseLeave={() => { setHotBlock(null) }}
877
935
  >
878
- <div className={css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
879
- <div className={css.sideColGrid}>
936
+ <div ref={leftColRef} className={wrap ? `${css.sideCol} ${css.sideColWrap}` : css.sideCol} style={{ flexBasis: `${split * 100}%`, paddingTop: blockBarClearance }}>
937
+ <div
938
+ ref={bodyState.kind === 'editor' ? denseGridRef : alignedGridRef}
939
+ className={wrap ? `${css.sideColGrid} ${css.sideColGridWrap}` : css.sideColGrid}
940
+ >
880
941
  {bodyState.kind === 'editor' ? (
881
942
  /* While armed the left column renders the index side DENSE —
882
943
  one row per index line, no diff holes — because the right
@@ -888,16 +949,18 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
888
949
  const k = leftWin.start + kk
889
950
  const { row, i } = entry
890
951
  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
952
  return (
894
- <Fragment key={`l${i}`}>
895
- <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left!.line}</span>
896
- <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.left !== null && row.block >= 0 ? row.block : undefined}>
897
- {renderSideCode(row.left, leftSyntax?.[i])}
898
- {hot && k === hotFirstLeft ? blockBar(row.block) : null}
899
- </span>
900
- </Fragment>
953
+ <SideCells
954
+ key={`l${i}`}
955
+ row={row} side="left" index={i} rows={rows}
956
+ current={row.block >= 0 && row.block === currentBlock}
957
+ tokens={leftSyntax?.[i]}
958
+ // Marked by its DENSE index: this column's rows are the
959
+ // index side's lines, not the aligned ones. Nothing to
960
+ // impose a height for — the buffer beside it wraps itself.
961
+ mark={wrap ? rowMark('d', k) : undefined}
962
+ bar={hot && k === hotFirstLeft ? blockBar(row.block) : null}
963
+ />
901
964
  )
902
965
  })}
903
966
  <RowSpacer height={leftWin.padBottom} />
@@ -908,20 +971,19 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
908
971
  {rows.slice(win.start, win.end).map((row, k) => {
909
972
  const i = win.start + k
910
973
  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
974
  return (
918
- <Fragment key={i}>
919
- <span className={`${sideNumClass(row, 'left')}${hotClass}`}>{row.left === null ? '' : row.left.line}</span>
920
- <span className={`${css.sideCode} ${sideCodeClass(row, 'left')}${hotClass}`} data-block={row.left !== null && row.block >= 0 ? row.block : undefined}>
921
- {renderSideCode(row.left, leftSyntax?.[i])}
922
- {bar}
923
- </span>
924
- </Fragment>
975
+ <SideCells
976
+ key={i}
977
+ row={row} side="left" index={i} rows={rows}
978
+ current={row.block >= 0 && row.block === currentBlock}
979
+ tokens={leftSyntax?.[i]}
980
+ mark={wrap ? rowMark('a', i) : undefined}
981
+ minHeight={wrap ? flow.rowHeight(i) : undefined}
982
+ // The block's action bar rides in this column only for a
983
+ // row with no right-hand side — a pure deletion, where the
984
+ // right column has no cell to hang it on.
985
+ bar={hot && i === hotFirst && row.right === null ? blockBar(row.block) : null}
986
+ />
925
987
  )
926
988
  })}
927
989
  <RowSpacer height={win.padBottom} />
@@ -930,7 +992,11 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
930
992
  </div>
931
993
  </div>
932
994
  <PaneDivider label={t('resizeSides')} onDrag={onSplitDrag} />
933
- <div className={`${css.sideCol} ${css.sideColRight}`} style={{ paddingTop: blockBarClearance }}>
995
+ <div
996
+ ref={rightColRef}
997
+ className={wrap ? `${css.sideCol} ${css.sideColRight} ${css.sideColWrap}` : `${css.sideCol} ${css.sideColRight}`}
998
+ style={{ paddingTop: blockBarClearance }}
999
+ >
934
1000
  {bodyState.kind === 'editor' ? (
935
1001
  <CodeEditor
936
1002
  value={edit.buffer}
@@ -940,28 +1006,26 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
940
1006
  indent={indentOfBuffer}
941
1007
  ariaLabel={path}
942
1008
  onSave={() => { if (dirty && !saving) void runSave(edit.baseSha) }}
1009
+ wrap={wrap}
943
1010
  />
944
1011
  ) : (
945
- <div className={css.sideColGrid}>
1012
+ <div className={wrap ? `${css.sideColGrid} ${css.sideColGridWrap}` : css.sideColGrid}>
946
1013
  <RowSpacer height={win.padTop} />
947
1014
  {rows.slice(win.start, win.end).map((row, k) => {
948
1015
  const i = win.start + k
949
1016
  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
1017
  return (
954
- <Fragment key={i}>
955
- <span className={`${sideNumClass(row, 'right')}${hotClass}`}>{row.right === null ? '' : row.right.line}</span>
956
- <span
957
- className={`${css.sideCode} ${sideCodeClass(row, 'right')}${hotClass}${layer === 'unstaged' && armable ? ` ${css.sideArmable}` : ''}`}
958
- data-block={row.right !== null && row.block >= 0 ? row.block : undefined}
959
- onClick={layer === 'unstaged' && armable ? armFromCell : undefined}
960
- >
961
- {renderSideCode(row.right, rightSyntax?.[i])}
962
- {bar}
963
- </span>
964
- </Fragment>
1018
+ <SideCells
1019
+ key={i}
1020
+ row={row} side="right" index={i} rows={rows}
1021
+ current={row.block >= 0 && row.block === currentBlock}
1022
+ tokens={rightSyntax?.[i]}
1023
+ mark={wrap ? rowMark('a', i) : undefined}
1024
+ minHeight={wrap ? flow.rowHeight(i) : undefined}
1025
+ bar={hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null}
1026
+ armable={layer === 'unstaged' && armable}
1027
+ onArm={layer === 'unstaged' && armable ? armFromCell : undefined}
1028
+ />
965
1029
  )
966
1030
  })}
967
1031
  <RowSpacer height={win.padBottom} />
@@ -969,6 +1033,8 @@ export function SideBySideView({ t, path, palette, statsPath, fetchSides, writeC
969
1033
  )}
970
1034
  </div>
971
1035
  </div>
1036
+ <SideRails leftRef={leftColRef} rightRef={rightColRef} split={split} />
1037
+ </>
972
1038
  )}
973
1039
  </div>
974
1040
  {pendingLayer !== null ? (
@@ -1026,74 +1092,3 @@ export function LeaveEditsConfirm({ t, path, onCancel, onConfirm }: {
1026
1092
  </div>
1027
1093
  )
1028
1094
  }
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
- }