dsh-tiddlywiki 0.28.2 → 0.28.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 变成你和 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.2",
4
+ "version": "0.28.4",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Align a `conversation.input.dock` entry with the composer input card.
3
+ *
4
+ * WHY THIS EXISTS (v0.28.2)
5
+ * -------------------------
6
+ * The dock slot renders entries as a full-width flex column that is a SIBLING of
7
+ * the composer, not inside it. The composer's input card is centred and has its
8
+ * own max-width, so an entry's right edge sits far to the right of the input box
9
+ * — visibly misaligned.
10
+ *
11
+ * The fix is to measure the composer card and pad the entry's right side by the
12
+ * difference. That measurement used to live only inside `quick-note-dock.ts`, so
13
+ * the moment a SECOND dock entry was added (the per-session knowledge-base
14
+ * selector) it simply did not align — nobody had published the rule. This module
15
+ * is that rule, written once.
16
+ *
17
+ * HOW IT FINDS THE CARD
18
+ * ---------------------
19
+ * Climb from the entry until an ancestor's subtree contains a wide text field
20
+ * (>300px — the composer input; dock entries' own inputs are small), then take
21
+ * the widest box between that field and its containing column (the visible card).
22
+ *
23
+ * WHEN IT RE-MEASURES
24
+ * -------------------
25
+ * ResizeObserver on the whole ancestor chain up to the conversation root
26
+ * (`[data-phase]`), plus `resize` / `visibilitychange`, plus a short self-healing
27
+ * interval that stops once the layout has measured cleanly three times (or after
28
+ * 20 tries, so a shell we cannot understand never leaks a timer).
29
+ *
30
+ * @module dsh-tiddlywiki/client/dock-align
31
+ */
32
+
33
+ /** The measurement/padding key, exported so the gate can assert on it. */
34
+ export const DOCK_ALIGN_MIN_INPUT_WIDTH = 300
35
+
36
+ /**
37
+ * Start aligning `wrap` with the composer input card. Returns the disposer; call
38
+ * it from the component's effect cleanup.
39
+ *
40
+ * A React ref is intentionally NOT taken: the caller passes its own element, so
41
+ * this works for any dock entry regardless of how it renders.
42
+ */
43
+ export function alignDockEntry(wrap: HTMLElement): () => void {
44
+ /** Find the composer input CARD (see the module doc for the algorithm). */
45
+ const findCard = (): HTMLElement | null => {
46
+ let input: Element | null = null
47
+ let column: HTMLElement | null = null
48
+ let node: HTMLElement | null = wrap
49
+ while (node !== null && node !== document.documentElement) {
50
+ node = node.parentElement
51
+ if (node === null) break
52
+ for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
53
+ if (el.getBoundingClientRect().width > DOCK_ALIGN_MIN_INPUT_WIDTH) { input = el; column = node; break }
54
+ }
55
+ if (column !== null) break
56
+ }
57
+ if (input === null || column === null) return null
58
+ const colW = column.getBoundingClientRect().width
59
+ let cur: HTMLElement | null = input as HTMLElement
60
+ let card: HTMLElement | null = null
61
+ let cardW = 0
62
+ while (cur !== null && cur !== column) {
63
+ const w = cur.getBoundingClientRect().width
64
+ if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
65
+ cur = cur.parentElement
66
+ }
67
+ return card
68
+ }
69
+
70
+ /** Measure once; returns true when the composer card was found (valid measure). */
71
+ const align = (): boolean => {
72
+ const wrapRect = wrap.getBoundingClientRect()
73
+ const card = findCard()
74
+ const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
75
+ const pad = Math.max(0, wrapRect.right - right)
76
+ if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
77
+ return card !== null
78
+ }
79
+
80
+ align()
81
+ // A sidebar toggle changes the conversation column width → the centred,
82
+ // max-width composer card moves → the padding must be re-measured. Observing
83
+ // only the dock container misses that (it may not resize at all), so observe
84
+ // the whole ancestor chain up to the conversation root.
85
+ const ro = new ResizeObserver(() => { align() })
86
+ let node: Element | null = wrap
87
+ while (node !== null && node !== document.documentElement) {
88
+ ro.observe(node)
89
+ if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
90
+ node = node.parentElement
91
+ }
92
+ window.addEventListener('resize', align)
93
+ document.addEventListener('visibilitychange', align)
94
+
95
+ // Self-healing fallback for layout changes we cannot observe. Each tick is a
96
+ // few getBoundingClientRect reads. It stops after 3 clean measurements, and
97
+ // gives up after 20 tries so an unrecognisable shell cannot leak a timer.
98
+ let guardHits = 0
99
+ let guardTicks = 0
100
+ const guard = window.setInterval(() => {
101
+ guardTicks++
102
+ if (!align()) {
103
+ if (guardTicks >= 20) window.clearInterval(guard)
104
+ return
105
+ }
106
+ guardHits++
107
+ if (guardHits >= 3) window.clearInterval(guard)
108
+ }, 1500)
109
+ // The composer card may mount slightly later; re-measure a couple of times.
110
+ const t1 = window.setTimeout(align, 120)
111
+ const t2 = window.setTimeout(align, 600)
112
+
113
+ return () => {
114
+ ro.disconnect()
115
+ window.removeEventListener('resize', align)
116
+ document.removeEventListener('visibilitychange', align)
117
+ window.clearInterval(guard)
118
+ window.clearTimeout(t1)
119
+ window.clearTimeout(t2)
120
+ }
121
+ }
@@ -21,7 +21,7 @@ import type { NoteWidgetHandle } from './note-widget.ts'
21
21
  import type { SyncController } from './sync-button.ts'
