dsh-tiddlywiki 0.1.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.
@@ -0,0 +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
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Sidebar entry injection — structure ported from dsh-taskboard's
3
+ * sidebar-entry.ts (verified live in this shell): scope to the sidebar root,
4
+ * find the New Session button, and insert the entry as a direct child of that
5
+ * root next to the family block. A body-level MutationObserver self-heals
6
+ * React re-renders; a slow timer covers shells that mount late without
7
+ * further mutations. The row is plain DOM so it never disturbs the shell's
8
+ * reconciliation.
9
+ *
10
+ * @module dsh-tiddlywiki/client/sidebar-entry
11
+ */
12
+ import type { PanelState } from './state.ts'
13
+
14
+ /** Stable data attribute identifying this entry row. */
15
+ export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
16
+
17
+ /** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
18
+ const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
19
+
20
+ /** Family entries from sibling plugins, kept in a stable relative order. */
21
+ const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
22
+
23
+ /** Find the sidebar shell root element, or undefined while not yet mounted. */
24
+ function sidebarRoot(): HTMLElement | undefined {
25
+ const column = document.querySelector<HTMLElement>(
26
+ '[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface',
27
+ )
28
+ if (column === null) return undefined
29
+ const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
30
+ return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
31
+ }
32
+
33
+ /** The New Session button inside the sidebar root. */
34
+ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
35
+ const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
36
+ if (nested !== null) return nested
37
+ for (const child of root.children) {
38
+ if (child instanceof HTMLButtonElement && !child.matches(ENTRY_SELECTOR)) return child
39
+ }
40
+ const byAria = root.querySelector<HTMLButtonElement>(
41
+ 'button[aria-label="新建会话"], button[aria-label="New Session"], button[aria-label*="新会话"], button[aria-label*="new session" i]',
42
+ )
43
+ if (byAria !== null) return byAria
44
+ const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
45
+ return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
46
+ }
47
+
48
+ /** Build the entry row (a detached button; insert once the shell is up). */
49
+ function createEntry(state: PanelState): HTMLButtonElement {
50
+ const entry = document.createElement('button')
51
+ entry.type = 'button'
52
+ entry.dataset.dshTwEntry = ''
53
+ entry.className = 'dsh-tw-entry'
54
+ entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
55
+ entry.innerHTML = `<span class="dsh-tw-entry-icon">${ICON}</span><span class="dsh-tw-entry-label">TiddlyWiki</span>`
56
+ entry.addEventListener('click', () => { state.toggle() })
57
+ return entry
58
+ }
59
+
60
+ /** Re-insert the entry before the whole family block (stable ordering). */
61
+ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
62
+ const button = newSessionButton(root)
63
+ if (button === undefined) return false
64
+ if (entry.parentElement !== root) {
65
+ const row = button.closest('[class*="logoRow"]')
66
+ const base = (row !== null && row.parentElement === root) ? row : button
67
+ const family = Array.from(root.children).filter(
68
+ (el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_SELECTOR),
69
+ )
70
+ const anchor = family.length > 0 ? (family[0] ?? null) : (base.nextElementSibling ?? null)
71
+ root.insertBefore(entry, anchor)
72
+ }
73
+ return true
74
+ }
75
+
76
+ /** Debug counters (window.__twDebug) — evidence if the entry fails to appear. */
77
+ interface TwDebug { attempts: number; found: boolean; placed: boolean }
78
+
79
+ /**
80
+ * Mount the sidebar entry, waiting for the shell and self-healing on later
81
+ * re-renders.
82
+ * @param state - the shared panel state the entry toggles.
83
+ * @returns disposer removing the entry and its observers.
84
+ */
85
+ export function mountSidebarEntry(state: PanelState): () => void {
86
+ const entry = createEntry(state)
87
+ const debug: TwDebug = { attempts: 0, found: false, placed: false }
88
+ const host = globalThis.location?.hostname
89
+ if (host === 'localhost' || host === '127.0.0.1') {
90
+ ;(window as unknown as { __twDebug?: TwDebug }).__twDebug = debug
91
+ }
92
+ let root: HTMLElement | undefined
93
+ let placed = false
94
+
95
+ const tryPlace = (): void => {
96
+ debug.attempts++
97
+ if (root !== undefined && !root.isConnected) {
98
+ rootObserver.disconnect()
99
+ root = undefined
100
+ placed = false
101
+ }
102
+ if (placed) {
103
+ if (document.body.contains(entry)) return
104
+ rootObserver.disconnect()
105
+ root = undefined
106
+ placed = false
107
+ }
108
+ root ??= sidebarRoot()
109
+ if (root === undefined) return
110
+ debug.found = newSessionButton(root) !== undefined
111
+ placed = placeEntry(root, entry)
112
+ debug.placed = placed
113
+ if (placed) rootObserver.observe(root, { childList: true, subtree: true })
114
+ }
115
+
116
+ // Body-level watcher as the whole-rebuild fallback.
117
+ const waitObserver = new MutationObserver(() => { tryPlace() })
118
+ waitObserver.observe(document.body, { childList: true, subtree: true })
119
+
120
+ // Self-heal: re-insert in the same frame when a re-render displaces the row.
121
+ const rootObserver = new MutationObserver(() => {
122
+ if (root === undefined || !root.isConnected) {
123
+ placed = false
124
+ tryPlace()
125
+ return
126
+ }
127
+ if (!root.contains(entry)) placed = placeEntry(root, entry)
128
+ })
129
+
130
+ // Belt-and-braces: a late shell mount with no further mutations still heals.
131
+ const retry = setInterval(() => { tryPlace() }, 2_000)
132
+
133
+ const syncActive = (): void => {
134
+ if (state.isOpen()) entry.dataset.active = 'true'
135
+ else delete entry.dataset.active
136
+ }
137
+ const unsubscribe = state.subscribe(syncActive)
138
+ syncActive()
139
+ tryPlace()
140
+
141
+ return () => {
142
+ clearInterval(retry)
143
+ waitObserver.disconnect()
144
+ rootObserver.disconnect()
145
+ unsubscribe()
146
+ entry.remove()
147
+ }
148
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
+ *
4
+ * @module dsh-tiddlywiki/client/state
5
+ */
6
+
7
+ export type PanelListener = (open: boolean) => void
8
+
9
+ export class PanelState {
10
+ private open = false
11
+ private readonly listeners = new Set<PanelListener>()
12
+
13
+ isOpen(): boolean {
14
+ return this.open
15
+ }
16
+
17
+ toggle(): void {
18
+ this.set(!this.open)
19
+ }
20
+
21
+ openPanel(): void {
22
+ this.set(true)
23
+ }
24
+
25
+ closePanel(): void {
26
+ this.set(false)
27
+ }
28
+
29
+ set(value: boolean): void {
30
+ if (this.open === value) return
31
+ this.open = value
32
+ for (const listener of [...this.listeners]) listener(value)
33
+ }
34
+
35
+ subscribe(listener: PanelListener): () => void {
36
+ this.listeners.add(listener)
37
+ return () => { this.listeners.delete(listener) }
38
+ }
39
+ }