dsh-tiddlywiki 0.22.3 → 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.3",
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 && node scripts/verify-frame-guards.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",
@@ -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,10 +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
- // 事件名单一来源:两个协议常量与 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'
31
+ // 事件名与 frame 生命周期助手都只有一份,住在 tw-frame.ts(内核)。
32
+ import { ACTIVATE_EVENT, createTwFrameSurface, openTiddlerInLiveTab, type TwFrameSkin } from './tw-frame.ts'
29
33
 
30
34
  /**
31
35
  * Center-column targets, most-specific shell generation first. The official
@@ -58,7 +62,20 @@ const APP_OVERLAY_Z_INDEX = 20
58
62
  /** Safety re-measure cadence for shell layout changes CSS can't see. */
59
63
  const SYNC_INTERVAL_MS = 2_000
60
64
 
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
@@ -102,53 +119,13 @@ function conversationColumn(): HTMLElement | undefined {
102
119
  export function mountPanel(state: PanelState): () => void {
103
120
  let container: HTMLDivElement | undefined
104
121
  let columnEl: HTMLElement | undefined
105
- let iframe: HTMLIFrameElement | undefined
106
- let frameArea: HTMLDivElement | undefined
107
- let errorArea: HTMLDivElement | undefined
108
- let refreshTimer: number | undefined
109
- /** In-flight hash-readiness retry timers (cancelled by dispose, v0.19.1). */
110
- const hashWaitTimers = new Set<number>()
111
- let refreshAttempts = 0
112
- let themeSyncDispose: (() => void) | undefined
113
- /** true once the iframe's document finished loading (hash navigation target ready). */
114
- let frameLoaded = false
115
- /** A tiddler-hash open request waiting for the frame to become ready. */
116
- let pendingHash: string | null = null
117
122
  /** Set by the disposer: no timers/observers may touch DOM or the iframe after. */
118
123
  let disposed = false
119
124
  /** Last observed presence of the better-sidebar host layer (applyChrome cache). */
120
125
  let lastHasHost: boolean | undefined
121
126
 
122
- const build = (): HTMLDivElement => {
123
- const view = document.createElement('div')
124
- view.dataset.dshTwView = ''
125
- view.className = 'dsh-tw-view'
126
-
127
- frameArea = document.createElement('div')
128
- frameArea.className = 'dsh-tw-panel-frame-wrap'
129
- frameArea.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'
130
- iframe = document.createElement('iframe')
131
- iframe.className = 'dsh-tw-panel-frame'
132
- iframe.title = 'TiddlyWiki'
133
- iframe.hidden = true
134
- frameArea.append(iframe)
135
- // Track load so a pending tiddler-hash navigation can target a ready
136
- // document (setting contentWindow.location.hash before load is a no-op).
137
- iframe.addEventListener('load', () => {
138
- frameLoaded = true
139
- applyPendingHash()
140
- })
141
- // Embedded TW follows the DSH light/dark theme (non-persisting palette
142
- // swap inside the same-origin iframe; re-applied on load + theme change).
143
- themeSyncDispose = attachThemeSync(iframe)
144
-
145
- errorArea = document.createElement('div')
146
- errorArea.className = 'dsh-tw-panel-error'
147
- errorArea.hidden = true
148
-
149
- view.append(frameArea, errorArea)
150
- return view
151
- }
127
+ /** iframe + /status lifecycle (shared with the rightbar tab, tw-frame.ts). */
128
+ const surface = createTwFrameSurface(PANEL_SKIN)
152
129
 
153
130
  /** Pin the overlay to the center column's current viewport rect. */
154
131
  const syncRect = (): void => {
@@ -193,176 +170,26 @@ export function mountPanel(state: PanelState): () => void {
193
170
  if (columnEl === undefined || !columnEl.isConnected) columnEl = conversationColumn()
194
171
  if (container !== undefined) return
195
172
  if (columnEl === undefined) return
196
- container = build()
173
+ // 惰性构建:内核自己也记着可见性,晚于 setVisible(true) 建 DOM 时会自动补一次
174
+ // /status(否则面板会停在空壳上)。
175
+ container = surface.build()
197
176
  container.style.position = 'fixed'
198
177
  applyChrome(true)
199
178
  document.body.append(container)
200
179
  syncRect()
201
180
  }
202
181
 
203
- const showError = (message: string): void => {
204
- if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
205
- iframe.hidden = true
206
- errorArea.hidden = false
207
- errorArea.textContent = ''
208
- const p = document.createElement('div')
209
- p.textContent = 'TiddlyWiki 服务不可用'
210
- const code = document.createElement('code')
211
- code.textContent = message
212
- const retry = document.createElement('button')
213
- retry.type = 'button'
214
- retry.textContent = '重试'
215
- retry.addEventListener('click', () => {
216
- retry.disabled = true
217
- retry.textContent = '重启中…'
218
- void requestRestart().finally(() => { void doRefresh() })
219
- })
220
- errorArea.append(p, code, retry)
221
- }
222
-
223
- const showStarting = (): void => {
224
- if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
225
- iframe.hidden = true
226
- errorArea.hidden = false
227
- errorArea.textContent = ''
228
- const p = document.createElement('div')
229
- p.textContent = 'TiddlyWiki 服务正在启动…'
230
- errorArea.append(p)
231
- }
232
-
233
- const showFrame = (url: string): void => {
234
- if (iframe === undefined || errorArea === undefined) return
235
- errorArea.hidden = true
236
- // 只有面板打开时才显示 iframe(对照 tw-frame.ts 的 `frame.hidden = !visible`):
237
- // doRefresh() 在面板关闭后仍可能在途,无条件 hidden=false 会让已关闭的面板
238
- // 被一个迟到的 /status 响应重新显示出来。
239
- iframe.hidden = !state.isOpen()
240
- // Set the src only when the url actually changed, so an editor in the
241
- // iframe never loses unsaved state on a status refresh.
242
- if (iframe.dataset.loaded !== url) {
243
- iframe.dataset.loaded = url
244
- frameLoaded = false
245
- iframe.src = url
246
- }
247
- }
248
-
249
- /**
250
- * Apply a pending tiddler-hash navigation once the iframe document is ready.
251
- * Preferred: same-origin `contentWindow.location.hash` — a hashchange INSIDE
252
- * the frame (no reload), TW's story handler opens the tiddler. Fallback: a
253
- * full `iframe.src` reload with the hash (TW auto-saves drafts, acceptable).
254
- *
255
- * TW registers its hashchange listener in a STARTUP module that runs after
256
- * the iframe's `load` event, so setting `location.hash` right on load is a
257
- * no-op (hash lost → TW stays on its home page). We therefore wait for TW to
258
- * become ready inside the frame (the `$tw` global appears once boot settles)
259
- * before setting the hash; if it never does (service down / slow boot) we
260
- * fall back to a full reload with the hash, which TW processes at startup.
261
- * A newer open request supersedes an in-flight wait.
262
- */
263
- const applyPendingHash = (): void => {
264
- if (pendingHash === null || iframe === undefined || !frameLoaded) return
265
- const hash = pendingHash
266
- const win = iframe.contentWindow
267
- if (win === null) {
268
- fallbackLoad(hash)
269
- return
270
- }
271
- const tryOnce = (attempt: number): void => {
272
- if (disposed) return // unmounted: do not keep waiting or touch the iframe
273
- if (pendingHash !== hash) return // superseded by a newer request
274
- const frameTw = win as { $tw?: unknown }
275
- if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
276
- if (attempt < 40) {
277
- // Track the retry timer so dispose() can cancel it (v0.19.1): an
278
- // untracked 40×150ms chain kept the closure (and the iframe) alive up
279
- // to ~6s after hot-reload/unmount.
280
- const timer = window.setTimeout(() => {
281
- hashWaitTimers.delete(timer)
282
- tryOnce(attempt + 1)
283
- }, 150)
284
- hashWaitTimers.add(timer)
285
- return
286
- }
287
- fallbackLoad(hash)
288
- return
289
- }
290
- pendingHash = null
291
- try {
292
- if (win.location.hash === hash) return
293
- win.location.hash = hash
294
- } catch {
295
- fallbackLoad(hash)
296
- }
297
- }
298
- tryOnce(0)
299
- }
300
-
301
- const fallbackLoad = (hash: string): void => {
302
- if (disposed || iframe === undefined) return // never drive a detached frame
303
- if (pendingHash === hash) pendingHash = null
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
310
- }
311
-
312
182
  const onOpenTiddler = (event: Event): void => {
313
183
  const detail = (event as CustomEvent).detail as { title?: unknown } | undefined
314
184
  const title = typeof detail?.title === 'string' && detail.title.length > 0 ? detail.title : ''
315
185
  if (title.length === 0) return
316
- // 侧边栏(rightbar)的 TW tab 可见时,链接直接在那里
317
- // 打开(与聊天并排);否则退回中央面板。互斥由 tw-frame.ts 共享的
318
- // dsh-panel-activate 协议保证。
186
+ // 侧边栏(rightbar)的 TW tab 可见时,链接直接在那里打开(与聊天并排);
187
+ // 否则退回中央面板。互斥由 tw-frame.ts 共享的 dsh-panel-activate 协议保证。
319
188
  if (openTiddlerInLiveTab(title)) return
320
- pendingHash = `#${encodeURIComponent(title)}`
189
+ // 顺序有意义:applyActive 由 openPanel() 同步触发,先把内核切到可见,
190
+ // openTiddler() 才会被内核接受(否则内核以 visible=false 拒绝,链接丢失)。
321
191
  state.openPanel()
322
- applyPendingHash()
323
- }
324
-
325
- const doRefresh = async (): Promise<void> => {
326
- if (refreshTimer !== undefined) {
327
- window.clearTimeout(refreshTimer)
328
- refreshTimer = undefined
329
- }
330
- const payload = await fetchStatus()
331
- if (disposed) return // unmounted while fetching: stop, don't touch DOM
332
- if (payload === null) {
333
- showError('无法访问 /dsh-tiddlywiki/status')
334
- return
335
- }
336
- if (payload.status === 'running') {
337
- refreshAttempts = 0
338
- // Keep the embedded TW's theme adaption in step with the settings page.
339
- setThemeSyncConfig({
340
- enabled: payload.ui?.followDshTheme !== false,
341
- darkPalette: payload.ui?.darkPalette,
342
- })
343
- // Same-origin proxy URL: build from the page's own origin so it works no
344
- // matter which host/domain the user reached DSH on. Fall back to the
345
- // legacy loopback `url` for older servers that do not send twProxy.
346
- if (typeof payload.twProxy === 'string') {
347
- showFrame(new URL(payload.twProxy, window.location.origin).href)
348
- } else if (typeof payload.url === 'string') {
349
- showFrame(payload.url)
350
- } else {
351
- showError('服务未返回编辑器地址')
352
- }
353
- return
354
- }
355
- if (payload.status === 'starting') {
356
- showStarting()
357
- // Auto-poll while starting (bounded).
358
- if (refreshAttempts < 30) {
359
- refreshAttempts++
360
- refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
361
- }
362
- return
363
- }
364
- refreshAttempts = 0
365
- showError(payload.error ?? `服务状态:${payload.status}`)
192
+ surface.openTiddler(title)
366
193
  }
367
194
 
368
195
  const applyActive = (): void => {
@@ -373,15 +200,12 @@ export function mountPanel(state: PanelState): () => void {
373
200
  // syncRect() 现在只在打开时测量(关闭状态早退),所以打开这一帧必须主动
374
201
  // 请求一次布局:否则容器要等到下一个 2s interval 才有 left/top/width/height。
375
202
  scheduleLayout()
376
- void doRefresh()
377
203
  } else {
378
204
  document.documentElement.removeAttribute(ACTIVE_ATTR)
379
- if (refreshTimer !== undefined) {
380
- window.clearTimeout(refreshTimer)
381
- refreshTimer = undefined
382
- }
383
- refreshAttempts = 0
384
205
  }
206
+ // 打开 = 首次加载 + 每次重探 /status;关闭 = 隐藏 frame 并停掉有界轮询
207
+ // (下次打开重新给满预算)。两种语义都在内核里。
208
+ surface.setVisible(state.isOpen())
385
209
  }
386
210
  const onOtherActivate = (event: Event): void => {
387
211
  const detail = (event as CustomEvent).detail
@@ -431,25 +255,13 @@ export function mountPanel(state: PanelState): () => void {
431
255
  ensure()
432
256
  applyActive()
433
257
 
434
- // The "知识库" FAB's 重载面板 entry dispatches this event to reload the
435
- // iframe (the panel itself no longer owns a floating status/reload button).
436
- const onReloadRequest = (): void => {
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
444
- }
445
- document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
258
+ // 链接路由入口(回复流工具卡 / 文档点击拦截器派发,见 `openTiddler()`)。
259
+ // FAB 的「重载面板」事件由内核自己挂监听(含「只重载真的载入过 TW 的 frame」
260
+ // 这条判据),panel 不再参与。
446
261
  document.addEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
447
262
 
448
263
  return () => {
449
264
  disposed = true
450
- if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
451
- for (const timer of hashWaitTimers) window.clearTimeout(timer)
452
- hashWaitTimers.clear()
453
265
  if (layoutRaf !== undefined) window.cancelAnimationFrame(layoutRaf)
454
266
  layoutRaf = undefined
455
267
  window.clearInterval(syncInterval)
@@ -458,12 +270,11 @@ export function mountPanel(state: PanelState): () => void {
458
270
  resizeObserver.disconnect()
459
271
  document.removeEventListener('click', onClickSidebarRow, true)
460
272
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
461
- document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
462
273
  document.removeEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
463
274
  waitObserver.disconnect()
464
275
  unsubscribe()
465
- themeSyncDispose?.()
466
276
  document.documentElement.removeAttribute(ACTIVE_ATTR)
277
+ surface.dispose()
467
278
  container?.remove()
468
279
  }
469
280
  }