dsh-manage-session 0.4.8 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.en.md +4 -0
  3. package/README.md +4 -0
  4. package/cordis.patch.yml +6 -1
  5. package/dsh.plugin.json +22 -22
  6. package/lib/client.js +3848 -202
  7. package/lib/index.d.ts +759 -29
  8. package/lib/index.js +2604 -93
  9. package/locale/de.json +6 -0
  10. package/locale/en.json +6 -0
  11. package/locale/es.json +6 -0
  12. package/locale/fr.json +6 -0
  13. package/locale/it.json +6 -0
  14. package/locale/ja.json +6 -0
  15. package/locale/ko.json +6 -0
  16. package/locale/ru.json +6 -0
  17. package/locale/zh.json +6 -0
  18. package/package.json +43 -32
  19. package/src/client/bridge-scope.ts +213 -0
  20. package/src/client/index.ts +154 -26
  21. package/src/client/locales.ts +857 -223
  22. package/src/client/panel.tsx +139 -118
  23. package/src/client/search/card.tsx +563 -0
  24. package/src/client/search/host-api.ts +208 -0
  25. package/src/client/search/locales.ts +780 -0
  26. package/src/client/search/manage.tsx +400 -0
  27. package/src/client/search/panel.tsx +815 -0
  28. package/src/client/search/platform.ts +136 -0
  29. package/src/config.ts +54 -5
  30. package/src/host/history/archive-source.ts +48 -0
  31. package/src/host/history/archive.ts +56 -1
  32. package/src/host/history/favorites.ts +71 -0
  33. package/src/host/index/archive-source.ts +90 -0
  34. package/src/host/index/engine.ts +571 -0
  35. package/src/host/index/extract.ts +272 -0
  36. package/src/host/index/peers.ts +62 -0
  37. package/src/host/index/rebuild.ts +368 -0
  38. package/src/host/index/schema.ts +136 -0
  39. package/src/host/index/snapshot.ts +135 -0
  40. package/src/host/index/sync.ts +286 -0
  41. package/src/host/registry/archive-registry.ts +317 -0
  42. package/src/host/settings-bridge.ts +237 -0
  43. package/src/index.ts +1421 -532
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Session-search settings card(0.1.7 起不再挂载:设置表单由 host 侧 .volatile() 字段自动生成)。
3
+ * plugin, following the dsh-thinking-levels card pattern.
4
+ *
5
+ * The card binds the `switch-search` settings namespace through the
6
+ * `settingsScope` cordis service and renders its fields as one editable card:
7
+ * the enable switch, the default panel mode, the independent-index sync
8
+ * knobs, and the index-lifecycle block (status, the non-destructive 整理
9
+ * button, and the JSON snapshot export/import seam). Every change commits
10
+ * immediately through the scope (no staged form).
11
+ *
12
+ * Kept dependency-free beyond react: the scope is subscribed with
13
+ * `useSyncExternalStore`, and the controls are plain HTML reusing the
14
+ * stylesheet the client half injects.
15
+ */
16
+ import { useEffect, useState, useSyncExternalStore, type JSX } from 'react'
17
+ import { createElement } from 'react'
18
+ import type { SwitchSearchConfig } from '../../config.ts'
19
+ import {
20
+ callHostAny,
21
+ downloadSnapshot,
22
+ type HostIndexStatus,
23
+ } from './host-api.ts'
24
+ import { translate, type LocaleKey } from './locales.ts'
25
+
26
+ /**
27
+ * Structural mirror of the settings scope (the plugin must not value- or
28
+ * type-import a single release of the official settings packages).
29
+ */
30
+ export interface SwitchCardScope {
31
+ getSnapshot(): {
32
+ status: 'loading' | 'ready' | 'unavailable'
33
+ value: SwitchSearchConfig | undefined
34
+ revision: number | undefined
35
+ writable: boolean
36
+ }
37
+ subscribe(listener: () => void): () => void
38
+ set(field: string, value: unknown): Promise<void>
39
+ }
40
+
41
+ /** Injected face the settings slot factory hands the card. */
42
+ export interface SearchSettingsCardInjected {
43
+ scope: SwitchCardScope
44
+ /** Optional host dictionary lookup (present when the locale service exists). */
45
+ t?: (key: LocaleKey, params?: Record<string, unknown>) => string
46
+ }
47
+
48
+ /** Full card props. */
49
+ export type SearchSettingsCardProps = SearchSettingsCardInjected
50
+
51
+ /** Row shared by every field of the card. */
52
+ function Row(props: { title: string; desc?: string; control: JSX.Element }): JSX.Element {
53
+ return createElement('div', { className: 'dsws_setRow' }, [
54
+ createElement('div', { key: 'text', className: 'dsws_setText' }, [
55
+ createElement('span', { key: 't', className: 'dsws_setTitle' }, props.title),
56
+ props.desc !== undefined
57
+ && createElement('span', { key: 'd', className: 'dsws_setDesc' }, props.desc),
58
+ ]),
59
+ createElement('div', { key: 'ctl' }, props.control),
60
+ ])
61
+ }
62
+
63
+ /**
64
+ * A boolean switch editing one namespace field.
65
+ *
66
+ * Drawn entirely with inline styles (thinking-levels discipline): the card
67
+ * must not depend on the injected stylesheet — scoped or late-loaded settings
68
+ * pages left the class-based switch rendering as a bare checkbox dot.
69
+ */
70
+ function Toggle(props: { checked: boolean; writable: boolean; onChange: (checked: boolean) => void }): JSX.Element {
71
+ const checked = props.checked
72
+ return createElement('label', {
73
+ style: {
74
+ position: 'relative',
75
+ width: '40px',
76
+ height: '22px',
77
+ flex: 'none',
78
+ display: 'inline-block',
79
+ cursor: props.writable ? 'pointer' : 'not-allowed',
80
+ },
81
+ }, [
82
+ createElement('input', {
83
+ key: 'input',
84
+ type: 'checkbox',
85
+ checked,
86
+ disabled: !props.writable,
87
+ onChange: (e: { target: { checked: boolean } }) => props.onChange(e.target.checked),
88
+ style: {
89
+ position: 'absolute',
90
+ inset: 0,
91
+ width: '100%',
92
+ height: '100%',
93
+ opacity: 0,
94
+ margin: 0,
95
+ cursor: props.writable ? 'pointer' : 'not-allowed',
96
+ },
97
+ }),
98
+ createElement('span', {
99
+ key: 'track',
100
+ style: {
101
+ position: 'absolute',
102
+ inset: 0,
103
+ background: checked
104
+ ? 'var(--dsw-alias-state-business-primary, #4c6ef5)'
105
+ : 'var(--dsw-alias-bg-module-platform, rgba(127,127,127,0.25))',
106
+ border: `1px solid ${checked
107
+ ? 'var(--dsw-alias-state-business-primary, #4c6ef5)'
108
+ : 'var(--dsw-alias-border-l2, rgba(127,127,127,0.35))'}`,
109
+ borderRadius: '11px',
110
+ transition: 'background .15s ease, border-color .15s ease',
111
+ pointerEvents: 'none',
112
+ },
113
+ }),
114
+ createElement('span', {
115
+ key: 'thumb',
116
+ style: {
117
+ position: 'absolute',
118
+ top: '2px',
119
+ left: '2px',
120
+ width: '16px',
121
+ height: '16px',
122
+ background: '#fff',
123
+ borderRadius: '50%',
124
+ boxShadow: '0 1px 2px rgba(0,0,0,.2)',
125
+ transition: 'transform .15s ease',
126
+ transform: checked ? 'translateX(18px)' : 'none',
127
+ pointerEvents: 'none',
128
+ },
129
+ }),
130
+ ])
131
+ }
132
+
133
+ /**
134
+ * A status pill in the official ConnectionIndicator visual language: rounded
135
+ * chip, semantic state tokens, animated dots while syncing.
136
+ */
137
+ function StatusPill(props: { state: 'ready' | 'syncing' | 'error' | 'neutral'; label: string; icon: string; dots?: boolean }): JSX.Element {
138
+ const stateClass = props.state === 'ready' ? 'dsws_pillReady'
139
+ : props.state === 'syncing' ? 'dsws_pillWarn'
140
+ : props.state === 'error' ? 'dsws_pillError'
141
+ : 'dsws_pillNeutral'
142
+ return createElement('span', { className: `dsws_pill ${stateClass}` }, [
143
+ createElement('span', { key: 'icon', className: 'dsws_pillIcon', 'aria-hidden': true }, props.icon),
144
+ createElement('span', { key: 'label', className: 'dsws_pillLabel' }, [
145
+ createElement('span', { key: 'text' }, props.label),
146
+ props.dots === true && createElement('span', {
147
+ key: 'dots',
148
+ className: 'dsws_pillDots',
149
+ 'aria-hidden': true,
150
+ }, createElement('span', {}, '.'), createElement('span', {}, '.'), createElement('span', {}, '.')),
151
+ ]),
152
+ ])
153
+ }
154
+
155
+ /** The independent-index lifecycle block (status + 整理 + snapshot seam). */
156
+ function IndexBlock(props: { t?: SearchSettingsCardProps['t'] }): JSX.Element {
157
+ const [status, setStatus] = useState<HostIndexStatus | null>(null)
158
+ const [fetchError, setFetchError] = useState<string | null>(null)
159
+ const [attempt, setAttempt] = useState(0)
160
+ const [note, setNote] = useState<string | null>(null)
161
+ const [busy, setBusy] = useState(false)
162
+
163
+ useEffect(() => {
164
+ let cancelled = false
165
+ let timer: number | undefined
166
+ const refresh = (): void => {
167
+ void callHostAny<HostIndexStatus>('index-status', {}).then((res) => {
168
+ if (cancelled) return
169
+ if (res.ok) {
170
+ setStatus(res as HostIndexStatus)
171
+ setFetchError(null)
172
+ } else {
173
+ // The host half may be older than the client bundle (browser
174
+ // refresh does not reload the host) — surface it, never spin.
175
+ setStatus(null)
176
+ setFetchError(res.error ?? 'index-status 请求失败')
177
+ }
178
+ const rebuilding = res.rebuild?.state === 'building' || res.rebuild?.state === 'swapping'
179
+ if (rebuilding) timer = window.setTimeout(refresh, 2000)
180
+ else setBusy(false)
181
+ })
182
+ }
183
+ refresh()
184
+ return () => {
185
+ cancelled = true
186
+ if (timer !== undefined) window.clearTimeout(timer)
187
+ }
188
+ }, [attempt])
189
+
190
+ const t = props.t
191
+ const rebuilding = status?.rebuild?.state === 'building' || status?.rebuild?.state === 'swapping'
192
+ const rebuildError = status?.rebuild?.state === 'error' ? status.rebuild.error : undefined
193
+ const syncFailures = status?.sync?.failures?.length ?? 0
194
+ const blocking = busy || rebuilding
195
+
196
+ const onRebuild = (): void => {
197
+ setBusy(true)
198
+ setNote(null)
199
+ void callHostAny<HostIndexStatus>('index-rebuild', {}).then((res) => {
200
+ if (!res.ok) {
201
+ setNote(translate(t, 'card.action.failed', { error: res.error ?? '?' }))
202
+ setBusy(false)
203
+ }
204
+ })
205
+ }
206
+
207
+ const onExport = (): void => {
208
+ setBusy(true)
209
+ setNote(null)
210
+ void downloadSnapshot()
211
+ .then(() => setNote(translate(t, 'card.index.exported')))
212
+ .catch((err: unknown) => setNote(translate(t, 'card.action.failed', { error: String(err instanceof Error ? err.message : err) })))
213
+ .finally(() => setBusy(false))
214
+ }
215
+
216
+ const onImportFile = (file: File): void => {
217
+ setBusy(true)
218
+ setNote(null)
219
+ void file.text().then((text) => callHostAny<HostIndexStatus>('index-import', text, 30_000)).then((res) => {
220
+ if (res.ok) setNote(translate(t, 'card.index.import.started'))
221
+ else setNote(res.error ?? translate(t, 'card.index.importParse'))
222
+ }).catch((err: unknown) => setNote(translate(t, 'card.action.failed', { error: String(err instanceof Error ? err.message : err) })))
223
+ .finally(() => setBusy(false))
224
+ }
225
+
226
+ const pillState: 'ready' | 'syncing' | 'error' | 'neutral' = rebuilding
227
+ ? 'syncing'
228
+ : status?.rebuild?.state === 'error' || fetchError !== null
229
+ ? 'error'
230
+ : status === null || status.available !== true
231
+ ? 'neutral'
232
+ : 'ready'
233
+ const pillLabel = fetchError !== null
234
+ ? '状态读取失败'
235
+ : rebuilding
236
+ ? translate(t, 'card.index.rebuilding', { done: status?.rebuild?.done ?? 0, total: status?.rebuild?.total || '?' })
237
+ : status === null
238
+ ? translate(t, 'card.index.reading')
239
+ : status.available === true
240
+ ? translate(t, 'card.index.desc', { indexed: status.sync?.indexed ?? '?' })
241
+ : translate(t, 'card.index.empty')
242
+
243
+ const statusLine = fetchError !== null
244
+ ? `索引状态读取失败:${fetchError}。请完全重启 dsh web(浏览器刷新不会重载 Host 半)后重试。`
245
+ : status === null
246
+ ? translate(t, 'card.index.reading')
247
+ : rebuilding
248
+ ? translate(t, 'card.index.rebuilding', { done: status.rebuild?.done ?? 0, total: status.rebuild?.total || '?' })
249
+ : status.available === true
250
+ ? `${translate(t, 'card.index.desc', { indexed: status.sync?.indexed ?? '?' })}${(status.archives?.length ?? 0) > 0 ? translate(t, 'card.index.archives', { archives: status.archives?.length }) : ''}`
251
+ : translate(t, 'card.index.empty')
252
+
253
+ // Rebuild progress: a labeled bar with percentage, indeterminate sweep when
254
+ // the total is not known yet. Rendered prominently above the action row.
255
+ const rebuildDone = status?.rebuild?.done ?? 0
256
+ const rebuildTotal = status?.rebuild?.total ?? 0
257
+ const progressPct = rebuilding && rebuildTotal > 0
258
+ ? Math.min(100, Math.round((rebuildDone / rebuildTotal) * 100))
259
+ : undefined
260
+ const progressBlock = rebuilding
261
+ ? createElement('div', {
262
+ key: 'progress',
263
+ style: { display: 'flex', flexDirection: 'column', gap: '4px', margin: '2px 0 6px', width: '100%' },
264
+ }, [
265
+ createElement('div', {
266
+ key: 'bar',
267
+ style: {
268
+ height: '6px',
269
+ borderRadius: '3px',
270
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,0.2))',
271
+ overflow: 'hidden',
272
+ },
273
+ }, createElement('div', {
274
+ style: progressPct === undefined
275
+ ? {
276
+ height: '100%',
277
+ width: '30%',
278
+ borderRadius: '3px',
279
+ background: 'var(--dsw-alias-state-business-primary, #4c6ef5)',
280
+ opacity: 0.6,
281
+ }
282
+ : {
283
+ height: '100%',
284
+ width: `${progressPct}%`,
285
+ borderRadius: '3px',
286
+ background: 'var(--dsw-alias-state-business-primary, #4c6ef5)',
287
+ transition: 'width .4s ease',
288
+ },
289
+ })),
290
+ createElement('span', {
291
+ style: {
292
+ color: 'var(--dsw-alias-state-business-primary, #4c6ef5)',
293
+ fontSize: '12px',
294
+ lineHeight: '18px',
295
+ fontWeight: 600,
296
+ fontVariantNumeric: 'tabular-nums',
297
+ },
298
+ }, progressPct === undefined
299
+ ? translate(t, 'card.index.rebuilding', { done: rebuildDone, total: '?' })
300
+ : `${progressPct}% · ${translate(t, 'card.index.rebuilding', { done: rebuildDone, total: rebuildTotal })}`),
301
+ ])
302
+ : null
303
+
304
+ return createElement('div', { className: 'dsws_setRow', style: rebuilding ? { flexDirection: 'column', alignItems: 'stretch' } : undefined }, [
305
+ createElement('div', { key: 'text', className: 'dsws_setText' }, [
306
+ createElement('span', { key: 't', className: 'dsws_setTitle' }, translate(t, 'card.index')),
307
+ createElement('span', { key: 'p', style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' } }, [
308
+ createElement(StatusPill, {
309
+ key: 'pill',
310
+ state: pillState,
311
+ label: pillLabel,
312
+ icon: pillState === 'ready' ? '✓' : pillState === 'syncing' || pillState === 'error' ? '!' : '·',
313
+ dots: pillState === 'syncing',
314
+ }),
315
+ (status?.archivedSessions ?? 0) > 0 && createElement('span', {
316
+ key: 'archived',
317
+ className: 'dsws_pill dsws_pillNeutral',
318
+ }, translate(t, 'panel.archived', { count: status?.archivedSessions ?? 0 })),
319
+ ]),
320
+ createElement('span', { key: 'd', className: 'dsws_setDesc' }, statusLine),
321
+ // The archived count points at a capability this plugin does not own.
322
+ // Left bare it read as a broken feature: "there are 72 archived sessions,
323
+ // where do I manage them?" The wording follows the host's probe — telling
324
+ // someone to install what they already have is worse than saying nothing,
325
+ // so only a definite `missing` says so.
326
+ (status?.archivedSessions ?? 0) > 0 && createElement(
327
+ 'span',
328
+ { key: 'archHint', className: 'dsws_setDesc' },
329
+ status?.steward === 'missing'
330
+ ? translate(t, 'card.index.archivedMissing')
331
+ : translate(t, 'card.index.archivedOwner'),
332
+ ),
333
+ rebuildError !== null && rebuildError !== undefined
334
+ && createElement('span', { key: 'err', className: 'dsws_setDesc' }, translate(t, 'card.index.rebuildError', { error: rebuildError })),
335
+ syncFailures > 0 && createElement('span', { key: 'warn', className: 'dsws_setDesc' }, translate(t, 'card.index.failures', { count: syncFailures })),
336
+ note !== null && createElement('span', { key: 'note', className: 'dsws_setDesc' }, note),
337
+ createElement('span', { key: 'hint', className: 'dsws_setDesc' }, translate(t, 'card.index.rebuild.hint')),
338
+ ]),
339
+ progressBlock,
340
+ createElement('div', { key: 'btns', className: 'dsws_btnRow' }, [
341
+ createElement('button', {
342
+ key: 'rebuild',
343
+ type: 'button',
344
+ className: 'dsws_actBtn',
345
+ disabled: blocking,
346
+ onClick: onRebuild,
347
+ }, rebuilding ? translate(t, 'card.index.rebuilding.btn') : translate(t, 'card.index.rebuild')),
348
+ createElement('button', {
349
+ key: 'export',
350
+ type: 'button',
351
+ className: 'dsws_actBtn',
352
+ disabled: blocking || status?.available !== true,
353
+ onClick: onExport,
354
+ }, translate(t, 'card.index.export')),
355
+ fetchError !== null && createElement('button', {
356
+ key: 'retryStatus',
357
+ type: 'button',
358
+ className: 'dsws_actBtn',
359
+ onClick: () => { setAttempt(n => n + 1) },
360
+ }, '重试状态'),
361
+ createElement('label', { key: 'import', className: 'dsws_actBtn' }, [
362
+ translate(t, 'card.index.import'),
363
+ createElement('input', {
364
+ key: 'file',
365
+ type: 'file',
366
+ accept: '.jsonl,.json,text/plain,application/json',
367
+ style: { display: 'none' },
368
+ onChange: (e: { target: { files: { [index: number]: File | null } | null; value: string } }) => {
369
+ const file = e.target.files?.[0] ?? undefined
370
+ e.target.value = ''
371
+ if (file !== undefined && file !== null) onImportFile(file)
372
+ },
373
+ }),
374
+ ]),
375
+ ]),
376
+ ])
377
+ }
378
+
379
+ /**
380
+ * The settings card body: a collapsed drawer shell (title + description +
381
+ * chevron, thinking-levels pattern) expanding into the namespace fields and
382
+ * the index-lifecycle block.
383
+ * @param props - locale seat (optional) and the bound namespace scope.
384
+ */
385
+ export function SearchSettingsCard(props: SearchSettingsCardProps): JSX.Element {
386
+ const { scope } = props
387
+ const t = props.t
388
+ // family tab 里默认展开:折叠态在 tab 面板里看起来像「没有设置项」。
389
+ const [open, setOpen] = useState(true)
390
+ const snapshot = useSyncExternalStore(
391
+ (listener) => scope.subscribe(listener),
392
+ () => scope.getSnapshot(),
393
+ )
394
+
395
+ const body = createCardBody({ t, snapshot, scope })
396
+
397
+ return createElement('div', {
398
+ style: {
399
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
400
+ background: 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
401
+ borderRadius: '12px',
402
+ transition: 'border-color 0.16s, background 0.16s',
403
+ },
404
+ }, [
405
+ createElement('button', {
406
+ key: 'head',
407
+ type: 'button',
408
+ 'aria-expanded': open,
409
+ style: {
410
+ appearance: 'none',
411
+ width: '100%',
412
+ font: 'inherit',
413
+ color: 'inherit',
414
+ textAlign: 'left',
415
+ cursor: 'pointer',
416
+ background: 'none',
417
+ border: 0,
418
+ borderRadius: '12px',
419
+ display: 'flex',
420
+ alignItems: 'center',
421
+ gap: '12px',
422
+ padding: '14px 16px',
423
+ },
424
+ onClick: () => { setOpen(current => !current) },
425
+ }, [
426
+ createElement('span', { key: 'text', style: { flex: '1 1 0%', minWidth: 0 } }, [
427
+ createElement('div', {
428
+ key: 'title',
429
+ style: { fontSize: '14px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' },
430
+ }, translate(t, 'card.title')),
431
+ createElement('div', {
432
+ key: 'desc',
433
+ style: { color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))', fontSize: '13px', lineHeight: 1.5 },
434
+ }, translate(t, 'card.description')),
435
+ ]),
436
+ createElement('svg', {
437
+ key: 'chev',
438
+ width: 16,
439
+ height: 16,
440
+ viewBox: '0 0 16 16',
441
+ 'aria-hidden': true,
442
+ style: {
443
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
444
+ flex: '0 0 auto',
445
+ transition: 'transform 0.16s',
446
+ transform: open ? 'rotate(180deg)' : 'none',
447
+ },
448
+ }, createElement('path', {
449
+ d: 'M4 6l4 4 4-4',
450
+ fill: 'none',
451
+ stroke: 'currentColor',
452
+ strokeWidth: 1.5,
453
+ strokeLinecap: 'round',
454
+ strokeLinejoin: 'round',
455
+ })),
456
+ ]),
457
+ open && createElement('div', { key: 'body', style: { padding: '12px 16px' } }, body),
458
+ ])
459
+ }
460
+
461
+ /** The card's expandable content: namespace fields plus the index block. */
462
+ function createCardBody(props: {
463
+ t?: SearchSettingsCardProps['t']
464
+ snapshot: ReturnType<SwitchCardScope['getSnapshot']>
465
+ scope: SwitchCardScope
466
+ }): JSX.Element[] {
467
+ const t = props.t
468
+ const snapshot = props.snapshot
469
+ const scope = props.scope
470
+
471
+ if (snapshot.status === 'unavailable') {
472
+ return [
473
+ createElement('div', { className: 'dsws_setRow', key: 'unavailable' },
474
+ createElement('span', { className: 'dsws_setTitle' }, translate(t, 'card.unavailable'))),
475
+ ]
476
+ }
477
+
478
+ const value: Partial<SwitchSearchConfig> = snapshot.value ?? {}
479
+ const writable = snapshot.writable
480
+ const syncIntervalSeconds = Math.round((value.syncIntervalMs ?? 30_000) / 1000)
481
+ const archiveKeep = value.archiveKeep ?? 2
482
+
483
+ const children: JSX.Element[] = [
484
+ createElement(Row, {
485
+ key: 'enable',
486
+ title: translate(t, 'card.enabled'),
487
+ desc: translate(t, 'card.enabled.desc'),
488
+ control: createElement(Toggle, {
489
+ checked: value.enabled ?? true,
490
+ writable,
491
+ onChange: (checked) => { void scope.set('enabled', checked) },
492
+ }),
493
+ }),
494
+ createElement(Row, {
495
+ key: 'mode',
496
+ title: translate(t, 'card.defaultMode'),
497
+ desc: translate(t, 'card.defaultMode.desc'),
498
+ control: createElement('div', { className: 'dsws_seg', role: 'group' },
499
+ (['title', 'content'] as const).map(mode => createElement('button', {
500
+ key: mode,
501
+ type: 'button',
502
+ className: `dsws_segBtn${(value.defaultMode ?? 'title') === mode ? ' dsws_segBtnActive' : ''}`,
503
+ 'aria-pressed': (value.defaultMode ?? 'title') === mode,
504
+ disabled: !writable,
505
+ onClick: () => { void scope.set('defaultMode', mode) },
506
+ }, mode === 'title' ? translate(t, 'card.mode.title') : translate(t, 'card.mode.content')))),
507
+ }),
508
+ createElement(Row, {
509
+ key: 'autoSync',
510
+ title: translate(t, 'card.autoSync'),
511
+ desc: translate(t, 'card.autoSync.desc'),
512
+ control: createElement(Toggle, {
513
+ checked: value.autoSync ?? true,
514
+ writable,
515
+ onChange: (checked) => { void scope.set('autoSync', checked) },
516
+ }),
517
+ }),
518
+ createElement(Row, {
519
+ key: 'interval',
520
+ title: translate(t, 'card.syncInterval'),
521
+ desc: translate(t, 'card.syncInterval.desc'),
522
+ control: createElement('input', {
523
+ type: 'number',
524
+ min: 5,
525
+ max: 3600,
526
+ disabled: !writable,
527
+ value: syncIntervalSeconds,
528
+ style: { width: '72px', boxSizing: 'border-box' },
529
+ className: 'dsws_search',
530
+ onChange: (e: { target: { value: string } }) => {
531
+ const seconds = Number(e.target.value)
532
+ if (Number.isFinite(seconds) && seconds >= 5) void scope.set('syncIntervalMs', Math.round(seconds * 1000))
533
+ },
534
+ }),
535
+ }),
536
+ createElement(Row, {
537
+ key: 'archiveKeep',
538
+ title: translate(t, 'card.archiveKeep'),
539
+ desc: translate(t, 'card.archiveKeep.desc'),
540
+ control: createElement('input', {
541
+ type: 'number',
542
+ min: 0,
543
+ max: 20,
544
+ disabled: !writable,
545
+ value: archiveKeep,
546
+ style: { width: '72px', boxSizing: 'border-box' },
547
+ className: 'dsws_search',
548
+ onChange: (e: { target: { value: string } }) => {
549
+ const count = Number(e.target.value)
550
+ if (Number.isFinite(count) && count >= 0) void scope.set('archiveKeep', Math.round(count))
551
+ },
552
+ }),
553
+ }),
554
+ ]
555
+
556
+ const indexBlock = IndexBlock({ t })
557
+ children.push(indexBlock)
558
+
559
+ if (!writable) {
560
+ children.push(createElement('div', { key: 'ro', className: 'dsws_setDesc' }, translate(t, 'card.readonly')))
561
+ }
562
+ return children
563
+ }