dsh-tiddlywiki 0.1.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.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.bundle.js +1417 -0
- package/lib/client.js +1427 -0
- package/lib/index.js +2153 -0
- package/lib/index.js.map +1 -0
- package/package.json +63 -0
- package/src/client/editor-popup.ts +121 -0
- package/src/client/index.ts +76 -0
- package/src/client/note-widget.ts +210 -0
- package/src/client/panel.ts +304 -0
- package/src/client/settings-page.ts +397 -0
- package/src/client/sidebar-entry.ts +148 -0
- package/src/client/state.ts +39 -0
- package/src/client/styles.ts +235 -0
- package/src/client/toast.ts +22 -0
- package/src/host/admin.ts +408 -0
- package/src/host/config.ts +86 -0
- package/src/host/git.ts +218 -0
- package/src/host/routes.ts +233 -0
- package/src/host/seed-notes.ts +62 -0
- package/src/host/tools.ts +254 -0
- package/src/host/tw-api.ts +157 -0
- package/src/host/wiki.ts +287 -0
- package/src/index.ts +331 -0
- package/src/sdk.ts +198 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
|
|
3
|
+
* collapsible, independent of any shell DOM. Lets the human jot drafts /
|
|
4
|
+
* scratch notes while waiting for the AI or drafting the next prompt.
|
|
5
|
+
*
|
|
6
|
+
* Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tag
|
|
7
|
+
* editable; defaults: timestamp title + config tag, usually "inbox").
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-tiddlywiki/client/note-widget
|
|
10
|
+
*/
|
|
11
|
+
import { toast } from './toast.ts'
|
|
12
|
+
import { openEditorPopup } from './editor-popup.ts'
|
|
13
|
+
|
|
14
|
+
const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
|
|
15
|
+
const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
|
|
16
|
+
const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
|
|
17
|
+
|
|
18
|
+
function pad(n: number): string {
|
|
19
|
+
return n < 10 ? `0${n}` : String(n)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default note title: `YYYY-MM-DD HH:mm`. */
|
|
23
|
+
function timestampTitle(date = new Date()): string {
|
|
24
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fetchDefaultTag(): Promise<string> {
|
|
28
|
+
try {
|
|
29
|
+
const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
|
|
30
|
+
if (!res.ok) return 'inbox'
|
|
31
|
+
const payload = (await res.json()) as { note?: { tag?: string } }
|
|
32
|
+
return payload.note?.tag ?? 'inbox'
|
|
33
|
+
} catch {
|
|
34
|
+
return 'inbox'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mountNoteWidget(): () => void {
|
|
39
|
+
const root = document.createElement('div')
|
|
40
|
+
root.className = 'dsh-tw-note'
|
|
41
|
+
|
|
42
|
+
const card = document.createElement('div')
|
|
43
|
+
card.className = 'dsh-tw-note-card'
|
|
44
|
+
card.hidden = true
|
|
45
|
+
|
|
46
|
+
const head = document.createElement('div')
|
|
47
|
+
head.className = 'dsh-tw-note-head'
|
|
48
|
+
const label = document.createElement('span')
|
|
49
|
+
label.className = 'dsh-tw-note-label'
|
|
50
|
+
label.textContent = '📝 快速笔记'
|
|
51
|
+
const closeBtn = document.createElement('button')
|
|
52
|
+
closeBtn.type = 'button'
|
|
53
|
+
closeBtn.className = 'dsh-tw-note-close'
|
|
54
|
+
closeBtn.title = '收起'
|
|
55
|
+
closeBtn.textContent = '✕'
|
|
56
|
+
head.append(label, closeBtn)
|
|
57
|
+
|
|
58
|
+
const fields = document.createElement('div')
|
|
59
|
+
fields.className = 'dsh-tw-note-fields'
|
|
60
|
+
const titleInput = document.createElement('input')
|
|
61
|
+
titleInput.className = 'dsh-tw-note-title'
|
|
62
|
+
titleInput.placeholder = '标题(默认时间戳)'
|
|
63
|
+
const tagInput = document.createElement('input')
|
|
64
|
+
tagInput.className = 'dsh-tw-note-tag'
|
|
65
|
+
tagInput.placeholder = 'tag(默认 inbox)'
|
|
66
|
+
fields.append(titleInput, tagInput)
|
|
67
|
+
|
|
68
|
+
const textarea = document.createElement('textarea')
|
|
69
|
+
textarea.className = 'dsh-tw-note-text'
|
|
70
|
+
textarea.placeholder = '写点东西…(Ctrl+Enter 保存)'
|
|
71
|
+
|
|
72
|
+
const foot = document.createElement('div')
|
|
73
|
+
foot.className = 'dsh-tw-note-foot'
|
|
74
|
+
const hint = document.createElement('span')
|
|
75
|
+
hint.className = 'dsh-tw-note-hint'
|
|
76
|
+
hint.textContent = 'Ctrl+Enter'
|
|
77
|
+
const edit = document.createElement('button')
|
|
78
|
+
edit.type = 'button'
|
|
79
|
+
edit.className = 'dsh-tw-note-edit'
|
|
80
|
+
edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
|
|
81
|
+
edit.textContent = '✏️ 在 TW 中编辑'
|
|
82
|
+
const save = document.createElement('button')
|
|
83
|
+
save.type = 'button'
|
|
84
|
+
save.className = 'dsh-tw-note-save'
|
|
85
|
+
save.textContent = '保存'
|
|
86
|
+
foot.append(hint, edit, save)
|
|
87
|
+
|
|
88
|
+
card.append(head, fields, textarea, foot)
|
|
89
|
+
|
|
90
|
+
const toggle = document.createElement('button')
|
|
91
|
+
toggle.type = 'button'
|
|
92
|
+
toggle.className = 'dsh-tw-note-toggle'
|
|
93
|
+
toggle.textContent = '📝 快速笔记'
|
|
94
|
+
|
|
95
|
+
root.append(card, toggle)
|
|
96
|
+
document.body.append(root)
|
|
97
|
+
|
|
98
|
+
let defaultTag = 'inbox'
|
|
99
|
+
let opened = false
|
|
100
|
+
|
|
101
|
+
const resetTitle = (): void => {
|
|
102
|
+
titleInput.value = timestampTitle()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const open = async (): Promise<void> => {
|
|
106
|
+
card.hidden = false
|
|
107
|
+
opened = true
|
|
108
|
+
resetTitle()
|
|
109
|
+
if (tagInput.value.length === 0) tagInput.value = defaultTag
|
|
110
|
+
textarea.focus()
|
|
111
|
+
// Refresh the configured default tag on each open (cheap, best effort).
|
|
112
|
+
defaultTag = await fetchDefaultTag()
|
|
113
|
+
if (tagInput.value.length === 0) tagInput.value = defaultTag
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const close = (): void => {
|
|
117
|
+
card.hidden = true
|
|
118
|
+
opened = false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
toggle.addEventListener('click', () => { void (opened ? close() : open()) })
|
|
122
|
+
closeBtn.addEventListener('click', close)
|
|
123
|
+
|
|
124
|
+
const doSave = async (): Promise<void> => {
|
|
125
|
+
const text = textarea.value.trim()
|
|
126
|
+
if (text.length === 0) {
|
|
127
|
+
toast('内容为空,未保存')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
save.disabled = true
|
|
131
|
+
save.textContent = '保存中…'
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(NOTE_ENDPOINT, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: { 'content-type': 'application/json' },
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
title: titleInput.value.trim(),
|
|
138
|
+
tag: tagInput.value.trim(),
|
|
139
|
+
text,
|
|
140
|
+
}),
|
|
141
|
+
signal: AbortSignal.timeout(10_000),
|
|
142
|
+
})
|
|
143
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
|
|
144
|
+
if (!res.ok || payload?.ok !== true) {
|
|
145
|
+
toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
textarea.value = ''
|
|
149
|
+
resetTitle()
|
|
150
|
+
toast(`已保存「${payload.title ?? titleInput.value}」`)
|
|
151
|
+
close()
|
|
152
|
+
} catch (err) {
|
|
153
|
+
toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
|
|
154
|
+
} finally {
|
|
155
|
+
save.disabled = false
|
|
156
|
+
save.textContent = '保存'
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
save.addEventListener('click', () => { void doSave() })
|
|
161
|
+
textarea.addEventListener('keydown', (event) => {
|
|
162
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
163
|
+
event.preventDefault()
|
|
164
|
+
void doSave()
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
/** Save (if non-empty) and open the tiddler in TW's native editor. */
|
|
169
|
+
const doEdit = async (): Promise<void> => {
|
|
170
|
+
const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
|
|
171
|
+
const text = textarea.value
|
|
172
|
+
const tag = tagInput.value.trim()
|
|
173
|
+
edit.disabled = true
|
|
174
|
+
edit.textContent = '打开中…'
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(EDIT_ENDPOINT, {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'content-type': 'application/json' },
|
|
179
|
+
body: JSON.stringify({ title, tag, text }),
|
|
180
|
+
signal: AbortSignal.timeout(10_000),
|
|
181
|
+
})
|
|
182
|
+
const payload = (await res.json().catch(() => null)) as
|
|
183
|
+
| { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
|
|
184
|
+
| null
|
|
185
|
+
if (!res.ok || payload?.ok !== true) {
|
|
186
|
+
toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
|
|
190
|
+
toast('打开失败:服务未返回编辑器地址')
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
|
|
194
|
+
toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
|
|
195
|
+
} catch (err) {
|
|
196
|
+
toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
|
|
197
|
+
} finally {
|
|
198
|
+
edit.disabled = false
|
|
199
|
+
edit.textContent = '✏️ 在 TW 中编辑'
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
edit.addEventListener('click', () => { void doEdit() })
|
|
204
|
+
|
|
205
|
+
return () => {
|
|
206
|
+
root.remove()
|
|
207
|
+
const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
|
|
208
|
+
toastEl?.remove()
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,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
|
+
/** 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
|
+
}
|