@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.
@@ -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
- // A comparison's per-file diff would need the ref range, which `fileDiff`
1178
- // does not take. Answering with the working tree's diff for that path
1179
- // would be plainly wrong, so a file past the payload cap simply has no
1180
- // detail on this tab.
1181
- if (tab === 'compare') return Promise.resolve('')
1182
- return fetchFileDiff(statsPath, path, tab === 'history' ? commitHash ?? undefined : undefined, signal)
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
- {tab === 'history' && branches.length > 0 ? (
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
- <RefPicker
2236
- t={t} label={t('historyRefLabel')} value={historyRef}
2237
- branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
2238
- onPick={onHistoryRef}
2239
- allLabel={t('allBranches')}
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 stacks: the commit list across the top, the tree and diff
2256
- below it. Three columns left every one of them too narrow — the
2257
- subjects, which are the reason to read a log at all, were the part
2258
- that gave way and the drawer's total width is fixed, so dragging
2259
- a divider could only move the shortage somewhere else. Stacked, the
2260
- subject gets the whole drawer, and the lower half becomes exactly
2261
- the Changes tab's layout: one arrangement to learn, not two. */}
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
- <PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
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 of exactly {@link GRAPH_ROW_H} pixels, so consecutive rows
3546
- * butt together and a lane reads as one unbroken line down the list. The dot
3547
- * sits at the vertical centre; edges leave the top edge, the dot, or the bottom
3548
- * edge, and a cubic with its control points at the quarter heights gives the
3549
- * S-curve every git client draws for a branch or a merge.
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 = GRAPH_ROW_H / 2
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 ${GRAPH_ROW_H}`} stroke={stroke(lane)} />)
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 ${GRAPH_ROW_H}`} stroke={stroke(lane)} />
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)} ${GRAPH_ROW_H}`}
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={GRAPH_ROW_H}
3593
- viewBox={`0 0 ${w} ${GRAPH_ROW_H}`}
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
- {/* One line, in git log --oneline's order: a fixed-width hash, then
3685
- the subject, then who and when pushed to the right. The hash
3686
- being fixed width is what aligns every subject into a column the
3687
- eye can run down leading with the author's name instead would
3688
- start each subject at a different place. */}
3689
- <code className={css.commitHash}>{commit.hash}</code>
3690
- <span className={css.commitSubjectRow}>
3691
- {/* Capped at two. A release commit can carry six refs, and the
3692
- subject is what the row is actually for — the rest are counted
3693
- and named in the title rather than crowding it out. */}
3694
- {refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
3695
- <span key={ref} className={css.commitRef} title={ref}>{ref}</span>
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
- ) : null}
3702
- <span className={css.commitSubject}>{commit.subject}</span>
3703
- {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
3704
- </span>
3705
- <span className={css.commitMeta}>
3706
- {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3707
- <span className={css.commitWhen}>{commit.when}</span>
3708
- </span>
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 commit log as its own full-height pane.
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
- * It sits BESIDE the file tree rather than stacked above it, which is what
3899
- * GitHub Desktop, the JetBrains git log and GitKraken all do: a commit list and
3900
- * the selected commit's files are peer panes, each with its own scrollbar. The
3901
- * earlier stacked layout had to be collapsible because two scrolling lists were
3902
- * sharing one narrow column a control that hid the thing you were reading and
3903
- * that nobody could be expected to discover. Side by side, there is nothing to
3904
- * collapse and nothing to explain.
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 width, when the divider has been used. */
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
- const { goToChange } = useChangeNav(scrollRef)
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
- () => highlightFile(rows.map(row => row.left === null ? '' : row.left.text), lang, shikiTheme),
5139
- [rows, lang, shikiTheme, grammarGen],
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
- () => highlightFile(rows.map(row => row.right === null ? '' : row.right.text), lang, shikiTheme),
5143
- [rows, lang, shikiTheme, grammarGen],
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
- leftRows.map((entry, k) => {
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
- rows.map((row, i) => {
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
- {rows.map((row, i) => {
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>()