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/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.28.13",
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
- menuOpen = true
311
- menu.hidden = false
312
- // 每次打开都重画一次选中标记(v0.28.3):切库可能发生在菜单关着的时候
313
- // (侧边栏入口、快速笔记卡片),不重画就会显示上一次的选中项。
314
- repaintWikiMenu?.()
315
- renderDot()
316
- void refreshTwStatus()
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
- const tagEditor = buildTagEditor({ onChange: scheduleDraft })
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
- * Read the roster and decide the card's default target (v0.28.0).
492
- *
493
- * Before this resolves, `targetWiki` is undefined = the plain endpoints, and
494
- * with `roster.length <= 1` it STAYS undefined — a single-wiki install keeps
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
- void fetchStatus().then((payload) => {
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 === defaultId ? '(默认)' : ''
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
- // 未在本卡片里手动选过时,跟随 GUI 的焦点库(你在看哪个库,笔记就进哪个库)。
532
- focusOff = subscribeFocusWiki(() => {
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()
@@ -76,6 +76,7 @@ const PANEL_SKIN: TwFrameSkin = {
76
76
  frameWrapStyle: 'flex:1;min-height:0;display:flex;flex-direction:column',
77
77
  frame: 'dsh-tw-panel-frame',
78
78
  error: 'dsh-tw-panel-error',
79
+ loading: 'dsh-tw-loading',
79
80
  }
80
81
 
81
82
  /**
@@ -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。模块级(与 editingWiki 同级):切换 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
- const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
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
- const serverChanged = configState.host !== undefined && configState.signature !== signature
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
- if (configState.host === undefined || (serverChanged && !dirty)) {
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
- let runningWikis: FocusableWiki[] = []
180
+ /** 完整名册(含未运行的库)——只用于解析"面板此刻显示哪个库"(v0.29.0)。 */
181
+ let rosterAll: FocusableWiki[] = []
181
182
  let defaultWikiId: string | undefined
182
- /** 面板此刻显示的那个库。 */
183
- const shownWiki = (): string | undefined => resolveFocusWiki(runningWikis, defaultWikiId)
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
- runningWikis = running
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
- const unsubscribeFocus = subscribeFocusWiki(() => { void applyRoster() })
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
@@ -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. */
@@ -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
- useInvalidateBodies(rows.map((row) => row.title))
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
- useInvalidateBodies([props.title])
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 || '(已删除)')