dsh-tiddlywiki 0.22.2 → 0.22.3

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.3",
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-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
  }
@@ -23,8 +23,9 @@
23
23
  import type { PanelState } from './state.ts'
24
24
  import { ENTRY_SELECTOR } from './sidebar-entry.ts'
25
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'
26
+ // 事件名单一来源:两个协议常量与 frame 生命周期助手都由 tw-frame.ts 提供
27
+ // (panel/rightbar 共同依赖)。requestRestart 也只此一份(v0.22.3 去重)。
28
+ import { ACTIVATE_EVENT, loadableFrameUrl, openTiddlerInLiveTab, PANEL_RELOAD_EVENT, requestRestart } from './tw-frame.ts'
28
29
 
29
30
  /**
30
31
  * Center-column targets, most-specific shell generation first. The official
@@ -57,7 +58,6 @@ const APP_OVERLAY_Z_INDEX = 20
57
58
  /** Safety re-measure cadence for shell layout changes CSS can't see. */
58
59
  const SYNC_INTERVAL_MS = 2_000
59
60
 
60
- import { RESTART_ENDPOINT } from './endpoints.ts'
61
61
  import { fetchStatus } from './status-cache.ts'
62
62
 
63
63
  /**
@@ -99,15 +99,6 @@ function conversationColumn(): HTMLElement | undefined {
99
99
  return undefined
100
100
  }
101
101
 
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
102
  export function mountPanel(state: PanelState): () => void {
112
103
  let container: HTMLDivElement | undefined
113
104
  let columnEl: HTMLElement | undefined
@@ -310,8 +301,12 @@ export function mountPanel(state: PanelState): () => void {
310
301
  const fallbackLoad = (hash: string): void => {
311
302
  if (disposed || iframe === undefined) return // never drive a detached frame
312
303
  if (pendingHash === hash) pendingHash = null
313
- const base = iframe.src.split('#')[0]
314
- if (iframe.src !== `${base}${hash}`) iframe.src = `${base}${hash}`
304
+ // dataset.loaded(showFrame 只写这个)而不是 iframe.src:src 为空时读出来
305
+ // 是**DSH 页面自己的 URL**,赋 `'' + '#标题'` 会把 DSH 载进 iframe(v0.22.3)。
306
+ const base = loadableFrameUrl(iframe.dataset)
307
+ if (base === null) return
308
+ const next = `${base.split('#')[0]}${hash}`
309
+ if (iframe.src !== next) iframe.src = next
315
310
  }
316
311
 
317
312
  const onOpenTiddler = (event: Event): void => {
@@ -439,10 +434,13 @@ export function mountPanel(state: PanelState): () => void {
439
434
  // The "知识库" FAB's 重载面板 entry dispatches this event to reload the
440
435
  // iframe (the panel itself no longer owns a floating status/reload button).
441
436
  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
437
+ // 只有真的载入过 TW 代理地址的 iframe 才允许重载:`!iframe.hidden` 已经不足
438
+ // 以证明这一点(showFrame 之外,iframe 也可能是「面板打开但首个 /status 还在
439
+ // 途」),而 `iframe.src = iframe.src` 在 src 为空串时会把 **DSH 页面**载进
440
+ // iframe(v0.22.3)。判据统一用 dataset.loaded(只由 showFrame 写)。
441
+ if (iframe === undefined) return
442
+ const loaded = loadableFrameUrl(iframe.dataset)
443
+ if (loaded !== null && !iframe.hidden) iframe.src = loaded
446
444
  }
447
445
  document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
448
446
  document.addEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
@@ -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
  )
@@ -42,7 +42,9 @@ export function getTabLabel(): string {
42
42
  return tabLabel
43
43
  }
44
44
 
45
- async function requestRestart(): Promise<boolean> { try {
45
+ /** POST /restart; `false` on any failure. Shared by both TW surfaces (v0.22.3). */
46
+ export async function requestRestart(): Promise<boolean> {
47
+ try {
46
48
  const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
47
49
  return res.ok
48
50
  } catch {
@@ -60,6 +62,30 @@ export function TwTabIcon({ size = 16, className }: { size?: number; className?:
60
62
  )
61
63
  }
62
64
 
65
+ /**
66
+ * The last URL a TW surface actually assigned to its iframe, or null when the
67
+ * frame never loaded one. Both surfaces cache it in `iframe.dataset.loaded`
68
+ * (only `showFrame` writes it), which keeps "is this frame pointing at the TW
69
+ * proxy?" answerable without trusting `src` — see `loadableFrameUrl`.
70
+ */
71
+ export interface FrameLoadedState {
72
+ loaded?: string
73
+ }
74
+
75
+ /**
76
+ * Non-empty `dataset.loaded`, or null when the frame has no real URL yet
77
+ * (v0.22.3). NEVER read `iframe.src` for this: for an iframe whose `src`
78
+ * attribute was never assigned the property returns the EMBEDDING page's URL,
79
+ * and assigning it back to `src` makes the DSH GUI load itself inside the
80
+ * iframe (a second DSH instance: duplicate FAB, duplicated global listeners,
81
+ * blank white page). Both callers below drive a full reload, so they must
82
+ * refuse to touch a frame that has no loaded TW URL.
83
+ */
84
+ export function loadableFrameUrl(dataset: FrameLoadedState): string | null {
85
+ const loaded = dataset.loaded
86
+ return loaded === undefined || loaded.length === 0 ? null : loaded
87
+ }
88
+
63
89
  /** The plain-DOM TW frame controller owned by one surface body. */
64
90
  export interface TwFrameController {
65
91
  /** Reflect the surface's visibility; loads lazily on the first show. */
@@ -236,8 +262,12 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
236
262
  const fallbackLoad = (hash: string): void => {
237
263
  if (disposed) return
238
264
  if (pendingHash === hash) pendingHash = null
239
- const base = frame.src.split('#')[0]
240
- if (frame.src !== `${base}${hash}`) frame.src = `${base}${hash}`
265
+ // Guard against the never-loaded frame: `frame.src` is '' before showFrame
266
+ // ran, and `'' + '#title'` resolves against the DSH page URL (v0.22.3).
267
+ const base = loadableFrameUrl(frame.dataset)
268
+ if (base === null) return
269
+ const next = `${base.split('#')[0]}${hash}`
270
+ if (frame.src !== next) frame.src = next
241
271
  }
242
272
 
243
273
  const doRefresh = async (): Promise<void> => {
@@ -282,7 +312,13 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
282
312
  }
283
313
 
284
314
  const onReloadRequest = (): void => {
285
- if (!frame.hidden) frame.src = frame.src
315
+ // Reload = re-assign the URL the frame is ALREADY showing (full reload, so
316
+ // TW re-reads the wiki; `location.reload()` could be blocked mid-edit).
317
+ // `hidden` alone is not enough (v0.22.3): setVisible(true) reveals the frame
318
+ // before the first /status response sets its src, and `frame.src = frame.src`
319
+ // with `src === ''` loads the DSH page into the iframe.
320
+ const loaded = loadableFrameUrl(frame.dataset)
321
+ if (loaded !== null && !frame.hidden) frame.src = loaded
286
322
  }
287
323
  document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
288
324
 
@@ -295,15 +331,17 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
295
331
  if (!started) {
296
332
  // Nothing loaded yet: stay hidden until the first show kicks the load.
297
333
  frame.hidden = true
298
- view.dataset.visible = next ? '1' : '0'
299
334
  if (next) {
300
335
  started = true
301
336
  void doRefresh()
302
337
  }
303
338
  return
304
339
  }
340
+ // Revealing before the first /status response is harmless as long as
341
+ // every src-writing path checks `dataset.loaded` (onReloadRequest /
342
+ // fallbackLoad), which also keeps the surface visible while a switch to
343
+ // another wiki re-resolves its URL.
305
344
  frame.hidden = !next
306
- view.dataset.visible = next ? '1' : '0'
307
345
  // 每次重新显示都重探一次状态:启动轮询是有界的(30×1.5s),首次打开时
308
346
  // 服务没起来就会把错误界面永久固定(切走再切回也不恢复)。doRefresh 自己
309
347
  // 会清旧 timer,不会重复轮询。
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'