dsh-deferred-boot 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project are documented in this file.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.3.1] - 2026-11-07
8
+
9
+ ### Added
10
+ - Plugin list inside the configuration form: installed plugins are grouped into
11
+ third-party and official entries (official collapsed by default), each with a
12
+ lightweight / medium / heavy startup-cost grade and an estimated mount cost.
13
+ - Checkbox selection writes `deferredPlugins` entries (`id`, `name`,
14
+ `strategy: "idle"`); the plugin itself and `@deepseek-ai/*` core rows are locked.
15
+ - Estimated extra startup wait shown at the bottom of the form.
16
+
17
+ ### Changed
18
+ - The plugin page item renders a one-line summary in the list and the full form
19
+ when opened (view-aware: `summary` vs `page`).
20
+ - Right-side columns of the plugin list are fixed-width so grades, state and
21
+ cost line up.
22
+
23
+ ### Fixed
24
+ - The form no longer crashes (and disappears) before the settings controller is
25
+ ready: snapshot reads, strict-context access and JSON edits are guarded.
26
+ - The list entry is registered through `plugins.item` so settings → plugins
27
+ shows the card again.
28
+
7
29
  ## [0.3.0] - 2026-11-07
8
30
 
9
31
  ### Added
@@ -52,5 +74,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
52
74
  - Strategies: `idle`, `manual`, `inline-lazy`, `disabled`.
53
75
  - Tests for the scheduler, config normalization, and the client module.
54
76
 
55
- [0.2.0]: https://github.com/your-name/dsh-deferred-boot/releases/tag/v0.2.0
56
- [0.1.0]: https://github.com/your-name/dsh-deferred-boot/releases/tag/v0.1.0
77
+ [0.3.1]: https://github.com/Bu-sheng/dsh-deferred-boot/releases/tag/v0.3.1
78
+ [0.3.0]: https://github.com/Bu-sheng/dsh-deferred-boot/releases/tag/v0.3.0
79
+ [0.2.0]: https://github.com/Bu-sheng/dsh-deferred-boot/releases/tag/v0.2.0
80
+ [0.1.0]: https://github.com/Bu-sheng/dsh-deferred-boot/releases/tag/v0.1.0
package/lib/client.js CHANGED
@@ -1,9 +1,10 @@
1
1
  // Client half of dsh-deferred-boot.
2
2
  //
