dsh-ssh-manage 0.2.2 → 0.3.1

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
@@ -18,6 +18,12 @@ window.__ModuleLoader__.load({
18
18
  'tabs.sessions': ['会话', 'Sessions'],
19
19
  'session.restoring': ['正在恢复终端会话…', 'Restoring terminal sessions…'],
20
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'],
21
27
  'tabs.batch': ['批量命令', 'Batch'],
22
28
  'tabs.settings': ['设置', 'Settings'],
23
29
  'session.new': ['新建连接', 'New connection'],
@@ -154,6 +160,22 @@ window.__ModuleLoader__.load({
154
160
  'terminal.reconnect': ['重新连接', 'Reconnect'],
155
161
  'terminal.openDetails': ['打开主机详情', 'Open details'],
156
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.'],
164
+ 'terminal.search': ['搜索终端', 'Search terminal'],
165
+ 'terminal.searchNext': ['下一个', 'Next match'],
166
+ 'terminal.searchPrevious': ['上一个', 'Previous match'],
167
+ 'terminal.searchCase': ['区分大小写', 'Match case'],
168
+ 'terminal.searchClose': ['关闭搜索', 'Close search'],
169
+ 'terminal.fontSmaller': ['缩小字号', 'Smaller terminal font'],
170
+ 'terminal.fontLarger': ['放大字号', 'Larger terminal font'],
171
+ 'terminal.menu.copy': ['复制', 'Copy'],
172
+ 'terminal.menu.paste': ['粘贴', 'Paste'],
173
+ 'terminal.menu.selectAll': ['全选', 'Select all'],
174
+ 'terminal.menu.clear': ['清屏', 'Clear'],
175
+ 'terminal.menu.title': ['终端菜单', 'Terminal menu'],
176
+ 'terminal.searchUnavailable': ['终端搜索加载失败,请重启 DSH 后重试。', 'Terminal search failed to load; restart DSH and retry.'],
177
+ 'terminal.copyFailed': ['复制失败,请检查浏览器剪贴板权限。', 'Copy failed; check browser clipboard permissions.'],
178
+ 'terminal.pasteFailed': ['粘贴失败,请检查浏览器剪贴板权限。', 'Paste failed; check browser clipboard permissions.'],
157
179
  'terminal.backendHint': ['无法连接到插件后端。如果刚更新插件,请重启 DSH 后重试。', 'Cannot reach the plugin backend. If the plugin was just updated, restart DSH and retry.'],
158
180
  'terminal.passwordMissing': ['该主机还没有保存密码,请先打开「主机详情」设置服务器密码。', 'No password stored for this host. Open Details and set the server password first.'],
159
181
  'drawer.exit': ['退出码 {code}', 'exit {code}'],
@@ -1145,6 +1167,25 @@ window.__ModuleLoader__.load({
1145
1167
  }
1146
1168
  }
1147
1169
 
1170
+ const TERMINAL_FONT_KEY = 'dsh-ssh-manage:terminal-font-size'
1171
+ const TERMINAL_FONT_MIN = 10
1172
+ const TERMINAL_FONT_MAX = 20
1173
+
1174
+ function clampTerminalFontSize(value) {
1175
+ const numeric = Number(value)
1176
+ if (!Number.isFinite(numeric)) return 13
1177
+ return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, Math.round(numeric)))
1178
+ }
1179
+
1180
+ function readTerminalFontSize() {
1181
+ try {
1182
+ const stored = window.localStorage.getItem(TERMINAL_FONT_KEY)
1183
+ return stored === null ? 13 : clampTerminalFontSize(stored)
1184
+ } catch {
1185
+ return 13
1186
+ }
1187
+ }
1188
+
1148
1189
  function App({ ctx }) {
1149
1190
  const t = useT(ctx)
1150
1191
  const localeId = useLocaleId(ctx)
@@ -1180,12 +1221,18 @@ window.__ModuleLoader__.load({
1180
1221
  )
1181
1222
  const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
1182
1223
  const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
1224
+ const [terminalFontSize, setTerminalFontSize] = React.useState(readTerminalFontSize)
1183
1225
  const [clearSignals, setClearSignals] = React.useState({})
1226
+ const [sessionStates, setSessionStates] = React.useState({})
1184
1227
  const toastTimer = React.useRef(null)
1185
1228
  const floatingRef = React.useRef(null)
1186
1229
  const editorCloseGuard = React.useRef(null)
1187
1230
  const groupsCloseGuard = React.useRef(null)
1188
1231
 
1232
+ const setSessionState = React.useCallback((id, status) => {
1233
+ setSessionStates((prev) => (prev[id] === status ? prev : { ...prev, [id]: status }))
1234
+ }, [])
1235
+
1189
1236
  const notify = React.useCallback((message, tone = 'info') => {
1190
1237
  if (toastTimer.current) clearTimeout(toastTimer.current)
1191
1238
  setToastState({ message, tone })
@@ -1214,6 +1261,12 @@ window.__ModuleLoader__.load({
1214
1261
  } catch {}
1215
1262
  }, [treeWidth])
1216
1263
 
1264
+ React.useEffect(() => {
1265
+ try {
1266
+ window.localStorage.setItem(TERMINAL_FONT_KEY, String(terminalFontSize))
1267
+ } catch {}
1268
+ }, [terminalFontSize])
1269
+
1217
1270
  React.useEffect(() => {
1218
1271
  for (const session of sessions) ptyActiveIds.add(session.id)
1219
1272
  }, [sessions])
@@ -1314,6 +1367,13 @@ window.__ModuleLoader__.load({
1314
1367
  for (const key of keys) if (ids.has(key)) next[key] = prev[key]
1315
1368
  return next
1316
1369
  })
1370
+ setSessionStates((prev) => {
1371
+ const keys = Object.keys(prev)
1372
+ if (keys.every((key) => ids.has(key))) return prev
1373
+ const next = {}
1374
+ for (const key of keys) if (ids.has(key)) next[key] = prev[key]
1375
+ return next
1376
+ })
1317
1377
  }, [sessions])
1318
1378
 
1319
1379
  // Keep one valid active session, and leave the sessions tab when it becomes empty.
@@ -1698,8 +1758,12 @@ window.__ModuleLoader__.load({
1698
1758
  groupsCloseGuard,
1699
1759
  treeWidth,
1700
1760
  setTreeWidth,
1761
+ terminalFontSize,
1762
+ setTerminalFontSize,
1701
1763
  sessions,
1702
1764
  activeSessionId,
1765
+ sessionStates,
1766
+ setSessionState,
1703
1767
  setActiveSessionId,
1704
1768
  clearSignals,
1705
1769
  openSession,
@@ -1884,9 +1948,15 @@ window.__ModuleLoader__.load({
1884
1948
  ]
1885
1949
  }
1886
1950
 
1951
+ const sessionAttention = sessions.some((session) => {
1952
+ const state = sessionStates[session.id]
1953
+ return state === 'error' || state === 'closed'
1954
+ })
1887
1955
  const tabItems = [
1888
1956
  ['hosts', t('tabs.hosts')],
1889
- ...(sessions.length > 0 ? [['sessions', t('tabs.sessions')]] : []),
1957
+ ...(sessions.length > 0
1958
+ ? [['sessions', h('span', { className: 'ssh-tabLabel' }, t('tabs.sessions'), sessionAttention ? h('span', { className: 'ssh-tabAlert', title: t('session.attention') }) : null)]]
1959
+ : []),
1890
1960
  ['batch', t('tabs.batch')],
1891
1961
  ['settings', t('tabs.settings')],
1892
1962
  ]
@@ -2669,6 +2739,7 @@ window.__ModuleLoader__.load({
2669
2739
  t,
2670
2740
  hosts,
2671
2741
  sessions,
2742
+ sessionStates,
2672
2743
  activeSessionId,
2673
2744
  setActiveSessionId,
2674
2745
  setActiveAlias,
@@ -2732,6 +2803,10 @@ window.__ModuleLoader__.load({
2732
2803
  h('span', {
2733
2804
  className: cx('ssh-sessionDot', credential ? (credential.key === 'password' ? 'ok' : 'warn') : 'warn'),
2734
2805
  }),
2806
+ h('span', {
2807
+ className: cx('ssh-sessionState', sessionStates[session.id] ?? 'loading'),
2808
+ title: t(`session.state.${sessionStates[session.id] ?? 'loading'}`),
2809
+ }),
2735
2810
  h('span', { className: 'ssh-sessionTabTitle' }, host ? hostName(host) : session.hostAlias),
2736
2811
  h(
2737
2812
  'button',
@@ -2801,6 +2876,24 @@ window.__ModuleLoader__.load({
2801
2876
  },
2802
2877
  h(IconServer, { size: 15 }),
2803
2878
  ),
2879
+ h(
2880
+ IconButton,
2881
+ {
2882
+ title: t('terminal.fontSmaller'),
2883
+ disabled: model.terminalFontSize <= TERMINAL_FONT_MIN,
2884
+ onClick: () => model.setTerminalFontSize((value) => Math.max(TERMINAL_FONT_MIN, value - 1)),
2885
+ },
2886
+ h('span', { className: 'ssh-fontGlyph' }, 'A−'),
2887
+ ),
2888
+ h(
2889
+ IconButton,
2890
+ {
2891
+ title: t('terminal.fontLarger'),
2892
+ disabled: model.terminalFontSize >= TERMINAL_FONT_MAX,
2893
+ onClick: () => model.setTerminalFontSize((value) => Math.min(TERMINAL_FONT_MAX, value + 1)),
2894
+ },
2895
+ h('span', { className: 'ssh-fontGlyph' }, 'A+'),
2896
+ ),
2804
2897
  h(
2805
2898
  IconButton,
2806
2899
  {
@@ -2837,6 +2930,7 @@ window.__ModuleLoader__.load({
2837
2930
  model,
2838
2931
  host,
2839
2932
  sessionId: session.id,
2933
+ fontSize: model.terminalFontSize,
2840
2934
  clearSignal: clearSignals[session.id] ?? 0,
2841
2935
  active: isVisible && session.id === activeSession.id,
2842
2936
  })
@@ -2937,6 +3031,14 @@ window.__ModuleLoader__.load({
2937
3031
  css: 'dsh-ssh-manage-xterm-css',
2938
3032
  script: 'dsh-ssh-manage-xterm-script',
2939
3033
  fit: 'dsh-ssh-manage-xterm-fit',
3034
+ search: 'dsh-ssh-manage-xterm-search',
3035
+ }
3036
+
3037
+ function terminalAssetReady(id) {
3038
+ if (id === TERMINAL_ASSET_IDS.script) return Boolean(window.Terminal)
3039
+ if (id === TERMINAL_ASSET_IDS.fit) return Boolean(window.FitAddon && window.FitAddon.FitAddon)
3040
+ if (id === TERMINAL_ASSET_IDS.search) return Boolean(window.SearchAddon && window.SearchAddon.SearchAddon)
3041
+ return false
2940
3042
  }
2941
3043
  let terminalAssetsPromise = null
2942
3044
 
@@ -2944,10 +3046,7 @@ window.__ModuleLoader__.load({
2944
3046
  return new Promise((resolve, reject) => {
2945
3047
  const existing = document.getElementById(id)
2946
3048
  if (existing) {
2947
- const ready =
2948
- id === TERMINAL_ASSET_IDS.script
2949
- ? Boolean(window.Terminal)
2950
- : Boolean(window.FitAddon && window.FitAddon.FitAddon)
3049
+ const ready = terminalAssetReady(id)
2951
3050
  if (ready) {
2952
3051
  resolve()
2953
3052
  return
@@ -2971,8 +3070,12 @@ window.__ModuleLoader__.load({
2971
3070
  }
2972
3071
 
2973
3072
  function loadTerminalAssets() {
2974
- if (window.Terminal && window.FitAddon && window.FitAddon.FitAddon) {
2975
- return Promise.resolve({ Terminal: window.Terminal, FitAddon: window.FitAddon.FitAddon })
3073
+ if (window.Terminal && window.FitAddon && window.FitAddon.FitAddon && window.SearchAddon?.SearchAddon) {
3074
+ return Promise.resolve({
3075
+ Terminal: window.Terminal,
3076
+ FitAddon: window.FitAddon.FitAddon,
3077
+ SearchAddon: window.SearchAddon.SearchAddon,
3078
+ })
2976
3079
  }
2977
3080
  if (terminalAssetsPromise) return terminalAssetsPromise
2978
3081
  if (!document.getElementById(TERMINAL_ASSET_IDS.css)) {
@@ -2984,9 +3087,17 @@ window.__ModuleLoader__.load({
2984
3087
  }
2985
3088
  terminalAssetsPromise = loadScriptAsset(TERMINAL_ASSET_IDS.script, '/dsh-ssh-manage/assets/xterm.js')
2986
3089
  .then(() => loadScriptAsset(TERMINAL_ASSET_IDS.fit, '/dsh-ssh-manage/assets/addon-fit.js'))
3090
+ // Search is optional: a missing/blocked addon must not break the terminal.
3091
+ .then(() => loadScriptAsset(TERMINAL_ASSET_IDS.search, '/dsh-ssh-manage/assets/addon-search.js').catch(() => null))
2987
3092
  .then(() => {
2988
- if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) throw new Error('Terminal assets did not initialize')
2989
- return { Terminal: window.Terminal, FitAddon: window.FitAddon.FitAddon }
3093
+ if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) {
3094
+ throw new Error('Terminal assets did not initialize')
3095
+ }
3096
+ return {
3097
+ Terminal: window.Terminal,
3098
+ FitAddon: window.FitAddon.FitAddon,
3099
+ SearchAddon: window.SearchAddon?.SearchAddon ?? null,
3100
+ }
2990
3101
  })
2991
3102
  .catch((error) => {
2992
3103
  terminalAssetsPromise = null
@@ -3118,12 +3229,28 @@ window.__ModuleLoader__.load({
3118
3229
  const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
3119
3230
  const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
3120
3231
  const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
3232
+ const PTY_TOUCH_ROUTE = '/dsh-ssh-manage/pty/touch'
3121
3233
  const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
3122
3234
  const ptyActiveIds = new Set()
3123
3235
  const ptyClosedIds = new Set()
3124
3236
  const MAX_TERMINAL_SESSIONS = 16
3125
3237
  const ptyStreamLostHandlers = new Set()
3126
3238
 
3239
+ if (typeof window !== 'undefined') {
3240
+ const heartbeat = setInterval(() => {
3241
+ if (ptyActiveIds.size === 0) return
3242
+ try {
3243
+ void fetch(PTY_TOUCH_ROUTE, {
3244
+ method: 'POST',
3245
+ headers: { 'content-type': 'application/json' },
3246
+ body: JSON.stringify({ ids: [...ptyActiveIds] }),
3247
+ keepalive: true,
3248
+ }).catch(() => {})
3249
+ } catch {}
3250
+ }, 60_000)
3251
+ heartbeat.unref?.()
3252
+ }
3253
+
3127
3254
  function closePtySession(id) {
3128
3255
  if (!id) return
3129
3256
  ptyClosedIds.add(id)
@@ -3267,6 +3394,29 @@ window.__ModuleLoader__.load({
3267
3394
  void ptyPost(PTY_CLEAR_ROUTE, { id }, 15_000).catch(() => {})
3268
3395
  }
3269
3396
 
3397
+ async function copyTextToClipboard(text) {
3398
+ if (!text) return false
3399
+ try {
3400
+ if (navigator.clipboard?.writeText) {
3401
+ await navigator.clipboard.writeText(text)
3402
+ return true
3403
+ }
3404
+ } catch {}
3405
+ try {
3406
+ const textarea = document.createElement('textarea')
3407
+ textarea.value = text
3408
+ textarea.style.position = 'fixed'
3409
+ textarea.style.opacity = '0'
3410
+ document.body.appendChild(textarea)
3411
+ textarea.select()
3412
+ const copied = document.execCommand('copy')
3413
+ textarea.remove()
3414
+ return copied
3415
+ } catch {
3416
+ return false
3417
+ }
3418
+ }
3419
+
3270
3420
  function decodeBase64(text) {
3271
3421
  const binary = atob(String(text ?? ''))
3272
3422
  const bytes = new Uint8Array(binary.length)
@@ -3274,19 +3424,35 @@ window.__ModuleLoader__.load({
3274
3424
  return bytes
3275
3425
  }
3276
3426
 
3277
- function TerminalPane({ model, host, sessionId, active, clearSignal }) {
3427
+ function TerminalPane({ model, host, sessionId, active, clearSignal, fontSize }) {
3278
3428
  const { t } = model
3279
3429
  const hostRef = React.useRef(host)
3280
3430
  hostRef.current = host
3281
3431
  const containerRef = React.useRef(null)
3282
3432
  const termRef = React.useRef(null)
3283
3433
  const fitRef = React.useRef(null)
3434
+ const searchAddonRef = React.useRef(null)
3435
+ const searchInputRef = React.useRef(null)
3436
+ const menuRef = React.useRef(null)
3437
+ const fontSizeRef = React.useRef(fontSize)
3438
+ fontSizeRef.current = fontSize
3284
3439
  const activeRef = React.useRef(active === true)
3285
3440
  const [status, setStatus] = React.useState('loading')
3441
+ const [searchOpen, setSearchOpen] = React.useState(false)
3442
+ const searchOpenRef = React.useRef(false)
3443
+ const [searchText, setSearchText] = React.useState('')
3444
+ const [searchCase, setSearchCase] = React.useState(false)
3445
+ const [menu, setMenu] = React.useState(null)
3286
3446
  const [message, setMessage] = React.useState('')
3287
3447
  const [retryToken, setRetryToken] = React.useState(0)
3288
3448
  const clearRequested = React.useRef(false)
3449
+ const reportStatus = model.setSessionState
3450
+
3451
+ React.useEffect(() => {
3452
+ reportStatus?.(sessionId, status)
3453
+ }, [reportStatus, sessionId, status])
3289
3454
  const syncedRef = React.useRef(false)
3455
+ const clearEpochRef = React.useRef(0)
3290
3456
 
3291
3457
  React.useEffect(() => {
3292
3458
  activeRef.current = active === true
@@ -3303,6 +3469,7 @@ window.__ModuleLoader__.load({
3303
3469
 
3304
3470
  React.useEffect(() => {
3305
3471
  if (!clearSignal) return
3472
+ clearEpochRef.current += 1
3306
3473
  // Only a clear issued before attach needs to survive into the attach
3307
3474
  // replay. A clear on a live, synced terminal is handled by the server
3308
3475
  // route directly and must not truncate the next attach replay.
@@ -3315,6 +3482,131 @@ window.__ModuleLoader__.load({
3315
3482
  } catch {}
3316
3483
  }, [clearSignal])
3317
3484
 
3485
+ React.useEffect(() => {
3486
+ const term = termRef.current
3487
+ if (!term) return
3488
+ try {
3489
+ if (term.options.fontSize !== fontSize) {
3490
+ term.options.fontSize = fontSize
3491
+ fitRef.current?.fit()
3492
+ }
3493
+ } catch {}
3494
+ }, [fontSize])
3495
+
3496
+ const openSearch = () => {
3497
+ if (!searchAddonRef.current) {
3498
+ try { model.notify?.(t('terminal.searchUnavailable'), 'error') } catch {}
3499
+ return
3500
+ }
3501
+ searchOpenRef.current = true
3502
+ setSearchOpen(true)
3503
+ requestAnimationFrame(() => searchInputRef.current?.focus())
3504
+ }
3505
+
3506
+ const closeSearch = () => {
3507
+ searchOpenRef.current = false
3508
+ setSearchOpen(false)
3509
+ try { searchAddonRef.current?.clearDecorations?.() } catch {}
3510
+ try { termRef.current?.focus() } catch {}
3511
+ }
3512
+
3513
+ const findInTerminal = (direction, caseSensitive = searchCase) => {
3514
+ const addon = searchAddonRef.current
3515
+ const text = searchText.trim()
3516
+ if (!addon) return
3517
+ try {
3518
+ if (text === '') addon.clearDecorations?.()
3519
+ else if (direction < 0) addon.findPrevious(text, { caseSensitive })
3520
+ else addon.findNext(text, { caseSensitive })
3521
+ } catch {}
3522
+ }
3523
+
3524
+ const updateSearchText = (value) => {
3525
+ setSearchText(value)
3526
+ const addon = searchAddonRef.current
3527
+ if (!addon) return
3528
+ try {
3529
+ if (value.trim() === '') addon.clearDecorations?.()
3530
+ else addon.findNext(value.trim(), { caseSensitive: searchCase })
3531
+ } catch {}
3532
+ }
3533
+
3534
+ const terminalMenuAction = (action) => {
3535
+ const term = termRef.current
3536
+ setMenu(null)
3537
+ if (!term) return
3538
+ const refocus = () => {
3539
+ requestAnimationFrame(() => {
3540
+ try { termRef.current?.focus() } catch {}
3541
+ })
3542
+ }
3543
+ if (action === 'selectAll') {
3544
+ try { term.selectAll() } catch {}
3545
+ refocus()
3546
+ return
3547
+ }
3548
+ if (action === 'clear') {
3549
+ // Route through App so clearRequested/clearEpoch and the host buffer
3550
+ // stay in sync with the session-bar clear button.
3551
+ if (typeof model.clearSession === 'function') model.clearSession(sessionId)
3552
+ else clearPtySession(sessionId)
3553
+ try { term.clear(); term.write('\x1b[H\x1b[2J') } catch {}
3554
+ refocus()
3555
+ return
3556
+ }
3557
+ if (action === 'copy') {
3558
+ const text = term.getSelection()
3559
+ void copyTextToClipboard(text).then((ok) => {
3560
+ if (!ok) {
3561
+ try { model.notify?.(t('terminal.copyFailed'), 'error') } catch {}
3562
+ }
3563
+ refocus()
3564
+ })
3565
+ return
3566
+ }
3567
+ if (action === 'paste') {
3568
+ if (!navigator.clipboard?.readText) {
3569
+ try { model.notify?.(t('terminal.pasteFailed'), 'error') } catch {}
3570
+ refocus()
3571
+ return
3572
+ }
3573
+ navigator.clipboard.readText().then((text) => {
3574
+ if (text) {
3575
+ try { term.paste(text) } catch {}
3576
+ }
3577
+ refocus()
3578
+ }).catch(() => {
3579
+ try { model.notify?.(t('terminal.pasteFailed'), 'error') } catch {}
3580
+ refocus()
3581
+ })
3582
+ }
3583
+ }
3584
+
3585
+ React.useEffect(() => {
3586
+ if (!menu) return undefined
3587
+ const close = () => setMenu(null)
3588
+ const onKey = (event) => {
3589
+ if (event.key !== 'Escape') return
3590
+ event.preventDefault()
3591
+ event.stopPropagation()
3592
+ setMenu(null)
3593
+ try { termRef.current?.focus() } catch {}
3594
+ }
3595
+ const onDown = (event) => {
3596
+ if (menuRef.current && menuRef.current.contains(event.target)) return
3597
+ setMenu(null)
3598
+ }
3599
+ document.addEventListener('mousedown', onDown)
3600
+ document.addEventListener('keydown', onKey, true)
3601
+ requestAnimationFrame(() => {
3602
+ menuRef.current?.querySelector('button')?.focus()
3603
+ })
3604
+ return () => {
3605
+ document.removeEventListener('mousedown', onDown)
3606
+ document.removeEventListener('keydown', onKey, true)
3607
+ }
3608
+ }, [menu])
3609
+
3318
3610
  React.useEffect(() => {
3319
3611
  const container = containerRef.current
3320
3612
  if (!container) return undefined
@@ -3379,7 +3671,7 @@ window.__ModuleLoader__.load({
3379
3671
  term = new Terminal({
3380
3672
  cursorBlink: true,
3381
3673
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
3382
- fontSize: 13,
3674
+ fontSize: fontSizeRef.current ?? 13,
3383
3675
  lineHeight: 1.2,
3384
3676
  scrollback: 5000,
3385
3677
  allowProposedApi: true,
@@ -3389,9 +3681,39 @@ window.__ModuleLoader__.load({
3389
3681
  })
3390
3682
  fit = new FitAddon()
3391
3683
  term.loadAddon(fit)
3684
+ if (assets.SearchAddon) {
3685
+ const searchAddon = new assets.SearchAddon()
3686
+ term.loadAddon(searchAddon)
3687
+ searchAddonRef.current = searchAddon
3688
+ }
3392
3689
  term.open(container)
3393
3690
  termRef.current = term
3394
3691
  fitRef.current = fit
3692
+ term.attachCustomKeyEventHandler((event) => {
3693
+ if (event.type !== 'keydown') return true
3694
+ const meta = event.metaKey || event.ctrlKey
3695
+ if (meta && event.key.toLowerCase() === 'f') {
3696
+ event.preventDefault()
3697
+ openSearch()
3698
+ return false
3699
+ }
3700
+ if (meta && (event.key === '=' || event.key === '+')) {
3701
+ event.preventDefault()
3702
+ model.setTerminalFontSize((value) => Math.min(TERMINAL_FONT_MAX, value + 1))
3703
+ return false
3704
+ }
3705
+ if (meta && (event.key === '-' || event.key === '_')) {
3706
+ event.preventDefault()
3707
+ model.setTerminalFontSize((value) => Math.max(TERMINAL_FONT_MIN, value - 1))
3708
+ return false
3709
+ }
3710
+ if (event.key === 'Escape' && searchOpenRef.current) {
3711
+ event.preventDefault()
3712
+ closeSearch()
3713
+ return false
3714
+ }
3715
+ return true
3716
+ })
3395
3717
  try { fit.fit() } catch {}
3396
3718
  themeUnsub = subscribeTerminalTheme(() => {
3397
3719
  if (removed || !term) return
@@ -3443,7 +3765,6 @@ window.__ModuleLoader__.load({
3443
3765
  sessionKey = sessionId
3444
3766
  const id = sessionKey
3445
3767
  let synced = false
3446
- let lastSeq = 0
3447
3768
  const pendingEvents = []
3448
3769
 
3449
3770
  const clearTerminal = () => {
@@ -3454,16 +3775,87 @@ window.__ModuleLoader__.load({
3454
3775
  } catch {}
3455
3776
  }
3456
3777
 
3778
+ let resyncing = false
3779
+ let resyncFailedAt = 0
3780
+ let resyncAttempts = 0
3781
+ let resyncGraceUntil = 0
3782
+ const resyncQueue = []
3783
+ let lastSeq = 0
3784
+
3457
3785
  const applyOutput = (event) => {
3458
3786
  const seq = Number(event.seq)
3459
3787
  if (Number.isFinite(seq)) {
3460
3788
  if (seq <= lastSeq) return
3461
- lastSeq = seq
3789
+ if (lastSeq > 0 && seq > lastSeq + 1) {
3790
+ if (Date.now() < resyncGraceUntil) {
3791
+ lastSeq = seq
3792
+ } else {
3793
+ resyncAttempts += 1
3794
+ if (resyncAttempts > 3) {
3795
+ resyncAttempts = 0
3796
+ resyncGraceUntil = Date.now() + 5_000
3797
+ lastSeq = seq
3798
+ } else {
3799
+ resyncQueue.push(event)
3800
+ void resync()
3801
+ return
3802
+ }
3803
+ }
3804
+ } else {
3805
+ lastSeq = seq
3806
+ resyncAttempts = 0
3807
+ }
3462
3808
  }
3463
3809
  try { term.write(decodeBase64(event.data)) } catch {}
3464
3810
  }
3465
3811
 
3812
+ const resync = async () => {
3813
+ if (resyncing || stopped || finished || !sessionKey) return
3814
+ const clearEpoch = clearEpochRef.current
3815
+ resyncing = true
3816
+ try {
3817
+ const value = await ptyPost(PTY_ATTACH_ROUTE, {
3818
+ id: sessionKey,
3819
+ cols: term?.cols ?? 80,
3820
+ rows: term?.rows ?? 24,
3821
+ }, 15_000)
3822
+ if (removed || stopped || finished) {
3823
+ resyncing = false
3824
+ return
3825
+ }
3826
+ if (clearEpochRef.current !== clearEpoch) {
3827
+ clearRequested.current = false
3828
+ clearTerminal()
3829
+ } else if (value && value.output) {
3830
+ try {
3831
+ term.reset()
3832
+ term.write(decodeBase64(value.output))
3833
+ } catch {}
3834
+ }
3835
+ const seq = Number(value?.seq)
3836
+ if (Number.isFinite(seq)) lastSeq = seq
3837
+ resyncing = false
3838
+ for (const event of resyncQueue.splice(0)) {
3839
+ if (finished || stopped) break
3840
+ handleEvent(event)
3841
+ }
3842
+ } catch {
3843
+ resyncing = false
3844
+ resyncFailedAt = Date.now()
3845
+ resyncGraceUntil = Date.now() + 5_000
3846
+ try { model.notify?.(t('terminal.resyncFailed'), 'error') } catch {}
3847
+ for (const event of resyncQueue.splice(0)) {
3848
+ if (finished || stopped) break
3849
+ handleEvent(event)
3850
+ }
3851
+ }
3852
+ }
3853
+
3466
3854
  const handleEvent = (event) => {
3855
+ if (resyncing) {
3856
+ resyncQueue.push(event)
3857
+ return
3858
+ }
3467
3859
  if (event.kind === 'output') {
3468
3860
  if (!synced) pendingEvents.push(event)
3469
3861
  else applyOutput(event)
@@ -3566,6 +3958,7 @@ window.__ModuleLoader__.load({
3566
3958
  }
3567
3959
  termRef.current = null
3568
3960
  fitRef.current = null
3961
+ searchAddonRef.current = null
3569
3962
  }
3570
3963
  }, [sessionId, retryToken])
3571
3964
 
@@ -3573,8 +3966,81 @@ window.__ModuleLoader__.load({
3573
3966
  const showOverlay = status !== 'ready'
3574
3967
  return h(
3575
3968
  'div',
3576
- { className: 'ssh-terminalPane' },
3969
+ {
3970
+ className: 'ssh-terminalPane',
3971
+ onContextMenu: (event) => {
3972
+ if (!termRef.current || status !== 'ready') return
3973
+ if (event.target?.closest?.('.ssh-terminalSearch')) return
3974
+ event.preventDefault()
3975
+ const rect = event.currentTarget.getBoundingClientRect()
3976
+ setMenu({
3977
+ x: Math.max(0, Math.min(event.clientX - rect.left, rect.width - 170)),
3978
+ y: Math.max(0, Math.min(event.clientY - rect.top, rect.height - 150)),
3979
+ })
3980
+ },
3981
+ onMouseDown: () => setMenu(null),
3982
+ },
3577
3983
  h('div', { className: 'ssh-terminalHost', ref: containerRef }),
3984
+ searchOpen
3985
+ ? h(
3986
+ 'div',
3987
+ {
3988
+ className: 'ssh-terminalSearch',
3989
+ onMouseDown: (event) => event.stopPropagation(),
3990
+ },
3991
+ h(IconSearch, { size: 13 }),
3992
+ h('input', {
3993
+ ref: searchInputRef,
3994
+ className: 'ssh-terminalSearchInput',
3995
+ value: searchText,
3996
+ placeholder: t('terminal.search'),
3997
+ 'aria-label': t('terminal.search'),
3998
+ onChange: (event) => updateSearchText(event.target.value),
3999
+ onKeyDown: (event) => {
4000
+ event.stopPropagation()
4001
+ if (event.key === 'Escape') closeSearch()
4002
+ else if (event.key === 'Enter') findInTerminal(event.shiftKey ? -1 : 1)
4003
+ },
4004
+ }),
4005
+ h('button', {
4006
+ type: 'button',
4007
+ className: cx('ssh-terminalSearchBtn', searchCase && 'on'),
4008
+ title: t('terminal.searchCase'),
4009
+ 'aria-pressed': searchCase,
4010
+ onMouseDown: (event) => event.preventDefault(),
4011
+ onClick: () => {
4012
+ const next = !searchCase
4013
+ setSearchCase(next)
4014
+ if (searchText.trim()) findInTerminal(1, next)
4015
+ },
4016
+ }, 'Aa'),
4017
+ h('button', { type: 'button', className: 'ssh-terminalSearchBtn', title: t('terminal.searchPrevious'), 'aria-label': t('terminal.searchPrevious'), onMouseDown: (event) => event.preventDefault(), onClick: () => findInTerminal(-1) }, h(IconChevronLeft, { size: 12 })),
4018
+ h('button', { type: 'button', className: 'ssh-terminalSearchBtn', title: t('terminal.searchNext'), 'aria-label': t('terminal.searchNext'), onMouseDown: (event) => event.preventDefault(), onClick: () => findInTerminal(1) }, h(IconChevronRight, { size: 12 })),
4019
+ h('button', { type: 'button', className: 'ssh-terminalSearchBtn', title: t('terminal.searchClose'), 'aria-label': t('terminal.searchClose'), onClick: closeSearch }, h(IconClose, { size: 12 })),
4020
+ )
4021
+ : null,
4022
+ menu
4023
+ ? h(
4024
+ 'div',
4025
+ {
4026
+ ref: menuRef,
4027
+ className: 'ssh-terminalMenu',
4028
+ role: 'menu',
4029
+ 'aria-label': t('terminal.menu.title'),
4030
+ style: { left: menu.x, top: menu.y },
4031
+ onMouseDown: (event) => event.stopPropagation(),
4032
+ },
4033
+ ['copy', 'paste', 'selectAll', 'clear'].map((action) =>
4034
+ h('button', {
4035
+ key: action,
4036
+ type: 'button',
4037
+ role: 'menuitem',
4038
+ className: 'ssh-terminalMenuItem',
4039
+ onClick: () => terminalMenuAction(action),
4040
+ }, t(`terminal.menu.${action}`)),
4041
+ ),
4042
+ )
4043
+ : null,
3578
4044
  showOverlay
3579
4045
  ? h(
3580
4046
  'div',
@@ -4823,6 +5289,22 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
4823
5289
  .ssh-terminalOverlayBody{max-width:560px;font-size:11.5px;line-height:1.7;color:var(--ssh-text-3);word-break:break-word}
4824
5290
  .ssh-terminalOverlayActions{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
4825
5291
  .ssh-sessionLayer{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--ssh-bg)}
5292
+ .ssh-sessionState{width:6px;height:6px;border-radius:50%;background:var(--ssh-dim);flex:0 0 auto}
5293
+ .ssh-fontGlyph{font-size:10.5px;font-weight:600;letter-spacing:-.4px}
5294
+ .ssh-terminalSearch{position:absolute;top:8px;right:18px;z-index:5;max-width:calc(100% - 24px);display:flex;align-items:center;gap:5px;padding:5px 7px;border:.5px solid var(--ssh-border-strong);border-radius:10px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow);color:var(--ssh-text-3)}
5295
+ .ssh-terminalSearchInput{width:min(180px,42vw);max-width:100%;height:24px;padding:0 7px;border:.5px solid var(--ssh-border);border-radius:6px;background:var(--ssh-bg);color:var(--ssh-text);font:inherit;font-size:12px;outline:none}
5296
+ .ssh-terminalSearchInput:focus{border-color:var(--ssh-brand)}
5297
+ .ssh-terminalSearchBtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border:0;border-radius:6px;background:transparent;color:var(--ssh-text-2);font:inherit;font-size:11px;cursor:pointer}
5298
+ .ssh-terminalSearchBtn:hover{background:var(--ssh-hover);color:var(--ssh-text)}
5299
+ .ssh-terminalSearchBtn.on{background:color-mix(in srgb,var(--ssh-brand) 15%,transparent);color:var(--ssh-brand)}
5300
+ .ssh-terminalMenu{position:absolute;z-index:7;min-width:150px;padding:4px;border:.5px solid var(--ssh-border-strong);border-radius:9px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow)}
5301
+ .ssh-terminalMenuItem{appearance:none;display:flex;align-items:center;width:100%;height:28px;padding:0 9px;border:0;border-radius:6px;background:transparent;color:var(--ssh-text-2);font:inherit;font-size:12px;text-align:left;cursor:pointer}
5302
+ .ssh-terminalMenuItem:hover{background:var(--ssh-hover);color:var(--ssh-text)}
5303
+ .ssh-sessionState.loading,.ssh-sessionState.connecting{background:var(--ssh-warn)}
5304
+ .ssh-sessionState.ready{background:var(--ssh-success)}
5305
+ .ssh-sessionState.error,.ssh-sessionState.closed{background:var(--ssh-danger)}
5306
+ .ssh-tabLabel{display:inline-flex;align-items:center;gap:5px}
5307
+ .ssh-tabAlert{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--ssh-danger);flex:0 0 auto}
4826
5308
  .ssh-sessions{flex:1;min-height:0;display:flex;flex-direction:column}
4827
5309
  .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}
4828
5310
  .ssh-sessionTabs{flex:1;min-width:0;display:flex;align-items:center;gap:4px;overflow-x:auto;overflow-y:hidden;padding-bottom:1px}