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/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.2",
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, recentBtn, recentWrap }
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
- const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
1071
- toastEl?.remove()
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
  }
@@ -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 panel reads /status first and only
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
- import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
26
- // 事件名单一来源:两个协议常量由 tw-frame.ts 定义(panel/rightbar 共同依赖)。
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
- import { RESTART_ENDPOINT } from './endpoints.ts'
61
- import { fetchStatus } from './status-cache.ts'
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
- const build = (): HTMLDivElement => {
132
- const view = document.createElement('div')
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
- container = build()
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
- // 打开(与聊天并排);否则退回中央面板。互斥由 tw-frame.ts 共享的
323
- // dsh-panel-activate 协议保证。
186
+ // 侧边栏(rightbar)的 TW tab 可见时,链接直接在那里打开(与聊天并排);
187
+ // 否则退回中央面板。互斥由 tw-frame.ts 共享的 dsh-panel-activate 协议保证。
324
188
  if (openTiddlerInLiveTab(title)) return
325
- pendingHash = `#${encodeURIComponent(title)}`
189
+ // 顺序有意义:applyActive 由 openPanel() 同步触发,先把内核切到可见,
190
+ // openTiddler() 才会被内核接受(否则内核以 visible=false 拒绝,链接丢失)。
326
191
  state.openPanel()
327
- applyPendingHash()
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
- // The "知识库" FAB's 重载面板 entry dispatches this event to reload the
440
- // iframe (the panel itself no longer owns a floating status/reload button).
441
- const onReloadRequest = (): void => {
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) => setMode(cfg.quickNoteMode))
47
- return () => window.removeEventListener(NOTE_STATE_EVENT, onState)
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
- return React.createElement('div', { ref: hostRef, className: 'dsh-tw-rightbar-tab', 'data-dsh-tw-rightbar': '' })
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, input, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
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, input, initial, read: () => input.checked, changed: () => input.checked !== initial })
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, input, initial, read, changed: () => read() !== initial })
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, input: select, initial, read: () => select.value, changed: () => select.value !== initial })
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, input, initial, read: () => input.value.trim(), changed: () => input.value.trim() !== initial })
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
- let activeThemeName = themeList.length > 0 ? themeList[themeList.length - 1] : 'tiddlywiki/vanilla'
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')
@@ -224,18 +224,33 @@ function syncColorScheme($tw: NonNullable<TwRuntime['$tw']>, frame: HTMLIFrameEl
224
224
  }
225
225
  }
226
226
 
227
- /** Apply the current DSH theme to one TW iframe (no-op until TW is ready). */
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
- const $tw = (frame.contentWindow as TwRuntime | null)?.$tw
230
- if ($tw === undefined || $tw.wiki === undefined) return
231
- installPaletteSaveGuard($tw)
232
- if (syncEnabled) {
233
- applyPalette($tw, readDshDark())
234
- } else {
235
- // Feature off → undo any palette we forced (leave the user's own choice).
236
- applyPalette($tw, false)
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
  /**
@@ -227,7 +227,9 @@ function ToolCardShell(props: {
227
227
  ? React.createElement(
228
228
  'span',
229
229
  { className: 'dsh-tw-toolcard-tags' },
230
- ...props.tags.map((tag) => React.createElement('span', { className: 'dsh-tw-toolcard-tag', key: tag }, tag)),
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
  )