dsh-ssh-manage 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -7
- package/README.md +10 -9
- package/client.js +319 -19
- package/index.js +120 -26
- package/lib/known-hosts.js +136 -0
- package/lib/metadata.js +28 -11
- package/lib/ssh-client.js +20 -3
- package/lib/ssh-config.js +15 -2
- package/lib/storage.js +58 -11
- package/lib/vault.js +30 -12
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -16,6 +16,14 @@ window.__ModuleLoader__.load({
|
|
|
16
16
|
panel: ['SSH 主机', 'SSH Hosts'],
|
|
17
17
|
'tabs.hosts': ['主机', 'Hosts'],
|
|
18
18
|
'tabs.sessions': ['会话', 'Sessions'],
|
|
19
|
+
'session.restoring': ['正在恢复终端会话…', 'Restoring terminal sessions…'],
|
|
20
|
+
'session.tooMany': ['最多同时打开 16 个终端会话,请先关闭一个。', 'At most 16 terminal sessions can be open. Close one first.'],
|
|
21
|
+
'session.attention': ['有会话已结束或出错', 'A session exited or failed'],
|
|
22
|
+
'session.state.loading': ['加载中', 'Loading'],
|
|
23
|
+
'session.state.connecting': ['连接中', 'Connecting'],
|
|
24
|
+
'session.state.ready': ['运行中', 'Running'],
|
|
25
|
+
'session.state.closed': ['已结束', 'Exited'],
|
|
26
|
+
'session.state.error': ['错误', 'Error'],
|
|
19
27
|
'tabs.batch': ['批量命令', 'Batch'],
|
|
20
28
|
'tabs.settings': ['设置', 'Settings'],
|
|
21
29
|
'session.new': ['新建连接', 'New connection'],
|
|
@@ -141,6 +149,7 @@ window.__ModuleLoader__.load({
|
|
|
141
149
|
'editor.required': ['请填写别名和服务器地址。', 'Alias and hostname are required.'],
|
|
142
150
|
'editor.invalidAlias': ['别名只能包含字母、数字、点、下划线、@ 和短横线。', 'Alias may only contain letters, digits, dot, underscore, @ and dash.'],
|
|
143
151
|
'editor.delete': ['删除这台主机', 'Delete this host'],
|
|
152
|
+
'editor.discardChanges': ['有未保存的修改,确定放弃?', 'Discard unsaved changes?'],
|
|
144
153
|
'drawer.files': ['文件', 'Files'],
|
|
145
154
|
'drawer.details': ['详情', 'Details'],
|
|
146
155
|
'terminal.loading': ['正在加载终端…', 'Loading terminal…'],
|
|
@@ -151,6 +160,7 @@ window.__ModuleLoader__.load({
|
|
|
151
160
|
'terminal.reconnect': ['重新连接', 'Reconnect'],
|
|
152
161
|
'terminal.openDetails': ['打开主机详情', 'Open details'],
|
|
153
162
|
'terminal.assetsError': ['终端组件加载失败:{message}。如果刚更新插件,请重启 DSH 后重试。', 'Could not load the terminal component: {message}. If the plugin was just updated, restart DSH and retry.'],
|
|
163
|
+
'terminal.resyncFailed': ['终端输出曾中断,已尝试重新同步;如仍异常请重连。', 'Terminal output was interrupted; attempted a resync. Reconnect if problems persist.'],
|
|
154
164
|
'terminal.backendHint': ['无法连接到插件后端。如果刚更新插件,请重启 DSH 后重试。', 'Cannot reach the plugin backend. If the plugin was just updated, restart DSH and retry.'],
|
|
155
165
|
'terminal.passwordMissing': ['该主机还没有保存密码,请先打开「主机详情」设置服务器密码。', 'No password stored for this host. Open Details and set the server password first.'],
|
|
156
166
|
'drawer.exit': ['退出码 {code}', 'exit {code}'],
|
|
@@ -1178,8 +1188,15 @@ window.__ModuleLoader__.load({
|
|
|
1178
1188
|
const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
|
|
1179
1189
|
const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
|
|
1180
1190
|
const [clearSignals, setClearSignals] = React.useState({})
|
|
1191
|
+
const [sessionStates, setSessionStates] = React.useState({})
|
|
1181
1192
|
const toastTimer = React.useRef(null)
|
|
1182
1193
|
const floatingRef = React.useRef(null)
|
|
1194
|
+
const editorCloseGuard = React.useRef(null)
|
|
1195
|
+
const groupsCloseGuard = React.useRef(null)
|
|
1196
|
+
|
|
1197
|
+
const setSessionState = React.useCallback((id, status) => {
|
|
1198
|
+
setSessionStates((prev) => (prev[id] === status ? prev : { ...prev, [id]: status }))
|
|
1199
|
+
}, [])
|
|
1183
1200
|
|
|
1184
1201
|
const notify = React.useCallback((message, tone = 'info') => {
|
|
1185
1202
|
if (toastTimer.current) clearTimeout(toastTimer.current)
|
|
@@ -1309,6 +1326,13 @@ window.__ModuleLoader__.load({
|
|
|
1309
1326
|
for (const key of keys) if (ids.has(key)) next[key] = prev[key]
|
|
1310
1327
|
return next
|
|
1311
1328
|
})
|
|
1329
|
+
setSessionStates((prev) => {
|
|
1330
|
+
const keys = Object.keys(prev)
|
|
1331
|
+
if (keys.every((key) => ids.has(key))) return prev
|
|
1332
|
+
const next = {}
|
|
1333
|
+
for (const key of keys) if (ids.has(key)) next[key] = prev[key]
|
|
1334
|
+
return next
|
|
1335
|
+
})
|
|
1312
1336
|
}, [sessions])
|
|
1313
1337
|
|
|
1314
1338
|
// Keep one valid active session, and leave the sessions tab when it becomes empty.
|
|
@@ -1375,6 +1399,10 @@ window.__ModuleLoader__.load({
|
|
|
1375
1399
|
if (existing) {
|
|
1376
1400
|
setActiveSessionId(existing.id)
|
|
1377
1401
|
} else {
|
|
1402
|
+
if (sessions.length >= MAX_TERMINAL_SESSIONS) {
|
|
1403
|
+
notify(t('session.tooMany'), 'error')
|
|
1404
|
+
return
|
|
1405
|
+
}
|
|
1378
1406
|
const id = newPtySessionId()
|
|
1379
1407
|
ptyActiveIds.add(id)
|
|
1380
1408
|
setSessions((prev) => [...prev, { id, hostAlias: host.host }])
|
|
@@ -1402,6 +1430,7 @@ window.__ModuleLoader__.load({
|
|
|
1402
1430
|
}
|
|
1403
1431
|
|
|
1404
1432
|
function clearSession(id) {
|
|
1433
|
+
clearPtySession(id)
|
|
1405
1434
|
setClearSignals((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
|
|
1406
1435
|
}
|
|
1407
1436
|
|
|
@@ -1584,11 +1613,15 @@ window.__ModuleLoader__.load({
|
|
|
1584
1613
|
return
|
|
1585
1614
|
}
|
|
1586
1615
|
if (editor) {
|
|
1587
|
-
|
|
1616
|
+
const guard = editorCloseGuard.current
|
|
1617
|
+
if (typeof guard === 'function') guard()
|
|
1618
|
+
else setEditor(null)
|
|
1588
1619
|
return
|
|
1589
1620
|
}
|
|
1590
1621
|
if (groupsOpen) {
|
|
1591
|
-
|
|
1622
|
+
const guard = groupsCloseGuard.current
|
|
1623
|
+
if (typeof guard === 'function') guard()
|
|
1624
|
+
else setGroupsOpen(false)
|
|
1592
1625
|
return
|
|
1593
1626
|
}
|
|
1594
1627
|
if (drawer) setDrawer(null)
|
|
@@ -1680,10 +1713,14 @@ window.__ModuleLoader__.load({
|
|
|
1680
1713
|
setFloating,
|
|
1681
1714
|
setEditor,
|
|
1682
1715
|
setDrawer,
|
|
1716
|
+
editorCloseGuard,
|
|
1717
|
+
groupsCloseGuard,
|
|
1683
1718
|
treeWidth,
|
|
1684
1719
|
setTreeWidth,
|
|
1685
1720
|
sessions,
|
|
1686
1721
|
activeSessionId,
|
|
1722
|
+
sessionStates,
|
|
1723
|
+
setSessionState,
|
|
1687
1724
|
setActiveSessionId,
|
|
1688
1725
|
clearSignals,
|
|
1689
1726
|
openSession,
|
|
@@ -1868,9 +1905,15 @@ window.__ModuleLoader__.load({
|
|
|
1868
1905
|
]
|
|
1869
1906
|
}
|
|
1870
1907
|
|
|
1908
|
+
const sessionAttention = sessions.some((session) => {
|
|
1909
|
+
const state = sessionStates[session.id]
|
|
1910
|
+
return state === 'error' || state === 'closed'
|
|
1911
|
+
})
|
|
1871
1912
|
const tabItems = [
|
|
1872
1913
|
['hosts', t('tabs.hosts')],
|
|
1873
|
-
...(sessions.length > 0
|
|
1914
|
+
...(sessions.length > 0
|
|
1915
|
+
? [['sessions', h('span', { className: 'ssh-tabLabel' }, t('tabs.sessions'), sessionAttention ? h('span', { className: 'ssh-tabAlert', title: t('session.attention') }) : null)]]
|
|
1916
|
+
: []),
|
|
1874
1917
|
['batch', t('tabs.batch')],
|
|
1875
1918
|
['settings', t('tabs.settings')],
|
|
1876
1919
|
]
|
|
@@ -1934,7 +1977,7 @@ window.__ModuleLoader__.load({
|
|
|
1934
1977
|
'div',
|
|
1935
1978
|
{ className: 'ssh-loadingPage' },
|
|
1936
1979
|
h(Spinner, { size: 22 }),
|
|
1937
|
-
h('span', null, t('state.loading')),
|
|
1980
|
+
h('span', null, sessions.length > 0 ? t('session.restoring') : t('state.loading')),
|
|
1938
1981
|
)
|
|
1939
1982
|
: !snapshot && error
|
|
1940
1983
|
? h(
|
|
@@ -1951,7 +1994,7 @@ window.__ModuleLoader__.load({
|
|
|
1951
1994
|
: effectiveTab === 'batch'
|
|
1952
1995
|
? h(BatchTab, { model })
|
|
1953
1996
|
: h(SettingsTab, { model }),
|
|
1954
|
-
sessions.length > 0
|
|
1997
|
+
snapshot && sessions.length > 0
|
|
1955
1998
|
? h(
|
|
1956
1999
|
'div',
|
|
1957
2000
|
{ className: 'ssh-sessionLayer', style: { display: effectiveTab === 'sessions' ? 'flex' : 'none' } },
|
|
@@ -2653,6 +2696,7 @@ window.__ModuleLoader__.load({
|
|
|
2653
2696
|
t,
|
|
2654
2697
|
hosts,
|
|
2655
2698
|
sessions,
|
|
2699
|
+
sessionStates,
|
|
2656
2700
|
activeSessionId,
|
|
2657
2701
|
setActiveSessionId,
|
|
2658
2702
|
setActiveAlias,
|
|
@@ -2716,6 +2760,10 @@ window.__ModuleLoader__.load({
|
|
|
2716
2760
|
h('span', {
|
|
2717
2761
|
className: cx('ssh-sessionDot', credential ? (credential.key === 'password' ? 'ok' : 'warn') : 'warn'),
|
|
2718
2762
|
}),
|
|
2763
|
+
h('span', {
|
|
2764
|
+
className: cx('ssh-sessionState', sessionStates[session.id] ?? 'loading'),
|
|
2765
|
+
title: t(`session.state.${sessionStates[session.id] ?? 'loading'}`),
|
|
2766
|
+
}),
|
|
2719
2767
|
h('span', { className: 'ssh-sessionTabTitle' }, host ? hostName(host) : session.hostAlias),
|
|
2720
2768
|
h(
|
|
2721
2769
|
'button',
|
|
@@ -3039,13 +3087,91 @@ window.__ModuleLoader__.load({
|
|
|
3039
3087
|
}
|
|
3040
3088
|
}
|
|
3041
3089
|
|
|
3090
|
+
const terminalThemeListeners = new Set()
|
|
3091
|
+
let terminalThemeObserver = null
|
|
3092
|
+
let terminalThemeMedia = null
|
|
3093
|
+
let terminalThemeScheduled = false
|
|
3094
|
+
|
|
3095
|
+
function emitTerminalTheme() {
|
|
3096
|
+
for (const listener of [...terminalThemeListeners]) {
|
|
3097
|
+
try { listener() } catch {}
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
function scheduleTerminalTheme() {
|
|
3102
|
+
if (terminalThemeScheduled) return
|
|
3103
|
+
terminalThemeScheduled = true
|
|
3104
|
+
const run = () => {
|
|
3105
|
+
terminalThemeScheduled = false
|
|
3106
|
+
emitTerminalTheme()
|
|
3107
|
+
}
|
|
3108
|
+
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
|
|
3109
|
+
else setTimeout(run, 16)
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
function ensureTerminalThemeWatcher() {
|
|
3113
|
+
if (typeof MutationObserver !== 'undefined' && !terminalThemeObserver) {
|
|
3114
|
+
terminalThemeObserver = new MutationObserver(scheduleTerminalTheme)
|
|
3115
|
+
const options = {
|
|
3116
|
+
attributes: true,
|
|
3117
|
+
attributeFilter: ['class', 'style', 'data-theme', 'data-color-mode', 'data-ds-dark-theme', 'data-ds-theme-source'],
|
|
3118
|
+
}
|
|
3119
|
+
terminalThemeObserver.observe(document.documentElement, options)
|
|
3120
|
+
if (document.body) terminalThemeObserver.observe(document.body, options)
|
|
3121
|
+
}
|
|
3122
|
+
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function' && !terminalThemeMedia) {
|
|
3123
|
+
terminalThemeMedia = window.matchMedia('(prefers-color-scheme: dark)')
|
|
3124
|
+
const onChange = () => scheduleTerminalTheme()
|
|
3125
|
+
if (typeof terminalThemeMedia.addEventListener === 'function') terminalThemeMedia.addEventListener('change', onChange)
|
|
3126
|
+
else if (typeof terminalThemeMedia.addListener === 'function') terminalThemeMedia.addListener(onChange)
|
|
3127
|
+
terminalThemeMedia.__sshThemeChange = onChange
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
function subscribeTerminalTheme(listener) {
|
|
3132
|
+
terminalThemeListeners.add(listener)
|
|
3133
|
+
ensureTerminalThemeWatcher()
|
|
3134
|
+
return () => {
|
|
3135
|
+
terminalThemeListeners.delete(listener)
|
|
3136
|
+
if (terminalThemeListeners.size > 0) return
|
|
3137
|
+
if (terminalThemeObserver) {
|
|
3138
|
+
terminalThemeObserver.disconnect()
|
|
3139
|
+
terminalThemeObserver = null
|
|
3140
|
+
}
|
|
3141
|
+
if (terminalThemeMedia) {
|
|
3142
|
+
const onChange = terminalThemeMedia.__sshThemeChange
|
|
3143
|
+
if (typeof terminalThemeMedia.removeEventListener === 'function') terminalThemeMedia.removeEventListener('change', onChange)
|
|
3144
|
+
else if (typeof terminalThemeMedia.removeListener === 'function') terminalThemeMedia.removeListener(onChange)
|
|
3145
|
+
terminalThemeMedia = null
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3042
3150
|
const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
|
|
3043
3151
|
const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
|
|
3152
|
+
const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
|
|
3153
|
+
const PTY_TOUCH_ROUTE = '/dsh-ssh-manage/pty/touch'
|
|
3044
3154
|
const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
|
|
3045
3155
|
const ptyActiveIds = new Set()
|
|
3046
3156
|
const ptyClosedIds = new Set()
|
|
3157
|
+
const MAX_TERMINAL_SESSIONS = 16
|
|
3047
3158
|
const ptyStreamLostHandlers = new Set()
|
|
3048
3159
|
|
|
3160
|
+
if (typeof window !== 'undefined') {
|
|
3161
|
+
const heartbeat = setInterval(() => {
|
|
3162
|
+
if (ptyActiveIds.size === 0) return
|
|
3163
|
+
try {
|
|
3164
|
+
void fetch(PTY_TOUCH_ROUTE, {
|
|
3165
|
+
method: 'POST',
|
|
3166
|
+
headers: { 'content-type': 'application/json' },
|
|
3167
|
+
body: JSON.stringify({ ids: [...ptyActiveIds] }),
|
|
3168
|
+
keepalive: true,
|
|
3169
|
+
}).catch(() => {})
|
|
3170
|
+
} catch {}
|
|
3171
|
+
}, 60_000)
|
|
3172
|
+
heartbeat.unref?.()
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3049
3175
|
function closePtySession(id) {
|
|
3050
3176
|
if (!id) return
|
|
3051
3177
|
ptyClosedIds.add(id)
|
|
@@ -3065,13 +3191,23 @@ window.__ModuleLoader__.load({
|
|
|
3065
3191
|
return () => ptyStreamLostHandlers.delete(handler)
|
|
3066
3192
|
}
|
|
3067
3193
|
|
|
3068
|
-
function resetPtyStream() {
|
|
3194
|
+
function resetPtyStream(reason = new Error('Terminal event stream closed')) {
|
|
3195
|
+
const wasReady = ptyStream.ready === true
|
|
3196
|
+
const rejectPending = ptyStream.reject
|
|
3197
|
+
ptyStream.reject = null
|
|
3069
3198
|
if (ptyStream.source) {
|
|
3070
3199
|
try { ptyStream.source.close() } catch {}
|
|
3071
3200
|
}
|
|
3072
3201
|
ptyStream.source = null
|
|
3073
3202
|
ptyStream.ready = false
|
|
3074
3203
|
ptyStream.promise = null
|
|
3204
|
+
if (rejectPending) {
|
|
3205
|
+
try { rejectPending(reason) } catch {}
|
|
3206
|
+
}
|
|
3207
|
+
// A stream that never finished connecting is not a lost terminal yet;
|
|
3208
|
+
// its waiting panes get the rejected ensure promise and keep their
|
|
3209
|
+
// normal connecting/error overlay.
|
|
3210
|
+
if (!wasReady) return
|
|
3075
3211
|
for (const handler of [...ptyStreamLostHandlers]) {
|
|
3076
3212
|
try { handler() } catch {}
|
|
3077
3213
|
}
|
|
@@ -3085,8 +3221,18 @@ window.__ModuleLoader__.load({
|
|
|
3085
3221
|
}
|
|
3086
3222
|
|
|
3087
3223
|
function ensurePtyStream() {
|
|
3088
|
-
if (ptyStream.
|
|
3089
|
-
|
|
3224
|
+
if (ptyStream.source) {
|
|
3225
|
+
const state = ptyStream.source.readyState
|
|
3226
|
+
if (state === 1 && ptyStream.ready) return Promise.resolve()
|
|
3227
|
+
// CONNECTING: all callers must share the one in-flight promise; never
|
|
3228
|
+
// tear it down. This is the multi-tab restore / rapid-second-connect
|
|
3229
|
+
// race that used to leave N-1 panes in a permanent error overlay.
|
|
3230
|
+
if (state === 0) {
|
|
3231
|
+
if (ptyStream.promise) return ptyStream.promise
|
|
3232
|
+
try { ptyStream.source.close() } catch {}
|
|
3233
|
+
ptyStream.source = null
|
|
3234
|
+
} else if (state === 2) resetPtyStream()
|
|
3235
|
+
}
|
|
3090
3236
|
if (ptyStream.promise) return ptyStream.promise
|
|
3091
3237
|
ptyStream.promise = new Promise((resolve, reject) => {
|
|
3092
3238
|
let settled = false
|
|
@@ -3100,14 +3246,17 @@ window.__ModuleLoader__.load({
|
|
|
3100
3246
|
ptyStream.source = null
|
|
3101
3247
|
ptyStream.ready = false
|
|
3102
3248
|
ptyStream.promise = null
|
|
3249
|
+
ptyStream.reject = null
|
|
3103
3250
|
reject(error)
|
|
3104
3251
|
}
|
|
3105
3252
|
ptyStream.source = source
|
|
3253
|
+
ptyStream.reject = reject
|
|
3106
3254
|
timer = setTimeout(() => fail(new Error('Terminal event stream timed out')), 10_000)
|
|
3107
3255
|
source.addEventListener('open', () => {
|
|
3108
3256
|
if (settled) return
|
|
3109
3257
|
settled = true
|
|
3110
3258
|
clearTimeout(timer)
|
|
3259
|
+
ptyStream.reject = null
|
|
3111
3260
|
ptyStream.ready = true
|
|
3112
3261
|
resolve()
|
|
3113
3262
|
})
|
|
@@ -3161,6 +3310,11 @@ window.__ModuleLoader__.load({
|
|
|
3161
3310
|
}
|
|
3162
3311
|
}
|
|
3163
3312
|
|
|
3313
|
+
function clearPtySession(id) {
|
|
3314
|
+
if (!id) return
|
|
3315
|
+
void ptyPost(PTY_CLEAR_ROUTE, { id }, 15_000).catch(() => {})
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3164
3318
|
function decodeBase64(text) {
|
|
3165
3319
|
const binary = atob(String(text ?? ''))
|
|
3166
3320
|
const bytes = new Uint8Array(binary.length)
|
|
@@ -3179,6 +3333,14 @@ window.__ModuleLoader__.load({
|
|
|
3179
3333
|
const [status, setStatus] = React.useState('loading')
|
|
3180
3334
|
const [message, setMessage] = React.useState('')
|
|
3181
3335
|
const [retryToken, setRetryToken] = React.useState(0)
|
|
3336
|
+
const clearRequested = React.useRef(false)
|
|
3337
|
+
const reportStatus = model.setSessionState
|
|
3338
|
+
|
|
3339
|
+
React.useEffect(() => {
|
|
3340
|
+
reportStatus?.(sessionId, status)
|
|
3341
|
+
}, [reportStatus, sessionId, status])
|
|
3342
|
+
const syncedRef = React.useRef(false)
|
|
3343
|
+
const clearEpochRef = React.useRef(0)
|
|
3182
3344
|
|
|
3183
3345
|
React.useEffect(() => {
|
|
3184
3346
|
activeRef.current = active === true
|
|
@@ -3194,7 +3356,13 @@ window.__ModuleLoader__.load({
|
|
|
3194
3356
|
}, [active])
|
|
3195
3357
|
|
|
3196
3358
|
React.useEffect(() => {
|
|
3197
|
-
if (!clearSignal
|
|
3359
|
+
if (!clearSignal) return
|
|
3360
|
+
clearEpochRef.current += 1
|
|
3361
|
+
// Only a clear issued before attach needs to survive into the attach
|
|
3362
|
+
// replay. A clear on a live, synced terminal is handled by the server
|
|
3363
|
+
// route directly and must not truncate the next attach replay.
|
|
3364
|
+
clearRequested.current = syncedRef.current !== true
|
|
3365
|
+
if (!termRef.current) return
|
|
3198
3366
|
try {
|
|
3199
3367
|
termRef.current.clear()
|
|
3200
3368
|
termRef.current.write('\x1b[H\x1b[2J')
|
|
@@ -3217,6 +3385,8 @@ window.__ModuleLoader__.load({
|
|
|
3217
3385
|
let opened = false
|
|
3218
3386
|
let inputChain = Promise.resolve()
|
|
3219
3387
|
let streamLost = null
|
|
3388
|
+
let themeUnsub = null
|
|
3389
|
+
syncedRef.current = false
|
|
3220
3390
|
setStatus('loading')
|
|
3221
3391
|
setMessage('')
|
|
3222
3392
|
|
|
@@ -3278,6 +3448,10 @@ window.__ModuleLoader__.load({
|
|
|
3278
3448
|
termRef.current = term
|
|
3279
3449
|
fitRef.current = fit
|
|
3280
3450
|
try { fit.fit() } catch {}
|
|
3451
|
+
themeUnsub = subscribeTerminalTheme(() => {
|
|
3452
|
+
if (removed || !term) return
|
|
3453
|
+
try { term.options.theme = terminalThemeFor(container) } catch {}
|
|
3454
|
+
})
|
|
3281
3455
|
term.onData((data) => {
|
|
3282
3456
|
const id = sessionKey
|
|
3283
3457
|
if (!id || !data || !opened) return
|
|
@@ -3324,22 +3498,103 @@ window.__ModuleLoader__.load({
|
|
|
3324
3498
|
sessionKey = sessionId
|
|
3325
3499
|
const id = sessionKey
|
|
3326
3500
|
let synced = false
|
|
3327
|
-
let lastSeq = 0
|
|
3328
3501
|
const pendingEvents = []
|
|
3329
3502
|
|
|
3503
|
+
const clearTerminal = () => {
|
|
3504
|
+
if (!term) return
|
|
3505
|
+
try {
|
|
3506
|
+
term.clear()
|
|
3507
|
+
term.write('\x1b[H\x1b[2J')
|
|
3508
|
+
} catch {}
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
let resyncing = false
|
|
3512
|
+
let resyncFailedAt = 0
|
|
3513
|
+
let resyncAttempts = 0
|
|
3514
|
+
let resyncGraceUntil = 0
|
|
3515
|
+
const resyncQueue = []
|
|
3516
|
+
let lastSeq = 0
|
|
3517
|
+
|
|
3330
3518
|
const applyOutput = (event) => {
|
|
3331
3519
|
const seq = Number(event.seq)
|
|
3332
3520
|
if (Number.isFinite(seq)) {
|
|
3333
3521
|
if (seq <= lastSeq) return
|
|
3334
|
-
lastSeq
|
|
3522
|
+
if (lastSeq > 0 && seq > lastSeq + 1) {
|
|
3523
|
+
if (Date.now() < resyncGraceUntil) {
|
|
3524
|
+
lastSeq = seq
|
|
3525
|
+
} else {
|
|
3526
|
+
resyncAttempts += 1
|
|
3527
|
+
if (resyncAttempts > 3) {
|
|
3528
|
+
resyncAttempts = 0
|
|
3529
|
+
resyncGraceUntil = Date.now() + 5_000
|
|
3530
|
+
lastSeq = seq
|
|
3531
|
+
} else {
|
|
3532
|
+
resyncQueue.push(event)
|
|
3533
|
+
void resync()
|
|
3534
|
+
return
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
} else {
|
|
3538
|
+
lastSeq = seq
|
|
3539
|
+
resyncAttempts = 0
|
|
3540
|
+
}
|
|
3335
3541
|
}
|
|
3336
3542
|
try { term.write(decodeBase64(event.data)) } catch {}
|
|
3337
3543
|
}
|
|
3338
3544
|
|
|
3545
|
+
const resync = async () => {
|
|
3546
|
+
if (resyncing || stopped || finished || !sessionKey) return
|
|
3547
|
+
const clearEpoch = clearEpochRef.current
|
|
3548
|
+
resyncing = true
|
|
3549
|
+
try {
|
|
3550
|
+
const value = await ptyPost(PTY_ATTACH_ROUTE, {
|
|
3551
|
+
id: sessionKey,
|
|
3552
|
+
cols: term?.cols ?? 80,
|
|
3553
|
+
rows: term?.rows ?? 24,
|
|
3554
|
+
}, 15_000)
|
|
3555
|
+
if (removed || stopped || finished) {
|
|
3556
|
+
resyncing = false
|
|
3557
|
+
return
|
|
3558
|
+
}
|
|
3559
|
+
if (clearEpochRef.current !== clearEpoch) {
|
|
3560
|
+
clearRequested.current = false
|
|
3561
|
+
clearTerminal()
|
|
3562
|
+
} else if (value && value.output) {
|
|
3563
|
+
try {
|
|
3564
|
+
term.reset()
|
|
3565
|
+
term.write(decodeBase64(value.output))
|
|
3566
|
+
} catch {}
|
|
3567
|
+
}
|
|
3568
|
+
const seq = Number(value?.seq)
|
|
3569
|
+
if (Number.isFinite(seq)) lastSeq = seq
|
|
3570
|
+
resyncing = false
|
|
3571
|
+
for (const event of resyncQueue.splice(0)) {
|
|
3572
|
+
if (finished || stopped) break
|
|
3573
|
+
handleEvent(event)
|
|
3574
|
+
}
|
|
3575
|
+
} catch {
|
|
3576
|
+
resyncing = false
|
|
3577
|
+
resyncFailedAt = Date.now()
|
|
3578
|
+
resyncGraceUntil = Date.now() + 5_000
|
|
3579
|
+
try { model.notify?.(t('terminal.resyncFailed'), 'error') } catch {}
|
|
3580
|
+
for (const event of resyncQueue.splice(0)) {
|
|
3581
|
+
if (finished || stopped) break
|
|
3582
|
+
handleEvent(event)
|
|
3583
|
+
}
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3339
3587
|
const handleEvent = (event) => {
|
|
3588
|
+
if (resyncing) {
|
|
3589
|
+
resyncQueue.push(event)
|
|
3590
|
+
return
|
|
3591
|
+
}
|
|
3340
3592
|
if (event.kind === 'output') {
|
|
3341
3593
|
if (!synced) pendingEvents.push(event)
|
|
3342
3594
|
else applyOutput(event)
|
|
3595
|
+
} else if (event.kind === 'clear') {
|
|
3596
|
+
if (!synced) pendingEvents.push(event)
|
|
3597
|
+
else clearTerminal()
|
|
3343
3598
|
} else if (event.kind === 'exit') {
|
|
3344
3599
|
if (!synced) {
|
|
3345
3600
|
pendingEvents.push(event)
|
|
@@ -3386,18 +3641,23 @@ window.__ModuleLoader__.load({
|
|
|
3386
3641
|
closePtySession(id)
|
|
3387
3642
|
return
|
|
3388
3643
|
}
|
|
3389
|
-
if (removed) return
|
|
3390
|
-
if (
|
|
3644
|
+
if (removed || stopped || finished) return
|
|
3645
|
+
if (clearRequested.current) {
|
|
3646
|
+
clearRequested.current = false
|
|
3647
|
+
clearTerminal()
|
|
3648
|
+
clearPtySession(id)
|
|
3649
|
+
} else if (value && value.output) {
|
|
3391
3650
|
try { term.write(decodeBase64(value.output)) } catch {}
|
|
3392
3651
|
}
|
|
3393
3652
|
const seq = Number(value?.seq)
|
|
3394
3653
|
if (Number.isFinite(seq)) lastSeq = seq
|
|
3395
3654
|
synced = true
|
|
3396
3655
|
for (const event of pendingEvents.splice(0)) {
|
|
3397
|
-
if (finished) break
|
|
3656
|
+
if (finished || stopped) break
|
|
3398
3657
|
handleEvent(event)
|
|
3399
3658
|
}
|
|
3400
|
-
if (finished) return
|
|
3659
|
+
if (finished || stopped || removed) return
|
|
3660
|
+
syncedRef.current = true
|
|
3401
3661
|
opened = true
|
|
3402
3662
|
setStatus('ready')
|
|
3403
3663
|
setMessage('')
|
|
@@ -3422,6 +3682,7 @@ window.__ModuleLoader__.load({
|
|
|
3422
3682
|
|
|
3423
3683
|
return () => {
|
|
3424
3684
|
removed = true
|
|
3685
|
+
if (themeUnsub) themeUnsub()
|
|
3425
3686
|
if (streamLost) streamLost()
|
|
3426
3687
|
if (resizeObserver) resizeObserver.disconnect()
|
|
3427
3688
|
teardown()
|
|
@@ -3809,6 +4070,28 @@ window.__ModuleLoader__.load({
|
|
|
3809
4070
|
setDraft((prev) => ({ ...prev, [field]: value }))
|
|
3810
4071
|
}
|
|
3811
4072
|
|
|
4073
|
+
const dirty = password !== '' || JSON.stringify(draft) !== JSON.stringify(state.draft)
|
|
4074
|
+
const requestClose = () => {
|
|
4075
|
+
if (busy) return
|
|
4076
|
+
if (!dirty) {
|
|
4077
|
+
model.setEditor(null)
|
|
4078
|
+
return
|
|
4079
|
+
}
|
|
4080
|
+
let discard = true
|
|
4081
|
+
try {
|
|
4082
|
+
discard = typeof window.confirm !== 'function' || window.confirm(t('editor.discardChanges'))
|
|
4083
|
+
} catch {}
|
|
4084
|
+
if (discard) model.setEditor(null)
|
|
4085
|
+
}
|
|
4086
|
+
|
|
4087
|
+
React.useEffect(() => {
|
|
4088
|
+
const guard = () => requestClose()
|
|
4089
|
+
model.editorCloseGuard.current = guard
|
|
4090
|
+
return () => {
|
|
4091
|
+
if (model.editorCloseGuard.current === guard) model.editorCloseGuard.current = null
|
|
4092
|
+
}
|
|
4093
|
+
})
|
|
4094
|
+
|
|
3812
4095
|
async function submit() {
|
|
3813
4096
|
const alias = draft.host.trim()
|
|
3814
4097
|
const hostname = draft.hostname.trim()
|
|
@@ -3864,7 +4147,7 @@ window.__ModuleLoader__.load({
|
|
|
3864
4147
|
t('editor.delete'),
|
|
3865
4148
|
)
|
|
3866
4149
|
: h('span', { key: 'spacer', className: 'ssh-toolSpacer' }),
|
|
3867
|
-
h(Button, { key: 'cancel', onClick:
|
|
4150
|
+
h(Button, { key: 'cancel', onClick: requestClose, disabled: busy }, t('common.cancel')),
|
|
3868
4151
|
h(
|
|
3869
4152
|
Button,
|
|
3870
4153
|
{ key: 'save', variant: 'primary', onClick: () => void submit(), disabled: busy },
|
|
@@ -3881,7 +4164,7 @@ window.__ModuleLoader__.load({
|
|
|
3881
4164
|
width: 700,
|
|
3882
4165
|
icon: h(IconTerminal, { size: 16 }),
|
|
3883
4166
|
closeLabel: t('common.close'),
|
|
3884
|
-
onClose:
|
|
4167
|
+
onClose: requestClose,
|
|
3885
4168
|
footer,
|
|
3886
4169
|
},
|
|
3887
4170
|
h(
|
|
@@ -4004,6 +4287,17 @@ window.__ModuleLoader__.load({
|
|
|
4004
4287
|
const groups = buildGroups(hosts, t).filter((group) => group.key !== '')
|
|
4005
4288
|
const [drafts, setDrafts] = React.useState(() => Object.fromEntries(groups.map((group) => [group.key, group.key])))
|
|
4006
4289
|
const busy = isPending('groups')
|
|
4290
|
+
const requestClose = () => {
|
|
4291
|
+
if (!busy) model.setGroupsOpen(false)
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
React.useEffect(() => {
|
|
4295
|
+
const guard = () => requestClose()
|
|
4296
|
+
model.groupsCloseGuard.current = guard
|
|
4297
|
+
return () => {
|
|
4298
|
+
if (model.groupsCloseGuard.current === guard) model.groupsCloseGuard.current = null
|
|
4299
|
+
}
|
|
4300
|
+
})
|
|
4007
4301
|
|
|
4008
4302
|
async function commit(name) {
|
|
4009
4303
|
const next = String(drafts[name] ?? '').trim()
|
|
@@ -4020,8 +4314,8 @@ window.__ModuleLoader__.load({
|
|
|
4020
4314
|
width: 560,
|
|
4021
4315
|
icon: h(IconFolder, { size: 16 }),
|
|
4022
4316
|
closeLabel: t('common.close'),
|
|
4023
|
-
onClose:
|
|
4024
|
-
footer: [h(Button, { key: 'close', onClick:
|
|
4317
|
+
onClose: requestClose,
|
|
4318
|
+
footer: [h(Button, { key: 'close', onClick: requestClose }, t('common.close'))],
|
|
4025
4319
|
},
|
|
4026
4320
|
groups.length === 0
|
|
4027
4321
|
? h('div', { className: 'ssh-sectionHint' }, t('groups.empty'))
|
|
@@ -4654,6 +4948,12 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
|
|
|
4654
4948
|
.ssh-terminalOverlayBody{max-width:560px;font-size:11.5px;line-height:1.7;color:var(--ssh-text-3);word-break:break-word}
|
|
4655
4949
|
.ssh-terminalOverlayActions{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
|
|
4656
4950
|
.ssh-sessionLayer{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--ssh-bg)}
|
|
4951
|
+
.ssh-sessionState{width:6px;height:6px;border-radius:50%;background:var(--ssh-dim);flex:0 0 auto}
|
|
4952
|
+
.ssh-sessionState.loading,.ssh-sessionState.connecting{background:var(--ssh-warn)}
|
|
4953
|
+
.ssh-sessionState.ready{background:var(--ssh-success)}
|
|
4954
|
+
.ssh-sessionState.error,.ssh-sessionState.closed{background:var(--ssh-danger)}
|
|
4955
|
+
.ssh-tabLabel{display:inline-flex;align-items:center;gap:5px}
|
|
4956
|
+
.ssh-tabAlert{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ssh-danger);flex:0 0 auto}
|
|
4657
4957
|
.ssh-sessions{flex:1;min-height:0;display:flex;flex-direction:column}
|
|
4658
4958
|
.ssh-sessionBar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:7px 12px;border-bottom:.5px solid var(--ssh-border);min-height:48px}
|
|
4659
4959
|
.ssh-sessionTabs{flex:1;min-width:0;display:flex;align-items:center;gap:4px;overflow-x:auto;overflow-y:hidden;padding-bottom:1px}
|