dsh-tiddlywiki 0.27.2 → 0.28.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.
Files changed (41) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +3 -0
  3. package/cordis.patch.yml +13 -13
  4. package/docs/CHANGELOG.md +2 -1
  5. package/docs/code-review-2026-09-17.md +151 -0
  6. package/docs/features.md +5 -2
  7. package/docs/usage.md +34 -3
  8. package/docs/wiki-split.md +97 -0
  9. package/lib/client.js +54 -27
  10. package/lib/index.js +2803 -517
  11. package/lib/index.js.map +1 -1
  12. package/package.json +5 -4
  13. package/skills/wiki-split/SKILL.md +106 -0
  14. package/src/client/endpoints.ts +37 -0
  15. package/src/client/index.ts +14 -0
  16. package/src/client/knowledge-fab.ts +62 -3
  17. package/src/client/markdown-editor.ts +128 -128
  18. package/src/client/panel.ts +2 -1
  19. package/src/client/settings-page.ts +217 -3
  20. package/src/client/state.ts +39 -39
  21. package/src/client/status-cache.ts +17 -0
  22. package/src/client/styles.ts +27 -0
  23. package/src/client/toast.ts +22 -22
  24. package/src/client/tw-frame.ts +28 -4
  25. package/src/client/wiki-focus.ts +70 -0
  26. package/src/client/wiki-scope-dock.ts +116 -0
  27. package/src/host/admin.ts +175 -35
  28. package/src/host/git.ts +50 -5
  29. package/src/host/path-key.ts +35 -0
  30. package/src/host/prompt.ts +24 -0
  31. package/src/host/repo-committers.ts +149 -0
  32. package/src/host/routes.ts +254 -81
  33. package/src/host/seeds.ts +51 -10
  34. package/src/host/session-scope.ts +154 -0
  35. package/src/host/skill-install.ts +121 -0
  36. package/src/host/tools.ts +141 -49
  37. package/src/host/wiki-farm.ts +417 -0
  38. package/src/host/wiki-instance.ts +596 -0
  39. package/src/host/wiki-registry.ts +601 -0
  40. package/src/host/wiki.ts +19 -0
  41. package/src/index.ts +686 -459