22
22
  import { PANEL_RELOAD_EVENT } from './tw-frame.ts'
23
23
  import { ROUTE_PREFIX } from './endpoints.ts'
24
- import { resolveFocusWiki, setFocusWiki } from './wiki-focus.ts'
24
+ import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
25
25
 
26
26
  import { fetchStatus } from './status-cache.ts'
27
27
  import { fetchUiConfig } from './ui-config.ts'
@@ -91,6 +91,13 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
91
91
  let tip: HTMLDivElement | undefined
92
92
  let panelLabel: HTMLSpanElement | undefined
93
93
  let menuOpen = false
94
+ /** 焦点库订阅(多库菜单需要,dispose 时回收)。 */
95
+ let focusOff: (() => void) | undefined
96
+ /**
97
+ * 重画"知识库"分组的选中标记。打开菜单前调用一次 —— 订阅负责切库时即时重画,
98
+ * 这里负责兜住"订阅建立之前的窗口"与外部改动(v0.28.3)。
99
+ */
100
+ let repaintWikiMenu: (() => void) | undefined
94
101
  /** Latest TW health snapshot, merged into the hover tip. */
95
102
  let twHealth: TwHealth = { state: 'unknown', text: 'TiddlyWiki 服务…', logs: [] }
96
103
 
@@ -196,17 +203,33 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
196
203
  // 多知识库切换(v0.28.0):只有多于一个库时才长出这一段,单库安装的菜单不变。
197
204
  // 点一下 = 把"焦点库"切过去并打开面板——面板会加载那个库自己的 /tw/<id>/。
