@linxin666/dsh-ssh 0.4.2 → 0.4.4
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/lib/client.js +371 -461
- package/lib/client.js.map +1 -1
- package/lib/index.js +707 -466
- package/lib/types/client/api.d.ts +55 -4
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/native-panel.d.ts +70 -0
- package/lib/types/client/panel/SshPanel.d.ts +1 -3
- package/lib/types/client/panel/TerminalTab.d.ts +6 -1
- package/lib/types/client/panel/controller.d.ts +71 -4
- package/lib/types/engine/terminal-sessions.d.ts +67 -0
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/protocol.d.ts +12 -1
- package/lib/types/routes.d.ts +2 -0
- package/package.json +19 -16
- package/src/client/api.ts +137 -24
- package/src/client/index.ts +31 -9
- package/src/client/locales.ts +2 -0
- package/src/client/native-panel.tsx +141 -0
- package/src/client/panel/SshPanel.tsx +25 -28
- package/src/client/panel/TerminalTab.tsx +54 -14
- package/src/client/panel/controller.ts +126 -7
- package/src/client/panel/panel.module.css +8 -128
- package/src/engine/terminal-sessions.ts +280 -0
- package/src/index.ts +4 -1
- package/src/mount-once.ts +107 -13
- package/src/protocol.ts +6 -1
- package/src/routes.ts +26 -107
- package/lib/types/client/body-mutations.d.ts +0 -45
- package/lib/types/client/mount.d.ts +0 -27
- package/lib/types/client/panel-mount-core.d.ts +0 -56
- package/lib/types/client/sidebar-entry-core.d.ts +0 -55
- package/lib/types/client/sidebar-entry.d.ts +0 -26
- package/src/client/body-mutations.ts +0 -133
- package/src/client/mount.tsx +0 -47
- package/src/client/panel-mount-core.ts +0 -155
- package/src/client/sidebar-entry-core.ts +0 -237
- package/src/client/sidebar-entry.ts +0 -51
package/src/client/api.ts
CHANGED
|
@@ -76,10 +76,83 @@ function query(params: Record<string, string | number | undefined>): string {
|
|
|
76
76
|
return text === '' ? '' : '?' + text
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** The location facts a terminal socket URL is built from. */
|
|
80
|
+
interface PageLocation {
|
|
81
|
+
/** Page scheme with its colon (for example `https:` or `dsh-app:`). */
|
|
82
|
+
protocol: string
|
|
83
|
+
/** Page authority, empty for a scheme that carries none. */
|
|
84
|
+
host: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The WebSocket URL the terminal route is reached at, or undefined when this
|
|
89
|
+
* page cannot carry one.
|
|
90
|
+
*
|
|
91
|
+
* A page delivered by an application on this machine (the official DSH
|
|
92
|
+
* Desktop shell serves its Web GUI from `dsh-app://app/`) has no WebSocket
|
|
93
|
+
* transport: its scheme handler forwards HTTP requests to the local host but
|
|
94
|
+
* cannot upgrade a socket, so `ws://app/...` never connects and the terminal
|
|
95
|
+
* reports "connection error" (issue #1744). Every other page is a web page and
|
|
96
|
+
* resolves the socket against its own origin, exactly as before.
|
|
97
|
+
*
|
|
98
|
+
* A blank authority is treated the same as an application scheme: there is
|
|
99
|
+
* nothing to dial, and the caller gets the actionable reason instead of a
|
|
100
|
+
* socket that can only fail.
|
|
101
|
+
*
|
|
102
|
+
* @param location - the page location to read.
|
|
103
|
+
* @param search - the query string carrying `alias` or `session`.
|
|
104
|
+
* @returns the absolute `ws:`/`wss:` URL, or undefined when the page cannot carry one.
|
|
105
|
+
*/
|
|
106
|
+
export function terminalSocketUrl(location: PageLocation, search: string): string | undefined {
|
|
107
|
+
if (WEB_PAGE_PROTOCOLS.includes(location.protocol)) {
|
|
108
|
+
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
|
109
|
+
return scheme + '://' + location.host + SSH_API.terminal + search
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Schemes a network page can be delivered with. Every other scheme belongs to
|
|
116
|
+
* an application on this machine, which is the same classification the remote
|
|
117
|
+
* channel and the update seat use (`isWebPageProtocol` / `isApplicationDeliveredPage`).
|
|
118
|
+
*/
|
|
119
|
+
const WEB_PAGE_PROTOCOLS: readonly string[] = [
|
|
120
|
+
'http:',
|
|
121
|
+
'https:',
|
|
122
|
+
'blob:',
|
|
123
|
+
'data:',
|
|
124
|
+
'about:',
|
|
125
|
+
'filesystem:',
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A terminal connection that never opened: it reports one failure once the
|
|
130
|
+
* view has attached its `onExit`, so the tab shows why instead of sitting on a
|
|
131
|
+
* spinner, and every later frame is a no-op.
|
|
132
|
+
*/
|
|
133
|
+
function failedTerminal(reason: string): TerminalConnection {
|
|
134
|
+
const connection: TerminalConnection = {
|
|
135
|
+
onReady: undefined,
|
|
136
|
+
onOutput: undefined,
|
|
137
|
+
onExit: undefined,
|
|
138
|
+
onAuthPrompt: undefined,
|
|
139
|
+
send: () => undefined,
|
|
140
|
+
resize: () => undefined,
|
|
141
|
+
sendAuthResponse: () => undefined,
|
|
142
|
+
detach: () => undefined,
|
|
143
|
+
close: () => undefined,
|
|
144
|
+
}
|
|
145
|
+
// The view binds onExit after the open() call returns, so the failure is
|
|
146
|
+
// delivered on the next microtask - never synchronously, which would race
|
|
147
|
+
// the handler the caller is about to assign.
|
|
148
|
+
queueMicrotask(() => { connection.onExit?.(null, reason) })
|
|
149
|
+
return connection
|
|
150
|
+
}
|
|
151
|
+
|
|
79
152
|
/** One open terminal connection (WebSocket JSON frames). */
|
|
80
153
|
export interface TerminalConnection {
|
|
81
|
-
/** Fired on the ready frame (shell is up). */
|
|
82
|
-
onReady: (() => void) | undefined
|
|
154
|
+
/** Fired on the ready frame (shell is up); carries the host session id and alias. */
|
|
155
|
+
onReady: ((sessionId: string, alias: string) => void) | undefined
|
|
83
156
|
/** Fired on every output frame. */
|
|
84
157
|
onOutput: ((data: string) => void) | undefined
|
|
85
158
|
/** Fired on the exit frame (or transport error). */
|
|
@@ -92,7 +165,13 @@ export interface TerminalConnection {
|
|
|
92
165
|
resize(cols: number, rows: number): void
|
|
93
166
|
/** Send interactive 2FA response codes to the remote server. */
|
|
94
167
|
sendAuthResponse(responses: string[]): void
|
|
95
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Leave the socket while the host keeps the remote shell alive, so a later
|
|
170
|
+
* {@link SshApi.attachTerminal} picks the session up where it left off.
|
|
171
|
+
* This is what a view does on unmount; it is not a disconnect.
|
|
172
|
+
*/
|
|
173
|
+
detach(): void
|
|
174
|
+
/** Close the socket and end the remote session. */
|
|
96
175
|
close(): void
|
|
97
176
|
}
|
|
98
177
|
|
|
@@ -344,32 +423,66 @@ export class SshApi {
|
|
|
344
423
|
}
|
|
345
424
|
|
|
346
425
|
// ------------------------------------------------------------ terminal
|
|
347
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* Open a new host terminal session for the alias.
|
|
428
|
+
* @param alias - the configured host alias.
|
|
429
|
+
* @param cols - initial PTY width.
|
|
430
|
+
* @param rows - initial PTY height.
|
|
431
|
+
* @returns the live connection; its ready callback carries the session id
|
|
432
|
+
* {@link attachTerminal} uses after a view detach.
|
|
433
|
+
*/
|
|
348
434
|
openTerminal(alias: string, cols: number, rows: number): TerminalConnection {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
435
|
+
return this.terminalSocket(query({ alias, cols, rows }))
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Reattach to a host-side session that outlived its view (a panel switch, a
|
|
440
|
+
* page navigation). The host replays its scrollback before the ready frame.
|
|
441
|
+
* @param sessionId - the id the previous connection reported on ready.
|
|
442
|
+
* @param cols - the view's current width.
|
|
443
|
+
* @param rows - the view's current height.
|
|
444
|
+
*/
|
|
445
|
+
attachTerminal(sessionId: string, cols: number, rows: number): TerminalConnection {
|
|
446
|
+
return this.terminalSocket(query({ session: sessionId, cols, rows }))
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** One terminal socket over either an alias (open) or a session id (attach). */
|
|
450
|
+
private terminalSocket(search: string): TerminalConnection {
|
|
451
|
+
const target = terminalSocketUrl(window.location, search)
|
|
452
|
+
if (target === undefined) {
|
|
453
|
+
// This page has no WebSocket transport at all (the desktop shell's
|
|
454
|
+
// custom scheme forwards HTTP only). Say so plainly instead of opening
|
|
455
|
+
// a socket that can only report "connection error".
|
|
456
|
+
return failedTerminal(tt('terminal.noWebSocket'))
|
|
457
|
+
}
|
|
458
|
+
let socket: WebSocket
|
|
459
|
+
try {
|
|
460
|
+
socket = new WebSocket(target)
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return failedTerminal(error instanceof Error ? error.message : String(error))
|
|
463
|
+
}
|
|
464
|
+
// A deliberate detach/close must not surface as a transport error: the
|
|
465
|
+
// view tears down silently, and only an UNEXPECTED close reports exit.
|
|
466
|
+
let leaving = false
|
|
467
|
+
const sendFrame = (frame: TerminalClientFrame): void => {
|
|
468
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame))
|
|
469
|
+
}
|
|
352
470
|
const connection: TerminalConnection = {
|
|
353
471
|
onReady: undefined,
|
|
354
472
|
onOutput: undefined,
|
|
355
473
|
onExit: undefined,
|
|
356
474
|
onAuthPrompt: undefined,
|
|
357
|
-
send: (data) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
socket.send(JSON.stringify({ type: 'resize', cols, rows } satisfies TerminalClientFrame))
|
|
365
|
-
}
|
|
366
|
-
},
|
|
367
|
-
sendAuthResponse: (responses) => {
|
|
368
|
-
if (socket.readyState === WebSocket.OPEN) {
|
|
369
|
-
socket.send(JSON.stringify({ type: 'auth_response', responses } satisfies TerminalClientFrame))
|
|
370
|
-
}
|
|
475
|
+
send: (data) => { sendFrame({ type: 'input', data }) },
|
|
476
|
+
resize: (cols, rows) => { sendFrame({ type: 'resize', cols, rows }) },
|
|
477
|
+
sendAuthResponse: (responses) => { sendFrame({ type: 'auth_response', responses }) },
|
|
478
|
+
detach: () => {
|
|
479
|
+
leaving = true
|
|
480
|
+
sendFrame({ type: 'detach' })
|
|
481
|
+
try { socket.close() } catch { /* already closed */ }
|
|
371
482
|
},
|
|
372
483
|
close: () => {
|
|
484
|
+
leaving = true
|
|
485
|
+
sendFrame({ type: 'close' })
|
|
373
486
|
try { socket.close() } catch { /* already closed */ }
|
|
374
487
|
},
|
|
375
488
|
}
|
|
@@ -380,13 +493,13 @@ export class SshApi {
|
|
|
380
493
|
} catch {
|
|
381
494
|
return
|
|
382
495
|
}
|
|
383
|
-
if (frame.type === 'ready') connection.onReady?.()
|
|
496
|
+
if (frame.type === 'ready') connection.onReady?.(frame.sessionId, frame.alias)
|
|
384
497
|
else if (frame.type === 'output') connection.onOutput?.(frame.data)
|
|
385
498
|
else if (frame.type === 'exit') connection.onExit?.(frame.code, frame.error)
|
|
386
499
|
else if (frame.type === 'auth_prompt') connection.onAuthPrompt?.(frame.name, frame.instructions, frame.prompts)
|
|
387
500
|
}
|
|
388
|
-
socket.onclose = () => { connection.onExit?.(null, 'connection closed') }
|
|
389
|
-
socket.onerror = () => { connection.onExit?.(null, 'connection error') }
|
|
501
|
+
socket.onclose = () => { if (!leaving) connection.onExit?.(null, 'connection closed') }
|
|
502
|
+
socket.onerror = () => { if (!leaving) connection.onExit?.(null, 'connection error') }
|
|
390
503
|
return connection
|
|
391
504
|
}
|
|
392
505
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -21,12 +21,11 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
|
21
21
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
22
22
|
import { SshApi } from './api.ts'
|
|
23
23
|
import { en, zh, type SshKey } from './locales.ts'
|
|
24
|
-
import {
|
|
25
|
-
import { PanelController } from './panel/controller.ts'
|
|
24
|
+
import { registerSshPanel } from './native-panel.tsx'
|
|
25
|
+
import { PanelController, SSH_PANEL_ID } from './panel/controller.ts'
|
|
26
26
|
import type { TerminalFontSource } from './panel/helpers.ts'
|
|
27
27
|
import { setRuntimeTranslate } from './panel/helpers.ts'
|
|
28
28
|
import { bindSettingsReader } from './settings-binding.ts'
|
|
29
|
-
import { mountSidebarEntry } from './sidebar-entry.ts'
|
|
30
29
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
31
30
|
|
|
32
31
|
/** Locale namespace this plugin owns. */
|
|
@@ -55,7 +54,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
55
54
|
}
|
|
56
55
|
|
|
57
56
|
/** Required services (fiber inject waiting — the runtime must be up first). */
|
|
58
|
-
export const inject = ['slots', 'locale', 'configForms']
|
|
57
|
+
export const inject = ['slots', 'locale', 'configForms', 'layout']
|
|
59
58
|
|
|
60
59
|
/** Type-only surface (export discipline: no value exports beyond the plugin contract). */
|
|
61
60
|
export type { PanelControllerSnapshot } from './panel/controller.ts'
|
|
@@ -90,7 +89,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
90
89
|
// follow the Language setting without a reload.
|
|
91
90
|
try { setRuntimeTranslate(ctx.locale.bind(NS)) } catch { /* locale missing: document-language fallback stays */ }
|
|
92
91
|
|
|
93
|
-
|
|
92
|
+
// Panel navigation belongs to the layout service: the panel asks it to
|
|
93
|
+
// select its own panel id (or the conversation) instead of taking over the
|
|
94
|
+
// center column at the DOM level. The lookup stays lazy so a selection
|
|
95
|
+
// issued before the service settles still lands once it does, and the face
|
|
96
|
+
// throws by contract before the layout root entry mounts, which the
|
|
97
|
+
// controller tolerates.
|
|
98
|
+
const controller = new PanelController({
|
|
99
|
+
panel: {
|
|
100
|
+
select: panelId => {
|
|
101
|
+
const layout = (ctx as unknown as { get?(name: string): unknown }).get?.('layout') as { selectPanel?: (id: string | null) => void } | undefined
|
|
102
|
+
layout?.selectPanel?.(panelId)
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
})
|
|
94
106
|
const api = new SshApi()
|
|
95
107
|
// Live terminal-font preference (issue #577): the field lives in this
|
|
96
108
|
// plugin's own profile entry, whose settings page the Host generates from
|
|
@@ -107,11 +119,21 @@ export function apply(ctx: ClientContext): void {
|
|
|
107
119
|
ctx.effect(() => () => { settings.dispose() }, 'dsh-ssh: settings binding')
|
|
108
120
|
const disposers: Array<() => void> = []
|
|
109
121
|
try {
|
|
110
|
-
disposers.push(
|
|
111
|
-
|
|
122
|
+
disposers.push(registerSshPanel(ctx, controller, api, terminalFont))
|
|
123
|
+
// The layout's panel selection is reconciled back into the controller, so
|
|
124
|
+
// the panel follows the user clicking another panel row.
|
|
125
|
+
const layoutFace = (ctx as unknown as { get?(name: string): unknown }).get?.('layout') as { panelInfo?: { subscribe(listener: () => void): () => void; getSnapshot(): { activePanelId: unknown } } } | undefined
|
|
126
|
+
if (layoutFace?.panelInfo !== undefined) {
|
|
127
|
+
const sync = (): void => {
|
|
128
|
+
const active = layoutFace.panelInfo!.getSnapshot().activePanelId
|
|
129
|
+
controller.syncPanelSelection(active === SSH_PANEL_ID ? SSH_PANEL_ID : null)
|
|
130
|
+
}
|
|
131
|
+
sync()
|
|
132
|
+
disposers.push(layoutFace.panelInfo.subscribe(sync))
|
|
133
|
+
}
|
|
112
134
|
} catch (error) {
|
|
113
|
-
//
|
|
114
|
-
console.warn('[dsh-ssh]
|
|
135
|
+
// Registration failures degrade the panel, never the GUI.
|
|
136
|
+
console.warn('[dsh-ssh] panel registration failed:', error)
|
|
115
137
|
}
|
|
116
138
|
ctx.effect(() => () => {
|
|
117
139
|
for (const dispose of disposers.splice(0)) dispose()
|
package/src/client/locales.ts
CHANGED
|
@@ -93,6 +93,7 @@ export const zh = {
|
|
|
93
93
|
'terminal.ready': '终端已连接({alias})',
|
|
94
94
|
'terminal.exited': '终端已退出({alias})',
|
|
95
95
|
'terminal.error': '终端错误:{error}',
|
|
96
|
+
'terminal.noWebSocket': '当前页面不支持 WebSocket(应用外壳只转发 HTTP),请改用浏览器打开的 Web 界面使用终端。',
|
|
96
97
|
'terminal.auth.title': '二次身份验证 (2FA / 交互式认证)',
|
|
97
98
|
'terminal.auth.submit': '提交验证码',
|
|
98
99
|
'terminal.auth.cancel': '取消',
|
|
@@ -253,6 +254,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
253
254
|
'terminal.ready': 'Terminal connected ({alias})',
|
|
254
255
|
'terminal.exited': 'Terminal exited ({alias})',
|
|
255
256
|
'terminal.error': 'Terminal error: {error}',
|
|
257
|
+
'terminal.noWebSocket': 'This page cannot carry a WebSocket (the application shell forwards HTTP only). Open the Web UI in a browser to use the terminal.',
|
|
256
258
|
'terminal.auth.title': 'Two-Factor Authentication (2FA)',
|
|
257
259
|
'terminal.auth.submit': 'Submit Code',
|
|
258
260
|
'terminal.auth.cancel': 'Cancel',
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native panel registration for the SSH panel.
|
|
3
|
+
*
|
|
4
|
+
* The panel is an official-style center-column page, not a DOM takeover: it
|
|
5
|
+
* contributes a row into the sidebar shell's own global panel list
|
|
6
|
+
* (`sidebar.panellist`) and its page into the layout's keyed `main` slot. The
|
|
7
|
+
* shell then owns the row box, the label, the active highlight, the collapsed
|
|
8
|
+
* rail and the panel switch, exactly as it does for the shipped Plugins and
|
|
9
|
+
* Schedule pages and for the task board and the skill center.
|
|
10
|
+
*
|
|
11
|
+
* Both registrations go through `ctx.slots.inject`, which fires only once the
|
|
12
|
+
* owning shell entry has declared the seat: load order between this plugin and
|
|
13
|
+
* ui-layout / ui-sidebar therefore does not matter, and neither does a shell
|
|
14
|
+
* that cannot serve the seats (the callback simply never runs).
|
|
15
|
+
*
|
|
16
|
+
* The terminal's PTY shell lives on the host and outlives this page (see
|
|
17
|
+
* engine/terminal-sessions.ts), so the layout unmounting a deselected page
|
|
18
|
+
* costs the view its xterm instance, not the remote session: the terminal tab
|
|
19
|
+
* reattaches by session id when the page mounts again.
|
|
20
|
+
*
|
|
21
|
+
* @module @linxin666/dsh-client-ssh/client
|
|
22
|
+
*/
|
|
23
|
+
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
24
|
+
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
25
|
+
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
|
26
|
+
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
27
|
+
import type { SshApi } from './api.ts'
|
|
28
|
+
import { SSH_PANEL_ID, type PanelController } from './panel/controller.ts'
|
|
29
|
+
import { tt, type TerminalFontSource } from './panel/helpers.ts'
|
|
30
|
+
import { SshPanel } from './panel/SshPanel.tsx'
|
|
31
|
+
import css from './panel/panel.module.css'
|
|
32
|
+
|
|
33
|
+
/** The panel id shared by the sidebar row and the main-slot occupant. */
|
|
34
|
+
export { SSH_PANEL_ID }
|
|
35
|
+
|
|
36
|
+
/** Row order among the shell's global panel rows (Plugins 0, Schedule 10, board 20, skill center 30). */
|
|
37
|
+
const PANEL_ORDER = 40
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The sidebar row glyph the shell asks for at its own size and active state.
|
|
41
|
+
* The shell owns the button, label, tooltip and rail geometry; this component
|
|
42
|
+
* draws only the glyph, like every other panel row.
|
|
43
|
+
*
|
|
44
|
+
* The glyph carries `data-dsh-panel-entry` because it is the only DOM this
|
|
45
|
+
* panel's own code owns inside that shell-owned row: the L2 contract (skins)
|
|
46
|
+
* resolves which row belongs to which plugin through it, since the shell
|
|
47
|
+
* stamps no per-entry hook of its own (see contracts/semantic-attrs-v1.md).
|
|
48
|
+
* @param props - the shell's icon share: square edge and selection state.
|
|
49
|
+
* @returns the decorative terminal glyph.
|
|
50
|
+
*/
|
|
51
|
+
export function SshPanelIcon({ size }: { size: number; active: boolean }): React.ReactElement {
|
|
52
|
+
return (
|
|
53
|
+
<svg
|
|
54
|
+
data-dsh-panel-entry={SSH_PANEL_ID}
|
|
55
|
+
viewBox="0 0 16 16"
|
|
56
|
+
width={size}
|
|
57
|
+
height={size}
|
|
58
|
+
fill="none"
|
|
59
|
+
stroke="currentColor"
|
|
60
|
+
strokeWidth="1.5"
|
|
61
|
+
strokeLinecap="round"
|
|
62
|
+
strokeLinejoin="round"
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
>
|
|
65
|
+
<rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75" />
|
|
66
|
+
<path d="M4.25 5.25l2.75 2.75-2.75 2.75" />
|
|
67
|
+
<path d="M8.5 10.75h3.25" />
|
|
68
|
+
</svg>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The main-slot page. The layout mounts it only while this panel is selected;
|
|
74
|
+
* the wrapper carries the pinned `data-dsh-ssh-view` semantic anchor (L2
|
|
75
|
+
* contract, skins) the takeover container used to own.
|
|
76
|
+
* @param props - the framework main-slot share plus this entry's injected face.
|
|
77
|
+
* @returns the SSH panel page.
|
|
78
|
+
*/
|
|
79
|
+
export function SshPanelPage({ controller, api, terminalFont }: {
|
|
80
|
+
controller: PanelController
|
|
81
|
+
api: SshApi
|
|
82
|
+
terminalFont?: TerminalFontSource
|
|
83
|
+
}): React.ReactElement {
|
|
84
|
+
return (
|
|
85
|
+
<div className={css.view} data-dsh-ssh-view="" data-dsh-plugin="ssh">
|
|
86
|
+
<SshPanel controller={controller} api={api} terminalFont={terminalFont} />
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The injected face both registrations receive. */
|
|
92
|
+
interface PanelFace {
|
|
93
|
+
controller: PanelController
|
|
94
|
+
api: SshApi
|
|
95
|
+
terminalFont?: TerminalFontSource
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Register the SSH panel's sidebar row and center-column page.
|
|
100
|
+
*
|
|
101
|
+
* Both seats are declared by shell plugins this package does not depend on at
|
|
102
|
+
* runtime, so each registration is wrapped in `ctx.slots.inject`: the callback
|
|
103
|
+
* runs only after the owning entry declares the seat, and a shell that never
|
|
104
|
+
* declares it leaves the panel simply absent instead of failing boot.
|
|
105
|
+
* @param ctx - client root context (services: slots).
|
|
106
|
+
* @param controller - the controller the page and the row drive.
|
|
107
|
+
* @param api - the SSH API client the page operates through.
|
|
108
|
+
* @param terminalFont - live terminal-font setting source (issue #577).
|
|
109
|
+
* @returns disposer releasing both registrations.
|
|
110
|
+
*/
|
|
111
|
+
export function registerSshPanel(
|
|
112
|
+
ctx: ClientContext,
|
|
113
|
+
controller: PanelController,
|
|
114
|
+
api: SshApi,
|
|
115
|
+
terminalFont?: TerminalFontSource,
|
|
116
|
+
): () => void {
|
|
117
|
+
const slots = ctx.slots as {
|
|
118
|
+
inject(key: string, callback: () => () => void): () => void
|
|
119
|
+
register(options: Record<string, unknown>, component: unknown): () => void
|
|
120
|
+
}
|
|
121
|
+
const disposers: Array<() => void> = []
|
|
122
|
+
|
|
123
|
+
disposers.push(slots.inject('sidebar.panellist', () => slots.register({
|
|
124
|
+
name: 'sidebar.panellist',
|
|
125
|
+
id: SSH_PANEL_ID,
|
|
126
|
+
order: PANEL_ORDER,
|
|
127
|
+
// The shell resolves this through resolveSlotLabel on every locale change,
|
|
128
|
+
// so the module-level translate reads the active language at call time.
|
|
129
|
+
label: () => tt('entry.label'),
|
|
130
|
+
}, SshPanelIcon)))
|
|
131
|
+
|
|
132
|
+
disposers.push(slots.inject('main', () => slots.register({
|
|
133
|
+
name: 'main',
|
|
134
|
+
key: SSH_PANEL_ID,
|
|
135
|
+
inject: (): PanelFace => ({ controller, api, terminalFont }),
|
|
136
|
+
}, SshPanelPage as never)))
|
|
137
|
+
|
|
138
|
+
return () => {
|
|
139
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The SSH operations panel shell: a header with a close control, a five-tab
|
|
3
|
-
* bar, and the active tab's content.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* bar, and the active tab's content.
|
|
4
|
+
*
|
|
5
|
+
* The active tab, the pending connect request and the live terminal session id
|
|
6
|
+
* live in the controller, not in component state: the layout mounts this page
|
|
7
|
+
* only while the panel is selected, so local state would reset the tab on
|
|
8
|
+
* every panel switch and lose the id the terminal tab needs to reattach its
|
|
9
|
+
* host-side session. Inactive tabs unmount, so each tab fetches its own data
|
|
10
|
+
* on activation.
|
|
7
11
|
*/
|
|
8
|
-
import { useCallback,
|
|
12
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
9
13
|
import type { SshApi } from '../api.ts'
|
|
10
|
-
import type { PanelController } from './controller.ts'
|
|
14
|
+
import type { PanelController, SshTab } from './controller.ts'
|
|
11
15
|
import { tt, type TerminalFontSource } from './helpers.ts'
|
|
12
16
|
import { ClusterTab } from './ClusterTab.tsx'
|
|
13
17
|
import { HostsTab } from './HostsTab.tsx'
|
|
@@ -16,12 +20,9 @@ import { TransferTab } from './TransferTab.tsx'
|
|
|
16
20
|
import { TunnelsTab } from './TunnelsTab.tsx'
|
|
17
21
|
import css from './panel.module.css'
|
|
18
22
|
|
|
19
|
-
/** The panel's tab identifiers. */
|
|
20
|
-
export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
|
|
21
|
-
|
|
22
23
|
/** Panel shell props. */
|
|
23
24
|
export interface SshPanelProps {
|
|
24
|
-
/** The panel state owner (open/close/toggle). */
|
|
25
|
+
/** The panel state owner (open/close/toggle, active tab, terminal session). */
|
|
25
26
|
controller: PanelController
|
|
26
27
|
/** The SSH API client every tab operates through. */
|
|
27
28
|
api: SshApi
|
|
@@ -38,25 +39,12 @@ const TABS: ReadonlyArray<{ id: SshTab; label: () => string }> = [
|
|
|
38
39
|
{ id: 'cluster', label: () => tt('tab.cluster') },
|
|
39
40
|
]
|
|
40
41
|
|
|
41
|
-
/** A pending "connect this host" request handed to the terminal tab. */
|
|
42
|
-
interface ConnectRequest {
|
|
43
|
-
alias: string
|
|
44
|
-
nonce: number
|
|
45
|
-
}
|
|
46
|
-
|
|
47
42
|
/** The tabbed SSH panel. */
|
|
48
43
|
export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
|
|
49
|
-
const panelOpen = useSyncExternalStore(
|
|
44
|
+
const { panelOpen, activeTab, connectRequest, terminalSessionId } = useSyncExternalStore(
|
|
50
45
|
useCallback(listener => controller.subscribe(listener), [controller]),
|
|
51
|
-
useCallback(() => controller.getSnapshot()
|
|
46
|
+
useCallback(() => controller.getSnapshot(), [controller]),
|
|
52
47
|
)
|
|
53
|
-
const [activeTab, setActiveTab] = useState<SshTab>('hosts')
|
|
54
|
-
const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
|
|
55
|
-
|
|
56
|
-
const handleConnect = (alias: string): void => {
|
|
57
|
-
setActiveTab('terminal')
|
|
58
|
-
setConnectRequest(prev => ({ alias, nonce: (prev?.nonce ?? 0) + 1 }))
|
|
59
|
-
}
|
|
60
48
|
|
|
61
49
|
return (
|
|
62
50
|
<div className={css.panel} data-dsh-plugin="ssh">
|
|
@@ -76,14 +64,23 @@ export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
|
|
|
76
64
|
</div>
|
|
77
65
|
<div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
|
|
78
66
|
{TABS.map(tab => (
|
|
79
|
-
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} data-dsh-part="tab" className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
|
|
67
|
+
<button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} data-dsh-part="tab" className={css.tab} onClick={() => { controller.setActiveTab(tab.id) }}>
|
|
80
68
|
{tab.label()}
|
|
81
69
|
</button>
|
|
82
70
|
))}
|
|
83
71
|
</div>
|
|
84
72
|
<div className={css.panelContent}>
|
|
85
|
-
{activeTab === 'hosts' && <HostsTab api={api} onConnect={
|
|
86
|
-
{activeTab === 'terminal' &&
|
|
73
|
+
{activeTab === 'hosts' && <HostsTab api={api} onConnect={alias => { controller.requestConnect(alias) }} />}
|
|
74
|
+
{activeTab === 'terminal' && (
|
|
75
|
+
<TerminalTab
|
|
76
|
+
api={api}
|
|
77
|
+
controller={controller}
|
|
78
|
+
presetAlias={connectRequest?.alias}
|
|
79
|
+
requestId={connectRequest?.nonce}
|
|
80
|
+
sessionId={terminalSessionId}
|
|
81
|
+
terminalFont={terminalFont}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
87
84
|
{activeTab === 'transfer' && <TransferTab api={api} />}
|
|
88
85
|
{activeTab === 'tunnels' && <TunnelsTab api={api} active={panelOpen} />}
|
|
89
86
|
{activeTab === 'cluster' && <ClusterTab api={api} />}
|
|
@@ -10,6 +10,7 @@ import { Terminal, type IDisposable } from '@xterm/xterm'
|
|
|
10
10
|
import { FitAddon } from '@xterm/addon-fit'
|
|
11
11
|
import type { SshApi, TerminalConnection } from '../api.ts'
|
|
12
12
|
import type { SshHostSummary } from '../../protocol.ts'
|
|
13
|
+
import type { PanelController } from './controller.ts'
|
|
13
14
|
import { XTERM_CSS } from './xterm.css.ts'
|
|
14
15
|
import { errorMessage, resolveTerminalFontFamily, tt, type TerminalFontSource } from './helpers.ts'
|
|
15
16
|
import css from './panel.module.css'
|
|
@@ -17,6 +18,10 @@ import css from './panel.module.css'
|
|
|
17
18
|
/** Terminal tab props. */
|
|
18
19
|
export interface TerminalTabProps {
|
|
19
20
|
api: SshApi
|
|
21
|
+
/** The panel controller that owns the live host session id across mounts. */
|
|
22
|
+
controller: PanelController
|
|
23
|
+
/** Host session to reattach to on mount (survives a panel switch). */
|
|
24
|
+
sessionId?: string
|
|
20
25
|
/** Alias preselected by a "connect" action from the hosts tab. */
|
|
21
26
|
presetAlias?: string
|
|
22
27
|
/** Monotonic id of the connect request (re-applies presetAlias). */
|
|
@@ -63,7 +68,7 @@ const NO_FONT_SOURCE: TerminalFontSource = {
|
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
/** The xterm terminal view. */
|
|
66
|
-
export function TerminalTab({ api, presetAlias, requestId, terminalFont }: TerminalTabProps) {
|
|
71
|
+
export function TerminalTab({ api, controller, sessionId, presetAlias, requestId, terminalFont }: TerminalTabProps) {
|
|
67
72
|
const [hosts, setHosts] = useState<SshHostSummary[]>([])
|
|
68
73
|
const [alias, setAlias] = useState(presetAlias ?? '')
|
|
69
74
|
const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
|
|
@@ -111,7 +116,12 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
111
116
|
if (presetAlias !== undefined) setAlias(presetAlias)
|
|
112
117
|
}, [presetAlias, requestId])
|
|
113
118
|
|
|
114
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Tear the view down.
|
|
121
|
+
* @param leave - `detach` leaves the host session alive for a reattach
|
|
122
|
+
* (panel switch, unmount); `close` ends it (the disconnect control).
|
|
123
|
+
*/
|
|
124
|
+
const teardown = (leave: 'detach' | 'close'): void => {
|
|
115
125
|
setAuthPrompt(undefined)
|
|
116
126
|
setAuthInputs([])
|
|
117
127
|
const connection = connRef.current
|
|
@@ -121,7 +131,8 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
121
131
|
connection.onOutput = undefined
|
|
122
132
|
connection.onExit = undefined
|
|
123
133
|
connection.onAuthPrompt = undefined
|
|
124
|
-
connection.
|
|
134
|
+
if (leave === 'detach') connection.detach()
|
|
135
|
+
else connection.close()
|
|
125
136
|
}
|
|
126
137
|
// Release the xterm input subscription explicitly and dispose the
|
|
127
138
|
// terminal so no listener (or the terminal Renderer) survives a
|
|
@@ -133,8 +144,9 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
133
144
|
fitRef.current = null
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
// Unmount cleanup (never touches state on an unmounting component).
|
|
137
|
-
|
|
147
|
+
// Unmount cleanup (never touches state on an unmounting component). The
|
|
148
|
+
// host keeps the shell alive, so returning to this tab reattaches to it.
|
|
149
|
+
useEffect(() => () => { teardown('detach') }, [])
|
|
138
150
|
|
|
139
151
|
// Keep the terminal fitted to its container. A window resize is only one
|
|
140
152
|
// trigger: the status banner appearing after connect, panel resizes, and
|
|
@@ -170,12 +182,15 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
170
182
|
}
|
|
171
183
|
}, [])
|
|
172
184
|
|
|
173
|
-
|
|
174
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Build the xterm view and bind one connection to it.
|
|
187
|
+
* @param open - opens the transport once the terminal has a size.
|
|
188
|
+
*/
|
|
189
|
+
const startSession = (open: (cols: number, rows: number) => TerminalConnection): void => {
|
|
175
190
|
const container = containerRef.current
|
|
176
|
-
if (
|
|
191
|
+
if (container === null) return
|
|
177
192
|
if (status.kind === 'connecting' || status.kind === 'connected') return
|
|
178
|
-
teardown()
|
|
193
|
+
teardown('detach')
|
|
179
194
|
setStatus({ kind: 'connecting' })
|
|
180
195
|
const term = new Terminal({
|
|
181
196
|
convertEol: false,
|
|
@@ -188,20 +203,25 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
188
203
|
term.loadAddon(fit)
|
|
189
204
|
term.open(container)
|
|
190
205
|
fit.fit()
|
|
191
|
-
const connection =
|
|
206
|
+
const connection = open(term.cols, term.rows)
|
|
192
207
|
termRef.current = term
|
|
193
208
|
fitRef.current = fit
|
|
194
209
|
connRef.current = connection
|
|
195
210
|
let settled = false
|
|
211
|
+
let connectedAlias = ''
|
|
196
212
|
dataSubRef.current = term.onData(data => { connection.send(data) })
|
|
197
213
|
connection.onAuthPrompt = (name, instructions, prompts) => {
|
|
198
214
|
setAuthPrompt({ name, instructions, prompts })
|
|
199
215
|
setAuthInputs(prompts.map(() => ''))
|
|
200
216
|
}
|
|
201
|
-
connection.onReady = () => {
|
|
217
|
+
connection.onReady = (id, readyAlias) => {
|
|
218
|
+
// Remember the host session so a later mount of this tab reattaches to
|
|
219
|
+
// the same shell instead of opening a second one.
|
|
220
|
+
connectedAlias = readyAlias
|
|
221
|
+
controller.setTerminalSession(id)
|
|
202
222
|
setAuthPrompt(undefined)
|
|
203
223
|
setAuthInputs([])
|
|
204
|
-
setStatus({ kind: 'connected', alias:
|
|
224
|
+
setStatus({ kind: 'connected', alias: readyAlias })
|
|
205
225
|
}
|
|
206
226
|
connection.onOutput = data => { term.write(data) }
|
|
207
227
|
connection.onExit = (_code, error) => {
|
|
@@ -213,13 +233,33 @@ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: Termi
|
|
|
213
233
|
dataSubRef.current = null
|
|
214
234
|
term.options.disableStdin = true
|
|
215
235
|
connRef.current = null
|
|
236
|
+
// The session is gone: drop the id so the next mount starts fresh.
|
|
237
|
+
controller.clearTerminalSession()
|
|
216
238
|
// Keep the last output visible; input is now disabled.
|
|
217
|
-
setStatus({ kind: 'exited', alias:
|
|
239
|
+
setStatus({ kind: 'exited', alias: connectedAlias !== '' ? connectedAlias : alias, detail: error })
|
|
218
240
|
}
|
|
219
241
|
}
|
|
220
242
|
|
|
243
|
+
const connect = (): void => {
|
|
244
|
+
const target = alias
|
|
245
|
+
if (target === '') return
|
|
246
|
+
startSession((cols, rows) => api.openTerminal(target, cols, rows))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Reattach to a host session that outlived this view (panel switch or a
|
|
250
|
+
// remount of the page). The host replays its scrollback before ready, so
|
|
251
|
+
// the terminal comes back with its history instead of a blank screen.
|
|
252
|
+
const reattachedRef = useRef<string | undefined>(undefined)
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (sessionId === undefined || connRef.current !== null) return
|
|
255
|
+
if (reattachedRef.current === sessionId) return
|
|
256
|
+
reattachedRef.current = sessionId
|
|
257
|
+
startSession((cols, rows) => api.attachTerminal(sessionId, cols, rows))
|
|
258
|
+
}, [sessionId])
|
|
259
|
+
|
|
221
260
|
const disconnect = (): void => {
|
|
222
|
-
teardown()
|
|
261
|
+
teardown('close')
|
|
262
|
+
controller.clearTerminalSession()
|
|
223
263
|
setStatus({ kind: 'idle' })
|
|
224
264
|
}
|
|
225
265
|
|