@young1lin/dsh-ui-gitworkbench 0.1.6 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@young1lin/dsh-ui-gitworkbench",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Out-of-tree dsh web UI plugin: a session-header git workbench chip opening a drawer with the file tree, per-file diff, history, compare, staging, commit, and sync (fetch/pull/push).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -1278,7 +1278,11 @@
1278
1278
  the compact control it hosts, so a pane with tools and a pane without one
1279
1279
  still line up across the divider. */
1280
1280
  .paneHead {
1281
- display: flex; align-items: center; gap: 8px;
1281
+ /* Wraps rather than overflows. Dragged narrow the pane cannot hold a title,
1282
+ a funnel and a search box on one line, and what it used to do was clip the
1283
+ search box at the pane's edge — a control half off the screen, with no
1284
+ sign that the rest of it existed. */
1285
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
1282
1286
  flex: none;
1283
1287
  min-height: 37px; box-sizing: border-box;
1284
1288
  padding: 6px var(--gs-gutter-pane);
@@ -1320,10 +1324,12 @@
1320
1324
  .commitLine {
1321
1325
  display: flex; align-items: stretch;
1322
1326
  flex: none;
1323
- /* Must equal GRAPH_ROW_H in GitWorkbenchPanel.tsx, or the lanes drawn per
1324
- row stop meeting across the seam between rows. Guarded by
1325
- tests/commit-row-height.test.ts. */
1326
- height: 28px;
1327
+ /* Published by the panel from `COMMIT_ROW_H`, which the lane graph also draws
1328
+ each row's segment at one source, because a row and its segment that
1329
+ disagree leave the lanes short of the seam between rows. The two
1330
+ arrangements want different rows, which is why this is a property and not
1331
+ a number. Guarded by tests/commit-row-height.test.ts. */
1332
+ height: var(--gs-commit-row);
1327
1333
  }
1328
1334
  .graphCell { flex: none; display: block; }
1329
1335
  /* An inset row, like dsh's own session rows: 12px radius, filled when active. */
@@ -1343,6 +1349,20 @@
1343
1349
  overflow: hidden;
1344
1350
  transition: background 120ms ease, border-color 120ms ease;
1345
1351
  }
1352
+ /* Beside the diff the row turns into two lines: a pane of about 340px has no
1353
+ width for the hash, the author, the date AND the subject on one, and the
1354
+ subject is the part that would have given way. Above the diff it stays one
1355
+ line — see the row's two shapes in `CommitRow`. */
1356
+ .commitsPane[data-layout="columns"] .commit {
1357
+ flex-direction: column; align-items: stretch; justify-content: center; gap: 2px;
1358
+ }
1359
+ /* The subject line must not absorb the row's spare height, or the two lines
1360
+ stop sitting together in the middle of the row. */
1361
+ .commitsPane[data-layout="columns"] .commitSubjectRow { flex: none; }
1362
+ .commitTop {
1363
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
1364
+ min-width: 0;
1365
+ }
1346
1366
  .commit:hover { background: var(--gs-raise); }
1347
1367
  .commitActive { border-color: var(--gs-accent-border); background: var(--gs-accent-bg); }
1348
1368
  /* Pushed to the right end of the row, and never squeezed: the subject is what
@@ -2093,7 +2113,7 @@
2093
2113
  * its own padding, radius and font-size, and the same "small secondary button"
2094
2114
  * had drifted to four heights and three radii across one screen.
2095
2115
  */