198
205
  if (roster.wikis.length > 1) {
199
- const focus = resolveFocusWiki(roster.wikis, roster.defaultId)
200
206
  const heading = document.createElement('div')
201
207
  heading.className = 'dsh-tw-fab-group'
202
208
  heading.textContent = '知识库'
203
209
  menu.append(heading)
210
+ /** 每个库的菜单项,便于焦点变化时就地刷新选中标记。 */
211
+ const wikiItems = new Map<string, HTMLButtonElement>()
212
+ /**
213
+ * 重画选中标记(v0.28.3)。
214
+ *
215
+ * 原来的实现只在**菜单构建时**算一次 focus,之后无论怎么切都不再更新 ——
216
+ * 于是"切了库、选中项还停在上一个"(作者报障)。这里改成从当前焦点重算,
217
+ * 并在 setFocusWiki 的订阅里重画;菜单关闭时也重画,保证下次打开是对的。
218
+ */
219
+ const paint = (): void => {
220
+ const current = resolveFocusWiki(roster.wikis, roster.defaultId)
221
+ for (const [id, el] of wikiItems) {
222
+ const isCurrent = id === current
223
+ el.dataset.current = isCurrent ? '1' : '0'
224
+ const wiki = roster.wikis.find((w) => w.id === id)
225
+ el.textContent = `${isCurrent ? '●' : '○'} ${wiki?.label ?? id}`
226
+ }
227
+ }
204
228
  for (const wiki of roster.wikis) {
205
229
  const item = document.createElement('button')
206
230
  item.type = 'button'
207
231
  item.className = 'dsh-tw-fab-item'
208
- item.dataset.current = wiki.id === focus ? '1' : '0'
209
- item.textContent = `${wiki.id === focus ? '●' : '○'} ${wiki.label}`
232
+ item.dataset.current = '0'
210
233
  item.title = `${wiki.path}${wiki.running ? '' : '(未运行,打开会启动)'}`
211
234
  item.addEventListener('click', () => {
212
235
  closeMenu()
@@ -224,8 +247,14 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
224
247
  }
225
248
  state.openPanel()
226
249
  })
250
+ wikiItems.set(wiki.id, item)
227
251
  menu.append(item)
228
252
  }
253
+ paint()
254
+ // 焦点变化(可能来自别的入口:侧边栏某一行、快速笔记卡片)也要跟上。
255
+ focusOff = subscribeFocusWiki(() => { paint() })
256
+ // 每次打开菜单前重画一次,避免因为订阅时机错过而显示旧值。
257
+ repaintWikiMenu = paint
229
258
  }
230
259
 
231
260
  panelLabel = document.createElement('span')
@@ -280,6 +309,9 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
280
309
  if (menuOpen) { closeMenu(); return }
281
310
  menuOpen = true
282
311
  menu.hidden = false
312
+ // 每次打开都重画一次选中标记(v0.28.3):切库可能发生在菜单关着的时候
313
+ // (侧边栏入口、快速笔记卡片),不重画就会显示上一次的选中项。
314
+ repaintWikiMenu?.()
283
315
  renderDot()
284
316
  void refreshTwStatus()
285
317
  })
@@ -307,6 +339,8 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
307
339
 
