dsh-tiddlywiki 0.16.11 → 0.16.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/client.bundle.js +26 -84
- package/lib/client.js +26 -84
- package/lib/index.js +300 -477
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +21 -12
- package/src/client/endpoints.ts +10 -0
- package/src/client/index.ts +0 -4
- package/src/client/knowledge-fab.ts +14 -8
- package/src/client/note-widget.ts +28 -9
- package/src/client/panel.ts +11 -6
- package/src/client/quick-note-dock.ts +1 -4
- package/src/client/session-summary.ts +29 -1
- package/src/client/settings-page.ts +7 -2
- package/src/client/sidebar-entry.ts +2 -1
- package/src/client/styles.ts +0 -58
- package/src/client/sync-button.ts +5 -3
- package/src/client/ui-config.ts +1 -1
- package/src/host/admin.ts +2 -24
- package/src/host/http.ts +57 -0
- package/src/host/routes.ts +25 -92
- package/src/host/seeds.ts +114 -167
- package/src/host/session-summary.ts +35 -15
- package/src/host/tools.ts +41 -33
- package/src/index.ts +20 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.13",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -76,15 +76,19 @@ function ensurePopup(): void {
|
|
|
76
76
|
})
|
|
77
77
|
|
|
78
78
|
// Drag by the title bar (un-center by setting explicit left/top + margin 0).
|
|
79
|
-
bar
|
|
79
|
+
// Pointer Events + setPointerCapture: the pointer is captured by the bar, so
|
|
80
|
+
// pointermove/pointerup keep firing on it even over the iframe or outside the
|
|
81
|
+
// window — no window-level listeners, hence nothing to leak on a lost mouseup.
|
|
82
|
+
bar.addEventListener('pointerdown', (event) => {
|
|
80
83
|
if (event.button !== 0 || root === undefined) return
|
|
81
84
|
event.preventDefault()
|
|
85
|
+
bar.setPointerCapture(event.pointerId)
|
|
82
86
|
const rect = root.getBoundingClientRect()
|
|
83
87
|
const startX = event.clientX
|
|
84
88
|
const startY = event.clientY
|
|
85
89
|
const baseLeft = rect.left
|
|
86
90
|
const baseTop = rect.top
|
|
87
|
-
const onMove = (ev:
|
|
91
|
+
const onMove = (ev: PointerEvent): void => {
|
|
88
92
|
if (root === undefined) return
|
|
89
93
|
root.style.left = `${baseLeft + ev.clientX - startX}px`
|
|
90
94
|
root.style.top = `${baseTop + ev.clientY - startY}px`
|
|
@@ -93,24 +97,27 @@ function ensurePopup(): void {
|
|
|
93
97
|
root.style.bottom = 'auto'
|
|
94
98
|
}
|
|
95
99
|
const onUp = (): void => {
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
bar.removeEventListener('pointermove', onMove)
|
|
101
|
+
bar.removeEventListener('pointerup', onUp)
|
|
102
|
+
bar.removeEventListener('pointercancel', onUp)
|
|
98
103
|
}
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
bar.addEventListener('pointermove', onMove)
|
|
105
|
+
bar.addEventListener('pointerup', onUp)
|
|
106
|
+
bar.addEventListener('pointercancel', onUp)
|
|
101
107
|
})
|
|
102
108
|
|
|
103
109
|
// Resize from the bottom-right corner.
|
|
104
|
-
resize.addEventListener('
|
|
110
|
+
resize.addEventListener('pointerdown', (event) => {
|
|
105
111
|
if (event.button !== 0 || root === undefined) return
|
|
106
112
|
event.preventDefault()
|
|
107
113
|
event.stopPropagation()
|
|
114
|
+
resize.setPointerCapture(event.pointerId)
|
|
108
115
|
const rect = root.getBoundingClientRect()
|
|
109
116
|
const startX = event.clientX
|
|
110
117
|
const startY = event.clientY
|
|
111
118
|
const baseW = rect.width
|
|
112
119
|
const baseH = rect.height
|
|
113
|
-
const onMove = (ev:
|
|
120
|
+
const onMove = (ev: PointerEvent): void => {
|
|
114
121
|
if (root === undefined) return
|
|
115
122
|
root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
|
|
116
123
|
root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
|
|
@@ -118,10 +125,12 @@ function ensurePopup(): void {
|
|
|
118
125
|
root.style.bottom = 'auto'
|
|
119
126
|
}
|
|
120
127
|
const onUp = (): void => {
|
|
121
|
-
|
|
122
|
-
|
|
128
|
+
resize.removeEventListener('pointermove', onMove)
|
|
129
|
+
resize.removeEventListener('pointerup', onUp)
|
|
130
|
+
resize.removeEventListener('pointercancel', onUp)
|
|
123
131
|
}
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
resize.addEventListener('pointermove', onMove)
|
|
133
|
+
resize.addEventListener('pointerup', onUp)
|
|
134
|
+
resize.addEventListener('pointercancel', onUp)
|
|
126
135
|
})
|
|
127
136
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin DSH endpoints the client calls. The authoritative route table
|
|
3
|
+
* lives in src/host/routes.ts (see AGENTS.md §1「DSH 路由」) — this is just the
|
|
4
|
+
* client-side mirror, so the path string exists once per endpoint instead of
|
|
5
|
+
* being re-typed in every widget.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-tiddlywiki/client/endpoints
|
|
8
|
+
*/
|
|
9
|
+
export const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
|
|
10
|
+
export const GET_ENDPOINT = '/dsh-tiddlywiki/get'
|
package/src/client/index.ts
CHANGED
|
@@ -71,10 +71,6 @@ export function apply(ctx: ClientContextFace): void {
|
|
|
71
71
|
if (ctx.slots !== undefined) {
|
|
72
72
|
void fetchUiConfig().then((cfg) => {
|
|
73
73
|
if (clientDisposed) return
|
|
74
|
-
// 输入框上方「快速笔记」快捷按钮(conversation.input.dock 槽位)。该槽位
|
|
75
|
-
// 是官方为「输入框上方的全宽条目」预留的挂载点,todo/cost-meter/goal/
|
|
76
|
-
// queue/git-graph 等插件内容都渲染在这里、按纵向 flex 排列,天然不重叠。
|
|
77
|
-
// 由 ui.showQuickNoteDock 配置控制(默认开)。
|
|
78
74
|
if (cfg.showQuickNoteDock) {
|
|
79
75
|
const removeDock = ctx.slots?.inject('conversation.input.dock', () =>
|
|
80
76
|
ctx.slots?.register(
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
import type { PanelState } from './state.ts'
|
|
20
20
|
import type { NoteWidgetHandle } from './note-widget.ts'
|
|
21
21
|
import type { SyncController } from './sync-button.ts'
|
|
22
|
+
import { PANEL_RELOAD_EVENT } from './panel.ts'
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
import { STATUS_ENDPOINT } from './endpoints.ts'
|
|
24
25
|
|
|
25
26
|
/** Book icon (same visual family as the sidebar entry). */
|
|
26
27
|
const BOOK_ICON = '<svg viewBox="0 0 16 16" width="17" height="17" 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>'
|
|
@@ -84,6 +85,15 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
84
85
|
if (tip !== undefined) tip.hidden = true
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** Capture-phase outside-click handler (closes the open menu); must be
|
|
89
|
+
* removable so unmount does not leak the document listener. */
|
|
90
|
+
const onDocumentClick = (event: Event): void => {
|
|
91
|
+
if (!menuOpen) return
|
|
92
|
+
const target = event.target as Node
|
|
93
|
+
if (root !== undefined && root.contains(target)) return
|
|
94
|
+
closeMenu()
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
/** Rebuild the TW status row's hover tip (TW 服务 + git 状态 + 最近日志). */
|
|
88
98
|
const renderTip = (): void => {
|
|
89
99
|
if (tip === undefined) return
|
|
@@ -181,7 +191,7 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
181
191
|
reload.textContent = '🔄 重载 TW 面板'
|
|
182
192
|
reload.addEventListener('click', () => {
|
|
183
193
|
closeMenu()
|
|
184
|
-
document.dispatchEvent(new CustomEvent(
|
|
194
|
+
document.dispatchEvent(new CustomEvent(PANEL_RELOAD_EVENT))
|
|
185
195
|
void refreshTwStatus()
|
|
186
196
|
})
|
|
187
197
|
menu.append(reload)
|
|
@@ -224,12 +234,7 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
224
234
|
renderDot()
|
|
225
235
|
|
|
226
236
|
// Outside click closes the menu.
|
|
227
|
-
document.addEventListener('click',
|
|
228
|
-
if (!menuOpen) return
|
|
229
|
-
const target = event.target as Node
|
|
230
|
-
if (root !== undefined && root.contains(target)) return
|
|
231
|
-
closeMenu()
|
|
232
|
-
}, true)
|
|
237
|
+
document.addEventListener('click', onDocumentClick, true)
|
|
233
238
|
}
|
|
234
239
|
|
|
235
240
|
// Reflect panel open/close in the menu label.
|
|
@@ -247,6 +252,7 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
247
252
|
|
|
248
253
|
return () => {
|
|
249
254
|
disposed = true
|
|
255
|
+
document.removeEventListener('click', onDocumentClick, true)
|
|
250
256
|
unsubPanel()
|
|
251
257
|
unsubSync()
|
|
252
258
|
root?.remove()
|
|
@@ -32,13 +32,19 @@ import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
|
|
|
32
32
|
|
|
33
33
|
const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
|
|
34
34
|
const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
|
|
35
|
-
|
|
35
|
+
import { STATUS_ENDPOINT } from './endpoints.ts'
|
|
36
36
|
const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
|
|
37
37
|
const RECENT_ENDPOINT = '/dsh-tiddlywiki/recent'
|
|
38
38
|
const GET_ENDPOINT = '/dsh-tiddlywiki/get'
|
|
39
39
|
const UPLOAD_ENDPOINT = '/dsh-tiddlywiki/upload'
|
|
40
40
|
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Broadcast by the card on open/close (detail: { open }). The input-dock quick-
|
|
44
|
+
* note button (quick-note-dock.ts) listens to highlight while the card is open.
|
|
45
|
+
*/
|
|
46
|
+
export const NOTE_STATE_EVENT = 'dsh-tw-note-state'
|
|
47
|
+
|
|
42
48
|
/** localStorage key for the autosaved quick-note draft. */
|
|
43
49
|
const DRAFT_KEY = 'dsh-tw-note-draft-v1'
|
|
44
50
|
/** Draft auto-save debounce. */
|
|
@@ -119,6 +125,8 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
|
|
|
119
125
|
getTags: () => string[]
|
|
120
126
|
setDefault: (tag: string) => void
|
|
121
127
|
setTags: (tags: string[]) => void
|
|
128
|
+
/** Remove the document-level outside-click listener (unmount must not leak). */
|
|
129
|
+
dispose: () => void
|
|
122
130
|
} {
|
|
123
131
|
const wrap = document.createElement('div')
|
|
124
132
|
wrap.className = 'dsh-tw-note-tags'
|
|
@@ -225,9 +233,10 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
|
|
|
225
233
|
hideSuggest()
|
|
226
234
|
}
|
|
227
235
|
})
|
|
228
|
-
|
|
236
|
+
const onDocClick = (event: Event): void => {
|
|
229
237
|
if (!wrap.contains(event.target as Node)) hideSuggest()
|
|
230
|
-
}
|
|
238
|
+
}
|
|
239
|
+
document.addEventListener('click', onDocClick, true)
|
|
231
240
|
|
|
232
241
|
return {
|
|
233
242
|
el: wrap,
|
|
@@ -255,6 +264,7 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
|
|
|
255
264
|
renderChips()
|
|
256
265
|
hideSuggest()
|
|
257
266
|
},
|
|
267
|
+
dispose: () => document.removeEventListener('click', onDocClick, true),
|
|
258
268
|
}
|
|
259
269
|
}
|
|
260
270
|
|
|
@@ -341,7 +351,7 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
341
351
|
*/
|
|
342
352
|
const emitState = (open: boolean): void => {
|
|
343
353
|
try {
|
|
344
|
-
window.dispatchEvent(new CustomEvent(
|
|
354
|
+
window.dispatchEvent(new CustomEvent(NOTE_STATE_EVENT, { detail: { open } }))
|
|
345
355
|
} catch { /* event dispatch is best-effort */ }
|
|
346
356
|
}
|
|
347
357
|
|
|
@@ -630,12 +640,18 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
630
640
|
close()
|
|
631
641
|
}
|
|
632
642
|
|
|
643
|
+
// In-flight guard: the save button is disabled while saving, but Ctrl+Enter
|
|
644
|
+
// from the editor keymap is not — a second POST within the same second
|
|
645
|
+
// would carry the same timestamp title and silently overwrite the first.
|
|
646
|
+
let saving = false
|
|
633
647
|
doSave = async (): Promise<void> => {
|
|
648
|
+
if (saving) return
|
|
634
649
|
const text = editor.getValue().trim()
|
|
635
650
|
if (text.length === 0) {
|
|
636
651
|
toast('内容为空,未保存')
|
|
637
652
|
return
|
|
638
653
|
}
|
|
654
|
+
saving = true
|
|
639
655
|
saveBtn.disabled = true
|
|
640
656
|
saveBtn.textContent = '保存中…'
|
|
641
657
|
try {
|
|
@@ -655,6 +671,7 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
655
671
|
} catch (err) {
|
|
656
672
|
toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
|
|
657
673
|
} finally {
|
|
674
|
+
saving = false
|
|
658
675
|
saveBtn.disabled = false
|
|
659
676
|
saveBtn.textContent = '保存'
|
|
660
677
|
}
|
|
@@ -785,8 +802,12 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
785
802
|
} else {
|
|
786
803
|
resetTitle()
|
|
787
804
|
defaultTag = (await fetchUiOptions()).defaultTag
|
|
805
|
+
// dispose() may have run while awaiting (sets ui = undefined); a live
|
|
806
|
+
// `ui` reference must be re-read under the same guard as above.
|
|
807
|
+
if (disposed || ui === undefined) return
|
|
788
808
|
ui.tagEditor.setDefault(defaultTag)
|
|
789
809
|
}
|
|
810
|
+
if (ui === undefined) return
|
|
790
811
|
ui.editor.focus()
|
|
791
812
|
},
|
|
792
813
|
close() {
|
|
@@ -806,6 +827,7 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
806
827
|
dispose() {
|
|
807
828
|
disposed = true
|
|
808
829
|
if (draftTimer !== undefined) { clearTimeout(draftTimer); draftTimer = undefined }
|
|
830
|
+
ui?.tagEditor.dispose()
|
|
809
831
|
ui?.editor.view.destroy()
|
|
810
832
|
ui?.root.remove()
|
|
811
833
|
const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
|
|
@@ -815,8 +837,5 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
815
837
|
}
|
|
816
838
|
}
|
|
817
839
|
|
|
818
|
-
|
|
819
|
-
export
|
|
820
|
-
const handle = createNoteWidget()
|
|
821
|
-
return () => handle.dispose()
|
|
822
|
-
}
|
|
840
|
+
// (The FAB owns the trigger since v0.5 — there is deliberately no standalone
|
|
841
|
+
// mount export here; index.ts wires createNoteWidget into mountKnowledgeFab.)
|
package/src/client/panel.ts
CHANGED
|
@@ -24,7 +24,7 @@ import type { PanelState } from './state.ts'
|
|
|
24
24
|
import { ENTRY_SELECTOR } from './sidebar-entry.ts'
|
|
25
25
|
import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
|
|
26
26
|
|
|
27
|
-
export const
|
|
27
|
+
export const PANEL_RELOAD_EVENT = 'dsh-tw-panel-reload'
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Center-column targets, most-specific shell generation first. The official
|
|
@@ -57,7 +57,7 @@ const APP_OVERLAY_Z_INDEX = 20
|
|
|
57
57
|
/** Safety re-measure cadence for shell layout changes CSS can't see. */
|
|
58
58
|
const SYNC_INTERVAL_MS = 2_000
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
import { STATUS_ENDPOINT } from './endpoints.ts'
|
|
61
61
|
const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -100,7 +100,7 @@ interface StatusPayload {
|
|
|
100
100
|
wikiPath?: string
|
|
101
101
|
error?: string
|
|
102
102
|
note?: { tag?: string }
|
|
103
|
-
ui?: {
|
|
103
|
+
ui?: { followDshTheme?: boolean; darkPalette?: string }
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
function conversationColumn(): HTMLElement | undefined {
|
|
@@ -143,6 +143,8 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
143
143
|
let frameLoaded = false
|
|
144
144
|
/** A tiddler-hash open request waiting for the frame to become ready. */
|
|
145
145
|
let pendingHash: string | null = null
|
|
146
|
+
/** Set by the disposer: no timers/observers may touch DOM or the iframe after. */
|
|
147
|
+
let disposed = false
|
|
146
148
|
|
|
147
149
|
const build = (): HTMLDivElement => {
|
|
148
150
|
const view = document.createElement('div')
|
|
@@ -281,6 +283,7 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
281
283
|
return
|
|
282
284
|
}
|
|
283
285
|
const tryOnce = (attempt: number): void => {
|
|
286
|
+
if (disposed) return // unmounted: do not keep waiting or touch the iframe
|
|
284
287
|
if (pendingHash !== hash) return // superseded by a newer request
|
|
285
288
|
const frameTw = win as { $tw?: unknown }
|
|
286
289
|
if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
|
|
@@ -303,7 +306,7 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
303
306
|
}
|
|
304
307
|
|
|
305
308
|
const fallbackLoad = (hash: string): void => {
|
|
306
|
-
if (iframe === undefined) return
|
|
309
|
+
if (disposed || iframe === undefined) return // never drive a detached frame
|
|
307
310
|
if (pendingHash === hash) pendingHash = null
|
|
308
311
|
const base = iframe.src.split('#')[0]
|
|
309
312
|
if (iframe.src !== `${base}${hash}`) iframe.src = `${base}${hash}`
|
|
@@ -324,6 +327,7 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
324
327
|
refreshTimer = undefined
|
|
325
328
|
}
|
|
326
329
|
const payload = await fetchStatus()
|
|
330
|
+
if (disposed) return // unmounted while fetching: stop, don't touch DOM
|
|
327
331
|
if (payload === null) {
|
|
328
332
|
showError('无法访问 /dsh-tiddlywiki/status')
|
|
329
333
|
return
|
|
@@ -413,10 +417,11 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
413
417
|
const onReloadRequest = (): void => {
|
|
414
418
|
if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
|
|
415
419
|
}
|
|
416
|
-
document.addEventListener(
|
|
420
|
+
document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
417
421
|
document.addEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
|
|
418
422
|
|
|
419
423
|
return () => {
|
|
424
|
+
disposed = true
|
|
420
425
|
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
|
421
426
|
window.clearInterval(syncInterval)
|
|
422
427
|
window.removeEventListener('resize', onWindowResize)
|
|
@@ -424,7 +429,7 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
424
429
|
resizeObserver.disconnect()
|
|
425
430
|
document.removeEventListener('click', onClickSidebarRow, true)
|
|
426
431
|
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
427
|
-
document.removeEventListener(
|
|
432
|
+
document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
428
433
|
document.removeEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
|
|
429
434
|
waitObserver.disconnect()
|
|
430
435
|
unsubscribe()
|
|
@@ -15,10 +15,7 @@
|
|
|
15
15
|
* @module dsh-tiddlywiki/client/quick-note-dock
|
|
16
16
|
*/
|
|
17
17
|
import * as React from 'react'
|
|
18
|
-
import type
|
|
19
|
-
|
|
20
|
-
/** Event name the note card dispatches on open/close (detail: { open }). */
|
|
21
|
-
export const NOTE_STATE_EVENT = 'dsh-tw-note-state'
|
|
18
|
+
import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
|
|
22
19
|
|
|
23
20
|
/**
|
|
24
21
|
* Build the dock entry component bound to one note-widget handle. Called once
|
|
@@ -18,12 +18,13 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as React from 'react'
|
|
20
20
|
import { attachThemeSync } from './theme-sync.ts'
|
|
21
|
+
import { GET_ENDPOINT } from './endpoints.ts'
|
|
21
22
|
|
|
22
23
|
export const SESSION_SUMMARY_VIEW_ID = 'dsh-tiddlywiki-summary'
|
|
23
24
|
const SUMMARY_ENDPOINT = '/dsh-tiddlywiki/session/summary'
|
|
24
25
|
/** Same-origin TW proxy base(与 host TW_PROXY_PATH 一致,client 不能 import 它)。 */
|
|
25
26
|
const TW_PROXY_BASE = '/dsh-tiddlywiki/tw/'
|
|
26
|
-
|
|
27
|
+
const SESSION_SUMMARY_LABEL_DEFAULT = '知识库'
|
|
27
28
|
|
|
28
29
|
/** Tab 显示名缓存:客户端从 /status 读到 `ui.tabLabel` 后更新;默认「知识库」。 */
|
|
29
30
|
let tabLabel = SESSION_SUMMARY_LABEL_DEFAULT
|
|
@@ -49,6 +50,7 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
49
50
|
const { sessionId, viewRequest, completeViewRequest } = props
|
|
50
51
|
const [phase, setPhase] = React.useState<'loading' | 'ready' | 'error'>('loading')
|
|
51
52
|
const [twUrl, setTwUrl] = React.useState<string | null>(null)
|
|
53
|
+
const [summaryTitle, setSummaryTitle] = React.useState<string | null>(null)
|
|
52
54
|
const [error, setError] = React.useState('')
|
|
53
55
|
const [frameNonce, setFrameNonce] = React.useState(0)
|
|
54
56
|
const genRef = React.useRef(0)
|
|
@@ -77,6 +79,7 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
77
79
|
return
|
|
78
80
|
}
|
|
79
81
|
if (typeof data.title === 'string' && data.title.length > 0) {
|
|
82
|
+
setSummaryTitle(data.title)
|
|
80
83
|
setTwUrl(new URL(TW_PROXY_BASE, window.location.origin).href + '#' + encodeURIComponent(data.title))
|
|
81
84
|
// 新 nonce 强制 iframe 重挂载 → 用最新汇总重新加载(含刷新场景)。
|
|
82
85
|
setFrameNonce((n) => n + 1)
|
|
@@ -94,6 +97,31 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
|
|
|
94
97
|
void generate()
|
|
95
98
|
}, [generate])
|
|
96
99
|
|
|
100
|
+
// 自愈:汇总条目是 volatile 的 `$:/temp`,TW 重启即消失;若 iframe 已就绪但条目
|
|
101
|
+
// 被清掉(/get 404),自动重新生成,避免停在 TW 的「佚失条目」页。连续 3 次仍
|
|
102
|
+
// 缺失(例如服务端写不进去)就停止自动重试,交还手动「🔄 刷新」。
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
if (phase !== 'ready' || summaryTitle === null) return
|
|
105
|
+
let misses = 0
|
|
106
|
+
const timer = window.setInterval(() => {
|
|
107
|
+
void (async () => {
|
|
108
|
+
try {
|
|
109
|
+
const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(summaryTitle)}`, { signal: AbortSignal.timeout(8_000) })
|
|
110
|
+
const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
|
|
111
|
+
if (res.status === 404 || data?.notFound === true) {
|
|
112
|
+
misses++
|
|
113
|
+
if (misses < 3) void generate()
|
|
114
|
+
} else {
|
|
115
|
+
misses = 0
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* 检查失败保持现状,下个周期再试 */
|
|
119
|
+
}
|
|
120
|
+
})()
|
|
121
|
+
}, 30_000)
|
|
122
|
+
return () => window.clearInterval(timer)
|
|
123
|
+
}, [phase, summaryTitle, generate])
|
|
124
|
+
|
|
97
125
|
// 一次性 focus 请求直接确认(本视图无可聚焦子目标,避免 shell 挂起)。
|
|
98
126
|
React.useEffect(() => {
|
|
99
127
|
if (viewRequest !== null && viewRequest !== undefined) completeViewRequest?.()
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Sections:
|
|
14
14
|
* 1. 状态/重启 TW 运行状态 + git 概览 + 重启按钮
|
|
15
|
-
* 2. 常规配置 note.tag / git.* /
|
|
15
|
+
* 2. 常规配置 note.tag / git.* / ui.*(含会话「知识库」Tab 名称、sendToAgent
|
|
16
|
+
* 开关/token/endpoint;改了什么保存什么)
|
|
16
17
|
* 3. 插件管理 自带官方插件勾选(可搜索)→ 应用并重启 TW
|
|
17
18
|
* 4. 主题管理 自带主题单选 → 应用并重启 TW
|
|
18
19
|
* 5. 初始化 一次性预置(doc-note/send-to-agent/home-index/all-articles/menubar-theme/tw-web-host)
|
|
@@ -232,9 +233,13 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
232
233
|
checkField('ui.showPanelStatus', '显示「知识库」按钮里的 TW 面板/重载入口与状态行', ui.showPanelStatus !== false)
|
|
233
234
|
checkField('ui.showSyncButton', '显示「知识库」按钮里的「同步」入口与 git 状态点', ui.showSyncButton !== false)
|
|
234
235
|
checkField('ui.followDshTheme', '嵌入式 TW 跟随 DSH 深浅主题(暗色时自动切深色 palette,不写回 wiki)', ui.followDshTheme !== false)
|
|
235
|
-
textField('ui.darkPalette', '暗色时 TW palette(tiddler 标题)', typeof ui.darkPalette === 'string' && ui.darkPalette.length > 0 ? ui.darkPalette : '$:/palettes/CupertinoDark')
|
|
236
|
+
textField('ui.darkPalette', '暗色时 TW palette(tiddler 标题)', typeof ui.darkPalette === 'string' && ui.darkPalette.trim().length > 0 ? ui.darkPalette.trim() : '$:/palettes/CupertinoDark')
|
|
236
237
|
textField('ui.tabLabel', '会话顶部「知识库」Tab 名称', typeof ui.tabLabel === 'string' && ui.tabLabel.trim().length > 0 ? ui.tabLabel.trim() : '知识库')
|
|
237
238
|
checkField('ui.showSessionTab', '显示会话顶部「知识库」Tab(本会话相关 wiki 笔记汇总)', ui.showSessionTab !== false)
|
|
239
|
+
const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
|
|
240
|
+
checkField('ui.sendToAgent.enabled', '启用「发送给 Agent」(TW 笔记 → DSH 会话注入)', sendToAgent.enabled !== false)
|
|
241
|
+
textField('ui.sendToAgent.endpoint', 'TW 端请求基址(空=自动取当前 DSH origin)', typeof sendToAgent.endpoint === 'string' ? sendToAgent.endpoint : '')
|
|
242
|
+
textField('ui.sendToAgent.token', '共享 token(非空时路由校验 x-send-to-agent-token 头)', typeof sendToAgent.token === 'string' ? sendToAgent.token : '')
|
|
238
243
|
const allArticles = (ui.allArticles ?? {}) as Record<string, unknown>
|
|
239
244
|
numField('ui.allArticles.pageSize', '「所有文章」每页条数', typeof allArticles.pageSize === 'number' ? allArticles.pageSize : 10)
|
|
240
245
|
// 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @module dsh-tiddlywiki/client/sidebar-entry
|
|
11
11
|
*/
|
|
12
12
|
import type { PanelState } from './state.ts'
|
|
13
|
+
import { STATUS_ENDPOINT } from './endpoints.ts'
|
|
13
14
|
|
|
14
15
|
/** Stable data attribute identifying this entry row. */
|
|
15
16
|
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
@@ -96,7 +97,7 @@ export function mountSidebarEntry(state: PanelState, initialLabel = 'TiddlyWiki'
|
|
|
96
97
|
// 异步到达后原地更新,无需重建 DOM(旧 host 无该字段时保持默认名)。
|
|
97
98
|
void (async () => {
|
|
98
99
|
try {
|
|
99
|
-
const res = await fetch(
|
|
100
|
+
const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
|
|
100
101
|
if (!res.ok) return
|
|
101
102
|
const p = (await res.json()) as { ui?: { sidebarLabel?: string } }
|
|
102
103
|
const label = p.ui?.sidebarLabel
|
package/src/client/styles.ts
CHANGED
|
@@ -51,32 +51,6 @@ html[data-dsh-tw-active] [data-pane="conversation"] > :not([data-dsh-tw-view]),
|
|
|
51
51
|
html[data-dsh-tw-active] [class*="centerCol"] > :not([data-dsh-tw-view]),
|
|
52
52
|
html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view]) { display: none !important; }
|
|
53
53
|
|
|
54
|
-
/* ── bottom-right status / reload floating buttons ───────────────────
|
|
55
|
-
The top panel bar (title + status + reload row) is gone so TW's menubar
|
|
56
|
-
reaches the panel top; status/reload now live here as floating buttons,
|
|
57
|
-
same visual language as the quick-note toggle. The ui.showPanelStatus
|
|
58
|
-
setting controls whether they mount at all. */
|
|
59
|
-
.dsh-tw-panel-status {
|
|
60
|
-
/* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
|
|
61
|
-
垂直排在「同步」按钮(bottom:140)上方;
|
|
62
|
-
两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
|
|
63
|
-
position: fixed; right: 24px; bottom: 192px; z-index: 950;
|
|
64
|
-
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
|
65
|
-
}
|
|
66
|
-
.dsh-tw-panel-status-btn {
|
|
67
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
68
|
-
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
69
|
-
font: inherit; font-size: 12px; padding: 7px 12px; border-radius: 999px;
|
|
70
|
-
box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
|
|
71
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
72
|
-
transition: background-color 120ms ease;
|
|
73
|
-
}
|
|
74
|
-
.dsh-tw-panel-status-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
75
|
-
.dsh-tw-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
|
|
76
|
-
.dsh-tw-status-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
77
|
-
.dsh-tw-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
78
|
-
.dsh-tw-status-dot[data-state="failed"], .dsh-tw-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
79
|
-
|
|
80
54
|
/* The iframe background follows the DSH theme so a blank/preload frame never
|
|
81
55
|
flashes pure white in dark mode (TW paints its own palette once loaded). */
|
|
82
56
|
.dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
@@ -264,38 +238,6 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
264
238
|
.dsh-tw-editor-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
|
|
265
239
|
.dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
266
240
|
.dsh-tw-editor-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
|
|
267
|
-
.dsh-tw-note-toggle {
|
|
268
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
269
|
-
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
270
|
-
font: inherit; font-size: 13px; padding: 9px 16px; border-radius: 999px;
|
|
271
|
-
box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
|
|
272
|
-
display: inline-flex; align-items: center; gap: 6px;
|
|
273
|
-
transition: background-color 120ms ease;
|
|
274
|
-
}
|
|
275
|
-
.dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
276
|
-
|
|
277
|
-
/* ── floating sync button (bottom-right) ───────────────────────
|
|
278
|
-
Pill FAB between the quick-note toggle (bottom:88) and the TW panel
|
|
279
|
-
status/reload floaters (bottom:192); dot reflects git state. */
|
|
280
|
-
.dsh-tw-sync {
|
|
281
|
-
position: fixed; right: 24px; bottom: 140px; z-index: 950;
|
|
282
|
-
display: inline-flex; align-items: center; gap: 7px;
|
|
283
|
-
padding: 9px 14px; border-radius: 999px;
|
|
284
|
-
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
285
|
-
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
286
|
-
font: inherit; font-size: 13px; box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16));
|
|
287
|
-
cursor: pointer; transition: background-color 120ms ease;
|
|
288
|
-
}
|
|
289
|
-
.dsh-tw-sync:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
290
|
-
.dsh-tw-sync:disabled { opacity: .65; cursor: default; }
|
|
291
|
-
.dsh-tw-sync-icon { display: inline-flex; flex: none; }
|
|
292
|
-
.dsh-tw-sync-spin .dsh-tw-sync-icon svg { animation: dsh-tw-spin 1s linear infinite; transform-origin: center; }
|
|
293
|
-
@keyframes dsh-tw-spin { to { transform: rotate(360deg); } }
|
|
294
|
-
.dsh-tw-sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
|
|
295
|
-
.dsh-tw-sync-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
296
|
-
.dsh-tw-sync-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
297
|
-
.dsh-tw-sync-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
298
|
-
.dsh-tw-sync-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
299
241
|
|
|
300
242
|
/* ── toast ──────────────────────────────────────────────────── */
|
|
301
243
|
.dsh-tw-toast {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { toast } from './toast.ts'
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
import { STATUS_ENDPOINT } from './endpoints.ts'
|
|
22
22
|
const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
|
|
23
23
|
const POLL_MS = 30_000
|
|
24
24
|
|
|
@@ -38,7 +38,6 @@ interface StatusPayload {
|
|
|
38
38
|
ok?: boolean
|
|
39
39
|
status?: string
|
|
40
40
|
git?: GitView | null
|
|
41
|
-
ui?: { showSyncButton?: boolean }
|
|
42
41
|
}
|
|
43
42
|
|
|
44
43
|
export interface SyncStateView {
|
|
@@ -121,6 +120,7 @@ export function createSyncController(): SyncController {
|
|
|
121
120
|
let lastSync: Date | undefined
|
|
122
121
|
let timer: number | undefined
|
|
123
122
|
let syncing = false
|
|
123
|
+
let disposed = false
|
|
124
124
|
const listeners = new Set<() => void>()
|
|
125
125
|
|
|
126
126
|
const emit = (): void => {
|
|
@@ -136,11 +136,12 @@ export function createSyncController(): SyncController {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
const poll = async (): Promise<void> => {
|
|
139
|
+
if (disposed) return // unmounted: no further fetches or state churn
|
|
139
140
|
applyStatus(await fetchStatus())
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
const doSync = async (): Promise<SyncStateView> => {
|
|
143
|
-
if (syncing) return state
|
|
144
|
+
if (disposed || syncing) return state
|
|
144
145
|
syncing = true
|
|
145
146
|
state = { ...state, state: 'syncing', label: '同步中…' }
|
|
146
147
|
emit()
|
|
@@ -184,6 +185,7 @@ export function createSyncController(): SyncController {
|
|
|
184
185
|
return () => { listeners.delete(cb) }
|
|
185
186
|
},
|
|
186
187
|
dispose() {
|
|
188
|
+
disposed = true
|
|
187
189
|
if (timer !== undefined) { clearInterval(timer); timer = undefined }
|
|
188
190
|
listeners.clear()
|
|
189
191
|
},
|
package/src/client/ui-config.ts
CHANGED
package/src/host/admin.ts
CHANGED
|
@@ -24,6 +24,8 @@ import type { TiddlyWebClient } from './tw-api.ts'
|
|
|
24
24
|
import type { WikiServer } from './wiki.ts'
|
|
25
25
|
import { ROUTE_PREFIX, type WebServerFace } from './routes.ts'
|
|
26
26
|
import { CONFIG_TIDDLER, type ConfigStore, type PluginConfigShape } from './config.ts'
|
|
27
|
+
import { readBody, json } from './http.ts'
|
|
28
|
+
import { GitFace } from './git.ts'
|
|
27
29
|
|
|
28
30
|
/** One bundled plugin/theme from the catalog. */
|
|
29
31
|
export interface CatalogEntry {
|
|
@@ -219,29 +221,6 @@ export async function ensureLanguage(wikiPath: string, twRoot: string, lang: str
|
|
|
219
221
|
return true
|
|
220
222
|
}
|
|
221
223
|
|
|
222
|
-
function json(res: ServerResponse, payload: unknown, status = 200): void {
|
|
223
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
224
|
-
res.end(JSON.stringify(payload))
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function readBody(req: IncomingMessage, limit = 1024 * 1024): Promise<string> {
|
|
228
|
-
return new Promise((resolveP, rejectP) => {
|
|
229
|
-
let size = 0
|
|
230
|
-
const chunks: Buffer[] = []
|
|
231
|
-
req.on('data', (chunk: Buffer) => {
|
|
232
|
-
size += chunk.length
|
|
233
|
-
if (size > limit) {
|
|
234
|
-
rejectP(new Error('body too large'))
|
|
235
|
-
req.destroy()
|
|
236
|
-
return
|
|
237
|
-
}
|
|
238
|
-
chunks.push(chunk)
|
|
239
|
-
})
|
|
240
|
-
req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
|
|
241
|
-
req.on('error', rejectP)
|
|
242
|
-
})
|
|
243
|
-
}
|
|
244
|
-
|
|
245
224
|
export interface AdminDeps {
|
|
246
225
|
server: WikiServer
|
|
247
226
|
getClient: () => TiddlyWebClient | undefined
|
|
@@ -264,7 +243,6 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
|
|
|
264
243
|
const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())])
|
|
265
244
|
let git: unknown = null
|
|
266
245
|
try {
|
|
267
|
-
const { GitFace } = await import('./git.ts')
|
|
268
246
|
git = await new GitFace().status(wikiPath)
|
|
269
247
|
} catch {
|
|
270
248
|
git = null
|