dsh-remote 0.8.42 → 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 +334 -10
- package/lib/forwards.js +22 -0
- package/lib/index.js +228 -30
- 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/README.en.md
CHANGED
|
@@ -57,6 +57,7 @@ The image above is the overview. What follows is only what the image does not ma
|
|
|
57
57
|
The rest:
|
|
58
58
|
|
|
59
59
|
- **22 model tools** (listed so they can be copied or searched): `rw_info`, `rw_connect`, `rw_machines`, `rw_pick_workspace`, `rw_list_dir`, `rw_stat`, `rw_read_file`, `rw_write_file`, `rw_edit`, `rw_append`, `rw_mkdir`, `rw_remove`, `rw_move`, `rw_exec`, `rw_search`, `rw_download`, `rw_upload`, `rw_sync`, `rw_push`, `rw_forward`, `rw_disconnect`, `rw_deploy_probe`.
|
|
60
|
+
- **Tell local and remote Sessions apart in the sidebar** (`0.8.43+`) — a remote Session row carries a **green dot** and its hover card names `user@host:port` plus the remote path; opening it also shows a host chip in the conversation header (a local Session shows neither). A Session counts as remote only when its cwd sits inside a `$DSH_HOME/remote-workspaces/…` mirror, so a machine that is merely saved never labels a local Session.
|
|
60
61
|
- **Open a remote machine's DSH Web UI locally** (`0.8.36+`) — nothing has to be exposed on the remote: the plugin SSHes out, starts a `dsh web` there that binds **`127.0.0.1` only**, and tunnels its port back to a loopback port on this machine. The URL carries a one-time login token, consumed once by a redirect.
|
|
61
62
|
- **One-click health check & deploy** (`0.8.36+`) — **a failed connection is usually the remote dsh version or environment, not the plugin.** The read-only check reports platform / node / npm / dsh version / **whether the native module can load** / proxy and npm registry, with a suggested fix; "Deploy & verify" installs into a remote **private directory** (no system directory, no PATH change, no replacing the version you use — deleting it is a complete rollback) and verifies step by step. The button **no longer requires running the check first** (`0.8.40+`) — the deploy re-checks internally. If that still fails, the built-in `dsh-remote-deploy` skill takes over.
|
|
62
63
|
- **Cross-platform remotes** — all file access is SFTP-protocol-level (no POSIX shell), so Linux/macOS/Windows remotes all work.
|
|
@@ -242,6 +243,8 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
242
243
|
| `hostKeyMode` | string | `accept-new` | host-key policy: `accept-new` (TOFU), `verify` (reject unknown hosts), `off` (skip) |
|
|
243
244
|
| `useAgent` | bool | `false` | authenticate via the OpenSSH agent (`SSH_AUTH_SOCK`) |
|
|
244
245
|
| `keyboardInteractive` | bool | `false` | allow keyboard-interactive auth (OTP/MFA) with the configured password |
|
|
246
|
+
| `backend` | string | `native` | SSH transport backend (issue #51). `native` = the built-in ssh2 client (password / private key / agent / OTP); `openssh` = **drive the local `ssh` command**, which is the only way to reach hosts whose sole authentication method is GSSAPI/Kerberos (log in once with `kinit`), and which also honours `ProxyCommand`, `PKCS11Provider`, `Match` blocks and the rest of `~/.ssh/config`. **Settable per machine.** The cost is a fresh connection per command (slower than native); SFTP still reuses one session. The host name is handed to ssh **verbatim** (so `~/.ssh/config` stays the single source of truth) and host keys are checked by ssh against your own `known_hosts` |
|
|
247
|
+
| `sshBinary` | string | `''` | the `ssh` executable the `openssh` backend drives (empty = the `ssh` on PATH) |
|
|
245
248
|
| `proxy` | object | — | jump host: `{ host, port?, username?, password?, privateKeyPath? }` |
|
|
246
249
|
| `autoPush` | bool | `false` | auto-push edited mirror files back to the remote (watcher, debounced) |
|
|
247
250
|
| `auditLog` | bool | `true` | append executed commands to `$DSH_HOME/remote-workspaces/audit.log` |
|
|
@@ -276,6 +279,8 @@ Deploying to a product profile is a separate, explicit action (`./sync.sh`) for
|
|
|
276
279
|
|
|
277
280
|
**Cannot reach an internal machine** — set a jump host (or add the bastion as its own machine first).
|
|
278
281
|
|
|
282
|
+
**The host only allows GSSAPI/Kerberos (`ssh` works after `kinit`, the plugin does not)** — in Settings → 远程工作区 → Advanced, switch **Backend** to `openssh` (drive the local `ssh`) and tick "use a ~/.ssh/config alias", with Host set to the **alias** in `~/.ssh/config` (not the IP). The login is then performed by your local ssh with your Kerberos ticket, and `ProxyCommand`, `GSSAPIAuthentication` etc. all apply per `~/.ssh/config` — **one source of truth for the configuration** (issues #50/#51). Command-line equivalent: `rw_connect(host="<alias>", useSshConfig=true, backend="openssh")`.
|
|
283
|
+
|
|
279
284
|
**`rw_sync`/`rw_push` reports conflicts** — files changed on both sides are skipped and listed (never silently overwritten); merge manually and retry, or pass `force=true`.
|
|
280
285
|
|
|
281
286
|
**Windows remotes** — everything goes over SFTP, no POSIX shell needed; read Chinese files with `encoding=gbk`.
|
package/README.md
CHANGED
|
@@ -46,6 +46,7 @@ DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.
|
|
|
46
46
|
其余要点:
|
|
47
47
|
|
|
48
48
|
- **22 个模型工具**(便于复制/检索):`rw_info`、`rw_connect`、`rw_machines`、`rw_pick_workspace`、`rw_list_dir`、`rw_stat`、`rw_read_file`、`rw_write_file`、`rw_edit`、`rw_append`、`rw_mkdir`、`rw_remove`、`rw_move`、`rw_exec`、`rw_search`、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_forward`、`rw_disconnect`、`rw_deploy_probe`。
|
|
49
|
+
- **会话列表里区分本地/远程**(`0.8.43+`)—— 远程会话的行首有一个**绿点**,悬停显示 `user@host:port` 与远端路径;打开该会话后,标题栏还会常显一个主机标签(本地会话两者都没有)。判定只看会话 cwd 是否落在 `$DSH_HOME/remote-workspaces/…` 镜像里,所以「保存过但没在用」的机器不会给本地会话打标。
|
|
49
50
|
- **把远端机器的 DSH 界面挂到本机**(`0.8.36+`)—— 不用在远端开任何端口:插件主动 SSH 连过去,在远端**只监听 `127.0.0.1`** 地起一个 `dsh web`,再把端口经隧道搬回本机的一个 loopback 端口。地址带一次性登录令牌,只在本机这次跳转里用一次。
|
|
50
51
|
- **连不上时一键体检与部署**(`0.8.36+`)—— **连不上大多不是插件的问题,而是远端 dsh 的版本或环境不对**。体检(只读)会报出平台/node/npm/dsh 版本、**原生模块能否启动**、代理与 npm 源,并给出修复建议;「部署并验证」把它装到远端**私有目录**(不写系统目录、不改 PATH、不覆盖你在用的版本,删目录即回滚)并逐步校验。按钮**不再需要先体检**——直接点即可(`0.8.40+`),部署过程自己会重新体检。失败还能交给内置的 `dsh-remote-deploy` 技能排查。
|
|
51
52
|
- **远端跨平台** —— 文件访问走 SFTP 协议层(不依赖 POSIX shell),Linux/macOS/Windows 远端都能列/读/写/搜索/同步。
|
|
@@ -224,6 +225,8 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
224
225
|
| `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
|
|
225
226
|
| `useAgent` | bool | `false` | 用 OpenSSH agent(`SSH_AUTH_SOCK`)认证 |
|
|
226
227
|
| `keyboardInteractive` | bool | `false` | 允许 keyboard-interactive 认证(OTP/MFA)并复用配置的密码 |
|
|
228
|
+
| `backend` | string | `native` | SSH 传输后端(issue #51)。`native`=内置 ssh2(密码/私钥/agent/动态码);`openssh`=**调用本机 `ssh` 命令**,因此支持 GSSAPI/Kerberos(`kinit` 后免密)以及 `ProxyCommand`、`PKCS11Provider`、`Match` 等 OpenSSH 专有特性。**可按机器单独设置**。代价:每条命令新建一次连接(比 native 慢);SFTP 仍复用单条长连接。主机名会被**原样**交给 ssh(`~/.ssh/config` 是配置唯一来源),主机密钥也由 ssh 用你自己的 `known_hosts` 校验 |
|
|
229
|
+
| `sshBinary` | string | `''` | `openssh` 后端调用的 `ssh` 可执行文件(留空=用 PATH 里的 `ssh`) |
|
|
227
230
|
| `proxy` | object | — | 跳板机:`{ host, port?, username?, password?, privateKeyPath? }` |
|
|
228
231
|
| `autoPush` | bool | `false` | 镜像内文件被编辑后自动推回远端(watcher,带防抖) |
|
|
229
232
|
| `auditLog` | bool | `true` | 把执行的命令追加到 `$DSH_HOME/remote-workspaces/audit.log` |
|
|
@@ -258,6 +261,8 @@ scripts/dev-run.sh --status # 是否在运行
|
|
|
258
261
|
|
|
259
262
|
**连不上内网机器** —— 填「跳板机」主机(也可先把跳板机本身配成一台机器)。
|
|
260
263
|
|
|
264
|
+
**企业机器只允许 GSSAPI/Kerberos(`kinit` 后 `ssh` 能进,插件进不去)** —— 在设置 → 远程工作区 → 高级配置里把「连接后端」改成 **`openssh`**(调用本机 `ssh`),并勾选「使用 ssh_config 别名」,Host 填 `~/.ssh/config` 里那个**别名**(不是 IP)。这样登录由本机 ssh 用你的 Kerberos 票据完成,且 `ProxyCommand`、`GSSAPIAuthentication` 等全部照 `~/.ssh/config` 生效 —— **配置只有 `~/.ssh/config` 一个来源**(issue #50/#51)。命令行等价写法:`rw_connect(host="<别名>", useSshConfig=true, backend="openssh")`。
|
|
265
|
+
|
|
261
266
|
**`rw_sync`/`rw_push` 报冲突** —— 两边都改过的文件会被跳过并列出(绝不静默覆盖);手动合并后重试,或用 `force=true` 以一边为准。
|
|
262
267
|
|
|
263
268
|
**Windows 远程** —— 全部走 SFTP,不依赖 POSIX shell;中文文件用 `encoding=gbk`。
|
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 条目',
|
|
@@ -257,6 +266,9 @@ window.__ModuleLoader__.load({
|
|
|
257
266
|
'explorer.noRemoteWs': '未设置远程工作区',
|
|
258
267
|
'explorer.sessionLocal': '当前会话未使用远程工作区',
|
|
259
268
|
'explorer.localSession': '当前会话是本地工作区,未使用远程文件。\n\n如需访问远程,可在设置页将某台机器「设为当前」,再用上方「…」选择远程工作区。',
|
|
269
|
+
// issue #49:会话列表里标识「这是远程会话,连的是哪台机」
|
|
270
|
+
'session.remoteBadge': '远程会话:{target}',
|
|
271
|
+
'session.remoteHover': '远程主机:{target}',
|
|
260
272
|
'explorer.loading': '加载中…',
|
|
261
273
|
'explorer.dirMissing': '目录不存在或已被删除',
|
|
262
274
|
'explorer.notConnected': '未连接远程主机(重启后需在设置中将机器「设为当前」,或刷新右侧列)',
|
|
@@ -315,6 +327,15 @@ window.__ModuleLoader__.load({
|
|
|
315
327
|
'settings.addMachine': 'Add machine',
|
|
316
328
|
'settings.editMachine': 'Edit machine',
|
|
317
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',
|
|
318
339
|
'settings.sshImport': 'Import from ~/.ssh/config',
|
|
319
340
|
'settings.sshCollapse': 'Collapse',
|
|
320
341
|
'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
|
|
@@ -523,6 +544,9 @@ window.__ModuleLoader__.load({
|
|
|
523
544
|
'explorer.noRemoteWs': 'No remote workspace set',
|
|
524
545
|
'explorer.sessionLocal': 'This session does not use a remote workspace',
|
|
525
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}',
|
|
526
550
|
'explorer.loading': 'Loading…',
|
|
527
551
|
'explorer.dirMissing': 'Directory does not exist or was deleted',
|
|
528
552
|
'explorer.notConnected': 'Not connected to the remote host (after a restart, set a machine as "current" in Settings, or refresh the right column)',
|
|
@@ -763,8 +787,40 @@ window.__ModuleLoader__.load({
|
|
|
763
787
|
document.head.appendChild(el)
|
|
764
788
|
} catch { /* vm tests / no DOM */ }
|
|
765
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
|
+
|
|
766
821
|
function PromptDialog(props) {
|
|
767
822
|
const { title, initial, placeholder, onSubmit, onCancel } = props
|
|
823
|
+
const backdrop = useBackdropGuard()
|
|
768
824
|
const [val, setVal] = React.useState(initial || '')
|
|
769
825
|
React.useEffect(() => {
|
|
770
826
|
try {
|
|
@@ -779,7 +835,10 @@ window.__ModuleLoader__.load({
|
|
|
779
835
|
}, [val, onSubmit, onCancel])
|
|
780
836
|
return React.createElement('div', {
|
|
781
837
|
style: { position: 'fixed', inset: 0, zIndex: 1000000, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 },
|
|
782
|
-
|
|
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() },
|
|
783
842
|
},
|
|
784
843
|
React.createElement('div', {
|
|
785
844
|
className: 'dsh-rw-modal-panel',
|
|
@@ -890,6 +949,9 @@ window.__ModuleLoader__.load({
|
|
|
890
949
|
host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
|
|
891
950
|
password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
892
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',
|
|
893
955
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
894
956
|
})
|
|
895
957
|
.then((r) => {
|
|
@@ -1075,15 +1137,17 @@ window.__ModuleLoader__.load({
|
|
|
1075
1137
|
encryptPassword: !!(m.credentialBackend && m.credentialBackend !== 'plain'),
|
|
1076
1138
|
proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
|
|
1077
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 || '',
|
|
1078
1142
|
id: m.id,
|
|
1079
1143
|
useSshConfig: !!m.useSshConfig,
|
|
1080
1144
|
})
|
|
1081
|
-
// 编辑时若该机器用了高级配置(私钥/agent
|
|
1082
|
-
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')) {
|
|
1083
1147
|
setAdvOpen(true)
|
|
1084
1148
|
}
|
|
1085
1149
|
}
|
|
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 })
|
|
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 })
|
|
1087
1151
|
const save = (action) => {
|
|
1088
1152
|
setBusy(true); setErr(''); setMsg('')
|
|
1089
1153
|
if (action === 'delete') {
|
|
@@ -1095,6 +1159,8 @@ window.__ModuleLoader__.load({
|
|
|
1095
1159
|
username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
|
|
1096
1160
|
workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
|
|
1097
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 || '',
|
|
1098
1164
|
proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
|
|
1099
1165
|
}).then((r) => {
|
|
1100
1166
|
refresh(); clearForm()
|
|
@@ -1185,6 +1251,10 @@ window.__ModuleLoader__.load({
|
|
|
1185
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 }) : ''))
|
|
1186
1252
|
: (m.username + '@' + m.host + ':' + m.port)),
|
|
1187
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,
|
|
1188
1258
|
m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
|
|
1189
1259
|
m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
|
|
1190
1260
|
(m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
|
|
@@ -1246,6 +1316,23 @@ window.__ModuleLoader__.load({
|
|
|
1246
1316
|
style: { ...buttonS, width: '100%', marginBottom: advOpen ? 10 : 0, color: advOpen ? T.primary : 'inherit', fontWeight: advOpen ? 600 : 400, borderStyle: 'dashed' },
|
|
1247
1317
|
}, advOpen ? tr('settings.advCollapse') : tr('settings.advExpand')),
|
|
1248
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,
|
|
1249
1336
|
row(tr('settings.privateKey'), React.createElement('input', { value: form.privateKeyPath, onChange: setF('privateKeyPath'), placeholder: 'Empty = password or agent', style: inputS }), 'k'),
|
|
1250
1337
|
row(tr('settings.passphrase'), React.createElement('input', { type: 'password', value: form.passphrase, onChange: setF('passphrase'), placeholder: 'Encrypted private key passphrase', style: inputS }), 'pp'),
|
|
1251
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'),
|
|
@@ -1608,6 +1695,13 @@ window.__ModuleLoader__.load({
|
|
|
1608
1695
|
const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
|
|
1609
1696
|
const suggestTimer = React.useRef(null)
|
|
1610
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()
|
|
1611
1705
|
// Autocomplete performance (slow per-keystroke remote scans):
|
|
1612
1706
|
// - postedCurrentRef/currentPromiseRef: POST /current once per machine
|
|
1613
1707
|
// switch; concurrent callers share ONE in-flight promise and a failed
|
|
@@ -2037,7 +2131,8 @@ window.__ModuleLoader__.load({
|
|
|
2037
2131
|
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
|
2038
2132
|
return React.createElement('div', {
|
|
2039
2133
|
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
|
2040
|
-
|
|
2134
|
+
onMouseDown: localBackdrop.onMouseDown,
|
|
2135
|
+
onClick: (e) => { if (localBackdrop.onClick(e)) setLocalPop(null) },
|
|
2041
2136
|
},
|
|
2042
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() },
|
|
2043
2138
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
|
|
@@ -2148,7 +2243,15 @@ window.__ModuleLoader__.load({
|
|
|
2148
2243
|
onClick: () => switchTab(t),
|
|
2149
2244
|
style: { ...buttonS, fontWeight: tab === t ? 700 : 400, padding: '5px 14px' },
|
|
2150
2245
|
}, lbl)
|
|
2151
|
-
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
|
+
},
|
|
2152
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() },
|
|
2153
2256
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 } },
|
|
2154
2257
|
React.createElement('div', { style: { fontSize: 15, fontWeight: 650 } }, tr('picker.title')),
|
|
@@ -2169,7 +2272,7 @@ window.__ModuleLoader__.load({
|
|
|
2169
2272
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
2170
2273
|
localPop ? renderLocalPopup() : null,
|
|
2171
2274
|
)
|
|
2172
|
-
: 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' } },
|
|
2173
2276
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
2174
2277
|
React.createElement('label', { style: { fontSize: 12, opacity: 0.8, whiteSpace: 'nowrap' } }, tr('picker.remoteMachine')),
|
|
2175
2278
|
React.createElement('div', { ref: ddRef, style: { position: 'relative', display: 'inline-block' } },
|
|
@@ -2209,14 +2312,29 @@ window.__ModuleLoader__.load({
|
|
|
2209
2312
|
React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, whiteSpace: 'nowrap' }, onClick: pickHome, disabled: !machineId }, tr('picker.home')),
|
|
2210
2313
|
),
|
|
2211
2314
|
(suggestOpen && suggest.length)
|
|
2212
|
-
|
|
2213
|
-
|
|
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)),
|
|
2214
2329
|
)
|
|
2215
2330
|
: null,
|
|
2216
2331
|
),
|
|
2217
2332
|
popOpen ? renderDirPopup() : null,
|
|
2218
2333
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
2219
|
-
|
|
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') } },
|
|
2220
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 }) : ''),
|
|
2221
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')),
|
|
2222
2340
|
),
|
|
@@ -2275,6 +2393,199 @@ window.__ModuleLoader__.load({
|
|
|
2275
2393
|
.catch(() => '')
|
|
2276
2394
|
}
|
|
2277
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
|
+
|
|
2278
2589
|
function readRemoteFile(path, maxBytes, sessionId) {
|
|
2279
2590
|
return api('POST', '/dsh-remote/read', withSessionBody({ path, maxBytes }, sessionId)).catch((e) => {
|
|
2280
2591
|
throw new Error(tr('file.readFail', { msg: ((e && e.message) || e) }))
|
|
@@ -3083,6 +3394,19 @@ window.__ModuleLoader__.load({
|
|
|
3083
3394
|
inner.effect(() => registerSidebarIntegration(inner), 'dsh-remote.betterSidebar')
|
|
3084
3395
|
})
|
|
3085
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
|
+
)
|
|
3086
3410
|
}
|
|
3087
3411
|
|
|
3088
3412
|
exports.name = name
|
package/lib/forwards.js
CHANGED
|
@@ -155,6 +155,22 @@ export class ForwardManager {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
_startReverse(d, client) {
|
|
158
|
+
// The system-OpenSSH backend (issue #51) cannot do ssh2's two-phase
|
|
159
|
+
// `forwardIn` + `tcpip`: the `ssh -R` CLI needs the final target as an
|
|
160
|
+
// argument, so it exposes `reverseForward()` instead. Prefer it when
|
|
161
|
+
// present — otherwise every reverse tunnel would fail on that backend.
|
|
162
|
+
if (typeof client.reverseForward === 'function') {
|
|
163
|
+
return client.reverseForward({
|
|
164
|
+
bindHost: '127.0.0.1',
|
|
165
|
+
listenPort: d.listenPort,
|
|
166
|
+
targetHost: d.targetHost,
|
|
167
|
+
targetPort: d.targetPort,
|
|
168
|
+
}).then((handle) => {
|
|
169
|
+
this.servers.set(d.id, { kind: 'reverse-cli', handle, client })
|
|
170
|
+
}).catch((err) => {
|
|
171
|
+
throw new Error(`远端监听 ${d.listenPort} 失败: ${(err && err.message) || err}`)
|
|
172
|
+
})
|
|
173
|
+
}
|
|
158
174
|
return new Promise((resolve, reject) => {
|
|
159
175
|
// ssh2 calls the remote TCP listen API `forwardIn` (NOT openssh_forwardIn,
|
|
160
176
|
// which in ssh2 >= 1.16 is stream-local only).
|
|
@@ -170,6 +186,9 @@ export class ForwardManager {
|
|
|
170
186
|
}
|
|
171
187
|
|
|
172
188
|
_handleTcpip(info, accept, reject) {
|
|
189
|
+
// Only the ssh2 backend reaches here: `ssh -R` handles the remote listener
|
|
190
|
+
// and its targets entirely inside the CLI, so a 'reverse-cli' entry never
|
|
191
|
+
// produces a 'tcpip' event.
|
|
173
192
|
for (const entry of this.servers.values()) {
|
|
174
193
|
if (entry.kind === 'reverse' && Number(info.destPort) === Number(this._defFor(entry).listenPort)) {
|
|
175
194
|
const channel = accept()
|
|
@@ -205,6 +224,9 @@ export class ForwardManager {
|
|
|
205
224
|
if (entry.kind === 'local') {
|
|
206
225
|
entry.server.close()
|
|
207
226
|
for (const s of entry.sockets) { try { s.destroy() } catch {} }
|
|
227
|
+
} else if (entry.kind === 'reverse-cli') {
|
|
228
|
+
// The `ssh -R` child IS the tunnel: killing it closes the remote listener.
|
|
229
|
+
if (entry.handle && typeof entry.handle.close === 'function') entry.handle.close()
|
|
208
230
|
} else if (entry.client && typeof entry.client.unforwardIn === 'function') {
|
|
209
231
|
const d = this._defFor(entry)
|
|
210
232
|
entry.client.unforwardIn('127.0.0.1', d.listenPort, () => {})
|