dsh-remote 0.8.21 → 0.8.23

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
@@ -48,9 +48,19 @@ window.__ModuleLoader__.load({
48
48
  'settings.noActive': '当前没有活动远程机:保存的机器只是备用连接,不会自动进入会话上下文(本地会话不受影响)。',
49
49
  'settings.addMachine': '添加机器',
50
50
  'settings.editMachine': '编辑机器',
51
+ 'settings.badgeSshConfig': 'ssh cfg',
51
52
  'settings.sshImport': '从 ~/.ssh/config 导入',
52
53
  'settings.sshCollapse': '收起',
53
54
  'settings.sshNoEntries': '~/.ssh/config 里没有可导入的 Host 条目',
55
+ 'settings.sshUseAlias': '用别名(不复制)',
56
+ 'settings.sshCopyFields': '复制字段',
57
+ 'settings.sshAliasHint': '别名模式:机器只记住「{alias}」这个名字,主机/用户/端口/私钥/跳板每次连接都从 ~/.ssh/config 实时解析(改配置立刻生效)。',
58
+ 'settings.useSshConfig': '使用 ~/.ssh/config 别名(只存别名,不存副本)',
59
+ 'settings.useSshConfigHint': '留空或保持默认的字段(用户 root、端口 22、私钥、跳板)将从 ~/.ssh/config 实时解析;填了非默认值则覆盖。',
60
+ 'settings.sshResolvedTo': '→ {user}@{host}:{port}',
61
+ 'settings.sshResolvedNone': '~/.ssh/config 中没有匹配「{alias}」的 Host 条目',
62
+ 'settings.sshResolvedWarn': '注意:{msg}',
63
+ 'settings.sshAliasSaved': '已按别名保存(连接时解析)',
54
64
  'settings.name': '名称',
55
65
  'settings.host': '主机',
56
66
  'settings.port': '端口',
@@ -262,9 +272,19 @@ window.__ModuleLoader__.load({
262
272
  'settings.noActive': 'No active remote machine: saved machines are standby connections and never enter a session context automatically (local sessions are unaffected).',
263
273
  'settings.addMachine': 'Add machine',
264
274
  'settings.editMachine': 'Edit machine',
275
+ 'settings.badgeSshConfig': 'ssh cfg',
265
276
  'settings.sshImport': 'Import from ~/.ssh/config',
266
277
  'settings.sshCollapse': 'Collapse',
267
278
  'settings.sshNoEntries': 'No importable Host entries in ~/.ssh/config',
279
+ 'settings.sshUseAlias': 'Use alias (no copy)',
280
+ 'settings.sshCopyFields': 'Copy fields',
281
+ 'settings.sshAliasHint': 'Alias mode: the machine remembers only the name "{alias}"; host/user/port/key/jump host are resolved from ~/.ssh/config at every connect, so editing that file takes effect immediately.',
282
+ 'settings.useSshConfig': 'Use a ~/.ssh/config alias (keep the alias, not a copy)',
283
+ 'settings.useSshConfigHint': 'Fields left empty or at their default (user root, port 22, key, jump host) come from ~/.ssh/config at connect time; a non-default value overrides it.',
284
+ 'settings.sshResolvedTo': '→ {user}@{host}:{port}',
285
+ 'settings.sshResolvedNone': 'no ~/.ssh/config block matches "{alias}"',
286
+ 'settings.sshResolvedWarn': 'Heads-up: {msg}',
287
+ 'settings.sshAliasSaved': 'Saved as an alias (resolved on connect)',
268
288
  'settings.name': 'Name',
269
289
  'settings.host': 'Host',
270
290
  'settings.port': 'Port',
@@ -741,6 +761,9 @@ window.__ModuleLoader__.load({
741
761
  name: '', host: '', port: '22', username: 'root', password: '', privateKeyPath: '',
742
762
  passphrase: '', workspace: '', hostKeyMode: '', useAgent: false, keyboardInteractive: false,
743
763
  encryptPassword: false, proxyHost: '', proxyPort: '22', proxyUser: '', proxyPassword: '', proxyKey: '', id: '',
764
+ // Issue #38: alias mode — `host` is a ~/.ssh/config Host name and the
765
+ // resolved values are never copied into the registry.
766
+ useSshConfig: false,
744
767
  })
745
768
  const [busy, setBusy] = React.useState(false)
746
769
  const [msg, setMsg] = React.useState('')
@@ -762,12 +785,20 @@ window.__ModuleLoader__.load({
762
785
  api('POST', '/dsh-remote/test-connect', {
763
786
  host: form.host, port: Number(form.port) || 22, username: form.username || 'root',
764
787
  password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
788
+ useSshConfig: !!form.useSshConfig,
765
789
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
766
790
  })
767
791
  .then((r) => {
768
792
  if (r && r.ok) {
769
793
  const shellTag = r.platform === 'windows' ? (r.gitBash ? tr('settings.shellWin') : tr('settings.shellWinNoGit')) : (r.platform === 'posix' ? tr('settings.shellPosix') : '')
770
- setTst('ok'); setMsg(r.latencyMs != null ? tr('settings.connOkWith', { user: r.user, host: r.host, latency: r.latencyMs, shell: shellTag }) : tr('settings.connOk')); refresh()
794
+ setTst('ok'); setMsg(r.latencyMs != null ? tr('settings.connOkWith', { user: r.user, host: r.host, latency: r.latencyMs, shell: shellTag }) : tr('settings.connOk'))
795
+ // Issue #38: show where an alias resolved to (and what could not
796
+ // be honoured), instead of leaving the user guessing.
797
+ if (r.alias) {
798
+ setMsg((m) => m + ' ' + tr('settings.sshResolvedTo', { user: r.alias.user || '?', host: r.alias.host, port: r.alias.port }))
799
+ if (r.alias.warnings && r.alias.warnings.length) setErr(tr('settings.sshResolvedWarn', { msg: r.alias.warnings.join(' ') }))
800
+ }
801
+ refresh()
771
802
  }
772
803
  else { setTst('failing'); setErr((r && r.error) || tr('settings.connFail')) }
773
804
  })
@@ -832,13 +863,14 @@ window.__ModuleLoader__.load({
832
863
  proxyHost: (m.proxy && m.proxy.host) || '', proxyPort: String((m.proxy && m.proxy.port) || 22),
833
864
  proxyUser: (m.proxy && m.proxy.username) || '', proxyPassword: '', proxyKey: (m.proxy && m.proxy.privateKeyPath) || '',
834
865
  id: m.id,
866
+ useSshConfig: !!m.useSshConfig,
835
867
  })
836
868
  // 编辑时若该机器用了高级配置(私钥/agent/跳板/钥匙串等),自动展开折叠区
837
869
  if (m.privateKeyPath || m.useAgent || m.keyboardInteractive || m.workspace || (m.proxy && m.proxy.host) || (m.credentialBackend && m.credentialBackend !== 'plain')) {
838
870
  setAdvOpen(true)
839
871
  }
840
872
  }
841
- 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: '' })
873
+ 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 })
842
874
  const save = (action) => {
843
875
  setBusy(true); setErr(''); setMsg('')
844
876
  if (action === 'delete') {
@@ -849,7 +881,7 @@ window.__ModuleLoader__.load({
849
881
  action: form.id ? 'update' : 'add', id: form.id || undefined, name: form.name, host: form.host, port: Number(form.port) || 22,
850
882
  username: form.username, password: form.password, privateKeyPath: form.privateKeyPath, passphrase: form.passphrase,
851
883
  workspace: form.workspace, hostKeyMode: form.hostKeyMode, useAgent: form.useAgent, keyboardInteractive: form.keyboardInteractive,
852
- encryptPassword: form.encryptPassword,
884
+ encryptPassword: form.encryptPassword, useSshConfig: !!form.useSshConfig,
853
885
  proxy: form.proxyHost ? { host: form.proxyHost, port: Number(form.proxyPort) || 22, username: form.proxyUser || '', password: form.proxyPassword || '', privateKeyPath: form.proxyKey || '' } : undefined,
854
886
  }).then((r) => {
855
887
  refresh(); clearForm()
@@ -869,10 +901,25 @@ window.__ModuleLoader__.load({
869
901
  api('GET', '/dsh-remote/ssh-config').then((r) => setSshEntries(r && r.present ? (r.entries || []) : []))
870
902
  .catch((e) => setErr(tr('settings.sshReadFail', { msg: String((e && e.message) || e) })))
871
903
  }
904
+ // Issue #38, primary action: save the machine as an ALIAS. Nothing from
905
+ // ~/.ssh/config is copied into the registry — the host/user/port/key/jump
906
+ // host are re-read at every connect, so editing that file is enough.
907
+ const useSshAlias = (e) => {
908
+ setForm({
909
+ ...form,
910
+ name: e.host, host: e.host, port: '22', username: 'root',
911
+ privateKeyPath: '', passphrase: '', useSshConfig: true,
912
+ })
913
+ setSshEntries(null)
914
+ setMsg(tr('settings.sshAliasHint', { alias: e.host }))
915
+ }
916
+ // Legacy/escape hatch: materialise the current values as a normal machine
917
+ // (for users who want to override them individually).
872
918
  const applySshEntry = (e) => {
873
- setForm({ ...form,
919
+ setForm({
920
+ ...form,
874
921
  name: e.host, host: e.hostName || e.host, port: String(e.port || 22),
875
- username: e.user || 'root', privateKeyPath: e.identityFile || '',
922
+ username: e.user || 'root', privateKeyPath: e.identityFile || '', useSshConfig: false,
876
923
  })
877
924
  setSshEntries(null)
878
925
  setMsg(tr('settings.filledSsh'))
@@ -921,13 +968,21 @@ window.__ModuleLoader__.load({
921
968
  React.createElement('div', { style: { flex: 1, fontSize: 13, minWidth: 180 } },
922
969
  React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' } },
923
970
  React.createElement('span', { style: { fontWeight: 600 } }, m.name),
924
- React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } }, m.username + '@' + m.host + ':' + m.port),
971
+ React.createElement('code', { style: { fontSize: 12, opacity: 0.8 } },
972
+ m.useSshConfig ? (m.host + (m.sshConfigResolved && m.sshConfigResolved.matched ? ' ' + tr('settings.sshResolvedTo', { user: m.sshConfigResolved.username || '?', host: m.sshConfigResolved.host, port: m.sshConfigResolved.port }) : ''))
973
+ : (m.username + '@' + m.host + ':' + m.port)),
974
+ m.useSshConfig ? pill('', tr('settings.badgeSshConfig')) : null,
925
975
  m.id === currentId ? pill('is-ok', tr('settings.current')) : null,
926
976
  m.passwordSet ? pill('', tr('settings.badgePwd')) : null,
927
977
  (m.credentialBackend && m.credentialBackend !== 'plain') ? pill('', tr('settings.badgeKeychain')) : null,
928
978
  m.proxy && m.proxy.host ? pill('', tr('settings.badgeProxy')) : null,
929
979
  m.latencyMs != null ? React.createElement('span', { style: { fontSize: 11, opacity: 0.6 } }, m.latencyMs + 'ms') : null,
930
- )),
980
+ ),
981
+ // Issue #38: an alias that no longer resolves (or a ProxyJump
982
+ // the plugin had to trim) must be visible, not silent.
983
+ (m.sshConfigResolved && m.sshConfigResolved.warnings && m.sshConfigResolved.warnings.length)
984
+ ? React.createElement('div', { style: { fontSize: 11, color: T.warn, marginTop: 2 } }, tr('settings.sshResolvedWarn', { msg: m.sshConfigResolved.warnings.join(' ') }))
985
+ : null),
931
986
  React.createElement('div', { style: { display: 'flex', gap: 6, flexShrink: 0 } },
932
987
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => startEdit(m) }, tr('settings.edit')),
933
988
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: buttonS, onClick: () => del(m.id) }, tr('settings.delete')),
@@ -943,13 +998,30 @@ window.__ModuleLoader__.load({
943
998
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 } },
944
999
  React.createElement('div', { style: { fontSize: 13, fontWeight: 600 } }, form.id ? tr('settings.editMachine') : tr('settings.addMachine')),
945
1000
  React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 12, padding: '3px 8px' }, onClick: () => (sshEntries ? setSshEntries(null) : importSsh()) }, sshEntries ? tr('settings.sshCollapse') : tr('settings.sshImport'))),
