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/LICENSE +21 -0
- package/README.md +286 -2
- package/README.zh.md +262 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/lib/client.js +570 -0
- package/lib/index.js +664 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/market/index.json +53 -0
- package/package.json +53 -4
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
|
+
})
|