dsh-remote-workspaces 0.1.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/LICENSE +21 -0
- package/README.md +149 -0
- package/README.zh.md +151 -0
- package/cordis.patch.yml +25 -0
- package/package.json +44 -0
- package/src/anchor.js +67 -0
- package/src/client.js +756 -0
- package/src/containment.js +71 -0
- package/src/errors.js +17 -0
- package/src/fs-sftp.js +231 -0
- package/src/index.js +327 -0
- package/src/local-backend.js +224 -0
- package/src/machine-store.js +238 -0
- package/src/registry.js +76 -0
- package/src/routing-fs.js +230 -0
- package/src/search.js +286 -0
- package/src/shell-exec.js +423 -0
- package/src/ssh-config.js +52 -0
- package/src/ssh-uri.js +19 -0
- package/src/transport.js +401 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of the SSH remote workspace plugin:
|
|
3
|
+
*
|
|
4
|
+
* 1. A "远程工作区" settings section — a multi-machine SSH registry (add /
|
|
5
|
+
* edit / delete / test) with `~/.ssh/config` as a one-click form-fill
|
|
6
|
+
* convenience. NO remote browsing here.
|
|
7
|
+
*
|
|
8
|
+
* 2. A composed directory-flow picker registered into the harness's two
|
|
9
|
+
* workspace-add holes (`conversation.hero.workspace.directoryFlow` and
|
|
10
|
+
* `sidebar.workspaces.directoryFlow`) at a lower priority so it shadows the
|
|
11
|
+
* native chooser and offers BOTH "本地文件夹" (delegates to the host
|
|
12
|
+
* chooser) and "远程目录" (pick a machine → browse the remote → open it as
|
|
13
|
+
* a remote workspace → hand the anchor path back through `onPicked`).
|
|
14
|
+
*
|
|
15
|
+
* Served verbatim as a classic script, so it self-registers through
|
|
16
|
+
* `window.__ModuleLoader__` in factory form (no ESM import/export); `react` is
|
|
17
|
+
* a platform seed word resolved via `require("react")`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Remote contract (must match src/index.js). Parameters carry strict codecs
|
|
22
|
+
// with a pass-through `parse` (the client `$mount` face rejects `src-json`).
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
var PACKAGE = 'dsh-remote-workspaces'
|
|
25
|
+
var NAMESPACE = 'remoteWorkspaces'
|
|
26
|
+
|
|
27
|
+
var JSON_CODEC = Object.freeze({
|
|
28
|
+
mode: 'strict',
|
|
29
|
+
typeSymbol: 'JsonValue',
|
|
30
|
+
schema: Object.freeze({
|
|
31
|
+
parse: function (value) { return value },
|
|
32
|
+
}),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
function jsonParameter(name) {
|
|
36
|
+
return { name: name, wire: name, source: 'json', codec: JSON_CODEC }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function invocation(method, parameters) {
|
|
40
|
+
return {
|
|
41
|
+
id: NAMESPACE + '/' + method,
|
|
42
|
+
service: NAMESPACE,
|
|
43
|
+
namespace: NAMESPACE,
|
|
44
|
+
method: method,
|
|
45
|
+
invocation: { kind: 'direct' },
|
|
46
|
+
parameters: parameters || [],
|
|
47
|
+
result: JSON_CODEC,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
var INVOCATIONS = [
|
|
52
|
+
invocation('listMachines'),
|
|
53
|
+
invocation('saveMachine', [jsonParameter('machine')]),
|
|
54
|
+
invocation('deleteMachine', [jsonParameter('id')]),
|
|
55
|
+
invocation('listSshAliases'),
|
|
56
|
+
invocation('sshAliasDetail', [jsonParameter('alias')]),
|
|
57
|
+
invocation('testConnection', [jsonParameter('machine')]),
|
|
58
|
+
invocation('listRemoteDir', [jsonParameter('machine'), jsonParameter('path')]),
|
|
59
|
+
invocation('openRemoteWorkspace', [jsonParameter('machine'), jsonParameter('path')]),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
function unwrapRemote(res) {
|
|
63
|
+
if (res === undefined || res === null) return { ok: false, error: '无响应' }
|
|
64
|
+
if (res.ok === false) {
|
|
65
|
+
var e = res.error
|
|
66
|
+
return { ok: false, error: e && e.message ? e.message : '调用失败' }
|
|
67
|
+
}
|
|
68
|
+
return res.value || { ok: false, error: '空结果' }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
window.__ModuleLoader__.load({
|
|
72
|
+
id: PACKAGE,
|
|
73
|
+
factory: (require) => {
|
|
74
|
+
var module = { exports: {} }
|
|
75
|
+
var exports = module.exports
|
|
76
|
+
var React = require('react')
|
|
77
|
+
|
|
78
|
+
var sectionStyle = { padding: 16, fontSize: 14, lineHeight: 1.6, maxWidth: 820 }
|
|
79
|
+
var labelStyle = { color: 'var(--dsw-alias-label-secondary, #888)', margin: 0, fontSize: 12.5 }
|
|
80
|
+
var monoStyle = { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 12.5 }
|
|
81
|
+
var btnStyle = { padding: '5px 12px', cursor: 'pointer' }
|
|
82
|
+
var inputStyle = { padding: '5px 8px', fontSize: 13, width: '100%', boxSizing: 'border-box' }
|
|
83
|
+
var dangerColor = 'var(--dsw-alias-danger, #c00)'
|
|
84
|
+
var successColor = 'var(--dsw-alias-success, #0a0)'
|
|
85
|
+
var borderColor = 'var(--dsw-alias-border, #333)'
|
|
86
|
+
var chipStyle = { display: 'inline-block', padding: '1px 8px', borderRadius: 10, background: 'rgba(127,127,127,0.18)', fontSize: 11.5, lineHeight: '18px' }
|
|
87
|
+
|
|
88
|
+
function pathBasename(p) {
|
|
89
|
+
if (!p) return ''
|
|
90
|
+
var s = String(p).replace(/[\\/]+$/, '')
|
|
91
|
+
var idx = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
|
|
92
|
+
return idx >= 0 ? s.slice(idx + 1) : s
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// =========================================================================
|
|
96
|
+
// Settings section: machine registry only (no remote browsing).
|
|
97
|
+
// =========================================================================
|
|
98
|
+
function SshWorkspaceSection(props) {
|
|
99
|
+
var getRemote = props.getRemote
|
|
100
|
+
var mount = props.mount
|
|
101
|
+
|
|
102
|
+
var machinesState = React.useState([])
|
|
103
|
+
var machines = machinesState[0]
|
|
104
|
+
var setMachines = machinesState[1]
|
|
105
|
+
var loadingState = React.useState(true)
|
|
106
|
+
var loading = loadingState[0]
|
|
107
|
+
var setLoading = loadingState[1]
|
|
108
|
+
var errorState = React.useState(null)
|
|
109
|
+
var error = errorState[0]
|
|
110
|
+
var setError = errorState[1]
|
|
111
|
+
var remoteState = React.useState(null)
|
|
112
|
+
var remote = remoteState[0]
|
|
113
|
+
var setRemote = remoteState[1]
|
|
114
|
+
var mountErrorState = React.useState(null)
|
|
115
|
+
var mountError = mountErrorState[0]
|
|
116
|
+
var setMountError = mountErrorState[1]
|
|
117
|
+
var formState = React.useState(null)
|
|
118
|
+
var form = formState[0]
|
|
119
|
+
var setForm = formState[1]
|
|
120
|
+
var aliasesState = React.useState(null)
|
|
121
|
+
var aliases = aliasesState[0]
|
|
122
|
+
var setAliases = aliasesState[1]
|
|
123
|
+
var showAliasesState = React.useState(false)
|
|
124
|
+
var showAliases = showAliasesState[0]
|
|
125
|
+
var setShowAliases = showAliasesState[1]
|
|
126
|
+
var resultsState = React.useState({})
|
|
127
|
+
var results = resultsState[0]
|
|
128
|
+
var setResults = resultsState[1]
|
|
129
|
+
var expandedHostsState = React.useState({})
|
|
130
|
+
var expandedHosts = expandedHostsState[0]
|
|
131
|
+
var setExpandedHosts = expandedHostsState[1]
|
|
132
|
+
var deleteTargetState = React.useState(null)
|
|
133
|
+
var deleteTarget = deleteTargetState[0]
|
|
134
|
+
var setDeleteTarget = deleteTargetState[1]
|
|
135
|
+
var deletingState = React.useState(false)
|
|
136
|
+
var deleting = deletingState[0]
|
|
137
|
+
var setDeleting = deletingState[1]
|
|
138
|
+
|
|
139
|
+
React.useEffect(function () {
|
|
140
|
+
var alive = true
|
|
141
|
+
mount.then(
|
|
142
|
+
function () {
|
|
143
|
+
if (!alive) return
|
|
144
|
+
var ns = getRemote()
|
|
145
|
+
setRemote(ns)
|
|
146
|
+
ns.listMachines().then(
|
|
147
|
+
function (res) {
|
|
148
|
+
var b = unwrapRemote(res)
|
|
149
|
+
if (!alive) return
|
|
150
|
+
if (b.ok) setMachines(b.machines || [])
|
|
151
|
+
else setError(b.error || '加载主机失败')
|
|
152
|
+
setLoading(false)
|
|
153
|
+
},
|
|
154
|
+
function (err) {
|
|
155
|
+
if (!alive) return
|
|
156
|
+
setError(err && err.message ? err.message : String(err))
|
|
157
|
+
setLoading(false)
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
},
|
|
161
|
+
function (err) { if (alive) setMountError(err && err.message ? err.message : String(err)) },
|
|
162
|
+
)
|
|
163
|
+
return function () { alive = false }
|
|
164
|
+
}, [])
|
|
165
|
+
|
|
166
|
+
function refreshMachines() {
|
|
167
|
+
setLoading(true)
|
|
168
|
+
setError(null)
|
|
169
|
+
remote.listMachines().then(
|
|
170
|
+
function (res) {
|
|
171
|
+
var b = unwrapRemote(res)
|
|
172
|
+
setLoading(false)
|
|
173
|
+
if (b.ok) setMachines(b.machines || [])
|
|
174
|
+
else setError(b.error || '加载主机失败')
|
|
175
|
+
},
|
|
176
|
+
function (err) {
|
|
177
|
+
setLoading(false)
|
|
178
|
+
setError(err && err.message ? err.message : String(err))
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function openNew() {
|
|
184
|
+
setForm({ isNew: true, machine: { id: undefined, alias: '', host: '', port: '', user: '', identityFile: '', hasPassword: false } })
|
|
185
|
+
}
|
|
186
|
+
function openEdit(machine) { setForm({ isNew: false, machine: machine }) }
|
|
187
|
+
function closeForm() { setForm(null) }
|
|
188
|
+
|
|
189
|
+
function doSave(values) {
|
|
190
|
+
var payload = {
|
|
191
|
+
id: form.isNew ? undefined : form.machine.id,
|
|
192
|
+
alias: values.alias,
|
|
193
|
+
host: values.host,
|
|
194
|
+
port: values.port,
|
|
195
|
+
user: values.user,
|
|
196
|
+
identityFile: values.identityFile,
|
|
197
|
+
password: values.password === '' ? undefined : values.password,
|
|
198
|
+
passphrase: values.passphrase === '' ? undefined : values.passphrase,
|
|
199
|
+
}
|
|
200
|
+
remote.saveMachine(payload).then(
|
|
201
|
+
function (res) {
|
|
202
|
+
var b = unwrapRemote(res)
|
|
203
|
+
if (b.ok) { closeForm(); refreshMachines() }
|
|
204
|
+
else setError(b.error || '保存失败')
|
|
205
|
+
},
|
|
206
|
+
function (err) { setError(err && err.message ? err.message : String(err)) },
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function doDelete(machine) {
|
|
211
|
+
setDeleteTarget(machine)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function closeDelete() {
|
|
215
|
+
if (!deleting) setDeleteTarget(null)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function confirmDelete() {
|
|
219
|
+
if (!remote || !deleteTarget || deleting) return
|
|
220
|
+
setDeleting(true)
|
|
221
|
+
var machine = deleteTarget
|
|
222
|
+
remote.deleteMachine(machine.id).then(
|
|
223
|
+
function () {
|
|
224
|
+
setDeleting(false)
|
|
225
|
+
setDeleteTarget(null)
|
|
226
|
+
refreshMachines()
|
|
227
|
+
},
|
|
228
|
+
function (err) {
|
|
229
|
+
setDeleting(false)
|
|
230
|
+
setError(err && err.message ? err.message : String(err))
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function doTest(machine) {
|
|
236
|
+
var key = machine.id
|
|
237
|
+
setResults(function (prev) { var next = Object.assign({}, prev); next[key] = { testing: true }; return next })
|
|
238
|
+
remote.testConnection(machine).then(
|
|
239
|
+
function (res) {
|
|
240
|
+
setResults(function (prev) { var next = Object.assign({}, prev); next[key] = unwrapRemote(res); return next })
|
|
241
|
+
},
|
|
242
|
+
function (err) {
|
|
243
|
+
setResults(function (prev) {
|
|
244
|
+
var next = Object.assign({}, prev)
|
|
245
|
+
next[key] = { ok: false, error: err && err.message ? err.message : String(err) }
|
|
246
|
+
return next
|
|
247
|
+
})
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function toggleAliases() {
|
|
253
|
+
var next = !showAliases
|
|
254
|
+
setShowAliases(next)
|
|
255
|
+
if (next && aliases === null) {
|
|
256
|
+
remote.listSshAliases().then(
|
|
257
|
+
function (res) {
|
|
258
|
+
var b = unwrapRemote(res)
|
|
259
|
+
if (b.ok) setAliases(b.aliases || [])
|
|
260
|
+
else setAliases([])
|
|
261
|
+
},
|
|
262
|
+
function () { setAliases([]) },
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function fillFromAlias(alias) {
|
|
268
|
+
remote.sshAliasDetail(alias).then(
|
|
269
|
+
function (res) {
|
|
270
|
+
var b = unwrapRemote(res)
|
|
271
|
+
if (b.ok) setForm({ isNew: true, machine: b.machine })
|
|
272
|
+
else setError(b.error || '读取别名失败')
|
|
273
|
+
},
|
|
274
|
+
function (err) { setError(err && err.message ? err.message : String(err)) },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toggleHost(id) {
|
|
279
|
+
setExpandedHosts(function (prev) {
|
|
280
|
+
var n = Object.assign({}, prev)
|
|
281
|
+
n[id] = prev[id] === false // default open → first click collapses
|
|
282
|
+
return n
|
|
283
|
+
})
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return React.createElement(
|
|
287
|
+
'div',
|
|
288
|
+
{ style: sectionStyle },
|
|
289
|
+
React.createElement('div', { style: { fontWeight: 600, fontSize: 16, marginBottom: 8 } }, '远程工作区'),
|
|
290
|
+
React.createElement('p', { style: { margin: '0 0 12px' } },
|
|
291
|
+
'管理 SSH 主机与已打开的远程工作区。添加远程工作区请在侧边栏「添加工作区」里选「远程目录」。'),
|
|
292
|
+
mountError !== null
|
|
293
|
+
? React.createElement('p', { style: { color: dangerColor, margin: '0 0 12px' } }, 'Remote 命名空间挂载失败:' + mountError)
|
|
294
|
+
: null,
|
|
295
|
+
error !== null
|
|
296
|
+
? React.createElement('p', { style: { color: dangerColor, margin: '0 0 12px' } }, error)
|
|
297
|
+
: null,
|
|
298
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 12 } },
|
|
299
|
+
React.createElement('button', { type: 'button', onClick: openNew, disabled: !remote, style: btnStyle }, '添加主机'),
|
|
300
|
+
React.createElement('button', { type: 'button', onClick: toggleAliases, disabled: !remote, style: btnStyle }, '从 ~/.ssh/config 导入'),
|
|
301
|
+
),
|
|
302
|
+
showAliases
|
|
303
|
+
? React.createElement('div', { style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 10, marginBottom: 12 } },
|
|
304
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: 6 } }, '选择要填充到表单的别名'),
|
|
305
|
+
aliases === null
|
|
306
|
+
? React.createElement('div', { style: labelStyle }, '读取中…')
|
|
307
|
+
: aliases.length === 0
|
|
308
|
+
? React.createElement('div', { style: labelStyle }, '未找到 ~/.ssh/config 或其中没有 Host 别名')
|
|
309
|
+
: React.createElement('div', {},
|
|
310
|
+
aliases.map(function (alias) {
|
|
311
|
+
return React.createElement('span', { key: alias, onClick: function () { fillFromAlias(alias) }, style: Object.assign({}, monoStyle, { display: 'inline-block', padding: '3px 8px', margin: '0 6px 6px 0', border: '1px solid ' + borderColor, borderRadius: 4, cursor: 'pointer' }) }, alias)
|
|
312
|
+
}),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
: null,
|
|
316
|
+
form !== null
|
|
317
|
+
? React.createElement(FormPanel, { key: (form.machine.alias || '') + '|' + (form.machine.host || '') + '|' + (form.machine.id || 'new'), form: form, onSave: doSave, onCancel: closeForm })
|
|
318
|
+
: null,
|
|
319
|
+
loading
|
|
320
|
+
? React.createElement('div', { style: labelStyle }, '加载中…')
|
|
321
|
+
: machines.length === 0
|
|
322
|
+
? React.createElement('div', { style: labelStyle }, '还没有主机,点「添加主机」配置一台。')
|
|
323
|
+
: React.createElement('div', { style: { marginTop: 4 } },
|
|
324
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 } },
|
|
325
|
+
React.createElement('div', { style: { fontWeight: 600 } }, '主机(' + machines.length + ')'),
|
|
326
|
+
),
|
|
327
|
+
machines.map(function (machine) {
|
|
328
|
+
var open = expandedHosts[machine.id] !== false
|
|
329
|
+
return MachineRow(
|
|
330
|
+
machine,
|
|
331
|
+
results[machine.id],
|
|
332
|
+
open,
|
|
333
|
+
function () { toggleHost(machine.id) },
|
|
334
|
+
function () { doTest(machine) },
|
|
335
|
+
function () { openEdit(machine) },
|
|
336
|
+
function () { doDelete(machine) },
|
|
337
|
+
)
|
|
338
|
+
}),
|
|
339
|
+
),
|
|
340
|
+
deleteTarget !== null
|
|
341
|
+
? React.createElement('div', { style: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 } },
|
|
342
|
+
React.createElement('div', { style: { background: 'var(--dsw-alias-bg, #1c1c1c)', border: '1px solid ' + borderColor, borderRadius: 8, padding: 16, width: 440, maxWidth: '90vw', color: 'var(--dsw-alias-text, #ddd)' } },
|
|
343
|
+
React.createElement('div', { style: { fontWeight: 600, fontSize: 15, marginBottom: 10 } }, '删除主机'),
|
|
344
|
+
React.createElement('p', { style: { margin: '0 0 12px', lineHeight: 1.6 } },
|
|
345
|
+
'确定删除主机「' + (deleteTarget.alias || deleteTarget.host) + '」吗?(远端数据不受影响)'),
|
|
346
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
|
|
347
|
+
React.createElement('button', { type: 'button', onClick: closeDelete, disabled: deleting, style: btnStyle }, '取消'),
|
|
348
|
+
React.createElement('button', { type: 'button', onClick: confirmDelete, disabled: deleting, style: Object.assign({}, btnStyle, { background: dangerColor, color: '#fff', borderColor: dangerColor }) }, deleting ? '删除中…' : '删除'),
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
: null,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function MachineRow(machine, result, open, onToggle, onTest, onEdit, onDelete) {
|
|
357
|
+
var summary = [machine.alias || '(未命名)']
|
|
358
|
+
if (machine.host) summary.push(machine.host)
|
|
359
|
+
if (machine.user) summary.push('@' + machine.user)
|
|
360
|
+
if (machine.port) summary.push(':' + machine.port)
|
|
361
|
+
return React.createElement(
|
|
362
|
+
'div',
|
|
363
|
+
{ style: { borderTop: '1px solid ' + borderColor, padding: '8px 0' } },
|
|
364
|
+
React.createElement('div', { onClick: onToggle, style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
365
|
+
React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #888)', width: 16, flexShrink: 0 } }, open ? '▾' : '▸'),
|
|
366
|
+
React.createElement('span', { style: Object.assign({}, monoStyle, { fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }) }, summary.join(' ')),
|
|
367
|
+
React.createElement('span', { style: { flex: 1 } }),
|
|
368
|
+
React.createElement('button', { type: 'button', onClick: function (e) { e.stopPropagation(); onTest() }, style: btnStyle }, '测试连接'),
|
|
369
|
+
React.createElement('button', { type: 'button', onClick: function (e) { e.stopPropagation(); onEdit() }, style: btnStyle }, '编辑'),
|
|
370
|
+
React.createElement('button', { type: 'button', onClick: function (e) { e.stopPropagation(); onDelete() }, style: btnStyle }, '删除'),
|
|
371
|
+
),
|
|
372
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap', paddingLeft: 24 } },
|
|
373
|
+
machine.identityFile
|
|
374
|
+
? React.createElement('span', { title: '私钥:' + machine.identityFile, style: chipStyle }, '私钥 ' + pathBasename(machine.identityFile))
|
|
375
|
+
: null,
|
|
376
|
+
machine.hasPassword
|
|
377
|
+
? React.createElement('span', { title: '登录密码已保存在本地', style: chipStyle }, '密码已保存')
|
|
378
|
+
: null,
|
|
379
|
+
machine.hasPassphrase
|
|
380
|
+
? React.createElement('span', { title: '私钥口令已保存在本地,连接时会自动使用', style: chipStyle }, '口令已保存')
|
|
381
|
+
: null,
|
|
382
|
+
),
|
|
383
|
+
result && result.testing
|
|
384
|
+
? React.createElement('div', { style: Object.assign({}, labelStyle, { paddingLeft: 24, marginTop: 2 }) }, '测试中…')
|
|
385
|
+
: result && result.ok === true
|
|
386
|
+
? React.createElement('div', { style: { color: successColor, margin: 0, paddingLeft: 24, marginTop: 2 } }, '已连接(' + result.ms + 'ms)')
|
|
387
|
+
: result
|
|
388
|
+
? React.createElement('div', { style: { color: dangerColor, margin: 0, paddingLeft: 24, marginTop: 2 } }, result.error || '连接失败')
|
|
389
|
+
: null,
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function FormPanel(props) {
|
|
394
|
+
var initial = props.form.machine || {}
|
|
395
|
+
var valuesState = React.useState({
|
|
396
|
+
alias: initial.alias || '',
|
|
397
|
+
host: initial.host || '',
|
|
398
|
+
port: initial.port || '',
|
|
399
|
+
user: initial.user || '',
|
|
400
|
+
identityFile: initial.identityFile || '',
|
|
401
|
+
password: '',
|
|
402
|
+
passphrase: '',
|
|
403
|
+
})
|
|
404
|
+
var values = valuesState[0]
|
|
405
|
+
var setValues = valuesState[1]
|
|
406
|
+
|
|
407
|
+
function set(field) {
|
|
408
|
+
return function (e) {
|
|
409
|
+
var next = Object.assign({}, values)
|
|
410
|
+
next[field] = e.target.value
|
|
411
|
+
setValues(next)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function submit(e) {
|
|
416
|
+
e.preventDefault()
|
|
417
|
+
props.onSave(values)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function field(label, name, type, placeholder) {
|
|
421
|
+
return React.createElement('div', { style: { marginBottom: 8 } },
|
|
422
|
+
React.createElement('div', { style: labelStyle }, label),
|
|
423
|
+
React.createElement('input', { type: type || 'text', value: values[name], onChange: set(name), placeholder: placeholder || '', style: inputStyle }),
|
|
424
|
+
)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return React.createElement(
|
|
428
|
+
'form',
|
|
429
|
+
{ onSubmit: submit, style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 12, marginBottom: 12 } },
|
|
430
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: 8 } }, props.form.isNew ? '添加主机' : '编辑主机'),
|
|
431
|
+
field('别名(alias)', 'alias', 'text', '如 dev'),
|
|
432
|
+
field('主机地址(host)', 'host', 'text', '如 192.168.1.10 或 example.com'),
|
|
433
|
+
field('端口(port)', 'port', 'number', '默认 22'),
|
|
434
|
+
field('用户名(user)', 'user', 'text', '默认当前用户'),
|
|
435
|
+
field('私钥路径(identityFile)', 'identityFile', 'text', '如 ~/.ssh/id_rsa,留空用默认密钥'),
|
|
436
|
+
field('密码(password)', 'password', 'password', props.form.isNew ? '可选' : '留空保持不变,输入则替换'),
|
|
437
|
+
field('私钥口令(passphrase)', 'passphrase', 'password', props.form.isNew ? '可选' : '留空保持不变'),
|
|
438
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 4 } },
|
|
439
|
+
React.createElement('button', { type: 'submit', style: btnStyle }, '保存'),
|
|
440
|
+
React.createElement('button', { type: 'button', onClick: props.onCancel, style: btnStyle }, '取消'),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// =========================================================================
|
|
446
|
+
// Composed directory-flow picker (workspace add): local + remote.
|
|
447
|
+
// =========================================================================
|
|
448
|
+
function joinPosix(base, name) {
|
|
449
|
+
if (base === '' || base === undefined || base === null) return name
|
|
450
|
+
if (base === '/') return '/' + name
|
|
451
|
+
return base.replace(/\/+$/, '') + '/' + name
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function parentPosix(path) {
|
|
455
|
+
if (path === '' || path === undefined || path === null || path === '/') return '/'
|
|
456
|
+
var s = path.replace(/\/+$/, '')
|
|
457
|
+
if (s === '') return '/'
|
|
458
|
+
var idx = s.lastIndexOf('/')
|
|
459
|
+
return idx <= 0 ? '/' : s.slice(0, idx)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// One remote directory/file row: directories render first with a folder
|
|
463
|
+
// icon and are clickable; files render below with a muted style.
|
|
464
|
+
function EntryRow(props) {
|
|
465
|
+
var entry = props.entry
|
|
466
|
+
var onOpen = props.onOpen
|
|
467
|
+
var hoverState = React.useState(false)
|
|
468
|
+
var hover = hoverState[0]
|
|
469
|
+
var setHover = hoverState[1]
|
|
470
|
+
var rowStyle = {
|
|
471
|
+
display: 'flex',
|
|
472
|
+
alignItems: 'center',
|
|
473
|
+
gap: 8,
|
|
474
|
+
padding: '4px 8px',
|
|
475
|
+
borderRadius: 4,
|
|
476
|
+
cursor: entry.dir ? 'pointer' : 'default',
|
|
477
|
+
background: hover ? 'rgba(127,127,127,0.16)' : 'transparent',
|
|
478
|
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
479
|
+
fontSize: 13,
|
|
480
|
+
color: entry.dir ? 'var(--dsw-alias-text, #ddd)' : 'var(--dsw-alias-label-secondary, #888)',
|
|
481
|
+
}
|
|
482
|
+
return React.createElement(
|
|
483
|
+
'div',
|
|
484
|
+
{
|
|
485
|
+
style: rowStyle,
|
|
486
|
+
onClick: entry.dir ? function () { onOpen(entry.name) } : undefined,
|
|
487
|
+
onMouseEnter: function () { setHover(true) },
|
|
488
|
+
onMouseLeave: function () { setHover(false) },
|
|
489
|
+
title: entry.dir ? entry.name + '/' : entry.name,
|
|
490
|
+
},
|
|
491
|
+
React.createElement('span', { style: { width: 18, textAlign: 'center', flexShrink: 0 } }, entry.dir ? '📁' : '📄'),
|
|
492
|
+
React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.name),
|
|
493
|
+
entry.dir ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #888)', flexShrink: 0 } }, '/') : null,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function RemoteDirectoryFlow(props) {
|
|
498
|
+
var open = props.open
|
|
499
|
+
var busy = props.busy
|
|
500
|
+
var onPicked = props.onPicked
|
|
501
|
+
var onCancel = props.onCancel
|
|
502
|
+
var onError = props.onError
|
|
503
|
+
var getRemote = props.getRemote
|
|
504
|
+
var pickLocal = props.pickLocal
|
|
505
|
+
|
|
506
|
+
var modeState = React.useState('remote')
|
|
507
|
+
var mode = modeState[0]
|
|
508
|
+
var setMode = modeState[1]
|
|
509
|
+
var machinesState = React.useState([])
|
|
510
|
+
var machines = machinesState[0]
|
|
511
|
+
var setMachines = machinesState[1]
|
|
512
|
+
var machinesLoadedState = React.useState(false)
|
|
513
|
+
var machinesLoaded = machinesLoadedState[0]
|
|
514
|
+
var setMachinesLoaded = machinesLoadedState[1]
|
|
515
|
+
var selectedState = React.useState(null)
|
|
516
|
+
var selected = selectedState[0]
|
|
517
|
+
var setSelected = selectedState[1]
|
|
518
|
+
var browseState = React.useState(null)
|
|
519
|
+
var browse = browseState[0]
|
|
520
|
+
var setBrowse = browseState[1]
|
|
521
|
+
var openingState = React.useState(false)
|
|
522
|
+
var opening = openingState[0]
|
|
523
|
+
var setOpening = openingState[1]
|
|
524
|
+
var localPickingState = React.useState(false)
|
|
525
|
+
var localPicking = localPickingState[0]
|
|
526
|
+
var setLocalPicking = localPickingState[1]
|
|
527
|
+
var pathInputState = React.useState('')
|
|
528
|
+
var pathInput = pathInputState[0]
|
|
529
|
+
var setPathInput = pathInputState[1]
|
|
530
|
+
|
|
531
|
+
// Reset per open edge, then load machines.
|
|
532
|
+
React.useEffect(function () {
|
|
533
|
+
if (!open) return
|
|
534
|
+
setMode('remote')
|
|
535
|
+
setMachines([])
|
|
536
|
+
setMachinesLoaded(false)
|
|
537
|
+
setSelected(null)
|
|
538
|
+
setBrowse(null)
|
|
539
|
+
setOpening(false)
|
|
540
|
+
setLocalPicking(false)
|
|
541
|
+
var ns = getRemote()
|
|
542
|
+
if (!ns) return
|
|
543
|
+
ns.listMachines().then(
|
|
544
|
+
function (res) {
|
|
545
|
+
var b = unwrapRemote(res)
|
|
546
|
+
if (b.ok) setMachines(b.machines || [])
|
|
547
|
+
setMachinesLoaded(true)
|
|
548
|
+
},
|
|
549
|
+
function () { setMachinesLoaded(true) },
|
|
550
|
+
)
|
|
551
|
+
}, [open])
|
|
552
|
+
|
|
553
|
+
// Keep the path input in sync with the browsed directory (but not while
|
|
554
|
+
// the user is editing it).
|
|
555
|
+
React.useEffect(function () {
|
|
556
|
+
if (browse && browse.path !== undefined && browse.path !== null) setPathInput(browse.path)
|
|
557
|
+
else setPathInput('')
|
|
558
|
+
}, [browse && browse.path])
|
|
559
|
+
|
|
560
|
+
function pickMachine(machine) {
|
|
561
|
+
setSelected(machine)
|
|
562
|
+
setBrowse(null)
|
|
563
|
+
loadEntries(machine, '')
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function loadEntries(machine, path) {
|
|
567
|
+
setBrowse({ machine: machine, path: path, loading: true, error: null, entries: [] })
|
|
568
|
+
getRemote().listRemoteDir(machine, path).then(
|
|
569
|
+
function (res) {
|
|
570
|
+
var b = unwrapRemote(res)
|
|
571
|
+
setBrowse(function (prev) {
|
|
572
|
+
if (!prev || prev.machine !== machine) return prev
|
|
573
|
+
if (b.ok) {
|
|
574
|
+
// The host returns the resolved absolute path (home expanded),
|
|
575
|
+
// so "上一级" can walk past home all the way up to `/`.
|
|
576
|
+
var resolved = b.path !== undefined && b.path !== null && b.path !== '' ? b.path : path
|
|
577
|
+
return { machine: machine, path: resolved, loading: false, error: null, entries: b.entries || [] }
|
|
578
|
+
}
|
|
579
|
+
return { machine: machine, path: path, loading: false, error: b.error || '列出目录失败', entries: [] }
|
|
580
|
+
})
|
|
581
|
+
},
|
|
582
|
+
function (err) {
|
|
583
|
+
setBrowse(function (prev) {
|
|
584
|
+
if (!prev || prev.machine !== machine) return prev
|
|
585
|
+
return { machine: machine, path: path, loading: false, error: err && err.message ? err.message : String(err), entries: [] }
|
|
586
|
+
})
|
|
587
|
+
},
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function navigate(name) {
|
|
592
|
+
loadEntries(browse.machine, joinPosix(browse.path, name))
|
|
593
|
+
}
|
|
594
|
+
function goUp() {
|
|
595
|
+
if (!browse || !browse.path) return
|
|
596
|
+
var parent = parentPosix(browse.path)
|
|
597
|
+
if (parent === browse.path) return
|
|
598
|
+
loadEntries(browse.machine, parent)
|
|
599
|
+
}
|
|
600
|
+
function jumpTo() {
|
|
601
|
+
if (!selected) return
|
|
602
|
+
loadEntries(selected, pathInput)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function doLocal() {
|
|
606
|
+
if (localPicking) return
|
|
607
|
+
setLocalPicking(true)
|
|
608
|
+
pickLocal().then(
|
|
609
|
+
function (path) {
|
|
610
|
+
if (path === null || path === undefined || path === '') onCancel()
|
|
611
|
+
else onPicked(path)
|
|
612
|
+
},
|
|
613
|
+
function (err) {
|
|
614
|
+
onError(err && err.message ? err.message : String(err))
|
|
615
|
+
},
|
|
616
|
+
)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function doOpenRemote() {
|
|
620
|
+
if (opening || !selected || !browse) return
|
|
621
|
+
setOpening(true)
|
|
622
|
+
getRemote().openRemoteWorkspace(selected, browse.path).then(
|
|
623
|
+
function (res) {
|
|
624
|
+
var b = unwrapRemote(res)
|
|
625
|
+
if (b.ok && b.localDir) onPicked(b.localDir)
|
|
626
|
+
else onError(b.error || '打开远程工作区失败')
|
|
627
|
+
},
|
|
628
|
+
function (err) { onError(err && err.message ? err.message : String(err)) },
|
|
629
|
+
)
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (!open) return null
|
|
633
|
+
|
|
634
|
+
return React.createElement(
|
|
635
|
+
'div',
|
|
636
|
+
{ style: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 } },
|
|
637
|
+
React.createElement(
|
|
638
|
+
'div',
|
|
639
|
+
{ style: { background: 'var(--dsw-alias-bg, #1c1c1c)', border: '1px solid ' + borderColor, borderRadius: 8, padding: 16, width: 560, maxWidth: '92vw', maxHeight: '82vh', overflow: 'auto', color: 'var(--dsw-alias-text, #ddd)' } },
|
|
640
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', marginBottom: 12 } },
|
|
641
|
+
React.createElement('span', { style: { fontWeight: 600, fontSize: 15 } }, '打开文件夹'),
|
|
642
|
+
React.createElement('span', { style: { flex: 1 } }),
|
|
643
|
+
React.createElement('button', { type: 'button', onClick: onCancel, style: btnStyle }, '取消'),
|
|
644
|
+
),
|
|
645
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 12 } },
|
|
646
|
+
React.createElement('button', { type: 'button', onClick: function () { setMode('local') }, style: Object.assign({}, btnStyle, mode === 'local' ? { border: '2px solid ' + successColor } : {}) }, '本地文件夹'),
|
|
647
|
+
React.createElement('button', { type: 'button', onClick: function () { setMode('remote') }, style: Object.assign({}, btnStyle, mode === 'remote' ? { border: '2px solid ' + successColor } : {}) }, '远程目录'),
|
|
648
|
+
),
|
|
649
|
+
mode === 'local'
|
|
650
|
+
? React.createElement('div', {},
|
|
651
|
+
React.createElement('p', { style: { margin: '0 0 12px' } }, '在本机打开系统文件夹选择器,选取一个本地目录作为工作区。'),
|
|
652
|
+
React.createElement('button', { type: 'button', onClick: doLocal, disabled: localPicking || busy, style: btnStyle }, localPicking ? '等待选择…' : '选择本地文件夹'),
|
|
653
|
+
)
|
|
654
|
+
: React.createElement('div', {},
|
|
655
|
+
React.createElement('div', { style: { marginBottom: 8 } }, '选择 SSH 主机(在「设置 → 远程工作区」中配置):'),
|
|
656
|
+
machinesLoaded && machines.length === 0
|
|
657
|
+
? React.createElement('div', { style: { color: dangerColor, margin: '0 0 8px' } }, '还没有配置主机,请先到「设置 → 远程工作区」添加。')
|
|
658
|
+
: React.createElement('select', {
|
|
659
|
+
value: selected ? selected.id : '',
|
|
660
|
+
onChange: function (e) {
|
|
661
|
+
var id = e.target.value
|
|
662
|
+
var m = machines.find(function (x) { return x.id === id })
|
|
663
|
+
if (m) pickMachine(m)
|
|
664
|
+
},
|
|
665
|
+
style: Object.assign({}, inputStyle, { marginBottom: 8 }),
|
|
666
|
+
},
|
|
667
|
+
React.createElement('option', { value: '' }, '选择主机…'),
|
|
668
|
+
machines.map(function (m) {
|
|
669
|
+
return React.createElement('option', { key: m.id, value: m.id }, (m.alias || m.host) + ' (' + m.host + (m.user ? '@' + m.user : '') + ')')
|
|
670
|
+
}),
|
|
671
|
+
),
|
|
672
|
+
selected
|
|
673
|
+
? React.createElement('div', { style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 10 } },
|
|
674
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
|
|
675
|
+
React.createElement('input', {
|
|
676
|
+
type: 'text',
|
|
677
|
+
value: pathInput,
|
|
678
|
+
onChange: function (e) { setPathInput(e.target.value) },
|
|
679
|
+
onKeyDown: function (e) { if (e.key === 'Enter') jumpTo() },
|
|
680
|
+
placeholder: '输入绝对路径,或 ~ 回到主目录',
|
|
681
|
+
spellCheck: false,
|
|
682
|
+
style: Object.assign({}, monoStyle, { flex: 1, minWidth: 0, padding: '5px 8px', fontSize: 13, boxSizing: 'border-box' }),
|
|
683
|
+
}),
|
|
684
|
+
React.createElement('button', { type: 'button', onClick: jumpTo, disabled: !browse || browse.loading, style: btnStyle }, '跳转'),
|
|
685
|
+
React.createElement('button', { type: 'button', onClick: goUp, disabled: !browse || browse.loading || !browse.path || browse.path === '/', style: btnStyle }, '上一级'),
|
|
686
|
+
),
|
|
687
|
+
browse && browse.loading
|
|
688
|
+
? React.createElement('div', { style: labelStyle }, '加载中…')
|
|
689
|
+
: browse && browse.error
|
|
690
|
+
? React.createElement('div', { style: { color: dangerColor, margin: 0 } }, browse.error)
|
|
691
|
+
: browse
|
|
692
|
+
? React.createElement('div', { style: { maxHeight: 220, overflow: 'auto', border: '1px solid ' + borderColor, borderRadius: 6 } },
|
|
693
|
+
(browse.entries || []).slice().sort(function (a, b) {
|
|
694
|
+
if (a.dir !== b.dir) return a.dir ? -1 : 1
|
|
695
|
+
var an = a.name.toLowerCase()
|
|
696
|
+
var bn = b.name.toLowerCase()
|
|
697
|
+
return an < bn ? -1 : an > bn ? 1 : 0
|
|
698
|
+
}).map(function (e) {
|
|
699
|
+
return React.createElement(EntryRow, { key: (e.dir ? 'd:' : 'f:') + e.name, entry: e, onOpen: navigate })
|
|
700
|
+
}),
|
|
701
|
+
)
|
|
702
|
+
: null,
|
|
703
|
+
React.createElement('div', { style: { marginTop: 10 } },
|
|
704
|
+
React.createElement('button', { type: 'button', onClick: doOpenRemote, disabled: opening || busy || (browse && browse.loading), style: btnStyle },
|
|
705
|
+
opening ? '打开中…' : '打开此目录'),
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
: null,
|
|
709
|
+
),
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
exports.inject = ['slots', 'remote', 'uiWorkspace']
|
|
715
|
+
|
|
716
|
+
exports.apply = function apply(ctx) {
|
|
717
|
+
var mount = ctx.remote.$mount({ package: PACKAGE, descriptors: INVOCATIONS })
|
|
718
|
+
var getRemote = function () { return ctx.get('remote.' + NAMESPACE) }
|
|
719
|
+
|
|
720
|
+
// Settings section: machines + open remote workspaces (grouped by host).
|
|
721
|
+
ctx.slots.inject('settings.section', function () {
|
|
722
|
+
return ctx.slots.register(
|
|
723
|
+
{ name: 'settings.section', id: 'dsh-remote-workspaces', order: 100, label: '远程工作区' },
|
|
724
|
+
function () {
|
|
725
|
+
return React.createElement(SshWorkspaceSection, {
|
|
726
|
+
mount: mount,
|
|
727
|
+
getRemote: getRemote,
|
|
728
|
+
})
|
|
729
|
+
},
|
|
730
|
+
)
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
// Composed workspace-add picker (shadows the native chooser at a lower priority).
|
|
734
|
+
var flowInjected = function () {
|
|
735
|
+
return {
|
|
736
|
+
getRemote: getRemote,
|
|
737
|
+
pickLocal: function () { return ctx.uiWorkspace.pickDirectory() },
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
ctx.slots.inject('conversation.hero.workspace.directoryFlow', function () {
|
|
741
|
+
return ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () {
|
|
742
|
+
yield ctx.slots.register(
|
|
743
|
+
{ name: 'conversation.hero.workspace.directoryFlow', inject: flowInjected, priority: -1 },
|
|
744
|
+
RemoteDirectoryFlow,
|
|
745
|
+
)
|
|
746
|
+
yield ctx.slots.register(
|
|
747
|
+
{ name: 'sidebar.workspaces.directoryFlow', inject: flowInjected, priority: -1 },
|
|
748
|
+
RemoteDirectoryFlow,
|
|
749
|
+
)
|
|
750
|
+
})
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
return module.exports
|
|
755
|
+
},
|
|
756
|
+
})
|