dsh-tiddlywiki 0.22.2 → 0.22.4

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.
@@ -6,8 +6,16 @@
6
6
  * TW proxy (`/dsh-tiddlywiki/tw/`) in an iframe and share the same lifecycle:
7
7
  * lazy-load on first show, `/status` polling with restart/error states, DSH
8
8
  * theme sync, reload-on-FAB-event, and tiddler-hash navigation. This module
9
- * owns that machinery plus the two small shared bits of state the surfaces
10
- * mirror (the tab chip label from `ui.tabLabel` and the tab icon).
9
+ * owns that machinery — `createTwFrameSurface()` is the ONE implementation —
10
+ * plus the two small shared bits of state the surfaces mirror (the tab chip
11
+ * label from `ui.tabLabel` and the tab icon).
12
+ *
13
+ * v0.22.4 convergence: `panel.ts` used to carry its own copies of
14
+ * showError/showStarting/showFrame/fallbackLoad/doRefresh. They drifted, and
15
+ * the drift is exactly what produced the v0.22.3 empty-`src` bug (one copy got
16
+ * the guard, the other did not). A surface now supplies only its SKIN (class
17
+ * names), its lazy build point and its visibility; every lifecycle decision
18
+ * lives here. Add new lifecycle behaviour in this file, never at a call site.
11
19
  *
12
20
  * Link routing: each mounted TW surface registers its live frame controller
13
21
  * here; panel.ts asks `openTiddlerInLiveTab` first, so a wiki link lands in a
@@ -42,7 +50,9 @@ export function getTabLabel(): string {
42
50
  return tabLabel
43
51
  }
44
52
 
