@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.
- package/lib/client.js +303 -459
- package/lib/client.js.map +1 -1
- package/lib/index.js +707 -466
- package/lib/types/client/api.d.ts +27 -4
- 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 +52 -22
- package/src/client/index.ts +30 -8
- 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/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. */
|
|
@@ -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.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.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()
|
|
@@ -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
|
|
|
@@ -1,23 +1,77 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SSH panel controller: the single owner of the panel's
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
}
|