946
- sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 160, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
947
- sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, className: 'dsh-rw-list-item', onClick: () => applySshEntry(e), style: { padding: '6px 10px', cursor: 'pointer', fontSize: 12, borderBottom: '1px solid ' + T.border } },
948
- e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')))
1001
+ sshEntries ? React.createElement('div', { style: { marginBottom: 8, maxHeight: 200, overflowY: 'auto', border: '1px solid ' + T.border, borderRadius: T.radius } },
1002
+ sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, className: 'dsh-rw-list-item', style: { padding: '6px 10px', fontSize: 12, borderBottom: '1px solid ' + T.border, display: 'flex', gap: 8, alignItems: 'center' } },
1003
+ React.createElement('div', { onClick: () => useSshAlias(e), style: { flex: 1, cursor: 'pointer' }, title: tr('settings.sshUseAlias') },
1004
+ e.host + ' → ' + (e.hostName || '?') + (e.user ? ' (' + e.user + ')' : '') + (e.identityFile ? ' [key]' : '') + (e.proxyJump ? ' ⛳' : '')),
1005
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap' }, onClick: () => useSshAlias(e) }, tr('settings.sshUseAlias')),
1006
+ React.createElement('button', { type: 'button', className: 'dsh-rw-btn', style: { ...buttonS, fontSize: 11, padding: '2px 8px', whiteSpace: 'nowrap', opacity: 0.8 }, onClick: () => applySshEntry(e) }, tr('settings.sshCopyFields'))))
949
1007
  : React.createElement('div', { style: { padding: 8, fontSize: 12, opacity: 0.6 } }, tr('settings.sshNoEntries')))
