dsh-tiddlywiki 0.1.1 → 0.1.2

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.
@@ -1,397 +1,397 @@
1
- /**
2
- * Settings-page half (design doc §13, config panel): a pure-DOM page mounted
3
- * inside a `settings.section` React wrapper. Everything talks to the host
4
- * admin routes — same-origin JSON, no client services beyond `slots`:
5
- *
6
- * GET /dsh-tiddlywiki/admin/state current info + catalog + config
7
- * POST /dsh-tiddlywiki/admin/info { plugins?, themes? } → restart TW
8
- * POST /dsh-tiddlywiki/admin/config { ...patch } → persist
9
- * POST /dsh-tiddlywiki/admin/restart restart the TW child
10
- *
11
- * Sections:
12
- * 1. 状态/重启 TW 运行状态 + git 概览 + 重启按钮
13
- * 2. 常规配置 note.tag / git.* / uiLanguage(改了什么保存什么)
14
- * 3. 插件管理 自带官方插件勾选(可搜索)→ 应用并重启 TW
15
- * 4. 主题管理 自带主题单选 → 应用并重启 TW
16
- *
17
- * @module dsh-tiddlywiki/client/settings-page
18
- */
19
- import * as React from 'react'
20
- import { toast } from './toast.ts'
21
-
22
- const STATE_ENDPOINT = '/dsh-tiddlywiki/admin/state'
23
- const INFO_ENDPOINT = '/dsh-tiddlywiki/admin/info'
24
- const CONFIG_ENDPOINT = '/dsh-tiddlywiki/admin/config'
25
- const RESTART_ENDPOINT = '/dsh-tiddlywiki/admin/restart'
26
-
27
- interface CatalogEntry {
28
- name: string
29
- title: string
30
- label: string
31
- description: string
32
- }
33
-
34
- interface AdminState {
35
- ok?: boolean
36
- server?: { status?: string; url?: string; wikiPath?: string; error?: string }
37
- info?: { plugins?: string[]; themes?: string[]; languages?: string[] }
38
- catalog?: { plugins?: CatalogEntry[]; themes?: CatalogEntry[]; languages?: CatalogEntry[] }
39
- config?: Record<string, unknown>
40
- git?: { exists?: boolean; branch?: string; dirty?: boolean; lastCommit?: string; remote?: string } | null
41
- error?: string
42
- }
43
-
44
- function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
45
- const node = document.createElement(tag)
46
- if (className !== undefined) node.className = className
47
- if (text !== undefined) node.textContent = text
48
- return node
49
- }
50
-
51
- async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
52
- const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) })
53
- const data = (await res.json().catch(() => ({}))) as T
54
- if (!res.ok) {
55
- const err = (data as { error?: string }).error ?? `HTTP ${res.status}`
56
- throw new Error(err)
57
- }
58
- return data
59
- }
60
-
61
- /** Form controls registry for the config section (changed-only patch). */
62
- interface ConfigField {
63
- key: string
64
- input: HTMLInputElement
65
- initial: string | boolean | number
66
- read: () => string | boolean | number
67
- changed: () => boolean
68
- }
69
-
70
- export function mountSettingsPage(container: HTMLElement): () => void {
71
- let disposed = false
72
- container.classList.add('dsh-tw-settings')
73
-
74
- const statusRow = make('div', 'dsh-tw-settings-row dsh-tw-settings-status')
75
- const body = make('div', 'dsh-tw-settings-body')
76
- container.append(statusRow, body)
77
-
78
- const disposers: Array<() => void> = []
79
-
80
- const refresh = async (): Promise<void> => {
81
- try {
82
- const state = await fetchJson<AdminState>(STATE_ENDPOINT)
83
- if (disposed) return
84
- renderStatus(statusRow, state, refresh)
85
- renderMain(body, state, refresh)
86
- } catch (err) {
87
- if (disposed) return
88
- body.replaceChildren()
89
- statusRow.replaceChildren()
90
- const msg = make('div', 'dsh-tw-settings-error', `加载配置失败:${err instanceof Error ? err.message : String(err)}`)
91
- const retry = make('button', 'dsh-tw-settings-btn', '重试')
92
- retry.type = 'button'
93
- retry.addEventListener('click', () => { void refresh() })
94
- body.append(msg, retry)
95
- }
96
- }
97
-
98
- void refresh()
99
- disposers.push(() => {
100
- disposed = true
101
- container.replaceChildren()
102
- container.classList.remove('dsh-tw-settings')
103
- })
104
- return () => {
105
- for (const dispose of disposers.splice(0)) dispose()
106
- }
107
- }
108
-
109
- function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promise<void>): void {
110
- row.replaceChildren()
111
- const server = state.server ?? {}
112
- const status = server.status ?? 'unknown'
113
- const chip = make('span', `dsh-tw-settings-chip`, status)
114
- chip.dataset.state = status
115
- const info = [
116
- server.url !== undefined ? `TW ${server.url}` : '',
117
- state.git?.branch !== undefined ? `git ${state.git.branch}` : '',
118
- state.git?.lastCommit !== undefined ? state.git.lastCommit : '',
119
- state.git?.dirty === true ? '有未提交改动' : '',
120
- ].filter(Boolean).join(' · ')
121
- const label = make('span', 'dsh-tw-settings-muted', info)
122
- const restart = make('button', 'dsh-tw-settings-btn', '重启 TW')
123
- restart.type = 'button'
124
- restart.addEventListener('click', () => {
125
- restart.disabled = true
126
- restart.textContent = '重启中…'
127
- void (async () => {
128
- try {
129
- await fetchJson(RESTART_ENDPOINT, { method: 'POST' })
130
- toast('TW 已重启')
131
- } catch (err) {
132
- toast(`重启失败:${err instanceof Error ? err.message : String(err)}`)
133
- } finally {
134
- restart.disabled = false
135
- restart.textContent = '重启 TW'
136
- void refresh()
137
- }
138
- })()
139
- })
140
- row.append(chip, label, restart)
141
- }
142
-
143
- /** Config section: fields bound to effective config, changed-only save. */
144
- function renderConfigSection(body: HTMLElement, config: Record<string, unknown>, refresh: () => Promise<void>): void {
145
- const section = make('section', 'dsh-tw-settings-section')
146
- section.append(make('h3', 'dsh-tw-settings-h', '常规配置'))
147
- const note = (config.note ?? {}) as Record<string, unknown>
148
- const git = (config.git ?? {}) as Record<string, unknown>
149
- const fields: ConfigField[] = []
150
-
151
- const textField = (key: string, label: string, initial: string): void => {
152
- const input = make('input', 'dsh-tw-settings-input')
153
- input.value = initial
154
- const wrap = make('label', 'dsh-tw-settings-field')
155
- wrap.append(make('span', 'dsh-tw-settings-label', label), input)
156
- fields.push({ key, input, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
157
- section.append(wrap)
158
- }
159
- const checkField = (key: string, label: string, initial: boolean): void => {
160
- const input = make('input', 'dsh-tw-settings-check')
161
- input.type = 'checkbox'
162
- input.checked = initial
163
- const wrap = make('label', 'dsh-tw-settings-field dsh-tw-settings-field-check')
164
- wrap.append(input, make('span', 'dsh-tw-settings-label', label))
165
- fields.push({ key, input, initial, read: () => input.checked, changed: () => input.checked !== initial })
166
- section.append(wrap)
167
- }
168
- const numField = (key: string, label: string, initial: number): void => {
169
- const input = make('input', 'dsh-tw-settings-input')
170
- input.type = 'number'
171
- input.value = String(initial)
172
- const wrap = make('label', 'dsh-tw-settings-field')
173
- wrap.append(make('span', 'dsh-tw-settings-label', label), input)
174
- fields.push({ key, input, initial, read: () => Number(input.value) || initial, changed: () => (Number(input.value) || initial) !== initial })
175
- section.append(wrap)
176
- }
177
-
178
- textField('note.tag', '快速笔记默认 tag', typeof note.tag === 'string' ? note.tag : 'inbox')
179
- checkField('git.autoCommit', '自动 commit(防抖)', git.autoCommit !== false)
180
- numField('git.debounceMs', '自动 commit 防抖(ms)', typeof git.debounceMs === 'number' ? git.debounceMs : 60_000)
181
- textField('git.remote', 'git 远端(空=仅本地)', typeof git.remote === 'string' ? git.remote : '')
182
- textField('git.branch', 'git 分支', typeof git.branch === 'string' ? git.branch : 'main')
183
- // 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
184
-
185
- const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
186
- save.type = 'button'
187
- save.addEventListener('click', () => {
188
- save.disabled = true
189
- void (async () => {
190
- const patch: Record<string, unknown> = {}
191
- for (const field of fields) {
192
- if (!field.changed()) continue
193
- const parts = field.key.split('.')
194
- if (parts.length === 1) {
195
- const key = parts[0]
196
- if (key !== undefined) patch[key] = field.read()
197
- } else {
198
- const [top, rest] = parts as [string, string]
199
- const obj = (patch[top] ?? {}) as Record<string, unknown>
200
- obj[rest] = field.read()
201
- patch[top] = obj
202
- }
203
- }
204
- try {
205
- await fetchJson(CONFIG_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
206
- toast('配置已保存')
207
- void refresh()
208
- } catch (err) {
209
- toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
210
- } finally {
211
- save.disabled = false
212
- }
213
- })()
214
- })
215
- section.append(save)
216
- body.append(section)
217
- }
218
-
219
- /** Plugin/theme manager: checkboxes/radios + apply (writes info, restarts). */
220
- function renderCatalogSection(
221
- body: HTMLElement,
222
- info: AdminState['info'],
223
- catalog: AdminState['catalog'],
224
- refresh: () => Promise<void>,
225
- ): void {
226
- const plugins = catalog?.plugins ?? []
227
- const themes = catalog?.themes ?? []
228
- const languages = catalog?.languages ?? []
229
- const activePlugins = new Set(info?.plugins ?? [])
230
- const loadedThemes = new Set(info?.themes ?? [])
231
- const activeLanguages = new Set(info?.languages ?? [])
232
-
233
- // ── plugins ──────────────────────────────────────────────────────────────
234
- const pluginSection = make('section', 'dsh-tw-settings-section')
235
- pluginSection.append(make('h3', 'dsh-tw-settings-h', '插件管理(自带官方插件)'))
236
- const search = make('input', 'dsh-tw-settings-input dsh-tw-settings-search')
237
- search.placeholder = '搜索插件…'
238
- const listWrap = make('div', 'dsh-tw-settings-list')
239
- const applyPlugins = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用插件(重启 TW)')
240
- applyPlugins.type = 'button'
241
-
242
- const renderPluginList = (needle: string): void => {
243
- listWrap.replaceChildren()
244
- const q = needle.toLowerCase()
245
- for (const plugin of plugins) {
246
- if (q.length > 0 && !`${plugin.label} ${plugin.name} ${plugin.description}`.toLowerCase().includes(q)) continue
247
- const input = make('input', 'dsh-tw-settings-check')
248
- input.type = 'checkbox'
249
- input.checked = activePlugins.has(plugin.name)
250
- input.addEventListener('change', () => {
251
- if (input.checked) activePlugins.add(plugin.name)
252
- else activePlugins.delete(plugin.name)
253
- })
254
- const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
255
- const name = make('span', 'dsh-tw-settings-name', plugin.label)
256
- name.title = plugin.name
257
- const desc = make('span', 'dsh-tw-settings-muted', plugin.description || plugin.name)
258
- label.append(input, name, desc)
259
- listWrap.append(label)
260
- }
261
- }
262
- search.addEventListener('input', () => renderPluginList(search.value))
263
- applyPlugins.addEventListener('click', () => {
264
- applyPlugins.disabled = true
265
- void (async () => {
266
- try {
267
- await fetchJson(INFO_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ plugins: [...activePlugins] }) })
268
- toast('插件已应用,TW 已重启')
269
- void refresh()
270
- } catch (err) {
271
- toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
272
- applyPlugins.disabled = false
273
- }
274
- })()
275
- })
276
- renderPluginList('')
277
- pluginSection.append(search, listWrap, applyPlugins)
278
- body.append(pluginSection)
279
-
280
- // ── themes ───────────────────────────────────────────────────────────────
281
- // `info.themes` = WHICH theme plugins are LOADED (multi-select; TW's own
282
- // default is [vanilla, snowwhite]); `$:/theme` = the ACTIVE one (single).
283
- // Two controls per theme: ☑ 加载 (multi) + ◉ 活动 (single, auto-added to the
284
- // loaded set on apply). The host computes the dependency closure (heavier →
285
- // vanilla+snowwhite+heavier) and writes $:/theme.
286
- const themeSection = make('section', 'dsh-tw-settings-section')
287
- themeSection.append(make('h3', 'dsh-tw-settings-h', '主题管理(自带主题)'))
288
- const themeHint = make('div', 'dsh-tw-settings-muted', '「加载」= TW 里可用的主题(可多选,依赖链自动带上,如 heavier 会带 snowwhite+vanilla);「活动」= 当前视觉主题(单选,自动加入加载集)。应用后重启 TW。')
289
- const themeHead = make('div', 'dsh-tw-settings-row dsh-tw-settings-head')
290
- themeHead.append(
291
- make('span', 'dsh-tw-settings-col', '加载'),
292
- make('span', 'dsh-tw-settings-col', '活动'),
293
- make('span', 'dsh-tw-settings-name', '主题'),
294
- )
295
- const themeList = info?.themes ?? []
296
- let activeThemeName = themeList.length > 0 ? themeList[themeList.length - 1] : 'tiddlywiki/vanilla'
297
- const themeWrap = make('div', 'dsh-tw-settings-list')
298
- for (const theme of themes) {
299
- const load = make('input', 'dsh-tw-settings-check')
300
- load.type = 'checkbox'
301
- load.checked = loadedThemes.has(theme.name)
302
- load.title = '加载该主题'
303
- load.addEventListener('change', () => {
304
- if (load.checked) loadedThemes.add(theme.name)
305
- else loadedThemes.delete(theme.name)
306
- })
307
- const act = make('input', 'dsh-tw-settings-check')
308
- act.type = 'radio'
309
- act.name = 'dsh-tw-active-theme'
310
- act.checked = theme.name === activeThemeName
311
- act.title = '设为活动主题'
312
- act.addEventListener('change', () => {
313
- if (act.checked) activeThemeName = theme.name
314
- })
315
- const name = make('span', 'dsh-tw-settings-name', theme.label)
316
- name.title = theme.name
317
- const desc = make('span', 'dsh-tw-settings-muted', theme.description || theme.name)
318
- const row = make('div', 'dsh-tw-settings-row dsh-tw-settings-plugin')
319
- row.append(load, act, name, desc)
320
- themeWrap.append(row)
321
- }
322
- const applyThemes = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用主题(重启 TW)')
323
- applyThemes.type = 'button'
324
- applyThemes.addEventListener('click', () => {
325
- applyThemes.disabled = true
326
- void (async () => {
327
- try {
328
- await fetchJson(INFO_ENDPOINT, {
329
- method: 'POST',
330
- headers: { 'content-type': 'application/json' },
331
- body: JSON.stringify({ themes: [...loadedThemes], themeActive: activeThemeName }),
332
- })
333
- toast('主题已应用,TW 已重启')
334
- void refresh()
335
- } catch (err) {
336
- toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
337
- applyThemes.disabled = false
338
- }
339
- })()
340
- })
341
- themeSection.append(themeHint, themeHead, themeWrap, applyThemes)
342
- body.append(themeSection)
343
-
344
- // ── languages (bundled, offline — enable → restart TW) ──────────────────
345
- const langSection = make('section', 'dsh-tw-settings-section')
346
- langSection.append(make('h3', 'dsh-tw-settings-h', '语言管理(自带官方语言包)'))
347
- const langHint = make('div', 'dsh-tw-settings-muted', '勾选启用语言插件并重启 TW;如中文请选 zh-Hans(简体)或 zh-CN。')
348
- const langWrap = make('div', 'dsh-tw-settings-list')
349
- for (const lang of languages) {
350
- const input = make('input', 'dsh-tw-settings-check')
351
- input.type = 'checkbox'
352
- input.checked = activeLanguages.has(lang.name)
353
- input.addEventListener('change', () => {
354
- if (input.checked) activeLanguages.add(lang.name)
355
- else activeLanguages.delete(lang.name)
356
- })
357
- const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
358
- const name = make('span', 'dsh-tw-settings-name', lang.label)
359
- name.title = lang.name
360
- const desc = make('span', 'dsh-tw-settings-muted', lang.description || lang.name)
361
- label.append(input, name, desc)
362
- langWrap.append(label)
363
- }
364
- const applyLangs = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用语言(重启 TW)')
365
- applyLangs.type = 'button'
366
- applyLangs.addEventListener('click', () => {
367
- applyLangs.disabled = true
368
- void (async () => {
369
- try {
370
- await fetchJson(INFO_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ languages: [...activeLanguages] }) })
371
- toast('语言已应用,TW 已重启')
372
- void refresh()
373
- } catch (err) {
374
- toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
375
- applyLangs.disabled = false
376
- }
377
- })()
378
- })
379
- langSection.append(langHint, langWrap, applyLangs)
380
- body.append(langSection)
381
- }
382
-
383
- function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise<void>): void {
384
- body.replaceChildren()
385
- renderConfigSection(body, state.config ?? {}, refresh)
386
- renderCatalogSection(body, state.info, state.catalog, refresh)
387
- }
388
-
389
- /** React wrapper consumed by the shell's settings.section slot. */
390
- export function SettingsSection(): React.ReactElement {
391
- const ref = React.useRef<HTMLDivElement | null>(null)
392
- React.useEffect(() => {
393
- const el = ref.current
394
- return el === null ? undefined : mountSettingsPage(el)
395
- }, [])
396
- return React.createElement('div', { ref })
397
- }
1
+ /**
2
+ * Settings-page half (design doc §13, config panel): a pure-DOM page mounted
3
+ * inside a `settings.section` React wrapper. Everything talks to the host
4
+ * admin routes — same-origin JSON, no client services beyond `slots`:
5
+ *
6
+ * GET /dsh-tiddlywiki/admin/state current info + catalog + config
7
+ * POST /dsh-tiddlywiki/admin/info { plugins?, themes? } → restart TW
8
+ * POST /dsh-tiddlywiki/admin/config { ...patch } → persist
9
+ * POST /dsh-tiddlywiki/admin/restart restart the TW child
10
+ *
11
+ * Sections:
12
+ * 1. 状态/重启 TW 运行状态 + git 概览 + 重启按钮
13
+ * 2. 常规配置 note.tag / git.* / uiLanguage(改了什么保存什么)
14
+ * 3. 插件管理 自带官方插件勾选(可搜索)→ 应用并重启 TW
15
+ * 4. 主题管理 自带主题单选 → 应用并重启 TW
16
+ *
17
+ * @module dsh-tiddlywiki/client/settings-page
18
+ */
19
+ import * as React from 'react'
20
+ import { toast } from './toast.ts'
21
+
22
+ const STATE_ENDPOINT = '/dsh-tiddlywiki/admin/state'
23
+ const INFO_ENDPOINT = '/dsh-tiddlywiki/admin/info'
24
+ const CONFIG_ENDPOINT = '/dsh-tiddlywiki/admin/config'
25
+ const RESTART_ENDPOINT = '/dsh-tiddlywiki/admin/restart'
26
+
27
+ interface CatalogEntry {
28
+ name: string
29
+ title: string
30
+ label: string
31
+ description: string
32
+ }
33
+
34
+ interface AdminState {
35
+ ok?: boolean
36
+ server?: { status?: string; url?: string; wikiPath?: string; error?: string }
37
+ info?: { plugins?: string[]; themes?: string[]; languages?: string[] }
38
+ catalog?: { plugins?: CatalogEntry[]; themes?: CatalogEntry[]; languages?: CatalogEntry[] }
39
+ config?: Record<string, unknown>
40
+ git?: { exists?: boolean; branch?: string; dirty?: boolean; lastCommit?: string; remote?: string } | null
41
+ error?: string
42
+ }
43
+
44
+ function make<K extends keyof HTMLElementTagNameMap>(tag: K, className?: string, text?: string): HTMLElementTagNameMap[K] {
45
+ const node = document.createElement(tag)
46
+ if (className !== undefined) node.className = className
47
+ if (text !== undefined) node.textContent = text
48
+ return node
49
+ }
50
+
51
+ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
52
+ const res = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) })
53
+ const data = (await res.json().catch(() => ({}))) as T
54
+ if (!res.ok) {
55
+ const err = (data as { error?: string }).error ?? `HTTP ${res.status}`
56
+ throw new Error(err)
57
+ }
58
+ return data
59
+ }
60
+
61
+ /** Form controls registry for the config section (changed-only patch). */
62
+ interface ConfigField {
63
+ key: string
64
+ input: HTMLInputElement
65
+ initial: string | boolean | number
66
+ read: () => string | boolean | number
67
+ changed: () => boolean
68
+ }
69
+
70
+ export function mountSettingsPage(container: HTMLElement): () => void {
71
+ let disposed = false
72
+ container.classList.add('dsh-tw-settings')
73
+
74
+ const statusRow = make('div', 'dsh-tw-settings-row dsh-tw-settings-status')
75
+ const body = make('div', 'dsh-tw-settings-body')
76
+ container.append(statusRow, body)
77
+
78
+ const disposers: Array<() => void> = []
79
+
80
+ const refresh = async (): Promise<void> => {
81
+ try {
82
+ const state = await fetchJson<AdminState>(STATE_ENDPOINT)
83
+ if (disposed) return
84
+ renderStatus(statusRow, state, refresh)
85
+ renderMain(body, state, refresh)
86
+ } catch (err) {
87
+ if (disposed) return
88
+ body.replaceChildren()
89
+ statusRow.replaceChildren()
90
+ const msg = make('div', 'dsh-tw-settings-error', `加载配置失败:${err instanceof Error ? err.message : String(err)}`)
91
+ const retry = make('button', 'dsh-tw-settings-btn', '重试')
92
+ retry.type = 'button'
93
+ retry.addEventListener('click', () => { void refresh() })
94
+ body.append(msg, retry)
95
+ }
96
+ }
97
+
98
+ void refresh()
99
+ disposers.push(() => {
100
+ disposed = true
101
+ container.replaceChildren()
102
+ container.classList.remove('dsh-tw-settings')
103
+ })
104
+ return () => {
105
+ for (const dispose of disposers.splice(0)) dispose()
106
+ }
107
+ }
108
+
109
+ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promise<void>): void {
110
+ row.replaceChildren()
111
+ const server = state.server ?? {}
112
+ const status = server.status ?? 'unknown'
113
+ const chip = make('span', `dsh-tw-settings-chip`, status)
114
+ chip.dataset.state = status
115
+ const info = [
116
+ server.url !== undefined ? `TW ${server.url}` : '',
117
+ state.git?.branch !== undefined ? `git ${state.git.branch}` : '',
118
+ state.git?.lastCommit !== undefined ? state.git.lastCommit : '',
119
+ state.git?.dirty === true ? '有未提交改动' : '',
120
+ ].filter(Boolean).join(' · ')
121
+ const label = make('span', 'dsh-tw-settings-muted', info)
122
+ const restart = make('button', 'dsh-tw-settings-btn', '重启 TW')
123
+ restart.type = 'button'
124
+ restart.addEventListener('click', () => {
125
+ restart.disabled = true
126
+ restart.textContent = '重启中…'
127
+ void (async () => {
128
+ try {
129
+ await fetchJson(RESTART_ENDPOINT, { method: 'POST' })
130
+ toast('TW 已重启')
131
+ } catch (err) {
132
+ toast(`重启失败:${err instanceof Error ? err.message : String(err)}`)
133
+ } finally {
134
+ restart.disabled = false
135
+ restart.textContent = '重启 TW'
136
+ void refresh()
137
+ }
138
+ })()
139
+ })
140
+ row.append(chip, label, restart)
141
+ }
142
+
143
+ /** Config section: fields bound to effective config, changed-only save. */
144
+ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>, refresh: () => Promise<void>): void {
145
+ const section = make('section', 'dsh-tw-settings-section')
146
+ section.append(make('h3', 'dsh-tw-settings-h', '常规配置'))
147
+ const note = (config.note ?? {}) as Record<string, unknown>
148
+ const git = (config.git ?? {}) as Record<string, unknown>
149
+ const fields: ConfigField[] = []
150
+
151
+ const textField = (key: string, label: string, initial: string): void => {
152
+ const input = make('input', 'dsh-tw-settings-input')
153
+ input.value = initial
154
+ const wrap = make('label', 'dsh-tw-settings-field')
155
+ wrap.append(make('span', 'dsh-tw-settings-label', label), input)
156
+ fields.push({ key, input, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
157
+ section.append(wrap)
158
+ }
159
+ const checkField = (key: string, label: string, initial: boolean): void => {
160
+ const input = make('input', 'dsh-tw-settings-check')
161
+ input.type = 'checkbox'
162
+ input.checked = initial
163
+ const wrap = make('label', 'dsh-tw-settings-field dsh-tw-settings-field-check')
164
+ wrap.append(input, make('span', 'dsh-tw-settings-label', label))
165
+ fields.push({ key, input, initial, read: () => input.checked, changed: () => input.checked !== initial })
166
+ section.append(wrap)
167
+ }
168
+ const numField = (key: string, label: string, initial: number): void => {
169
+ const input = make('input', 'dsh-tw-settings-input')
170
+ input.type = 'number'
171
+ input.value = String(initial)
172
+ const wrap = make('label', 'dsh-tw-settings-field')
173
+ wrap.append(make('span', 'dsh-tw-settings-label', label), input)
174
+ fields.push({ key, input, initial, read: () => Number(input.value) || initial, changed: () => (Number(input.value) || initial) !== initial })
175
+ section.append(wrap)
176
+ }
177
+
178
+ textField('note.tag', '快速笔记默认 tag', typeof note.tag === 'string' ? note.tag : 'inbox')
179
+ checkField('git.autoCommit', '自动 commit(防抖)', git.autoCommit !== false)
180
+ numField('git.debounceMs', '自动 commit 防抖(ms)', typeof git.debounceMs === 'number' ? git.debounceMs : 60_000)
181
+ textField('git.remote', 'git 远端(空=仅本地)', typeof git.remote === 'string' ? git.remote : '')
182
+ textField('git.branch', 'git 分支', typeof git.branch === 'string' ? git.branch : 'main')
183
+ // 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
184
+
185
+ const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
186
+ save.type = 'button'
187
+ save.addEventListener('click', () => {
188
+ save.disabled = true
189
+ void (async () => {
190
+ const patch: Record<string, unknown> = {}
191
+ for (const field of fields) {
192
+ if (!field.changed()) continue
193
+ const parts = field.key.split('.')
194
+ if (parts.length === 1) {
195
+ const key = parts[0]
196
+ if (key !== undefined) patch[key] = field.read()
197
+ } else {
198
+ const [top, rest] = parts as [string, string]
199
+ const obj = (patch[top] ?? {}) as Record<string, unknown>
200
+ obj[rest] = field.read()
201
+ patch[top] = obj
202
+ }
203
+ }
204
+ try {
205
+ await fetchJson(CONFIG_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
206
+ toast('配置已保存')
207
+ void refresh()
208
+ } catch (err) {
209
+ toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
210
+ } finally {
211
+ save.disabled = false
212
+ }
213
+ })()
214
+ })
215
+ section.append(save)
216
+ body.append(section)
217
+ }
218
+
219
+ /** Plugin/theme manager: checkboxes/radios + apply (writes info, restarts). */
220
+ function renderCatalogSection(
221
+ body: HTMLElement,
222
+ info: AdminState['info'],
223
+ catalog: AdminState['catalog'],
224
+ refresh: () => Promise<void>,
225
+ ): void {
226
+ const plugins = catalog?.plugins ?? []
227
+ const themes = catalog?.themes ?? []
228
+ const languages = catalog?.languages ?? []
229
+ const activePlugins = new Set(info?.plugins ?? [])
230
+ const loadedThemes = new Set(info?.themes ?? [])
231
+ const activeLanguages = new Set(info?.languages ?? [])
232
+
233
+ // ── plugins ──────────────────────────────────────────────────────────────
234
+ const pluginSection = make('section', 'dsh-tw-settings-section')
235
+ pluginSection.append(make('h3', 'dsh-tw-settings-h', '插件管理(自带官方插件)'))
236
+ const search = make('input', 'dsh-tw-settings-input dsh-tw-settings-search')
237
+ search.placeholder = '搜索插件…'
238
+ const listWrap = make('div', 'dsh-tw-settings-list')
239
+ const applyPlugins = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用插件(重启 TW)')
240
+ applyPlugins.type = 'button'
241
+
242
+ const renderPluginList = (needle: string): void => {
243
+ listWrap.replaceChildren()
244
+ const q = needle.toLowerCase()
245
+ for (const plugin of plugins) {
246
+ if (q.length > 0 && !`${plugin.label} ${plugin.name} ${plugin.description}`.toLowerCase().includes(q)) continue
247
+ const input = make('input', 'dsh-tw-settings-check')
248
+ input.type = 'checkbox'
249
+ input.checked = activePlugins.has(plugin.name)
250
+ input.addEventListener('change', () => {
251
+ if (input.checked) activePlugins.add(plugin.name)
252
+ else activePlugins.delete(plugin.name)
253
+ })
254
+ const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
255
+ const name = make('span', 'dsh-tw-settings-name', plugin.label)
256
+ name.title = plugin.name
257
+ const desc = make('span', 'dsh-tw-settings-muted', plugin.description || plugin.name)
258
+ label.append(input, name, desc)
259
+ listWrap.append(label)
260
+ }
261
+ }
262
+ search.addEventListener('input', () => renderPluginList(search.value))
263
+ applyPlugins.addEventListener('click', () => {
264
+ applyPlugins.disabled = true
265
+ void (async () => {
266
+ try {
267
+ await fetchJson(INFO_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ plugins: [...activePlugins] }) })
268
+ toast('插件已应用,TW 已重启')
269
+ void refresh()
270
+ } catch (err) {
271
+ toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
272
+ applyPlugins.disabled = false
273
+ }
274
+ })()
275
+ })
276
+ renderPluginList('')
277
+ pluginSection.append(search, listWrap, applyPlugins)
278
+ body.append(pluginSection)
279
+
280
+ // ── themes ───────────────────────────────────────────────────────────────
281
+ // `info.themes` = WHICH theme plugins are LOADED (multi-select; TW's own
282
+ // default is [vanilla, snowwhite]); `$:/theme` = the ACTIVE one (single).
283
+ // Two controls per theme: ☑ 加载 (multi) + ◉ 活动 (single, auto-added to the
284
+ // loaded set on apply). The host computes the dependency closure (heavier →
285
+ // vanilla+snowwhite+heavier) and writes $:/theme.
286
+ const themeSection = make('section', 'dsh-tw-settings-section')
287
+ themeSection.append(make('h3', 'dsh-tw-settings-h', '主题管理(自带主题)'))
288
+ const themeHint = make('div', 'dsh-tw-settings-muted', '「加载」= TW 里可用的主题(可多选,依赖链自动带上,如 heavier 会带 snowwhite+vanilla);「活动」= 当前视觉主题(单选,自动加入加载集)。应用后重启 TW。')
289
+ const themeHead = make('div', 'dsh-tw-settings-row dsh-tw-settings-head')
290
+ themeHead.append(
291
+ make('span', 'dsh-tw-settings-col', '加载'),
292
+ make('span', 'dsh-tw-settings-col', '活动'),
293
+ make('span', 'dsh-tw-settings-name', '主题'),
294
+ )
295
+ const themeList = info?.themes ?? []
296
+ let activeThemeName = themeList.length > 0 ? themeList[themeList.length - 1] : 'tiddlywiki/vanilla'
297
+ const themeWrap = make('div', 'dsh-tw-settings-list')
298
+ for (const theme of themes) {
299
+ const load = make('input', 'dsh-tw-settings-check')
300
+ load.type = 'checkbox'
301
+ load.checked = loadedThemes.has(theme.name)
302
+ load.title = '加载该主题'
303
+ load.addEventListener('change', () => {
304
+ if (load.checked) loadedThemes.add(theme.name)
305
+ else loadedThemes.delete(theme.name)
306
+ })
307
+ const act = make('input', 'dsh-tw-settings-check')
308
+ act.type = 'radio'
309
+ act.name = 'dsh-tw-active-theme'
310
+ act.checked = theme.name === activeThemeName
311
+ act.title = '设为活动主题'
312
+ act.addEventListener('change', () => {
313
+ if (act.checked) activeThemeName = theme.name
314
+ })
315
+ const name = make('span', 'dsh-tw-settings-name', theme.label)
316
+ name.title = theme.name
317
+ const desc = make('span', 'dsh-tw-settings-muted', theme.description || theme.name)
318
+ const row = make('div', 'dsh-tw-settings-row dsh-tw-settings-plugin')
319
+ row.append(load, act, name, desc)
320
+ themeWrap.append(row)
321
+ }
322
+ const applyThemes = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用主题(重启 TW)')
323
+ applyThemes.type = 'button'
324
+ applyThemes.addEventListener('click', () => {
325
+ applyThemes.disabled = true
326
+ void (async () => {
327
+ try {
328
+ await fetchJson(INFO_ENDPOINT, {
329
+ method: 'POST',
330
+ headers: { 'content-type': 'application/json' },
331
+ body: JSON.stringify({ themes: [...loadedThemes], themeActive: activeThemeName }),
332
+ })
333
+ toast('主题已应用,TW 已重启')
334
+ void refresh()
335
+ } catch (err) {
336
+ toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
337
+ applyThemes.disabled = false
338
+ }
339
+ })()
340
+ })
341
+ themeSection.append(themeHint, themeHead, themeWrap, applyThemes)
342
+ body.append(themeSection)
343
+
344
+ // ── languages (bundled, offline — enable → restart TW) ──────────────────
345
+ const langSection = make('section', 'dsh-tw-settings-section')
346
+ langSection.append(make('h3', 'dsh-tw-settings-h', '语言管理(自带官方语言包)'))
347
+ const langHint = make('div', 'dsh-tw-settings-muted', '勾选启用语言插件并重启 TW;如中文请选 zh-Hans(简体)或 zh-CN。')
348
+ const langWrap = make('div', 'dsh-tw-settings-list')
349
+ for (const lang of languages) {
350
+ const input = make('input', 'dsh-tw-settings-check')
351
+ input.type = 'checkbox'
352
+ input.checked = activeLanguages.has(lang.name)
353
+ input.addEventListener('change', () => {
354
+ if (input.checked) activeLanguages.add(lang.name)
355
+ else activeLanguages.delete(lang.name)
356
+ })
357
+ const label = make('label', 'dsh-tw-settings-row dsh-tw-settings-plugin')
358
+ const name = make('span', 'dsh-tw-settings-name', lang.label)
359
+ name.title = lang.name
360
+ const desc = make('span', 'dsh-tw-settings-muted', lang.description || lang.name)
361
+ label.append(input, name, desc)
362
+ langWrap.append(label)
363
+ }
364
+ const applyLangs = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '应用语言(重启 TW)')
365
+ applyLangs.type = 'button'
366
+ applyLangs.addEventListener('click', () => {
367
+ applyLangs.disabled = true
368
+ void (async () => {
369
+ try {
370
+ await fetchJson(INFO_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ languages: [...activeLanguages] }) })
371
+ toast('语言已应用,TW 已重启')
372
+ void refresh()
373
+ } catch (err) {
374
+ toast(`应用失败:${err instanceof Error ? err.message : String(err)}`)
375
+ applyLangs.disabled = false
376
+ }
377
+ })()
378
+ })
379
+ langSection.append(langHint, langWrap, applyLangs)
380
+ body.append(langSection)
381
+ }
382
+
383
+ function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise<void>): void {
384
+ body.replaceChildren()
385
+ renderConfigSection(body, state.config ?? {}, refresh)
386
+ renderCatalogSection(body, state.info, state.catalog, refresh)
387
+ }
388
+
389
+ /** React wrapper consumed by the shell's settings.section slot. */
390
+ export function SettingsSection(): React.ReactElement {
391
+ const ref = React.useRef<HTMLDivElement | null>(null)
392
+ React.useEffect(() => {
393
+ const el = ref.current
394
+ return el === null ? undefined : mountSettingsPage(el)
395
+ }, [])
396
+ return React.createElement('div', { ref })
397
+ }