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/README.md +28 -1
- package/README.zh.md +26 -1
- package/lib/binding.js +9 -3
- package/lib/client.js +275 -56
- package/lib/file-reference.js +457 -0
- package/lib/index.js +456 -84
- package/lib/pool.js +49 -18
- package/lib/routes-fs.js +14 -2
- package/lib/sshconfig.js +415 -18
- package/package.json +1 -1
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'))
|
|
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({
|
|
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 } },
|
|
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:
|
|
947
|
-
sshEntries.length ? sshEntries.map((e) => React.createElement('div', { key: e.host, className: 'dsh-rw-list-item',
|
|
948
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
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([
|
|
1197
|
-
base[idx] =
|
|
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) {
|
|
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
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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) {
|
|
1549
|
+
if (!machineId || !dir) { killSuggestions(); return }
|
|
1339
1550
|
setSuggestOpen(false)
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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);
|
|
1366
|
-
|
|
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
|
-
|
|
1586
|
+
killSuggestions()
|
|
1372
1587
|
setPopOpen(false)
|
|
1373
1588
|
}
|
|
1374
1589
|
|
|
1375
1590
|
const pickHome = () => {
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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
|
}
|