950
1008
  : null,
951
1009
  row(tr('settings.name'), React.createElement('input', { className: 'dsh-rw-input', value: form.name, onChange: setF('name'), placeholder: tr('settings.name') + ' (e.g. build-server)', style: inputS }), 'n'),
952
- row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: 'IP or hostname', style: inputS }), 'h'),
1010
+ row(tr('settings.host'), React.createElement('input', { className: 'dsh-rw-input', value: form.host, onChange: setF('host'), placeholder: form.useSshConfig ? tr('settings.sshImport') + ' Host 别名' : 'IP or hostname', style: inputS }), 'h'),
1011
+ row(tr('settings.useSshConfig'), React.createElement('input', { type: 'checkbox', checked: !!form.useSshConfig, onChange: setFb('useSshConfig') }), 'sshc'),
1012
+ form.useSshConfig ? React.createElement('div', { style: { fontSize: 11, opacity: 0.8, marginBottom: 8, lineHeight: 1.5 } }, tr('settings.useSshConfigHint')) : null,
1013
+ form.useSshConfig ? (() => {
1014
+ // Preview where the alias points, from the same resolution the
1015
+ // connect path uses (the host list carries it — issue #38).
1016
+ const preview = machines.find((m) => m.host === form.host && m.useSshConfig)
1017
+ const res = preview && preview.sshConfigResolved
1018
+ if (!res) return null
1019
+ return React.createElement('div', { style: { fontSize: 11, marginBottom: 8, color: res.matched ? T.ok : T.warn, lineHeight: 1.5 } },
1020
+ res.matched
1021
+ ? form.host + ' ' + tr('settings.sshResolvedTo', { user: res.username || '?', host: res.host, port: res.port })
1022
+ : tr('settings.sshResolvedNone', { alias: form.host }),
1023
+ (res.warnings && res.warnings.length) ? React.createElement('div', { style: { opacity: 0.85 } }, tr('settings.sshResolvedWarn', { msg: res.warnings.join(' ') })) : null)
1024
+ })() : null,
953
1025
  row(tr('settings.port'), React.createElement('input', { className: 'dsh-rw-input', value: form.port, onChange: setF('port'), placeholder: '22', style: { ...inputS, width: 70 } }), 'p'),
