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.
- package/README.md +9 -4
- package/lib/client.js +27 -27
- package/lib/index.js +25 -2
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/note-widget.ts +5 -4
- package/src/client/panel.ts +40 -231
- package/src/client/quick-note-dock.ts +10 -2
- package/src/client/rightbar-tab.ts +2 -1
- package/src/client/session-summary.ts +18 -4
- package/src/client/settings-page.ts +18 -8
- package/src/client/theme-sync.ts +25 -10
- package/src/client/tool-views.ts +3 -1
- package/src/client/tw-frame.ts +280 -110
- package/src/host/admin.ts +28 -1
- package/src/index.ts +1 -1
package/src/client/tw-frame.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
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
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
85
|
-
const liveFrames = new Set<TwFrameController>()
|
|
97
|
+
/* ────────────────────────── the shared frame kernel ─────────────────────── */
|
|
86
98
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
|
|
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
|
-
|
|
371
|
+
return {
|
|
372
|
+
build,
|
|
293
373
|
setVisible(next: boolean): void {
|
|
374
|
+
if (disposed) return
|
|
294
375
|
visible = next
|
|
295
|
-
if (!started)
|
|
296
|
-
|
|
297
|
-
frame
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
|
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'
|