dsh-plugin-showcase 0.0.0-stage → 0.2.0

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/lib/client.js ADDED
@@ -0,0 +1,570 @@
1
+ // dsh-plugin-showcase · client half (lazy-CJS factory, no build step).
2
+ // Loading this file only registers a factory; the body runs when the module is materialized.
3
+ window.__ModuleLoader__.load({
4
+ id: 'dsh-plugin-showcase',
5
+ factory: (require) => {
6
+ const React = require('react')
7
+ const h = React.createElement
8
+
9
+ const VERSION = '0.2.0'
10
+ const TAB_ID = 'showcase-panel'
11
+ const TAB_KIND = 'showcase'
12
+
13
+ const dict = {
14
+ en: {
15
+ title: 'Showcase',
16
+ subtitle: 'A DeepSeek Harness reference plugin: one tool, one hook, one command, a settings card and a right-sidebar panel.',
17
+ panelTitle: 'Showcase panel',
18
+ panelEmpty: 'No snapshot available — the Showcase host service is not reachable right now.',
19
+ gateOn: 'Guard gate: on',
20
+ gateOff: 'Guard gate: off',
21
+ openPanel: 'Open showcase panel',
22
+ refresh: 'Refresh',
23
+ welcome: 'Welcome to Showcase',
24
+ stepDoctorTitle: '1 · Run a health check',
25
+ stepDoctorBody: 'Send this command in the conversation box; the host answers with a full DSH health report.',
26
+ stepSettingsTitle: '2 · Open the settings card',
27
+ stepSettingsBody: 'Settings → Showcase shows the same snapshot, the guard-gate state and this plugin version.',
28
+ stepGateTitle: '3 · Understand the guard gate',
29
+ stepGateBody: 'The gate refuses destructive shell commands before they run. This command turns it on or off.',
30
+ copyHint: 'Copy',
31
+ copied: 'Copied',
32
+ doctorHint: 'The client half cannot call the host directly, so the command is handed to you as selectable text.',
33
+ aboutBody: 'Tools, hooks, commands, settings and a sidebar panel in one small package — a reference implementation you can copy.',
34
+ },
35
+ zh: {
36
+ title: '功能展示',
37
+ subtitle: 'DeepSeek Harness 参考插件:一个工具、一个钩子、一条命令、一张设置卡片与一个右侧栏面板。',
38
+ panelTitle: '巡检面板',
39
+ panelEmpty: '暂时取不到巡检数据:Showcase 宿主服务当前不可达。',
40
+ gateOn: '拦截门:已开启',
41
+ gateOff: '拦截门:已关闭',
42
+ openPanel: '打开巡检面板',
43
+ refresh: '刷新',
44
+ welcome: '欢迎使用功能展示',
45
+ stepDoctorTitle: '1 · 跑一次体检',
46
+ stepDoctorBody: '把这条命令发到对话输入框,宿主会回一份完整的 DSH 体检报告。',
47
+ stepSettingsTitle: '2 · 打开设置卡',
48
+ stepSettingsBody: '在「设置 → 功能展示」里能看到同一份巡检快照、拦截门状态与本插件版本。',
49
+ stepGateTitle: '3 · 了解拦截门',
50
+ stepGateBody: '拦截门会在破坏性 shell 命令执行前拦下它,用这条命令开启或关闭。',
51
+ copyHint: '复制',
52
+ copied: '已复制',
53
+ doctorHint: '客户端无法直接调用宿主,所以这里把命令作为可选中文本交给你。',
54
+ aboutBody: '工具、钩子、命令、设置卡与侧栏面板都装在这一个小包里——可直接抄走的参考实现。',
55
+ },
56
+ }
57
+
58
+ // The three seats are mounted by different owners; the Client Context of this
59
+ // plugin is the one dependency they share, so it is kept here once apply ran.
60
+ let currentCtx = null
61
+
62
+ function pickLang(value) {
63
+ const tag = typeof value === 'string' ? value.toLowerCase().split(/[-_]/)[0] : ''
64
+ return tag === 'zh' || tag === 'en' ? tag : null
65
+ }
66
+
67
+ function browserLang() {
68
+ const nav = typeof navigator === 'undefined' ? '' : navigator.language
69
+ return typeof nav === 'string' && nav.toLowerCase().startsWith('zh') ? 'zh' : 'en'
70
+ }
71
+
72
+ function lang(ctx) {
73
+ const target = ctx === undefined || ctx === null ? currentCtx : ctx
74
+ try {
75
+ const state = target === null || target === undefined ? null : target.get?.('locale')?.getSnapshot?.()
76
+ return pickLang(state?.active) || pickLang(state?.locale) || browserLang()
77
+ } catch (error) {
78
+ return browserLang()
79
+ }
80
+ }
81
+
82
+ function text(ctx) {
83
+ return dict[lang(ctx)]
84
+ }
85
+
86
+ function getOf(ctx, key) {
87
+ if (ctx === null || ctx === undefined) return undefined
88
+ try {
89
+ const value = ctx[key]
90
+ if (value !== undefined && value !== null) return value
91
+ return typeof ctx.get === 'function' ? ctx.get(key) : undefined
92
+ } catch (error) {
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ // ---- data ----------------------------------------------------------------
98
+
99
+ // B owns the wire name (§7); every shape it may legitimately take is tried in
100
+ // one place, and any failure degrades to null instead of throwing.
101
+ function remoteCandidate(ctx) {
102
+ const target = ctx === undefined || ctx === null ? currentCtx : ctx
103
+ const reads = [
104
+ () => getOf(target, 'remote.showcase'),
105
+ () => getOf(getOf(target, 'remote'), 'showcase'),
106
+ () => getOf(target, 'showcase'),
107
+ ]
108
+ for (const read of reads) {
109
+ try {
110
+ const service = read()
111
+ if (service !== undefined && service !== null && typeof service.snapshot === 'function') return service
112
+ } catch (error) {
113
+ // try the next shape
114
+ }
115
+ }
116
+ return null
117
+ }
118
+
119
+ function asSnapshot(value) {
120
+ if (value === null || typeof value !== 'object') return null
121
+ if (Array.isArray(value.sections) || typeof value.generatedAt === 'string') return value
122
+ return null
123
+ }
124
+
125
+ // A RemoteResult carries { ok, value } | { ok:false, error }; a plain service
126
+ // answers the Snapshot itself.
127
+ function unwrap(result) {
128
+ if (result === null || typeof result !== 'object') return null
129
+ if (typeof result.ok === 'boolean') return result.ok ? asSnapshot(result.value) : null
130
+ return asSnapshot(result)
131
+ }
132
+
133
+ async function fetchSnapshot(ctx) {
134
+ try {
135
+ const service = remoteCandidate(ctx)
136
+ if (service === null) return null
137
+ return unwrap(await service.snapshot())
138
+ } catch (error) {
139
+ return null
140
+ }
141
+ }
142
+
143
+ // ---- shared UI -----------------------------------------------------------
144
+
145
+ const MONO = 'ui-monospace, SFMono-Regular, Consolas, monospace'
146
+
147
+ // Every box is min-width:0 + overflowX:hidden so a squeezed container can only
148
+ // shorten lines, never force the host into a horizontal scrollbar.
149
+ const S = {
150
+ card: { display: 'flex', flexDirection: 'column', gap: 10, padding: '16px 18px', minWidth: 0, maxWidth: '100%', boxSizing: 'border-box', overflowX: 'hidden', fontSize: 13, color: 'inherit' },
151
+ cardNarrow: { gap: 8, padding: '10px 8px' },
152
+ pane: { display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', minWidth: 0, maxWidth: '100%', boxSizing: 'border-box', overflowX: 'hidden', fontSize: 13, color: 'inherit' },
153
+ paneNarrow: { gap: 8, padding: '8px' },
154
+ head: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 },
155
+ title: { margin: 0, fontSize: 14, fontWeight: 600 },
156
+ subtitle: { margin: 0, fontSize: 12, lineHeight: 1.55, opacity: 0.72 },
157
+ welcome: { margin: 0, fontSize: 13, fontWeight: 600 },
158
+ about: { margin: 0, fontSize: 12, lineHeight: 1.55, opacity: 0.72 },
159
+ toolbar: { display: 'flex', flexWrap: 'wrap', gap: 8 },
160
+ button: { font: 'inherit', fontSize: 12, padding: '3px 10px', borderRadius: 6, border: '1px solid rgba(128,128,128,.45)', background: 'transparent', color: 'inherit', cursor: 'pointer' },
161
+ footerAction: { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 26, height: 26, borderRadius: 6, border: 0, background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 14 },
162
+ note: { margin: 0, fontSize: 12, lineHeight: 1.55, opacity: 0.7 },
163
+ steps: { display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 },
164
+ step: { display: 'flex', flexDirection: 'column', gap: 4, padding: '8px 10px', borderRadius: 8, border: '1px solid rgba(128,128,128,.25)', minWidth: 0 },
165
+ stepTitle: { margin: 0, fontSize: 13, fontWeight: 600 },
166
+ stepBody: { margin: 0, fontSize: 12, lineHeight: 1.5, opacity: 0.72, overflowWrap: 'anywhere' },
167
+ copyRow: { display: 'flex', gap: 6, alignItems: 'center', minWidth: 0 },
168
+ copyRowNarrow: { display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'stretch', minWidth: 0 },
169
+ code: { flex: '1 1 auto', minWidth: 0, padding: '3px 8px', borderRadius: 6, background: 'rgba(128,128,128,.14)', fontFamily: MONO, fontSize: 12, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', wordBreak: 'break-word' },
170
+ foot: { display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'baseline', fontSize: 11, opacity: 0.62, minWidth: 0 },
171
+ group: { display: 'flex', flexDirection: 'column', gap: 3 },
172
+ groupTitle: { margin: '4px 0 0', fontSize: 12, fontWeight: 600, opacity: 0.85 },
173
+ row: { display: 'flex', gap: 8, alignItems: 'baseline', minWidth: 0 },
174
+ rowNarrow: { display: 'flex', flexDirection: 'column', gap: 1, minWidth: 0 },
175
+ rowLabel: { flex: '0 0 auto', minWidth: 82, fontSize: 12, opacity: 0.6 },
176
+ rowLabelNarrow: { fontSize: 11, opacity: 0.6 },
177
+ rowValue: { flex: '1 1 auto', minWidth: 0, fontSize: 12, fontFamily: MONO, overflowWrap: 'anywhere', wordBreak: 'break-word' },
178
+ rowValueNarrow: { fontSize: 12, fontFamily: MONO, overflowWrap: 'anywhere', wordBreak: 'break-word' },
179
+ }
180
+
181
+ // Below this the label column alone eats the line, so pairs stack instead of
182
+ // squeezing one character per line.
183
+ const NARROW_MAX = 320
184
+ const COPIED_MS = 1600
185
+
186
+ function row(key, label, value, narrow) {
187
+ const shown = value === null || value === undefined || value === '' ? '—' : String(value)
188
+ return h('div', { key, style: narrow === true ? S.rowNarrow : S.row },
189
+ h('span', { style: narrow === true ? S.rowLabelNarrow : S.rowLabel }, label),
190
+ h('span', { style: narrow === true ? S.rowValueNarrow : S.rowValue }, shown))
191
+ }
192
+
193
+ function factRows(snapshot, t) {
194
+ const workspace = snapshot.workspace || {}
195
+ const session = snapshot.session || {}
196
+ const plugin = snapshot.plugin || {}
197
+ return [
198
+ ['generatedAt', snapshot.generatedAt],
199
+ ['cwd', workspace.cwd],
200
+ ['gitBranch', workspace.gitBranch],
201
+ ['model', session.model],
202
+ ['version', plugin.version],
203
+ ['gate', plugin.gateEnabled === true ? t.gateOn : t.gateOff],
204
+ ]
205
+ }
206
+
207
+ function snapshotBody(snapshot, t, narrow) {
208
+ const sections = Array.isArray(snapshot.sections) ? snapshot.sections : []
209
+ const facts = factRows(snapshot, t)
210
+ return h('div', { style: S.group },
211
+ h('h4', { style: S.groupTitle }, 'snapshot'),
212
+ facts.map(([label, value], index) => row('fact-' + index, label, value, narrow)),
213
+ sections.map((section, index) => h('div', { key: 'section-' + index, style: S.group },
214
+ h('h4', { style: S.groupTitle }, String((section && section.title) || '')),
215
+ (Array.isArray(section && section.items) ? section.items : []).map((item, itemIndex) =>
216
+ row('item-' + index + '-' + itemIndex, String((item && item.label) || ''), item && item.value, narrow)))))
217
+ }
218
+
219
+ // Contract v1 §7 fallback, kept: with no client-reachable remote the seats still
220
+ // say what this plugin offers instead of rendering nothing.
221
+ const CAPABILITIES = [
222
+ ['tool', 'showcase_inspect'],
223
+ ['command', '/showcase'],
224
+ ['plugin', 'dsh-plugin-showcase'],
225
+ ]
226
+
227
+ function capabilityBlock(t, narrow) {
228
+ return h('div', { style: S.group },
229
+ CAPABILITIES.map(([label, value], index) => row('cap-' + index, label, value, narrow)))
230
+ }
231
+
232
+ function copyText(value) {
233
+ try {
234
+ const clipboard = typeof navigator === 'undefined' ? null : navigator.clipboard
235
+ if (clipboard === null || clipboard === undefined || typeof clipboard.writeText !== 'function') return false
236
+ const pending = clipboard.writeText(value)
237
+ if (pending !== null && typeof pending === 'object' && typeof pending.catch === 'function') pending.catch(() => {})
238
+ return true
239
+ } catch (error) {
240
+ return false
241
+ }
242
+ }
243
+
244
+ // The command stays on screen as selectable text, so a blocked clipboard still
245
+ // leaves the reader something to copy by hand.
246
+ function copyRow(id, value, t, narrow, copiedId, onCopy) {
247
+ const copied = copiedId === id
248
+ return h('div', { key: 'copy-' + id, style: narrow === true ? S.copyRowNarrow : S.copyRow },
249
+ h('code', { style: S.code }, value),
250
+ h('button', {
251
+ type: 'button',
252
+ style: S.button,
253
+ onClick: () => onCopy(id, value),
254
+ 'aria-label': t.copyHint,
255
+ title: t.copyHint,
256
+ }, copied ? t.copied : t.copyHint))
257
+ }
258
+
259
+ function guideSteps(t, narrow, copiedId, onCopy) {
260
+ const steps = [
261
+ { id: 'doctor', title: t.stepDoctorTitle, body: t.stepDoctorBody, value: '/showcase doctor' },
262
+ { id: 'settings', title: t.stepSettingsTitle, body: t.stepSettingsBody, value: t.title },
263
+ { id: 'gate', title: t.stepGateTitle, body: t.stepGateBody, value: '/showcase on|off' },
264
+ ]
265
+ return steps.map(step => h('div', { key: 'step-' + step.id, style: S.step },
266
+ h('h4', { style: S.stepTitle }, step.title),
267
+ h('p', { style: S.stepBody }, step.body),
268
+ copyRow(step.id, step.value, t, narrow, copiedId, onCopy)))
269
+ }
270
+
271
+ function gateLabel(t, snapshot) {
272
+ if (snapshot === null || snapshot === undefined) return '—'
273
+ const plugin = snapshot.plugin || {}
274
+ return plugin.gateEnabled === true ? t.gateOn : t.gateOff
275
+ }
276
+
277
+ // ---- hooks ---------------------------------------------------------------
278
+
279
+ // Re-renders on a language switch so the visible copy follows the label thunks.
280
+ function useLangTick(ctx) {
281
+ if (typeof React.useState !== 'function' || typeof React.useEffect !== 'function') return
282
+ const [, setGen] = React.useState(0)
283
+ React.useEffect(() => {
284
+ const locale = getOf(ctx, 'locale')
285
+ let dispose = null
286
+ try {
287
+ dispose = typeof locale?.subscribe === 'function' ? locale.subscribe(() => setGen((value) => value + 1)) : null
288
+ } catch (error) {
289
+ dispose = null
290
+ }
291
+ return () => {
292
+ try {
293
+ if (typeof dispose === 'function') dispose()
294
+ } catch (error) {
295
+ // a locale subscription that cannot be released must not break unmount
296
+ }
297
+ }
298
+ }, [])
299
+ }
300
+
301
+ function useSnapshot(ctx) {
302
+ const [state, setState] = React.useState({ loading: true, data: null })
303
+ const refresh = () => {
304
+ setState((previous) => ({ loading: true, data: previous.data }))
305
+ fetchSnapshot(ctx).then(
306
+ (data) => setState({ loading: false, data }),
307
+ () => setState({ loading: false, data: null }))
308
+ }
309
+ React.useEffect(() => { refresh() }, [])
310
+ return [state, refresh]
311
+ }
312
+
313
+ function useCopyState() {
314
+ const [copiedId, setCopiedId] = React.useState(null)
315
+ const timer = useRefOf()
316
+ const copy = (id, value) => {
317
+ copyText(value)
318
+ setCopiedId(id)
319
+ try {
320
+ if (typeof setTimeout !== 'function') return
321
+ if (timer.current !== null && timer.current !== undefined) clearTimeout(timer.current)
322
+ timer.current = setTimeout(() => setCopiedId(null), COPIED_MS)
323
+ } catch (error) {
324
+ // the badge stays until the next render
325
+ }
326
+ }
327
+ return [copiedId, copy]
328
+ }
329
+
330
+ // The host forces a very narrow content column on small screens; stacking is
331
+ // driven by the measured box, not the viewport, because only the box knows.
332
+ function useNarrow(ref) {
333
+ const [narrow, setNarrow] = React.useState(false)
334
+ React.useEffect(() => {
335
+ const element = ref === null || ref === undefined ? null : ref.current
336
+ const box = element !== null && element !== undefined && typeof element.getBoundingClientRect === 'function'
337
+ ? element
338
+ : null
339
+ const viewport = () => (typeof window !== 'undefined' && typeof window.innerWidth === 'number' ? window.innerWidth : NARROW_MAX + 1)
340
+ const measure = () => {
341
+ const width = box === null ? viewport() : box.getBoundingClientRect().width
342
+ setNarrow(width > 0 && width < NARROW_MAX)
343
+ }
344
+ measure()
345
+ if (box !== null && typeof ResizeObserver === 'function') {
346
+ const observer = new ResizeObserver(measure)
347
+ observer.observe(box)
348
+ return () => observer.disconnect()
349
+ }
350
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
351
+ window.addEventListener('resize', measure)
352
+ return () => window.removeEventListener('resize', measure)
353
+ }
354
+ return undefined
355
+ }, [])
356
+ return narrow === true
357
+ }
358
+
359
+ function useRefOf() {
360
+ return typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
361
+ }
362
+
363
+ // Cached by the service watch below: the controller may arrive after apply.
364
+ let rightController = null
365
+
366
+ function resolveSidebarRight(ctx) {
367
+ const target = ctx === undefined || ctx === null ? currentCtx : ctx
368
+ const reads = [
369
+ () => (target === null || target === undefined ? undefined : target.get?.('sidebarRight')),
370
+ () => (target === null || target === undefined ? undefined : target.sidebarRight),
371
+ () => rightController,
372
+ ]
373
+ for (const read of reads) {
374
+ try {
375
+ const candidate = read()
376
+ if (candidate !== undefined && candidate !== null && typeof candidate.openTab === 'function') return candidate
377
+ } catch (error) {
378
+ // keep looking
379
+ }
380
+ }
381
+ return null
382
+ }
383
+
384
+ function openPanel(ctx) {
385
+ const right = resolveSidebarRight(ctx)
386
+ if (right === null) {
387
+ console.warn('[dsh-plugin-showcase] sidebarRight is not available; the showcase panel cannot be opened')
388
+ return false
389
+ }
390
+ try {
391
+ right.openTab(TAB_KIND)
392
+ return true
393
+ } catch (error) {
394
+ console.warn('[dsh-plugin-showcase] sidebarRight.openTab("' + TAB_KIND + '") failed:', error)
395
+ return false
396
+ }
397
+ }
398
+
399
+ // ---- seats ---------------------------------------------------------------
400
+
401
+ function SettingsCard() {
402
+ const ctx = currentCtx
403
+ const t = text(ctx)
404
+ useLangTick(ctx)
405
+ const [state, refresh] = useSnapshot(ctx)
406
+ const [copiedId, copy] = useCopyState()
407
+ const ref = useRefOf()
408
+ const narrow = useNarrow(ref)
409
+ const degraded = state.data === null
410
+ return h('section', { ref, style: narrow ? { ...S.card, ...S.cardNarrow } : S.card },
411
+ h('h3', { style: S.title }, t.title),
412
+ h('p', { style: S.subtitle }, t.subtitle),
413
+ h('p', { style: S.note }, t.doctorHint),
414
+ copyRow('card-doctor', '/showcase doctor', t, narrow, copiedId, copy),
415
+ h('div', { style: S.toolbar },
416
+ h('button', { type: 'button', style: S.button, onClick: refresh }, t.refresh),
417
+ h('button', { type: 'button', style: S.button, onClick: () => openPanel(ctx) }, t.openPanel)),
418
+ h('div', { style: S.foot },
419
+ h('span', null, 'v' + VERSION),
420
+ h('span', null, gateLabel(t, state.data))),
421
+ capabilityBlock(t, narrow),
422
+ degraded ? h('p', { style: S.note }, t.panelEmpty) : snapshotBody(state.data, t, narrow))
423
+ }
424
+
425
+ function ShowcasePanel() {
426
+ const ctx = currentCtx
427
+ const t = text(ctx)
428
+ useLangTick(ctx)
429
+ const [state, refresh] = useSnapshot(ctx)
430
+ const [copiedId, copy] = useCopyState()
431
+ const ref = useRefOf()
432
+ const narrow = useNarrow(ref)
433
+ const degraded = state.data === null
434
+ return h('div', { ref, style: narrow ? { ...S.pane, ...S.paneNarrow } : S.pane },
435
+ h('div', { style: S.head },
436
+ h('h3', { style: S.title }, t.panelTitle),
437
+ h('button', { type: 'button', style: S.button, onClick: refresh }, t.refresh)),
438
+ h('h4', { style: S.welcome }, t.welcome),
439
+ h('p', { style: S.about }, t.aboutBody),
440
+ h('div', { style: S.steps }, guideSteps(t, narrow, copiedId, copy)),
441
+ h('p', { style: S.note }, t.doctorHint),
442
+ degraded ? capabilityBlock(t, narrow) : snapshotBody(state.data, t, narrow),
443
+ h('div', { style: S.foot },
444
+ h('span', null, 'v' + VERSION),
445
+ degraded ? h('span', null, t.panelEmpty) : null))
446
+ }
447
+
448
+ function FooterAction(props) {
449
+ const ctx = currentCtx
450
+ const t = text(ctx)
451
+ useLangTick(ctx)
452
+ const open = props !== null && props !== undefined && typeof props.open === 'function'
453
+ ? props.open
454
+ : () => openPanel(ctx)
455
+ return h('button', {
456
+ type: 'button',
457
+ title: t.openPanel,
458
+ 'aria-label': t.openPanel,
459
+ onClick: open,
460
+ style: S.footerAction,
461
+ }, '◎')
462
+ }
463
+
464
+ // ---- apply ---------------------------------------------------------------
465
+
466
+ function effectOf(ctx) {
467
+ if (typeof ctx.effect === 'function') return (fn, label) => ctx.effect(fn, label)
468
+ return (fn) => {
469
+ const dispose = fn()
470
+ return () => {
471
+ if (typeof dispose === 'function') dispose()
472
+ }
473
+ }
474
+ }
475
+
476
+ // ui-sidebar-right provides these services in its own apply, which may run
477
+ // after ours: an immediate ctx.get() read loses the race and the tab type is
478
+ // never registered, so the service is awaited instead of sampled.
479
+ function watchService(ctx, name, receive) {
480
+ const settle = (service, scope) => {
481
+ if (service === undefined || service === null) return
482
+ try {
483
+ receive(service, scope)
484
+ } catch (error) {
485
+ console.warn('[dsh-plugin-showcase] handler for ' + name + ' failed:', error)
486
+ }
487
+ }
488
+ if (typeof ctx.inject === 'function') {
489
+ try {
490
+ ctx.inject([name], (scope) => settle(scope === null || scope === undefined ? undefined : scope[name], scope))
491
+ return
492
+ } catch (error) {
493
+ console.warn('[dsh-plugin-showcase] ctx.inject(' + name + ') is unusable, falling back to a service watch:', error)
494
+ }
495
+ }
496
+ const present = getOf(ctx, name)
497
+ if (present !== undefined && present !== null) {
498
+ settle(present, ctx)
499
+ return
500
+ }
501
+ if (typeof setTimeout !== 'function') return
502
+ effectOf(ctx)(() => {
503
+ let done = false
504
+ let tries = 0
505
+ let timer = null
506
+ const tick = () => {
507
+ if (done) return
508
+ const service = getOf(ctx, name)
509
+ if (service !== undefined && service !== null) {
510
+ done = true
511
+ settle(service, ctx)
512
+ return
513
+ }
514
+ tries += 1
515
+ if (tries < 20) timer = setTimeout(tick, 150)
516
+ }
517
+ timer = setTimeout(tick, 0)
518
+ return () => {
519
+ done = true
520
+ if (timer !== null) clearTimeout(timer)
521
+ }
522
+ }, 'showcase: await ' + name)
523
+ }
524
+
525
+ function apply(ctx) {
526
+ currentCtx = ctx
527
+ const slots = getOf(ctx, 'slots')
528
+ if (slots === undefined || slots === null) return
529
+ const effect = effectOf(ctx)
530
+
531
+ effect(() => slots.inject('settings.section', () => slots.register({
532
+ name: 'settings.section',
533
+ id: 'showcase',
534
+ order: 90,
535
+ label: () => dict[lang(ctx)].title,
536
+ }, SettingsCard)), 'showcase: settings section')
537
+
538
+ // The tab type owns the id; that same id is the key its body seats under.
539
+ watchService(ctx, 'sidebarRightTabs', (tabs) => {
540
+ if (typeof tabs.register !== 'function') return
541
+ effect(() => tabs.register({
542
+ id: TAB_ID,
543
+ kind: TAB_KIND,
544
+ canOpen: () => true,
545
+ title: () => dict[lang(ctx)].panelTitle,
546
+ }), 'showcase: right pane tab type')
547
+ })
548
+
549
+ watchService(ctx, 'sidebarRight', (controller) => { rightController = controller })
550
+
551
+ effect(() => slots.inject('sidebar.right.pane.tab', () => slots.register({
552
+ name: 'sidebar.right.pane.tab',
553
+ key: TAB_ID,
554
+ }, ShowcasePanel)), 'showcase: right pane body')
555
+
556
+ // 'label' is legal list metadata (string | (() => string), resolved by
557
+ // resolveSlotLabel), but this seat is projected with only { wide }, so the
558
+ // button's own glyph and aria-label carry the visible text.
559
+ effect(() => slots.inject('sidebar.footer.action', () => slots.register({
560
+ name: 'sidebar.footer.action',
561
+ id: 'showcase-open',
562
+ order: 90,
563
+ label: () => dict[lang(ctx)].openPanel,
564
+ inject: () => ({ open: () => openPanel(ctx) }),
565
+ }, FooterAction)), 'showcase: sidebar footer action')
566
+ }
567
+
568
+ return { apply, inject: ['slots'], fetchSnapshot, lang, dict, VERSION, TAB_ID, TAB_KIND }
569
+ },
570
+ })