2096
- .btn, .miniBtn, .treeIcon, .commitCopy, .scopeBtn, .refButton, .funnelButton, .funnelPreset {
2116
+ .btn, .miniBtn, .treeIcon, .commitCopy, .scopeBtn, .refButton, .funnelButton, .funnelPreset, .layoutButton {
2097
2117
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
2098
2118
  box-sizing: border-box;
2099
2119
  flex: none;
@@ -2111,14 +2131,14 @@
2111
2131
  transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
2112
2132
  }
2113
2133
  .btn:hover, .miniBtn:hover, .treeIcon:hover, .commitCopy:hover,
2114
- .scopeBtn:hover, .refButton:hover, .funnelButton:hover, .funnelPreset:hover {
2134
+ .scopeBtn:hover, .refButton:hover, .funnelButton:hover, .funnelPreset:hover, .layoutButton:hover {
2115
2135
  background: var(--gs-raise);
2116
2136
  color: var(--gs-fg);
2117
2137
  border-color: var(--gs-fg-fainter);
2118
2138
  }
2119
2139
  .btn:focus-visible, .miniBtn:focus-visible, .treeIcon:focus-visible,
2120
2140
  .commitCopy:focus-visible, .scopeBtn:focus-visible, .refButton:focus-visible,
2121
- .funnelPreset:focus-visible {
2141
+ .funnelPreset:focus-visible, .layoutButton:focus-visible {
2122
2142
  outline: 2px solid var(--gs-accent);
2123
2143
  outline-offset: 1px;
2124
2144
  }
@@ -2131,7 +2151,7 @@
2131
2151
 
2132
2152
  /* Pane tools give their vertical space back to the diff; the date presets sit
2133
2153
  inside a 320px popover, so they take the compact height too. */
2134
- .treeIcon, .commitCopy, .funnelPreset {
2154
+ .treeIcon, .commitCopy, .funnelPreset, .layoutButton {
2135
2155
  height: var(--gs-h-compact);
2136
2156
  padding: var(--gs-pad-compact);
2137
2157
  font-size: var(--gs-t-dense);
@@ -2141,6 +2161,19 @@
2141
2161
  the pane drags down to, two more text buttons cannot share a row with the
2142
2162
  count — and these two are the pair a tree header always carries. */
2143
2163
  .treeIcon { width: var(--gs-h-compact); padding: 0; }
2164
+ /* The arrangement switch: two square icon buttons at the end of the commit
2165
+ list's head, side by side with no gap so they read as one control. Icon-only
2166
+ for the same reason the tree's are — the head already carries a title, a
2167
+ funnel and a search box, and at the 190px the pane drags down to there is no
2168
+ room for two more words. */
2169
+ /* Pushed to the far end of History's toolbar row, away from the ref picker:
2170
+ one changes WHAT is listed, the other only where it is drawn. */
2171
+ .layoutSwitch { display: inline-flex; flex: none; gap: 2px; margin-left: auto; }
2172
+ .layoutButton { width: var(--gs-h-compact); padding: 0; }
2173
+ /* Qualified, like `.treeIconOn`: the shared vocabulary sets `color` at the
2174
+ same specificity a few rules above. */
2175
+ .layoutButton.layoutButtonOn { color: var(--gs-accent); border-color: var(--gs-accent); }
2176
+ .layoutGlyph { display: block; }
2144
2177
  /* Qualified, like `.funnelButtonActive`: this sits inside the shared button
2145
2178
  vocabulary, which sets `color` at the same specificity a few lines above. */
2146
2179
  .treeIcon.treeIconOn { color: var(--gs-accent); border-color: var(--gs-accent); }
@@ -60,6 +60,7 @@ import { parsePatch } from '../patch-model.ts'
60
60
  import { alignRows, blockCount, blockIsWholeFile, blockLines, blockTally, sideBodyState, type SideCell, type SideRow } from './side-rows.ts'
61
61
  import { countBlocks, unifiedBlocks } from './diff-nav.ts'
62
62
  import { clampPane, neighbourWidth } from './pane-size.ts'
63
+ import { COMMIT_ROW_H, DEFAULT_HISTORY_LAYOUT, isHistoryLayout, type HistoryLayout } from './history-layout.ts'
63
64
  import { useChangeNav } from './use-change-nav.ts'
64
65
  import {
65
66
  applySaveOk, applySides, armEdit, armRefusal, DISARMED, editableSides, gateLeave, isDirty,
@@ -423,6 +424,10 @@ const STORE_PANES = 'dsh-ui-gitworkbench:panes'
423
424
  * belongs here beside the layout rather than in the host's per-project store:
424
425
  * two people on one repository should not share each other's place. */
425
426
  const STORE_FILES = 'dsh-ui-gitworkbench:files'
427
+ /** Which way the History tab arranges its panes. Its own key rather than a
428
+ * field of the appearance object: that one is about colour, and this choice
429
+ * has to survive a build that adds a palette. */
430
+ const STORE_HISTORY_LAYOUT = 'dsh-ui-gitworkbench:history-layout'
426
431
 
427
432
  /** Dragged pane sizes in px; null on any of them keeps that pane's CSS default. */
428
433
  interface PaneWidths {
@@ -743,6 +748,16 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
743
748
  const [panes, setPanes] = useState<PaneWidths>(
744
749
  () => readStored(STORE_PANES, isPaneWidths, DEFAULT_PANES),
745
750
  )
751
+ /**
752
+ * The History tab's arrangement.
753
+ *
754
+ * Panel-level, beside the palette rather than inside the tab: the choice has
755
+ * to hold across tab switches and reopens, and the drawer's own card is where
756
+ * the row height it implies is published from.
757
+ */
758
+ const [historyLayout, setHistoryLayout] = useState<HistoryLayout>(
759
+ () => readStored(STORE_HISTORY_LAYOUT, isHistoryLayout, DEFAULT_HISTORY_LAYOUT),
760
+ )
746
761
  /** Per-project and global styling; both scopes, unresolved. */
747
762
  const [style, setStyle] = useState<StyleSettings>(EMPTY_SETTINGS)
748
763
  /** Whether dsh's resolved palette is currently dark. */
@@ -1435,6 +1450,18 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1435
1450
  if (persist) writeStored(STORE_WIDTH, clamped)
1436
1451
  }
1437
1452
 
1453
+ /**
1454
+ * Pick the History tab's arrangement.
1455
+ *
1456
+ * Written through immediately: unlike a drag this is one click, so there is
1457
+ * no intermediate frame to withhold a synchronous storage write for.
1458
+ * @param next - the arrangement to switch to.
1459
+ */
1460
+ const applyHistoryLayout = (next: HistoryLayout): void => {
1461
+ setHistoryLayout(next)
1462
+ writeStored(STORE_HISTORY_LAYOUT, next)
1463
+ }
1464
+
1438
1465
  /** Tab switch. No direction refetches the working tree: `viewKey` already
1439
1466
  * separates the tabs' per-file diff caches, so bumping `gen` here only cost a
1440
1467
  * redundant round trip. */
@@ -1534,6 +1561,8 @@ export function GitWorkbenchPanel({ sessionId, useSessions, t, fetchStats, fetch
1534
1561
  panes={panes}
1535
1562
  onPane={applyPane}
1536
1563
  onCommitsTall={applyCommitsTall}
1564
+ historyLayout={historyLayout}
1565
+ onHistoryLayout={applyHistoryLayout}
1537
1566
  onClose={() => setOpen(false)}
1538
1567
  onRefresh={refresh}
1539
1568
  commitDraft={commitDraft}
@@ -1747,6 +1776,9 @@ interface DrawerProps {
1747
1776
  onPane: (which: PaneWidthKey, next: number, measured: { drawer: number; commits: number; tree: number }, persist: boolean) => void
1748
1777
  /** Drag the History tab's horizontal split: the commit list's height in px. */
1749
1778
  onCommitsTall: (next: number, bodyHeight: number, persist: boolean) => void
1779
+ /** Which way the History tab arranges its panes. */
1780
+ historyLayout: HistoryLayout
1781
+ onHistoryLayout: (next: HistoryLayout) => void
1750
1782
  onClose: () => void
1751
1783
  onRefresh: () => void
1752
1784
  /** Commit draft, lifted so a tab switch cannot discard it. */
@@ -1793,7 +1825,7 @@ interface DrawerProps {
1793
1825
  onCollapsedChange: (next: Set<string>) => void
1794
1826
  }
1795
1827
 
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 {
1828
+ 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
1829
  // Empty stand-in while a commit's change set loads, so every hook below keeps a
1798
1830
  // stable shape and the panes simply render nothing.
1799
1831
  const body = shown ?? EMPTY_STATS
@@ -2066,6 +2098,15 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
2066
2098
  onCommitsTall(clientY - box.top, box.height, done)
2067
2099
  }
2068
2100
 
2101
+ /**
2102
+ * Whether the History tab is showing its stacked arrangement.
2103
+ *
2104
+ * Three things follow from it and they must agree: the body's direction, how
2105
+ * the commit list is sized, and which way its divider slides. Read from one
2106
+ * name so a fourth reader cannot be added out of step.
2107
+ */
2108
+ const stackedHistory = tab === 'history' && historyLayout === 'stacked'
2109
+
2069
2110
  // Width and the background's three tunables are inline because both are live
2070
2111
  // user values; the stylesheet only says what reads them. The pane floors are
2071
2112
  // inline for a different reason: they belong to the drag clamp above, and
@@ -2079,6 +2120,11 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
2079
2120
  '--gs-min-diff': `${MIN_DIFF_WIDTH}px`,
2080
2121
  '--gs-min-commits-tall': `${MIN_COMMITS_HEIGHT}px`,
2081
2122
  '--gs-min-stacked-lower': `${MIN_STACKED_LOWER}px`,
2123
+ // The commit row's height, which the lane graph also draws itself at.
2124
+ // Published rather than written into the stylesheet twice: the two
2125
+ // arrangements want different rows, and lanes only meet across the seam
2126
+ // between rows while both numbers come from `COMMIT_ROW_H`.
2127
+ '--gs-commit-row': `${COMMIT_ROW_H[historyLayout]}px`,
2082
2128
  } as CSSProperties,
2083
2129
  ...maximized || width === null ? {} : { width: `${width}px` },
2084
2130
  ...background === null ? {} : {
@@ -2230,14 +2276,46 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
2230
2276
  onHeadRef={onHeadRef}
2231
2277
  />
2232
2278
  ) : null}
2233
- {tab === 'history' && branches.length > 0 ? (
2279
+ {/* History's own toolbar: which ref is listed, and how the panes are
2280
+ arranged. The switch lives HERE rather than in the commit list's
2281
+ head because that head is about 340px wide in the column layout and
2282
+ already holds a title, a funnel and a search box — adding a fourth
2283
+ control pushed it off the pane, and the narrower the reader dragged
2284
+ the list the sooner it went. This row spans the drawer whichever
2285
+ arrangement is in force, so the control cannot be squeezed out of
2286
+ reach by the thing it controls.
2287
+
2288
+ The bar renders for the whole tab, not only when there are branches
2289
+ to pick between: a repository with an unborn HEAD still has an
2290
+ arrangement, and a control that comes and goes is worse than one
2291
+ beside an empty space. */}
2292
+ {tab === 'history' ? (
2234
2293
  <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
- />
2294
+ {branches.length > 0 ? (
2295
+ <RefPicker
2296
+ t={t} label={t('historyRefLabel')} value={historyRef}
2297
+ branches={branches} worktreeBranches={worktreeBranches} truncated={branchesTruncated}
2298
+ onPick={onHistoryRef}
2299
+ allLabel={t('allBranches')}
2300
+ />
2301
+ ) : null}
2302
+ {/* Two pressed-state buttons rather than one that toggles, so the
2303
+ arrangement in force is readable without knowing which way a
2304
+ toggle points. */}
2305
+ <div className={css.layoutSwitch} role="group" aria-label={t('historyLayout')}>
2306
+ <LayoutButton
2307
+ glyph={<ColumnsGlyph />}
2308
+ label={t('layoutColumns')}
2309
+ on={historyLayout === 'columns'}
2310
+ onPick={() => onHistoryLayout('columns')}
2311
+ />
2312
+ <LayoutButton
2313
+ glyph={<StackedGlyph />}
2314
+ label={t('layoutStacked')}
2315
+ on={historyLayout === 'stacked'}
2316
+ onPick={() => onHistoryLayout('stacked')}
2317
+ />
2318
+ </div>
2241
2319
  </div>
2242
2320
  ) : null}
2243
2321
  {/* Write operations act on the working tree, so they belong to the tab
@@ -2252,19 +2330,19 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
2252
2330
  role="status"
2253
2331
  >{opMessage(t, opResult.op, opResult.result)}</div>
2254
2332
  ) : 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}>
2333
+ {/* History arranges itself two ways and the reader picks; see
2334
+ `history-layout.ts` for what each is good at. Stacked, the commit
2335
+ list spans the top and the tree and diff sit below it, so a subject
2336
+ is never cut; in columns the list is a pane beside them, so the log
2337
+ is as tall as the drawer. Everything else on the tab is identical,
2338
+ which is why one flag decides all three differences here. */}
2339
+ <div ref={bodyRef} className={css.body} data-stacked={stackedHistory ? '' : undefined}>
2263
2340
  {tab === 'history' ? (
2264
2341
  <>
2265
2342
  <CommitList
2266
2343
  paneRef={commitsRef}
2267
- style={paneTall(panes.commitsTall ?? null)}
2344
+ style={stackedHistory ? paneTall(panes.commitsTall ?? null) : paneStyle(panes.commits)}
2345
+ layout={historyLayout}
2268
2346
  t={t}
2269
2347
  loading={historyLoading}
2270
2348
  commits={commits}
@@ -2281,7 +2359,12 @@ function Drawer({ stats, shown, tab, onSwitchTab, commits, commitHash, onSelectC
2281
2359
  fetchAuthors={fetchAuthors}
2282
2360
  fetchRepoTree={fetchRepoTree}
2283
2361
  />
2284
- <PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
2362
+ {/* Each arrangement drags its own stored size — a width and a
2363
+ height are separate fields, so switching back finds the pane
2364
+ where it was left rather than reset. */}
2365
+ {stackedHistory
2366
+ ? <PaneDivider axis="y" label={t('resizeCommits')} onDrag={commitsTallDrag} />
2367
+ : <PaneDivider label={t('resizeCommits')} onDrag={paneDrag('commits', commitsRef)} />}
2285
2368
  </>
2286
2369
  ) : null}
2287
2370
  <div className={css.bodyRow}>
@@ -3518,17 +3601,6 @@ function CopyCommitButton({ t, text }: { t: Translate; text: string }): ReactNod
3518
3601
  */
3519
3602
  /* ---------- commit graph ---------- */
3520
3603
 
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
3604
  /** Horizontal distance between lanes. */
3533
3605
  const GRAPH_LANE_W = 14
3534
3606
  /** Ref chips shown inline before the subject; the rest collapse into a "+N". */
@@ -3542,23 +3614,28 @@ const laneX = (lane: number): number => lane * GRAPH_LANE_W + GRAPH_LANE_W / 2
3542
3614
  /**
3543
3615
  * One row's slice of the commit graph.
3544
3616
  *
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.
3617
+ * Drawn as an SVG exactly as tall as the row, so consecutive rows butt together
3618
+ * and a lane reads as one unbroken line down the list. The dot sits at the
3619
+ * vertical centre; edges leave the top edge, the dot, or the bottom edge, and a
3620
+ * cubic with its control points at the quarter heights gives the S-curve every
3621
+ * git client draws for a branch or a merge.
3622
+ *
3623
+ * The height is passed in rather than read from a constant here: the two
3624
+ * History arrangements want differently shaped rows, and the segment and the
3625
+ * row it belongs to must come from the same entry of `COMMIT_ROW_H` or the
3626
+ * lanes stop meeting across the seam between rows.
3550
3627
  */
3551
- function GraphCell({ row, width, active }: { row: GraphRow; width: number; active: boolean }): ReactNode {
3628
+ function GraphCell({ row, width, active, rowH }: { row: GraphRow; width: number; active: boolean; rowH: number }): ReactNode {
3552
3629
  const lanes = Math.min(width, GRAPH_MAX_LANES)
3553
3630
  const w = lanes * GRAPH_LANE_W
3554
- const mid = GRAPH_ROW_H / 2
3631
+ const mid = rowH / 2
3555
3632
  const visible = (lane: number): boolean => lane < GRAPH_MAX_LANES
3556
3633
  const stroke = (lane: number): string => `var(--gs-graph-${lane % 6})`
3557
3634
 
3558
3635
  const paths: ReactNode[] = []
3559
3636
  for (const lane of row.through) {
3560
3637
  if (!visible(lane)) continue
3561
- paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />)
3638
+ paths.push(<path key={`t${lane}`} d={`M ${laneX(lane)} 0 V ${rowH}`} stroke={stroke(lane)} />)
3562
3639
  }
3563
3640
  for (const lane of row.into) {
3564
3641
  if (!visible(lane) || !visible(row.lane)) continue
@@ -3575,11 +3652,11 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
3575
3652
  for (const lane of row.outOf) {
3576
3653
  if (!visible(lane) || !visible(row.lane)) continue
3577
3654
  paths.push(lane === row.lane
3578
- ? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${GRAPH_ROW_H}`} stroke={stroke(lane)} />
3655
+ ? <path key={`o${lane}`} d={`M ${laneX(lane)} ${mid} V ${rowH}`} stroke={stroke(lane)} />
3579
3656
  : (
3580
3657
  <path
3581
3658
  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}`}
3659
+ d={`M ${laneX(row.lane)} ${mid} C ${laneX(row.lane)} ${mid + mid / 2}, ${laneX(lane)} ${mid + mid / 2}, ${laneX(lane)} ${rowH}`}
3583
3660
  stroke={stroke(lane)}
3584
3661
  />
3585
3662
  ))
@@ -3589,8 +3666,8 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
3589
3666
  <svg
3590
3667
  className={css.graphCell}
3591
3668
  width={w}
3592
- height={GRAPH_ROW_H}
3593
- viewBox={`0 0 ${w} ${GRAPH_ROW_H}`}
3669
+ height={rowH}
3670
+ viewBox={`0 0 ${w} ${rowH}`}
3594
3671
  aria-hidden="true"
3595
3672
  focusable="false"
3596
3673
  >
@@ -3611,7 +3688,7 @@ function GraphCell({ row, width, active }: { row: GraphRow; width: number; activ
3611
3688
  )
3612
3689
  }
3613
3690
 
3614
- function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3691
+ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth, layout }: {
3615
3692
  t: Translate
3616
3693
  commit: GitCommit
3617
3694
  active: boolean
@@ -3619,6 +3696,8 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3619
3696
  /** This commit's lane geometry; absent while the graph is still empty. */
3620
3697
  graphRow?: GraphRow
3621
3698
  graphWidth: number
3699
+ /** Which arrangement the list is in, which decides the row's shape. */
3700
+ layout: HistoryLayout
3622
3701
  }): ReactNode {
3623
3702
  const rowRef = useRef<HTMLButtonElement>(null)
3624
3703
  const [open, setOpen] = useState(false)
@@ -3661,6 +3740,25 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3661
3740
 
3662
3741
  const refs = commit.refs ?? []
3663
3742
 
3743
+ /* The part both shapes share, and the only part either is really for. */
3744
+ const subjectRow = (
3745
+ <span className={css.commitSubjectRow}>
3746
+ {/* Capped at two. A release commit can carry six refs, and the
3747
+ subject is what the row is actually for — the rest are counted
3748
+ and named in the title rather than crowding it out. */}
3749
+ {refs.slice(0, COMMIT_REF_CHIPS).map(ref => (
3750
+ <span key={ref} className={css.commitRef} title={ref}>{ref}</span>
3751
+ ))}
3752
+ {refs.length > COMMIT_REF_CHIPS ? (
3753
+ <span className={css.commitRefMore} title={refs.slice(COMMIT_REF_CHIPS).join('\n')}>
3754
+ +{refs.length - COMMIT_REF_CHIPS}
3755
+ </span>
3756
+ ) : null}
3757
+ <span className={css.commitSubject}>{commit.subject}</span>
3758
+ {body.length > 0 ? <span className={css.commitHasBody} aria-hidden="true">···</span> : null}
3759
+ </span>
3760
+ )
3761
+
3664
3762
  return (
3665
3763
  <>
3666
3764
  {/* The graph is a SIBLING of the row button, spanning the line's full
@@ -3669,7 +3767,7 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3669
3767
  inset and its rounded corners. */}
3670
3768
  <div className={css.commitLine}>
3671
3769
  {graphRow !== undefined
3672
- ? <GraphCell row={graphRow} width={graphWidth} active={active} />
3770
+ ? <GraphCell row={graphRow} width={graphWidth} active={active} rowH={COMMIT_ROW_H[layout]} />
3673
3771
  : null}
3674
3772
  <button
3675
3773
  ref={rowRef}
@@ -3681,31 +3779,33 @@ function CommitRow({ t, commit, active, onSelect, graphRow, graphWidth }: {
3681
3779
  onMouseEnter={show}
3682
3780
  onMouseLeave={hide}
3683
3781
  >
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}
3782
+ {layout === 'stacked' ? (
3783
+ <>
3784
+ {/* One line, in git log --oneline's order: a fixed-width hash,
3785
+ then the subject, then who and when pushed to the right. The
3786
+ hash being fixed width is what aligns every subject into a
3787
+ column the eye can run down — leading with the author's name
3788
+ instead would start each subject at a different place. */}
3789
+ <code className={css.commitHash}>{commit.hash}</code>
3790
+ {subjectRow}
3791
+ <span className={css.commitMeta}>
3792
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3793
+ <span className={css.commitWhen}>{commit.when}</span>
3700
3794
  </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>
3795
+ </>
3796
+ ) : (
3797
+ <>
3798
+ {/* Two lines, because a pane beside the diff has no width to
3799
+ spare: everything but the subject goes above it, and the
3800
+ subject then gets the column to itself. */}
3801
+ <span className={css.commitTop}>
3802
+ <code className={css.commitHash}>{commit.hash}</code>
3803
+ {authorName.length > 0 ? <span className={css.commitAuthor}>{authorName}</span> : null}
3804
+ <span className={css.commitWhen}>{commit.when}</span>
3805
+ </span>
3806
+ {subjectRow}
3807
+ </>
3808
+ )}
3709
3809
  </button>
3710
3810
  </div>
3711
3811
  {open && box !== null && host !== null ? createPortal(
@@ -3893,15 +3993,81 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
3893
3993
  }
3894
3994
 
3895
3995
  /**
3896
- * The commit log as its own full-height pane.
3996
+ * The two arrangements, drawn in the same 16px/1px idiom as the drawer's other
3997
+ * glyphs: a pane and its neighbour, either side by side or one over the other.
3998
+ * The filled half is the list, so the picture says which pane moves.
3999
+ */
4000
+ function ColumnsGlyph(): ReactNode {
4001
+ return (
4002
+ <svg
4003
+ className={css.layoutGlyph}
4004
+ width="16" height="16" viewBox="0 0 16 16"
4005
+ fill="none" stroke="currentColor" strokeWidth="1"
4006
+ strokeLinejoin="round" aria-hidden="true"
4007
+ >
4008
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
4009
+ <rect x="1.5" y="2.5" width="5" height="11" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
4010
+ <path d="M6.5 2.5 V13.5" />
4011
+ </svg>
4012
+ )
4013
+ }
4014
+
4015
+ function StackedGlyph(): ReactNode {
4016
+ return (
4017
+ <svg
4018
+ className={css.layoutGlyph}
4019
+ width="16" height="16" viewBox="0 0 16 16"
4020
+ fill="none" stroke="currentColor" strokeWidth="1"
4021
+ strokeLinejoin="round" aria-hidden="true"
4022
+ >
4023
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1.5" />
4024
+ <rect x="1.5" y="2.5" width="13" height="4" rx="1.5" fill="currentColor" stroke="none" opacity="0.55" />
4025
+ <path d="M1.5 6.5 H14.5" />
4026
+ </svg>
4027
+ )
4028
+ }
4029
+
4030
+ /**
4031
+ * One end of the arrangement switch.
4032
+ *
4033
+ * `aria-pressed` rather than a radio group: these are two states of one view
4034
+ * control, not a value being submitted, and a screen reader then reads the
4035
+ * arrangement in force without the group needing a name per option.
4036
+ * @param glyph - the arrangement, drawn.
4037
+ * @param label - accessible name, also the tooltip.
4038
+ * @param on - whether this arrangement is the one in force.
4039
+ * @param onPick - switch to it.
4040
+ */
4041
+ function LayoutButton({ glyph, label, on, onPick }: {
4042
+ glyph: ReactNode
4043
+ label: string
4044
+ on: boolean
4045
+ onPick: () => void
4046
+ }): ReactNode {
4047
+ return (
4048
+ <button
4049
+ type="button"
4050
+ className={on ? `${css.layoutButton} ${css.layoutButtonOn}` : css.layoutButton}
4051
+ aria-pressed={on}
4052
+ aria-label={label}
4053
+ title={label}
4054
+ onClick={onPick}
4055
+ >{glyph}</button>
4056
+ )
4057
+ }
4058
+
4059
+ /**
4060
+ * The commit log as its own pane, in whichever arrangement the reader picked.
3897
4061
  *
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.
4062
+ * Beside the file tree it is a peer pane the way GitHub Desktop, the JetBrains
4063
+ * git log and GitKraken all draw it list and selected commit's files side by
4064
+ * side, each with its own scrollbar. Across the top it is IDEA's git log
4065
+ * instead, which is the arrangement that stops a long subject being cut; see
4066
+ * `history-layout.ts` for what each costs. The switch that picks between them
4067
+ * is in the toolbar row above, not in this pane's head — the head is the first
4068
+ * thing to run out of room when the pane is dragged narrow, which is exactly
4069
+ * when a reader reaches for the switch. Either way there is nothing to
4070
+ * collapse and nothing to discover.
3905
4071
  *
3906
4072
  * Pages load by scrolling. A button at the end of a growing list is the worst
3907
4073
  * of both worlds — it retreats every time it is used, and it asks the reader to
@@ -3910,13 +4076,18 @@ function PathTreeRows({ dirs, depth, expanded, stateOf, onToggleOpen, onTogglePa
3910
4076
  * what GitHub and GitLens do. The observer is rebuilt whenever the list grows,
3911
4077
  * so a page too short to fill the pane immediately triggers the next one.
3912
4078
  */
3913
- function CommitList({ paneRef, style, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
4079
+ function CommitList({ paneRef, style, layout, t, loading, commits, active, onSelect, hasMore, loadingMore, onLoadMore, query, onQueryChange, error, statsPath, refName, fetchAuthors, fetchRepoTree }: {
3914
4080
  /** The pane element, which the divider beside it measures from. Not named
3915
4081
  * `ref`: React reserves that on a function component, so it would be stripped
3916
4082
  * from props and never reach this element. */
3917
4083
  paneRef: Ref<HTMLDivElement>
3918
- /** Dragged width, when the divider has been used. */
4084
+ /** Dragged size, when the divider has been used: a width beside the diff, a
4085
+ * height above it. */
3919
4086
  style: CSSProperties | undefined
4087
+ /** The arrangement in force, which decides the row's shape as well as the
4088
+ * pane's. The control that CHANGES it is not in here — see the toolbar row
4089
+ * above the panes. */
4090
+ layout: HistoryLayout
3920
4091
  t: Translate
3921
4092
  /** First page in flight — the pane says "loading", not "no history", which
3922
4093
  * would be a claim about the repository the data has not made. */
@@ -4107,7 +4278,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
4107
4278
  }, [hasMore, loadingMore, commits.length, onLoadMore])
4108
4279
 
4109
4280
  return (
4110
- <div ref={paneRef} className={css.commitsPane} style={style} data-gs-part="commits">
4281
+ <div ref={paneRef} className={css.commitsPane} style={style} data-layout={layout} data-gs-part="commits">
4111
4282
  {/* No count: the only number available is how many pages have been loaded,
4112
4283
  which is not how many commits exist. A number that cannot be right is
4113
4284
  worse than none. */}
@@ -4369,6 +4540,7 @@ function CommitList({ paneRef, style, t, loading, commits, active, onSelect, has
4369
4540
  onSelect={onSelect}
4370
4541
  graphRow={graph.rows[index]}
4371
4542
  graphWidth={graph.width}
4543
+ layout={layout}
4372
4544
  />
4373
4545
  ))}
4374
4546
  <div ref={sentinelRef} className={css.commitsSentinel} />