@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.
- package/README.md +75 -0
- package/cordis.patch.yml +12 -0
- package/lib/client.js +15638 -0
- package/lib/index.js +3297 -0
- package/lib/invariant.js +6 -0
- package/lib/types/client/api.d.ts +72 -0
- package/lib/types/client/index.d.ts +37 -0
- package/lib/types/client/locales.d.ts +137 -0
- package/lib/types/client/mount.d.ts +12 -0
- package/lib/types/client/panel/ClusterTab.d.ts +7 -0
- package/lib/types/client/panel/HostFormDialog.d.ts +12 -0
- package/lib/types/client/panel/HostsTab.d.ts +9 -0
- package/lib/types/client/panel/SshPanel.d.ts +13 -0
- package/lib/types/client/panel/TerminalTab.d.ts +11 -0
- package/lib/types/client/panel/TransferTab.d.ts +7 -0
- package/lib/types/client/panel/TunnelsTab.d.ts +7 -0
- package/lib/types/client/panel/controller.d.ts +22 -0
- package/lib/types/client/panel/helpers.d.ts +14 -0
- package/lib/types/client/panel/xterm.css.d.ts +2 -0
- package/lib/types/client/sidebar-entry.d.ts +24 -0
- package/lib/types/engine.d.ts +135 -0
- package/lib/types/index.d.ts +40 -0
- package/lib/types/invariant.d.ts +3 -0
- package/lib/types/protocol.d.ts +189 -0
- package/lib/types/routes.d.ts +29 -0
- package/lib/types/store.d.ts +54 -0
- package/lib/types/tools.d.ts +18 -0
- package/package.json +106 -0
- package/src/client/api.ts +363 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +68 -0
- package/src/client/locales.ts +284 -0
- package/src/client/mount.tsx +83 -0
- package/src/client/panel/ClusterTab.tsx +123 -0
- package/src/client/panel/HostFormDialog.tsx +221 -0
- package/src/client/panel/HostsTab.tsx +166 -0
- package/src/client/panel/SshPanel.tsx +77 -0
- package/src/client/panel/TerminalTab.tsx +176 -0
- package/src/client/panel/TransferTab.tsx +232 -0
- package/src/client/panel/TunnelsTab.tsx +177 -0
- package/src/client/panel/controller.ts +48 -0
- package/src/client/panel/helpers.ts +26 -0
- package/src/client/panel/panel.module.css +833 -0
- package/src/client/panel/xterm.css.ts +2 -0
- package/src/client/sidebar-entry.ts +123 -0
- package/src/engine.ts +780 -0
- package/src/index.ts +155 -0
- package/src/invariant.ts +4 -0
- package/src/protocol.ts +188 -0
- package/src/routes.ts +540 -0
- package/src/store.ts +344 -0
- package/src/tools.ts +371 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side API client for the /api/dsh-ssh route family. The only data
|
|
3
|
+
* access path the panel components use — plain fetch/WebSocket, same origin.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
SSH_API,
|
|
8
|
+
type ClusterResult,
|
|
9
|
+
type ExecResult,
|
|
10
|
+
type HostPayload,
|
|
11
|
+
type ImportResult,
|
|
12
|
+
type RemoteDirEntry,
|
|
13
|
+
type SshHostSummary,
|
|
14
|
+
type TerminalClientFrame,
|
|
15
|
+
type TerminalServerFrame,
|
|
16
|
+
type TestResult,
|
|
17
|
+
type TransferProgress,
|
|
18
|
+
type TransferStreamLine,
|
|
19
|
+
type TunnelInfo,
|
|
20
|
+
} from '../protocol.ts'
|
|
21
|
+
|
|
22
|
+
/** Minimal File System Access API surface (not in all lib.dom versions). */
|
|
23
|
+
interface WindowWithFileSystemAccess {
|
|
24
|
+
showSaveFilePicker?: (options: { suggestedName?: string }) => Promise<{
|
|
25
|
+
createWritable: () => Promise<{ write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> }>
|
|
26
|
+
}>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Error carrying the route's JSON error message. */
|
|
30
|
+
export class SshApiError extends Error {
|
|
31
|
+
constructor(message: string) {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'SshApiError'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Parse a JSON response or throw an SshApiError. */
|
|
38
|
+
async function readJson<T>(response: Response): Promise<T> {
|
|
39
|
+
let body: unknown
|
|
40
|
+
try {
|
|
41
|
+
body = await response.json()
|
|
42
|
+
} catch {
|
|
43
|
+
throw new SshApiError(`HTTP ${response.status}: invalid JSON response`)
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
const message = typeof body === 'object' && body !== null && typeof (body as { error?: unknown }).error === 'string'
|
|
47
|
+
? (body as { error: string }).error
|
|
48
|
+
: `HTTP ${response.status}`
|
|
49
|
+
throw new SshApiError(message)
|
|
50
|
+
}
|
|
51
|
+
return body as T
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Query-string helper. */
|
|
55
|
+
function query(params: Record<string, string | number | undefined>): string {
|
|
56
|
+
const search = new URLSearchParams()
|
|
57
|
+
for (const [key, value] of Object.entries(params)) {
|
|
58
|
+
if (value !== undefined && value !== '') search.set(key, String(value))
|
|
59
|
+
}
|
|
60
|
+
const text = search.toString()
|
|
61
|
+
return text === '' ? '' : '?' + text
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One open terminal connection (WebSocket JSON frames). */
|
|
65
|
+
export interface TerminalConnection {
|
|
66
|
+
/** Fired on the ready frame (shell is up). */
|
|
67
|
+
onReady: (() => void) | undefined
|
|
68
|
+
/** Fired on every output frame. */
|
|
69
|
+
onOutput: ((data: string) => void) | undefined
|
|
70
|
+
/** Fired on the exit frame (or transport error). */
|
|
71
|
+
onExit: ((code: number | null, error?: string) => void) | undefined
|
|
72
|
+
/** Send raw input to the remote shell. */
|
|
73
|
+
send(data: string): void
|
|
74
|
+
/** Resize the remote PTY. */
|
|
75
|
+
resize(cols: number, rows: number): void
|
|
76
|
+
/** Close the socket and the remote session. */
|
|
77
|
+
close(): void
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The browser half's only data entry point. */
|
|
81
|
+
export class SshApi {
|
|
82
|
+
// -------------------------------------------------------------- hosts
|
|
83
|
+
async listHosts(queryText?: string): Promise<SshHostSummary[]> {
|
|
84
|
+
const response = await fetch(SSH_API.hosts + query({ query: queryText }))
|
|
85
|
+
const body = await readJson<{ hosts: SshHostSummary[] }>(response)
|
|
86
|
+
return body.hosts
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async createHost(payload: HostPayload): Promise<SshHostSummary> {
|
|
90
|
+
const response = await fetch(SSH_API.hosts, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json' },
|
|
93
|
+
body: JSON.stringify(payload),
|
|
94
|
+
})
|
|
95
|
+
const body = await readJson<{ host: SshHostSummary }>(response)
|
|
96
|
+
return body.host
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async updateHost(alias: string, patch: HostPayload): Promise<SshHostSummary> {
|
|
100
|
+
const response = await fetch(SSH_API.hosts + query({ alias }), {
|
|
101
|
+
method: 'PATCH',
|
|
102
|
+
headers: { 'content-type': 'application/json' },
|
|
103
|
+
body: JSON.stringify(patch),
|
|
104
|
+
})
|
|
105
|
+
const body = await readJson<{ host: SshHostSummary }>(response)
|
|
106
|
+
return body.host
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async deleteHost(alias: string): Promise<void> {
|
|
110
|
+
const response = await fetch(SSH_API.hosts + query({ alias }), { method: 'DELETE' })
|
|
111
|
+
await readJson<{ ok: boolean }>(response)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async importSshConfig(): Promise<ImportResult> {
|
|
115
|
+
const response = await fetch(SSH_API.importSshConfig, { method: 'POST' })
|
|
116
|
+
const body = await readJson<{ result: ImportResult }>(response)
|
|
117
|
+
return body.result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------- ops
|
|
121
|
+
async testHost(alias: string): Promise<TestResult> {
|
|
122
|
+
const response = await fetch(SSH_API.test, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({ alias }),
|
|
126
|
+
})
|
|
127
|
+
const body = await readJson<{ result: TestResult }>(response)
|
|
128
|
+
return body.result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async exec(alias: string, command: string, timeoutMs?: number): Promise<ExecResult> {
|
|
132
|
+
const response = await fetch(SSH_API.exec, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({ alias, command, timeoutMs }),
|
|
136
|
+
})
|
|
137
|
+
const body = await readJson<{ result: ExecResult }>(response)
|
|
138
|
+
return body.result
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async cluster(options: {
|
|
142
|
+
command: string
|
|
143
|
+
aliases?: string[]
|
|
144
|
+
environment?: string
|
|
145
|
+
tags?: string[]
|
|
146
|
+
timeoutMs?: number
|
|
147
|
+
maxWorkers?: number
|
|
148
|
+
}): Promise<ClusterResult[]> {
|
|
149
|
+
const response = await fetch(SSH_API.cluster, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json' },
|
|
152
|
+
body: JSON.stringify(options),
|
|
153
|
+
})
|
|
154
|
+
const body = await readJson<{ results: ClusterResult[] }>(response)
|
|
155
|
+
return body.results
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ----------------------------------------------------------------- ls
|
|
159
|
+
async ls(alias: string, path: string): Promise<RemoteDirEntry[]> {
|
|
160
|
+
const response = await fetch(SSH_API.ls + query({ alias, path }))
|
|
161
|
+
const body = await readJson<{ entries: RemoteDirEntry[] }>(response)
|
|
162
|
+
return body.entries
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ------------------------------------------------------------- tunnel
|
|
166
|
+
async listTunnels(): Promise<TunnelInfo[]> {
|
|
167
|
+
const response = await fetch(SSH_API.tunnel, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'content-type': 'application/json' },
|
|
170
|
+
body: JSON.stringify({ action: 'list' }),
|
|
171
|
+
})
|
|
172
|
+
const body = await readJson<{ tunnels: TunnelInfo[] }>(response)
|
|
173
|
+
return body.tunnels
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async startTunnel(options: { alias: string; remotePort: number; remoteHost?: string; localPort?: number }): Promise<TunnelInfo> {
|
|
177
|
+
const response = await fetch(SSH_API.tunnel, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'content-type': 'application/json' },
|
|
180
|
+
body: JSON.stringify({ action: 'start', ...options }),
|
|
181
|
+
})
|
|
182
|
+
const body = await readJson<{ tunnel: TunnelInfo }>(response)
|
|
183
|
+
return body.tunnel
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async stopTunnel(tunnelId: string): Promise<boolean> {
|
|
187
|
+
const response = await fetch(SSH_API.tunnel, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: { 'content-type': 'application/json' },
|
|
190
|
+
body: JSON.stringify({ action: 'stop', tunnelId }),
|
|
191
|
+
})
|
|
192
|
+
const body = await readJson<{ ok: boolean }>(response)
|
|
193
|
+
return body.ok
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async stopAllTunnels(alias?: string): Promise<number> {
|
|
197
|
+
const response = await fetch(SSH_API.tunnel, {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'content-type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({ action: 'stop-all', alias }),
|
|
201
|
+
})
|
|
202
|
+
const body = await readJson<{ stopped: number }>(response)
|
|
203
|
+
return body.stopped
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ------------------------------------------------------------ transfer
|
|
207
|
+
/**
|
|
208
|
+
* Upload one file (raw bytes) to a remote path. Progress arrives through
|
|
209
|
+
* the NDJSON response stream; resolves when the result frame lands.
|
|
210
|
+
*/
|
|
211
|
+
async uploadFile(
|
|
212
|
+
file: File,
|
|
213
|
+
alias: string,
|
|
214
|
+
remotePath: string,
|
|
215
|
+
onProgress?: (progress: TransferProgress) => void,
|
|
216
|
+
): Promise<{ transferredBytes: number }> {
|
|
217
|
+
const response = await fetch(SSH_API.upload + query({ alias, remotePath }), {
|
|
218
|
+
method: 'POST',
|
|
219
|
+
body: file,
|
|
220
|
+
})
|
|
221
|
+
if (!response.ok || response.body === null) {
|
|
222
|
+
throw new SshApiError(`upload failed: HTTP ${response.status}`)
|
|
223
|
+
}
|
|
224
|
+
const reader = response.body.getReader()
|
|
225
|
+
const decoder = new TextDecoder()
|
|
226
|
+
let buffer = ''
|
|
227
|
+
let finalError: string | undefined
|
|
228
|
+
let sawResult = false
|
|
229
|
+
let transferredBytes = 0
|
|
230
|
+
for (;;) {
|
|
231
|
+
const { done, value } = await reader.read()
|
|
232
|
+
if (done) break
|
|
233
|
+
buffer += decoder.decode(value, { stream: true })
|
|
234
|
+
const lines = buffer.split('\n')
|
|
235
|
+
buffer = lines.pop() ?? ''
|
|
236
|
+
for (const line of lines) {
|
|
237
|
+
if (line.trim() === '') continue
|
|
238
|
+
let parsed: TransferStreamLine
|
|
239
|
+
try {
|
|
240
|
+
parsed = JSON.parse(line) as TransferStreamLine
|
|
241
|
+
} catch {
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (parsed.type === 'progress') {
|
|
245
|
+
onProgress?.(parsed.progress)
|
|
246
|
+
} else if (parsed.type === 'result') {
|
|
247
|
+
sawResult = true
|
|
248
|
+
if (parsed.ok) transferredBytes = parsed.transferredBytes ?? 0
|
|
249
|
+
finalError = parsed.ok ? undefined : parsed.error ?? 'upload failed'
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (finalError !== undefined) throw new SshApiError(finalError)
|
|
254
|
+
if (!sawResult) throw new SshApiError('upload ended without a result frame — the transfer did not complete')
|
|
255
|
+
return { transferredBytes }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Download a remote file with client-side progress. Streams straight to
|
|
260
|
+
* disk when the File System Access API is available (no full-file RAM
|
|
261
|
+
* copy); otherwise falls back to an in-memory Blob.
|
|
262
|
+
*/
|
|
263
|
+
async downloadFile(
|
|
264
|
+
alias: string,
|
|
265
|
+
remotePath: string,
|
|
266
|
+
onProgress?: (progress: TransferProgress) => void,
|
|
267
|
+
): Promise<{ blob?: Blob; filename: string; streamed: boolean; bytes: number }> {
|
|
268
|
+
const response = await fetch(SSH_API.download + query({ alias, remotePath }))
|
|
269
|
+
if (!response.ok || response.body === null) {
|
|
270
|
+
const text = await response.text().catch(() => '')
|
|
271
|
+
throw new SshApiError(text !== '' && text.startsWith('{') ? text : `download failed: HTTP ${response.status}`)
|
|
272
|
+
}
|
|
273
|
+
const total = Number(response.headers.get('content-length') ?? '0')
|
|
274
|
+
const disposition = response.headers.get('content-disposition') ?? ''
|
|
275
|
+
const match = /filename="([^"]+)"/.exec(disposition)
|
|
276
|
+
const filename = match?.[1] ?? remotePath.split('/').pop() ?? 'download'
|
|
277
|
+
const reader = response.body.getReader()
|
|
278
|
+
const picker = typeof window !== 'undefined'
|
|
279
|
+
? (window as WindowWithFileSystemAccess).showSaveFilePicker
|
|
280
|
+
: undefined
|
|
281
|
+
let streamed = false
|
|
282
|
+
let writable: { write: (data: Uint8Array) => Promise<void>; close: () => Promise<void> } | undefined
|
|
283
|
+
const chunks: Uint8Array<ArrayBuffer>[] = []
|
|
284
|
+
let received = 0
|
|
285
|
+
const progress = (): void => {
|
|
286
|
+
onProgress?.({
|
|
287
|
+
phase: 'transferring',
|
|
288
|
+
file: remotePath,
|
|
289
|
+
transferred: received,
|
|
290
|
+
total,
|
|
291
|
+
percent: total > 0 ? Math.round((received / total) * 1000) / 10 : 0,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
if (picker !== undefined) {
|
|
296
|
+
const handle = await picker.call(window, { suggestedName: filename })
|
|
297
|
+
writable = await handle.createWritable()
|
|
298
|
+
streamed = true
|
|
299
|
+
}
|
|
300
|
+
} catch {
|
|
301
|
+
// User cancelled the save dialog or the API is unavailable: fall back.
|
|
302
|
+
}
|
|
303
|
+
for (;;) {
|
|
304
|
+
const { done, value } = await reader.read()
|
|
305
|
+
if (done) break
|
|
306
|
+
if (writable !== undefined) {
|
|
307
|
+
await writable.write(value as Uint8Array)
|
|
308
|
+
} else {
|
|
309
|
+
chunks.push(value as Uint8Array<ArrayBuffer>)
|
|
310
|
+
}
|
|
311
|
+
received += value.length
|
|
312
|
+
progress()
|
|
313
|
+
}
|
|
314
|
+
if (writable !== undefined) await writable.close()
|
|
315
|
+
onProgress?.({ phase: 'done', file: remotePath, transferred: received, total: received > 0 ? received : total, percent: 100 })
|
|
316
|
+
return {
|
|
317
|
+
blob: streamed ? undefined : new Blob(chunks),
|
|
318
|
+
filename,
|
|
319
|
+
streamed,
|
|
320
|
+
bytes: received,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ------------------------------------------------------------ terminal
|
|
325
|
+
/** Open a WebSocket terminal session. */
|
|
326
|
+
openTerminal(alias: string, cols: number, rows: number): TerminalConnection {
|
|
327
|
+
const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
|
328
|
+
const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
|
|
329
|
+
const socket = new WebSocket(url)
|
|
330
|
+
const connection: TerminalConnection = {
|
|
331
|
+
onReady: undefined,
|
|
332
|
+
onOutput: undefined,
|
|
333
|
+
onExit: undefined,
|
|
334
|
+
send: (data) => {
|
|
335
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
336
|
+
socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
resize: (cols, rows) => {
|
|
340
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
341
|
+
socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
close: () => {
|
|
345
|
+
try { socket.close() } catch { /* already closed */ }
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
socket.onmessage = (event: MessageEvent<string>) => {
|
|
349
|
+
let frame: TerminalServerFrame
|
|
350
|
+
try {
|
|
351
|
+
frame = JSON.parse(event.data) as TerminalServerFrame
|
|
352
|
+
} catch {
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (frame.type === 'ready') connection.onReady?.()
|
|
356
|
+
else if (frame.type === 'output') connection.onOutput?.(frame.data)
|
|
357
|
+
else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
|
|
358
|
+
}
|
|
359
|
+
socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
|
|
360
|
+
socket.onerror = () => { connection.onExit?.(null, 'connection error') }
|
|
361
|
+
return connection
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-half entry for the dsh-ssh plugin — runs inside the dsh web GUI.
|
|
3
|
+
*
|
|
4
|
+
* Registers the dsh-ssh locale dictionaries and mounts the two DOM surfaces:
|
|
5
|
+
* the sidebar entry row (toggles the panel) and the SSH operations panel in
|
|
6
|
+
* the center column. Failure policy: DOM mounting problems are logged, never
|
|
7
|
+
* thrown — the web shell fails the whole boot when a plugin apply throws, and
|
|
8
|
+
* an external plugin must not take the GUI down.
|
|
9
|
+
*
|
|
10
|
+
* Export discipline (packages/client rule): the /client surface carries what
|
|
11
|
+
* cordis loading needs plus types only — all value exports stay internal.
|
|
12
|
+
*/
|
|
13
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
|
14
|
+
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
16
|
+
// Type-only: pulls the LocaleNamespaceMap merge table.
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
18
|
+
import { SshApi } from './api.ts'
|
|
19
|
+
import { en, zh, type SshKey } from './locales.ts'
|
|
20
|
+
import { mountPanel } from './mount.tsx'
|
|
21
|
+
import { PanelController } from './panel/controller.ts'
|
|
22
|
+
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
23
|
+
|
|
24
|
+
/** Locale namespace this plugin owns. */
|
|
25
|
+
const NS = 'dsh-ssh'
|
|
26
|
+
|
|
27
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
28
|
+
interface LocaleNamespaceMap {
|
|
29
|
+
/** dsh-ssh surface copy. */
|
|
30
|
+
'dsh-ssh': SshKey
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Required services (fiber inject waiting — the runtime must be up first). */
|
|
35
|
+
export const inject = ['slots', 'locale']
|
|
36
|
+
|
|
37
|
+
/** Type-only surface (export discipline: no value exports beyond the plugin contract). */
|
|
38
|
+
export type { PanelControllerSnapshot } from './panel/controller.ts'
|
|
39
|
+
export type { SshPanelProps } from './panel/SshPanel.tsx'
|
|
40
|
+
export type { HostsTabProps } from './panel/HostsTab.tsx'
|
|
41
|
+
export type { HostFormDialogProps } from './panel/HostFormDialog.tsx'
|
|
42
|
+
export type { TerminalTabProps } from './panel/TerminalTab.tsx'
|
|
43
|
+
export type { TransferTabProps } from './panel/TransferTab.tsx'
|
|
44
|
+
export type { TunnelsTabProps } from './panel/TunnelsTab.tsx'
|
|
45
|
+
export type { ClusterTabProps } from './panel/ClusterTab.tsx'
|
|
46
|
+
export type { SshKey } from './locales.ts'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mount the SSH panel.
|
|
50
|
+
* @param ctx - client root context (locale service).
|
|
51
|
+
*/
|
|
52
|
+
export function apply(ctx: ClientContext): void {
|
|
53
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-ssh: dictionaries')
|
|
54
|
+
|
|
55
|
+
const controller = new PanelController()
|
|
56
|
+
const api = new SshApi()
|
|
57
|
+
const disposers: Array<() => void> = []
|
|
58
|
+
try {
|
|
59
|
+
disposers.push(mountSidebarEntry(controller))
|
|
60
|
+
disposers.push(mountPanel(controller, api))
|
|
61
|
+
} catch (error) {
|
|
62
|
+
// DOM failures degrade the panel, never the GUI.
|
|
63
|
+
console.warn('[dsh-ssh] mount failed:', error)
|
|
64
|
+
}
|
|
65
|
+
ctx.effect(() => () => {
|
|
66
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
67
|
+
}, 'dsh-ssh: ui mounts')
|
|
68
|
+
}
|