45
- async function requestRestart(): Promise<boolean> { try {
53
+ /** POST /restart; `false` on any failure. Shared by both TW surfaces (v0.22.3). */
54
+ export async function requestRestart(): Promise<boolean> {
55
+ try {
46
56
  const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
47
57
  return res.ok
48
58
  } catch {
@@ -60,69 +70,83 @@ export function TwTabIcon({ size = 16, className }: { size?: number; className?:
60
70
  )
61
71
  }
62
72
 
63
- /** The plain-DOM TW frame controller owned by one surface body. */
64
- export interface TwFrameController {
65
- /** Reflect the surface's visibility; loads lazily on the first show. */
66
- setVisible(visible: boolean): void
67
- /** Open a tiddler by title; false when this controller cannot serve it. */
68
- openTiddler(title: string): boolean
69
- dispose(): void
73
+ /**
74
+ * The last URL a TW surface actually assigned to its iframe, or null when the
75
+ * frame never loaded one. Both surfaces cache it in `iframe.dataset.loaded`
76
+ * (only `showFrame` writes it), which keeps "is this frame pointing at the TW
77
+ * proxy?" answerable without trusting `src` — see `loadableFrameUrl`.
78
+ */
79
+ export interface FrameLoadedState {
80
+ loaded?: string
70
81
  }
71
82
 
72
83
  /**
73
- * Controller for an ALREADY-aborted signal: no DOM is ever built, so there is
74
- * nothing to show and nothing to reap (the abort listener would never fire).
84
+ * Non-empty `dataset.loaded`, or null when the frame has no real URL yet
85
+ * (v0.22.3). NEVER read `iframe.src` for this: for an iframe whose `src`
86
+ * attribute was never assigned the property returns the EMBEDDING page's URL,
87
+ * and assigning it back to `src` makes the DSH GUI load itself inside the
88
+ * iframe (a second DSH instance: duplicate FAB, duplicated global listeners,
89
+ * blank white page). Every full-reload path must refuse to touch a frame that
90
+ * has no loaded TW URL.
75
91
  */
76
- function disposedController(): TwFrameController {
77
- return {
78
- setVisible(): void {},
79
- openTiddler(): boolean { return false },
80
- dispose(): void {},
81
- }
92
+ export function loadableFrameUrl(dataset: FrameLoadedState): string | null {
93
+ const loaded = dataset.loaded
94
+ return loaded === undefined || loaded.length === 0 ? null : loaded
82
95
  }
83
96
 
84
- /** Live visible-capable TW frames; a controller joins on creation, leaves on dispose. */
85
- const liveFrames = new Set<TwFrameController>()
97
+ /* ────────────────────────── the shared frame kernel ─────────────────────── */
86
98
 
87
- /**
88
- * Ask every live side TW surface to open a tiddler; true when one served it.
89
- * A controller answers false while hidden or disposed, so the center overlay
90
- * only opens when no visible side TW tab can take the link.
91
- */
92
- export function openTiddlerInLiveTab(title: string): boolean {
93
- for (const controller of liveFrames) {
94
- if (controller.openTiddler(title)) return true
95
- }
96
- return false
99
+ /** Per-surface skin: the class names (and inline style) a surface wears. */
100
+ export interface TwFrameSkin {
101
+ /** Wrapper filling the surface body (owns the flex chain). */
102
+ view: string
103
+ /** `data-*` attributes for the view wrapper (panel tags it for its CSS). */
104
+ viewDataset?: Record<string, string>
105
+ /** Wrapper around the iframe. */
106
+ frameWrap: string
107
+ /** Inline style for the frame wrapper (the panel's has no stylesheet rule). */
108
+ frameWrapStyle?: string
109
+ /** The TW iframe. */
110
+ frame: string
111
+ /** Error / starting panel. */
112
+ error: string
97
113
  }
98
114
 
99
- /**
100
- * Refresh the shared surface label from `/status` (ui.tabLabel) so a surface
101
- * that mounts before the first status call already shows the right name.
102
- */
103
- export function warmTabLabel(): void {
104
- void fetchStatus().then((payload) => {
105
- if (payload?.ui !== undefined) setTabLabel(payload.ui.tabLabel ?? tabLabel)
106
- })
115
+ /** A TW iframe surface: DOM + lifecycle, shared by every embedding surface. */
116
+ export interface TwFrameSurface {
117
+ /**
118
+ * Build the surface DOM (view wrapper + iframe + error area) and return the
119
+ * view element; the CALLER decides where to append it. Idempotent, and safe
120
+ * to call AFTER `setVisible(true)`: a surface asked to show before its DOM
121
+ * existed (the panel builds lazily, once the center column appears) re-arms
122
+ * its `/status` load right here instead of stranding an empty surface.
123
+ */
124
+ build(): HTMLDivElement
125
+ /**
126
+ * Reflect visibility. The first `true` starts the lazy load and is the only
127
+ * thing that reveals the frame; `false` hides it and stops the bounded
128
+ * retry polling, so a later `true` starts a fresh budget. Only a frame that
129
+ * already carries a TW URL is ever revealed.
130
+ */
131
+ setVisible(visible: boolean): void
132
+ /** Open a tiddler by title; false when this surface cannot serve it. */
133
+ openTiddler(title: string): boolean
134
+ /** Tear down listeners/timers/theme sync and remove the view from the DOM. */
135
+ dispose(): void
107
136
  }
108
137
 
109
138
  /**
110
- * Create a TW frame controller inside `host`. The frame loads lazily on the
111
- * first `setVisible(true)`, polls `/status` (starting/restart/error states),
112
- * follows the DSH theme, reloads on the FAB's reload event, and navigates to
113
- * `#<title>` hashes once the frame is ready. `signal` (the tab's abort) tears
114
- * the whole controller down. Joins the live-frame registry on creation and
115
- * leaves it on dispose.
139
+ * Create the frame machinery for one surface. Every `/status`-driven decision
140
+ * (starting / error / running, iframe URL, theme config, shared chip label)
141
+ * and every tiddler-hash navigation is implemented here ONCE.
116
142
  */
117
- export function createTwFrameController(host: HTMLElement, signal: AbortSignal): TwFrameController {
118
- // Already aborted (the tab was closed before this body mounted): 'abort' will
119
- // never fire again, so registering here would strand the iframe in
120
- // liveFrames forever. Hand back a no-op controller and build nothing.
121
- if (signal.aborted) return disposedController()
122
-
143
+ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
123
144
  let visible = false
124
145
  let started = false
125
146
  let disposed = false
147
+ let view: HTMLDivElement | undefined
148
+ let frame: HTMLIFrameElement | undefined
149
+ let errorArea: HTMLDivElement | undefined
126
150
  let refreshTimer: number | undefined
127
151
  /** In-flight hash-readiness retry timers (cancelled by dispose, v0.19.1). */
128
152
  const hashWaitTimers = new Set<number>()
@@ -131,31 +155,16 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
131
155
  let pendingHash: string | null = null
132
156
  let themeSyncDispose: (() => void) | undefined
133
157
 
134
- const view = document.createElement('div')
135
- view.className = 'dsh-tw-rightbar-view'
136
-
137
- const frameArea = document.createElement('div')
138
- frameArea.className = 'dsh-tw-rightbar-frame-wrap'
139
- const frame = document.createElement('iframe')
140
- frame.className = 'dsh-tw-rightbar-frame'
141
- frame.title = 'TiddlyWiki'
142
- frame.hidden = true
143
- frameArea.append(frame)
144
-
145
- const errorArea = document.createElement('div')
146
- errorArea.className = 'dsh-tw-rightbar-error'
147
- errorArea.hidden = true
148
-
149
- view.append(frameArea, errorArea)
150
- host.append(view)
151
-
152
- frame.addEventListener('load', () => {
153
- frameLoaded = true
154
- applyPendingHash()
155
- })
156
- themeSyncDispose = attachThemeSync(frame)
158
+ /** Cancel the pending bounded retry (a new refresh supersedes it). */
159
+ const clearRetry = (): void => {
160
+ if (refreshTimer !== undefined) {
161
+ window.clearTimeout(refreshTimer)
162
+ refreshTimer = undefined
163
+ }
164
+ }
157
165
 
158
166
  const showError = (message: string): void => {
167
+ if (frame === undefined || errorArea === undefined) return
159
168
  frame.hidden = true
160
169
  errorArea.hidden = false
161
170
  errorArea.replaceChildren()
@@ -175,6 +184,7 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
175
184
  }
176
185
 
177
186
  const showStarting = (): void => {
187
+ if (frame === undefined || errorArea === undefined) return
178
188
  frame.hidden = true
179
189
  errorArea.hidden = false
180
190
  errorArea.replaceChildren()
@@ -184,8 +194,11 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
184
194
  }
185
195
 
186
196
  const showFrame = (url: string): void => {
197
+ if (frame === undefined || errorArea === undefined) return
187
198
  errorArea.hidden = true
188
- // Only reveal the frame while the surface is on screen.
199
+ // Only reveal the frame while the surface is on screen: doRefresh() can
200
+ // still be in flight after the surface was hidden, and an unconditional
201
+ // `hidden = false` would pop a closed surface back into view.
189
202
  frame.hidden = !visible
190
203
  // Set the src only when the url changed, so an editor never loses unsaved
191
204
  // state on a status refresh.
@@ -196,9 +209,22 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
196
209
  }
197
210
  }
198
211
 
199
- /** Apply a pending tiddler-hash once the frame is ready (see panel.ts). */
212
+ /**
213
+ * Apply a pending tiddler-hash navigation once the iframe document is ready.
214
+ * Preferred: same-origin `contentWindow.location.hash` — a hashchange INSIDE
215
+ * the frame (no reload), TW's story handler opens the tiddler. Fallback: a
216
+ * full `iframe.src` reload with the hash (TW auto-saves drafts, acceptable).
217
+ *
218
+ * TW registers its hashchange listener in a STARTUP module that runs after
219
+ * the iframe's `load` event, so setting `location.hash` right on load is a
220
+ * no-op (hash lost → TW stays on its home page). We therefore wait for TW to
221
+ * become ready inside the frame (the `$tw` global appears once boot settles)
222
+ * before setting the hash; if it never does (service down / slow boot) we
223
+ * fall back to a full reload with the hash, which TW processes at startup.
224
+ * A newer open request supersedes an in-flight wait.
225
+ */
200
226
  const applyPendingHash = (): void => {
201
- if (pendingHash === null || !frameLoaded) return
227
+ if (pendingHash === null || frame === undefined || !frameLoaded) return
202
228
  const hash = pendingHash
203
229
  const win = frame.contentWindow
204
230
  if (win === null) {
@@ -206,8 +232,8 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
206
232
  return
207
233
  }
208
234
  const tryOnce = (attempt: number): void => {
209
- if (disposed) return
210
- if (pendingHash !== hash) return
235
+ if (disposed) return // unmounted: do not keep waiting or touch the frame
236
+ if (pendingHash !== hash) return // superseded by a newer request
211
237
  const frameTw = win as { $tw?: unknown }
212
238
  if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
213
239
  if (attempt < 40) {
@@ -234,24 +260,28 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
234
260
  }
235
261
 
236
262
  const fallbackLoad = (hash: string): void => {
237
- if (disposed) return
263
+ if (disposed || frame === undefined) return // never drive a detached frame
238
264
  if (pendingHash === hash) pendingHash = null
239
- const base = frame.src.split('#')[0]
240
- if (frame.src !== `${base}${hash}`) frame.src = `${base}${hash}`
265
+ // Guard against the never-loaded frame: `frame.src` is '' before showFrame
266
+ // ran, and `'' + '#title'` resolves against the DSH page URL, which loads
267
+ // the GUI into the iframe (v0.22.3).
268
+ const base = loadableFrameUrl(frame.dataset)
269
+ if (base === null) return
270
+ const next = `${base.split('#')[0]}${hash}`
271
+ if (frame.src !== next) frame.src = next
241
272
  }
242
273
 
243
274
  const doRefresh = async (): Promise<void> => {
244
- if (refreshTimer !== undefined) {
245
- window.clearTimeout(refreshTimer)
246
- refreshTimer = undefined
247
- }
275
+ clearRetry()
248
276
  const payload = await fetchStatus()
249
- if (disposed) return
277
+ if (disposed) return // unmounted while fetching: stop, don't touch DOM
250
278
  if (payload === null) {
251
279
  showError('无法访问 /dsh-tiddlywiki/status')
252
280
  return
253
281
  }
254
282
  if (payload.ui !== undefined) {
283
+ // Shared surface label + theme config: both are module-level, so any
284
+ // surface that polls keeps them fresh for every other surface.
255
285
  setTabLabel(payload.ui.tabLabel ?? tabLabel)
256
286
  setThemeSyncConfig({
257
287
  enabled: payload.ui.followDshTheme !== false,
@@ -260,6 +290,9 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
260
290
  }
261
291
  if (payload.status === 'running') {
262
292
  refreshAttempts = 0
293
+ // Same-origin proxy URL: build from the page's own origin so it works no
294
+ // matter which host/domain the user reached DSH on. Fall back to the
295
+ // legacy loopback `url` for older servers that do not send twProxy.
263
296
  if (typeof payload.twProxy === 'string') {
264
297
  showFrame(new URL(payload.twProxy, window.location.origin).href)
265
298
  } else if (typeof payload.url === 'string') {
@@ -282,32 +315,80 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
282
315
  }
283
316
 
284
317
  const onReloadRequest = (): void => {
285
- if (!frame.hidden) frame.src = frame.src
318
+ // Reload = re-assign the URL the frame is ALREADY showing (full reload, so
319
+ // TW re-reads the wiki; `location.reload()` could be blocked mid-edit).
320
+ // The ONLY valid condition is `dataset.loaded` (v0.22.3): `frame.src` is ''
321
+ // before showFrame ran, and `frame.src = frame.src` with `src === ''` loads
322
+ // the DSH page into the iframe.
323
+ // Deliberately NOT gated on `frame.hidden`: the FAB entry is an EXPLICIT
324
+ // reload request, so it must reach every frame that really points at TW —
325
+ // including a closed panel that would otherwise reopen onto stale assets
326
+ // (v0.22.4; the panel used to reload in that state). An extra reload costs
327
+ // less than showing stale content.
328
+ if (frame === undefined) return
329
+ const loaded = loadableFrameUrl(frame.dataset)
330
+ if (loaded !== null) frame.src = loaded
286
331
  }
287
332
  document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
288
333
 
289
- const onAbort = (): void => controller.dispose()
290
- signal.addEventListener('abort', onAbort, { once: true })
334
+ const build = (): HTMLDivElement => {
335
+ if (view !== undefined) return view
336
+ const viewEl = document.createElement('div')
337
+ viewEl.className = skin.view
338
+ if (skin.viewDataset !== undefined) {
339
+ for (const [key, value] of Object.entries(skin.viewDataset)) viewEl.dataset[key] = value
340
+ }
341
+ const wrapEl = document.createElement('div')
342
+ wrapEl.className = skin.frameWrap
343
+ if (skin.frameWrapStyle !== undefined) wrapEl.style.cssText = skin.frameWrapStyle
344
+ const frameEl = document.createElement('iframe')
345
+ frameEl.className = skin.frame
346
+ frameEl.title = 'TiddlyWiki'
347
+ frameEl.hidden = true
348
+ wrapEl.append(frameEl)
349
+ const errorEl = document.createElement('div')
350
+ errorEl.className = skin.error
351
+ errorEl.hidden = true
352
+ viewEl.append(wrapEl, errorEl)
353
+ view = viewEl
354
+ frame = frameEl
355
+ errorArea = errorEl
356
+ // Track load so a pending tiddler-hash navigation can target a ready
357
+ // document (setting contentWindow.location.hash before load is a no-op).
358
+ frameEl.addEventListener('load', () => {
359
+ frameLoaded = true
360
+ applyPendingHash()
361
+ })
362
+ // Embedded TW follows the DSH light/dark theme (non-persisting palette
363
+ // swap inside the same-origin iframe; re-applied on load + theme change).
364
+ themeSyncDispose = attachThemeSync(frameEl)
365
+ // The surface may already have been asked to show before its DOM existed
366
+ // (the panel builds lazily): re-arm the load so it never stays empty.
367
+ if (visible) void doRefresh()
368
+ return viewEl
369
+ }
291
370
 
292
- const controller: TwFrameController = {
371
+ return {
372
+ build,
293
373
  setVisible(next: boolean): void {
374
+ if (disposed) return
294
375
  visible = next
295
- if (!started) {
296
- // Nothing loaded yet: stay hidden until the first show kicks the load.
297
- frame.hidden = true
298
- view.dataset.visible = next ? '1' : '0'
299
- if (next) {
300
- started = true
301
- void doRefresh()
302
- }
303
- return
376
+ if (next && !started) started = true
377
+ if (frame !== undefined) {
378
+ // Reveal only a frame that already carries a TW URL; showFrame /
379
+ // showStarting decide what to display until the first /status lands.
380
+ frame.hidden = !next || loadableFrameUrl(frame.dataset) === null
381
+ }
382
+ if (next) {
383
+ // 每次重新显示都重探一次状态:启动轮询是有界的(30×1.5s),首次打开时
384
+ // 服务没起来就会把错误界面永久固定(切走再切回也不恢复)。doRefresh 自己
385
+ // 会清旧 timer,不会重复轮询。
386
+ void doRefresh()
387
+ } else {
388
+ // Hidden surfaces stop polling and get a fresh budget on the next show.
389
+ clearRetry()
390
+ refreshAttempts = 0
304
391
  }
305
- frame.hidden = !next
306
- view.dataset.visible = next ? '1' : '0'
307
- // 每次重新显示都重探一次状态:启动轮询是有界的(30×1.5s),首次打开时
308
- // 服务没起来就会把错误界面永久固定(切走再切回也不恢复)。doRefresh 自己
309
- // 会清旧 timer,不会重复轮询。
310
- if (next) void doRefresh()
311
392
  },
312
393
  openTiddler(title: string): boolean {
313
394
  if (disposed || !visible) return false
@@ -323,16 +404,105 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
323
404
  dispose(): void {
324
405
  if (disposed) return
325
406
  disposed = true
326
- liveFrames.delete(controller)
327
407
  document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
328
- signal.removeEventListener('abort', onAbort)
329
- if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
408
+ clearRetry()
330
409
  for (const timer of hashWaitTimers) window.clearTimeout(timer)
331
410
  hashWaitTimers.clear()
332
411
  themeSyncDispose?.()
333
- view.remove()
412
+ view?.remove()
413
+ },
414
+ }
415
+ }
416
+
417
+ /** The plain-DOM TW frame controller owned by one surface body. */
418
+ export interface TwFrameController {
419
+ /** Reflect the surface's visibility; loads lazily on the first show. */
420
+ setVisible(visible: boolean): void
421
+ /** Open a tiddler by title; false when this controller cannot serve it. */
422
+ openTiddler(title: string): boolean
423
+ dispose(): void
424
+ }
425
+
426
+ /**
427
+ * Controller for an ALREADY-aborted signal: no DOM is ever built, so there is
428
+ * nothing to show and nothing to reap (the abort listener would never fire).
429
+ */
430
+ function disposedController(): TwFrameController {
431
+ return {
432
+ setVisible(): void {},
433
+ openTiddler(): boolean { return false },
434
+ dispose(): void {},
435
+ }
436
+ }
437
+
438
+ /** Live visible-capable TW frames; a controller joins on creation, leaves on dispose. */
439
+ const liveFrames = new Set<TwFrameController>()
440
+
441
+ /**
442
+ * Ask every live side TW surface to open a tiddler; true when one served it.
443
+ * A controller answers false while hidden or disposed, so the center overlay
444
+ * only opens when no visible side TW tab can take the link.
445
+ */
446
+ export function openTiddlerInLiveTab(title: string): boolean {
447
+ for (const controller of liveFrames) {
448
+ if (controller.openTiddler(title)) return true
449
+ }
450
+ return false
451
+ }
452
+
453
+ /**
454
+ * Refresh the shared surface label from `/status` (ui.tabLabel) so a surface
455
+ * that mounts before the first status call already shows the right name.
456
+ */
457
+ export function warmTabLabel(): void {
458
+ void fetchStatus().then((payload) => {
459
+ if (payload?.ui !== undefined) setTabLabel(payload.ui.tabLabel ?? tabLabel)
460
+ })
461
+ }
462
+
463
+ /** The rightbar tab's skin for the shared kernel (CSS owns the flex chain). */
464
+ const RIGHTBAR_SKIN: TwFrameSkin = {
465
+ view: 'dsh-tw-rightbar-view',
466
+ frameWrap: 'dsh-tw-rightbar-frame-wrap',
467
+ frame: 'dsh-tw-rightbar-frame',
468
+ error: 'dsh-tw-rightbar-error',
469
+ }
470
+
471
+ /**
472
+ * Create a TW frame controller inside `host`: the shared kernel (which loads
473
+ * lazily on the first `setVisible(true)`, polls `/status`, follows the DSH
474
+ * theme, reloads on the FAB's reload event and navigates `#<title>` hashes)
475
+ * plus the rightbar skin and the live-frame registry. `signal` (the tab's
476
+ * abort) tears the whole controller down.
477
+ */
478
+ export function createTwFrameController(host: HTMLElement, signal: AbortSignal): TwFrameController {
479
+ // Already aborted (the tab was closed before this body mounted): 'abort' will
480
+ // never fire again, so registering here would strand the iframe in
481
+ // liveFrames forever. Hand back a no-op controller and build nothing.
482
+ if (signal.aborted) return disposedController()
483
+
484
+ const surface = createTwFrameSurface(RIGHTBAR_SKIN)
485
+ host.append(surface.build())
486
+
487
+ let disposed = false
488
+ const onAbort = (): void => controller.dispose()
489
+ signal.addEventListener('abort', onAbort, { once: true })
490
+
491
+ const controller: TwFrameController = {
492
+ setVisible(visible: boolean): void {
493
+ surface.setVisible(visible)
494
+ },
495
+ openTiddler(title: string): boolean {
496
+ return surface.openTiddler(title)
497
+ },
498
+ dispose(): void {
499
+ if (disposed) return
500
+ disposed = true
501
+ liveFrames.delete(controller)
502
+ signal.removeEventListener('abort', onAbort)
503
+ surface.dispose()
334
504
  },
335
505
  }
336
506
  liveFrames.add(controller)
337
507
  return controller
338
- }
508
+ }
package/src/host/admin.ts CHANGED
@@ -155,6 +155,28 @@ export async function ensurePlugin(wikiPath: string, twRoot: string, name: strin
155
155
  return true
156
156
  }
157
157
 
158
+ /**
159
+ * The theme the TW runtime is ACTUALLY showing, as a catalog-style name
160
+ * (`tiddlywiki/heavier`), or undefined when unknown (wiki down, no `$:/theme`).
161
+ *
162
+ * `$:/theme` holds the active theme title and is normally written as
163
+ * `$:/themes/<name>` (that is what this route's own POST writes), but a user
164
+ * who picked a theme in TW's Control Panel can leave the fully-qualified
165
+ * title there — strip the prefix so both shapes compare against the catalog.
166
+ * Without this the settings page could only guess "the last loaded theme"
167
+ * (v0.22.3), and one click on 应用主题 silently overwrote the real choice.
168
+ */
169
+ export async function readActiveThemeName(client: TiddlyWebClient | undefined): Promise<string | undefined> {
170
+ try {
171
+ const tiddler = await client?.get('$:/theme')
172
+ const raw = typeof tiddler?.text === 'string' ? tiddler.text.trim() : ''
173
+ if (raw.length === 0) return undefined
174
+ return raw.startsWith('$:/themes/') ? raw.slice('$:/themes/'.length) : raw
175
+ } catch {
176
+ return undefined
177
+ }
178
+ }
179
+
158
180
  /** Enumerate bundled official plugins + themes + languages of tiddlywiki. */
159
181
  export async function bundledCatalog(twRoot: string): Promise<Catalog> {
160
182
  // TW themes are SKINS layered on the vanilla base (which carries the full
@@ -443,11 +465,16 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
443
465
  git = null
444
466
  }
445
467
  const view = deps.server.status()
468
+ // Which theme the runtime actually shows (v0.22.3): the settings page used
469
+ // to derive it from the LAST entry of info.themes, so a non-last active
470
+ // theme displayed the wrong radio — and re-applying it (even without
471
+ // touching the radios) rewrote `$:/theme` to that wrong pick.
472
+ const themeActive = await readActiveThemeName(deps.getClient())
446
473
  json(res, {
447
474
  ok: true,
448
475
  // Same redaction as GET /status: this route is unauthenticated too.
449
476
  server: { ...view, logs: redactLogLines(view.logs) },
450
- info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [] },
477
+ info: { plugins: info.plugins, themes: info.themes, languages: info.languages ?? [], themeActive },
451
478
  catalog,
452
479
  config: maskConfigSecrets(deps.config.get()),
453
480
  git,
package/src/index.ts CHANGED
@@ -71,7 +71,7 @@ export {
71
71
  export { openInTwEditor, registerRoutes } from './host/routes.ts'
72
72
  export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './host/routes.ts'
73
73
  export type { SessionQueryFace, SessionSummaryResult } from './host/routes.ts'
74
- export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes, MASKED_SECRET, maskConfigSecrets, stripMaskedSecrets } from './host/admin.ts'
74
+ export { registerAdminRoutes, resolveTwRoot, readWikiInfo, writeWikiInfo, ensurePlugin, bundledCatalog, ensureLanguage, normalizeThemes, readActiveThemeName, MASKED_SECRET, maskConfigSecrets, stripMaskedSecrets } from './host/admin.ts'
75
75
  export { escapeInline } from './host/session-summary.ts'
76
76
  export { seedDocNote, docNoteText, DOC_NOTE_TITLE, DOC_NOTE_TAG, DOC_NOTE_TEXT } from './host/seed-notes.ts'
77
77
  export { hashText, parseSeedMarker, readSeedMarker, writeSeedMarker, SEED_MARKER_VERSION } from './host/seed-util.ts'