dsh-tiddlywiki 0.16.19 → 0.16.21

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.
@@ -0,0 +1,440 @@
1
+ /**
2
+ * Right-Sidebar TiddlyWiki tab type (v0.17.0).
3
+ *
4
+ * DSH's new right column (dsh-client-ui-sidebar-right) lets plugins register
5
+ * "tab types": stage 1 registers the type into `ctx.sidebarRightTabs` — a
6
+ * guide entry box on the rightbar's home page is the entry point — and stage 2
7
+ * registers the tab's React body into the keyed `sidebar.right.pane.tab` seat
8
+ * under the definition's id. This module does both, guarded at the call site:
9
+ * the whole integration is optional (older DSH / rightbar absent → no-op).
10
+ *
11
+ * The body embeds the SAME-ORIGIN TW proxy (`/dsh-tiddlywiki/tw/`) in an
12
+ * iframe, exactly like the center-column panel. One TW client at a time is
13
+ * enforced: when this tab becomes visible the center overlay (and any sibling
14
+ * overlay panel) closes via the existing `dsh-panel-activate` protocol, and
15
+ * when another panel activates this tab closes itself. Wiki links in the reply
16
+ * stream route here while the tab is visible (see panel.ts openTiddler).
17
+ *
18
+ * The tab body is React only because the slot runtime renders React: the frame
19
+ * is plain DOM built inside a ref host, mirroring the plugin's DOM style.
20
+ *
21
+ * @module dsh-tiddlywiki/client/rightbar-tab
22
+ */
23
+ import * as React from 'react'
24
+ import { STATUS_ENDPOINT } from './endpoints.ts'
25
+ import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
26
+
27
+ /** The tab kind this package owns; what `openTab` names and the guide entry opens. */
28
+ export const TW_RIGHTBAR_KIND = 'dsh-tiddlywiki'
29
+ /** This implementation's identity in the tab system: the key its body registers under. */
30
+ export const TW_RIGHTBAR_ID = 'dsh-tiddlywiki'
31
+ /** Activation-event name for THIS tab (distinct from the center panel's 'dsh-tiddlywiki'). */
32
+ const RIGHTBAR_PANEL_NAME = 'dsh-tiddlywiki/rightbar'
33
+ /** Cross-plugin activation event; detail is the activating panel name. */
34
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
35
+ /** The "知识库" FAB's reload event; the rightbar frame reloads with the center one. */
36
+ const PANEL_RELOAD_EVENT = 'dsh-tw-panel-reload'
37
+
38
+ const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
39
+
40
+ /** Tab chip / guide copy default (label refreshed from `/status` ui.tabLabel). */
41
+ let rightbarLabel = '知识库'
42
+
43
+ /** Update the tab chip label from the live config (ui.tabLabel). */
44
+ function setLabel(label: string): void {
45
+ const trimmed = typeof label === 'string' ? label.trim() : ''
46
+ if (trimmed.length > 0) rightbarLabel = trimmed
47
+ }
48
+
49
+ /* ── structural faces over the rightbar contract (no @deepseek-ai imports) ── */
50
+
51
+ interface RightbarTabInfo {
52
+ sidebar: { expanded: boolean; fullscreen: boolean }
53
+ panel: { id: string }
54
+ tab: {
55
+ id: string
56
+ contentId: string
57
+ visible: boolean
58
+ navigation: { address: string; params?: unknown; revision: number }
59
+ signal: AbortSignal
60
+ actions: { close(): void; openTab?(kind: string, options?: unknown): void }
61
+ }
62
+ }
63
+
64
+ type UseRightbarTabInfo = () => RightbarTabInfo
65
+
66
+ export interface RightbarTabBodyProps {
67
+ useTabInfo: UseRightbarTabInfo
68
+ sessionId?: string
69
+ }
70
+
71
+ /** The slot-registry face this module needs (keyed seats register by `key`). */
72
+ export interface RightbarSlotsFace {
73
+ inject(name: string, callback: () => unknown): (() => void) | undefined
74
+ register(
75
+ opts: { name: string; key?: string; id?: string; order?: number; label?: string | (() => string) },
76
+ component: unknown,
77
+ ): () => void
78
+ }
79
+
80
+ interface StatusPayload {
81
+ ok?: boolean
82
+ status: string
83
+ url?: string
84
+ /** Same-origin TW proxy path (e.g. /dsh-tiddlywiki/tw/); the iframe base. */
85
+ twProxy?: string
86
+ wikiPath?: string
87
+ error?: string
88
+ ui?: { followDshTheme?: boolean; darkPalette?: string; tabLabel?: string }
89
+ }
90
+
91
+ async function fetchStatus(): Promise<StatusPayload | null> {
92
+ try {
93
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
94
+ if (!res.ok) return null
95
+ return (await res.json()) as StatusPayload
96
+ } catch {
97
+ return null
98
+ }
99
+ }
100
+
101
+ async function requestRestart(): Promise<boolean> {
102
+ try {
103
+ const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
104
+ return res.ok
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ /** Guide entry glyph: a wiki page with a TiddlyWiki-style "T" (same as the entry). */
111
+ function GuideIcon({ size = 16, className }: { size?: number; className?: string }): React.ReactElement {
112
+ return React.createElement(
113
+ 'svg',
114
+ { width: size, height: size, className, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
115
+ React.createElement('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' }),
116
+ React.createElement('path', { d: 'M6 6h4M6 8.5h2.5' }),
117
+ )
118
+ }
119
+
120
+ /** The plain-DOM TW frame controller owned by the React body. */
121
+ interface FrameController {
122
+ /** Reflect the tab's visibility; loads lazily on the first show. */
123
+ setVisible(visible: boolean): void
124
+ isVisible(): boolean
125
+ /** Open a tiddler by title; false when this controller cannot serve it. */
126
+ openTiddler(title: string): boolean
127
+ dispose(): void
128
+ }
129
+
130
+ /** Live controller handle for center-panel link routing (openTiddlerInRightbar). */
131
+ let liveFrame: FrameController | undefined
132
+
133
+ /**
134
+ * Ask the live rightbar TW tab to open a tiddler. False when no visible tab
135
+ * is there, so the center panel handles the request as before.
136
+ */
137
+ export function openTiddlerInRightbar(title: string): boolean {
138
+ if (liveFrame === undefined) return false
139
+ return liveFrame.openTiddler(title)
140
+ }
141
+
142
+ function createFrameController(host: HTMLElement, signal: AbortSignal): FrameController {
143
+ let visible = false
144
+ let started = false
145
+ let disposed = false
146
+ let refreshTimer: number | undefined
147
+ let refreshAttempts = 0
148
+ let frameLoaded = false
149
+ let pendingHash: string | null = null
150
+ let themeSyncDispose: (() => void) | undefined
151
+
152
+ const view = document.createElement('div')
153
+ view.className = 'dsh-tw-rightbar-view'
154
+
155
+ const frameArea = document.createElement('div')
156
+ frameArea.className = 'dsh-tw-rightbar-frame-wrap'
157
+ const frame = document.createElement('iframe')
158
+ frame.className = 'dsh-tw-rightbar-frame'
159
+ frame.title = 'TiddlyWiki'
160
+ frame.hidden = true
161
+ frameArea.append(frame)
162
+
163
+ const errorArea = document.createElement('div')
164
+ errorArea.className = 'dsh-tw-rightbar-error'
165
+ errorArea.hidden = true
166
+
167
+ view.append(frameArea, errorArea)
168
+ host.append(view)
169
+
170
+ frame.addEventListener('load', () => {
171
+ frameLoaded = true
172
+ applyPendingHash()
173
+ })
174
+ themeSyncDispose = attachThemeSync(frame)
175
+
176
+ const showError = (message: string): void => {
177
+ frame.hidden = true
178
+ errorArea.hidden = false
179
+ errorArea.replaceChildren()
180
+ const p = document.createElement('div')
181
+ p.textContent = 'TiddlyWiki 服务不可用'
182
+ const code = document.createElement('code')
183
+ code.textContent = message
184
+ const retry = document.createElement('button')
185
+ retry.type = 'button'
186
+ retry.textContent = '重试'
187
+ retry.addEventListener('click', () => {
188
+ retry.disabled = true
189
+ retry.textContent = '重启中…'
190
+ void requestRestart().finally(() => { void doRefresh() })
191
+ })
192
+ errorArea.append(p, code, retry)
193
+ }
194
+
195
+ const showStarting = (): void => {
196
+ frame.hidden = true
197
+ errorArea.hidden = false
198
+ errorArea.replaceChildren()
199
+ const p = document.createElement('div')
200
+ p.textContent = 'TiddlyWiki 服务正在启动…'
201
+ errorArea.append(p)
202
+ }
203
+
204
+ const showFrame = (url: string): void => {
205
+ errorArea.hidden = true
206
+ // Only reveal the frame while the tab is on screen.
207
+ frame.hidden = !visible
208
+ // Set the src only when the url changed, so an editor never loses unsaved
209
+ // state on a status refresh.
210
+ if (frame.dataset.loaded !== url) {
211
+ frame.dataset.loaded = url
212
+ frameLoaded = false
213
+ frame.src = url
214
+ }
215
+ }
216
+
217
+ /** Apply a pending tiddler-hash once the frame is ready (see panel.ts). */
218
+ const applyPendingHash = (): void => {
219
+ if (pendingHash === null || !frameLoaded) return
220
+ const hash = pendingHash
221
+ const win = frame.contentWindow
222
+ if (win === null) {
223
+ fallbackLoad(hash)
224
+ return
225
+ }
226
+ const tryOnce = (attempt: number): void => {
227
+ if (disposed) return
228
+ if (pendingHash !== hash) return
229
+ const frameTw = win as { $tw?: unknown }
230
+ if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
231
+ if (attempt < 40) {
232
+ window.setTimeout(() => tryOnce(attempt + 1), 150)
233
+ return
234
+ }
235
+ fallbackLoad(hash)
236
+ return
237
+ }
238
+ pendingHash = null
239
+ try {
240
+ if (win.location.hash !== hash) win.location.hash = hash
241
+ } catch {
242
+ fallbackLoad(hash)
243
+ }
244
+ }
245
+ tryOnce(0)
246
+ }
247
+
248
+ const fallbackLoad = (hash: string): void => {
249
+ if (disposed) return
250
+ if (pendingHash === hash) pendingHash = null
251
+ const base = frame.src.split('#')[0]
252
+ if (frame.src !== `${base}${hash}`) frame.src = `${base}${hash}`
253
+ }
254
+
255
+ const doRefresh = async (): Promise<void> => {
256
+ if (refreshTimer !== undefined) {
257
+ window.clearTimeout(refreshTimer)
258
+ refreshTimer = undefined
259
+ }
260
+ const payload = await fetchStatus()
261
+ if (disposed) return
262
+ if (payload === null) {
263
+ showError('无法访问 /dsh-tiddlywiki/status')
264
+ return
265
+ }
266
+ if (payload.ui !== undefined) {
267
+ setLabel(payload.ui.tabLabel ?? rightbarLabel)
268
+ setThemeSyncConfig({
269
+ enabled: payload.ui.followDshTheme !== false,
270
+ darkPalette: payload.ui.darkPalette,
271
+ })
272
+ }
273
+ if (payload.status === 'running') {
274
+ refreshAttempts = 0
275
+ if (typeof payload.twProxy === 'string') {
276
+ showFrame(new URL(payload.twProxy, window.location.origin).href)
277
+ } else if (typeof payload.url === 'string') {
278
+ showFrame(payload.url)
279
+ } else {
280
+ showError('服务未返回编辑器地址')
281
+ }
282
+ return
283
+ }
284
+ if (payload.status === 'starting') {
285
+ showStarting()
286
+ if (refreshAttempts < 30) {
287
+ refreshAttempts++
288
+ refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
289
+ }
290
+ return
291
+ }
292
+ refreshAttempts = 0
293
+ showError(payload.error ?? `服务状态:${payload.status}`)
294
+ }
295
+
296
+ const onReloadRequest = (): void => {
297
+ if (!frame.hidden) frame.src = frame.src
298
+ }
299
+ document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
300
+
301
+ const onAbort = (): void => controller.dispose()
302
+ signal.addEventListener('abort', onAbort, { once: true })
303
+
304
+ const controller: FrameController = {
305
+ setVisible(next: boolean): void {
306
+ visible = next
307
+ if (!started) {
308
+ // Nothing loaded yet: stay hidden until the first show kicks the load.
309
+ frame.hidden = true
310
+ view.dataset.visible = next ? '1' : '0'
311
+ if (next) {
312
+ started = true
313
+ void doRefresh()
314
+ }
315
+ return
316
+ }
317
+ frame.hidden = !next
318
+ view.dataset.visible = next ? '1' : '0'
319
+ },
320
+ isVisible(): boolean {
321
+ return visible
322
+ },
323
+ openTiddler(title: string): boolean {
324
+ if (disposed || !visible) return false
325
+ pendingHash = `#${encodeURIComponent(title)}`
326
+ if (!started) {
327
+ started = true
328
+ void doRefresh()
329
+ } else {
330
+ applyPendingHash()
331
+ }
332
+ return true
333
+ },
334
+ dispose(): void {
335
+ if (disposed) return
336
+ disposed = true
337
+ if (liveFrame === controller) liveFrame = undefined
338
+ document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
339
+ signal.removeEventListener('abort', onAbort)
340
+ if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
341
+ themeSyncDispose?.()
342
+ view.remove()
343
+ },
344
+ }
345
+ return controller
346
+ }
347
+
348
+ /**
349
+ * The tab body (stage 2): a thin React wrapper that mounts the TW iframe into
350
+ * a plain-DOM host. One mount per tab record; mutual exclusion rides the
351
+ * `dsh-panel-activate` protocol both ways.
352
+ */
353
+ export function TwRightbarTabBody(props: RightbarTabBodyProps): React.ReactElement {
354
+ const { useTabInfo } = props
355
+ const info = useTabInfo()
356
+ const tab = info.tab
357
+ const hostRef = React.useRef<HTMLDivElement | null>(null)
358
+ const controllerRef = React.useRef<FrameController | null>(null)
359
+
360
+ React.useEffect(() => {
361
+ const host = hostRef.current
362
+ if (host === null) return
363
+ const controller = createFrameController(host, tab.signal)
364
+ controllerRef.current = controller
365
+ liveFrame = controller
366
+ return () => {
367
+ controller.dispose()
368
+ controllerRef.current = null
369
+ }
370
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one controller per tab record (id + signal are stable per occurrence).
371
+ }, [tab.id, tab.signal])
372
+
373
+ React.useEffect(() => {
374
+ controllerRef.current?.setVisible(tab.visible)
375
+ }, [tab.visible])
376
+
377
+ // When this tab becomes visible, close the center overlay / sibling panels.
378
+ // When any OTHER panel activates, close this tab (symmetry → one TW client).
379
+ const visible = tab.visible
380
+ const close = tab.actions.close
381
+ React.useEffect(() => {
382
+ if (!visible) return
383
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: RIGHTBAR_PANEL_NAME }))
384
+ const onActivate = (event: Event): void => {
385
+ const detail = (event as CustomEvent).detail
386
+ if (detail !== RIGHTBAR_PANEL_NAME) close()
387
+ }
388
+ document.addEventListener(ACTIVATE_EVENT, onActivate)
389
+ return () => document.removeEventListener(ACTIVATE_EVENT, onActivate)
390
+ }, [visible, close])
391
+
392
+ return React.createElement('div', { ref: hostRef, className: 'dsh-tw-rightbar-tab', 'data-dsh-tw-rightbar': '' })
393
+ }
394
+
395
+ /** The tab definition (stage 1): a page type opened by kind, with a guide entry box. */
396
+ export function rightbarDefinition(): Record<string, unknown> {
397
+ return {
398
+ id: TW_RIGHTBAR_ID,
399
+ kind: TW_RIGHTBAR_KIND,
400
+ title: (): string => rightbarLabel,
401
+ guide: [
402
+ {
403
+ order: 10,
404
+ title: (): string => 'TiddlyWiki 知识库',
405
+ description: (): string => '在右侧边栏打开 TiddlyWiki 编辑器(与聊天并排)',
406
+ icon: GuideIcon,
407
+ },
408
+ ],
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Mount the rightbar integration for the caller's lifetime: register the type
414
+ * and the body seat. Call only when `sidebarRightTabs` is actually available.
415
+ */
416
+ export function mountRightbarTab(
417
+ tabs: { register(definition: unknown): () => void },
418
+ slots: RightbarSlotsFace,
419
+ ): (() => void) | undefined {
420
+ const disposers: Array<() => void> = []
421
+ try {
422
+ disposers.push(tabs.register(rightbarDefinition()))
423
+ const removeBody = slots.inject('sidebar.right.pane.tab', () =>
424
+ slots.register({ name: 'sidebar.right.pane.tab', key: TW_RIGHTBAR_ID }, TwRightbarTabBody),
425
+ )
426
+ if (removeBody !== undefined) disposers.push(removeBody)
427
+ } catch (error) {
428
+ for (const dispose of disposers.splice(0)) dispose()
429
+ console.error('[dsh-tiddlywiki] rightbar tab mount failed:', error)
430
+ return undefined
431
+ }
432
+ // Warm the chip label from the live config (ui.tabLabel) so the first open
433
+ // already shows the right name; the frame refreshes it on every status call.
434
+ void fetchStatus().then((payload) => {
435
+ if (payload?.ui !== undefined) setLabel(payload.ui.tabLabel ?? rightbarLabel)
436
+ })
437
+ return () => {
438
+ for (const dispose of disposers.splice(0)) dispose()
439
+ }
440
+ }