954
1026
  row(tr('settings.user'), React.createElement('input', { className: 'dsh-rw-input', value: form.username, onChange: setF('username'), placeholder: 'root', style: inputS }), 'u'),
955
1027
  row(tr('settings.password'), React.createElement('input', { className: 'dsh-rw-input', type: 'password', value: form.password, onChange: setF('password'), placeholder: 'SSH password (hidden)', style: inputS }), 'w'),
@@ -1141,9 +1213,25 @@ window.__ModuleLoader__.load({
1141
1213
  const [mkdirPrompt, setMkdirPrompt] = React.useState(false)
1142
1214
  const suggestTimer = React.useRef(null)
1143
1215
  const suggestRef = React.useRef(null)
1216
+ // Autocomplete performance (slow per-keystroke remote scans):
1217
+ // - postedCurrentRef/currentPromiseRef: POST /current once per machine
1218
+ // switch; concurrent callers share ONE in-flight promise and a failed
1219
+ // switch REJECTS (never run /ls — or cache its listing — on the wrong
1220
+ // machine)
1221
+ // - lsCacheRef/lsInflightRef: one /ls per (machine, dir), TTL-cached
1222
+ // - lsGenRef/fetchSeqRef: per-key fetch generation — a cache write from
1223
+ // a superseded fetch (fresh navigation, picker reopen) is dropped
1224
+ // - suggestSeqRef: drop stale suggestion responses (older keystrokes)
1225
+ const postedCurrentRef = React.useRef('')
1226
+ const currentPromiseRef = React.useRef(null)
1227
+ const lsCacheRef = React.useRef(new Map())
1228
+ const lsInflightRef = React.useRef(new Map())
1229
+ const lsGenRef = React.useRef(new Map())
1230
+ const fetchSeqRef = React.useRef(0)
1231
+ const suggestSeqRef = React.useRef(0)
1144
1232
  React.useEffect(() => {
1145
1233
  if (!suggestOpen) return
1146
- const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) setSuggestOpen(false) }
1234
+ const onDown = (ev) => { if (suggestRef.current && !suggestRef.current.contains(ev.target)) { suggestSeqRef.current++; setSuggestOpen(false) } }
1147
1235
  document.addEventListener('mousedown', onDown)
1148
1236
  return () => document.removeEventListener('mousedown', onDown)
1149
1237
  }, [suggestOpen])
