@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/README.i18n.yaml +2 -2
- package/README.md +4 -4
- package/README.zh.md +2 -2
- package/lib/client.js +67 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +26 -22
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +7 -11
- package/package.json +1 -1
- package/src/client/mount.tsx +1 -0
- package/src/client/panel/SshPanel.tsx +1 -1
- package/src/client/panel/TerminalTab.tsx +1 -1
- package/src/client/sidebar-entry-core.ts +208 -0
- package/src/client/sidebar-entry.ts +23 -138
- package/src/engine/connection-pool.ts +27 -21
- package/src/routes.ts +8 -3
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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, {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* (
|
|
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.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/mount.tsx
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* (
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
}
|