@linxin666/dsh-ssh 0.1.20 → 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.
@@ -3,7 +3,7 @@
3
3
  * add/edit/delete/test actions, ~/.ssh/config import, and a connect action
4
4
  * that hands the alias to the terminal tab via onConnect.
5
5
  */
6
- import { useCallback, useEffect, useRef, useState } from 'react'
6
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
7
7
  import type { SshApi } from '../api.ts'
8
8
  import type { SshHostSummary, TestResult } from '../../protocol.ts'
9
9
  import { errorMessage, tt } from './helpers.ts'
@@ -20,6 +20,44 @@ export interface HostsTabProps {
20
20
  /** The host-form dialog invocation. */
21
21
  type DialogState = { mode: 'create' } | { mode: 'edit'; host: SshHostSummary }
22
22
 
23
+ /** Host list grouping modes (#379). */
24
+ export type HostGroupBy = 'none' | 'environment' | 'tags'
25
+
26
+ /** One collapsible group section of the grouped host list. */
27
+ export interface HostGroup {
28
+ /** Group key: the environment name, one tag, or '' for the ungrouped bucket. */
29
+ key: string
30
+ hosts: SshHostSummary[]
31
+ }
32
+
33
+ /**
34
+ * Bucket hosts into collapsible groups (#379). Grouping by tags places a
35
+ * multi-tag host in every one of its tag groups (folder view); hosts without
36
+ * the grouping key land in the '' bucket, which always sorts last. Groups
37
+ * sort alphabetically; host order inside a group follows the API listing.
38
+ */
39
+ export function groupHosts(hosts: SshHostSummary[], groupBy: HostGroupBy): HostGroup[] {
40
+ if (groupBy === 'none') return [{ key: '', hosts }]
41
+ const buckets = new Map<string, SshHostSummary[]>()
42
+ const push = (key: string, host: SshHostSummary): void => {
43
+ const bucket = buckets.get(key)
44
+ if (bucket === undefined) buckets.set(key, [host])
45
+ else bucket.push(host)
46
+ }
47
+ for (const host of hosts) {
48
+ if (groupBy === 'environment') {
49
+ push(host.environment ?? '', host)
50
+ } else if (host.tags.length === 0) {
51
+ push('', host)
52
+ } else {
53
+ for (const tag of host.tags) push(tag, host)
54
+ }
55
+ }
56
+ return [...buckets.entries()]
57
+ .sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)))
58
+ .map(([key, group]) => ({ key, hosts: group }))
59
+ }
60
+
23
61
  /** The hosts table plus its toolbar and dialogs. */
