@linxin666/dsh-usage 0.3.22 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +6 -8
- package/README.zh.md +6 -8
- package/lib/client.js +749 -78
- package/lib/client.js.map +1 -1
- package/lib/index.js +171 -201
- package/lib/types/client/UsageSectionCard.d.ts +0 -1
- package/lib/types/client/UsageSidebarPanel.d.ts +34 -0
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/locales.d.ts +9 -4
- package/lib/types/client/sidebar-entry-core.d.ts +87 -0
- package/lib/types/client/sidebar-entry.d.ts +35 -0
- package/lib/types/client/sidebar-panel-mount.d.ts +40 -0
- package/lib/types/client/voucher.d.ts +2 -2
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +28 -73
- package/lib/types/index.d.ts +0 -2
- package/package.json +4 -3
- package/src/client/UsageSectionCard.tsx +1 -15
- package/src/client/UsageSidebarPanel.tsx +196 -0
- package/src/client/body-mutations.ts +133 -0
- package/src/client/index.ts +22 -1
- package/src/client/locales.ts +20 -10
- package/src/client/sidebar-entry-core.ts +321 -0
- package/src/client/sidebar-entry.ts +101 -0
- package/src/client/sidebar-panel-mount.tsx +113 -0
- package/src/client/usage.module.css +252 -0
- package/src/client/voucher.ts +2 -2
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +65 -214
- 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
|
+
}
|