dsh-tiddlywiki 0.28.13 → 0.29.0
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/docs/CHANGELOG.md +4 -0
- package/docs/development.md +41 -18
- package/docs/features.md +3 -3
- package/docs/seed-initialization.md +4 -3
- package/docs/usage.md +3 -2
- package/docs/wechat-publish-setup.md +1 -1
- package/lib/client.js +25 -15
- package/lib/index.js +249 -52
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/knowledge-fab.ts +46 -8
- package/src/client/note-widget.ts +83 -23
- package/src/client/panel.ts +1 -0
- package/src/client/rightbar-tab.ts +5 -0
- package/src/client/session-summary.ts +19 -1
- package/src/client/settings-page-config.ts +9 -0
- package/src/client/settings-page-runtime.ts +1 -6
- package/src/client/settings-page.ts +39 -7
- package/src/client/sidebar-entry.ts +33 -9
- package/src/client/styles.ts +10 -0
- package/src/client/tool-views.ts +5 -2
- package/src/client/tw-frame.ts +109 -7
- package/src/client/wiki-scope.ts +33 -8
- package/src/host/admin-routes.ts +45 -4
- package/src/host/clip-bridge.ts +19 -1
- package/src/host/config.ts +48 -6
- package/src/host/prompt.ts +47 -19
- package/src/host/routes-session.ts +10 -2
- package/src/host/routes.ts +49 -6
- package/src/host/seed-wechat-docs.ts +1 -1
- package/src/host/session-scope.ts +16 -1
- package/src/host/tools-attach.ts +1 -1
- package/src/host/wiki-farm.ts +55 -10
- package/src/host/wiki-instance.ts +18 -5
- package/src/index.ts +22 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "把 TiddlyWiki 5 变成你和 Agent 共用的本地文档中心(纯文本 + git):你在 DSH 界面里阅读、写作、记日记,Agent 用 15 个 tiddlywiki_* 工具读写同一批笔记;每条笔记都是你能读、能改、能审计、能随时带走的文件——不是自动记忆插件,不做后台抓取。可选一键发布到微信公众号草稿箱。 Turn TiddlyWiki 5 into a document center you and your agent share, in plain text under git — a knowledge base you can read, edit, audit and take with you, not an auto-memory plugin. Mounts via the official dsh plugin system; no DSH source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.29.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -145,8 +145,40 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
145
145
|
renderTip()
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Snapshot `build()` rendered from — lets the click path notice a stale menu. */
|
|
149
|
+
let currentFlags: UiFlags | undefined
|
|
150
|
+
let currentRoster: FabRoster | undefined
|
|
151
|
+
|
|
152
|
+
/** 名册是否变过(新增 / 移除 / 改名 / 启停库)。 */
|
|
153
|
+
const rosterChanged = (before: FabRoster, after: FabRoster): boolean => {
|
|
154
|
+
if (before.mode !== after.mode || before.defaultId !== after.defaultId) return true
|
|
155
|
+
if (before.wikis.length !== after.wikis.length) return true
|
|
156
|
+
return before.wikis.some((wiki, i) => {
|
|
157
|
+
const next = after.wikis[i]
|
|
158
|
+
return next === undefined || next.id !== wiki.id || next.label !== wiki.label || next.running !== wiki.running
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 打开菜单 —— 点击路径与「名册变了,先重建再打开」共用这一份。 */
|
|
163
|
+
const openMenuNow = (): void => {
|
|
164
|
+
if (menu === undefined) return
|
|
165
|
+
menuOpen = true
|
|
166
|
+
menu.hidden = false
|
|
167
|
+
// 每次打开都重画一次选中标记(v0.28.3):切库可能发生在菜单关着的时候
|
|
168
|
+
// (侧边栏入口、快速笔记卡片),不重画就会显示上一次的选中项。
|
|
169
|
+
repaintWikiMenu?.()
|
|
170
|
+
renderDot()
|
|
171
|
+
void refreshTwStatus()
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
const build = (flags: UiFlags, roster: FabRoster): void => {
|
|
149
175
|
if (disposed) return
|
|
176
|
+
currentFlags = flags
|
|
177
|
+
currentRoster = roster
|
|
178
|
+
// 重建路径会再次调用 build():旧的 document 监听必须先摘掉(同一个函数引用,
|
|
179
|
+
// 重复 add 会让菜单外的每一次点击都多跑一遍处理器)。
|
|
180
|
+
document.removeEventListener('click', onDocumentClick, true)
|
|
181
|
+
root?.remove()
|
|
150
182
|
root = document.createElement('div')
|
|
151
183
|
root.className = 'dsh-tw-fab-wrap'
|
|
152
184
|
|
|
@@ -305,15 +337,21 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
305
337
|
dot.dataset.state = sync.getState().state
|
|
306
338
|
fabBtn.append(icon, dot)
|
|
307
339
|
fabBtn.addEventListener('click', () => {
|
|
308
|
-
if (menu === undefined) return
|
|
309
340
|
if (menuOpen) { closeMenu(); return }
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
341
|
+
// 名册可能在界面开着的时候变(新增 / 移除 / 改名 / 启停库)。
|
|
342
|
+
// v0.29.0:打开前对一次账,变了就整块重建 —— 此前只在挂载时读一次,
|
|
343
|
+
// 于是菜单里少一个库 / 高亮错行,点已移除的那一项还会把面板指到
|
|
344
|
+
// `/tw/<gone>/`(侧边栏入口是每 10s 重读 /status 的,只有这里不会)。
|
|
345
|
+
void (async () => {
|
|
346
|
+
const fresh = await fetchRoster().catch(() => undefined)
|
|
347
|
+
if (disposed) return
|
|
348
|
+
if (fresh !== undefined && currentRoster !== undefined && currentFlags !== undefined && rosterChanged(currentRoster, fresh)) {
|
|
349
|
+
build(currentFlags, fresh)
|
|
350
|
+
openMenuNow()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
openMenuNow()
|
|
354
|
+
})()
|
|
317
355
|
})
|
|
318
356
|
|
|
319
357
|
root.append(menu, fabBtn)
|
|
@@ -119,9 +119,65 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
119
119
|
let rosterMode = 'single'
|
|
120
120
|
/** The knowledge-base roster (empty in single mode / before /status lands). */
|
|
121
121
|
let roster: Array<{ id: string; label: string; running: boolean }> = []
|
|
122
|
+
/** The registry default (its display name carries the 「(默认)」 mark). */
|
|
123
|
+
let defaultWikiId: string | undefined
|
|
122
124
|
/** 焦点库订阅(dispose 需回收)。 */
|
|
123
125
|
let focusOff: (() => void) | undefined
|
|
124
126
|
const wikiQuery = (url: string): string => withWikiQuery(url, targetWiki)
|
|
127
|
+
/**
|
|
128
|
+
* DOM half of `ensureTarget()`: repaint the 「写入」 selector from `roster`.
|
|
129
|
+
*
|
|
130
|
+
* A no-op until the Markdown card has been built — which is exactly why the
|
|
131
|
+
* roster resolution can live OUTSIDE `build()` (v0.29.0). The native path
|
|
132
|
+
* never builds the card, so it gets no selector but must still get the wiki.
|
|
133
|
+
*/
|
|
134
|
+
let syncTargetDom: () => void = () => {}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve WHICH knowledge base this card works on, ONCE, for BOTH entry paths.
|
|
137
|
+
*
|
|
138
|
+
* v0.29.0. This lookup used to live inside `build()`, so only the Markdown card
|
|
139
|
+
* (`open()`) ever learned the roster. The DEFAULT click path is `openNative()`
|
|
140
|
+
* (直达 TW 原生编辑器), which never builds the card — `targetWiki` therefore
|
|
141
|
+
* stayed undefined and `rosterMode` stayed `'single'`, and every quick note was
|
|
142
|
+
* written into the **default** wiki and opened in the default wiki's editor
|
|
143
|
+
* while the user was reading another one. The 「写入」 selector that was
|
|
144
|
+
* supposed to make that impossible never appeared either, because it is part of
|
|
145
|
+
* the card. Silent, and only reachable in a multi-wiki install.
|
|
146
|
+
*/
|
|
147
|
+
let targetPromise: Promise<void> | undefined
|
|
148
|
+
const ensureTarget = (): Promise<void> => {
|
|
149
|
+
targetPromise ??= fetchStatus().then((payload) => {
|
|
150
|
+
if (disposed) return
|
|
151
|
+
rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
|
|
152
|
+
roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
|
|
153
|
+
// 单库:`targetWiki` 保持 undefined = 逐字与升级前相同的行为。
|
|
154
|
+
if (roster.length <= 1) return
|
|
155
|
+
defaultWikiId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
|
|
156
|
+
const followFocus = (): void => {
|
|
157
|
+
if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultWikiId)
|
|
158
|
+
}
|
|
159
|
+
followFocus()
|
|
160
|
+
// 未在本卡片里手动选过时,跟随 GUI 的焦点库(你在看哪个库,笔记就进哪个库)。
|
|
161
|
+
focusOff ??= subscribeFocusWiki(() => {
|
|
162
|
+
followFocus()
|
|
163
|
+
syncTargetDom()
|
|
164
|
+
})
|
|
165
|
+
syncTargetDom()
|
|
166
|
+
})
|
|
167
|
+
return targetPromise
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The resolved target, when it is known AND not running.
|
|
171
|
+
*
|
|
172
|
+
* `/note` / `/edit` refuse a stopped wiki instead of writing into the default
|
|
173
|
+
* one (v0.29.0), so the honest answer is to say so BEFORE the user writes a
|
|
174
|
+
* paragraph — the card has `wikiHint` for that, the native path has only us.
|
|
175
|
+
*/
|
|
176
|
+
const stoppedTarget = (): { id: string; label: string } | undefined => {
|
|
177
|
+
if (targetWiki === undefined) return undefined
|
|
178
|
+
const item = roster.find((entry) => entry.id === targetWiki)
|
|
179
|
+
return item !== undefined && !item.running ? item : undefined
|
|
180
|
+
}
|
|
125
181
|
let draftTimer: number | undefined
|
|
126
182
|
let recentOpen = false
|
|
127
183
|
/**
|
|
@@ -448,7 +504,10 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
448
504
|
titleInput.className = 'dsh-tw-note-title'
|
|
449
505
|
titleInput.placeholder = '标题(默认时间戳)'
|
|
450
506
|
titleInput.setAttribute('aria-label', '笔记标题(默认时间戳)')
|
|
451
|
-
|
|
507
|
+
// 标签建议必须来自**同一张卡片写入的那个库**(v0.29.0):helper 一直支持
|
|
508
|
+
// `wikiQuery`,但这里漏传了,于是多库下建议列表来自默认库——点一个建议就把
|
|
509
|
+
// 一个只存在于别处的标签写进目标库。上传那两处一直是传的(见下)。
|
|
510
|
+
const tagEditor = buildTagEditor({ onChange: scheduleDraft, wikiQuery })
|
|
452
511
|
fields.append(titleInput, tagEditor.el)
|
|
453
512
|
|
|
454
513
|
// ── 目标知识库(v0.28.0,R7)────────────────────────────────────────────
|
|
@@ -488,16 +547,12 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
488
547
|
})
|
|
489
548
|
|
|
490
549
|
/**
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
* byte-identical behaviour and never sees the selector.
|
|
550
|
+
* Paint the selector (+ the "not running" hint) from the resolved roster.
|
|
551
|
+
* `ensureTarget()` may have resolved BEFORE this card existed (the native path
|
|
552
|
+
* resolves it too), in which case it already called the no-op stub — so build()
|
|
553
|
+
* calls it once more here to catch up.
|
|
496
554
|
*/
|
|
497
|
-
|
|
498
|
-
if (disposed) return
|
|
499
|
-
rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
|
|
500
|
-
roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
|
|
555
|
+
syncTargetDom = (): void => {
|
|
501
556
|
// 单库:连选择器都不该存在。这里**还有一个**兜底 —— 除 `hidden` 外把 select 禁用、
|
|
502
557
|
// 标签也藏掉,因为"能看见一个没有选项的下拉"比"什么都不显示"糟得多(v0.28.1 修)。
|
|
503
558
|
if (roster.length <= 1) {
|
|
@@ -508,11 +563,6 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
508
563
|
}
|
|
509
564
|
wikiField.style.display = ''
|
|
510
565
|
wikiSelect.disabled = false
|
|
511
|
-
const defaultId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
|
|
512
|
-
const followFocus = (): void => {
|
|
513
|
-
if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultId)
|
|
514
|
-
}
|
|
515
|
-
followFocus()
|
|
516
566
|
// 「默认库」不再作为选项出现(作者 2026-09-29 报障):那个位置就是**被设为默认的那个
|
|
517
567
|
// 库本身**,用它自己的显示名,只加「(默认)」作为身份标记。原来那个 `value=''` 的
|
|
518
568
|
// 占位项其实是多余的 —— 清单里本来就有这个库,选中它写进去的就是它。
|
|
@@ -520,7 +570,7 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
520
570
|
...roster.map((item) => {
|
|
521
571
|
const option = document.createElement('option')
|
|
522
572
|
option.value = item.id
|
|
523
|
-
const mark = item.id ===
|
|
573
|
+
const mark = item.id === defaultWikiId ? '(默认)' : ''
|
|
524
574
|
option.textContent = item.running ? `${item.label}${mark}` : `${item.label}${mark}(未运行)`
|
|
525
575
|
return option
|
|
526
576
|
}),
|
|
@@ -528,13 +578,8 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
528
578
|
wikiSelect.value = targetWiki ?? ''
|
|
529
579
|
wikiField.hidden = false
|
|
530
580
|
markStopped()
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
followFocus()
|
|
534
|
-
wikiSelect.value = targetWiki ?? ''
|
|
535
|
-
markStopped()
|
|
536
|
-
})
|
|
537
|
-
})
|
|
581
|
+
}
|
|
582
|
+
void ensureTarget().then(() => syncTargetDom())
|
|
538
583
|
titleInput.addEventListener('input', scheduleDraft)
|
|
539
584
|
|
|
540
585
|
// Mod-Enter save routes through doSave, which is assigned below (the editor
|
|
@@ -675,6 +720,13 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
675
720
|
toast('内容为空,未保存')
|
|
676
721
|
return
|
|
677
722
|
}
|
|
723
|
+
// 「写入」选中的那个库没在跑:host 现在会拒绝(而不是写进默认库),
|
|
724
|
+
// 所以先说清楚,别让用户写完一整段才发现(v0.29.0)。
|
|
725
|
+
const stopped = stoppedTarget()
|
|
726
|
+
if (stopped !== undefined) {
|
|
727
|
+
toast(`「${stopped.label}」当前没在运行:先在右下角「知识库」菜单里打开它,或改「写入」目标`)
|
|
728
|
+
return
|
|
729
|
+
}
|
|
678
730
|
saving = true
|
|
679
731
|
saveBtn.disabled = true
|
|
680
732
|
saveBtn.textContent = '保存中…'
|
|
@@ -854,6 +906,14 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
854
906
|
if (isEditorPopupOpen() && !isEditorPopupBlank()) return
|
|
855
907
|
nativeOpening = true
|
|
856
908
|
try {
|
|
909
|
+
// v0.29.0: 必须先解析目标库 —— 这条路径不建卡片,此前因此**永远**写进默认库。
|
|
910
|
+
await ensureTarget()
|
|
911
|
+
if (disposed) return
|
|
912
|
+
const stopped = stoppedTarget()
|
|
913
|
+
if (stopped !== undefined) {
|
|
914
|
+
toast(`「${stopped.label}」当前没在运行:先在右下角「知识库」菜单里打开它,再记这条笔记`)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
857
917
|
const hit = loadDraft()
|
|
858
918
|
const hasDraft = hit !== null && (hit.draft.text.trim().length > 0 || hit.draft.title.trim().length > 0)
|
|
859
919
|
const title = hasDraft && hit.draft.title.trim().length > 0 ? hit.draft.title.trim() : timestampTitle()
|
package/src/client/panel.ts
CHANGED
|
@@ -87,6 +87,11 @@ function TwRightbarTabBody(props: RightbarTabBodyProps): React.ReactElement {
|
|
|
87
87
|
if (host === null) return
|
|
88
88
|
const controller = createTwFrameController(host, tab.signal)
|
|
89
89
|
controllerRef.current = controller
|
|
90
|
+
// 创建即应用一次可见性(v0.29.0):如果 tab 记录被换掉(id/signal 变)而
|
|
91
|
+
// `visible` 仍是 true,下面的 effect 不会再跑(依赖没变),新控制器就永远
|
|
92
|
+
// 停在 `started = false` / iframe 隐藏 —— 右侧栏那块 tab 是空白的,直到用户
|
|
93
|
+
// 手动收起再展开。这里同步应用一次,两个 effect 谁先跑都不影响结果。
|
|
94
|
+
controller.setVisible(tab.visible)
|
|
90
95
|
return () => {
|
|
91
96
|
controller.dispose()
|
|
92
97
|
controllerRef.current = null
|
|
@@ -44,7 +44,7 @@ import { GET_ENDPOINT, SESSION_SUMMARY_ENDPOINT as SUMMARY_ENDPOINT, withWikiQue
|
|
|
44
44
|
import { fetchRenderFragment } from './render-fetch.ts'
|
|
45
45
|
// 本会话作用域的知识库(v0.28.8):汇总必须落在同一个库,否则标题条目在库里、
|
|
46
46
|
// 渲染却去默认库找 → 一直「条目不存在」(见 wiki-scope.ts)。
|
|
47
|
-
import { resolveSessionWikiId } from './wiki-scope.ts'
|
|
47
|
+
import { resolveSessionWikiId, subscribeSessionWikiId } from './wiki-scope.ts'
|
|
48
48
|
import { getTabLabel, setTabLabel } from './tw-frame.ts'
|
|
49
49
|
|
|
50
50
|
/** conversation.view 槽位注册 id(模块私有,v0.22.8:只有本文件的 mount 用)。 */
|
|
@@ -313,6 +313,24 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
313
313
|
}
|
|
314
314
|
}, [summaryTitle, generate])
|
|
315
315
|
|
|
316
|
+
/**
|
|
317
|
+
* 会话作用域一变就重生成(v0.29.0)。
|
|
318
|
+
*
|
|
319
|
+
* 为什么必须订阅而不是等下一轮:汇总缓存的是**某个库**的成品,还有 3 分钟的按时间
|
|
320
|
+
* 刷新节拍;选择器切库时只清了 scope 缓存,所以此前切库后最多三分钟仍显示旧库的内容
|
|
321
|
+
* 与旧库链接(`data-dsh-tw-wiki` 也是旧的)——正是这个模块存在的理由的反面。
|
|
322
|
+
* 手动刷新会清零失败额度,这里同样清零:换了库就是一次全新的生成。
|
|
323
|
+
*/
|
|
324
|
+
React.useEffect(() => {
|
|
325
|
+
return subscribeSessionWikiId((changed) => {
|
|
326
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return
|
|
327
|
+
if (changed !== undefined && changed !== sessionId) return
|
|
328
|
+
failuresRef.current = 0
|
|
329
|
+
lastGeneratedRef.current = 0
|
|
330
|
+
void generate()
|
|
331
|
+
})
|
|
332
|
+
}, [sessionId, generate])
|
|
333
|
+
|
|
316
334
|
// 一次性 focus 请求直接确认(本视图无可聚焦子目标,避免 shell 挂起)。
|
|
317
335
|
React.useEffect(() => {
|
|
318
336
|
if (viewRequest !== null && viewRequest !== undefined) completeViewRequest?.()
|
|
@@ -44,6 +44,15 @@ export interface ConfigField {
|
|
|
44
44
|
* inputs (and never discards the user's unsaved edits). */
|
|
45
45
|
export interface ConfigRenderState {
|
|
46
46
|
signature?: string
|
|
47
|
+
/**
|
|
48
|
+
* The knowledge base this form was built for (v0.29.0).
|
|
49
|
+
*
|
|
50
|
+
* `signature` alone was not enough: two wikis can hold IDENTICAL config JSON, so
|
|
51
|
+
* switching 「配置」 from one to the other looked like "nothing changed" and kept
|
|
52
|
+
* the previous wiki's form — including its unsaved edits — which the next 保存配置
|
|
53
|
+
* then wrote into the NEW wiki (with a misleading 「配置在别处被改动过」 banner).
|
|
54
|
+
*/
|
|
55
|
+
wiki?: string
|
|
47
56
|
host?: HTMLElement
|
|
48
57
|
/**
|
|
49
58
|
* 最近一次保存失败的文案。保存失败后要 refresh()(顶部可能因此出现 configError
|
|
@@ -110,14 +110,9 @@ export function setEditingWiki(id: string | undefined): void {
|
|
|
110
110
|
*/
|
|
111
111
|
export type SettingsTab = 'overview' | 'library' | 'global'
|
|
112
112
|
|
|
113
|
-
/** 当前 Tab
|
|
113
|
+
/** 当前 Tab(模块级,与 editingWiki 同级):切换 Tab 不重建配置表单。 */
|
|
114
114
|
export let activeTab: SettingsTab = 'overview'
|
|
115
115
|
|
|
116
|
-
/** Read the current tab (the scope bar / render order use it). */
|
|
117
|
-
export function currentTab(): SettingsTab {
|
|
118
|
-
return activeTab
|
|
119
|
-
}
|
|
120
|
-
|
|
121
116
|
/** Switch the tab WITHOUT re-rendering (callers `refresh()` themselves). */
|
|
122
117
|
export function setActiveTab(tab: SettingsTab): void {
|
|
123
118
|
activeTab = tab
|
|
@@ -105,6 +105,14 @@ function mountSettingsPage(container: HTMLElement): () => void {
|
|
|
105
105
|
* 守门:`scripts/verify-plugin-runtime.mjs` 的「未碰过 ≠ 空集合」一节。
|
|
106
106
|
*/
|
|
107
107
|
const catalogPending: CatalogPending = {}
|
|
108
|
+
/**
|
|
109
|
+
* 这份「未应用勾选」属于哪个库(v0.29.0)。
|
|
110
|
+
*
|
|
111
|
+
* 它是**要写进那个库 `tiddlywiki.info` 的清单**,所以只在同一个库内有意义:
|
|
112
|
+
* 在 A 库勾了几个插件、再点 B 库的「配置」,此前会把 A 的期望集合显示成 B 的,
|
|
113
|
+
* 点「应用」就把 A 的插件清单写进 B(并重启 B)。切库即作废。
|
|
114
|
+
*/
|
|
115
|
+
let catalogScope: string | undefined
|
|
108
116
|
/** 首屏是否已成功渲染过内容:决定占位提示与失败时能不能清 body。 */
|
|
109
117
|
let rendered = false
|
|
110
118
|
/**
|
|
@@ -137,7 +145,16 @@ function mountSettingsPage(container: HTMLElement): () => void {
|
|
|
137
145
|
loadError.hidden = true
|
|
138
146
|
loadError.replaceChildren()
|
|
139
147
|
renderStatus(statusRow, state, refresh)
|
|
140
|
-
renderMain(body, state, refresh, () => disposed, configState, catalogPending, rosterMode)
|
|
148
|
+
renderMain(body, state, refresh, () => disposed, configState, catalogPending, rosterMode, () => {
|
|
149
|
+
// 库作用域变了:上一个库的「未应用勾选」立刻作废(见 catalogScope)。
|
|
150
|
+
if (catalogScope !== editingWiki) {
|
|
151
|
+
delete catalogPending.plugins
|
|
152
|
+
delete catalogPending.themes
|
|
153
|
+
delete catalogPending.languages
|
|
154
|
+
delete catalogPending.themeActive
|
|
155
|
+
catalogScope = editingWiki
|
|
156
|
+
}
|
|
157
|
+
})
|
|
141
158
|
} catch (err) {
|
|
142
159
|
if (disposed) return
|
|
143
160
|
// 不再 body.replaceChildren() / statusRow.replaceChildren():那会把已经渲染出来的
|
|
@@ -190,7 +207,10 @@ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promis
|
|
|
190
207
|
sync.textContent = '同步中…'
|
|
191
208
|
void (async () => {
|
|
192
209
|
try {
|
|
193
|
-
|
|
210
|
+
// ⚠️ 必须带作用域(v0.29.0):这两个按钮在同一行显示的是**正在配置的那个库**
|
|
211
|
+
// 的 git 概览/TW 状态,却不带 `?wiki=` —— 于是作用域条写着「正在配置:books」,
|
|
212
|
+
// 点下去 pull/commit/push(或重启)的是**默认库**,而且看起来一切正常。
|
|
213
|
+
const res = await fetch(withWiki(SYNC_ENDPOINT), { method: 'POST', signal: AbortSignal.timeout(120_000) })
|
|
194
214
|
const payload = (await res.json().catch(() => null)) as SyncResultPayload | null
|
|
195
215
|
const result = describeSyncResult(payload, res.status)
|
|
196
216
|
toast(result.ok ? `同步完成:${result.message}` : `同步失败:${result.message}`)
|
|
@@ -213,7 +233,7 @@ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promis
|
|
|
213
233
|
// 120s 是显式的:宿主 /admin/restart 会等 TW 就绪才回包,软窗口默认 60s
|
|
214
234
|
// (大知识库冷启动更久,v0.22.5 记录过 44s+),而 fetchJson 默认只给 15s ——
|
|
215
235
|
// 于是宿主重启成功、前端却报「重启失败」。同一原因也命中过知识库切换。
|
|
216
|
-
await fetchJson(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
|
|
236
|
+
await fetchJson(withWiki(RESTART_ENDPOINT), { method: 'POST', signal: AbortSignal.timeout(120_000) })
|
|
217
237
|
toast('TW 已重启')
|
|
218
238
|
} catch (err) {
|
|
219
239
|
toast(`重启失败:${err instanceof Error ? err.message : String(err)}`)
|
|
@@ -321,8 +341,11 @@ function renderPickLibraryHint(body: HTMLElement): void {
|
|
|
321
341
|
'插件管理(自带官方插件)/ 主题管理 / 语言管理 / 初始化,都是**按知识库**生效的。请先在「总览」里点某个库的「配置」,这里才会显示它们。'))
|
|
322
342
|
}
|
|
323
343
|
|
|
324
|
-
function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise<void>, isDisposed: () => boolean, configState: ConfigRenderState, catalogPending: CatalogPending, rosterMode?: string): void {
|
|
344
|
+
function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise<void>, isDisposed: () => boolean, configState: ConfigRenderState, catalogPending: CatalogPending, rosterMode?: string, dropCatalogPendingOnScopeChange?: () => void): void {
|
|
325
345
|
body.replaceChildren()
|
|
346
|
+
// 库作用域变了 → 先作废上一个库的「未应用勾选」(v0.29.0),再渲染任何东西:
|
|
347
|
+
// 否则渲染出来的复选框是 A 库的期望集合,而页面说的是 B 库(点应用就写错库)。
|
|
348
|
+
dropCatalogPendingOnScopeChange?.()
|
|
326
349
|
// Loud, above everything else: an unparseable config tiddler means the config
|
|
327
350
|
// block below shows DEFAULTS that are not actually in effect, and saving is
|
|
328
351
|
// refused until it is fixed (v0.23.4 — that is how a user's prompt.extra /
|
|
@@ -336,19 +359,28 @@ function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise
|
|
|
336
359
|
// a field (v0.20.0). The host element is re-appended as-is, so its inputs and
|
|
337
360
|
// their pending values survive.
|
|
338
361
|
const signature = JSON.stringify(state.config ?? {})
|
|
339
|
-
|
|
362
|
+
// 库作用域也是「要不要重建」的一部分(v0.29.0):两个库的配置内容可能**逐字相同**,
|
|
363
|
+
// 只比 config 就会把上一个库的表单(连同它的未保存改动)留给新库,保存时写错库。
|
|
364
|
+
const scopeChanged = configState.wiki !== editingWiki
|
|
365
|
+
const serverChanged = configState.host !== undefined && (scopeChanged || configState.signature !== signature)
|
|
340
366
|
// 有未保存改动时**不重建**(v0.25.0):重建会静默丢掉用户刚敲的内容(典型触发
|
|
341
367
|
// 路径:另一个标签页保存了配置,或语言管理顺带写 uiLanguage → 本页 refresh())。
|
|
342
368
|
// 保留旧表单 + 显式提示,用户可以选择保存(覆盖别处的改动)或自己改回来。
|
|
369
|
+
// 例外:切库(scopeChanged)时那条提示是错的 —— 用户要的就是另一个库的表单,
|
|
370
|
+
// 上一个库的脏值本来就不该带过去。
|
|
343
371
|
const dirty = configState.isDirty?.() === true
|
|
344
|
-
if (serverChanged && dirty) {
|
|
372
|
+
if (serverChanged && dirty && !scopeChanged) {
|
|
345
373
|
body.append(makeErrorBanner('⚠️ 服务器上的配置在别处被改动过(另一个标签页保存 / 语言管理等),当前表单仍是旧值:直接点「保存配置」会以本页内容覆盖那些改动。'))
|
|
346
374
|
}
|
|
347
|
-
|
|
375
|
+
// 重建的三种情况(v0.29.0:把"切库"单独加进来,但**不能**顺手改掉 v0.25.0 的保护):
|
|
376
|
+
// ① 还没有表单;② 用户切了库(他就是要另一个库的表单,旧库的脏值不该带过去);
|
|
377
|
+
// ③ 服务器变了且表单没有未保存改动。离开 ③ 的后半句就会静默丢掉用户刚敲的内容。
|
|
378
|
+
if (configState.host === undefined || scopeChanged || (serverChanged && !dirty)) {
|
|
348
379
|
const host = make('div', 'dsh-tw-settings-confighost')
|
|
349
380
|
renderConfigSection(host, state.config ?? {}, refresh, configState, rosterMode, isDisposed)
|
|
350
381
|
configState.host = host
|
|
351
382
|
configState.signature = signature
|
|
383
|
+
configState.wiki = editingWiki
|
|
352
384
|
}
|
|
353
385
|
// Tab 栏 + 作用域条(v0.28.8):多库时页面按「总览 / 本库配置 / 全局」分开,
|
|
354
386
|
// 并在最上方 sticky 地说明正在配置哪个库、随时可以退出。单库两者都不渲染,
|
|
@@ -177,10 +177,19 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
177
177
|
* (见 `resolveEntryClick`)——判定必须与面板/内核用**同一套解析**:
|
|
178
178
|
* 记忆值 → 默认库 → 第一个可用库(`resolveFocusWiki`)。
|
|
179
179
|
*/
|
|
180
|
-
|
|
180
|
+
/** 完整名册(含未运行的库)——只用于解析"面板此刻显示哪个库"(v0.29.0)。 */
|
|
181
|
+
let rosterAll: FocusableWiki[] = []
|
|
181
182
|
let defaultWikiId: string | undefined
|
|
182
|
-
/**
|
|
183
|
-
|
|
183
|
+
/**
|
|
184
|
+
* 面板此刻显示的那个库。
|
|
185
|
+
*
|
|
186
|
+
* v0.29.0:必须按**完整名册**(含未运行的库)解析,与面板/内核同源 —— 内核用的就是
|
|
187
|
+
* 记住的那个 id 本身(`panel.ts` 的 `getFocusWiki`),只有名册里真的没有它才回落。
|
|
188
|
+
* 此前用的是"在运行的那些",于是焦点库只是**没在跑**(还没起来 / 刚被停)时这里会
|
|
189
|
+
* 回落到默认库:高亮的那一行 ≠ 面板显示的库,而点那一行会被判成"切换"而不是收起
|
|
190
|
+
* (v0.28.13 修的是"点两次"的主观感受,这条是同一判定链上的第二个来源)。
|
|
191
|
+
*/
|
|
192
|
+
const shownWiki = (): string | undefined => resolveFocusWiki(rosterAll, defaultWikiId)
|
|
184
193
|
const entryHooks: EntryHooks = { state, shownWiki }
|
|
185
194
|
const { entry, labelEl, iconEl } = createEntry(entryHooks, 'TiddlyWiki')
|
|
186
195
|
rows.set('', { entry, labelEl, iconEl })
|
|
@@ -215,7 +224,8 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
215
224
|
const list = Array.isArray(payload?.wikis) ? payload.wikis : []
|
|
216
225
|
const running = list.filter((w) => w.running)
|
|
217
226
|
// 点击判定与高亮都要用同一份名册(v0.28.13):存下来,`shownWiki()` 据此解析。
|
|
218
|
-
|
|
227
|
+
// 行只给"在运行"的库建(见函数头),但"显示的是哪个库"要按完整名册判断(v0.29.0)。
|
|
228
|
+
rosterAll = list
|
|
219
229
|
defaultWikiId = payload?.defaultId
|
|
220
230
|
// 单库:不建额外行,标签仍归 ui.sidebarLabel 管。
|
|
221
231
|
if (running.length <= 1) {
|
|
@@ -247,16 +257,30 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
247
257
|
// 图标每次同步(v0.28.4):在设置页改完图标,10s 内的轮询就会把它换过来。
|
|
248
258
|
applyWikiIcon(row.iconEl, w.icon)
|
|
249
259
|
row.entry.setAttribute('aria-label', `TiddlyWiki 知识库:${w.label}`)
|
|
250
|
-
// 当前焦点库高亮(与点击判定同源:`shownWiki()`,不会出现"高亮的行点一下反而关掉面板")
|
|
251
|
-
const focused = shownWiki() === w.id
|
|
252
|
-
if (focused) row.entry.dataset.focus = 'true'
|
|
253
|
-
else delete row.entry.dataset.focus
|
|
254
260
|
}
|
|
261
|
+
// 当前焦点库高亮:与点击判定同源(`shownWiki()`),不会出现"高亮的行点一下反而关掉面板"
|
|
262
|
+
paintFocus()
|
|
255
263
|
// 让放置逻辑把新行插进去(复用同一个 root)
|
|
256
264
|
if (root !== undefined) { placed = false; tryPlace() }
|
|
257
265
|
}
|
|
258
266
|
void applyRoster()
|
|
259
|
-
|
|
267
|
+
/**
|
|
268
|
+
* 焦点变化只需要重画**高亮**(v0.28.14)。
|
|
269
|
+
*
|
|
270
|
+
* 旧实现是 `subscribeFocusWiki(() => { void applyRoster() })`:每切一次库都**再打一次
|
|
271
|
+
* `/status`**(host 处理一次要跑最多 5 个 git 进程、实测 300–400ms)并把所有行重建一遍、
|
|
272
|
+
* 重新走一次放置逻辑。可是名册并没有变 —— 变的只是"哪一行亮着"。而那次往返正是
|
|
273
|
+
* 「点下去要等一下界面才动」的一部分(见 tw-frame.ts 的 switchFrameNow)。
|
|
274
|
+
*/
|
|
275
|
+
const paintFocus = (): void => {
|
|
276
|
+
const focus = shownWiki()
|
|
277
|
+
for (const [key, row] of rows) {
|
|
278
|
+
if (key === '') continue
|
|
279
|
+
if (key === focus) row.entry.dataset.focus = 'true'
|
|
280
|
+
else delete row.entry.dataset.focus
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const unsubscribeFocus = subscribeFocusWiki(paintFocus)
|
|
260
284
|
const rosterTimer = window.setInterval(() => { void applyRoster() }, 10_000)
|
|
261
285
|
let root: HTMLElement | undefined
|
|
262
286
|
let placed = false
|
package/src/client/styles.ts
CHANGED
|
@@ -88,6 +88,16 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
88
88
|
.dsh-tw-panel-error button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
89
89
|
.dsh-tw-panel-error code { font-size: 11px; opacity: .8; max-width: 80%; overflow-wrap: anywhere; }
|
|
90
90
|
|
|
91
|
+
/* 换库提示条(v0.28.14):换一份 wiki 文档要重新下载 10–30MB 且 TW 回 no-store(缓存不了),
|
|
92
|
+
那 1–2 秒里界面什么都不动 —— 这条就是"正在发生什么"的唯一说明。两个 TW 界面共用它:
|
|
93
|
+
它是 view 的最后一个孩子,在 flex column 里天然落在底部。 */
|
|
94
|
+
.dsh-tw-loading {
|
|
95
|
+
flex: 0 0 auto; padding: 6px 12px; font-size: 12px; text-align: center;
|
|
96
|
+
color: var(--dsw-alias-label-secondary, #666);
|
|
97
|
+
background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.08));
|
|
98
|
+
border-top: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.18));
|
|
99
|
+
}
|
|
100
|
+
|
|
91
101
|
/* ── right-sidebar tab (DSH new rightbar) ──────────────────────
|
|
92
102
|
The React body host fills the rightbar pane; the TW iframe lives inside
|
|
93
103
|
it and follows the DSH theme like the center-column panel. */
|
package/src/client/tool-views.ts
CHANGED
|
@@ -592,7 +592,9 @@ function BatchCard(props: { toolName: string; args: Record<string, unknown> }):
|
|
|
592
592
|
.filter((t): t is string => t !== null && t.length > 0)
|
|
593
593
|
.map((title) => ({ title }))
|
|
594
594
|
// 批量写入的标题在挂载时失效缓存(effect 阶段,见 useInvalidateBodies)。
|
|
595
|
-
|
|
595
|
+
// wikiId 必须带上(v0.29.0):缓存键是 `wikiId\0title`,只按标题清会留下
|
|
596
|
+
// `A\0X`,之后同一篇的读取卡还能拿回被覆盖前的旧正文。
|
|
597
|
+
useInvalidateBodies(rows.map((row) => row.title), useScopedWikiId())
|
|
596
598
|
return React.createElement(ListCard, {
|
|
597
599
|
toolName: props.toolName,
|
|
598
600
|
title: '批量写入',
|
|
@@ -657,7 +659,8 @@ function GitCard(props: { toolName: string; text: string }): React.ReactElement
|
|
|
657
659
|
|
|
658
660
|
function DeleteCard(props: { toolName: string; title: string; text: string }): React.ReactElement {
|
|
659
661
|
// 删除后同标题的缓存必须失效,否则紧跟着的读取卡会拿旧正文(effect 阶段执行)。
|
|
660
|
-
|
|
662
|
+
// 同样必须带 wikiId(v0.29.0,理由见 BatchCard)。
|
|
663
|
+
useInvalidateBodies([props.title], useScopedWikiId())
|
|
661
664
|
const body = props.title.length > 0
|
|
662
665
|
? React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, `已删除 tiddler「${props.title}」`)
|
|
663
666
|
: React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, props.text || '(已删除)')
|