@@ -36,6 +36,7 @@ import {
36
36
  ADMIN_WIKI_LOCATION_ENDPOINT as WIKI_LOCATION_ENDPOINT,
37
37
  ADMIN_WIKI_RESET_ENDPOINT as WIKI_RESET_ENDPOINT,
38
38
  ADMIN_WIKI_SWITCH_ENDPOINT as WIKI_SWITCH_ENDPOINT,
39
+ ADMIN_WIKIS_ENDPOINT as WIKI_LIST_ENDPOINT,
39
40
  SYNC_ENDPOINT,
40
41
  describeSyncResult,
41
42
  type SyncResultPayload,
@@ -189,7 +190,7 @@ function mountSettingsPage(container: HTMLElement): () => void {
189
190
  // 首屏先给占位(v0.24.x):以前 await 期间什么都不画,整块白屏看起来像插件坏了。
190
191
  if (!rendered) body.replaceChildren(make('div', 'dsh-tw-settings-muted', '加载中…'))
191
192
  try {
192
- const state = await fetchJson<AdminState>(STATE_ENDPOINT)
193
+ const state = await fetchJson<AdminState>(withWiki(STATE_ENDPOINT))
193
194
  if (disposed) return
194
195
  rendered = true
195
196
  loadError.hidden = true
@@ -520,7 +521,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
520
521
  // Send the DRAFT (v0.22.7): the host builds the text from these values
521
522
  // without saving them, so the preview follows the dropdown immediately
522
523
  // instead of showing the still-saved mode until 保存配置 is clicked.
523
- const data = await fetchJson<{ ok?: boolean; draft?: boolean; enabled?: boolean; mode?: string; length?: number; text?: string; error?: string }>(PROMPT_ENDPOINT, {
524
+ const data = await fetchJson<{ ok?: boolean; draft?: boolean; enabled?: boolean; mode?: string; length?: number; text?: string; error?: string }>(withWiki(PROMPT_ENDPOINT), {
524
525
  method: 'POST',
525
526
  headers: { 'content-type': 'application/json' },
526
527
  body: JSON.stringify({
@@ -604,7 +605,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
604
605
  return
605
606
  }
606
607
  try {
607
- await fetchJson(CONFIG_ENDPOINT, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
608
+ await fetchJson(withWiki(CONFIG_ENDPOINT), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) })
608
609
  // ui.* 已落盘:清掉客户端缓存,下一次读取立即拿到新值(否则最长 15s 才生效)。
609
610
  invalidateUiConfig()
610
611
  configState.saveError = undefined
@@ -931,6 +932,213 @@ function renderCatalogSection(
931
932
  * stops and restarts the TW child in place — a few seconds, and it can fail
932
933
  * (in which case the host rolls back and says so).
933
934
  */
935
+ /** One knowledge base as `GET /admin/wikis` reports it (v0.28.0). */
936
+ interface WikiListItemView {
937
+ id: string
938
+ label: string
939
+ root: string
940
+ name: string
941
+ path: string
942
+ agentVisible: boolean
943
+ autostart: boolean
944
+ running: boolean
945
+ status: string
946
+ }
947
+
948
+ /** `GET`/`POST /dsh-tiddlywiki/admin/wikis` payload (v0.28.0). */
949
+ interface WikisView {
950
+ ok?: boolean
951
+ error?: string
952
+ mode?: string
953
+ defaultId?: string
954
+ source?: string
955
+ registryFile?: string
956
+ warnings?: string[]
957
+ wikis?: WikiListItemView[]
958
+ change?: { started?: string[]; stopped?: string[]; updated?: string[]; errors?: Array<{ id: string; message: string }> }
959
+ }
960
+
961
+ /**
962
+ * 知识库列表(v0.28.0)—— 设置页的**第一块**。
963
+ *
964
+ * 为什么排在最前:多库的其余一切都要先有第二个库才能用,而在此之前多库模式只能靠
965
+ * 手写 `$DSH_HOME/dsh-tiddlywiki/wikis.json` 打开。
966
+ *
967
+ * 它显示的是**控制文件**(用户编辑的那份),与"现在跑着什么"是两件事:single 模式下
968
+ * farm 跑的是从旧指针合成的单条清单,而文件里可能已经列了好几个候选。所以每行同时给出
969
+ * 「在不在清单里」与「现在跑没跑」。
970
+ *
971
+ * 关于「编辑哪个库的配置」:常规配置区作用于**默认库**(★ 那一行)——多库时这是刻意的
972
+ * 简化,`git.*` 实际按仓库生效(见下),其余字段各库自己一份。
973
+ */
974
+ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, refresh: () => Promise<void>): void {
975
+ const section = make('section', 'dsh-tw-settings-section')
976
+ section.append(make('h3', 'dsh-tw-settings-h', '知识库列表'))
977
+ const status = make('div', 'dsh-tw-settings-muted', '读取中…')
978
+ const modeRow = make('div', 'dsh-tw-settings-row')
979
+ const list = make('div')
980
+ const addRoot = make('input', 'dsh-tw-settings-input')
981
+ addRoot.placeholder = '根目录(绝对路径,如 D:\\notes)'
982
+ const addName = make('input', 'dsh-tw-settings-input')
983
+ addName.placeholder = '文件夹名(如 books)'
984
+ const addLabel = make('input', 'dsh-tw-settings-input')
985
+ addLabel.placeholder = '显示名(留空用文件夹名)'
986
+ const addBtn = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '添加知识库')
987
+ addBtn.type = 'button'
988
+ const addRow = make('div', 'dsh-tw-settings-row dsh-tw-settings-field')
989
+ addRow.append(addRoot, addName, addLabel, addBtn)
990
+ const hint = make('div', 'dsh-tw-settings-muted', '多库模式下每个知识库是一个独立的 TW 子进程(约 150MB):标了「开局自启」的随 DSH 一起起,其余在你打开或选中它时启动。对 Agent 隐身的库不进会话选择器,检索/工具也永远不碰它。')
991
+ section.append(status, modeRow, list, addRow, hint)
992
+ body.append(section)
993
+
994
+ /** One action, then re-read (the response already carries the new state). */
995
+ const post = async (payload: Record<string, unknown>, busyLabel: string): Promise<void> => {
996
+ status.textContent = busyLabel
997
+ try {
998
+ const data = await fetchJson<WikisView>(WIKI_LIST_ENDPOINT, {
999
+ method: 'POST',
1000
+ headers: { 'content-type': 'application/json' },
1001
+ body: JSON.stringify(payload),
1002
+ // 新增/启动一个库可能要 `--init server` 并冷启动 TW(实测大库 40s+)。
1003
+ signal: AbortSignal.timeout(180_000),
1004
+ })
1005
+ if (isDisposed()) return
1006
+ const failures = data.change?.errors ?? []
1007
+ if (failures.length > 0) toast(`部分操作失败:${failures.map((item) => `${item.id}:${item.message}`).join(';')}`)
1008
+ render(data)
1009
+ await refresh()
1010
+ } catch (err) {
1011
+ if (isDisposed()) return
1012
+ toast(`操作失败:${err instanceof Error ? err.message : String(err)}`)
1013
+ await load()
1014
+ }
1015
+ }
1016
+
1017
+ const render = (view: WikisView): void => {
1018
+ status.textContent = view.error !== undefined && view.error.length > 0 ? `⚠️ ${view.error}` : ''
1019
+ modeRow.replaceChildren(make('span', 'dsh-tw-settings-label', '运行模式:'))
1020
+ const modes: Array<[string, string]> = [
1021
+ ['single', '单库(只跑默认库)'],
1022
+ ['multi', '多库(清单里的库都可用)'],
1023
+ ]
1024
+ for (const [value, text] of modes) {
1025
+ const active = view.mode === value
1026
+ const chip = make('button', `dsh-tw-settings-btn dsh-tw-settings-chipbtn${active ? ' dsh-tw-settings-primary' : ''}`, text)
1027
+ chip.type = 'button'
1028
+ chip.disabled = active
1029
+ chip.addEventListener('click', () => {
1030
+ const ok = window.confirm(value === 'multi'
1031
+ ? '切换到多库模式:清单里标了「开局自启」的知识库都会启动(每个库一个 TW 子进程)。继续?'
1032
+ : '切换到单库模式:除默认库外的知识库会被停掉(内容与清单都不受影响)。继续?')
1033
+ if (ok) void post({ action: 'set-mode', mode: value }, '切换模式中…')
1034
+ })
1035
+ modeRow.append(chip)
1036
+ }
1037
+
1038
+ list.replaceChildren()
1039
+ const wikis = view.wikis ?? []
1040
+ for (const wiki of wikis) {
1041
+ const row = make('div', 'dsh-tw-settings-kbrow')
1042
+ const isDefault = wiki.id === view.defaultId
1043
+ row.append(make('div', 'dsh-tw-settings-label', `${isDefault ? '★ ' : ''}${wiki.label}(${wiki.id})`))
1044
+ row.append(make('div', 'dsh-tw-settings-muted', [
1045
+ wiki.running ? '运行中' : '未运行',
1046
+ wiki.agentVisible ? 'Agent 可见' : 'Agent 隐身',
1047
+ wiki.autostart ? '开局自启' : '按需启动',
1048
+ wiki.path,
1049
+ ].join(' · ')))
1050
+
1051
+ const actions = make('div', 'dsh-tw-settings-row')
1052
+ // 「配置」把常规配置区切到这个库(每个库的配置存在它自己的 config tiddler 里,
1053
+ // 不说清楚就会出现"改了但不生效")。
1054
+ const configuring = editingWiki === wiki.id
1055
+ const configure = make('button', `dsh-tw-settings-btn dsh-tw-settings-chipbtn${configuring ? ' dsh-tw-settings-primary' : ''}`, configuring ? '正在配置' : '配置')
1056
+ configure.type = 'button'
1057
+ configure.disabled = configuring
1058
+ configure.addEventListener('click', () => {
1059
+ editingWiki = wiki.id
1060
+ void refresh()
1061
+ })
1062
+ const power = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', wiki.running ? '停止' : '启动')
1063
+ power.type = 'button'
1064
+ power.addEventListener('click', () => {
1065
+ void post({ action: wiki.running ? 'stop' : 'start', id: wiki.id }, wiki.running ? '停止中…' : '启动中…')
1066
+ })
1067
+ const makeDefault = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', '设为默认')
1068
+ makeDefault.type = 'button'
1069
+ makeDefault.disabled = isDefault
1070
+ makeDefault.addEventListener('click', () => { void post({ action: 'set-default', id: wiki.id }, '保存中…') })
1071
+ const visibility = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', wiki.agentVisible ? '对 Agent 隐身' : '对 Agent 可见')
1072
+ visibility.type = 'button'
1073
+ visibility.addEventListener('click', () => {
1074
+ void post({ action: 'update', wiki: { ...wiki, agentVisible: !wiki.agentVisible } }, '保存中…')
1075
+ })
1076
+ const autostart = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', wiki.autostart ? '取消开局自启' : '开局自启')
1077
+ autostart.type = 'button'
1078
+ autostart.addEventListener('click', () => {
1079
+ void post({ action: 'update', wiki: { ...wiki, autostart: !wiki.autostart } }, '保存中…')
1080
+ })
1081
+ const remove = make('button', 'dsh-tw-settings-btn dsh-tw-settings-chipbtn', '移出列表')
1082
+ remove.type = 'button'
1083
+ remove.disabled = wikis.length <= 1
1084
+ remove.addEventListener('click', () => {
1085
+ const ok = window.confirm(`把「${wiki.label}」移出清单?\n\n**目录与内容不会被删除**(仍在 ${wiki.path}),只是插件不再管理它。`)
1086
+ if (ok) void post({ action: 'remove', id: wiki.id }, '移出中…')
1087
+ })
1088
+ actions.append(configure, power, makeDefault, visibility, autostart, remove)
1089
+ row.append(actions)
1090
+ list.append(row)
1091
+ }
1092
+ // 新增时默认沿用第一个库的根目录(多数情况就是同一个父目录)。
1093
+ if (addRoot.value.length === 0 && typeof wikis[0]?.root === 'string') addRoot.value = wikis[0].root
1094
+ }
1095
+
1096
+ addBtn.addEventListener('click', () => {
1097
+ const root = addRoot.value.trim()
1098
+ const name = addName.value.trim()
1099
+ if (root.length === 0 || name.length === 0) {
1100
+ toast('请填写根目录与文件夹名')
1101
+ return
1102
+ }
1103
+ const wiki: Record<string, unknown> = { root, name }
1104
+ const label = addLabel.value.trim()
1105
+ if (label.length > 0) wiki.label = label
1106
+ void post({ action: 'add', wiki }, '添加中(新库需要初始化与冷启动,请稍候)…')
1107
+ })
1108
+
1109
+ const load = async (): Promise<void> => {
1110
+ if (isDisposed()) return
1111
+ try {
1112
+ const view = await fetchJson<WikisView>(WIKI_LIST_ENDPOINT)
1113
+ if (isDisposed()) return
1114
+ render(view)
1115
+ } catch (err) {
1116
+ if (isDisposed()) return
1117
+ status.textContent = `读取知识库列表失败:${err instanceof Error ? err.message : String(err)}`
1118
+ }
1119
+ }
1120
+ void load()
1121
+ }
1122
+
1123
+ /**
1124
+ * WHICH knowledge base the config block is editing (v0.28.0).
1125
+ *
1126
+ * Every per-wiki setting lives in that wiki's OWN
1127
+ * `$:/plugins/dsh-tiddlywiki/config` tiddler, so `/admin/state`, `/admin/config`
1128
+ * and `/admin/prompt` must be told which one. Without this, configuring the books
1129
+ * wiki would silently edit the DEFAULT wiki — a user would set `git.remote`, see
1130
+ * it saved, and never learn why pushes still went to the other repository.
1131
+ *
1132
+ * `undefined` = the default wiki, which is exactly what every route did before
1133
+ * this existed (and what a single-wiki install always gets).
1134
+ */
1135
+ let editingWiki: string | undefined
1136
+
1137
+ /** Append the editing scope to a per-wiki admin URL. */
1138
+ function withWiki(url: string): string {
1139
+ return editingWiki === undefined ? url : `${url}?wiki=${encodeURIComponent(editingWiki)}`
1140
+ }
1141
+
934
1142
  function renderWikiLocationSection(body: HTMLElement, isDisposed: () => boolean, refresh: () => Promise<void>): void {
935
1143
  const section = make('section', 'dsh-tw-settings-section')
936
1144
  section.append(make('h3', 'dsh-tw-settings-h', '知识库位置(可切换)'))
@@ -1322,7 +1530,13 @@ function renderMain(body: HTMLElement, state: AdminState, refresh: () => Promise
1322
1530
  configState.host = host
1323
1531
  configState.signature = signature
1324
1532
  }
1533
+ // 配置作用域(v0.28.0):每个库的配置存在它自己的 config tiddler 里,含糊其辞就会
1534
+ // 出现"改了但不生效"——所以这一行必须说清下面的表单在编辑哪个库。
1535
+ body.append(make('div', 'dsh-tw-settings-muted', editingWiki === undefined
1536
+ ? '配置作用域:默认知识库(在「知识库列表」里点某行的「配置」可切到那个库)'
1537
+ : `配置作用域:${editingWiki}(在「知识库列表」里改;编辑的是该库自己的配置 tiddler)`))
1325
1538
  body.append(configState.host)
1539
+ renderWikiListSection(body, isDisposed, refresh)
1326
1540
  renderWikiLocationSection(body, isDisposed, refresh)
1327
1541
  renderCatalogSection(body, state.info, state.catalog, state.runtimePlugins, refresh, catalogPending)
1328
1542
  renderSeedsSection(body, isDisposed)
@@ -1,39 +1,39 @@
1
- /**
2
- * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
- *
4
- * @module dsh-tiddlywiki/client/state
5
- */
6
-
7
- export type PanelListener = (open: boolean) => void
8
-
9
- export class PanelState {
10
- private open = false
11
- private readonly listeners = new Set<PanelListener>()
12
-
13
- isOpen(): boolean {
14
- return this.open
15
- }
16
-
17
- toggle(): void {
18
- this.set(!this.open)
19
- }
20
-
21
- openPanel(): void {
22
- this.set(true)
23
- }
24
-
25
- closePanel(): void {
26
- this.set(false)
27
- }
28
-
29
- set(value: boolean): void {
30
- if (this.open === value) return
31
- this.open = value
32
- for (const listener of [...this.listeners]) listener(value)
33
- }
34
-
35
- subscribe(listener: PanelListener): () => void {
36
- this.listeners.add(listener)
37
- return () => { this.listeners.delete(listener) }
38
- }
39
- }
1
+ /**
2
+ * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
+ *
4
+ * @module dsh-tiddlywiki/client/state
5
+ */
6
+
7
+ export type PanelListener = (open: boolean) => void
8
+
9
+ export class PanelState {
10
+ private open = false
11
+ private readonly listeners = new Set<PanelListener>()
12
+
13
+ isOpen(): boolean {
14
+ return this.open
15
+ }
16
+
17
+ toggle(): void {
18
+ this.set(!this.open)
19
+ }
20
+
21
+ openPanel(): void {
22
+ this.set(true)
23
+ }
24
+
25
+ closePanel(): void {
26
+ this.set(false)
27
+ }
28
+
29
+ set(value: boolean): void {
30
+ if (this.open === value) return
31
+ this.open = value
32
+ for (const listener of [...this.listeners]) listener(value)
33
+ }
34
+
35
+ subscribe(listener: PanelListener): () => void {
36
+ this.listeners.add(listener)
37
+ return () => { this.listeners.delete(listener) }
38
+ }
39
+ }
@@ -43,6 +43,23 @@ export interface StatusPayload {
43
43
  behind?: number
44
44
  }
45
45
  note?: { tag?: string }
46
+ /**
47
+ * The knowledge-base roster (v0.28.0). `mode` decides whether per-wiki paths
48
+ * are used at all; `defaultId` is what the agent falls back to. Deliberately
49
+ * WITHOUT per-wiki git status — that would spawn up to five git processes per
50
+ * wiki on every poll.
51
+ */
52
+ mode?: string
53
+ defaultId?: string
54
+ wikis?: Array<{
55
+ id: string
56
+ label: string
57
+ status: string
58
+ agentVisible: boolean
59
+ autostart: boolean
60
+ running: boolean
61
+ path: string
62
+ }>
46
63
  ui?: {
47
64
  showQuickNote?: boolean
48
65
  showQuickNoteDock?: boolean
@@ -299,6 +299,12 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
299
299
  /* ── settings page (config panel §13) ────────────────────────── */
300
300
  .dsh-tw-settings { display: flex; flex-direction: column; gap: 10px; padding: 12px 16px; min-width: 0; }
301
301
  .dsh-tw-settings-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
302
+ /* 知识库列表里的一行(v0.28.0):卡片式,便于扫读"哪个库、在哪、在不在跑"。 */
303
+ .dsh-tw-settings-kbrow {
304
+ display: flex; flex-direction: column; gap: 4px;
305
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12)); border-radius: 8px;
306
+ padding: 8px 10px; margin: 0 0 8px;
307
+ }
302
308
  .dsh-tw-settings-chip {
303
309
  font-size: 12px; padding: 2px 10px; border-radius: 999px;
304
310
  border: 1px solid var(--dsw-alias-border-l2, rgba(128,128,128,.25));
@@ -486,6 +492,27 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
486
492
  }
487
493
  .dsh-tw-fab-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
488
494
  .dsh-tw-fab-item:active { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); }
495
+ /* 多知识库切换分组(v0.28.0):一行小标题 + 每库一行;当前焦点用 ● 标记。 */
496
+ .dsh-tw-fab-group {
497
+ font-size: 11px; opacity: .6; padding: 6px 9px 2px; letter-spacing: .04em;
498
+ }
499
+ .dsh-tw-fab-item[data-current="1"] { font-weight: 600; }
500
+
501
+ /* ── 会话级知识库选择器(v0.28.0,同一个 conversation.input.dock 槽位)──
502
+ 与快速笔记按钮同区、纵向 flex 排列;只在"多于一个可见库"时渲染(组件自己返回
503
+ null),所以单库安装这一整块都不存在。 */
504
+ .dsh-tw-scope-dock {
505
+ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
506
+ padding: 4px 8px 0; box-sizing: border-box; width: 100%; font-size: 12px;
507
+ }
508
+ .dsh-tw-scope-label { opacity: .7; }
509
+ .dsh-tw-scope-select {
510
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
511
+ background: var(--dsw-alias-bg-layer-2, #fff); color: inherit; font: inherit; font-size: 12px;
512
+ padding: 3px 8px; border-radius: 999px; max-width: 260px;
513
+ }
514
+ .dsh-tw-scope-select:disabled { opacity: .6; }
515
+ .dsh-tw-scope-note { opacity: .75; }
489
516
 
490
517
  /* ── input-dock quick-note button(聊天输入框上方,conversation.input.dock)──
491
518
  该槽位渲染为「输入框上方的全宽条目」纵向 flex 排列(todo/cost-meter/goal/
@@ -1,22 +1,22 @@
1
- /**
2
- * One-shot transient toast, shared by the quick-note widget and the settings
3
- * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
- * class; the element is created lazily and styled by styles.ts.
5
- *
6
- * @module dsh-tiddlywiki/client/toast
7
- */
8
-
9
- export function toast(message: string): void {
10
- let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
- if (el === null) {
12
- el = document.createElement('div')
13
- el.className = 'dsh-tw-toast'
14
- document.body.append(el)
15
- }
16
- el.textContent = message
17
- el.classList.add('dsh-tw-toast-show')
18
- clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
- ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
- el?.classList.remove('dsh-tw-toast-show')
21
- }, 2_500)
22
- }
1
+ /**
2
+ * One-shot transient toast, shared by the quick-note widget and the settings
3
+ * page. Reuses a single body-level element (`.dsh-tw-toast`) with a show
4
+ * class; the element is created lazily and styled by styles.ts.
5
+ *
6
+ * @module dsh-tiddlywiki/client/toast
7
+ */
8
+
9
+ export function toast(message: string): void {
10
+ let el = document.querySelector<HTMLElement>('.dsh-tw-toast')
11
+ if (el === null) {
12
+ el = document.createElement('div')
13
+ el.className = 'dsh-tw-toast'
14
+ document.body.append(el)
15
+ }
16
+ el.textContent = message
17
+ el.classList.add('dsh-tw-toast-show')
18
+ clearTimeout((el as HTMLElement & { __twToastTimer?: number }).__twToastTimer)
19
+ ;(el as HTMLElement & { __twToastTimer?: number }).__twToastTimer = window.setTimeout(() => {
20
+ el?.classList.remove('dsh-tw-toast-show')
21
+ }, 2_500)
22
+ }
@@ -27,8 +27,9 @@
27
27
  * @module dsh-tiddlywiki/client/tw-frame
28
28
  */
29
29
  import * as React from 'react'
30
- import { RESTART_ENDPOINT, resolveTwUrl } from './endpoints.ts'
30
+ import { RESTART_ENDPOINT, resolveTwUrl, twProxyFor } from './endpoints.ts'
31
31
  import { fetchStatus } from './status-cache.ts'
32
+ import { getFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
32
33
  import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
33
34
 
34
35
  /** Cross-plugin activation event; detail is the activating panel name. */
@@ -140,7 +141,16 @@ export interface TwFrameSurface {
140
141
  * (starting / error / running, iframe URL, theme config, shared chip label)
141
142
  * and every tiddler-hash navigation is implemented here ONCE.
142
143
  */
143
- export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
144
+ /**
145
+ * Per-surface hooks (v0.28.0). `wikiId` answers "which knowledge base should
146
+ * this surface embed?" — the center panel and the rightbar tab both embed the
147
+ * wiki the user focused, and reload when that choice changes.
148
+ */
149
+ export interface TwFrameHooks {
150
+ wikiId?: () => string | undefined
151
+ }
152
+
153
+ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}): TwFrameSurface {
144
154
  let visible = false
145
155
  let started = false
146
156
  let disposed = false
@@ -318,7 +328,10 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
318
328
  // resolveTwUrl prefers the host's ABSOLUTE loopback base when THIS page
319
329
  // is not on http(s) (the DSH desktop app's `dsh-app:` renderer), because
320
330
  // TW refuses to load its sync adaptor anywhere else — see resolveTwUrl.
321
- showFrame(resolveTwUrl(payload.twProxy, payload.twProxyAbsolute))
331
+ // WHICH knowledge base (v0.28.0): the focused one in multi mode, else the
332
+ // bare path (= the default wiki, exactly as before).
333
+ const bases = twProxyFor(payload.mode, hooks.wikiId?.(), payload.twProxy, payload.twProxyAbsolute)
334
+ showFrame(resolveTwUrl(bases.relative, bases.absolute))
322
335
  } else if (typeof payload.url === 'string') {
323
336
  showFrame(payload.url)
324
337
  } else {
@@ -355,6 +368,16 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
355
368
  }
356
369
  document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
357
370
 
371
+ /**
372
+ * Switching the focused knowledge base needs a different proxy path, so the
373
+ * frame reloads (v0.28.0). TW reloads inside the iframe — one iframe cannot
374
+ * host two editors, and silently keeping the old wiki's data while the UI says
375
+ * otherwise is exactly the confusion this feature must not create.
376
+ */
377
+ const unsubscribeFocus = subscribeFocusWiki(() => {
378
+ if (!disposed && visible) void doRefresh()
379
+ })
380
+
358
381
  const build = (): HTMLDivElement => {
359
382
  if (view !== undefined) return view
360
383
  const viewEl = document.createElement('div')
@@ -429,6 +452,7 @@ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
429
452
  if (disposed) return
430
453
  disposed = true
431
454
  document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
455
+ unsubscribeFocus()
432
456
  clearRetry()
433
457
  for (const timer of hashWaitTimers) window.clearTimeout(timer)
434
458
  hashWaitTimers.clear()
@@ -505,7 +529,7 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
505
529
  // liveFrames forever. Hand back a no-op controller and build nothing.
506
530
  if (signal.aborted) return disposedController()
507
531
 
508
- const surface = createTwFrameSurface(RIGHTBAR_SKIN)
532
+ const surface = createTwFrameSurface(RIGHTBAR_SKIN, { wikiId: getFocusWiki })
509
533
  host.append(surface.build())
510
534
 
511
535
  let disposed = false
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Which knowledge base the GUI is currently working in (v0.28.0).
3
+ *
4
+ * The GUI is single-focus by design: the center panel and the rightbar tab each
5
+ * embed ONE wiki's editor at a time (`/tw/<id>/…`), and the FAB lists the others.
6
+ * The choice is remembered PER BROWSER (localStorage) rather than per session —
7
+ * it is a viewing preference, and reopening the GUI should land where you left
8
+ * off.
9
+ *
10
+ * A remembered id is only a HINT: `resolveFocusWiki()` validates it against the
11
+ * live roster from `/status` and falls back to the registry's default, so
12
+ * removing or renaming a wiki can never strand the panel on a 404.
13
+ *
14
+ * @module dsh-tiddlywiki/client/wiki-focus
15
+ */
16
+
17
+ /** localStorage key holding the focused wiki id. */
18
+ const STORAGE_KEY = 'dsh-tiddlywiki.focusWiki'
19
+
20
+ function readStored(): string | undefined {
21
+ try {
22
+ const raw = window.localStorage.getItem(STORAGE_KEY)
23
+ return raw !== null && raw.length > 0 ? raw : undefined
24
+ } catch {
25
+ // Private mode / storage disabled: the focus simply is not remembered.
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ let focusId: string | undefined = readStored()
31
+ const listeners = new Set<(id: string | undefined) => void>()
32
+
33
+ /** The remembered wiki id (may not exist any more — use `resolveFocusWiki`). */
34
+ export function getFocusWiki(): string | undefined {
35
+ return focusId
36
+ }
37
+
38
+ /** Remember a new focused wiki (and notify every surface). */
39
+ export function setFocusWiki(id: string | undefined): void {
40
+ if (id === focusId) return
41
+ focusId = id
42
+ try {
43
+ if (id === undefined) window.localStorage.removeItem(STORAGE_KEY)
44
+ else window.localStorage.setItem(STORAGE_KEY, id)
45
+ } catch {
46
+ /* a preference that cannot be persisted is still honoured for this page */
47
+ }
48
+ for (const listener of [...listeners]) listener(id)
49
+ }
50
+
51
+ /** Subscribe to focus changes; returns the unsubscribe function. */
52
+ export function subscribeFocusWiki(listener: (id: string | undefined) => void): () => void {
53
+ listeners.add(listener)
54
+ return () => { listeners.delete(listener) }
55
+ }
56
+
57
+ /** The minimum a roster entry must expose to be focusable. */
58
+ export interface FocusableWiki { id: string }
59
+
60
+ /**
61
+ * The wiki the GUI should actually show: the remembered one when it still
62
+ * exists, else the registry default, else the first entry. `undefined` only when
63
+ * there is nothing to show at all.
64
+ */
65
+ export function resolveFocusWiki(roster: readonly FocusableWiki[], defaultId: string | undefined): string | undefined {
66
+ if (roster.length === 0) return focusId
67
+ if (focusId !== undefined && roster.some((entry) => entry.id === focusId)) return focusId
68
+ if (defaultId !== undefined && roster.some((entry) => entry.id === defaultId)) return defaultId
69
+ return roster[0]?.id
70
+ }