@young1lin/dsh-ui-gitworkbench 0.1.6 → 0.1.8
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 +23 -0
- package/CHANGELOG_EN.md +24 -0
- package/lib/client.js +836 -413
- package/lib/index.js +48 -28
- package/package.json +1 -1
- package/src/client/GitWorkbenchPanel.module.css +46 -9
- package/src/client/GitWorkbenchPanel.tsx +366 -98
- package/src/client/diff-nav.ts +28 -0
- package/src/client/highlight.ts +56 -0
- package/src/client/history-layout.ts +52 -0
- package/src/client/index.ts +10 -2
- package/src/client/locales.ts +8 -0
- package/src/client/row-window.ts +132 -0
- package/src/client/use-change-nav.ts +19 -9
- package/src/index.ts +46 -25
|
@@ -58,8 +58,10 @@ import {
|
|
|
58
58
|
import { attachWordRanges, gutterSides, overlayRanges, parseRows, type Row, type RowWithRanges } from './diff-model.ts'
|
|
59
59
|
import { parsePatch } from '../patch-model.ts'
|
|
60
60
|
import { alignRows, blockCount, blockIsWholeFile, blockLines, blockTally, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
|
|
61
|
-
import { countBlocks, unifiedBlocks } from './diff-nav.ts'
|
|
61
|
+
import { blockTopsFromRows, countBlocks, unifiedBlocks } from './diff-nav.ts'
|
|
62
62
|
import { clampPane, neighbourWidth } from './pane-size.ts'
|
|
63
|
+
import { DIFF_GRID_PAD_TOP, DIFF_ROW_H, rowWindow, type RowWindow } from './row-window.ts'
|
|
64
|
+
import { COMMIT_ROW_H, DEFAULT_HISTORY_LAYOUT, isHistoryLayout, type HistoryLayout } from './history-layout.ts'
|
|
63
65
|
import { useChangeNav } from './use-change-nav.ts'
|
|
64
66
|
import {
|
|
65
67
|
applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, gateLeave, isDirty,
|
|
@@ -87,7 +89,7 @@ import {
|
|
|
87
89
|
fileCheckState, nextAction, nextBatch, pathsFor, rollUp, settledTicks, withPendingTicks,
|
|
88
90
|
type CheckState, type Tick, type TickAction,
|
|
89
91
|
} from './stage-tree.ts'
|
|
90
|
-
import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
92
|
+
import { grammarLoadCount, highlightFile, highlightForRows, highlightWholeFile, highlightWindow, shikiLangOf, shikiThemeOf, subscribeGrammarLoaded, type HighlightRun } from './highlight.ts'
|
|
91
93
|
import { badgeRepeatsBranch, bindingChanged, branchOfWorktree, pathKey, probesClosedBinding, samePath, showsPending, splitPath, turnSettled, viewedPath } from './worktree-view.ts'
|
|
92
94
|
import { BUSY_DELAY_MS, BUSY_HOLD_MS, holdRemaining, quietlyDisabled } from './op-feedback.ts'
|
|
93
95
|
import type { WorkbenchKey } from './locales.ts'
|
|
@@ -347,7 +349,7 @@ export type Translate = (key: string, params?: Record<string, string | number>)
|
|
|
347
349
|
type Props = PropsRuntime<'conversation.session.header.actions'> & {
|
|
348
350
|
readonly t: Translate
|
|
349
351
|
readonly fetchStats: (worktreePath: string | undefined, signal: AbortSignal) => Promise<WorkbenchStats | null>
|
|
350
|
-
readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, signal: AbortSignal) => Promise<string>
|
|
352
|
+
readonly fetchFileDiff: (worktreePath: string | undefined, path: string, commit: string | undefined, range: { base: string; head: string } | undefined, signal: AbortSignal) => Promise<string>
|
|
351
353
|
/** One layer of one file for the side-by-side diff pane. */
|
|
352
354
|
readonly fetchFileSides: (worktreePath: string | undefined, path: string, layer: SideLayer, signal: AbortSignal) => Promise<FileSides | null>
|
|
353
355
|
/** Save the editor buffer, checked against the sha it opened with. */
|
|
@@ -423,6 +425,10 @@ const STORE_PANES = 'dsh-ui-gitworkbench:panes'
|
|
|
423
425
|
* belongs here beside the layout rather than in the host's per-project store:
|
|
424
426
|
* two people on one repository should not share each other's place. */
|
|
425
427
|
const STORE_FILES = 'dsh-ui-gitworkbench:files'
|
|
428
|
+
/** Which way the History tab arranges its panes. Its own key rather than a
|
|
429
|
+
* field of the appearance object: that one is about colour, and this choice
|
|
430
|
+
* has to survive a build that adds a palette. */
|
|
431
|
+
const STORE_HISTORY_LAYOUT = 'dsh-ui-gitworkbench:history-layout'
|
|
426
432
|
|
|
427
433
|
/** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
|
|
428
434
|
interface PaneWidths {
|
|
@@ -743,6 +749,16 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
743
749
|
const [panes, setPanes] = useState<PaneWidths>(
|
|
744
750
|
() => readStored(STORE_PANES, isPaneWidths, DEFAULT_PANES),
|
|
745
751
|
)
|
|
752
|
+
/**
|
|
753
|
+
* The History tab's arrangement.
|
|
754
|
+
*
|
|
755
|
+
* Panel-level, beside the palette rather than inside the tab: the choice has
|
|
756
|
+
* to hold across tab switches and reopens, and the drawer's own card is where
|
|
757
|
+
* the row height it implies is published from.
|
|
758
|
+
*/
|
|
759
|
+
const [historyLayout, setHistoryLayout] = useState<HistoryLayout>(
|
|
760
|
+
() => readStored(STORE_HISTORY_LAYOUT, isHistoryLayout, DEFAULT_HISTORY_LAYOUT),
|
|
761
|
+
)
|
|
746
762
|
/** Per-project and global styling; both scopes, unresolved. */
|
|
747
763
|
const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
|
|
748
764
|
/** Whether dsh's resolved palette is currently dark. */
|
|
@@ -1174,14 +1190,15 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1174
1190
|
* drawer's on-demand effect stops re-running on every render. */
|
|
1175
1191
|
const fetchDiffForView = useCallback(
|
|
1176
1192
|
(path: string, signal: AbortSignal): Promise<string> => {
|
|
1177
|
-
//
|
|
1178
|
-
//
|
|
1179
|
-
//
|
|
1180
|
-
//
|
|
1181
|
-
|
|
1182
|
-
|
|
1193
|
+
// Each tab asks its own question about the path. Compare used to ask
|
|
1194
|
+
// nothing at all — `fileDiff` had no way to take a ref range, so a file
|
|
1195
|
+
// the bundled payload did not carry simply had no detail, which is what
|
|
1196
|
+
// an added XML file past the payload cap looked like.
|
|
1197
|
+
const range = tab === 'compare' ? { base: baseRef, head: headRef } : undefined
|
|
1198
|
+
const commit = tab === 'history' ? commitHash ?? undefined : undefined
|
|
1199
|
+
return fetchFileDiff(statsPath, path, commit, range, signal)
|
|
1183
1200
|
},
|
|
1184
|
-
[fetchFileDiff, statsPath, tab, commitHash],
|
|
1201
|
+
[fetchFileDiff, statsPath, tab, commitHash, baseRef, headRef],
|
|
1185
1202
|
)
|
|
1186
1203
|
|
|
1187
1204
|
// First stats fetch still in flight: render nothing. The cheap binding RPC
|
|
@@ -1435,6 +1452,18 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1435
1452
|
if (persist) writeStored(STORE_WIDTH, clamped)
|
|
1436
1453
|
}
|
|
1437
1454
|
|
|
1455
|
+
/**
|
|
1456
|
+
* Pick the History tab's arrangement.
|
|
1457
|
+
*
|
|
1458
|
+
* Written through immediately: unlike a drag this is one click, so there is
|
|
1459
|
+
* no intermediate frame to withhold a synchronous storage write for.
|
|
1460
|
+
* @param next - the arrangement to switch to.
|
|
1461
|
+
*/
|
|
1462
|
+
const applyHistoryLayout = (next: HistoryLayout): void => {
|
|
1463
|
+
setHistoryLayout(next)
|
|
1464
|
+
writeStored(STORE_HISTORY_LAYOUT, next)
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1438
1467
|
/** Tab switch. No direction refetches the working tree: `viewKey` already
|
|
1439
1468
|
* separates the tabs' per-file diff caches, so bumping `gen` here only cost a
|
|
1440
1469
|
* redundant round trip. */
|
|
@@ -1534,6 +1563,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
|
|
|
1534
1563
|
panes={panes}
|
|
1535
1564
|
onPane={applyPane}
|
|
1536
1565
|
onCommitsTall={applyCommitsTall}
|
|
1566
|
+
historyLayout={historyLayout}
|
|
1567
|
+
onHistoryLayout={applyHistoryLayout}
|
|
1537
1568
|
onClose={() => setOpen(false)}
|
|
1538
1569
|
onRefresh={refresh}
|
|
1539
1570
|
commitDraft={commitDraft}
|
|
@@ -1747,6 +1778,9 @@ interface DrawerProps {
|
|
|
1747
1778
|
onPane: (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
|
|
1748
1779
|
/** Drag the History tab's horizontal split: the commit list's height in px. */
|
|
1749
1780
|
onCommitsTall: (next: number, bodyHeight: number, persist: boolean) => void
|
|
1781
|
+
/** Which way the History tab arranges its panes. */
|
|
1782
|
+
historyLayout: HistoryLayout
|
|
1783
|
+
onHistoryLayout: (next: HistoryLayout) => void
|
|
1750
1784
|
onClose: () => void
|
|
1751
1785
|
onRefresh: () => void
|
|
1752
1786
|
/** Commit draft, lifted so a tab switch cannot discard it. */
|
|
@@ -1793,7 +1827,7 @@ interface DrawerProps {
|
|
|
1793
1827
|
onCollapsedChange: (next: Set<string>) => void
|
|
1794
1828
|
}
|
|
1795
1829
|
|
|
1796
|
-
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onCommitsTall, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, viewKey, gen, collapsed, onCollapsedChange, filesPlaces, onFilesPlace, filesTrees, onFilesTree }: DrawerProps): ReactNode {
|
|
1830
|
+
function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectCommit, hasMoreCommits, loadingMore, onLoadMoreCommits, historyRef, onHistoryRef, historyQuery, onHistoryQuery, historyError, fetchAuthors, fetchRepoTree, branches, worktreeBranches, branchesTruncated, baseRef, headRef, onBaseRef, onHeadRef, comparable, t, binding, worktrees, sessionPath, statsPath, onSwitchSource, segments, selected, onSelect, maximized, onToggleMaximized, theme, mode, family, onMode, onFamily, style, background, onStyle, width, onWidth, panes, onPane, onCommitsTall, historyLayout, onHistoryLayout, onClose, onRefresh, commitDraft, onCommitDraft, commitAmend, onCommitAmend, sync, treeLoading, historyLoading, busy, opResult, runOp, fetchDiscardPlan, onOpError, pendingTicks, onTick, fetchFileDiff, fetchFileSides, writeChecked, fetchBlame, fetchFileImage, viewKey, gen, collapsed, onCollapsedChange, filesPlaces, onFilesPlace, filesTrees, onFilesTree }: DrawerProps): ReactNode {
|
|
1797
1831
|
// Empty stand-in while a commit's change set loads, so every hook below keeps a
|
|
1798
1832
|
// stable shape and the panes simply render nothing.
|
|
1799
1833
|
const body = shown ?? EMPTY_STATS
|
|
@@ -2066,6 +2100,15 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2066
2100
|
onCommitsTall(clientY - box.top, box.height, done)
|
|
2067
2101
|
}
|
|
2068
2102
|
|
|
2103
|
+
/**
|
|
2104
|
+
* Whether the History tab is showing its stacked arrangement.
|
|
2105
|
+
*
|
|
2106
|
+
* Three things follow from it and they must agree: the body's direction, how
|
|
2107
|
+
* the commit list is sized, and which way its divider slides. Read from one
|
|
2108
|
+
* name so a fourth reader cannot be added out of step.
|
|
2109
|
+
*/
|
|
2110
|
+
const stackedHistory = tab === 'history' && historyLayout === 'stacked'
|
|
2111
|
+
|
|
2069
2112
|
// Width and the background's three tunables are inline because both are live
|
|
2070
2113
|
// user values; the stylesheet only says what reads them. The pane floors are
|
|
2071
2114
|
// inline for a different reason: they belong to the drag clamp above, and
|
|
@@ -2079,6 +2122,11 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2079
2122
|
'--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
|
|
2080
2123
|
'--gs-min-commits-tall': `${MIN_COMMITS_HEIGHT}px`,
|
|
2081
2124
|
'--gs-min-stacked-lower': `${MIN_STACKED_LOWER}px`,
|
|
2125
|
+
// The commit row's height, which the lane graph also draws itself at.
|
|
2126
|
+
// Published rather than written into the stylesheet twice: the two
|
|
2127
|
+
// arrangements want different rows, and lanes only meet across the seam
|
|
2128
|
+
// between rows while both numbers come from `COMMIT_ROW_H`.
|
|
2129
|
+
'--gs-commit-row': `${COMMIT_ROW_H[historyLayout]}px`,
|
|
2082
2130
|
} as CSSProperties,
|
|
2083
2131
|
...maximized || width === null ? {} : { width: `${width}px` },
|
|
2084
2132
|
...background === null ? {} : {
|
|
@@ -2230,14 +2278,46 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2230
2278
|
onHeadRef={onHeadRef}
|
|
2231
2279
|
/>
|
|
2232
2280
|
) : null}
|
|
2233
|
-
{
|
|
2281
|
+
{/* History's own toolbar: which ref is listed, and how the panes are
|
|
2282
|
+
arranged. The switch lives HERE rather than in the commit list's
|
|
2283
|
+
head because that head is about 340px wide in the column layout and
|
|
2284
|
+
already holds a title, a funnel and a search box — adding a fourth
|
|
2285
|
+
control pushed it off the pane, and the narrower the reader dragged
|
|
2286
|
+
the list the sooner it went. This row spans the drawer whichever
|
|
2287
|
+
arrangement is in force, so the control cannot be squeezed out of
|
|
2288
|
+
reach by the thing it controls.
|
|
2289
|
+
|
|
2290
|
+
The bar renders for the whole tab, not only when there are branches
|
|
2291
|
+
to pick between: a repository with an unborn HEAD still has an
|
|
2292
|
+
arrangement, and a control that comes and goes is worse than one
|
|
2293
|
+
beside an empty space. */}
|
|
2294
|
+
{tab === 'history' ? (
|
|
2234
2295
|
<div className={css.compareBar}>
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2296
|
+
{branches.length > 0 ? (
|
|
2297
|
+
<RefPicker
|
|
2298
|
+
t={t} label={t('historyRefLabel')} value={historyRef}
|
|
2299
|
+
branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
|
|
2300
|
+
onPick={onHistoryRef}
|
|
2301
|
+
allLabel={t('allBranches')}
|
|
2302
|
+
/>
|
|
2303
|
+
) : null}
|
|
2304
|
+
{/* Two pressed-state buttons rather than one that toggles, so the
|
|
2305
|
+
arrangement in force is readable without knowing which way a
|
|
2306
|
+
toggle points. */}
|
|
2307
|
+
<div className={css.layoutSwitch} role="group" aria-label={t('historyLayout')}>
|
|
2308
|
+
<LayoutButton
|
|
2309
|
+
glyph={<ColumnsGlyph />}
|
|
2310
|
+
label={t('layoutColumns')}
|
|
2311
|
+
on={historyLayout === 'columns'}
|
|
2312
|
+
onPick={() => onHistoryLayout('columns')}
|
|
2313
|
+
/>
|
|
2314
|
+
<LayoutButton
|
|
2315
|
+
glyph={<StackedGlyph />}
|
|
2316
|
+
label={t('layoutStacked')}
|
|
2317
|
+
on={historyLayout === 'stacked'}
|
|
2318
|
+
onPick={() => onHistoryLayout('stacked')}
|
|
2319
|
+
/>
|
|
2320
|
+
</div>
|
|
2241
2321
|
</div>
|
|
2242
2322
|
) : null}
|
|
2243
2323
|
{/* Write operations act on the working tree, so they belong to the tab
|
|
@@ -2252,19 +2332,19 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2252
2332
|
role="status"
|
|
2253
2333
|
>{opMessage(t, opResult.op, opResult.result)}</div>
|
|
2254
2334
|
) : null}
|
|
2255
|
-
{/* History
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
<div ref={bodyRef} className={css.body} data-stacked={tab === 'history' ? '' : undefined}>
|
|
2335
|
+
{/* History arranges itself two ways and the reader picks; see
|
|
2336
|
+
`history-layout.ts` for what each is good at. Stacked, the commit
|
|
2337
|
+
list spans the top and the tree and diff sit below it, so a subject
|
|
2338
|
+
is never cut; in columns the list is a pane beside them, so the log
|
|
2339
|
+
is as tall as the drawer. Everything else on the tab is identical,
|
|
2340
|
+
which is why one flag decides all three differences here. */}
|
|
2341
|
+
<div ref={bodyRef} className={css.body} data-stacked={stackedHistory ? '' : undefined}>
|
|
2263
2342
|
{tab === 'history' ? (
|
|
2264
2343
|
<>
|
|
2265
2344
|
<CommitList
|
|
2266
2345
|
paneRef={commitsRef}
|
|
2267
|
-
style={paneTall(panes.commitsTall ?? null)}
|
|
2346
|
+
style={stackedHistory ? paneTall(panes.commitsTall ?? null) : paneStyle(panes.commits)}
|
|
2347
|
+
layout={historyLayout}
|
|
2268
2348
|
t={t}
|
|
2269
2349
|
loading={historyLoading}
|
|
2270
2350
|
commits={commits}
|
|
@@ -2281,7 +2361,12 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
|
|
|
2281
2361
|
fetchAuthors={fetchAuthors}
|
|
2282
2362
|
fetchRepoTree={fetchRepoTree}
|
|
2283
2363
|
/>
|
|
2284
|
-
|
|
2364
|
+
{/* Each arrangement drags its own stored size — a width and a
|
|
2365
|
+
height are separate fields, so switching back finds the pane
|
|
2366
|
+
where it was left rather than reset. */}
|
|
2367
|
+
{stackedHistory
|
|
2368
|
+
? <PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
|
|
2369
|
+
: <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />}
|
|
2285
2370
|
</>
|
|
2286
2371
|
) : null}
|
|
2287
2372
|
<div className={css.bodyRow}>
|
|
@@ -3518,17 +3603,6 @@ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNod
|
|
|
3518
3603
|
*/
|
|
3519
3604
|
/* ---------- commit graph ---------- */
|
|
3520
3605
|
|
|
3521
|
-
/**
|
|
3522
|
-
* Row height the graph and the list agree on. The lines only join up if every
|
|
3523
|
-
* row is exactly as tall as the segment drawn for it, so this number and
|
|
3524
|
-
* `.commitLine`'s `height` are one fact with two homes —
|
|
3525
|
-
* `tests/commit-row-height.test.ts` holds them together.
|
|
3526
|
-
*
|
|
3527
|
-
* 28 since the History tab stacked: the list spans the drawer's full width, a
|
|
3528
|
-
* commit fits on one line, and 48px of row would spend the vertical space the
|
|
3529
|
-
* stacked layout has least of on empty padding.
|
|
3530
|
-
*/
|
|
3531
|
-
const GRAPH_ROW_H = 28
|
|
3532
3606
|
/** Horizontal distance between lanes. */
|
|
3533
3607
|
const GRAPH_LANE_W = 14
|
|
3534
3608
|
/** Ref chips shown inline before the subject; the rest collapse into a "+N". */
|
|
@@ -3542,23 +3616,28 @@ const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
|
|
|
3542
3616
|
/**
|
|
3543
3617
|
* One row's slice of the commit graph.
|
|
3544
3618
|
*
|
|
3545
|
-
* Drawn as an SVG
|
|
3546
|
-
*
|
|
3547
|
-
*
|
|
3548
|
-
*
|
|
3549
|
-
*
|
|
3619
|
+
* Drawn as an SVG exactly as tall as the row, so consecutive rows butt together
|
|
3620
|
+
* and a lane reads as one unbroken line down the list. The dot sits at the
|
|
3621
|
+
* vertical centre; edges leave the top edge, the dot, or the bottom edge, and a
|
|
3622
|
+
* cubic with its control points at the quarter heights gives the S-curve every
|
|
3623
|
+
* git client draws for a branch or a merge.
|
|
3624
|
+
*
|
|
3625
|
+
* The height is passed in rather than read from a constant here: the two
|
|
3626
|
+
* History arrangements want differently shaped rows, and the segment and the
|
|
3627
|
+
* row it belongs to must come from the same entry of `COMMIT_ROW_H` or the
|
|
3628
|
+
* lanes stop meeting across the seam between rows.
|
|
3550
3629
|
*/
|
|
3551
|
-
function GraphCell({ row, width, active }: { row: GraphRow; width: number; active: boolean }): ReactNode {
|
|
3630
|
+
function GraphCell({ row, width, active, rowH }: { row: GraphRow; width: number; active: boolean; rowH: number }): ReactNode {
|
|
3552
3631
|
const lanes = Math.min(width, GRAPH_MAX_LANES)
|
|
3553
3632
|
const w = lanes * GRAPH_LANE_W
|
|
3554
|
-
const mid =
|
|
3633
|
+
const mid = rowH / 2
|
|
3555
3634
|
const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
|
|
3556
3635
|
const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
|
|
3557
3636
|
|
|
3558
3637
|
const paths: ReactNode[] = []
|
|
3559
3638
|
for (const lane of row.through) {
|
|
3560
3639
|
if (!visible(lane)) continue
|
|
3561
|
-
paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${
|
|
3640
|
+
paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${rowH}`} stroke={stroke(lane)} />)
|
|
3562
3641
|
}
|
|
3563
3642
|
for (const lane of row.into) {
|
|
3564
3643
|
if (!visible(lane) || !visible(row.lane)) continue
|
|
@@ -3575,11 +3654,11 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
|
|
|
3575
3654
|
for (const lane of row.outOf) {
|
|
3576
3655
|
if (!visible(lane) || !visible(row.lane)) continue
|
|
3577
3656
|
paths.push(lane === row.lane
|
|
3578
|
-
? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${
|
|
3657
|
+
? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${rowH}`} stroke={stroke(lane)} />
|
|
3579
3658
|
: (
|
|
3580
3659
|
<path
|
|
3581
3660
|
key={`o${lane}`}
|
|
3582
|
-
d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${
|
|
3661
|
+
d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${rowH}`}
|
|
3583
3662
|
stroke={stroke(lane)}
|
|
3584
3663
|
/>
|
|
3585
3664
|
))
|
|
@@ -3589,8 +3668,8 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
|
|
|
3589
3668
|
<svg
|
|
3590
3669
|
className={css.graphCell}
|
|
3591
3670
|
width={w}
|
|
3592
|
-
height={
|
|
3593
|
-
viewBox={`0 0 ${w} ${
|
|
3671
|
+
height={rowH}
|
|
3672
|
+
viewBox={`0 0 ${w} ${rowH}`}
|
|
3594
3673
|
aria-hidden="true"
|
|
3595
3674
|
focusable="false"
|
|
3596
3675
|
>
|
|
@@ -3611,7 +3690,7 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
|
|
|
3611
3690
|
)
|
|
3612
3691
|
}
|
|
3613
3692
|
|
|
3614
|
-
function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
3693
|
+
function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth, layout }: {
|
|
3615
3694
|
t: Translate
|
|
3616
3695
|
commit: GitCommit
|
|
3617
3696
|
active: boolean
|
|
@@ -3619,6 +3698,8 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3619
3698
|
/** This commit's lane geometry; absent while the graph is still empty. */
|
|
3620
3699
|
graphRow?: GraphRow
|
|
3621
3700
|
graphWidth: number
|
|
3701
|
+
/** Which arrangement the list is in, which decides the row's shape. */
|
|
3702
|
+
layout: HistoryLayout
|
|
3622
3703
|
}): ReactNode {
|
|
3623
3704
|
const rowRef = useRef<HTMLButtonElement>(null)
|
|
3624
3705
|
const [open, setOpen] = useState(false)
|
|
@@ -3661,6 +3742,25 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3661
3742
|
|
|
3662
3743
|
const refs = commit.refs ?? []
|
|
3663
3744
|
|
|
3745
|
+
/* The part both shapes share, and the only part either is really for. */
|
|
3746
|
+
const subjectRow = (
|
|
3747
|
+
<span className={css.commitSubjectRow}>
|
|
3748
|
+
{/* Capped at two. A release commit can carry six refs, and the
|
|
3749
|
+
subject is what the row is actually for — the rest are counted
|
|
3750
|
+
and named in the title rather than crowding it out. */}
|
|
3751
|
+
{refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
|
|
3752
|
+
<span key={ref} className={css.commitRef} title={ref}>{ref}</span>
|
|
3753
|
+
))}
|
|
3754
|
+
{refs.length > COMMIT_REF_CHIPS ? (
|
|
3755
|
+
<span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
|
|
3756
|
+
+{refs.length - COMMIT_REF_CHIPS}
|
|
3757
|
+
</span>
|
|
3758
|
+
) : null}
|
|
3759
|
+
<span className={css.commitSubject}>{commit.subject}</span>
|
|
3760
|
+
{body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
|
|
3761
|
+
</span>
|
|
3762
|
+
)
|
|
3763
|
+
|
|
3664
3764
|
return (
|
|
3665
3765
|
<>
|
|
3666
3766
|
{/* The graph is a SIBLING of the row button, spanning the line's full
|
|
@@ -3669,7 +3769,7 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3669
3769
|
inset and its rounded corners. */}
|
|
3670
3770
|
<div className={css.commitLine}>
|
|
3671
3771
|
{graphRow !== undefined
|
|
3672
|
-
? <GraphCell row={graphRow} width={graphWidth} active={active} />
|
|
3772
|
+
? <GraphCell row={graphRow} width={graphWidth} active={active} rowH={COMMIT_ROW_H[layout]} />
|
|
3673
3773
|
: null}
|
|
3674
3774
|
<button
|
|
3675
3775
|
ref={rowRef}
|
|
@@ -3681,31 +3781,33 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
|
|
|
3681
3781
|
onMouseEnter={show}
|
|
3682
3782
|
onMouseLeave={hide}
|
|
3683
3783
|
>
|
|
3684
|
-
{
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
))}
|
|
3697
|
-
{refs.length > COMMIT_REF_CHIPS ? (
|
|
3698
|
-
<span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
|
|
3699
|
-
+{refs.length - COMMIT_REF_CHIPS}
|
|
3784
|
+
{layout === 'stacked' ? (
|
|
3785
|
+
<>
|
|
3786
|
+
{/* One line, in git log --oneline's order: a fixed-width hash,
|
|
3787
|
+
then the subject, then who and when pushed to the right. The
|
|
3788
|
+
hash being fixed width is what aligns every subject into a
|
|
3789
|
+
column the eye can run down — leading with the author's name
|
|
3790
|
+
instead would start each subject at a different place. */}
|
|
3791
|
+
<code className={css.commitHash}>{commit.hash}</code>
|
|
3792
|
+
{subjectRow}
|
|
3793
|
+
<span className={css.commitMeta}>
|
|
3794
|
+
{authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
|
|
3795
|
+
<span className={css.commitWhen}>{commit.when}</span>
|
|
3700
3796
|
</span>
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3797
|
+
</>
|
|
3798
|
+
) : (
|
|
3799
|
+
<>
|
|
3800
|
+
{/* Two lines, because a pane beside the diff has no width to
|
|
3801
|
+
spare: everything but the subject goes above it, and the
|
|
3802
|
+
subject then gets the column to itself. */}
|
|
3803
|
+
<span className={css.commitTop}>
|
|
3804
|
+
<code className={css.commitHash}>{commit.hash}</code>
|
|
3805
|
+
{authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
|
|
3806
|
+
<span className={css.commitWhen}>{commit.when}</span>
|
|
3807
|
+
</span>
|
|
3808
|
+
{subjectRow}
|
|
3809
|
+
</>
|
|
3810
|
+
)}
|
|
3709
3811
|
</button>
|
|
3710
3812
|
</div>
|
|
3711
3813
|
{open && box !== null && host !== null ? createPortal(
|
|
@@ -3893,15 +3995,81 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
|
|
|
3893
3995
|
}
|
|
3894
3996
|
|
|
3895
3997
|
/**
|
|
3896
|
-
* The
|
|
3998
|
+
* The two arrangements, drawn in the same 16px/1px idiom as the drawer's other
|
|
3999
|
+
* glyphs: a pane and its neighbour, either side by side or one over the other.
|
|
4000
|
+
* The filled half is the list, so the picture says which pane moves.
|
|
4001
|
+
*/
|
|
4002
|
+
function ColumnsGlyph(): ReactNode {
|
|
4003
|
+
return (
|
|
4004
|
+
<svg
|
|
4005
|
+
className={css.layoutGlyph}
|
|
4006
|
+
width="16" height="16" viewBox="0 0 16 16"
|
|
4007
|
+
fill="none" stroke="currentColor" strokeWidth="1"
|
|
4008
|
+
strokeLinejoin="round" aria-hidden="true"
|
|
4009
|
+
>
|
|
4010
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
|
|
4011
|
+
<rect x="1.5" y="2.5" width="5" height="11" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
|
|
4012
|
+
<path d="M6.5 2.5 V13.5" />
|
|
4013
|
+
</svg>
|
|
4014
|
+
)
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
function StackedGlyph(): ReactNode {
|
|
4018
|
+
return (
|
|
4019
|
+
<svg
|
|
4020
|
+
className={css.layoutGlyph}
|
|
4021
|
+
width="16" height="16" viewBox="0 0 16 16"
|
|
4022
|
+
fill="none" stroke="currentColor" strokeWidth="1"
|
|
4023
|
+
strokeLinejoin="round" aria-hidden="true"
|
|
4024
|
+
>
|
|
4025
|
+
<rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
|
|
4026
|
+
<rect x="1.5" y="2.5" width="13" height="4" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
|
|
4027
|
+
<path d="M1.5 6.5 H14.5" />
|
|
4028
|
+
</svg>
|
|
4029
|
+
)
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
/**
|
|
4033
|
+
* One end of the arrangement switch.
|
|
4034
|
+
*
|
|
4035
|
+
* `aria-pressed` rather than a radio group: these are two states of one view
|
|
4036
|
+
* control, not a value being submitted, and a screen reader then reads the
|
|
4037
|
+
* arrangement in force without the group needing a name per option.
|
|
4038
|
+
* @param glyph - the arrangement, drawn.
|
|
4039
|
+
* @param label - accessible name, also the tooltip.
|
|
4040
|
+
* @param on - whether this arrangement is the one in force.
|
|
4041
|
+
* @param onPick - switch to it.
|
|
4042
|
+
*/
|
|
4043
|
+
function LayoutButton({ glyph, label, on, onPick }: {
|
|
4044
|
+
glyph: ReactNode
|
|
4045
|
+
label: string
|
|
4046
|
+
on: boolean
|
|
4047
|
+
onPick: () => void
|
|
4048
|
+
}): ReactNode {
|
|
4049
|
+
return (
|
|
4050
|
+
<button
|
|
4051
|
+
type="button"
|
|
4052
|
+
className={on ? `${css.layoutButton} ${css.layoutButtonOn}` : css.layoutButton}
|
|
4053
|
+
aria-pressed={on}
|
|
4054
|
+
aria-label={label}
|
|
4055
|
+
title={label}
|
|
4056
|
+
onClick={onPick}
|
|
4057
|
+
>{glyph}</button>
|
|
4058
|
+
)
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
/**
|
|
4062
|
+
* The commit log as its own pane, in whichever arrangement the reader picked.
|
|
3897
4063
|
*
|
|
3898
|
-
*
|
|
3899
|
-
*
|
|
3900
|
-
*
|
|
3901
|
-
*
|
|
3902
|
-
*
|
|
3903
|
-
*
|
|
3904
|
-
*
|
|
4064
|
+
* Beside the file tree it is a peer pane the way GitHub Desktop, the JetBrains
|
|
4065
|
+
* git log and GitKraken all draw it — list and selected commit's files side by
|
|
4066
|
+
* side, each with its own scrollbar. Across the top it is IDEA's git log
|
|
4067
|
+
* instead, which is the arrangement that stops a long subject being cut; see
|
|
4068
|
+
* `history-layout.ts` for what each costs. The switch that picks between them
|
|
4069
|
+
* is in the toolbar row above, not in this pane's head — the head is the first
|
|
4070
|
+
* thing to run out of room when the pane is dragged narrow, which is exactly
|
|
4071
|
+
* when a reader reaches for the switch. Either way there is nothing to
|
|
4072
|
+
* collapse and nothing to discover.
|
|
3905
4073
|
*
|
|
3906
4074
|
* Pages load by scrolling. A button at the end of a growing list is the worst
|
|
3907
4075
|
* of both worlds — it retreats every time it is used, and it asks the reader to
|
|
@@ -3910,13 +4078,18 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
|
|
|
3910
4078
|
* what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
|
|
3911
4079
|
* so a page too short to fill the pane immediately triggers the next one.
|
|
3912
4080
|
*/
|
|
3913
|
-
function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
|
|
4081
|
+
function CommitList({ paneRef, style, layout, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
|
|
3914
4082
|
/** The pane element, which the divider beside it measures from. Not named
|
|
3915
4083
|
* `ref`: React reserves that on a function component, so it would be stripped
|
|
3916
4084
|
* from props and never reach this element. */
|
|
3917
4085
|
paneRef: Ref<HTMLDivElement>
|
|
3918
|
-
/** Dragged
|
|
4086
|
+
/** Dragged size, when the divider has been used: a width beside the diff, a
|
|
4087
|
+
* height above it. */
|
|
3919
4088
|
style: CSSProperties | undefined
|
|
4089
|
+
/** The arrangement in force, which decides the row's shape as well as the
|
|
4090
|
+
* pane's. The control that CHANGES it is not in here — see the toolbar row
|
|
4091
|
+
* above the panes. */
|
|
4092
|
+
layout: HistoryLayout
|
|
3920
4093
|
t: Translate
|
|
3921
4094
|
/** First page in flight — the pane says "loading", not "no history", which
|
|
3922
4095
|
* would be a claim about the repository the data has not made. */
|
|
@@ -4107,7 +4280,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
4107
4280
|
}, [hasMore, loadingMore, commits.length, onLoadMore])
|
|
4108
4281
|
|
|
4109
4282
|
return (
|
|
4110
|
-
<div ref={paneRef} className={css.commitsPane} style={style} data-gs-part="commits">
|
|
4283
|
+
<div ref={paneRef} className={css.commitsPane} style={style} data-layout={layout} data-gs-part="commits">
|
|
4111
4284
|
{/* No count: the only number available is how many pages have been loaded,
|
|
4112
4285
|
which is not how many commits exist. A number that cannot be right is
|
|
4113
4286
|
worse than none. */}
|
|
@@ -4369,6 +4542,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
|
|
|
4369
4542
|
onSelect={onSelect}
|
|
4370
4543
|
graphRow={graph.rows[index]}
|
|
4371
4544
|
graphWidth={graph.width}
|
|
4545
|
+
layout={layout}
|
|
4372
4546
|
/>
|
|
4373
4547
|
))}
|
|
4374
4548
|
<div ref={sentinelRef} className={css.commitsSentinel} />
|
|
@@ -5040,7 +5214,16 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5040
5214
|
const colsRef = useRef<HTMLDivElement>(null)
|
|
5041
5215
|
/** The pane's one vertical scroller — what "next change" moves. */
|
|
5042
5216
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
5043
|
-
|
|
5217
|
+
/** Where the rows are, filled in below once they exist. A ref, because the
|
|
5218
|
+
* walk is set up here and the rows are decided further down; reading it
|
|
5219
|
+
* only when a key is pressed is what lets the two live apart. */
|
|
5220
|
+
const rowsForNav = useRef<readonly number[]>([])
|
|
5221
|
+
const { goToChange } = useChangeNav(
|
|
5222
|
+
scrollRef,
|
|
5223
|
+
// Derived, not measured: the pane renders only the rows near the viewport
|
|
5224
|
+
// now, so the block being walked to usually has no element at all.
|
|
5225
|
+
useCallback(() => blockTopsFromRows(rowsForNav.current, DIFF_ROW_H, DIFF_GRID_PAD_TOP), []),
|
|
5226
|
+
)
|
|
5044
5227
|
|
|
5045
5228
|
const [sides, setSides] = useState<FileSides | null>(null)
|
|
5046
5229
|
// Set when the RPC itself failed — most plausibly a host half older than
|
|
@@ -5134,13 +5317,25 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5134
5317
|
// `undefined` and renders the plain text for it; what crashes is indexing
|
|
5135
5318
|
// the array itself, and `strict` is off in tsconfig, so the compiler will
|
|
5136
5319
|
// not say so.
|
|
5320
|
+
// Only the rows the reader can see reach the DOM, and only they are re-lexed
|
|
5321
|
+
// line by line. Declared here because both the render and the highlighting
|
|
5322
|
+
// below are bounded by it.
|
|
5323
|
+
const win = useRowWindow(scrollRef, rows.length)
|
|
5324
|
+
//
|
|
5325
|
+
// Two passes with two lifetimes. The whole-file pass runs once per file and
|
|
5326
|
+
// is what knows about block comments and template literals; the per-line
|
|
5327
|
+
// re-lex — one Shiki call each, and the reason a 4,000-line file froze the
|
|
5328
|
+
// pane for 2.8 seconds — runs only over the rows in the window, and so again
|
|
5329
|
+
// whenever the reader scrolls.
|
|
5330
|
+
const leftLines = useMemo(() => rows.map(row => row.left === null ? '' : row.left.text), [rows])
|
|
5331
|
+
const rightLines = useMemo(() => rows.map(row => row.right === null ? '' : row.right.text), [rows])
|
|
5137
5332
|
const leftSyntax = useMemo(
|
|
5138
|
-
() =>
|
|
5139
|
-
[
|
|
5333
|
+
() => highlightWindow(leftLines, lang, shikiTheme, win.start, win.end),
|
|
5334
|
+
[leftLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5140
5335
|
)
|
|
5141
5336
|
const rightSyntax = useMemo(
|
|
5142
|
-
() =>
|
|
5143
|
-
[
|
|
5337
|
+
() => highlightWindow(rightLines, lang, shikiTheme, win.start, win.end),
|
|
5338
|
+
[rightLines, lang, shikiTheme, win.start, win.end, grammarGen],
|
|
5144
5339
|
)
|
|
5145
5340
|
|
|
5146
5341
|
/** The editor half of the pane, present only on the unstaged layer. */
|
|
@@ -5198,6 +5393,14 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5198
5393
|
// editor. Each entry keeps its index into `rows` for its syntax tokens.
|
|
5199
5394
|
const leftRows = useMemo(() => rows.map((row, i) => ({ row, i })).filter(entry => entry.row.left !== null), [rows])
|
|
5200
5395
|
|
|
5396
|
+
// Only the rows the reader can see reach the DOM. Two windows because the
|
|
5397
|
+
// two columns render two different row lists while the editor is armed: the
|
|
5398
|
+
// right side is a buffer, and the left side is then the index side DENSE,
|
|
5399
|
+
// one row per index line rather than one per aligned row.
|
|
5400
|
+
const leftWin = useRowWindow(scrollRef, leftRows.length)
|
|
5401
|
+
// Kept current for the change walk set up at the top of this component.
|
|
5402
|
+
rowsForNav.current = useMemo(() => rows.map(row => row.block), [rows])
|
|
5403
|
+
|
|
5201
5404
|
// Arming drops the caret straight into the buffer: the click that armed the
|
|
5202
5405
|
// editor said "I want to type here", and a second click to focus is a tax.
|
|
5203
5406
|
|
|
@@ -5564,7 +5767,10 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5564
5767
|
one row per index line, no diff holes — because the right
|
|
5565
5768
|
column is a buffer whose line count diverges from the diff
|
|
5566
5769
|
the moment a keystroke lands. */
|
|
5567
|
-
|
|
5770
|
+
<>
|
|
5771
|
+
<RowSpacer height={leftWin.padTop} />
|
|
5772
|
+
{leftRows.slice(leftWin.start, leftWin.end).map((entry, kk) => {
|
|
5773
|
+
const k = leftWin.start + kk
|
|
5568
5774
|
const { row, i } = entry
|
|
5569
5775
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
5570
5776
|
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
@@ -5577,9 +5783,14 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5577
5783
|
</span>
|
|
5578
5784
|
</Fragment>
|
|
5579
5785
|
)
|
|
5580
|
-
})
|
|
5786
|
+
})}
|
|
5787
|
+
<RowSpacer height={leftWin.padBottom} />
|
|
5788
|
+
</>
|
|
5581
5789
|
) : (
|
|
5582
|
-
|
|
5790
|
+
<>
|
|
5791
|
+
<RowSpacer height={win.padTop} />
|
|
5792
|
+
{rows.slice(win.start, win.end).map((row, k) => {
|
|
5793
|
+
const i = win.start + k
|
|
5583
5794
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
5584
5795
|
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5585
5796
|
// The block's action bar rides in this column only for a row
|
|
@@ -5595,7 +5806,9 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5595
5806
|
</span>
|
|
5596
5807
|
</Fragment>
|
|
5597
5808
|
)
|
|
5598
|
-
})
|
|
5809
|
+
})}
|
|
5810
|
+
<RowSpacer height={win.padBottom} />
|
|
5811
|
+
</>
|
|
5599
5812
|
)}
|
|
5600
5813
|
</div>
|
|
5601
5814
|
</div>
|
|
@@ -5613,7 +5826,9 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5613
5826
|
/>
|
|
5614
5827
|
) : (
|
|
5615
5828
|
<div className={css.sideColGrid}>
|
|
5616
|
-
{
|
|
5829
|
+
<RowSpacer height={win.padTop} />
|
|
5830
|
+
{rows.slice(win.start, win.end).map((row, k) => {
|
|
5831
|
+
const i = win.start + k
|
|
5617
5832
|
const hot = hotBlock !== null && row.block === hotBlock
|
|
5618
5833
|
const hotClass = hot ? ` ${css.sideBlockHot}` : ''
|
|
5619
5834
|
const bar = hot && i === hotFirst && row.right !== null ? blockBar(row.block) : null
|
|
@@ -5631,6 +5846,7 @@ function SideBySideView({ t, path, palette, statsPath, fetchSides, writeChecked,
|
|
|
5631
5846
|
</Fragment>
|
|
5632
5847
|
)
|
|
5633
5848
|
})}
|
|
5849
|
+
<RowSpacer height={win.padBottom} />
|
|
5634
5850
|
</div>
|
|
5635
5851
|
)}
|
|
5636
5852
|
</div>
|
|
@@ -5724,6 +5940,58 @@ function renderSideCode(cell: SideCell | null, tokens: readonly HighlightRun[] |
|
|
|
5724
5940
|
|
|
5725
5941
|
/* ---------- shared helpers ---------- */
|
|
5726
5942
|
|
|
5943
|
+
/**
|
|
5944
|
+
* The rows a diff actually has to put in the DOM, tracked against its scroller.
|
|
5945
|
+
*
|
|
5946
|
+
* Rendering a whole file cost the pane 3.9 seconds of main thread and a
|
|
5947
|
+
* 3.6-second frozen frame at 4,000 lines — for a one-line change, because the
|
|
5948
|
+
* side-by-side view draws every line of the file whether it changed or not.
|
|
5949
|
+
* This is the fix that removes the length from the cost rather than capping it.
|
|
5950
|
+
*
|
|
5951
|
+
* The window is held in state rather than the raw scroll offset so a scroll
|
|
5952
|
+
* that does not move it renders nothing: `start` only changes once a whole row
|
|
5953
|
+
* has passed under the viewport's edge.
|
|
5954
|
+
*
|
|
5955
|
+
* @param scrollRef - the element that scrolls the rows.
|
|
5956
|
+
* @param rowCount - how many rows the diff has.
|
|
5957
|
+
* @returns the rows to render and the spacer heights standing in for the rest.
|
|
5958
|
+
*/
|
|
5959
|
+
function useRowWindow(scrollRef: { current: HTMLElement | null }, rowCount: number): RowWindow {
|
|
5960
|
+
const [win, setWin] = useState<RowWindow>(() => rowWindow(0, 0, rowCount))
|
|
5961
|
+
useEffect(() => {
|
|
5962
|
+
const el = scrollRef.current
|
|
5963
|
+
if (el === null) return
|
|
5964
|
+
const read = (): void => {
|
|
5965
|
+
const next = rowWindow(el.scrollTop, el.clientHeight, rowCount)
|
|
5966
|
+
setWin(prev => prev.start === next.start && prev.end === next.end ? prev : next)
|
|
5967
|
+
}
|
|
5968
|
+
read()
|
|
5969
|
+
// Passive: this listener never calls preventDefault, and saying so keeps
|
|
5970
|
+
// it off the scroll's critical path.
|
|
5971
|
+
el.addEventListener('scroll', read, { passive: true })
|
|
5972
|
+
// The drawer resizes without the page doing so — a dragged edge, the
|
|
5973
|
+
// maximize button — and a taller pane needs more rows.
|
|
5974
|
+
const observer = new ResizeObserver(read)
|
|
5975
|
+
observer.observe(el)
|
|
5976
|
+
return () => {
|
|
5977
|
+
el.removeEventListener('scroll', read)
|
|
5978
|
+
observer.disconnect()
|
|
5979
|
+
}
|
|
5980
|
+
}, [scrollRef, rowCount])
|
|
5981
|
+
return win
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
/**
|
|
5985
|
+
* The spacer standing in for the rows above or below the window.
|
|
5986
|
+
*
|
|
5987
|
+
* It spans every column of the grid, so a blame gutter does not change it.
|
|
5988
|
+
* @param height - px of rows it stands in for; nothing is rendered for 0.
|
|
5989
|
+
*/
|
|
5990
|
+
function RowSpacer({ height }: { height: number }): ReactNode {
|
|
5991
|
+
if (height <= 0) return null
|
|
5992
|
+
return <span className={css.sideSpacer} style={{ height: `${height}px` }} aria-hidden="true" />
|
|
5993
|
+
}
|
|
5994
|
+
|
|
5727
5995
|
/** Split a combined `git diff` into path -> its segment text. */
|
|
5728
5996
|
function splitDiff(diff: string): Map<string, string> {
|
|
5729
5997
|
const out = new Map<string, string>()
|