@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
package/src/routes.ts CHANGED
@@ -15,10 +15,11 @@ import { tmpdir } from 'node:os'
15
15
  import { randomBytes } from 'node:crypto'
16
16
  import { WebSocket, WebSocketServer } from 'ws'
17
17
  import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
18
- import type { SshEngine, ShellSession, KeyboardInteractiveHandler } from './engine.ts'
18
+ import type { SshEngine } from './engine.ts'
19
+ import { TerminalSessionRegistry } from './engine/terminal-sessions.ts'
19
20
  import { readJsonBody, writeJson } from './http.ts'
20
21
  import { isLoopbackRequest } from './loopback.ts'
21
- import { SSH_API, type HostPayload, type TerminalClientFrame, type TerminalServerFrame } from './protocol.ts'
22
+ import { SSH_API, type HostPayload, type TerminalServerFrame } from './protocol.ts'
22
23
  import type { HostStore } from './store.ts'
23
24
 
24
25
  /** Cap on declared upload bodies (staged to disk before SFTP). */
@@ -31,12 +32,6 @@ const MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024
31
32
  */
32
33
  const terminalWss = new WebSocketServer({ noServer: true })
33
34
 
34
- /** Pause the shell when the socket's send buffer exceeds this… */
35
- const BACKPRESSURE_HIGH_WATER = 1024 * 1024
36
-
37
- /** …and resume once it drains below this. */
38
- const BACKPRESSURE_LOW_WATER = 512 * 1024
39
-
40
35
  /** URL query helper (first value, decoded). */
