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/README.en.md +11 -11
- package/README.md +14 -13
- package/assets/LICENSE.addon-search +19 -0
- package/assets/addon-search.js +2 -0
- package/client.js +496 -14
- package/index.js +52 -12
- package/lib/known-hosts.js +136 -0
- package/lib/ssh-client.js +20 -3
- package/package.json +1 -1
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
|
|
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({
|
|
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)
|
|
2989
|
-
|
|
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
|
|
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
|
-
{
|
|
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}
|