dsh-remote 0.8.42 → 0.8.45

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