dsh-remote-vs 0.9.3 → 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 (44) hide show
  1. package/README.en.md +13 -10
  2. package/README.md +14 -9
  3. package/cordis.patch.yml +10 -5
  4. package/lib/client.js +3319 -1093
  5. package/lib/index.js +211 -9
  6. package/lib/types/client/api.d.ts +12 -0
  7. package/lib/types/client/composer.d.ts +5 -1
  8. package/lib/types/client/exec-output.d.ts +58 -0
  9. package/lib/types/client/i18n.d.ts +1 -1
  10. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  11. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  12. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  13. package/lib/types/client/scm/actions.d.ts +50 -3
  14. package/lib/types/client/scm/commit-model.d.ts +119 -0
  15. package/lib/types/client/store.d.ts +9 -3
  16. package/lib/types/client/vsc/kit.d.ts +16 -0
  17. package/lib/types/git-format.d.ts +5 -0
  18. package/lib/types/git-ops.d.ts +57 -2
  19. package/lib/types/index.d.ts +5 -0
  20. package/lib/types/pool.d.ts +9 -0
  21. package/lib/types/protocol.d.ts +23 -0
  22. package/lib/types/timeouts.d.ts +17 -2
  23. package/package.json +37 -37
  24. package/src/client/SettingsHosts.tsx +2 -1
  25. package/src/client/api.ts +61 -0
  26. package/src/client/composer.ts +11 -2
  27. package/src/client/exec-output.ts +129 -0
  28. package/src/client/i18n.ts +133 -4
  29. package/src/client/scm/CommitPane.tsx +726 -0
  30. package/src/client/scm/ExecPanel.tsx +124 -0
  31. package/src/client/scm/HistoryPanel.tsx +483 -104
  32. package/src/client/scm/ScmPanel.tsx +247 -9
  33. package/src/client/scm/actions.ts +93 -10
  34. package/src/client/scm/commit-model.ts +317 -0
  35. package/src/client/store.tsx +13 -6
  36. package/src/client/vsc/kit.tsx +71 -0
  37. package/src/client/vsc/vsc.css +647 -0
  38. package/src/git-format.ts +7 -3
  39. package/src/git-ops.ts +83 -7
  40. package/src/index.ts +10 -2
  41. package/src/pool.ts +84 -4
  42. package/src/protocol.ts +14 -0
  43. package/src/routes.ts +135 -1
  44. package/src/timeouts.ts +18 -2
