@linxin666/dsh-ssh 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/client.js +371 -461
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +707 -466
  4. package/lib/types/client/api.d.ts +55 -4
  5. package/lib/types/client/locales.d.ts +1 -0
  6. package/lib/types/client/native-panel.d.ts +70 -0
  7. package/lib/types/client/panel/SshPanel.d.ts +1 -3
  8. package/lib/types/client/panel/TerminalTab.d.ts +6 -1
  9. package/lib/types/client/panel/controller.d.ts +71 -4
  10. package/lib/types/engine/terminal-sessions.d.ts +67 -0
  11. package/lib/types/mount-once.d.ts +38 -7
  12. package/lib/types/protocol.d.ts +12 -1
  13. package/lib/types/routes.d.ts +2 -0
  14. package/package.json +19 -16
  15. package/src/client/api.ts +137 -24
  16. package/src/client/index.ts +31 -9
  17. package/src/client/locales.ts +2 -0
  18. package/src/client/native-panel.tsx +141 -0
  19. package/src/client/panel/SshPanel.tsx +25 -28
  20. package/src/client/panel/TerminalTab.tsx +54 -14
  21. package/src/client/panel/controller.ts +126 -7
  22. package/src/client/panel/panel.module.css +8 -128
  23. package/src/engine/terminal-sessions.ts +280 -0
  24. package/src/index.ts +4 -1
  25. package/src/mount-once.ts +107 -13
  26. package/src/protocol.ts +6 -1
  27. package/src/routes.ts +26 -107
  28. package/lib/types/client/body-mutations.d.ts +0 -45
  29. package/lib/types/client/mount.d.ts +0 -27
  30. package/lib/types/client/panel-mount-core.d.ts +0 -56
  31. package/lib/types/client/sidebar-entry-core.d.ts +0 -55
  32. package/lib/types/client/sidebar-entry.d.ts +0 -26
  33. package/src/client/body-mutations.ts +0 -133
  34. package/src/client/mount.tsx +0 -47
  35. package/src/client/panel-mount-core.ts +0 -155
  36. package/src/client/sidebar-entry-core.ts +0 -237
  37. package/src/client/sidebar-entry.ts +0 -51
