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/README.md +2 -0
- package/lib/client.js +15 -15
- 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 +16 -18
- 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 +44 -6
- 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.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,
|
|
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
|
@@ -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
|
-
//
|
|
27
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
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) =>
|
|
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
|
)
|
package/src/client/tw-frame.ts
CHANGED
|
@@ -42,7 +42,9 @@ export function getTabLabel(): string {
|
|
|
42
42
|
return tabLabel
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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'
|