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.
@@ -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
- // Bottom-right status/reload buttons, gated by ui.showPanelStatus.
351
- void (async () => {
352
- const payload = await fetchStatus()
353
- if (payload?.ui?.showPanelStatus === false) return
354
- statusFloater = buildStatusFloater()
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', '显示「快速笔记」悬浮按钮', ui.showQuickNote !== false)
214
- checkField('ui.showPanelStatus', '显示 TW 面板右下角「状态/重载」悬浮按钮', ui.showPanelStatus !== false)
215
- checkField('ui.showSyncButton', '显示右下角「同步」悬浮按钮', ui.showSyncButton !== false)
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', '保存配置')
@@ -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
- * Floating "sync" button (bottom-right) — one click pulls + commits + pushes
3
- * the wiki git repository and reports the result, with a live status dot so
4
- * the human always sees whether the knowledge base is in sync.
5
- *
6
- * - honors `ui.showSyncButton`: the button is mounted async and never created
7
- * when the option is off (settings page toggle);
8
- * - polls /dsh-tiddlywiki/status every 30s for the git state;
9
- * - clicking POSTs /dsh-tiddlywiki/sync (pull → commit → push) and toasts the
10
- * outcome, then refreshes the dot immediately.
11
- *
12
- * Status dot mapping (git summary from /status):
13
- * offline → gray (no repo / service unreachable)
14
- * dirty → amber (uncommitted changes)
15
- * behind → red (remote has commits we don't — pull will rebase)
16
- * clean → green (worktree clean)
17
- *
18
- * @module dsh-tiddlywiki/client/sync-button
19
- */
20
- import { toast } from './toast.ts'
21
-
22
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
23
- const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
24
- const POLL_MS = 30_000
25
-
26
- /** Public git summary shape (mirrors GitStatusViewPublic on the host). */
27
- interface GitView {
28
- exists?: boolean
29
- branch?: string
30
- dirty?: boolean
31
- dirtyFiles?: string[]
32
- remote?: string
33
- lastCommit?: string
34
- ahead?: number
35
- behind?: number
36
- }
37
-
38
- interface StatusPayload {
39
- ok?: boolean
40
- status?: string
41
- git?: GitView | null
42
- ui?: { showSyncButton?: boolean }
43
- }
44
-
45
- /** Inline sync icon (circular arrows, stroke-based, theme-colored). */
46
- const SYNC_ICON = '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.4-3.5"/><path d="M13 2.5v3h-3"/></svg>'
47
-
48
- function pad(n: number): string {
49
- return n < 10 ? `0${n}` : String(n)
50
- }
51
-
52
- /** Compact local time for the tooltip (e.g. "10:32"). */
53
- function clock(date: Date): string {
54
- return `${pad(date.getHours())}:${pad(date.getMinutes())}`
55
- }
56
-
57
- async function fetchStatus(): Promise<StatusPayload | null> {
58
- try {
59
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
60
- if (!res.ok) return null
61
- return (await res.json()) as StatusPayload
62
- } catch {
63
- return null
64
- }
65
- }
66
-
67
- /**
68
- * Mount the floating sync button. Fetches /status first: when
69
- * `ui.showSyncButton` is off the button is never created. Returns a disposer.
70
- */
71
- export function mountSyncButton(): () => void {
72
- let disposed = false
73
- let root: HTMLButtonElement | undefined
74
- let dot: HTMLSpanElement | undefined
75
- let label: HTMLSpanElement | undefined
76
- let lastSync: Date | undefined
77
- let timer: number | undefined
78
-
79
- const build = (): HTMLButtonElement => {
80
- const btn = document.createElement('button')
81
- btn.type = 'button'
82
- btn.className = 'dsh-tw-sync'
83
- btn.title = '同步知识库(pull → commit → push)'
84
-
85
- const icon = document.createElement('span')
86
- icon.className = 'dsh-tw-sync-icon'
87
- icon.innerHTML = SYNC_ICON
88
-
89
- dot = document.createElement('span')
90
- dot.className = 'dsh-tw-sync-dot'
91
- dot.dataset.state = 'offline'
92
-
93
- label = document.createElement('span')
94
- label.className = 'dsh-tw-sync-label'
95
- label.textContent = '同步'
96
-
97
- btn.append(icon, label, dot)
98
- btn.addEventListener('click', () => { void doSync() })
99
- document.body.append(btn)
100
- return btn
101
- }
102
-
103
- /** Map a git summary onto the dot + label + tooltip. */
104
- const applyStatus = (payload: StatusPayload | null): void => {
105
- if (root === undefined || dot === undefined || label === undefined) return
106
- const git = payload?.git
107
- const bits: string[] = ['同步知识库']
108
- let state = 'offline'
109
- let word = '离线'
110
-
111
- if (git === null || git === undefined || git.exists !== true) {
112
- bits.push('git 仓库不可用')
113
- } else {
114
- bits.push(`分支 ${git.branch ?? '?'}`)
115
- if (typeof git.lastCommit === 'string') bits.push(git.lastCommit)
116
- const behind = typeof git.behind === 'number' ? git.behind : 0
117
- const ahead = typeof git.ahead === 'number' ? git.ahead : 0
118
- if (ahead > 0) bits.push(`领先 ${ahead}`)
119
- if (behind > 0) bits.push(`落后 ${behind}`)
120
- if (git.dirty === true) {
121
- state = 'dirty'
122
- word = '待提交'
123
- bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`)
124
- } else if (behind > 0) {
125
- state = 'behind'
126
- word = '可更新'
127
- } else {
128
- state = 'clean'
129
- word = '已同步'
130
- }
131
- }
132
- if (lastSync !== undefined) bits.push(`上次同步 ${clock(lastSync)}`)
133
- root.title = bits.join(' · ')
134
- dot.dataset.state = state
135
- label.textContent = word
136
- }
137
-
138
- const doSync = async (): Promise<void> => {
139
- if (root === undefined) return
140
- root.disabled = true
141
- root.classList.add('dsh-tw-sync-spin')
142
- if (label !== undefined) label.textContent = '同步中…'
143
- if (dot !== undefined) dot.dataset.state = 'syncing'
144
- try {
145
- const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
146
- const payload = (await res.json().catch(() => null)) as
147
- | { ok?: boolean; message?: string; error?: string; status?: GitView; conflictFiles?: string[]; push?: string; changed?: boolean; restarted?: boolean; restartError?: string }
148
- | null
149
- lastSync = new Date()
150
- if (payload === null || payload.ok !== true) {
151
- const msg = payload?.error ?? payload?.message ?? `HTTP ${res.status}`
152
- toast(`同步失败:${msg}`)
153
- } else {
154
- let detail = ''
155
- if (payload.push && payload.push !== 'nothing to commit') detail = `(${payload.push})`
156
- if (payload.changed === true) {
157
- detail += payload.restarted === true ? ',TW 已重启' : ',TW 未自动重启'
158
- if (payload.restartError) detail += `(${payload.restartError})`
159
- }
160
- toast(`同步完成:${payload.message ?? 'OK'}${detail}`)
161
- }
162
- applyStatus(await fetchStatus())
163
- } catch (err) {
164
- toast(`同步失败:${err instanceof Error ? err.message : String(err)}`)
165
- } finally {
166
- root.disabled = false
167
- root.classList.remove('dsh-tw-sync-spin')
168
- }
169
- }
170
-
171
- const poll = async (): Promise<void> => {
172
- applyStatus(await fetchStatus())
173
- }
174
-
175
- void (async () => {
176
- const payload = await fetchStatus()
177
- if (disposed || payload?.ui?.showSyncButton === false) return
178
- root = build()
179
- applyStatus(payload)
180
- timer = window.setInterval(() => { void poll() }, POLL_MS)
181
- })()
182
-
183
- return () => {
184
- disposed = true
185
- if (timer !== undefined) window.clearInterval(timer)
186
- root?.remove()
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
+ }