@@ -1175,26 +1263,125 @@ window.__ModuleLoader__.load({
1175
1263
  return b.replace(/[\\/]+$/, '') + sep + n
1176
1264
  }
1177
1265
 
1178
- const loadLevels = (id, p, toIndex) => {
1179
- if (!id) return
1180
- setLoading(true); setErr('')
1181
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
1182
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(p || '')))
1183
- .then((res) => {
1184
- setWin(res && res.platform === 'windows')
1185
- const real = res && res.path ? res.path : (p || '')
1266
+ // Identity of the machine a listing was answered for (the /ls response
1267
+ // echoes it), checked against the machine the fetch INTENDED. The picker
1268
+ // memoizes "machine already active", but the settings page can switch
1269
+ // machines too — validate every listing before trusting the memo. A
1270
+ // machine may show up as its ~/.ssh/config alias OR its resolved host, so
1271
+ // accept either identity; nothing to compare against → accept.
1272
+ const echoMatches = (id, echo) => {
1273
+ if (!echo || !echo.host) return true // older host half without an echo
1274
+ const m = (machines || []).find((x) => x && x.id === id)
1275
+ if (!m) return true
1276
+ const cands = [{ host: String(m.host || ''), port: Number(m.port) || 22, username: String(m.username || '') }]
1277
+ const r = m.sshConfigResolved
1278
+ if (r && r.host) cands.push({ host: String(r.host), port: Number(r.port) || 22, username: String(r.username || m.username || '') })
1279
+ return cands.some((c) => c.host === String(echo.host) && c.port === (Number(echo.port) || 22) && c.username === String(echo.username || ''))
1280
+ }
1281
+
1282
+ // POST /current once per machine switch. The picker used to post it before
1283
+ // EVERY /ls; the host re-applies the machine on that route, which used to
1284
+ // close the live SSH connection — so each autocomplete keystroke paid a
1285
+ // full reconnect. The memo is set only on SUCCESS and concurrent callers
1286
+ // share ONE in-flight promise, so no request can overtake a pending switch
1287
+ // and read the OLD machine's listing. A failed switch REJECTS: the
1288
+ // caller's fetch chain aborts WITHOUT writing the cache.
1289
+ const ensureCurrent = (id) => {
1290
+ if (!id) return Promise.resolve()
1291
+ if (postedCurrentRef.current === id) return Promise.resolve()
1292
+ const inflight = currentPromiseRef.current
1293
+ if (inflight) {
1294
+ if (inflight.id === id) return inflight.promise
1295
+ // Serialize switches: ours must land AFTER any pending one (the host
1296
+ // applies whichever POST arrives last), never race it.
1297
+ return inflight.promise.then(() => ensureCurrent(id), () => ensureCurrent(id))
1298
+ }
1299
+ const p = api('POST', '/dsh-remote/current', { id }).then(() => {
1300
+ postedCurrentRef.current = id
1301
+ })
1302
+ const entry = { id, promise: p }
1303
+ currentPromiseRef.current = entry
1304
+ const clear = () => { if (currentPromiseRef.current === entry) currentPromiseRef.current = null }
1305
+ p.then(clear, clear)
1306
+ return p
1307
+ }
1308
+
1309
+ const LS_TTL_MS = 30000
1310
+ // Cached remote listing per (machine, dir): typing inside one parent
1311
+ // directory used to re-fetch the identical list on every keystroke.
1312
+ // `fresh` bypasses the cache (browse navigation / post-mkdir refresh) and
1313
+ // SUPERSEDES older fetches for the key: a cache write from a superseded
1314
+ // fetch is dropped (it still resolves its own callers), so a pre-mkdir
1315
+ // listing can never overwrite the post-mkdir one. Concurrent identical
1316
+ // requests share one in-flight fetch.
1317
+ const fetchDirList = (id, dirParam, fresh) => {
1318
+ const key = String(id) + '\u0000' + String(dirParam)
1319
+ const hit = lsCacheRef.current.get(key)
1320
+ if (!fresh && hit && Date.now() - hit.at < LS_TTL_MS) return Promise.resolve(hit.node)
1321
+ const inflight = fresh ? null : lsInflightRef.current.get(key)
1322
+ if (inflight) return inflight
1323
+ const gen = ++fetchSeqRef.current
1324
+ lsGenRef.current.set(key, gen)
1325
+ const toNode = (res) => {
1326
+ const real = res && res.path ? res.path : String(dirParam)
1186
1327
  // Server returns [{ type, name, size, mtime, path }] where `path` is
1187
1328
  // the FULL display-form path (C:\Users\dev on Windows) — the client
1188
1329
  // never joins paths itself, so drives and Windows paths just work.
1189
1330
  const list = Array.isArray(res && res.items)
1190
1331
  ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', size: it.size, mtime: it.mtime, path: it.path || joinDisplay(real, it.name), drive: !!it.drive }))
1191
1332
  : parseLs(res && res.text)
1192
- const node = { path: real, dirs: sortItems(list.filter((it) => it.dir)), all: sortItems(list) }
1333
+ return {
1334
+ path: real,
1335
+ platform: !!(res && res.platform === 'windows'),
1336
+ dirs: sortItems(list.filter((it) => it.dir)),
1337
+ all: sortItems(list),
1338
+ }
1339
+ }
1340
+ const run = (retried) => ensureCurrent(id)
1341
+ .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(dirParam)))
1342
+ .then((res) => {
1343
+ // External-switch defense: on an identity-echo mismatch the host is
1344
+ // no longer on the machine this fetch intended — bust the memo, force
1345
+ // one re-switch and retry ONCE. A mismatched listing is never cached.
1346
+ if (!echoMatches(id, res && res.machine)) {
1347
+ if (retried) return toNode(res)
1348
+ postedCurrentRef.current = ''
1349
+ return run(true)
1350
+ }
1351
+ const node = toNode(res)
1352
+ if (lsGenRef.current.get(key) === gen) lsCacheRef.current.set(key, { at: Date.now(), node })
1353
+ return node
1354
+ })
1355
+ const p = run(false)
1356
+ .finally(() => { if (lsInflightRef.current.get(key) === p) lsInflightRef.current.delete(key) })
1357
+ lsInflightRef.current.set(key, p)
1358
+ return p
1359
+ }
1360
+
1361
+ // Fresh session on every open: forget everything a previous open left
1362
+ // behind. A stale in-flight response must not repopulate the cache
1363
+ // (generation fence) and a stale suggestion response must not reopen the
1364
+ // dropdown on the now-live tree.
1365
+ const resetPickerFetchState = () => {
1366
+ postedCurrentRef.current = ''
1367
+ lsCacheRef.current.clear()
1368
+ lsInflightRef.current.clear()
1369
+ lsGenRef.current.clear()
1370
+ suggestSeqRef.current++
1371
+ }
1372
+
1373
+ const loadLevels = (id, p, toIndex) => {
1374
+ if (!id) return
1375
+ setLoading(true); setErr('')
1376
+ fetchDirList(id, p || '', true)
1377
+ .then((node) => {
1378
+ setWin(node.platform)
1379
+ const level = { path: node.path, dirs: node.dirs, all: node.all }
1193
1380
  setLevels((prev) => {
1194
1381
  const base = prev && prev.length ? prev.slice() : []
1195
1382
  let idx = typeof toIndex === 'number' && toIndex >= 0 ? toIndex : base.length
1196
- if (idx >= base.length) return base.concat([node])
1197
- base[idx] = node
1383
+ if (idx >= base.length) return base.concat([level])
1384
+ base[idx] = level
1198
1385
  return base.slice(0, idx + 1)
1199
1386
  })
1200
1387
  })
@@ -1207,6 +1394,12 @@ window.__ModuleLoader__.load({
1207
1394
  // path — reset the path bar (and the browse state) on every open.
1208
1395
  setPath(''); setLevels(null); setWin(false)
1209
1396
  setLocalPop(null); setNewDirName('')
1397
+ // Fresh session: re-post /current on the first request and forget
1398
+ // everything a previous open left behind (TTL bounds staleness within
1399
+ // one open; reopening should see the live tree) — cached AND in-flight
1400
+ // listings, plus any pending suggestion response.
1401
+ resetPickerFetchState()
1402
+ if (suggestTimer.current) { clearTimeout(suggestTimer.current); suggestTimer.current = null }
1210
1403
  api('GET', '/dsh-remote/machines').then((r) => {
1211
1404
  setMachines(r.machines || [])
1212
1405
  const cur = (r.machines || []).find((m) => m.id === r.currentId)
@@ -1301,11 +1494,38 @@ window.__ModuleLoader__.load({
1301
1494
  if (par) loadLevels(machineId, par, 1)
1302
1495
  }
1303
1496
 
1497
+ // Drop any pending suggestion response and close the dropdown.
1498
+ const killSuggestions = () => {
1499
+ suggestSeqRef.current++
1500
+ setSuggest([])
1501
+ setSuggestOpen(false)
1502
+ }
1503
+
1504
+ // One seq-guarded "fetch listing → derive suggestion paths" pipeline
1505
+ // shared by loadSuggestions (typing: dirs matching a prefix) and
1506
+ // continueSuggest (expanding a picked dir: all its entries) — the
1507
+ // differing parts stay at the call sites as the `pick` parameter. Only
1508
+ // the newest request owns the dropdown: a slow older /ls must never
1509
+ // overwrite the suggestions for the text now in the input. The catch
1510
+ // keeps the seq-guarded empty fallback (a failed machine switch rejects
1511
+ // too, and must leave the dropdown closed and empty).
1512
+ const suggestFrom = (id, dirParam, pick, fresh) => {
1513
+ const seq = ++suggestSeqRef.current
1514
+ return fetchDirList(id, dirParam, fresh).then((node) => {
1515
+ if (seq !== suggestSeqRef.current) return
1516
+ const matches = pick(node).slice(0, 40)
1517
+ setSuggest(matches)
1518
+ setSuggestOpen(!!matches.length)
1519
+ }).catch(() => {
1520
+ if (seq !== suggestSeqRef.current) return
1521
+ setSuggest([]); setSuggestOpen(false)
1522
+ })
1523
+ }
1524
+
1304
1525
  const loadSuggestions = (raw, mid) => {
1305
1526
  const id = mid || machineId
1306
- if (!id || !raw) { setSuggest([]); setSuggestOpen(false); return }
1307
1527
  const t = String(raw || '').trim()
1308
- if (!t) { setSuggest([]); setSuggestOpen(false); return }
1528
+ if (!id || !t) { killSuggestions(); return }
1309
1529
  // Separator-aware split: `\` when the input looks like a Windows path.
1310
1530
  const backslash = t.includes('\\') || /^[a-zA-Z]:/.test(t)
1311
1531
  const sep = backslash ? '\\' : '/'
@@ -1314,18 +1534,9 @@ window.__ModuleLoader__.load({
1314
1534
  if (idx < 0) { parent = backslash ? t + '\\' : '/'; lastSeg = backslash ? '' : t }
1315
1535
  else if (idx === t.length - 1) { parent = t.slice(0, idx) || '/'; lastSeg = '' }
1316
1536
  else { parent = t.slice(0, idx) || '/'; lastSeg = t.slice(idx + 1) }
1317
- api('POST', '/dsh-remote/current', { id }).catch(() => {})
1318
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(parent || '/')))
1319
- .then((res) => {
1320
- const list = Array.isArray(res && res.items)
1321
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(parent, it.name) }))
1322
- : []
1323
- const matches = list
1324
- .filter((it) => it.dir && (!lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase())))
1325
- .slice(0, 40).map((it) => it.path)
1326
- setSuggest(matches)
1327
- setSuggestOpen(!!matches.length)
1328
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
1537
+ suggestFrom(id, parent || '/', (node) => node.dirs
1538
+ .filter((it) => !lastSeg || it.name.toLowerCase().startsWith(String(lastSeg).toLowerCase()))
1539
+ .map((it) => it.path))
1329
1540
  }
1330
1541
 
1331
1542
  const onPathChange = (raw) => {
@@ -1335,18 +1546,13 @@ window.__ModuleLoader__.load({
1335
1546
  }
1336
1547
 
1337
1548
  const continueSuggest = (dir) => {
1338
- if (!machineId || !dir) { setSuggest([]); setSuggestOpen(false); return }
1549
+ if (!machineId || !dir) { killSuggestions(); return }
1339
1550
  setSuggestOpen(false)
1340
- api('POST', '/dsh-remote/current', { id: machineId }).catch(() => {})
1341
- .then(() => api('GET', '/dsh-remote/ls?path=' + encodeURIComponent(String(dir).replace(/[\\/]+$/, '') || '/')))
1342
- .then((res) => {
1343
- const list = Array.isArray(res && res.items)
1344
- ? res.items.map((it) => ({ name: it.name, dir: it.type === 'dir', path: it.path || joinDisplay(dir, it.name) }))
1345
- : []
1346
- const kids = list.slice(0, 40).map((it) => it.path)
1347
- setSuggest(kids)
1348
- setSuggestOpen(!!kids.length)
1349
- }).catch(() => { setSuggest([]); setSuggestOpen(false) })
1551
+ const target = String(dir).replace(/[\\/]+$/, '') || '/'
1552
+ // Expanding a just-selected directory is a NAVIGATION step: the caching
1553
+ // spec only sanctions "typing inside one parent directory, local
1554
+ // filtering", so fetch fresh instead of serving up to 30s-stale entries.
1555
+ suggestFrom(machineId, target, (node) => node.all.map((it) => it.path), true)
1350
1556
  }
1351
1557
 
1352
1558
  const selectSuggestion = (s) => {
@@ -1362,21 +1568,34 @@ window.__ModuleLoader__.load({
1362
1568
  const commitPath = (p) => {
1363
1569
  const target = String(p || '').trim()
1364
1570
  if (!target || !machineId || busy) return
1365
- setPopOpen(false); setSuggestOpen(false)
1366
- api('POST', '/dsh-remote/mirror', { path: target }).then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || ''))).catch((e) => setErr(String((e && e.message) || e)))
1571
+ setPopOpen(false); killSuggestions()
1572
+ // State the machine this pick was made on. The picker acts on the shared
1573
+ // active pool, and another actor (the settings page, or rw_connect in
1574
+ // another session) can switch it while the picker is open — the mirror
1575
+ // would then be created with THAT machine's identity while the user was
1576
+ // looking at this one's tree. The host re-asserts the named machine
1577
+ // instead of trusting whichever happens to be active.
1578
+ ensureCurrent(machineId)
1579
+ .then(() => api('POST', '/dsh-remote/mirror', { path: target, machineId }))
1580
+ .then((res) => (res && res.localMirror ? onPicked(res.localMirror) : setErr((res && res.error) || '')))
1581
+ .catch((e) => setErr(String((e && e.message) || e)))
1367
1582
  }
1368
1583
 
1369
1584
  const acceptBrowserPick = (p) => {
1370
1585
  setPath(String(p || ''))
1371
- setSuggestOpen(false)
1586
+ killSuggestions()
1372
1587
  setPopOpen(false)
1373
1588
  }
1374
1589
 
1375
1590
  const pickHome = () => {
1376
- api('POST', '/dsh-remote/home').then((r) => {
1377
- if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
1378
- else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
1379
- }).catch((e) => setErr(String((e && e.message) || e)))
1591
+ // Same identity contract as commitPath: /home is answered by the active
1592
+ // pool, so name the machine this picker is showing.
1593
+ ensureCurrent(machineId)
1594
+ .then(() => api('POST', '/dsh-remote/home', { machineId }))
1595
+ .then((r) => {
1596
+ if (r && r.home) { setPath(r.home); setErr(''); loadSuggestions(r.home) }
1597
+ else setErr((r && (r.hint || r.error)) || tr('picker.noHome'))
1598
+ }).catch((e) => setErr(String((e && e.message) || e)))
1380
1599
  }
1381
1600
 
1382
1601
  const mkdirHere = () => {
@@ -1512,7 +1731,7 @@ window.__ModuleLoader__.load({
1512
1731
  if (mkdirPrompt) return
1513
1732
  if (e.key === 'Escape') {
1514
1733
  if (popOpen) { e.preventDefault(); setPopOpen(false); return }
1515
- if (suggestOpen) { e.preventDefault(); setSuggestOpen(false); return }
1734
+ if (suggestOpen) { e.preventDefault(); suggestSeqRef.current++; setSuggestOpen(false); return }
1516
1735
  if (!busy) { e.preventDefault(); onCancel() }
1517
1736
  return
1518
1737
  }