dsh-tiddlywiki 0.1.0 → 0.1.2

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.
@@ -1,304 +1,345 @@
1
- /**
2
- * Center-column TiddlyWiki editor panel (design doc §12).
3
- *
4
- * A fixed-position overlay sized to the CENTER column's bounding rect and
5
- * appended to document.body, so it never depends on the shell's flex/grid
6
- * height model or on the column being a positioning ancestor (both caused the
7
- * earlier "half-height, scrollable" symptom). The rect is re-measured on
8
- * resize, on layout mutations, and on a light interval. While active it hides
9
- * the conversation content via a stylesheet rule keyed on `data-dsh-tw-active`.
10
- * Toggling rides the shared PanelState; cross-plugin exclusivity rides the
11
- * `dsh-panel-activate` event (same protocol as dsh-taskboard).
12
- *
13
- * The iframe points DIRECTLY at the TW service (http://127.0.0.1:<port>),
14
- * bypassing reverse-proxy sensitivity (design doc R1 — proxy is only a
15
- * fallback). The panel reads /status first and only sets iframe.src when the
16
- * service is actually running.
17
- *
18
- * @module dsh-tiddlywiki/client/panel
19
- */
20
- import type { PanelState } from './state.ts'
21
- import { ENTRY_SELECTOR } from './sidebar-entry.ts'
22
-
23
- export const PANEL_VIEW_SELECTOR = '[data-dsh-tw-view]'
24
-
25
- /**
26
- * Center-column targets, most-specific shell generation first. The official
27
- * layout shell (dsh-client-ui-layout) drops data-pane and uses a CSS-Module
28
- * hashed `centerCol`; older shells put `data-pane="conversation"` on the same
29
- * full-height grid item; DSH Desktop exposes the non-compat
30
- * `.dshDesktopConversationSurface`.
31
- */
32
- const COLUMN_SELECTORS = ['[class*="centerCol"]', '[data-pane="conversation"]', '.dshDesktopConversationSurface']
33
-
34
- const ACTIVE_ATTR = 'data-dsh-tw-active'
35
- /** Sibling panels' activation attributes, evicted when this panel opens. */
36
- const OTHER_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
37
- /** Cross-plugin activation event; detail is the activating panel name. */
38
- const ACTIVATE_EVENT = 'dsh-panel-activate'
39
- const PANEL_NAME = 'dsh-tiddlywiki'
40
-
41
- /** Overlay z-index: above the shell content, below the note widget (950). */
42
- const PANEL_Z_INDEX = 40
43
- /** Safety re-measure cadence for shell layout changes CSS can't see. */
44
- const SYNC_INTERVAL_MS = 2_000
45
-
46
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
47
- const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
48
-
49
- interface StatusPayload {
50
- ok?: boolean
51
- status: string
52
- url?: string
53
- wikiPath?: string
54
- error?: string
55
- note?: { tag?: string }
56
- }
57
-
58
- function conversationColumn(): HTMLElement | undefined {
59
- for (const selector of COLUMN_SELECTORS) {
60
- const el = document.querySelector<HTMLElement>(selector)
61
- if (el !== null) return el
62
- }
63
- return undefined
64
- }
65
-
66
- async function fetchStatus(): Promise<StatusPayload | null> {
67
- try {
68
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
69
- if (!res.ok) return null
70
- return (await res.json()) as StatusPayload
71
- } catch {
72
- return null
73
- }
74
- }
75
-
76
- async function requestRestart(): Promise<boolean> {
77
- try {
78
- const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
79
- return res.ok
80
- } catch {
81
- return false
82
- }
83
- }
84
-
85
- export function mountPanel(state: PanelState): () => void {
86
- let container: HTMLDivElement | undefined
87
- let columnEl: HTMLElement | undefined
88
- let iframe: HTMLIFrameElement | undefined
89
- let frameArea: HTMLDivElement | undefined
90
- let errorArea: HTMLDivElement | undefined
91
- let chip: HTMLSpanElement | undefined
92
- let refreshTimer: number | undefined
93
- let refreshAttempts = 0
94
-
95
- const build = (): HTMLDivElement => {
96
- const view = document.createElement('div')
97
- view.dataset.dshTwView = ''
98
- view.className = 'dsh-tw-view'
99
-
100
- const bar = document.createElement('div')
101
- bar.className = 'dsh-tw-panel-bar'
102
- const title = document.createElement('span')
103
- title.className = 'dsh-tw-panel-title'
104
- title.textContent = 'TiddlyWiki 知识库'
105
- chip = document.createElement('span')
106
- chip.className = 'dsh-tw-status-chip'
107
- chip.dataset.state = 'unknown'
108
- chip.textContent = '—'
109
- const reload = document.createElement('button')
110
- reload.type = 'button'
111
- reload.textContent = '重载'
112
- reload.addEventListener('click', () => {
113
- if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
114
- })
115
- const refresh = document.createElement('button')
116
- refresh.type = 'button'
117
- refresh.textContent = '状态'
118
- refresh.addEventListener('click', () => { void doRefresh() })
119
- bar.append(title, chip, refresh, reload)
120
-
121
- frameArea = document.createElement('div')
122
- frameArea.className = 'dsh-tw-panel-frame-wrap'
123
- frameArea.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'
124
- iframe = document.createElement('iframe')
125
- iframe.className = 'dsh-tw-panel-frame'
126
- iframe.title = 'TiddlyWiki'
127
- iframe.hidden = true
128
- frameArea.append(iframe)
129
-
130
- errorArea = document.createElement('div')
131
- errorArea.className = 'dsh-tw-panel-error'
132
- errorArea.hidden = true
133
-
134
- view.append(bar, frameArea, errorArea)
135
- return view
136
- }
137
-
138
- const setChip = (stateName: string, text: string): void => {
139
- if (chip === undefined) return
140
- chip.dataset.state = stateName
141
- chip.textContent = text
142
- }
143
-
144
- /** Pin the overlay to the center column's current viewport rect. */
145
- const syncRect = (): void => {
146
- if (container === undefined || columnEl === undefined) return
147
- const rect = columnEl.getBoundingClientRect()
148
- if (rect.width === 0 && rect.height === 0) return
149
- const left = `${rect.left}px`
150
- const top = `${rect.top}px`
151
- const width = `${rect.width}px`
152
- const height = `${rect.height}px`
153
- if (container.style.left !== left) container.style.left = left
154
- if (container.style.top !== top) container.style.top = top
155
- if (container.style.width !== width) container.style.width = width
156
- if (container.style.height !== height) container.style.height = height
157
- }
158
-
159
- const ensure = (): void => {
160
- if (container !== undefined) return
161
- columnEl = conversationColumn()
162
- if (columnEl === undefined) return
163
- container = build()
164
- container.style.position = 'fixed'
165
- container.style.zIndex = String(PANEL_Z_INDEX)
166
- document.body.append(container)
167
- syncRect()
168
- }
169
-
170
- const showError = (message: string): void => {
171
- if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
172
- iframe.hidden = true
173
- errorArea.hidden = false
174
- errorArea.textContent = ''
175
- const p = document.createElement('div')
176
- p.textContent = 'TiddlyWiki 服务不可用'
177
- const code = document.createElement('code')
178
- code.textContent = message
179
- const retry = document.createElement('button')
180
- retry.type = 'button'
181
- retry.textContent = '重试'
182
- retry.addEventListener('click', () => {
183
- retry.disabled = true
184
- retry.textContent = '重启中…'
185
- void requestRestart().finally(() => { void doRefresh() })
186
- })
187
- errorArea.append(p, code, retry)
188
- }
189
-
190
- const showStarting = (): void => {
191
- if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
192
- iframe.hidden = true
193
- errorArea.hidden = false
194
- errorArea.textContent = ''
195
- const p = document.createElement('div')
196
- p.textContent = 'TiddlyWiki 服务正在启动…'
197
- errorArea.append(p)
198
- }
199
-
200
- const showFrame = (url: string): void => {
201
- if (iframe === undefined || errorArea === undefined) return
202
- errorArea.hidden = true
203
- iframe.hidden = false
204
- // Set the src only when the url actually changed, so an editor in the
205
- // iframe never loses unsaved state on a status refresh.
206
- if (iframe.dataset.loaded !== url) {
207
- iframe.dataset.loaded = url
208
- iframe.src = url
209
- }
210
- }
211
-
212
- const doRefresh = async (): Promise<void> => {
213
- if (refreshTimer !== undefined) {
214
- window.clearTimeout(refreshTimer)
215
- refreshTimer = undefined
216
- }
217
- const payload = await fetchStatus()
218
- if (payload === null) {
219
- setChip('failed', '状态不可达')
220
- showError('无法访问 /dsh-tiddlywiki/status')
221
- return
222
- }
223
- if (payload.status === 'running' && typeof payload.url === 'string') {
224
- setChip('running', '在线')
225
- refreshAttempts = 0
226
- showFrame(payload.url)
227
- return
228
- }
229
- if (payload.status === 'starting') {
230
- setChip('starting', '启动中')
231
- showStarting()
232
- // Auto-poll while starting (bounded).
233
- if (refreshAttempts < 30) {
234
- refreshAttempts++
235
- refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
236
- }
237
- return
238
- }
239
- setChip('failed', '离线')
240
- refreshAttempts = 0
241
- showError(payload.error ?? `服务状态:${payload.status}`)
242
- }
243
-
244
- const applyActive = (): void => {
245
- if (state.isOpen()) {
246
- for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
247
- document.documentElement.setAttribute(ACTIVE_ATTR, '')
248
- document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
249
- void doRefresh()
250
- } else {
251
- document.documentElement.removeAttribute(ACTIVE_ATTR)
252
- if (refreshTimer !== undefined) {
253
- window.clearTimeout(refreshTimer)
254
- refreshTimer = undefined
255
- }
256
- refreshAttempts = 0
257
- }
258
- }
259
- const onOtherActivate = (event: Event): void => {
260
- const detail = (event as CustomEvent).detail
261
- if (detail !== PANEL_NAME && state.isOpen()) state.closePanel()
262
- }
263
- const onClickSidebarRow = (event: MouseEvent): void => {
264
- if (!state.isOpen()) return
265
- const target = event.target as HTMLElement | null
266
- if (target === null) return
267
- if (target.closest(ENTRY_SELECTOR) !== null) return
268
- const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
269
- if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) state.closePanel()
270
- }
271
-
272
- // Mount the container once the column exists; self-heal on re-renders.
273
- const waitObserver = new MutationObserver(() => { ensure() })
274
- waitObserver.observe(document.body, { childList: true, subtree: true })
275
-
276
- // Keep the overlay pinned to the column: resize, layout mutations, scroll.
277
- const resizeObserver = new ResizeObserver(() => syncRect())
278
- resizeObserver.observe(document.body)
279
- const syncInterval = window.setInterval(syncRect, SYNC_INTERVAL_MS)
280
- const onWindowResize = (): void => syncRect()
281
- window.addEventListener('resize', onWindowResize)
282
- const onAnyScroll = (): void => syncRect()
283
- window.addEventListener('scroll', onAnyScroll, true)
284
-
285
- document.addEventListener('click', onClickSidebarRow, true)
286
- document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
287
- const unsubscribe = state.subscribe(applyActive)
288
- ensure()
289
- applyActive()
290
-
291
- return () => {
292
- if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
293
- window.clearInterval(syncInterval)
294
- window.removeEventListener('resize', onWindowResize)
295
- window.removeEventListener('scroll', onAnyScroll, true)
296
- resizeObserver.disconnect()
297
- document.removeEventListener('click', onClickSidebarRow, true)
298
- document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
299
- waitObserver.disconnect()
300
- unsubscribe()
301
- document.documentElement.removeAttribute(ACTIVE_ATTR)
302
- container?.remove()
303
- }
304
- }
1
+ /**
2
+ * Center-column TiddlyWiki editor panel (design doc §12).
3
+ *
4
+ * A fixed-position overlay sized to the CENTER column's bounding rect and
5
+ * appended to document.body, so it never depends on the shell's flex/grid
6
+ * height model or on the column being a positioning ancestor (both caused the
7
+ * earlier "half-height, scrollable" symptom). The rect is re-measured on
8
+ * resize, on layout mutations, and on a light interval. While active it hides
9
+ * the conversation content via a stylesheet rule keyed on `data-dsh-tw-active`.
10
+ * Toggling rides the shared PanelState; cross-plugin exclusivity rides the
11
+ * `dsh-panel-activate` event (same protocol as dsh-taskboard).
12
+ *
13
+ * The iframe points DIRECTLY at the TW service (http://127.0.0.1:<port>),
14
+ * bypassing reverse-proxy sensitivity (design doc R1 — proxy is only a
15
+ * fallback). The panel reads /status first and only sets iframe.src when the
16
+ * service is actually running.
17
+ *
18
+ * @module dsh-tiddlywiki/client/panel
19
+ */
20
+ import type { PanelState } from './state.ts'
21
+ import { ENTRY_SELECTOR } from './sidebar-entry.ts'
22
+
23
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-tw-view]'
24
+
25
+ /**
26
+ * Center-column targets, most-specific shell generation first. The official
27
+ * layout shell (dsh-client-ui-layout) drops data-pane and uses a CSS-Module
28
+ * hashed `centerCol`; older shells put `data-pane="conversation"` on the same
29
+ * full-height grid item; DSH Desktop exposes the non-compat
30
+ * `.dshDesktopConversationSurface`.
31
+ */
32
+ const COLUMN_SELECTORS = ['[class*="centerCol"]', '[data-pane="conversation"]', '.dshDesktopConversationSurface']
33
+
34
+ const ACTIVE_ATTR = 'data-dsh-tw-active'
35
+ /** Sibling panels' activation attributes, evicted when this panel opens. */
36
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
37
+ /** Cross-plugin activation event; detail is the activating panel name. */
38
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
39
+ const PANEL_NAME = 'dsh-tiddlywiki'
40
+
41
+ /** Overlay z-index: above the shell content, below the note widget (950). */
42
+ const PANEL_Z_INDEX = 40
43
+ /**
44
+ * dsh-better-sidebar's unified fixed host layer (its persistent
45
+ * expand/collapse toggle cluster lives inside it, at a global z-index of 25
46
+ * — its internal 45 is trapped by the host's stacking context). When this
47
+ * host is present the panel must stay BELOW it, so the sidebar toggle
48
+ * buttons stay visible and clickable above the full-screen TW panel.
49
+ */
50
+ const PANEL_HOST_SELECTOR = '[data-dsh-panel-host]'
51
+ /** The app's own shell overlay layer (dsh-client-ui-layout pins it at 20). */
52
+ const APP_OVERLAY_Z_INDEX = 20
53
+ /** Safety re-measure cadence for shell layout changes CSS can't see. */
54
+ const SYNC_INTERVAL_MS = 2_000
55
+
56
+ const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
57
+ const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
58
+
59
+ /**
60
+ * The panel's z-index: below any dsh-better-sidebar host layer so its
61
+ * persistent toggle cluster (top-right corner) stays visible and clickable
62
+ * above the full-screen TW panel, otherwise the default 40. The host's live
63
+ * computed z-index is read instead of hardcoding 25, so the rule tracks
64
+ * plugin updates; the panel is still clamped above the app's own shell
65
+ * overlay layer (dsh-client-ui-layout pins it at 20).
66
+ */
67
+ function resolvePanelZIndex(): number {
68
+ const host = document.querySelector<HTMLElement>(PANEL_HOST_SELECTOR)
69
+ if (host === null) return PANEL_Z_INDEX
70
+ const parsed = parseInt(getComputedStyle(host).zIndex, 10)
71
+ if (!Number.isFinite(parsed)) return PANEL_Z_INDEX
72
+ return Math.max(APP_OVERLAY_Z_INDEX, Math.min(PANEL_Z_INDEX, parsed - 1))
73
+ }
74
+
75
+ interface StatusPayload {
76
+ ok?: boolean
77
+ status: string
78
+ url?: string
79
+ wikiPath?: string
80
+ error?: string
81
+ note?: { tag?: string }
82
+ }
83
+
84
+ function conversationColumn(): HTMLElement | undefined {
85
+ for (const selector of COLUMN_SELECTORS) {
86
+ const el = document.querySelector<HTMLElement>(selector)
87
+ if (el !== null) return el
88
+ }
89
+ return undefined
90
+ }
91
+
92
+ async function fetchStatus(): Promise<StatusPayload | null> {
93
+ try {
94
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
95
+ if (!res.ok) return null
96
+ return (await res.json()) as StatusPayload
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ async function requestRestart(): Promise<boolean> {
103
+ try {
104
+ const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
105
+ return res.ok
106
+ } catch {
107
+ return false
108
+ }
109
+ }
110
+
111
+ export function mountPanel(state: PanelState): () => void {
112
+ let container: HTMLDivElement | undefined
113
+ let columnEl: HTMLElement | undefined
114
+ let iframe: HTMLIFrameElement | undefined
115
+ let frameArea: HTMLDivElement | undefined
116
+ let errorArea: HTMLDivElement | undefined
117
+ let chip: HTMLSpanElement | undefined
118
+ let refreshTimer: number | undefined
119
+ let refreshAttempts = 0
120
+
121
+ const build = (): HTMLDivElement => {
122
+ const view = document.createElement('div')
123
+ view.dataset.dshTwView = ''
124
+ view.className = 'dsh-tw-view'
125
+
126
+ const bar = document.createElement('div')
127
+ bar.className = 'dsh-tw-panel-bar'
128
+ const title = document.createElement('span')
129
+ title.className = 'dsh-tw-panel-title'
130
+ title.textContent = 'TiddlyWiki 知识库'
131
+ chip = document.createElement('span')
132
+ chip.className = 'dsh-tw-status-chip'
133
+ chip.dataset.state = 'unknown'
134
+ chip.textContent = '—'
135
+ const reload = document.createElement('button')
136
+ reload.type = 'button'
137
+ reload.textContent = '重载'
138
+ reload.addEventListener('click', () => {
139
+ if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
140
+ })
141
+ const refresh = document.createElement('button')
142
+ refresh.type = 'button'
143
+ refresh.textContent = '状态'
144
+ refresh.addEventListener('click', () => { void doRefresh() })
145
+ bar.append(title, chip, refresh, reload)
146
+
147
+ frameArea = document.createElement('div')
148
+ frameArea.className = 'dsh-tw-panel-frame-wrap'
149
+ frameArea.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'
150
+ iframe = document.createElement('iframe')
151
+ iframe.className = 'dsh-tw-panel-frame'
152
+ iframe.title = 'TiddlyWiki'
153
+ iframe.hidden = true
154
+ frameArea.append(iframe)
155
+
156
+ errorArea = document.createElement('div')
157
+ errorArea.className = 'dsh-tw-panel-error'
158
+ errorArea.hidden = true
159
+
160
+ view.append(bar, frameArea, errorArea)
161
+ return view
162
+ }
163
+
164
+ const setChip = (stateName: string, text: string): void => {
165
+ if (chip === undefined) return
166
+ chip.dataset.state = stateName
167
+ chip.textContent = text
168
+ }
169
+
170
+ /** Pin the overlay to the center column's current viewport rect. */
171
+ const syncRect = (): void => {
172
+ if (container === undefined || columnEl === undefined) return
173
+ const rect = columnEl.getBoundingClientRect()
174
+ if (rect.width === 0 && rect.height === 0) return
175
+ const left = `${rect.left}px`
176
+ const top = `${rect.top}px`
177
+ const width = `${rect.width}px`
178
+ const height = `${rect.height}px`
179
+ if (container.style.left !== left) container.style.left = left
180
+ if (container.style.top !== top) container.style.top = top
181
+ if (container.style.width !== width) container.style.width = width
182
+ if (container.style.height !== height) container.style.height = height
183
+ }
184
+
185
+ /**
186
+ * Coexist with dsh-better-sidebar: keep the panel's z-index below the
187
+ * host layer (so its toggle cluster stays clickable above the panel) and
188
+ * flag the host's presence so CSS can reserve the cluster's width at the
189
+ * panel bar's right end. Re-run whenever the host mounts/unmounts.
190
+ */
191
+ const applyChrome = (): void => {
192
+ if (container === undefined) return
193
+ container.style.zIndex = String(resolvePanelZIndex())
194
+ const hasHost = document.querySelector(PANEL_HOST_SELECTOR) !== null
195
+ if (hasHost) container.dataset.sidebarHost = '1'
196
+ else delete container.dataset.sidebarHost
197
+ }
198
+
199
+ const ensure = (): void => {
200
+ if (container !== undefined) return
201
+ columnEl = conversationColumn()
202
+ if (columnEl === undefined) return
203
+ container = build()
204
+ container.style.position = 'fixed'
205
+ applyChrome()
206
+ document.body.append(container)
207
+ syncRect()
208
+ }
209
+
210
+ const showError = (message: string): void => {
211
+ if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
212
+ iframe.hidden = true
213
+ errorArea.hidden = false
214
+ errorArea.textContent = ''
215
+ const p = document.createElement('div')
216
+ p.textContent = 'TiddlyWiki 服务不可用'
217
+ const code = document.createElement('code')
218
+ code.textContent = message
219
+ const retry = document.createElement('button')
220
+ retry.type = 'button'
221
+ retry.textContent = '重试'
222
+ retry.addEventListener('click', () => {
223
+ retry.disabled = true
224
+ retry.textContent = '重启中…'
225
+ void requestRestart().finally(() => { void doRefresh() })
226
+ })
227
+ errorArea.append(p, code, retry)
228
+ }
229
+
230
+ const showStarting = (): void => {
231
+ if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
232
+ iframe.hidden = true
233
+ errorArea.hidden = false
234
+ errorArea.textContent = ''
235
+ const p = document.createElement('div')
236
+ p.textContent = 'TiddlyWiki 服务正在启动…'
237
+ errorArea.append(p)
238
+ }
239
+
240
+ const showFrame = (url: string): void => {
241
+ if (iframe === undefined || errorArea === undefined) return
242
+ errorArea.hidden = true
243
+ iframe.hidden = false
244
+ // Set the src only when the url actually changed, so an editor in the
245
+ // iframe never loses unsaved state on a status refresh.
246
+ if (iframe.dataset.loaded !== url) {
247
+ iframe.dataset.loaded = url
248
+ iframe.src = url
249
+ }
250
+ }
251
+
252
+ const doRefresh = async (): Promise<void> => {
253
+ if (refreshTimer !== undefined) {
254
+ window.clearTimeout(refreshTimer)
255
+ refreshTimer = undefined
256
+ }
257
+ const payload = await fetchStatus()
258
+ if (payload === null) {
259
+ setChip('failed', '状态不可达')
260
+ showError('无法访问 /dsh-tiddlywiki/status')
261
+ return
262
+ }
263
+ if (payload.status === 'running' && typeof payload.url === 'string') {
264
+ setChip('running', '在线')
265
+ refreshAttempts = 0
266
+ showFrame(payload.url)
267
+ return
268
+ }
269
+ if (payload.status === 'starting') {
270
+ setChip('starting', '启动中')
271
+ showStarting()
272
+ // Auto-poll while starting (bounded).
273
+ if (refreshAttempts < 30) {
274
+ refreshAttempts++
275
+ refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
276
+ }
277
+ return
278
+ }
279
+ setChip('failed', '离线')
280
+ refreshAttempts = 0
281
+ showError(payload.error ?? `服务状态:${payload.status}`)
282
+ }
283
+
284
+ const applyActive = (): void => {
285
+ if (state.isOpen()) {
286
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
287
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
288
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
289
+ void doRefresh()
290
+ } else {
291
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
292
+ if (refreshTimer !== undefined) {
293
+ window.clearTimeout(refreshTimer)
294
+ refreshTimer = undefined
295
+ }
296
+ refreshAttempts = 0
297
+ }
298
+ }
299
+ const onOtherActivate = (event: Event): void => {
300
+ const detail = (event as CustomEvent).detail
301
+ if (detail !== PANEL_NAME && state.isOpen()) state.closePanel()
302
+ }
303
+ const onClickSidebarRow = (event: MouseEvent): void => {
304
+ if (!state.isOpen()) return
305
+ const target = event.target as HTMLElement | null
306
+ if (target === null) return
307
+ if (target.closest(ENTRY_SELECTOR) !== null) return
308
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
309
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) state.closePanel()
310
+ }
311
+
312
+ // Mount the container once the column exists; self-heal on re-renders and
313
+ // re-resolve the coexistence chrome when better-sidebar mounts/unmounts.
314
+ const waitObserver = new MutationObserver(() => { ensure(); applyChrome() })
315
+ waitObserver.observe(document.body, { childList: true, subtree: true })
316
+
317
+ // Keep the overlay pinned to the column: resize, layout mutations, scroll.
318
+ const resizeObserver = new ResizeObserver(() => syncRect())
319
+ resizeObserver.observe(document.body)
320
+ const syncInterval = window.setInterval(syncRect, SYNC_INTERVAL_MS)
321
+ const onWindowResize = (): void => syncRect()
322
+ window.addEventListener('resize', onWindowResize)
323
+ const onAnyScroll = (): void => syncRect()
324
+ window.addEventListener('scroll', onAnyScroll, true)
325
+
326
+ document.addEventListener('click', onClickSidebarRow, true)
327
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
328
+ const unsubscribe = state.subscribe(applyActive)
329
+ ensure()
330
+ applyActive()
331
+
332
+ return () => {
333
+ if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
334
+ window.clearInterval(syncInterval)
335
+ window.removeEventListener('resize', onWindowResize)
336
+ window.removeEventListener('scroll', onAnyScroll, true)
337
+ resizeObserver.disconnect()
338
+ document.removeEventListener('click', onClickSidebarRow, true)
339
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
340
+ waitObserver.disconnect()
341
+ unsubscribe()
342
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
343
+ container?.remove()
344
+ }
345
+ }