dsh-remote-vs 0.0.0-stage → 0.8.3
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/LICENSE +21 -0
- package/README.en.md +385 -0
- package/README.md +347 -2
- package/cordis.patch.yml +259 -0
- package/icon.svg +7 -0
- package/lib/client.js +62602 -0
- package/lib/index.js +4287 -0
- package/lib/p2-bridge-refs.js +10 -0
- package/lib/p2-bridge.js +16 -0
- package/lib/p2-router-fs.js +252 -0
- package/lib/p2-router-refs.js +466 -0
- package/lib/p2-router-spill.js +62 -0
- package/lib/p2-router-subprocess.js +64 -0
- package/lib/p2-shell-bash.js +24 -0
- package/lib/ssh-proc-arI5lm4W.js +632 -0
- package/lib/types/changes/recorder.d.ts +240 -0
- package/lib/types/changes/status.d.ts +201 -0
- package/lib/types/changes/text-diff.d.ts +65 -0
- package/lib/types/client/CodeEditor.d.ts +125 -0
- package/lib/types/client/Dialog.d.ts +14 -0
- package/lib/types/client/DirectoryFlow.d.ts +30 -0
- package/lib/types/client/EditorPane.d.ts +30 -0
- package/lib/types/client/HostsView.d.ts +10 -0
- package/lib/types/client/NativeExplorer.d.ts +39 -0
- package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
- package/lib/types/client/RemoteEditorView.d.ts +20 -0
- package/lib/types/client/RemoteExplorer.d.ts +18 -0
- package/lib/types/client/RemoteRoot.d.ts +16 -0
- package/lib/types/client/SearchView.d.ts +14 -0
- package/lib/types/client/SettingsHosts.d.ts +15 -0
- package/lib/types/client/TerminalView.d.ts +16 -0
- package/lib/types/client/WorkspaceWizard.d.ts +16 -0
- package/lib/types/client/WorkspacesView.d.ts +14 -0
- package/lib/types/client/XTerm.d.ts +15 -0
- package/lib/types/client/api.d.ts +162 -0
- package/lib/types/client/changes/ChangesDock.d.ts +35 -0
- package/lib/types/client/changes/model.d.ts +36 -0
- package/lib/types/client/changes/undo-affordance.d.ts +48 -0
- package/lib/types/client/changes/undo-context.d.ts +25 -0
- package/lib/types/client/composer.d.ts +165 -0
- package/lib/types/client/i18n.d.ts +17 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/remote-file-open-target.d.ts +247 -0
- package/lib/types/client/remote-file-open.d.ts +35 -0
- package/lib/types/client/reveal.d.ts +15 -0
- package/lib/types/client/scm/DiffView.d.ts +24 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
- package/lib/types/client/scm/RepoPicker.d.ts +24 -0
- package/lib/types/client/scm/ScmPanel.d.ts +15 -0
- package/lib/types/client/scm/actions.d.ts +170 -0
- package/lib/types/client/scm/diff-model.d.ts +29 -0
- package/lib/types/client/scm/stage-queue.d.ts +99 -0
- package/lib/types/client/scm/status-map.d.ts +157 -0
- package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
- package/lib/types/client/selection-popup.d.ts +61 -0
- package/lib/types/client/store.d.ts +437 -0
- package/lib/types/client/theme.d.ts +15 -0
- package/lib/types/client/ui.d.ts +46 -0
- package/lib/types/client/upload-plan.d.ts +78 -0
- package/lib/types/client/upload-source.d.ts +46 -0
- package/lib/types/client/use-remote-chat-target.d.ts +17 -0
- package/lib/types/client/vsc/FindWidget.d.ts +81 -0
- package/lib/types/client/vsc/change-marks.d.ts +150 -0
- package/lib/types/client/vsc/editor-langs.d.ts +5 -0
- package/lib/types/client/vsc/editor-palette.d.ts +37 -0
- package/lib/types/client/vsc/editor-theme.d.ts +32 -0
- package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
- package/lib/types/client/vsc/kit.d.ts +195 -0
- package/lib/types/client/vsc/markdown.d.ts +11 -0
- package/lib/types/client/vsc/reveal-line.d.ts +38 -0
- package/lib/types/client/vsc/tokenize.d.ts +104 -0
- package/lib/types/deploy.d.ts +94 -0
- package/lib/types/fs-ops.d.ts +8 -0
- package/lib/types/git-format.d.ts +34 -0
- package/lib/types/git-ops.d.ts +183 -0
- package/lib/types/http.d.ts +14 -0
- package/lib/types/importers.d.ts +20 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/keygen.d.ts +19 -0
- package/lib/types/loopback.d.ts +7 -0
- package/lib/types/p2/bridge-refs.d.ts +21 -0
- package/lib/types/p2/bridge.d.ts +24 -0
- package/lib/types/p2/file-refs.d.ts +73 -0
- package/lib/types/p2/helper-rpc.d.ts +63 -0
- package/lib/types/p2/in-flight-gate.d.ts +25 -0
- package/lib/types/p2/router-fs.d.ts +92 -0
- package/lib/types/p2/router-refs.d.ts +45 -0
- package/lib/types/p2/router-spill.d.ts +11 -0
- package/lib/types/p2/router-subprocess.d.ts +23 -0
- package/lib/types/p2/shell-bash.d.ts +16 -0
- package/lib/types/p2/ssh-proc.d.ts +22 -0
- package/lib/types/p2/worlds.d.ts +179 -0
- package/lib/types/paths.d.ts +12 -0
- package/lib/types/pool.d.ts +105 -0
- package/lib/types/protocol.d.ts +452 -0
- package/lib/types/routes.d.ts +27 -0
- package/lib/types/search-ops.d.ts +68 -0
- package/lib/types/sftp.d.ts +90 -0
- package/lib/types/store.d.ts +66 -0
- package/lib/types/terminal.d.ts +51 -0
- package/lib/types/timeouts.d.ts +83 -0
- package/lib/types/tools.d.ts +41 -0
- package/lib/worlds-PUumRIh7.js +18765 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +189 -4
- package/src/changes/recorder.ts +902 -0
- package/src/changes/status.ts +448 -0
- package/src/changes/text-diff.ts +377 -0
- package/src/client/CodeEditor.tsx +923 -0
- package/src/client/Dialog.tsx +76 -0
- package/src/client/DirectoryFlow.tsx +431 -0
- package/src/client/EditorPane.tsx +512 -0
- package/src/client/HostsView.tsx +312 -0
- package/src/client/NativeExplorer.tsx +121 -0
- package/src/client/RemoteDirectoryPicker.tsx +157 -0
- package/src/client/RemoteEditorView.tsx +54 -0
- package/src/client/RemoteExplorer.tsx +1398 -0
- package/src/client/RemoteRoot.tsx +389 -0
- package/src/client/SearchView.tsx +407 -0
- package/src/client/SettingsHosts.tsx +136 -0
- package/src/client/TerminalView.tsx +308 -0
- package/src/client/WorkspaceWizard.tsx +199 -0
- package/src/client/WorkspacesView.tsx +93 -0
- package/src/client/XTerm.tsx +308 -0
- package/src/client/api.ts +331 -0
- package/src/client/changes/ChangesDock.tsx +348 -0
- package/src/client/changes/changes.css +230 -0
- package/src/client/changes/model.ts +347 -0
- package/src/client/changes/undo-affordance.ts +389 -0
- package/src/client/changes/undo-context.ts +34 -0
- package/src/client/composer.ts +436 -0
- package/src/client/css.d.ts +7 -0
- package/src/client/i18n.ts +702 -0
- package/src/client/index.tsx +286 -0
- package/src/client/remote-file-open-target.ts +553 -0
- package/src/client/remote-file-open.ts +261 -0
- package/src/client/reveal.ts +33 -0
- package/src/client/scm/DiffView.tsx +570 -0
- package/src/client/scm/HistoryPanel.tsx +353 -0
- package/src/client/scm/RepoPicker.tsx +238 -0
- package/src/client/scm/ScmPanel.tsx +857 -0
- package/src/client/scm/actions.ts +460 -0
- package/src/client/scm/diff-model.ts +206 -0
- package/src/client/scm/stage-queue.ts +264 -0
- package/src/client/scm/status-map.ts +381 -0
- package/src/client/scm/untracked-dirs.ts +139 -0
- package/src/client/selection-popup.ts +157 -0
- package/src/client/store.tsx +1670 -0
- package/src/client/theme.ts +54 -0
- package/src/client/ui.tsx +136 -0
- package/src/client/upload-plan.ts +153 -0
- package/src/client/upload-source.ts +145 -0
- package/src/client/use-remote-chat-target.ts +153 -0
- package/src/client/vsc/FindWidget.tsx +446 -0
- package/src/client/vsc/change-marks.ts +586 -0
- package/src/client/vsc/editor-langs.ts +137 -0
- package/src/client/vsc/editor-palette.ts +107 -0
- package/src/client/vsc/editor-theme.ts +93 -0
- package/src/client/vsc/fallback-highlight.ts +145 -0
- package/src/client/vsc/kit.tsx +777 -0
- package/src/client/vsc/markdown.tsx +172 -0
- package/src/client/vsc/reveal-line.ts +142 -0
- package/src/client/vsc/tokenize.ts +569 -0
- package/src/client/vsc/vsc.css +2669 -0
- package/src/deploy.ts +481 -0
- package/src/fs-ops.ts +57 -0
- package/src/git-format.ts +205 -0
- package/src/git-ops.ts +693 -0
- package/src/http.ts +55 -0
- package/src/importers.ts +141 -0
- package/src/index.ts +442 -0
- package/src/keygen.ts +77 -0
- package/src/loopback.ts +47 -0
- package/src/p2/bridge-refs.ts +27 -0
- package/src/p2/bridge.ts +32 -0
- package/src/p2/file-refs.ts +392 -0
- package/src/p2/helper-rpc.ts +394 -0
- package/src/p2/in-flight-gate.ts +63 -0
- package/src/p2/router-fs.ts +327 -0
- package/src/p2/router-refs.ts +166 -0
- package/src/p2/router-spill.ts +75 -0
- package/src/p2/router-subprocess.ts +92 -0
- package/src/p2/shell-bash.ts +30 -0
- package/src/p2/ssh-proc.ts +802 -0
- package/src/p2/worlds.ts +735 -0
- package/src/paths.ts +50 -0
- package/src/pool.ts +811 -0
- package/src/protocol.ts +460 -0
- package/src/routes.ts +802 -0
- package/src/search-ops.ts +220 -0
- package/src/sftp.ts +677 -0
- package/src/store.ts +364 -0
- package/src/terminal.ts +291 -0
- package/src/timeouts.ts +122 -0
- package/src/tools.ts +320 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
- package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History view: the commit graph with branch lanes and merge curves (lane
|
|
3
|
+
* layout ported from the previous Git view), plus click-to-expand changed
|
|
4
|
+
* files and a per-file diff of that commit.
|
|
5
|
+
*
|
|
6
|
+
* Loading is a SINGLE capped call (`api.git.log(..., GIT_LOG_MAX)`); a deep
|
|
7
|
+
* 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
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
12
|
+
import type { GitCommit, GitCommitFile } from '../../protocol.ts'
|
|
13
|
+
import { GIT_LOG_MAX } from '../../git-format.ts'
|
|
14
|
+
import { api } from '../api.ts'
|
|
15
|
+
import { useExplorer } from '../store.tsx'
|
|
16
|
+
import { checkoutCommit, loadCommitFiles } from './actions.ts'
|
|
17
|
+
import {
|
|
18
|
+
IconButton,
|
|
19
|
+
IconChevronDown,
|
|
20
|
+
IconChevronRight,
|
|
21
|
+
IconRefresh,
|
|
22
|
+
MiniSpinner,
|
|
23
|
+
confirmDialog,
|
|
24
|
+
FileIcon,
|
|
25
|
+
gitStatusClass,
|
|
26
|
+
relTime,
|
|
27
|
+
toast,
|
|
28
|
+
} from '../vsc/kit.tsx'
|
|
29
|
+
|
|
30
|
+
const ROW_H = 22
|
|
31
|
+
const LANE_W = 13
|
|
32
|
+
const PAD_X = 9
|
|
33
|
+
const LANE_COLORS = ['#e2b714', '#4ec9b0', '#58a6ff', '#d2a8ff', '#ff7b72', '#79c0ff', '#7ee787', '#ffa657']
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Ref badge decoration for one `%D` entry. Extracted from a pair of nested
|
|
37
|
+
* ternaries (forbidden by this project's review checklist) that were doing two
|
|
38
|
+
* different jobs — CSS modifier and display text — inside one JSX expression.
|
|
39
|
+
*/
|
|
40
|
+
function refBadgeClass(ref: string): string {
|
|
41
|
+
if (ref.startsWith('HEAD ->')) return ' head'
|
|
42
|
+
if (ref.startsWith('tag:')) return ' tag'
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function refBadgeLabel(ref: string): string {
|
|
47
|
+
if (ref.startsWith('HEAD -> ')) return ref.slice('HEAD -> '.length)
|
|
48
|
+
return ref.replace(/^tag: /, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface GraphRow {
|
|
52
|
+
commit: GitCommit
|
|
53
|
+
lane: number
|
|
54
|
+
before: (string | null)[]
|
|
55
|
+
after: (string | null)[]
|
|
56
|
+
merges: { lane: number; fresh: boolean }[]
|
|
57
|
+
cleared: number[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Assign one lane per commit line; extra parents open or ride lanes. */
|
|
61
|
+
function layoutLanes(commits: GitCommit[]): { rows: GraphRow[]; laneCount: number } {
|
|
62
|
+
const lanes: (string | null)[] = []
|
|
63
|
+
const rows: GraphRow[] = []
|
|
64
|
+
for (const commit of commits) {
|
|
65
|
+
let i = lanes.indexOf(commit.hash)
|
|
66
|
+
if (i === -1) {
|
|
67
|
+
const free = lanes.indexOf(null)
|
|
68
|
+
if (free === -1) {
|
|
69
|
+
lanes.push(commit.hash)
|
|
70
|
+
i = lanes.length - 1
|
|
71
|
+
} else {
|
|
72
|
+
lanes[free] = commit.hash
|
|
73
|
+
i = free
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const before = [...lanes]
|
|
77
|
+
const merges: GraphRow['merges'] = []
|
|
78
|
+
const cleared: number[] = []
|
|
79
|
+
const parents = commit.parents
|
|
80
|
+
if (parents.length === 0) {
|
|
81
|
+
lanes[i] = null
|
|
82
|
+
} else {
|
|
83
|
+
lanes[i] = parents[0] as string
|
|
84
|
+
for (let k = 0; k < lanes.length; k++) {
|
|
85
|
+
if (k !== i && lanes[k] === parents[0]) {
|
|
86
|
+
lanes[k] = null
|
|
87
|
+
cleared.push(k)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (let p = 1; p < parents.length; p++) {
|
|
91
|
+
const parentHash = parents[p] as string
|
|
92
|
+
const j = lanes.indexOf(parentHash)
|
|
93
|
+
if (j === -1) {
|
|
94
|
+
const free = lanes.indexOf(null)
|
|
95
|
+
if (free === -1) {
|
|
96
|
+
lanes.push(parentHash)
|
|
97
|
+
merges.push({ lane: lanes.length - 1, fresh: true })
|
|
98
|
+
} else {
|
|
99
|
+
lanes[free] = parentHash
|
|
100
|
+
merges.push({ lane: free, fresh: true })
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
merges.push({ lane: j, fresh: false })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
rows.push({ commit, lane: i, before, after: [...lanes], merges, cleared })
|
|
108
|
+
}
|
|
109
|
+
let used = 1
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
used = Math.max(used, row.lane + 1)
|
|
112
|
+
row.before.forEach((hash, j) => { if (hash !== null) used = Math.max(used, j + 1) })
|
|
113
|
+
row.after.forEach((hash, j) => { if (hash !== null) used = Math.max(used, j + 1) })
|
|
114
|
+
for (const merge of row.merges) used = Math.max(used, merge.lane + 1)
|
|
115
|
+
}
|
|
116
|
+
return { rows, laneCount: used }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function laneColor(lane: number): string {
|
|
120
|
+
return LANE_COLORS[lane % LANE_COLORS.length] as string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function HistoryPanel(): ReactNode {
|
|
124
|
+
const store = useExplorer()
|
|
125
|
+
const { t, lang, alias, repoRoot } = store
|
|
126
|
+
const [commits, setCommits] = useState<GitCommit[]>([])
|
|
127
|
+
const [loading, setLoading] = useState(false)
|
|
128
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
129
|
+
const [expanded, setExpanded] = useState<string | undefined>(undefined)
|
|
130
|
+
const [files, setFiles] = useState<Record<string, GitCommitFile[]>>({})
|
|
131
|
+
const [filter, setFilter] = useState('')
|
|
132
|
+
const head = store.head?.branch ?? ''
|
|
133
|
+
/**
|
|
134
|
+
* Ticket for the log request. The panel stays mounted across binding
|
|
135
|
+
* switches, so two `git log` calls can overlap; without this the slower one
|
|
136
|
+
* lands last and the tab lists the OTHER workspace's commits, with checkout
|
|
137
|
+
* buttons that act on the currently bound repository.
|
|
138
|
+
*/
|
|
139
|
+
const loadSeq = useRef(0)
|
|
140
|
+
|
|
141
|
+
const load = useCallback(async (): Promise<void> => {
|
|
142
|
+
if (alias === '' || repoRoot === undefined) {
|
|
143
|
+
loadSeq.current += 1
|
|
144
|
+
setCommits([])
|
|
145
|
+
setLoading(false)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const ticket = ++loadSeq.current
|
|
149
|
+
setLoading(true)
|
|
150
|
+
setError(undefined)
|
|
151
|
+
try {
|
|
152
|
+
const list = await api.git.log(alias, repoRoot, GIT_LOG_MAX)
|
|
153
|
+
if (loadSeq.current !== ticket) return
|
|
154
|
+
setCommits(list)
|
|
155
|
+
} catch (caught) {
|
|
156
|
+
if (loadSeq.current !== ticket) return
|
|
157
|
+
setCommits([])
|
|
158
|
+
setError(caught instanceof Error ? caught.message : String(caught))
|
|
159
|
+
} finally {
|
|
160
|
+
if (loadSeq.current === ticket) setLoading(false)
|
|
161
|
+
}
|
|
162
|
+
}, [alias, repoRoot])
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
void load()
|
|
166
|
+
}, [load])
|
|
167
|
+
|
|
168
|
+
const visible = useMemo(() => {
|
|
169
|
+
if (filter.trim() === '') return commits
|
|
170
|
+
const needle = filter.trim().toLowerCase()
|
|
171
|
+
return commits.filter(commit =>
|
|
172
|
+
commit.subject.toLowerCase().includes(needle)
|
|
173
|
+
|| commit.author.toLowerCase().includes(needle)
|
|
174
|
+
|| commit.hash.startsWith(needle))
|
|
175
|
+
}, [commits, filter])
|
|
176
|
+
|
|
177
|
+
const graph = useMemo(() => layoutLanes(visible), [visible])
|
|
178
|
+
|
|
179
|
+
const toggleCommit = async (hash: string): Promise<void> => {
|
|
180
|
+
if (expanded === hash) {
|
|
181
|
+
setExpanded(undefined)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
setExpanded(hash)
|
|
185
|
+
if (files[hash] !== undefined || repoRoot === undefined) return
|
|
186
|
+
try {
|
|
187
|
+
const rows = await loadCommitFiles(alias, repoRoot, hash)
|
|
188
|
+
setFiles(prev => ({ ...prev, [hash]: rows }))
|
|
189
|
+
} catch (error) {
|
|
190
|
+
toast(error instanceof Error ? error.message : String(error), true)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const x = (lane: number): number => PAD_X + lane * LANE_W
|
|
195
|
+
const y = (row: number): number => row * ROW_H + ROW_H / 2
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<div className="rvs-pane grow">
|
|
199
|
+
<div className="rvs-header">
|
|
200
|
+
<span className="rvs-title">{t('tab.history')}</span>
|
|
201
|
+
{head !== '' && <span className="rvs-pill">⑂ {store.head?.detached === true ? t('scm.detached') : head}</span>}
|
|
202
|
+
<IconButton title={t('scm.refresh')} onClick={() => { void load() }} disabled={loading}>
|
|
203
|
+
<IconRefresh size={14} />
|
|
204
|
+
</IconButton>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="rvs-search-row">
|
|
207
|
+
<input
|
|
208
|
+
className="rvs-input"
|
|
209
|
+
placeholder={t('history.filter')}
|
|
210
|
+
value={filter}
|
|
211
|
+
spellCheck={false}
|
|
212
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
{error !== undefined && <div className="rvs-banner error">{error}</div>}
|
|
216
|
+
<div className="rvs-scroll">
|
|
217
|
+
{loading && commits.length === 0 && <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>}
|
|
218
|
+
{!loading && commits.length === 0 && error === undefined && <div className="rvs-empty">{t('scm.logEmpty')}</div>}
|
|
219
|
+
{graph.rows.map((row, index) => {
|
|
220
|
+
const commit = row.commit
|
|
221
|
+
const isOpen = expanded === commit.hash
|
|
222
|
+
const changed = files[commit.hash]
|
|
223
|
+
return (
|
|
224
|
+
<div key={commit.hash}>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className={`rvs-commit-item${isOpen ? ' selected' : ''}`}
|
|
228
|
+
title={`${commit.hash}\n${commit.author} <${commit.email}>\n${commit.subject}`}
|
|
229
|
+
onClick={() => { void toggleCommit(commit.hash) }}
|
|
230
|
+
>
|
|
231
|
+
<span className="rvs-node-icon" style={{ width: 12 }}>
|
|
232
|
+
{isOpen ? <IconChevronDown size={11} /> : <IconChevronRight size={11} />}
|
|
233
|
+
</span>
|
|
234
|
+
<svg className="rvs-graph-svg" width={PAD_X * 2 + (graph.laneCount - 1) * LANE_W} height={ROW_H}>
|
|
235
|
+
{row.before.map((hash, lane) => {
|
|
236
|
+
const after = row.after[lane] ?? null
|
|
237
|
+
if (hash === null || after === null || hash !== after) return null
|
|
238
|
+
return (
|
|
239
|
+
<line
|
|
240
|
+
key={`v${lane}`}
|
|
241
|
+
x1={x(lane)}
|
|
242
|
+
y1={y(index) - ROW_H / 2}
|
|
243
|
+
x2={x(lane)}
|
|
244
|
+
y2={y(index) + ROW_H / 2}
|
|
245
|
+
stroke={laneColor(lane)}
|
|
246
|
+
strokeWidth="2"
|
|
247
|
+
/>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
{index > 0 && row.before[row.lane] != null && (
|
|
251
|
+
<line x1={x(row.lane)} y1={y(index) - ROW_H / 2} x2={x(row.lane)} y2={y(index)} stroke={laneColor(row.lane)} strokeWidth="2" />
|
|
252
|
+
)}
|
|
253
|
+
{row.after[row.lane] != null && (
|
|
254
|
+
<line x1={x(row.lane)} y1={y(index)} x2={x(row.lane)} y2={y(index) + ROW_H / 2} stroke={laneColor(row.lane)} strokeWidth="2" />
|
|
255
|
+
)}
|
|
256
|
+
{row.cleared.map(lane => (
|
|
257
|
+
<path
|
|
258
|
+
key={`c${lane}`}
|
|
259
|
+
d={`M ${x(lane)} ${y(index) - ROW_H / 2} Q ${x(lane)} ${y(index)} ${x(row.lane)} ${y(index)}`}
|
|
260
|
+
fill="none"
|
|
261
|
+
stroke={laneColor(lane)}
|
|
262
|
+
strokeWidth="2"
|
|
263
|
+
/>
|
|
264
|
+
))}
|
|
265
|
+
{row.merges.map(merge => (
|
|
266
|
+
<path
|
|
267
|
+
key={`m${merge.lane}`}
|
|
268
|
+
d={merge.fresh
|
|
269
|
+
? `M ${x(row.lane)} ${y(index)} Q ${x(row.lane)} ${y(index) + ROW_H / 2} ${x(merge.lane)} ${y(index) + ROW_H / 2}`
|
|
270
|
+
: `M ${x(row.lane)} ${y(index)} Q ${(x(row.lane) + x(merge.lane)) / 2} ${y(index) + 9} ${x(merge.lane)} ${y(index)}`}
|
|
271
|
+
fill="none"
|
|
272
|
+
stroke={laneColor(merge.lane)}
|
|
273
|
+
strokeWidth="2"
|
|
274
|
+
/>
|
|
275
|
+
))}
|
|
276
|
+
<circle cx={x(row.lane)} cy={y(index)} r="3.4" fill={laneColor(row.lane)} />
|
|
277
|
+
</svg>
|
|
278
|
+
{commit.refs.length > 0 && (
|
|
279
|
+
<span style={{ display: 'flex', gap: 3, flex: 'none' }}>
|
|
280
|
+
{commit.refs.slice(0, 2).map(ref => (
|
|
281
|
+
<span
|
|
282
|
+
key={ref}
|
|
283
|
+
className={`rvs-ref-badge${refBadgeClass(ref)}`}
|
|
284
|
+
>
|
|
285
|
+
{refBadgeLabel(ref)}
|
|
286
|
+
</span>
|
|
287
|
+
))}
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
<span className="rvs-commit-hash">{commit.hash.slice(0, 7)}</span>
|
|
291
|
+
<span className="rvs-commit-msg">{commit.subject}</span>
|
|
292
|
+
<span className="rvs-commit-date">{relTime(commit.date, lang)}</span>
|
|
293
|
+
</button>
|
|
294
|
+
{isOpen && (
|
|
295
|
+
<div className="rvs-detail">
|
|
296
|
+
<div className="rvs-detail-meta">
|
|
297
|
+
{commit.author} <{commit.email}> · {relTime(commit.date, lang)} · {commit.hash.slice(0, 10)}
|
|
298
|
+
</div>
|
|
299
|
+
{changed === undefined
|
|
300
|
+
? <div className="rvs-empty" style={{ padding: 8 }}><MiniSpinner /> {t('common.loading')}</div>
|
|
301
|
+
: changed.length === 0
|
|
302
|
+
? <div className="rvs-empty" style={{ padding: 8 }}>{t('scm.logEmpty')}</div>
|
|
303
|
+
: changed.slice(0, 200).map(file => (
|
|
304
|
+
<div
|
|
305
|
+
key={`${file.status}:${file.path}`}
|
|
306
|
+
className="rvs-file-row"
|
|
307
|
+
title={file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`}
|
|
308
|
+
onClick={() => { store.openCommitFile(commit.hash, file.path) }}
|
|
309
|
+
>
|
|
310
|
+
<span className="rvs-node-icon"><FileIcon path={file.path} size={13} /></span>
|
|
311
|
+
<span className="rvs-file-path">
|
|
312
|
+
{file.origPath === undefined ? file.path : `${file.origPath} → ${file.path}`}
|
|
313
|
+
</span>
|
|
314
|
+
<span className={`rvs-file-status rvs-letter-${gitStatusClass(file.status)}`}>{file.status[0]}</span>
|
|
315
|
+
</div>
|
|
316
|
+
))}
|
|
317
|
+
<div className="rvs-commit-row">
|
|
318
|
+
<button
|
|
319
|
+
type="button"
|
|
320
|
+
className="rvs-chip"
|
|
321
|
+
onClick={() => {
|
|
322
|
+
void (async () => {
|
|
323
|
+
const ok = await confirmDialog({
|
|
324
|
+
title: t('scm.checkoutCommitTitle'),
|
|
325
|
+
text: t('scm.checkoutCommitText').replace('{hash}', commit.hash.slice(0, 10)),
|
|
326
|
+
okText: t('scm.checkout'),
|
|
327
|
+
})
|
|
328
|
+
if (!ok || repoRoot === undefined) return
|
|
329
|
+
try {
|
|
330
|
+
await checkoutCommit(alias, repoRoot, commit.hash)
|
|
331
|
+
await store.afterWrite()
|
|
332
|
+
toast(t('scm.checkedOut'))
|
|
333
|
+
} catch (caught) {
|
|
334
|
+
toast(caught instanceof Error ? caught.message : String(caught), true)
|
|
335
|
+
}
|
|
336
|
+
})()
|
|
337
|
+
}}
|
|
338
|
+
>
|
|
339
|
+
{t('scm.checkout')}
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
)
|
|
346
|
+
})}
|
|
347
|
+
{visible.length >= GIT_LOG_MAX && (
|
|
348
|
+
<div className="rvs-empty">{t('scm.logCapped').replace('{n}', String(GIT_LOG_MAX))}</div>
|
|
349
|
+
)}
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository directory picker (SCM): a workspace binding is often a container
|
|
3
|
+
* (`/srv/sites`) while the repository sits one or more levels below it
|
|
4
|
+
* (`/srv/sites/shop/.git`). Git only answers for the directory it runs in, so
|
|
5
|
+
* this dialog browses the REMOTE tree under the workspace root and reports, per
|
|
6
|
+
* directory, which repository it would resolve to before the user commits.
|
|
7
|
+
*
|
|
8
|
+
* Three ways in, because they cover different situations:
|
|
9
|
+
* - the discovered-repository list (one `find` under the workspace root, max
|
|
10
|
+
* depth 3) for the common case,
|
|
11
|
+
* - drilling through directories (dirs only, `..` included),
|
|
12
|
+
* - typing or pasting a path (relative or absolute inside the workspace) when
|
|
13
|
+
* the tree is deep.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
16
|
+
import { api } from '../api.ts'
|
|
17
|
+
import { normalizeRepoSubdir, repoSubdirOf, repoWorkdir } from '../../git-ops.ts'
|
|
18
|
+
import { resolveRepoRoot } from './actions.ts'
|
|
19
|
+
import { IconButton, IconChevronRight, IconClose, IconFolder, IconRefresh, FolderIcon, MiniSpinner } from '../vsc/kit.tsx'
|
|
20
|
+
import { useExplorer } from '../store.tsx'
|
|
21
|
+
|
|
22
|
+
/** What git resolves to for the directory currently being browsed. */
|
|
23
|
+
type Probe =
|
|
24
|
+
| { kind: 'loading' }
|
|
25
|
+
| { kind: 'root' }
|
|
26
|
+
| { kind: 'inside'; subdir: string }
|
|
27
|
+
| { kind: 'none' }
|
|
28
|
+
| { kind: 'error'; message: string }
|
|
29
|
+
|
|
30
|
+
export function RepoPicker(props: {
|
|
31
|
+
alias: string
|
|
32
|
+
/** Absolute workspace root (the tree the picker may browse). */
|
|
33
|
+
root: string
|
|
34
|
+
/** Currently selected workspace-relative directory ('' = the root). */
|
|
35
|
+
current: string
|
|
36
|
+
onPick: (subdir: string) => void
|
|
37
|
+
onClose: () => void
|
|
38
|
+
}): ReactNode {
|
|
39
|
+
const store = useExplorer()
|
|
40
|
+
const { t, discoverRepos } = store
|
|
41
|
+
const [cwd, setCwd] = useState<string>(() => repoWorkdir(props.root, props.current))
|
|
42
|
+
const [dirs, setDirs] = useState<{ name: string; path: string }[]>([])
|
|
43
|
+
const [listing, setListing] = useState(false)
|
|
44
|
+
const [probe, setProbe] = useState<Probe>({ kind: 'loading' })
|
|
45
|
+
const [candidates, setCandidates] = useState<string[] | undefined>(undefined)
|
|
46
|
+
const [finding, setFinding] = useState(false)
|
|
47
|
+
const [typed, setTyped] = useState('')
|
|
48
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
49
|
+
|
|
50
|
+
const rel = repoSubdirOf(props.root, cwd) ?? ''
|
|
51
|
+
const insideRoot = repoSubdirOf(props.root, cwd) !== undefined
|
|
52
|
+
|
|
53
|
+
// Directory listing + repository probe for the browsed directory.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let cancelled = false
|
|
56
|
+
setListing(true)
|
|
57
|
+
setProbe({ kind: 'loading' })
|
|
58
|
+
api.fileOp.list(props.alias, cwd).then(
|
|
59
|
+
(entries) => {
|
|
60
|
+
if (cancelled) return
|
|
61
|
+
setDirs(
|
|
62
|
+
entries
|
|
63
|
+
.filter(entry => entry.isDir)
|
|
64
|
+
.map(entry => ({ name: entry.name, path: entry.path }))
|
|
65
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
66
|
+
)
|
|
67
|
+
setListing(false)
|
|
68
|
+
},
|
|
69
|
+
(e: unknown) => {
|
|
70
|
+
if (cancelled) return
|
|
71
|
+
setDirs([])
|
|
72
|
+
setListing(false)
|
|
73
|
+
setError(e instanceof Error ? e.message : String(e))
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
resolveRepoRoot(props.alias, cwd).then(
|
|
77
|
+
(found) => {
|
|
78
|
+
if (cancelled) return
|
|
79
|
+
if (found === undefined) setProbe({ kind: 'none' })
|
|
80
|
+
else if (found === cwd) setProbe({ kind: 'root' })
|
|
81
|
+
else setProbe({ kind: 'inside', subdir: repoSubdirOf(props.root, found) ?? found })
|
|
82
|
+
},
|
|
83
|
+
(e: unknown) => {
|
|
84
|
+
if (cancelled) return
|
|
85
|
+
setProbe({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
return () => { cancelled = true }
|
|
89
|
+
}, [props.alias, props.root, cwd])
|
|
90
|
+
|
|
91
|
+
const find = useCallback(async (): Promise<void> => {
|
|
92
|
+
setFinding(true)
|
|
93
|
+
try {
|
|
94
|
+
setCandidates(await discoverRepos())
|
|
95
|
+
} finally {
|
|
96
|
+
setFinding(false)
|
|
97
|
+
}
|
|
98
|
+
}, [discoverRepos])
|
|
99
|
+
|
|
100
|
+
// Discovery runs once when the dialog opens, and again only when the dialog
|
|
101
|
+
// is re-pointed at another binding: it is the shortest path for the "my
|
|
102
|
+
// repository is in a subdirectory somewhere" case. The dependency used to be
|
|
103
|
+
// the whole `store`, whose snapshot is a NEW object on every publish — so
|
|
104
|
+
// every background git refresh under an open dialog re-ran a remote
|
|
105
|
+
// `git rev-parse` (plus a `find -maxdepth 3` when the root is not a repo).
|
|
106
|
+
// It does not depend on `cwd`: the scan starts at the workspace root.
|
|
107
|
+
useEffect(() => { void find() }, [find, props.alias, props.root])
|
|
108
|
+
|
|
109
|
+
const parent = useMemo(() => {
|
|
110
|
+
if (!insideRoot) return undefined
|
|
111
|
+
if (rel === '') return undefined
|
|
112
|
+
const cut = rel.lastIndexOf('/')
|
|
113
|
+
return repoWorkdir(props.root, cut === -1 ? '' : rel.slice(0, cut))
|
|
114
|
+
}, [insideRoot, rel, props.root])
|
|
115
|
+
|
|
116
|
+
const jump = (input: string): void => {
|
|
117
|
+
const value = input.trim()
|
|
118
|
+
if (value === '') return
|
|
119
|
+
if (value.startsWith('/')) {
|
|
120
|
+
const subdir = repoSubdirOf(props.root, value.replace(/\/+$/, ''))
|
|
121
|
+
if (subdir === undefined) {
|
|
122
|
+
setError(t('scm.pickOutside'))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
setError(undefined)
|
|
126
|
+
setCwd(repoWorkdir(props.root, subdir))
|
|
127
|
+
setTyped('')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
setError(undefined)
|
|
131
|
+
setCwd(repoWorkdir(props.root, normalizeRepoSubdir(value)))
|
|
132
|
+
setTyped('')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const crumbs = rel === '' ? [] : rel.split('/')
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="rvs-modal-mask" onMouseDown={(e) => { if (e.target === e.currentTarget) props.onClose() }}>
|
|
139
|
+
<div className="rvs-modal-box rvs-repopick" role="dialog" aria-label={t('scm.pickTitle')}>
|
|
140
|
+
<div className="rvs-modal-title">
|
|
141
|
+
{t('scm.pickTitle')}
|
|
142
|
+
<IconButton title={t('common.close')} onClick={props.onClose}><IconClose size={13} /></IconButton>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<div className="rvs-repopick-bar">
|
|
146
|
+
<button type="button" className="rvs-crumb" onClick={() => setCwd(props.root)}>{t('scm.repoDirRoot')}</button>
|
|
147
|
+
{crumbs.map((part, index) => (
|
|
148
|
+
<span key={`${part}-${index}`} className="rvs-crumb-wrap">
|
|
149
|
+
<IconChevronRight size={11} />
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
className="rvs-crumb"
|
|
153
|
+
onClick={() => setCwd(repoWorkdir(props.root, crumbs.slice(0, index + 1).join('/')))}
|
|
154
|
+
>
|
|
155
|
+
{part}
|
|
156
|
+
</button>
|
|
157
|
+
</span>
|
|
158
|
+
))}
|
|
159
|
+
{listing && <MiniSpinner />}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div className={`rvs-repopick-probe ${probe.kind}`}>
|
|
163
|
+
{probe.kind === 'loading' && t('common.loading')}
|
|
164
|
+
{probe.kind === 'root' && `✓ ${t('scm.pickRoot')}`}
|
|
165
|
+
{probe.kind === 'inside' && `${t('scm.pickInside')} ${probe.subdir === '' ? t('scm.repoDirRoot') : probe.subdir}`}
|
|
166
|
+
{probe.kind === 'none' && t('scm.pickNone')}
|
|
167
|
+
{probe.kind === 'error' && probe.message}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{candidates !== undefined && (
|
|
171
|
+
<div className="rvs-repopick-found">
|
|
172
|
+
<span className="rvs-repopick-found-label">{t('scm.discoverFound')}</span>
|
|
173
|
+
{candidates.length === 0
|
|
174
|
+
? <span className="rvs-repopick-dim">{t('scm.discoverEmpty')}</span>
|
|
175
|
+
: candidates.map(dir => (
|
|
176
|
+
<button
|
|
177
|
+
key={dir === '' ? '.' : dir}
|
|
178
|
+
type="button"
|
|
179
|
+
className="rvs-chip"
|
|
180
|
+
title={repoWorkdir(props.root, dir)}
|
|
181
|
+
onClick={() => setCwd(repoWorkdir(props.root, dir))}
|
|
182
|
+
>
|
|
183
|
+
{dir === '' ? t('scm.repoDirRoot') : dir}
|
|
184
|
+
</button>
|
|
185
|
+
))}
|
|
186
|
+
<IconButton title={t('scm.discover')} onClick={() => { void find() }} disabled={finding}>
|
|
187
|
+
<IconRefresh size={12} />
|
|
188
|
+
</IconButton>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
<div className="rvs-repopick-list">
|
|
193
|
+
{parent !== undefined && (
|
|
194
|
+
<button type="button" className="rvs-repopick-row up" onClick={() => setCwd(parent)}>
|
|
195
|
+
<IconFolder size={14} /><span>..</span>
|
|
196
|
+
</button>
|
|
197
|
+
)}
|
|
198
|
+
{dirs.map(dir => (
|
|
199
|
+
<button key={dir.path} type="button" className="rvs-repopick-row" title={dir.path} onClick={() => setCwd(dir.path)}>
|
|
200
|
+
<FolderIcon path={dir.path} open={false} size={14} />
|
|
201
|
+
<span>{dir.name}</span>
|
|
202
|
+
</button>
|
|
203
|
+
))}
|
|
204
|
+
{!listing && dirs.length === 0 && <div className="rvs-repopick-dim">{t('scm.pickEmptyDir')}</div>}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<form
|
|
208
|
+
className="rvs-repopick-type"
|
|
209
|
+
onSubmit={(e) => { e.preventDefault(); jump(typed) }}
|
|
210
|
+
>
|
|
211
|
+
<input
|
|
212
|
+
className="rvs-input"
|
|
213
|
+
value={typed}
|
|
214
|
+
spellCheck={false}
|
|
215
|
+
placeholder={t('scm.pickPathPlaceholder')}
|
|
216
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
217
|
+
/>
|
|
218
|
+
<button type="submit" className="rvs-chip" disabled={typed.trim() === ''}>{t('scm.pickGo')}</button>
|
|
219
|
+
</form>
|
|
220
|
+
|
|
221
|
+
{error !== undefined && <div className="rvs-repopick-error">{error}</div>}
|
|
222
|
+
|
|
223
|
+
<div className="rvs-modal-row">
|
|
224
|
+
<button type="button" className="rvs-modal-btn" onClick={props.onClose}>{t('scm.pickCancel')}</button>
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
className="rvs-modal-btn primary"
|
|
228
|
+
disabled={!insideRoot}
|
|
229
|
+
title={cwd}
|
|
230
|
+
onClick={() => props.onPick(rel)}
|
|
231
|
+
>
|
|
232
|
+
{t('scm.pickChoose')}
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|