308
340
  return () => {
309
341
  disposed = true
342
+ focusOff?.()
343
+ focusOff = undefined
310
344
  document.removeEventListener('click', onDocumentClick, true)
311
345
  unsubPanel()
312
346
  unsubSync()
@@ -835,7 +835,16 @@ export function createNoteWidget(): NoteWidgetHandle {
835
835
  if (disposed) return
836
836
  rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
837
837
  roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
838
- if (roster.length <= 1) return
838
+ // 单库:连选择器都不该存在。这里**还有一个**兜底 —— 除 `hidden` 外把 select 禁用、
839
+ // 标签也藏掉,因为"能看见一个没有选项的下拉"比"什么都不显示"糟得多(v0.28.1 修)。
840
+ if (roster.length <= 1) {
841
+ wikiField.hidden = true
842
+ wikiField.style.display = 'none'
843
+ wikiSelect.disabled = true
844
+ return
845
+ }
846
+ wikiField.style.display = ''
847
+ wikiSelect.disabled = false
839
848
  const defaultId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
840
849
  const followFocus = (): void => {
841
850
  if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultId)
@@ -17,6 +17,7 @@
17
17
  import * as React from 'react'
18
18
  import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
19
19
  import { fetchUiConfig } from './ui-config.ts'
20
+ import { alignDockEntry } from './dock-align.ts'
20
21
  import { isEditorPopupOpen, isEditorPopupBlank, closeEditorPopup } from './editor-popup.ts'
21
22
 
22
23
  /**
@@ -54,89 +55,13 @@ export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactEl
54
55
  window.removeEventListener(NOTE_STATE_EVENT, onState)
55
56
  }
56
57
  }, [])
58
+ // 与 composer 输入框右缘对齐:测量逻辑已抽到 client/dock-align.ts(v0.28.2),
59
+ // 因为第二个 dock 条目(会话级知识库选择器)加进来时它没有对齐 —— 规则只写在
60
+ // 这里,别人看不见。
57
61
  React.useLayoutEffect(() => {
58
62
  const wrap = wrapRef.current
59
63
  if (wrap === null) return
60
- /**
61
- * Find the composer input CARD: climb from the dock entry until an
62
- * ancestor's subtree contains a wide text field (>300px — the composer
63
- * input; dock entries' own inputs are small), then take the widest box
64
- * between that field and its containing column (the visible card).
65
- */
66
- const findCard = (): HTMLElement | null => {
67
- let input: Element | null = null
68
- let column: HTMLElement | null = null
69
- let node: HTMLElement | null = wrap
70
- while (node !== null && node !== document.documentElement) {
71
- node = node.parentElement
72
- if (node === null) break
73
- for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
74
- if (el.getBoundingClientRect().width > 300) { input = el; column = node; break }
75
- }
76
- if (column !== null) break
77
- }
78
- if (input === null || column === null) return null
79
- const colW = column.getBoundingClientRect().width
80
- let cur: HTMLElement | null = input as HTMLElement
81
- let card: HTMLElement | null = null
82
- let cardW = 0
83
- while (cur !== null && cur !== column) {
84
- const w = cur.getBoundingClientRect().width
85
- if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
86
- cur = cur.parentElement
87
- }
88
- return card
89
- }
90
- /** 测量一次对齐;返回 true = 找到了 composer 输入卡片(测量有效)。 */
91
- const align = (): boolean => {
92
- const wrapRect = wrap.getBoundingClientRect()
93
- const card = findCard()
94
- const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
95
- const pad = Math.max(0, wrapRect.right - right)
96
- if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
97
- return card !== null
98
- }
99
- align()
100
- // 侧边栏开/关会改变对话列宽度 → composer 输入卡片(居中、有 max-width)的
101
- // 右缘随之移动。旧实现只观察了 dock 容器,侧边栏变化时它不一定触发 resize,
102
- // 导致按钮停在上次的位置、不再对齐。这里观察「从 dock 条目一直到对话根节点
103
- // (带 data-phase)的整条祖先链」,任一祖先尺寸变化都会重测对齐。
104
- const ro = new ResizeObserver(align)
105
- let node: Element | null = wrap
106
- while (node !== null && node !== document.documentElement) {
107
- ro.observe(node)
108
- if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
109
- node = node.parentElement
110
- }
111
- window.addEventListener('resize', align)
112
- document.addEventListener('visibilitychange', align)
113
- // 自愈兜底:任何未观测到的布局变化(侧边栏切换、插件重渲染等)也会在
114
- // 1.5s 内被纠正;每帧只是几次 getBoundingClientRect 读取,开销可忽略。
115
- // 但常驻定时器没有必要:连续 3 次测量成功(找到 composer 输入卡片)即认为
116
- // 布局已稳定,清掉 interval;即使一直测不到(异常 shell),也在 20 次后
117
- // 强制收手,之后由 ResizeObserver + resize + visibilitychange 覆盖。
118
- let guardHits = 0
119
- let guardTicks = 0
120
- const guard = window.setInterval(() => {
121
- guardTicks++
122
- if (!align()) {
123
- if (guardTicks >= 20) window.clearInterval(guard)
124
- return
125
- }
126
- guardHits++
127
- if (guardHits >= 3) window.clearInterval(guard)
128
- }, 1500)
129
- // The composer card may mount slightly later; re-measure a couple of times.
130
- const t1 = window.setTimeout(align, 120)
131
- const t2 = window.setTimeout(align, 600)
132
- return () => {
133
- ro.disconnect()
134
- window.removeEventListener('resize', align)
135
- document.removeEventListener('visibilitychange', align)
136
- window.clearInterval(guard)
137
- window.clearTimeout(t1)
138
- window.clearTimeout(t2)
139
- }
64
+ return alignDockEntry(wrap)
140
65
  }, [])
