@linxin666/dsh-ssh 0.2.0 → 0.2.1

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 {
@@ -2283,7 +2283,10 @@ function makeRoutes(deps) {
2283
2283
  const { store, engine } = deps;
2284
2284
  const staging = deps.stagingDir ?? join(tmpdir(), "dsh-ssh-uploads");
2285
2285
  const maxUploadBytes = deps.maxUploadBytes ?? MAX_UPLOAD_BYTES;
2286
- mkdirSync(staging, { recursive: true });
2286
+ mkdirSync(staging, {
2287
+ recursive: true,
2288
+ mode: 448
2289
+ });
2287
2290
  /** Guard helper: fence + method check. */
2288
2291
  const guard = (req, res, method) => {
2289
2292
  if (!isLoopbackRequest(req)) {
@@ -2543,7 +2546,7 @@ function makeRoutes(deps) {
2543
2546
  } catch {}
2544
2547
  };
2545
2548
  const tmp = join(staging, `upload-${randomBytes(6).toString("hex")}`);
2546
- const sink = createWriteStream(tmp);
2549
+ const sink = createWriteStream(tmp, { mode: 384 });
2547
2550
  let settled = false;
2548
2551
  const fail = (error) => {
2549
2552
  if (settled) return;
@@ -2649,6 +2652,7 @@ function makeRoutes(deps) {
2649
2652
  }
2650
2653
  const tmp = join(staging, `download-${randomBytes(6).toString("hex")}`);
2651
2654
  try {
2655
+ closeSync(openSync(tmp, "w", 384));
2652
2656
  const outcome = await engine.download(alias, remotePath, tmp);
2653
2657
  res.writeHead(200, {
2654
2658
  "content-type": "application/octet-stream",
@@ -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. */
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.1",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -57,6 +57,7 @@ export function mountPanel(controller: PanelController, api: SshApi): () => void
57
57
  if (column === undefined) return
58
58
  container = document.createElement('div')
59
59
  container.dataset.dshSshView = ''
60
+ container.dataset.dshPlugin = 'ssh'
60
61
  container.className = css.view
61
62
  column.appendChild(container)
62
63
  root = createRoot(container)
@@ -66,7 +66,7 @@ export function SshPanel({ controller, api }: SshPanelProps) {
66
66
  </button>
67
67
  <h2 className={css.panelTitle}>{tt('panel.title')}</h2>
68
68
  </div>
69
- <div className={css.tabBar} role="tablist">
69
+ <div className={css.tabBar} role="tablist" data-dsh-part="tab-bar">
70
70
  {TABS.map(tab => (
71
71
  <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
72
  {tab.label()}
@@ -173,7 +173,7 @@ export function TerminalTab({ api, presetAlias, requestId }: TerminalTabProps) {
173
173
  )}
174
174
  {status.kind === 'error' && <div className={css.banner} data-kind="error">{tt('terminal.error', { error: status.detail })}</div>}
175
175
  <div className={css.termWrap}>
176
- <div ref={containerRef} className={css.termContainer} />
176
+ <div ref={containerRef} className={css.termContainer} data-dsh-part="terminal" />
177
177
  {status.kind === 'idle' && (
178
178
  <div className={css.termPlaceholder}>{hosts.length === 0 ? tt('hosts.empty') : tt('terminal.placeholder')}</div>
179
179
  )}
@@ -0,0 +1,208 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/sidebar-entry-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared sidebar entry injection core.
4
+ *
5
+ * dsh's sidebar shell exposes no slot an external plugin can register into,
6
+ * so the entry row is injected between the shell's New Session button and the
7
+ * workspace browser. The injection self-heals: a MutationObserver watches the
8
+ * sidebar 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 view it toggles is a separate root owned by the caller.
13
+ *
14
+ * Packages receive this file as a generated copy via scripts/sync-shared.mjs;
15
+ * edit the shared source and re-run the sync instead of editing a copy.
16
+ */
17
+
18
+ /** Per-package configuration for one sidebar entry row. */
19
+ export interface SidebarEntryOptions {
20
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
21
+ rowAttribute: string
22
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
23
+ rowSelector: string
24
+ /**
25
+ * L2 semantic-attribute plugin id (issue #506, enum table:
26
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
27
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
28
+ * leaves the row without semantic attributes.
29
+ */
30
+ plugin?: string
31
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
32
+ icon: string
33
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
34
+ css: Record<string, string>
35
+ /** Localized row label (aria-label + visible text). */
36
+ label(): string
37
+ /** Optional localized tooltip (title attribute). */
38
+ tooltip?(): string
39
+ /** Click action (open/toggle the owning panel). */
40
+ onToggle(): void
41
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
42
+ position: 'before' | 'after'
43
+ /**
44
+ * Selectors of the sibling plugin entry rows this package orders against
45
+ * (its own row included — the placement guard excludes a row that is
46
+ * already inside the root). Each package passes the same list it used
47
+ * before the consolidation so the rendered order stays stable.
48
+ */
49
+ familySelectors: readonly string[]
50
+ /** Optional active-state bridge; highlights the row while the panel is open. */
51
+ active?: {
52
+ subscribe(listener: () => void): () => void
53
+ isOpen(): boolean
54
+ }
55
+ }
56
+
57
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
58
+ function sidebarRoot(): HTMLElement | undefined {
59
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
60
+ if (column === null) return undefined
61
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
62
+ // Prefer the element that owns the logo row — the real sidebar UI root —
63
+ // and fall back to the column's first child for legacy shells.
64
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
65
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
66
+ }
67
+
68
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
69
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
70
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
71
+ if (nested !== null) return nested
72
+ for (const child of root.children) {
73
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
74
+ }
75
+ return undefined
76
+ }
77
+
78
+ /** Build the entry row (a detached button; insert once the shell is up). */
79
+ function createEntry(options: SidebarEntryOptions): HTMLButtonElement {
80
+ const entry = document.createElement('button')
81
+ entry.type = 'button'
82
+ entry.setAttribute(options.rowAttribute, '')
83
+ if (options.plugin !== undefined) {
84
+ entry.setAttribute('data-dsh-plugin', options.plugin)
85
+ entry.setAttribute('data-dsh-part', 'sidebar-entry')
86
+ }
87
+ entry.className = options.css['entry'] ?? ''
88
+ entry.setAttribute('aria-label', options.label())
89
+ if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
90
+ entry.innerHTML = '<span class="' + (options.css['entryIcon'] ?? '') + '">' + options.icon
91
+ + '</span><span class="' + (options.css['entryLabel'] ?? '') + '">' + options.label() + '</span>'
92
+ entry.addEventListener('click', options.onToggle)
93
+ return entry
94
+ }
95
+
96
+ /** Re-insert the entry after the New Session row (before the browser region). */
97
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement, options: SidebarEntryOptions): boolean {
98
+ const button = newSessionButton(root)
99
+ if (button === undefined) return false
100
+ if (entry.parentElement !== root) {
101
+ // Position relative to the family block (entries injected by sibling
102
+ // plugins), never relative to transient logoRow geometry: every family
103
+ // plugin that self-heals during a re-render then lands in the same
104
+ // relative order, so the entries cannot swap positions regardless of
105
+ // observer callback order or of shell wrapper changes. There is no
106
+ // append-to-end fallback: appending at the end would randomly reorder
107
+ // the block after a shell re-render.
108
+ const row = button.closest('[class*="logoRow"]')
109
+ const base = (row !== null && row.parentElement === root) ? row : button
110
+ const family = Array.from(root.children).filter(
111
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
112
+ )
113
+ const anchor = options.position === 'before'
114
+ ? (family.length > 0 ? family[0] : base.nextElementSibling)
115
+ : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
116
+ root.insertBefore(entry, anchor)
117
+ }
118
+ return true
119
+ }
120
+
121
+ /**
122
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
123
+ * on later React re-renders.
124
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
125
+ * @returns disposer removing the entry and its observers.
126
+ */
127
+ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
128
+ // DOM-level idempotency: whatever path mounted an entry row before this
129
+ // call (a duplicated apply, an HMR re-injection, a stale module still
130
+ // alive), never mount a second one. The existing row keeps working; a full
131
+ // page reload is the ultimate reset.
132
+ if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
133
+ return () => {}
134
+ }
135
+ const entry = createEntry(options)
136
+ let root: HTMLElement | undefined
137
+ let placed = false
138
+
139
+ const tryPlace = (): void => {
140
+ if (root !== undefined && !root.isConnected) {
141
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
142
+ // observer is gone with the old tree, so detach it and re-query from
143
+ // scratch. The new pane is later noticed by the body-level watcher.
144
+ rootObserver.disconnect()
145
+ root = undefined
146
+ placed = false
147
+ }
148
+ if (placed) {
149
+ // Cheap short-circuit: entry still lives in a mountable subtree.
150
+ if (document.body.contains(entry)) return
151
+ // Entry was torn down together with the old tree; reset and re-place.
152
+ rootObserver.disconnect()
153
+ root = undefined
154
+ placed = false
155
+ }
156
+ root ??= sidebarRoot()
157
+ if (root === undefined) return
158
+ placed = placeEntry(root, entry, options)
159
+ if (placed) {
160
+ rootObserver.observe(root, { childList: true, subtree: true })
161
+ }
162
+ }
163
+
164
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
165
+ // tears down the whole sidebar pane, the root observer is gone with it and
166
+ // only this body observation can notice the new pane mounting. It is no
167
+ // longer disconnected after placement; the placed-and-still-mounted case
168
+ // short-circuits through the cheap document.body.contains(entry) check, so
169
+ // unrelated app mutations (e.g. chat streaming) cost one contains check
170
+ // instead of churning the full re-query.
171
+ const waitObserver = new MutationObserver(() => { tryPlace() })
172
+ waitObserver.observe(document.body, { childList: true, subtree: true })
173
+
174
+ // Self-heal: if a React re-render displaces the row, re-insert it in the
175
+ // same frame (microtask before paint -> no visible flicker).
176
+ const rootObserver = new MutationObserver(() => {
177
+ if (root === undefined || !root.isConnected) {
178
+ placed = false
179
+ tryPlace()
180
+ return
181
+ }
182
+ if (!root.contains(entry)) {
183
+ placed = placeEntry(root, entry, options)
184
+ }
185
+ })
186
+
187
+ // Reflect the panel's open state on the row (active highlight). Note: assigning
188
+ // undefined to dataset.active materializes data-active="undefined" and keeps the
189
+ // row permanently highlighted — delete the attribute instead.
190
+ const unsubscribeActive = options.active === undefined ? undefined : (() => {
191
+ const syncActive = (): void => {
192
+ if (options.active!.isOpen()) entry.dataset.active = 'true'
193
+ else delete entry.dataset.active
194
+ }
195
+ const unsubscribe = options.active.subscribe(syncActive)
196
+ syncActive()
197
+ return unsubscribe
198
+ })()
199
+
200
+ tryPlace()
201
+
202
+ return () => {
203
+ waitObserver.disconnect()
204
+ rootObserver.disconnect()
205
+ unsubscribeActive?.()
206
+ entry.remove()
207
+ }
208
+ }
@@ -1,20 +1,17 @@
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
  import { tt } from './panel/helpers.ts'
17
13
  import css from './panel/panel.module.css'
14
+ import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
18
15
 
19
16
  /** Stable data attribute identifying the injected entry row. */
20
17
  export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
@@ -22,64 +19,6 @@ export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
22
19
  /** Inline icon (matches the shell's 16px nav-icon look): a terminal prompt glyph. */
23
20
  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>'
24
21
 
25
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
26
- function sidebarRoot(): HTMLElement | undefined {
27
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
- if (column === null) return undefined
29
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
30
- // Prefer the element that owns the logo row — the real sidebar UI root —
31
- // and fall back to the column's first child for legacy shells.
32
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
33
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
34
- }
35
-
36
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
37
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
38
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
39
- if (nested !== null) return nested
40
- for (const child of root.children) {
41
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
42
- }
43
- return undefined
44
- }
45
-
46
- /** Build the entry row (a detached button; insert once the shell is up). */
47
- function createEntry(controller: PanelController): HTMLButtonElement {
48
- const entry = document.createElement('button')
49
- entry.type = 'button'
50
- entry.dataset.dshSshEntry = ''
51
- entry.className = css.entry
52
- entry.setAttribute('aria-label', tt('entry.label'))
53
- entry.setAttribute('title', tt('entry.tooltip'))
54
- entry.innerHTML = '<span class="' + css.entryIcon + '">' + ICON + '</span><span class="' + css.entryLabel + '">' + tt('entry.label') + '</span>'
55
- entry.addEventListener('click', () => { controller.toggle() })
56
- return entry
57
- }
58
-
59
- /** Re-insert the entry after the New Session row (before the browser region). */
60
- function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
61
- const button = newSessionButton(root)
62
- if (button === undefined) return false
63
- if (entry.parentElement !== root) {
64
- // Position relative to the family block (entries injected by sibling
65
- // plugins), never relative to transient logoRow geometry: every family
66
- // plugin that self-heals during a re-render then lands in the same
67
- // relative order, so the entries cannot swap positions regardless of
68
- // observer callback order or of shell wrapper changes. There is no
69
- // append-to-end fallback: appending at the end would randomly reorder
70
- // the block after a shell re-render.
71
- const row = button.closest('[class*="logoRow"]')
72
- const base = (row !== null && row.parentElement === root) ? row : button
73
- const family = Array.from(root.children).filter(
74
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry]'),
75
- )
76
- // ssh sits after the whole family block.
77
- const anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
78
- root.insertBefore(entry, anchor)
79
- }
80
- return true
81
- }
82
-
83
22
  /**
84
23
  * Mount the sidebar entry, waiting for the shell to render and self-healing
85
24
  * on later React re-renders.
@@ -87,74 +26,20 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
87
26
  * @returns disposer removing the entry and its observers.
88
27
  */
89
28
  export function mountSidebarEntry(controller: PanelController): () => void {
90
- const entry = createEntry(controller)
91
- let root: HTMLElement | undefined
92
- let placed = false
93
-
94
- const tryPlace = (): void => {
95
- if (root !== undefined && !root.isConnected) {
96
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
97
- // observer is gone with the old tree, so detach it and re-query from
98
- // scratch. The new pane is later noticed by the body-level watcher.
99
- rootObserver.disconnect()
100
- root = undefined
101
- placed = false
102
- }
103
- if (placed) {
104
- // Cheap short-circuit: entry still lives in a mountable subtree.
105
- if (document.body.contains(entry)) return
106
- // Entry was torn down together with the old tree; reset and re-place.
107
- rootObserver.disconnect()
108
- root = undefined
109
- placed = false
110
- }
111
- root ??= sidebarRoot()
112
- if (root === undefined) return
113
- placed = placeEntry(root, entry)
114
- if (placed) {
115
- rootObserver.observe(root, { childList: true, subtree: true })
116
- }
117
- }
118
-
119
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
120
- // tears down the whole sidebar pane, the root observer is gone with it and
121
- // only this body observation can notice the new pane mounting. It is no
122
- // longer disconnected after placement; the placed-and-still-mounted case
123
- // short-circuits through the cheap document.body.contains(entry) check, so
124
- // unrelated app mutations (e.g. chat streaming) cost one contains check
125
- // instead of churning the full re-query.
126
- const waitObserver = new MutationObserver(() => { tryPlace() })
127
- waitObserver.observe(document.body, { childList: true, subtree: true })
128
-
129
- // Self-heal: if a React re-render displaces the row, re-insert it in the
130
- // same frame (microtask before paint -> no visible flicker).
131
- const rootObserver = new MutationObserver(() => {
132
- if (root === undefined || !root.isConnected) {
133
- placed = false
134
- tryPlace()
135
- return
136
- }
137
- if (!root.contains(entry)) {
138
- placed = placeEntry(root, entry)
139
- }
29
+ return mountSharedSidebarEntry({
30
+ rowAttribute: 'data-dsh-ssh-entry',
31
+ rowSelector: ENTRY_SELECTOR,
32
+ plugin: 'ssh',
33
+ icon: ICON,
34
+ css,
35
+ label: () => tt('entry.label'),
36
+ tooltip: () => tt('entry.tooltip'),
37
+ onToggle: () => { controller.toggle() },
38
+ position: 'after',
39
+ familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
40
+ active: {
41
+ subscribe: (listener) => controller.subscribe(listener),
42
+ isOpen: () => controller.getSnapshot().panelOpen,
43
+ },
140
44
  })
141
-
142
- // Reflect the panel's open state on the row (active highlight). Note: assigning
143
- // undefined to dataset.active materializes data-active="undefined" and keeps the
144
- // row permanently highlighted — delete the attribute instead.
145
- const syncActive = () => {
146
- if (controller.getSnapshot().panelOpen) entry.dataset.active = 'true'
147
- else delete entry.dataset.active
148
- }
149
- const unsubscribe = controller.subscribe(syncActive)
150
- syncActive()
151
-
152
- tryPlace()
153
-
154
- return () => {
155
- waitObserver.disconnect()
156
- rootObserver.disconnect()
157
- unsubscribe()
158
- entry.remove()
159
- }
160
45
  }