@linxin666/dsh-ssh 0.4.1 → 0.4.3

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 (35) hide show
  1. package/lib/client.js +303 -459
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +707 -466
  4. package/lib/types/client/api.d.ts +27 -4
  5. package/lib/types/client/native-panel.d.ts +70 -0
  6. package/lib/types/client/panel/SshPanel.d.ts +1 -3
  7. package/lib/types/client/panel/TerminalTab.d.ts +6 -1
  8. package/lib/types/client/panel/controller.d.ts +71 -4
  9. package/lib/types/engine/terminal-sessions.d.ts +67 -0
  10. package/lib/types/mount-once.d.ts +38 -7
  11. package/lib/types/protocol.d.ts +12 -1
  12. package/lib/types/routes.d.ts +2 -0
  13. package/package.json +19 -16
  14. package/src/client/api.ts +52 -22
  15. package/src/client/index.ts +30 -8
  16. package/src/client/native-panel.tsx +141 -0
  17. package/src/client/panel/SshPanel.tsx +25 -28
  18. package/src/client/panel/TerminalTab.tsx +54 -14
  19. package/src/client/panel/controller.ts +126 -7
  20. package/src/client/panel/panel.module.css +8 -128
  21. package/src/engine/terminal-sessions.ts +280 -0
  22. package/src/index.ts +4 -1
  23. package/src/mount-once.ts +107 -13
  24. package/src/protocol.ts +6 -1
  25. package/src/routes.ts +26 -107
  26. package/lib/types/client/body-mutations.d.ts +0 -45
  27. package/lib/types/client/mount.d.ts +0 -27
  28. package/lib/types/client/panel-mount-core.d.ts +0 -56
  29. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  30. package/lib/types/client/sidebar-entry.d.ts +0 -26
  31. package/src/client/body-mutations.ts +0 -133
  32. package/src/client/mount.tsx +0 -47
  33. package/src/client/panel-mount-core.ts +0 -155
  34. package/src/client/sidebar-entry-core.ts +0 -237
  35. package/src/client/sidebar-entry.ts +0 -51
@@ -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. */
@@ -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.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.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()
@@ -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
 
@@ -1,23 +1,77 @@
1
1
  /**
2
- * SSH panel controller: the single owner of the panel's open/closed state.
2
+ * SSH panel controller: the single owner of the panel's view state.
3
3
  *
4
4
  * Framework-free (structural runtime faces, task-board core/controller.ts
5
- * style) so the DOM mounts and the React panel share one tiny subscription
5
+ * style) so the sidebar row and the React panel share one tiny subscription
6
6
  * surface. The state lives only for the browser session (no persistence).
7
+ *
8
+ * The active tab, the hosts-tab connect request and the live terminal session
9
+ * id live here rather than inside the panel components: the layout mounts a
10
+ * keyed page only while its panel is selected, so component-local state would
11
+ * reset the tab on every panel switch and lose the id a reattach needs.
7
12
  */
8
13
 
14
+ /** The panel's tab identifiers. */
15
+ export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster'
16
+
17
+ /** A "connect" action from the hosts tab, handed to the terminal tab. */
18
+ export interface ConnectRequest {
19
+ alias: string
20
+ /** Monotonic id so a repeated connect for the same alias still re-applies. */
21
+ nonce: number
22
+ }
23
+
24
+ /** Stable id shared by the sidebar panel row and the main-slot page. */
25
+ export const SSH_PANEL_ID = 'ssh'
26
+
9
27
  /** Immutable controller snapshot for UI subscriptions. */
10
28
  export interface PanelControllerSnapshot {
29
+ /** Whether the center column shows this panel. */
11
30
  panelOpen: boolean
31
+ /** The tab the panel is showing. */
32
+ activeTab: SshTab
33
+ /** Pending hosts-tab connect request, or null. */
34
+ connectRequest: ConnectRequest | null
35
+ /** Host-side terminal session the terminal tab should reattach to, if any. */
36
+ terminalSessionId: string | undefined
12
37
  }
13
38
 
