@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/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: 4ea116d416ac311ffde1f1052d9857e46b9c4849
6
- README.zh.md: d5bec45d8054b999d24832a4a8d103269b2c816d
5
+ README.md: f9476df50bac571b567512cfa5272b5a64a1c3d6
6
+ README.zh.md: ec991222c0a38f2376acdd21ae52fe185facd0c2
package/README.md CHANGED
@@ -33,10 +33,10 @@ Built on the capability list of [badseal/ssh-skill](https://github.com/badseal/s
33
33
  Install the family aggregate package `@linxin666/dsh-web-ui-all` (all plugins and skins in one) or this plugin alone:
34
34
 
35
35
  ```sh
36
- ### 从 npm 安装(推荐)
37
- dsh plugin --profile web add @linxin666/dsh-ssh
36
+ ### From npm (recommended)
37
+ dsh plugin --profile web add @linxin666/dsh-ssh@latest
38
38
 
39
- ### 从仓库安装(开发调试)
39
+ ### From the repository (development)
40
40
  git clone https://github.com/zhu1090093659/dsh-web-ui.git
41
41
  cd dsh-web-ui
42
42
  pnpm install && pnpm -r build
@@ -48,12 +48,12 @@ After installing, **restart `dsh web`**: a "SSH" entry appears in the sidebar; t
48
48
 
49
49
  ## Configuration
50
50
 
51
- The settings panel (plugin config) toggles `announceToAgent` (whether to announce the plugin to the Agent) and `enabled` (master switch).
51
+ The settings panel (plugin config) toggles `announceToAgent` (whether to announce the plugin to the Agent) and `enabled` (master switch), and sets `terminalFontFamily` (the web terminal font; empty defers to the CSS chain: `--dsh-ssh-terminal-font` → the official `--ds-font-family-code` token → the built-in monospace stack). The terminal font is fixed in the xterm constructor, so a plain stylesheet cannot override it; to render powerline / Nerd Font glyphs, enter a Nerd Font stack here (e.g. `"SauceCodePro Nerd Font", monospace`). Changes re-apply to open terminals live, no reconnect needed.
52
52
 
53
53
  ## Data
54
54
 
55
55
  - Host config: `~/.dsh/dsh-ssh.json` (versioned JSON, atomic write)
56
- - Transfer staging: `os.tmpdir()/dsh-ssh-uploads/`
56
+ - Transfer staging: `os.tmpdir()/dsh-ssh-uploads/` (0700 directory, 0600 in-flight files)
57
57
 
58
58
  ## Development
59
59
 
package/README.zh.md CHANGED
@@ -34,7 +34,7 @@
34
34
 
35
35
  ```sh
36
36
  ### 从 npm 安装(推荐)
37
- dsh plugin --profile web add @linxin666/dsh-ssh
37
+ dsh plugin --profile web add @linxin666/dsh-ssh@latest
38
38
 
39
39
  ### 从仓库安装(开发调试)
40
40
  git clone https://github.com/zhu1090093659/dsh-web-ui.git
@@ -48,12 +48,12 @@ dsh plugin --profile web add link:$(pwd)/packages/dsh-ssh
48
48
 
49
49
  ## 配置
50
50
 
51
- 设置面板(插件配置)可开关 `announceToAgent`(是否向 Agent 宣告插件)与 `enabled`(总开关)。
51
+ 设置面板(插件配置)可开关 `announceToAgent`(是否向 Agent 宣告插件)与 `enabled`(总开关),并可设置 `terminalFontFamily`(Web 终端字体,留空则按 CSS 链解析:`--dsh-ssh-terminal-font` → 官方 `--ds-font-family-code` token → 内置 monospace 栈)。终端字体写死在 xterm 构造参数里,CSS 无法直接覆盖;要渲染 powerline / Nerd Font 图标,请在此填入对应 Nerd Font 栈(如 `"SauceCodePro Nerd Font", monospace`),修改对已打开的终端即时生效,无需重连。
52
52
 
53
53
  ## 数据
54
54
 
55
55
  - 主机配置:`~/.dsh/dsh-ssh.json`(版本化 JSON,原子写入)
56
- - 传输暂存:`os.tmpdir()/dsh-ssh-uploads/`
56
+ - 传输暂存:`os.tmpdir()/dsh-ssh-uploads/`(目录 0700,传输中的文件 0600)
57
57
 
58
58
  ## 开发
59
59
 
package/lib/client.js CHANGED
@@ -609,6 +609,36 @@ window.__ModuleLoader__.load({
609
609
  if (error instanceof Error) return error.message;
610
610
  return String(error);
611
611
  }
612
+ /**
613
+ * The terminal font stack used before issue #577, kept as the last resort
614
+ * when neither the plugin setting nor a CSS custom property names a font.
615
+ */
616
+ const TERMINAL_FONT_FALLBACK = "Menlo, Consolas, \"Liberation Mono\", monospace";
617
+ /**
618
+ * Resolve the xterm `fontFamily` (issue #577). xterm's DOM renderer takes
619
+ * the font only from constructor/options, so a plain stylesheet rule cannot
620
+ * retarget it — the value must be read back into the options. The chain,
621
+ * first non-empty value wins:
622
+ * 1. `override` — the `terminalFontFamily` plugin setting;
623
+ * 2. `--dsh-ssh-terminal-font` — dedicated hook for skins and user CSS
624
+ * (e.g. a Nerd Font for powerline glyphs);
625
+ * 3. `--ds-font-family-code` — the official code-font token several skins
626
+ * already remap;
627
+ * 4. {@link TERMINAL_FONT_FALLBACK}.
628
+ */
629
+ function resolveTerminalFontFamily(override) {
630
+ const trimmed = override?.trim();
631
+ if (trimmed !== void 0 && trimmed !== "") return trimmed;
632
+ if (typeof getComputedStyle === "function" && typeof document !== "undefined") {
633
+ const target = document.body ?? document.documentElement;
634
+ const style = getComputedStyle(target);
635
+ for (const name of ["--dsh-ssh-terminal-font", "--ds-font-family-code"]) {
636
+ const value = style.getPropertyValue(name).trim();
637
+ if (value !== "") return value;
638
+ }
639
+ }
640
+ return TERMINAL_FONT_FALLBACK;
641
+ }
612
642
  //#endregion
613
643
  //#region \0dsh-css:packages/dsh-ssh/src/client/panel/panel.module.css.mjs
614
644
  const css = "[data-pane=conversation],[class*=centerCol]{position:relative}[data-dsh-ssh-view]{z-index:60;background:var(--dsw-alias-bg-base);display:none;position:absolute;inset:0}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-dsh-ssh-view]{display:block}html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [data-pane=conversation]>:not([data-dsh-ssh-view]),html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*=centerCol]>:not([data-dsh-ssh-view]){display:none!important}.mL8Uca_entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}.mL8Uca_entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}.mL8Uca_entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}.mL8Uca_entryLabel{text-overflow:ellipsis;overflow:hidden}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entry{justify-content:center;width:100%;padding:0}[data-dsh-frame][data-sidebar-collapsed] .mL8Uca_entryLabel{display:none}.mL8Uca_view{overflow:hidden}.mL8Uca_panel{background:var(--dsw-alias-bg-base);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex}.mL8Uca_panelHeader{flex:none;align-items:center;gap:10px;display:flex}.mL8Uca_panelTitle{color:var(--dsw-alias-label-primary);white-space:nowrap;flex:1;margin:0;font-size:16px;font-weight:700}.mL8Uca_backButton{align-items:center;gap:4px;display:inline-flex}.mL8Uca_tabBar{border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;gap:2px;display:flex}.mL8Uca_tab{color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;border-radius:6px 6px 0 0;padding:7px 14px;font-size:13px}.mL8Uca_tab:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_tab[data-active]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:600}.mL8Uca_panelContent{flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden}.mL8Uca_tabBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow-y:auto}.mL8Uca_fillBody{flex-direction:column;flex:1;gap:10px;min-height:0;display:flex;overflow:hidden}.mL8Uca_toolbar,.mL8Uca_controls{flex-wrap:wrap;flex:none;align-items:center;gap:8px;display:flex}.mL8Uca_controls .mL8Uca_input{flex:0 260px;min-width:150px}.mL8Uca_toolbarSpacer{flex:1}.mL8Uca_search{min-width:120px;color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;flex:0 260px;padding:6px 10px;font-size:13px}.mL8Uca_search::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_groupBySelect{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;outline:none;padding:6px 8px;font-size:13px}.mL8Uca_groupSection+.mL8Uca_groupSection{border-top:1px solid var(--dsw-alias-border-l1)}.mL8Uca_groupHeader{background:var(--dsw-alias-bg-base,var(--dsw-specific-input-major));align-items:center;gap:8px;padding:4px 8px;display:flex;position:sticky;top:0}.mL8Uca_groupToggle{min-width:0;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;border-radius:6px;flex:1;align-items:center;gap:6px;padding:4px 6px;font-size:12.5px;font-weight:500;display:flex}.mL8Uca_groupToggle:hover{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_groupChevron{border-right:1.5px solid var(--dsw-alias-label-tertiary);border-bottom:1.5px solid var(--dsw-alias-label-tertiary);flex:none;width:7px;height:7px;margin-bottom:2px;transition:transform .12s;transform:rotate(45deg)}.mL8Uca_groupChevron[data-collapsed]{margin-bottom:0;transform:rotate(-45deg)}.mL8Uca_groupName{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.mL8Uca_groupCount{color:var(--dsw-alias-label-tertiary);flex:none;font-weight:400}.mL8Uca_tableWrap{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex:1;min-height:0;overflow:auto}.mL8Uca_table{border-collapse:collapse;width:100%;font-size:12.5px}.mL8Uca_table th{z-index:1;text-align:left;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;padding:8px 10px;font-weight:600;position:sticky;top:0}.mL8Uca_table td{border-bottom:1px solid var(--dsw-alias-separator-primary);vertical-align:top;padding:7px 10px}.mL8Uca_table tbody tr:last-child td{border-bottom:none}.mL8Uca_table tbody tr:hover td{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellMuted{color:var(--dsw-alias-label-tertiary)}.mL8Uca_actions{white-space:nowrap;align-items:center;gap:8px;display:flex}.mL8Uca_inlineTest{color:var(--dsw-alias-label-secondary);align-items:center;gap:6px;font-size:11.5px;display:inline-flex}.mL8Uca_inlineTest[data-status=ok]{color:var(--dsw-alias-state-success-primary)}.mL8Uca_inlineTest[data-status=fail]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;border-radius:999px;padding:1px 8px;font-size:11px;line-height:1.6;display:inline-block}.mL8Uca_badge[data-kind=key]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_badge[data-kind=password]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_badge[data-status=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_badge[data-status=fail]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_badge[data-status=timeout]{color:var(--dsw-alias-state-warn-primary);border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_primaryButton{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-info-fill);cursor:pointer;white-space:nowrap;border:none;border-radius:8px;padding:6px 14px;font-size:13px;font-weight:600}.mL8Uca_primaryButton:hover:not(:disabled){background:var(--dsw-alias-button-info-hover)}.mL8Uca_primaryButton:disabled{opacity:.5;cursor:default}.mL8Uca_ghostButton{color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;white-space:nowrap;background:0 0;border-radius:8px;padding:5px 12px;font-size:12px}.mL8Uca_ghostButton:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_ghostButton:disabled{opacity:.45;cursor:default}.mL8Uca_iconButton{width:26px;height:26px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:6px;justify-content:center;align-items:center;padding:0;font-size:13px;display:inline-flex}.mL8Uca_iconButton:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.mL8Uca_linkButton{color:var(--dsw-alias-state-business-primary);cursor:pointer;white-space:nowrap;background:0 0;border:none;padding:0;font-size:12px}.mL8Uca_linkButton:hover:not(:disabled){text-decoration:underline}.mL8Uca_linkButton:disabled{opacity:.45;cursor:default}.mL8Uca_linkButton[data-danger]{color:var(--dsw-alias-state-error-primary)}.mL8Uca_spinner{border:2px solid var(--dsw-alias-state-business-primary);vertical-align:-1px;border-top-color:#0000;border-radius:50%;flex:none;width:11px;height:11px;animation:.8s linear infinite mL8Uca_dshSshSpin;display:inline-block}@keyframes mL8Uca_dshSshSpin{to{transform:rotate(360deg)}}.mL8Uca_banner{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere;border-radius:8px;padding:8px 12px;font-size:12.5px;line-height:1.5}.mL8Uca_banner[data-kind=ok]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_banner[data-kind=error]{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_banner[data-kind=info]{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_empty,.mL8Uca_loading{text-align:center;color:var(--dsw-alias-label-tertiary);padding:28px 12px;font-size:12.5px}.mL8Uca_modalBackdrop{z-index:40;background:var(--dsw-alias-bg-mask-1);justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.mL8Uca_modal{background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2);width:min(560px,100vw - 48px);max-height:calc(100vh - 96px);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:14px;flex-direction:column;gap:12px;padding:18px;display:flex;overflow-y:auto}.mL8Uca_modalTitle{margin:0;font-size:15px;font-weight:700}.mL8Uca_modalFooter{justify-content:flex-end;gap:10px;margin-top:4px;display:flex}.mL8Uca_formError{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}.mL8Uca_hint{color:var(--dsw-alias-label-tertiary);font-size:11.5px}.mL8Uca_field{flex-direction:column;gap:5px;display:flex}.mL8Uca_fieldLabel{color:var(--dsw-alias-label-secondary);font-size:12px;font-weight:600}.mL8Uca_input{color:var(--dsw-alias-label-primary);background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2);resize:vertical;border-radius:8px;outline:none;padding:7px 10px;font-family:inherit;font-size:13px}.mL8Uca_input:focus{border-color:var(--dsw-alias-state-business-primary)}.mL8Uca_input::placeholder{color:var(--dsw-alias-label-tertiary)}.mL8Uca_input:disabled{opacity:.55}.mL8Uca_formRow{grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 12px;display:grid}.mL8Uca_radioRow{align-items:center;gap:16px;display:flex}.mL8Uca_radioLabel{color:var(--dsw-alias-label-primary);cursor:pointer;align-items:center;gap:6px;font-size:13px;display:inline-flex}.mL8Uca_termBody{flex-direction:column;flex:1;gap:8px;min-height:0;display:flex;overflow:hidden}.mL8Uca_termWrap{border:1px solid var(--dsw-alias-border-l1);background:#0b0e14;border-radius:10px;flex:1;min-height:0;position:relative;overflow:hidden}.mL8Uca_termContainer{padding:8px 10px;position:absolute;inset:0}.mL8Uca_termPlaceholder{z-index:2;text-align:center;color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-base);justify-content:center;align-items:center;padding:0 24px;font-size:12.5px;display:flex;position:absolute;inset:0}.mL8Uca_hiddenFile{display:none}.mL8Uca_browsePanel{border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;display:flex;overflow:hidden}.mL8Uca_browseHeader{background:var(--dsw-alias-bg-layer-2);border-bottom:1px solid var(--dsw-alias-border-l1);align-items:center;gap:8px;padding:8px 10px;display:flex}.mL8Uca_browsePath{text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--dsw-alias-label-secondary);flex:1;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;overflow:hidden}.mL8Uca_browseList{max-height:240px;overflow-y:auto}.mL8Uca_dirRow{text-align:left;width:100%;font:inherit;color:var(--dsw-alias-label-primary);border:none;border-bottom:1px solid var(--dsw-alias-separator-primary);cursor:pointer;background:0 0;grid-template-columns:minmax(0,1fr) auto auto;gap:12px;padding:5px 10px;font-size:12.5px;display:grid}.mL8Uca_dirRow:hover{background:var(--dsw-alias-interactive-bg-hover)}.mL8Uca_dirRow[data-type=dir] .mL8Uca_dirName{color:var(--dsw-alias-state-business-primary)}.mL8Uca_dirRow[data-up]{color:var(--dsw-alias-label-secondary)}.mL8Uca_dirName{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;overflow:hidden}.mL8Uca_dirType{color:var(--dsw-alias-label-tertiary);white-space:nowrap}.mL8Uca_dirSize{color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-align:right}.mL8Uca_transferBlock{flex-direction:column;flex:none;gap:6px;display:flex}.mL8Uca_progressMeta{color:var(--dsw-alias-label-secondary);flex-wrap:wrap;gap:12px;font-size:12px;display:flex}.mL8Uca_progressTrack{background:var(--dsw-alias-interactive-bg-hover);border-radius:999px;height:8px;overflow:hidden}.mL8Uca_progressBar{background:var(--dsw-alias-state-business-primary);border-radius:999px;height:100%;transition:width .12s linear}.mL8Uca_tunnelList{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_tunnelRow{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:10px;align-items:center;gap:10px;padding:8px 12px;display:flex}.mL8Uca_tunnelRow[data-state=forwarding]{border-color:var(--dsw-alias-state-success-primary)}.mL8Uca_tunnelRow[data-state=connecting]{border-color:var(--dsw-alias-state-warn-primary)}.mL8Uca_tunnelRow[data-state=failed]{border-color:var(--dsw-alias-state-error-primary)}.mL8Uca_tunnelLabel{text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12.5px;overflow:hidden}.mL8Uca_formCard{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:10px;flex-direction:column;flex:none;gap:10px;padding:12px;display:flex}.mL8Uca_clusterForm{flex-direction:column;flex:none;gap:8px;display:flex}.mL8Uca_clusterFilters{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:10px;display:grid}.mL8Uca_commandInput{min-height:96px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.mL8Uca_cellDetails summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px}.mL8Uca_cellPre{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);white-space:pre-wrap;word-break:break-all;border-radius:6px;max-height:180px;margin:4px 0 0;padding:6px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;overflow:auto}";
@@ -1300,15 +1330,19 @@ window.__ModuleLoader__.load({
1300
1330
  const [collapsed, setCollapsed] = (0, react.useState)({});
1301
1331
  const [testingGroup, setTestingGroup] = (0, react.useState)(null);
1302
1332
  const seqRef = (0, react.useRef)(0);
1333
+ const mountedRef = (0, react.useRef)(true);
1334
+ (0, react.useEffect)(() => () => {
1335
+ mountedRef.current = false;
1336
+ }, []);
1303
1337
  const load = (0, react.useCallback)(async (query) => {
1304
1338
  const seq = ++seqRef.current;
1305
1339
  try {
1306
1340
  const list = await api.listHosts(query);
1307
- if (seq !== seqRef.current) return;
1341
+ if (!mountedRef.current || seq !== seqRef.current) return;
1308
1342
  setHosts(list);
1309
1343
  setError(null);
1310
1344
  } catch (cause) {
1311
- if (seq !== seqRef.current) return;
1345
+ if (!mountedRef.current || seq !== seqRef.current) return;
1312
1346
  setError(errorMessage(cause));
1313
1347
  }
1314
1348
  }, [api]);
@@ -14677,8 +14711,13 @@ window.__ModuleLoader__.load({
14677
14711
  style.textContent = XTERM_CSS;
14678
14712
  document.head.appendChild(style);
14679
14713
  }
14714
+ /** No-op source stand-in so the hook order stays stable without the prop. */
14715
+ const NO_FONT_SOURCE = {
14716
+ get: () => void 0,
14717
+ subscribe: () => () => void 0
14718
+ };
14680
14719
  /** The xterm terminal view. */
14681
- function TerminalTab({ api, presetAlias, requestId }) {
14720
+ function TerminalTab({ api, presetAlias, requestId, terminalFont }) {
14682
14721
  const [hosts, setHosts] = (0, react.useState)([]);
14683
14722
  const [alias, setAlias] = (0, react.useState)(presetAlias ?? "");
14684
14723
  const [status, setStatus] = (0, react.useState)({ kind: "idle" });
@@ -14687,9 +14726,20 @@ window.__ModuleLoader__.load({
14687
14726
  const fitRef = (0, react.useRef)(null);
14688
14727
  const connRef = (0, react.useRef)(null);
14689
14728
  const dataSubRef = (0, react.useRef)(null);
14729
+ const fontSource = terminalFont ?? NO_FONT_SOURCE;
14730
+ const fontOverride = (0, react.useSyncExternalStore)(fontSource.subscribe, fontSource.get);
14690
14731
  (0, react.useEffect)(() => {
14691
14732
  ensureXtermCss();
14692
14733
  }, []);
14734
+ (0, react.useEffect)(() => {
14735
+ const term = termRef.current;
14736
+ if (term === null) return;
14737
+ const next = resolveTerminalFontFamily(fontOverride);
14738
+ if (term.options.fontFamily === next) return;
14739
+ term.options.fontFamily = next;
14740
+ fitRef.current?.fit();
14741
+ connRef.current?.resize(term.cols, term.rows);
14742
+ }, [fontOverride]);
14693
14743
  (0, react.useEffect)(() => {
14694
14744
  let disposed = false;
14695
14745
  (async () => {
@@ -14752,7 +14802,7 @@ window.__ModuleLoader__.load({
14752
14802
  convertEol: false,
14753
14803
  cursorBlink: true,
14754
14804
  fontSize: 13,
14755
- fontFamily: "Menlo, Consolas, \"Liberation Mono\", monospace",
14805
+ fontFamily: resolveTerminalFontFamily(fontOverride),
14756
14806
  theme: {
14757
14807
  background: "#0b0e14",
14758
14808
  foreground: "#d8dee9",
@@ -14780,7 +14830,7 @@ window.__ModuleLoader__.load({
14780
14830
  connection.onOutput = (data) => {
14781
14831
  term.write(data);
14782
14832
  };
14783
- connection.onExit = (code, error) => {
14833
+ connection.onExit = (_code, error) => {
14784
14834
  if (settled) return;
14785
14835
  settled = true;
14786
14836
  dataSubRef.current?.dispose();
@@ -14864,7 +14914,8 @@ window.__ModuleLoader__.load({
14864
14914
  className: panel_module_css_default.termWrap,
14865
14915
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
14866
14916
  ref: containerRef,
14867
- className: panel_module_css_default.termContainer
14917
+ className: panel_module_css_default.termContainer,
14918
+ "data-dsh-part": "terminal"
14868
14919
  }), status.kind === "idle" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
14869
14920
  className: panel_module_css_default.termPlaceholder,
14870
14921
  children: hosts.length === 0 ? tt("hosts.empty") : tt("terminal.placeholder")
@@ -15500,7 +15551,7 @@ window.__ModuleLoader__.load({
15500
15551
  }
15501
15552
  ];
15502
15553
  /** The tabbed SSH panel. */
15503
- function SshPanel({ controller, api }) {
15554
+ function SshPanel({ controller, api, terminalFont }) {
15504
15555
  const [activeTab, setActiveTab] = (0, react.useState)("hosts");
15505
15556
  const [connectRequest, setConnectRequest] = (0, react.useState)(null);
15506
15557
  const handleConnect = (alias) => {
@@ -15534,6 +15585,7 @@ window.__ModuleLoader__.load({
15534
15585
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
15535
15586
  className: panel_module_css_default.tabBar,
15536
15587
  role: "tablist",
15588
+ "data-dsh-part": "tab-bar",
15537
15589
  children: TABS.map((tab) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
15538
15590
  type: "button",
15539
15591
  role: "tab",
@@ -15556,7 +15608,8 @@ window.__ModuleLoader__.load({
15556
15608
  activeTab === "terminal" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalTab, {
15557
15609
  api,
15558
15610
  presetAlias: connectRequest?.alias,
15559
- requestId: connectRequest?.nonce
15611
+ requestId: connectRequest?.nonce,
15612
+ terminalFont
15560
15613
  }),
15561
15614
  activeTab === "transfer" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TransferTab, { api }),
15562
15615
  activeTab === "tunnels" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TunnelsTab, { api }),
@@ -15597,9 +15650,10 @@ window.__ModuleLoader__.load({
15597
15650
  * to the controller's panelOpen state.
15598
15651
  * @param controller - the panel controller driving the view.
15599
15652
  * @param api - the SSH API client the tabs operate through.
15653
+ * @param terminalFont - live terminal-font setting source (issue #577).
15600
15654
  * @returns disposer unmounting the tree and restoring the column.
15601
15655
  */
15602
- function mountPanel(controller, api) {
15656
+ function mountPanel(controller, api, terminalFont) {
15603
15657
  let root;
15604
15658
  let container;
15605
15659
  const ensure = () => {
@@ -15614,12 +15668,14 @@ window.__ModuleLoader__.load({
15614
15668
  if (column === void 0) return;
15615
15669
  container = document.createElement("div");
15616
15670
  container.dataset.dshSshView = "";
15671
+ container.dataset.dshPlugin = "ssh";
15617
15672
  container.className = panel_module_css_default.view;
15618
15673
  column.appendChild(container);
15619
15674
  root = (0, react_dom_client.createRoot)(container);
15620
15675
  root.render(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SshPanel, {
15621
15676
  controller,
15622
- api
15677
+ api,
15678
+ terminalFont
15623
15679
  }));
15624
15680
  };
15625
15681
  const waitObserver = new MutationObserver(() => {
@@ -15697,7 +15753,7 @@ window.__ModuleLoader__.load({
15697
15753
  }
15698
15754
  };
15699
15755
  //#endregion
15700
- //#region src/client/sidebar-entry.ts
15756
+ //#region src/client/sidebar-entry-core.ts
15701
15757
  /** Find the sidebar shell root element, or undefined while not yet mounted. */
15702
15758
  function sidebarRoot() {
15703
15759
  const column = document.querySelector("[data-pane=\"sidebar\"], [class*=\"sidebarCol\"]");
@@ -15711,28 +15767,30 @@ window.__ModuleLoader__.load({
15711
15767
  for (const child of root.children) if (child.tagName === "BUTTON") return child;
15712
15768
  }
15713
15769
  /** Build the entry row (a detached button; insert once the shell is up). */
15714
- function createEntry(controller) {
15770
+ function createEntry(options) {
15715
15771
  const entry = document.createElement("button");
15716
15772
  entry.type = "button";
15717
- entry.dataset.dshSshEntry = "";
15718
- entry.className = panel_module_css_default.entry;
15719
- entry.setAttribute("aria-label", tt("entry.label"));
15720
- entry.setAttribute("title", tt("entry.tooltip"));
15721
- entry.innerHTML = "<span class=\"" + panel_module_css_default.entryIcon + "\"><svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"2.5\" width=\"12\" height=\"11\" rx=\"1.5\"/><path d=\"M4.5 5.5l2.5 2.5-2.5 2.5\"/><path d=\"M8.5 10.5h3\"/></svg></span><span class=\"" + panel_module_css_default.entryLabel + "\">" + tt("entry.label") + "</span>";
15722
- entry.addEventListener("click", () => {
15723
- controller.toggle();
15724
- });
15773
+ entry.setAttribute(options.rowAttribute, "");
15774
+ if (options.plugin !== void 0) {
15775
+ entry.setAttribute("data-dsh-plugin", options.plugin);
15776
+ entry.setAttribute("data-dsh-part", "sidebar-entry");
15777
+ }
15778
+ entry.className = options.css["entry"] ?? "";
15779
+ entry.setAttribute("aria-label", options.label());
15780
+ if (options.tooltip !== void 0) entry.setAttribute("title", options.tooltip());
15781
+ entry.innerHTML = "<span class=\"" + (options.css["entryIcon"] ?? "") + "\">" + options.icon + "</span><span class=\"" + (options.css["entryLabel"] ?? "") + "\">" + options.label() + "</span>";
15782
+ entry.addEventListener("click", options.onToggle);
15725
15783
  return entry;
15726
15784
  }
15727
15785
  /** Re-insert the entry after the New Session row (before the browser region). */
15728
- function placeEntry(root, entry) {
15786
+ function placeEntry(root, entry, options) {
15729
15787
  const button = newSessionButton(root);
15730
15788
  if (button === void 0) return false;
15731
15789
  if (entry.parentElement !== root) {
15732
15790
  const row = button.closest("[class*=\"logoRow\"]");
15733
15791
  const base = row !== null && row.parentElement === root ? row : button;
15734
- const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches("[data-dsh-taskboard-entry], [data-dsh-ssh-entry]"));
15735
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
15792
+ const family = Array.from(root.children).filter((el) => el instanceof HTMLElement && el.matches(options.familySelectors.join(", ")));
15793
+ const anchor = options.position === "before" ? family.length > 0 ? family[0] : base.nextElementSibling : family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling;
15736
15794
  root.insertBefore(entry, anchor);
15737
15795
  }
15738
15796
  return true;
@@ -15740,11 +15798,12 @@ window.__ModuleLoader__.load({
15740
15798
  /**
15741
15799
  * Mount the sidebar entry, waiting for the shell to render and self-healing
15742
15800
  * on later React re-renders.
15743
- * @param controller - the panel controller the entry toggles.
15801
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
15744
15802
  * @returns disposer removing the entry and its observers.
15745
15803
  */
15746
- function mountSidebarEntry(controller) {
15747
- const entry = createEntry(controller);
15804
+ function mountSidebarEntry$1(options) {
15805
+ if (typeof document !== "undefined" && document.querySelector(options.rowSelector) !== null) return () => {};
15806
+ const entry = createEntry(options);
15748
15807
  let root;
15749
15808
  let placed = false;
15750
15809
  const tryPlace = () => {
@@ -15761,7 +15820,7 @@ window.__ModuleLoader__.load({
15761
15820
  }
15762
15821
  root ??= sidebarRoot();
15763
15822
  if (root === void 0) return;
15764
- placed = placeEntry(root, entry);
15823
+ placed = placeEntry(root, entry, options);
15765
15824
  if (placed) rootObserver.observe(root, {
15766
15825
  childList: true,
15767
15826
  subtree: true
@@ -15780,28 +15839,69 @@ window.__ModuleLoader__.load({
15780
15839
  tryPlace();
15781
15840
  return;
15782
15841
  }
15783
- if (!root.contains(entry)) placed = placeEntry(root, entry);
15842
+ if (!root.contains(entry)) placed = placeEntry(root, entry, options);
15784
15843
  });
15785
- const syncActive = () => {
15786
- if (controller.getSnapshot().panelOpen) entry.dataset.active = "true";
15787
- else delete entry.dataset.active;
15788
- };
15789
- const unsubscribe = controller.subscribe(syncActive);
15790
- syncActive();
15844
+ const unsubscribeActive = options.active === void 0 ? void 0 : (() => {
15845
+ const syncActive = () => {
15846
+ if (options.active.isOpen()) entry.dataset.active = "true";
15847
+ else delete entry.dataset.active;
15848
+ };
15849
+ const unsubscribe = options.active.subscribe(syncActive);
15850
+ syncActive();
15851
+ return unsubscribe;
15852
+ })();
15791
15853
  tryPlace();
15792
15854
  return () => {
15793
15855
  waitObserver.disconnect();
15794
15856
  rootObserver.disconnect();
15795
- unsubscribe();
15857
+ unsubscribeActive?.();
15796
15858
  entry.remove();
15797
15859
  };
15798
15860
  }
15799
15861
  //#endregion
15862
+ //#region src/client/sidebar-entry.ts
15863
+ /** Stable data attribute identifying the injected entry row. */
15864
+ const ENTRY_SELECTOR = "[data-dsh-ssh-entry]";
15865
+ /** Inline icon (matches the shell's 16px nav-icon look): a terminal prompt glyph. */
15866
+ const ICON = "<svg viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"2.5\" width=\"12\" height=\"11\" rx=\"1.5\"/><path d=\"M4.5 5.5l2.5 2.5-2.5 2.5\"/><path d=\"M8.5 10.5h3\"/></svg>";
15867
+ /**
15868
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
15869
+ * on later React re-renders.
15870
+ * @param controller - the panel controller the entry toggles.
15871
+ * @returns disposer removing the entry and its observers.
15872
+ */
15873
+ function mountSidebarEntry(controller) {
15874
+ return mountSidebarEntry$1({
15875
+ rowAttribute: "data-dsh-ssh-entry",
15876
+ rowSelector: ENTRY_SELECTOR,
15877
+ plugin: "ssh",
15878
+ icon: ICON,
15879
+ css: panel_module_css_default,
15880
+ label: () => tt("entry.label"),
15881
+ tooltip: () => tt("entry.tooltip"),
15882
+ onToggle: () => {
15883
+ controller.toggle();
15884
+ },
15885
+ position: "after",
15886
+ familySelectors: ["[data-dsh-taskboard-entry]", "[data-dsh-ssh-entry]"],
15887
+ active: {
15888
+ subscribe: (listener) => controller.subscribe(listener),
15889
+ isOpen: () => controller.getSnapshot().panelOpen
15890
+ }
15891
+ });
15892
+ }
15893
+ //#endregion
15800
15894
  //#region src/client/index.ts
15801
15895
  /** Locale namespace this plugin owns. */
15802
15896
  const NS = "dsh-ssh";
15897
+ /** Settings namespace the terminal-font preference lives in (issue #577). */
15898
+ const SETTINGS_NS = "dsh-ssh";
15803
15899
  /** Required services (fiber inject waiting — the runtime must be up first). */
15804
- const inject = ["slots", "locale"];
15900
+ const inject = [
15901
+ "slots",
15902
+ "locale",
15903
+ "settingsScope"
15904
+ ];
15805
15905
  /**
15806
15906
  * Mount the SSH panel.
15807
15907
  * @param ctx - client root context (locale service).
@@ -15813,10 +15913,18 @@ window.__ModuleLoader__.load({
15813
15913
  }), "dsh-ssh: dictionaries");
15814
15914
  const controller = new PanelController();
15815
15915
  const api = new SshApi();
15916
+ const scope = (ctx.get("webUiSettings") ?? ctx.settingsScope).bind({ namespace: SETTINGS_NS });
15917
+ const terminalFont = {
15918
+ get: () => {
15919
+ const snapshot = scope.getSnapshot();
15920
+ return snapshot.status === "ready" ? snapshot.value?.terminalFontFamily : void 0;
15921
+ },
15922
+ subscribe: (listener) => scope.subscribe(listener)
15923
+ };
15816
15924
  const disposers = [];
15817
15925
  try {
15818
15926
  disposers.push(mountSidebarEntry(controller));
15819
- disposers.push(mountPanel(controller, api));
15927
+ disposers.push(mountPanel(controller, api, terminalFont));
15820
15928
  } catch (error) {
15821
15929
  console.warn("[dsh-ssh] mount failed:", error);
15822
15930
  }