dsh-remote 0.8.41 → 0.8.44
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 +5 -0
- package/README.md +5 -0
- package/lib/client.js +464 -18
- package/lib/deploy-tasks.js +142 -0
- package/lib/forwards.js +22 -0
- package/lib/index.js +339 -93
- package/lib/openssh.js +838 -0
- package/lib/pool.js +139 -54
- package/lib/registry.js +6 -0
- package/lib/sftp-surface.js +76 -0
- package/package.json +2 -1
package/lib/client.js
CHANGED
|
@@ -49,6 +49,15 @@ 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',
|
|
52
61
|
'settings.sshImport': '从 ~/.ssh/config 导入',
|
|
53
62
|
'settings.sshCollapse': '收起',
|
|
54
63
|
'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
|
|
@@ -134,6 +143,14 @@ window.__ModuleLoader__.load({
|
|
|
134
143
|
'settings.deployUseCommand': '将使用的 dsh',
|
|
135
144
|
'settings.deploySteps': '安装步骤',
|
|
136
145
|
'settings.deployDone': '部署完成,已记录该机器使用的 dsh',
|
|
146
|
+
'settings.deployFailed': '部署失败',
|
|
147
|
+
'settings.deployResumed': '已有部署任务在运行,已恢复显示其进度',
|
|
148
|
+
'settings.deployTaskTitle': '部署任务',
|
|
149
|
+
'settings.deployTaskRunning': '进行中',
|
|
150
|
+
'settings.deployTaskDone': '已完成',
|
|
151
|
+
'settings.deployTaskFailed': '失败',
|
|
152
|
+
'settings.deployTaskCancelled': '已取消',
|
|
153
|
+
'settings.deployCancel': '取消',
|
|
137
154
|
'settings.deployInvestigateStarted': '已创建排查会话,请在会话里跟进。',
|
|
138
155
|
'settings.badgePwd': '密码',
|
|
139
156
|
'settings.badgeKeychain': '钥匙串',
|
|
@@ -249,6 +266,9 @@ window.__ModuleLoader__.load({
|
|
|
249
266
|
'explorer.noRemoteWs': '未设置远程工作区',
|
|
250
267
|
'explorer.sessionLocal': '当前会话未使用远程工作区',
|
|
251
268
|
'explorer.localSession': '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。',
|
|
269
|
+
// issue #49:会话列表里标识「这是远程会话,连的是哪台机」
|
|
270
|
+
'session.remoteBadge': '远程会话:{target}',
|
|
271
|
+
'session.remoteHover': '远程主机:{target}',
|
|
252
272
|
'explorer.loading': '加载中…',
|
|
253
273
|
'explorer.dirMissing': '目录不存在或已被删除',
|
|
254
274
|
'explorer.notConnected': '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)',
|
|
@@ -307,6 +327,15 @@ window.__ModuleLoader__.load({
|
|
|
307
327
|
'settings.addMachine': 'Add machine',
|
|
308
328
|
'settings.editMachine': 'Edit machine',
|
|
309
329
|
'settings.badgeSshConfig': 'ssh cfg',
|
|
330
|
+
// Issue #51 — transport backend (GSSAPI/Kerberos via system OpenSSH).
|
|
331
|
+
'settings.badgeOpenssh': 'openssh',
|
|
332
|
+
'settings.backend': 'Backend',
|
|
333
|
+
'settings.backendNative': 'native (built-in ssh2: password / key / agent / OTP)',
|
|
334
|
+
'settings.backendOpenssh': 'openssh (drive the local ssh command)',
|
|
335
|
+
'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.',
|
|
336
|
+
'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.',
|
|
337
|
+
'settings.sshBinary': 'ssh path',
|
|
338
|
+
'settings.sshBinaryPlaceholder': 'Empty = the ssh on PATH',
|
|
310
339
|
'settings.sshImport': 'Import from ~/.ssh/config',
|
|
311
340
|
'settings.sshCollapse': 'Collapse',
|
|
312
341
|
'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
|
|
@@ -392,6 +421,14 @@ window.__ModuleLoader__.load({
|
|
|
392
421
|
'settings.deployUseCommand': 'dsh that will be used',
|
|
393
422
|
'settings.deploySteps': 'Install steps',
|
|
394
423
|
'settings.deployDone': 'Deployed; this machine now records the dsh it uses',
|
|
424
|
+
'settings.deployFailed': 'Deploy failed',
|
|
425
|
+
'settings.deployResumed': 'A deploy is already running — re-attached to its progress',
|
|
426
|
+
'settings.deployTaskTitle': 'Deploy task',
|
|
427
|
+
'settings.deployTaskRunning': 'running',
|
|
428
|
+
'settings.deployTaskDone': 'done',
|
|
429
|
+
'settings.deployTaskFailed': 'failed',
|
|
430
|
+
'settings.deployTaskCancelled': 'cancelled',
|
|
431
|
+
'settings.deployCancel': 'Cancel',
|
|
395
432
|
'settings.deployInvestigateStarted': 'A session was created — follow up there.',
|
|
396
433
|
'settings.badgePwd': 'password',
|
|
397
434
|
'settings.badgeKeychain': 'keychain',
|
|
@@ -507,6 +544,9 @@ window.__ModuleLoader__.load({
|
|
|
507
544
|
'explorer.noRemoteWs': 'No remote workspace set',
|
|
508
545
|
'explorer.sessionLocal': 'This session does not use a remote workspace',
|
|
509
546
|
'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.',
|
|
547
|
+
// issue #49: tell a remote Session apart in the Session list, and name its host.
|
|
548
|
+
'session.remoteBadge': 'Remote session: {target}',
|
|
549
|
+
'session.remoteHover': 'Remote host: {target}',
|
|
510
550
|
'explorer.loading': 'Loading…',
|
|
511
551
|
'explorer.dirMissing': 'Directory does not exist or was deleted',
|
|
512
552
|
'explorer.notConnected': 'Not connected to the remote host (after a restart, set a machine as "current" in Settings, or refresh the right column)',
|
|
@@ -747,8 +787,40 @@ window.__ModuleLoader__.load({
|
|
|
747
787
|
document.head.appendChild(el)
|
|
748
788
|
} catch { /* vm tests / no DOM */ }
|
|
749
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* Issue #52 — a backdrop may close its dialog only when the PRESS began on
|
|
792
|
+
* the backdrop itself.
|
|
793
|
+
*
|
|
794
|
+
* `click` is dispatched on the nearest common ancestor of the mousedown and
|
|
795
|
+
* mouseup targets. A gesture that pressed an element INSIDE the panel and
|
|
796
|
+
* released on the backdrop — because the panel re-laid-out mid-gesture (the
|
|
797
|
+
* remote autocomplete list used to sit in document flow, so expanding it
|
|
798
|
+
* pushed a centred panel's edges out from under the pointer) — is therefore
|
|
799
|
+
* reported as a click ON THE BACKDROP, and a bare `onClick` closed the whole
|
|
800
|
+
* dialog. Selecting a suggestion kicked the user out of the picker.
|
|
801
|
+
*
|
|
802
|
+
* Guarding on the press origin keeps "click outside to dismiss" while a
|
|
803
|
+
* drag that merely ENDS outside can no longer dismiss anything. The panel's
|
|
804
|
+
* own `stopPropagation` stays as a second line of defence.
|
|
805
|
+
*
|
|
806
|
+
* @returns {{onMouseDown: Function, onClick: (e: object) => boolean}} props to
|
|
807
|
+
* spread on the backdrop; `onClick` reports whether the press started there.
|
|
808
|
+
*/
|
|
809
|
+
function useBackdropGuard() {
|
|
810
|
+
const pressedOnBackdrop = React.useRef(false)
|
|
811
|
+
return {
|
|
812
|
+
onMouseDown: (e) => { pressedOnBackdrop.current = e.target === e.currentTarget },
|
|
813
|
+
onClick: (e) => {
|
|
814
|
+
const startedOnBackdrop = pressedOnBackdrop.current
|
|
815
|
+
pressedOnBackdrop.current = false
|
|
816
|
+
return startedOnBackdrop && e.target === e.currentTarget
|
|
817
|
+
},
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
750
821
|
function PromptDialog(props) {
|
|
751
822
|
const { title, initial, placeholder, onSubmit, onCancel } = props
|
|
823
|
+
const backdrop = useBackdropGuard()
|
|
752
824
|
const [val, setVal] = React.useState(initial || '')
|
|
753
825
|
React.useEffect(() => {
|
|
754
826
|
try {
|
|
@@ -763,7 +835,10 @@ window.__ModuleLoader__.load({
|
|
|
763
835
|
}, [val, onSubmit, onCancel])
|
|
764
836
|
return React.createElement('div', {
|
|
765
837
|
style: { position: 'fixed', inset: 0, zIndex: 1000000, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
766
|
-
|
|
838
|
+
onMouseDown: backdrop.onMouseDown,
|
|
839
|
+
// Issue #52: only a press that STARTED here may dismiss (a gesture that
|
|
840
|
+
// began inside the panel and ended on the backdrop must not).
|
|
841
|
+
onClick: (e) => { if (backdrop.onClick(e)) onCancel() },
|
|
767
842
|
},
|
|
768
843
|
React.createElement('div', {
|
|
769
844
|
className: 'dsh-rw-modal-panel',
|
|
@@ -860,6 +935,10 @@ window.__ModuleLoader__.load({
|
|
|
860
935
|
const [deployBusy, setDeployBusy] = React.useState('')
|
|
861
936
|
const [deployErr, setDeployErr] = React.useState('')
|
|
862
937
|
const [deployOpen, setDeployOpen] = React.useState(false)
|
|
938
|
+
// The HOST-SIDE deploy task (resumable across window close/reopen). This
|
|
939
|
+
// is the object the poll keeps fresh; it survives because the work runs
|
|
940
|
+
// on the host, not in this tab.
|
|
941
|
+
const [deployTask, setDeployTask] = React.useState(null)
|
|
863
942
|
// session's machine", which the host resolves from the session binding.
|
|
864
943
|
React.useEffect(() => { ensureChromeCss() }, [])
|
|
865
944
|
|
|
@@ -870,6 +949,9 @@ window.__ModuleLoader__.load({
|
|
|
870
949
|
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
871
950
|
password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
872
951
|
useSshConfig: !!form.useSshConfig,
|
|
952
|
+
// Issue #51: the probe must exercise the SAME backend the machine will
|
|
953
|
+
// use — a host reachable only via GSSAPI fails the native probe.
|
|
954
|
+
backend: form.backend || 'native',
|
|
873
955
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
874
956
|
})
|
|
875
957
|
.then((r) => {
|
|
@@ -953,10 +1035,31 @@ window.__ModuleLoader__.load({
|
|
|
953
1035
|
// ── 远程 DSH 界面(issue #46)─────────────────────────────────────────
|
|
954
1036
|
// The remote never opens an inbound port; we SSH out and carry the remote
|
|
955
1037
|
// loopback web UI back to a local port. One tunnel per machine.
|
|
1038
|
+
//
|
|
1039
|
+
// This SAME poll carries the deploy-task state, so a REOPENED window picks
|
|
1040
|
+
// up an in-flight install on the first tick (15s worst case — plus the
|
|
1041
|
+
// install button itself resumes). When a deploy is running the interval
|
|
1042
|
+
// tightens so step progress looks live.
|
|
956
1043
|
const refreshWebAttaches = () => api('GET', '/dsh-remote/web-attach')
|
|
957
|
-
.then((r) =>
|
|
1044
|
+
.then((r) => {
|
|
1045
|
+
setWebAttaches(r.attaches || [])
|
|
1046
|
+
const t = (r.deploys || []).find((d) => d.machineId === webAttachMachine) || r.deploy || null
|
|
1047
|
+
if (t && (t.status === 'running' || t.status === 'queued')) {
|
|
1048
|
+
setDeployTask(t)
|
|
1049
|
+
setDeployBusy('install')
|
|
1050
|
+
} else if (t) {
|
|
1051
|
+
setDeployTask(t)
|
|
1052
|
+
}
|
|
1053
|
+
return t
|
|
1054
|
+
})
|
|
958
1055
|
.catch(() => {})
|
|
959
|
-
React.useEffect(() => {
|
|
1056
|
+
React.useEffect(() => {
|
|
1057
|
+
refreshWebAttaches()
|
|
1058
|
+
const t = setInterval(() => {
|
|
1059
|
+
refreshWebAttaches()
|
|
1060
|
+
}, 15000)
|
|
1061
|
+
return () => clearInterval(t)
|
|
1062
|
+
}, [])
|
|
960
1063
|
const webAttachAction = (action, machineId, extra) => {
|
|
961
1064
|
setWebAttachBusy(machineId + ':' + action); setWebAttachErr('')
|
|
962
1065
|
return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
|
|
@@ -968,23 +1071,60 @@ window.__ModuleLoader__.load({
|
|
|
968
1071
|
// ── 远程部署:体检 / 部署并验证 / 让 AI 排查 ───────────────────────────
|
|
969
1072
|
// The probe is read-only, so it is safe to run before the user agrees to
|
|
970
1073
|
// anything; the install requires an explicit second click (confirm: true).
|
|
1074
|
+
//
|
|
1075
|
+
// Install is a HOST-SIDE BACKGROUND TASK: the call returns immediately
|
|
1076
|
+
// with a task handle, and progress arrives by polling. Closing the window
|
|
1077
|
+
// loses nothing — reopening picks the task back up from the periodic
|
|
1078
|
+
// web-attach poll (which carries the deploy state), and clicking 部署并验证
|
|
1079
|
+
// again while one runs RESUMES it rather than starting a second npm.
|
|
1080
|
+
const deployPoll = () => api('GET', '/dsh-remote/web-attach')
|
|
1081
|
+
.then((r) => {
|
|
1082
|
+
if (!r) return null
|
|
1083
|
+
// Refresh the attach list like before…
|
|
1084
|
+
setWebAttaches(r.attaches || [])
|
|
1085
|
+
// …and adopt any deploy task for the selected machine.
|
|
1086
|
+
const t = r.deploy || (r.deploys || []).find((d) => d.machineId === webAttachMachine) || null
|
|
1087
|
+
if (t) {
|
|
1088
|
+
setDeployTask(t)
|
|
1089
|
+
if (t.status === 'running' || t.status === 'queued') {
|
|
1090
|
+
setDeployBusy('install')
|
|
1091
|
+
} else if (deployBusy === 'install') {
|
|
1092
|
+
// The task settled since the last poll.
|
|
1093
|
+
setDeployBusy('')
|
|
1094
|
+
if (t.status === 'done') setDeployMsg(tr('settings.deployDone'))
|
|
1095
|
+
if (t.status === 'failed') setDeployErr(t.error || tr('settings.deployFailed'))
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return t
|
|
1099
|
+
})
|
|
1100
|
+
.catch(() => null)
|
|
1101
|
+
|
|
971
1102
|
const deployAction = (action, machineId, extra) => {
|
|
972
|
-
setDeployBusy(action); setDeployErr('')
|
|
1103
|
+
setDeployBusy(action); setDeployErr(''); setDeployMsg('')
|
|
973
1104
|
return api('POST', '/dsh-remote/web-attach', { action, machineId, ...(extra || {}) })
|
|
974
1105
|
.then((r) => {
|
|
975
1106
|
if (action === 'probe') setDeploy({ kind: 'probe', ...r, at: Date.now() })
|
|
976
|
-
if (action === 'install')
|
|
1107
|
+
if (action === 'install') {
|
|
1108
|
+
// The task is running on the host NOW. Adopt it immediately so the
|
|
1109
|
+
// steps appear without waiting for the first poll tick.
|
|
1110
|
+
if (r && r.task) setDeployTask(r.task)
|
|
1111
|
+
if (r && r.resumed) setDeployMsg(tr('settings.deployResumed'))
|
|
1112
|
+
}
|
|
977
1113
|
if (action === 'investigate') setDeploy({ kind: 'investigate', ...r, at: Date.now() })
|
|
978
1114
|
return r
|
|
979
1115
|
})
|
|
980
1116
|
.catch((e) => {
|
|
981
1117
|
const msg = String((e && e.message) || e)
|
|
982
1118
|
setDeployErr(msg)
|
|
983
|
-
// Keep the failing step list so the operator sees WHERE it stopped.
|
|
984
1119
|
setDeploy({ kind: action, ok: false, error: msg, at: Date.now() })
|
|
985
1120
|
return null
|
|
986
1121
|
})
|
|
987
|
-
.then((r) => {
|
|
1122
|
+
.then((r) => {
|
|
1123
|
+
if (action !== 'install') setDeployBusy('')
|
|
1124
|
+
// install keeps busy='install' until the task settles (poll clears it).
|
|
1125
|
+
refreshWebAttaches()
|
|
1126
|
+
return r
|
|
1127
|
+
})
|
|
988
1128
|
}
|
|
989
1129
|
|
|
990
1130
|
const setF = (k) => (ev) => setForm({ ...form, [k]: ev.target.value })
|
|
@@ -997,15 +1137,17 @@ window.__ModuleLoader__.load({
|
|
|
997
1137
|
encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
|
|
998
1138
|
proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
|
|
999
1139
|
proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
|
|
1140
|
+
// Issue #51: the transport backend is per machine.
|
|
1141
|
+
backend: m.backend === 'openssh' ? 'openssh' : 'native', sshBinary: m.sshBinary || '',
|
|
1000
1142
|
id: m.id,
|
|
1001
1143
|
useSshConfig: !!m.useSshConfig,
|
|
1002
1144
|
})
|
|
1003
|
-
// 编辑时若该机器用了高级配置(私钥/agent
|
|
1004
|
-
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1145
|
+
// 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串/系统 OpenSSH 后端),自动展开折叠区
|
|
1146
|
+
if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || m.backend === 'openssh' || (m.credentialBackend && m.credentialBackend !== 'plain')) {
|
|
1005
1147
|
setAdvOpen(true)
|
|
1006
1148
|
}
|
|
1007
1149
|
}
|
|
1008
|
-
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 })
|
|
1150
|
+
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: '', id: '', useSshConfig: false })
|
|
1009
1151
|
const save = (action) => {
|
|
1010
1152
|
setBusy(true); setErr(''); setMsg('')
|
|
1011
1153
|
if (action === 'delete') {
|
|
@@ -1017,6 +1159,8 @@ window.__ModuleLoader__.load({
|
|
|
1017
1159
|
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
1018
1160
|
workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
|
|
1019
1161
|
encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
|
|
1162
|
+
// Issue #51: the transport backend (and, for `openssh`, the executable).
|
|
1163
|
+
backend: form.backend || 'native', sshBinary: form.sshBinary || '',
|
|
1020
1164
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
1021
1165
|
}).then((r) => {
|
|
1022
1166
|
refresh(); clearForm()
|
|
@@ -1107,6 +1251,10 @@ window.__ModuleLoader__.load({
|
|
|
1107
1251
|
m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
|
|
1108
1252
|
: (m.username + '@' + m.host + ':' + m.port)),
|
|
1109
1253
|
m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
|
|
1254
|
+
// Issue #51: a machine on the system-OpenSSH backend behaves
|
|
1255
|
+
// differently enough (auth from ~/.ssh/config, GSSAPI) that
|
|
1256
|
+
// the row must say so.
|
|
1257
|
+
m.backend === 'openssh' ? pill('', tr('settings.badgeOpenssh')) : null,
|
|
1110
1258
|
m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
|
|
1111
1259
|
m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
|
|
1112
1260
|
(m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
|
|
@@ -1168,6 +1316,23 @@ window.__ModuleLoader__.load({
|
|
|
1168
1316
|
style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
|
|
1169
1317
|
}, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
|
|
1170
1318
|
advOpen ? React.createElement('div', { key: 'adv', style: { paddingTop: 2 } },
|
|
1319
|
+
// Issue #51 — transport backend. Placed FIRST in the advanced block
|
|
1320
|
+
// because it changes what the fields below even mean: with the
|
|
1321
|
+
// system-OpenSSH backend, host/user/port/key all come from
|
|
1322
|
+
// ~/.ssh/config and only the machine name (an alias) matters.
|
|
1323
|
+
row(tr('settings.backend'), React.createElement('select', { value: form.backend || 'native', onChange: setF('backend'), style: { ...inputS, maxWidth: 260 } },
|
|
1324
|
+
React.createElement('option', { value: 'native' }, tr('settings.backendNative')),
|
|
1325
|
+
React.createElement('option', { value: 'openssh' }, tr('settings.backendOpenssh'))), 'be'),
|
|
1326
|
+
(form.backend === 'openssh')
|
|
1327
|
+
? React.createElement('div', { style: { fontSize: 11, opacity: 0.85, marginBottom: 8, marginLeft: LABEL_W + 8, lineHeight: 1.55 } },
|
|
1328
|
+
React.createElement('div', null, tr('settings.backendOpensshHint')),
|
|
1329
|
+
form.useSshConfig
|
|
1330
|
+
? React.createElement('div', { style: { color: T.ok, marginTop: 2 } }, tr('settings.backendOpensshAliasOk'))
|
|
1331
|
+
: null)
|
|
1332
|
+
: null,
|
|
1333
|
+
(form.backend === 'openssh')
|
|
1334
|
+
? row(tr('settings.sshBinary'), React.createElement('input', { value: form.sshBinary || '', onChange: setF('sshBinary'), placeholder: tr('settings.sshBinaryPlaceholder'), style: inputS }), 'sb')
|
|
1335
|
+
: null,
|
|
1171
1336
|
row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
|
|
1172
1337
|
row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
|
|
1173
1338
|
row(tr('settings.workspace'), React.createElement('input', { value: form.workspace, onChange: setF('workspace'), placeholder: 'Optional: default remote dir for this machine', style: inputS }), 'ws'),
|
|
@@ -1273,6 +1438,46 @@ window.__ModuleLoader__.load({
|
|
|
1273
1438
|
disabled: !!deployBusy || !webAttachMachine,
|
|
1274
1439
|
onClick: () => deployAction('probe', webAttachMachine) },
|
|
1275
1440
|
deployBusy === 'probe' ? tr('settings.deployProbing') : tr('settings.deployProbe'))),
|
|
1441
|
+
// ── live deploy task (resumable; survives window close) ─────────
|
|
1442
|
+
deployTask
|
|
1443
|
+
? React.createElement('div', { style: { marginTop: 6, fontSize: 12, lineHeight: 1.6, border: '1px solid ' + T.border, borderRadius: 8, padding: 8 } },
|
|
1444
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: 4 } },
|
|
1445
|
+
tr('settings.deployTaskTitle')
|
|
1446
|
+
+ ' — ' + (deployTask.status === 'running' || deployTask.status === 'queued'
|
|
1447
|
+
? tr('settings.deployTaskRunning')
|
|
1448
|
+
: deployTask.status === 'done' ? tr('settings.deployTaskDone')
|
|
1449
|
+
: deployTask.status === 'cancelled' ? tr('settings.deployTaskCancelled')
|
|
1450
|
+
: tr('settings.deployTaskFailed'))),
|
|
1451
|
+
// The in-flight step: the one thing a re-attached user most
|
|
1452
|
+
// wants to see ("现在装到哪了").
|
|
1453
|
+
deployTask.current && (deployTask.status === 'running')
|
|
1454
|
+
? React.createElement('div', { style: { fontSize: 12, color: T.warn, marginBottom: 4 } }, '▶ ' + deployTask.current)
|
|
1455
|
+
: null,
|
|
1456
|
+
(deployTask.steps || []).length
|
|
1457
|
+
? React.createElement('div', null,
|
|
1458
|
+
React.createElement('div', { style: { fontWeight: 600, marginBottom: 2, fontSize: 11, opacity: 0.8 } }, tr('settings.deploySteps')),
|
|
1459
|
+
deployTask.steps.map((s) => React.createElement('div', { key: s.id, style: { fontSize: 11, fontFamily: 'monospace', display: 'flex', gap: 6 } },
|
|
1460
|
+
React.createElement('span', { style: { color: s.ok ? T.ok : T.danger } }, s.ok ? '✓' : '✗'),
|
|
1461
|
+
React.createElement('span', null, s.title),
|
|
1462
|
+
!s.ok && s.output ? React.createElement('span', { style: { opacity: 0.75, whiteSpace: 'pre-wrap', wordBreak: 'break-all' } }, ' — ' + String(s.output).slice(0, 300)) : null)))
|
|
1463
|
+
: null,
|
|
1464
|
+
deployTask.status === 'running' || deployTask.status === 'queued'
|
|
1465
|
+
? React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 6 } },
|
|
1466
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.7 } },
|
|
1467
|
+
(deployTask.progress && deployTask.progress.doneSteps || 0) + '/' + (deployTask.progress && deployTask.progress.totalSteps || '?')),
|
|
1468
|
+
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, padding: '2px 10px' },
|
|
1469
|
+
onClick: () => deployAction('install-cancel', webAttachMachine) }, tr('settings.deployCancel')))
|
|
1470
|
+
: null,
|
|
1471
|
+
// A finished task: continue the journey — connect & open.
|
|
1472
|
+
deployTask.status === 'done'
|
|
1473
|
+
? React.createElement('div', { style: { marginTop: 6, color: T.ok, fontSize: 12 } },
|
|
1474
|
+
tr('settings.deployDone')
|
|
1475
|
+
+ (deployTask.result && deployTask.result.command ? ' · ' + deployTask.result.command : ''))
|
|
1476
|
+
: null,
|
|
1477
|
+
deployTask.status === 'failed'
|
|
1478
|
+
? React.createElement('div', { style: { marginTop: 6, color: T.danger, fontSize: 12, wordBreak: 'break-all' } }, deployTask.error || tr('settings.deployFailed'))
|
|
1479
|
+
: null)
|
|
1480
|
+
: null,
|
|
1276
1481
|
// Verdict from the last probe/install, rendered as findings with the
|
|
1277
1482
|
// severity the backend assigned — the UI never re-classifies them.
|
|
1278
1483
|
deploy ? React.createElement('div', { style: { fontSize: 12, lineHeight: 1.6 } },
|
|
@@ -1317,9 +1522,13 @@ window.__ModuleLoader__.load({
|
|
|
1317
1522
|
// reason to run. The host action re-probes internally anyway, so it is
|
|
1318
1523
|
// safe to offer directly; clicking it is the explicit consent.
|
|
1319
1524
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn dsh-rw-primary', style: { ...primaryBtnS, marginTop: 6 },
|
|
1320
|
-
disabled:
|
|
1525
|
+
disabled: !webAttachMachine,
|
|
1321
1526
|
onClick: () => { if (window.confirm(tr('settings.deployConfirm'))) deployAction('install', webAttachMachine, { confirm: true }) } },
|
|
1322
|
-
|
|
1527
|
+
// While a task runs, the button is the RESUME affordance (disabled
|
|
1528
|
+
// — the poll renders live state; a second click must not restart).
|
|
1529
|
+
(deployTask && (deployTask.status === 'running' || deployTask.status === 'queued'))
|
|
1530
|
+
? tr('settings.deployInstalling')
|
|
1531
|
+
: deployBusy === 'install' ? tr('settings.deployInstalling') : tr('settings.deployInstall')),
|
|
1323
1532
|
// The AI escape hatch: offered after a failure, and ONLY on click —
|
|
1324
1533
|
// it costs a model call, so it is never automatic.
|
|
1325
1534
|
(deployErr || (deploy && deploy.ok === false))
|
|
@@ -1486,6 +1695,13 @@ window.__ModuleLoader__.load({
|
|
|
1486
1695
|
const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
|
|
1487
1696
|
const suggestTimer = React.useRef(null)
|
|
1488
1697
|
const suggestRef = React.useRef(null)
|
|
1698
|
+
// Issue #52 defect 1: the modal backdrop must dismiss only when the press
|
|
1699
|
+
// STARTED on it (a suggestion click whose mouseup landed on the backdrop
|
|
1700
|
+
// used to close the whole picker). The local-browse popup below is its own
|
|
1701
|
+
// full-screen overlay, so it gets its own guard ref — sharing one ref
|
|
1702
|
+
// would make a press on either backdrop arm the other.
|
|
1703
|
+
const backdrop = useBackdropGuard()
|
|
1704
|
+
const localBackdrop = useBackdropGuard()
|
|
1489
1705
|
// Autocomplete performance (slow per-keystroke remote scans):
|
|
1490
1706
|
// - postedCurrentRef/currentPromiseRef: POST /current once per machine
|
|
1491
1707
|
// switch; concurrent callers share ONE in-flight promise and a failed
|
|
@@ -1915,7 +2131,8 @@ window.__ModuleLoader__.load({
|
|
|
1915
2131
|
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
|
1916
2132
|
return React.createElement('div', {
|
|
1917
2133
|
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
|
1918
|
-
|
|
2134
|
+
onMouseDown: localBackdrop.onMouseDown,
|
|
2135
|
+
onClick: (e) => { if (localBackdrop.onClick(e)) setLocalPop(null) },
|
|
1919
2136
|
},
|
|
1920
2137
|
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() },
|
|
1921
2138
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
|
|
@@ -2026,7 +2243,15 @@ window.__ModuleLoader__.load({
|
|
|
2026
2243
|
onClick: () => switchTab(t),
|
|
2027
2244
|
style: { ...buttonS, fontWeight: tab === t ? 700 : 400, padding: '5px 14px' },
|
|
2028
2245
|
}, lbl)
|
|
2029
|
-
return React.createElement('div', {
|
|
2246
|
+
return React.createElement('div', {
|
|
2247
|
+
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
2248
|
+
onMouseDown: backdrop.onMouseDown,
|
|
2249
|
+
// Issue #52 defect 1: this was a bare onClick, so picking an autocomplete
|
|
2250
|
+
// suggestion (mousedown inside the panel, panel re-laid-out, mouseup on
|
|
2251
|
+
// the backdrop) closed the whole dialog. Only a press that began on the
|
|
2252
|
+
// backdrop dismisses now.
|
|
2253
|
+
onClick: (e) => { if (backdrop.onClick(e) && !busy && !mkdirPrompt) onCancel() },
|
|
2254
|
+
},
|
|
2030
2255
|
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() },
|
|
2031
2256
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
|
|
2032
2257
|
React.createElement('div', { style: { fontSize: 15, fontWeight: 650 } }, tr('picker.title')),
|
|
@@ -2047,7 +2272,7 @@ window.__ModuleLoader__.load({
|
|
|
2047
2272
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
2048
2273
|
localPop ? renderLocalPopup() : null,
|
|
2049
2274
|
)
|
|
2050
|
-
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
2275
|
+
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: suggestOpen ? 'visible' : 'auto' } },
|
|
2051
2276
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
2052
2277
|
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
|
|
2053
2278
|
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
@@ -2087,14 +2312,29 @@ window.__ModuleLoader__.load({
|
|
|
2087
2312
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, tr('picker.home')),
|
|
2088
2313
|
),
|
|
2089
2314
|
(suggestOpen && suggest.length)
|
|
2090
|
-
|
|
2091
|
-
|
|
2315
|
+
// Issue #52 defect 2: this used to be an in-flow block, so
|
|
2316
|
+
// expanding it pushed the 「设为远程工作区」 button down and the
|
|
2317
|
+
// user could never hit a button that was moving. It is now an
|
|
2318
|
+
// absolutely-positioned overlay hanging off `suggestRef`
|
|
2319
|
+
// (position: relative), which also keeps the modal's height —
|
|
2320
|
+
// and therefore its centred edges — stable while open (that
|
|
2321
|
+
// stability is what makes the press-origin guard above exact).
|
|
2322
|
+
// `paddingBottom` lets the last entries scroll clear of the
|
|
2323
|
+
// button row, which is lifted over the dropdown by zIndex.
|
|
2324
|
+
? 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)' } },
|
|
2325
|
+
// Issue #52 defect 1, local belt-and-braces: swallow the
|
|
2326
|
+
// gesture so no ancestor (least of all the backdrop) can
|
|
2327
|
+
// observe a click that began on a suggestion.
|
|
2328
|
+
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)),
|
|
2092
2329
|
)
|
|
2093
2330
|
: null,
|
|
2094
2331
|
),
|
|
2095
2332
|
popOpen ? renderDirPopup() : null,
|
|
2096
2333
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
2097
|
-
|
|
2334
|
+
// The action row rides ABOVE the dropdown (zIndex 70) on its own
|
|
2335
|
+
// opaque layer, so the list slides out from behind it instead of
|
|
2336
|
+
// covering 「设为远程工作区」.
|
|
2337
|
+
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') } },
|
|
2098
2338
|
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, path ? tr('picker.selected', { path }) : ''),
|
|
2099
2339
|
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')),
|
|
2100
2340
|
),
|
|
@@ -2153,6 +2393,199 @@ window.__ModuleLoader__.load({
|
|
|
2153
2393
|
.catch(() => '')
|
|
2154
2394
|
}
|
|
2155
2395
|
|
|
2396
|
+
// ── which sessions are remote (issue #49) ────────────────────────────────
|
|
2397
|
+
//
|
|
2398
|
+
// The sidebar must say, at a glance, whether a Session row is a LOCAL
|
|
2399
|
+
// session or one working on a remote host — and which host. The truth is
|
|
2400
|
+
// already on the client: every Session summary carries its own cwd, and a
|
|
2401
|
+
// cwd that lives inside a `$DSH_HOME/remote-workspaces/<host-user-port>/…`
|
|
2402
|
+
// mirror IS a remote session bound to that mirror's recorded origin (the
|
|
2403
|
+
// same invariant lib/binding.js uses host-side; see issue #13).
|
|
2404
|
+
//
|
|
2405
|
+
// So one GET /dsh-remote/bindings (the whole mirror registry, with each
|
|
2406
|
+
// mirror's recorded host/port/user/path) answers every row, instead of one
|
|
2407
|
+
// request per row. The active machine is deliberately NOT consulted: a saved
|
|
2408
|
+
// machine that happens to be "current" must never label a local session.
|
|
2409
|
+
//
|
|
2410
|
+
// Failure is not fatal and not retried in a loop: no registry means no
|
|
2411
|
+
// badge, which is exactly what a user with zero mirrors sees.
|
|
2412
|
+
|
|
2413
|
+
const MIRRORS = { list: [], loaded: false, error: '' }
|
|
2414
|
+
const mirrorWatchers = new Set()
|
|
2415
|
+
let mirrorLoad = null
|
|
2416
|
+
let mirrorTimer = null
|
|
2417
|
+
|
|
2418
|
+
const notifyMirrors = () => {
|
|
2419
|
+
for (const fn of mirrorWatchers) { try { fn() } catch { /* one watcher must not stop the rest */ } }
|
|
2420
|
+
}
|
|
2421
|
+
/** Load the mirror registry once (concurrent callers share the request). */
|
|
2422
|
+
function ensureMirrors() {
|
|
2423
|
+
if (MIRRORS.loaded) return Promise.resolve(MIRRORS)
|
|
2424
|
+
if (mirrorLoad) return mirrorLoad
|
|
2425
|
+
mirrorLoad = api('GET', '/dsh-remote/bindings')
|
|
2426
|
+
.then((r) => {
|
|
2427
|
+
MIRRORS.list = (r && Array.isArray(r.mirrors)) ? r.mirrors : []
|
|
2428
|
+
MIRRORS.error = ''
|
|
2429
|
+
})
|
|
2430
|
+
.catch((e) => { MIRRORS.error = String((e && e.message) || e) })
|
|
2431
|
+
.then(() => {
|
|
2432
|
+
MIRRORS.loaded = true
|
|
2433
|
+
mirrorLoad = null
|
|
2434
|
+
notifyMirrors()
|
|
2435
|
+
return MIRRORS
|
|
2436
|
+
})
|
|
2437
|
+
return mirrorLoad
|
|
2438
|
+
}
|
|
2439
|
+
/** Force a refresh (mirrors appear when a workspace is picked). */
|
|
2440
|
+
function refreshMirrors() {
|
|
2441
|
+
MIRRORS.loaded = false
|
|
2442
|
+
return ensureMirrors()
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* The mirror containing a local path, or null.
|
|
2446
|
+
*
|
|
2447
|
+
* Separator-aware on purpose: `/a/mirror-2` must NOT be treated as living
|
|
2448
|
+
* inside `/a/mirror` (the same trap lib/binding.js documents). The deepest
|
|
2449
|
+
* match wins, so a future nested layout stays stable. Windows paths compare
|
|
2450
|
+
* case-insensitively because the platform does.
|
|
2451
|
+
*/
|
|
2452
|
+
function mirrorForCwd(cwd, mirrors) {
|
|
2453
|
+
const raw = String(cwd || '')
|
|
2454
|
+
if (!raw) return null
|
|
2455
|
+
const list = Array.isArray(mirrors) ? mirrors : []
|
|
2456
|
+
const isWin = raw.includes('\\')
|
|
2457
|
+
const sep = isWin ? '\\' : '/'
|
|
2458
|
+
const fold = (s) => (isWin ? s.toLowerCase() : s)
|
|
2459
|
+
const base = fold(raw.replace(/[\\/]+$/, ''))
|
|
2460
|
+
let best = null
|
|
2461
|
+
for (const m of list) {
|
|
2462
|
+
const dirRaw = String((m && m.dir) || '').replace(/[\\/]+$/, '')
|
|
2463
|
+
if (!dirRaw) continue
|
|
2464
|
+
const dir = fold(dirRaw)
|
|
2465
|
+
if (base !== dir && !base.startsWith(dir + sep) && !base.startsWith(dir + '/')) continue
|
|
2466
|
+
if (!best || dir.length > fold(String(best.dir).replace(/[\\/]+$/, '')).length) best = m
|
|
2467
|
+
}
|
|
2468
|
+
return best
|
|
2469
|
+
}
|
|
2470
|
+
/** `user@host:port` for a mirror record (port omitted when it is 22). */
|
|
2471
|
+
function mirrorTarget(m) {
|
|
2472
|
+
if (!m) return ''
|
|
2473
|
+
const user = m.username ? m.username + '@' : ''
|
|
2474
|
+
const port = Number(m.port) && Number(m.port) !== 22 ? ':' + m.port : ''
|
|
2475
|
+
return user + String(m.host || '') + port
|
|
2476
|
+
}
|
|
2477
|
+
/** Host as the reader asked for it: the address, plus the saved machine name. */
|
|
2478
|
+
function mirrorLabel(m) {
|
|
2479
|
+
if (!m) return ''
|
|
2480
|
+
const target = mirrorTarget(m)
|
|
2481
|
+
return m.name && m.name !== m.host ? target + ' · ' + m.name : target
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* The remote binding of one Session row, read from that row's own cwd.
|
|
2485
|
+
*
|
|
2486
|
+
* A row with no cwd (a blank New Session) is not remote — a blank session
|
|
2487
|
+
* has no workspace yet, and must never inherit the badge from a neighbor.
|
|
2488
|
+
*
|
|
2489
|
+
* Hook shape is CONSTANT: when the framework does not hand the entry a
|
|
2490
|
+
* `useSessions` (older sidebar), a stable no-op hook is selected instead of
|
|
2491
|
+
* skipping the call, so the hook count cannot change across renders (a
|
|
2492
|
+
* conditional hook is a crash, not a degraded badge).
|
|
2493
|
+
*/
|
|
2494
|
+
const NO_SELECT = () => undefined
|
|
2495
|
+
function useSessionMirror(sessionId, useSessions) {
|
|
2496
|
+
// `mirrorVersion` is only a re-render trigger: the registry arrives
|
|
2497
|
+
// asynchronously, and every mounted mark must repaint when it does.
|
|
2498
|
+
const [, bumpMirrorVersion] = React.useState(0)
|
|
2499
|
+
const select = typeof useSessions === 'function' ? useSessions : NO_SELECT
|
|
2500
|
+
const cwd = select((s) => (sessionId && s && s.byId && s.byId[sessionId] ? s.byId[sessionId].cwd : undefined))
|
|
2501
|
+
React.useEffect(() => {
|
|
2502
|
+
const fn = () => bumpMirrorVersion((n) => n + 1)
|
|
2503
|
+
mirrorWatchers.add(fn)
|
|
2504
|
+
ensureMirrors()
|
|
2505
|
+
// Mirrors change when a remote workspace is picked in THIS tab; a slow
|
|
2506
|
+
// poll covers a pick made in another tab without a per-render request.
|
|
2507
|
+
if (!mirrorTimer && typeof setInterval === 'function') {
|
|
2508
|
+
mirrorTimer = setInterval(refreshMirrors, 60000)
|
|
2509
|
+
}
|
|
2510
|
+
return () => {
|
|
2511
|
+
mirrorWatchers.delete(fn)
|
|
2512
|
+
if (!mirrorWatchers.size && mirrorTimer) { clearInterval(mirrorTimer); mirrorTimer = null }
|
|
2513
|
+
}
|
|
2514
|
+
}, [])
|
|
2515
|
+
return mirrorForCwd(cwd, MIRRORS.list)
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// Leading seat of a Session row: the 16px cell before the title. A green dot
|
|
2519
|
+
// means "this session works on a remote host"; a local session renders
|
|
2520
|
+
// nothing at all, so the cell stays blank exactly as before. The owner only
|
|
2521
|
+
// mounts this seat while the row is idle (a running/pending row keeps its
|
|
2522
|
+
// own status dot there) — the hover card below therefore carries the host
|
|
2523
|
+
// for every row, running or not.
|
|
2524
|
+
function RemoteSessionMark(props) {
|
|
2525
|
+
const m = useSessionMirror(props.sessionId, props.useSessions)
|
|
2526
|
+
if (!m) return null
|
|
2527
|
+
const target = mirrorTarget(m)
|
|
2528
|
+
return React.createElement('span', {
|
|
2529
|
+
'data-dsh-remote-mark': '',
|
|
2530
|
+
role: 'img',
|
|
2531
|
+
'aria-label': tr('session.remoteBadge', { target }),
|
|
2532
|
+
title: tr('session.remoteBadge', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
|
|
2533
|
+
style: { width: 16, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
|
|
2534
|
+
}, React.createElement('span', {
|
|
2535
|
+
'aria-hidden': 'true',
|
|
2536
|
+
style: {
|
|
2537
|
+
width: 8, height: 8, borderRadius: '50%',
|
|
2538
|
+
background: 'var(--dsw-alias-state-success-primary, #4caf7d)',
|
|
2539
|
+
boxShadow: '0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-success-primary, #4caf7d) 22%, transparent)',
|
|
2540
|
+
},
|
|
2541
|
+
}))
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Hover-card section of a Session row, between its relative time and its
|
|
2545
|
+
// trailing status line: names the remote host (and the remote path it
|
|
2546
|
+
// works on) for a remote row. A local row renders nothing.
|
|
2547
|
+
function RemoteSessionHover(props) {
|
|
2548
|
+
const m = useSessionMirror(props.sessionId, props.useSessions)
|
|
2549
|
+
if (!m) return null
|
|
2550
|
+
return React.createElement('div', { 'data-dsh-remote-hover': '', style: { display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, lineHeight: '16px' } },
|
|
2551
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
2552
|
+
React.createElement('span', {
|
|
2553
|
+
'aria-hidden': 'true',
|
|
2554
|
+
style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
|
|
2555
|
+
}),
|
|
2556
|
+
React.createElement('span', { style: { color: '#cfd3d6', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, mirrorLabel(m)),
|
|
2557
|
+
),
|
|
2558
|
+
m.remotePath
|
|
2559
|
+
? React.createElement('div', { style: { color: '#adb2b8', wordBreak: 'break-all', paddingLeft: 13 } }, m.remotePath)
|
|
2560
|
+
: null,
|
|
2561
|
+
)
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
// Conversation header chip for the CURRENT session: the one place the host
|
|
2565
|
+
// can stay visible without hovering anything (the row's 16px cell is too
|
|
2566
|
+
// narrow for an address). Renders nothing for a local session.
|
|
2567
|
+
//
|
|
2568
|
+
// This seat is session-scoped, so the owner hands the entry its `sessionId`
|
|
2569
|
+
// (exactly like the shipped "open in app" utility) and the chip needs
|
|
2570
|
+
// nothing from the sidebar.
|
|
2571
|
+
function RemoteSessionChip(props) {
|
|
2572
|
+
const m = useSessionMirror(props.sessionId, props.useSessions)
|
|
2573
|
+
if (!m) return null
|
|
2574
|
+
const target = mirrorTarget(m)
|
|
2575
|
+
return React.createElement('span', {
|
|
2576
|
+
'data-dsh-remote-chip': '',
|
|
2577
|
+
className: 'dsh-rw-pill is-ok',
|
|
2578
|
+
title: tr('session.remoteHover', { target }) + (m.remotePath ? '\n' + m.remotePath : ''),
|
|
2579
|
+
style: { maxWidth: 220, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-flex', alignItems: 'center', gap: 5 },
|
|
2580
|
+
},
|
|
2581
|
+
React.createElement('span', {
|
|
2582
|
+
'aria-hidden': 'true',
|
|
2583
|
+
style: { width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: 'var(--dsw-alias-state-success-primary, #4caf7d)' },
|
|
2584
|
+
}),
|
|
2585
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, target),
|
|
2586
|
+
)
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2156
2589
|
function readRemoteFile(path, maxBytes, sessionId) {
|
|
2157
2590
|
return api('POST', '/dsh-remote/read', withSessionBody({ path, maxBytes }, sessionId)).catch((e) => {
|
|
2158
2591
|
throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
|
|
@@ -2961,6 +3394,19 @@ window.__ModuleLoader__.load({
|
|
|
2961
3394
|
inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
|
|
2962
3395
|
})
|
|
2963
3396
|
ctx.inject(['sidebarRightTabs'], registerNativeSidebar)
|
|
3397
|
+
// Issue #49: mark the remote Sessions in the sidebar's own Session list.
|
|
3398
|
+
// These three seats belong to the SHIPPED ui-workspace browser, so they
|
|
3399
|
+
// are injected (not assumed): a composition with a replacement sidebar
|
|
3400
|
+
// simply never declares them, and the mark is absent rather than broken.
|
|
3401
|
+
slots.inject('sidebar.session.row.leading', () =>
|
|
3402
|
+
slots.register({ name: 'sidebar.session.row.leading', id: 'dsh-remote-remote-mark', order: 5 }, RemoteSessionMark),
|
|
3403
|
+
)
|
|
3404
|
+
slots.inject('sidebar.session.row.hover', () =>
|
|
3405
|
+
slots.register({ name: 'sidebar.session.row.hover', id: 'dsh-remote-remote-host', order: 5 }, RemoteSessionHover),
|
|
3406
|
+
)
|
|
3407
|
+
slots.inject('conversation.session.header.utilities', () =>
|
|
3408
|
+
slots.register({ name: 'conversation.session.header.utilities', id: 'dsh-remote-remote-chip', order: 15 }, RemoteSessionChip),
|
|
3409
|
+
)
|
|
2964
3410
|
}
|
|
2965
3411
|
|
|
2966
3412
|
exports.name = name
|