@tiphareth/dsh-hardssh 0.1.2-alpha

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.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17794 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +142 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +99 -0
  126. package/src/client/workspace-gate.ts +232 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Terminal tab: an xterm.js PTY view over the host's WebSocket terminal route.
3
+ * A host <select> plus connect/disconnect controls; the terminal container is
4
+ * sized by FitAddon (default 80x24 before first fit). On remote exit the last
5
+ * output stays visible and input is disabled. xterm's stylesheet is injected
6
+ * once per page load (module-level guard).
7
+ */
8
+ import { useEffect, useRef, useState } from 'react'
9
+ import { Terminal } from '@xterm/xterm'
10
+ import { FitAddon } from '@xterm/addon-fit'
11
+ import type { SshApi, TerminalConnection } from '../api.ts'
12
+ import type { SshHostSummary } from '../../../ssh/protocol.ts'
13
+ import { XTERM_CSS } from './xterm.css.ts'
14
+ import { errorMessage, tt } from './helpers.ts'
15
+ import css from './panel.module.css'
16
+
17
+ /** Terminal tab props. */
18
+ export interface TerminalTabProps {
19
+ api: SshApi
20
+ /** Alias preselected by a "connect" action from the hosts tab. */
21
+ presetAlias?: string
22
+ /** Monotonic id of the connect request (re-applies presetAlias). */
23
+ requestId?: number
24
+ }
25
+
26
+ /** The terminal session lifecycle state shown in the status banner. */
27
+ type TerminalStatus =
28
+ | { kind: 'idle' }
29
+ | { kind: 'connecting' }
30
+ | { kind: 'connected'; alias: string }
31
+ | { kind: 'exited'; alias: string; detail?: string }
32
+ | { kind: 'error'; detail: string }
33
+
34
+ /** Injected-once guard for the xterm stylesheet (one tag per page load). */
35
+ let xtermCssInjected = false
36
+
37
+ function ensureXtermCss(): void {
38
+ if (xtermCssInjected || typeof document === 'undefined') return
39
+ xtermCssInjected = true
40
+ if (document.querySelector('style[data-dsh-ssh-xterm]') !== null) return
41
+ const style = document.createElement('style')
42
+ style.dataset.dshSshXterm = ''
43
+ style.textContent = XTERM_CSS
44
+ document.head.appendChild(style)
45
+ }
46
+
47
+ /** The xterm terminal view. */
48
+ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
49
+ const [hosts, setHosts] = useState<SshHostSummary[]>([])
50
+ const [alias, setAlias] = useState(presetAlias ?? '')
51
+ const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
52
+ const containerRef = useRef<HTMLDivElement | null>(null)
53
+ const termRef = useRef<Terminal | null>(null)
54
+ const fitRef = useRef<FitAddon | null>(null)
55
+ const connRef = useRef<TerminalConnection | null>(null)
56
+
57
+ useEffect(() => { ensureXtermCss() }, [])
58
+
59
+ // Fetch the host list on tab activation.
60
+ useEffect(() => {
61
+ let disposed = false
62
+ void (async () => {
63
+ try {
64
+ const list = await api.listHosts()
65
+ if (!disposed) setHosts(list)
66
+ } catch (cause) {
67
+ if (!disposed) setStatus({ kind: 'error', detail: errorMessage(cause) })
68
+ }
69
+ })()
70
+ return () => { disposed = true }
71
+ }, [api])
72
+
73
+ // A hosts-tab connect action preselects its alias here.
74
+ useEffect(() => {
75
+ if (presetAlias !== undefined) setAlias(presetAlias)
76
+ }, [presetAlias, requestId])
77
+
78
+ const teardown = (): void => {
79
+ const connection = connRef.current
80
+ connRef.current = null
81
+ if (connection !== null) {
82
+ connection.onReady = undefined
83
+ connection.onOutput = undefined
84
+ connection.onExit = undefined
85
+ connection.close()
86
+ }
87
+ termRef.current?.dispose()
88
+ termRef.current = null
89
+ fitRef.current = null
90
+ }
91
+
92
+ // Unmount cleanup (never touches state on an unmounting component).
93
+ useEffect(() => () => { teardown() }, [])
94
+
95
+ // Keep the terminal fitted to its container while connected.
96
+ useEffect(() => {
97
+ const onResize = (): void => {
98
+ const term = termRef.current
99
+ const fit = fitRef.current
100
+ if (term === null || fit === null) return
101
+ fit.fit()
102
+ connRef.current?.resize(term.cols, term.rows)
103
+ }
104
+ window.addEventListener('resize', onResize)
105
+ return () => { window.removeEventListener('resize', onResize) }
106
+ }, [])
107
+
108
+ const connect = (): void => {
109
+ const target = alias
110
+ const container = containerRef.current
111
+ if (target === '' || container === null) return
112
+ if (status.kind === 'connecting' || status.kind === 'connected') return
113
+ teardown()
114
+ setStatus({ kind: 'connecting' })
115
+ const term = new Terminal({
116
+ convertEol: false,
117
+ cursorBlink: true,
118
+ fontSize: 13,
119
+ fontFamily: 'Menlo, Consolas, "Liberation Mono", monospace',
120
+ theme: { background: '#0b0e14', foreground: '#d8dee9', cursor: '#a3b8d0' },
121
+ })
122
+ const fit = new FitAddon()
123
+ term.loadAddon(fit)
124
+ term.open(container)
125
+ fit.fit()
126
+ const connection = api.openTerminal(target, term.cols, term.rows)
127
+ termRef.current = term
128
+ fitRef.current = fit
129
+ connRef.current = connection
130
+ let settled = false
131
+ const dataSub = term.onData(data => { connection.send(data) })
132
+ connection.onReady = () => { setStatus({ kind: 'connected', alias: target }) }
133
+ connection.onOutput = data => { term.write(data) }
134
+ connection.onExit = (code, error) => {
135
+ if (settled) return
136
+ settled = true
137
+ dataSub.dispose()
138
+ term.options.disableStdin = true
139
+ connRef.current = null
140
+ // Keep the last output visible; input is now disabled.
141
+ setStatus({ kind: 'exited', alias: target, detail: error })
142
+ }
143
+ }
144
+
145
+ const disconnect = (): void => {
146
+ teardown()
147
+ setStatus({ kind: 'idle' })
148
+ }
149
+
150
+ const active = status.kind === 'connecting' || status.kind === 'connected'
151
+
152
+ return (
153
+ <div className={css.termBody}>
154
+ <div className={css.controls}>
155
+ <select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
156
+ <option value="">{tt('terminal.selectHost')}</option>
157
+ {hosts.map(host => <option key={host.alias} value={host.alias}>{host.alias} ({host.host})</option>)}
158
+ </select>
159
+ <button type="button" className={css.primaryButton} disabled={alias === '' || active} onClick={connect}>{tt('terminal.connect')}</button>
160
+ <button type="button" className={css.ghostButton} disabled={!active} onClick={disconnect}>{tt('terminal.disconnect')}</button>
161
+ </div>
162
+ {status.kind === 'connecting' && <div className={css.banner} data-kind="info">{tt('terminal.connecting')}</div>}
163
+ {status.kind === 'connected' && <div className={css.banner} data-kind="ok">{tt('terminal.ready', { alias: status.alias })}</div>}
164
+ {status.kind === 'exited' && (
165
+ <div className={css.banner} data-kind="info">{tt('terminal.exited', { alias: status.alias })}{status.detail !== undefined ? ' (' + status.detail + ')' : ''}</div>
166
+ )}
167
+ {status.kind === 'error' && <div className={css.banner} data-kind="error">{tt('terminal.error', { error: status.detail })}</div>}
168
+ <div className={css.termWrap}>
169
+ <div ref={containerRef} className={css.termContainer} />
170
+ {status.kind === 'idle' && (
171
+ <div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
172
+ )}
173
+ </div>
174
+ </div>
175
+ )
176
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Transfer tab: upload/download one file with progress, plus a toggleable
3
+ * remote directory browser (api.ls) whose file rows fill the remote path
4
+ * input and whose directory rows navigate.
5
+ */
6
+ import { useEffect, useRef, useState, type ChangeEvent } from 'react'
7
+ import type { SshApi } from '../api.ts'
8
+ import type { RemoteDirEntry, SshHostSummary, TransferProgress } from '../../../ssh/protocol.ts'
9
+ import { errorMessage, tt } from './helpers.ts'
10
+ import css from './panel.module.css'
11
+
12
+ /** Transfer tab props. */
13
+ export interface TransferTabProps {
14
+ api: SshApi
15
+ }
16
+
17
+ /** One in-flight transfer (drives the progress bar). */
18
+ interface TransferState {
19
+ kind: 'upload' | 'download'
20
+ phase: TransferProgress['phase']
21
+ percent: number
22
+ speedBps?: number
23
+ file: string
24
+ }
25
+
26
+ /** The last transfer's outcome line. */
27
+ type TransferStatus = { kind: 'ok'; bytes: number } | { kind: 'error'; error: string }
28
+
29
+ /** Join a directory path with a child entry name. */
30
+ function joinRemotePath(dir: string, name: string): string {
31
+ const base = dir.endsWith('/') ? dir : dir + '/'
32
+ return base + name
33
+ }
34
+
35
+ /** Parent path of a remote directory ('/' stays '/'). */
36
+ function parentOf(dir: string): string {
37
+ const trimmed = dir.replace(/\/+$/, '')
38
+ const index = trimmed.lastIndexOf('/')
39
+ if (index <= 0) return '/'
40
+ return trimmed.slice(0, index)
41
+ }
42
+
43
+ /** Human byte sizes through the transfer.* locale templates. */
44
+ function formatBytes(bytes: number): string {
45
+ if (bytes < 1024) return tt('transfer.bytes', { value: bytes })
46
+ if (bytes < 1024 * 1024) return tt('transfer.kib', { value: (bytes / 1024).toFixed(1) })
47
+ if (bytes < 1024 * 1024 * 1024) return tt('transfer.mib', { value: (bytes / (1024 * 1024)).toFixed(1) })
48
+ return tt('transfer.gib', { value: (bytes / (1024 * 1024 * 1024)).toFixed(1) })
49
+ }
50
+
51
+ /** The upload/download tab. */
52
+ export function TransferTab({ api }: TransferTabProps) {
53
+ const [hosts, setHosts] = useState<SshHostSummary[]>([])
54
+ const [listError, setListError] = useState<string | null>(null)
55
+ const [alias, setAlias] = useState('')
56
+ const [remotePath, setRemotePath] = useState('')
57
+ const [browseOpen, setBrowseOpen] = useState(false)
58
+ const [browseDir, setBrowseDir] = useState('/')
59
+ const [entries, setEntries] = useState<RemoteDirEntry[]>([])
60
+ const [browsing, setBrowsing] = useState(false)
61
+ const [transfer, setTransfer] = useState<TransferState | null>(null)
62
+ const [status, setStatus] = useState<TransferStatus | null>(null)
63
+ const fileRef = useRef<HTMLInputElement | null>(null)
64
+ const seqRef = useRef(0)
65
+
66
+ useEffect(() => {
67
+ let disposed = false
68
+ void (async () => {
69
+ try {
70
+ const list = await api.listHosts()
71
+ if (!disposed) setHosts(list)
72
+ } catch (cause) {
73
+ if (!disposed) setListError(errorMessage(cause))
74
+ }
75
+ })()
76
+ return () => { disposed = true }
77
+ }, [api])
78
+
79
+ const loadDir = async (path: string): Promise<void> => {
80
+ if (alias === '') return
81
+ const seq = ++seqRef.current
82
+ setBrowsing(true)
83
+ try {
84
+ const list = await api.ls(alias, path)
85
+ if (seq !== seqRef.current) return
86
+ const sorted = [...list].sort((a, b) => {
87
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1
88
+ return a.name.localeCompare(b.name)
89
+ })
90
+ setEntries(sorted)
91
+ setBrowseDir(path)
92
+ setListError(null)
93
+ } catch (cause) {
94
+ if (seq === seqRef.current) setListError(errorMessage(cause))
95
+ } finally {
96
+ if (seq === seqRef.current) setBrowsing(false)
97
+ }
98
+ }
99
+
100
+ const openBrowse = (): void => {
101
+ if (alias === '') return
102
+ setBrowseOpen(true)
103
+ const path = remotePath.trim() === '' ? '/' : remotePath.trim()
104
+ void loadDir(path)
105
+ }
106
+
107
+ const handleFile = async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
108
+ const file = event.target.files?.[0]
109
+ event.target.value = ''
110
+ if (file === undefined || alias === '' || remotePath.trim() === '' || transfer !== null) return
111
+ setStatus(null)
112
+ setTransfer({ kind: 'upload', phase: 'connecting', percent: 0, file: file.name })
113
+ try {
114
+ const outcome = await api.uploadFile(file, alias, remotePath.trim(), progress => {
115
+ setTransfer(prev => prev === null ? prev : {
116
+ kind: 'upload',
117
+ phase: progress.phase,
118
+ percent: progress.percent,
119
+ speedBps: progress.speedBps,
120
+ file: progress.file,
121
+ })
122
+ })
123
+ setStatus({ kind: 'ok', bytes: outcome.transferredBytes })
124
+ } catch (cause) {
125
+ setStatus({ kind: 'error', error: errorMessage(cause) })
126
+ } finally {
127
+ setTransfer(null)
128
+ }
129
+ }
130
+
131
+ const handleDownload = async (): Promise<void> => {
132
+ if (alias === '' || remotePath.trim() === '' || transfer !== null) return
133
+ setStatus(null)
134
+ setTransfer({ kind: 'download', phase: 'connecting', percent: 0, file: remotePath.trim() })
135
+ try {
136
+ const result = await api.downloadFile(alias, remotePath.trim(), progress => {
137
+ setTransfer(prev => prev === null ? prev : {
138
+ kind: 'download',
139
+ phase: progress.phase,
140
+ percent: progress.percent,
141
+ speedBps: progress.speedBps,
142
+ file: progress.file,
143
+ })
144
+ })
145
+ // Streamed downloads (File System Access API) were already saved; the
146
+ // Blob fallback triggers a browser save here.
147
+ if (!result.streamed && result.blob !== undefined) {
148
+ const url = URL.createObjectURL(result.blob)
149
+ const anchor = document.createElement('a')
150
+ anchor.href = url
151
+ anchor.download = result.filename
152
+ document.body.appendChild(anchor)
153
+ anchor.click()
154
+ anchor.remove()
155
+ // Defer the revoke: some engines need the URL alive past the click.
156
+ setTimeout(() => { URL.revokeObjectURL(url) }, 10_000)
157
+ }
158
+ setStatus({ kind: 'ok', bytes: result.bytes })
159
+ } catch (cause) {
160
+ setStatus({ kind: 'error', error: errorMessage(cause) })
161
+ } finally {
162
+ setTransfer(null)
163
+ }
164
+ }
165
+
166
+ const ready = alias !== '' && remotePath.trim() !== '' && transfer === null
167
+
168
+ return (
169
+ <div className={css.tabBody}>
170
+ <div className={css.controls}>
171
+ <select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
172
+ <option value="">{tt('transfer.selectHost')}</option>
173
+ {hosts.map(host => <option key={host.alias} value={host.alias}>{host.alias} ({host.host})</option>)}
174
+ </select>
175
+ <input className={css.input} placeholder={tt('transfer.remotePathHint')} value={remotePath} onChange={event => { setRemotePath(event.target.value) }} />
176
+ <button type="button" className={css.ghostButton} disabled={alias === ''} onClick={openBrowse}>{tt('transfer.browseRemote')}</button>
177
+ <div className={css.toolbarSpacer} />
178
+ <button type="button" className={css.primaryButton} disabled={!ready} onClick={() => { fileRef.current?.click() }}>{tt('transfer.upload')}</button>
179
+ <button type="button" className={css.ghostButton} disabled={!ready} onClick={() => { void handleDownload() }}>{tt('transfer.download')}</button>
180
+ <input ref={fileRef} type="file" className={css.hiddenFile} onChange={event => { void handleFile(event) }} />
181
+ </div>
182
+ {listError !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error: listError })}</div>}
183
+ {browseOpen && (
184
+ <div className={css.browsePanel}>
185
+ <div className={css.browseHeader}>
186
+ <span className={css.browsePath}>{browseDir}</span>
187
+ <button type="button" className={css.linkButton} disabled={browsing} onClick={() => { void loadDir(browseDir) }}>{tt('transfer.refresh')}</button>
188
+ </div>
189
+ <div className={css.browseList}>
190
+ {browseDir !== '/' && (
191
+ <button type="button" className={css.dirRow} data-up onClick={() => { void loadDir(parentOf(browseDir)) }}>
192
+ <span className={css.dirName}>{tt('transfer.upLevel')}</span>
193
+ <span className={css.dirType} />
194
+ <span className={css.dirSize} />
195
+ </button>
196
+ )}
197
+ {entries.map(entry => (
198
+ <button key={entry.name} type="button" className={css.dirRow} data-type={entry.type} onClick={() => {
199
+ if (entry.type === 'dir') {
200
+ void loadDir(joinRemotePath(browseDir, entry.name))
201
+ } else {
202
+ setRemotePath(joinRemotePath(browseDir, entry.name))
203
+ }
204
+ }}>
205
+ <span className={css.dirName}>{entry.name}</span>
206
+ <span className={css.dirType}>{entry.type === 'dir' ? '[' + tt('transfer.dir') + ']' : entry.type === 'file' ? '[' + tt('transfer.file') + ']' : ''}</span>
207
+ <span className={css.dirSize}>{formatBytes(entry.size)}</span>
208
+ </button>
209
+ ))}
210
+ </div>
211
+ </div>
212
+ )}
213
+ {transfer !== null && (
214
+ <div className={css.transferBlock}>
215
+ <div className={css.progressMeta}>
216
+ <span>{transfer.kind === 'upload' ? tt('transfer.uploading', { file: transfer.file }) : tt('transfer.downloading')}</span>
217
+ <span>{tt('transfer.percent', { value: Math.round(transfer.percent) })}</span>
218
+ {transfer.speedBps !== undefined && transfer.phase === 'transferring' && <span>{tt('transfer.speed', { value: formatBytes(transfer.speedBps) })}</span>}
219
+ </div>
220
+ <div className={css.progressTrack}>
221
+ <div className={css.progressBar} style={{ width: Math.min(100, Math.max(0, transfer.percent)) + '%' }} />
222
+ </div>
223
+ </div>
224
+ )}
225
+ {status !== null && (
226
+ <div className={css.banner} data-kind={status.kind}>
227
+ {status.kind === 'ok' ? tt('transfer.done', { bytes: status.bytes }) : tt('transfer.failed', { error: status.error })}
228
+ </div>
229
+ )}
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tunnels tab: the live local port-forward list (auto-refresh every 5s while
3
+ * mounted) with per-row stop, a stop-all action scoped to the selected alias,
4
+ * and a new-tunnel form.
5
+ */
6
+ import { useEffect, useRef, useState } from 'react'
7
+ import type { SshApi } from '../api.ts'
8
+ import type { SshHostSummary, TunnelInfo } from '../../../ssh/protocol.ts'
9
+ import { errorMessage, tt } from './helpers.ts'
10
+ import css from './panel.module.css'
11
+
12
+ /** Tunnels tab props. */
13
+ export interface TunnelsTabProps {
14
+ api: SshApi
15
+ }
16
+
17
+ /** The tunnels tab. */
18
+ export function TunnelsTab({ api }: TunnelsTabProps) {
19
+ const [hosts, setHosts] = useState<SshHostSummary[]>([])
20
+ const [tunnels, setTunnels] = useState<TunnelInfo[] | null>(null)
21
+ const [error, setError] = useState<string | null>(null)
22
+ const [notice, setNotice] = useState<string | null>(null)
23
+ const [alias, setAlias] = useState('')
24
+ const [remotePort, setRemotePort] = useState('')
25
+ const [remoteHost, setRemoteHost] = useState('')
26
+ const [localPort, setLocalPort] = useState('')
27
+ const [busy, setBusy] = useState(false)
28
+
29
+ // Hosts for the new-tunnel form (failure does not block tunnel listing).
30
+ useEffect(() => {
31
+ let disposed = false
32
+ void (async () => {
33
+ try {
34
+ const list = await api.listHosts()
35
+ if (!disposed) setHosts(list)
36
+ } catch {
37
+ // Tunnels may still exist; keep the list usable.
38
+ }
39
+ })()
40
+ return () => { disposed = true }
41
+ }, [api])
42
+
43
+ // Live list with a 5s heartbeat while mounted. Every load carries a
44
+ // sequence number so stale responses never overwrite newer state.
45
+ const seqRef = useRef(0)
46
+ useEffect(() => {
47
+ const load = async (): Promise<void> => {
48
+ const seq = ++seqRef.current
49
+ try {
50
+ const list = await api.listTunnels()
51
+ if (seq !== seqRef.current) return
52
+ setTunnels(list)
53
+ setError(null)
54
+ } catch (cause) {
55
+ if (seq !== seqRef.current) return
56
+ setError(errorMessage(cause))
57
+ }
58
+ }
59
+ void load()
60
+ const timer = setInterval(() => { void load() }, 5000)
61
+ return () => { clearInterval(timer) }
62
+ }, [api])
63
+
64
+ const refresh = async (): Promise<void> => {
65
+ const seq = ++seqRef.current
66
+ try {
67
+ const list = await api.listTunnels()
68
+ if (seq !== seqRef.current) return
69
+ setTunnels(list)
70
+ setError(null)
71
+ } catch (cause) {
72
+ if (seq !== seqRef.current) return
73
+ setError(errorMessage(cause))
74
+ }
75
+ }
76
+
77
+ const stopTunnel = async (tunnelId: string): Promise<void> => {
78
+ try {
79
+ await api.stopTunnel(tunnelId)
80
+ await refresh()
81
+ } catch (cause) {
82
+ setError(errorMessage(cause))
83
+ }
84
+ }
85
+
86
+ const stopAll = async (): Promise<void> => {
87
+ if (!window.confirm(tt('tunnel.stopAllConfirm'))) return
88
+ setBusy(true)
89
+ try {
90
+ await api.stopAllTunnels(alias === '' ? undefined : alias)
91
+ await refresh()
92
+ } catch (cause) {
93
+ setError(errorMessage(cause))
94
+ } finally {
95
+ setBusy(false)
96
+ }
97
+ }
98
+
99
+ const start = async (): Promise<void> => {
100
+ if (alias === '' || remotePort.trim() === '') return
101
+ const remotePortNumber = Number(remotePort)
102
+ const localPortNumber = localPort.trim() === '' ? undefined : Number(localPort)
103
+ if (!Number.isInteger(remotePortNumber) || remotePortNumber < 1 || remotePortNumber > 65535) {
104
+ setError(tt('form.portInvalid'))
105
+ return
106
+ }
107
+ if (localPortNumber !== undefined && (!Number.isInteger(localPortNumber) || localPortNumber < 1 || localPortNumber > 65535)) {
108
+ setError(tt('form.portInvalid'))
109
+ return
110
+ }
111
+ setBusy(true)
112
+ setNotice(null)
113
+ try {
114
+ const tunnel = await api.startTunnel({
115
+ alias,
116
+ remotePort: remotePortNumber,
117
+ remoteHost: remoteHost.trim() === '' ? undefined : remoteHost.trim(),
118
+ localPort: localPortNumber,
119
+ })
120
+ setNotice(tt('tunnel.started', { localPort: tunnel.localPort }))
121
+ setRemotePort('')
122
+ setRemoteHost('')
123
+ setLocalPort('')
124
+ await refresh()
125
+ } catch (cause) {
126
+ setError(tt('tunnel.failed', { error: errorMessage(cause) }))
127
+ } finally {
128
+ setBusy(false)
129
+ }
130
+ }
131
+
132
+ return (
133
+ <div className={css.tabBody}>
134
+ <div className={css.controls}>
135
+ <button type="button" className={css.ghostButton} disabled={busy} onClick={() => { void stopAll() }}>{tt('tunnel.stopAll')}</button>
136
+ <button type="button" className={css.ghostButton} onClick={() => { void refresh() }}>{tt('common.refresh')}</button>
137
+ </div>
138
+ {error !== null && <div className={css.banner} data-kind="error">{error}</div>}
139
+ {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
140
+ {tunnels !== null && tunnels.length === 0 && <div className={css.empty}>{tt('tunnel.empty')}</div>}
141
+ <div className={css.tunnelList}>
142
+ {(tunnels ?? []).map(tunnel => (
143
+ <div key={tunnel.id} className={css.tunnelRow} data-state={tunnel.state}>
144
+ <span className={css.tunnelLabel}>{tt('tunnel.row', { alias: tunnel.alias, localPort: tunnel.localPort, remoteHost: tunnel.remoteHost, remotePort: tunnel.remotePort })}</span>
145
+ <button type="button" className={css.ghostButton} onClick={() => { void stopTunnel(tunnel.id) }}>{tt('tunnel.stop')}</button>
146
+ </div>
147
+ ))}
148
+ </div>
149
+ <div className={css.formCard}>
150
+ <div className={css.formRow}>
151
+ <label className={css.field}>
152
+ <span className={css.fieldLabel}>{tt('tunnel.alias')}</span>
153
+ <select className={css.input} value={alias} onChange={event => { setAlias(event.target.value) }}>
154
+ <option value="">{tt('terminal.selectHost')}</option>
155
+ {hosts.map(host => <option key={host.alias} value={host.alias}>{host.alias}</option>)}
156
+ </select>
157
+ </label>
158
+ <label className={css.field}>
159
+ <span className={css.fieldLabel}>{tt('tunnel.remotePort')}</span>
160
+ <input className={css.input} type="number" min={1} max={65535} value={remotePort} onChange={event => { setRemotePort(event.target.value) }} />
161
+ </label>
162
+ <label className={css.field}>
163
+ <span className={css.fieldLabel}>{tt('tunnel.remoteHost')}</span>
164
+ <input className={css.input} value={remoteHost} placeholder={tt('tunnel.remoteHostHint')} onChange={event => { setRemoteHost(event.target.value) }} />
165
+ </label>
166
+ <label className={css.field}>
167
+ <span className={css.fieldLabel}>{tt('tunnel.localPort')}</span>
168
+ <input className={css.input} type="number" min={1} max={65535} value={localPort} placeholder={tt('tunnel.localPortHint')} onChange={event => { setLocalPort(event.target.value) }} />
169
+ </label>
170
+ </div>
171
+ <div>
172
+ <button type="button" className={css.primaryButton} disabled={busy || alias === '' || remotePort.trim() === ''} onClick={() => { void start() }}>{tt('tunnel.start')}</button>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ )
177
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * SSH panel controller: the single owner of the panel's open/closed state.
3
+ *
4
+ * Framework-free (structural runtime faces, task-board core/controller.ts
5
+ * style) so the DOM mounts and the React panel share one tiny subscription
6
+ * surface. The state lives only for the browser session (no persistence).
7
+ */
8
+
9
+ /** Immutable controller snapshot for UI subscriptions. */
10
+ export interface PanelControllerSnapshot {
11
+ panelOpen: boolean
12
+ }
13
+
14
+ /** The panel state owner the sidebar entry toggles and the view renders from. */
15
+ export class PanelController {
16
+ private panelOpen = false
17
+ private listeners = new Set<() => void>()
18
+
19
+ getSnapshot(): PanelControllerSnapshot {
20
+ return { panelOpen: this.panelOpen }
21
+ }
22
+
23
+ subscribe(fn: () => void): () => void {
24
+ this.listeners.add(fn)
25
+ return () => { this.listeners.delete(fn) }
26
+ }
27
+
28
+ open(): void {
29
+ if (this.panelOpen) return
30
+ this.panelOpen = true
31
+ this.notify()
32
+ }
33
+
34
+ close(): void {
35
+ if (!this.panelOpen) return
36
+ this.panelOpen = false
37
+ this.notify()
38
+ }
39
+
40
+ toggle(): void {
41
+ if (this.panelOpen) this.close()
42
+ else this.open()
43
+ }
44
+
45
+ private notify(): void {
46
+ for (const fn of [...this.listeners]) fn()
47
+ }
48
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared panel helpers: the active-dictionary pick (document-language based,
3
+ * task-board precedent) bound to the dsh-ssh interpolator in locales.ts, plus
4
+ * a small error-message extractor. All copy stays in the locale dictionaries.
5
+ */
6
+ import { en, t, zh, type SshKey } from '../locales.ts'
7
+
8
+ /** Template values accepted by the interpolator. */
9
+ export type TranslateValues = Record<string, string | number>
10
+
11
+ /** Active dictionary, picked by the document language at call time. */
12
+ export function dictionary(): Record<string, string> {
13
+ const lang = typeof document !== 'undefined' ? document.documentElement.lang : 'zh'
14
+ return lang.toLowerCase().startsWith('en') ? { ...en } : { ...zh }
15
+ }
16
+
17
+ /** Translate a key with optional {name} template params (current language). */
18
+ export function tt(key: SshKey, values?: TranslateValues): string {
19
+ return t(dictionary(), key, values)
20
+ }
21
+
22
+ /** Human-readable error text from an unknown thrown value. */
23
+ export function errorMessage(error: unknown): string {
24
+ if (error instanceof Error) return error.message
25
+ return String(error)
26
+ }