41
36
  function queryParam(url: URL, name: string): string | undefined {
42
37
  const value = url.searchParams.get(name)
@@ -60,7 +55,7 @@ maxUploadBytes?: number
60
55
  * @param deps - store, engine, staging dir.
61
56
  * @returns routes and the upgrade route.
62
57
  */
63
- export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute } {
58
+ export function makeRoutes(deps: SshRoutesDeps): { routes: WebRoute[]; upgrade: WebUpgradeRoute; terminalSessions: TerminalSessionRegistry } {
64
59
  const { store, engine } = deps
65
60
  const staging = deps.stagingDir ?? join(tmpdir(), 'dsh-ssh-uploads')
66
61
  const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
@@ -452,6 +447,12 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
452
447
  ]
453
448
 
454
449
  // ---------------------------------------------- terminal (upgrade)
450
+ // The shell outlives the view that opened it: a session is created once and
451
+ // any later socket attaches by id, so a panel switch or a dropped socket
452
+ // does not tear the remote shell down (engine/terminal-sessions.ts).
453
+ const terminalSessions = new TerminalSessionRegistry({
454
+ openShell: (alias, size, onKeyboardInteractive) => engine.openShell(alias, size, onKeyboardInteractive),
455
+ })
455
456
  const upgrade: WebUpgradeRoute = {
456
457
  path: SSH_API.terminal,
457
458
  handler: (req, socket, head) => {
@@ -462,114 +463,32 @@ const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES
462
463
  }
463
464
  const url = new URL(req.url ?? '/', 'http://localhost')
464
465
  const alias = queryParam(url, 'alias')
465
- if (alias === undefined) {
466
+ const sessionId = queryParam(url, 'session')
467
+ if (alias === undefined && sessionId === undefined) {
466
468
  socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
467
469
  socket.destroy()
468
470
  return
469
471
  }
470
472
  const cols = Number.parseInt(queryParam(url, 'cols') ?? '80', 10)
471
473
  const rows = Number.parseInt(queryParam(url, 'rows') ?? '24', 10)
474
+ const size = {
475
+ cols: Number.isFinite(cols) ? cols : 80,
476
+ rows: Number.isFinite(rows) ? rows : 24,
477
+ }
472
478
  terminalWss.handleUpgrade(req, socket, head, (ws) => {
473
- let session: ShellSession | undefined
474
- let closed = false
475
- let paused = false
476
- let pendingAuthFinish: ((responses: string[]) => void) | undefined
477
- // Resume the shell once the socket's send buffer drains below the
478
- // low-water mark (transport backpressure).
479
- const resume = (): void => {
480
- if (paused && ws.bufferedAmount < BACKPRESSURE_LOW_WATER) {
481
- paused = false
482
- session?.resume()
483
- }
484
- }
485
- const sendFrame = (frame: TerminalServerFrame): void => {
486
- if (closed || ws.readyState !== WebSocket.OPEN) return
487
- ws.send(JSON.stringify(frame), resume)
488
- if (!paused && ws.bufferedAmount > BACKPRESSURE_HIGH_WATER) {
489
- paused = true
490
- session?.pause()
491
- }
492
- }
493
- const closeSession = (): void => {
494
- if (pendingAuthFinish !== undefined) {
495
- const fn = pendingAuthFinish
496
- pendingAuthFinish = undefined
497
- try { fn([]) } catch { /* ignore */ }
498
- }
499
- const opened = session
500
- session = undefined
501
- if (opened !== undefined) opened.close()
502
- }
503
-
504
- const onKeyboardInteractive: KeyboardInteractiveHandler = (name, instructions, _lang, prompts, finish) => {
505
- const entry = store.find(alias)
506
- // Auto-answer password if configured and prompt is a password request
507
- if (entry?.auth.kind === 'password' && entry.auth.password !== undefined && prompts.length > 0 && prompts.every(p => /password/i.test(p.prompt))) {
508
- const password = entry.auth.password
509
- finish(prompts.map(() => password))
510
- return
511
- }
512
- // Interactive 2FA prompt sent to the client terminal
513
- pendingAuthFinish = finish
514
- sendFrame({
515
- type: 'auth_prompt',
516
- name,
517
- instructions,
518
- prompts: prompts.map(p => ({ prompt: p.prompt, echo: p.echo })),
519
- })
520
- }
521
-
522
- engine.openShell(alias, {
523
- cols: Number.isFinite(cols) ? cols : 80,
524
- rows: Number.isFinite(rows) ? rows : 24,
525
- }, onKeyboardInteractive).then((opened) => {
526
- if (ws.readyState !== WebSocket.OPEN) {
527
- opened.close()
528
- return
529
- }
530
- session = opened
531
- sendFrame({ type: 'ready', alias })
532
- opened.onData = (data) => sendFrame({ type: 'output', data: data.toString('utf8') })
533
- opened.onExit = (code, error) => {
534
- sendFrame({ type: 'exit', code, error })
535
- closed = true
536
- try { ws.close(1000) } catch { /* already closed */ }
537
- }
538
- }).catch((error) => {
539
- sendFrame({ type: 'exit', code: null, error: error instanceof Error ? error.message : String(error) })
540
- closed = true
479
+ if (sessionId !== undefined) {
480
+ if (terminalSessions.attach(ws, sessionId, size)) return
481
+ // The session was reaped or the host restarted: tell the view so it
482
+ // can fall back to a fresh connect instead of sitting blank.
483
+ const expired: TerminalServerFrame = { type: 'exit', code: null, error: 'terminal session expired' }
484
+ try { ws.send(JSON.stringify(expired)) } catch { /* socket raced away */ }
541
485
  try { ws.close(1000) } catch { /* already closed */ }
542
- })
543
- ws.on('message', (data) => {
544
- let frame: TerminalClientFrame
545
- try {
546
- frame = JSON.parse(String(data)) as TerminalClientFrame
547
- } catch {
548
- return
549
- }
550
- if (frame.type === 'input') {
551
- session?.send(frame.data)
552
- } else if (frame.type === 'resize') {
553
- session?.resize(Math.max(2, frame.cols), Math.max(1, frame.rows))
554
- } else if (frame.type === 'auth_response') {
555
- if (pendingAuthFinish !== undefined) {
556
- const fn = pendingAuthFinish
557
- pendingAuthFinish = undefined
558
- fn(frame.responses)
559
- }
560
- }
561
- })
562
- ws.on('close', () => {
563
- closed = true
564
- closeSession()
565
- })
566
- ws.on('error', () => {
567
- closed = true
568
- closeSession()
569
- })
486
+ return
487
+ }
488
+ void terminalSessions.open(ws, alias as string, size)
570
489
  })
571
490
  },
572
491
  }
573
492
 
574
- return { routes, upgrade }
493
+ return { routes, upgrade, terminalSessions }
575
494
  }
