dsh-remote 0.8.42 → 0.8.45
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 +38 -3
- package/README.md +63 -2
- package/lib/client.js +540 -10
- package/lib/forwards.js +45 -6
- package/lib/index.js +247 -31
- package/lib/openssh.js +838 -0
- package/lib/pool.js +177 -57
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/lib/web-attach.js +384 -17
- package/lib/web-deploy.js +43 -1
- package/package.json +2 -1
package/lib/client.js
CHANGED
|
@@ -49,6 +49,22 @@ window.__ModuleLoader__.load({
|
|
|
49
49
|
'settings.addMachine': '添加机器',
|
|
50
50
|
'settings.editMachine': '编辑机器',
|
|
51
51
|
'settings.badgeSshConfig': 'ssh cfg',
|
|
52
|
+
// Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
|
|
53
|
+
'settings.badgeOpenssh': 'openssh',
|
|
54
|
+
'settings.backend': '连接后端',
|
|
55
|
+
'settings.backendNative': 'native(内置 ssh2:密码 / 私钥 / agent / 动态码)',
|
|
56
|
+
'settings.backendOpenssh': 'openssh(调用本机 ssh 命令)',
|
|
57
|
+
'settings.backendOpensshHint': '调用本机 ssh,因此完全遵循 ~/.ssh/config:支持 GSSAPI/Kerberos(kinit 后免密登录)、ProxyCommand、PKCS11Provider、Match 等 OpenSSH 专有特性;主机密钥由 ssh 用你自己的 known_hosts 校验。代价是每条命令都要新建一次连接(比 native 慢),文件浏览(SFTP)仍保持长连接。',
|
|
58
|
+
'settings.backendOpensshAliasOk': '已勾选「使用 ssh_config 别名」:主机名原样交给 ssh,端口/用户/私钥/跳板机一律由 ~/.ssh/config 的 Host 块决定,对应的输入框在 openssh 后端下填了也不生效(这就是「配置唯一来源」,issue #50)。密码/Passphrase 仍会通过 askpass 交给 ssh,仅当 Host 块里没有可用认证时才用得上。',
|
|
59
|
+
'settings.sshBinary': 'ssh 路径',
|
|
60
|
+
'settings.sshBinaryPlaceholder': '留空 = 用 PATH 里的 ssh',
|
|
61
|
+
// Issue #54: the README told users to point `webAttachCommand` at a
|
|
62
|
+
// private-prefix dsh, but the settings UI offered nowhere to type it —
|
|
63
|
+
// it could only be written by the deploy flow or by hand-editing
|
|
64
|
+
// machines.json. It is an ordinary per-machine field now.
|
|
65
|
+
'settings.webAttachCmd': '远端 dsh 命令',
|
|
66
|
+
'settings.webAttachCmdPlaceholder': '留空 = 用全局 webAttachCommand(默认 dsh)',
|
|
67
|
+
'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。',
|
|
52
68
|
'settings.sshImport': '从 ~/.ssh/config 导入',
|
|
53
69
|
'settings.sshCollapse': '收起',
|
|
54
70
|
'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
|
|
@@ -257,6 +273,9 @@ window.__ModuleLoader__.load({
|
|
|
257
273
|
'explorer.noRemoteWs': '未设置远程工作区',
|
|
258
274
|
'explorer.sessionLocal': '当前会话未使用远程工作区',
|
|
259
275
|
'explorer.localSession': '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。',
|
|
276
|
+
// issue #49:会话列表里标识「这是远程会话,连的是哪台机」
|
|
277
|
+
'session.remoteBadge': '远程会话:{target}',
|
|
278
|
+
'session.remoteHover': '远程主机:{target}',
|
|
260
279
|
'explorer.loading': '加载中…',
|
|
261
280
|
'explorer.dirMissing': '目录不存在或已被删除',
|
|
262
281
|
'explorer.notConnected': '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)',
|
|
@@ -296,6 +315,10 @@ window.__ModuleLoader__.load({
|
|
|
296
315
|
'file.downloadedAt': ': {path}',
|
|
297
316
|
'file.readFail': '远程读取失败: {msg}',
|
|
298
317
|
'file.listFail': '远程目录读取失败: {msg}',
|
|
318
|
+
// Issue #55: a file link in a remote Session cannot be mapped onto the
|
|
319
|
+
// remote host (no mirror for the Session's cwd), so say so instead of
|
|
320
|
+
// rendering an empty editor.
|
|
321
|
+
'file.linkUnresolved': '这个文件链接无法对应到远端路径(该会话的工作区不是一个远程镜像目录)。请用侧栏「远程文件」打开,或用 rw_read_file 读取。',
|
|
299
322
|
'common.refresh': '刷新',
|
|
300
323
|
'common.close': '关闭',
|
|
301
324
|
'common.confirm': '确认',
|
|
@@ -315,6 +338,20 @@ window.__ModuleLoader__.load({
|
|
|
315
338
|
'settings.addMachine': 'Add machine',
|
|
316
339
|
'settings.editMachine': 'Edit machine',
|
|
317
340
|
'settings.badgeSshConfig': 'ssh cfg',
|
|
341
|
+
// Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
|
|
342
|
+
'settings.badgeOpenssh': 'openssh',
|
|
343
|
+
'settings.backend': 'Backend',
|
|
344
|
+
'settings.backendNative': 'native (built-in ssh2: password / key / agent / OTP)',
|
|
345
|
+
'settings.backendOpenssh': 'openssh (drive the local ssh command)',
|
|
346
|
+
'settings.backendOpensshHint': 'Delegates the transport to the local `ssh`, so the whole of ~/.ssh/config applies: GSSAPI/Kerberos (log in once with kinit and no secret is needed), ProxyCommand, PKCS11Provider, Match blocks… Host keys are checked by ssh against your own known_hosts. The cost is a fresh connection per command (slower than native); file browsing over SFTP still reuses one session.',
|
|
347
|
+
'settings.backendOpensshAliasOk': '"Use a ~/.ssh/config alias" is on: the name is handed to ssh verbatim, and Port/User/IdentityFile/ProxyJump come from that Host block — the matching fields below have no effect (this is the "one source of truth" case, issue #50). A password/passphrase is still offered to ssh through askpass, and is only used if the Host block has no usable authentication.',
|
|
348
|
+
'settings.sshBinary': 'ssh path',
|
|
349
|
+
'settings.sshBinaryPlaceholder': 'Empty = the ssh on PATH',
|
|
350
|
+
// Issue #54: the README documented `webAttachCommand` but the settings
|
|
351
|
+
// UI had no field for it.
|
|
352
|
+
'settings.webAttachCmd': 'Remote dsh command',
|
|
353
|
+
'settings.webAttachCmdPlaceholder': 'Empty = the global webAttachCommand (default: dsh)',
|
|
354
|
+
'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.',
|
|
318
355
|
'settings.sshImport': 'Import from ~/.ssh/config',
|
|
319
356
|
'settings.sshCollapse': 'Collapse',
|
|
320
357
|
'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
|
|
@@ -523,6 +560,9 @@ window.__ModuleLoader__.load({
|
|
|
523
560
|
'explorer.noRemoteWs': 'No remote workspace set',
|
|
524
561
|
'explorer.sessionLocal': 'This session does not use a remote workspace',
|
|
525
562
|
'explorer.localSession': 'This session is a local workspace and does not use remote files.\n\nTo access remote, set a machine as "current" in Settings, then pick a remote workspace with the "…" button above.',
|
|
563
|
+
// issue #49: tell a remote Session apart in the Session list, and name its host.
|
|
564
|
+
'session.remoteBadge': 'Remote session: {target}',
|
|
565
|
+
'session.remoteHover': 'Remote host: {target}',
|
|
526
566
|
'explorer.loading': 'Loading…',
|
|
527
567
|
'explorer.dirMissing': 'Directory does not exist or was deleted',
|
|
528
568
|
'explorer.notConnected': 'Not connected to the remote host (after a restart, set a machine as "current" in Settings, or refresh the right column)',
|
|
@@ -562,6 +602,7 @@ window.__ModuleLoader__.load({
|
|
|
562
602
|
'file.downloadedAt': ': {path}',
|
|
563
603
|
'file.readFail': 'Failed to read remote: {msg}',
|
|
564
604
|
'file.listFail': 'Failed to list remote directory: {msg}',
|
|
605
|
+
'file.linkUnresolved': 'This file link does not map onto a remote path (the Session workspace is not a remote mirror directory). Open it from the sidebar "Remote Files" tab, or read it with rw_read_file.',
|
|
565
606
|
'common.refresh': 'Refresh',
|
|
566
607
|
'common.close': 'Close',
|
|
567
608
|
'common.confirm': 'Confirm',
|
|
@@ -763,8 +804,40 @@ window.__ModuleLoader__.load({
|
|
|
763
804
|
document.head.appendChild(el)
|
|
764
805
|
} catch { /* vm tests / no DOM */ }
|
|
765
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* Issue #52 — a backdrop may close its dialog only when the PRESS began on
|
|
809
|
+
* the backdrop itself.
|
|
810
|
+
*
|
|
811
|
+
* `click` is dispatched on the nearest common ancestor of the mousedown and
|
|
812
|
+
* mouseup targets. A gesture that pressed an element INSIDE the panel and
|
|
813
|
+
* released on the backdrop — because the panel re-laid-out mid-gesture (the
|
|
814
|
+
* remote autocomplete list used to sit in document flow, so expanding it
|
|
815
|
+
* pushed a centred panel's edges out from under the pointer) — is therefore
|
|
816
|
+
* reported as a click ON THE BACKDROP, and a bare `onClick` closed the whole
|
|
817
|
+
* dialog. Selecting a suggestion kicked the user out of the picker.
|
|
818
|
+
*
|
|
819
|
+
* Guarding on the press origin keeps "click outside to dismiss" while a
|
|
820
|
+
* drag that merely ENDS outside can no longer dismiss anything. The panel's
|
|
821
|
+
* own `stopPropagation` stays as a second line of defence.
|
|
822
|
+
*
|
|
823
|
+
* @returns {{onMouseDown: Function, onClick: (e: object) => boolean}} props to
|
|
824
|
+
* spread on the backdrop; `onClick` reports whether the press started there.
|
|
825
|
+
*/
|
|
826
|
+
function useBackdropGuard() {
|
|
827
|
+
const pressedOnBackdrop = React.useRef(false)
|
|
828
|
+
return {
|
|
829
|
+
onMouseDown: (e) => { pressedOnBackdrop.current = e.target === e.currentTarget },
|
|
830
|
+
onClick: (e) => {
|
|
831
|
+
const startedOnBackdrop = pressedOnBackdrop.current
|
|
832
|
+
pressedOnBackdrop.current = false
|
|
833
|
+
return startedOnBackdrop && e.target === e.currentTarget
|
|
834
|
+
},
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
766
838
|
function PromptDialog(props) {
|
|
767
839
|
const { title, initial, placeholder, onSubmit, onCancel } = props
|
|
840
|
+
const backdrop = useBackdropGuard()
|
|
768
841
|
const [val, setVal] = React.useState(initial || '')
|
|
769
842
|
React.useEffect(() => {
|
|
770
843
|
try {
|
|
@@ -779,7 +852,10 @@ window.__ModuleLoader__.load({
|
|
|
779
852
|
}, [val, onSubmit, onCancel])
|
|
780
853
|
return React.createElement('div', {
|
|
781
854
|
style: { position: 'fixed', inset: 0, zIndex: 1000000, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
782
|
-
|
|
855
|
+
onMouseDown: backdrop.onMouseDown,
|
|
856
|
+
// Issue #52: only a press that STARTED here may dismiss (a gesture that
|
|
857
|
+
// began inside the panel and ended on the backdrop must not).
|
|
858
|
+
onClick: (e) => { if (backdrop.onClick(e)) onCancel() },
|
|
783
859
|
},
|
|
784
860
|
React.createElement('div', {
|
|
785
861
|
className: 'dsh-rw-modal-panel',
|
|
@@ -890,6 +966,9 @@ window.__ModuleLoader__.load({
|
|
|
890
966
|
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
891
967
|
password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
892
968
|
useSshConfig: !!form.useSshConfig,
|
|
969
|
+
// Issue #51: the probe must exercise the SAME backend the machine will
|
|
970
|
+
// use — a host reachable only via GSSAPI fails the native probe.
|
|
971
|
+
backend: form.backend || 'native',
|
|
893
972
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
894
973
|
})
|
|
895
974
|
.then((r) => {
|
|
@@ -1075,15 +1154,19 @@ window.__ModuleLoader__.load({
|
|
|
1075
1154
|
encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
|
|
1076
1155
|
proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
|
|
1077
1156
|
proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
|
|
1157
|
+
// Issue #51: the transport backend is per machine.
|
|
1158
|
+
backend: m.backend === 'openssh' ? 'openssh' : 'native', sshBinary: m.sshBinary || '',
|
|
1159
|
+
// Issue #54: the per-machine remote-dsh command for Web UI attach.
|
|
1160
|
+
webAttachCommand: m.webAttachCommand || '',
|
|
1078
1161
|
id: m.id,
|
|
1079
1162
|
useSshConfig: !!m.useSshConfig,
|
|
1080
1163
|
})
|
|
1081
|
-
// 编辑时若该机器用了高级配置(私钥/agent
|
|
1082
|
-
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1164
|
+
// 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串/系统 OpenSSH 后端),自动展开折叠区
|
|
1165
|
+
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || m.webAttachCommand || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1083
1166
|
setAdvOpen(true)
|
|
1084
1167
|
}
|
|
1085
1168
|
}
|
|
1086
|
-
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: '', id: '', useSshConfig: false })
|
|
1169
|
+
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 })
|
|
1087
1170
|
const save = (action) => {
|
|
1088
1171
|
setBusy(true); setErr(''); setMsg('')
|
|
1089
1172
|
if (action === 'delete') {
|
|
@@ -1095,6 +1178,10 @@ window.__ModuleLoader__.load({
|
|
|
1095
1178
|
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
1096
1179
|
workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
|
|
1097
1180
|
encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
|
|
1181
|
+
// Issue #51: the transport backend (and, for `openssh`, the executable).
|
|
1182
|
+
backend: form.backend || 'native', sshBinary: form.sshBinary || '',
|
|
1183
|
+
// Issue #54: the per-machine remote-dsh command for Web UI attach.
|
|
1184
|
+
webAttachCommand: form.webAttachCommand || '',
|
|
1098
1185
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
1099
1186
|
}).then((r) => {
|
|
1100
1187
|
refresh(); clearForm()
|
|
@@ -1185,6 +1272,10 @@ window.__ModuleLoader__.load({
|
|
|
1185
1272
|
m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
|
|
1186
1273
|
: (m.username + '@' + m.host + ':' + m.port)),
|
|
1187
1274
|
m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
|
|
1275
|
+
// Issue #51: a machine on the system-OpenSSH backend behaves
|
|
1276
|
+
// differently enough (auth from ~/.ssh/config, GSSAPI) that
|
|
1277
|
+
// the row must say so.
|
|
1278
|
+
m.backend === 'openssh' ? pill('', tr('settings.badgeOpenssh')) : null,
|
|
1188
1279
|
m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
|
|
1189
1280
|
m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
|
|
1190
1281
|
(m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
|
|
@@ -1246,6 +1337,23 @@ window.__ModuleLoader__.load({
|
|
|
1246
1337
|
style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
|
|
1247
1338
|
}, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
|
|
1248
1339
|
advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
|
|
1340
|
+
// Issue #51 — transport backend. Placed FIRST in the advanced block
|
|
1341
|
+
// because it changes what the fields below even mean: with the
|
|
1342
|
+
// system-OpenSSH backend, host/user/port/key all come from
|
|
1343
|
+
// ~/.ssh/config and only the machine name (an alias) matters.
|
|
1344
|
+
row(tr('settings.backend'), React.createElement('select', { value: form.backend || 'native', onChange: setF('backend'), style: { ...inputS, maxWidth: 260 } },
|
|
1345
|
+
React.createElement('option', { value: 'native' }, tr('settings.backendNative')),
|
|
1346
|
+
React.createElement('option', { value: 'openssh' }, tr('settings.backendOpenssh'))), 'be'),
|
|
1347
|
+
(form.backend === 'openssh')
|
|
1348
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.85, marginBottom: 8, marginLeft: LABEL_W + 8, lineHeight: 1.55 } },
|
|
1349
|
+
React.createElement('div', null, tr('settings.backendOpensshHint')),
|
|
1350
|
+
form.useSshConfig
|
|
1351
|
+
? React.createElement('div', { style: { color: T.ok, marginTop: 2 } }, tr('settings.backendOpensshAliasOk'))
|
|
1352
|
+
: null)
|
|
1353
|
+
: null,
|
|
1354
|
+
(form.backend === 'openssh')
|
|
1355
|
+
? row(tr('settings.sshBinary'), React.createElement('input', { value: form.sshBinary || '', onChange: setF('sshBinary'), placeholder: tr('settings.sshBinaryPlaceholder'), style: inputS }), 'sb')
|
|
1356
|
+
: null,
|
|
1249
1357
|
row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
|
|
1250
1358
|
row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
|
|
1251
1359
|
row(tr('settings.workspace'), React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: 'Optional: default remote dir for this machine', style: inputS }), 'ws'),
|
|
@@ -1267,6 +1375,10 @@ window.__ModuleLoader__.load({
|
|
|
1267
1375
|
React.createElement('input', { value: form.proxyUser, onChange: setF('proxyUser'), placeholder: 'Jump user', style: { ...inputS, minWidth: 140 } }),
|
|
1268
1376
|
React.createElement('input', { type: 'password', value: form.proxyPassword, onChange: setF('proxyPassword'), placeholder: 'Jump password', style: { ...inputS, minWidth: 140 } })), 'pu'),
|
|
1269
1377
|
row(tr('settings.proxyKey'), React.createElement('input', { value: form.proxyKey, onChange: setF('proxyKey'), placeholder: 'Jump private key path (optional)', style: inputS }), 'pk'),
|
|
1378
|
+
// Issue #54: where "远程 DSH 界面" gets its remote dsh. The README
|
|
1379
|
+
// pointed at `webAttachCommand` and there was no field for it.
|
|
1380
|
+
row(tr('settings.webAttachCmd'), React.createElement('input', { value: form.webAttachCommand || '', onChange: setF('webAttachCommand'), placeholder: tr('settings.webAttachCmdPlaceholder'), style: inputS }), 'wac'),
|
|
1381
|
+
React.createElement('div', { style: { paddingLeft: LABEL_W + 8, marginBottom: 8, fontSize: 11, lineHeight: 1.5, opacity: 0.7 } }, tr('settings.webAttachCmdHint')),
|
|
1270
1382
|
) : null,
|
|
1271
1383
|
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', flexWrap: 'wrap', marginTop: 8 } },
|
|
1272
1384
|
msg ? React.createElement('span', { style: { color: T.ok, fontSize: 12, marginRight: 'auto' } }, msg) : null,
|
|
@@ -1608,6 +1720,13 @@ window.__ModuleLoader__.load({
|
|
|
1608
1720
|
const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
|
|
1609
1721
|
const suggestTimer = React.useRef(null)
|
|
1610
1722
|
const suggestRef = React.useRef(null)
|
|
1723
|
+
// Issue #52 defect 1: the modal backdrop must dismiss only when the press
|
|
1724
|
+
// STARTED on it (a suggestion click whose mouseup landed on the backdrop
|
|
1725
|
+
// used to close the whole picker). The local-browse popup below is its own
|
|
1726
|
+
// full-screen overlay, so it gets its own guard ref — sharing one ref
|
|
1727
|
+
// would make a press on either backdrop arm the other.
|
|
1728
|
+
const backdrop = useBackdropGuard()
|
|
1729
|
+
const localBackdrop = useBackdropGuard()
|
|
1611
1730
|
// Autocomplete performance (slow per-keystroke remote scans):
|
|
1612
1731
|
// - postedCurrentRef/currentPromiseRef: POST /current once per machine
|
|
1613
1732
|
// switch; concurrent callers share ONE in-flight promise and a failed
|
|
@@ -2037,7 +2156,8 @@ window.__ModuleLoader__.load({
|
|
|
2037
2156
|
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
|
2038
2157
|
return React.createElement('div', {
|
|
2039
2158
|
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
|
2040
|
-
|
|
2159
|
+
onMouseDown: localBackdrop.onMouseDown,
|
|
2160
|
+
onClick: (e) => { if (localBackdrop.onClick(e)) setLocalPop(null) },
|
|
2041
2161
|
},
|
|
2042
2162
|
React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
|
|
2043
2163
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
|
|
@@ -2148,7 +2268,15 @@ window.__ModuleLoader__.load({
|
|
|
2148
2268
|
onClick: () => switchTab(t),
|
|
2149
2269
|
style: { ...buttonS, fontWeight: tab === t ? 700 : 400, padding: '5px 14px' },
|
|
2150
2270
|
}, lbl)
|
|
2151
|
-
return React.createElement('div', {
|
|
2271
|
+
return React.createElement('div', {
|
|
2272
|
+
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
2273
|
+
onMouseDown: backdrop.onMouseDown,
|
|
2274
|
+
// Issue #52 defect 1: this was a bare onClick, so picking an autocomplete
|
|
2275
|
+
// suggestion (mousedown inside the panel, panel re-laid-out, mouseup on
|
|
2276
|
+
// the backdrop) closed the whole dialog. Only a press that began on the
|
|
2277
|
+
// backdrop dismisses now.
|
|
2278
|
+
onClick: (e) => { if (backdrop.onClick(e) && !busy && !mkdirPrompt) onCancel() },
|
|
2279
|
+
},
|
|
2152
2280
|
React.createElement('div', { className: 'dsh-rw-modal-panel', style: { background: v('--dsw-alias-bg-layer-1', '#18181b'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 12, boxShadow: '0 12px 48px rgba(0,0,0,0.5)', width: 'min(600px, 94vw)', maxHeight: 'min(680px, 92vh)', display: 'flex', flexDirection: 'column', padding: 16, boxSizing: 'border-box' }, onClick: (e) => e.stopPropagation() },
|
|
2153
2281
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
|
|
2154
2282
|
React.createElement('div', { style: { fontSize: 15, fontWeight: 650 } }, tr('picker.title')),
|
|
@@ -2169,7 +2297,7 @@ window.__ModuleLoader__.load({
|
|
|
2169
2297
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
2170
2298
|
localPop ? renderLocalPopup() : null,
|
|
2171
2299
|
)
|
|
2172
|
-
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
2300
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: suggestOpen ? 'visible' : 'auto' } },
|
|
2173
2301
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
2174
2302
|
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
|
|
2175
2303
|
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
@@ -2209,14 +2337,29 @@ window.__ModuleLoader__.load({
|
|
|
2209
2337
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, tr('picker.home')),
|
|
2210
2338
|
),
|
|
2211
2339
|
(suggestOpen && suggest.length)
|
|
2212
|
-
|
|
2213
|
-
|
|
2340
|
+
// Issue #52 defect 2: this used to be an in-flow block, so
|
|
2341
|
+
// expanding it pushed the 「设为远程工作区」 button down and the
|
|
2342
|
+
// user could never hit a button that was moving. It is now an
|
|
2343
|
+
// absolutely-positioned overlay hanging off `suggestRef`
|
|
2344
|
+
// (position: relative), which also keeps the modal's height —
|
|
2345
|
+
// and therefore its centred edges — stable while open (that
|
|
2346
|
+
// stability is what makes the press-origin guard above exact).
|
|
2347
|
+
// `paddingBottom` lets the last entries scroll clear of the
|
|
2348
|
+
// button row, which is lifted over the dropdown by zIndex.
|
|
2349
|
+
? React.createElement('div', { style: { position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 60, marginTop: 6, paddingBottom: 36, background: v('--dsw-alias-bg-overlay', '#1e1e1e'), color: T.label, border: '1px solid ' + T.borderStrong, borderRadius: 8, maxHeight: 200, overflowY: 'auto', boxShadow: '0 6px 24px rgba(0,0,0,0.25)' } },
|
|
2350
|
+
// Issue #52 defect 1, local belt-and-braces: swallow the
|
|
2351
|
+
// gesture so no ancestor (least of all the backdrop) can
|
|
2352
|
+
// observe a click that began on a suggestion.
|
|
2353
|
+
suggest.map((s, i) => React.createElement('div', { key: s + i, className: 'dsh-rw-list-item', onMouseDown: (e) => { e.preventDefault(); e.stopPropagation(); selectSuggestion(s) }, style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, s)),
|
|
2214
2354
|
)
|
|
2215
2355
|
: null,
|
|
2216
2356
|
),
|
|
2217
2357
|
popOpen ? renderDirPopup() : null,
|
|
2218
2358
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
2219
|
-
|
|
2359
|
+
// The action row rides ABOVE the dropdown (zIndex 70) on its own
|
|
2360
|
+
// opaque layer, so the list slides out from behind it instead of
|
|
2361
|
+
// covering 「设为远程工作区」.
|
|
2362
|
+
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center', position: 'relative', zIndex: 70, background: v('--dsw-alias-bg-layer-1', '#18181b') } },
|
|
2220
2363
|
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? tr('picker.selected', { path }) : ''),
|
|
2221
2364
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: primaryBtnS, onClick: () => commitPath(path), disabled: busy || !machineId || !path.trim() }, busy ? tr('picker.mirroring') : tr('picker.setRemoteWs')),
|
|
2222
2365
|
),
|
|
@@ -2275,6 +2418,199 @@ window.__ModuleLoader__.load({
|
|
|
2275
2418
|
.catch(() => '')
|
|
2276
2419
|
}
|
|
2277
2420
|
|
|
2421
|
+
// ── which sessions are remote (issue #49) ────────────────────────────────
|
|
2422
|
+
//
|
|
2423
|
+
// The sidebar must say, at a glance, whether a Session row is a LOCAL
|
|
2424
|
+
// session or one working on a remote host — and which host. The truth is
|
|
2425
|
+
// already on the client: every Session summary carries its own cwd, and a
|
|
2426
|
+
// cwd that lives inside a `$DSH_HOME/remote-workspaces/<host-user-port>/…`
|
|
2427
|
+
// mirror IS a remote session bound to that mirror's recorded origin (the
|
|
2428
|
+
// same invariant lib/binding.js uses host-side; see issue #13).
|
|
2429
|
+
//
|
|
2430
|
+
// So one GET /dsh-remote/bindings (the whole mirror registry, with each
|
|
2431
|
+
// mirror's recorded host/port/user/path) answers every row, instead of one
|
|
2432
|
+
// request per row. The active machine is deliberately NOT consulted: a saved
|
|
2433
|
+
// machine that happens to be "current" must never label a local session.
|
|
2434
|
+
//
|
|
2435
|
+
// Failure is not fatal and not retried in a loop: no registry means no
|
|
2436
|
+
// badge, which is exactly what a user with zero mirrors sees.
|
|
2437
|
+
|
|
2438
|
+
const MIRRORS = { list: [], loaded: false, error: '' }
|
|
2439
|
+
const mirrorWatchers = new Set()
|
|
2440
|
+
let mirrorLoad = null
|
|
2441
|
+
let mirrorTimer = null
|
|
2442
|
+
|
|
2443
|
+
const notifyMirrors = () => {
|
|
2444
|
+
for (const fn of mirrorWatchers) { try { fn() } catch { /* one watcher must not stop the rest */ } }
|
|
2445
|
+
}
|
|
2446
|
+
/** Load the mirror registry once (concurrent callers share the request). */
|
|
2447
|
+
function ensureMirrors() {
|
|
2448
|
+
if (MIRRORS.loaded) return Promise.resolve(MIRRORS)
|
|
2449
|
+
if (mirrorLoad) return mirrorLoad
|
|
2450
|
+
mirrorLoad = api('GET', '/dsh-remote/bindings')
|
|
2451
|
+
.then((r) => {
|
|
2452
|
+
MIRRORS.list = (r && Array.isArray(r.mirrors)) ? r.mirrors : []
|
|
2453
|
+
MIRRORS.error = ''
|
|
2454
|
+
})
|
|
2455
|
+
.catch((e) => { MIRRORS.error = String((e && e.message) || e) })
|
|
2456
|
+
.then(() => {
|
|
2457
|
+
MIRRORS.loaded = true
|
|
2458
|
+
mirrorLoad = null
|
|
2459
|
+
notifyMirrors()
|
|
2460
|
+
return MIRRORS
|
|
2461
|
+
})
|
|
2462
|
+
return mirrorLoad
|
|
2463
|
+
}
|
|
2464
|
+
/** Force a refresh (mirrors appear when a workspace is picked). */
|
|
2465
|
+
function refreshMirrors() {
|
|
2466
|
+
MIRRORS.loaded = false
|
|
2467
|
+
return ensureMirrors()
|
|
2468
|
+
}
|
|
2469
|
+
/**
|
|
2470
|
+
* The mirror containing a local path, or null.
|
|
2471
|
+
*
|
|
2472
|
+
* Separator-aware on purpose: `/a/mirror-2` must NOT be treated as living
|
|
2473
|
+
* inside `/a/mirror` (the same trap lib/binding.js documents). The deepest
|
|
2474
|
+
* match wins, so a future nested layout stays stable. Windows paths compare
|
|
2475
|
+
* case-insensitively because the platform does.
|
|
2476
|
+
*/
|
|
2477
|
+
function mirrorForCwd(cwd, mirrors) {
|
|
2478
|
+
const raw = String(cwd || '')
|
|
2479
|
+
if (!raw) return null
|
|
2480
|
+
const list = Array.isArray(mirrors) ? mirrors : []
|
|
2481
|
+
const isWin = raw.includes('\\')
|
|
2482
|
+
const sep = isWin ? '\\' : '/'
|
|
2483
|
+
const fold = (s) => (isWin ? s.toLowerCase() : s)
|
|
2484
|
+
const base = fold(raw.replace(/[\\/]+$/, ''))
|
|
2485
|
+
let best = null
|
|
2486
|
+
for (const m of list) {
|
|
2487
|
+
const dirRaw = String((m && m.dir) || '').replace(/[\\/]+$/, '')
|
|
2488
|
+
if (!dirRaw) continue
|
|
2489
|
+
const dir = fold(dirRaw)
|
|
2490
|
+
if (base !== dir && !base.startsWith(dir + sep) && !base.startsWith(dir + '/')) continue
|
|
2491
|
+
if (!best || dir.length > fold(String(best.dir).replace(/[\\/]+$/, '')).length) best = m
|
|
2492
|
+
}
|
|
2493
|
+
return best
|
|
2494
|
+
}
|
|
2495
|
+
/** `user@host:port` for a mirror record (port omitted when it is 22). */
|
|
2496
|
+
function mirrorTarget(m) {
|
|
2497
|
+
if (!m) return ''
|
|
2498
|
+
const user = m.username ? m.username + '@' : ''
|
|
2499
|
+
const port = Number(m.port) && Number(m.port) !== 22 ? ':' + m.port : ''
|
|
2500
|
+
return user + String(m.host || '') + port
|
|
2501
|
+
}
|
|
2502
|
+
/** Host as the reader asked for it: the address, plus the saved machine name. */
|
|
2503
|
+
function mirrorLabel(m) {
|
|
2504
|
+
if (!m) return ''
|
|
2505
|
+
const target = mirrorTarget(m)
|
|
2506
|
+
return m.name && m.name !== m.host ? target + ' · ' + m.name : target
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* The remote binding of one Session row, read from that row's own cwd.
|
|
2510
|
+
*
|
|
2511
|
+
* A row with no cwd (a blank New Session) is not remote — a blank session
|
|
2512
|
+
* has no workspace yet, and must never inherit the badge from a neighbor.
|
|
2513
|
+
*
|
|
2514
|
+
* Hook shape is CONSTANT: when the framework does not hand the entry a
|
|
2515
|
+
* `useSessions` (older sidebar), a stable no-op hook is selected instead of
|
|
2516
|
+
* skipping the call, so the hook count cannot change across renders (a
|
|
2517
|
+
* conditional hook is a crash, not a degraded badge).
|
|
2518
|
+
*/
|
|
2519
|
+
const NO_SELECT = () => undefined
|
|
2520
|
+
function useSessionMirror(sessionId, useSessions) {
|
|
2521
|
+
// `mirrorVersion` is only a re-render trigger: the registry arrives
|
|
2522
|
+
// asynchronously, and every mounted mark must repaint when it does.
|
|
2523
|
+
const [, bumpMirrorVersion] = React.useState(0)
|
|
2524
|
+
const select = typeof useSessions === 'function' ? useSessions : NO_SELECT
|
|
2525
|
+
const cwd = select((s) => (sessionId && s && s.byId && s.byId[sessionId] ? s.byId[sessionId].cwd : undefined))
|
|
2526
|
+
React.useEffect(() => {
|
|
2527
|
+
const fn = () => bumpMirrorVersion((n) => n + 1)
|
|
2528
|
+
mirrorWatchers.add(fn)
|
|
2529
|
+
ensureMirrors()
|
|
2530
|
+
// Mirrors change when a remote workspace is picked in THIS tab; a slow
|
|
2531
|
+
// poll covers a pick made in another tab without a per-render request.
|
|
2532
|
+
if (!mirrorTimer && typeof setInterval === 'function') {
|
|
2533
|
+
mirrorTimer = setInterval(refreshMirrors, 60000)
|
|
2534
|
+
}
|
|
2535
|
+
return () => {
|
|
2536
|
+
mirrorWatchers.delete(fn)
|
|
2537
|
+
if (!mirrorWatchers.size && mirrorTimer) { clearInterval(mirrorTimer); mirrorTimer = null }
|
|
2538
|
+
}
|
|
2539
|
+
}, [])
|
|
2540
|
+
return mirrorForCwd(cwd, MIRRORS.list)
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// Leading seat of a Session row: the 16px cell before the title. A green dot
|
|
2544
|
+
// means "this session works on a remote host"; a local session renders
|
|
2545
|
+
// nothing at all, so the cell stays blank exactly as before. The owner only
|
|
2546
|
+
// mounts this seat while the row is idle (a running/pending row keeps its
|
|
2547
|
+
// own status dot there) — the hover card below therefore carries the host
|
|
2548
|
+
// for every row, running or not.
|
|
2549
|
+
function RemoteSessionMark(props) {
|
|
2550
|
+
const m = useSessionMirror(props.sessionId, props.useSessions)
|
|
2551
|
+
if (!m) return null
|
|
2552
|
+
const target = mirrorTarget(m)
|
|
2553
|
+
return React.createElement('span', {
|
|
2554
|
+
'data-dsh-remote-mark': '',
|
|
2555
|
+
role: 'img',
|
|
2556
|
+
'aria-label': tr('session.remoteBadge', { target }),
|
|
2557
|
+
title: tr('session.remoteBadge', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
|
|
2558
|
+
style: { width: 16, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
|
|
2559
|
+
}, React.createElement('span', {
|
|
2560
|
+
'aria-hidden': 'true',
|
|
2561
|
+
style: {
|
|
2562
|
+
width: 8, height: 8, borderRadius: '50%',
|
|
2563
|
+
background: 'var(--dsw-alias-state-success-primary, #4caf7d)',
|
|
2564
|
+
boxShadow: '0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary, #4caf7d) 22%, transparent)',
|
|
2565
|
+
},
|
|
2566
|
+
}))
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
// Hover-card section of a Session row, between its relative time and its
|
|
2570
|
+
// trailing status line: names the remote host (and the remote path it
|
|
2571
|
+
// works on) for a remote row. A local row renders nothing.
|
|
2572
|
+
function RemoteSessionHover(props) {
|
|
2573
|
+
const m = useSessionMirror(props.sessionId, props.useSessions)
|
|
2574
|
+
if (!m) return null
|
|
2575
|
+
return React.createElement('div', { 'data-dsh-remote-hover': '', style: { display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, lineHeight: '16px' } },
|
|
2576
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
2577
|
+
React.createElement('span', {
|
|
2578
|
+
'aria-hidden': 'true',
|
|
2579
|
+
style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
|
|
2580
|
+
}),
|
|
2581
|
+
React.createElement('span', { style: { color: '#cfd3d6', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, mirrorLabel(m)),
|
|
2582
|
+
),
|
|
2583
|
+
m.remotePath
|
|
2584
|
+
? React.createElement('div', { style: { color: '#adb2b8', wordBreak: 'break-all', paddingLeft: 13 } }, m.remotePath)
|
|
2585
|
+
: null,
|
|
2586
|
+
)
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// Conversation header chip for the CURRENT session: the one place the host
|
|
2590
|
+
// can stay visible without hovering anything (the row's 16px cell is too
|
|
2591
|
+
// narrow for an address). Renders nothing for a local session.
|
|
2592
|
+
//
|
|
2593
|
+
// This seat is session-scoped, so the owner hands the entry its `sessionId`
|
|
2594
|
+
// (exactly like the shipped "open in app" utility) and the chip needs
|
|
2595
|
+
// nothing from the sidebar.
|
|
2596
|
+
function RemoteSessionChip(props) {
|
|
2597
|
+
const m = useSessionMirror(props.sessionId, props.useSessions)
|
|
2598
|
+
if (!m) return null
|
|
2599
|
+
const target = mirrorTarget(m)
|
|
2600
|
+
return React.createElement('span', {
|
|
2601
|
+
'data-dsh-remote-chip': '',
|
|
2602
|
+
className: 'dsh-rw-pill is-ok',
|
|
2603
|
+
title: tr('session.remoteHover', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
|
|
2604
|
+
style: { maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-flex', alignItems: 'center', gap: 5 },
|
|
2605
|
+
},
|
|
2606
|
+
React.createElement('span', {
|
|
2607
|
+
'aria-hidden': 'true',
|
|
2608
|
+
style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
|
|
2609
|
+
}),
|
|
2610
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, target),
|
|
2611
|
+
)
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2278
2614
|
function readRemoteFile(path, maxBytes, sessionId) {
|
|
2279
2615
|
return api('POST', '/dsh-remote/read', withSessionBody({ path, maxBytes }, sessionId)).catch((e) => {
|
|
2280
2616
|
throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
|
|
@@ -3013,6 +3349,17 @@ window.__ModuleLoader__.load({
|
|
|
3013
3349
|
// paths are local filesystem resources. Each address is session-scoped.
|
|
3014
3350
|
const NATIVE_EXPLORER = 'dsh-remote/explorer'
|
|
3015
3351
|
const NATIVE_FILE = 'dsh-remote/file'
|
|
3352
|
+
// Issue #55: the address the CORE emits for a file link, which is NOT the
|
|
3353
|
+
// one this plugin mints for its own explorer. A model-emitted
|
|
3354
|
+
// `[x](src/main.c)` (or a `@src/main.c` chip) is turned into
|
|
3355
|
+
// `dsh-resource://file/session/<sessionId>/<path>` by the chat, and the only
|
|
3356
|
+
// shipped claimant is the built-in text preview, which reads it through the
|
|
3357
|
+
// HOST's ctx.fs. For a remote Session the header cwd is the LOCAL MIRROR, so
|
|
3358
|
+
// the link opens an empty (or stale) local directory and fails. A plugin
|
|
3359
|
+
// cannot re-register protocol `file` (one provider per protocol, and it is
|
|
3360
|
+
// taken), so the supported interception is a tab type that CLAIMS that
|
|
3361
|
+
// address and serves it over SFTP instead.
|
|
3362
|
+
const NATIVE_LINK = 'dsh-remote/remote-link'
|
|
3016
3363
|
function remoteFileAddress(sessionId, path) {
|
|
3017
3364
|
return 'dsh-resource://dsh-remote/' + encodeURIComponent(sessionId) + '/' + encodeURIComponent(path)
|
|
3018
3365
|
}
|
|
@@ -3021,6 +3368,74 @@ window.__ModuleLoader__.load({
|
|
|
3021
3368
|
if (url.protocol !== 'dsh-resource:' || url.hostname !== 'dsh-remote') throw new Error('Invalid remote resource')
|
|
3022
3369
|
return decodeURIComponent(url.pathname.split('/').slice(2).join('/'))
|
|
3023
3370
|
}
|
|
3371
|
+
/**
|
|
3372
|
+
* Parse the core's `dsh-resource://file/session/<id>/<path>` address.
|
|
3373
|
+
*
|
|
3374
|
+
* Mirrors the grammar the core uses (`dsh-util-workspace-path`): the first
|
|
3375
|
+
* segment after the scheme is the scope (`session`), then the id, then the
|
|
3376
|
+
* path. Anything else — including the `absolute/` scope — is not ours: an
|
|
3377
|
+
* absolute remote path cannot be mapped back onto a mirror, so refusing is
|
|
3378
|
+
* correct rather than guessing.
|
|
3379
|
+
*
|
|
3380
|
+
* @param {string} address - the address a link click produced.
|
|
3381
|
+
* @returns {{sessionId: string, path: string}|null} the parts, or null when
|
|
3382
|
+
* this is not a session-scoped core file address.
|
|
3383
|
+
*/
|
|
3384
|
+
function parseCoreFileAddress(address) {
|
|
3385
|
+
let url
|
|
3386
|
+
try { url = new URL(String(address || '')) } catch { return null }
|
|
3387
|
+
if (url.protocol !== 'dsh-resource:' || url.hostname !== 'file') return null
|
|
3388
|
+
const rest = url.pathname.replace(/^\/+/, '')
|
|
3389
|
+
const [scope, ...tail] = rest.split('/')
|
|
3390
|
+
if (scope !== 'session' || tail.length < 2) return null
|
|
3391
|
+
const sessionId = decodeURIComponent(tail[0])
|
|
3392
|
+
if (!sessionId) return null
|
|
3393
|
+
// Dropping the leading empty segment keeps a session-root address ('') a
|
|
3394
|
+
// legitimate empty path instead of turning it into '/'.
|
|
3395
|
+
return { sessionId, path: decodeURIComponent(tail.slice(1).join('/')) }
|
|
3396
|
+
}
|
|
3397
|
+
/**
|
|
3398
|
+
* The ABSOLUTE remote path a core file address refers to, or null.
|
|
3399
|
+
*
|
|
3400
|
+
* A relative link is mirror-rooted and an absolute one is already absolute;
|
|
3401
|
+
* either way what the reader must see is the path on the REMOTE host. The
|
|
3402
|
+
* session's own cwd (a mirror directory) identifies the mirror, whose record
|
|
3403
|
+
* carries the remote root the mirror was taken from — the same invariant
|
|
3404
|
+
* `lib/binding.js` uses host-side.
|
|
3405
|
+
*
|
|
3406
|
+
* Separator-aware on purpose (`mirrorForCwd` handles `/a/mirror` vs
|
|
3407
|
+
* `/a/mirror-2`): showing the WRONG remote file would be worse than the
|
|
3408
|
+
* current failure, so an ambiguous address returns null.
|
|
3409
|
+
*
|
|
3410
|
+
* @param {{sessionId: string, path: string}} parsed - a parsed core address.
|
|
3411
|
+
* @param {string} cwd - the session's local cwd (its mirror directory).
|
|
3412
|
+
* @returns {string} an absolute remote path, or null when it cannot be mapped.
|
|
3413
|
+
*/
|
|
3414
|
+
function remotePathForCoreAddress(parsed, cwd) {
|
|
3415
|
+
const mirror = mirrorForCwd(cwd, MIRRORS.list)
|
|
3416
|
+
if (!mirror) return null
|
|
3417
|
+
const remoteRoot = String(mirror.remotePath || '').replace(/[\\/]+$/, '')
|
|
3418
|
+
const raw = String(parsed.path || '')
|
|
3419
|
+
const isWin = raw.includes('\\') || remoteRoot.includes('\\')
|
|
3420
|
+
const sep = isWin ? '\\' : '/'
|
|
3421
|
+
const norm = (s) => String(s).replace(/[\\/]+/g, sep).replace(new RegExp('\\' + sep + '+$'), '')
|
|
3422
|
+
const rel = norm(raw)
|
|
3423
|
+
// The mirror's own directory prefix may be baked into the address when the
|
|
3424
|
+
// link was written against the local path; strip it so the result is
|
|
3425
|
+
// remote-relative rather than duplicated under the remote root.
|
|
3426
|
+
const mirrorDir = norm(mirror.dir || '')
|
|
3427
|
+
const relBody = rel && mirrorDir && (rel === mirrorDir || rel.startsWith(mirrorDir + sep))
|
|
3428
|
+
? rel.slice(mirrorDir.length).replace(new RegExp('^\\' + sep + '+'), '')
|
|
3429
|
+
: rel
|
|
3430
|
+
if (!remoteRoot) return relBody || sep
|
|
3431
|
+
if (!relBody) return remoteRoot
|
|
3432
|
+
// An absolute remote path that is already inside the remote root is used
|
|
3433
|
+
// as-is; anything else absolute is left alone (the reader meant it).
|
|
3434
|
+
if (/^[\\/]/.test(relBody) || /^[A-Za-z]:[\\/]/.test(relBody)) {
|
|
3435
|
+
return relBody
|
|
3436
|
+
}
|
|
3437
|
+
return remoteRoot + sep + relBody
|
|
3438
|
+
}
|
|
3024
3439
|
function NativeExplorer(props) {
|
|
3025
3440
|
const info = props.useTabInfo()
|
|
3026
3441
|
const [expanded, setExpanded] = React.useState([])
|
|
@@ -3036,9 +3451,63 @@ window.__ModuleLoader__.load({
|
|
|
3036
3451
|
const path = remoteFilePath(info.tab.navigation.address)
|
|
3037
3452
|
return React.createElement(RemoteFileTab, { key: path, tab: { path }, scope: { sessionId: props.sessionId } })
|
|
3038
3453
|
}
|
|
3454
|
+
/**
|
|
3455
|
+
* The body for a CORE file link in a remote Session (issue #55).
|
|
3456
|
+
*
|
|
3457
|
+
* It resolves the address to an absolute REMOTE path before rendering the
|
|
3458
|
+
* same editor the plugin's own explorer uses, so a link and the explorer
|
|
3459
|
+
* agree on what a path means. Resolution is attempted synchronously first
|
|
3460
|
+
* (the mirror registry is usually warm) and retried once the registry
|
|
3461
|
+
* arrives, so a click during page load still ends up in the right file.
|
|
3462
|
+
*/
|
|
3463
|
+
function NativeRemoteLink(props) {
|
|
3464
|
+
const info = props.useTabInfo()
|
|
3465
|
+
const address = info.tab.navigation.address
|
|
3466
|
+
const parsed = parseCoreFileAddress(address)
|
|
3467
|
+
const sessionId = (parsed && parsed.sessionId) || props.sessionId
|
|
3468
|
+
const [remotePath, setRemotePath] = React.useState(() => {
|
|
3469
|
+
const p = parsed || parseCoreFileAddress(address)
|
|
3470
|
+
return p ? remotePathForCoreAddress(p, sessionCwdFor(sessionId)) : null
|
|
3471
|
+
})
|
|
3472
|
+
React.useEffect(() => {
|
|
3473
|
+
if (remotePath || !parsed) return undefined
|
|
3474
|
+
let cancelled = false
|
|
3475
|
+
ensureMirrors().then(() => {
|
|
3476
|
+
if (cancelled) return
|
|
3477
|
+
const cwd = sessionCwdFor(sessionId)
|
|
3478
|
+
const resolved = remotePathForCoreAddress(parsed, cwd)
|
|
3479
|
+
if (resolved) setRemotePath(resolved)
|
|
3480
|
+
})
|
|
3481
|
+
return () => { cancelled = true }
|
|
3482
|
+
}, [address, remotePath])
|
|
3483
|
+
if (!remotePath) {
|
|
3484
|
+
return React.createElement('div', { style: { padding: 16, fontSize: 12, opacity: 0.7 } },
|
|
3485
|
+
tr('file.linkUnresolved'))
|
|
3486
|
+
}
|
|
3487
|
+
return React.createElement(RemoteFileTab, { key: remotePath, tab: { path: remotePath }, scope: { sessionId } })
|
|
3488
|
+
}
|
|
3489
|
+
/** The local cwd of a session, from the client's own session store. */
|
|
3490
|
+
function sessionCwdFor(sessionId) {
|
|
3491
|
+
try {
|
|
3492
|
+
const list = SESSIONS && SESSIONS.list && typeof SESSIONS.list.getSnapshot === 'function'
|
|
3493
|
+
? SESSIONS.list.getSnapshot()
|
|
3494
|
+
: null
|
|
3495
|
+
const entry = list && list.byId && list.byId[sessionId]
|
|
3496
|
+
return (entry && entry.cwd) ? String(entry.cwd) : ''
|
|
3497
|
+
} catch { return '' }
|
|
3498
|
+
}
|
|
3039
3499
|
function registerNativeSidebar(inner) {
|
|
3040
3500
|
const tabs = inner.get('sidebarRightTabs')
|
|
3041
3501
|
const slots = inner.get('slots')
|
|
3502
|
+
// Whether this build accepted the remote-link type. A local (not module
|
|
3503
|
+
// level) flag, so two plugin lifetimes cannot see each other's state.
|
|
3504
|
+
let linkTypeAccepted = false
|
|
3505
|
+
// Issue #55: `canOpen` is synchronous and runs on every routing decision,
|
|
3506
|
+
// so the mirror registry has to be warm BEFORE the first link click —
|
|
3507
|
+
// otherwise the very first click of a page load falls through to the
|
|
3508
|
+
// built-in local preview. This is a warm-up, not a dependency: a failure
|
|
3509
|
+
// just leaves the conservative behaviour in place.
|
|
3510
|
+
try { ensureMirrors() } catch { /* the registry is best-effort */ }
|
|
3042
3511
|
inner.effect(() => tabs.register({
|
|
3043
3512
|
id: NATIVE_EXPLORER, kind: NATIVE_EXPLORER,
|
|
3044
3513
|
title: () => tr('explorer.tabTitle'),
|
|
@@ -3049,11 +3518,59 @@ window.__ModuleLoader__.load({
|
|
|
3049
3518
|
patterns: ['dsh-resource://dsh-remote/**'],
|
|
3050
3519
|
title: (address) => remoteFilePath(address).split(/[\\/]/).pop() || tr('explorer.tabTitle'),
|
|
3051
3520
|
}), 'dsh-remote.native-file-type')
|
|
3521
|
+
// Issue #55. No `priority` is declared, so this registers in the
|
|
3522
|
+
// `extension` band, which by contract outranks every viewer the product
|
|
3523
|
+
// ships — including the built-in text preview that currently claims
|
|
3524
|
+
// `dsh-resource://file/**` in the `fallback` band and reads locally.
|
|
3525
|
+
// `canOpen` is the whole safety story: it must return true ONLY for a
|
|
3526
|
+
// session whose cwd is PROVEN to be a remote mirror. Local sessions, and
|
|
3527
|
+
// a page load before the mirror registry has arrived, fall through to the
|
|
3528
|
+
// built-in preview and behave exactly as they did before.
|
|
3529
|
+
//
|
|
3530
|
+
// Registration is wrapped because the address-claiming half of this
|
|
3531
|
+
// contract (`patterns` + `canOpen`) is not something every DSH line
|
|
3532
|
+
// necessarily has. The plugin supports both the 0.1.x and 0.2.x lines, and
|
|
3533
|
+
// on a build whose tab registry cannot express an address claim the right
|
|
3534
|
+
// outcome is "file links keep working the old way" — NOT a throw that
|
|
3535
|
+
// would take the explorer and file types down with it.
|
|
3536
|
+
inner.effect(() => {
|
|
3537
|
+
try {
|
|
3538
|
+
const dispose = tabs.register({
|
|
3539
|
+
id: NATIVE_LINK, kind: NATIVE_LINK,
|
|
3540
|
+
patterns: ['dsh-resource://file/**'],
|
|
3541
|
+
canOpen: (address) => {
|
|
3542
|
+
const parsed = parseCoreFileAddress(address)
|
|
3543
|
+
if (!parsed) return false
|
|
3544
|
+
const cwd = sessionCwdFor(parsed.sessionId)
|
|
3545
|
+
// `MIRRORS.loaded` is checked so a cold start NEVER steals a local
|
|
3546
|
+
// link: an empty registry cannot prove a mirror, and guessing would
|
|
3547
|
+
// break the text preview for every local session.
|
|
3548
|
+
if (!cwd || !MIRRORS.loaded) return false
|
|
3549
|
+
return !!mirrorForCwd(cwd, MIRRORS.list)
|
|
3550
|
+
},
|
|
3551
|
+
title: (address) => {
|
|
3552
|
+
const parsed = parseCoreFileAddress(address)
|
|
3553
|
+
return ((parsed && parsed.path) || '').split(/[\\/]/).pop() || tr('explorer.tabTitle')
|
|
3554
|
+
},
|
|
3555
|
+
})
|
|
3556
|
+
linkTypeAccepted = true
|
|
3557
|
+
return () => { linkTypeAccepted = false; if (typeof dispose === 'function') dispose() }
|
|
3558
|
+
} catch (err) {
|
|
3559
|
+
try { console.warn('dsh-remote: remote file links are unavailable on this DSH build', err) } catch { /* no console */ }
|
|
3560
|
+
return undefined
|
|
3561
|
+
}
|
|
3562
|
+
}, 'dsh-remote.native-link-type')
|
|
3052
3563
|
inner.effect(() => slots.inject('sidebar.right.pane.tab', () => {
|
|
3053
3564
|
const disposers = [
|
|
3054
3565
|
slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_EXPLORER }, NativeExplorer),
|
|
3055
3566
|
slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_FILE }, NativeFile),
|
|
3056
3567
|
]
|
|
3568
|
+
// Only register the link body when its type was accepted: a body under a
|
|
3569
|
+
// kind nothing claims would never render, and on a build that rejected
|
|
3570
|
+
// the type the seat may reject the body too.
|
|
3571
|
+
if (linkTypeAccepted) {
|
|
3572
|
+
disposers.push(slots.register({ name: 'sidebar.right.pane.tab', key: NATIVE_LINK }, NativeRemoteLink))
|
|
3573
|
+
}
|
|
3057
3574
|
return () => disposers.forEach((dispose) => dispose())
|
|
3058
3575
|
}), 'dsh-remote.native-bodies')
|
|
3059
3576
|
}
|
|
@@ -3083,6 +3600,19 @@ window.__ModuleLoader__.load({
|
|
|
3083
3600
|
inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
|
|
3084
3601
|
})
|
|
3085
3602
|
ctx.inject(['sidebarRightTabs'], registerNativeSidebar)
|
|
3603
|
+
// Issue #49: mark the remote Sessions in the sidebar's own Session list.
|
|
3604
|
+
// These three seats belong to the SHIPPED ui-workspace browser, so they
|
|
3605
|
+
// are injected (not assumed): a composition with a replacement sidebar
|
|
3606
|
+
// simply never declares them, and the mark is absent rather than broken.
|
|
3607
|
+
slots.inject('sidebar.session.row.leading', () =>
|
|
3608
|
+
slots.register({ name: 'sidebar.session.row.leading', id: 'dsh-remote-remote-mark', order: 5 }, RemoteSessionMark),
|
|
3609
|
+
)
|
|
3610
|
+
slots.inject('sidebar.session.row.hover', () =>
|
|
3611
|
+
slots.register({ name: 'sidebar.session.row.hover', id: 'dsh-remote-remote-host', order: 5 }, RemoteSessionHover),
|
|
3612
|
+
)
|
|
3613
|
+
slots.inject('conversation.session.header.utilities', () =>
|
|
3614
|
+
slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-remote-remote-chip', order: 15 }, RemoteSessionChip),
|
|
3615
|
+
)
|
|
3086
3616
|
}
|
|
3087
3617
|
|
|
3088
3618
|
exports.name = name
|