141
66
  const native = mode === 'native'
142
67
  const label = native ? '快速笔记' : (open ? '快速笔记(已打开)' : '快速笔记')
@@ -24,6 +24,7 @@
24
24
  import * as React from 'react'
25
25
  import { toast } from './toast.ts'
26
26
  import { invalidateUiConfig } from './ui-config.ts'
27
+ import { ICON_NAMES } from './wiki-icon.ts'
27
28
  import {
28
29
  ADMIN_CONFIG_ENDPOINT as CONFIG_ENDPOINT,
29
30
  ADMIN_INFO_ENDPOINT as INFO_ENDPOINT,
@@ -943,8 +944,28 @@ interface WikiListItemView {
943
944
  autostart: boolean
944
945
  running: boolean
945
946
  status: string
947
+ /** Per-wiki entry icon (v0.28.4): a curated name or a short emoji. */
948
+ icon?: string
946
949
  }
947
950
 
951
+ /**
952
+ * Icon picker labels (v0.28.4). The NAMES come from `wiki-icon.ts`, so the set
953
+ * the picker offers is exactly the set the sidebar can render.
954
+ */
955
+ const ICON_LABELS: Record<string, string> = {
956
+ book: '📖 书(book)',
957
+ briefcase: '💼 公文包(briefcase)',
958
+ home: '🏠 房子(home)',
959
+ notebook: '📓 笔记本(notebook)',
960
+ flask: '🧪 实验(flask)',
961
+ globe: '🌐 地球(globe)',
962
+ star: '⭐ 星标(star)',
963
+ archive: '🗄 归档(archive)',
964
+ }
965
+
966
+ /** A few emoji that read well at sidebar size and are easy to tell apart. */
967
+ const EMOJI_CHOICES = ['📚', '💼', '🏠', '✍️', '🧪', '🌱', '🎯', '🗂️']
968
+
948
969
  /** `GET`/`POST /dsh-tiddlywiki/admin/wikis` payload (v0.28.0). */
949
970
  interface WikisView {
950
971
  ok?: boolean
@@ -1111,6 +1132,29 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
1111
1132
  const ok = window.confirm(`把「${wiki.label}」移出清单?\n\n**目录与内容不会被删除**(仍在 ${wiki.path}),只是插件不再管理它。`)
1112
1133
  if (ok) void post({ action: 'remove', id: wiki.id }, '移出中…')
1113
1134
  })
1135
+ // 图标选择(v0.28.4):下拉里是内置图形 + 一组常用 emoji;选「默认」即清除自定义。
1136
+ // 它和 label 一样属于"库的身份",所以在列表里改,而不是塞进常规配置区。
1137
+ const iconPicker = make('select', 'dsh-tw-settings-icon-select')
1138
+ iconPicker.setAttribute('aria-label', `「${wiki.label}」的入口图标`)
1139
+ const currentIcon = typeof wiki.icon === 'string' ? wiki.icon : ''
1140
+ const options: Array<[string, string]> = [['', '默认图标']]
1141
+ for (const name of ICON_NAMES) options.push([name, ICON_LABELS[name] ?? name])
1142
+ for (const emoji of EMOJI_CHOICES) options.push([emoji, emoji])
1143
+ // 当前值不在候选里(用户填了自己的 emoji):保留它,别让下拉把它抹掉。
1144
+ if (currentIcon.length > 0 && !options.some(([v]) => v === currentIcon)) options.push([currentIcon, currentIcon])
1145
+ for (const [value, text] of options) {
1146
+ const opt = document.createElement('option')
1147
+ opt.value = value
1148
+ opt.textContent = text
1149
+ iconPicker.append(opt)
1150
+ }
1151
+ iconPicker.value = currentIcon
1152
+ iconPicker.addEventListener('change', () => {
1153
+ const next = iconPicker.value
1154
+ void post(next.length > 0
1155
+ ? { action: 'update', wiki: { ...wiki, icon: next } }
1156
+ : { action: 'update', wiki: { ...wiki, icon: '' } }, '保存中…')
1157
+ })
1114
1158
  // 「改目录」只在多库模式出现(v0.28.1):单库模式下这件事由「知识库位置」负责
