dsh-ssh-manage 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
10
  [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](package.json)
11
+ [![npm](https://img.shields.io/npm/v/dsh-ssh-manage.svg)](https://www.npmjs.com/package/dsh-ssh-manage)
11
12
  [![DSH](https://img.shields.io/badge/DeepSeek%20Harness-plugin-4E7CF6)](https://github.com/deepseek-ai/deepseek-harness)
12
13
  [![GitHub](https://img.shields.io/badge/github-sunChargedd%2Fdsh--plugins-181717?logo=github)](https://github.com/sunChargedd/dsh-plugins)
13
14
 
@@ -33,7 +34,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
33
34
 
34
35
  ### Host management
35
36
 
36
- - Group tree on the left, host table on the right: search, filter by group, favorite, tags, notes, connection state and latency.
37
+ - Group tree on the left, host table on the right: search, filter by group, favorite, tags, notes, connection state and latency. Drag the divider to resize the group sidebar (double-click resets; the width is remembered).
37
38
  - Create, edit, rename and delete hosts; renaming migrates group, tags, favorites, notes, inspection data and the saved password.
38
39
  - Configurable table columns (info / notes / compact rows), responsive on narrow screens.
39
40
  - Host details drawer: address, user, port, group, tags, notes, distro, CPU, memory, disk, latency, plus "Test connection" and "Inspect".
@@ -43,7 +44,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
43
44
  ### Interactive terminal
44
45
 
45
46
  - Click "Connect" to open a real PTY shell, not a one-command-at-a-time console.
46
- - Multiple hosts stay as tabs inside the plugin page; switching tabs keeps output, closing a tab ends that session.
47
+ - Multiple hosts stay as tabs: switching to another DSH session and coming back re-attaches to the existing shell and replays roughly the last 256 KB of output; only closing the tab ends the session.
47
48
  - xterm.js 6.0.0 rendering with 256 colors, cursor, scrollback, selection copy, paste and resize.
48
49
  - Terminal theme follows the DSH light/dark theme; the session bar has quick actions for address, password status, test, files, details, clear and close.
49
50
  - Output is streamed over a same-origin HTTP/SSE connection; input, resize and close use same-origin HTTP POST. A tested WebSocket channel is kept as a fallback.
@@ -93,18 +94,18 @@ Creating a host requires alias, hostname and password. The password is not writt
93
94
 
94
95
  ## Installation
95
96
 
96
- ### From this repository (current method)
97
+ ### From npm (recommended)
97
98
 
98
- The plugin is not published to npm yet. Install it from the monorepo subdirectory:
99
+ npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.0`).
99
100
 
100
101
  ```text
101
- plugin_manager action=install_bundle target=<repository>/dsh-ssh-manage
102
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.0
102
103
  ```
103
104
 
104
105
  Or with the DSH CLI:
105
106
 
106
107
  ```bash
107
- dsh plugin add <repository>/dsh-ssh-manage
108
+ dsh plugin add dsh-ssh-manage@0.2.0
108
109
  ```
109
110
 
110
111
  After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
@@ -215,7 +216,7 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`) and
215
216
  ## Known limitations
216
217
 
217
218
  - **Host key verification** is not implemented; ssh2 accepts the server-provided key. Use trusted networks only.
218
- - **Session lifetime**: terminal sessions live in plugin page memory and reset when leaving the SSH panel, refreshing the page or HMR; page close sends best-effort close notifications, and orphaned sessions are cleaned up by the idle reaper.
219
+ - **Session lifetime**: sessions are kept on the Host; switching to another DSH session, temporarily leaving the panel, or refreshing the page re-attaches to the existing shell and replays roughly the last 256 KB of output. Only closing the tab or exiting the Host process ends them; orphaned sessions are reaped after 6 hours of inactivity (max 16 sessions).
219
220
  - **Password auth only**: IdentityFile / SSH agent / ProxyJump are not used for transport; those config fields are preserved but ignored. ProxyJump hosts fail with a clear error.
220
221
  - **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
221
222
  - **Batch commands** run one command; they are not an interactive terminal. Per-host output is capped at 1 MB and marked `truncated` when exceeded.
package/README.md CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
10
  [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](package.json)
11
+ [![npm](https://img.shields.io/npm/v/dsh-ssh-manage.svg)](https://www.npmjs.com/package/dsh-ssh-manage)
11
12
  [![DSH](https://img.shields.io/badge/DeepSeek%20Harness-plugin-4E7CF6)](https://github.com/deepseek-ai/deepseek-harness)
12
13
  [![GitHub](https://img.shields.io/badge/github-sunChargedd%2Fdsh--plugins-181717?logo=github)](https://github.com/sunChargedd/dsh-plugins)
13
14
 
@@ -33,7 +34,7 @@
33
34
 
34
35
  ### 主机管理
35
36
 
36
- - 左侧分组树 + 右侧主机表格:搜索、按分组筛选、收藏、标签、备注、连接状态与延迟一目了然。
37
+ - 左侧分组树 + 右侧主机表格:搜索、按分组筛选、收藏、标签、备注、连接状态与延迟一目了然;分组栏可拖拽调整宽度(双击恢复默认),宽度会记住。
37
38
  - 新建、编辑、重命名、删除主机;重命名自动迁移分组、标签、收藏、备注、巡检信息和已保存密码。
38
39
  - 表格列可配置(信息列 / 备注列 / 紧凑行高),窄屏自适应。
39
40
  - 主机详情抽屉:地址、用户、端口、分组、标签、备注、发行版、CPU、内存、磁盘、延迟,以及「测试连接」「巡检」。
@@ -43,7 +44,7 @@
43
44
  ### 交互式终端
44
45
 
45
46
  - 点「连接」打开真正的 PTY shell,不是一条命令一条命令地执行。
46
- - 多台主机以标签页常驻在当前插件页面,切换标签不丢输出;关闭标签才结束会话。
47
+ - 多台主机以标签页常驻:切换到其它 DSH 会话再回来,终端会重新附着到原来的 shell,并回放最近约 256 KB 输出;关闭标签才结束会话。
47
48
  - xterm.js 6.0.0 渲染,支持 256 色、光标、滚动回看、选中复制、粘贴、窗口 resize。
48
49
  - 终端主题跟随 DSH 明暗主题;会话顶部有地址、密码状态、测试连接、文件、详情、清屏、关闭等快捷操作。
49
50
  - 输出走同源 HTTP/SSE 长连接,输入 / 尺寸 / 关闭走同源 HTTP POST;插件同时保留经过测试的 WebSocket 备用通道。
@@ -95,18 +96,18 @@
95
96
 
96
97
  ## 安装
97
98
 
98
- ### 从本仓库安装(当前方式)
99
+ ### 从 npm 安装(推荐)
99
100
 
100
- 本插件尚未发布 npm,直接从 monorepo 子目录安装:
101
+ npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.0`)。
101
102
 
102
103
  ```text
103
- plugin_manager action=install_bundle target=<仓库绝对路径>/dsh-ssh-manage
104
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.0
104
105
  ```
105
106
 
106
107
  或使用 DSH CLI:
107
108
 
108
109
  ```bash
109
- dsh plugin add <仓库绝对路径>/dsh-ssh-manage
110
+ dsh plugin add dsh-ssh-manage@0.2.0
110
111
  ```
111
112
 
112
113
  安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
@@ -217,7 +218,7 @@ npm test
217
218
  ## 已知限制
218
219
 
219
220
  - **主机密钥校验**:未接入 `known_hosts`,ssh2 默认接受服务器主机密钥;仅在可信网络使用。
220
- - **会话生命周期**:终端会话保存在插件页面内存中,离开 SSH 插件面板、页面刷新或 HMR 会重置;页面关闭时会尽力发送关闭通知,异常崩溃留下的孤儿会话由空闲清理兜底。
221
+ - **会话生命周期**:终端会话由 Host 保留,切换 DSH 会话/暂时离开插件面板/刷新页面后会重新附着并回放最近约 256 KB 输出;只有关闭标签或 Host 进程退出才真正结束。Host 侧空闲超过 6 小时会清理孤儿会话(最大 16 个)。
221
222
  - **仅密码认证**:不支持 IdentityFile / SSH agent / ProxyJump 传输;配置中的这些字段会原样保留,但不会用于连接。ProxyJump 主机会明确报错。
222
223
  - **配置编辑范围**:编辑器只修改 `Host`、`HostName`、`User`、`Port` 与插件元数据;其它 SSH 指令请在 `~/.ssh/config` 中维护。
223
224
  - **批量命令**:一次执行一条命令,不等同于交互式终端;单台输出上限 1 MB,超出会标记 `truncated`。
package/client.js CHANGED
@@ -26,6 +26,7 @@ window.__ModuleLoader__.load({
26
26
  'app.configFile': ['配置文件', 'Config file'],
27
27
  'app.copyConfig': ['复制配置路径', 'Copy config path'],
28
28
  'toolbar.collapse': ['收起', 'Collapse'],
29
+ 'toolbar.resize': ['拖动调整分组栏宽度,双击恢复默认', 'Drag to resize the group sidebar; double-click to reset'],
29
30
  'toolbar.expand': ['展开', 'Expand'],
30
31
  'toolbar.refresh': ['刷新', 'Refresh'],
31
32
  'toolbar.groups': ['分组', 'Groups'],
@@ -1068,7 +1069,7 @@ window.__ModuleLoader__.load({
1068
1069
 
1069
1070
  function tableTracks(prefs) {
1070
1071
  const tracks = ['32px', '112px', '92px', 'minmax(0,1.1fr)', 'minmax(0,1.2fr)']
1071
- if (prefs.info) tracks.push('minmax(0,1.5fr)')
1072
+ if (prefs.info) tracks.push('minmax(250px,1.5fr)')
1072
1073
  if (prefs.notes) tracks.push('minmax(0,0.9fr)')
1073
1074
  tracks.push('176px', '32px')
1074
1075
  return tracks.join(' ')
@@ -1093,6 +1094,48 @@ window.__ModuleLoader__.load({
1093
1094
  // App
1094
1095
  // ---------------------------------------------------------------------
1095
1096
 
1097
+ // Survives the plugin panel unmounting when the user switches to another
1098
+ // DSH session (and a client-module reload). Host-side PTYs stay alive;
1099
+ // mounting again attaches to them and replays recent output.
1100
+ const PANEL_STATE_KEY = 'dsh-ssh-manage:panel-state'
1101
+ const TREE_WIDTH_KEY = 'dsh-ssh-manage:tree-width'
1102
+
1103
+ function readPanelState() {
1104
+ try {
1105
+ const parsed = JSON.parse(String(window.sessionStorage.getItem(PANEL_STATE_KEY) ?? 'null'))
1106
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sessions)) {
1107
+ return { sessions: [], activeSessionId: null, tab: 'hosts' }
1108
+ }
1109
+ const sessions = parsed.sessions
1110
+ .filter((session) => session && typeof session.id === 'string' && typeof session.hostAlias === 'string')
1111
+ .map((session) => ({ id: session.id, hostAlias: session.hostAlias }))
1112
+ const tab = ['hosts', 'sessions', 'batch', 'settings'].includes(parsed.tab) ? parsed.tab : 'hosts'
1113
+ const activeSessionId = typeof parsed.activeSessionId === 'string' ? parsed.activeSessionId : null
1114
+ return { sessions, activeSessionId, tab }
1115
+ } catch {
1116
+ return { sessions: [], activeSessionId: null, tab: 'hosts' }
1117
+ }
1118
+ }
1119
+
1120
+ const persistentPanelState = readPanelState()
1121
+ const TREE_MIN_WIDTH = 220
1122
+ const TREE_MAX_WIDTH = 560
1123
+
1124
+ function clampTreeWidth(value) {
1125
+ const numeric = Number(value)
1126
+ if (!Number.isFinite(numeric)) return 300
1127
+ return Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, Math.round(numeric)))
1128
+ }
1129
+
1130
+ function readTreeWidth() {
1131
+ try {
1132
+ const stored = window.localStorage.getItem(TREE_WIDTH_KEY)
1133
+ return stored === null ? 300 : clampTreeWidth(stored)
1134
+ } catch {
1135
+ return 300
1136
+ }
1137
+ }
1138
+
1096
1139
  function App({ ctx }) {
1097
1140
  const t = useT(ctx)
1098
1141
  const localeId = useLocaleId(ctx)
@@ -1100,7 +1143,11 @@ window.__ModuleLoader__.load({
1100
1143
  const [loading, setLoading] = React.useState(true)
1101
1144
  const [refreshing, setRefreshing] = React.useState(false)
1102
1145
  const [error, setError] = React.useState('')
1103
- const [tab, setTab] = React.useState('hosts')
1146
+ const [tab, setTab] = React.useState(() => {
1147
+ const saved = persistentPanelState.tab
1148
+ if (saved === 'sessions' && persistentPanelState.sessions.length === 0) return 'hosts'
1149
+ return ['hosts', 'sessions', 'batch', 'settings'].includes(saved) ? saved : 'hosts'
1150
+ })
1104
1151
  const [filter, setFilter] = React.useState('')
1105
1152
  const [favoriteOnly, setFavoriteOnly] = React.useState(false)
1106
1153
  const [groupFilter, setGroupFilter] = React.useState(null)
@@ -1119,12 +1166,14 @@ window.__ModuleLoader__.load({
1119
1166
  const [pending, setPending] = React.useState({})
1120
1167
  const [sort, setSort] = React.useState({ key: 'name', dir: 'asc' })
1121
1168
  const [columns, setColumns] = React.useState({ info: true, notes: true, compact: false })
1122
- const [sessions, setSessions] = React.useState([])
1123
- const [activeSessionId, setActiveSessionId] = React.useState(null)
1169
+ const [sessions, setSessions] = React.useState(() =>
1170
+ persistentPanelState.sessions.map((session) => ({ id: session.id, hostAlias: session.hostAlias })),
1171
+ )
1172
+ const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
1173
+ const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
1124
1174
  const [clearSignals, setClearSignals] = React.useState({})
1125
1175
  const toastTimer = React.useRef(null)
1126
1176
  const floatingRef = React.useRef(null)
1127
- const sessionCounter = React.useRef(0)
1128
1177
 
1129
1178
  const notify = React.useCallback((message, tone = 'info') => {
1130
1179
  if (toastTimer.current) clearTimeout(toastTimer.current)
@@ -1139,6 +1188,25 @@ window.__ModuleLoader__.load({
1139
1188
  [],
1140
1189
  )
1141
1190
 
1191
+ React.useEffect(() => {
1192
+ persistentPanelState.sessions = sessions.map((session) => ({ id: session.id, hostAlias: session.hostAlias }))
1193
+ persistentPanelState.activeSessionId = activeSessionId
1194
+ persistentPanelState.tab = tab
1195
+ try {
1196
+ window.sessionStorage.setItem(PANEL_STATE_KEY, JSON.stringify(persistentPanelState))
1197
+ } catch {}
1198
+ }, [sessions, activeSessionId, tab])
1199
+
1200
+ React.useEffect(() => {
1201
+ try {
1202
+ window.localStorage.setItem(TREE_WIDTH_KEY, String(treeWidth))
1203
+ } catch {}
1204
+ }, [treeWidth])
1205
+
1206
+ React.useEffect(() => {
1207
+ for (const session of sessions) ptyActiveIds.add(session.id)
1208
+ }, [sessions])
1209
+
1142
1210
  const applySnapshot = React.useCallback((next) => {
1143
1211
  if (!next) return
1144
1212
  const aliases = new Set((next.hosts ?? []).map((host) => host.host))
@@ -1217,8 +1285,11 @@ window.__ModuleLoader__.load({
1217
1285
  if (!snapshot) return
1218
1286
  const aliases = new Set(hosts.map((host) => host.host))
1219
1287
  setSessions((prev) => {
1288
+ const removed = prev.filter((session) => !aliases.has(session.hostAlias))
1220
1289
  const next = prev.filter((session) => aliases.has(session.hostAlias))
1221
- return next.length === prev.length ? prev : next
1290
+ if (removed.length === 0) return prev
1291
+ for (const session of removed) closePtySession(session.id)
1292
+ return next
1222
1293
  })
1223
1294
  }, [hosts, snapshot])
1224
1295
 
@@ -1298,8 +1369,8 @@ window.__ModuleLoader__.load({
1298
1369
  if (existing) {
1299
1370
  setActiveSessionId(existing.id)
1300
1371
  } else {
1301
- sessionCounter.current += 1
1302
- const id = `ssh-session-${Date.now()}-${sessionCounter.current}`
1372
+ const id = newPtySessionId()
1373
+ ptyActiveIds.add(id)
1303
1374
  setSessions((prev) => [...prev, { id, hostAlias: host.host }])
1304
1375
  setActiveSessionId(id)
1305
1376
  }
@@ -1309,6 +1380,7 @@ window.__ModuleLoader__.load({
1309
1380
  function closeSession(id) {
1310
1381
  const index = sessions.findIndex((session) => session.id === id)
1311
1382
  if (index < 0) return
1383
+ closePtySession(id)
1312
1384
  const next = sessions.filter((session) => session.id !== id)
1313
1385
  setSessions(next)
1314
1386
  setClearSignals((prev) => {
@@ -1602,6 +1674,8 @@ window.__ModuleLoader__.load({
1602
1674
  setFloating,
1603
1675
  setEditor,
1604
1676
  setDrawer,
1677
+ treeWidth,
1678
+ setTreeWidth,
1605
1679
  sessions,
1606
1680
  activeSessionId,
1607
1681
  setActiveSessionId,
@@ -2498,10 +2572,55 @@ window.__ModuleLoader__.load({
2498
2572
  }
2499
2573
 
2500
2574
  function HostsTab({ model }) {
2575
+ const { t } = model
2576
+ const beginResize = (event) => {
2577
+ if (!model.sidebarOpen || event.button !== 0) return
2578
+ event.preventDefault()
2579
+ event.stopPropagation()
2580
+ const startX = event.clientX
2581
+ const startWidth = model.treeWidth
2582
+ document.body.classList.add('ssh-col-resizing')
2583
+ const onMove = (moveEvent) => {
2584
+ model.setTreeWidth(clampTreeWidth(startWidth + moveEvent.clientX - startX))
2585
+ }
2586
+ const onUp = () => {
2587
+ document.body.classList.remove('ssh-col-resizing')
2588
+ window.removeEventListener('pointermove', onMove)
2589
+ window.removeEventListener('pointerup', onUp)
2590
+ window.removeEventListener('pointercancel', onUp)
2591
+ }
2592
+ window.addEventListener('pointermove', onMove)
2593
+ window.addEventListener('pointerup', onUp)
2594
+ window.addEventListener('pointercancel', onUp)
2595
+ }
2596
+ const nudgeWidth = (event) => {
2597
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
2598
+ event.preventDefault()
2599
+ model.setTreeWidth(clampTreeWidth(model.treeWidth + (event.key === 'ArrowLeft' ? -20 : 20)))
2600
+ }
2501
2601
  return h(
2502
2602
  'div',
2503
- { className: cx('ssh-body', !model.sidebarOpen && 'collapsed') },
2603
+ {
2604
+ className: cx('ssh-body', !model.sidebarOpen && 'collapsed'),
2605
+ style: { '--ssh-tree-width': `${model.treeWidth}px` },
2606
+ },
2504
2607
  h(Sidebar, { model }),
2608
+ model.sidebarOpen
2609
+ ? h('div', {
2610
+ className: 'ssh-treeResizer',
2611
+ role: 'separator',
2612
+ tabIndex: 0,
2613
+ 'aria-orientation': 'vertical',
2614
+ 'aria-label': t('toolbar.resize'),
2615
+ 'aria-valuemin': TREE_MIN_WIDTH,
2616
+ 'aria-valuemax': TREE_MAX_WIDTH,
2617
+ 'aria-valuenow': model.treeWidth,
2618
+ title: t('toolbar.resize'),
2619
+ onPointerDown: beginResize,
2620
+ onKeyDown: nudgeWidth,
2621
+ onDoubleClick: () => model.setTreeWidth(300),
2622
+ })
2623
+ : null,
2505
2624
  model.sidebarOpen ? h('div', { className: 'ssh-sideScrim', onClick: () => model.setSidebarOpen(false) }) : null,
2506
2625
  h(
2507
2626
  'main',
@@ -2913,10 +3032,26 @@ window.__ModuleLoader__.load({
2913
3032
  }
2914
3033
 
2915
3034
  const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
3035
+ const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
2916
3036
  const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
2917
3037
  const ptyActiveIds = new Set()
3038
+ const ptyClosedIds = new Set()
2918
3039
  const ptyStreamLostHandlers = new Set()
2919
3040
 
3041
+ function closePtySession(id) {
3042
+ if (!id) return
3043
+ ptyClosedIds.add(id)
3044
+ ptyActiveIds.delete(id)
3045
+ try {
3046
+ void fetch('/dsh-ssh-manage/pty/close', {
3047
+ method: 'POST',
3048
+ headers: { 'content-type': 'application/json' },
3049
+ body: JSON.stringify({ id }),
3050
+ keepalive: true,
3051
+ })
3052
+ } catch {}
3053
+ }
3054
+
2920
3055
  function onPtyStreamLost(handler) {
2921
3056
  ptyStreamLostHandlers.add(handler)
2922
3057
  return () => ptyStreamLostHandlers.delete(handler)
@@ -2934,17 +3069,6 @@ window.__ModuleLoader__.load({
2934
3069
  }
2935
3070
  }
2936
3071
 
2937
- if (typeof window !== 'undefined') {
2938
- window.addEventListener('pagehide', () => {
2939
- for (const id of ptyActiveIds) {
2940
- try {
2941
- navigator.sendBeacon('/dsh-ssh-manage/pty/close', new Blob([JSON.stringify({ id })], { type: 'application/json' }))
2942
- } catch {}
2943
- }
2944
- ptyActiveIds.clear()
2945
- })
2946
- }
2947
-
2948
3072
  function subscribePty(id, listener) {
2949
3073
  ptyStream.listeners.set(id, listener)
2950
3074
  return () => {
@@ -3043,7 +3167,6 @@ window.__ModuleLoader__.load({
3043
3167
  const containerRef = React.useRef(null)
3044
3168
  const termRef = React.useRef(null)
3045
3169
  const fitRef = React.useRef(null)
3046
- const sessionIdRef = React.useRef(null)
3047
3170
  const activeRef = React.useRef(active === true)
3048
3171
  const [status, setStatus] = React.useState('loading')
3049
3172
  const [message, setMessage] = React.useState('')
@@ -3099,24 +3222,7 @@ window.__ModuleLoader__.load({
3099
3222
  unsubscribe()
3100
3223
  unsubscribe = null
3101
3224
  }
3102
- const id = sessionKey
3103
3225
  sessionKey = null
3104
- sessionIdRef.current = null
3105
- if (id) {
3106
- ptyActiveIds.delete(id)
3107
- postClose(id)
3108
- }
3109
- }
3110
-
3111
- const postClose = (id) => {
3112
- try {
3113
- void fetch('/dsh-ssh-manage/pty/close', {
3114
- method: 'POST',
3115
- headers: { 'content-type': 'application/json' },
3116
- body: JSON.stringify({ id }),
3117
- keepalive: true,
3118
- })
3119
- } catch {}
3120
3226
  }
3121
3227
 
3122
3228
  const failSession = (error) => {
@@ -3207,42 +3313,83 @@ window.__ModuleLoader__.load({
3207
3313
  return
3208
3314
  }
3209
3315
  if (removed) return
3210
- sessionKey = newPtySessionId()
3211
- sessionIdRef.current = sessionKey
3212
- ptyActiveIds.add(sessionKey)
3213
- unsubscribe = subscribePty(sessionKey, (event) => {
3214
- if (removed) return
3316
+ sessionKey = sessionId
3317
+ const id = sessionKey
3318
+ let synced = false
3319
+ let lastSeq = 0
3320
+ const pendingEvents = []
3321
+
3322
+ const applyOutput = (event) => {
3323
+ const seq = Number(event.seq)
3324
+ if (Number.isFinite(seq)) {
3325
+ if (seq <= lastSeq) return
3326
+ lastSeq = seq
3327
+ }
3328
+ try { term.write(decodeBase64(event.data)) } catch {}
3329
+ }
3330
+
3331
+ const handleEvent = (event) => {
3215
3332
  if (event.kind === 'output') {
3216
- try { term.write(decodeBase64(event.data)) } catch {}
3333
+ if (!synced) pendingEvents.push(event)
3334
+ else applyOutput(event)
3217
3335
  } else if (event.kind === 'exit') {
3336
+ if (!synced) {
3337
+ pendingEvents.push(event)
3338
+ return
3339
+ }
3218
3340
  finished = true
3219
3341
  setStatus('closed')
3220
3342
  setMessage(t('terminal.exited'))
3221
3343
  teardown()
3222
3344
  } else if (event.kind === 'error') {
3345
+ if (!synced) {
3346
+ pendingEvents.push(event)
3347
+ return
3348
+ }
3223
3349
  finished = true
3224
3350
  setStatus('error')
3225
3351
  setMessage(String(event.message ?? t('terminal.failed')))
3226
3352
  teardown()
3227
3353
  }
3354
+ }
3355
+
3356
+ unsubscribe = subscribePty(id, (event) => {
3357
+ if (removed) return
3358
+ handleEvent(event)
3228
3359
  })
3229
3360
  let dims = { cols: 80, rows: 24 }
3230
3361
  try {
3231
3362
  const proposed = fit?.proposeDimensions?.()
3232
3363
  if (proposed && proposed.cols > 0 && proposed.rows > 0) dims = proposed
3233
3364
  } catch {}
3234
- const openedId = sessionKey
3235
3365
  try {
3236
- await ptyPost('/dsh-ssh-manage/pty/open', {
3237
- id: openedId,
3238
- host: hostRef.current.host,
3239
- cols: dims.cols,
3240
- rows: dims.rows,
3241
- }, 40_000)
3242
- if (removed || finished) {
3243
- postClose(openedId)
3366
+ let value
3367
+ try {
3368
+ value = await ptyPost(PTY_ATTACH_ROUTE, { id, cols: dims.cols, rows: dims.rows }, 15_000)
3369
+ } catch {
3370
+ value = await ptyPost('/dsh-ssh-manage/pty/open', {
3371
+ id,
3372
+ host: hostRef.current.host,
3373
+ cols: dims.cols,
3374
+ rows: dims.rows,
3375
+ }, 40_000)
3376
+ }
3377
+ if (ptyClosedIds.has(id)) {
3378
+ closePtySession(id)
3244
3379
  return
3245
3380
  }
3381
+ if (removed) return
3382
+ if (value && value.output) {
3383
+ try { term.write(decodeBase64(value.output)) } catch {}
3384
+ }
3385
+ const seq = Number(value?.seq)
3386
+ if (Number.isFinite(seq)) lastSeq = seq
3387
+ synced = true
3388
+ for (const event of pendingEvents.splice(0)) {
3389
+ if (finished) break
3390
+ handleEvent(event)
3391
+ }
3392
+ if (finished) return
3246
3393
  opened = true
3247
3394
  setStatus('ready')
3248
3395
  setMessage('')
@@ -4283,8 +4430,13 @@ window.__ModuleLoader__.load({
4283
4430
  .ssh-errorBar{flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:8px 16px;border-bottom:.5px solid var(--ssh-border);color:var(--ssh-danger);font-size:11.5px;background:color-mix(in srgb,var(--ssh-danger) 7%,var(--ssh-bg))}
4284
4431
  .ssh-errorText{flex:1;min-width:0}
4285
4432
  .ssh-loadingPage{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--ssh-text-3);font-size:12.5px;padding:40px}
4286
- .ssh-body{flex:1;min-height:0;display:grid;grid-template-columns:300px minmax(0,1fr);position:relative;overflow:hidden}
4433
+ .ssh-body{flex:1;min-height:0;display:grid;grid-template-columns:var(--ssh-tree-width,300px) minmax(0,1fr);position:relative;overflow:hidden}
4287
4434
  .ssh-body.collapsed{grid-template-columns:0 minmax(0,1fr)}
4435
+ .ssh-treeResizer{position:absolute;top:0;bottom:0;left:var(--ssh-tree-width,300px);width:10px;transform:translateX(-5px);z-index:26;cursor:col-resize;touch-action:none;outline:none}
4436
+ .ssh-treeResizer::after{content:"";position:absolute;top:0;bottom:0;left:4px;width:1px;background:transparent;transition:background .12s ease}
4437
+ .ssh-treeResizer:hover::after,.ssh-treeResizer:focus-visible::after{background:var(--ssh-brand);box-shadow:0 0 0 1px color-mix(in srgb,var(--ssh-brand) 35%,transparent)}
4438
+ .ssh-body.collapsed .ssh-treeResizer{display:none}
4439
+ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-select:none!important}
4288
4440
  .ssh-sidebar{display:flex;flex-direction:column;min-height:0;border-right:.5px solid var(--ssh-border);background:var(--ssh-bg)}
4289
4441
  .ssh-body.collapsed .ssh-sidebar{display:none}
4290
4442
  .ssh-sideScrim{display:none}
@@ -4369,8 +4521,8 @@ window.__ModuleLoader__.load({
4369
4521
  .ssh-row:hover .ssh-inlineActions{opacity:1}
4370
4522
  .ssh-miniBtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:4px;background:transparent;color:var(--ssh-text-3);cursor:pointer}
4371
4523
  .ssh-miniBtn:hover{background:var(--ssh-hover);color:var(--ssh-text)}
4372
- .ssh-colInfo{gap:4px;flex-wrap:nowrap;overflow:hidden}
4373
- .ssh-chip{display:inline-flex;align-items:center;height:21px;max-width:100%;padding:0 7px;border-radius:6px;border:.5px solid var(--ssh-border);background:var(--ssh-layer-3);font-size:10.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
4524
+ .ssh-colInfo{gap:4px;row-gap:3px;flex-wrap:wrap;align-content:center;overflow:visible}
4525
+ .ssh-chip{display:inline-flex;flex:0 0 auto;align-items:center;height:21px;max-width:100%;padding:0 7px;border-radius:6px;border:.5px solid var(--ssh-border);background:var(--ssh-layer-3);font-size:10.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
4374
4526
  .ssh-mutedText{font-size:11.5px;color:var(--ssh-dim)}
4375
4527
  .ssh-colNotes{min-width:0}
4376
4528
  .ssh-noteText{font-size:11.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -4620,6 +4772,7 @@ window.__ModuleLoader__.load({
4620
4772
  .ssh-tree::-webkit-scrollbar-thumb:hover,.ssh-tableScroll::-webkit-scrollbar-thumb:hover,.ssh-details::-webkit-scrollbar-thumb:hover,.ssh-terminalLog::-webkit-scrollbar-thumb:hover,.ssh-fileList::-webkit-scrollbar-thumb:hover,.ssh-page::-webkit-scrollbar-thumb:hover,.ssh-modalBody::-webkit-scrollbar-thumb:hover,.ssh-floating::-webkit-scrollbar-thumb:hover,.ssh-targetList::-webkit-scrollbar-thumb:hover{background:var(--dsw-alias-scrollbar-hover-l1,var(--ssh-border-strong));border:2px solid transparent;background-clip:padding-box}
4621
4773
  @media (max-width: 900px){
4622
4774
  .ssh-body{grid-template-columns:minmax(0,1fr)}
4775
+ .ssh-treeResizer{display:none}
4623
4776
  .ssh-sidebar{position:absolute;top:0;bottom:0;left:0;width:290px;z-index:25;display:none;box-shadow:var(--ssh-shadow)}
4624
4777
  .ssh-sidebar.open{display:flex}
4625
4778
  .ssh-body:not(.collapsed) .ssh-sideScrim{display:block;position:absolute;inset:0;z-index:20;background:color-mix(in srgb,var(--ssh-bg) 45%,transparent)}
package/index.js CHANGED
@@ -15,9 +15,11 @@ const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
15
15
  const PTY_OPEN_ROUTE = '/dsh-ssh-manage/pty/open'
16
16
  const PTY_INPUT_ROUTE = '/dsh-ssh-manage/pty/input'
17
17
  const PTY_RESIZE_ROUTE = '/dsh-ssh-manage/pty/resize'
18
+ const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
18
19
  const PTY_CLOSE_ROUTE = '/dsh-ssh-manage/pty/close'
19
20
  const MAX_PTY_SESSIONS = 16
20
- const PTY_IDLE_MS = 30 * 60 * 1000
21
+ const PTY_BUFFER_BYTES = 256 * 1024
22
+ const PTY_IDLE_MS = 6 * 60 * 60 * 1000
21
23
  const MAX_PTY_BUFFER_BYTES = 4 * 1024 * 1024
22
24
  const PACKAGE_DIRECTORY = path.dirname(fileURLToPath(import.meta.url))
23
25
  const ASSETS = [
@@ -330,11 +332,18 @@ async function openTextSession(paths, payload) {
330
332
  try { session.close() } catch {}
331
333
  throw new Error('Terminal session already exists')
332
334
  }
333
- const entry = { id, session, cols, rows, lastActivity: Date.now() }
335
+ const entry = { id, session, cols, rows, lastActivity: Date.now(), seq: 0, buffer: [], bufferBytes: 0 }
334
336
  ptyTextSessions.set(id, entry)
335
337
  session.stream.on('data', (chunk) => {
336
338
  entry.lastActivity = Date.now()
337
- broadcastPty({ kind: 'output', id, data: chunk.toString('base64') })
339
+ const copy = Buffer.from(chunk)
340
+ entry.seq += 1
341
+ entry.buffer.push(copy)
342
+ entry.bufferBytes += copy.length
343
+ while (entry.bufferBytes > PTY_BUFFER_BYTES && entry.buffer.length > 0) {
344
+ entry.bufferBytes -= entry.buffer.shift().length
345
+ }
346
+ broadcastPty({ kind: 'output', id, seq: entry.seq, data: copy.toString('base64') })
338
347
  })
339
348
  session.stream.on('close', () => {
340
349
  closeTextSession(id)
@@ -347,7 +356,7 @@ async function openTextSession(paths, payload) {
347
356
  closeTextSession(id, false)
348
357
  broadcastPty({ kind: 'error', id, message: errorMessage(error) })
349
358
  })
350
- return { id, cols, rows }
359
+ return { id, seq: 0, cols, rows, output: '' }
351
360
  } finally {
352
361
  ptyPendingIds.delete(id)
353
362
  ptyOpening -= 1
@@ -424,6 +433,42 @@ function registerPtyHttpRoutes(ctx, paths) {
424
433
  'pty open route',
425
434
  )
426
435
 
436
+ register(
437
+ {
438
+ kind: 'exact',
439
+ path: PTY_ATTACH_ROUTE,
440
+ handler: async (req, res) => {
441
+ if (rejectUnauthenticated(ctx, req, res)) return
442
+ if (req.method !== 'POST') {
443
+ res.statusCode = 405
444
+ res.end()
445
+ return
446
+ }
447
+ const body = (await readJsonBody(req)) ?? {}
448
+ const id = typeof body.id === 'string' ? body.id : ''
449
+ const entry = ptyTextSessions.get(id)
450
+ if (!entry) {
451
+ sendJson(res, 200, { ok: false, error: { code: 'pty-session-missing', message: 'Terminal session was not found' } })
452
+ return
453
+ }
454
+ try {
455
+ entry.lastActivity = Date.now()
456
+ entry.cols = boundedInteger(body.cols, 20, 500, entry.cols)
457
+ entry.rows = boundedInteger(body.rows, 5, 300, entry.rows)
458
+ entry.session.stream.setWindow(entry.rows, entry.cols, 0, 0)
459
+ const output = Buffer.concat(entry.buffer, entry.bufferBytes).toString('base64')
460
+ sendJson(res, 200, {
461
+ ok: true,
462
+ value: { id, seq: entry.seq, cols: entry.cols, rows: entry.rows, output },
463
+ })
464
+ } catch (error) {
465
+ sendJson(res, 200, { ok: false, error: { code: 'pty-attach-failed', message: errorMessage(error) } })
466
+ }
467
+ },
468
+ },
469
+ 'pty attach route',
470
+ )
471
+
427
472
  for (const [path, action, label] of [
428
473
  [PTY_INPUT_ROUTE, 'input', 'pty input route'],
429
474
  [PTY_RESIZE_ROUTE, 'resize', 'pty resize route'],
@@ -669,7 +714,7 @@ function registerAssetRoutes(ctx) {
669
714
  }
670
715
  }
671
716
 
672
- export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, MAX_PTY_SESSIONS, ASSETS, handlePtyUpgrade, ptyTextSessions }
717
+ export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
673
718
 
674
719
  export const inject = ['webServer']
675
720
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-manage",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A DSH sidebar plugin for managing SSH host profiles with a real interactive PTY terminal.",
5
5
  "license": "MIT",
6
6
  "private": false,