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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "TiddlyWiki 5 as the DSH persistent knowledge base: fifteen tiddlywiki_* agent tools (filtered search/recent/tags/batch/rewrite/trash/lint + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a right-side wiki tab, a per-session summary tab, a quick-note card with draft auto-save behind a single knowledge FAB, an optional clip bookmarklet that also saves selected images as attachments, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
-
"version": "0.22.
|
|
4
|
+
"version": "0.22.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"smoke:client": "node scripts/smoke-client.mjs",
|
|
86
86
|
"selftest": "node scripts/selftest.mjs",
|
|
87
87
|
"verify:static": "node scripts/verify-send-to-agent-bundle.mjs && node scripts/verify-render-bundle.mjs && node scripts/verify-package-contents.mjs && node scripts/verify-version-consistency.mjs && node scripts/verify-constants.mjs",
|
|
88
|
-
"verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-prompt.mjs && npx tsx scripts/verify-status-cache.mjs",
|
|
88
|
+
"verify:unit": "node scripts/verify-clip-bridge.mjs && node scripts/verify-seed-error-policy.mjs && node scripts/verify-render-sanitizer.mjs && node scripts/verify-secret-masking.mjs && node scripts/verify-write-policy.mjs && node scripts/verify-prompt.mjs && node scripts/verify-frame-guards.mjs && npx tsx scripts/verify-frame-surface.mjs && npx tsx scripts/verify-status-cache.mjs",
|
|
89
89
|
"verify:e2e": "node scripts/verify-auth.mjs && node scripts/verify-tools.mjs && node scripts/verify-audit-fixes.mjs && node scripts/verify-git-resolve.mjs && node scripts/verify-resilience.mjs && node scripts/verify-seed-send-to-agent.mjs && node scripts/verify-seeds-admin.mjs && node scripts/verify-wiki-switch.mjs",
|
|
90
90
|
"verify:large": "node scripts/verify-large-wiki.mjs",
|
|
91
91
|
"verify": "npm run verify:static && npm run verify:unit && npm run verify:e2e && npm run verify:large",
|
|
@@ -356,7 +356,6 @@ interface BuiltUi {
|
|
|
356
356
|
draftBanner: HTMLDivElement
|
|
357
357
|
/** 草稿横幅文案(恢复「其它窗口草稿」时改写为带来源的提示)。 */
|
|
358
358
|
bannerText: HTMLSpanElement
|
|
359
|
-
recentBtn: HTMLButtonElement
|
|
360
359
|
recentWrap: HTMLDivElement
|
|
361
360
|
}
|
|
362
361
|
|
|
@@ -820,7 +819,7 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
820
819
|
root.append(card, recentWrap)
|
|
821
820
|
document.body.append(root)
|
|
822
821
|
|
|
823
|
-
const handle: BuiltUi = { root, card, editor, titleInput, tagEditor, saveBtn, editBtn, draftBanner, bannerText,
|
|
822
|
+
const handle: BuiltUi = { root, card, editor, titleInput, tagEditor, saveBtn, editBtn, draftBanner, bannerText, recentWrap }
|
|
824
823
|
ui = handle
|
|
825
824
|
|
|
826
825
|
const close = (): void => {
|
|
@@ -1067,8 +1066,10 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
1067
1066
|
ui?.tagEditor.dispose()
|
|
1068
1067
|
ui?.editor.view.destroy()
|
|
1069
1068
|
ui?.root.remove()
|
|
1070
|
-
|
|
1071
|
-
|
|
1069
|
+
// NOTE: `.dsh-tw-toast` is deliberately NOT removed here (v0.22.3). It is a
|
|
1070
|
+
// page-level singleton owned by toast.ts and shared with the settings page;
|
|
1071
|
+
// deleting it on this widget's unmount killed a toast another surface was
|
|
1072
|
+
// showing (toast.ts would only re-create it on the next call).
|
|
1072
1073
|
ui = undefined
|
|
1073
1074
|
},
|
|
1074
1075
|
}
|
package/src/client/panel.ts
CHANGED
|
@@ -10,10 +10,16 @@
|
|
|
10
10
|
* Toggling rides the shared PanelState; cross-plugin exclusivity rides the
|
|
11
11
|
* `dsh-panel-activate` event (same protocol as dsh-taskboard).
|
|
12
12
|
*
|
|
13
|
+
* This module owns the overlay LAYOUT only (rect pinning, lazy build, chrome,
|
|
14
|
+
* exclusion). The iframe's whole lifecycle — `/status` polling, error/starting
|
|
15
|
+
* states, theme sync, FAB reload, tiddler-hash navigation — is the shared
|
|
16
|
+
* kernel in tw-frame.ts (v0.22.4), so the panel and the rightbar tab can no
|
|
17
|
+
* longer drift apart (the drift is what produced the v0.22.3 empty-src bug).
|
|
18
|
+
*
|
|
13
19
|
* The iframe points at the SAME-ORIGIN TW proxy (`<origin>/dsh-tiddlywiki/tw/`,
|
|
14
20
|
* remote-access mode R1): the browser only talks to the DSH origin — which it
|
|
15
21
|
* already reaches over loopback, LAN, Tailscale, a domain or HTTPS — and DSH
|
|
16
|
-
* proxies to the loopback TW child. The
|
|
22
|
+
* proxies to the loopback TW child. The kernel reads /status first and only
|
|
17
23
|
* sets iframe.src when the service is actually running. Because the proxy URL
|
|
18
24
|
* is origin-relative and port-independent, a TW restart never reloads the
|
|
19
25
|
* iframe, so an in-progress edit keeps its unsaved state.
|
|
@@ -22,9 +28,8 @@
|
|
|
22
28
|
*/
|
|
23
29
|
import type { PanelState } from './state.ts'
|
|
24
30
|
import { ENTRY_SELECTOR } from './sidebar-entry.ts'
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
import { ACTIVATE_EVENT, openTiddlerInLiveTab, PANEL_RELOAD_EVENT } from './tw-frame.ts'
|
|
31
|
+
// 事件名与 frame 生命周期助手都只有一份,住在 tw-frame.ts(内核)。
|
|
32
|
+
import { ACTIVATE_EVENT, createTwFrameSurface, openTiddlerInLiveTab, type TwFrameSkin } from './tw-frame.ts'
|
|
28
33
|
|
|
29
34
|
/**
|
|
30
35
|
* Center-column targets, most-specific shell generation first. The official
|
|
@@ -57,8 +62,20 @@ const APP_OVERLAY_Z_INDEX = 20
|
|
|
57
62
|
/** Safety re-measure cadence for shell layout changes CSS can't see. */
|
|
58
63
|
const SYNC_INTERVAL_MS = 2_000
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The panel's skin for the shared frame kernel. The inline flex chain is
|
|
67
|
+
* required because — unlike `.dsh-tw-rightbar-frame-wrap` — the panel's frame
|
|
68
|
+
* wrapper has no stylesheet rule; `data-dsh-tw-view` is what the stylesheet
|
|
69
|
+
* keys on to hide the conversation content the overlay covers.
|
|
70
|
+
*/
|
|
71
|
+
const PANEL_SKIN: TwFrameSkin = {
|
|
72
|
+
view: 'dsh-tw-view',
|
|
73
|
+
viewDataset: { dshTwView: '' },
|
|
74
|
+
frameWrap: 'dsh-tw-panel-frame-wrap',
|
|
75
|
+
frameWrapStyle: 'flex:1;min-height:0;display:flex;flex-direction:column',
|
|
76
|
+
frame: 'dsh-tw-panel-frame',
|
|
77
|
+
error: 'dsh-tw-panel-error',
|
|
78
|
+
}
|
|
62
79
|
|
|
63
80
|
/**
|
|
64
81
|
* Cross-module open request: the reply-stream tool cards and the document
|
|
@@ -99,65 +116,16 @@ function conversationColumn(): HTMLElement | undefined {
|
|
|
99
116
|
return undefined
|
|
100
117
|
}
|
|
101
118
|
|
|
102
|
-
async function requestRestart(): Promise<boolean> {
|
|
103
|
-
try {
|
|
104
|
-
const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
|
|
105
|
-
return res.ok
|
|
106
|
-
} catch {
|
|
107
|
-
return false
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
119
|
export function mountPanel(state: PanelState): () => void {
|
|
112
120
|
let container: HTMLDivElement | undefined
|
|
113
121
|
let columnEl: HTMLElement | undefined
|
|
114
|
-
let iframe: HTMLIFrameElement | undefined
|
|
115
|
-
let frameArea: HTMLDivElement | undefined
|
|
116
|
-
let errorArea: HTMLDivElement | undefined
|
|
117
|
-
let refreshTimer: number | undefined
|
|
118
|
-
/** In-flight hash-readiness retry timers (cancelled by dispose, v0.19.1). */
|
|
119
|
-
const hashWaitTimers = new Set<number>()
|
|
120
|
-
let refreshAttempts = 0
|
|
121
|
-
let themeSyncDispose: (() => void) | undefined
|
|
122
|
-
/** true once the iframe's document finished loading (hash navigation target ready). */
|
|
123
|
-
let frameLoaded = false
|
|
124
|
-
/** A tiddler-hash open request waiting for the frame to become ready. */
|
|
125
|
-
let pendingHash: string | null = null
|
|
126
122
|
/** Set by the disposer: no timers/observers may touch DOM or the iframe after. */
|
|
127
123
|
let disposed = false
|
|
128
124
|
/** Last observed presence of the better-sidebar host layer (applyChrome cache). */
|
|
129
125
|
let lastHasHost: boolean | undefined
|
|
130
126
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
view.dataset.dshTwView = ''
|
|
134
|
-
view.className = 'dsh-tw-view'
|
|
135
|
-
|
|
136
|
-
frameArea = document.createElement('div')
|
|
137
|
-
frameArea.className = 'dsh-tw-panel-frame-wrap'
|
|
138
|
-
frameArea.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'
|
|
139
|
-
iframe = document.createElement('iframe')
|
|
140
|
-
iframe.className = 'dsh-tw-panel-frame'
|
|
141
|
-
iframe.title = 'TiddlyWiki'
|
|
142
|
-
iframe.hidden = true
|
|
143
|
-
frameArea.append(iframe)
|
|
144
|
-
// Track load so a pending tiddler-hash navigation can target a ready
|
|
145
|
-
// document (setting contentWindow.location.hash before load is a no-op).
|
|
146
|
-
iframe.addEventListener('load', () => {
|
|
147
|
-
frameLoaded = true
|
|
148
|
-
applyPendingHash()
|
|
149
|
-
})
|
|
150
|
-
// Embedded TW follows the DSH light/dark theme (non-persisting palette
|
|
151
|
-
// swap inside the same-origin iframe; re-applied on load + theme change).
|
|
152
|
-
themeSyncDispose = attachThemeSync(iframe)
|
|
153
|
-
|
|
154
|
-
errorArea = document.createElement('div')
|
|
155
|
-
errorArea.className = 'dsh-tw-panel-error'
|
|
156
|
-
errorArea.hidden = true
|
|
157
|
-
|
|
158
|
-
view.append(frameArea, errorArea)
|
|
159
|
-
return view
|
|
160
|
-
}
|
|
127
|
+
/** iframe + /status lifecycle (shared with the rightbar tab, tw-frame.ts). */
|
|
128
|
+
const surface = createTwFrameSurface(PANEL_SKIN)
|
|
161
129
|
|
|
162
130
|
/** Pin the overlay to the center column's current viewport rect. */
|
|
163
131
|
const syncRect = (): void => {
|
|
@@ -202,172 +170,26 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
202
170
|
if (columnEl === undefined || !columnEl.isConnected) columnEl = conversationColumn()
|
|
203
171
|
if (container !== undefined) return
|
|
204
172
|
if (columnEl === undefined) return
|
|
205
|
-
|
|
173
|
+
// 惰性构建:内核自己也记着可见性,晚于 setVisible(true) 建 DOM 时会自动补一次
|
|
174
|
+
// /status(否则面板会停在空壳上)。
|
|
175
|
+
container = surface.build()
|
|
206
176
|
container.style.position = 'fixed'
|
|
207
177
|
applyChrome(true)
|
|
208
178
|
document.body.append(container)
|
|
209
179
|
syncRect()
|
|
210
180
|
}
|
|
211
181
|
|
|
212
|
-
const showError = (message: string): void => {
|
|
213
|
-
if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
|
|
214
|
-
iframe.hidden = true
|
|
215
|
-
errorArea.hidden = false
|
|
216
|
-
errorArea.textContent = ''
|
|
217
|
-
const p = document.createElement('div')
|
|
218
|
-
p.textContent = 'TiddlyWiki 服务不可用'
|
|
219
|
-
const code = document.createElement('code')
|
|
220
|
-
code.textContent = message
|
|
221
|
-
const retry = document.createElement('button')
|
|
222
|
-
retry.type = 'button'
|
|
223
|
-
retry.textContent = '重试'
|
|
224
|
-
retry.addEventListener('click', () => {
|
|
225
|
-
retry.disabled = true
|
|
226
|
-
retry.textContent = '重启中…'
|
|
227
|
-
void requestRestart().finally(() => { void doRefresh() })
|
|
228
|
-
})
|
|
229
|
-
errorArea.append(p, code, retry)
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const showStarting = (): void => {
|
|
233
|
-
if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
|
|
234
|
-
iframe.hidden = true
|
|
235
|
-
errorArea.hidden = false
|
|
236
|
-
errorArea.textContent = ''
|
|
237
|
-
const p = document.createElement('div')
|
|
238
|
-
p.textContent = 'TiddlyWiki 服务正在启动…'
|
|
239
|
-
errorArea.append(p)
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const showFrame = (url: string): void => {
|
|
243
|
-
if (iframe === undefined || errorArea === undefined) return
|
|
244
|
-
errorArea.hidden = true
|
|
245
|
-
// 只有面板打开时才显示 iframe(对照 tw-frame.ts 的 `frame.hidden = !visible`):
|
|
246
|
-
// doRefresh() 在面板关闭后仍可能在途,无条件 hidden=false 会让已关闭的面板
|
|
247
|
-
// 被一个迟到的 /status 响应重新显示出来。
|
|
248
|
-
iframe.hidden = !state.isOpen()
|
|
249
|
-
// Set the src only when the url actually changed, so an editor in the
|
|
250
|
-
// iframe never loses unsaved state on a status refresh.
|
|
251
|
-
if (iframe.dataset.loaded !== url) {
|
|
252
|
-
iframe.dataset.loaded = url
|
|
253
|
-
frameLoaded = false
|
|
254
|
-
iframe.src = url
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Apply a pending tiddler-hash navigation once the iframe document is ready.
|
|
260
|
-
* Preferred: same-origin `contentWindow.location.hash` — a hashchange INSIDE
|
|
261
|
-
* the frame (no reload), TW's story handler opens the tiddler. Fallback: a
|
|
262
|
-
* full `iframe.src` reload with the hash (TW auto-saves drafts, acceptable).
|
|
263
|
-
*
|
|
264
|
-
* TW registers its hashchange listener in a STARTUP module that runs after
|
|
265
|
-
* the iframe's `load` event, so setting `location.hash` right on load is a
|
|
266
|
-
* no-op (hash lost → TW stays on its home page). We therefore wait for TW to
|
|
267
|
-
* become ready inside the frame (the `$tw` global appears once boot settles)
|
|
268
|
-
* before setting the hash; if it never does (service down / slow boot) we
|
|
269
|
-
* fall back to a full reload with the hash, which TW processes at startup.
|
|
270
|
-
* A newer open request supersedes an in-flight wait.
|
|
271
|
-
*/
|
|
272
|
-
const applyPendingHash = (): void => {
|
|
273
|
-
if (pendingHash === null || iframe === undefined || !frameLoaded) return
|
|
274
|
-
const hash = pendingHash
|
|
275
|
-
const win = iframe.contentWindow
|
|
276
|
-
if (win === null) {
|
|
277
|
-
fallbackLoad(hash)
|
|
278
|
-
return
|
|
279
|
-
}
|
|
280
|
-
const tryOnce = (attempt: number): void => {
|
|
281
|
-
if (disposed) return // unmounted: do not keep waiting or touch the iframe
|
|
282
|
-
if (pendingHash !== hash) return // superseded by a newer request
|
|
283
|
-
const frameTw = win as { $tw?: unknown }
|
|
284
|
-
if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
|
|
285
|
-
if (attempt < 40) {
|
|
286
|
-
// Track the retry timer so dispose() can cancel it (v0.19.1): an
|
|
287
|
-
// untracked 40×150ms chain kept the closure (and the iframe) alive up
|
|
288
|
-
// to ~6s after hot-reload/unmount.
|
|
289
|
-
const timer = window.setTimeout(() => {
|
|
290
|
-
hashWaitTimers.delete(timer)
|
|
291
|
-
tryOnce(attempt + 1)
|
|
292
|
-
}, 150)
|
|
293
|
-
hashWaitTimers.add(timer)
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
fallbackLoad(hash)
|
|
297
|
-
return
|
|
298
|
-
}
|
|
299
|
-
pendingHash = null
|
|
300
|
-
try {
|
|
301
|
-
if (win.location.hash === hash) return
|
|
302
|
-
win.location.hash = hash
|
|
303
|
-
} catch {
|
|
304
|
-
fallbackLoad(hash)
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
tryOnce(0)
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const fallbackLoad = (hash: string): void => {
|
|
311
|
-
if (disposed || iframe === undefined) return // never drive a detached frame
|
|
312
|
-
if (pendingHash === hash) pendingHash = null
|
|
313
|
-
const base = iframe.src.split('#')[0]
|
|
314
|
-
if (iframe.src !== `${base}${hash}`) iframe.src = `${base}${hash}`
|
|
315
|
-
}
|
|
316
|
-
|
|
317
182
|
const onOpenTiddler = (event: Event): void => {
|
|
318
183
|
const detail = (event as CustomEvent).detail as { title?: unknown } | undefined
|
|
319
184
|
const title = typeof detail?.title === 'string' && detail.title.length > 0 ? detail.title : ''
|
|
320
185
|
if (title.length === 0) return
|
|
321
|
-
// 侧边栏(rightbar)的 TW tab
|
|
322
|
-
//
|
|
323
|
-
// dsh-panel-activate 协议保证。
|
|
186
|
+
// 侧边栏(rightbar)的 TW tab 可见时,链接直接在那里打开(与聊天并排);
|
|
187
|
+
// 否则退回中央面板。互斥由 tw-frame.ts 共享的 dsh-panel-activate 协议保证。
|
|
324
188
|
if (openTiddlerInLiveTab(title)) return
|
|
325
|
-
|
|
189
|
+
// 顺序有意义:applyActive 由 openPanel() 同步触发,先把内核切到可见,
|
|
190
|
+
// openTiddler() 才会被内核接受(否则内核以 visible=false 拒绝,链接丢失)。
|
|
326
191
|
state.openPanel()
|
|
327
|
-
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const doRefresh = async (): Promise<void> => {
|
|
331
|
-
if (refreshTimer !== undefined) {
|
|
332
|
-
window.clearTimeout(refreshTimer)
|
|
333
|
-
refreshTimer = undefined
|
|
334
|
-
}
|
|
335
|
-
const payload = await fetchStatus()
|
|
336
|
-
if (disposed) return // unmounted while fetching: stop, don't touch DOM
|
|
337
|
-
if (payload === null) {
|
|
338
|
-
showError('无法访问 /dsh-tiddlywiki/status')
|
|
339
|
-
return
|
|
340
|
-
}
|
|
341
|
-
if (payload.status === 'running') {
|
|
342
|
-
refreshAttempts = 0
|
|
343
|
-
// Keep the embedded TW's theme adaption in step with the settings page.
|
|
344
|
-
setThemeSyncConfig({
|
|
345
|
-
enabled: payload.ui?.followDshTheme !== false,
|
|
346
|
-
darkPalette: payload.ui?.darkPalette,
|
|
347
|
-
})
|
|
348
|
-
// Same-origin proxy URL: build from the page's own origin so it works no
|
|
349
|
-
// matter which host/domain the user reached DSH on. Fall back to the
|
|
350
|
-
// legacy loopback `url` for older servers that do not send twProxy.
|
|
351
|
-
if (typeof payload.twProxy === 'string') {
|
|
352
|
-
showFrame(new URL(payload.twProxy, window.location.origin).href)
|
|
353
|
-
} else if (typeof payload.url === 'string') {
|
|
354
|
-
showFrame(payload.url)
|
|
355
|
-
} else {
|
|
356
|
-
showError('服务未返回编辑器地址')
|
|
357
|
-
}
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
if (payload.status === 'starting') {
|
|
361
|
-
showStarting()
|
|
362
|
-
// Auto-poll while starting (bounded).
|
|
363
|
-
if (refreshAttempts < 30) {
|
|
364
|
-
refreshAttempts++
|
|
365
|
-
refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
|
|
366
|
-
}
|
|
367
|
-
return
|
|
368
|
-
}
|
|
369
|
-
refreshAttempts = 0
|
|
370
|
-
showError(payload.error ?? `服务状态:${payload.status}`)
|
|
192
|
+
surface.openTiddler(title)
|
|
371
193
|
}
|
|
372
194
|
|
|
373
195
|
const applyActive = (): void => {
|
|
@@ -378,15 +200,12 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
378
200
|
// syncRect() 现在只在打开时测量(关闭状态早退),所以打开这一帧必须主动
|
|
379
201
|
// 请求一次布局:否则容器要等到下一个 2s interval 才有 left/top/width/height。
|
|
380
202
|
scheduleLayout()
|
|
381
|
-
void doRefresh()
|
|
382
203
|
} else {
|
|
383
204
|
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
384
|
-
if (refreshTimer !== undefined) {
|
|
385
|
-
window.clearTimeout(refreshTimer)
|
|
386
|
-
refreshTimer = undefined
|
|
387
|
-
}
|
|
388
|
-
refreshAttempts = 0
|
|
389
205
|
}
|
|
206
|
+
// 打开 = 首次加载 + 每次重探 /status;关闭 = 隐藏 frame 并停掉有界轮询
|
|
207
|
+
// (下次打开重新给满预算)。两种语义都在内核里。
|
|
208
|
+
surface.setVisible(state.isOpen())
|
|
390
209
|
}
|
|
391
210
|
const onOtherActivate = (event: Event): void => {
|
|
392
211
|
const detail = (event as CustomEvent).detail
|
|
@@ -436,22 +255,13 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
436
255
|
ensure()
|
|
437
256
|
applyActive()
|
|
438
257
|
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
// `!iframe.hidden` 在 showFrame 修正后仍然成立:iframe 只在「面板打开 + 服务
|
|
443
|
-
// running」时被显示,因此 !hidden ⇒ 面板开着且已加载真实 twProxy 地址(也避免
|
|
444
|
-
// 对尚未设置 src 的 iframe 赋值空串把 DSH 页面载进自身)。
|
|
445
|
-
if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
|
|
446
|
-
}
|
|
447
|
-
document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
258
|
+
// 链接路由入口(回复流工具卡 / 文档点击拦截器派发,见 `openTiddler()`)。
|
|
259
|
+
// FAB 的「重载面板」事件由内核自己挂监听(含「只重载真的载入过 TW 的 frame」
|
|
260
|
+
// 这条判据),panel 不再参与。
|
|
448
261
|
document.addEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
|
|
449
262
|
|
|
450
263
|
return () => {
|
|
451
264
|
disposed = true
|
|
452
|
-
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
|
453
|
-
for (const timer of hashWaitTimers) window.clearTimeout(timer)
|
|
454
|
-
hashWaitTimers.clear()
|
|
455
265
|
if (layoutRaf !== undefined) window.cancelAnimationFrame(layoutRaf)
|
|
456
266
|
layoutRaf = undefined
|
|
457
267
|
window.clearInterval(syncInterval)
|
|
@@ -460,12 +270,11 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
460
270
|
resizeObserver.disconnect()
|
|
461
271
|
document.removeEventListener('click', onClickSidebarRow, true)
|
|
462
272
|
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
463
|
-
document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
464
273
|
document.removeEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
|
|
465
274
|
waitObserver.disconnect()
|
|
466
275
|
unsubscribe()
|
|
467
|
-
themeSyncDispose?.()
|
|
468
276
|
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
277
|
+
surface.dispose()
|
|
469
278
|
container?.remove()
|
|
470
279
|
}
|
|
471
280
|
}
|
|
@@ -38,13 +38,21 @@ export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactEl
|
|
|
38
38
|
const wrapRef = React.useRef<HTMLDivElement | null>(null)
|
|
39
39
|
const btnRef = React.useRef<HTMLButtonElement | null>(null)
|
|
40
40
|
React.useEffect(() => {
|
|
41
|
+
// alive 守卫(v0.22.3):卸载后不再 setState;fetchUiConfig 带 TTL 缓存但
|
|
42
|
+
// 首次调用会真的发请求,回调可能在组件卸载之后才 resolve。
|
|
43
|
+
let alive = true
|
|
41
44
|
const onState = (event: Event): void => {
|
|
42
45
|
const detail = (event as CustomEvent<{ open?: boolean }>).detail
|
|
43
46
|
setOpen(detail?.open === true)
|
|
44
47
|
}
|
|
45
48
|
window.addEventListener(NOTE_STATE_EVENT, onState)
|
|
46
|
-
void fetchUiConfig().then((cfg) =>
|
|
47
|
-
|
|
49
|
+
void fetchUiConfig().then((cfg) => {
|
|
50
|
+
if (alive) setMode(cfg.quickNoteMode)
|
|
51
|
+
})
|
|
52
|
+
return () => {
|
|
53
|
+
alive = false
|
|
54
|
+
window.removeEventListener(NOTE_STATE_EVENT, onState)
|
|
55
|
+
}
|
|
48
56
|
}, [])
|
|
49
57
|
React.useLayoutEffect(() => {
|
|
50
58
|
const wrap = wrapRef.current
|
|
@@ -112,7 +112,8 @@ export function TwRightbarTabBody(props: RightbarTabBodyProps): React.ReactEleme
|
|
|
112
112
|
return () => document.removeEventListener(ACTIVATE_EVENT, onActivate)
|
|
113
113
|
}, [visible, close])
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
// 不再挂 `data-dsh-tw-rightbar`(v0.22.3):没有任何选择器/代码消费它。
|
|
116
|
+
return React.createElement('div', { ref: hostRef, className: 'dsh-tw-rightbar-tab' })
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
/** The tab definition (stage 1): a page type opened by kind, with a guide entry box. */
|
|
@@ -95,6 +95,13 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
95
95
|
const [summaryTitle, setSummaryTitle] = React.useState<string | null>(null)
|
|
96
96
|
const [error, setError] = React.useState('')
|
|
97
97
|
const genRef = React.useRef(0)
|
|
98
|
+
/**
|
|
99
|
+
* 卸载守卫(v0.22.3):genRef 只能识别「被更新的一次 generate 取代」,覆盖不到
|
|
100
|
+
* 卸载——组件卸载后 genRef 不再变化,在途的 fetch 一 resolve 就会 setState,并
|
|
101
|
+
* 在「条目缺失」分支里再递归触发一轮请求(clearInterval 挡不住已在途的那次)。
|
|
102
|
+
* 置 false 的效果排在同组件其它 effect 之前执行,所以清理顺序不影响它。
|
|
103
|
+
*/
|
|
104
|
+
const mountedRef = React.useRef(true)
|
|
98
105
|
/**
|
|
99
106
|
* 统一「连续失败计数」:任何一次 generate() 失败(HTTP 失败 / 缺标题 / 渲染
|
|
100
107
|
* 服务不可用 / 条目缺失 / 抛错)都递增,任何一次成功归零。达到 MAX_MISSES 后
|
|
@@ -105,6 +112,8 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
105
112
|
|
|
106
113
|
const generate = React.useCallback(async (): Promise<void> => {
|
|
107
114
|
const gen = ++genRef.current
|
|
115
|
+
// 卸载后不再发起新一轮(含「条目缺失 → 自动重建」的递归调用)。
|
|
116
|
+
if (!mountedRef.current) return
|
|
108
117
|
setPhase('loading')
|
|
109
118
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
110
119
|
setPhase('error')
|
|
@@ -125,7 +134,7 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
125
134
|
signal: AbortSignal.timeout(25_000),
|
|
126
135
|
})
|
|
127
136
|
const data = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
|
|
128
|
-
if (gen !== genRef.current) return
|
|
137
|
+
if (!mountedRef.current || gen !== genRef.current) return
|
|
129
138
|
if (!res.ok || data?.ok !== true) {
|
|
130
139
|
failuresRef.current++
|
|
131
140
|
setPhase('error')
|
|
@@ -142,12 +151,12 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
142
151
|
// 原生渲染:wikitext → HTML 片段(块解析,标题/表格/列表/引用齐全,
|
|
143
152
|
// 链接已重写为 /dsh-tiddlywiki/tw/#标题,点击由全局拦截器接管)。
|
|
144
153
|
const fragment = await fetchRenderFragment(data.title)
|
|
145
|
-
if (gen !== genRef.current) return
|
|
154
|
+
if (!mountedRef.current || gen !== genRef.current) return
|
|
146
155
|
if (fragment === null) {
|
|
147
156
|
// 区分「条目被清(TW 重启把 $:/temp 冲掉了)」与「渲染服务不可用」:
|
|
148
157
|
// 前者自动重建(受 MAX_MISSES 约束,有界),后者直接报错交还手动重试。
|
|
149
158
|
const exists = await tiddlerExists(data.title)
|
|
150
|
-
if (gen !== genRef.current) return
|
|
159
|
+
if (!mountedRef.current || gen !== genRef.current) return
|
|
151
160
|
if (!exists) {
|
|
152
161
|
failuresRef.current++
|
|
153
162
|
if (failuresRef.current < MAX_MISSES) {
|
|
@@ -167,7 +176,7 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
167
176
|
setHtml(fragment)
|
|
168
177
|
setPhase('ready')
|
|
169
178
|
} catch (err) {
|
|
170
|
-
if (gen !== genRef.current) return
|
|
179
|
+
if (!mountedRef.current || gen !== genRef.current) return
|
|
171
180
|
failuresRef.current++
|
|
172
181
|
setPhase('error')
|
|
173
182
|
setError(err instanceof Error ? err.message : String(err))
|
|
@@ -175,8 +184,13 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
175
184
|
}, [sessionId])
|
|
176
185
|
|
|
177
186
|
// 进入 tab 自动触发生成;切走再切回会重新挂载 → 再次生成(总是最新)。
|
|
187
|
+
// 卸载时置 mountedRef=false:在途请求 resolve 后不再 setState / 不再递归重建。
|
|
178
188
|
React.useEffect(() => {
|
|
189
|
+
mountedRef.current = true
|
|
179
190
|
void generate()
|
|
191
|
+
return () => {
|
|
192
|
+
mountedRef.current = false
|
|
193
|
+
}
|
|
180
194
|
}, [generate])
|
|
181
195
|
|
|
182
196
|
// 自愈:汇总条目是 volatile 的 `$:/temp`,TW 重启即消失;若已就绪但条目
|
|
@@ -49,7 +49,7 @@ interface CatalogEntry {
|
|
|
49
49
|
interface AdminState {
|
|
50
50
|
ok?: boolean
|
|
51
51
|
server?: { status?: string; url?: string; wikiPath?: string; error?: string }
|
|
52
|
-
info?: { plugins?: string[]; themes?: string[]; languages?: string[] }
|
|
52
|
+
info?: { plugins?: string[]; themes?: string[]; languages?: string[]; themeActive?: string }
|
|
53
53
|
catalog?: { plugins?: CatalogEntry[]; themes?: CatalogEntry[]; languages?: CatalogEntry[] }
|
|
54
54
|
config?: Record<string, unknown>
|
|
55
55
|
git?: { exists?: boolean; branch?: string; dirty?: boolean; lastCommit?: string; remote?: string } | null
|
|
@@ -101,7 +101,6 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
|
|
101
101
|
/** Form controls registry for the config section (changed-only patch). */
|
|
102
102
|
interface ConfigField {
|
|
103
103
|
key: string
|
|
104
|
-
input: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
|
105
104
|
initial: string | boolean | number
|
|
106
105
|
read: () => string | boolean | number
|
|
107
106
|
changed: () => boolean
|
|
@@ -224,7 +223,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
224
223
|
input.value = initial
|
|
225
224
|
const wrap = make('label', 'dsh-tw-settings-field')
|
|
226
225
|
wrap.append(make('span', 'dsh-tw-settings-label', label), input)
|
|
227
|
-
fields.push({ key,
|
|
226
|
+
fields.push({ key, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
|
|
228
227
|
section.append(wrap)
|
|
229
228
|
}
|
|
230
229
|
const checkField = (key: string, label: string, initial: boolean): void => {
|
|
@@ -233,7 +232,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
233
232
|
input.checked = initial
|
|
234
233
|
const wrap = make('label', 'dsh-tw-settings-field dsh-tw-settings-field-check')
|
|
235
234
|
wrap.append(input, make('span', 'dsh-tw-settings-label', label))
|
|
236
|
-
fields.push({ key,
|
|
235
|
+
fields.push({ key, initial, read: () => input.checked, changed: () => input.checked !== initial })
|
|
237
236
|
section.append(wrap)
|
|
238
237
|
}
|
|
239
238
|
const numField = (key: string, label: string, initial: number): void => {
|
|
@@ -250,7 +249,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
250
249
|
}
|
|
251
250
|
const wrap = make('label', 'dsh-tw-settings-field')
|
|
252
251
|
wrap.append(make('span', 'dsh-tw-settings-label', label), input)
|
|
253
|
-
fields.push({ key,
|
|
252
|
+
fields.push({ key, initial, read, changed: () => read() !== initial })
|
|
254
253
|
section.append(wrap)
|
|
255
254
|
}
|
|
256
255
|
const selectField = (key: string, label: string, initial: string, options: Array<{ value: string; label: string }>): void => {
|
|
@@ -264,7 +263,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
264
263
|
}
|
|
265
264
|
const wrap = make('label', 'dsh-tw-settings-field')
|
|
266
265
|
wrap.append(make('span', 'dsh-tw-settings-label', label), select)
|
|
267
|
-
fields.push({ key,
|
|
266
|
+
fields.push({ key, initial, read: () => select.value, changed: () => select.value !== initial })
|
|
268
267
|
section.append(wrap)
|
|
269
268
|
}
|
|
270
269
|
/**
|
|
@@ -279,7 +278,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
279
278
|
input.value = initial
|
|
280
279
|
const wrap = make('label', 'dsh-tw-settings-field dsh-tw-settings-field-area')
|
|
281
280
|
wrap.append(make('span', 'dsh-tw-settings-label', label), input)
|
|
282
|
-
fields.push({ key,
|
|
281
|
+
fields.push({ key, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
|
|
283
282
|
section.append(wrap)
|
|
284
283
|
return input
|
|
285
284
|
}
|
|
@@ -477,7 +476,18 @@ function renderCatalogSection(
|
|
|
477
476
|
make('span', 'dsh-tw-settings-name', '主题'),
|
|
478
477
|
)
|
|
479
478
|
const themeList = info?.themes ?? []
|
|
480
|
-
|
|
479
|
+
// The ACTIVE theme comes from the host (`$:/theme`, v0.22.3). Deriving it from
|
|
480
|
+
// the last loaded entry was a guess: with an explicitly activated non-last
|
|
481
|
+
// theme the wrong radio showed up checked, and one click on 应用主题 (without
|
|
482
|
+
// touching the radios) rewrote `$:/theme` to that wrong pick. Guessing stays
|
|
483
|
+
// as the fallback only when the host cannot tell (wiki down / no `$:/theme`).
|
|
484
|
+
const activeFromServer = info?.themeActive
|
|
485
|
+
let activeThemeName =
|
|
486
|
+
typeof activeFromServer === 'string' && themes.some((theme) => theme.name === activeFromServer)
|
|
487
|
+
? activeFromServer
|
|
488
|
+
: themeList.length > 0
|
|
489
|
+
? themeList[themeList.length - 1]
|
|
490
|
+
: 'tiddlywiki/vanilla'
|
|
481
491
|
const themeWrap = make('div', 'dsh-tw-settings-list')
|
|
482
492
|
for (const theme of themes) {
|
|
483
493
|
const load = make('input', 'dsh-tw-settings-check')
|
package/src/client/theme-sync.ts
CHANGED
|
@@ -224,18 +224,33 @@ function syncColorScheme($tw: NonNullable<TwRuntime['$tw']>, frame: HTMLIFrameEl
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Apply the current DSH theme to one TW iframe (no-op until TW is ready).
|
|
229
|
+
*
|
|
230
|
+
* Every path in here is best-effort and MUST NOT throw: `setThemeSyncConfig()`
|
|
231
|
+
* calls the registered appliers synchronously from inside `doRefresh()`'s
|
|
232
|
+
* `await` chain, so one exception becomes an unhandled rejection (and skips the
|
|
233
|
+
* rest of that refresh) for every caller — including the two surfaces that
|
|
234
|
+
* mount this on the frame's `load` event. The access below is the sharp edge:
|
|
235
|
+
* as soon as the frame navigates somewhere cross-origin (a link inside the
|
|
236
|
+
* embedded TW), reading `contentWindow.$tw` itself raises SecurityError — the
|
|
237
|
+
* inner helpers' try/catch blocks never get a chance to run.
|
|
238
|
+
*/
|
|
228
239
|
function applyToFrame(frame: HTMLIFrameElement): void {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
240
|
+
try {
|
|
241
|
+
const $tw = (frame.contentWindow as TwRuntime | null)?.$tw
|
|
242
|
+
if ($tw === undefined || $tw.wiki === undefined) return
|
|
243
|
+
installPaletteSaveGuard($tw)
|
|
244
|
+
if (syncEnabled) {
|
|
245
|
+
applyPalette($tw, readDshDark())
|
|
246
|
+
} else {
|
|
247
|
+
// Feature off → undo any palette we forced (leave the user's own choice).
|
|
248
|
+
applyPalette($tw, false)
|
|
249
|
+
}
|
|
250
|
+
syncColorScheme($tw, frame)
|
|
251
|
+
} catch {
|
|
252
|
+
// Cross-origin frame, TW not booted, detached frame: the next pass retries.
|
|
237
253
|
}
|
|
238
|
-
syncColorScheme($tw, frame)
|
|
239
254
|
}
|
|
240
255
|
|
|
241
256
|
/**
|
package/src/client/tool-views.ts
CHANGED
|
@@ -227,7 +227,9 @@ function ToolCardShell(props: {
|
|
|
227
227
|
? React.createElement(
|
|
228
228
|
'span',
|
|
229
229
|
{ className: 'dsh-tw-toolcard-tags' },
|
|
230
|
-
|
|
230
|
+
// index in the key: a tiddler can carry the same tag twice (TW does
|
|
231
|
+
// not dedup), and duplicate keys make React drop/reshuffle chips.
|
|
232
|
+
...props.tags.map((tag, index) => React.createElement('span', { className: 'dsh-tw-toolcard-tag', key: `${index}-${tag}` }, tag)),
|
|
231
233
|
)
|
|
232
234
|
: null,
|
|
233
235
|
)
|