dsh-home-sync 0.2.1

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/lib/ui.js ADDED
@@ -0,0 +1,841 @@
1
+ // Draggable, keyboard-accessible synchronization panel with bounded scrolling.
2
+
3
+ (function () {
4
+ if (window.__dshHomeSyncCleanup) window.__dshHomeSyncCleanup()
5
+ const API = '/dsh-home-sync/api'
6
+ const NS = 'dhs8'
7
+ const POS_KEY = 'dshome-sync-pos'
8
+ const globalListeners = []
9
+ const actionButtons = new Set()
10
+ let pendingActions = 0
11
+ const listen = (el, type, fn) => { el.addEventListener(type, fn); globalListeners.push(() => el.removeEventListener(type, fn)) }
12
+
13
+ const PALETTES = {
14
+ light: {
15
+ accent: '#2f6feb', accentFg: '#ffffff', text: '#1f2328', muted: '#5f6b7a',
16
+ border: '#c9d2dc', panelBg: '#ffffff', headBg: '#f6f8fa', fieldBg: '#f3f5f7',
17
+ hoverBg: '#e8f0fe', danger: '#d1242f', logBg: '#f6f8fa', dotRing: '#ffffff',
18
+ shadow: 'rgba(16,24,40,.16)',
19
+ },
20
+ dark: {
21
+ accent: '#6ea0ff', accentFg: '#0b1220', text: '#e6e9f0', muted: '#9aa4b2',
22
+ border: '#3a414c', panelBg: '#20242b', headBg: '#262b34', fieldBg: '#161a20',
23
+ hoverBg: '#2c3547', danger: '#ff7b72', logBg: '#15181d', dotRing: '#20242b',
24
+ shadow: 'rgba(0,0,0,.55)',
25
+ },
26
+ }
27
+
28
+ let currentPalette = PALETTES.light
29
+ const pal = Object.fromEntries(Object.keys(PALETTES.light).map(key => [key, 'var(--dhs-' + key + ')']))
30
+ let historyRows = []
31
+ let design = { accent: '', radius: 10, font: '' }
32
+ let dirtyCount = 0
33
+ let pos = { x: 48, y: null, docked: false }
34
+ let docWired = false
35
+ let observerRef = null
36
+ let ctx = null // open popover context
37
+ let followId = null
38
+
39
+ const EDGE = 90
40
+ const GAP = 10
41
+
42
+ function q(id) { return document.getElementById(id) }
43
+
44
+ // ---------- theme / design ----------
45
+ function luminance(rgb) { return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255 }
46
+ function parseRgb(str) {
47
+ const m = /rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?/.exec(str || '')
48
+ if (!m) return null
49
+ const a = m[4] === undefined ? 1 : Number(m[4])
50
+ return a < 0.5 ? null : [Number(m[1]), Number(m[2]), Number(m[3])]
51
+ }
52
+ function detectTheme() {
53
+ const hay =
54
+ ((document.documentElement.getAttribute('data-theme') || '') + ' ' + (document.documentElement.className || '') +
55
+ ' ' + (document.body.getAttribute('data-theme') || '') + ' ' + (document.body.className || '')).toLowerCase()
56
+ if (/\bdark\b/.test(hay)) return 'dark'
57
+ if (/\blight\b/.test(hay)) return 'light'
58
+ for (const el of [document.body, document.documentElement]) {
59
+ const rgb = parseRgb(getComputedStyle(el).backgroundColor)
60
+ if (rgb) return luminance(rgb) < 0.35 ? 'dark' : 'light'
61
+ }
62
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
63
+ }
64
+ function readVar(names) {
65
+ const cs = getComputedStyle(document.documentElement)
66
+ for (const n of names) {
67
+ const v = (cs.getPropertyValue(n) || '').trim()
68
+ if (v && v !== 'inherit' && v !== 'unset') return v
69
+ }
70
+ return ''
71
+ }
72
+ function hexOk(v) { return /^#?[0-9a-f]{6}$/i.test(String(v).trim()) }
73
+ function sampleDesign() {
74
+ let accent = readVar(['--accent', '--accent-color', '--brand', '--primary', '--dsh-accent', '--color-accent'])
75
+ if (!hexOk(accent)) {
76
+ accent = ''
77
+ for (const el of Array.from(document.querySelectorAll('button'))) {
78
+ if (el.id?.startsWith(NS + '-')) continue
79
+ const rgb = parseRgb(getComputedStyle(el).backgroundColor)
80
+ if (rgb && luminance(rgb) < 0.55 && luminance(rgb) > 0.08) { accent = 'rgb(' + rgb.join(',') + ')'; break }
81
+ }
82
+ }
83
+ const radius = parseInt(String(readVar(['--radius', '--radius-lg', '--border-radius', '--dsh-radius']) || '').replace(/px$/, ''), 10)
84
+ design = { accent, radius: isNaN(radius) ? 10 : radius, font: getComputedStyle(document.body).fontFamily || '' }
85
+ }
86
+ function accent() { return pal.accent }
87
+ function applyPalette(el) {
88
+ if (!el) return
89
+ for (const [key, value] of Object.entries(currentPalette)) el.style.setProperty('--dhs-' + key, key === 'accent' ? design.accent || value : value)
90
+ }
91
+ function radius() { return isNaN(design.radius) || !design.radius ? 10 : design.radius }
92
+ function uiFont() { return design.font || 'system-ui,sans-serif' }
93
+
94
+ function loadPos() {
95
+ try {
96
+ const raw = localStorage.getItem(POS_KEY)
97
+ if (raw) {
98
+ const p = JSON.parse(raw)
99
+ if (typeof p.x === 'number' && typeof p.y === 'number') pos = { x: p.x, y: p.y, docked: !!p.docked }
100
+ }
101
+ } catch { /* ignore */ }
102
+ }
103
+ function savePos() {
104
+ try { localStorage.setItem(POS_KEY, JSON.stringify(pos)) } catch { /* ignore */ }
105
+ }
106
+
107
+ // ---------- fab ----------
108
+ function setThemeColors() {
109
+ for (const suffix of ['fab', 'fb', 'pop', 'toast']) applyPalette(q(NS + '-' + suffix))
110
+ const fab = q(NS + '-fab')
111
+ if (!fab) return
112
+ fab.style.background = pal.panelBg
113
+ fab.style.borderColor = pal.border
114
+ fab.style.color = pal.text
115
+ const ico = q(NS + '-ico')
116
+ if (ico && ico.setAttribute) ico.setAttribute('stroke', accent())
117
+ const dot = q(NS + '-dot')
118
+ if (dot) { dot.style.background = pal.danger; dot.style.borderColor = pal.dotRing }
119
+ }
120
+ function applyPos(animate) {
121
+ const fab = q(NS + '-fab')
122
+ if (!fab) return
123
+ const w = fab.offsetWidth || 84
124
+ const h = fab.offsetHeight || 34
125
+ let y = pos.y
126
+ if (y === null) y = Math.round(innerHeight / 2 - h / 2)
127
+ y = Math.max(8, Math.min(innerHeight - h - 8, y))
128
+ const x = Math.max(4, Math.min(innerWidth - w - 4, pos.x))
129
+ fab.style.transition = animate ? 'left .24s cubic-bezier(.2,.8,.3,1), top .24s cubic-bezier(.2,.8,.3,1)' : 'none'
130
+ fab.style.left = x + 'px'
131
+ fab.style.top = y + 'px'
132
+ pos.x = x
133
+ pos.y = y
134
+ }
135
+
136
+ function pressDown() {
137
+ const fab = q(NS + '-fab')
138
+ if (!fab || !fab.animate) return
139
+ try { fab.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.93)' }], { duration: 80, easing: 'ease-out' }) } catch { /* ignore */ }
140
+ }
141
+ function pressUp() {
142
+ const fab = q(NS + '-fab')
143
+ if (!fab || !fab.animate) return
144
+ try {
145
+ fab.animate(
146
+ [{ transform: 'scale(0.93)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }],
147
+ { duration: 260, easing: 'cubic-bezier(.34,1.56,.64,1)' },
148
+ )
149
+ } catch { /* ignore */ }
150
+ }
151
+
152
+ // ---------- api ----------
153
+ async function api(path, body) {
154
+ const mutating = body !== undefined
155
+ if (mutating) { pendingActions++; actionButtons.forEach(b => { b.disabled = true }) }
156
+ const controller = new AbortController()
157
+ const timeout = setTimeout(() => controller.abort(), 300000)
158
+ try {
159
+ const res = await fetch(API + path, {
160
+ method: mutating ? 'POST' : 'GET',
161
+ headers: mutating ? { 'Content-Type': 'application/json' } : {},
162
+ body: mutating ? JSON.stringify(body) : undefined,
163
+ signal: controller.signal,
164
+ })
165
+ const result = await res.json().catch(() => ({ ok: false, error: '响应格式错误(HTTP ' + res.status + ')' }))
166
+ if (!res.ok) return { ...result, ok: false, error: result.error || result.message || 'HTTP ' + res.status }
167
+ return result
168
+ } finally {
169
+ clearTimeout(timeout)
170
+ if (mutating) { pendingActions--; actionButtons.forEach(b => { b.disabled = pendingActions > 0 }) }
171
+ }
172
+ }
173
+ // Floating toast feedback (replaces the old inline log box).
174
+ function showToast(txt, ok) {
175
+ let el = q(NS + '-toast')
176
+ if (!el) {
177
+ el = document.createElement('div')
178
+ el.id = NS + '-toast'
179
+ document.body.appendChild(el)
180
+ }
181
+ const kind = ok ? pal.accent : pal.danger
182
+ el.style.cssText =
183
+ 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(10px);z-index:2147483003;' +
184
+ 'background:' + pal.panelBg + ';color:' + pal.text + ';border:1px solid ' + pal.border + ';border-left:3px solid ' + kind + ';' +
185
+ 'padding:9px 16px;border-radius:10px;font:13px/1.4 ' + uiFont() + ';box-shadow:0 10px 28px ' + pal.shadow + ';' +
186
+ 'opacity:0;max-width:min(420px,84vw);text-align:center'
187
+ applyPalette(el)
188
+ el.textContent = txt
189
+ requestAnimationFrame(() => {
190
+ el.style.transition = 'opacity .18s ease, transform .18s ease'
191
+ el.style.opacity = '1'
192
+ el.style.transform = 'translateX(-50%) translateY(0)'
193
+ })
194
+ clearTimeout(el.__t)
195
+ el.__t = setTimeout(() => {
196
+ if (el) {
197
+ el.style.transition = 'opacity .18s ease, transform .18s ease'
198
+ el.style.opacity = '0'
199
+ el.style.transform = 'translateX(-50%) translateY(10px)'
200
+ setTimeout(() => el.remove(), 200)
201
+ }
202
+ }, 2400)
203
+ }
204
+ function logLine(txt, ok) {
205
+ if (ctx && ctx.logEl) ctx.logEl.textContent = txt
206
+ historyRows = [{ at: new Date().toISOString(), ok: ok !== false, message: txt }, ...historyRows].slice(0, 100)
207
+ renderHistory()
208
+ showToast(txt, ok === false ? false : true)
209
+ }
210
+ function renderHistory(warning) {
211
+ if (!ctx?.historyEl) return
212
+ const labels = { init: '初始化', sync: '同步', pull: '拉取', config: '设置' }
213
+ ctx.historyEl.textContent = (warning ? warning + '\n\n' : '') + (historyRows.map(row =>
214
+ (row.at || '') + ' ' + (labels[row.kind] || '') + (row.ok ? ' 成功' : ' 失败') + '\n' + row.message +
215
+ (row.backupDir ? '\n备份:' + row.backupDir + '\n恢复指引:备份目录中的 MERGE.md' : '')).join('\n\n') || '暂无操作记录。')
216
+ }
217
+ function setBadge() {
218
+ const fab = q(NS + '-fab')
219
+ if (fab) fab.title = dirtyCount === null ? 'Home Sync · 状态读取失败' : dirtyCount > 0 ? 'Home Sync · ' + dirtyCount + ' 个文件待提交' : 'Home Sync · 点击打开'
220
+ const dot = q(NS + '-dot')
221
+ if (dot) dot.style.display = dirtyCount === null || dirtyCount > 0 ? 'block' : 'none'
222
+ }
223
+ async function fetchStatus() {
224
+ try {
225
+ const r = await api('/status')
226
+ if (Array.isArray(r.history)) historyRows = r.history
227
+ renderHistory(r.historyWarning)
228
+ dirtyCount = r.ok && Array.isArray(r.dirty) ? r.dirty.length : null
229
+ setBadge()
230
+ return r
231
+ } catch (error) {
232
+ dirtyCount = null; setBadge()
233
+ return { ok: false, error: '状态读取失败:' + error.message }
234
+ }
235
+ }
236
+ function renderStatus(r) {
237
+ if (!ctx) return
238
+ const el = ctx.statusEl
239
+ el.innerHTML = ''
240
+ const grid = document.createElement('div')
241
+ grid.style.cssText = 'display:grid;grid-template-columns:auto 1fr;gap:2px 10px;font:12px/1.6 ' + uiFont() + ';color:' + pal.text
242
+ const add = (k, v) => {
243
+ const a = document.createElement('div'); a.textContent = k; a.style.color = pal.muted
244
+ const b = document.createElement('div'); b.textContent = v
245
+ b.style.cssText = 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap'; b.title = v
246
+ grid.append(a, b)
247
+ }
248
+ add('分支', r.branch || '-')
249
+ add('远端', r.remote || '未设置')
250
+ add('待提交', Array.isArray(r.dirty) ? String(r.dirty.length) : '未知')
251
+ if (r.ahead !== null && r.ahead !== undefined) add('待推送 / 待拉取', r.ahead + ' / ' + r.behind)
252
+ if (r.branchMatches === false) add('分支不一致', '请切换到 ' + r.config.branch)
253
+ if (!r.ok) add('状态错误', r.error || r.reason || '无法读取仓库')
254
+ if (r.operation) add('正在执行', r.operation.kind)
255
+ if (r.lastResult && !r.lastResult.ok) add('最近失败', r.lastResult.message || r.lastResult.reason)
256
+ el.append(grid)
257
+ }
258
+
259
+ // ---------- popover layout (follows pill, direction aware, smooth) ----------
260
+ function relayout(smooth) {
261
+ if (!ctx) return
262
+ const fab = ctx.anchor || q(NS + '-fab') || q(NS + '-fb')
263
+ if (!fab) return
264
+ const pop = ctx.host
265
+ const card = ctx.card
266
+ const W = 320
267
+ card.style.maxHeight = 'none'
268
+ card.style.overflow = 'visible'
269
+ const need = Math.max(120, card.offsetHeight)
270
+ const r = fab.getBoundingClientRect()
271
+ const vw = innerWidth
272
+ const vh = innerHeight
273
+ const belowSpace = vh - r.bottom - GAP
274
+ const aboveSpace = r.top - GAP
275
+ const rightSpace = vw - r.right - GAP
276
+ const leftSpace = r.left - GAP
277
+
278
+ // Cap the card at a fixed maximum height; overflow scrolls INSIDE the
279
+ // card (wheel) when content exceeds it.
280
+ const MAX = Math.max(240, Math.min(460, vh - 24))
281
+ // Direction decision must use the CAPPED height (what will actually be
282
+ // shown), not the full content height — overflow scrolls anyway.
283
+ const want = Math.min(need, MAX)
284
+
285
+ // Re-evaluate direction on EVERY layout from the pill's LIVE position:
286
+ // near an edge -> open sideways; otherwise default below, flipping above
287
+ // only when the bottom is too tight. Using proximity (not the persisted
288
+ // docked flag) keeps the transition smooth while dragging off an edge.
289
+ const size = fab.offsetWidth || 38
290
+ const nearEdge = pos.x <= EDGE || pos.x >= innerWidth - size - EDGE
291
+ let dir
292
+ if (nearEdge && (rightSpace >= W || leftSpace >= W)) {
293
+ dir = r.left < innerWidth / 2 ? 'right' : 'left'
294
+ if (dir === 'right' && rightSpace < W) dir = 'left'
295
+ if (dir === 'left' && leftSpace < W) dir = 'right'
296
+ } else if (belowSpace >= want) {
297
+ dir = 'below'
298
+ } else if (aboveSpace >= want) {
299
+ dir = 'above'
300
+ } else {
301
+ // Neither side fits the capped height: pick the side with more room.
302
+ dir = belowSpace >= aboveSpace ? 'below' : 'above'
303
+ }
304
+ const ease = 'cubic-bezier(.2,.8,.3,1)'
305
+ pop.style.transition = smooth ? 'left .3s ' + ease + ', top .3s ' + ease : 'none'
306
+ card.style.transition = smooth ? 'max-height .24s ' + ease : 'none'
307
+ let left = 0
308
+ let top = 0
309
+ let h = want
310
+ if (dir === 'below') {
311
+ const space = Math.max(120, vh - 8 - (r.bottom + GAP))
312
+ h = Math.min(h, space)
313
+ left = Math.max(8, Math.min(r.left, vw - W - 8))
314
+ top = r.bottom + GAP
315
+ } else if (dir === 'above') {
316
+ const space = Math.max(120, r.top - GAP - 8)
317
+ h = Math.min(h, space)
318
+ left = Math.max(8, Math.min(r.left, vw - W - 8))
319
+ top = Math.max(8, r.top - GAP - h)
320
+ } else if (dir === 'right') {
321
+ h = Math.min(h, vh - 16)
322
+ left = r.right + GAP
323
+ top = Math.max(8, Math.min(r.top + r.height / 2 - h / 2, vh - h - 8))
324
+ } else {
325
+ h = Math.min(h, vh - 16)
326
+ left = Math.max(8, r.left - W - GAP)
327
+ top = Math.max(8, Math.min(r.top + r.height / 2 - h / 2, vh - h - 8))
328
+ }
329
+ pop.style.left = left + 'px'
330
+ pop.style.top = top + 'px'
331
+ card.style.maxHeight = h + 'px'
332
+ card.style.overflow = h < need - 0.5 ? 'auto' : 'hidden'
333
+ ctx.dir = dir
334
+ }
335
+
336
+ // Frame-level follower: while the popover is open, watch the pill every
337
+ // animation frame and re-lay the card whenever the pill moves (manual drag
338
+ // or its own snap transition), so the card never lags behind / stays put.
339
+ function followTick() {
340
+ if (!ctx) {
341
+ followId = null
342
+ return
343
+ }
344
+ const fab = ctx.anchor || q(NS + '-fab') || q(NS + '-fb')
345
+ if (fab) {
346
+ const r = fab.getBoundingClientRect()
347
+ if (ctx.fx === undefined || Math.abs(r.left - ctx.fx) > 0.4 || Math.abs(r.top - ctx.fy) > 0.4) {
348
+ ctx.fx = r.left
349
+ ctx.fy = r.top
350
+ relayout(true)
351
+ }
352
+ }
353
+ followId = requestAnimationFrame(followTick)
354
+ }
355
+ function startFollow() {
356
+ if (followId === null) followId = requestAnimationFrame(followTick)
357
+ }
358
+ // While a section is expanding/collapsing, re-lay every frame so the card
359
+ // grows (or shrinks) together with the content instead of jumping at the end.
360
+ function animateGrow() {
361
+ const t0 = performance.now()
362
+ const step = (now) => {
363
+ if (!ctx) return
364
+ relayout(true)
365
+ if (now - t0 < 400) requestAnimationFrame(step)
366
+ else relayout(true)
367
+ }
368
+ requestAnimationFrame(step)
369
+ }
370
+
371
+ // ---------- popover build ----------
372
+ function makeBtn(label, kind, onClick) {
373
+ const b = document.createElement('button')
374
+ b.textContent = label
375
+ const base =
376
+ 'padding:6px 12px;border-radius:' + Math.round(radius() * 0.7) + 'px;font:500 13px/1 ' + uiFont() + ';' +
377
+ 'cursor:pointer;outline:none;white-space:nowrap;'
378
+ if (kind === 'primary') b.style.cssText = base + 'background:' + accent() + ';border:1px solid ' + accent() + ';color:' + pal.accentFg + ';'
379
+ else if (kind === 'danger') b.style.cssText = base + 'background:transparent;border:1px solid ' + pal.danger + ';color:' + pal.danger + ';'
380
+ else b.style.cssText = base + 'background:' + pal.panelBg + ';border:1px solid ' + pal.border + ';color:' + pal.text + ';'
381
+ b.disabled = pendingActions > 0
382
+ actionButtons.add(b)
383
+ b.addEventListener('click', () => { if (!b.disabled) onClick() })
384
+ return b
385
+ }
386
+ function field(label, input) {
387
+ const w = document.createElement('div')
388
+ w.style.cssText = 'margin:7px 0'
389
+ const l = document.createElement('label')
390
+ l.textContent = label
391
+ l.style.cssText = 'display:block;font:500 11.5px/1.4 ' + uiFont() + ';color:' + pal.muted + ';margin-bottom:3px'
392
+ w.append(l, input)
393
+ return w
394
+ }
395
+ function textInput(placeholder) {
396
+ const i = document.createElement('input')
397
+ i.type = 'text'
398
+ i.style.cssText =
399
+ 'width:100%;box-sizing:border-box;background:' + pal.fieldBg + ';border:1px solid ' + pal.border + ';color:' + pal.text + ';' +
400
+ 'border-radius:' + Math.round(radius() * 0.7) + 'px;padding:5px 8px;font:12.5px ' + uiFont() + ';outline:none'
401
+ if (placeholder) i.placeholder = placeholder
402
+ return i
403
+ }
404
+ // Smooth section: grid-rows 0fr <-> 1fr expansion.
405
+ function section(title, innerEl) {
406
+ const wrap = document.createElement('div')
407
+ wrap.style.cssText = 'margin:8px 0 0;border-top:1px solid ' + pal.border + ';padding-top:6px'
408
+ const head = document.createElement('button')
409
+ head.type = 'button'
410
+ head.setAttribute('aria-expanded', 'false')
411
+ head.style.cssText =
412
+ 'display:flex;width:100%;align-items:center;gap:6px;background:none;border:none;cursor:pointer;' +
413
+ 'font:600 12.5px/1.4 ' + uiFont() + ';color:' + pal.text + ';padding:4px 2px;outline:none'
414
+ const chev = document.createElement('span')
415
+ chev.textContent = '▸'
416
+ chev.style.cssText = 'transition:transform .22s cubic-bezier(.2,.8,.3,1);font-size:10px;color:' + pal.muted
417
+ const t = document.createElement('span')
418
+ t.textContent = title
419
+ head.append(chev, t)
420
+ const track = document.createElement('div')
421
+ track.style.cssText = 'display:grid;grid-template-rows:0fr;transition:grid-template-rows .26s cubic-bezier(.2,.8,.3,1)'
422
+ const inner = document.createElement('div')
423
+ inner.style.cssText = 'overflow:hidden;min-height:0'
424
+ inner.append(innerEl)
425
+ track.append(inner)
426
+ let open = false
427
+ head.addEventListener('click', () => {
428
+ open = !open
429
+ head.setAttribute('aria-expanded', String(open))
430
+ chev.style.transform = open ? 'rotate(90deg)' : ''
431
+ track.style.gridTemplateRows = open ? '1fr' : '0fr'
432
+ relayout(true)
433
+ animateGrow()
434
+ })
435
+ wrap.append(head, track)
436
+ return { wrapEl: wrap, open: () => open }
437
+ }
438
+
439
+ function wireDocument() {
440
+ if (docWired) return
441
+ docWired = true
442
+ listen(document, 'keydown', (e) => { if (e.key === 'Escape') closePop() })
443
+ listen(document, 'click', (e) => {
444
+ const pop = ctx && ctx.host
445
+ const anchor = ctx && ctx.anchor
446
+ if (pop && !pop.contains(e.target) && !(anchor && anchor.contains(e.target))) closePop()
447
+ })
448
+ }
449
+ function openPop(anchor) {
450
+ if (ctx) return closePop()
451
+ wireDocument()
452
+ const fab = anchor || q(NS + '-fab') || q(NS + '-fb')
453
+ if (!fab) return
454
+
455
+ const host = document.createElement('div')
456
+ host.id = NS + '-pop'
457
+ host.setAttribute('role', 'dialog')
458
+ host.setAttribute('aria-label', 'Home Sync')
459
+ host.style.cssText = 'position:fixed;z-index:2147483002;width:320px;max-width:min(320px,88vw);visibility:hidden;opacity:0;'
460
+ applyPalette(host)
461
+ const shadow = host.attachShadow({ mode: 'open' })
462
+ const style = document.createElement('style')
463
+ style.textContent =
464
+ ':host{all:initial}*{box-sizing:border-box;margin:0}' +
465
+ '.card{background:' + pal.panelBg + ';color:' + pal.text + ';border:1px solid ' + pal.border + ';' +
466
+ 'border-radius:' + radius() + 'px;box-shadow:0 8px 28px ' + pal.shadow + ';overflow:hidden;' +
467
+ 'font:13px/1.5 ' + uiFont() + ';animation:pop .18s cubic-bezier(.2,.9,.3,1.05)}' +
468
+ '@keyframes pop{from{opacity:0;transform:scale(.97) translateY(-3px)}to{opacity:1;transform:none}}' +
469
+ '.head{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid ' + pal.border + ';background:' + pal.headBg + ';' +
470
+ 'border-top-left-radius:' + radius() + 'px;border-top-right-radius:' + radius() + 'px;}' +
471
+ '.title{font:600 13px/1 ' + uiFont() + ';color:' + pal.text + '}' +
472
+ '.body{padding:10px 12px 12px}' +
473
+ '.btns{display:flex;gap:8px;flex-wrap:wrap;margin:9px 0}' +
474
+ '#log{position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}#log:empty{display:none}' +
475
+ 'p.hint{color:' + pal.muted + ';font:11.5px/1.5 ' + uiFont() + ';margin:4px 0}' +
476
+ 'button:focus-visible,input:focus-visible{outline:2px solid ' + accent() + '!important;outline-offset:2px}button:disabled{opacity:.5;cursor:wait}'
477
+ shadow.append(style)
478
+
479
+ const card = document.createElement('div')
480
+ card.className = 'card'
481
+ const head = document.createElement('div')
482
+ head.className = 'head'
483
+ const ic = document.createElement('span'); ic.textContent = '⟳'; ic.style.cssText = 'color:' + accent()
484
+ const tt = document.createElement('span'); tt.className = 'title'; tt.textContent = 'Home Sync'
485
+ const bs = document.createElement('span'); bs.id = NS + '-busy'; bs.textContent = '…'
486
+ bs.style.cssText = 'display:none;color:' + accent()
487
+ const x = document.createElement('button')
488
+ x.textContent = '✕'
489
+ x.title = '关闭 (Esc)'
490
+ x.style.cssText = 'margin-left:auto;background:none;border:none;color:' + pal.muted + ';cursor:pointer;font-size:13px;padding:1px 6px;border-radius:6px'
491
+ x.addEventListener('click', closePop)
492
+ head.append(ic, tt, bs, x)
493
+ card.append(head)
494
+
495
+ const body = document.createElement('div')
496
+ body.className = 'body'
497
+ const status = document.createElement('div')
498
+ body.append(status)
499
+ const log = document.createElement('div')
500
+ log.id = 'log'
501
+ log.setAttribute('role', 'status')
502
+ log.setAttribute('aria-live', 'polite')
503
+ body.append(log)
504
+
505
+ const btns = document.createElement('div')
506
+ btns.className = 'btns'
507
+ const act = (name, p) => () => {
508
+ bs.style.display = 'inline'
509
+ api(p.path, p.body)
510
+ .then((r) => {
511
+ const okR = !!(r && r.ok)
512
+ logLine(name + ' → ' + (okR ? '成功' + (r.message ? ':' + r.message : '') : '失败:' + ((r && (r.error || r.message || r.reason)) || '无响应')), okR)
513
+ if (p.path !== '/config') fetchStatus().then((st) => st && renderStatus(st))
514
+ })
515
+ .catch((e) => logLine(name + ' 异常: ' + e, false))
516
+ .finally(() => { bs.style.display = 'none' })
517
+ }
518
+ btns.append(
519
+ makeBtn('拉取', '', act('拉取', { path: '/pull', body: {} })),
520
+ makeBtn('同步到远端', 'primary', act('推送', { path: '/push', body: {} })),
521
+ makeBtn('刷新', '', () => fetchStatus().then((st) => { if (st) renderStatus(st) })),
522
+ )
523
+ body.append(btns)
524
+
525
+ // Settings section
526
+ const row = (input, label) => {
527
+ const w = document.createElement('div')
528
+ w.style.cssText = 'display:flex;gap:7px;align-items:center;margin:5px 0;font:12.5px ' + uiFont() + ';color:' + pal.text
529
+ const s = document.createElement('span')
530
+ s.textContent = label
531
+ w.append(input, s)
532
+ return w
533
+ }
534
+ const as2 = document.createElement('input'); as2.type = 'checkbox'; as2.style.cssText = 'accent-color:' + accent()
535
+ const ap = document.createElement('input'); ap.type = 'checkbox'; ap.style.cssText = 'accent-color:' + accent()
536
+ const msg = textInput('commit message')
537
+ const br = textInput('main')
538
+ const applyConfig = config => {
539
+ if (!config) return
540
+ as2.checked = !!(config.autoSync ?? config.autoSyncOnStartup)
541
+ ap.checked = !!config.autoPullOnStartup
542
+ msg.value = config.commitMessage || ''
543
+ br.value = config.branch || 'main'
544
+ }
545
+ const cfgInner = document.createElement('div')
546
+ cfgInner.append(
547
+ row(as2, '自动同步(定期拉取并推送改动)'),
548
+ row(ap, '启动时仅拉取'),
549
+ field('commit 信息', msg),
550
+ field('分支', br),
551
+ )
552
+ const saveBtn = makeBtn('保存', '', () => {
553
+ const p = {
554
+ path: '/config',
555
+ body: {
556
+ autoSync: as2.checked,
557
+ autoPullOnStartup: ap.checked,
558
+ commitMessage: msg.value.trim() || 'sync: home config & memory',
559
+ branch: br.value.trim() || 'main',
560
+ },
561
+ }
562
+ bs.style.display = 'inline'
563
+ api(p.path, p.body)
564
+ .then((r) => {
565
+ logLine('设置 → ' + (r && r.ok ? '已保存' : '失败:' + ((r && (r.error || r.message || r.reason)) || '')), !!(r && r.ok))
566
+ fetchStatus().then((st) => st && renderStatus(st))
567
+ })
568
+ .catch((e) => logLine('设置 异常: ' + e, false))
569
+ .finally(() => { bs.style.display = 'none' })
570
+ })
571
+ cfgInner.append(saveBtn)
572
+ body.append(section('设置', cfgInner).wrapEl)
573
+
574
+ // Init section
575
+ const mkMode = (value, label) => {
576
+ const lab = document.createElement('label')
577
+ lab.style.cssText = 'display:flex;gap:6px;align-items:flex-start;margin:2px 0;font:12px/1.4 ' + uiFont() + ';color:' + pal.text + ';cursor:pointer'
578
+ const inp = document.createElement('input')
579
+ inp.type = 'radio'
580
+ inp.name = NS + '-init-mode'
581
+ inp.value = value
582
+ inp.style.cssText = 'accent-color:' + pal.accent + ';margin-top:2px'
583
+ const sp = document.createElement('span')
584
+ sp.textContent = label
585
+ lab.append(inp, sp)
586
+ return lab
587
+ }
588
+ const modeSel = document.createElement('div')
589
+ modeSel.style.cssText = 'margin:6px 0'
590
+ modeSel.append(
591
+ mkMode('reset', '全新覆盖:以远端为准(适合新装机器)'),
592
+ mkMode('merge', '迁移合并:先备份本机内容再重置,随后手动合并'),
593
+ )
594
+ const selReset = modeSel.querySelector('input[value="reset"]')
595
+ if (selReset) selReset.checked = true
596
+ const remote = textInput('git@… 或 https://…')
597
+ const ck = document.createElement('input'); ck.type = 'checkbox'; ck.style.cssText = 'accent-color:' + pal.danger
598
+ const ckw = document.createElement('label')
599
+ ckw.style.cssText = 'display:flex;gap:6px;align-items:center;margin:4px 0;font:12.5px ' + uiFont() + ';color:' + pal.text
600
+ const ckl = document.createElement('span'); ckl.textContent = '我确认执行'
601
+ ckw.append(ck, ckl)
602
+ const hint = document.createElement('p'); hint.className = 'hint'
603
+ hint.textContent = '先检查远端文件范围,再备份受影响文件和 Git 历史后覆盖。备份保存在主目录旁,初始化后自动任务暂停。迁移合并需手动比较备份并保留所需内容。'
604
+ const initInner = document.createElement('div')
605
+ initInner.append(hint, modeSel, field('远端 URL', remote), ckw)
606
+ const initBtn = makeBtn('执行', 'danger', () => {
607
+ const rv = remote.value.trim()
608
+ const modeIn = modeSel.querySelector('input:checked')
609
+ const mode = modeIn ? modeIn.value : 'reset'
610
+ if (!rv) return logLine('请填写远端 URL', false)
611
+ if (!ck.checked) return logLine('请先勾选确认', false)
612
+ ck.checked = false
613
+ bs.style.display = 'inline'
614
+ api('/init', { remote: rv, branch: br.value.trim() || 'main', confirm: true, mode })
615
+ .then((r) => {
616
+ bs.style.display = 'none'
617
+ if (r && r.ok) {
618
+ // Initialization is an explicit configuration reset; discard stale form switches immediately.
619
+ applyConfig(r.config || { autoSync: false, autoPullOnStartup: false, commitMessage: msg.value, branch: br.value })
620
+ if (mode === 'merge') {
621
+ logLine('已备份并重置。备份:' + (r.backupDir || '') + '(含 MERGE.md 指引)——请按指引合并本机旧内容后重启。', true)
622
+ } else {
623
+ logLine('初始化完成。备份:' + (r.backupDir || '') + '。自动任务已暂停,请检查后重启 dsh web 并安装插件依赖。', true)
624
+ }
625
+ fetchStatus().then((st) => st && renderStatus(st))
626
+ } else {
627
+ logLine('初始化失败:' + ((r && (r.error || r.message || r.reason || r.step)) || '无响应'), false)
628
+ }
629
+ })
630
+ .catch((e) => logLine('初始化 异常: ' + e, false))
631
+ .finally(() => { bs.style.display = 'none' })
632
+ })
633
+ initInner.append(initBtn)
634
+ body.append(section('设备初始化', initInner).wrapEl)
635
+ const history = document.createElement('div')
636
+ history.id = 'history'
637
+ history.style.cssText = 'white-space:pre-wrap;overflow-wrap:anywhere;user-select:text;max-height:220px;overflow:auto;background:' + pal.logBg + ';color:' + pal.text + ';padding:8px;font:12px/1.5 ' + uiFont()
638
+ body.append(section('操作记录', history).wrapEl)
639
+
640
+ card.append(body)
641
+ shadow.append(card)
642
+ document.body.appendChild(host)
643
+
644
+ ctx = { host, card, statusEl: status, logEl: log, historyEl: history, anchor: fab, dir: null, fx: undefined, fy: undefined }
645
+ renderHistory()
646
+ fab.setAttribute('aria-expanded', 'true')
647
+ x.focus?.()
648
+ relayout(false)
649
+ host.style.visibility = 'visible'
650
+ // Re-trigger the open animation reliably (hidden->visible may otherwise
651
+ // swallow the first run).
652
+ card.style.animation = 'none'
653
+ void card.offsetWidth
654
+ card.style.animation = 'pop .18s cubic-bezier(.2,.9,.3,1.05)'
655
+ requestAnimationFrame(() => { host.style.opacity = '1' })
656
+ startFollow()
657
+
658
+ const owner = ctx
659
+ fetchStatus().then((st) => {
660
+ if (!st || ctx !== owner) return
661
+ renderStatus(st)
662
+ applyConfig(st.config)
663
+ relayout(true)
664
+ })
665
+ }
666
+
667
+ function closePop() {
668
+ if (!ctx) return
669
+ const host = ctx.host
670
+ host.removeAttribute('id')
671
+ const anchor = ctx.anchor
672
+ const card = ctx.card
673
+ ctx = null // stop follower
674
+ actionButtons.clear()
675
+ anchor?.setAttribute('aria-expanded', 'false')
676
+ anchor?.focus?.()
677
+ // Fade + fold out, then remove from the DOM.
678
+ try {
679
+ card.style.transition = 'opacity .13s ease, transform .15s ease'
680
+ card.style.animation = 'none'
681
+ card.style.opacity = '0'
682
+ card.style.transform = 'translateY(-5px) scale(.97)'
683
+ } catch { /* ignore */ }
684
+ setTimeout(() => {
685
+ try { host.remove() } catch { /* ignore */ }
686
+ }, 160)
687
+ }
688
+
689
+ // ---------- mount ----------
690
+ function mount() {
691
+ loadPos()
692
+ sampleDesign()
693
+ currentPalette = PALETTES[detectTheme() === 'dark' ? 'dark' : 'light']
694
+
695
+ const fab = document.createElement('button')
696
+ fab.id = NS + '-fab'
697
+ fab.type = 'button'
698
+ fab.setAttribute('aria-label', '打开 Home Sync')
699
+ fab.setAttribute('aria-expanded', 'false')
700
+ fab.style.cssText =
701
+ 'position:fixed;z-index:2147483000;display:flex;align-items:center;justify-content:center;cursor:grab;border:1px solid ' + pal.border + ';' +
702
+ 'background:' + pal.panelBg + ';color:' + pal.text + ';width:38px;height:38px;padding:0;border-radius:50%;' +
703
+ 'box-shadow:0 1px 3px rgba(0,0,0,.06), 0 4px 14px ' + pal.shadow + ';' +
704
+ 'user-select:none;touch-action:none;will-change:transform,left,top;'
705
+ const SVGNS = 'http://www.w3.org/2000/svg'
706
+ const svg = document.createElementNS(SVGNS, 'svg')
707
+ svg.id = NS + '-ico'
708
+ svg.setAttribute('viewBox', '0 0 24 24')
709
+ svg.setAttribute('fill', 'none')
710
+ svg.setAttribute('stroke', accent())
711
+ svg.setAttribute('stroke-width', '2.2')
712
+ svg.setAttribute('stroke-linecap', 'round')
713
+ svg.setAttribute('stroke-linejoin', 'round')
714
+ svg.style.cssText = 'width:20px;height:20px;display:block;flex:none'
715
+ svg.innerHTML =
716
+ '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>' +
717
+ '<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>'
718
+ const dot = document.createElement('span')
719
+ dot.id = NS + '-dot'
720
+ dot.style.cssText = 'position:absolute;top:-1px;right:-1px;width:9px;height:9px;border-radius:50%;display:none;' +
721
+ 'pointer-events:none;border:1.5px solid ' + pal.dotRing + ';background:' + pal.danger
722
+ fab.append(svg, dot)
723
+ document.body.appendChild(fab)
724
+ applyPalette(fab)
725
+ applyPos(false)
726
+
727
+ let dragging = false
728
+ let moved = 0
729
+ let sx = 0
730
+ let sy = 0
731
+ let suppressClick = false
732
+ fab.addEventListener('pointerdown', (e) => {
733
+ dragging = true
734
+ moved = 0
735
+ suppressClick = false
736
+ sx = e.clientX - pos.x
737
+ sy = e.clientY - pos.y
738
+ try { fab.setPointerCapture(e.pointerId) } catch { /* ignore */ }
739
+ fab.style.cursor = 'grabbing'
740
+ fab.style.transition = 'none'
741
+ pressDown()
742
+ })
743
+ fab.addEventListener('pointermove', (e) => {
744
+ if (!dragging) return
745
+ moved += Math.abs(e.movementX || 0) + Math.abs(e.movementY || 0)
746
+ pos.x = Math.max(4, Math.min(innerWidth - (fab.offsetWidth || 38) - 4, e.clientX - sx))
747
+ pos.y = Math.max(4, Math.min(innerHeight - (fab.offsetHeight || 38) - 4, e.clientY - sy))
748
+ fab.style.left = pos.x + 'px'
749
+ fab.style.top = pos.y + 'px'
750
+ // No direct relayout here: the rAF follower moves the card smoothly.
751
+ })
752
+ fab.addEventListener('pointerup', () => {
753
+ if (!dragging) return
754
+ dragging = false
755
+ fab.style.cursor = 'grab'
756
+ const w = fab.offsetWidth || 80
757
+ const nearL = pos.x <= EDGE
758
+ const nearR = pos.x >= innerWidth - w - EDGE
759
+ if (nearL || nearR) {
760
+ pos.docked = true
761
+ pos.x = nearL ? 4 : Math.max(4, innerWidth - (fab.offsetWidth || 38) - 4)
762
+ } else {
763
+ pos.docked = false
764
+ }
765
+ savePos()
766
+ applyPos(true)
767
+ if (ctx) relayout(true)
768
+ pressUp()
769
+ suppressClick = moved >= 5
770
+ })
771
+ fab.addEventListener('click', () => {
772
+ if (suppressClick) { suppressClick = false; return }
773
+ openPop(fab)
774
+ })
775
+ fab.addEventListener('pointercancel', () => { if (dragging) { dragging = false; fab.style.cursor = 'grab' } })
776
+ fab.addEventListener('dblclick', () => {
777
+ pos.docked = !pos.docked
778
+ if (pos.docked) pos.x = pos.x < innerWidth / 2 ? 4 : Math.max(4, innerWidth - (fab.offsetWidth || 38) - 4)
779
+ savePos()
780
+ applyPos(true)
781
+ if (ctx) relayout(true)
782
+ })
783
+
784
+ wireDocument()
785
+ if (!observerRef) {
786
+ observerRef = new MutationObserver(() => {
787
+ const next = PALETTES[detectTheme() === 'dark' ? 'dark' : 'light']
788
+ currentPalette = next
789
+ sampleDesign()
790
+ setThemeColors()
791
+ })
792
+ observerRef.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] })
793
+ observerRef.observe(document.body, { attributes: true, attributeFilter: ['class', 'data-theme', 'style'] })
794
+ const media = window.matchMedia?.('(prefers-color-scheme: dark)')
795
+ if (media?.addEventListener) listen(media, 'change', () => {
796
+ currentPalette = PALETTES[detectTheme() === 'dark' ? 'dark' : 'light']
797
+ sampleDesign(); setThemeColors()
798
+ })
799
+ }
800
+
801
+ listen(window, 'resize', () => { applyPos(false); if (ctx) relayout(false) })
802
+ setBadge()
803
+ fetchStatus()
804
+ }
805
+
806
+ function createFallback() {
807
+ if (q(NS + '-fb')) return
808
+ const b = document.createElement('button')
809
+ b.id = NS + '-fb'
810
+ b.textContent = '⟳ 同步'
811
+ b.style.cssText =
812
+ 'position:fixed;left:16px;top:50%;transform:translateY(-50%);z-index:2147483647;background:#2f6feb;color:#fff;' +
813
+ 'border:none;border-radius:999px;padding:9px 16px;font:600 14px/1 system-ui,sans-serif;cursor:pointer;' +
814
+ 'box-shadow:0 4px 14px rgba(16,24,40,.3)'
815
+ b.setAttribute('aria-expanded', 'false')
816
+ b.addEventListener('click', () => { try { openPop(b) } catch (e) { console.error(e) } })
817
+ document.body.appendChild(b)
818
+ console.error('[dsh-home-sync] fallback trigger created')
819
+ }
820
+ function runMount() {
821
+ try {
822
+ if (!q(NS + '-fab')) mount()
823
+ } catch (e) {
824
+ console.error('[dsh-home-sync] mount failed:', e)
825
+ createFallback()
826
+ }
827
+ if (!q(NS + '-fab') && !q(NS + '-fb')) createFallback()
828
+ }
829
+
830
+ if (document.readyState === 'loading') listen(document, 'DOMContentLoaded', runMount)
831
+ else runMount()
832
+ listen(window, 'load', () => { if (!q(NS + '-fab') && !q(NS + '-fb')) runMount() })
833
+ const mountTimer = setInterval(() => { if (!q(NS + '-fab') && !q(NS + '-fb')) runMount() }, 2500)
834
+ const statusTimer = setInterval(() => fetchStatus().then(st => { if (ctx) renderStatus(st) }), 15000)
835
+ window.__dshHomeSyncCleanup = () => {
836
+ closePop(); clearInterval(mountTimer); clearInterval(statusTimer)
837
+ observerRef?.disconnect()
838
+ for (const remove of globalListeners) remove()
839
+ for (const suffix of ['fab', 'fb', 'pop', 'toast']) q(NS + '-' + suffix)?.remove()
840
+ }
841
+ })()