dsh-ssh-manage 0.1.1 → 0.2.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 CHANGED
@@ -34,7 +34,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
34
34
 
35
35
  ### Host management
36
36
 
37
- - 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).
38
38
  - Create, edit, rename and delete hosts; renaming migrates group, tags, favorites, notes, inspection data and the saved password.
39
39
  - Configurable table columns (info / notes / compact rows), responsive on narrow screens.
40
40
  - Host details drawer: address, user, port, group, tags, notes, distro, CPU, memory, disk, latency, plus "Test connection" and "Inspect".
@@ -44,7 +44,7 @@ No more jumping between a terminal and DSH, and no need to remember where each h
44
44
  ### Interactive terminal
45
45
 
46
46
  - Click "Connect" to open a real PTY shell, not a one-command-at-a-time console.
47
- - 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.
48
48
  - xterm.js 6.0.0 rendering with 256 colors, cursor, scrollback, selection copy, paste and resize.
49
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.
50
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.
@@ -96,16 +96,16 @@ Creating a host requires alias, hostname and password. The password is not writt
96
96
 
97
97
  ### From npm (recommended)
98
98
 
99
- npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.1.1`).
99
+ npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.1`).
100
100
 
101
101
  ```text
102
- plugin_manager action=install_bundle target=dsh-ssh-manage@0.1.1
102
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.1
103
103
  ```
104
104
 
105
105
  Or with the DSH CLI:
106
106
 
107
107
  ```bash
108
- dsh plugin add dsh-ssh-manage@0.1.1
108
+ dsh plugin add dsh-ssh-manage@0.2.1
109
109
  ```
110
110
 
111
111
  After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
@@ -216,7 +216,7 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`) and
216
216
  ## Known limitations
217
217
 
218
218
  - **Host key verification** is not implemented; ssh2 accepts the server-provided key. Use trusted networks only.
219
- - **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).
220
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.
221
221
  - **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
222
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
@@ -34,7 +34,7 @@
34
34
 
35
35
  ### 主机管理
36
36
 
37
- - 左侧分组树 + 右侧主机表格:搜索、按分组筛选、收藏、标签、备注、连接状态与延迟一目了然。
37
+ - 左侧分组树 + 右侧主机表格:搜索、按分组筛选、收藏、标签、备注、连接状态与延迟一目了然;分组栏可拖拽调整宽度(双击恢复默认),宽度会记住。
38
38
  - 新建、编辑、重命名、删除主机;重命名自动迁移分组、标签、收藏、备注、巡检信息和已保存密码。
39
39
  - 表格列可配置(信息列 / 备注列 / 紧凑行高),窄屏自适应。
40
40
  - 主机详情抽屉:地址、用户、端口、分组、标签、备注、发行版、CPU、内存、磁盘、延迟,以及「测试连接」「巡检」。
@@ -44,7 +44,7 @@
44
44
  ### 交互式终端
45
45
 
46
46
  - 点「连接」打开真正的 PTY shell,不是一条命令一条命令地执行。
47
- - 多台主机以标签页常驻在当前插件页面,切换标签不丢输出;关闭标签才结束会话。
47
+ - 多台主机以标签页常驻:切换到其它 DSH 会话再回来,终端会重新附着到原来的 shell,并回放最近约 256 KB 输出;关闭标签才结束会话。
48
48
  - xterm.js 6.0.0 渲染,支持 256 色、光标、滚动回看、选中复制、粘贴、窗口 resize。
49
49
  - 终端主题跟随 DSH 明暗主题;会话顶部有地址、密码状态、测试连接、文件、详情、清屏、关闭等快捷操作。
50
50
  - 输出走同源 HTTP/SSE 长连接,输入 / 尺寸 / 关闭走同源 HTTP POST;插件同时保留经过测试的 WebSocket 备用通道。
@@ -98,16 +98,16 @@
98
98
 
99
99
  ### 从 npm 安装(推荐)
100
100
 
101
- npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.1.1`)。
101
+ npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.1`)。
102
102
 
103
103
  ```text
104
- plugin_manager action=install_bundle target=dsh-ssh-manage@0.1.1
104
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.1
105
105
  ```
106
106
 
107
107
  或使用 DSH CLI:
108
108
 
109
109
  ```bash
