dsh-remote-vs 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.en.md +8 -6
  2. package/README.md +8 -6
  3. package/lib/client.js +3313 -1089
  4. package/lib/index.js +209 -7
  5. package/lib/types/client/api.d.ts +12 -0
  6. package/lib/types/client/exec-output.d.ts +58 -0
  7. package/lib/types/client/i18n.d.ts +1 -1
  8. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  9. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  10. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  11. package/lib/types/client/scm/actions.d.ts +50 -3
  12. package/lib/types/client/scm/commit-model.d.ts +119 -0
  13. package/lib/types/client/store.d.ts +9 -3
  14. package/lib/types/client/vsc/kit.d.ts +16 -0
  15. package/lib/types/git-format.d.ts +5 -0
  16. package/lib/types/git-ops.d.ts +57 -2
  17. package/lib/types/pool.d.ts +9 -0
  18. package/lib/types/protocol.d.ts +23 -0
  19. package/lib/types/timeouts.d.ts +17 -2
  20. package/package.json +1 -1
  21. package/src/client/api.ts +61 -0
  22. package/src/client/exec-output.ts +129 -0
  23. package/src/client/i18n.ts +131 -2
  24. package/src/client/scm/CommitPane.tsx +726 -0
  25. package/src/client/scm/ExecPanel.tsx +124 -0
  26. package/src/client/scm/HistoryPanel.tsx +483 -104
  27. package/src/client/scm/ScmPanel.tsx +247 -9
  28. package/src/client/scm/actions.ts +93 -10
  29. package/src/client/scm/commit-model.ts +317 -0
  30. package/src/client/store.tsx +11 -5
  31. package/src/client/vsc/kit.tsx +71 -0
  32. package/src/client/vsc/vsc.css +647 -0
  33. package/src/git-format.ts +7 -3
  34. package/src/git-ops.ts +83 -7
  35. package/src/pool.ts +84 -4
  36. package/src/protocol.ts +14 -0
  37. package/src/routes.ts +135 -1
  38. package/src/timeouts.ts +18 -2
@@ -1,37 +1,46 @@
1
1
  /**
2
2
  * History view: the commit graph with branch lanes and merge curves (lane
3
- * layout ported from the previous Git view), plus click-to-expand changed
4
- * files and a per-file diff of that commit.
3
+ * layout ported from the previous Git view), plus a COMMIT PANE below it —
4
+ * the selected commit's 提交详情 / 内容 / 差异 tabs (see `./CommitPane.tsx`).
5
+ *
6
+ * The pane replaced the old per-row inline expansion: a commit is a subject
7
+ * with three answers, not a disclosure widget, and the pane keeps the graph
8
+ * readable while one commit is open. Clicking a row selects (the pane opens on
9
+ * 提交详情), clicking a changed file shows its patch in 差异, and a commit's
10
+ * per-file two-sided diff still opens in the editor through `openCommitFile`.
5
11
  *
6
12
  * Loading is a SINGLE capped call (`api.git.log(..., GIT_LOG_MAX)`); a deep
7
13
  * repository shows the newest GIT_LOG_MAX commits and is filtered client-side.
8
- * The docstring here used to promise scroll-triggered paging that does not
9
- * exist, which is worse than no comment: a reader trusts it.
10
14
  *
11
15
  * Each repository's last answer is kept in memory (`./panel-cache.ts`), so a
12
16
  * switch back to a workspace repaints its commit list at once and the log is
13
17
  * re-read behind it — the tab used to blank on every switch, and worse, kept the
14
18
  * PREVIOUS repository's rows until the new log arrived.
19
+ *
20
+ * Per-commit reads are cached separately (message + files, and the lazy patch),
21
+ * keyed by repository AND hash, so switching repositories can never show one
22
+ * host's commit body under another host's graph.
15
23
  */
16
24
  import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
17
25
  import type { GitCommit, GitCommitFile } from '../../protocol.ts'
18
26
  import { GIT_LOG_MAX } from '../../git-format.ts'
19
27
  import { api } from '../api.ts'
20
28
  import { gitReadSettleMs, useExplorer } from '../store.tsx'
21
- import { checkoutCommit, loadCommitFiles } from './actions.ts'
29
+ import { checkoutCommit, joinRemote, loadCommitFiles, loadCommitMessage, loadCommitPatch } from './actions.ts'
30
+ import { CommitPane } from './CommitPane.tsx'
31
+ import { isHistoryTab, needsPatchLoad, refBadgeClass, refBadgeLabel, type HistoryTab } from './commit-model.ts'
22
32
  import { ReadQueue } from './read-queue.ts'