24
62
  export function HostsTab({ api, onConnect }: HostsTabProps) {
25
63
  const [hosts, setHosts] = useState<SshHostSummary[] | null>(null)
@@ -30,6 +68,9 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
30
68
  const [importing, setImporting] = useState(false)
31
69
  const [notice, setNotice] = useState<string | null>(null)
32
70
  const [dialog, setDialog] = useState<DialogState | null>(null)
71
+ const [groupBy, setGroupBy] = useState<HostGroupBy>('none')
72
+ const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
73
+ const [testingGroup, setTestingGroup] = useState<string | null>(null)
33
74
  const seqRef = useRef(0)
34
75
 
35
76
  const load = useCallback(async (query?: string): Promise<void> => {
@@ -78,6 +119,16 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
78
119
  }
79
120
  }
80
121
 
122
+ // Group-header batch action (#379): test every host in the group.
123
+ const testGroup = async (group: HostGroup): Promise<void> => {
124
+ setTestingGroup(group.key)
125
+ try {
126
+ await Promise.all(group.hosts.map(host => runTest(host.alias)))
127
+ } finally {
128
+ setTestingGroup(null)
129
+ }
130
+ }
131
+
81
132
  const importConfig = async (): Promise<void> => {
82
133
  setImporting(true)
83
134
  try {
@@ -91,10 +142,73 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
91
142
  }
92
143
  }
93
144
 
145
+ const renderHostRow = (host: SshHostSummary): ReactNode => {
146
+ const test = testResults[host.alias]
147
+ return (
148
+ <tr key={host.alias}>
149
+ <td className={css.mono}>{host.alias}</td>
150
+ <td className={css.mono}>{host.host}:{host.port}</td>
151
+ <td>{host.user}</td>
152
+ <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
153
+ <td className={css.cellMuted}>{host.environment ?? ''}</td>
154
+ <td className={css.cellMuted}>{host.tags.join(', ')}</td>
155
+ <td className={css.cellMuted}>{host.description ?? ''}</td>
156
+ <td>
157
+ <div className={css.actions}>
158
+ <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
159
+ {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
160
+ </button>
161
+ {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
162
+ {test !== undefined && (
163
+ <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
164
+ {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
165
+ </span>
166
+ )}
167
+ <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
168
+ <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
169
+ <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
170
+ </div>
171
+ </td>
172
+ </tr>
173
+ )
174
+ }
175
+
176
+ const renderHostTable = (rows: SshHostSummary[]): ReactNode => (
177
+ <table className={css.table}>
178
+ <thead>
179
+ <tr>
180
+ <th>{tt('hosts.col.alias')}</th>
181
+ <th>{tt('hosts.col.host')}</th>
182
+ <th>{tt('hosts.col.user')}</th>
183
+ <th>{tt('hosts.col.auth')}</th>
184
+ <th>{tt('hosts.col.environment')}</th>
185
+ <th>{tt('hosts.col.tags')}</th>
186
+ <th>{tt('hosts.col.description')}</th>
187
+ <th>{tt('hosts.col.actions')}</th>
188
+ </tr>
189
+ </thead>
190
+ <tbody>
191
+ {rows.map(renderHostRow)}
192
+ </tbody>
193
+ </table>
194
+ )
195
+
196
+ const groups = hosts === null ? [] : groupHosts(hosts, groupBy)
197
+
94
198
  return (
95
199
  <div className={css.fillBody}>
96
200
  <div className={css.toolbar}>
97
201
  <input className={css.search} type="search" placeholder={tt('hosts.search')} value={search} onChange={event => { setSearch(event.target.value) }} />
202
+ <select
203
+ className={css.groupBySelect}
204
+ aria-label={tt('hosts.groupBy.label')}
205
+ value={groupBy}
206
+ onChange={event => { setGroupBy(event.target.value as HostGroupBy) }}
207
+ >
208
+ <option value="none">{tt('hosts.groupBy.none')}</option>
209
+ <option value="environment">{tt('hosts.groupBy.environment')}</option>
210
+ <option value="tags">{tt('hosts.groupBy.tags')}</option>
211
+ </select>
98
212
  <div className={css.toolbarSpacer} />
99
213
  <button type="button" className={css.primaryButton} onClick={() => { setDialog({ mode: 'create' }) }}>{tt('hosts.add')}</button>
100
214
  <button type="button" className={css.ghostButton} disabled={importing} onClick={() => { void importConfig() }}>{importing ? tt('common.loading') : tt('hosts.import')}</button>
@@ -103,54 +217,44 @@ export function HostsTab({ api, onConnect }: HostsTabProps) {
103
217
  {error !== null && <div className={css.banner} data-kind="error">{tt('common.error', { error })}</div>}
104
218
  {hosts === null && error === null && <div className={css.loading}>{tt('common.loading')}</div>}
105
219
  {hosts !== null && hosts.length === 0 && <div className={css.empty}>{tt('hosts.empty')}</div>}
106
- {hosts !== null && hosts.length > 0 && (
220
+ {hosts !== null && hosts.length > 0 && groupBy === 'none' && (
221
+ <div className={css.tableWrap}>
222
+ {renderHostTable(hosts)}
223
+ </div>
224
+ )}
225
+ {hosts !== null && hosts.length > 0 && groupBy !== 'none' && (
107
226
  <div className={css.tableWrap}>
108
- <table className={css.table}>
109
- <thead>
110
- <tr>
111
- <th>{tt('hosts.col.alias')}</th>
112
- <th>{tt('hosts.col.host')}</th>
113
- <th>{tt('hosts.col.user')}</th>
114
- <th>{tt('hosts.col.auth')}</th>
115
- <th>{tt('hosts.col.environment')}</th>
116
- <th>{tt('hosts.col.tags')}</th>
117
- <th>{tt('hosts.col.description')}</th>
118
- <th>{tt('hosts.col.actions')}</th>
119
- </tr>
120
- </thead>
121
- <tbody>
122
- {hosts.map(host => {
123
- const test = testResults[host.alias]
124
- return (
125
- <tr key={host.alias}>
126
- <td className={css.mono}>{host.alias}</td>
127
- <td className={css.mono}>{host.host}:{host.port}</td>
128
- <td>{host.user}</td>
129
- <td><span className={css.badge} data-kind={host.auth}>{host.auth === 'key' ? tt('form.auth.key') : tt('form.auth.password')}</span></td>
130
- <td className={css.cellMuted}>{host.environment ?? ''}</td>
131
- <td className={css.cellMuted}>{host.tags.join(', ')}</td>
132
- <td className={css.cellMuted}>{host.description ?? ''}</td>
133
- <td>
134
- <div className={css.actions}>
135
- <button type="button" className={css.linkButton} disabled={testingAlias === host.alias} onClick={() => { void runTest(host.alias) }}>
136
- {testingAlias === host.alias ? tt('hosts.testing') : tt('hosts.test')}
137
- </button>
138
- {testingAlias === host.alias && <span className={css.spinner} aria-hidden="true" />}
139
- {test !== undefined && (
140
- <span className={css.inlineTest} data-status={test.ok ? 'ok' : 'fail'}>
141
- {test.ok ? tt('hosts.testOk', { latency: test.latencyMs ?? 0 }) : tt('hosts.testFail', { error: test.error ?? '' })}
142
- </span>
143
- )}
144
- <button type="button" className={css.linkButton} onClick={() => { setDialog({ mode: 'edit', host }) }}>{tt('hosts.edit')}</button>
145
- <button type="button" className={css.linkButton} data-danger onClick={() => { void deleteHost(host.alias) }}>{tt('hosts.delete')}</button>
146
- <button type="button" className={css.ghostButton} onClick={() => { onConnect(host.alias) }}>{tt('hosts.connected')}</button>
147
- </div>
148
- </td>
149
- </tr>
150
- )
151
- })}
152
- </tbody>
153
- </table>
227
+ {groups.map(group => {
228
+ const isCollapsed = collapsed[group.key] === true
229
+ const label = group.key === ''
230
+ ? (groupBy === 'tags' ? tt('hosts.group.noTags') : tt('hosts.group.ungrouped'))
231
+ : group.key
232
+ return (
233
+ <section key={group.key} className={css.groupSection}>
234
+ <div className={css.groupHeader}>
235
+ <button
236
+ type="button"
237
+ className={css.groupToggle}
238
+ aria-expanded={!isCollapsed}
239
+ onClick={() => { setCollapsed(prev => ({ ...prev, [group.key]: !isCollapsed })) }}
240
+ >
241
+ <span className={css.groupChevron} data-collapsed={isCollapsed || undefined} aria-hidden="true" />
242
+ <span className={css.groupName}>{label}</span>
243
+ <span className={css.groupCount}>{tt('hosts.group.count', { count: group.hosts.length })}</span>
244
+ </button>
245
+ <button
246
+ type="button"
247
+ className={css.linkButton}
248
+ disabled={testingGroup === group.key}
249
+ onClick={() => { void testGroup(group) }}
250
+ >
251
+ {testingGroup === group.key ? tt('hosts.testing') : tt('hosts.group.testAll')}
252
+ </button>
253
+ </div>
254
+ {!isCollapsed && renderHostTable(group.hosts)}
255
+ </section>
256
+ )
257
+ })}
154
258
  </div>
155
259
  )}
156
260
  {dialog !== null && (
@@ -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
  )}
@@ -242,6 +242,81 @@ html[data-dsh-ssh-active]:not([data-dsh-taskboard-active]) [class*='centerCol']
242
242
  color: var(--dsw-alias-label-tertiary);
243
243
  }
244
244
 
245
+ .groupBySelect {
246
+ padding: 6px 8px;
247
+ font-size: 13px;
248
+ color: var(--dsw-alias-label-primary);
249
+ background: var(--dsw-specific-input-major);
250
+ border: 1px solid var(--dsw-alias-border-l2);
251
+ border-radius: 8px;
252
+ outline: none;
253
+ }
254
+
255
+ /* --- host groups (#379) ------------------------------------------------------- */
256
+
257
+ .groupSection + .groupSection {
258
+ border-top: 1px solid var(--dsw-alias-border-l1);
259
+ }
260
+
261
+ .groupHeader {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 8px;
265
+ padding: 4px 8px;
266
+ position: sticky;
267
+ top: 0;
268
+ background: var(--dsw-alias-bg-base, var(--dsw-specific-input-major));
269
+ }
270
+
271
+ .groupToggle {
272
+ display: flex;
273
+ align-items: center;
274
+ gap: 6px;
275
+ flex: 1;
276
+ min-width: 0;
277
+ padding: 4px 6px;
278
+ border: none;
279
+ background: none;
280
+ color: var(--dsw-alias-label-primary);
281
+ font-size: 12.5px;
282
+ font-weight: 500;
283
+ cursor: pointer;
284
+ text-align: left;
285
+ border-radius: 6px;
286
+ }
287
+
288
+ .groupToggle:hover {
289
+ background: var(--dsw-alias-interactive-bg-hover);
290
+ }
291
+
292
+ .groupChevron {
293
+ width: 7px;
294
+ height: 7px;
295
+ border-right: 1.5px solid var(--dsw-alias-label-tertiary);
296
+ border-bottom: 1.5px solid var(--dsw-alias-label-tertiary);
297
+ transform: rotate(45deg);
298
+ transition: transform 120ms ease;
299
+ flex: none;
300
+ margin-bottom: 2px;
301
+ }
302
+
303
+ .groupChevron[data-collapsed] {
304
+ transform: rotate(-45deg);
305
+ margin-bottom: 0;
306
+ }
307
+
308
+ .groupName {
309
+ overflow: hidden;
310
+ text-overflow: ellipsis;
311
+ white-space: nowrap;
312
+ }
313
+
314
+ .groupCount {
315
+ color: var(--dsw-alias-label-tertiary);
316
+ font-weight: 400;
317
+ flex: none;
318
+ }
319
+
245
320
  /* --- tables ------------------------------------------------------------------- */
246
321
 
247
322
  .tableWrap {
@@ -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
+ }