dsh-remote 0.8.54 → 0.8.61
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 +19 -3
- package/README.md +18 -4
- package/lib/client.js +360 -79
- package/lib/credential.js +52 -4
- package/lib/errors.js +34 -3
- package/lib/index.js +229 -30
- package/lib/instructions.js +288 -288
- package/lib/pool.js +10 -1
- package/lib/registry.js +118 -4
- package/lib/routes-fs.js +14 -2
- package/lib/web-deploy.js +7 -1
- package/package.json +7 -3
package/lib/client.js
CHANGED
|
@@ -36,13 +36,30 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
const L = {
|
|
37
37
|
zh: {
|
|
38
38
|
'settings.title': '远程工作区(dsh-remote)',
|
|
39
|
-
'settings.intro': '
|
|
40
|
-
'settings.
|
|
41
|
-
|
|
39
|
+
'settings.intro': '下面直接列出 ~/.ssh/config 里的机器:点「连接」,再选一个远程目录就能开始。没有的机器可以手动添加(密码 / 私钥 / 跳板机)。',
|
|
40
|
+
'settings.noMachines': '还没有保存的机器 —— 上面列出的是 ~/.ssh/config 里的主机,点「连接」即可。',
|
|
41
|
+
// ── issue #73:首屏就是 ssh 配置里的主机列表(像 VSCode Remote-SSH)──
|
|
42
|
+
'settings.pickHost': '选择远程机器',
|
|
43
|
+
'settings.hostListSource': '来自 ~/.ssh/config',
|
|
44
|
+
'settings.hostListLoading': '正在读取 ~/.ssh/config…',
|
|
45
|
+
'settings.hostListRefresh': '刷新',
|
|
46
|
+
'settings.hostListRefreshHint': '重新读取 ~/.ssh/config(改完文件点这里,不用重开设置页)',
|
|
47
|
+
'settings.hostListEmpty': '没在 {file} 里找到主机。配好 ssh(Host 段)后点「刷新」,或在下面手动添加一台。',
|
|
48
|
+
'settings.hostConnect': '连接',
|
|
49
|
+
'settings.hostConnecting': '连接中…',
|
|
50
|
+
'settings.hostConnectHint': '连上并设为当前机器(之后就能选远程目录)',
|
|
51
|
+
'settings.hostConnected': '已连接 · {latency}',
|
|
52
|
+
'settings.hostConnectedHint': '已连接,选择要打开的远程目录即可开始工作。',
|
|
53
|
+
'settings.hostFillForm': '填表',
|
|
54
|
+
'settings.hostFillFormHint': '需要密码或单独覆盖参数时,把它填进下方表单',
|
|
55
|
+
'settings.badgeKey': '私钥',
|
|
56
|
+
'settings.openWorkspace': '选目录',
|
|
57
|
+
'settings.openWorkspaceHint': '在这台机器上选一个远程目录作为工作区',
|
|
58
|
+
'settings.workspaceOpened': '已打开远程工作区:{path}',
|
|
59
|
+
'settings.addMachineManual': '手动添加(密码 / 私钥 / 跳板机)',
|
|
42
60
|
'settings.current': '当前',
|
|
43
61
|
'settings.edit': '编辑',
|
|
44
62
|
'settings.delete': '删除',
|
|
45
|
-
'settings.setCurrent': '设为当前',
|
|
46
63
|
'settings.currentMachine': '当前远程机:{name}',
|
|
47
64
|
'settings.clearCurrent': '取消设为当前(active remote = none)',
|
|
48
65
|
'settings.noActive': '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。',
|
|
@@ -66,13 +83,8 @@ window.__ModuleLoader__.load({
|
|
|
66
83
|
'settings.webAttachCmdPlaceholder': '留空 = 用全局 webAttachCommand(默认 dsh)',
|
|
67
84
|
'settings.webAttachCmdHint': '远端 dsh 不在 SSH 登录 PATH 时(私有前缀 / nvm 装的 node)填它的绝对路径,例如 /home/you/.nvm/versions/node/v22.19.0/bin/node /home/you/.dsh-remote/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js。留空则用全局的 webAttachCommand。',
|
|
68
85
|
'settings.sshImport': '从 ~/.ssh/config 导入',
|
|
69
|
-
'settings.
|
|
70
|
-
'settings.
|
|
71
|
-
'settings.sshUseAlias': '用别名(不复制)',
|
|
72
|
-
'settings.sshCopyFields': '复制字段',
|
|
73
|
-
'settings.sshAliasHint': '别名模式:机器只记住「{alias}」这个名字,主机/用户/端口/私钥/跳板每次连接都从 ~/.ssh/config 实时解析(改配置立刻生效)。',
|
|
74
|
-
'settings.useSshConfig': '使用 ~/.ssh/config 别名(只存别名,不存副本)',
|
|
75
|
-
'settings.useSshConfigHint': '留空或保持默认的字段(用户 root、端口 22、私钥、跳板)将从 ~/.ssh/config 实时解析;填了非默认值则覆盖。',
|
|
86
|
+
'settings.useSshConfig': 'Host 名来自 ~/.ssh/config(主机栏填 Host 名即可)',
|
|
87
|
+
'settings.useSshConfigHint': '连的时候会按 ~/.ssh/config 里这个 Host 的实际配置去连(用户/端口/私钥/跳板机都从文件读,改文件就生效);这里留空即可。',
|
|
76
88
|
'settings.saveWritesSshConfig': '保存时会把 主机/用户/端口/私钥 写入 ~/.ssh/config 的受管块(# dsh-remote: managed 标记);此后该文件优先——直接改它立即生效。',
|
|
77
89
|
'settings.sshResolvedTo': '→ {user}@{host}:{port}',
|
|
78
90
|
'settings.sshResolvedNone': '~/.ssh/config 中没有匹配「{alias}」的 Host 条目',
|
|
@@ -83,6 +95,23 @@ window.__ModuleLoader__.load({
|
|
|
83
95
|
'settings.port': '端口',
|
|
84
96
|
'settings.user': '用户',
|
|
85
97
|
'settings.password': '密码',
|
|
98
|
+
// 密码框永远是空的(宿主不会把密文/明文回传),所以「有没有存过密码」
|
|
99
|
+
// 只能靠 placeholder 表达:星号 = 已保存。空框 + 中性提示会让人以为
|
|
100
|
+
// 插件把密码弄丢了。
|
|
101
|
+
'settings.passwordPlaceholder': 'SSH 密码(隐藏输入;不用密码可留空)',
|
|
102
|
+
'settings.secretSavedPlaceholder': '••••••••••••(已保存,留空 = 不修改)',
|
|
103
|
+
'settings.secretSavedHint': '已保存密码 ••••••••••••(出于安全不回显):留空保存 = 沿用已保存的密码,填新值 = 覆盖。',
|
|
104
|
+
'settings.passphraseSavedHint': '已保存私钥 passphrase ••••••••••••:留空保存 = 沿用,填新值 = 覆盖。',
|
|
105
|
+
'settings.proxyPwdSavedHint': '已保存跳板机密码 ••••••••••••:留空保存 = 沿用,填新值 = 覆盖。',
|
|
106
|
+
'settings.noCredentialHint': '⚠️ 这台机器还没有任何凭据(密码 / 私钥 / agent 都没填),连接会报「缺少凭据」。',
|
|
107
|
+
'settings.testUsedSaved': '(本次测试使用了已保存的密码)',
|
|
108
|
+
'settings.testHint': '用上面的配置测试连接;留空的密码/私钥/passphrase 会自动沿用已保存的值',
|
|
109
|
+
'settings.testNeedsHost': '先填写主机地址才能测试',
|
|
110
|
+
// 灰按钮必须能解释自己:用户「点了没反应」时唯一能拿到的线索就是 hover 提示。
|
|
111
|
+
'settings.saveNeedsHost': '先填写主机地址才能保存',
|
|
112
|
+
'settings.saveHint': '保存这台机器(留空的密码/私钥/passphrase 沿用已保存的值)',
|
|
113
|
+
'settings.fwdNeedsPort': '先填写本地监听端口才能添加转发',
|
|
114
|
+
'settings.fwdAddHint': '添加一条端口转发',
|
|
86
115
|
'settings.advCollapse': '▲ 收起高级配置',
|
|
87
116
|
'settings.advExpand': '▼ 高级配置(私钥 / 跳板机 / agent 等)',
|
|
88
117
|
'settings.privateKey': '私钥路径',
|
|
@@ -144,6 +173,8 @@ window.__ModuleLoader__.load({
|
|
|
144
173
|
'settings.deployInstall': '部署并验证',
|
|
145
174
|
'settings.deployInstalling': '正在部署…',
|
|
146
175
|
'settings.deployConfirm': '将在远端私有目录安装 dsh(不写系统目录、不改 PATH、不覆盖用户在用的版本)。继续?',
|
|
176
|
+
'settings.deployNeedsMachine': '先选择一台机器才能部署',
|
|
177
|
+
'settings.deployInstallHint': '在远端私有目录安装 dsh 并验证(不覆盖用户在用的版本)',
|
|
147
178
|
'settings.deployInvestigate': '让 AI 排查',
|
|
148
179
|
'settings.deployInvestigating': '正在创建排查会话…',
|
|
149
180
|
'settings.deployOk': '远端环境可用',
|
|
@@ -152,7 +183,6 @@ window.__ModuleLoader__.load({
|
|
|
152
183
|
'settings.deploySteps': '安装步骤',
|
|
153
184
|
'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
|
|
154
185
|
'settings.deployFailed': '部署失败',
|
|
155
|
-
'settings.deployResumed': '已有部署任务在运行,已恢复显示其进度',
|
|
156
186
|
'settings.deployTaskTitle': '部署任务',
|
|
157
187
|
'settings.deployTaskRunning': '进行中',
|
|
158
188
|
'settings.deployTaskDone': '已完成',
|
|
@@ -183,7 +213,6 @@ window.__ModuleLoader__.load({
|
|
|
183
213
|
'settings.added': '已添加 — 可设为当前',
|
|
184
214
|
'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已按 credentialFallback=plain 回退为明文保存在 machines.json。',
|
|
185
215
|
'settings.secretStoreRefused': '密码未能写入系统密钥库({msg}),已放弃保存该密码(不会以明文落盘)。修复密钥库后重新保存,或把 credentialFallback 设为 plain 以接受明文回退。',
|
|
186
|
-
'settings.switched': '已切换为当前远程机',
|
|
187
216
|
'settings.cleared': '已取消当前远程机 — 本地会话不再自动使用远程上下文',
|
|
188
217
|
'settings.filledSsh': '已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)',
|
|
189
218
|
'settings.workspaceSwitched': '已切换到工作区 {path}',
|
|
@@ -208,7 +237,6 @@ window.__ModuleLoader__.load({
|
|
|
208
237
|
'settings.updateModeSet': '更新模式已设为「{mode}」',
|
|
209
238
|
'settings.updateModeSaveFail': '模式保存失败: {msg}',
|
|
210
239
|
'settings.deleteConfirm': '确定删除这台机器?',
|
|
211
|
-
'settings.sshReadFail': '读取 ~/.ssh/config 失败: {msg}',
|
|
212
240
|
'settings.delFail': '删除失败: {msg}',
|
|
213
241
|
'settings.addFail': '保存失败: {msg}',
|
|
214
242
|
'settings.modeAuto': '自动',
|
|
@@ -328,13 +356,29 @@ window.__ModuleLoader__.load({
|
|
|
328
356
|
},
|
|
329
357
|
en: {
|
|
330
358
|
'settings.title': 'Remote workspace (dsh-remote)',
|
|
331
|
-
'settings.intro': '
|
|
332
|
-
'settings.
|
|
333
|
-
'settings.
|
|
359
|
+
'settings.intro': 'Your ~/.ssh/config hosts are listed below: click Connect, then pick a remote folder. Anything not in that file can be added manually (password / key / jump host).',
|
|
360
|
+
'settings.noMachines': 'No saved machines yet — the list above comes from ~/.ssh/config; click 连接 to use one.',
|
|
361
|
+
'settings.pickHost': 'Choose a remote machine',
|
|
362
|
+
'settings.hostListSource': 'from ~/.ssh/config',
|
|
363
|
+
'settings.hostListLoading': 'Reading ~/.ssh/config…',
|
|
364
|
+
'settings.hostListRefresh': 'Refresh',
|
|
365
|
+
'settings.hostListRefreshHint': 'Re-read ~/.ssh/config (no need to reopen this page)',
|
|
366
|
+
'settings.hostListEmpty': 'No hosts found in {file}. Add Host blocks to your ssh config and hit Refresh, or add a machine manually below.',
|
|
367
|
+
'settings.hostConnect': 'Connect',
|
|
368
|
+
'settings.hostConnecting': 'Connecting…',
|
|
369
|
+
'settings.hostConnectHint': 'Connect and make it the current machine (then pick a remote folder)',
|
|
370
|
+
'settings.hostConnected': 'Connected · {latency}',
|
|
371
|
+
'settings.hostConnectedHint': 'Connected — pick the remote folder to start working.',
|
|
372
|
+
'settings.hostFillForm': 'Fill form',
|
|
373
|
+
'settings.hostFillFormHint': 'Needs a password or per-machine overrides? Load it into the form below',
|
|
374
|
+
'settings.badgeKey': 'key',
|
|
375
|
+
'settings.openWorkspace': 'Folder',
|
|
376
|
+
'settings.openWorkspaceHint': 'Pick a remote folder on this machine as the workspace',
|
|
377
|
+
'settings.workspaceOpened': 'Opened remote workspace: {path}',
|
|
378
|
+
'settings.addMachineManual': 'Add manually (password / key / jump host)',
|
|
334
379
|
'settings.current': 'current',
|
|
335
380
|
'settings.edit': 'Edit',
|
|
336
381
|
'settings.delete': 'Delete',
|
|
337
|
-
'settings.setCurrent': 'Set as current',
|
|
338
382
|
'settings.currentMachine': 'Current remote: {name}',
|
|
339
383
|
'settings.clearCurrent': 'Clear current (active remote = none)',
|
|
340
384
|
'settings.noActive': 'No active remote machine: saved machines are standby connections and never enter a session context automatically (local sessions are unaffected).',
|
|
@@ -356,13 +400,8 @@ window.__ModuleLoader__.load({
|
|
|
356
400
|
'settings.webAttachCmdPlaceholder': 'Empty = the global webAttachCommand (default: dsh)',
|
|
357
401
|
'settings.webAttachCmdHint': 'Set the absolute path when the remote dsh is not on the SSH login PATH (a private prefix, or node installed with nvm), e.g. /home/you/.nvm/versions/node/v22.19.0/bin/node /home/you/.dsh-remote/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js. Empty falls back to the global webAttachCommand.',
|
|
358
402
|
'settings.sshImport': 'Import from ~/.ssh/config',
|
|
359
|
-
'settings.
|
|
360
|
-
'settings.
|
|
361
|
-
'settings.sshUseAlias': 'Use alias (no copy)',
|
|
362
|
-
'settings.sshCopyFields': 'Copy fields',
|
|
363
|
-
'settings.sshAliasHint': 'Alias mode: the machine remembers only the name "{alias}"; host/user/port/key/jump host are resolved from ~/.ssh/config at every connect, so editing that file takes effect immediately.',
|
|
364
|
-
'settings.useSshConfig': 'Use a ~/.ssh/config alias (keep the alias, not a copy)',
|
|
365
|
-
'settings.useSshConfigHint': 'Fields left empty or at their default (user root, port 22, key, jump host) come from ~/.ssh/config at connect time; a non-default value overrides it.',
|
|
403
|
+
'settings.useSshConfig': 'Host name comes from ~/.ssh/config (put the Host name in 主机)',
|
|
404
|
+
'settings.useSshConfigHint': 'Connecting follows that Host block in ~/.ssh/config (user / port / key / jump host are read from the file, so editing it takes effect). Leave the other fields empty.',
|
|
366
405
|
'settings.saveWritesSshConfig': 'Saving writes host/user/port/key into a managed block in ~/.ssh/config (marked # dsh-remote: managed); the file then takes precedence — edit it directly and it takes effect at once.',
|
|
367
406
|
'settings.sshResolvedTo': '→ {user}@{host}:{port}',
|
|
368
407
|
'settings.sshResolvedNone': 'no ~/.ssh/config block matches "{alias}"',
|
|
@@ -373,6 +412,19 @@ window.__ModuleLoader__.load({
|
|
|
373
412
|
'settings.port': 'Port',
|
|
374
413
|
'settings.user': 'User',
|
|
375
414
|
'settings.password': 'Password',
|
|
415
|
+
'settings.passwordPlaceholder': 'SSH password (hidden; leave blank if unused)',
|
|
416
|
+
'settings.secretSavedPlaceholder': '•••••••••••• (saved — leave blank to keep)',
|
|
417
|
+
'settings.secretSavedHint': 'A password is saved (••••••••••••) and is never echoed back for safety: leave the box empty to keep it, type a new value to replace it.',
|
|
418
|
+
'settings.passphraseSavedHint': 'A private-key passphrase is saved (••••••••••••): leave the box empty to keep it, type a new value to replace it.',
|
|
419
|
+
'settings.proxyPwdSavedHint': 'A jump-host password is saved (••••••••••••): leave the box empty to keep it, type a new value to replace it.',
|
|
420
|
+
'settings.noCredentialHint': '⚠️ This machine has no credential yet (no password, private key or agent) — connecting will fail with "no credentials".',
|
|
421
|
+
'settings.testUsedSaved': '(this test used the saved password)',
|
|
422
|
+
'settings.testHint': 'Probe with the values above; a blank password/key/passphrase falls back to the saved one',
|
|
423
|
+
'settings.testNeedsHost': 'Fill in the host before testing',
|
|
424
|
+
'settings.saveNeedsHost': 'Fill in the host before saving',
|
|
425
|
+
'settings.saveHint': 'Save this machine (a blank password/key/passphrase keeps the saved one)',
|
|
426
|
+
'settings.fwdNeedsPort': 'Fill in the local listen port before adding a forward',
|
|
427
|
+
'settings.fwdAddHint': 'Add a port forward',
|
|
376
428
|
'settings.advCollapse': '▲ Collapse advanced config',
|
|
377
429
|
'settings.advExpand': '▼ Advanced (private key / proxy jump / agent, etc.)',
|
|
378
430
|
'settings.privateKey': 'Private key path',
|
|
@@ -434,6 +486,8 @@ window.__ModuleLoader__.load({
|
|
|
434
486
|
'settings.deployInstall': 'Deploy & verify',
|
|
435
487
|
'settings.deployInstalling': 'Deploying…',
|
|
436
488
|
'settings.deployConfirm': 'Install dsh into a private directory on the remote (no system directory, no PATH change, no replacing the version in use). Continue?',
|
|
489
|
+
'settings.deployNeedsMachine': 'Pick a machine before deploying',
|
|
490
|
+
'settings.deployInstallHint': 'Install dsh into a private directory on the remote and verify it',
|
|
437
491
|
'settings.deployInvestigate': 'Let the AI investigate',
|
|
438
492
|
'settings.deployInvestigating': 'Creating a session…',
|
|
439
493
|
'settings.deployOk': 'Remote environment is usable',
|
|
@@ -442,7 +496,6 @@ window.__ModuleLoader__.load({
|
|
|
442
496
|
'settings.deploySteps': 'Install steps',
|
|
443
497
|
'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
|
|
444
498
|
'settings.deployFailed': 'Deploy failed',
|
|
445
|
-
'settings.deployResumed': 'A deploy is already running — re-attached to its progress',
|
|
446
499
|
'settings.deployTaskTitle': 'Deploy task',
|
|
447
500
|
'settings.deployTaskRunning': 'running',
|
|
448
501
|
'settings.deployTaskDone': 'done',
|
|
@@ -473,7 +526,6 @@ window.__ModuleLoader__.load({
|
|
|
473
526
|
'settings.added': 'Added — you can set it as current',
|
|
474
527
|
'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); credentialFallback=plain wrote it to machines.json in plaintext.',
|
|
475
528
|
'settings.secretStoreRefused': 'The OS keychain refused the password ({msg}); the password was NOT saved (it will not be written to disk in plaintext). Repair the keychain and save again, or set credentialFallback to plain to accept the plaintext fallback.',
|
|
476
|
-
'settings.switched': 'Switched to current remote machine',
|
|
477
529
|
'settings.cleared': 'Cleared current remote — local sessions no longer use remote context automatically',
|
|
478
530
|
'settings.filledSsh': 'Filled from ~/.ssh/config (only references the private key path, never reads its content)',
|
|
479
531
|
'settings.workspaceSwitched': 'Switched to workspace {path}',
|
|
@@ -498,7 +550,6 @@ window.__ModuleLoader__.load({
|
|
|
498
550
|
'settings.updateModeSet': 'Update mode set to "{mode}"',
|
|
499
551
|
'settings.updateModeSaveFail': 'Failed to save mode: {msg}',
|
|
500
552
|
'settings.deleteConfirm': 'Delete this machine?',
|
|
501
|
-
'settings.sshReadFail': 'Failed to read ~/.ssh/config: {msg}',
|
|
502
553
|
'settings.delFail': 'Delete failed: {msg}',
|
|
503
554
|
'settings.addFail': 'Save failed: {msg}',
|
|
504
555
|
'settings.modeAuto': 'Auto',
|
|
@@ -930,6 +981,11 @@ window.__ModuleLoader__.load({
|
|
|
930
981
|
// Issue #38: alias mode — `host` is a ~/.ssh/config Host name and the
|
|
931
982
|
// resolved values are never copied into the registry.
|
|
932
983
|
useSshConfig: false,
|
|
984
|
+
// Secrets never come back from the host (it strips them), so the form
|
|
985
|
+
// tracks only WHETHER one is saved: the boxes then show
|
|
986
|
+
// "••••••••(已保存)" instead of looking empty, which is the difference
|
|
987
|
+
// between "the plugin remembered my password" and "the plugin lost it".
|
|
988
|
+
passwordSet: false, passphraseSet: false, proxyPasswordSet: false,
|
|
933
989
|
})
|
|
934
990
|
const [busy, setBusy] = React.useState(false)
|
|
935
991
|
const [msg, setMsg] = React.useState('')
|
|
@@ -938,7 +994,6 @@ window.__ModuleLoader__.load({
|
|
|
938
994
|
const [forwards, setForwards] = React.useState([])
|
|
939
995
|
const [fwdForm, setFwdForm] = React.useState({ listenPort: '', targetHost: '127.0.0.1', targetPort: '', direction: 'local', autoStart: false })
|
|
940
996
|
const [auditLines, setAuditLines] = React.useState([])
|
|
941
|
-
const [sshEntries, setSshEntries] = React.useState(null)
|
|
942
997
|
const [workspaces, setWorkspaces] = React.useState([])
|
|
943
998
|
const [advOpen, setAdvOpen] = React.useState(false) // 高级配置折叠
|
|
944
999
|
const [fwdOpen, setFwdOpen] = React.useState(true)
|
|
@@ -962,16 +1017,48 @@ window.__ModuleLoader__.load({
|
|
|
962
1017
|
// is the object the poll keeps fresh; it survives because the work runs
|
|
963
1018
|
// on the host, not in this tab.
|
|
964
1019
|
const [deployTask, setDeployTask] = React.useState(null)
|
|
1020
|
+
// Issue #73: the FIRST thing the page shows is the host list from
|
|
1021
|
+
// ~/.ssh/config (the way VSCode Remote-SSH does it), not a form. These hold
|
|
1022
|
+
// that list, the row currently connecting, the per-row result, and the
|
|
1023
|
+
// "pick a folder" step that follows a successful connect.
|
|
1024
|
+
const [sshHosts, setSshHosts] = React.useState(null) // null = not loaded yet
|
|
1025
|
+
const [sshFile, setSshFile] = React.useState('')
|
|
1026
|
+
const [connectingKey, setConnectingKey] = React.useState('')
|
|
1027
|
+
const [rowState, setRowState] = React.useState({}) // key → { ok, text }
|
|
1028
|
+
const [manualOpen, setManualOpen] = React.useState(false)
|
|
1029
|
+
const [dirPick, setDirPick] = React.useState(null) // { machineId } while the picker is open
|
|
965
1030
|
// session's machine", which the host resolves from the session binding.
|
|
966
1031
|
React.useEffect(() => { ensureChromeCss() }, [])
|
|
967
1032
|
|
|
1033
|
+
/** Load the ~/.ssh/config host list (cheap: the host reads a local file). */
|
|
1034
|
+
const loadSshHosts = () => api('GET', '/dsh-remote/ssh-config')
|
|
1035
|
+
.then((r) => {
|
|
1036
|
+
setSshHosts(Array.isArray(r && r.entries) ? r.entries : [])
|
|
1037
|
+
setSshFile(String((r && r.file) || ''))
|
|
1038
|
+
})
|
|
1039
|
+
.catch(() => setSshHosts([]))
|
|
1040
|
+
React.useEffect(() => { loadSshHosts() }, [])
|
|
1041
|
+
|
|
1042
|
+
const setRow = (key, next) => setRowState((prev) => ({ ...prev, [key]: next }))
|
|
1043
|
+
|
|
968
1044
|
const testConn = () => {
|
|
969
1045
|
if (!form.host.trim() || tst === 'testing') return
|
|
970
1046
|
setTst('testing'); setErr(''); setMsg('')
|
|
971
1047
|
api('POST', '/dsh-remote/test-connect', {
|
|
1048
|
+
// `id` lets the host resolve THIS machine's saved credential: the form
|
|
1049
|
+
// cannot send the password back (the host strips secrets), so without
|
|
1050
|
+
// it "编辑一台连得上的机器 → 测试连接" always failed with
|
|
1051
|
+
// 「缺少凭据」 even though connecting worked.
|
|
1052
|
+
id: form.id || undefined,
|
|
972
1053
|
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
973
1054
|
password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
974
1055
|
useSshConfig: !!form.useSshConfig,
|
|
1056
|
+
// Auth METHOD flags: a probe that ignores them tests a different
|
|
1057
|
+
// authentication than the machine will use.
|
|
1058
|
+
useAgent: !!form.useAgent,
|
|
1059
|
+
keyboardInteractive: !!form.keyboardInteractive,
|
|
1060
|
+
hostKeyMode: form.hostKeyMode || undefined,
|
|
1061
|
+
sshBinary: form.sshBinary || undefined,
|
|
975
1062
|
// Issue #51: the probe must exercise the SAME backend the machine will
|
|
976
1063
|
// use — a host reachable only via GSSAPI fails the native probe.
|
|
977
1064
|
backend: form.backend || 'native',
|
|
@@ -981,6 +1068,9 @@ window.__ModuleLoader__.load({
|
|
|
981
1068
|
if (r && r.ok) {
|
|
982
1069
|
const shellTag = r.platform === 'windows' ? (r.gitBash ? tr('settings.shellWin') : tr('settings.shellWinNoGit')) : (r.platform === 'posix' ? tr('settings.shellPosix') : '')
|
|
983
1070
|
setTst('ok'); setMsg(r.latencyMs != null ? tr('settings.connOkWith', { user: r.user, host: r.host, latency: r.latencyMs, shell: shellTag }) : tr('settings.connOk'))
|
|
1071
|
+
// Say WHEN the probe used the saved credential: an empty password
|
|
1072
|
+
// box next to "连接成功" is otherwise indistinguishable from magic.
|
|
1073
|
+
if (r.usedSavedCredential) setMsg((m) => m + ' ' + tr('settings.testUsedSaved'))
|
|
984
1074
|
// Issue #38: show where an alias resolved to (and what could not
|
|
985
1075
|
// be honoured), instead of leaving the user guessing.
|
|
986
1076
|
if (r.alias) {
|
|
@@ -1112,9 +1202,10 @@ window.__ModuleLoader__.load({
|
|
|
1112
1202
|
if (t.status === 'running' || t.status === 'queued') {
|
|
1113
1203
|
setDeployBusy('install')
|
|
1114
1204
|
} else if (deployBusy === 'install') {
|
|
1115
|
-
// The task settled since the last poll.
|
|
1205
|
+
// The task settled since the last poll. The panel renders the task
|
|
1206
|
+
// itself (steps + result), so only the FAILURE needs to reach the
|
|
1207
|
+
// error line — there is no separate "message" state any more.
|
|
1116
1208
|
setDeployBusy('')
|
|
1117
|
-
if (t.status === 'done') setDeployMsg(tr('settings.deployDone'))
|
|
1118
1209
|
if (t.status === 'failed') setDeployErr(t.error || tr('settings.deployFailed'))
|
|
1119
1210
|
}
|
|
1120
1211
|
}
|
|
@@ -1123,7 +1214,11 @@ window.__ModuleLoader__.load({
|
|
|
1123
1214
|
.catch(() => null)
|
|
1124
1215
|
|
|
1125
1216
|
const deployAction = (action, machineId, extra) => {
|
|
1126
|
-
|
|
1217
|
+
// No `setDeployMsg` here: the state it referred to was removed when the
|
|
1218
|
+
// panel started rendering the host-side task, but these calls stayed — the
|
|
1219
|
+
// whole deploy panel threw `ReferenceError: setDeployMsg is not defined`
|
|
1220
|
+
// the instant a button was clicked (found by test/ui-deploy-flow.test.js).
|
|
1221
|
+
setDeployBusy(action); setDeployErr('')
|
|
1127
1222
|
return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
|
|
1128
1223
|
.then((r) => {
|
|
1129
1224
|
if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
|
|
@@ -1131,7 +1226,6 @@ window.__ModuleLoader__.load({
|
|
|
1131
1226
|
// The task is running on the host NOW. Adopt it immediately so the
|
|
1132
1227
|
// steps appear without waiting for the first poll tick.
|
|
1133
1228
|
if (r && r.task) setDeployTask(r.task)
|
|
1134
|
-
if (r && r.resumed) setDeployMsg(tr('settings.deployResumed'))
|
|
1135
1229
|
}
|
|
1136
1230
|
if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
|
|
1137
1231
|
return r
|
|
@@ -1166,13 +1260,18 @@ window.__ModuleLoader__.load({
|
|
|
1166
1260
|
webAttachCommand: m.webAttachCommand || '',
|
|
1167
1261
|
id: m.id,
|
|
1168
1262
|
useSshConfig: !!m.useSshConfig,
|
|
1263
|
+
// "which secrets are already saved" — the boxes stay empty on purpose,
|
|
1264
|
+
// their placeholders show the dots.
|
|
1265
|
+
passwordSet: !!m.passwordSet,
|
|
1266
|
+
passphraseSet: !!m.passphraseSet,
|
|
1267
|
+
proxyPasswordSet: !!(m.proxy && m.proxy.passwordSet),
|
|
1169
1268
|
})
|
|
1170
1269
|
// 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串/系统 OpenSSH 后端),自动展开折叠区
|
|
1171
1270
|
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || m.webAttachCommand || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1172
1271
|
setAdvOpen(true)
|
|
1173
1272
|
}
|
|
1174
1273
|
}
|
|
1175
|
-
const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', backend: 'native', sshBinary: '', webAttachCommand: '', id: '', useSshConfig: false })
|
|
1274
|
+
const clearForm = () => setForm({ name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '', passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false, encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', backend: 'native', sshBinary: '', webAttachCommand: '', id: '', useSshConfig: false, passwordSet: false, passphraseSet: false, proxyPasswordSet: false })
|
|
1176
1275
|
const save = (action) => {
|
|
1177
1276
|
setBusy(true); setErr(''); setMsg('')
|
|
1178
1277
|
if (action === 'delete') {
|
|
@@ -1188,9 +1287,34 @@ window.__ModuleLoader__.load({
|
|
|
1188
1287
|
backend: form.backend || 'native', sshBinary: form.sshBinary || '',
|
|
1189
1288
|
// Issue #54: the per-machine remote-dsh command for Web UI attach.
|
|
1190
1289
|
webAttachCommand: form.webAttachCommand || '',
|
|
1191
|
-
proxy:
|
|
1290
|
+
// `proxy: null` (NOT `undefined`) when the jump-host field is empty:
|
|
1291
|
+
// JSON.stringify drops undefined keys, so `undefined` reads as "not
|
|
1292
|
+
// mentioned" and the host would KEEP a stored jump host — the field
|
|
1293
|
+
// could never be cleared. `null` states "this machine has no jump host".
|
|
1294
|
+
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : null,
|
|
1192
1295
|
}).then((r) => {
|
|
1193
|
-
refresh()
|
|
1296
|
+
refresh()
|
|
1297
|
+
// Keep the form BOUND to the machine that was just saved instead of
|
|
1298
|
+
// wiping it. The old code called clearForm(), which emptied `host` —
|
|
1299
|
+
// and the 测试连接 button requires a host, so "保存后想立刻验证" was
|
|
1300
|
+
// impossible: the button went dead the moment the save succeeded. The
|
|
1301
|
+
// secret inputs are cleared (the host strips them anyway) while the
|
|
1302
|
+
// *_Set flags tell the placeholders a credential is stored.
|
|
1303
|
+
const rec = r && r.machine
|
|
1304
|
+
if (rec && rec.id) {
|
|
1305
|
+
setForm((f) => ({
|
|
1306
|
+
...f,
|
|
1307
|
+
id: rec.id,
|
|
1308
|
+
password: '', passphrase: '', proxyPassword: '',
|
|
1309
|
+
passwordSet: !!rec.passwordSet,
|
|
1310
|
+
passphraseSet: !!rec.passphraseSet,
|
|
1311
|
+
proxyPasswordSet: !!(rec.proxy && rec.proxy.passwordSet),
|
|
1312
|
+
encryptPassword: !!(rec.credentialBackend && rec.credentialBackend !== 'plain'),
|
|
1313
|
+
}))
|
|
1314
|
+
setTst('')
|
|
1315
|
+
} else {
|
|
1316
|
+
clearForm()
|
|
1317
|
+
}
|
|
1194
1318
|
// Issue #30: "加密保存密码"失败时服务端回退明文并带警告 —— 不能再静默
|
|
1195
1319
|
// 保存一台没有凭据的机器(那会让之后每次连接都失败且没有原因)。
|
|
1196
1320
|
if (r && r.warning === 'secret-store-failed') setErr(tr('settings.secretStoreFailed', { msg: r.warningDetail || 'unknown' }))
|
|
@@ -1201,40 +1325,65 @@ window.__ModuleLoader__.load({
|
|
|
1201
1325
|
else setMsg(form.id ? tr('settings.saved') : tr('settings.added'))
|
|
1202
1326
|
}).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false))
|
|
1203
1327
|
}
|
|
1204
|
-
const useNow = (id) => { setBusy(true); api('POST', '/dsh-remote/current', { id }).then((r) => { setCurrentId(r.currentId); setMsg(tr('settings.switched')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
1205
1328
|
// Issue #13: "active remote = none" — saved machines stay, but no machine
|
|
1206
1329
|
// is current, so local sessions are never dragged into a remote context.
|
|
1207
1330
|
const clearCurrent = () => { setBusy(true); api('POST', '/dsh-remote/current', { id: '' }).then((r) => { setCurrentId(''); setMsg(tr('settings.cleared')) }).catch((e) => setErr(String((e && e.message) || e))).finally(() => setBusy(false)) }
|
|
1208
1331
|
const del = (id) => { if (window.confirm(tr('settings.deleteConfirm'))) { api('POST', '/dsh-remote/machines', { action: 'delete', id }).then(refresh).then(() => setMsg(tr('settings.deleted'))).catch((e) => setErr(String((e && e.message) || e))) } }
|
|
1209
1332
|
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
// Issue #38, primary action: save the machine as an ALIAS. Nothing from
|
|
1215
|
-
// ~/.ssh/config is copied into the registry — the host/user/port/key/jump
|
|
1216
|
-
// host are re-read at every connect, so editing that file is enough.
|
|
1217
|
-
const useSshAlias = (e) => {
|
|
1218
|
-
setForm({
|
|
1219
|
-
...form,
|
|
1220
|
-
name: e.host, host: e.host, port: '22', username: 'root',
|
|
1221
|
-
privateKeyPath: '', passphrase: '', useSshConfig: true,
|
|
1222
|
-
})
|
|
1223
|
-
setSshEntries(null)
|
|
1224
|
-
setMsg(tr('settings.sshAliasHint', { alias: e.host }))
|
|
1225
|
-
}
|
|
1226
|
-
// Legacy/escape hatch: materialise the current values as a normal machine
|
|
1227
|
-
// (for users who want to override them individually).
|
|
1333
|
+
// Issue #73: the manual form's escape hatch from the host list. A host that
|
|
1334
|
+
// needs a password (or any per-machine override) is filled into the form
|
|
1335
|
+
// instead of being adopted as an alias — one click, no jargon, and the form
|
|
1336
|
+
// opens with the values already in place.
|
|
1228
1337
|
const applySshEntry = (e) => {
|
|
1338
|
+
const alias = String(e.alias || e.host)
|
|
1229
1339
|
setForm({
|
|
1230
1340
|
...form,
|
|
1231
|
-
name:
|
|
1341
|
+
name: alias, host: e.hostName || alias, port: String(e.port || 22),
|
|
1232
1342
|
username: e.user || 'root', privateKeyPath: e.identityFile || '', useSshConfig: false,
|
|
1233
1343
|
})
|
|
1234
|
-
|
|
1344
|
+
setManualOpen(true)
|
|
1235
1345
|
setMsg(tr('settings.filledSsh'))
|
|
1236
1346
|
}
|
|
1237
1347
|
|
|
1348
|
+
/**
|
|
1349
|
+
* Issue #73: connect to one row of the host list.
|
|
1350
|
+
*
|
|
1351
|
+
* One click does what the user means by "connect": adopt the host (for a
|
|
1352
|
+
* ~/.ssh/config entry this upserts the alias machine — the host does it, so
|
|
1353
|
+
* there is no duplicate and no form), make it current, then PROBE it so the
|
|
1354
|
+
* row can say whether it really works. A failure stays on the row it belongs
|
|
1355
|
+
* to instead of in a panel-level error the user has to correlate.
|
|
1356
|
+
*/
|
|
1357
|
+
const connectHost = (entry) => {
|
|
1358
|
+
const key = entry.key
|
|
1359
|
+
if (connectingKey) return
|
|
1360
|
+
setConnectingKey(key); setErr(''); setMsg('')
|
|
1361
|
+
setRow(key, { pending: true, text: tr('settings.hostConnecting') })
|
|
1362
|
+
const activate = entry.machineId
|
|
1363
|
+
? api('POST', '/dsh-remote/current', { id: entry.machineId })
|
|
1364
|
+
: api('POST', '/dsh-remote/machines', { action: 'from-ssh-config', alias: entry.alias })
|
|
1365
|
+
activate
|
|
1366
|
+
.then((r) => {
|
|
1367
|
+
const id = (r && r.currentId) || entry.machineId
|
|
1368
|
+
setCurrentId(id)
|
|
1369
|
+
refresh()
|
|
1370
|
+
return api('POST', '/dsh-remote/test-connect', { id, host: entry.host, port: entry.port, username: entry.username || 'root' })
|
|
1371
|
+
.then((probe) => ({ id, probe }))
|
|
1372
|
+
})
|
|
1373
|
+
.then(({ id, probe }) => {
|
|
1374
|
+
if (probe && probe.ok) {
|
|
1375
|
+
setRow(key, { ok: true, text: tr('settings.hostConnected', { latency: probe.latencyMs != null ? probe.latencyMs + 'ms' : '?' }) })
|
|
1376
|
+
setMsg(tr('settings.hostConnectedHint'))
|
|
1377
|
+
// Next step in the VSCode-shaped flow: pick the remote folder.
|
|
1378
|
+
setDirPick({ machineId: id })
|
|
1379
|
+
} else {
|
|
1380
|
+
setRow(key, { ok: false, text: (probe && probe.error) || tr('settings.connFail') })
|
|
1381
|
+
}
|
|
1382
|
+
})
|
|
1383
|
+
.catch((e) => setRow(key, { ok: false, text: String((e && e.message) || e) }))
|
|
1384
|
+
.finally(() => setConnectingKey(''))
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1238
1387
|
const setWorkspaceNow = (p) => {
|
|
1239
1388
|
api('POST', '/dsh-remote/workspace', { path: p })
|
|
1240
1389
|
.then(() => setMsg(tr('settings.workspaceSwitched', { path: p })))
|
|
@@ -1267,12 +1416,74 @@ window.__ModuleLoader__.load({
|
|
|
1267
1416
|
extra || null,
|
|
1268
1417
|
)
|
|
1269
1418
|
|
|
1419
|
+
// Issue #73: hosts from ~/.ssh/config that are NOT saved yet — they are the
|
|
1420
|
+
// first thing the page shows, because that is the whole list a VSCode
|
|
1421
|
+
// Remote-SSH user expects. A saved alias machine hides its file entry (the row
|
|
1422
|
+
// below it is the same host, with the actions that belong to a saved machine).
|
|
1423
|
+
const savedAliases = new Set(machines.filter((m) => m.useSshConfig).map((m) => String(m.host)))
|
|
1424
|
+
const discovered = (sshHosts || []).filter((h) => !savedAliases.has(String(h.alias || h.host)))
|
|
1425
|
+
|
|
1426
|
+
/** The inline result of a connect attempt, shown on its own row. */
|
|
1427
|
+
const rowResult = (key) => {
|
|
1428
|
+
const st = rowState[key]
|
|
1429
|
+
if (!st) return null
|
|
1430
|
+
return React.createElement('div', {
|
|
1431
|
+
style: {
|
|
1432
|
+
fontSize: 11, marginTop: 2, lineHeight: 1.5, wordBreak: 'break-all',
|
|
1433
|
+
color: st.pending ? T.muted : st.ok ? T.ok : T.danger,
|
|
1434
|
+
},
|
|
1435
|
+
}, (st.pending ? '… ' : st.ok ? '✓ ' : '✗ ') + st.text)
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1270
1438
|
return React.createElement('div', { style: { padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 860 } },
|
|
1271
1439
|
React.createElement('div', { style: { fontSize: 16, fontWeight: 650, letterSpacing: '-0.01em' } }, tr('settings.title')),
|
|
1272
1440
|
React.createElement('div', { style: { fontSize: 12, opacity: 0.8, lineHeight: 1.55 } },
|
|
1273
1441
|
tr('settings.intro')),
|
|
1274
1442
|
React.createElement('div', { style: boxS },
|
|
1275
|
-
React.createElement('div', { style: {
|
|
1443
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' } },
|
|
1444
|
+
React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, tr('settings.pickHost')),
|
|
1445
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, tr('settings.hostListSource')),
|
|
1446
|
+
React.createElement('button', {
|
|
1447
|
+
type: 'button', className: 'dsh-rw-btn',
|
|
1448
|
+
style: { ...buttonS, fontSize: 11, padding: '2px 8px', marginLeft: 'auto' },
|
|
1449
|
+
onClick: () => loadSshHosts(),
|
|
1450
|
+
title: tr('settings.hostListRefreshHint'),
|
|
1451
|
+
}, tr('settings.hostListRefresh'))),
|
|
1452
|
+
// A) ~/.ssh/config 里的主机:一行一个「连接」(点一下 = 采用 + 设为当前 + 探测)
|
|
1453
|
+
sshHosts === null
|
|
1454
|
+
? React.createElement('div', { style: { fontSize: 12, opacity: 0.6 } }, tr('settings.hostListLoading'))
|
|
1455
|
+
: (discovered.length
|
|
1456
|
+
? discovered.map((h) => {
|
|
1457
|
+
const alias = String(h.alias || h.host)
|
|
1458
|
+
const key = 'ssh:' + alias
|
|
1459
|
+
const target = (h.user ? h.user + '@' : '') + (h.hostName || alias) + (Number(h.port) && Number(h.port) !== 22 ? ':' + h.port : '')
|
|
1460
|
+
return React.createElement('div', { key, className: 'dsh-rw-row', style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 6, paddingBottom: 6, borderBottom: '1px solid ' + T.border, flexWrap: 'wrap' } },
|
|
1461
|
+
React.createElement('div', { style: { flex: 1, fontSize: 13, minWidth: 180 } },
|
|
1462
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
|
|
1463
|
+
React.createElement('span', { style: { fontWeight: 600 } }, alias),
|
|
1464
|
+
React.createElement('code', { style: { fontSize: 12, opacity: 0.75 } }, target),
|
|
1465
|
+
h.proxyJump ? pill('', tr('settings.badgeProxy')) : null,
|
|
1466
|
+
h.identityFile ? pill('', tr('settings.badgeKey')) : null),
|
|
1467
|
+
rowResult(key)),
|
|
1468
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
|
|
1469
|
+
React.createElement('button', {
|
|
1470
|
+
type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px' },
|
|
1471
|
+
onClick: () => applySshEntry(h),
|
|
1472
|
+
title: tr('settings.hostFillFormHint'),
|
|
1473
|
+
}, tr('settings.hostFillForm')),
|
|
1474
|
+
React.createElement('button', {
|
|
1475
|
+
type: 'button', className: 'dsh-rw-btn dsh-rw-primary',
|
|
1476
|
+
style: { ...primaryBtnS, padding: '4px 12px', whiteSpace: 'nowrap' },
|
|
1477
|
+
disabled: !!connectingKey,
|
|
1478
|
+
title: tr('settings.hostConnectHint'),
|
|
1479
|
+
onClick: () => connectHost({ key, alias, host: alias, port: Number(h.port) || 22, username: h.user || '', useSshConfig: true }),
|
|
1480
|
+
}, connectingKey === key ? tr('settings.hostConnecting') : tr('settings.hostConnect'))))
|
|
1481
|
+
})
|
|
1482
|
+
: (sshHosts.length
|
|
1483
|
+
? null
|
|
1484
|
+
: React.createElement('div', { style: { fontSize: 11, opacity: 0.65, lineHeight: 1.6, marginBottom: 6 } },
|
|
1485
|
+
tr('settings.hostListEmpty', { file: sshFile || '~/.ssh/config' })))),
|
|
1486
|
+
// B) 已保存的机器:连接 / 打开工作区 / 编辑 / 删除
|
|
1276
1487
|
machines.length
|
|
1277
1488
|
? machines.map((m) => React.createElement('div', { key: m.id, className: 'dsh-rw-row', style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 8, paddingBottom: 8, borderBottom: '1px solid ' + T.border, flexWrap: 'wrap' } },
|
|
1278
1489
|
React.createElement('div', { style: { flex: 1, fontSize: 13, minWidth: 180 } },
|
|
@@ -1299,11 +1510,32 @@ window.__ModuleLoader__.load({
|
|
|
1299
1510
|
// the plugin had to trim) must be visible, not silent.
|
|
1300
1511
|
(m.sshConfigResolved && m.sshConfigResolved.warnings && m.sshConfigResolved.warnings.length)
|
|
1301
1512
|
? React.createElement('div', { style: { fontSize: 11, color: T.warn, marginTop: 2 } }, tr('settings.sshResolvedWarn', { msg: m.sshConfigResolved.warnings.join(' ') }))
|
|
1302
|
-
: null
|
|
1513
|
+
: null,
|
|
1514
|
+
rowResult('machine:' + m.id)),
|
|
1303
1515
|
React.createElement('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
|
|
1304
1516
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => startEdit(m) }, tr('settings.edit')),
|
|
1305
1517
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => del(m.id) }, tr('settings.delete')),
|
|
1306
|
-
React.createElement('button', {
|
|
1518
|
+
React.createElement('button', {
|
|
1519
|
+
type: 'button', className: 'dsh-rw-btn',
|
|
1520
|
+
style: { ...buttonS, padding: '6px 10px', whiteSpace: 'nowrap' },
|
|
1521
|
+
title: tr('settings.openWorkspaceHint'),
|
|
1522
|
+
onClick: () => setDirPick({ machineId: m.id }),
|
|
1523
|
+
}, tr('settings.openWorkspace')),
|
|
1524
|
+
React.createElement('button', {
|
|
1525
|
+
type: 'button', className: 'dsh-rw-btn dsh-rw-primary',
|
|
1526
|
+
style: { ...primaryBtnS, padding: '6px 12px', whiteSpace: 'nowrap' },
|
|
1527
|
+
disabled: !!connectingKey,
|
|
1528
|
+
title: tr('settings.hostConnectHint'),
|
|
1529
|
+
onClick: () => connectHost({
|
|
1530
|
+
key: 'machine:' + m.id,
|
|
1531
|
+
alias: m.host,
|
|
1532
|
+
machineId: m.id,
|
|
1533
|
+
host: m.host,
|
|
1534
|
+
port: Number(m.port) || 22,
|
|
1535
|
+
username: m.username || '',
|
|
1536
|
+
useSshConfig: !!m.useSshConfig,
|
|
1537
|
+
}),
|
|
1538
|
+
}, connectingKey === 'machine:' + m.id ? tr('settings.hostConnecting') : tr('settings.hostConnect')))))
|
|
1307
1539
|
: React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, tr('settings.noMachines')),
|
|
1308
1540
|
currentId
|
|
1309
1541
|
? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', paddingTop: 10, fontSize: 12, color: T.ok } },
|
|
@@ -1311,18 +1543,12 @@ window.__ModuleLoader__.load({
|
|
|
1311
1543
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, tr('settings.clearCurrent')))
|
|
1312
1544
|
: React.createElement('div', { style: { paddingTop: 10, fontSize: 12, opacity: 0.6 } }, tr('settings.noActive')),
|
|
1313
1545
|
),
|
|
1546
|
+
// Issue #73: the manual form is the SECONDARY path now — collapsed by
|
|
1547
|
+
// default, and only forced open while editing a saved machine. Everything
|
|
1548
|
+
// it can do (password / key / jump host / keychain / system-OpenSSH) stays.
|
|
1314
1549
|
React.createElement('div', { style: boxS },
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? tr('settings.sshCollapse') : tr('settings.sshImport'))),
|
|
1318
|
-
sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 200, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
|
|
1319
|
-
sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, className: 'dsh-rw-list-item', style: { padding: '6px 10px', fontSize: 12, borderBottom: '1px solid ' + T.border, display: 'flex', gap: 8, alignItems: 'center' } },
|
|
1320
|
-
React.createElement('div', { onClick: () => useSshAlias(e), style: { flex: 1, cursor: 'pointer' }, title: tr('settings.sshUseAlias') },
|
|
1321
|
-
e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')),
|
|
1322
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap' }, onClick: () => useSshAlias(e) }, tr('settings.sshUseAlias')),
|
|
1323
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap', opacity: 0.8 }, onClick: () => applySshEntry(e) }, tr('settings.sshCopyFields'))))
|
|
1324
|
-
: React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, tr('settings.sshNoEntries')))
|
|
1325
|
-
: null,
|
|
1550
|
+
sectionToggle(manualOpen || !!form.id, setManualOpen, form.id ? tr('settings.editMachine') : tr('settings.addMachineManual')),
|
|
1551
|
+
(manualOpen || form.id) ? React.createElement(React.Fragment, null,
|
|
1326
1552
|
row(tr('settings.name'), React.createElement('input', { className: 'dsh-rw-input', value: form.name, onChange: setF('name'), placeholder: tr('settings.name') + ' (e.g. build-server)', style: inputS }), 'n'),
|
|
1327
1553
|
row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: form.useSshConfig ? tr('settings.sshImport') + ' Host 别名' : 'IP or hostname', style: inputS }), 'h'),
|
|
1328
1554
|
row(tr('settings.useSshConfig'), React.createElement('input', { type: 'checkbox', checked: !!form.useSshConfig, onChange: setFb('useSshConfig') }), 'sshc'),
|
|
@@ -1344,7 +1570,31 @@ window.__ModuleLoader__.load({
|
|
|
1344
1570
|
})() : null,
|
|
1345
1571
|
row(tr('settings.port'), React.createElement('input', { className: 'dsh-rw-input', value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
|
|
1346
1572
|
row(tr('settings.user'), React.createElement('input', { className: 'dsh-rw-input', value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
|
|
1347
|
-
|
|
1573
|
+
// The password box is EMPTY by design (the host never echoes a secret
|
|
1574
|
+
// back) — but an empty box with a neutral placeholder reads as "this
|
|
1575
|
+
// machine has no password", which is exactly the wrong conclusion to
|
|
1576
|
+
// reach while debugging a connection. When a secret IS stored, the
|
|
1577
|
+
// placeholder shows dots and says what an empty box will do.
|
|
1578
|
+
row(tr('settings.password'), React.createElement('input', {
|
|
1579
|
+
className: 'dsh-rw-input',
|
|
1580
|
+
type: 'password',
|
|
1581
|
+
value: form.password,
|
|
1582
|
+
onChange: setF('password'),
|
|
1583
|
+
placeholder: form.passwordSet ? tr('settings.secretSavedPlaceholder') : tr('settings.passwordPlaceholder'),
|
|
1584
|
+
style: inputS,
|
|
1585
|
+
title: form.passwordSet ? tr('settings.secretSavedHint') : '',
|
|
1586
|
+
}), 'w'),
|
|
1587
|
+
form.passwordSet
|
|
1588
|
+
? React.createElement('div', { style: { marginLeft: LABEL_W + 8, marginBottom: 8, fontSize: 11, lineHeight: 1.5, color: T.ok } },
|
|
1589
|
+
tr('settings.secretSavedHint'))
|
|
1590
|
+
: null,
|
|
1591
|
+
// A machine with NO credential of any kind cannot connect, and the error
|
|
1592
|
+
// it produces ("缺少凭据") blames the user's configuration. Say it here,
|
|
1593
|
+
// while the form is open, instead of after a failed connect.
|
|
1594
|
+
(!form.passwordSet && !form.password && !form.privateKeyPath.trim() && !form.useAgent && !form.keyboardInteractive)
|
|
1595
|
+
? React.createElement('div', { style: { marginLeft: LABEL_W + 8, marginBottom: 8, fontSize: 11, lineHeight: 1.5, color: T.warn } },
|
|
1596
|
+
tr('settings.noCredentialHint'))
|
|
1597
|
+
: null,
|
|
1348
1598
|
// 高级配置折叠开关
|
|
1349
1599
|
React.createElement('button', {
|
|
1350
1600
|
type: 'button',
|
|
@@ -1371,7 +1621,10 @@ window.__ModuleLoader__.load({
|
|
|
1371
1621
|
? row(tr('settings.sshBinary'), React.createElement('input', { value: form.sshBinary || '', onChange: setF('sshBinary'), placeholder: tr('settings.sshBinaryPlaceholder'), style: inputS }), 'sb')
|
|
1372
1622
|
: null,
|
|
1373
1623
|
row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
|
|
1374
|
-
row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
|
|
1624
|
+
row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: form.passphraseSet ? tr('settings.secretSavedPlaceholder') : 'Encrypted private key passphrase', style: inputS, title: form.passphraseSet ? tr('settings.secretSavedHint') : '' }), 'pp'),
|
|
1625
|
+
form.passphraseSet
|
|
1626
|
+
? React.createElement('div', { style: { marginLeft: LABEL_W + 8, marginBottom: 8, fontSize: 11, lineHeight: 1.5, color: T.ok } }, tr('settings.passphraseSavedHint'))
|
|
1627
|
+
: null,
|
|
1375
1628
|
row(tr('settings.workspace'), React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: 'Optional: default remote dir for this machine', style: inputS }), 'ws'),
|
|
1376
1629
|
row(tr('settings.hostKeyMode'), React.createElement('select', { value: form.hostKeyMode, onChange: setF('hostKeyMode'), style: { ...inputS, maxWidth: 220 } },
|
|
1377
1630
|
React.createElement('option', { value: '' }, tr('settings.hkDefault')),
|
|
@@ -1389,7 +1642,7 @@ window.__ModuleLoader__.load({
|
|
|
1389
1642
|
row(tr('settings.proxyPortUser'), React.createElement('div', { style: { display: 'flex', gap: 6, flex: 1, flexWrap: 'wrap' } },
|
|
1390
1643
|
React.createElement('input', { value: form.proxyPort, onChange: setF('proxyPort'), placeholder: 'Port (22)', style: { ...inputS, minWidth: 90, maxWidth: 120 } }),
|
|
1391
1644
|
React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: 'Jump user', style: { ...inputS, minWidth: 140 } }),
|
|
1392
|
-
React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: 'Jump password', style: { ...inputS, minWidth: 140 } })), 'pu'),
|
|
1645
|
+
React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: form.proxyPasswordSet ? tr('settings.secretSavedPlaceholder') : 'Jump password', style: { ...inputS, minWidth: 140 }, title: form.proxyPasswordSet ? tr('settings.proxyPwdSavedHint') : '' })), 'pu'),
|
|
1393
1646
|
row(tr('settings.proxyKey'), React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: 'Jump private key path (optional)', style: inputS }), 'pk'),
|
|
1394
1647
|
// Issue #54: where "远程 DSH 界面" gets its remote dsh. The README
|
|
1395
1648
|
// pointed at `webAttachCommand` and there was no field for it.
|
|
@@ -1400,10 +1653,11 @@ window.__ModuleLoader__.load({
|
|
|
1400
1653
|
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
1401
1654
|
form.id ? React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => save('delete') }, tr('settings.delete')) : null,
|
|
1402
1655
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => { clearForm(); setErr(''); setTst('idle'); setAdvOpen(false) } }, form.id ? tr('settings.cancelEdit') : tr('settings.clear')),
|
|
1403
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing' },
|
|
1656
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontFamily: 'monospace', whiteSpace: 'nowrap' }, onClick: testConn, disabled: busy || !form.host.trim() || tst === 'testing', title: form.host.trim() ? tr('settings.testHint') : tr('settings.testNeedsHost') },
|
|
1404
1657
|
tst === 'testing' ? tr('settings.testing') : tst === 'ok' ? tr('settings.testOk') : tr('settings.testConn')),
|
|
1405
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim() }, busy ? tr('settings.saving') : tr('settings.save')),
|
|
1658
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: () => save(form.id ? 'update' : 'add'), disabled: busy || !form.host.trim(), title: form.host.trim() ? tr('settings.saveHint') : tr('settings.saveNeedsHost') }, busy ? tr('settings.saving') : tr('settings.save')),
|
|
1406
1659
|
),
|
|
1660
|
+
) : null,
|
|
1407
1661
|
),
|
|
1408
1662
|
// 快捷切换工作区(该机器的最近工作区)
|
|
1409
1663
|
workspaces.length ? React.createElement('div', { style: boxS },
|
|
@@ -1563,7 +1817,11 @@ window.__ModuleLoader__.load({
|
|
|
1563
1817
|
// reason to run. The host action re-probes internally anyway, so it is
|
|
1564
1818
|
// safe to offer directly; clicking it is the explicit consent.
|
|
1565
1819
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
|
|
1566
|
-
|
|
1820
|
+
// A RUNNING task also disables the button: the comment below always
|
|
1821
|
+
// said a second click must not restart anything, but the prop only
|
|
1822
|
+
// checked the machine — so a double click re-POSTed the install.
|
|
1823
|
+
disabled: !webAttachMachine || !!deployBusy,
|
|
1824
|
+
title: webAttachMachine ? tr('settings.deployInstallHint') : tr('settings.deployNeedsMachine'),
|
|
1567
1825
|
onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
|
|
1568
1826
|
// While a task runs, the button is the RESUME affordance (disabled
|
|
1569
1827
|
// — the poll renders live state; a second click must not restart).
|
|
@@ -1605,7 +1863,7 @@ window.__ModuleLoader__.load({
|
|
|
1605
1863
|
React.createElement('input', { className: 'dsh-rw-input', value: fwdForm.targetPort, onChange: (e) => setFwdForm({ ...fwdForm, targetPort: e.target.value }), placeholder: tr('settings.fwdTargetPort'), style: { ...inputS, maxWidth: 90 } }),
|
|
1606
1864
|
React.createElement('label', { style: { fontSize: 12, display: 'flex', gap: 4, alignItems: 'center' } },
|
|
1607
1865
|
React.createElement('input', { type: 'checkbox', checked: fwdForm.autoStart, onChange: (e) => setFwdForm({ ...fwdForm, autoStart: e.target.checked }) }), tr('settings.fwdAuto')),
|
|
1608
|
-
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: fwdDefine, disabled: !fwdForm.listenPort }, tr('settings.fwdAdd'))),
|
|
1866
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: fwdDefine, disabled: !fwdForm.listenPort, title: fwdForm.listenPort ? tr('settings.fwdAddHint') : tr('settings.fwdNeedsPort') }, tr('settings.fwdAdd'))),
|
|
1609
1867
|
) : null,
|
|
1610
1868
|
),
|
|
1611
1869
|
// 审计日志
|
|
@@ -1659,6 +1917,29 @@ window.__ModuleLoader__.load({
|
|
|
1659
1917
|
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, tr('settings.feedback')),
|
|
1660
1918
|
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
|
|
1661
1919
|
),
|
|
1920
|
+
// Issue #73, step 2 of the VSCode-shaped flow: after 连接, pick the remote
|
|
1921
|
+
// folder. Reuses the SAME picker the sidebar uses (one implementation, so
|
|
1922
|
+
// the two entry points cannot drift), and committing it goes through
|
|
1923
|
+
// /dsh-remote/mirror — the route that actually creates the mirror.
|
|
1924
|
+
dirPick
|
|
1925
|
+
? React.createElement(DirPicker, {
|
|
1926
|
+
open: true,
|
|
1927
|
+
busy: false,
|
|
1928
|
+
onPicked: (picked) => {
|
|
1929
|
+
const target = dirPick.machineId
|
|
1930
|
+
setDirPick(null)
|
|
1931
|
+
if (!picked) return
|
|
1932
|
+
api('POST', '/dsh-remote/mirror', { path: picked, machineId: target })
|
|
1933
|
+
.then((r) => {
|
|
1934
|
+
setMsg(tr('settings.workspaceOpened', { path: picked }))
|
|
1935
|
+
refresh()
|
|
1936
|
+
if (r && r.localMirror) setWorkspaces((w) => (w.includes(picked) ? w : [picked, ...w]))
|
|
1937
|
+
})
|
|
1938
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
1939
|
+
},
|
|
1940
|
+
onCancel: () => setDirPick(null),
|
|
1941
|
+
})
|
|
1942
|
+
: null,
|
|
1662
1943
|
)
|
|
1663
1944
|
}
|
|
1664
1945
|
|
|
@@ -3730,4 +4011,4 @@ window.__ModuleLoader__.load({
|
|
|
3730
4011
|
exports.apply = apply
|
|
3731
4012
|
return module.exports
|
|
3732
4013
|
},
|
|
3733
|
-
})
|
|
4014
|
+
})
|