dsh-remote 0.8.67 → 0.8.71

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/lib/client.js CHANGED
@@ -47,7 +47,7 @@ window.__ModuleLoader__.load({
47
47
  const L = {
48
48
  zh: {
49
49
  'settings.title': '远程工作区(dsh-remote)',
50
- 'settings.intro': '下面直接列出 ~/.ssh/config 里的机器:点「连接」,再选一个远程目录就能开始。没有的机器可以手动添加(密码 / 私钥 / 跳板机)。',
50
+ 'settings.intro': '下面直接列出 ~/.ssh/config 里的机器:点「连接」,再选一个远程目录就能开始。要加机器,点「打开配置文件」编辑这个文件,改完点「刷新」。',
51
51
  'settings.noMachines': '还没有保存的机器 —— 上面列出的是 ~/.ssh/config 里的主机,点「连接」即可。',
52
52
  // ── issue #73:首屏就是 ssh 配置里的主机列表(像 VSCode Remote-SSH)──
53
53
  'settings.pickHost': '选择远程机器',
@@ -55,15 +55,17 @@ window.__ModuleLoader__.load({
55
55
  'settings.hostListLoading': '正在读取 ~/.ssh/config…',
56
56
  'settings.hostListRefresh': '刷新',
57
57
  'settings.hostListRefreshHint': '重新读取 ~/.ssh/config(改完文件点这里,不用重开设置页)',
58
- 'settings.hostListEmpty': '没在 {file} 里找到主机。配好 ssh(Host 段)后点「刷新」,或在下面手动添加一台。',
58
+ 'settings.hostListEmpty': '没在 {file} 里找到主机。写好 Host 段后点「刷新」。',
59
59
  'settings.hostListFailed': '读取失败:{msg}。这不是你的 ssh 配置有问题(文件根本没读到)——点「刷新」重试;一直失败说明插件版本与服务端不一致,重启 DSH 即可。',
60
60
  'settings.hostConnect': '连接',
61
61
  'settings.hostConnecting': '连接中…',
62
62
  'settings.hostConnectHint': '连上并设为当前机器(之后就能选远程目录)',
63
63
  'settings.hostConnected': '已连接 · {latency}',
64
64
  'settings.hostConnectedHint': '已连接,选择要打开的远程目录即可开始工作。',
65
- 'settings.hostFillForm': '填表',
66
- 'settings.hostFillFormHint': '需要密码或单独覆盖参数时,把它填进下方表单',
65
+ 'settings.openSshConfig': '打开配置文件',
66
+ 'settings.openSshConfigHint': '用本机编辑器打开 ~/.ssh/config。改完点「刷新」,新的 Host 会出现在列表里。',
67
+ 'settings.sshConfigOpened': '已打开:{file}',
68
+ 'settings.sshConfigOpenFailed': '无法自动打开({msg})——可以直接编辑这个文件:{file}',
67
69
  'settings.badgeKey': '私钥',
68
70
  'settings.openWorkspace': '选目录',
69
71
  'settings.openWorkspaceHint': '在这台机器上选一个远程目录作为工作区',
@@ -72,7 +74,6 @@ window.__ModuleLoader__.load({
72
74
  'settings.openWorkspaceHere': '打开',
73
75
  'settings.openWorkspaceOnHint': '在 {host} 上打开这个目录(会创建/复用本地镜像)',
74
76
  'settings.workspaceOpened': '已打开远程工作区:{path}',
75
- 'settings.addMachineManual': '手动添加(密码 / 私钥 / 跳板机)',
76
77
  // 列表的搜索、排序与连接状态(机器一多就要用)。
77
78
  'settings.hostFilter': '搜索主机(名字 / 地址 / 用户)',
78
79
  'settings.liveNow': '已连接',
@@ -87,7 +88,6 @@ window.__ModuleLoader__.load({
87
88
  'settings.currentMachine': '当前远程机:{name}',
88
89
  'settings.clearCurrent': '取消设为当前(active remote = none)',
89
90
  'settings.noActive': '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。',
90
- 'settings.addMachine': '添加机器',
91
91
  'settings.editMachine': '编辑机器',
92
92
  'settings.badgeSshConfig': 'ssh cfg',
93
93
  // Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
@@ -219,7 +219,6 @@ window.__ModuleLoader__.load({
219
219
  'settings.secretStoreFailed': '密码未能写入系统密钥库({msg}),已按 credentialFallback=plain 回退为明文保存在 machines.json。',
220
220
  'settings.secretStoreRefused': '密码未能写入系统密钥库({msg}),已放弃保存该密码(不会以明文落盘)。修复密钥库后重新保存,或把 credentialFallback 设为 plain 以接受明文回退。',
221
221
  'settings.cleared': '已取消当前远程机 — 本地会话不再自动使用远程上下文',
222
- 'settings.filledSsh': '已从 ~/.ssh/config 填入(仅引用私钥路径,不读取内容)',
223
222
  'settings.connOk': '连接成功',
224
223
  'settings.connOkWith': '连接成功({user}@{host},{latency}ms){shell}',
225
224
  'settings.connFail': '连接失败',
@@ -360,22 +359,24 @@ window.__ModuleLoader__.load({
360
359
  },
361
360
  en: {
362
361
  'settings.title': 'Remote workspace (dsh-remote)',
363
- '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).',
362
+ 'settings.intro': 'Your ~/.ssh/config hosts are listed below: click Connect, then pick a remote folder. To add a host, click Open config, edit the file, then Refresh.',
364
363
  'settings.noMachines': 'No saved machines yet — the list above comes from ~/.ssh/config; click 连接 to use one.',
365
364
  'settings.pickHost': 'Choose a remote machine',
366
365
  'settings.hostListSource': 'from ~/.ssh/config',
367
366
  'settings.hostListLoading': 'Reading ~/.ssh/config…',
368
367
  'settings.hostListRefresh': 'Refresh',
369
368
  'settings.hostListRefreshHint': 'Re-read ~/.ssh/config (no need to reopen this page)',
370
- 'settings.hostListEmpty': 'No hosts found in {file}. Add Host blocks to your ssh config and hit Refresh, or add a machine manually below.',
369
+ 'settings.hostListEmpty': 'No hosts found in {file}. Add Host blocks, then hit Refresh.',
371
370
  'settings.hostListFailed': 'Could not read it: {msg}. This is NOT a problem with your ssh config (the file was never read) — hit Refresh; if it keeps failing the plugin versions are out of sync, so restart DSH.',
372
371
  'settings.hostConnect': 'Connect',
373
372
  'settings.hostConnecting': 'Connecting…',
374
373
  'settings.hostConnectHint': 'Connect and make it the current machine (then pick a remote folder)',
375
374
  'settings.hostConnected': 'Connected · {latency}',
376
375
  'settings.hostConnectedHint': 'Connected — pick the remote folder to start working.',
377
- 'settings.hostFillForm': 'Fill form',
378
- 'settings.hostFillFormHint': 'Needs a password or per-machine overrides? Load it into the form below',
376
+ 'settings.openSshConfig': 'Open config',
377
+ 'settings.openSshConfigHint': 'Open ~/.ssh/config in your editor. Hit Refresh afterwards and the new Host shows up here.',
378
+ 'settings.sshConfigOpened': 'Opened: {file}',
379
+ 'settings.sshConfigOpenFailed': 'Could not open it automatically ({msg}) — edit this file yourself: {file}',
379
380
  'settings.badgeKey': 'key',
380
381
  'settings.openWorkspace': 'Folder',
381
382
  'settings.openWorkspaceHint': 'Pick a remote folder on this machine as the workspace',
@@ -384,7 +385,6 @@ window.__ModuleLoader__.load({
384
385
  'settings.openWorkspaceHere': 'Open',
385
386
  'settings.openWorkspaceOnHint': 'Open this folder on {host} (creates or reuses the local mirror)',
386
387
  'settings.workspaceOpened': 'Opened remote workspace: {path}',
387
- 'settings.addMachineManual': 'Add manually (password / key / jump host)',
388
388
  'settings.hostFilter': 'Search hosts (name / address / user)',
389
389
  'settings.liveNow': 'Connected',
390
390
  'settings.lastConn': 'Last connected {when}{ms}',
@@ -398,7 +398,6 @@ window.__ModuleLoader__.load({
398
398
  'settings.currentMachine': 'Current remote: {name}',
399
399
  'settings.clearCurrent': 'Clear current (active remote = none)',
400
400
  'settings.noActive': 'No active remote machine: saved machines are standby connections and never enter a session context automatically (local sessions are unaffected).',
401
- 'settings.addMachine': 'Add machine',
402
401
  'settings.editMachine': 'Edit machine',
403
402
  'settings.badgeSshConfig': 'ssh cfg',
404
403
  // Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
@@ -524,7 +523,6 @@ window.__ModuleLoader__.load({
524
523
  'settings.secretStoreFailed': 'The OS keychain refused the password ({msg}); credentialFallback=plain wrote it to machines.json in plaintext.',
525
524
  '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.',
526
525
  'settings.cleared': 'Cleared current remote — local sessions no longer use remote context automatically',
527
- 'settings.filledSsh': 'Filled from ~/.ssh/config (only references the private key path, never reads its content)',
528
526
  'settings.connOk': 'Connection successful',
529
527
  'settings.connOkWith': 'Connection successful ({user}@{host}, {latency}ms){shell}',
530
528
  'settings.connFail': 'Connection failed',
@@ -1018,7 +1016,6 @@ window.__ModuleLoader__.load({
1018
1016
  const [sshError, setSshError] = React.useState('')
1019
1017
  const [connectingKey, setConnectingKey] = React.useState('')
1020
1018
  const [rowState, setRowState] = React.useState({}) // key → { ok, text }
1021
- const [manualOpen, setManualOpen] = React.useState(false)
1022
1019
  const [dirPick, setDirPick] = React.useState(null) // { machineId } while the picker is open
1023
1020
  const [hostFilter, setHostFilter] = React.useState('')
1024
1021
  // Which machine rows have their 最近工作区 list expanded (issue #73: host → folders).
@@ -1046,6 +1043,24 @@ window.__ModuleLoader__.load({
1046
1043
  }
1047
1044
  React.useEffect(() => { loadSshHosts() }, [])
1048
1045
 
1046
+ /** Open ~/.ssh/config in the local editor (issue #73). New hosts are Host
1047
+ * blocks in that file, not a form in this page. */
1048
+ const openSshConfig = () => {
1049
+ setErr('')
1050
+ setMsg('')
1051
+ return api('POST', '/dsh-remote/ssh-config', { action: 'open' })
1052
+ .then((r) => {
1053
+ const file = String((r && r.file) || sshFile || '~/.ssh/config')
1054
+ if (r && r.file) setSshFile(file)
1055
+ setMsg(tr('settings.sshConfigOpened', { file }))
1056
+ return loadSshHosts()
1057
+ })
1058
+ .catch((e) => setErr(tr('settings.sshConfigOpenFailed', {
1059
+ msg: String((e && e.message) || e),
1060
+ file: sshFile || '~/.ssh/config',
1061
+ })))
1062
+ }
1063
+
1049
1064
  const setRow = (key, next) => setRowState((prev) => ({ ...prev, [key]: next }))
1050
1065
 
1051
1066
  const testConn = () => {
@@ -1276,21 +1291,6 @@ window.__ModuleLoader__.load({
1276
1291
  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)) }
1277
1292
  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))) } }
1278
1293
 
1279
- // Issue #73: the manual form's escape hatch from the host list. A host that
1280
- // needs a password (or any per-machine override) is filled into the form
1281
- // instead of being adopted as an alias — one click, no jargon, and the form
1282
- // opens with the values already in place.
1283
- const applySshEntry = (e) => {
1284
- const alias = String(e.alias || e.host)
1285
- setForm({
1286
- ...form,
1287
- name: alias, host: e.hostName || alias, port: String(e.port || 22),
1288
- username: e.user || 'root', privateKeyPath: e.identityFile || '', useSshConfig: false,
1289
- })
1290
- setManualOpen(true)
1291
- setMsg(tr('settings.filledSsh'))
1292
- }
1293
-
1294
1294
  /**
1295
1295
  * Issue #73: connect to one row of the host list.
1296
1296
  *
@@ -1341,7 +1341,7 @@ window.__ModuleLoader__.load({
1341
1341
  */
1342
1342
  const openWorkspaceOn = (machineId, path) => {
1343
1343
  setBusy(true); setErr(''); setMsg('')
1344
- api('POST', '/dsh-remote/mirror', { path, machineId })
1344
+ api('POST', '/dsh-remote/mirror', { path, machineId, registerWorkspace: false })
1345
1345
  .then((r) => {
1346
1346
  setCurrentId((r && r.currentId) || machineId)
1347
1347
  setMsg(tr('settings.workspaceOpened', { path }))
@@ -1443,9 +1443,18 @@ window.__ModuleLoader__.load({
1443
1443
  React.createElement('button', {
1444
1444
  type: 'button', className: 'dsh-rw-btn',
1445
1445
  style: { ...buttonS, fontSize: 11, padding: '2px 8px', marginLeft: 'auto' },
1446
+ onClick: () => openSshConfig(),
1447
+ title: tr('settings.openSshConfigHint'),
1448
+ }, tr('settings.openSshConfig')),
1449
+ React.createElement('button', {
1450
+ type: 'button', className: 'dsh-rw-btn',
1451
+ style: { ...buttonS, fontSize: 11, padding: '2px 8px' },
1446
1452
  onClick: () => loadSshHosts(),
1447
1453
  title: tr('settings.hostListRefreshHint'),
1448
1454
  }, tr('settings.hostListRefresh'))),
1455
+ sshFile
1456
+ ? React.createElement('div', { style: { fontSize: 11, opacity: 0.65, marginBottom: 6, fontFamily: 'monospace', wordBreak: 'break-all' } }, sshFile)
1457
+ : null,
1449
1458
  // One search box for BOTH halves of the list — with a dozen hosts, "scroll
1450
1459
  // until you find it" stops working.
1451
1460
  React.createElement('input', {
@@ -1472,11 +1481,6 @@ window.__ModuleLoader__.load({
1472
1481
  h.identityFile ? pill('', tr('settings.badgeKey')) : null),
1473
1482
  rowResult(key)),
1474
1483
  React.createElement('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
1475
- React.createElement('button', {
1476
- type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px' },
1477
- onClick: () => applySshEntry(h),
1478
- title: tr('settings.hostFillFormHint'),
1479
- }, tr('settings.hostFillForm')),
1480
1484
  React.createElement('button', {
1481
1485
  type: 'button', className: 'dsh-rw-btn dsh-rw-primary',
1482
1486
  style: { ...primaryBtnS, padding: '4px 12px', whiteSpace: 'nowrap' },
@@ -1576,12 +1580,13 @@ window.__ModuleLoader__.load({
1576
1580
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 8px', fontSize: 12, marginLeft: 'auto' }, onClick: clearCurrent }, tr('settings.clearCurrent')))
1577
1581
  : React.createElement('div', { style: { paddingTop: 10, fontSize: 12, opacity: 0.6 } }, tr('settings.noActive')),
1578
1582
  ),
1579
- // Issue #73: the manual form is the SECONDARY path now — collapsed by
1580
- // default, and only forced open while editing a saved machine. Everything
1581
- // it can do (password / key / jump host / keychain / system-OpenSSH) stays.
1582
- React.createElement('div', { style: boxS },
1583
- sectionToggle(manualOpen || !!form.id, setManualOpen, form.id ? tr('settings.editMachine') : tr('settings.addMachineManual')),
1584
- (manualOpen || form.id) ? React.createElement(React.Fragment, null,
1583
+ // Issue #73: there is no "add a machine" form. New hosts are Host blocks
1584
+ // in ~/.ssh/config (打开配置文件, then 刷新). This card exists only while
1585
+ // editing a saved machine — a password, or any override the file cannot
1586
+ // store. Clicking 编辑 on a row is what opens it.
1587
+ form.id ? React.createElement('div', { style: boxS },
1588
+ React.createElement('div', { style: { fontSize: 13, fontWeight: 600, marginBottom: 8 } }, tr('settings.editMachine')),
1589
+ React.createElement(React.Fragment, null,
1585
1590
  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'),
1586
1591
  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'),
1587
1592
  row(tr('settings.useSshConfig'), React.createElement('input', { type: 'checkbox', checked: !!form.useSshConfig, onChange: setFb('useSshConfig') }), 'sshc'),
@@ -1690,8 +1695,8 @@ window.__ModuleLoader__.load({
1690
1695
  tst === 'testing' ? tr('settings.testing') : tst === 'ok' ? tr('settings.testOk') : tr('settings.testConn')),
1691
1696
  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')),
1692
1697
  ),
1693
- ) : null,
1694
- ),
1698
+ ),
1699
+ ) : null,
1695
1700
  // 最近工作区现在挂在各自的机器行下面(issue #73:host → folders 的树),
1696
1701
  // 这个「只显示当前机器」的卡片就没有意义了。
1697
1702
  // 远程 DSH 界面(issue #46)
@@ -1868,6 +1873,8 @@ window.__ModuleLoader__.load({
1868
1873
  // 选目录 on a machine that is NOT current must browse THAT machine, not
1869
1874
  // whichever one happens to be current.
1870
1875
  initialMachineId: dirPick.machineId,
1876
+ // Settings picks a default folder; it must not create a workspace.
1877
+ registerWorkspace: false,
1871
1878
  onPicked: (localMirror, remotePath) => {
1872
1879
  const target = dirPick.machineId
1873
1880
  setDirPick(null)
@@ -1944,7 +1951,11 @@ window.__ModuleLoader__.load({
1944
1951
  // initialMachineId lets a caller open the picker FOR a specific machine. The
1945
1952
  // settings page needs it: 选目录 on a non-current machine must not silently
1946
1953
  // browse the current one.
1947
- const { open, busy, onPicked, onCancel, initialMachineId } = props
1954
+ // registerWorkspace: this picker is the CORE's "Add workspace" directory
1955
+ // flow, which adopts the returned mirror as a workspace right after we
1956
+ // commit. The plugin's own pickers (settings, Remote Files) only choose a
1957
+ // folder, so they pass false and never grow a stray workspace.
1958
+ const { open, busy, onPicked, onCancel, initialMachineId, registerWorkspace } = props
1948
1959
  const [tab, setTab] = React.useState('local')
1949
1960
  const [machines, setMachines] = React.useState([])
1950
1961
  const [machineId, setMachineId] = React.useState('')
@@ -2334,7 +2345,7 @@ window.__ModuleLoader__.load({
2334
2345
  // looking at this one's tree. The host re-asserts the named machine
2335
2346
  // instead of trusting whichever happens to be active.
2336
2347
  ensureCurrent(machineId)
2337
- .then(() => api('POST', '/dsh-remote/mirror', { path: target, machineId }))
2348
+ .then(() => api('POST', '/dsh-remote/mirror', { path: target, machineId, registerWorkspace: registerWorkspace !== false }))
2338
2349
  // `onPicked(localMirror, remotePath)`: the mirror is ALREADY created here.
2339
2350
  // The sidebar opens the local mirror (1st arg); a caller that only wants to
2340
2351
  // report/continue from the remote path uses the 2nd. Handing back only the
@@ -3406,7 +3417,7 @@ window.__ModuleLoader__.load({
3406
3417
  ? React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px', wordBreak: 'break-all' } }, tr('explorer.remoteWs', { path: root, host: (status.host ? tr('explorer.hostTag', { user: status.username, host: status.host }) : '') }))
3407
3418
  : React.createElement('div', { style: { fontSize: 11, opacity: 0.7, padding: '4px 8px' } }, root ? tr('explorer.noRemoteWs') : tr('explorer.sessionLocal')),
3408
3419
  picker ? React.createElement('div', { style: { padding: 8 } }, React.createElement(DirPicker, {
3409
- open: true, busy: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
3420
+ open: true, busy: false, registerWorkspace: false, onPicked: () => { setPicker(false); refreshStatus() }, onCancel: () => setPicker(false),
3410
3421
  })) : null,
3411
3422
  React.createElement('div', { style: { flex: 1, overflowY: 'auto', minHeight: 0, padding: '2px 0' } },
3412
3423
  !root
package/lib/exec-job.js CHANGED
@@ -111,6 +111,13 @@ export function startExecJob(registry, { pool, command, label, owner, jobDeadlin
111
111
  abort = ac
112
112
  const done = (async () => {
113
113
  let res
114
+ // Whether the backend actually streamed anything through the tap. A
115
+ // backend without an onData implementation (openssh before issue #80)
116
+ // leaves the ring EMPTY even though the command produced output — the
117
+ // only copy then lives in the return value below, which the callers of
118
+ // a job never see. Flush that copy into the ring at settle time, but
119
+ // ONLY when nothing streamed, or every line would arrive twice.
120
+ let sawStream = false
114
121
  try {
115
122
  res = await pool.exec(command, {
116
123
  // 0/undefined ⇒ no transport deadline: the job outlives the call.
@@ -124,12 +131,19 @@ export function startExecJob(registry, { pool, command, label, owner, jobDeadlin
124
131
  onData: (channel, chunk) => {
125
132
  const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
126
133
  if (!text) return
134
+ sawStream = true
127
135
  try { job.append(text, { channel }) } catch { /* settled: ring closed */ }
128
136
  },
129
137
  })
130
138
  } catch (err) {
131
139
  return { status: 'failed', detail: String((err && err.message) || err) }
132
140
  }
141
+ if (!sawStream) {
142
+ try {
143
+ if (res.stdout) job.append(String(res.stdout), { channel: 'stdout' })
144
+ if (res.stderr) job.append(String(res.stderr), { channel: 'stderr' })
145
+ } catch { /* settled: ring closed */ }
146
+ }
133
147
  if (res.signal === 'ABORTED' || cancelled) return { status: 'killed', detail: cancelled || 'cancelled' }
134
148
  if (res.signal === 'TIMEOUT') return { status: 'killed', detail: `timed out after ${jobDeadlineMs}ms` }
135
149
  const code = res.code