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/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
- setEditor(null)
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
- setGroupsOpen(false)
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 ? [['sessions', t('tabs.sessions')]] : []),
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.ready && ptyStream.source && ptyStream.source.readyState === 1) return Promise.resolve()
3089
- if (ptyStream.source && ptyStream.source.readyState !== 1) resetPtyStream()
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 || !termRef.current) return
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 = seq
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 (value && value.output) {
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: () => model.setEditor(null), disabled: busy }, t('common.cancel')),
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: () => model.setEditor(null),
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: () => model.setGroupsOpen(false),
4024
- footer: [h(Button, { key: 'close', onClick: () => model.setGroupsOpen(false) }, t('common.close'))],
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}