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.
- package/README.en.md +13 -10
- package/README.md +14 -9
- package/cordis.patch.yml +10 -5
- package/lib/client.js +3319 -1093
- package/lib/index.js +211 -9
- package/lib/types/client/api.d.ts +12 -0
- package/lib/types/client/composer.d.ts +5 -1
- 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/index.d.ts +5 -0
- 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 +37 -37
- package/src/client/SettingsHosts.tsx +2 -1
- package/src/client/api.ts +61 -0
- package/src/client/composer.ts +11 -2
- package/src/client/exec-output.ts +129 -0
- package/src/client/i18n.ts +133 -4
- 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 +13 -6
- 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/index.ts +10 -2
- package/src/pool.ts +84 -4
- package/src/protocol.ts +14 -0
- package/src/routes.ts +135 -1
- 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
|
+
}
|