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.
- package/README.en.md +8 -6
- package/README.md +8 -6
- package/lib/client.js +3313 -1089
- package/lib/index.js +209 -7
- package/lib/types/client/api.d.ts +12 -0
- package/lib/types/client/exec-output.d.ts +58 -0
- package/lib/types/client/i18n.d.ts +1 -1
- package/lib/types/client/scm/CommitPane.d.ts +73 -0
- package/lib/types/client/scm/ExecPanel.d.ts +34 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
- package/lib/types/client/scm/actions.d.ts +50 -3
- package/lib/types/client/scm/commit-model.d.ts +119 -0
- package/lib/types/client/store.d.ts +9 -3
- package/lib/types/client/vsc/kit.d.ts +16 -0
- package/lib/types/git-format.d.ts +5 -0
- package/lib/types/git-ops.d.ts +57 -2
- package/lib/types/pool.d.ts +9 -0
- package/lib/types/protocol.d.ts +23 -0
- package/lib/types/timeouts.d.ts +17 -2
- package/package.json +1 -1
- package/src/client/api.ts +61 -0
- package/src/client/exec-output.ts +129 -0
- package/src/client/i18n.ts +131 -2
- package/src/client/scm/CommitPane.tsx +726 -0
- package/src/client/scm/ExecPanel.tsx +124 -0
- package/src/client/scm/HistoryPanel.tsx +483 -104
- package/src/client/scm/ScmPanel.tsx +247 -9
- package/src/client/scm/actions.ts +93 -10
- package/src/client/scm/commit-model.ts +317 -0
- package/src/client/store.tsx +11 -5
- package/src/client/vsc/kit.tsx +71 -0
- package/src/client/vsc/vsc.css +647 -0
- package/src/git-format.ts +7 -3
- package/src/git-ops.ts +83 -7
- package/src/pool.ts +84 -4
- package/src/protocol.ts +14 -0
- package/src/routes.ts +135 -1
- 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
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
234
|
-
if (
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
}
|