@linxin666/dsh-ssh 0.2.0 → 0.2.2

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
- import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { closeSync, createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { Client } from "ssh2";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -1414,26 +1414,26 @@ async function connectChain(engine, entry) {
1414
1414
  const hops = [];
1415
1415
  let sock;
1416
1416
  const chain = entry.proxyJump;
1417
- for (let index = 0; index < chain.length; index += 1) {
1418
- const hopAlias = chain[index];
1419
- const hop = engine.store.find(hopAlias);
1420
- if (hop === void 0) {
1421
- for (const client of hops) client.end();
1422
- throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1423
- }
1424
- const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
1425
- hops.push(hopClient);
1426
- const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1427
- const nextHost = next !== void 0 ? next.host : entry.host;
1428
- const nextPort = next !== void 0 ? next.port : entry.port;
1429
- sock = await new Promise((resolve, reject) => {
1430
- hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1431
- if (error !== void 0) {
1432
- for (const client of hops) client.end();
1433
- reject(error);
1434
- } else resolve(stream);
1417
+ try {
1418
+ for (let index = 0; index < chain.length; index += 1) {
1419
+ const hopAlias = chain[index];
1420
+ const hop = engine.store.find(hopAlias);
1421
+ if (hop === void 0) throw new Error("proxyJump alias '" + hopAlias + "' not found — create it first");
1422
+ const hopClient = await connectClient(buildConnectConfig(hop, sock, engine.opts));
1423
+ hops.push(hopClient);
1424
+ const next = index + 1 < chain.length ? engine.store.find(chain[index + 1]) : void 0;
1425
+ const nextHost = next !== void 0 ? next.host : entry.host;
1426
+ const nextPort = next !== void 0 ? next.port : entry.port;
1427
+ sock = await new Promise((resolve, reject) => {
1428
+ hopClient.forwardOut("127.0.0.1", 0, nextHost, nextPort, (error, stream) => {
1429
+ if (error !== void 0) reject(error);
1430
+ else resolve(stream);
1431
+ });
1435
1432
  });
1436
- });
1433
+ }
1434
+ } catch (error) {
1435
+ for (const client of hops) client.end();
1436
+ throw error;
1437
1437
  }
1438
1438
  let target;
1439
1439
  try {
@@ -1614,6 +1614,11 @@ async function execCommand(engine, alias, command, timeoutMs) {
1614
1614
  //#endregion
1615
1615
  //#region src/engine/pty.ts
1616
1616
  /**
1617
+ * PTY shell sessions for the web terminal: a standalone (non-pooled)
1618
+ * ssh2 connection with a long-lived shell channel, resize, and pausable
1619
+ * output delivery.
1620
+ */
1621
+ /**
1617
1622
  * Open a PTY shell session for the web terminal (standalone connection).
1618
1623
  * The shell is a long-lived exclusive stream: it uses its own connection so
1619
1624
  * closing it can never tear down a pooled exec/tunnel sharing the alias.
@@ -2283,7 +2288,10 @@ function makeRoutes(deps) {
2283
2288
  const { store, engine } = deps;
2284
2289
  const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
2285
2290
  const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
2286
- mkdirSync(staging, { recursive: true });
2291
+ mkdirSync(staging, {
2292
+ recursive: true,
2293
+ mode: 448
2294
+ });
2287
2295
  /** Guard helper: fence + method check. */
2288
2296
  const guard = (req, res, method) => {
2289
2297
  if (!isLoopbackRequest(req)) {
@@ -2543,7 +2551,7 @@ function makeRoutes(deps) {
2543
2551
  } catch {}
2544
2552
  };
2545
2553
  const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
2546
- const sink = createWriteStream(tmp);
2554
+ const sink = createWriteStream(tmp, { mode: 384 });
2547
2555
  let settled = false;
2548
2556
  const fail = (error) => {
2549
2557
  if (settled) return;
@@ -2649,6 +2657,7 @@ function makeRoutes(deps) {
2649
2657
  }
2650
2658
  const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
2651
2659
  try {
2660
+ closeSync(openSync(tmp, "w", 384));
2652
2661
  const outcome = await engine.download(alias, remotePath, tmp);
2653
2662
  res.writeHead(200, {
2654
2663
  "content-type": "application/octet-stream",
@@ -3408,7 +3417,8 @@ const inject = [
3408
3417
  const SSH_SETTINGS_NAMESPACE = settingsNamespace("dsh-ssh");
3409
3418
  const Config = import_lib.default.object({
3410
3419
  announceToAgent: import_lib.default.boolean().default(true),
3411
- enabled: import_lib.default.boolean().default(true)
3420
+ enabled: import_lib.default.boolean().default(true),
3421
+ terminalFontFamily: import_lib.default.string().default("")
3412
3422
  });
3413
3423
  /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
3414
3424
  const DEFAULT_ANNOUNCE = true;
@@ -10,8 +10,20 @@
10
10
  * Export discipline (packages/client rule): the /client surface carries what
11
11
  * cordis loading needs plus types only — all value exports stay internal.
12
12
  */
13
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
13
+ import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client';
14
14
  import { type SshKey } from './locales.ts';
15
+ declare module '@deepseek-ai/cordis' {
16
+ interface Context {
17
+ /**
18
+ * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
19
+ * absent when that group plugin is not installed, so callers fall back to
20
+ * the official settings scope.
21
+ */
22
+ webUiSettings?: {
23
+ bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S>;
24
+ };
25
+ }
26
+ }
15
27
  declare module '@deepseek-ai/dsh-client-ui-slots' {
16
28
  interface LocaleNamespaceMap {
17
29
  /** dsh-ssh surface copy. */
@@ -1,5 +1,6 @@
1
1
  import type { SshApi } from './api.ts';
2
2
  import type { PanelController } from './panel/controller.ts';
3
+ import type { TerminalFontSource } from './panel/helpers.ts';
3
4
  /** The injected panel container (kept in the DOM, hidden when inactive). */
4
5
  export declare const PANEL_VIEW_SELECTOR = "[data-dsh-ssh-view]";
5
6
  /**
@@ -7,6 +8,7 @@ export declare const PANEL_VIEW_SELECTOR = "[data-dsh-ssh-view]";
7
8
  * to the controller's panelOpen state.
8
9
  * @param controller - the panel controller driving the view.
9
10
  * @param api - the SSH API client the tabs operate through.
11
+ * @param terminalFont - live terminal-font setting source (issue #577).
10
12
  * @returns disposer unmounting the tree and restoring the column.
11
13
  */
12
- export declare function mountPanel(controller: PanelController, api: SshApi): () => void;
14
+ export declare function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource): () => void;
@@ -1,5 +1,6 @@
1
1
  import type { SshApi } from '../api.ts';
2
2
  import type { PanelController } from './controller.ts';
3
+ import { type TerminalFontSource } from './helpers.ts';
3
4
  /** The panel's tab identifiers. */
4
5
  export type SshTab = 'hosts' | 'terminal' | 'transfer' | 'tunnels' | 'cluster';
5
6
  /** Panel shell props. */
@@ -8,6 +9,8 @@ export interface SshPanelProps {
8
9
  controller: PanelController;
9
10
  /** The SSH API client every tab operates through. */
10
11
  api: SshApi;
12
+ /** Live terminal-font setting source handed to the terminal tab (issue #577). */
13
+ terminalFont?: TerminalFontSource;
11
14
  }
12
15
  /** The tabbed SSH panel. */
13
- export declare function SshPanel({ controller, api }: SshPanelProps): import("react").JSX.Element;
16
+ export declare function SshPanel({ controller, api, terminalFont }: SshPanelProps): import("react").JSX.Element;
@@ -1,4 +1,5 @@
1
1
  import type { SshApi } from '../api.ts';
2
+ import { type TerminalFontSource } from './helpers.ts';
2
3
  /** Terminal tab props. */
3
4
  export interface TerminalTabProps {
4
5
  api: SshApi;
@@ -6,6 +7,11 @@ export interface TerminalTabProps {
6
7
  presetAlias?: string;
7
8
  /** Monotonic id of the connect request (re-applies presetAlias). */
8
9
  requestId?: number;
10
+ /**
11
+ * Live terminal-font setting source (issue #577). Absent in tests and
12
+ * legacy mounts: the font then comes from the CSS custom-property chain.
13
+ */
14
+ terminalFont?: TerminalFontSource;
9
15
  }
10
16
  /** The xterm terminal view. */
11
- export declare function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps): import("react").JSX.Element;
17
+ export declare function TerminalTab({ api, presetAlias, requestId, terminalFont }: TerminalTabProps): import("react").JSX.Element;
@@ -12,3 +12,32 @@ export declare function dictionary(): Record<string, string>;
12
12
  export declare function tt(key: SshKey, values?: TranslateValues): string;
13
13
  /** Human-readable error text from an unknown thrown value. */
14
14
  export declare function errorMessage(error: unknown): string;
15
+ /**
16
+ * The terminal font stack used before issue #577, kept as the last resort
17
+ * when neither the plugin setting nor a CSS custom property names a font.
18
+ */
19
+ export declare const TERMINAL_FONT_FALLBACK = "Menlo, Consolas, \"Liberation Mono\", monospace";
20
+ /**
21
+ * Live source of the user's configured terminal font family: the client
22
+ * entry binds the dsh-ssh settings namespace into one of these and hands it
23
+ * to the panel, so a settings change re-applies without a remount.
24
+ */
25
+ export interface TerminalFontSource {
26
+ /** Current configured family, or undefined when unset / not yet loaded. */
27
+ get(): string | undefined;
28
+ /** Subscribe to changes; the returned function unsubscribes. */
29
+ subscribe(listener: () => void): () => void;
30
+ }
31
+ /**
32
+ * Resolve the xterm `fontFamily` (issue #577). xterm's DOM renderer takes
33
+ * the font only from constructor/options, so a plain stylesheet rule cannot
34
+ * retarget it — the value must be read back into the options. The chain,
35
+ * first non-empty value wins:
36
+ * 1. `override` — the `terminalFontFamily` plugin setting;
37
+ * 2. `--dsh-ssh-terminal-font` — dedicated hook for skins and user CSS
38
+ * (e.g. a Nerd Font for powerline glyphs);
39
+ * 3. `--ds-font-family-code` — the official code-font token several skins
40
+ * already remap;
41
+ * 4. {@link TERMINAL_FONT_FALLBACK}.
42
+ */
43
+ export declare function resolveTerminalFontFamily(override?: string): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared sidebar entry injection core.
3
+ *
4
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
5
+ * so the entry row is injected between the shell's New Session button and the
6
+ * workspace browser. The injection self-heals: a MutationObserver watches the
7
+ * sidebar root and re-inserts the row whenever a React re-render displaces it
8
+ * (re-insertion happens in the same frame, before paint, so no flicker).
9
+ *
10
+ * The row is plain DOM (no React tree) so it can never disturb the shell's
11
+ * reconciliation; the view it toggles is a separate root owned by the caller.
12
+ *
13
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
14
+ * edit the shared source and re-run the sync instead of editing a copy.
15
+ */
16
+ /** Per-package configuration for one sidebar entry row. */
17
+ export interface SidebarEntryOptions {
18
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
19
+ rowAttribute: string;
20
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
21
+ rowSelector: string;
22
+ /**
23
+ * L2 semantic-attribute plugin id (issue #506, enum table:
24
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
25
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
26
+ * leaves the row without semantic attributes.
27
+ */
28
+ plugin?: string;
29
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
30
+ icon: string;
31
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
32
+ css: Record<string, string>;
33
+ /** Localized row label (aria-label + visible text). */
34
+ label(): string;
35
+ /** Optional localized tooltip (title attribute). */
36
+ tooltip?(): string;
37
+ /** Click action (open/toggle the owning panel). */
38
+ onToggle(): void;
39
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
40
+ position: 'before' | 'after';
41
+ /**
42
+ * Selectors of the sibling plugin entry rows this package orders against
43
+ * (its own row included — the placement guard excludes a row that is
44
+ * already inside the root). Each package passes the same list it used
45
+ * before the consolidation so the rendered order stays stable.
46
+ */
47
+ familySelectors: readonly string[];
48
+ /** Optional active-state bridge; highlights the row while the panel is open. */
49
+ active?: {
50
+ subscribe(listener: () => void): () => void;
51
+ isOpen(): boolean;
52
+ };
53
+ }
54
+ /**
55
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
56
+ * on later React re-renders.
57
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
58
+ * @returns disposer removing the entry and its observers.
59
+ */
60
+ export declare function mountSidebarEntry(options: SidebarEntryOptions): () => void;
@@ -1,16 +1,12 @@
1
1
  /**
2
- * Sidebar entry injection.
2
+ * Sidebar entry injection — package-specific wiring over the shared core.
3
3
  *
4
- * dsh's sidebar shell exposes no slot an external plugin can register into,
5
- * so — following the task-board precedent of DOM-level extension — the entry
6
- * row is injected between the shell's New Session button and the workspace
7
- * browser. The injection self-heals: a MutationObserver watches the sidebar
8
- * root and re-inserts the row whenever a React re-render displaces it
9
- * (re-insertion happens in the same frame, before paint, so no flicker).
10
- *
11
- * The row is plain DOM (no React tree) so it can never disturb the shell's
12
- * reconciliation; the panel view it toggles is a separate React root mounted
13
- * in the center column (see mount.tsx).
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).
14
10
  */
15
11
  import type { PanelController } from './panel/controller.ts';
16
12
  /** Stable data attribute identifying the injected entry row. */
@@ -28,6 +28,13 @@ export interface Config {
28
28
  announceToAgent?: boolean;
29
29
  /** Master switch for the plugin (routes, tools, prompt section). */
30
30
  enabled?: boolean;
31
+ /**
32
+ * xterm `fontFamily` for the web terminal (issue #577). Empty (default)
33
+ * defers to the CSS chain: `--dsh-ssh-terminal-font`, then the official
34
+ * `--ds-font-family-code` token, then the built-in monospace stack. Set a
35
+ * Nerd Font stack here to render powerline/Nerd glyphs.
36
+ */
37
+ terminalFontFamily?: string;
31
38
  }
32
39
  export declare const Config: z<Config>;
33
40
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-ssh",
3
3
  "description": "Remote SSH operations for the dsh web GUI: host config store (~/.dsh/dsh-ssh.json, import from ~/.ssh/config), a persistent ssh2 connection pool with jump-host support, exec / PTY web terminal / SFTP transfer / local port-forward tunnels / cluster execution, plus agent tools (ssh_list, ssh_exec, ssh_upload, ssh_download, ssh_tunnel, ssh_cluster). Hot-pluggable — mounted via ~/.dsh/cordis.patch.yml + a profile node_modules symlink, no dsh source changes.",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -53,7 +53,6 @@
53
53
  "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
54
54
  "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
55
55
  "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
56
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.7",
57
56
  "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
58
57
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
59
58
  "@deepseek-ai/dsh-llm": "^0.1.0-rc.7",
@@ -66,7 +65,6 @@
66
65
  "@types/ssh2": "^1.15.5",
67
66
  "@types/ws": "^8.5.13",
68
67
  "jsdom": "^25.0.0",
69
- "lightningcss": "1.32.0",
70
68
  "react": "^18.3.1",
71
69
  "react-dom": "^18.3.1",
72
70
  "schemastery": "^3.18.0",
@@ -10,20 +10,43 @@
10
10
  * Export discipline (packages/client rule): the /client surface carries what
11
11
  * cordis loading needs plus types only — all value exports stay internal.
12
12
  */
13
- import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
13
+ import type { ClientContext, SettingsScope, SettingsScopeSpec } from '@deepseek-ai/dsh-client-runtime/client'
14
14
  // Type-only: pulls the locale plugin's Context merge (ctx.locale).
15
15
  import type {} from '@deepseek-ai/dsh-client-locale/client'
16
+ // Type-only: pulls the settings-surface Context merge (ctx.settingsScope).
17
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
16
18
  // Type-only: pulls the LocaleNamespaceMap merge table.
17
19
  import type {} from '@deepseek-ai/dsh-client-ui-slots'
18
20
  import { SshApi } from './api.ts'
19
21
  import { en, zh, type SshKey } from './locales.ts'
20
22
  import { mountPanel } from './mount.tsx'
21
23
  import { PanelController } from './panel/controller.ts'
24
+ import type { TerminalFontSource } from './panel/helpers.ts'
22
25
  import { mountSidebarEntry } from './sidebar-entry.ts'
23
26
 
24
27
  /** Locale namespace this plugin owns. */
25
28
  const NS = 'dsh-ssh'
26
29
 
30
+ /** Settings namespace the terminal-font preference lives in (issue #577). */
31
+ const SETTINGS_NS = 'dsh-ssh'
32
+
33
+ /** The dsh-ssh settings surface the browser half reads. */
34
+ interface SshClientSettings {
35
+ /** User-configured xterm fontFamily; empty/undefined means the CSS chain. */
36
+ terminalFontFamily?: string
37
+ }
38
+
39
+ declare module '@deepseek-ai/cordis' {
40
+ interface Context {
41
+ /**
42
+ * Optional rc.6 compatibility binder provided by dsh-web-ui-settings;
43
+ * absent when that group plugin is not installed, so callers fall back to
44
+ * the official settings scope.
45
+ */
46
+ webUiSettings?: { bind<S>(spec: SettingsScopeSpec<S>): SettingsScope<S> }
47
+ }
48
+ }
49
+
27
50
  declare module '@deepseek-ai/dsh-client-ui-slots' {
28
51
  interface LocaleNamespaceMap {
29
52
  /** dsh-ssh surface copy. */
@@ -32,7 +55,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
32
55
  }
33
56
 
34
57
  /** Required services (fiber inject waiting — the runtime must be up first). */
35
- export const inject = ['slots', 'locale']
58
+ export const inject = ['slots', 'locale', 'settingsScope']
36
59
 
37
60
  /** Type-only surface (export discipline: no value exports beyond the plugin contract). */
38
61
  export type { PanelControllerSnapshot } from './panel/controller.ts'
@@ -54,10 +77,22 @@ export function apply(ctx: ClientContext): void {
54
77
 
55
78
  const controller = new PanelController()
56
79
  const api = new SshApi()
80
+ // Live terminal-font preference (issue #577): the settings namespace is
81
+ // edited by the host-registered GUI section; the panel re-applies changes
82
+ // to open terminals without a reconnect.
83
+ const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
84
+ const scope = binder.bind<SshClientSettings>({ namespace: SETTINGS_NS })
85
+ const terminalFont: TerminalFontSource = {
86
+ get: () => {
87
+ const snapshot = scope.getSnapshot()
88
+ return snapshot.status === 'ready' ? snapshot.value?.terminalFontFamily : undefined
89
+ },
90
+ subscribe: (listener) => scope.subscribe(listener),
91
+ }
57
92
  const disposers: Array<() => void> = []
58
93
  try {
59
94
  disposers.push(mountSidebarEntry(controller))
60
- disposers.push(mountPanel(controller, api))
95
+ disposers.push(mountPanel(controller, api, terminalFont))
61
96
  } catch (error) {
62
97
  // DOM failures degrade the panel, never the GUI.
63
98
  console.warn('[dsh-ssh] mount failed:', error)
@@ -14,6 +14,7 @@
14
14
  import { createRoot, type Root } from 'react-dom/client'
15
15
  import type { SshApi } from './api.ts'
16
16
  import type { PanelController } from './panel/controller.ts'
17
+ import type { TerminalFontSource } from './panel/helpers.ts'
17
18
  import { SshPanel } from './panel/SshPanel.tsx'
18
19
  import css from './panel/panel.module.css'
19
20
 
@@ -38,9 +39,10 @@ function conversationColumn(): HTMLElement | undefined {
38
39
  * to the controller's panelOpen state.
39
40
  * @param controller - the panel controller driving the view.
40
41
  * @param api - the SSH API client the tabs operate through.
42
+ * @param terminalFont - live terminal-font setting source (issue #577).
41
43
  * @returns disposer unmounting the tree and restoring the column.
42
44
  */
43
- export function mountPanel(controller: PanelController, api: SshApi): () => void {
45
+ export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource): () => void {
44
46
  let root: Root | undefined
45
47
  let container: HTMLDivElement | undefined
46
48
 
@@ -57,10 +59,11 @@ export function mountPanel(controller: PanelController, api: SshApi): () => void
57
59
  if (column === undefined) return
58
60
  container = document.createElement('div')
59
61
  container.dataset.dshSshView = ''
62
+ container.dataset.dshPlugin = 'ssh'
60
63
  container.className = css.view
61
64
  column.appendChild(container)
62
65
  root = createRoot(container)
63
- root.render(<SshPanel controller={controller} api={api} />)
66
+ root.render(<SshPanel controller={controller} api={api} terminalFont={terminalFont} />)
64
67
  }
65
68
 
66
69
  // The frame mounts after boot settlement; watch for the column's arrival.
@@ -72,16 +72,24 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
72
72
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
73
73
  const [testingGroup, setTestingGroup] = useState<string | null>(null)
74
74
  const seqRef = useRef(0)
75
+ // Unmount guard for the async load below: the seq check only orders
76
+ // overlapping loads, it does not stop a late resolution/rejection landing
77
+ // after the tab unmounted — a setState there races the test-environment
78
+ // teardown (window is not defined; observed as a main-CI flake). The
79
+ // sibling tabs (terminal / transfer / tunnels) already guard with a
80
+ // disposed flag.
81
+ const mountedRef = useRef(true)
82
+ useEffect(() => () => { mountedRef.current = false }, [])
75
83
 
76
84
  const load = useCallback(async (query?: string): Promise<void> => {
77
85
  const seq = ++seqRef.current
78
86
  try {
79
87
  const list = await api.listHosts(query)
80
- if (seq !== seqRef.current) return
88
+ if (!mountedRef.current || seq !== seqRef.current) return
81
89
  setHosts(list)
82
90
  setError(null)
83
91
  } catch (cause) {
84
- if (seq !== seqRef.current) return
92
+ if (!mountedRef.current || seq !== seqRef.current) return
85
93
  setError(errorMessage(cause))
86
94
  }
87
95
  }, [api])
@@ -8,7 +8,7 @@
8
8
  import { useState } from 'react'
9
9
  import type { SshApi } from '../api.ts'
10
10
  import type { PanelController } from './controller.ts'
11
- import { tt } from './helpers.ts'
11
+ import { tt, type TerminalFontSource } from './helpers.ts'
12
12
  import { ClusterTab } from './ClusterTab.tsx'
13
13
  import { HostsTab } from './HostsTab.tsx'
14
14
  import { TerminalTab } from './TerminalTab.tsx'
@@ -25,6 +25,8 @@ export interface SshPanelProps {
25
25
  controller: PanelController
26
26
  /** The SSH API client every tab operates through. */
27
27
  api: SshApi
28
+ /** Live terminal-font setting source handed to the terminal tab (issue #577). */
29
+ terminalFont?: TerminalFontSource
28
30
  }
29
31
 
30
32
  /** The tab bar definition (labels resolved at render time). */
@@ -43,7 +45,7 @@ interface ConnectRequest {
43
45
  }
44
46
 
45
47
  /** The tabbed SSH panel. */
46
- export function SshPanel({ controller, api }: SshPanelProps) {
48
+ export function SshPanel({ controller, api, terminalFont }: SshPanelProps) {
47
49
  const [activeTab, setActiveTab] = useState<SshTab>('hosts')
48
50
  const [connectRequest, setConnectRequest] = useState<ConnectRequest | null>(null)
49
51
 
@@ -66,7 +68,7 @@ export function SshPanel({ controller, api }: SshPanelProps) {
66
68
  </button>
67
69
  <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
68
70
  </div>
69
- <div className={css.tabBar} role="tablist">
71
+ <div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
70
72
  {TABS.map(tab => (
71
73
  <button key={tab.id} type="button" role="tab" aria-selected={activeTab === tab.id} data-active={activeTab === tab.id ? '' : undefined} className={css.tab} onClick={() => { setActiveTab(tab.id) }}>
72
74
  {tab.label()}
@@ -75,7 +77,7 @@ export function SshPanel({ controller, api }: SshPanelProps) {
75
77
  </div>
76
78
  <div className={css.panelContent}>
77
79
  {activeTab === 'hosts' && <HostsTab api={api} onConnect={handleConnect} />}
78
- {activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} />}
80
+ {activeTab === 'terminal' && <TerminalTab api={api} presetAlias={connectRequest?.alias} requestId={connectRequest?.nonce} terminalFont={terminalFont} />}
79
81
  {activeTab === 'transfer' && <TransferTab api={api} />}
80
82
  {activeTab === 'tunnels' && <TunnelsTab api={api} />}
81
83
  {activeTab === 'cluster' && <ClusterTab api={api} />}
@@ -5,13 +5,13 @@
5
5
  * output stays visible and input is disabled. xterm's stylesheet is injected
6
6
  * once per page load (module-level guard).
7
7
  */
8
- import { useEffect, useRef, useState } from 'react'
8
+ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
9
9
  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
13
  import { XTERM_CSS } from './xterm.css.ts'
14
- import { errorMessage, tt } from './helpers.ts'
14
+ import { errorMessage, resolveTerminalFontFamily, tt, type TerminalFontSource } from './helpers.ts'
15
15
  import css from './panel.module.css'
16
16
 
17
17
  /** Terminal tab props. */
@@ -21,6 +21,11 @@ export interface TerminalTabProps {
21
21
  presetAlias?: string
22
22
  /** Monotonic id of the connect request (re-applies presetAlias). */
23
23
  requestId?: number
24
+ /**
25
+ * Live terminal-font setting source (issue #577). Absent in tests and
26
+ * legacy mounts: the font then comes from the CSS custom-property chain.
27
+ */
28
+ terminalFont?: TerminalFontSource
24
29
  }
25
30
 
26
31
  /** The terminal session lifecycle state shown in the status banner. */
@@ -44,8 +49,14 @@ function ensureXtermCss(): void {
44
49
  document.head.appendChild(style)
45
50
  }
46
51
 
52
+ /** No-op source stand-in so the hook order stays stable without the prop. */
53
+ const NO_FONT_SOURCE: TerminalFontSource = {
54
+ get: () => undefined,
55
+ subscribe: () => () => undefined,
56
+ }
57
+
47
58
  /** The xterm terminal view. */
48
- export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
59
+ export function TerminalTab({ api, presetAlias, requestId, terminalFont }: TerminalTabProps) {
49
60
  const [hosts, setHosts] = useState<SshHostSummary[]>([])
50
61
  const [alias, setAlias] = useState(presetAlias ?? '')
51
62
  const [status, setStatus] = useState<TerminalStatus>({ kind: 'idle' })
@@ -54,9 +65,24 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
54
65
  const fitRef = useRef<FitAddon | null>(null)
55
66
  const connRef = useRef<TerminalConnection | null>(null)
56
67
  const dataSubRef = useRef<IDisposable | null>(null)
68
+ const fontSource = terminalFont ?? NO_FONT_SOURCE
69
+ const fontOverride = useSyncExternalStore(fontSource.subscribe, fontSource.get)
57
70
 
58
71
  useEffect(() => { ensureXtermCss() }, [])
59
72
 
73
+ // Live re-apply a terminal-font change (issue #577): xterm re-measures
74
+ // and repaints on the options write; a refit keeps cols/rows aligned with
75
+ // the new metrics and the remote PTY learns the new size.
76
+ useEffect(() => {
77
+ const term = termRef.current
78
+ if (term === null) return
79
+ const next = resolveTerminalFontFamily(fontOverride)
80
+ if (term.options.fontFamily === next) return
81
+ term.options.fontFamily = next
82
+ fitRef.current?.fit()
83
+ connRef.current?.resize(term.cols, term.rows)
84
+ }, [fontOverride])
85
+
60
86
  // Fetch the host list on tab activation.
61
87
  useEffect(() => {
62
88
  let disposed = false
@@ -122,7 +148,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
122
148
  convertEol: false,
123
149
  cursorBlink: true,
124
150
  fontSize: 13,
125
- fontFamily: 'Menlo, Consolas, "Liberation Mono", monospace',
151
+ fontFamily: resolveTerminalFontFamily(fontOverride),
126
152
  theme: { background: '#0b0e14', foreground: '#d8dee9', cursor: '#a3b8d0' },
127
153
  })
128
154
  const fit = new FitAddon()
@@ -137,7 +163,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
137
163
  dataSubRef.current = term.onData(data => { connection.send(data) })
138
164
  connection.onReady = () => { setStatus({ kind: 'connected', alias: target }) }
139
165
  connection.onOutput = data => { term.write(data) }
140
- connection.onExit = (code, error) => {
166
+ connection.onExit = (_code, error) => {
141
167
  if (settled) return
142
168
  settled = true
143
169
  dataSubRef.current?.dispose()
@@ -173,7 +199,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
173
199
  )}
174
200
  {status.kind === 'error' && <div className={css.banner} data-kind="error">{tt('terminal.error', { error: status.detail })}</div>}
175
201
  <div className={css.termWrap}>
176
- <div ref={containerRef} className={css.termContainer} />
202
+ <div ref={containerRef} className={css.termContainer} data-dsh-part="terminal" />
177
203
  {status.kind === 'idle' && (
178
204
  <div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
179
205
  )}