@linxin666/dsh-usage 0.3.23 → 0.3.24

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 (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -7
  3. package/README.zh.md +5 -7
  4. package/lib/client.js +744 -73
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +171 -201
  7. package/lib/types/client/UsageSectionCard.d.ts +0 -1
  8. package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
  9. package/lib/types/client/body-mutations.d.ts +45 -0
  10. package/lib/types/client/locales.d.ts +9 -4
  11. package/lib/types/client/sidebar-entry-core.d.ts +87 -0
  12. package/lib/types/client/sidebar-entry.d.ts +35 -0
  13. package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
  14. package/lib/types/core/provider-routes.d.ts +26 -0
  15. package/lib/types/core/types.d.ts +14 -1
  16. package/lib/types/host/access.d.ts +17 -0
  17. package/lib/types/host/pair-access.d.ts +34 -0
  18. package/lib/types/host/routes.d.ts +16 -7
  19. package/lib/types/host/usage-service.d.ts +28 -73
  20. package/lib/types/index.d.ts +0 -2
  21. package/package.json +4 -3
  22. package/src/client/UsageSectionCard.tsx +0 -14
  23. package/src/client/UsageSidebarPanel.tsx +196 -0
  24. package/src/client/body-mutations.ts +133 -0
  25. package/src/client/index.ts +22 -1
  26. package/src/client/locales.ts +18 -8
  27. package/src/client/sidebar-entry-core.ts +321 -0
  28. package/src/client/sidebar-entry.ts +101 -0
  29. package/src/client/sidebar-panel-mount.tsx +113 -0
  30. package/src/client/usage.module.css +252 -0
  31. package/src/core/adapters.ts +40 -6
  32. package/src/core/provider-routes.ts +37 -0
  33. package/src/core/types.ts +14 -1
  34. package/src/host/access.ts +21 -0
  35. package/src/host/pair-access.ts +49 -0
  36. package/src/host/routes.ts +19 -10
  37. package/src/host/usage-service.ts +65 -214
  38. package/src/index.ts +1 -6
@@ -0,0 +1,321 @@
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
+ /**
20
+ * One trailing action button seated at the entry row's right edge (e.g. a
21
+ * refresh command or a collapse chevron). Rows with actions switch from the
22
+ * classic single-button structure to a container with a main button plus the
23
+ * action buttons (nested interactive elements are invalid HTML); rows without
24
+ * actions keep the classic structure untouched.
25
+ */
26
+ export interface SidebarEntryAction {
27
+ /** Stable id stamped as data-dsh-entry-action="<id>" (CSS hooks and tests). */
28
+ id: string
29
+ /** Inline icon markup while the row is active (always, when inactiveIcon is unset). */
30
+ icon: string
31
+ /**
32
+ * Optional icon while the row is inactive. Setting it makes the action a
33
+ * state mirror (a collapse chevron): the core swaps the icon and stamps
34
+ * aria-expanded on every active-state change. Requires the active bridge.
35
+ */
36
+ inactiveIcon?: string
37
+ /** Localized accessible label (aria-label + title); receives the active state. */
38
+ label(active: boolean): string
39
+ /**
40
+ * Click handler; receives the button so the caller can drive transient
41
+ * affordances (a refresh cooldown) without a re-render.
42
+ */
43
+ onClick(button: HTMLButtonElement): void
44
+ }
45
+
46
+ /** Per-package configuration for one sidebar entry row. */
47
+ export interface SidebarEntryOptions {
48
+ /** Full attribute name identifying the injected row (idempotency key), e.g. 'data-dsh-ssh-entry'. */
49
+ rowAttribute: string
50
+ /** CSS selector matching the injected row, e.g. '[data-dsh-ssh-entry]'. */
51
+ rowSelector: string
52
+ /**
53
+ * L2 semantic-attribute plugin id (issue #506, enum table:
54
+ * skins/skin-center/contracts/semantic-attrs-v1.md). When set, the row also
55
+ * outputs data-dsh-plugin="<id>" and data-dsh-part="sidebar-entry"; unset
56
+ * leaves the row without semantic attributes.
57
+ */
58
+ plugin?: string
59
+ /** Inline icon markup (matches the shell's 16px nav-icon look). */
60
+ icon: string
61
+ /** CSS module class names for the row and its two spans (entry / entryIcon / entryLabel). */
62
+ css: Record<string, string>
63
+ /** Localized row label (aria-label + visible text). */
64
+ label(): string
65
+ /** Optional localized tooltip (title attribute). */
66
+ tooltip?(): string
67
+ /**
68
+ * Optional locale-change subscription: re-applies label / aria-label /
69
+ * tooltip whenever the active locale changes. Plain-DOM rows would
70
+ * otherwise keep the label captured at mount; pass the SDK locale runtime
71
+ * subscription (ctx.locale.subscribe) so the row follows the language
72
+ * switch without a reload.
73
+ */
74
+ refresh?: { subscribe(listener: () => void): () => void }
75
+ /** Click action (open/toggle the owning panel). */
76
+ onToggle(): void
77
+ /**
78
+ * Optional trailing action buttons at the row's right edge. Requires the
79
+ * package CSS to seat entryMain / entryAction; empty keeps the classic
80
+ * single-button row.
81
+ */
82
+ actions?: readonly SidebarEntryAction[]
83
+ /** Family-block position: 'before' inserts ahead of sibling plugin rows, 'after' behind them. */
84
+ position: 'before' | 'after'
85
+ /**
86
+ * Selectors of the sibling plugin entry rows this package orders against
87
+ * (its own row included — the placement guard excludes a row that is
88
+ * already inside the root). Each package passes the same list it used
89
+ * before the consolidation so the rendered order stays stable.
90
+ */
91
+ familySelectors: readonly string[]
92
+ /** Optional active-state bridge; highlights the row while the panel is open. */
93
+ active?: {
94
+ subscribe(listener: () => void): () => void
95
+ isOpen(): boolean
96
+ }
97
+ }
98
+
99
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
100
+ function sidebarRoot(): HTMLElement | undefined {
101
+ const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
102
+ if (column === null) return undefined
103
+ // Current shells wrap the sidebar UI: column > wrapper > root(logoRow owner).
104
+ // Prefer the element that owns the logo row — the real sidebar UI root —
105
+ // and fall back to the column's first child for legacy shells.
106
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
107
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
108
+ }
109
+
110
+ /** The New Session button: nested in the logo row on current shells, a direct child on legacy shells. */
111
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
112
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
113
+ if (nested !== null) return nested
114
+ for (const child of root.children) {
115
+ if (child.tagName === 'BUTTON') return child as HTMLButtonElement
116
+ }
117
+ return undefined
118
+ }
119
+
120
+ /** Build the entry row (detached; inserted once the shell is up). */
121
+ function createEntry(options: SidebarEntryOptions): { entry: HTMLElement; applyLabel: () => void; setOpen: (open: boolean) => void } {
122
+ const actions = options.actions ?? []
123
+ const composite = actions.length > 0
124
+ const entry = document.createElement(composite ? 'div' : 'button') as HTMLElement
125
+ if (!composite) (entry as HTMLButtonElement).type = 'button'
126
+ entry.setAttribute(options.rowAttribute, '')
127
+ if (options.plugin !== undefined) {
128
+ entry.setAttribute('data-dsh-plugin', options.plugin)
129
+ entry.setAttribute('data-dsh-part', 'sidebar-entry')
130
+ }
131
+ entry.className = options.css['entry'] ?? ''
132
+ const labelSpan = document.createElement('span')
133
+ labelSpan.className = options.css['entryLabel'] ?? ''
134
+ const iconSpan = document.createElement('span')
135
+ iconSpan.className = options.css['entryIcon'] ?? ''
136
+ iconSpan.innerHTML = options.icon
137
+ // The main hit area: the classic row IS the button; with trailing actions
138
+ // the row becomes a container and the main area its own button, so the row
139
+ // never nests interactive elements.
140
+ const main = composite ? document.createElement('button') : (entry as HTMLButtonElement)
141
+ if (composite) {
142
+ main.type = 'button'
143
+ main.className = options.css['entryMain'] ?? ''
144
+ main.append(iconSpan, labelSpan)
145
+ entry.append(main)
146
+ } else {
147
+ entry.append(iconSpan, labelSpan)
148
+ }
149
+ const actionButtons: { action: SidebarEntryAction; button: HTMLButtonElement }[] = []
150
+ for (const action of actions) {
151
+ const button = document.createElement('button')
152
+ button.type = 'button'
153
+ button.className = options.css['entryAction'] ?? ''
154
+ button.setAttribute('data-dsh-entry-action', action.id)
155
+ button.innerHTML = action.icon
156
+ button.addEventListener('click', () => { action.onClick(button) })
157
+ entry.append(button)
158
+ actionButtons.push({ action, button })
159
+ }
160
+ let open = false
161
+ const applyActions = (): void => {
162
+ for (const { action, button } of actionButtons) {
163
+ button.innerHTML = open || action.inactiveIcon === undefined ? action.icon : action.inactiveIcon
164
+ const text = action.label(open)
165
+ button.setAttribute('aria-label', text)
166
+ button.setAttribute('title', text)
167
+ if (action.inactiveIcon !== undefined) button.setAttribute('aria-expanded', String(open))
168
+ }
169
+ }
170
+ const applyLabel = (): void => {
171
+ main.setAttribute('aria-label', options.label())
172
+ if (options.tooltip !== undefined) main.setAttribute('title', options.tooltip())
173
+ labelSpan.textContent = options.label()
174
+ applyActions()
175
+ }
176
+ applyLabel()
177
+ main.addEventListener('click', options.onToggle)
178
+ if (composite) {
179
+ // The old row was clickable edge to edge; keep the padding between the
180
+ // main area and the action buttons a toggle target as well.
181
+ entry.addEventListener('click', (event) => {
182
+ if (event.target === entry) options.onToggle()
183
+ })
184
+ }
185
+ return {
186
+ entry,
187
+ applyLabel,
188
+ setOpen: (next: boolean) => {
189
+ open = next
190
+ applyActions()
191
+ },
192
+ }
193
+ }
194
+
195
+ /** Re-insert the entry after the New Session row (before the browser region). */
196
+ function placeEntry(root: HTMLElement, entry: HTMLElement, options: SidebarEntryOptions): boolean {
197
+ const button = newSessionButton(root)
198
+ if (button === undefined) return false
199
+ if (entry.parentElement !== root) {
200
+ // Position relative to the family block (entries injected by sibling
201
+ // plugins), never relative to transient logoRow geometry: every family
202
+ // plugin that self-heals during a re-render then lands in the same
203
+ // relative order, so the entries cannot swap positions regardless of
204
+ // observer callback order or of shell wrapper changes. There is no
205
+ // append-to-end fallback: appending at the end would randomly reorder
206
+ // the block after a shell re-render.
207
+ const row = button.closest('[class*="logoRow"]')
208
+ const base = (row !== null && row.parentElement === root) ? row : button
209
+ const family = Array.from(root.children).filter(
210
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(options.familySelectors.join(', ')),
211
+ )
212
+ const anchor = options.position === 'before'
213
+ ? (family.length > 0 ? family[0] : base.nextElementSibling)
214
+ : (family.length > 0 ? family[family.length - 1]!.nextElementSibling : base.nextElementSibling)
215
+ root.insertBefore(entry, anchor)
216
+ }
217
+ return true
218
+ }
219
+
220
+ /**
221
+ * Mount the sidebar entry, waiting for the shell to render and self-healing
222
+ * on later React re-renders.
223
+ * @param options - the row's attribute/icon/copy/action/ordering configuration.
224
+ * @returns disposer removing the entry and its observers.
225
+ */
226
+ export function mountSidebarEntry(options: SidebarEntryOptions): () => void {
227
+ // DOM-level idempotency: whatever path mounted an entry row before this
228
+ // call (a duplicated apply, an HMR re-injection, a stale module still
229
+ // alive), never mount a second one. The existing row keeps working; a full
230
+ // page reload is the ultimate reset.
231
+ if (typeof document !== 'undefined' && document.querySelector(options.rowSelector) !== null) {
232
+ return () => {}
233
+ }
234
+ const { entry, applyLabel, setOpen } = createEntry(options)
235
+ let root: HTMLElement | undefined
236
+ let placed = false
237
+ let unsubscribeRefresh: (() => void) | undefined
238
+ if (options.refresh !== undefined) {
239
+ try {
240
+ unsubscribeRefresh = options.refresh.subscribe(applyLabel)
241
+ } catch {
242
+ // A throwing subscription must not break the mount; the label stays at
243
+ // its initial value and the next reload resolves it again.
244
+ }
245
+ }
246
+
247
+ const tryPlace = (): void => {
248
+ if (root !== undefined && !root.isConnected) {
249
+ // The shell rebuilt the sidebar pane (whole-tree teardown); the root
250
+ // observer is gone with the old tree, so detach it and re-query from
251
+ // scratch. The new pane is later noticed by the body-level watcher.
252
+ rootObserver.disconnect()
253
+ root = undefined
254
+ placed = false
255
+ }
256
+ if (placed) {
257
+ // Cheap short-circuit: entry still lives in a mountable subtree.
258
+ if (document.body.contains(entry)) return
259
+ // Entry was torn down together with the old tree; reset and re-place.
260
+ rootObserver.disconnect()
261
+ root = undefined
262
+ placed = false
263
+ }
264
+ root ??= sidebarRoot()
265
+ if (root === undefined) return
266
+ placed = placeEntry(root, entry, options)
267
+ if (placed) {
268
+ rootObserver.observe(root, { childList: true, subtree: true })
269
+ }
270
+ }
271
+
272
+ // Body-level watcher retained as the "whole rebuild" fallback: when the shell
273
+ // tears down the whole sidebar pane, the root observer is gone with it and
274
+ // only this body observation can notice the new pane mounting. It stays
275
+ // subscribed after placement; the placed-and-still-mounted case
276
+ // short-circuits through the cheap document.body.contains(entry) check, so
277
+ // unrelated app mutations (e.g. chat streaming) cost one contains check per
278
+ // frame instead of churning the full re-query. The body observation itself is
279
+ // the page-wide hub (shared/client/body-mutations.ts): every family plugin
280
+ // used to hold its own body subtree observer, so the per-mutation cost grew
281
+ // with the number of installed plugins; the hub keeps exactly one.
282
+ const unsubscribeBody = subscribeBodyInvalidations(() => { tryPlace() })
283
+
284
+ // Self-heal: if a React re-render displaces the row, re-insert it in the
285
+ // same frame (microtask before paint -> no visible flicker).
286
+ const rootObserver = new MutationObserver(() => {
287
+ if (root === undefined || !root.isConnected) {
288
+ placed = false
289
+ tryPlace()
290
+ return
291
+ }
292
+ if (!root.contains(entry)) {
293
+ placed = placeEntry(root, entry, options)
294
+ }
295
+ })
296
+
297
+ // Reflect the panel's open state on the row (active highlight). Note: assigning
298
+ // undefined to dataset.active materializes data-active="undefined" and keeps the
299
+ // row permanently highlighted — delete the attribute instead.
300
+ const unsubscribeActive = options.active === undefined ? undefined : (() => {
301
+ const syncActive = (): void => {
302
+ const open = options.active!.isOpen()
303
+ if (open) entry.dataset.active = 'true'
304
+ else delete entry.dataset.active
305
+ setOpen(open)
306
+ }
307
+ const unsubscribe = options.active.subscribe(syncActive)
308
+ syncActive()
309
+ return unsubscribe
310
+ })()
311
+
312
+ tryPlace()
313
+
314
+ return () => {
315
+ unsubscribeBody()
316
+ rootObserver.disconnect()
317
+ unsubscribeRefresh?.()
318
+ unsubscribeActive?.()
319
+ entry.remove()
320
+ }
321
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Sidebar entry injection for the usage panel (issue #1592) — wiring over the
3
+ * shared core. Unlike the panel-takeover family (task board / SSH / skill
4
+ * center) this row does not participate in the family block, so it is placed
5
+ * directly under the New Session row and ordered after those entries. The row
6
+ * seats the panel's controls as trailing actions: a refresh command and a
7
+ * collapse chevron mirroring the open state, so the panel below renders only
8
+ * its content.
9
+ * @module @linxin666/dsh-usage/client/sidebar-entry
10
+ */
11
+
12
+ import { t } from './locales.ts'
13
+ import css from './usage.module.css'
14
+ import { mountSidebarEntry as mountSharedSidebarEntry, type SidebarEntryAction } from './sidebar-entry-core.ts'
15
+
16
+ /** Stable data attribute identifying the injected entry row. */
17
+ export const ENTRY_SELECTOR = '[data-dsh-usage-entry]'
18
+
19
+ /** Inline gauge glyph normalized to the shell's 18px navigation glyph size. */
20
+ const ICON = '<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 12.5a7 7 0 0 1 11 -4.3"/><path d="M8 12.5V8.2l2.9-2.1"/><circle cx="8" cy="12.5" r="1"/></svg>'
21
+
22
+ /** Refresh glyph: a clockwise circular arrow, 14px like the sibling action icons. */
23
+ const REFRESH_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.4 8a5.4 5.4 0 1 1-1.58-3.82"/><path d="M13.4 2.6v2.7h-2.7"/></svg>'
24
+
25
+ /** Disclosure chevrons: down while expanded, right while collapsed. */
26
+ const CHEVRON_OPEN_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m4 6 4 4 4-4"/></svg>'
27
+ const CHEVRON_CLOSED_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 4 4 4-4 4"/></svg>'
28
+
29
+ /** Refresh cooldown: one forced probe cycle per click, the icon spins meanwhile. */
30
+ const REFRESH_COOLDOWN_MS = 3000
31
+
32
+ /** Locale-change subscription the shared core asks for (ctx.locale.subscribe shape). */
33
+ export interface LocaleRefreshSource { subscribe(listener: () => void): () => void }
34
+
35
+ /** Panel-facing callbacks and state the entry row's controls drive. */
36
+ export interface UsageSidebarEntryHandlers {
37
+ /** Toggles the panel (the row's main area and the collapse chevron). */
38
+ onToggle(): void
39
+ /** Forces one host probe cycle (the refresh action). */
40
+ onRefresh(): void
41
+ /** Reads the panel's expanded state (row highlight and chevron direction). */
42
+ isOpen(): boolean
43
+ /** Subscribes to expanded-state changes (the chevron mirrors them live). */
44
+ subscribeOpen(listener: () => void): () => void
45
+ }
46
+
47
+ /** Trailing actions seated at the row's right edge. */
48
+ function entryActions(handlers: UsageSidebarEntryHandlers): readonly SidebarEntryAction[] {
49
+ return [
50
+ {
51
+ id: 'refresh',
52
+ icon: REFRESH_ICON,
53
+ label: () => t('usage.refresh'),
54
+ onClick: (button) => {
55
+ handlers.onRefresh()
56
+ button.disabled = true
57
+ window.setTimeout(() => { button.disabled = false }, REFRESH_COOLDOWN_MS)
58
+ },
59
+ },
60
+ {
61
+ id: 'collapse',
62
+ icon: CHEVRON_OPEN_ICON,
63
+ inactiveIcon: CHEVRON_CLOSED_ICON,
64
+ label: (open) => t(open ? 'usage.sidebar.toggle.collapse' : 'usage.sidebar.toggle.expand'),
65
+ onClick: () => { handlers.onToggle() },
66
+ },
67
+ ]
68
+ }
69
+
70
+ /**
71
+ * Mount the usage sidebar entry.
72
+ * @param handlers - the panel's toggle/refresh callbacks and open-state face.
73
+ * @param locale - locale-change source; when given, re-applies the label on a
74
+ * language switch (the plain-DOM row otherwise keeps the mount-time copy).
75
+ * @returns disposer removing the entry and its observers.
76
+ */
77
+ export function mountSidebarEntry(
78
+ handlers: UsageSidebarEntryHandlers,
79
+ locale?: LocaleRefreshSource,
80
+ ): () => void {
81
+ return mountSharedSidebarEntry({
82
+ rowAttribute: 'data-dsh-usage-entry',
83
+ rowSelector: ENTRY_SELECTOR,
84
+ plugin: 'usage',
85
+ icon: ICON,
86
+ css,
87
+ label: () => t('usage.sidebar.entry.label'),
88
+ tooltip: () => t('usage.sidebar.entry.tooltip'),
89
+ refresh: locale === undefined ? undefined : { subscribe: (listener) => locale.subscribe(listener) },
90
+ onToggle: handlers.onToggle,
91
+ actions: entryActions(handlers),
92
+ // Directly after the family entries (task board / SSH / skill center) and
93
+ // before the workspace browser, matching the requested sidebar order.
94
+ position: 'after',
95
+ familySelectors: ['[data-dsh-taskboard-entry]', '[data-dsh-ssh-entry]', '[data-dsh-skill-explorer-entry]', '[data-dsh-usage-entry]'],
96
+ active: {
97
+ subscribe: handlers.subscribeOpen,
98
+ isOpen: handlers.isOpen,
99
+ },
100
+ })
101
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Sidebar usage panel mounting (issue #1592).
3
+ *
4
+ * The panel is a plain DOM row inserted into the sidebar right below the
5
+ * usage entry row, carrying its own React root. Placement self-heals with the
6
+ * same page-wide body-mutation hub the entry row uses; the entry row stays the
7
+ * anchor, so the sidebar order never depends on this module's timing.
8
+ *
9
+ * The mount owns the panel's single open state: initialized from localStorage,
10
+ * flipped by the entry row's toggle controls, and mirrored to the React body
11
+ * through the subscribe/isOpen face (the same face the entry row's chevron
12
+ * and highlight read).
13
+ * @module @linxin666/dsh-usage/client/sidebar-panel-mount
14
+ */
15
+
16
+ import { createElement } from 'react'
17
+ import { createRoot, type Root } from 'react-dom/client'
18
+ import { subscribeBodyInvalidations } from './body-mutations.ts'
19
+ import { UsageSidebarPanel, type OpenStateSource } from './UsageSidebarPanel.tsx'
20
+ import type { UsageStoreInstance } from './usage-store.ts'
21
+ import { ENTRY_SELECTOR } from './sidebar-entry.ts'
22
+
23
+ /** localStorage key holding the collapsed flag ('1' = collapsed). */
24
+ export const COLLAPSED_STORAGE_KEY = 'dsh-usage.sidebar.collapsed'
25
+
26
+ /** Read the persisted collapsed flag (absent = expanded). */
27
+ function readCollapsed(): boolean {
28
+ try {
29
+ return window.localStorage.getItem(COLLAPSED_STORAGE_KEY) === '1'
30
+ } catch {
31
+ // Storage can be unavailable (privacy modes); the panel stays expanded.
32
+ return false
33
+ }
34
+ }
35
+
36
+ /** Persist the collapsed flag; storage failures keep the session state. */
37
+ function writeCollapsed(collapsed: boolean): void {
38
+ try {
39
+ window.localStorage.setItem(COLLAPSED_STORAGE_KEY, collapsed ? '1' : '0')
40
+ } catch {
41
+ // Storage unavailable: the state still flips for this session.
42
+ }
43
+ }
44
+
45
+ /** Panel inputs the mount forwards into the React tree. */
46
+ export interface UsagePanelMountProps {
47
+ store: UsageStoreInstance
48
+ /** Fetch one overview now. */
49
+ poll: () => void
50
+ }
51
+
52
+ /** Mounted panel controller: the entry row drives the toggle and mirrors the state. */
53
+ export interface UsagePanelMount {
54
+ /** Whether the panel body is currently expanded. */
55
+ isOpen(): boolean
56
+ /** Subscribe to open/collapse changes (the entry row's chevron and highlight). */
57
+ subscribe(listener: () => void): () => void
58
+ /** Flip the open state, persist it, and notify subscribers. */
59
+ toggle(): void
60
+ /** Unmount the panel and remove its container. */
61
+ dispose(): void
62
+ }
63
+
64
+ /**
65
+ * Mount the sidebar usage panel below its entry row.
66
+ * @param props - the store and poll callback of the apply body.
67
+ * @returns the panel controller.
68
+ */
69
+ export function mountUsagePanel(props: UsagePanelMountProps): UsagePanelMount {
70
+ const container = document.createElement('div')
71
+ container.setAttribute('data-dsh-usage-view', '')
72
+
73
+ const listeners = new Set<() => void>()
74
+ let open = !readCollapsed()
75
+ const emit = (): void => { for (const listener of [...listeners]) listener() }
76
+ const openState: OpenStateSource = {
77
+ subscribe: (listener: () => void) => {
78
+ listeners.add(listener)
79
+ return () => { listeners.delete(listener) }
80
+ },
81
+ isOpen: () => open,
82
+ }
83
+
84
+ const root: Root = createRoot(container)
85
+ root.render(createElement(UsageSidebarPanel, { store: props.store, poll: props.poll, open: openState }))
86
+
87
+ /** Keep the panel as the row directly after the usage entry. */
88
+ const place = (): void => {
89
+ const entry = document.querySelector<HTMLElement>(ENTRY_SELECTOR)
90
+ if (entry === null || entry.parentElement === null) return
91
+ if (container.parentElement !== entry.parentElement || entry.nextElementSibling !== container) {
92
+ entry.parentElement.insertBefore(container, entry.nextElementSibling)
93
+ }
94
+ }
95
+ place()
96
+ const unsubscribeBody = subscribeBodyInvalidations(place)
97
+
98
+ return {
99
+ isOpen: () => open,
100
+ subscribe: openState.subscribe,
101
+ toggle: () => {
102
+ open = !open
103
+ writeCollapsed(!open)
104
+ emit()
105
+ },
106
+ dispose: () => {
107
+ unsubscribeBody()
108
+ root.unmount()
109
+ container.remove()
110
+ listeners.clear()
111
+ },
112
+ }
113
+ }