@@ -0,0 +1,726 @@
1
+ /**
2
+ * The history panel's commit pane: the selected commit in the three views the
3
+ * DSH-better-sidebar changes pane exposes — `提交详情` (the card), `内容` (the
4
+ * files the commit touched) and `差异` (its patch).
5
+ *
6
+ * Deliberately better than the reference pane in the places a narrow sidebar
7
+ * hurts most:
8
+ * - a sticky patch toolbar (previous/next file, current file, wrap toggle,
9
+ * copy) so a long diff never loses the reader's position or the controls;
10
+ * - sticky file headers per block, because "which file am I looking at" was
11
+ * the reference's biggest scroll cost;
12
+ * - `内容` groups files by directory with collapsible folders and a path
13
+ * filter, instead of a flat wall of paths;
14
+ * - right-click opens a real menu on every changed file (open in the editor,
15
+ * show in the diff, copy the path / the file patch) — the editor's own
16
+ * gesture, not a second mental model;
17
+ * - expand/restore the pane to full height, and copy the commit message,
18
+ * without leaving the panel.
19
+ *
20
+ * Frameless by design: the split, the frame and the pane height belong to the
21
+ * history panel, so this fills whatever box it is handed and scrolls itself.
22
+ * Read-only: a commit's patch is history, so the only mutations are "check this
23
+ * commit out" (confirmed) and copying — staging/reverting lives on worktree
24
+ * diffs.
25
+ *
26
+ * The patch is loaded lazily: a reader of 提交详情 must not pay a `git show
27
+ * --patch` round trip per clicked commit, and the pane asks for it only while
28
+ * the 差异 tab is on screen.
29
+ */
30
+ import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
31
+ import type { GitCommit, GitCommitFile } from '../../protocol.ts'
32
+ import type { Lang } from '../i18n.ts'
33
+ import {
34
+ ContextMenu,
35
+ FileIcon,
36
+ FolderIcon,
37
+ IconButton,
38
+ IconChevronDown,
39
+ IconChevronLeft,
40
+ IconChevronRight,
41
+ IconClock,
42
+ IconClose,
43
+ IconCollapse,
44
+ IconCopy,
45
+ IconDiff,
46
+ IconExpand,
47
+ IconUser,
48
+ IconWrap,
49
+ MiniSpinner,
50
+ copyText,
51
+ fullTime,
52
+ gitStatusClass,
53
+ relTime,
54
+ toast,
55
+ useContextMenu,
56
+ } from '../vsc/kit.tsx'
57
+ import {
58
+ HISTORY_TABS,
59
+ autoFoldCommitPatch,
60
+ blockForPath,
61
+ buildCommitPatch,
62
+ groupCommitFiles,
63
+ refBadgeClass,
64
+ refBadgeLabel,
65
+ splitCommitPatch,
66
+ stepHistoryTab,
67
+ stepPath,
68
+ type CommitPatchFile,
69
+ type HistoryTab,
70
+ type PatchRow,
71
+ } from './commit-model.ts'
72
+
73
+ /** Translator shape shared by every view (`makeT` returns the same). */
74
+ type Translate = (key: string) => string
75
+
76
+ /** Where the reader's wrap preference lives (one profile-wide UI choice). */
77
+ const WRAP_KEY = 'dsh-remote-vs.historyWrap'
78
+
79
+ function readWrap(): boolean {
80
+ try {
81
+ // Wrap is ON by default: in a ~400px column the two 42px line-number
82
+ // gutters leave ~250px of text, and horizontal scrolling to read any
83
+ // ordinary line is the first thing that reads as "broken". Only an explicit
84
+ // user choice to turn it off is persisted.
85
+ return localStorage.getItem(WRAP_KEY) !== '0'
86
+ } catch {
87
+ return true
88
+ }
89
+ }
90
+
91
+ function writeWrap(value: boolean): void {
92
+ try {
93
+ localStorage.setItem(WRAP_KEY, value ? '1' : '0')
94
+ } catch {
95
+ // Ignore: a blocked localStorage must not break the pane.
96
+ }
97
+ }
98
+
99
+ export interface CommitPaneProps {
100
+ commit: GitCommit
101
+ tab: HistoryTab
102
+ onTab: (next: HistoryTab) => void
103
+ onClose: () => void
104
+ /** True while the pane covers the whole panel instead of its usual split. */
105
+ expanded: boolean
106
+ onToggleExpand: () => void
107
+ /** Message text below the subject line ('' = none, or the read failed). */
108
+ body: string
109
+ /** `git show --name-status` rows; undefined while the read is in flight. */
110
+ files: GitCommitFile[] | undefined
111
+ /** Set when the file-list read failed (the 内容 tab shows it). */
112
+ filesError: string | undefined
113
+ /** Raw `git show --patch` text; undefined until the 差异 tab asks for it. */
114
+ patch: string | undefined
115
+ /** The remote read hit its byte cap: the parsed prefix is NOT the whole diff. */
116
+ patchTruncated: boolean
117
+ patchLoading: boolean
118
+ patchError: string | undefined
119
+ /** A file selected in 内容 (or a patch header), highlighted in 差异. */
120
+ focusPath: string | undefined
121
+ onFocusPath: (path: string) => void
122
+ /** `origPath` is a rename source: the editor drill-down must diff BOTH paths. */
123
+ onOpenFile: (path: string, origPath?: string) => void
124
+ /** Reveal a changed file's containing directory in the explorer. */
125
+ onRevealFile: (path: string) => void
126
+ /** Open a terminal tab already `cd`d into the file's directory. */
127
+ onOpenTerminal: (path: string) => void
128
+ /** Remote ABSOLUTE path of a repo-relative commit path (copy-absolute). */
129
+ absPathOf: (path: string) => string
130
+ onCheckout: () => void
131
+ onReload: () => void
132
+ t: Translate
133
+ lang: Lang
134
+ }
135
+
136
+ const TAB_LABELS: Record<HistoryTab, string> = {
137
+ details: 'history.details',
138
+ content: 'history.content',
139
+ diff: 'history.diff',
140
+ }
141
+
142
+ /** One `内容` row's path spelling: renames keep both sides visible. */
143
+ function fileLabel(file: GitCommitFile): string {
144
+ if (file.origPath === undefined) return file.path
145
+ return `${file.origPath} → ${file.path}`
146
+ }
147
+
148
+ /** Rename/copy blocks show both sides in the patch header too. */
149
+ function blockLabel(file: CommitPatchFile): string {
150
+ if (file.oldPath === file.path) return file.path
151
+ return `${file.oldPath} → ${file.path}`
152
+ }
153
+
154
+ export function CommitPane(props: CommitPaneProps): ReactNode {
155
+ const { commit, tab, t } = props
156
+ const [wrap, setWrap] = useState<boolean>(readWrap)
157
+ const toggleWrap = (): void => {
158
+ const next = !wrap
159
+ setWrap(next)
160
+ writeWrap(next)
161
+ }
162
+ const onStripKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
163
+ const next = stepHistoryTab(tab, event.key)
164
+ if (next === undefined) return
165
+ event.preventDefault()
166
+ props.onTab(next)
167
+ event.currentTarget.querySelector<HTMLButtonElement>(`[data-tab="${next}"]`)?.focus()
168
+ }
169
+ /**
170
+ * Escape closes the pane from anywhere inside it — except from the filter
171
+ * input (where Escape means "leave my typing alone") and while a context
172
+ * menu is open (the menu owns that Escape; closing both layers at once is a
173
+ * surprise nobody asked for).
174
+ */
175
+ const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
176
+ if (event.key !== 'Escape') return
177
+ if ((event.target as HTMLElement).tagName === 'INPUT') return
178
+ if (typeof document !== 'undefined' && document.querySelector('.rvs-menu') !== null) return
179
+ event.stopPropagation()
180
+ props.onClose()
181
+ }
182
+ return (
183
+ <div className="rvs-hpane-body" onKeyDown={onRootKeyDown}>
184
+ <div className="rvs-htabs">
185
+ <div className="rvs-hpane-tablist" role="tablist" aria-label={t('history.paneLabel')} onKeyDown={onStripKeyDown}>
186
+ {HISTORY_TABS.map(id => (
187
+ <button
188
+ key={id}
189
+ type="button"
190
+ role="tab"
191
+ data-tab={id}
192
+ id={`rvs-htab-${id}`}
193
+ aria-selected={tab === id}
194
+ aria-controls={`rvs-htab-${id}-panel`}
195
+ tabIndex={tab === id ? 0 : -1}
196
+ className={`rvs-htab${tab === id ? ' active' : ''}`}
197
+ onClick={() => { props.onTab(id) }}
198
+ >
199
+ {t(TAB_LABELS[id])}
200
+ </button>
201
+ ))}
202
+ </div>
203
+ <span className="rvs-htabs-spacer" />
204
+ <code className="rvs-hhash-mini" title={commit.hash}>{commit.hash.slice(0, 7)}</code>
205
+ <IconButton
206
+ title={props.expanded ? t('history.restore') : t('history.expand')}
207
+ onClick={props.onToggleExpand}
208
+ >
209
+ {props.expanded ? <IconCollapse size={12} /> : <IconExpand size={12} />}
210
+ </IconButton>
211
+ <IconButton title={t('history.close')} onClick={props.onClose}>
212
+ <IconClose size={11} />
213
+ </IconButton>
214
+ </div>
215
+ <DetailsTab {...props} hidden={tab !== 'details'} />
216
+ <ContentTab {...props} hidden={tab !== 'content'} />
217
+ <DiffTab {...props} hidden={tab !== 'diff'} wrap={wrap} onToggleWrap={toggleWrap} />
218
+ </div>
219
+ )
220
+ }
221
+
222
+ /**
223
+ * All three bodies stay mounted and are hidden with `hidden`, not unmounted:
224
+ * the 差异 tab's scroll position and parsed patch survive a look at 提交详情,
225
+ * and the lazy patch load keeps its state instead of restarting on every tab
226
+ * flip.
227
+ */
228
+ function DetailsTab(props: CommitPaneProps & { hidden: boolean }): ReactNode {
229
+ const { commit, body, files, t, lang } = props
230
+ const copyHash = (): void => {
231
+ void copyText(commit.hash).then(() => toast(t('history.copiedHash')))
232
+ }
233
+ const copyMessage = (): void => {
234
+ const text = body === '' ? commit.subject : `${commit.subject}\n\n${body}`
235
+ void copyText(text).then(() => toast(t('history.copiedMessage')))
236
+ }
237
+ return (
238
+ <div
239
+ className="rvs-hbody rvs-scroll"
240
+ hidden={props.hidden}
241
+ role="tabpanel"
242
+ id="rvs-htab-details-panel"
243
+ aria-labelledby="rvs-htab-details"
244
+ >
245
+ <div className="rvs-hsubject">{commit.subject === '' ? commit.hash.slice(0, 7) : commit.subject}</div>
246
+ <div className="rvs-hmeta">
247
+ <span className="rvs-hmeta-icon"><IconUser size={12} /></span>
248
+ <span className="rvs-hmeta-value">{commit.author}{commit.email === '' ? '' : ` <${commit.email}>`}</span>
249
+ </div>
250
+ <div className="rvs-hmeta">
251
+ <span className="rvs-hmeta-icon"><IconClock size={12} /></span>
252
+ <span className="rvs-hmeta-value">{fullTime(commit.date)} · {relTime(commit.date, lang)}</span>
253
+ </div>
254
+ <div className="rvs-hmeta">
255
+ <code className="rvs-hhash">{commit.hash}</code>
256
+ <IconButton title={t('history.copyHash')} onClick={copyHash}>
257
+ <IconCopy size={12} />
258
+ </IconButton>
259
+ </div>
260
+ {files !== undefined && (
261
+ <div className="rvs-hmeta">
262
+ <span className="rvs-hmeta-icon"><IconDiff size={12} /></span>
263
+ <span className="rvs-hmeta-value">{t('history.filesCount').replace('{n}', String(files.length))}</span>
264
+ </div>
265
+ )}
266
+ <div className="rvs-hmeta">
267
+ <span className="rvs-hmeta-label">{t('history.parents')}</span>
268
+ {parentsOf(commit)}
269
+ </div>
270
+ {commit.refs.length > 0 && (
271
+ <div className="rvs-hrefs">
272
+ {commit.refs.map(ref => (
273
+ <span key={ref} className={`rvs-ref-badge${refBadgeClass(ref)}`}>{refBadgeLabel(ref)}</span>
274
+ ))}
275
+ </div>
276
+ )}
277
+ {body === '' ? null : <div className="rvs-hmsg">{body}</div>}
278
+ <div className="rvs-hfooter">
279
+ <button type="button" className="rvs-chip" onClick={props.onCheckout}>{t('scm.checkout')}</button>
280
+ <button type="button" className="rvs-chip" onClick={copyMessage}>{t('history.copyMessage')}</button>
281
+ </div>
282
+ </div>
283
+ )
284
+ }
285
+
286
+ /** Parent hashes as short chips; a root commit says so with an em dash. */
287
+ function parentsOf(commit: GitCommit): ReactNode {
288
+ if (commit.parents.length === 0) return <span className="rvs-hdash">—</span>
289
+ return commit.parents.map((parent, index) => (
290
+ <code key={`${parent}:${index}`} className="rvs-hparent" title={parent}>{parent.slice(0, 7)}</code>
291
+ ))
292
+ }
293
+
294
+ function ContentTab(props: CommitPaneProps & { hidden: boolean }): ReactNode {
295
+ const { files, filesError, t } = props
296
+ const [filter, setFilter] = useState('')
297
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>())
298
+ const menu = useContextMenu()
299
+ // A filter typed for one commit must not follow the selection to the next:
300
+ // "没有匹配的文件" on a 2-file commit looked like the commit was empty.
301
+ useEffect(() => {
302
+ setFilter('')
303
+ setCollapsed(new Set<string>())
304
+ }, [props.commit.hash])
305
+ const filtered = useMemo(() => {
306
+ if (files === undefined) return undefined
307
+ const needle = filter.trim().toLowerCase()
308
+ if (needle === '') return files
309
+ return files.filter(file =>
310
+ file.path.toLowerCase().includes(needle)
311
+ || (file.origPath ?? '').toLowerCase().includes(needle))
312
+ }, [files, filter])
313
+ const groups = useMemo(() => (filtered === undefined ? [] : groupCommitFiles(filtered)), [filtered])
314
+ const toggleDir = (dir: string): void => {
315
+ setCollapsed(prev => {
316
+ const next = new Set(prev)
317
+ if (next.has(dir)) next.delete(dir)
318
+ else next.add(dir)
319
+ return next
320
+ })
321
+ }
322
+ const openFileMenu = (event: MouseEvent, file: GitCommitFile): void => {
323
+ event.preventDefault()
324
+ event.stopPropagation()
325
+ menu.open(event.clientX, event.clientY, [
326
+ { label: t('history.openInEditor'), onSelect: () => { props.onOpenFile(file.path, file.origPath) } },
327
+ { label: t('history.viewInDiff'), onSelect: () => { props.onFocusPath(file.path) } },
328
+ { label: t('history.revealInExplorer'), onSelect: () => { props.onRevealFile(file.path) } },
329
+ { label: t('history.openInTerminal'), onSelect: () => { props.onOpenTerminal(file.path) } },
330
+ {
331
+ label: t('files.copyAbs'),
332
+ separated: true,
333
+ onSelect: () => { void copyText(props.absPathOf(file.path)).then(() => toast(t('files.copiedClipboard'))) },
334
+ },
335
+ {
336
+ label: t('files.copyRel'),
337
+ onSelect: () => { void copyText(file.path).then(() => toast(t('files.copiedClipboard'))) },
338
+ },
339
+ ])
340
+ }
341
+ return (
342
+ <div
343
+ className="rvs-hbody rvs-scroll"
344
+ hidden={props.hidden}
345
+ role="tabpanel"
346
+ id="rvs-htab-content-panel"
347
+ aria-labelledby="rvs-htab-content"
348
+ >
349
+ {contentBody(props, groups, filtered, filter, setFilter, collapsed, toggleDir, openFileMenu)}
350
+ {menu.menu !== undefined && (
351
+ <ContextMenu x={menu.menu.x} y={menu.menu.y} entries={menu.menu.entries} onClose={menu.close} />
352
+ )}
353
+ </div>
354
+ )
355
+ }
356
+
357
+ function contentBody(
358
+ props: CommitPaneProps,
359
+ groups: ReturnType<typeof groupCommitFiles>,
360
+ filtered: readonly GitCommitFile[] | undefined,
361
+ filter: string,
362
+ setFilter: (value: string) => void,
363
+ collapsed: ReadonlySet<string>,
364
+ toggleDir: (dir: string) => void,
365
+ openFileMenu: (event: MouseEvent, file: GitCommitFile) => void,
366
+ ): ReactNode {
367
+ const { files, filesError, t } = props
368
+ if (filesError !== undefined) {
369
+ return (
370
+ <>
371
+ <div className="rvs-banner error">{filesError}</div>
372
+ <button type="button" className="rvs-chip" onClick={props.onReload}>{t('history.reload')}</button>
373
+ </>
374
+ )
375
+ }
376
+ if (files === undefined) return <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>
377
+ if (files.length === 0) return <div className="rvs-empty">{t('history.noFiles')}</div>
378
+ const filtering = filter.trim() !== ''
379
+ const countLabel = filtering
380
+ ? t('history.filesCountFiltered').replace('{matched}', String(filtered?.length ?? 0)).replace('{total}', String(files.length))
381
+ : t('history.filesCount').replace('{n}', String(files.length))
382
+ return (
383
+ <>
384
+ <div className="rvs-hcontent-head">
385
+ <span className="rvs-hcount">{countLabel}</span>
386
+ <input
387
+ className="rvs-input rvs-hfilter"
388
+ placeholder={t('history.filterFiles')}
389
+ aria-label={t('history.filterFiles')}
390
+ value={filter}
391
+ spellCheck={false}
392
+ onChange={(event) => { setFilter(event.target.value) }}
393
+ />
394
+ </div>
395
+ <div className="rvs-hhint">{t('history.contentHint')}</div>
396
+ {groups.length === 0 ? <div className="rvs-empty">{t('history.noMatchFiles')}</div> : null}
397
+ <div className="rvs-hfiles">
398
+ {groups.map(group => (
399
+ <div key={group.dir === '' ? '\u0000root' : group.dir}>
400
+ {group.dir === '' ? null : (
401
+ <div
402
+ className="rvs-hfolder"
403
+ role="button"
404
+ tabIndex={0}
405
+ title={group.dir}
406
+ onClick={() => { toggleDir(group.dir) }}
407
+ onKeyDown={(event) => {
408
+ if (event.key !== 'Enter' && event.key !== ' ') return
409
+ event.preventDefault()
410
+ toggleDir(group.dir)
411
+ }}
412
+ >
413
+ <span className="rvs-node-icon">
414
+ {collapsed.has(group.dir) ? <IconChevronRight size={11} /> : <IconChevronDown size={11} />}
415
+ </span>
416
+ <span className="rvs-node-icon"><FolderIcon path={group.dir} open={!collapsed.has(group.dir)} size={13} /></span>
417
+ <span className="rvs-hfolder-name">{group.dir}</span>
418
+ <span className="rvs-hfolder-count">{group.files.length}</span>
419
+ </div>
420
+ )}
421
+ {group.dir !== '' && collapsed.has(group.dir) ? null : group.files.map(file => (
422
+ <div
423
+ key={`${file.status}:${file.path}`}
424
+ role="button"
425
+ tabIndex={0}
426
+ className={`rvs-file-row${group.dir === '' ? '' : ' indent'}${props.focusPath === file.path ? ' active' : ''}`}
427
+ title={`${fileLabel(file)} — ${t('history.contentHint')}`}
428
+ onClick={() => { props.onFocusPath(file.path) }}
429
+ onContextMenu={(event) => { openFileMenu(event, file) }}
430
+ onKeyDown={(event) => {
431
+ if (event.key !== 'Enter') return
432
+ props.onFocusPath(file.path)
433
+ }}
434
+ >
435
+ <span className="rvs-node-icon"><FileIcon path={file.path} size={13} /></span>
436
+ <span className="rvs-file-path">{fileLabel(file)}</span>
437
+ <span className={`rvs-file-status rvs-letter-${gitStatusClass(file.status)}`}>{file.status[0]}</span>
438
+ <IconButton
439
+ title={t('history.openInEditor')}
440
+ onClick={() => { props.onOpenFile(file.path, file.origPath) }}
441
+ className="rvs-file-open"
442
+ >
443
+ <IconDiff size={12} />
444
+ </IconButton>
445
+ </div>
446
+ ))}
447
+ </div>
448
+ ))}
449
+ </div>
450
+ </>
451
+ )
452
+ }
453
+
454
+ function DiffTab(props: CommitPaneProps & { hidden: boolean; wrap: boolean; onToggleWrap: () => void }): ReactNode {
455
+ const { t } = props
456
+ const model = useMemo(() => buildCommitPatch(props.patch ?? ''), [props.patch])
457
+ /** Per-file raw patch text, index-aligned with `model.files`. */
458
+ const blocks = useMemo(() => splitCommitPatch(props.patch ?? ''), [props.patch])
459
+ const paths = useMemo(() => model.files.map(file => file.path), [model])
460
+ /** Folded file blocks: a commit touching one huge file should not wall off
461
+ * every other file behind it. Starts from the pure default-budget policy so
462
+ * a 40-file commit opens as a readable outline instead of a 6000-row wall. */
463
+ const [folded, setFolded] = useState<ReadonlySet<string>>(() => new Set<string>(autoFoldCommitPatch(model)))
464
+ useEffect(() => {
465
+ setFolded(new Set<string>(autoFoldCommitPatch(model)))
466
+ }, [model])
467
+ const toggleFold = (path: string): void => {
468
+ setFolded(prev => {
469
+ const next = new Set(prev)
470
+ if (next.has(path)) next.delete(path)
471
+ else next.add(path)
472
+ return next
473
+ })
474
+ }
475
+ const blockRefs = useRef(new Map<string, HTMLElement>())
476
+ const menu = useContextMenu()
477
+ const resolvedFocus = props.focusPath === undefined ? undefined : blockForPath(model, props.focusPath)
478
+ const currentPath = resolvedFocus ?? paths[0]
479
+ const currentIndex = currentPath === undefined ? -1 : paths.indexOf(currentPath)
480
+ // Content-list selection jumps to its block. `model` is a dependency because
481
+ // the blocks do not exist on the first frame after the patch lands.
482
+ useEffect(() => {
483
+ if (resolvedFocus === undefined) return
484
+ blockRefs.current.get(resolvedFocus)?.scrollIntoView({ block: 'start' })
485
+ }, [resolvedFocus, model])
486
+ const register = (path: string) => (element: HTMLElement | null): void => {
487
+ if (element === null) blockRefs.current.delete(path)
488
+ else blockRefs.current.set(path, element)
489
+ }
490
+ const step = (direction: 1 | -1): void => {
491
+ const next = stepPath(paths, currentPath, direction)
492
+ if (next !== undefined) props.onFocusPath(next)
493
+ }
494
+ const copyBlock = (path: string): void => {
495
+ const index = paths.indexOf(path)
496
+ const text = index < 0 ? undefined : blocks[index]
497
+ if (text === undefined) return
498
+ void copyText(text).then(() => toast(t('history.copiedFilePatch')))
499
+ }
500
+ const copyAll = (): void => {
501
+ if (props.patch === undefined || props.patch === '') return
502
+ void copyText(props.patch).then(() => toast(t('history.copiedCommitPatch')))
503
+ }
504
+ const openBlockMenu = (event: MouseEvent, file: CommitPatchFile): void => {
505
+ event.preventDefault()
506
+ event.stopPropagation()
507
+ menu.open(event.clientX, event.clientY, [
508
+ { label: t('history.openInEditor'), onSelect: () => { props.onOpenFile(file.path, file.oldPath === file.path ? undefined : file.oldPath) } },
509
+ { label: t('history.revealInExplorer'), onSelect: () => { props.onRevealFile(file.path) } },
510
+ { label: t('history.openInTerminal'), onSelect: () => { props.onOpenTerminal(file.path) } },
511
+ {
512
+ label: t('files.copyAbs'),
513
+ separated: true,
514
+ onSelect: () => { void copyText(props.absPathOf(file.path)).then(() => toast(t('files.copiedClipboard'))) },
515
+ },
516
+ {
517
+ label: t('files.copyRel'),
518
+ onSelect: () => { void copyText(file.path).then(() => toast(t('files.copiedClipboard'))) },
519
+ },
520
+ {
521
+ label: t('history.copyFilePatch'),
522
+ separated: true,
523
+ onSelect: () => { copyBlock(file.path) },
524
+ },
525
+ ])
526
+ }
527
+ return (
528
+ <div
529
+ className="rvs-hbody rvs-hdiff"
530
+ hidden={props.hidden}
531
+ role="tabpanel"
532
+ id="rvs-htab-diff-panel"
533
+ aria-labelledby="rvs-htab-diff"
534
+ >
535
+ <div className="rvs-ptoolbar" role="toolbar" aria-label={t('history.diffToolbar')}>
536
+ <IconButton title={t('history.prevFile')} onClick={() => { step(-1) }} disabled={paths.length === 0}>
537
+ <IconChevronLeft size={13} />
538
+ </IconButton>
539
+ <IconButton title={t('history.nextFile')} onClick={() => { step(1) }} disabled={paths.length === 0}>
540
+ <IconChevronRight size={13} />
541
+ </IconButton>
542
+ <span className="rvs-pfile-index">{currentIndex < 0 ? '–' : `${currentIndex + 1}/${paths.length}`}</span>
543
+ <span className="rvs-pfile-name" title={currentPath ?? ''}>{currentPath ?? '–'}</span>
544
+ <IconButton
545
+ title={t('history.wrap')}
546
+ className={props.wrap ? 'active' : ''}
547
+ onClick={props.onToggleWrap}
548
+ >
549
+ <IconWrap size={13} />
550
+ </IconButton>
551
+ {(model.truncated || props.patchTruncated) && (
552
+ <span className="rvs-ptruncated" title={t('history.patchTruncated')}>✂</span>
553
+ )}
554
+ <IconButton
555
+ title={t('history.copyCommitPatch')}
556
+ onClick={copyAll}
557
+ disabled={props.patch === undefined || props.patch === ''}
558
+ >
559
+ <IconCopy size={13} />
560
+ </IconButton>
561
+ </div>
562
+ <div className="rvs-pscroll rvs-scroll">
563
+ {diffBody(props, model, blocks, props.wrap, folded, toggleFold, register, openBlockMenu)}
564
+ </div>
565
+ {menu.menu !== undefined && (
566
+ <ContextMenu x={menu.menu.x} y={menu.menu.y} entries={menu.menu.entries} onClose={menu.close} />
567
+ )}
568
+ </div>
569
+ )
570
+ }
571
+
572
+ function diffBody(
573
+ props: CommitPaneProps,
574
+ model: ReturnType<typeof buildCommitPatch>,
575
+ blocks: string[],
576
+ wrap: boolean,
577
+ folded: ReadonlySet<string>,
578
+ toggleFold: (path: string) => void,
579
+ register: (path: string) => (element: HTMLElement | null) => void,
580
+ openBlockMenu: (event: MouseEvent, file: CommitPatchFile) => void,
581
+ ): ReactNode {
582
+ const { t } = props
583
+ if (props.patchError !== undefined) {
584
+ return (
585
+ <>
586
+ <div className="rvs-banner error">{props.patchError}</div>
587
+ <button type="button" className="rvs-chip" onClick={props.onReload}>{t('history.reload')}</button>
588
+ </>
589
+ )
590
+ }
591
+ if (props.patch === undefined) {
592
+ // A read in flight says so; a marker left by a superseded read is NOT a
593
+ // finished patch (the reported empty-diff bug), so it offers a retry
594
+ // instead of the silent blank the old branch rendered.
595
+ if (props.patchLoading) return <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>
596
+ return (
597
+ <>
598
+ <div className="rvs-empty">{t('history.patchNotLoaded')}</div>
599
+ <button type="button" className="rvs-chip" onClick={props.onReload}>{t('history.reload')}</button>
600
+ </>
601
+ )
602
+ }
603
+ if (model.files.length === 0) return <div className="rvs-empty">{t('history.noTextDiff')}</div>
604
+ return (
605
+ <>
606
+ <div className="rvs-patch">
607
+ {model.files.map((file, index) => (
608
+ <PatchBlock
609
+ key={`${file.status}:${file.path}`}
610
+ file={file}
611
+ active={props.focusPath === file.path || props.focusPath === file.oldPath}
612
+ register={register(file.path)}
613
+ wrap={wrap}
614
+ folded={folded.has(file.path)}
615
+ hasPatch={blocks[index] !== undefined}
616
+ onFocus={() => { props.onFocusPath(file.path) }}
617
+ onOpen={() => { props.onOpenFile(file.path, file.oldPath === file.path ? undefined : file.oldPath) }}
618
+ onToggleFold={() => { toggleFold(file.path) }}
619
+ onMenu={(event) => { openBlockMenu(event, file) }}
620
+ t={t}
621
+ />
622
+ ))}
623
+ </div>
624
+ {model.truncated || props.patchTruncated ? <div className="rvs-empty">{t('history.patchTruncated')}</div> : null}
625
+ </>
626
+ )
627
+ }
628
+
629
+ function PatchBlock(props: {
630
+ file: CommitPatchFile
631
+ active: boolean
632
+ register: (element: HTMLElement | null) => void
633
+ wrap: boolean
634
+ folded: boolean
635
+ hasPatch: boolean
636
+ onFocus: () => void
637
+ onOpen: () => void
638
+ onToggleFold: () => void
639
+ onMenu: (event: MouseEvent) => void
640
+ t: Translate
641
+ }): ReactNode {
642
+ const { file, t } = props
643
+ return (
644
+ <section
645
+ ref={props.register}
646
+ className={`rvs-patch-file${props.active ? ' active' : ''}${props.folded ? ' folded' : ''}`}
647
+ data-path={file.path}
648
+ >
649
+ <header className="rvs-patch-head" title={blockLabel(file)} onClick={props.onFocus} onContextMenu={props.onMenu}>
650
+ <span
651
+ className={`rvs-patch-chevron${props.folded ? '' : ' open'}`}
652
+ role="button"
653
+ tabIndex={0}
654
+ aria-expanded={!props.folded}
655
+ aria-label={props.folded ? t('history.unfoldFile') : t('history.foldFile')}
656
+ title={props.folded ? t('history.unfoldFile') : t('history.foldFile')}
657
+ onClick={(event) => {
658
+ event.stopPropagation()
659
+ props.onToggleFold()
660
+ }}
661
+ onKeyDown={(event) => {
662
+ if (event.key !== 'Enter' && event.key !== ' ') return
663
+ event.preventDefault()
664
+ event.stopPropagation()
665
+ props.onToggleFold()
666
+ }}
667
+ >
668
+ <IconChevronRight size={11} />
669
+ </span>
670
+ <FileIcon path={file.path} size={13} />
671
+ <span className="rvs-patch-path">{blockLabel(file)}</span>
672
+ <span className={`rvs-patch-letter rvs-letter-${gitStatusClass(file.status)}`}>{file.status[0]}</span>
673
+ <span className="rvs-patch-counts">
674
+ <span className="rvs-patch-add-count">+{file.added}</span>
675
+ <span className="rvs-patch-del-count">−{file.deleted}</span>
676
+ </span>
677
+ <span onClick={(event) => { event.stopPropagation() }}>
678
+ <IconButton title={t('history.openInEditor')} onClick={props.onOpen}>
679
+ <IconDiff size={13} />
680
+ </IconButton>
681
+ </span>
682
+ </header>
683
+ {props.folded ? null : patchBlockBody(file, props.wrap, props.hasPatch, t)}
684
+ </section>
685
+ )
686
+ }
687
+
688
+ /**
689
+ * One block's body: binary note, a cap-specific note, "no text change", or the
690
+ * hunk rows.
691
+ *
692
+ * The cap case is distinct on purpose: a file whose rows all fell past
693
+ * `PATCH_ROW_CAP` has `rows.length === 0` but a non-zero `+/−` header, and the
694
+ * plain "没有文本改动" copy there was a lie the header itself contradicted.
695
+ */
696
+ function patchBlockBody(file: CommitPatchFile, wrap: boolean, hasPatch: boolean, t: Translate): ReactNode {
697
+ if (file.binary) return <div className="rvs-patch-note">{t('history.binaryFile')}</div>
698
+ if (file.rows.length === 0) {
699
+ return <div className="rvs-patch-note">{file.truncated ? t('history.fileNotRendered') : t('history.noTextDiff')}</div>
700
+ }
701
+ return (
702
+ <div className={`rvs-patch-rows${wrap ? ' wrap' : ''}${hasPatch ? '' : ' partial'}`}>
703
+ {file.rows.map((row, index) => <PatchLine key={index} row={row} />)}
704
+ {file.truncated && <div className="rvs-patch-note">{t('history.patchTruncated')}</div>}
705
+ </div>
706
+ )
707
+ }
708
+
709
+ function PatchLine(props: { row: PatchRow }): ReactNode {
710
+ const { row } = props
711
+ if (row.kind === 'skip') return <div className="rvs-patch-skip">⋯</div>
712
+ return (
713
+ <div className={`rvs-patch-row ${row.kind}`}>
714
+ <span className="rvs-patch-num">{row.oldLine ?? ''}</span>
715
+ <span className="rvs-patch-num">{row.newLine ?? ''}</span>
716
+ <span className="rvs-patch-sign">{signOf(row)}</span>
717
+ <span className="rvs-patch-text">{row.text}</span>
718
+ </div>
719
+ )
720
+ }
721
+
722
+ function signOf(row: PatchRow): string {
723
+ if (row.kind === 'add') return '+'
724
+ if (row.kind === 'del') return '-'
725
+ return ' '
726
+ }