3
- // It renders the plugin's form on the plugin page (the bundle-config slot of
4
- // dsh-client-ui-plugin-manager) and reads/writes the `deferred-boot` settings
5
- // namespace through ctx.configForms. The host half (src/index.js) declares the
6
- // four fields as volatile so this form is the only place they are edited.
3
+ // It renders the plugin's form in the settings item slot of the plugin
4
+ // manager, lists the installed plugins with a startup cost grade, and
5
+ // reads/writes the `deferred-boot` settings namespace through ctx.configForms.
6
+ // The host half (src/index.js) declares the four fields as volatile so this
7
+ // form is the only place they are edited.
7
8
  window.__ModuleLoader__.load({
8
9
  id: 'dsh-deferred-boot',
9
10
  factory: (require) => {
@@ -52,6 +53,51 @@ window.__ModuleLoader__.load({
52
53
  font: 'inherit',
53
54
  },
54
55
  status: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
56
+ list: { margin: '4px 0 0', padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 2 },
57
+ item: { display: 'flex', alignItems: 'center', gap: 8, padding: '3px 0' },
58
+ itemName: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
59
+ itemMeta: { flex: '0 0 auto', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' },
60
+ badge: {
61
+ flex: '0 0 auto',
62
+ padding: '0 6px',
63
+ borderRadius: 4,
64
+ fontSize: 11,
65
+ lineHeight: '18px',
66
+ border: '1px solid var(--dsh-border, #3a3a3a)',
67
+ color: 'var(--dsh-text-2, #c8c8c8)',
68
+ },
69
+ badgeHeavy: { borderColor: '#a33', color: '#e88' },
70
+ badgeMedium: { borderColor: '#a73', color: '#ec8' },
71
+ badgeLight: { borderColor: '#3a7', color: '#8d8' },
72
+ footer: {
73
+ display: 'flex',
74
+ justifyContent: 'space-between',
75
+ alignItems: 'center',
76
+ marginTop: 10,
77
+ paddingTop: 8,
78
+ borderTop: '1px solid var(--dsh-border, #3a3a3a)',
79
+ fontSize: 12,
80
+ color: 'var(--dsh-text-3, #8b8b8b)',
81
+ },
82
+ estimate: { color: 'var(--dsh-text-2, #c8c8c8)' },
83
+ groupTitle: { margin: '12px 0 2px', fontSize: 12, color: 'var(--dsh-text-2, #c8c8c8)' },
84
+ linkButton: {
85
+ background: 'transparent',
86
+ border: 0,
87
+ padding: 0,
88
+ color: 'var(--dsh-accent, #4d6bfe)',
89
+ font: 'inherit',
90
+ fontSize: 12,
91
+ cursor: 'pointer',
92
+ },
93
+ card: {
94
+ border: '1px solid var(--dsh-border, #3a3a3a)',
95
+ borderRadius: 10,
96
+ padding: '12px 14px',
97
+ marginBottom: 10,
98
+ background: 'var(--dsh-surface-2, #1c1c1c)',
99
+ },
100
+ cardTitle: { margin: '0 0 10px', fontSize: 13, fontWeight: 600, color: 'var(--dsh-text-1, #e8e8e8)' },
55
101
  }
56
102
 
57
103
  const ZH = {
@@ -66,6 +112,21 @@ window.__ModuleLoader__.load({
66
112
  'hint.disposeTimeout': '卸载或热重载目标插件时最多等待多久(毫秒)。超时视为已卸载并继续,防止卸载卡住调度。',
67
113
  'hint.readyFallbackMs': '等不到“主界面已就绪”信号时的兜底超时(毫秒)。超时后按已就绪处理,继续延迟挂载。',
68
114
  'hint.plugins': '每项一个目标插件:id 为 loader 条目 id,name 为模块名,strategy 决定挂载方式,config 传给目标插件。',
115
+ 'catalog.title': '可放缓启动的插件',
116
+ 'catalog.hint': '勾选后这些插件会在主界面就绪后逐个挂载;评级按启动开销经验估算,仅供参考。',
117
+ 'catalog.loading': '正在读取已安装插件…',
118
+ 'catalog.failed': '读取插件列表失败(需要新版 DSH 客户端)。',
119
+ 'catalog.empty': '没有可勾选的插件。',
120
+ 'catalog.core': '核心',
121
+ 'catalog.self': '本插件',
122
+ 'catalog.running': '运行中',
123
+ 'catalog.disabled': '已禁用',
124
+ 'catalog.unknown': '未运行',
125
+ 'grade.light': '较轻',
126
+ 'grade.medium': '中等',
127
+ 'grade.heavy': '严重',
128
+ 'advanced': '高级:直接编辑 deferredPlugins(JSON)',
129
+ 'estimate': '预计本次启动额外等待 ≈ {seconds} 秒(估算)',
69
130
  save: '保存',
70
131
  saving: '保存中…',
71
132
  saved: '已保存,下一次操作起生效。',
@@ -75,6 +136,14 @@ window.__ModuleLoader__.load({
75
136
  notArray: '延迟插件必须是 JSON 数组。',
76
137
  unavailable: '配置服务暂不可用(需要新版 DSH 客户端)。',
77
138
  note: '配置写入当前 profile 的 Cordis patch,无需手动编辑 cordis.patch.yml。',
139
+ 'section.hint': '主界面就绪后再逐个挂载插件,避免成批初始化拖住首屏。',
140
+ 'section.open': '配置',
141
+ 'section.close': '收起',
142
+ 'panel.timing': '启动时机',
143
+ 'group.third': '第三方插件',
144
+ 'group.official': '官方插件',
145
+ 'group.expand': '展开官方插件',
146
+ 'group.collapse': '收起官方插件',
78
147
  }
79
148
 
80
149
  const EN = {
@@ -89,6 +158,21 @@ window.__ModuleLoader__.load({
89
158
  'hint.disposeTimeout': 'Maximum time to wait while unloading or hot-reloading a target (ms). On timeout the target is treated as unmounted and scheduling continues.',
90
159
  'hint.readyFallbackMs': 'Fallback timeout for “main UI ready” (ms). When no ready signal arrives, mounting starts after this delay.',
91
160
  'hint.plugins': 'One entry per deferred plugin: id is the loader entry id, name is the module name, strategy controls how it mounts, config is passed to the target.',
161
+ 'catalog.title': 'Plugins that can start later',
162
+ 'catalog.hint': 'Checked plugins mount one by one after the main UI is ready. Grades are rough startup-cost estimates for reference only.',
163
+ 'catalog.loading': 'Reading installed plugins…',
164
+ 'catalog.failed': 'Could not read the installed plugin list (a newer DSH client is required).',
165
+ 'catalog.empty': 'No plugins to choose from.',
166
+ 'catalog.core': 'core',
167
+ 'catalog.self': 'this plugin',
168
+ 'catalog.running': 'running',
169
+ 'catalog.disabled': 'disabled',
170
+ 'catalog.unknown': 'not running',
171
+ 'grade.light': 'light',
172
+ 'grade.medium': 'medium',
173
+ 'grade.heavy': 'heavy',
174
+ 'advanced': 'Advanced: edit deferredPlugins (JSON) directly',
175
+ 'estimate': 'Estimated extra wait this start ≈ {seconds}s (rough)',
92
176
  save: 'Save',
93
177
  saving: 'Saving…',
94
178
  saved: 'Saved; takes effect from the next operation.',
@@ -98,6 +182,27 @@ window.__ModuleLoader__.load({
98
182
  notArray: 'Deferred plugins must be a JSON array.',
99
183
  unavailable: 'The config form service is unavailable (a newer DSH client is required).',
100
184
  note: 'The change is written to the current profile Cordis patch; cordis.patch.yml stays untouched.',
185
+ 'section.hint': 'Mount plugins one by one after the main UI is ready so a batch of initializers cannot hold up the first screen.',
186
+ 'section.open': 'Configure',
187
+ 'section.close': 'Collapse',
188
+ 'panel.timing': 'Startup timing',
189
+ 'group.third': 'Third-party plugins',
190
+ 'group.official': 'Official plugins',
191
+ 'group.expand': 'Show official plugins',
192
+ 'group.collapse': 'Hide official plugins',
193
+ }
194
+
195
+ // Startup-cost grades are rough estimates used only for ordering and the
196
+ // bottom-right figure. They do not claim to be measurements.
197
+ const COST = { light: 200, medium: 600, heavy: 1800 }
198
+ const HEAVY = /billion-context|dshmarket|univer|office-toolkit|solidworks|mech-workbench|tabbit/
199
+ const MEDIUM = /dsh-pilot|whale|token-usage|plugin-mgr|dsh-unlag/
200
+
201
+ function gradeOf(name) {
202
+ const text = String(name || '')
203
+ if (HEAVY.test(text)) return 'heavy'
204
+ if (MEDIUM.test(text)) return 'medium'
205
+ return 'light'
101
206
  }
102
207
 
103
208
  function Row(props) {
@@ -110,44 +215,226 @@ window.__ModuleLoader__.load({
110
215
  }
111
216
 
112
217
  function useSnapshot(form) {
113
- const [snapshot, setSnapshot] = React.useState(() => form.getSnapshot())
218
+ // A settings controller can refuse `getSnapshot()` before its describe
219
+ // round-trip lands. That throw used to unmount the whole slot subtree
220
+ // (the form "flashed" and vanished), so every read is guarded.
221
+ const read = () => {
222
+ try {
223
+ if (!form || typeof form.getSnapshot !== 'function') return null
224
+ const value = form.getSnapshot()
225
+ return value && typeof value === 'object' ? value : null
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+ const [snapshot, setSnapshot] = React.useState(read)
114
231
  React.useEffect(() => {
115
- setSnapshot(form.getSnapshot())
116
- return form.subscribe(setSnapshot)
232
+ if (!form || typeof form.subscribe !== 'function') return undefined
233
+ const sync = () => {
234
+ try {
235
+ setSnapshot(form.getSnapshot())
236
+ } catch {}
237
+ }
238
+ sync()
239
+ let off = null
240
+ try {
241
+ off = form.subscribe(sync)
242
+ } catch {}
243
+ return () => {
244
+ try {
245
+ if (typeof off === 'function') off()
246
+ } catch {}
247
+ }
117
248
  }, [form])
118
249
  return snapshot
119
250
  }
120
251
 
252
+ function toPlugins(snapshot) {
253
+ const value = (snapshot && snapshot.value) || {}
254
+ return Array.isArray(value.deferredPlugins) ? value.deferredPlugins : []
255
+ }
256
+
121
257
  function toDraft(snapshot) {
122
258
  const value = (snapshot && snapshot.value) || {}
123
- const plugins = Array.isArray(value.deferredPlugins) ? value.deferredPlugins : []
124
259
  return {
125
260
  idleDelay: String(value.idleDelay ?? 1200),
126
261
  disposeTimeout: String(value.disposeTimeout ?? 10000),
127
262
  readyFallbackMs: String(value.readyFallbackMs ?? 5000),
128
- plugins: JSON.stringify(plugins, null, 2),
263
+ plugins: JSON.stringify(toPlugins(snapshot), null, 2),
129
264
  }
130
265
  }
131
266
 
267
+ function badgeStyle(grade) {
268
+ if (grade === 'heavy') return Object.assign({}, styles.badge, styles.badgeHeavy)
269
+ if (grade === 'medium') return Object.assign({}, styles.badge, styles.badgeMedium)
270
+ return Object.assign({}, styles.badge, styles.badgeLight)
271
+ }
272
+
132
273
  // The slot system does not guarantee that a form component receives the
133
274
  // plugin ctx, so keep the injected instance reachable from apply().
134
275
  let pluginCtx = null
135
276
 
277
+ // Reading a service off a Cordis context is gated by `inject` and throws on
278
+ // the strict proxy when the service is not injected for this component, so
279
+ // every access goes through try/catch: a throw here would unmount the whole
280
+ // slot subtree (the form "flashes" and disappears).
281
+ function configFormsOf(props) {
282
+ try {
283
+ const forms = props && props.ctx && props.ctx.configForms
284
+ if (forms && typeof forms.get === 'function') return forms
285
+ } catch {}
286
+ try {
287
+ const forms = pluginCtx && pluginCtx.configForms
288
+ if (forms && typeof forms.get === 'function') return forms
289
+ } catch {}
290
+ return null
291
+ }
292
+
293
+ // The slot renders the full form directly: opening the settings card (or
294
+ // the plugin page) lands on the form itself, without an extra collapsed
295
+ // row. The form lives in its own component so it mounts hook-safely.
136
296
  function DeferredBootForm(props) {
137
- const t = typeof props?.t === 'function' ? props.t : (key) => key
138
- const forms = (props && props.ctx && props.ctx.configForms) || (pluginCtx && pluginCtx.configForms)
139
- if (!forms || typeof forms.get !== 'function') return h('p', { style: styles.intro }, t('unavailable'))
140
- const form = forms.get('deferred-boot')
297
+ const t = typeof props?.t === 'function'
298
+ ? props.t
299
+ : (key, params) => {
300
+ let text = ZH[key] ?? key
301
+ if (params && params.seconds !== undefined) text = text.replace('{seconds}', params.seconds)
302
+ return text
303
+ }
304
+ const forms = configFormsOf(props)
305
+ if (!forms) return h('p', { style: styles.intro }, t('unavailable'))
306
+ let form = null
307
+ for (const namespace of ['deferred-boot', 'include:deferred-boot']) {
308
+ try {
309
+ const candidate = forms.get(namespace)
310
+ if (candidate && typeof candidate.getSnapshot === 'function') {
311
+ form = candidate
312
+ break
313
+ }
314
+ } catch {}
315
+ }
316
+ if (!form || typeof form.getSnapshot !== 'function') return h('p', { style: styles.intro }, t('unavailable'))
141
317
  const snapshot = useSnapshot(form)
142
318
  const [draft, setDraft] = React.useState(() => toDraft(snapshot))
143
319
  const [status, setStatus] = React.useState('')
320
+ const [catalog, setCatalog] = React.useState({ status: 'loading', rows: [] })
321
+ const [officialOpen, setOfficialOpen] = React.useState(false)
144
322
 
145
323
  React.useEffect(() => {
146
324
  setDraft(toDraft(snapshot))
147
325
  }, [snapshot])
148
326
 
327
+ // Read the installed plugin rows through the owner-side remote API. The
328
+ // owner page is always allowed, so no remoteAccess gate is needed.
329
+ React.useEffect(() => {
330
+ const ctx = pluginCtx || (props && props.ctx)
331
+ let alive = true
332
+ let timer = null
333
+ const fail = (message) => {
334
+ if (!alive) return
335
+ setCatalog({ status: 'failed', rows: [], error: message || '' })
336
+ }
337
+ if (!ctx) {
338
+ fail('no context')
339
+ return () => {
340
+ alive = false
341
+ }
342
+ }
343
+ // `remote` may be mounted slightly after the page loads, so retry a
344
+ // few times before giving up. Cordis 4 contexts are strict proxies:
345
+ // reading an unknown service throws, so guard every touch.
346
+ const attempt = (remaining) => {
347
+ let api = null
348
+ try {
349
+ api = (typeof ctx.get === 'function' ? ctx.get('remote.pluginManager') : null)
350
+ || (ctx.remote && ctx.remote.pluginManager)
351
+ } catch (error) {
352
+ fail(error && error.message ? error.message : 'remote unavailable')
353
+ return
354
+ }
355
+ if (!api || typeof api.listPlugins !== 'function') {
356
+ fail('pluginManager/listPlugins unavailable')
357
+ return
358
+ }
359
+ Promise.resolve()
360
+ .then(() => api.listPlugins())
361
+ .then((answer) => {
362
+ if (!alive) return
363
+ if (!answer || !answer.ok) {
364
+ if (remaining > 0) {
365
+ timer = setTimeout(() => attempt(remaining - 1), 1200)
366
+ return
367
+ }
368
+ fail(answer && answer.error ? (answer.error.code || answer.error.message) : 'listPlugins refused')
369
+ return
370
+ }
371
+ const rows = (Array.isArray(answer.value) ? answer.value : [])
372
+ .filter((row) => row && typeof row.moduleName === 'string')
373
+ .map((row) => ({
374
+ id: row.entryId ?? row.moduleName,
375
+ name: row.moduleName,
376
+ enabled: row.enabled !== false,
377
+ phase: row.fiberPhase ?? null,
378
+ }))
379
+ setCatalog({ status: 'ready', rows })
380
+ })
381
+ .catch((error) => {
382
+ if (!alive) return
383
+ if (remaining > 0) {
384
+ timer = setTimeout(() => attempt(remaining - 1), 1200)
385
+ return
386
+ }
387
+ fail(error && error.message ? error.message : 'listPlugins failed')
388
+ })
389
+ }
390
+ attempt(2)
391
+ return () => {
392
+ alive = false
393
+ if (timer) clearTimeout(timer)
394
+ }
395
+ }, [props && props.ctx])
396
+
149
397
  const set = (key) => (event) => setDraft((prev) => ({ ...prev, [key]: event.target.value }))
150
398
 
399
+ const plugins = React.useMemo(() => {
400
+ try {
401
+ const parsed = JSON.parse(draft.plugins)
402
+ return Array.isArray(parsed) ? parsed : []
403
+ } catch {
404
+ return []
405
+ }
406
+ }, [draft.plugins])
407
+
408
+ const selected = React.useMemo(() => {
409
+ const map = new Map()
410
+ for (const item of plugins) {
411
+ if (item && typeof item.name === 'string') map.set(item.name, item)
412
+ }
413
+ return map
414
+ }, [plugins])
415
+
416
+ const toggle = (row, checked) => {
417
+ let current = []
418
+ try {
419
+ const parsed = JSON.parse(draft.plugins)
420
+ current = Array.isArray(parsed) ? parsed : []
421
+ } catch {}
422
+ const list = current.slice()
423
+ const index = list.findIndex((item) => item && item.name === row.name)
424
+ if (checked && index < 0) {
425
+ list.push({ id: row.id, name: row.name, strategy: 'idle' })
426
+ } else if (!checked && index >= 0) {
427
+ list.splice(index, 1)
428
+ }
429
+ setDraft((prev) => ({ ...prev, plugins: JSON.stringify(list, null, 2) }))
430
+ }
431
+
432
+ const estimateMs = React.useMemo(() => {
433
+ let total = Number(draft.idleDelay) || 0
434
+ for (const name of selected.keys()) total += COST[gradeOf(name)] || 0
435
+ return total
436
+ }, [selected, draft.idleDelay])
437
+
151
438
  const save = async () => {
152
439
  let plugins
153
440
  try {
@@ -179,50 +466,128 @@ window.__ModuleLoader__.load({
179
466
  }
180
467
  }
181
468
 
469
+ const rows = catalog.rows
470
+ const groups = React.useMemo(() => {
471
+ const official = []
472
+ const third = []
473
+ for (const row of rows) {
474
+ if (row.name.indexOf('@deepseek-ai/') === 0) official.push(row)
475
+ else third.push(row)
476
+ }
477
+ return { official, third }
478
+ }, [rows])
479
+
480
+ const renderRow = (row) => {
481
+ const isSelf = row.name === 'dsh-deferred-boot'
482
+ const isCore = row.name.indexOf('@deepseek-ai/') === 0
483
+ const locked = isSelf || isCore
484
+ const grade = gradeOf(row.name)
485
+ const checked = selected.has(row.name)
486
+ const stateText = row.phase === 'failed' ? t('catalog.unknown') : row.enabled ? t('catalog.running') : t('catalog.disabled')
487
+ return h('li', { key: row.id, style: styles.item },
488
+ h('input', {
489
+ type: 'checkbox',
490
+ checked,
491
+ disabled: locked,
492
+ onChange: (event) => { if (!locked) toggle(row, event.target.checked) },
493
+ }),
494
+ h('span', { style: styles.itemName, title: row.name }, row.name),
495
+ h('span', { style: Object.assign({}, badgeStyle(grade), { width: 56, flex: '0 0 56px', textAlign: 'center' }) }, t('grade.' + grade)),
496
+ h('span', { style: { width: 56, flex: '0 0 56px', textAlign: 'right', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, isSelf ? t('catalog.self') : isCore ? t('catalog.core') : stateText),
497
+ h('span', { style: { width: 72, flex: '0 0 72px', textAlign: 'right', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)', fontVariantNumeric: 'tabular-nums' } }, '~' + COST[grade] + 'ms'),
498
+ )
499
+ }
500
+
182
501
  return h(
183
502
  'div',
184
503
  { style: styles.root },
185
504
  h('p', { style: styles.intro }, t('intro')),
186
- h('div', { style: styles.row, padding: 0 }, h('span', { style: styles.label }, t('plugins.label'))),
187
- h(Row, {
188
- label: t('field.idleDelay'),
189
- hint: t('hint.idleDelay'),
190
- }, h('input', { style: styles.input, type: 'number', value: draft.idleDelay, onChange: set('idleDelay') })),
191
- h(Row, {
192
- label: t('field.disposeTimeout'),
193
- hint: t('hint.disposeTimeout'),
194
- }, h('input', { style: styles.input, type: 'number', value: draft.disposeTimeout, onChange: set('disposeTimeout') })),
195
- h(Row, {
196
- label: t('field.readyFallbackMs'),
197
- hint: t('hint.readyFallbackMs'),
198
- }, h('input', { style: styles.input, type: 'number', value: draft.readyFallbackMs, onChange: set('readyFallbackMs') })),
199
- h('p', { style: { margin: '10px 0 0', color: 'var(--dsh-text-2, #c8c8c8)' } }, t('plugins.label')),
200
- h('p', { style: styles.note, margin: '2px 0 6px' }, t('plugins.hint')),
201
- h('textarea', { style: styles.textarea, value: draft.plugins, onChange: set('plugins') }),
202
- h('p', { style: styles.hint, margin: '2px 0 0' }, t('hint.plugins')),
203
- h('div', { style: styles.actions }, h('button', { style: styles.button, type: 'button', onClick: save }, t('save')), h('span', { style: styles.status }, status || t('note'))),
505
+ h('section', { style: styles.card },
506
+ h('h3', { style: styles.cardTitle }, t('panel.timing')),
507
+ h(Row, {
508
+ label: t('field.idleDelay'),
509
+ hint: t('hint.idleDelay'),
510
+ }, h('input', { style: styles.input, type: 'number', value: draft.idleDelay, onChange: set('idleDelay') })),
511
+ h(Row, {
512
+ label: t('field.disposeTimeout'),
513
+ hint: t('hint.disposeTimeout'),
514
+ }, h('input', { style: styles.input, type: 'number', value: draft.disposeTimeout, onChange: set('disposeTimeout') })),
515
+ h(Row, {
516
+ label: t('field.readyFallbackMs'),
517
+ hint: t('hint.readyFallbackMs'),
518
+ }, h('input', { style: styles.input, type: 'number', value: draft.readyFallbackMs, onChange: set('readyFallbackMs') })),
519
+ ),
520
+ h('section', { style: styles.card },
521
+ h('h3', { style: styles.cardTitle }, t('catalog.title')),
522
+ h('p', { style: { margin: '0 0 8px', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('catalog.hint')),
523
+ catalog.status === 'loading'
524
+ ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.loading'))
525
+ : catalog.status === 'failed'
526
+ ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.failed') + (catalog.error ? '(' + catalog.error + ')' : ''))
527
+ : rows.length === 0
528
+ ? h('p', { style: Object.assign({}, styles.note, { margin: '0' }) }, t('catalog.empty'))
529
+ : h('div', null,
530
+ h('p', { style: styles.groupTitle }, t('group.third') + '(' + groups.third.length + ')'),
531
+ groups.third.length === 0 ? null : h('ul', { style: styles.list }, groups.third.map(renderRow)),
532
+ h('button', { type: 'button', style: styles.linkButton, onClick: () => setOfficialOpen((value) => !value) },
533
+ (officialOpen ? t('group.collapse') : t('group.expand')) + '(' + groups.official.length + ')'),
534
+ officialOpen ? h('ul', { style: styles.list }, groups.official.map(renderRow)) : null,
535
+ ),
536
+ ),
537
+
538
+ h('details', { style: { marginTop: 10 } },
539
+ h('summary', { style: { cursor: 'pointer', fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('advanced')),
540
+ h('textarea', { style: styles.textarea, value: draft.plugins, onChange: set('plugins') }),
541
+ ),
542
+
543
+ h('div', { style: styles.footer },
544
+ h('span', { style: styles.status }, status || t('note')),
545
+ h('span', { style: styles.estimate }, t('estimate', { seconds: (estimateMs / 1000).toFixed(1) })),
546
+ ),
547
+ h('div', { style: styles.actions },
548
+ h('button', { style: styles.button, type: 'button', onClick: save }, t('save')),
549
+ typeof props?.onClose === 'function'
550
+ ? h('button', { type: 'button', style: styles.linkButton, onClick: props.onClose }, t('section.close'))
551
+ : null,
552
+ ),
204
553
  )
205
554
  }
206
555
 
556
+ // The settings item is rendered twice by the manager: once as the card
557
+ // summary in the installed list and once as the page body after opening
558
+ // it. `view` tells them apart, so the long form only mounts on the page.
559
+ function DeferredBootItem(props) {
560
+ const t = typeof props?.t === 'function'
561
+ ? props.t
562
+ : (key) => ZH[key] ?? key
563
+ if (props && props.view === 'summary') {
564
+ return h('span', { style: { fontSize: 12, color: 'var(--dsh-text-3, #8b8b8b)' } }, t('section.hint'))
565
+ }
566
+ return h(DeferredBootForm, props)
567
+ }
568
+
207
569
  function apply(ctx) {
208
570
  pluginCtx = ctx
209
- if (ctx.locale && typeof ctx.locale.register === 'function') {
210
- ctx.effect(() => ctx.locale.register('deferredBoot', 'zh', ZH), 'dsh-deferred-boot: zh dictionary')
211
- ctx.effect(() => ctx.locale.register('deferredBoot', 'en', EN), 'dsh-deferred-boot: en dictionary')
571
+ try {
572
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
573
+ ctx.effect(() => ctx.locale.register('deferredBoot', 'zh', ZH), 'dsh-deferred-boot: zh dictionary')
574
+ ctx.effect(() => ctx.locale.register('deferredBoot', 'en', EN), 'dsh-deferred-boot: en dictionary')
575
+ }
576
+ } catch (error) {
577
+ // A locale failure must never prevent the configuration form from mounting.
578
+ console.warn('dsh-deferred-boot: locale registration failed', error)
212
579
  }
213
580
  ctx.effect(
214
581
  () =>
215
- ctx.configForms.whileServed(['deferred-boot'], () =>
216
- ctx.slots.inject('plugins.bundle.config', () =>
217
- ctx.slots.register({ name: 'plugins.bundle.config', key: 'dsh-deferred-boot', locale: 'deferredBoot' }, DeferredBootForm),
218
- ),
582
+ ctx.slots.inject('plugins.item', () =>
583
+ ctx.slots.register({ name: 'plugins.item', id: 'dsh-deferred-boot', order: 40, label: '延迟启动' }, DeferredBootItem),
219
584
  ),
220
- 'dsh-deferred-boot: bundle config form',
585
+ 'dsh-deferred-boot: settings item',
221
586
  )
222
587
  }
223
588
 
224
589
  exports.apply = apply
225
- exports.inject = ['slots', 'configForms', 'remote', 'locale']
590
+ exports.inject = ['slots', 'configForms', 'remote']
226
591
  return module.exports
227
592
  },
228
593
  })
package/market/index.json CHANGED
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "versions": [
37
37
  {
38
- "version": "0.3.0",
39
- "publishTimestamp": 1794044331,
38
+ "version": "0.3.1",
39
+ "publishTimestamp": 1794052210,
40
40
  "source": {
41
41
  "type": "npm"
42
42
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-deferred-boot",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Mount deferred plugins after the main UI is ready, one idle slice at a time",
5
5
  "type": "module",
6
6
  "main": "src/index.js",