dsh-tiddlywiki 0.4.0 → 0.5.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/README.md +33 -20
- package/lib/client.bundle.js +899 -384
- package/lib/client.js +899 -384
- package/lib/index.js +663 -29
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/index.ts +10 -4
- package/src/client/knowledge-fab.ts +232 -0
- package/src/client/markdown-editor.ts +5 -0
- package/src/client/note-widget.ts +721 -475
- package/src/client/panel.ts +7 -56
- package/src/client/settings-page.ts +3 -3
- package/src/client/styles.ts +110 -0
- package/src/client/sync-button.ts +191 -188
- package/src/host/git.ts +21 -0
- package/src/host/routes.ts +65 -0
- package/src/host/seed-notes.ts +7 -5
- package/src/host/tools.ts +589 -281
- package/src/host/tw-api.ts +80 -6
- package/src/index.ts +12 -4
- package/src/sdk.ts +8 -2
package/src/client/panel.ts
CHANGED
|
@@ -115,9 +115,6 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
115
115
|
let iframe: HTMLIFrameElement | undefined
|
|
116
116
|
let frameArea: HTMLDivElement | undefined
|
|
117
117
|
let errorArea: HTMLDivElement | undefined
|
|
118
|
-
let chip: HTMLSpanElement | undefined
|
|
119
|
-
let statusDot: HTMLSpanElement | undefined
|
|
120
|
-
let statusFloater: HTMLDivElement | undefined
|
|
121
118
|
let refreshTimer: number | undefined
|
|
122
119
|
let refreshAttempts = 0
|
|
123
120
|
|
|
@@ -143,48 +140,6 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
143
140
|
return view
|
|
144
141
|
}
|
|
145
142
|
|
|
146
|
-
/**
|
|
147
|
-
* Bottom-right floating status/reload buttons (same visual language as the
|
|
148
|
-
* quick-note toggle) — the top panel bar is gone, so TW's menubar reaches
|
|
149
|
-
* the very top of the panel and clears the host's top-right buttons. Mount
|
|
150
|
-
* is gated by `ui.showPanelStatus` (settings page toggle).
|
|
151
|
-
*/
|
|
152
|
-
const buildStatusFloater = (): HTMLDivElement => {
|
|
153
|
-
const wrap = document.createElement('div')
|
|
154
|
-
wrap.className = 'dsh-tw-panel-status'
|
|
155
|
-
|
|
156
|
-
const statusBtn = document.createElement('button')
|
|
157
|
-
statusBtn.type = 'button'
|
|
158
|
-
statusBtn.className = 'dsh-tw-panel-status-btn'
|
|
159
|
-
statusBtn.title = '刷新状态'
|
|
160
|
-
statusDot = document.createElement('span')
|
|
161
|
-
statusDot.className = 'dsh-tw-status-dot'
|
|
162
|
-
statusDot.dataset.state = 'unknown'
|
|
163
|
-
chip = document.createElement('span')
|
|
164
|
-
chip.className = 'dsh-tw-panel-status-text'
|
|
165
|
-
chip.textContent = '—'
|
|
166
|
-
statusBtn.append(statusDot, chip)
|
|
167
|
-
statusBtn.addEventListener('click', () => { void doRefresh() })
|
|
168
|
-
|
|
169
|
-
const reloadBtn = document.createElement('button')
|
|
170
|
-
reloadBtn.type = 'button'
|
|
171
|
-
reloadBtn.className = 'dsh-tw-panel-status-btn'
|
|
172
|
-
reloadBtn.textContent = '重载'
|
|
173
|
-
reloadBtn.title = '重载 TiddlyWiki 面板'
|
|
174
|
-
reloadBtn.addEventListener('click', () => {
|
|
175
|
-
if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
wrap.append(statusBtn, reloadBtn)
|
|
179
|
-
document.body.append(wrap)
|
|
180
|
-
return wrap
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const setChip = (stateName: string, text: string): void => {
|
|
184
|
-
if (statusDot !== undefined) statusDot.dataset.state = stateName
|
|
185
|
-
if (chip !== undefined) chip.textContent = text
|
|
186
|
-
}
|
|
187
|
-
|
|
188
143
|
/** Pin the overlay to the center column's current viewport rect. */
|
|
189
144
|
const syncRect = (): void => {
|
|
190
145
|
if (container === undefined || columnEl === undefined) return
|
|
@@ -274,18 +229,15 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
274
229
|
}
|
|
275
230
|
const payload = await fetchStatus()
|
|
276
231
|
if (payload === null) {
|
|
277
|
-
setChip('failed', '状态不可达')
|
|
278
232
|
showError('无法访问 /dsh-tiddlywiki/status')
|
|
279
233
|
return
|
|
280
234
|
}
|
|
281
235
|
if (payload.status === 'running' && typeof payload.url === 'string') {
|
|
282
|
-
setChip('running', '在线')
|
|
283
236
|
refreshAttempts = 0
|
|
284
237
|
showFrame(payload.url)
|
|
285
238
|
return
|
|
286
239
|
}
|
|
287
240
|
if (payload.status === 'starting') {
|
|
288
|
-
setChip('starting', '启动中')
|
|
289
241
|
showStarting()
|
|
290
242
|
// Auto-poll while starting (bounded).
|
|
291
243
|
if (refreshAttempts < 30) {
|
|
@@ -294,7 +246,6 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
294
246
|
}
|
|
295
247
|
return
|
|
296
248
|
}
|
|
297
|
-
setChip('failed', '离线')
|
|
298
249
|
refreshAttempts = 0
|
|
299
250
|
showError(payload.error ?? `服务状态:${payload.status}`)
|
|
300
251
|
}
|
|
@@ -347,12 +298,12 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
347
298
|
ensure()
|
|
348
299
|
applyActive()
|
|
349
300
|
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
301
|
+
// The "知识库" FAB's 重载面板 entry dispatches this event to reload the
|
|
302
|
+
// iframe (the panel itself no longer owns a floating status/reload button).
|
|
303
|
+
const onReloadRequest = (): void => {
|
|
304
|
+
if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
|
|
305
|
+
}
|
|
306
|
+
document.addEventListener('dsh-tw-panel-reload', onReloadRequest)
|
|
356
307
|
|
|
357
308
|
return () => {
|
|
358
309
|
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
|
@@ -362,10 +313,10 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
362
313
|
resizeObserver.disconnect()
|
|
363
314
|
document.removeEventListener('click', onClickSidebarRow, true)
|
|
364
315
|
document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
|
|
316
|
+
document.removeEventListener('dsh-tw-panel-reload', onReloadRequest)
|
|
365
317
|
waitObserver.disconnect()
|
|
366
318
|
unsubscribe()
|
|
367
319
|
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
368
320
|
container?.remove()
|
|
369
|
-
statusFloater?.remove()
|
|
370
321
|
}
|
|
371
322
|
}
|
|
@@ -210,9 +210,9 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
210
210
|
numField('git.debounceMs', '自动 commit 防抖(ms)', typeof git.debounceMs === 'number' ? git.debounceMs : 60_000)
|
|
211
211
|
textField('git.remote', 'git 远端(空=仅本地)', typeof git.remote === 'string' ? git.remote : '')
|
|
212
212
|
textField('git.branch', 'git 分支', typeof git.branch === 'string' ? git.branch : 'main')
|
|
213
|
-
checkField('ui.showQuickNote', '
|
|
214
|
-
checkField('ui.showPanelStatus', '
|
|
215
|
-
checkField('ui.showSyncButton', '
|
|
213
|
+
checkField('ui.showQuickNote', '显示「知识库」按钮里的「快速笔记」入口', ui.showQuickNote !== false)
|
|
214
|
+
checkField('ui.showPanelStatus', '显示「知识库」按钮里的 TW 面板/重载入口与状态行', ui.showPanelStatus !== false)
|
|
215
|
+
checkField('ui.showSyncButton', '显示「知识库」按钮里的「同步」入口与 git 状态点', ui.showSyncButton !== false)
|
|
216
216
|
// 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
|
|
217
217
|
|
|
218
218
|
const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
|
package/src/client/styles.ts
CHANGED
|
@@ -104,6 +104,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
104
104
|
display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
|
|
105
105
|
font-family: inherit;
|
|
106
106
|
}
|
|
107
|
+
/* The wrapper's display:flex would beat the UA [hidden]{display:none} rule,
|
|
108
|
+
so the card's show/hide (root.hidden) needs an explicit rule. */
|
|
109
|
+
.dsh-tw-note[hidden] { display: none; }
|
|
107
110
|
.dsh-tw-note-card {
|
|
108
111
|
width: 340px; max-width: calc(100vw - 40px);
|
|
109
112
|
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
@@ -344,6 +347,113 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
|
|
|
344
347
|
.dsh-tw-settings-error { color: var(--dsw-alias-state-error-primary, #d13b3b); font-size: 12px; }
|
|
345
348
|
.dsh-tw-settings-search { flex: 0 0 auto; max-width: 220px; }
|
|
346
349
|
.dsh-tw-settings-check { accent-color: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
350
|
+
|
|
351
|
+
/* ── "知识库" FAB (v0.5: quick-note + sync + panel status merged) ──────────
|
|
352
|
+
One fixed cluster above the shutdown launcher FAB (bottom:24). The FAB
|
|
353
|
+
carries a git status dot; the menu pops upward from it. */
|
|
354
|
+
.dsh-tw-fab-wrap {
|
|
355
|
+
position: fixed; right: 24px; bottom: 88px; z-index: 960;
|
|
356
|
+
display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
|
|
357
|
+
}
|
|
358
|
+
.dsh-tw-fab {
|
|
359
|
+
position: relative;
|
|
360
|
+
width: 46px; height: 46px; border-radius: 50%;
|
|
361
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
362
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
363
|
+
box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
|
|
364
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
365
|
+
transition: background-color 120ms ease, transform 80ms ease;
|
|
366
|
+
}
|
|
367
|
+
.dsh-tw-fab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
|
|
368
|
+
.dsh-tw-fab:active { transform: scale(.94); }
|
|
369
|
+
.dsh-tw-fab-icon { display: inline-flex; }
|
|
370
|
+
.dsh-tw-fab-dot {
|
|
371
|
+
position: absolute; right: 0; bottom: 0; width: 11px; height: 11px;
|
|
372
|
+
border-radius: 50%; border: 2px solid var(--dsw-alias-bg-layer-2, #fff);
|
|
373
|
+
background: #999; box-sizing: border-box;
|
|
374
|
+
}
|
|
375
|
+
.dsh-tw-fab-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
376
|
+
.dsh-tw-fab-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
377
|
+
.dsh-tw-fab-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
378
|
+
.dsh-tw-fab-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
379
|
+
.dsh-tw-fab-menu {
|
|
380
|
+
min-width: 220px; max-width: calc(100vw - 48px);
|
|
381
|
+
display: flex; flex-direction: column; gap: 2px; padding: 6px;
|
|
382
|
+
border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.15));
|
|
383
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
384
|
+
box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
|
|
385
|
+
font-size: 13px;
|
|
386
|
+
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
|
|
387
|
+
animation: dsh-tw-note-in 160ms ease;
|
|
388
|
+
}
|
|
389
|
+
.dsh-tw-fab-menu[hidden] { display: none; }
|
|
390
|
+
.dsh-tw-fab-status {
|
|
391
|
+
display: flex; align-items: center; gap: 7px;
|
|
392
|
+
padding: 6px 9px; font-size: 12px; color: var(--dsw-alias-label-secondary, #666);
|
|
393
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
|
|
394
|
+
margin-bottom: 4px;
|
|
395
|
+
}
|
|
396
|
+
.dsh-tw-fab-status + .dsh-tw-fab-status { border-bottom: 0; margin-bottom: 4px; }
|
|
397
|
+
.dsh-tw-fab-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
398
|
+
.dsh-tw-fab-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
|
|
399
|
+
.dsh-tw-fab-status-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
400
|
+
.dsh-tw-fab-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
401
|
+
.dsh-tw-fab-status-dot[data-state="failed"], .dsh-tw-fab-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
402
|
+
.dsh-tw-fab-status-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
|
|
403
|
+
.dsh-tw-fab-status-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
|
|
404
|
+
.dsh-tw-fab-status-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
|
|
405
|
+
.dsh-tw-fab-status-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
|
|
406
|
+
.dsh-tw-fab-item {
|
|
407
|
+
display: flex; align-items: center; gap: 6px; text-align: left;
|
|
408
|
+
border: none; background: transparent; color: inherit; font: inherit; font-size: 13px;
|
|
409
|
+
padding: 7px 9px; border-radius: 8px; cursor: pointer;
|
|
410
|
+
}
|
|
411
|
+
.dsh-tw-fab-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
412
|
+
.dsh-tw-fab-item:active { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); }
|
|
413
|
+
|
|
414
|
+
/* ── quick-note restored-draft banner ─────────────────────────────────────── */
|
|
415
|
+
.dsh-tw-note-draft {
|
|
416
|
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
|
417
|
+
padding: 5px 9px; border-radius: 8px; font-size: 12px;
|
|
418
|
+
background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #d9822b) 12%, transparent);
|
|
419
|
+
color: var(--dsw-alias-label-primary, #222);
|
|
420
|
+
}
|
|
421
|
+
.dsh-tw-note-draft[hidden] { display: none; }
|
|
422
|
+
.dsh-tw-note-draft-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
423
|
+
.dsh-tw-note-draft-discard {
|
|
424
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
425
|
+
background: transparent; color: inherit; font: inherit; font-size: 12px;
|
|
426
|
+
padding: 2px 10px; border-radius: 999px; cursor: pointer; flex: none;
|
|
427
|
+
}
|
|
428
|
+
.dsh-tw-note-draft-discard:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
429
|
+
|
|
430
|
+
/* ── quick-note recent-notes picker ───────────────────────────────────────── */
|
|
431
|
+
.dsh-tw-note-recent {
|
|
432
|
+
position: absolute; right: 0; bottom: calc(100% - 8px); z-index: 70;
|
|
433
|
+
width: 340px; max-width: calc(100vw - 40px); max-height: 280px; overflow-y: auto;
|
|
434
|
+
display: flex; flex-direction: column; gap: 2px; padding: 6px;
|
|
435
|
+
border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.15));
|
|
436
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
437
|
+
box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
|
|
438
|
+
font-size: 13px;
|
|
439
|
+
}
|
|
440
|
+
.dsh-tw-note-recent[hidden] { display: none; }
|
|
441
|
+
.dsh-tw-note-recent-item {
|
|
442
|
+
display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
|
|
443
|
+
padding: 6px 8px; border-radius: 7px; cursor: pointer;
|
|
444
|
+
}
|
|
445
|
+
.dsh-tw-note-recent-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
|
|
446
|
+
.dsh-tw-note-recent-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
447
|
+
.dsh-tw-note-recent-meta { flex: none; font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
|
|
448
|
+
.dsh-tw-note-recent-muted { padding: 8px 10px; font-size: 12px; color: var(--dsw-alias-label-dimmed, #999); }
|
|
449
|
+
.dsh-tw-note-recent-btn {
|
|
450
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
|
|
451
|
+
background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
|
|
452
|
+
font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
|
|
453
|
+
transition: filter 120ms ease, transform 80ms ease;
|
|
454
|
+
}
|
|
455
|
+
.dsh-tw-note-recent-btn:hover { filter: brightness(.96); }
|
|
456
|
+
.dsh-tw-note-recent-btn:active { transform: scale(.96); }
|
|
347
457
|
`
|
|
348
458
|
|
|
349
459
|
export function injectStyles(): void {
|
|
@@ -1,188 +1,191 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
let
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Sync controller — the git pull→commit→push logic + live git status, decoupled
|
|
3
|
+
* from any DOM. In v0.5 the visual (status dot, 同步 entry, syncing spinner)
|
|
4
|
+
* lives in the "知识库" FAB (knowledge-fab.ts); this module only owns state,
|
|
5
|
+
* polling and the one-click sync call.
|
|
6
|
+
*
|
|
7
|
+
* - polls /dsh-tiddlywiki/status every 30s for the git state;
|
|
8
|
+
* - `trigger()` POSTs /dsh-tiddlywiki/sync (pull → commit → push), returns the
|
|
9
|
+
* fresh state and notifies subscribers.
|
|
10
|
+
*
|
|
11
|
+
* Status dot mapping (git summary from /status):
|
|
12
|
+
* offline → gray (no repo / service unreachable)
|
|
13
|
+
* dirty → amber (uncommitted changes)
|
|
14
|
+
* behind → red (remote has commits we don't — pull will rebase)
|
|
15
|
+
* clean → green (worktree clean)
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-tiddlywiki/client/sync-controller
|
|
18
|
+
*/
|
|
19
|
+
import { toast } from './toast.ts'
|
|
20
|
+
|
|
21
|
+
const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
|
|
22
|
+
const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
|
|
23
|
+
const POLL_MS = 30_000
|
|
24
|
+
|
|
25
|
+
/** Public git summary shape (mirrors GitStatusViewPublic on the host). */
|
|
26
|
+
interface GitView {
|
|
27
|
+
exists?: boolean
|
|
28
|
+
branch?: string
|
|
29
|
+
dirty?: boolean
|
|
30
|
+
dirtyFiles?: string[]
|
|
31
|
+
remote?: string
|
|
32
|
+
lastCommit?: string
|
|
33
|
+
ahead?: number
|
|
34
|
+
behind?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface StatusPayload {
|
|
38
|
+
ok?: boolean
|
|
39
|
+
status?: string
|
|
40
|
+
git?: GitView | null
|
|
41
|
+
ui?: { showSyncButton?: boolean }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SyncStateView {
|
|
45
|
+
/** Dot color key: offline | dirty | behind | clean | syncing. */
|
|
46
|
+
state: 'offline' | 'dirty' | 'behind' | 'clean' | 'syncing'
|
|
47
|
+
/** Short label for the menu entry ("已同步" / "待提交" / "可更新" / "离线"). */
|
|
48
|
+
label: string
|
|
49
|
+
/** Full tooltip line (branch / commits / dirty files / last sync). */
|
|
50
|
+
tooltip: string
|
|
51
|
+
lastSync?: Date
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pad(n: number): string {
|
|
55
|
+
return n < 10 ? `0${n}` : String(n)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compact local time for the tooltip (e.g. "10:32"). */
|
|
59
|
+
function clock(date: Date): string {
|
|
60
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchStatus(): Promise<StatusPayload | null> {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
|
|
66
|
+
if (!res.ok) return null
|
|
67
|
+
return (await res.json()) as StatusPayload
|
|
68
|
+
} catch {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Map a git summary onto a SyncStateView. */
|
|
74
|
+
function buildState(payload: StatusPayload | null, lastSync: Date | undefined): SyncStateView {
|
|
75
|
+
const git = payload?.git
|
|
76
|
+
const bits: string[] = ['同步知识库']
|
|
77
|
+
let state: SyncStateView['state'] = 'offline'
|
|
78
|
+
let label = '离线'
|
|
79
|
+
|
|
80
|
+
if (git === null || git === undefined || git.exists !== true) {
|
|
81
|
+
bits.push('git 仓库不可用')
|
|
82
|
+
} else {
|
|
83
|
+
bits.push(`分支 ${git.branch ?? '?'}`)
|
|
84
|
+
if (typeof git.lastCommit === 'string') bits.push(git.lastCommit)
|
|
85
|
+
const behind = typeof git.behind === 'number' ? git.behind : 0
|
|
86
|
+
const ahead = typeof git.ahead === 'number' ? git.ahead : 0
|
|
87
|
+
if (ahead > 0) bits.push(`领先 ${ahead}`)
|
|
88
|
+
if (behind > 0) bits.push(`落后 ${behind}`)
|
|
89
|
+
if (git.dirty === true) {
|
|
90
|
+
state = 'dirty'
|
|
91
|
+
label = '待提交'
|
|
92
|
+
bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`)
|
|
93
|
+
} else if (behind > 0) {
|
|
94
|
+
state = 'behind'
|
|
95
|
+
label = '可更新'
|
|
96
|
+
} else {
|
|
97
|
+
state = 'clean'
|
|
98
|
+
label = '已同步'
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (lastSync !== undefined) bits.push(`上次同步 ${clock(lastSync)}`)
|
|
102
|
+
return { state, label, tooltip: bits.join(' · '), ...(lastSync !== undefined ? { lastSync } : {}) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface SyncController {
|
|
106
|
+
/** Current state snapshot. */
|
|
107
|
+
getState(): SyncStateView
|
|
108
|
+
/** One-click pull → commit → push; returns the fresh state. */
|
|
109
|
+
trigger(): Promise<SyncStateView>
|
|
110
|
+
/** Notified whenever the state changes (including during syncing). */
|
|
111
|
+
subscribe(cb: () => void): () => void
|
|
112
|
+
dispose(): void
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create the sync controller: starts a 30s status poll immediately and exposes
|
|
117
|
+
* `trigger()` for the FAB's 同步 entry. No DOM is created here.
|
|
118
|
+
*/
|
|
119
|
+
export function createSyncController(): SyncController {
|
|
120
|
+
let state: SyncStateView = { state: 'offline', label: '离线', tooltip: '同步知识库' }
|
|
121
|
+
let lastSync: Date | undefined
|
|
122
|
+
let timer: number | undefined
|
|
123
|
+
let syncing = false
|
|
124
|
+
const listeners = new Set<() => void>()
|
|
125
|
+
|
|
126
|
+
const emit = (): void => {
|
|
127
|
+
for (const cb of [...listeners]) cb()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const applyStatus = (payload: StatusPayload | null): void => {
|
|
131
|
+
const next = buildState(payload, lastSync)
|
|
132
|
+
if (next.state !== state.state || next.tooltip !== state.tooltip) {
|
|
133
|
+
state = next
|
|
134
|
+
emit()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const poll = async (): Promise<void> => {
|
|
139
|
+
applyStatus(await fetchStatus())
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const doSync = async (): Promise<SyncStateView> => {
|
|
143
|
+
if (syncing) return state
|
|
144
|
+
syncing = true
|
|
145
|
+
state = { ...state, state: 'syncing', label: '同步中…' }
|
|
146
|
+
emit()
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
|
|
149
|
+
const payload = (await res.json().catch(() => null)) as
|
|
150
|
+
| { ok?: boolean; message?: string; error?: string; status?: GitView; conflictFiles?: string[]; push?: string; changed?: boolean; restarted?: boolean; restartError?: string }
|
|
151
|
+
| null
|
|
152
|
+
lastSync = new Date()
|
|
153
|
+
if (payload === null || payload.ok !== true) {
|
|
154
|
+
const msg = payload?.error ?? payload?.message ?? `HTTP ${res.status}`
|
|
155
|
+
toast(`同步失败:${msg}`)
|
|
156
|
+
} else {
|
|
157
|
+
let detail = ''
|
|
158
|
+
if (payload.push && payload.push !== 'nothing to commit') detail = `(${payload.push})`
|
|
159
|
+
if (payload.changed === true) {
|
|
160
|
+
detail += payload.restarted === true ? ',TW 已重启' : ',TW 未自动重启'
|
|
161
|
+
if (payload.restartError) detail += `(${payload.restartError})`
|
|
162
|
+
}
|
|
163
|
+
toast(`同步完成:${payload.message ?? 'OK'}${detail}`)
|
|
164
|
+
}
|
|
165
|
+
await poll()
|
|
166
|
+
} catch (err) {
|
|
167
|
+
toast(`同步失败:${err instanceof Error ? err.message : String(err)}`)
|
|
168
|
+
} finally {
|
|
169
|
+
syncing = false
|
|
170
|
+
// Re-sync the label (may still be dirty after a failed sync).
|
|
171
|
+
await poll()
|
|
172
|
+
}
|
|
173
|
+
return state
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
void poll()
|
|
177
|
+
timer = window.setInterval(() => { void poll() }, POLL_MS)
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
getState: () => state,
|
|
181
|
+
trigger: doSync,
|
|
182
|
+
subscribe(cb) {
|
|
183
|
+
listeners.add(cb)
|
|
184
|
+
return () => { listeners.delete(cb) }
|
|
185
|
+
},
|
|
186
|
+
dispose() {
|
|
187
|
+
if (timer !== undefined) { clearInterval(timer); timer = undefined }
|
|
188
|
+
listeners.clear()
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
}
|