@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.
- package/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- 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
|
+
}
|