23
33
  import { readHistorySnapshot, repoCacheKey, writeHistorySnapshot } from './panel-cache.ts'
24
34
  import {
35
+ ContextMenu,
25
36
  IconButton,
26
- IconChevronDown,
27
- IconChevronRight,
28
37
  IconRefresh,
29
38
  MiniSpinner,
30
39
  confirmDialog,
31
- FileIcon,
32
- gitStatusClass,
40
+ copyText,
33
41
  relTime,
34
42
  toast,
43
+ useContextMenu,
35
44
  } from '../vsc/kit.tsx'
36
45
 
37
46
  const ROW_H = 22
@@ -39,6 +48,63 @@ const LANE_W = 13
39
48
  const PAD_X = 9
40
49
  const LANE_COLORS = ['#e2b714', '#4ec9b0', '#58a6ff', '#d2a8ff', '#ff7b72', '#79c0ff', '#7ee787', '#ffa657']
41
50
 
51
+ /** Persisted pane geometry / view: one profile-wide preference, like the split. */
52
+ const PANE_HEIGHT_KEY = 'dsh-remote-vs.historyPane'
53
+ const PANE_TAB_KEY = 'dsh-remote-vs.historyTab'
54
+ const PANE_MIN_HEIGHT = 180
55
+
56
+ /**
57
+ * Restore a persisted height, clamped to THIS window.
58
+ *
59
+ * A height saved on a tall window was previously replayed verbatim on a short
60
+ * one, and the percentage `max-height` in CSS can be powerless when the flex
61
+ * column's height is indefinite — the graph then collapsed to nothing. Clamp
62
+ * against the viewport at read time (the drag path clamps against the column).
63
+ */
64
+ function readPaneHeight(): number {
65
+ try {
66
+ const saved = Number(localStorage.getItem(PANE_HEIGHT_KEY))
67
+ const viewportCap = typeof window === 'undefined'
68
+ ? 900
69
+ : Math.round(window.innerHeight * 0.7)
70
+ const cap = Math.max(PANE_MIN_HEIGHT, viewportCap)
71
+ if (Number.isFinite(saved) && saved >= PANE_MIN_HEIGHT) return Math.round(Math.min(saved, cap))
72
+ } catch {
73
+ // Ignore.
74
+ }
75
+ return 300
76
+ }
77
+
78
+ function readPaneTab(): HistoryTab | undefined {
79
+ try {
80
+ const saved = localStorage.getItem(PANE_TAB_KEY)
81
+ if (isHistoryTab(saved)) return saved
82
+ } catch {
83
+ // Ignore.
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ /** Bounded cache insert (Map keeps insertion order: the first key is oldest). */
89
+ function cacheSet<K, V>(map: Map<K, V>, key: K, value: V, max: number): void {
90
+ map.set(key, value)
91
+ while (map.size > max) {
92
+ const oldest = map.keys().next().value
93
+ if (oldest === undefined) break
94
+ map.delete(oldest)
95
+ }
96
+ }
97
+
98
+ function errorText(error: unknown): string {
99
+ return error instanceof Error ? error.message : String(error)
100
+ }
101
+
102
+ /** The tab strip's choice plus whether the USER made it (first click lands on 提交详情). */
103
+ interface TabChoice {
104
+ id: HistoryTab
105
+ touched: boolean
106
+ }
107
+
42
108
  /**
43
109
  * One log read per repository at a time.
44
110
  *
@@ -49,22 +115,6 @@ const LANE_COLORS = ['#e2b714', '#4ec9b0', '#58a6ff', '#d2a8ff', '#ff7b72', '#79
49
115
  */
50
116
  const logReads = new ReadQueue()
51
117
 
52
- /**
53
- * Ref badge decoration for one `%D` entry. Extracted from a pair of nested
54
- * ternaries (forbidden by this project's review checklist) that were doing two
55
- * different jobs — CSS modifier and display text — inside one JSX expression.
56
- */
57
- function refBadgeClass(ref: string): string {
58
- if (ref.startsWith('HEAD ->')) return ' head'
59
- if (ref.startsWith('tag:')) return ' tag'
60
- return ''
61
- }
62
-
63
- function refBadgeLabel(ref: string): string {
64
- if (ref.startsWith('HEAD -> ')) return ref.slice('HEAD -> '.length)
65
- return ref.replace(/^tag: /, '')
66
- }
67
-
68
118
  interface GraphRow {
69
119
  commit: GitCommit
70
120
  lane: number
@@ -140,6 +190,23 @@ function laneColor(lane: number): string {
140
190
  /** A stable empty list: the "this repository was never read" fallback. */
141
191
  const EMPTY_COMMITS: GitCommit[] = []
142
192
 
193
+ /** What one commit's lazy detail reads produced (keyed by repo + hash). */
194
+ interface CommitDetail {
195
+ /** The `${repoKey}|${hash}` the values belong to. */
196
+ key: string
197
+ body: string
198
+ files?: GitCommitFile[]
199
+ filesError?: string
200
+ }
201
+
202
+ interface CommitPatchState {
203
+ key: string
204
+ text?: string
205
+ /** The remote read hit the byte cap (the pane says so). */
206
+ truncated?: boolean
207
+ error?: string
208
+ }
209
+
143
210
  export function HistoryPanel(): ReactNode {
144
211
  const store = useExplorer()
145
212
  const { t, lang, alias, repoRoot } = store
@@ -155,9 +222,27 @@ export function HistoryPanel(): ReactNode {
155
222
  const [commitsKey, setCommitsKey] = useState('')
156
223
  const [loading, setLoading] = useState(false)
157
224
  const [error, setError] = useState<string | undefined>(undefined)
158
- const [expanded, setExpanded] = useState<string | undefined>(undefined)
159
- const [files, setFiles] = useState<Record<string, GitCommitFile[]>>({})
160
225
  const [filter, setFilter] = useState('')
226
+ const [selectedHash, setSelectedHash] = useState<string | undefined>(undefined)
227
+ const [focusPath, setFocusPath] = useState<string | undefined>(undefined)
228
+ const [tabChoice, setTabChoice] = useState<TabChoice>(() => {
229
+ const saved = readPaneTab()
230
+ return { id: saved ?? 'details', touched: saved !== undefined }
231
+ })
232
+ const [paneHeight, setPaneHeight] = useState<number>(readPaneHeight)
233
+ const [dragging, setDragging] = useState(false)
234
+ /** Full-height mode: the graph makes room and the pane takes the panel. */
235
+ const [paneExpanded, setPaneExpanded] = useState(false)
236
+ const commitMenu = useContextMenu()
237
+ const [detail, setDetail] = useState<CommitDetail>({ key: '', body: '' })
238
+ const [patchState, setPatchState] = useState<CommitPatchState>({ key: '' })
239
+ const [reloadSeq, setReloadSeq] = useState(0)
240
+ /** The patch read for `detailKey` (a ref: it must not re-render on its own). */
241
+ const patchFlight = useRef<string | undefined>(undefined)
242
+ /** Monotonic ticket: only the LATEST read may paint (older ones only cache). */
243
+ const patchTicket = useRef(0)
244
+ /** Bumped after every patch settle so the lazy effect re-evaluates once. */
245
+ const [patchTick, setPatchTick] = useState(0)
161
246
  const head = store.head?.branch ?? ''
162
247
  /**
163
248
  * Ticket for the log request. The panel stays mounted across binding
@@ -166,6 +251,14 @@ export function HistoryPanel(): ReactNode {
166
251
  * buttons that act on the currently bound repository.
167
252
  */
168
253
  const loadSeq = useRef(0)
254
+ /** Per-commit caches: message+files (bounded 20) and patch text+cap flag (bounded 4). */
255
+ const detailCache = useRef(new Map<string, { body: string; files: GitCommitFile[] }>())
256
+ const patchCache = useRef(new Map<string, { text: string; truncated: boolean }>())
257
+ /** The commit object behind `selectedHash`, kept while the log no longer lists it. */
258
+ const [selectedSnapshot, setSelectedSnapshot] = useState<GitCommit | undefined>(undefined)
259
+ const containerRef = useRef<HTMLDivElement>(null)
260
+ const dragRef = useRef<{ startY: number; startHeight: number } | undefined>(undefined)
261
+ const paneHeightRef = useRef(paneHeight)
169
262
 
170
263
  const repoKey = alias === '' || repoRoot === undefined ? '' : repoCacheKey(alias, repoRoot)
171
264
  /** The cached log of the repository being shown, when it was read before. */
@@ -214,6 +307,98 @@ export function HistoryPanel(): ReactNode {
214
307
  void load()
215
308
  }, [load])
216
309
 
310
+ /**
311
+ * The selected commit as the graph currently knows it, falling back to the
312
+ * snapshot taken at selection time. Without the fallback a `git log` refresh
313
+ * that drops the hash from the capped window silently unmounted the pane (the
314
+ * commit was still cached and perfectly readable).
315
+ */
316
+ const selectedCommit = useMemo(() => {
317
+ if (selectedHash === undefined) return undefined
318
+ const live = rows.find(commit => commit.hash === selectedHash)
319
+ if (live !== undefined) return live
320
+ return selectedSnapshot !== undefined && selectedSnapshot.hash === selectedHash ? selectedSnapshot : undefined
321
+ }, [rows, selectedHash, selectedSnapshot])
322
+ /** Cache key of the selected commit ('' = nothing selected / no binding). */
323
+ const detailKey = selectedCommit === undefined || repoKey === '' ? '' : `${repoKey}|${selectedCommit.hash}`
324
+
325
+ // A repository switch drops the selection: the hash belongs to the old graph.
326
+ useEffect(() => {
327
+ setSelectedHash(undefined)
328
+ setFocusPath(undefined)
329
+ }, [repoKey])
330
+
331
+ // Message + file list: what 提交详情 and 内容 need. The patch is NOT read here
332
+ // (see the lazy effect below), so clicking through commits stays cheap.
333
+ useEffect(() => {
334
+ if (detailKey === '' || selectedCommit === undefined || alias === '' || repoRoot === undefined) {
335
+ setDetail({ key: '', body: '' })
336
+ return
337
+ }
338
+ const cached = detailCache.current.get(detailKey)
339
+ if (cached !== undefined) {
340
+ setDetail({ key: detailKey, body: cached.body, files: cached.files })
341
+ return
342
+ }
343
+ let cancelled = false
344
+ setDetail({ key: detailKey, body: '' })
345
+ void (async () => {
346
+ const [message, files] = await Promise.allSettled([
347
+ loadCommitMessage(alias, repoRoot, selectedCommit.hash),
348
+ loadCommitFiles(alias, repoRoot, selectedCommit.hash),
349
+ ])
350
+ if (cancelled) return
351
+ const body = message.status === 'fulfilled' ? message.value.body : ''
352
+ const fileRows = files.status === 'fulfilled' ? files.value : undefined
353
+ const filesError = files.status === 'rejected' ? errorText(files.reason) : undefined
354
+ if (fileRows !== undefined) cacheSet(detailCache.current, detailKey, { body, files: fileRows }, 20)
355
+ setDetail({ key: detailKey, body, files: fileRows, filesError })
356
+ })()
357
+ return () => { cancelled = true }
358
+ }, [detailKey, alias, repoRoot, selectedCommit, reloadSeq])
359
+
360
+ // The patch is read only while 差异 is on screen, and cached per commit: a
361
+ // reader of 提交详情 never pays a `git show --patch` round trip.
362
+ //
363
+ // `needsPatchLoad` (not `patchState.key === detailKey`) is the guard: a
364
+ // superseded read used to leave the key marker WITHOUT data, and the old
365
+ // equality check then treated that marker as "loaded" forever — the diff
366
+ // stayed empty with "这个提交没有改动文件" after any quick tab/commit flip.
367
+ // A request ticket (not a cleanup-cancelled promise) is what lets a read that
368
+ // finishes after the tab moved on still fill the cache, while a superseded
369
+ // selection can never paint its answer.
370
+ useEffect(() => {
371
+ if (selectedCommit === undefined || alias === '' || repoRoot === undefined) return
372
+ if (!needsPatchLoad(patchState, detailKey, tabChoice.id)) return
373
+ const cached = patchCache.current.get(detailKey)
374
+ if (cached !== undefined) {
375
+ // The cap flag travels WITH the text: a cache hit used to restore the
376
+ // cut patch without its "truncated" notice (silent truncation again).
377
+ setPatchState({ key: detailKey, text: cached.text, truncated: cached.truncated })
378
+ return
379
+ }
380
+ if (patchFlight.current === detailKey) return
381
+ const ticket = ++patchTicket.current
382
+ patchFlight.current = detailKey
383
+ setPatchState({ key: detailKey })
384
+ void (async () => {
385
+ try {
386
+ const result = await loadCommitPatch(alias, repoRoot, selectedCommit.hash)
387
+ cacheSet(patchCache.current, detailKey, { text: result.text, truncated: result.truncated }, 4)
388
+ if (patchTicket.current !== ticket) return
389
+ setPatchState({ key: detailKey, text: result.text, truncated: result.truncated })
390
+ } catch (caught) {
391
+ if (patchTicket.current !== ticket) return
392
+ setPatchState({ key: detailKey, error: errorText(caught) })
393
+ } finally {
394
+ if (patchFlight.current === detailKey) patchFlight.current = undefined
395
+ // Re-arm the effect after every settle: a superseded run must not leave
396
+ // the CURRENT view on an empty marker with nothing scheduled.
397
+ setPatchTick(value => value + 1)
398
+ }
399
+ })()
400
+ }, [tabChoice.id, detailKey, alias, repoRoot, selectedCommit, patchState, reloadSeq, patchTick])
401
+
217
402
  const visible = useMemo(() => {
218
403
  if (filter.trim() === '') return rows
219
404
  const needle = filter.trim().toLowerCase()
@@ -225,18 +410,201 @@ export function HistoryPanel(): ReactNode {
225
410
 
226
411
  const graph = useMemo(() => layoutLanes(visible), [visible])
227
412
 
228
- const toggleCommit = async (hash: string): Promise<void> => {
229
- if (expanded === hash) {
230
- setExpanded(undefined)
413
+ const selectCommit = (hash: string): void => {
414
+ setSelectedHash(hash)
415
+ setSelectedSnapshot(rows.find(commit => commit.hash === hash))
416
+ setFocusPath(undefined)
417
+ // The FIRST selection of a panel life lands on 提交详情; once the user has
418
+ // picked a tab by hand (or restored one), selection keeps it.
419
+ if (!tabChoice.touched) setTabChoice({ id: 'details', touched: false })
420
+ }
421
+
422
+ const chooseTab = useCallback((next: HistoryTab): void => {
423
+ setTabChoice({ id: next, touched: true })
424
+ try {
425
+ localStorage.setItem(PANE_TAB_KEY, next)
426
+ } catch {
427
+ // Ignore.
428
+ }
429
+ }, [])
430
+
431
+ const focusCommitFile = (path: string): void => {
432
+ setFocusPath(path)
433
+ chooseTab('diff')
434
+ }
435
+
436
+ const openCommitFile = (path: string, origPath?: string): void => {
437
+ if (selectedCommit === undefined) return
438
+ store.openCommitFile(selectedCommit.hash, path, origPath)
439
+ }
440
+
441
+ /** Remote directory of a repo-relative commit path ('' = repository root). */
442
+ const commitFileDir = (path: string): string => {
443
+ const slash = path.lastIndexOf('/')
444
+ const dir = slash < 0 ? '' : path.slice(0, slash)
445
+ return joinRemote(repoRoot ?? '', dir)
446
+ }
447
+
448
+ /** Right-click menu: show the changed file's folder in the explorer tree. */
449
+ const revealCommitFile = (path: string): void => {
450
+ if (repoRoot === undefined) return
451
+ store.revealInExplorer(commitFileDir(path))
452
+ }
453
+
454
+ /** Right-click menu: a terminal tab that starts in the changed file's folder. */
455
+ const openCommitFileTerminal = (path: string): void => {
456
+ if (repoRoot === undefined) return
457
+ store.openTerminalAt(commitFileDir(path))
458
+ }
459
+
460
+ /** Copy-absolute: repo-relative commit path → remote absolute path. */
461
+ const commitFileAbsPath = (path: string): string => joinRemote(repoRoot ?? '', path)
462
+
463
+ const closePane = (): void => {
464
+ setSelectedHash(undefined)
465
+ setSelectedSnapshot(undefined)
466
+ setFocusPath(undefined)
467
+ setPaneExpanded(false)
468
+ }
469
+
470
+ /** Up/Down move the graph selection (the list handles its own keys). */
471
+ const onListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
472
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
473
+ if (visible.length === 0) return
474
+ event.preventDefault()
475
+ const current = selectedHash === undefined ? -1 : visible.findIndex(commit => commit.hash === selectedHash)
476
+ const direction = event.key === 'ArrowDown' ? 1 : -1
477
+ let next: number
478
+ if (current < 0) next = direction === 1 ? 0 : visible.length - 1
479
+ else next = Math.min(visible.length - 1, Math.max(0, current + direction))
480
+ const commit = visible[next]
481
+ if (commit === undefined) return
482
+ selectCommit(commit.hash)
483
+ event.currentTarget.querySelector<HTMLButtonElement>(`[data-hash="${commit.hash}"]`)?.focus()
484
+ }
485
+
486
+ /** Graph context menu: the commit actions that do not need the pane open. */
487
+ const openCommitMenu = (event: React.MouseEvent, commit: GitCommit): void => {
488
+ event.preventDefault()
489
+ event.stopPropagation()
490
+ if (commit.hash !== selectedHash) selectCommit(commit.hash)
491
+ commitMenu.open(event.clientX, event.clientY, [
492
+ {
493
+ label: t('history.copyHash'),
494
+ onSelect: () => { void copyText(commit.hash).then(() => toast(t('history.copiedHash'))) },
495
+ },
496
+ {
497
+ label: t('history.copySubject'),
498
+ onSelect: () => { void copyText(commit.subject).then(() => toast(t('history.copiedSubject'))) },
499
+ },
500
+ {
501
+ label: t('scm.checkout'),
502
+ separated: true,
503
+ onSelect: () => { void checkoutCommitFlow(commit) },
504
+ },
505
+ ])
506
+ }
507
+
508
+ /** Drop the caches of the selected commit and re-read what the pane shows. */
509
+ const reloadSelected = (): void => {
510
+ if (detailKey !== '') {
511
+ detailCache.current.delete(detailKey)
512
+ patchCache.current.delete(detailKey)
513
+ }
514
+ setDetail({ key: '', body: '' })
515
+ setPatchState({ key: '' })
516
+ setReloadSeq(value => value + 1)
517
+ }
518
+
519
+ const checkoutCommitFlow = (commit: GitCommit): void => {
520
+ if (repoRoot === undefined) return
521
+ void (async () => {
522
+ const ok = await confirmDialog({
523
+ title: t('scm.checkoutCommitTitle'),
524
+ text: t('scm.checkoutCommitText').replace('{hash}', commit.hash.slice(0, 10)),
525
+ okText: t('scm.checkout'),
526
+ })
527
+ if (!ok) return
528
+ try {
529
+ await checkoutCommit(alias, repoRoot, commit.hash)
530
+ await store.afterWrite()
531
+ toast(t('scm.checkedOut'))
532
+ } catch (caught) {
533
+ toast(errorText(caught), true)
534
+ }
535
+ })()
536
+ }
537
+
538
+ const checkoutSelected = (): void => {
539
+ if (selectedCommit !== undefined) checkoutCommitFlow(selectedCommit)
540
+ }
541
+
542
+ // ── Pane splitter: drag up grows the pane (same gesture as the editor split;
543
+ // the handle reuses `.rvs-vsplit-handle`'s row-resize styling). ─────────
544
+ /**
545
+ * The RENDERED height, not the persisted one: CSS caps the pane at 82% of
546
+ * the column, so a height saved on a tall window is painted smaller than
547
+ * `paneHeightRef` claims — seeding the drag from the persisted number made the
548
+ * first part of every drag do nothing.
549
+ */
550
+ const renderedPaneHeight = (): number => {
551
+ const element = containerRef.current?.querySelector<HTMLElement>('.rvs-hpane')
552
+ return element?.offsetHeight ?? paneHeightRef.current
553
+ }
554
+ const onPaneDown = (event: React.PointerEvent<HTMLDivElement>): void => {
555
+ if (event.button !== 0) return
556
+ event.preventDefault()
557
+ try {
558
+ event.currentTarget.setPointerCapture(event.pointerId)
559
+ } catch {
560
+ // Older engine: the button gate covers it.
561
+ }
562
+ const startHeight = renderedPaneHeight()
563
+ paneHeightRef.current = startHeight
564
+ dragRef.current = { startY: event.clientY, startHeight }
565
+ setDragging(true)
566
+ }
567
+ const onPaneMove = (event: React.PointerEvent<HTMLDivElement>): void => {
568
+ const drag = dragRef.current
569
+ if (drag === undefined) return
570
+ if ((event.buttons & 1) === 0) {
571
+ dragRef.current = undefined
572
+ setDragging(false)
231
573
  return
232
574
  }
233
- setExpanded(hash)
234
- if (files[hash] !== undefined || repoRoot === undefined) return
575
+ const total = containerRef.current?.clientHeight ?? 0
576
+ if (total < 220) return
577
+ const max = Math.max(PANE_MIN_HEIGHT + 60, total * 0.82)
578
+ const next = Math.round(Math.max(PANE_MIN_HEIGHT, Math.min(max, drag.startHeight + (drag.startY - event.clientY))))
579
+ paneHeightRef.current = next
580
+ setPaneHeight(next)
581
+ }
582
+ const onPaneEnd = (): void => {
583
+ if (dragRef.current === undefined) return
584
+ dragRef.current = undefined
585
+ setDragging(false)
235
586
  try {
236
- const rows = await loadCommitFiles(alias, repoRoot, hash)
237
- setFiles(prev => ({ ...prev, [hash]: rows }))
238
- } catch (error) {
239
- toast(error instanceof Error ? error.message : String(error), true)
587
+ localStorage.setItem(PANE_HEIGHT_KEY, String(paneHeightRef.current))
588
+ } catch {
589
+ // Ignore.
590
+ }
591
+ }
592
+ /** Keyboard resize (the splitter is a focusable separator): ↑ grows, ↓ shrinks. */
593
+ const onPaneKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
594
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
595
+ event.preventDefault()
596
+ const total = containerRef.current?.clientHeight ?? 0
597
+ const base = renderedPaneHeight()
598
+ paneHeightRef.current = base
599
+ const max = total < 220 ? base : Math.max(PANE_MIN_HEIGHT + 60, total * 0.82)
600
+ const delta = event.key === 'ArrowUp' ? 24 : -24
601
+ const next = Math.round(Math.max(PANE_MIN_HEIGHT, Math.min(max, base + delta)))
602
+ paneHeightRef.current = next
603
+ setPaneHeight(next)
604
+ try {
605
+ localStorage.setItem(PANE_HEIGHT_KEY, String(next))
606
+ } catch {
607
+ // Ignore.
240
608
  }
241
609
  }
242
610
 
@@ -257,30 +625,31 @@ export function HistoryPanel(): ReactNode {
257
625
  <input
258
626
  className="rvs-input"
259
627
  placeholder={t('history.filter')}
628
+ aria-label={t('history.filter')}
260
629
  value={filter}
261
630
  spellCheck={false}
262
631
  onChange={(e) => setFilter(e.target.value)}
263
632
  />
264
633
  </div>
265
634
  {error !== undefined && <div className="rvs-banner error">{error}</div>}
266
- <div className="rvs-scroll">
267
- {loading && rows.length === 0 && <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>}
268
- {!loading && rows.length === 0 && error === undefined && <div className="rvs-empty">{t('scm.logEmpty')}</div>}
269
- {graph.rows.map((row, index) => {
270
- const commit = row.commit
271
- const isOpen = expanded === commit.hash
272
- const changed = files[commit.hash]
273
- return (
274
- <div key={commit.hash}>
635
+ <div className="rvs-history-body" ref={containerRef}>
636
+ <div className="rvs-scroll" hidden={paneExpanded} onKeyDown={onListKeyDown}>
637
+ {loading && rows.length === 0 && <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>}
638
+ {!loading && rows.length === 0 && error === undefined && <div className="rvs-empty">{t('scm.logEmpty')}</div>}
639
+ {graph.rows.map((row, index) => {
640
+ const commit = row.commit
641
+ const isSelected = commit.hash === selectedHash
642
+ return (
275
643
  <button
644
+ key={commit.hash}
276
645
  type="button"
277
- className={`rvs-commit-item${isOpen ? ' selected' : ''}`}
646
+ data-hash={commit.hash}
647
+ className={`rvs-commit-item${isSelected ? ' selected' : ''}`}
278
648
  title={`${commit.hash}\n${commit.author} <${commit.email}>\n${commit.subject}`}
279
- onClick={() => { void toggleCommit(commit.hash) }}
649
+ aria-current={isSelected ? 'true' : undefined}
650
+ onClick={() => selectCommit(commit.hash)}
651
+ onContextMenu={(event) => { openCommitMenu(event, commit) }}
280
652
  >
281
- <span className="rvs-node-icon" style={{ width: 12 }}>
282
- {isOpen ? <IconChevronDown size={11} /> : <IconChevronRight size={11} />}
283
- </span>
284
653
  <svg className="rvs-graph-svg" width={PAD_X * 2 + (graph.laneCount - 1) * LANE_W} height={ROW_H}>
285
654
  {row.before.map((hash, lane) => {
286
655
  const after = row.after[lane] ?? null
@@ -341,63 +710,73 @@ export function HistoryPanel(): ReactNode {
341
710
  <span className="rvs-commit-msg">{commit.subject}</span>
342
711
  <span className="rvs-commit-date">{relTime(commit.date, lang)}</span>
343
712
  </button>
344
- {isOpen && (
345
- <div className="rvs-detail">
346
- <div className="rvs-detail-meta">
347
- {commit.author} &lt;{commit.email}&gt; · {relTime(commit.date, lang)} · {commit.hash.slice(0, 10)}
348
- </div>
349
- {changed === undefined
350
- ? <div className="rvs-empty" style={{ padding: 8 }}><MiniSpinner /> {t('common.loading')}</div>
351
- : changed.length === 0
352
- ? <div className="rvs-empty" style={{ padding: 8 }}>{t('scm.logEmpty')}</div>
353
- : changed.slice(0, 200).map(file => (
354
- <div
355
- key={`${file.status}:${file.path}`}
356
- className="rvs-file-row"
357
- title={file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`}
358
- onClick={() => { store.openCommitFile(commit.hash, file.path) }}
359
- >
360
- <span className="rvs-node-icon"><FileIcon path={file.path} size={13} /></span>
361
- <span className="rvs-file-path">
362
- {file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`}
363
- </span>
364
- <span className={`rvs-file-status rvs-letter-${gitStatusClass(file.status)}`}>{file.status[0]}</span>
365
- </div>
366
- ))}
367
- <div className="rvs-commit-row">
368
- <button
369
- type="button"
370
- className="rvs-chip"
371
- onClick={() => {
372
- void (async () => {
373
- const ok = await confirmDialog({
374
- title: t('scm.checkoutCommitTitle'),
375
- text: t('scm.checkoutCommitText').replace('{hash}', commit.hash.slice(0, 10)),
376
- okText: t('scm.checkout'),
377
- })
378
- if (!ok || repoRoot === undefined) return
379
- try {
380
- await checkoutCommit(alias, repoRoot, commit.hash)
381
- await store.afterWrite()
382
- toast(t('scm.checkedOut'))
383
- } catch (caught) {
384
- toast(caught instanceof Error ? caught.message : String(caught), true)
385
- }
386
- })()
387
- }}
388
- >
389
- {t('scm.checkout')}
390
- </button>
391
- </div>
392
- </div>
393
- )}
713
+ )
714
+ })}
715
+ {visible.length >= GIT_LOG_MAX && (
716
+ <div className="rvs-empty">{t('scm.logCapped').replace('{n}', String(GIT_LOG_MAX))}</div>
717
+ )}
718
+ </div>
719
+ {selectedCommit !== undefined && (
720
+ <>
721
+ {paneExpanded ? null : (
722
+ <div
723
+ className="rvs-vsplit-handle"
724
+ role="separator"
725
+ aria-orientation="horizontal"
726
+ aria-label={t('history.resizeHint')}
727
+ aria-valuenow={paneHeight}
728
+ tabIndex={0}
729
+ data-dragging={dragging ? 'true' : 'false'}
730
+ title={t('history.resizeHint')}
731
+ onPointerDown={onPaneDown}
732
+ onPointerMove={onPaneMove}
733
+ onPointerUp={onPaneEnd}
734
+ onPointerCancel={onPaneEnd}
735
+ onKeyDown={onPaneKeyDown}
736
+ />
737
+ )}
738
+ <div
739
+ className={`rvs-hpane${paneExpanded ? ' expanded' : ''}`}
740
+ style={paneExpanded ? undefined : { height: `${paneHeight}px` }}
741
+ >
742
+ <CommitPane
743
+ commit={selectedCommit}
744
+ tab={tabChoice.id}
745
+ onTab={chooseTab}
746
+ onClose={closePane}
747
+ expanded={paneExpanded}
748
+ onToggleExpand={() => { setPaneExpanded(value => !value) }}
749
+ body={detail.key === detailKey ? detail.body : ''}
750
+ files={detail.key === detailKey ? detail.files : undefined}
751
+ filesError={detail.key === detailKey ? detail.filesError : undefined}
752
+ patch={patchState.key === detailKey ? patchState.text : undefined}
753
+ patchTruncated={patchState.key === detailKey && patchState.truncated === true}
754
+ patchLoading={tabChoice.id === 'diff'
755
+ && (patchState.key !== detailKey || (patchState.text === undefined && patchState.error === undefined))}
756
+ patchError={patchState.key === detailKey ? patchState.error : undefined}
757
+ focusPath={focusPath}
758
+ onFocusPath={focusCommitFile}
759
+ onOpenFile={openCommitFile}
760
+ onRevealFile={revealCommitFile}
761
+ onOpenTerminal={openCommitFileTerminal}
762
+ absPathOf={commitFileAbsPath}
763
+ onCheckout={checkoutSelected}
764
+ onReload={reloadSelected}
765
+ t={t}
766
+ lang={lang}
767
+ />
394
768
  </div>
395
- )
396
- })}
397
- {visible.length >= GIT_LOG_MAX && (
398
- <div className="rvs-empty">{t('scm.logCapped').replace('{n}', String(GIT_LOG_MAX))}</div>
769
+ </>
399
770
  )}
400
771
  </div>
772
+ {commitMenu.menu !== undefined && (
773
+ <ContextMenu
774
+ x={commitMenu.menu.x}
775
+ y={commitMenu.menu.y}
776
+ entries={commitMenu.menu.entries}
777
+ onClose={commitMenu.close}
778
+ />
779
+ )}
401
780
  </div>
402
781
  )
403
782
  }