@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.
Files changed (37) hide show
  1. package/lib/client.js +371 -461
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +707 -466
  4. package/lib/types/client/api.d.ts +55 -4
  5. package/lib/types/client/locales.d.ts +1 -0
  6. package/lib/types/client/native-panel.d.ts +70 -0
  7. package/lib/types/client/panel/SshPanel.d.ts +1 -3
  8. package/lib/types/client/panel/TerminalTab.d.ts +6 -1
  9. package/lib/types/client/panel/controller.d.ts +71 -4
  10. package/lib/types/engine/terminal-sessions.d.ts +67 -0
  11. package/lib/types/mount-once.d.ts +38 -7
  12. package/lib/types/protocol.d.ts +12 -1
  13. package/lib/types/routes.d.ts +2 -0
  14. package/package.json +19 -16
  15. package/src/client/api.ts +137 -24
  16. package/src/client/index.ts +31 -9
  17. package/src/client/locales.ts +2 -0
  18. package/src/client/native-panel.tsx +141 -0
  19. package/src/client/panel/SshPanel.tsx +25 -28
  20. package/src/client/panel/TerminalTab.tsx +54 -14
  21. package/src/client/panel/controller.ts +126 -7
  22. package/src/client/panel/panel.module.css +8 -128
  23. package/src/engine/terminal-sessions.ts +280 -0
  24. package/src/index.ts +4 -1
  25. package/src/mount-once.ts +107 -13
  26. package/src/protocol.ts +6 -1
  27. package/src/routes.ts +26 -107
  28. package/lib/types/client/body-mutations.d.ts +0 -45
  29. package/lib/types/client/mount.d.ts +0 -27
  30. package/lib/types/client/panel-mount-core.d.ts +0 -56
  31. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  32. package/lib/types/client/sidebar-entry.d.ts +0 -26
  33. package/src/client/body-mutations.ts +0 -133
  34. package/src/client/mount.tsx +0 -47
  35. package/src/client/panel-mount-core.ts +0 -155
  36. package/src/client/sidebar-entry-core.ts +0 -237
  37. 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
- /** Close the socket and the remote session. */
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
- /** Open a WebSocket terminal session. */
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
- const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'
350
- const url = scheme + '://' + window.location.host + SSH_API.terminal + query({ alias, cols, rows })
351
- const socket = new WebSocket(url)
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
- if (socket.readyState === WebSocket.OPEN) {
359
- socket.send(JSON.stringify({ type: 'input', data } satisfies TerminalClientFrame))
360
- }
361
- },
362
- resize: (cols, rows) => {
363
- if (socket.readyState === WebSocket.OPEN) {
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
  }
@@ -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 { mountPanel } from './mount.tsx'
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
- const controller = new PanelController()
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(mountSidebarEntry(controller, ctx.locale))
111
- disposers.push(mountPanel(controller, api, terminalFont, ctx.locale))
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
- // DOM failures degrade the panel, never the GUI.
114
- console.warn('[dsh-ssh] mount failed:', error)
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()
@@ -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. 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.
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, useState, useSyncExternalStore } from 'react'
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().panelOpen, [controller]),
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={handleConnect} />}
86
- {activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} terminalFont={terminalFont} />}
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
- const teardown = (): void => {
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.close()
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
- useEffect(() => () => { teardown() }, [])
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
- const connect = (): void => {
174
- const target = alias
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 (target === '' || container === null) return
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 = api.openTerminal(target, term.cols, term.rows)
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: target })
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: target, detail: error })
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