@@ -1,133 +0,0 @@
1
- // Generated by scripts/sync-shared.mjs from shared/client/body-mutations.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * One `document.body` MutationObserver per page, shared by every family
4
- * plugin bundle.
5
- *
6
- * Family plugins that inject at the DOM level must notice when the shell
7
- * re-renders around their injected node: the sidebar entry rows
8
- * (sidebar-entry-core), the center-column panel containers
9
- * (panel-mount-core), and the aggregate shell's column shims. Each consumer
10
- * used to install its OWN `MutationObserver` on `document.body` with
11
- * `{ childList: true, subtree: true }`, so a page carrying N family plugins
12
- * paid N native observers and N callback invocations for EVERY mutation batch
13
- * the app produced — chat token streaming alone produces many per second, and
14
- * the count grows with the number of installed plugins.
15
- *
16
- * This module keeps exactly ONE body observer per page. The registry lives on
17
- * `globalThis` under a `Symbol.for` key, so the per-package generated copies
18
- * of this file (scripts/sync-shared.mjs) and separately bundled plugins all
19
- * reach the same hub at runtime instead of one hub per module instance.
20
- * Record subscribers receive the mutations accumulated since the last flush.
21
- * Consumers that only re-check their DOM use subscribeBodyInvalidations:
22
- * when all subscribers use that path, the hub retains no mutation records
23
- * (or detached subtrees) while a background page's animation frames pause.
24
- * Both paths run at most once per frame. The last subscriber disconnects the
25
- * observer, cancels the pending frame and releases its records.
26
- *
27
- * Failure policy: without a DOM or `MutationObserver` the subscription is a
28
- * no-op disposer (the same silence the per-consumer observers had), and when
29
- * `requestAnimationFrame` is unavailable the flush runs synchronously so a
30
- * subscriber is never silently dropped. A throwing subscriber cannot stop the
31
- * others.
32
- */
33
-
34
- /** The page-wide hub: one observer plus its subscribers and pending records. */
35
- interface BodyMutationHub {
36
- observer: MutationObserver
37
- subscribers: Set<(records: MutationRecord[]) => void>
38
- pending: MutationRecord[]
39
- scheduled: boolean
40
- frame?: number
41
- }
42
-
43
- /** Cross-bundle registry key; `Symbol.for` so every module copy agrees. */
44
- const HUB_KEY = Symbol.for('dsh-web.body-mutation-hub')
45
- const INVALIDATION_ONLY = Symbol.for('dsh-web.body-mutation-invalidation')
46
- type Subscriber = ((records: MutationRecord[]) => void) & { [INVALIDATION_ONLY]?: true }
47
-
48
- function needsRecords(subscribers: Set<(records: MutationRecord[]) => void>): boolean {
49
- for (const listener of subscribers) {
50
- if (!(listener as Subscriber)[INVALIDATION_ONLY]) return true
51
- }
52
- return false
53
- }
54
-
55
- /**
56
- * Subscribe to a coalesced DOM re-check without retaining mutation records.
57
- * The marked wrapper also works with an older hub, which delivers records
58
- * that it simply ignores until a page reload picks up the updated hub.
59
- */
60
- export function subscribeBodyInvalidations(subscriber: () => void): () => void {
61
- const listener: Subscriber = () => { subscriber() }
62
- listener[INVALIDATION_ONLY] = true
63
- return subscribeBodyMutations(listener)
64
- }
65
-
66
- /**
67
- * Subscribe to body-level childList mutations.
68
- * @param subscriber - called at most once per animation frame with the records
69
- * collected since the previous flush; must be safe to run repeatedly.
70
- * @returns the disposer removing this subscriber (and the observer when it was
71
- * the last one).
72
- */
73
- export function subscribeBodyMutations(subscriber: (records: MutationRecord[]) => void): () => void {
74
- if (typeof globalThis === 'undefined' || typeof document === 'undefined') return () => {}
75
- if (typeof MutationObserver !== 'function') return () => {}
76
- const registry = globalThis as unknown as Record<symbol, unknown>
77
- let hub = registry[HUB_KEY] as BodyMutationHub | undefined
78
- if (hub === undefined) {
79
- const subscribers = new Set<(records: MutationRecord[]) => void>()
80
- const created: BodyMutationHub = {
81
- observer: undefined as unknown as MutationObserver,
82
- subscribers,
83
- pending: [],
84
- scheduled: false,
85
- }
86
- const flush = (): void => {
87
- created.frame = undefined
88
- created.scheduled = false
89
- const batch = created.pending
90
- created.pending = []
91
- for (const listener of [...subscribers]) {
92
- if (!subscribers.has(listener)) continue
93
- try {
94
- listener(batch)
95
- } catch {
96
- // One subscriber must never break the others; the next flush retries it.
97
- }
98
- }
99
- }
100
- const schedule = (): void => {
101
- if (created.scheduled) return
102
- created.scheduled = true
103
- if (typeof requestAnimationFrame === 'function') created.frame = requestAnimationFrame(flush)
104
- else flush()
105
- }
106
- created.observer = new MutationObserver((records) => {
107
- if (needsRecords(subscribers)) {
108
- for (const record of records) created.pending.push(record)
109
- }
110
- schedule()
111
- })
112
- created.observer.observe(document.body ?? document.documentElement, { childList: true, subtree: true })
113
- registry[HUB_KEY] = created
114
- hub = created
115
- }
116
- const active = hub
117
- active.subscribers.add(subscriber)
118
- let subscribed = true
119
- return () => {
120
- if (!subscribed) return
121
- subscribed = false
122
- active.subscribers.delete(subscriber)
123
- if (!needsRecords(active.subscribers)) active.pending = []
124
- if (active.subscribers.size === 0 && registry[HUB_KEY] === active) {
125
- active.observer.disconnect()
126
- if (active.frame !== undefined && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(active.frame)
127
- active.frame = undefined
128
- active.pending = []
129
- active.scheduled = false
130
- delete registry[HUB_KEY]
131
- }
132
- }
133
- }
@@ -1,47 +0,0 @@
1
- /**
2
- * Panel view mounting (dsh-ssh wrapper).
3
- *
4
- * The single-occupant takeover lifecycle — container injection into the
5
- * center column, sibling eviction, remount resilience, sidebar click-out —
6
- * lives exactly once in shared/client/panel-mount-core.ts (synced copy); this
7
- * wrapper supplies the SSH panel tree, view dataset key, CSS module, and the
8
- * html attribute names. Those names are pinned by panel.module.css, skins,
9
- * and the semantic attributes contract.
10
- */
11
- import type { SshApi } from './api.ts'
12
- import type { PanelController } from './panel/controller.ts'
13
- import type { TerminalFontSource } from './panel/helpers.ts'
14
- import { SshPanel } from './panel/SshPanel.tsx'
15
- import { mountCenterPanel } from './panel-mount-core.ts'
16
- import type { LocaleRefreshSource } from './sidebar-entry.ts'
17
- import css from './panel/panel.module.css'
18
-
19
- /** The injected panel container (kept in the DOM, hidden when inactive). */
20
- export const PANEL_VIEW_SELECTOR = '[data-dsh-ssh-view]'
21
-
22
- /**
23
- * Mount the panel React tree into the center column and bind its visibility
24
- * to the controller's panelOpen state.
25
- * @param controller - the panel controller driving the view.
26
- * @param api - the SSH API client the tabs operate through.
27
- * @param terminalFont - live terminal-font setting source (issue #577).
28
- * @param locale - locale-change source; when given, re-renders an open panel
29
- * on a Language switch.
30
- * @returns disposer unmounting the tree and restoring the column.
31
- */
32
- export function mountPanel(controller: PanelController, api: SshApi, terminalFont?: TerminalFontSource, locale?: LocaleRefreshSource): () => void {
33
- return mountCenterPanel({
34
- render: root => root.render(<SshPanel controller={controller} api={api} terminalFont={terminalFont} />),
35
- viewDatasetKey: 'dshSshView',
36
- pluginName: 'ssh',
37
- viewClassName: css.view,
38
- activeAttribute: 'data-dsh-ssh-active',
39
- siblingActiveAttribute: 'data-dsh-taskboard-active',
40
- panelName: 'ssh',
41
- siblingPanelName: 'taskboard',
42
- isOpen: () => controller.getSnapshot().panelOpen,
43
- close: () => controller.close(),
44
- subscribe: listener => controller.subscribe(listener),
45
- locale,
46
- })
47
- }
@@ -1,155 +0,0 @@
1
- // Generated by scripts/sync-shared.mjs from shared/client/panel-mount-core.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
- /**
3
- * Center-column panel takeover lifecycle.
4
- *
5
- * The `conversation` slot is single-occupant (ui-conversation) and external
6
- * plugins cannot declare slots, so a family panel takes over the center
7
- * column at the DOM level: a container is appended inside the center column
8
- * (`[class*="centerCol"]`, the 0.1.0-rc.6+ AppFrame layout; previously
9
- * `[data-pane="conversation"]` on older shells — the mount selector keeps
10
- * both, ssh #243 / task-board #107) as an extra trailing child React never
11
- * manages, and a stylesheet rule hides the conversation content while the
12
- * panel is active. Toggling is a data attribute on <html> — no React
13
- * involvement, so the conversation subtree underneath stays mounted and
14
- * stateful.
15
- *
16
- * Consuming plugins keep a thin wrapper that supplies the panel tree,
17
- * container attribute names, and stylesheet class; those names are pinned by
18
- * each package's CSS, skins, and the semantic-attributes contract. The
19
- * sidebar row toggling the panel shares its core the same way
20
- * (shared/client/sidebar-entry-core.ts, synced copy).
21
- */
22
- import { createRoot, type Root } from 'react-dom/client'
23
- import { subscribeBodyInvalidations } from './body-mutations.ts'
24
-
25
- /** Options for mountCenterPanel; dsh-ssh mount.tsx and dsh-task-board board-mount.tsx are the canonical consumers. */
26
- export interface CenterPanelMountOptions {
27
- /** Render the panel tree (first open, remount while open, locale refresh). */
28
- render: (root: Root) => void
29
- /** dataset key of the injected container's view attribute, e.g. `dshSshView` for `data-dsh-ssh-view`. */
30
- viewDatasetKey: string
31
- /** value of the container's L2 `data-dsh-plugin` semantic attribute. */
32
- pluginName: string
33
- /** stylesheet class applied to the injected container. */
34
- viewClassName: string
35
- /** <html> attribute set while this panel is active. */
36
- activeAttribute: string
37
- /** the sibling panel's active attribute, removed from <html> when this panel opens. */
38
- siblingActiveAttribute: string
39
- /** detail value this panel broadcasts on the cross-plugin activation event. */
40
- panelName: string
41
- /** sibling detail value whose activation closes this panel. */
42
- siblingPanelName: string
43
- /** open flag of the owning controller. */
44
- isOpen: () => boolean
45
- /** close the panel, handing the center column back to the conversation. */
46
- close: () => void
47
- /** subscribe to the owning controller's open-state changes; returns an unsubscriber. */
48
- subscribe: (listener: () => void) => () => void
49
- /** locale-change source; when given, re-renders an open panel on a Language switch. */
50
- locale?: { subscribe(listener: () => void): () => void }
51
- }
52
-
53
- const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
54
- /** Cross-plugin activation event; detail is the activating panel name. */
55
- const ACTIVATE_EVENT = 'dsh-panel-activate'
56
- // Sidebar rows whose clicks hand the center column back to the conversation
57
- // (including the already-current row, which produces no session-change
58
- // event). Capture phase, so the panel closes before the shell processes the
59
- // click.
60
- const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
61
-
62
- /** Find the center column, or undefined while the frame is not mounted. */
63
- function conversationColumn(): HTMLElement | undefined {
64
- return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
65
- }
66
-
67
- /**
68
- * Mount a family panel into the center column and bind its visibility to the
69
- * owning controller's open state.
70
- * @returns disposer unmounting the tree and restoring the column.
71
- */
72
- export function mountCenterPanel(options: CenterPanelMountOptions): () => void {
73
- let root: Root | undefined
74
- let container: HTMLDivElement | undefined
75
- let unsubscribeLocale: (() => void) | undefined
76
- try {
77
- unsubscribeLocale = options.locale?.subscribe(() => {
78
- if (root !== undefined) options.render(root)
79
- })
80
- } catch { /* locale service absent: the panel follows its next natural re-render */ }
81
-
82
- const ensure = (): void => {
83
- if (container !== undefined && !container.isConnected) {
84
- // The conversation pane was replaced; drop the stale tree and remount.
85
- root?.unmount()
86
- root = undefined
87
- container.remove()
88
- container = undefined
89
- }
90
- if (container === undefined) {
91
- const column = conversationColumn()
92
- if (column === undefined) return
93
- container = document.createElement('div')
94
- container.dataset[options.viewDatasetKey] = ''
95
- container.dataset.dshPlugin = options.pluginName
96
- container.className = options.viewClassName
97
- column.appendChild(container)
98
- }
99
- // Keep a visited tree mounted so drafts and terminal sessions survive
100
- // close/reopen; an unused panel needs neither a React root nor effects.
101
- if (root !== undefined || !options.isOpen()) return
102
- root = createRoot(container)
103
- options.render(root)
104
- }
105
-
106
- // The frame mounts after boot settlement; watch for the column's arrival.
107
- // The observation is the page-wide hub (shared/client/body-mutations.ts):
108
- // every family panel used to hold its own document.body subtree observer, so
109
- // the per-mutation cost grew with the number of installed plugins.
110
- const unsubscribeBody = subscribeBodyInvalidations(() => { ensure() })
111
-
112
- const applyActive = (): void => {
113
- if (options.isOpen()) {
114
- ensure()
115
- // Single-occupant center column: opening this panel must evict the
116
- // sibling panel, both its html attribute and its controller state,
117
- // otherwise the two panels' visibility rules fight and the second
118
- // click appears dead.
119
- document.documentElement.removeAttribute(options.siblingActiveAttribute)
120
- document.documentElement.setAttribute(options.activeAttribute, '')
121
- document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: options.panelName }))
122
- } else {
123
- document.documentElement.removeAttribute(options.activeAttribute)
124
- }
125
- }
126
- const onOtherActivate = (event: Event): void => {
127
- if ((event as CustomEvent).detail === options.siblingPanelName && options.isOpen()) {
128
- options.close()
129
- }
130
- }
131
- const onClickSidebarRow = (event: MouseEvent): void => {
132
- if (!options.isOpen()) return
133
- const target = event.target as HTMLElement | null
134
- if (target === null) return
135
- if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) options.close()
136
- }
137
- document.addEventListener('click', onClickSidebarRow, true)
138
- document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
139
- const unsubscribe = options.subscribe(applyActive)
140
- applyActive()
141
- ensure()
142
-
143
- return () => {
144
- document.removeEventListener('click', onClickSidebarRow, true)
145
- document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
146
- unsubscribeBody()
147
- unsubscribe()
148
- unsubscribeLocale?.()
149
- document.documentElement.removeAttribute(options.activeAttribute)
150
- root?.unmount()
151
- root = undefined
152
- container?.remove()
153
- container = undefined
154
- }
155
- }
@@ -1,237 +0,0 @@
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
- import { subscribeBodyInvalidations } from './body-mutations.ts'
18
-
19
- /** Per-package configuration for one sidebar entry row. */
20
- export interface SidebarEntryOptions {
21
- /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
22
- rowAttribute: string
23
- /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
24
- rowSelector: string
25
- /**
26
- * L2 semantic-attribute plugin id (issue #506, enum table:
27
- * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
28
- * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
29
- * leaves the row without semantic attributes.
30
- */
31
- plugin?: string
32
- /** Inline icon markup (matches the shell's 16px nav-icon look). */
33
- icon: string
34
- /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
35
- css: Record<string, string>
36
- /** Localized row label (aria-label + visible text). */
37
- label(): string
38
- /** Optional localized tooltip (title attribute). */
39
- tooltip?(): string
40
- /**
41
- * Optional locale-change subscription: re-applies label / aria-label /
42
- * tooltip whenever the active locale changes. Plain-DOM rows would
43
- * otherwise keep the label captured at mount; pass the SDK locale runtime
44
- * subscription (ctx.locale.subscribe) so the row follows the language
45
- * switch without a reload.
46
- */
47
- refresh?: { subscribe(listener: () => void): () => void }
48
- /** Click action (open/toggle the owning panel). */
49
- onToggle(): void
50
- /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
51
- position: 'before' | 'after'
52
- /**
53
- * Selectors of the sibling plugin entry rows this package orders against
54
- * (its own row included — the placement guard excludes a row that is
55
- * already inside the root). Each package passes the same list it used
56
- * before the consolidation so the rendered order stays stable.
57
- */
58
- familySelectors: readonly string[]
59
- /** Optional active-state bridge; highlights the row while the panel is open. */
60
- active?: {
61
- subscribe(listener: () => void): () => void
62
- isOpen(): boolean
63
- }
64
- }
65
-
66
- /** Find the sidebar shell root element, or undefined while not yet mounted. */
67
- function sidebarRoot(): HTMLElement | undefined {
68
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
69
- if (column === null) return undefined
70
- // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
71
- // Prefer the element that owns the logo row — the real sidebar UI root —
72
- // and fall back to the column's first child for legacy shells.
73
- const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
74
- return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
75
- }
76
-
77
- /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
78
- function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
79
- const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
80
- if (nested !== null) return nested
81
- for (const child of root.children) {
82
- if (child.tagName === 'BUTTON') return child as HTMLButtonElement
83
- }
84
- return undefined
85
- }
86
-
87
- /** Build the entry row (detached; inserted once the shell is up). */
88
- function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void } {
89
- const entry = document.createElement('button')
90
- entry.type = 'button'
91
- entry.setAttribute(options.rowAttribute, '')
92
- if (options.plugin !== undefined) {
93
- entry.setAttribute('data-dsh-plugin', options.plugin)
94
- entry.setAttribute('data-dsh-part', 'sidebar-entry')
95
- }
96
- entry.className = options.css['entry'] ?? ''
97
- const labelSpan = document.createElement('span')
98
- labelSpan.className = options.css['entryLabel'] ?? ''
99
- const iconSpan = document.createElement('span')
100
- iconSpan.className = options.css['entryIcon'] ?? ''
101
- iconSpan.innerHTML = options.icon
102
- entry.append(iconSpan, labelSpan)
103
- const applyLabel = (): void => {
104
- entry.setAttribute('aria-label', options.label())
105
- if (options.tooltip !== undefined) entry.setAttribute('title', options.tooltip())
106
- labelSpan.textContent = options.label()
107
- }
108
- applyLabel()
109
- entry.addEventListener('click', options.onToggle)
110
- return { entry, applyLabel }
111
- }
112
-
113
- /** Re-insert the entry after the New Session row (before the browser region). */
114
- function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
115
- const button = newSessionButton(root)
116
- if (button === undefined) return false
117
- if (entry.parentElement !== root) {
118
- // Position relative to the family block (entries injected by sibling
119
- // plugins), never relative to transient logoRow geometry: every family
120
- // plugin that self-heals during a re-render then lands in the same
121
- // relative order, so the entries cannot swap positions regardless of
122
- // observer callback order or of shell wrapper changes. There is no
123
- // append-to-end fallback: appending at the end would randomly reorder
124
- // the block after a shell re-render.
125
- const row = button.closest('[class*="logoRow"]')
126
- const base = (row !== null && row.parentElement === root) ? row : button
127
- const family = Array.from(root.children).filter(
128
- (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
129
- )
130
- const anchor = options.position === 'before'
131
- ? (family.length > 0 ? family[0] : base.nextElementSibling)
132
- : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
133
- root.insertBefore(entry, anchor)
134
- }
135
- return true
136
- }
137
-
138
- /**
139
- * Mount the sidebar entry, waiting for the shell to render and self-healing
140
- * on later React re-renders.
141
- * @param options - the row's attribute/icon/copy/action/ordering configuration.
142
- * @returns disposer removing the entry and its observers.
143
- */
144
- export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
145
- // DOM-level idempotency: whatever path mounted an entry row before this
146
- // call (a duplicated apply, an HMR re-injection, a stale module still
147
- // alive), never mount a second one. The existing row keeps working; a full
148
- // page reload is the ultimate reset.
149
- if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
150
- return () => {}
151
- }
152
- const { entry, applyLabel } = createEntry(options)
153
- let root: HTMLElement | undefined
154
- let placed = false
155
- let unsubscribeRefresh: (() => void) | undefined
156
- if (options.refresh !== undefined) {
157
- try {
158
- unsubscribeRefresh = options.refresh.subscribe(applyLabel)
159
- } catch {
160
- // A throwing subscription must not break the mount; the label stays at
161
- // its initial value and the next reload resolves it again.
162
- }
163
- }
164
-
165
- const tryPlace = (): void => {
166
- if (root !== undefined && !root.isConnected) {
167
- // The shell rebuilt the sidebar pane (whole-tree teardown); the root
168
- // observer is gone with the old tree, so detach it and re-query from
169
- // scratch. The new pane is later noticed by the body-level watcher.
170
- rootObserver.disconnect()
171
- root = undefined
172
- placed = false
173
- }
174
- if (placed) {
175
- // Cheap short-circuit: entry still lives in a mountable subtree.
176
- if (document.body.contains(entry)) return
177
- // Entry was torn down together with the old tree; reset and re-place.
178
- rootObserver.disconnect()
179
- root = undefined
180
- placed = false
181
- }
182
- root ??= sidebarRoot()
183
- if (root === undefined) return
184
- placed = placeEntry(root, entry, options)
185
- if (placed) {
186
- rootObserver.observe(root, { childList: true, subtree: true })
187
- }
188
- }
189
-
190
- // Body-level watcher retained as the "whole rebuild" fallback: when the shell
191
- // tears down the whole sidebar pane, the root observer is gone with it and
192
- // only this body observation can notice the new pane mounting. It stays
193
- // subscribed after placement; the placed-and-still-mounted case
194
- // short-circuits through the cheap document.body.contains(entry) check, so
195
- // unrelated app mutations (e.g. chat streaming) cost one contains check per
196
- // frame instead of churning the full re-query. The body observation itself is
197
- // the page-wide hub (shared/client/body-mutations.ts): every family plugin
198
- // used to hold its own body subtree observer, so the per-mutation cost grew
199
- // with the number of installed plugins; the hub keeps exactly one.
200
- const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
201
-
202
- // Self-heal: if a React re-render displaces the row, re-insert it in the
203
- // same frame (microtask before paint -> no visible flicker).
204
- const rootObserver = new MutationObserver(() => {
205
- if (root === undefined || !root.isConnected) {
206
- placed = false
207
- tryPlace()
208
- return
209
- }
210
- if (!root.contains(entry)) {
211
- placed = placeEntry(root, entry, options)
212
- }
213
- })
214
-
215
- // Reflect the panel's open state on the row (active highlight). Note: assigning
216
- // undefined to dataset.active materializes data-active="undefined" and keeps the
217
- // row permanently highlighted — delete the attribute instead.
218
- const unsubscribeActive = options.active === undefined ? undefined : (() => {
219
- const syncActive = (): void => {
220
- if (options.active!.isOpen()) entry.dataset.active = 'true'
221
- else delete entry.dataset.active
222
- }
223
- const unsubscribe = options.active.subscribe(syncActive)
224
- syncActive()
225
- return unsubscribe
226
- })()
227
-
228
- tryPlace()
229
-
230
- return () => {
231
- unsubscribeBody()
232
- rootObserver.disconnect()
233
- unsubscribeRefresh?.()
234
- unsubscribeActive?.()
235
- entry.remove()
236
- }
237
- }
@@ -1,51 +0,0 @@
1
- /**
2
- * Sidebar entry injection — package-specific wiring over the shared core.
3
- *
4
- * The DOM injection / self-healing / idempotency logic lives exactly once in
5
- * shared/client/sidebar-entry-core.ts (synced copy); this wrapper supplies the
6
- * ssh icon, copy, CSS module, and the panel toggle. The row is plain DOM (no
7
- * React tree) so it can never disturb the shell's reconciliation; the panel
8
- * view it toggles is a separate React root mounted in the center column
9
- * (see mount.tsx).
10
- */
11
- import type { PanelController } from './panel/controller.ts'
12
- import { tt } from './panel/helpers.ts'
13
- import css from './panel/panel.module.css'
14
- import { mountSidebarEntry as mountSharedSidebarEntry } from './sidebar-entry-core.ts'
15
-
16
- /** Stable data attribute identifying the injected entry row. */
17
- export const ENTRY_SELECTOR = '[data-dsh-ssh-entry]'
18
-
19
- /** Inline terminal glyph sized to the shell's panel-row navigation icons. */
20
- const ICON = '<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="1.75" y="2.25" width="12.5" height="11.5" rx="1.75"/><path d="M4.25 5.25l2.75 2.75-2.75 2.75"/><path d="M8.5 10.75h3.25"/></svg>'
21
-
22
- /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
23
- export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
24
-
25
- /**
26
- * Mount the sidebar entry, waiting for the shell to render and self-healing
27
- * on later React re-renders.
28
- * @param controller - the panel controller the entry toggles.
29
- * @param locale - locale-change source; when given, re-applies the label on
30
- * a Language switch (the plain-DOM row otherwise keeps the mount-time copy).
31
- * @returns disposer removing the entry and its observers.
32
- */
33
- export function mountSidebarEntry(controller: PanelController, locale?: LocaleRefreshSource): () => void {
34
- return mountSharedSidebarEntry({
35
- rowAttribute: 'data-dsh-ssh-entry',
36
- rowSelector: ENTRY_SELECTOR,
37
- plugin: 'ssh',
38
- icon: ICON,
39
- css,
40
- label: () => tt('entry.label'),
41
- tooltip: () => tt('entry.tooltip'),
42
- refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
43
- onToggle: () => { controller.toggle() },
44
- position: 'after',
45
- familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]'],
46
- active: {
47
- subscribe: (listener) => controller.subscribe(listener),
48
- isOpen: () => controller.getSnapshot().panelOpen,
49
- },
50
- })
51
- }