1115
1159
  // (它写 wiki 之外的指针文件),两处并存会让用户不知道以哪个为准。
1116
1160
  if (view.mode === 'multi') {
@@ -1132,7 +1176,7 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
1132
1176
  })
1133
1177
  actions.append(move)
1134
1178
  }
1135
- actions.append(configure, power, makeDefault, visibility, autostart, remove)
1179
+ actions.append(configure, power, makeDefault, visibility, autostart, iconPicker, remove)
1136
1180
  row.append(actions)
1137
1181
  list.append(row)
1138
1182
  }
@@ -11,12 +11,13 @@
11
11
  */
12
12
  import type { PanelState } from './state.ts'
13
13
  import { fetchUiConfig, subscribeUiConfig } from './ui-config.ts'
14
+ import { fetchStatus } from './status-cache.ts'
15
+ import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
16
+ import { applyWikiIcon } from './wiki-icon.ts'
14
17
 
15
18
  /** Stable data attribute identifying this entry row. */
16
19
  export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
17
20
 
18
- /** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
19
- const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
20
21
 
21
22
  /** Family entries from sibling plugins, kept in a stable relative order. */
22
23
  const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
@@ -46,22 +47,38 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
46
47
  return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
47
48
  }
48
49
 
49
- /** Build the entry row (a detached button; insert once the shell is up). */
50
- function createEntry(state: PanelState, label: string): { entry: HTMLButtonElement; labelEl: HTMLSpanElement } {
50
+ /**
51
+ * Build one entry row (a detached button; inserted once the shell is up).
52
+ *
53
+ * `wikiId` is optional and only used in multi-wiki mode: clicking the row focuses
54
+ * that knowledge base before toggling the panel, so the row a user clicks is the
55
+ * wiki they get. Single-wiki installs pass nothing and behave exactly as before.
56
+ */
57
+ function createEntry(
58
+ state: PanelState,
59
+ label: string,
60
+ wikiId?: string,
61
+ icon?: string,
62
+ ): { entry: HTMLButtonElement; labelEl: HTMLSpanElement; iconEl: HTMLSpanElement } {
51
63
  const entry = document.createElement('button')
52
64
  entry.type = 'button'
53
65
  entry.dataset.dshTwEntry = ''
54
66
  entry.className = 'dsh-tw-entry'
55
- entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
56
- const icon = document.createElement('span')
57
- icon.className = 'dsh-tw-entry-icon'
58
- icon.innerHTML = ICON
67
+ if (wikiId !== undefined) entry.dataset.wiki = wikiId
68
+ entry.setAttribute('aria-label', `TiddlyWiki 知识库:${label}`)
69
+ const iconEl = document.createElement('span')
70
+ iconEl.className = 'dsh-tw-entry-icon'
71
+ applyWikiIcon(iconEl, icon)
59
72
  const labelEl = document.createElement('span')
60
73
  labelEl.className = 'dsh-tw-entry-label'
61
74
  labelEl.textContent = label
62
- entry.append(icon, labelEl)
63
- entry.addEventListener('click', () => { state.toggle() })
64
- return { entry, labelEl }
75
+ entry.append(iconEl, labelEl)
76
+ entry.addEventListener('click', () => {
77
+ // 多库:先把这个库设为「焦点库」,面板随后加载它自己的 /tw/<id>/。
78
+ if (wikiId !== undefined) setFocusWiki(wikiId)
79
+ state.toggle()
80
+ })
81
+ return { entry, labelEl, iconEl }
65
82
  }
66
83
 
67
84
  /** Re-insert the entry before the whole family block (stable ordering). */
@@ -98,7 +115,10 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
98
115
  * @returns disposer removing the entry and its observers.
99
116
  */
100
117
  export function mountSidebarEntry(state: PanelState): () => void {
101
- const { entry, labelEl } = createEntry(state, 'TiddlyWiki')
118
+ /** 多库:每个**在运行**的库一个入口行,用自己的显示名(作者 2026-09-28 要求)。 */
119
+ const rows = new Map<string, { entry: HTMLButtonElement; labelEl: HTMLSpanElement; iconEl: HTMLSpanElement }>()
120
+ const { entry, labelEl, iconEl } = createEntry(state, 'TiddlyWiki')
121
+ rows.set('', { entry, labelEl, iconEl })
102
122
  let disposed = false
103
123
  /**
104
124
  * 自定义显示名:/status 返回 ui.sidebarLabel(设置页「侧边栏入口显示名称」)。
@@ -114,6 +134,62 @@ export function mountSidebarEntry(state: PanelState): () => void {
114
134
  }
115
135
  void applyLabel()
116
136
  const unsubscribeLabel = subscribeUiConfig(() => { void applyLabel() })
137
+
138
+ /**
139
+ * 多库:给**每个在运行**的库挂一个入口行(v0.28.2,作者要求"有多少个 wiki 启动了就按
140
+ * 各自的展示名称显示入口")。
141
+ *
142
+ * 为什么只列「在运行」的:一个入口行点了就该能打开那个库;没在跑的库点开会先卡在启动
143
+ * 上。没在跑的库仍可从右下角「知识库」菜单启动(那里会把它们列全,并标注"未运行")。
144
+ *
145
+ * 单库模式(名册 ≤1):沿用**原来那一个**行与 ui.sidebarLabel —— 行为逐字不变。
146
+ */
147
+ const applyRoster = async (): Promise<void> => {
148
+ const payload = await fetchStatus()
149
+ if (disposed) return
150
+ const list = Array.isArray(payload?.wikis) ? payload.wikis : []
151
+ const running = list.filter((w) => w.running)
152
+ // 单库:不建额外行,标签仍归 ui.sidebarLabel 管。
153
+ if (running.length <= 1) {
154
+ for (const [key, row] of rows) {
155
+ if (key === '') continue
156
+ row.entry.remove()
157
+ rows.delete(key)
158
+ }
159
+ entry.hidden = false
160
+ return
161
+ }
162
+ // 多库:默认那行让位给每个库自己的行(否则会出现一个没有库名的第 4 行)。
163
+ entry.hidden = true
164
+ const wanted = new Set(running.map((w) => w.id))
165
+ // 移除已不在运行/已删除的库
166
+ for (const [key, row] of rows) {
167
+ if (key === '' || wanted.has(key)) continue
168
+ row.entry.remove()
169
+ rows.delete(key)
170
+ }
171
+ // 新增/更新
172
+ for (const w of running) {
173
+ let row = rows.get(w.id)
174
+ if (row === undefined) {
175
+ row = createEntry(state, w.label, w.id, w.icon)
176
+ rows.set(w.id, row)
177
+ }
178
+ row.labelEl.textContent = w.label
179
+ // 图标每次同步(v0.28.4):在设置页改完图标,10s 内的轮询就会把它换过来。
180
+ applyWikiIcon(row.iconEl, w.icon)
181
+ row.entry.setAttribute('aria-label', `TiddlyWiki 知识库:${w.label}`)
182
+ // 当前焦点库高亮
183
+ const focused = resolveFocusWiki(running, payload?.defaultId) === w.id
184
+ if (focused) row.entry.dataset.focus = 'true'
185
+ else delete row.entry.dataset.focus
186
+ }
187
+ // 让放置逻辑把新行插进去(复用同一个 root)
188
+ if (root !== undefined) { placed = false; tryPlace() }
189
+ }
190
+ void applyRoster()
191
+ const unsubscribeFocus = subscribeFocusWiki(() => { void applyRoster() })
192
+ const rosterTimer = window.setInterval(() => { void applyRoster() }, 10_000)
117
193
  let root: HTMLElement | undefined
118
194
  let placed = false
119
195
  /** 兜底轮询定时器:只在「尚未放置」或「shell 重建了 root」时运行。 */
@@ -135,6 +211,10 @@ export function mountSidebarEntry(state: PanelState): () => void {
135
211
  }, 2_000)
136
212
  }