110
- dsh plugin add dsh-ssh-manage@0.1.1
110
+ dsh plugin add dsh-ssh-manage@0.2.1
111
111
  ```
112
112
 
113
113
  安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
@@ -218,7 +218,7 @@ npm test
218
218
  ## 已知限制
219
219
 
220
220
  - **主机密钥校验**:未接入 `known_hosts`,ssh2 默认接受服务器主机密钥;仅在可信网络使用。
221
- - **会话生命周期**:终端会话保存在插件页面内存中,离开 SSH 插件面板、页面刷新或 HMR 会重置;页面关闭时会尽力发送关闭通知,异常崩溃留下的孤儿会话由空闲清理兜底。
221
+ - **会话生命周期**:终端会话由 Host 保留,切换 DSH 会话/暂时离开插件面板/刷新页面后会重新附着并回放最近约 256 KB 输出;只有关闭标签或 Host 进程退出才真正结束。Host 侧空闲超过 6 小时会清理孤儿会话(最大 16 个)。
222
222
  - **仅密码认证**:不支持 IdentityFile / SSH agent / ProxyJump 传输;配置中的这些字段会原样保留,但不会用于连接。ProxyJump 主机会明确报错。
223
223
  - **配置编辑范围**:编辑器只修改 `Host`、`HostName`、`User`、`Port` 与插件元数据;其它 SSH 指令请在 `~/.ssh/config` 中维护。
224
224
  - **批量命令**:一次执行一条命令,不等同于交互式终端;单台输出上限 1 MB,超出会标记 `truncated`。
package/client.js CHANGED
@@ -26,11 +26,12 @@ 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'],
32
33
  'toolbar.new': ['新建主机', 'New host'],
33
- 'toolbar.newShort': ['+ SSH', '+ SSH'],
34
+ 'toolbar.newShort': ['SSH', 'SSH'],
34
35
  'toolbar.filter': ['只看收藏', 'Favorites only'],
35
36
  'toolbar.info': ['信息', 'Info'],
36
37
  'toolbar.settings': ['显示设置', 'View options'],
@@ -279,6 +280,12 @@ window.__ModuleLoader__.load({
279
280
  return `${user}${target}${port}`
280
281
  }
281
282
 
283
+ // Table / session chrome shows only the hostname (the name column already
284
+ // carries the alias); the full user@host:port stays in titles and copy.
285
+ function hostTarget(host) {
286
+ return host.hostname || host.host
287
+ }
288
+
282
289
  function hostGroupKey(host) {
283
290
  return host.group || ''
284
291
  }
@@ -1047,7 +1054,7 @@ window.__ModuleLoader__.load({
1047
1054
  }
1048
1055
  let result = 0
1049
1056
  if (sort.key === 'system') result = String(a.system ?? '').localeCompare(String(b.system ?? ''))
1050
- else if (sort.key === 'address') result = hostAddress(a).localeCompare(hostAddress(b))
1057
+ else if (sort.key === 'address') result = hostTarget(a).localeCompare(hostTarget(b))
1051
1058
  else if (sort.key === 'notes') result = String(a.note ?? '').localeCompare(String(b.note ?? ''))
1052
1059
  else result = hostName(a).localeCompare(hostName(b), undefined, { numeric: true, sensitivity: 'base' })
1053
1060
  if (result === 0) result = a.host.localeCompare(b.host)
@@ -1067,10 +1074,10 @@ window.__ModuleLoader__.load({
1067
1074
  }
1068
1075
 
1069
1076
  function tableTracks(prefs) {
1070
- 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.notes) tracks.push('minmax(0,0.9fr)')
1073
- tracks.push('176px', '32px')
1077
+ const tracks = ['32px', '96px', '80px', 'minmax(90px,1fr)', 'minmax(150px,1.25fr)']
1078
+ if (prefs.info) tracks.push('minmax(210px,1.4fr)')
1079
+ if (prefs.notes) tracks.push('minmax(120px,0.8fr)')
1080
+ tracks.push('150px', '28px')
1074
1081
  return tracks.join(' ')
1075
1082
  }
1076
1083
 
@@ -1093,6 +1100,48 @@ window.__ModuleLoader__.load({
1093
1100
  // App
1094
1101
  // ---------------------------------------------------------------------
1095
1102
 
1103
+ // Survives the plugin panel unmounting when the user switches to another
1104
+ // DSH session (and a client-module reload). Host-side PTYs stay alive;
1105
+ // mounting again attaches to them and replays recent output.
1106
+ const PANEL_STATE_KEY = 'dsh-ssh-manage:panel-state'
1107
+ const TREE_WIDTH_KEY = 'dsh-ssh-manage:tree-width'
1108
+
1109
+ function readPanelState() {
1110
+ try {
1111
+ const parsed = JSON.parse(String(window.sessionStorage.getItem(PANEL_STATE_KEY) ?? 'null'))
1112
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sessions)) {
1113
+ return { sessions: [], activeSessionId: null, tab: 'hosts' }
1114
+ }
1115
+ const sessions = parsed.sessions
1116
+ .filter((session) => session && typeof session.id === 'string' && typeof session.hostAlias === 'string')
1117
+ .map((session) => ({ id: session.id, hostAlias: session.hostAlias }))
1118
+ const tab = ['hosts', 'sessions', 'batch', 'settings'].includes(parsed.tab) ? parsed.tab : 'hosts'
1119
+ const activeSessionId = typeof parsed.activeSessionId === 'string' ? parsed.activeSessionId : null
1120
+ return { sessions, activeSessionId, tab }
1121
+ } catch {
1122
+ return { sessions: [], activeSessionId: null, tab: 'hosts' }
1123
+ }
1124
+ }
1125
+
1126
+ const persistentPanelState = readPanelState()
1127
+ const TREE_MIN_WIDTH = 220
1128
+ const TREE_MAX_WIDTH = 560
1129
+
1130
+ function clampTreeWidth(value) {
1131
+ const numeric = Number(value)
1132
+ if (!Number.isFinite(numeric)) return 300
1133
+ return Math.min(TREE_MAX_WIDTH, Math.max(TREE_MIN_WIDTH, Math.round(numeric)))
1134
+ }
1135
+
1136
+ function readTreeWidth() {
1137
+ try {
1138
+ const stored = window.localStorage.getItem(TREE_WIDTH_KEY)
1139
+ return stored === null ? 300 : clampTreeWidth(stored)
1140
+ } catch {
1141
+ return 300
1142
+ }
1143
+ }
1144
+
1096
1145
  function App({ ctx }) {
1097
1146
  const t = useT(ctx)
1098
1147
  const localeId = useLocaleId(ctx)
@@ -1100,7 +1149,11 @@ window.__ModuleLoader__.load({
1100
1149
  const [loading, setLoading] = React.useState(true)
1101
1150
  const [refreshing, setRefreshing] = React.useState(false)
1102
1151
  const [error, setError] = React.useState('')
1103
- const [tab, setTab] = React.useState('hosts')
1152
+ const [tab, setTab] = React.useState(() => {
1153
+ const saved = persistentPanelState.tab
1154
+ if (saved === 'sessions' && persistentPanelState.sessions.length === 0) return 'hosts'
1155
+ return ['hosts', 'sessions', 'batch', 'settings'].includes(saved) ? saved : 'hosts'
1156
+ })
1104
1157
  const [filter, setFilter] = React.useState('')
1105
1158
  const [favoriteOnly, setFavoriteOnly] = React.useState(false)
1106
1159
  const [groupFilter, setGroupFilter] = React.useState(null)
@@ -1119,12 +1172,14 @@ window.__ModuleLoader__.load({
1119
1172
  const [pending, setPending] = React.useState({})
1120
1173
  const [sort, setSort] = React.useState({ key: 'name', dir: 'asc' })
1121
1174
  const [columns, setColumns] = React.useState({ info: true, notes: true, compact: false })
1122
- const [sessions, setSessions] = React.useState([])
1123
- const [activeSessionId, setActiveSessionId] = React.useState(null)
1175
+ const [sessions, setSessions] = React.useState(() =>
1176
+ persistentPanelState.sessions.map((session) => ({ id: session.id, hostAlias: session.hostAlias })),
1177
+ )
1178
+ const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
1179
+ const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
1124
1180
  const [clearSignals, setClearSignals] = React.useState({})
1125
1181
  const toastTimer = React.useRef(null)
1126
1182
  const floatingRef = React.useRef(null)
1127
- const sessionCounter = React.useRef(0)
1128
1183
 
1129
1184
  const notify = React.useCallback((message, tone = 'info') => {
1130
1185
  if (toastTimer.current) clearTimeout(toastTimer.current)
@@ -1139,6 +1194,25 @@ window.__ModuleLoader__.load({
1139
1194
  [],
1140
1195
  )
1141
1196
 
1197
+ React.useEffect(() => {
1198
+ persistentPanelState.sessions = sessions.map((session) => ({ id: session.id, hostAlias: session.hostAlias }))
1199
+ persistentPanelState.activeSessionId = activeSessionId
1200
+ persistentPanelState.tab = tab
1201
+ try {
1202
+ window.sessionStorage.setItem(PANEL_STATE_KEY, JSON.stringify(persistentPanelState))
1203
+ } catch {}
1204
+ }, [sessions, activeSessionId, tab])
1205
+
1206
+ React.useEffect(() => {
1207
+ try {
1208
+ window.localStorage.setItem(TREE_WIDTH_KEY, String(treeWidth))
1209
+ } catch {}
1210
+ }, [treeWidth])
1211
+
1212
+ React.useEffect(() => {
1213
+ for (const session of sessions) ptyActiveIds.add(session.id)
1214
+ }, [sessions])
1215
+
1142
1216
  const applySnapshot = React.useCallback((next) => {
1143
1217
  if (!next) return
1144
1218
  const aliases = new Set((next.hosts ?? []).map((host) => host.host))
@@ -1217,8 +1291,11 @@ window.__ModuleLoader__.load({
1217
1291
  if (!snapshot) return
1218
1292
  const aliases = new Set(hosts.map((host) => host.host))
1219
1293
  setSessions((prev) => {
1294
+ const removed = prev.filter((session) => !aliases.has(session.hostAlias))
1220
1295
  const next = prev.filter((session) => aliases.has(session.hostAlias))
1221
- return next.length === prev.length ? prev : next
1296
+ if (removed.length === 0) return prev
1297
+ for (const session of removed) closePtySession(session.id)
1298
+ return next
1222
1299
  })
1223
1300
  }, [hosts, snapshot])
1224
1301
 
@@ -1298,8 +1375,8 @@ window.__ModuleLoader__.load({
1298
1375
  if (existing) {
1299
1376
  setActiveSessionId(existing.id)
1300
1377
  } else {
1301
- sessionCounter.current += 1
1302
- const id = `ssh-session-${Date.now()}-${sessionCounter.current}`
1378
+ const id = newPtySessionId()
1379
+ ptyActiveIds.add(id)
1303
1380
  setSessions((prev) => [...prev, { id, hostAlias: host.host }])
1304
1381
  setActiveSessionId(id)
1305
1382
  }
@@ -1309,6 +1386,7 @@ window.__ModuleLoader__.load({
1309
1386
  function closeSession(id) {
1310
1387
  const index = sessions.findIndex((session) => session.id === id)
1311
1388
  if (index < 0) return
1389
+ closePtySession(id)
1312
1390
  const next = sessions.filter((session) => session.id !== id)
1313
1391
  setSessions(next)
1314
1392
  setClearSignals((prev) => {
@@ -1602,6 +1680,8 @@ window.__ModuleLoader__.load({
1602
1680
  setFloating,
1603
1681
  setEditor,
1604
1682
  setDrawer,
1683
+ treeWidth,
1684
+ setTreeWidth,
1605
1685
  sessions,
1606
1686
  activeSessionId,
1607
1687
  setActiveSessionId,
@@ -2320,7 +2400,7 @@ window.__ModuleLoader__.load({
2320
2400
  h(
2321
2401
  'div',
2322
2402
  { className: 'ssh-cell ssh-colAddress' },
2323
- h('span', { className: 'ssh-address', title: hostAddress(host) }, hostAddress(host)),
2403
+ h('span', { className: 'ssh-address', title: hostAddress(host) }, hostTarget(host)),
2324
2404
  h(
2325
2405
  'span',
2326
2406
  { className: 'ssh-inlineActions' },
@@ -2429,9 +2509,11 @@ window.__ModuleLoader__.load({
2429
2509
  observer.observe(node)
2430
2510
  return () => observer.disconnect()
2431
2511
  }, [])
2512
+ const hasInfo = visible.some((host) => Boolean(host.system || host.cpuCores || host.memoryGB || host.diskGB))
2513
+ const hasNotes = visible.some((host) => Boolean(host.note))
2432
2514
  const effective = {
2433
- info: columns.info && tableWidth >= 860,
2434
- notes: columns.notes && tableWidth >= 1000,
2515
+ info: columns.info && hasInfo && tableWidth >= 900,
2516
+ notes: columns.notes && hasNotes && tableWidth >= 1100,
2435
2517
  }
2436
2518
  const style = { gridTemplateColumns: tableTracks(effective) }
2437
2519
  const visibleAliases = visible.map((host) => host.host)
@@ -2498,10 +2580,55 @@ window.__ModuleLoader__.load({
2498
2580
  }
2499
2581
 
2500
2582
  function HostsTab({ model }) {
2583
+ const { t } = model
2584
+ const beginResize = (event) => {
2585
+ if (!model.sidebarOpen || event.button !== 0) return
2586
+ event.preventDefault()
2587
+ event.stopPropagation()
2588
+ const startX = event.clientX
2589
+ const startWidth = model.treeWidth
2590
+ document.body.classList.add('ssh-col-resizing')
2591
+ const onMove = (moveEvent) => {
2592
+ model.setTreeWidth(clampTreeWidth(startWidth + moveEvent.clientX - startX))
2593
+ }
2594
+ const onUp = () => {
2595
+ document.body.classList.remove('ssh-col-resizing')
2596
+ window.removeEventListener('pointermove', onMove)
2597
+ window.removeEventListener('pointerup', onUp)
2598
+ window.removeEventListener('pointercancel', onUp)
2599
+ }
2600
+ window.addEventListener('pointermove', onMove)
2601
+ window.addEventListener('pointerup', onUp)
2602
+ window.addEventListener('pointercancel', onUp)
2603
+ }
2604
+ const nudgeWidth = (event) => {
2605
+ if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
2606
+ event.preventDefault()
2607
+ model.setTreeWidth(clampTreeWidth(model.treeWidth + (event.key === 'ArrowLeft' ? -20 : 20)))
2608
+ }
2501
2609
  return h(
2502
2610
  'div',
2503
- { className: cx('ssh-body', !model.sidebarOpen && 'collapsed') },
2611
+ {
2612
+ className: cx('ssh-body', !model.sidebarOpen && 'collapsed'),
2613
+ style: { '--ssh-tree-width': `${model.treeWidth}px` },
2614
+ },
2504
2615
  h(Sidebar, { model }),
2616
+ model.sidebarOpen
2617
+ ? h('div', {
2618
+ className: 'ssh-treeResizer',
2619
+ role: 'separator',
2620
+ tabIndex: 0,
2621
+ 'aria-orientation': 'vertical',
2622
+ 'aria-label': t('toolbar.resize'),
2623
+ 'aria-valuemin': TREE_MIN_WIDTH,
2624
+ 'aria-valuemax': TREE_MAX_WIDTH,
2625
+ 'aria-valuenow': model.treeWidth,
2626
+ title: t('toolbar.resize'),
2627
+ onPointerDown: beginResize,
2628
+ onKeyDown: nudgeWidth,
2629
+ onDoubleClick: () => model.setTreeWidth(300),
2630
+ })
2631
+ : null,
2505
2632
  model.sidebarOpen ? h('div', { className: 'ssh-sideScrim', onClick: () => model.setSidebarOpen(false) }) : null,
2506
2633
  h(
2507
2634
  'main',
@@ -2622,7 +2749,7 @@ window.__ModuleLoader__.load({
2622
2749
  'div',
2623
2750
  { className: 'ssh-sessionActions' },
2624
2751
  activeHost
2625
- ? h('span', { className: 'ssh-sessionAddress', title: hostAddress(activeHost) }, hostAddress(activeHost))
2752
+ ? h('span', { className: 'ssh-sessionAddress', title: hostAddress(activeHost) }, hostTarget(activeHost))
2626
2753
  : null,
2627
2754
  activeCredential ? h(Badge, { tone: activeCredential.tone }, activeCredential.label) : null,
2628
2755
  h(
@@ -2734,7 +2861,7 @@ window.__ModuleLoader__.load({
2734
2861
  'div',
2735
2862
  { className: 'ssh-drawerTitles' },
2736
2863
  h('div', { className: 'ssh-drawerTitle', title: hostName(host) }, hostName(host)),
2737
- h('div', { className: 'ssh-drawerSub' }, hostAddress(host)),
2864
+ h('div', { className: 'ssh-drawerSub' }, hostTarget(host)),
2738
2865
  ),
2739
2866
  h(
2740
2867
  Badge,
@@ -2913,10 +3040,26 @@ window.__ModuleLoader__.load({
2913
3040
  }
2914
3041
 
2915
3042
  const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
3043
+ const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
2916
3044
  const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
2917
3045
  const ptyActiveIds = new Set()
3046
+ const ptyClosedIds = new Set()
2918
3047
  const ptyStreamLostHandlers = new Set()
2919
3048
 
3049
+ function closePtySession(id) {
3050
+ if (!id) return
3051
+ ptyClosedIds.add(id)
3052
+ ptyActiveIds.delete(id)
3053
+ try {
3054
+ void fetch('/dsh-ssh-manage/pty/close', {
3055
+ method: 'POST',
3056
+ headers: { 'content-type': 'application/json' },
3057
+ body: JSON.stringify({ id }),
3058
+ keepalive: true,
3059
+ })
3060
+ } catch {}
3061
+ }
3062
+
2920
3063
  function onPtyStreamLost(handler) {
2921
3064
  ptyStreamLostHandlers.add(handler)
2922
3065
  return () => ptyStreamLostHandlers.delete(handler)
@@ -2934,17 +3077,6 @@ window.__ModuleLoader__.load({
2934
3077
  }
2935
3078
  }
2936
3079
 
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
3080
  function subscribePty(id, listener) {
2949
3081
  ptyStream.listeners.set(id, listener)
2950
3082
  return () => {
@@ -3043,7 +3175,6 @@ window.__ModuleLoader__.load({
3043
3175
  const containerRef = React.useRef(null)
3044
3176
  const termRef = React.useRef(null)
3045
3177
  const fitRef = React.useRef(null)
3046
- const sessionIdRef = React.useRef(null)
3047
3178
  const activeRef = React.useRef(active === true)
3048
3179
  const [status, setStatus] = React.useState('loading')
3049
3180
  const [message, setMessage] = React.useState('')
@@ -3099,24 +3230,7 @@ window.__ModuleLoader__.load({
3099
3230
  unsubscribe()
3100
3231
  unsubscribe = null
3101
3232
  }
3102
- const id = sessionKey
3103
3233
  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
3234
  }
3121
3235
 
3122
3236
  const failSession = (error) => {
@@ -3207,42 +3321,83 @@ window.__ModuleLoader__.load({
3207
3321
  return
3208
3322
  }
3209
3323
  if (removed) return
3210
- sessionKey = newPtySessionId()
3211
- sessionIdRef.current = sessionKey
3212
- ptyActiveIds.add(sessionKey)
3213
- unsubscribe = subscribePty(sessionKey, (event) => {
3214
- if (removed) return
3324
+ sessionKey = sessionId
3325
+ const id = sessionKey
3326
+ let synced = false
3327
+ let lastSeq = 0
3328
+ const pendingEvents = []
3329
+
3330
+ const applyOutput = (event) => {
3331
+ const seq = Number(event.seq)
3332
+ if (Number.isFinite(seq)) {
3333
+ if (seq <= lastSeq) return
3334
+ lastSeq = seq
3335
+ }
3336
+ try { term.write(decodeBase64(event.data)) } catch {}
3337
+ }
3338
+
3339
+ const handleEvent = (event) => {
3215
3340
  if (event.kind === 'output') {
3216
- try { term.write(decodeBase64(event.data)) } catch {}
3341
+ if (!synced) pendingEvents.push(event)
3342
+ else applyOutput(event)
3217
3343
  } else if (event.kind === 'exit') {
3344
+ if (!synced) {
3345
+ pendingEvents.push(event)
3346
+ return
3347
+ }
3218
3348
  finished = true
3219
3349
  setStatus('closed')
3220
3350
  setMessage(t('terminal.exited'))
3221
3351
  teardown()
3222
3352
  } else if (event.kind === 'error') {
3353
+ if (!synced) {
3354
+ pendingEvents.push(event)
3355
+ return
3356
+ }
3223
3357
  finished = true
3224
3358
  setStatus('error')
3225
3359
  setMessage(String(event.message ?? t('terminal.failed')))
3226
3360
  teardown()
3227
3361
  }
3362
+ }
3363
+
3364
+ unsubscribe = subscribePty(id, (event) => {
3365
+ if (removed) return
3366
+ handleEvent(event)
3228
3367
  })
3229
3368
  let dims = { cols: 80, rows: 24 }
3230
3369
  try {
3231
3370
  const proposed = fit?.proposeDimensions?.()
3232
3371
  if (proposed && proposed.cols > 0 && proposed.rows > 0) dims = proposed
3233
3372
  } catch {}
3234
- const openedId = sessionKey
3235
3373
  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)
3374
+ let value
3375
+ try {
3376
+ value = await ptyPost(PTY_ATTACH_ROUTE, { id, cols: dims.cols, rows: dims.rows }, 15_000)
3377
+ } catch {
3378
+ value = await ptyPost('/dsh-ssh-manage/pty/open', {
3379
+ id,
3380
+ host: hostRef.current.host,
3381
+ cols: dims.cols,
3382
+ rows: dims.rows,
3383
+ }, 40_000)
3384
+ }
3385
+ if (ptyClosedIds.has(id)) {
3386
+ closePtySession(id)
3244
3387
  return
3245
3388
  }
3389
+ if (removed) return
3390
+ if (value && value.output) {
3391
+ try { term.write(decodeBase64(value.output)) } catch {}
3392
+ }
3393
+ const seq = Number(value?.seq)
3394
+ if (Number.isFinite(seq)) lastSeq = seq
3395
+ synced = true
3396
+ for (const event of pendingEvents.splice(0)) {
3397
+ if (finished) break
3398
+ handleEvent(event)
3399
+ }
3400
+ if (finished) return
3246
3401
  opened = true
3247
3402
  setStatus('ready')
3248
3403
  setMessage('')
@@ -4057,7 +4212,7 @@ window.__ModuleLoader__.load({
4057
4212
  label: t('table.select'),
4058
4213
  }),
4059
4214
  h('span', { className: 'ssh-targetName' }, hostName(host)),
4060
- h('span', { className: 'ssh-targetAddr' }, hostAddress(host)),
4215
+ h('span', { className: 'ssh-targetAddr' }, hostTarget(host)),
4061
4216
  h('span', { className: 'ssh-targetCred' }, h(Badge, { tone: credential.tone }, credential.label)),
4062
4217
  )
4063
4218
  }),
@@ -4283,8 +4438,13 @@ window.__ModuleLoader__.load({
4283
4438
  .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
4439
  .ssh-errorText{flex:1;min-width:0}
4285
4440
  .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}
4441
+ .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
4442
  .ssh-body.collapsed{grid-template-columns:0 minmax(0,1fr)}
4443
+ .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}
4444
+ .ssh-treeResizer::after{content:"";position:absolute;top:0;bottom:0;left:4px;width:1px;background:transparent;transition:background .12s ease}
4445
+ .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)}
4446
+ .ssh-body.collapsed .ssh-treeResizer{display:none}
4447
+ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-select:none!important}
4288
4448
  .ssh-sidebar{display:flex;flex-direction:column;min-height:0;border-right:.5px solid var(--ssh-border);background:var(--ssh-bg)}
4289
4449
  .ssh-body.collapsed .ssh-sidebar{display:none}
4290
4450
  .ssh-sideScrim{display:none}
@@ -4369,8 +4529,8 @@ window.__ModuleLoader__.load({
4369
4529
  .ssh-row:hover .ssh-inlineActions{opacity:1}
4370
4530
  .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
4531
  .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}
4532
+ .ssh-colInfo{gap:4px;row-gap:3px;flex-wrap:wrap;align-content:center;overflow:visible}
4533
+ .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
4534
  .ssh-mutedText{font-size:11.5px;color:var(--ssh-dim)}
4375
4535
  .ssh-colNotes{min-width:0}
4376
4536
  .ssh-noteText{font-size:11.5px;color:var(--ssh-text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -4620,6 +4780,7 @@ window.__ModuleLoader__.load({
4620
4780
  .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
4781
  @media (max-width: 900px){
4622
4782
  .ssh-body{grid-template-columns:minmax(0,1fr)}
4783
+ .ssh-treeResizer{display:none}
4623
4784
  .ssh-sidebar{position:absolute;top:0;bottom:0;left:0;width:290px;z-index:25;display:none;box-shadow:var(--ssh-shadow)}
4624
4785
  .ssh-sidebar.open{display:flex}
4625
4786
  .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.1",
3
+ "version": "0.2.1",
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,