@linxin666/dsh-ssh 0.1.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.
Files changed (52) hide show
  1. package/README.md +75 -0
  2. package/cordis.patch.yml +12 -0
  3. package/lib/client.js +15638 -0
  4. package/lib/index.js +3297 -0
  5. package/lib/invariant.js +6 -0
  6. package/lib/types/client/api.d.ts +72 -0
  7. package/lib/types/client/index.d.ts +37 -0
  8. package/lib/types/client/locales.d.ts +137 -0
  9. package/lib/types/client/mount.d.ts +12 -0
  10. package/lib/types/client/panel/ClusterTab.d.ts +7 -0
  11. package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
  12. package/lib/types/client/panel/HostsTab.d.ts +9 -0
  13. package/lib/types/client/panel/SshPanel.d.ts +13 -0
  14. package/lib/types/client/panel/TerminalTab.d.ts +11 -0
  15. package/lib/types/client/panel/TransferTab.d.ts +7 -0
  16. package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
  17. package/lib/types/client/panel/controller.d.ts +22 -0
  18. package/lib/types/client/panel/helpers.d.ts +14 -0
  19. package/lib/types/client/panel/xterm.css.d.ts +2 -0
  20. package/lib/types/client/sidebar-entry.d.ts +24 -0
  21. package/lib/types/engine.d.ts +135 -0
  22. package/lib/types/index.d.ts +40 -0
  23. package/lib/types/invariant.d.ts +3 -0
  24. package/lib/types/protocol.d.ts +189 -0
  25. package/lib/types/routes.d.ts +29 -0
  26. package/lib/types/store.d.ts +54 -0
  27. package/lib/types/tools.d.ts +18 -0
  28. package/package.json +106 -0
  29. package/src/client/api.ts +363 -0
  30. package/src/client/css-modules.d.ts +5 -0
  31. package/src/client/index.ts +68 -0
  32. package/src/client/locales.ts +284 -0
  33. package/src/client/mount.tsx +83 -0
  34. package/src/client/panel/ClusterTab.tsx +123 -0
  35. package/src/client/panel/HostFormDialog.tsx +221 -0
  36. package/src/client/panel/HostsTab.tsx +166 -0
  37. package/src/client/panel/SshPanel.tsx +77 -0
  38. package/src/client/panel/TerminalTab.tsx +176 -0
  39. package/src/client/panel/TransferTab.tsx +232 -0
  40. package/src/client/panel/TunnelsTab.tsx +177 -0
  41. package/src/client/panel/controller.ts +48 -0
  42. package/src/client/panel/helpers.ts +26 -0
  43. package/src/client/panel/panel.module.css +833 -0
  44. package/src/client/panel/xterm.css.ts +2 -0
  45. package/src/client/sidebar-entry.ts +123 -0
  46. package/src/engine.ts +780 -0
  47. package/src/index.ts +155 -0
  48. package/src/invariant.ts +4 -0
  49. package/src/protocol.ts +188 -0
  50. package/src/routes.ts +540 -0
  51. package/src/store.ts +344 -0
  52. package/src/tools.ts +371 -0
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Host form dialog: create/edit one stored host entry. A fixed backdrop with
3
+ * a centered card; Escape and backdrop clicks close it. Secrets (key path,
4
+ * passphrase, password) are never pre-filled when editing (the API only
5
+ * exposes the secret-free summary).
6
+ */
7
+ import { useEffect, useState } from 'react'
8
+ import type { SshApi } from '../api.ts'
9
+ import type { HostPayload, SshAuthKind, SshHostSummary } from '../../protocol.ts'
10
+ import { errorMessage, tt } from './helpers.ts'
11
+ import css from './panel.module.css'
12
+
13
+ /** Host form dialog props. */
14
+ export interface HostFormDialogProps {
15
+ api: SshApi
16
+ /** The host being edited; null/undefined means create. */
17
+ editing?: SshHostSummary | null
18
+ onClose: () => void
19
+ onSaved: (host: SshHostSummary) => void
20
+ }
21
+
22
+ /** The staged form values (all strings; parsed on save). */
23
+ interface FormState {
24
+ alias: string
25
+ host: string
26
+ port: string
27
+ user: string
28
+ authKind: SshAuthKind
29
+ keyPath: string
30
+ passphrase: string
31
+ password: string
32
+ proxyJump: string
33
+ description: string
34
+ environment: string
35
+ tags: string
36
+ location: string
37
+ }
38
+
39
+ /** Split a comma-separated input into a trimmed, non-empty string list. */
40
+ function splitList(text: string): string[] {
41
+ return text.split(',').map(part => part.trim()).filter(part => part !== '')
42
+ }
43
+
44
+ /** Initial form state: the summary's public fields plus empty secrets. */
45
+ function blankOf(editing: SshHostSummary | null | undefined): FormState {
46
+ return {
47
+ alias: editing?.alias ?? '',
48
+ host: editing?.host ?? '',
49
+ port: String(editing?.port ?? 22),
50
+ user: editing?.user ?? '',
51
+ authKind: editing?.auth ?? 'key',
52
+ keyPath: '',
53
+ passphrase: '',
54
+ password: '',
55
+ proxyJump: (editing?.proxyJump ?? []).join(', '),
56
+ description: editing?.description ?? '',
57
+ environment: editing?.environment ?? '',
58
+ tags: (editing?.tags ?? []).join(', '),
59
+ location: editing?.location ?? '',
60
+ }
61
+ }
62
+
63
+ /** The create/edit host modal. */
64
+ export function HostFormDialog({ api, editing, onClose, onSaved }: HostFormDialogProps) {
65
+ const [form, setForm] = useState<FormState>(() => blankOf(editing))
66
+ const [saving, setSaving] = useState(false)
67
+ const [error, setError] = useState<string | null>(null)
68
+
69
+ // Escape closes the dialog.
70
+ useEffect(() => {
71
+ const onKey = (event: KeyboardEvent): void => {
72
+ if (event.key === 'Escape') onClose()
73
+ }
74
+ document.addEventListener('keydown', onKey)
75
+ return () => { document.removeEventListener('keydown', onKey) }
76
+ }, [onClose])
77
+
78
+ const set = <K extends keyof FormState>(key: K, value: FormState[K]): void => {
79
+ setForm(prev => ({ ...prev, [key]: value }))
80
+ }
81
+
82
+ const save = async (): Promise<void> => {
83
+ const alias = form.alias.trim()
84
+ const host = form.host.trim()
85
+ const user = form.user.trim()
86
+ if (alias === '' || host === '' || user === '') {
87
+ setError(tt('form.required'))
88
+ return
89
+ }
90
+ const port = Number(form.port)
91
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
92
+ setError(tt('form.portInvalid'))
93
+ return
94
+ }
95
+ // Secrets are never echoed back by the API: when editing and the secret
96
+ // fields are left empty, the auth block is omitted so the stored
97
+ // authentication is preserved.
98
+ const secretEmpty = form.authKind === 'password'
99
+ ? form.password === ''
100
+ : form.keyPath.trim() === ''
101
+ const auth: HostPayload['auth'] = editing != null && secretEmpty
102
+ ? undefined
103
+ : form.authKind === 'password'
104
+ ? { kind: 'password', password: form.password }
105
+ : { kind: 'key', keyPath: form.keyPath.trim(), passphrase: form.passphrase === '' ? undefined : form.passphrase }
106
+ const payload: HostPayload = {
107
+ host,
108
+ port,
109
+ user,
110
+ auth,
111
+ proxyJump: splitList(form.proxyJump),
112
+ description: form.description.trim() === '' ? undefined : form.description.trim(),
113
+ environment: form.environment.trim() === '' ? undefined : form.environment.trim(),
114
+ tags: splitList(form.tags),
115
+ location: form.location.trim() === '' ? undefined : form.location.trim(),
116
+ }
117
+ setSaving(true)
118
+ setError(null)
119
+ try {
120
+ const saved = editing != null
121
+ ? await api.updateHost(editing.alias, payload)
122
+ : await api.createHost({ ...payload, alias })
123
+ onSaved(saved)
124
+ } catch (cause) {
125
+ setError(errorMessage(cause))
126
+ setSaving(false)
127
+ }
128
+ }
129
+
130
+ return (
131
+ <div className={css.modalBackdrop} onClick={onClose}>
132
+ <div className={css.modal} role="dialog" aria-modal="true" onClick={event => { event.stopPropagation() }}>
133
+ <h3 className={css.modalTitle}>{editing != null ? tt('form.title.edit', { alias: editing.alias }) : tt('form.title.create')}</h3>
134
+ <div className={css.formRow}>
135
+ <label className={css.field}>
136
+ <span className={css.fieldLabel}>{tt('form.alias')}</span>
137
+ <input className={css.input} value={form.alias} disabled={editing != null} onChange={event => { set('alias', event.target.value) }} />
138
+ <span className={css.hint}>{tt('form.aliasHint')}</span>
139
+ </label>
140
+ <label className={css.field}>
141
+ <span className={css.fieldLabel}>{tt('form.host')}</span>
142
+ <input className={css.input} value={form.host} onChange={event => { set('host', event.target.value) }} />
143
+ </label>
144
+ </div>
145
+ <div className={css.formRow}>
146
+ <label className={css.field}>
147
+ <span className={css.fieldLabel}>{tt('form.port')}</span>
148
+ <input className={css.input} type="number" min={1} max={65535} value={form.port} onChange={event => { set('port', event.target.value) }} />
149
+ </label>
150
+ <label className={css.field}>
151
+ <span className={css.fieldLabel}>{tt('form.user')}</span>
152
+ <input className={css.input} value={form.user} onChange={event => { set('user', event.target.value) }} />
153
+ </label>
154
+ </div>
155
+ <div className={css.field}>
156
+ <span className={css.fieldLabel}>{tt('form.auth')}</span>
157
+ <div className={css.radioRow}>
158
+ <label className={css.radioLabel}>
159
+ <input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'key'} onChange={() => { set('authKind', 'key') }} />
160
+ {tt('form.auth.key')}
161
+ </label>
162
+ <label className={css.radioLabel}>
163
+ <input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'password'} onChange={() => { set('authKind', 'password') }} />
164
+ {tt('form.auth.password')}
165
+ </label>
166
+ </div>
167
+ {editing != null && <span className={css.hint}>{tt('form.authKeepHint')}</span>}
168
+ </div>
169
+ {form.authKind === 'key' ? (
170
+ <div className={css.formRow}>
171
+ <label className={css.field}>
172
+ <span className={css.fieldLabel}>{tt('form.keyPath')}</span>
173
+ <input className={css.input} value={form.keyPath} onChange={event => { set('keyPath', event.target.value) }} />
174
+ <span className={css.hint}>{tt('form.keyPathHint')}</span>
175
+ </label>
176
+ <label className={css.field}>
177
+ <span className={css.fieldLabel}>{tt('form.passphrase')}</span>
178
+ <input className={css.input} type="password" value={form.passphrase} onChange={event => { set('passphrase', event.target.value) }} />
179
+ </label>
180
+ </div>
181
+ ) : (
182
+ <label className={css.field}>
183
+ <span className={css.fieldLabel}>{tt('form.password')}</span>
184
+ <input className={css.input} type="password" value={form.password} onChange={event => { set('password', event.target.value) }} />
185
+ <span className={css.hint}>{tt('form.passwordHint')}</span>
186
+ </label>
187
+ )}
188
+ <label className={css.field}>
189
+ <span className={css.fieldLabel}>{tt('form.proxyJump')}</span>
190
+ <input className={css.input} value={form.proxyJump} onChange={event => { set('proxyJump', event.target.value) }} />
191
+ <span className={css.hint}>{tt('form.proxyJumpHint')}</span>
192
+ </label>
193
+ <div className={css.formRow}>
194
+ <label className={css.field}>
195
+ <span className={css.fieldLabel}>{tt('form.environment')}</span>
196
+ <input className={css.input} value={form.environment} onChange={event => { set('environment', event.target.value) }} />
197
+ <span className={css.hint}>{tt('form.environmentHint')}</span>
198
+ </label>
199
+ <label className={css.field}>
200
+ <span className={css.fieldLabel}>{tt('form.location')}</span>
201
+ <input className={css.input} value={form.location} onChange={event => { set('location', event.target.value) }} />
202
+ </label>
203
+ </div>
204
+ <label className={css.field}>
205
+ <span className={css.fieldLabel}>{tt('form.description')}</span>
206
+ <input className={css.input} value={form.description} onChange={event => { set('description', event.target.value) }} />
207
+ </label>
208
+ <label className={css.field}>
209
+ <span className={css.fieldLabel}>{tt('form.tags')}</span>
210
+ <input className={css.input} value={form.tags} onChange={event => { set('tags', event.target.value) }} />
211
+ <span className={css.hint}>{tt('form.tagsHint')}</span>
212
+ </label>
213
+ {error !== null && <p className={css.formError}>{tt('common.error', { error })}</p>}
214
+ <div className={css.modalFooter}>
215
+ <button type="button" className={css.ghostButton} disabled={saving} onClick={onClose}>{tt('form.cancel')}</button>
216
+ <button type="button" className={css.primaryButton} disabled={saving} onClick={() => { void save() }}>{tt('form.save')}</button>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ )
221
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Hosts tab: the host table with search (debounced through listHosts),
3
+ * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
4
+ * that hands the alias to the terminal tab via onConnect.
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import type { SshApi } from '../api.ts'
8
+ import type { SshHostSummary, TestResult } from '../../protocol.ts'
9
+ import { errorMessage, tt } from './helpers.ts'
10
+ import { HostFormDialog } from './HostFormDialog.tsx'
11
+ import css from './panel.module.css'
12
+
13
+ /** Hosts tab props. */
14
+ export interface HostsTabProps {
15
+ api: SshApi
16
+ /** Connect the given alias in the terminal tab. */
17
+ onConnect: (alias: string) => void
18
+ }
19
+
20
+ /** The host-form dialog invocation. */
21
+ type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
22
+
23
+ /** The hosts table plus its toolbar and dialogs. */
24
+ export function HostsTab({ api, onConnect }: HostsTabProps) {
25
+ const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
26
+ const [error, setError] = useState<string | null>(null)
27
+ const [search, setSearch] = useState('')
28
+ const [testingAlias, setTestingAlias] = useState<string | null>(null)
29
+ const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
30
+ const [importing, setImporting] = useState(false)
31
+ const [notice, setNotice] = useState<string | null>(null)
32
+ const [dialog, setDialog] = useState<DialogState | null>(null)
33
+ const seqRef = useRef(0)
34
+
35
+ const load = useCallback(async (query?: string): Promise<void> => {
36
+ const seq = ++seqRef.current
37
+ try {
38
+ const list = await api.listHosts(query)
39
+ if (seq !== seqRef.current) return
40
+ setHosts(list)
41
+ setError(null)
42
+ } catch (cause) {
43
+ if (seq !== seqRef.current) return
44
+ setError(errorMessage(cause))
45
+ }
46
+ }, [api])
47
+
48
+ useEffect(() => { void load() }, [load])
49
+
50
+ // Debounced search: every keystroke re-filters through the API.
51
+ useEffect(() => {
52
+ const timer = setTimeout(() => {
53
+ const query = search.trim()
54
+ void load(query === '' ? undefined : query)
55
+ }, 300)
56
+ return () => { clearTimeout(timer) }
57
+ }, [search, load])
58
+
59
+ const runTest = async (alias: string): Promise<void> => {
60
+ setTestingAlias(alias)
61
+ try {
62
+ const result = await api.testHost(alias)
63
+ setTestResults(prev => ({ ...prev, [alias]: result }))
64
+ } catch (cause) {
65
+ setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } }))
66
+ } finally {
67
+ setTestingAlias(null)
68
+ }
69
+ }
70
+
71
+ const deleteHost = async (alias: string): Promise<void> => {
72
+ if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return
73
+ try {
74
+ await api.deleteHost(alias)
75
+ void load()
76
+ } catch (cause) {
77
+ setError(errorMessage(cause))
78
+ }
79
+ }
80
+
81
+ const importConfig = async (): Promise<void> => {
82
+ setImporting(true)
83
+ try {
84
+ const result = await api.importSshConfig()
85
+ setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped }))
86
+ void load()
87
+ } catch (cause) {
88
+ setError(errorMessage(cause))
89
+ } finally {
90
+ setImporting(false)
91
+ }
92
+ }
93
+
94
+ return (
95
+ <div className={css.fillBody}>
96
+ <div className={css.toolbar}>
97
+ <input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
98
+ <div className={css.toolbarSpacer} />
99
+ <button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
100
+ <button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
101
+ </div>
102
+ {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
103
+ {error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
104
+ {hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
105
+ {hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
106
+ {hosts !== null && hosts.length > 0 && (
107
+ <div className={css.tableWrap}>
108
+ <table className={css.table}>
109
+ <thead>
110
+ <tr>
111
+ <th>{tt('hosts.col.alias')}</th>
112
+ <th>{tt('hosts.col.host')}</th>
113
+ <th>{tt('hosts.col.user')}</th>
114
+ <th>{tt('hosts.col.auth')}</th>
115
+ <th>{tt('hosts.col.environment')}</th>
116
+ <th>{tt('hosts.col.tags')}</th>
117
+ <th>{tt('hosts.col.description')}</th>
118
+ <th>{tt('hosts.col.actions')}</th>
119
+ </tr>
120
+ </thead>
121
+ <tbody>
122
+ {hosts.map(host => {
123
+ const test = testResults[host.alias]
124
+ return (
125
+ <tr key={host.alias}>
126
+ <td className={css.mono}>{host.alias}</td>
127
+ <td className={css.mono}>{host.host}:{host.port}</td>
128
+ <td>{host.user}</td>
129
+ <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
130
+ <td className={css.cellMuted}>{host.environment ?? ''}</td>
131
+ <td className={css.cellMuted}>{host.tags.join(', ')}</td>
132
+ <td className={css.cellMuted}>{host.description ?? ''}</td>
133
+ <td>
134
+ <div className={css.actions}>
135
+ <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
136
+ {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
137
+ </button>
138
+ {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
139
+ {test !== undefined && (
140
+ <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
141
+ {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
142
+ </span>
143
+ )}
144
+ <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
145
+ <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
146
+ <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
147
+ </div>
148
+ </td>
149
+ </tr>
150
+ )
151
+ })}
152
+ </tbody>
153
+ </table>
154
+ </div>
155
+ )}
156
+ {dialog !== null && (
157
+ <HostFormDialog
158
+ api={api}
159
+ editing={dialog.mode === 'edit' ? dialog.host : null}
160
+ onClose={() => { setDialog(null) }}
161
+ onSaved={() => { setDialog(null); void load() }}
162
+ />
163
+ )}
164
+ </div>
165
+ )
166
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The SSH operations panel shell: a header with a close control, a five-tab
3
+ * bar, and the active tab's content. Tab state lives here (browser session
4
+ * state); inactive tabs unmount, so each tab fetches its own data on
5
+ * activation. The hosts tab's connect action switches here to the terminal
6
+ * tab with the chosen alias preselected.
7
+ */
8
+ import { useState } from 'react'
9
+ import type { SshApi } from '../api.ts'
10
+ import type { PanelController } from './controller.ts'
11
+ import { tt } from './helpers.ts'
12
+ import { ClusterTab } from './ClusterTab.tsx'
13
+ import { HostsTab } from './HostsTab.tsx'
14
+ import { TerminalTab } from './TerminalTab.tsx'
15
+ import { TransferTab } from './TransferTab.tsx'
16
+ import { TunnelsTab } from './TunnelsTab.tsx'
17
+ import css from './panel.module.css'
18
+
19
+ /** The panel's tab identifiers. */
20
+ export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
21
+
22
+ /** Panel shell props. */
23
+ export interface SshPanelProps {
24
+ /** The panel state owner (open/close/toggle). */
25
+ controller: PanelController
26
+ /** The SSH API client every tab operates through. */
27
+ api: SshApi
28
+ }
29
+
30
+ /** The tab bar definition (labels resolved at render time). */
31
+ const TABS: ReadonlyArray<{ id: SshTab; label: () => string }> = [
32
+ { id: 'hosts', label: () => tt('tab.hosts') },
33
+ { id: 'terminal', label: () => tt('tab.terminal') },
34
+ { id: 'transfer', label: () => tt('tab.transfer') },
35
+ { id: 'tunnels', label: () => tt('tab.tunnels') },
36
+ { id: 'cluster', label: () => tt('tab.cluster') },
37
+ ]
38
+
39
+ /** A pending "connect this host" request handed to the terminal tab. */
40
+ interface ConnectRequest {
41
+ alias: string
42
+ nonce: number
43
+ }
44
+
45
+ /** The tabbed SSH panel. */
46
+ export function SshPanel({ controller, api }: SshPanelProps) {
47
+ const [activeTab, setActiveTab] = useState<SshTab>('hosts')
48
+ const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
49
+
50
+ const handleConnect = (alias: string): void => {
51
+ setActiveTab('terminal')
52
+ setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
53
+ }
54
+
55
+ return (
56
+ <div className={css.panel}>
57
+ <div className={css.panelHeader}>
58
+ <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
59
+ <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>x</button>
60
+ </div>
61
+ <div className={css.tabBar} role="tablist">
62
+ {TABS.map(tab => (
63
+ <button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
64
+ {tab.label()}
65
+ </button>
66
+ ))}
67
+ </div>
68
+ <div className={css.panelContent}>
69
+ {activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
70
+ {activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
71
+ {activeTab === 'transfer' && <TransferTab api={api} />}
72
+ {activeTab === 'tunnels' && <TunnelsTab api={api} />}
73
+ {activeTab === 'cluster' && <ClusterTab api={api} />}
74
+ </div>
75
+ </div>
76
+ )
77
+ }
@@ -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 '../../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
+ }