137
213
 
214
+ /** 当前应该出现在侧边栏里的行(隐藏的默认行不算)。 */
215
+ const activeRows = (): HTMLButtonElement[] =>
216
+ [...rows.values()].map((r) => r.entry).filter((el) => !el.hidden)
217
+
138
218
  const tryPlace = (): void => {
139
219
  if (root !== undefined && !root.isConnected) {
140
220
  rootObserver.disconnect()
@@ -144,7 +224,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
144
224
  startRetry()
145
225
  }
146
226
  if (placed) {
147
- if (document.body.contains(entry)) return
227
+ if (activeRows().every((el) => document.body.contains(el))) return
148
228
  rootObserver.disconnect()
149
229
  root = undefined
150
230
  placed = false
@@ -152,7 +232,12 @@ export function mountSidebarEntry(state: PanelState): () => void {
152
232
  }
153
233
  root ??= sidebarRoot()
154
234
  if (root === undefined) return
155
- placed = placeEntry(root, entry)
235
+ // 逐行放置:多库时每一行都要插进侧边栏(顺序按 rows 的插入序)。
236
+ let any = false
237
+ for (const el of activeRows()) {
238
+ if (placeEntry(root, el)) any = true
239
+ }
240
+ placed = any
156
241
  if (placed) {
157
242
  // 放置成功即停掉兜底轮询:后续自愈由 rootObserver/waitObserver 负责。
158
243
  stopRetry()
@@ -164,7 +249,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
164
249
  const waitObserver = new MutationObserver(() => { tryPlace() })
165
250
  waitObserver.observe(document.body, { childList: true, subtree: true })
166
251
 
167
- // Self-heal: re-insert in the same frame when a re-render displaces the row.
252
+ // Self-heal: re-insert in the same frame when a re-render displaces the rows.
168
253
  const rootObserver = new MutationObserver(() => {
169
254
  if (root === undefined || !root.isConnected) {
170
255
  placed = false
@@ -172,7 +257,9 @@ export function mountSidebarEntry(state: PanelState): () => void {
172
257
  tryPlace()
173
258
  return
174
259
  }
175
- if (!root.contains(entry)) placed = placeEntry(root, entry)
260
+ for (const el of activeRows()) {
261
+ if (!root.contains(el)) placeEntry(root, el)
262
+ }
176
263
  })
177
264
 
178
265
  // Belt-and-braces: a late shell mount with no further mutations still heals.
@@ -180,8 +267,11 @@ export function mountSidebarEntry(state: PanelState): () => void {
180
267
  startRetry()
181
268
 
182
269
  const syncActive = (): void => {
183
- if (state.isOpen()) entry.dataset.active = 'true'
184
- else delete entry.dataset.active
270
+ // 面板是共享的:所有行一起高亮/取消,用户看到的"打开中"状态才一致。
271
+ for (const el of activeRows()) {
272
+ if (state.isOpen()) el.dataset.active = 'true'
273
+ else delete el.dataset.active
274
+ }
185
275
  }
186
276
  const unsubscribe = state.subscribe(syncActive)
187
277
  syncActive()
@@ -190,10 +280,13 @@ export function mountSidebarEntry(state: PanelState): () => void {
190
280
  return () => {
191
281
  disposed = true
192
282
  clearInterval(retry)
283
+ window.clearInterval(rosterTimer)
193
284
  waitObserver.disconnect()
194
285
  rootObserver.disconnect()
195
286
  unsubscribe()
196
287
  unsubscribeLabel()
197
- entry.remove()
288
+ unsubscribeFocus()
289
+ for (const row of rows.values()) row.entry.remove()
290
+ rows.clear()
198
291
  }
199
292
  }
@@ -59,6 +59,8 @@ export interface StatusPayload {
59
59
  autostart: boolean
60
60
  running: boolean
61
61
  path: string
62
+ /** Per-wiki entry icon (v0.28.4): a curated name or a short emoji. */
63
+ icon?: string
62
64
  }>
63
65
  ui?: {
64
66
  showQuickNote?: boolean