14
- /** The panel state owner the sidebar entry toggles and the view renders from. */
39
+ /**
40
+ * The layout's panel-navigation face: selecting the panel is the layout's
41
+ * business, and this is the one call that asks for it (null returns the column
42
+ * to the conversation). Optional, so a shell-less composition keeps the pure
43
+ * state transitions testable.
44
+ */
45
+ export interface PanelNavigationFace {
46
+ /** Select this panel, or null to hand the column back to the conversation. */
47
+ select(panelId: string | null): void
48
+ }
49
+
50
+ /** Controller dependencies. */
51
+ export interface PanelControllerDeps {
52
+ /** Layout panel selection; absent keeps the controller state-only. */
53
+ panel?: PanelNavigationFace
54
+ }
55
+
56
+ /** The panel state owner the sidebar row and the view both read. */
15
57
  export class PanelController {
16
58
  private panelOpen = false
59
+ private activeTab: SshTab = 'hosts'
60
+ private connectRequest: ConnectRequest | null = null
61
+ private terminalSessionId: string | undefined
17
62
  private listeners = new Set<() => void>()
63
+ /** Cached so getSnapshot is referentially stable between changes. */
64
+ private snapshot: PanelControllerSnapshot = {
65
+ panelOpen: false,
66
+ activeTab: 'hosts',
67
+ connectRequest: null,
68
+ terminalSessionId: undefined,
69
+ }
70
+
71
+ constructor(private readonly deps: PanelControllerDeps = {}) {}
18
72
 
19
73
  getSnapshot(): PanelControllerSnapshot {
20
- return { panelOpen: this.panelOpen }
74
+ return this.snapshot
21
75
  }
22
76
 
23
77
  subscribe(fn: () => void): () => void {
@@ -25,16 +79,25 @@ export class PanelController {
25
79
  return () => { this.listeners.delete(fn) }
26
80
  }
27
81
 
82
+ /**
83
+ * Show the panel. The layout owns which panel the column renders, so the
84
+ * state flip and the selection travel together; the selection is requested
85
+ * after the snapshot flips, so a subscriber never observes "open" while the
86
+ * shell still shows the conversation.
87
+ */
28
88
  open(): void {
29
89
  if (this.panelOpen) return
30
90
  this.panelOpen = true
31
- this.notify()
91
+ this.commit()
92
+ this.selectPanel(SSH_PANEL_ID)
32
93
  }
33
94
 
95
+ /** Return the column to the conversation, asking the layout explicitly. */
34
96
  close(): void {
35
97
  if (!this.panelOpen) return
36
98
  this.panelOpen = false
37
- this.notify()
99
+ this.commit()
100
+ this.selectPanel(null)
38
101
  }
39
102
 
40
103
  toggle(): void {
@@ -42,7 +105,63 @@ export class PanelController {
42
105
  else this.open()
43
106
  }
44
107
 
45
- private notify(): void {
108
+ /**
109
+ * Reflect a selection made outside this controller (the user clicked another
110
+ * sidebar row, or the layout dropped the panel id).
111
+ * @param panelId - the layout's current panel id, or null for the conversation.
112
+ */
113
+ syncPanelSelection(panelId: string | null): void {
114
+ const open = panelId === SSH_PANEL_ID
115
+ if (open === this.panelOpen) return
116
+ this.panelOpen = open
117
+ this.commit()
118
+ }
119
+
120
+ /** Switch tabs, keeping every other piece of view state. */
121
+ setActiveTab(tab: SshTab): void {
122
+ if (this.activeTab === tab) return
123
+ this.activeTab = tab
124
+ this.commit()
125
+ }
126
+
127
+ /** Hand the terminal tab a host to connect to (hosts-tab "connect" action). */
128
+ requestConnect(alias: string): void {
129
+ this.connectRequest = { alias, nonce: Date.now() }
130
+ this.activeTab = 'terminal'
131
+ this.commit()
132
+ }
133
+
134
+ /** Record the host-side session so a later page mount can reattach. */
135
+ setTerminalSession(sessionId: string): void {
136
+ if (this.terminalSessionId === sessionId) return
137
+ this.terminalSessionId = sessionId
138
+ this.commit()
139
+ }
140
+
141
+ /** Forget the session (it exited, or the user disconnected). */
142
+ clearTerminalSession(): void {
143
+ if (this.terminalSessionId === undefined) return
144
+ this.terminalSessionId = undefined
145
+ this.commit()
146
+ }
147
+
148
+ private commit(): void {
149
+ this.snapshot = {
150
+ panelOpen: this.panelOpen,
151
+ activeTab: this.activeTab,
152
+ connectRequest: this.connectRequest,
153
+ terminalSessionId: this.terminalSessionId,
154
+ }
46
155
  for (const fn of [...this.listeners]) fn()
47
156
  }
157
+
158
+ /** Ask the layout to select a panel; a shell without the face is a no-op. */
159
+ private selectPanel(panelId: string | null): void {
160
+ try {
161
+ this.deps.panel?.select(panelId)
162
+ } catch {
163
+ // The layout service throws by contract before its root entry mounts;
164
+ // the state flip above already stands and the next selection retries.
165
+ }
166
+ }
48
167
  }