@@ -1,45 +0,0 @@
1
- /**
2
- * One `document.body` MutationObserver per page, shared by every family
3
- * plugin bundle.
4
- *
5
- * Family plugins that inject at the DOM level must notice when the shell
6
- * re-renders around their injected node: the sidebar entry rows
7
- * (sidebar-entry-core), the center-column panel containers
8
- * (panel-mount-core), and the aggregate shell's column shims. Each consumer
9
- * used to install its OWN `MutationObserver` on `document.body` with
10
- * `{ childList: true, subtree: true }`, so a page carrying N family plugins
11
- * paid N native observers and N callback invocations for EVERY mutation batch
12
- * the app produced — chat token streaming alone produces many per second, and
13
- * the count grows with the number of installed plugins.
14
- *
15
- * This module keeps exactly ONE body observer per page. The registry lives on
16
- * `globalThis` under a `Symbol.for` key, so the per-package generated copies
17
- * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
18
- * reach the same hub at runtime instead of one hub per module instance.
19
- * Record subscribers receive the mutations accumulated since the last flush.
20
- * Consumers that only re-check their DOM use subscribeBodyInvalidations:
21
- * when all subscribers use that path, the hub retains no mutation records
22
- * (or detached subtrees) while a background page's animation frames pause.
23
- * Both paths run at most once per frame. The last subscriber disconnects the
24
- * observer, cancels the pending frame and releases its records.
25
- *
26
- * Failure policy: without a DOM or `MutationObserver` the subscription is a
27
- * no-op disposer (the same silence the per-consumer observers had), and when
28
- * `requestAnimationFrame` is unavailable the flush runs synchronously so a
29
- * subscriber is never silently dropped. A throwing subscriber cannot stop the
30
- * others.
31
- */
32
- /**
33
- * Subscribe to a coalesced DOM re-check without retaining mutation records.
34
- * The marked wrapper also works with an older hub, which delivers records
35
- * that it simply ignores until a page reload picks up the updated hub.
36
- */
37
- export declare function subscribeBodyInvalidations(subscriber: () => void): () => void;
38
- /**
39
- * Subscribe to body-level childList mutations.
40
- * @param subscriber - called at most once per animation frame with the records
41
- * collected since the previous flush; must be safe to run repeatedly.
42
- * @returns the disposer removing this subscriber (and the observer when it was
43
- * the last one).
44
- */
45
- export declare function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void;
@@ -1,27 +0,0 @@
1
- /**
2
- * Panel view mounting (dsh-ssh wrapper).
3
- *
4
- * The single-occupant takeover lifecycle — container injection into the
5
- * center column, sibling eviction, remount resilience, sidebar click-out —
6
- * lives exactly once in shared/client/panel-mount-core.ts (synced copy); this
7
- * wrapper supplies the SSH panel tree, view dataset key, CSS module, and the
8
- * html attribute names. Those names are pinned by panel.module.css, skins,
9
- * and the semantic attributes contract.
10
- */
11
- import type { SshApi } from './api.ts';
12
- import type { PanelController } from './panel/controller.ts';
13
- import type { TerminalFontSource } from './panel/helpers.ts';
14
- import type { LocaleRefreshSource } from './sidebar-entry.ts';
15
- /** The injected panel container (kept in the DOM, hidden when inactive). */
16
- export declare const PANEL_VIEW_SELECTOR = "[data-dsh-ssh-view]";
17
- /**
18
- * Mount the panel React tree into the center column and bind its visibility
19
- * to the controller's panelOpen state.
20
- * @param controller - the panel controller driving the view.
21
- * @param api - the SSH API client the tabs operate through.
22
- * @param terminalFont - live terminal-font setting source (issue #577).
23
- * @param locale - locale-change source; when given, re-renders an open panel
24
- * on a Language switch.
25
- * @returns disposer unmounting the tree and restoring the column.
26
- */
27
- export declare function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource, locale?: LocaleRefreshSource): () => void;
@@ -1,56 +0,0 @@
1
- /**
2
- * Center-column panel takeover lifecycle.
3
- *
4
- * The `conversation` slot is single-occupant (ui-conversation) and external
5
- * plugins cannot declare slots, so a family panel takes over the center
6
- * column at the DOM level: a container is appended inside the center column
7
- * (`[class*="centerCol"]`, the 0.1.0-rc.6+ AppFrame layout; previously
8
- * `[data-pane="conversation"]` on older shells — the mount selector keeps
9
- * both, ssh #243 / task-board #107) as an extra trailing child React never
10
- * manages, and a stylesheet rule hides the conversation content while the
11
- * panel is active. Toggling is a data attribute on <html> — no React
12
- * involvement, so the conversation subtree underneath stays mounted and
13
- * stateful.
14
- *
15
- * Consuming plugins keep a thin wrapper that supplies the panel tree,
16
- * container attribute names, and stylesheet class; those names are pinned by
17
- * each package's CSS, skins, and the semantic-attributes contract. The
18
- * sidebar row toggling the panel shares its core the same way
19
- * (shared/client/sidebar-entry-core.ts, synced copy).
20
- */
21
- import { type Root } from 'react-dom/client';
22
- /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
23
- export interface CenterPanelMountOptions {
24
- /** Render the panel tree (first open, remount while open, locale refresh). */
25
- render: (root: Root) => void;
26
- /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
27
- viewDatasetKey: string;
28
- /** value of the container's L2 `data-dsh-plugin` semantic attribute. */
29
- pluginName: string;
30
- /** stylesheet class applied to the injected container. */
31
- viewClassName: string;
32
- /** <html> attribute set while this panel is active. */
33
- activeAttribute: string;
34
- /** the sibling panel's active attribute, removed from <html> when this panel opens. */
35
- siblingActiveAttribute: string;
36
- /** detail value this panel broadcasts on the cross-plugin activation event. */
37
- panelName: string;
38
- /** sibling detail value whose activation closes this panel. */
39
- siblingPanelName: string;
40
- /** open flag of the owning controller. */
41
- isOpen: () => boolean;
42
- /** close the panel, handing the center column back to the conversation. */
43
- close: () => void;
44
- /** subscribe to the owning controller's open-state changes; returns an unsubscriber. */
45
- subscribe: (listener: () => void) => () => void;
46
- /** locale-change source; when given, re-renders an open panel on a Language switch. */
47
- locale?: {
48
- subscribe(listener: () => void): () => void;
49
- };
50
- }
51
- /**
52
- * Mount a family panel into the center column and bind its visibility to the
53
- * owning controller's open state.
54
- * @returns disposer unmounting the tree and restoring the column.
55
- */
56
- export declare function mountCenterPanel(options: CenterPanelMountOptions): () => void;
@@ -1,55 +0,0 @@
1
- /** Per-package configuration for one sidebar entry row. */
2
- export interface SidebarEntryOptions {
3
- /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
4
- rowAttribute: string;
5
- /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
6
- rowSelector: string;
7
- /**
8
- * L2 semantic-attribute plugin id (issue #506, enum table:
9
- * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
10
- * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
11
- * leaves the row without semantic attributes.
12
- */
13
- plugin?: string;
14
- /** Inline icon markup (matches the shell's 16px nav-icon look). */
15
- icon: string;
16
- /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
17
- css: Record<string, string>;
18
- /** Localized row label (aria-label + visible text). */
19
- label(): string;
20
- /** Optional localized tooltip (title attribute). */
21
- tooltip?(): string;
22
- /**
23
- * Optional locale-change subscription: re-applies label / aria-label /
24
- * tooltip whenever the active locale changes. Plain-DOM rows would
25
- * otherwise keep the label captured at mount; pass the SDK locale runtime
26
- * subscription (ctx.locale.subscribe) so the row follows the language
27
- * switch without a reload.
28
- */
29
- refresh?: {
30
- subscribe(listener: () => void): () => void;
31
- };
32
- /** Click action (open/toggle the owning panel). */
33
- onToggle(): void;
34
- /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
35
- position: 'before' | 'after';
36
- /**
37
- * Selectors of the sibling plugin entry rows this package orders against
38
- * (its own row included — the placement guard excludes a row that is
39
- * already inside the root). Each package passes the same list it used
40
- * before the consolidation so the rendered order stays stable.
41
- */
42
- familySelectors: readonly string[];
43
- /** Optional active-state bridge; highlights the row while the panel is open. */
44
- active?: {
45
- subscribe(listener: () => void): () => void;
46
- isOpen(): boolean;
47
- };
48
- }
49
- /**
50
- * Mount the sidebar entry, waiting for the shell to render and self-healing
51
- * on later React re-renders.
52
- * @param options - the row's attribute/icon/copy/action/ordering configuration.
53
- * @returns disposer removing the entry and its observers.
54
- */
55
- export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
@@ -1,26 +0,0 @@
1
- /**
2
- * Sidebar entry injection — package-specific wiring over the shared core.
3
- *
4
- * The DOM injection / self-healing / idempotency logic lives exactly once in
5
- * shared/client/sidebar-entry-core.ts (synced copy); this wrapper supplies the
6
- * ssh icon, copy, CSS module, and the panel toggle. The row is plain DOM (no
7
- * React tree) so it can never disturb the shell's reconciliation; the panel
8
- * view it toggles is a separate React root mounted in the center column
9
- * (see mount.tsx).
10
- */
11
- import type { PanelController } from './panel/controller.ts';
12
- /** Stable data attribute identifying the injected entry row. */
13
- export declare const ENTRY_SELECTOR = "[data-dsh-ssh-entry]";
14
- /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
15
- export interface LocaleRefreshSource {
16
- subscribe(listener: () => void): () => void;
17
- }
18
- /**
19
- * Mount the sidebar entry, waiting for the shell to render and self-healing
20
- * on later React re-renders.
21
- * @param controller - the panel controller the entry toggles.
22
- * @param locale - locale-change source; when given, re-applies the label on
23
- * a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
24
- * @returns disposer removing the entry and its observers.
25
- */
26
- export declare function mountSidebarEntry(controller: PanelController, locale?: LocaleRefreshSource): () => void;
@@ -1,133 +0,0 @@
1
- // Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * One `document.body` MutationObserver per page, shared by every family
4
- * plugin bundle.
5
- *
6
- * Family plugins that inject at the DOM level must notice when the shell
7
- * re-renders around their injected node: the sidebar entry rows
8
- * (sidebar-entry-core), the center-column panel containers
9
- * (panel-mount-core), and the aggregate shell's column shims. Each consumer
10
- * used to install its OWN `MutationObserver` on `document.body` with
11
- * `{ childList: true, subtree: true }`, so a page carrying N family plugins
12
- * paid N native observers and N callback invocations for EVERY mutation batch
13
- * the app produced — chat token streaming alone produces many per second, and
14
- * the count grows with the number of installed plugins.
15
- *
16
- * This module keeps exactly ONE body observer per page. The registry lives on
17
- * `globalThis` under a `Symbol.for` key, so the per-package generated copies
18
- * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
19
- * reach the same hub at runtime instead of one hub per module instance.
20
- * Record subscribers receive the mutations accumulated since the last flush.
21
- * Consumers that only re-check their DOM use subscribeBodyInvalidations:
22
- * when all subscribers use that path, the hub retains no mutation records
23
- * (or detached subtrees) while a background page's animation frames pause.
24
- * Both paths run at most once per frame. The last subscriber disconnects the
25
- * observer, cancels the pending frame and releases its records.
26
- *
27
- * Failure policy: without a DOM or `MutationObserver` the subscription is a
28
- * no-op disposer (the same silence the per-consumer observers had), and when
29
- * `requestAnimationFrame` is unavailable the flush runs synchronously so a
30
- * subscriber is never silently dropped. A throwing subscriber cannot stop the
31
- * others.
32
- */
33
-
34
- /** The page-wide hub: one observer plus its subscribers and pending records. */
35
- interface BodyMutationHub {
36
- observer: MutationObserver
37
- subscribers: Set<(records: MutationRecord[]) => void>
38
- pending: MutationRecord[]
39
- scheduled: boolean
40
- frame?: number
41
- }
42
-
43
- /** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
44
- const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
45
- const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
46
- type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
47
-
48
- function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
49
- for (const listener of subscribers) {
50
- if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
51
- }
52
- return false
53
- }
54
-
55
- /**
56
- * Subscribe to a coalesced DOM re-check without retaining mutation records.
57
- * The marked wrapper also works with an older hub, which delivers records
58
- * that it simply ignores until a page reload picks up the updated hub.
59
- */
60
- export function subscribeBodyInvalidations(subscriber: () => void): () => void {
61
- const listener: Subscriber = () => { subscriber() }
62
- listener[INVALIDATION_ONLY] = true
63
- return subscribeBodyMutations(listener)
64
- }
65
-
66
- /**
67
- * Subscribe to body-level childList mutations.
68
- * @param subscriber - called at most once per animation frame with the records
69
- * collected since the previous flush; must be safe to run repeatedly.
70
- * @returns the disposer removing this subscriber (and the observer when it was
71
- * the last one).
72
- */
73
- export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
74
- if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
75
- if (typeof MutationObserver !== 'function') return () => {}
76
- const registry = globalThis as unknown as Record<symbol, unknown>
77
- let hub = registry[HUB_KEY] as BodyMutationHub | undefined
78
- if (hub === undefined) {
79
- const subscribers = new Set<(records: MutationRecord[]) => void>()
80
- const created: BodyMutationHub = {
81
- observer: undefined as unknown as MutationObserver,
82
- subscribers,
83
- pending: [],
84
- scheduled: false,
85
- }
86
- const flush = (): void => {
87
- created.frame = undefined
88
- created.scheduled = false
89
- const batch = created.pending
90
- created.pending = []
91
- for (const listener of [...subscribers]) {
92
- if (!subscribers.has(listener)) continue
93
- try {
94
- listener(batch)
95
- } catch {
96
- // One subscriber must never break the others; the next flush retries it.
97
- }
98
- }
99
- }
100
- const schedule = (): void => {
101
- if (created.scheduled) return
102
- created.scheduled = true
103
- if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
104
- else flush()
105
- }
106
- created.observer = new MutationObserver((records) => {
107
- if (needsRecords(subscribers)) {
108
- for (const record of records) created.pending.push(record)
109
- }
110
- schedule()
111
- })
112
- created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
113
- registry[HUB_KEY] = created
114
- hub = created
115
- }
116
- const active = hub
117
- active.subscribers.add(subscriber)
118
- let subscribed = true
119
- return () => {
120
- if (!subscribed) return
121
- subscribed = false
122
- active.subscribers.delete(subscriber)
123
- if (!needsRecords(active.subscribers)) active.pending = []
124
- if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
125
- active.observer.disconnect()
126
- if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
127
- active.frame = undefined
128
- active.pending = []
129
- active.scheduled = false
130
- delete registry[HUB_KEY]
131
- }
132
- }
133
- }
@@ -1,47 +0,0 @@
1
- /**
2
- * Panel view mounting (dsh-ssh wrapper).
3
- *
4
- * The single-occupant takeover lifecycle — container injection into the
5
- * center column, sibling eviction, remount resilience, sidebar click-out —
6
- * lives exactly once in shared/client/panel-mount-core.ts (synced copy); this
7
- * wrapper supplies the SSH panel tree, view dataset key, CSS module, and the
8
- * html attribute names. Those names are pinned by panel.module.css, skins,
9
- * and the semantic attributes contract.
10
- */
11
- import type { SshApi } from './api.ts'
12
- import type { PanelController } from './panel/controller.ts'
13
- import type { TerminalFontSource } from './panel/helpers.ts'
14
- import { SshPanel } from './panel/SshPanel.tsx'
15
- import { mountCenterPanel } from './panel-mount-core.ts'
16
- import type { LocaleRefreshSource } from './sidebar-entry.ts'
17
- import css from './panel/panel.module.css'
18
-
19
- /** The injected panel container (kept in the DOM, hidden when inactive). */
20
- export const PANEL_VIEW_SELECTOR = '[data-dsh-ssh-view]'
21
-
22
- /**
23
- * Mount the panel React tree into the center column and bind its visibility
24
- * to the controller's panelOpen state.
25
- * @param controller - the panel controller driving the view.
26
- * @param api - the SSH API client the tabs operate through.
27
- * @param terminalFont - live terminal-font setting source (issue #577).
28
- * @param locale - locale-change source; when given, re-renders an open panel
29
- * on a Language switch.
30
- * @returns disposer unmounting the tree and restoring the column.
31
- */
32
- export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource, locale?: LocaleRefreshSource): () => void {
33
- return mountCenterPanel({
34
- render: root => root.render(<SshPanel controller={controller} api={api} terminalFont={terminalFont} />),
35
- viewDatasetKey: 'dshSshView',
36
- pluginName: 'ssh',
37
- viewClassName: css.view,
38
- activeAttribute: 'data-dsh-ssh-active',
39
- siblingActiveAttribute: 'data-dsh-taskboard-active',
40
- panelName: 'ssh',
41
- siblingPanelName: 'taskboard',
42
- isOpen: () => controller.getSnapshot().panelOpen,
43
- close: () => controller.close(),
44
- subscribe: listener => controller.subscribe(listener),
45
- locale,
46
- })
47
- }