@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,228 @@
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 '../../../ssh/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
+ <div className={css.modalHeader}>
134
+ <h3 className={css.modalTitle}>
135
+ <span className={css.whaleMark} aria-hidden="true" />
136
+ {editing != null ? tt('form.title.edit', { alias: editing.alias }) : tt('form.title.create')}
137
+ </h3>
138
+ </div>
139
+ <div className={css.modalBody}>
140
+ <div className={css.formRow}>
141
+ <label className={css.field}>
142
+ <span className={css.fieldLabel}>{tt('form.alias')}</span>
143
+ <input className={css.input} value={form.alias} disabled={editing != null} onChange={event => { set('alias', event.target.value) }} />
144
+ <span className={css.hint}>{tt('form.aliasHint')}</span>
145
+ </label>
146
+ <label className={css.field}>
147
+ <span className={css.fieldLabel}>{tt('form.host')}</span>
148
+ <input className={css.input} value={form.host} onChange={event => { set('host', event.target.value) }} />
149
+ </label>
150
+ </div>
151
+ <div className={css.formRow}>
152
+ <label className={css.field}>
153
+ <span className={css.fieldLabel}>{tt('form.port')}</span>
154
+ <input className={css.input} type="number" min={1} max={65535} value={form.port} onChange={event => { set('port', event.target.value) }} />
155
+ </label>
156
+ <label className={css.field}>
157
+ <span className={css.fieldLabel}>{tt('form.user')}</span>
158
+ <input className={css.input} value={form.user} onChange={event => { set('user', event.target.value) }} />
159
+ </label>
160
+ </div>
161
+ <div className={css.field}>
162
+ <span className={css.fieldLabel}>{tt('form.auth')}</span>
163
+ <div className={css.radioRow}>
164
+ <label className={css.radioLabel}>
165
+ <input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'key'} onChange={() => { set('authKind', 'key') }} />
166
+ {tt('form.auth.key')}
167
+ </label>
168
+ <label className={css.radioLabel}>
169
+ <input type="radio" name="dsh-ssh-auth" checked={form.authKind === 'password'} onChange={() => { set('authKind', 'password') }} />
170
+ {tt('form.auth.password')}
171
+ </label>
172
+ </div>
173
+ {editing != null && <span className={css.hint}>{tt('form.authKeepHint')}</span>}
174
+ </div>
175
+ {form.authKind === 'key' ? (
176
+ <div className={css.formRow}>
177
+ <label className={css.field}>
178
+ <span className={css.fieldLabel}>{tt('form.keyPath')}</span>
179
+ <input className={css.input} value={form.keyPath} onChange={event => { set('keyPath', event.target.value) }} />
180
+ <span className={css.hint}>{form.keyPath.trim() === '' ? tt('form.agentHint') : tt('form.keyPathHint')}</span>
181
+ </label>
182
+ <label className={css.field}>
183
+ <span className={css.fieldLabel}>{tt('form.passphrase')}</span>
184
+ <input className={css.input} type="password" value={form.passphrase} onChange={event => { set('passphrase', event.target.value) }} />
185
+ </label>
186
+ </div>
187
+ ) : (
188
+ <label className={css.field}>
189
+ <span className={css.fieldLabel}>{tt('form.password')}</span>
190
+ <input className={css.input} type="password" value={form.password} onChange={event => { set('password', event.target.value) }} />
191
+ <span className={css.hint}>{tt('form.passwordHint')}</span>
192
+ </label>
193
+ )}
194
+ <label className={css.field}>
195
+ <span className={css.fieldLabel}>{tt('form.proxyJump')}</span>
196
+ <input className={css.input} value={form.proxyJump} onChange={event => { set('proxyJump', event.target.value) }} />
197
+ <span className={css.hint}>{tt('form.proxyJumpHint')}</span>
198
+ </label>
199
+ <div className={css.formRow}>
200
+ <label className={css.field}>
201
+ <span className={css.fieldLabel}>{tt('form.environment')}</span>
202
+ <input className={css.input} value={form.environment} onChange={event => { set('environment', event.target.value) }} />
203
+ <span className={css.hint}>{tt('form.environmentHint')}</span>
204
+ </label>
205
+ <label className={css.field}>
206
+ <span className={css.fieldLabel}>{tt('form.location')}</span>
207
+ <input className={css.input} value={form.location} onChange={event => { set('location', event.target.value) }} />
208
+ </label>
209
+ </div>
210
+ <label className={css.field}>
211
+ <span className={css.fieldLabel}>{tt('form.description')}</span>
212
+ <input className={css.input} value={form.description} onChange={event => { set('description', event.target.value) }} />
213
+ </label>
214
+ <label className={css.field}>
215
+ <span className={css.fieldLabel}>{tt('form.tags')}</span>
216
+ <input className={css.input} value={form.tags} onChange={event => { set('tags', event.target.value) }} />
217
+ <span className={css.hint}>{tt('form.tagsHint')}</span>
218
+ </label>
219
+ {error !== null && <p className={css.formError}>{tt('common.error', { error })}</p>}
220
+ </div>
221
+ <div className={css.modalFooter}>
222
+ <button type="button" className={css.modalGhost} disabled={saving} onClick={onClose}>{tt('form.cancel')}</button>
223
+ <button type="button" className={css.modalPrimary} disabled={saving} onClick={() => { void save() }}>{tt('form.save')}</button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ )
228
+ }
@@ -0,0 +1,236 @@
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 '../../../ssh/protocol.ts'
9
+ import { errorMessage, tt } from './helpers.ts'
10
+ import { HostFormDialog } from './HostFormDialog.tsx'
11
+ import { HostFingerprintDialog } from './HostFingerprintDialog.tsx'
12
+ import { SessionSecretDialog } from './SessionSecretDialog.tsx'
13
+ import { SshApiError } from '../api.ts'
14
+ import css from './panel.module.css'
15
+
16
+ /** Hosts tab props. */
17
+ export interface HostsTabProps {
18
+ api: SshApi
19
+ /** Connect the given alias in the terminal tab. */
20
+ onConnect: (alias: string) => void
21
+ }
22
+
23
+ /** The host-form dialog invocation. */
24
+ type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
25
+
26
+ /** A pending host-key confirmation. */
27
+ interface HostKeyConfirmState {
28
+ alias: string
29
+ fingerprintSha256: string
30
+ mismatch: boolean
31
+ }
32
+
33
+ /** A pending session-secret prompt (VSCode Remote-SSH style). */
34
+ interface SessionSecretState {
35
+ alias: string
36
+ secret: 'password' | 'passphrase'
37
+ user?: string
38
+ }
39
+
40
+ /** The hosts table plus its toolbar and dialogs. */
41
+ export function HostsTab({ api, onConnect }: HostsTabProps) {
42
+ const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
43
+ const [error, setError] = useState<string | null>(null)
44
+ const [search, setSearch] = useState('')
45
+ const [testingAlias, setTestingAlias] = useState<string | null>(null)
46
+ const [testResults, setTestResults] = useState<Record<string, TestResult>>({})
47
+ const [importing, setImporting] = useState(false)
48
+ const [notice, setNotice] = useState<string | null>(null)
49
+ const [dialog, setDialog] = useState<DialogState | null>(null)
50
+ const [hostKeyConfirm, setHostKeyConfirm] = useState<HostKeyConfirmState | null>(null)
51
+ const [secretConfirm, setSecretConfirm] = useState<SessionSecretState | null>(null)
52
+ const seqRef = useRef(0)
53
+
54
+ const load = useCallback(async (query?: string): Promise<void> => {
55
+ const seq = ++seqRef.current
56
+ try {
57
+ const list = await api.listHosts(query)
58
+ if (seq !== seqRef.current) return
59
+ setHosts(list)
60
+ setError(null)
61
+ } catch (cause) {
62
+ if (seq !== seqRef.current) return
63
+ setError(errorMessage(cause))
64
+ }
65
+ }, [api])
66
+
67
+ useEffect(() => { void load() }, [load])
68
+
69
+ // Debounced search: every keystroke re-filters through the API.
70
+ useEffect(() => {
71
+ const timer = setTimeout(() => {
72
+ const query = search.trim()
73
+ void load(query === '' ? undefined : query)
74
+ }, 300)
75
+ return () => { clearTimeout(timer) }
76
+ }, [search, load])
77
+
78
+ const runTest = async (alias: string): Promise<void> => {
79
+ setTestingAlias(alias)
80
+ try {
81
+ const result = await api.testHost(alias)
82
+ if (result.code === 'NEEDS_PASSWORD' && (result.secret === 'password' || result.secret === 'passphrase')) {
83
+ // VSCode Remote-SSH style: prompt once per session, then retry.
84
+ setSecretConfirm({
85
+ alias,
86
+ secret: result.secret,
87
+ user: hosts?.find(host => host.alias === alias)?.user,
88
+ })
89
+ setTestingAlias(null)
90
+ return
91
+ }
92
+ setTestResults(prev => ({ ...prev, [alias]: result }))
93
+ } catch (cause) {
94
+ if (cause instanceof SshApiError && (cause.code === 'HOST_KEY_UNKNOWN' || cause.code === 'HOST_KEY_MISMATCH')) {
95
+ setHostKeyConfirm({
96
+ alias,
97
+ fingerprintSha256: cause.hostKeyFingerprint ?? '',
98
+ mismatch: cause.code === 'HOST_KEY_MISMATCH',
99
+ })
100
+ setTestingAlias(null)
101
+ return
102
+ }
103
+ setTestResults(prev => ({ ...prev, [alias]: { ok: false, error: errorMessage(cause) } }))
104
+ } finally {
105
+ setTestingAlias(null)
106
+ }
107
+ }
108
+
109
+ /** After the fingerprint is confirmed (or reset), retry the test. */
110
+ const retryAfterFingerprint = async (alias: string): Promise<void> => {
111
+ setHostKeyConfirm(null)
112
+ await runTest(alias)
113
+ }
114
+
115
+ /** After the session secret is provided, retry the test. */
116
+ const retryAfterSecret = async (alias: string): Promise<void> => {
117
+ setSecretConfirm(null)
118
+ await runTest(alias)
119
+ }
120
+
121
+ const deleteHost = async (alias: string): Promise<void> => {
122
+ if (!window.confirm(tt('hosts.deleteConfirm', { alias }))) return
123
+ try {
124
+ await api.deleteHost(alias)
125
+ void load()
126
+ } catch (cause) {
127
+ setError(errorMessage(cause))
128
+ }
129
+ }
130
+
131
+ const importConfig = async (): Promise<void> => {
132
+ setImporting(true)
133
+ try {
134
+ const result = await api.importSshConfig()
135
+ setNotice(tt('hosts.imported', { parsed: result.parsed, added: result.added, skipped: result.skipped }))
136
+ void load()
137
+ } catch (cause) {
138
+ setError(errorMessage(cause))
139
+ } finally {
140
+ setImporting(false)
141
+ }
142
+ }
143
+
144
+ return (
145
+ <div className={css.fillBody}>
146
+ <div className={css.toolbar}>
147
+ <input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
148
+ <div className={css.toolbarSpacer} />
149
+ <button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
150
+ <button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
151
+ </div>
152
+ {notice !== null && <div className={css.banner} data-kind="ok">{notice}</div>}
153
+ {error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
154
+ {hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
155
+ {hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
156
+ {hosts !== null && hosts.length > 0 && (
157
+ <div className={css.tableWrap}>
158
+ <table className={css.table}>
159
+ <thead>
160
+ <tr>
161
+ <th>{tt('hosts.col.alias')}</th>
162
+ <th>{tt('hosts.col.host')}</th>
163
+ <th>{tt('hosts.col.user')}</th>
164
+ <th>{tt('hosts.col.auth')}</th>
165
+ <th>{tt('hosts.col.environment')}</th>
166
+ <th>{tt('hosts.col.tags')}</th>
167
+ <th>{tt('hosts.col.description')}</th>
168
+ <th>{tt('hosts.col.actions')}</th>
169
+ </tr>
170
+ </thead>
171
+ <tbody>
172
+ {hosts.map(host => {
173
+ const test = testResults[host.alias]
174
+ return (
175
+ <tr key={host.alias}>
176
+ <td className={css.mono}>{host.alias}</td>
177
+ <td className={css.mono}>{host.host}:{host.port}</td>
178
+ <td>{host.user}</td>
179
+ <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
180
+ <td className={css.cellMuted}>{host.environment ?? ''}</td>
181
+ <td className={css.cellMuted}>{host.tags.join(', ')}</td>
182
+ <td className={css.cellMuted}>{host.description ?? ''}</td>
183
+ <td>
184
+ <div className={css.actions}>
185
+ <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
186
+ {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
187
+ </button>
188
+ {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
189
+ {test !== undefined && (
190
+ <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
191
+ {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
192
+ </span>
193
+ )}
194
+ <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
195
+ <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
196
+ <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
197
+ </div>
198
+ </td>
199
+ </tr>
200
+ )
201
+ })}
202
+ </tbody>
203
+ </table>
204
+ </div>
205
+ )}
206
+ {dialog !== null && (
207
+ <HostFormDialog
208
+ api={api}
209
+ editing={dialog.mode === 'edit' ? dialog.host : null}
210
+ onClose={() => { setDialog(null) }}
211
+ onSaved={() => { setDialog(null); void load() }}
212
+ />
213
+ )}
214
+ {hostKeyConfirm !== null && (
215
+ <HostFingerprintDialog
216
+ api={api}
217
+ alias={hostKeyConfirm.alias}
218
+ fingerprintSha256={hostKeyConfirm.fingerprintSha256}
219
+ mismatch={hostKeyConfirm.mismatch}
220
+ onClose={() => { setHostKeyConfirm(null) }}
221
+ onTrusted={() => { void retryAfterFingerprint(hostKeyConfirm.alias) }}
222
+ />
223
+ )}
224
+ {secretConfirm !== null && (
225
+ <SessionSecretDialog
226
+ api={api}
227
+ alias={secretConfirm.alias}
228
+ user={secretConfirm.user}
229
+ secret={secretConfirm.secret}
230
+ onClose={() => { setSecretConfirm(null) }}
231
+ onProvided={() => { void retryAfterSecret(secretConfirm.alias) }}
232
+ />
233
+ )}
234
+ </div>
235
+ )
236
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Session secret dialog (VSCode Remote-SSH style): shown when a connection
3
+ * needs a password/passphrase that the user hasn't entered this session
4
+ * (secretStorage='none' → secrets are never persisted). The credential is
5
+ * sent once to the host via /api/dsh-ssh/session-secret, held in the engine's
6
+ * in-memory session table for the connection-pool lifetime, and dropped on
7
+ * process exit. Nothing is written to disk.
8
+ */
9
+ import { useState } from 'react'
10
+ import type { SshApi } from '../api.ts'
11
+ import { tt } from './helpers.ts'
12
+ import css from './panel.module.css'
13
+
14
+ /** The dialog's props. */
15
+ export interface SessionSecretDialogProps {
16
+ api: SshApi
17
+ alias: string
18
+ /** SSH login name — rendered as user@alias. */
19
+ user?: string
20
+ /** Which secret the connection needs. */
21
+ secret: 'password' | 'passphrase'
22
+ onClose: () => void
23
+ /** Called after the secret was provided (retry the operation). */
24
+ onProvided: () => void
25
+ }
26
+
27
+ /** Prompt for a session-only credential. */
28
+ export function SessionSecretDialog({ api, alias, user, secret, onClose, onProvided }: SessionSecretDialogProps) {
29
+ const [value, setValue] = useState('')
30
+ const [busy, setBusy] = useState(false)
31
+ const [error, setError] = useState<string | null>(null)
32
+ const account = user !== undefined && user !== '' ? `${user}@${alias}` : alias
33
+
34
+ const submit = async (): Promise<void> => {
35
+ if (value === '') {
36
+ setError(tt('sessionSecret.required'))
37
+ return
38
+ }
39
+ setBusy(true)
40
+ setError(null)
41
+ try {
42
+ if (secret === 'password') await api.setSessionSecret(alias, { password: value })
43
+ else await api.setSessionSecret(alias, { passphrase: value })
44
+ onProvided()
45
+ } catch (cause) {
46
+ setError(cause instanceof Error ? cause.message : String(cause))
47
+ setBusy(false)
48
+ }
49
+ }
50
+
51
+ return (
52
+ <div className={css.modalBackdrop} onClick={onClose}>
53
+ <div className={css.modal} role="dialog" aria-modal="true" onClick={event => { event.stopPropagation() }}>
54
+ <div className={css.modalHeader}>
55
+ <h3 className={css.modalTitle}>
56
+ {secret === 'password' ? tt('sessionSecret.title', { account }) : tt('sessionSecret.titlePassphrase', { account })}
57
+ </h3>
58
+ </div>
59
+ <div className={css.modalBody}>
60
+ <input
61
+ className={css.input}
62
+ type="password"
63
+ value={value}
64
+ autoFocus
65
+ placeholder={secret === 'password' ? tt('sessionSecret.placeholder') : tt('sessionSecret.placeholderPassphrase')}
66
+ onChange={event => { setValue(event.target.value) }}
67
+ onKeyDown={event => { if (event.key === 'Enter') void submit() }}
68
+ />
69
+ {error !== null && <p className={css.formError}>{error}</p>}
70
+ </div>
71
+ <div className={css.modalFooter}>
72
+ <button type="button" className={css.modalGhost} disabled={busy} onClick={onClose}>{tt('sessionSecret.cancel')}</button>
73
+ <button type="button" className={css.modalPrimary} disabled={busy} onClick={() => { void submit() }}>
74
+ {tt('sessionSecret.connect')}
75
+ </button>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ )
80
+ }
@@ -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
+ }