@code_tin/dsh-sync 0.0.0-stage → 0.2.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/client.js ADDED
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Browser half of `dsh-sync`.
3
+ *
4
+ * Adds one page under Settings → 会话 Git 同步 (Settings → Session Git Sync).
5
+ * The page writes the repository URL, branch, and auto-sync schedule, and
6
+ * triggers pull/push through the host Remote service registered by `index.js`.
7
+ * All session bytes and git work stay on the host; this module only renders
8
+ * state and calls endpoints.
9
+ */
10
+ window.__ModuleLoader__.load({
11
+ id: '@code_tin/dsh-sync',
12
+ factory(require) {
13
+ const React = require('react')
14
+ const h = React.createElement
15
+
16
+ const NS = 'dsh-sync'
17
+
18
+ const ZH = {
19
+ nav: '会话 Git 同步',
20
+ title: '会话 Git 同步',
21
+ subtitle: '把本机的 DSH 会话镜像到一个 Git 仓库,在多个设备之间共享。拉取只新增本地不存在的会话,不会覆盖本地已有会话;也可以开启定时自动推送。',
22
+ remoteLabel: 'Git 仓库地址',
23
+ remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
24
+ branchLabel: '分支',
25
+ branchPlaceholder: 'main',
26
+ autoLabel: '自动同步',
27
+ autoHint: '开启后每隔下面的时间自动把本地会话推送到 Git(等同 /sync push),DSH 宿主进程需要保持运行。',
28
+ intervalLabel: '自动同步间隔(分钟)',
29
+ intervalUnit: '分钟',
30
+ intervalInvalid: '自动同步间隔需要是 1 到 1440 之间的整数分钟。',
31
+ save: '保存',
32
+ test: '测试连接',
33
+ pull: '从远程拉取',
34
+ push: '推送到远程',
35
+ refresh: '刷新状态',
36
+ unsaved: '有未保存的修改;拉取或推送前会自动保存。',
37
+ saved: '已保存设置。',
38
+ statusTitle: '同步状态',
39
+ commandsTitle: '命令',
40
+ cmdStatus: '查看同步状态',
41
+ cmdPull: '拉取远程会话到本地',
42
+ cmdPush: '把本地会话推送到远程',
43
+ cmdTest: '测试连接',
44
+ remoteStatus: '远程仓库',
45
+ branchStatus: '分支',
46
+ localStatus: '本地会话 / 文件',
47
+ mirrorStatus: '镜像仓库',
48
+ autoStatus: '自动同步',
49
+ autoOff: '已关闭',
50
+ autoOn: '每 {minutes} 分钟',
51
+ autoNext: '每 {minutes} 分钟,下次 {time}',
52
+ autoNoRemote: '已开启,尚未配置远程仓库',
53
+ lastPush: '上次推送',
54
+ lastPull: '上次拉取',
55
+ lastAutoPush: '上次自动推送',
56
+ dataDir: '本地会话目录',
57
+ repoDir: '镜像仓库目录',
58
+ notConfigured: '未配置',
59
+ mirrorMissing: '尚未初始化(首次推送时创建)',
60
+ never: '从未',
61
+ refreshed: '状态已刷新。',
62
+ loading: '正在读取同步状态…',
63
+ failed: '操作失败。',
64
+ pullDone: '拉取完成:新增 {sessions} 个会话({files} 个文件),跳过 {skipped} 个本地已存在的文件。',
65
+ pushDone: '推送完成:{sessions} 个会话({files} 个文件)。',
66
+ pushNoChanges: '推送完成:远程已是最新,无需更新。',
67
+ testOk: '连接成功:远程有 {branches} 个分支。',
68
+ testBranchMissing: '连接成功,但远程还没有分支 “{branch}”;第一次推送会创建它。',
69
+ noRpc: '浏览器连接服务不可用,设置页面无法调用宿主。',
70
+ loadFailed: '无法读取同步状态',
71
+ }
72
+
73
+ const EN = {
74
+ nav: 'Session Git Sync',
75
+ title: 'Session Git Sync',
76
+ subtitle: 'Mirror this device’s DSH sessions into a git repository and share them across devices. Pull only adds sessions missing locally; it never overwrites an existing local session. Optionally push on a timer.',
77
+ remoteLabel: 'Git repository URL',
78
+ remotePlaceholder: 'https://example.com/user/dsh-sessions.git',
79
+ branchLabel: 'Branch',
80
+ branchPlaceholder: 'main',
81
+ autoLabel: 'Auto sync',
82
+ autoHint: 'When enabled, local sessions are pushed to git every interval (same as /sync push). The DSH host process must stay running.',
83
+ intervalLabel: 'Auto sync interval (minutes)',
84
+ intervalUnit: 'min',
85
+ intervalInvalid: 'The auto sync interval must be a whole number between 1 and 1440 minutes.',
86
+ save: 'Save',
87
+ test: 'Test connection',
88
+ pull: 'Pull from remote',
89
+ push: 'Push to remote',
90
+ refresh: 'Refresh status',
91
+ unsaved: 'Unsaved changes; pull or push saves them first.',
92
+ saved: 'Settings saved.',
93
+ statusTitle: 'Sync status',
94
+ commandsTitle: 'Commands',
95
+ cmdStatus: 'show sync status',
96
+ cmdPull: 'copy remote sessions to this device',
97
+ cmdPush: 'push local sessions to the remote',
98
+ cmdTest: 'test the connection',
99
+ remoteStatus: 'Remote',
100
+ branchStatus: 'Branch',
101
+ localStatus: 'Local sessions / files',
102
+ mirrorStatus: 'Mirror',
103
+ autoStatus: 'Auto sync',
104
+ autoOff: 'Off',
105
+ autoOn: 'Every {minutes} min',
106
+ autoNext: 'Every {minutes} min, next {time}',
107
+ autoNoRemote: 'On, but no remote repository is configured',
108
+ lastPush: 'Last push',
109
+ lastPull: 'Last pull',
110
+ lastAutoPush: 'Last auto push',
111
+ dataDir: 'Local session directory',
112
+ repoDir: 'Mirror directory',
113
+ notConfigured: 'Not configured',
114
+ mirrorMissing: 'Not initialized (created by the first push)',
115
+ never: 'Never',
116
+ refreshed: 'Status refreshed.',
117
+ loading: 'Reading sync status…',
118
+ failed: 'The operation failed.',
119
+ pullDone: 'Pulled {sessions} new sessions ({files} files); skipped {skipped} files that already exist locally.',
120
+ pushDone: 'Pushed {sessions} sessions ({files} files).',
121
+ pushNoChanges: 'Push complete: the remote is already up to date.',
122
+ testOk: 'Connected: the remote has {branches} branches.',
123
+ testBranchMissing: 'Connected, but the remote has no branch “{branch}” yet; the first push creates it.',
124
+ noRpc: 'The browser connection service is unavailable, so this page cannot reach the host.',
125
+ loadFailed: 'Could not read sync status',
126
+ }
127
+
128
+ const CSS = `
129
+ .dshsync { display: flex; flex-direction: column; gap: 16px; max-width: 760px; padding: 4px 0 24px; }
130
+ .dshsync-header { display: flex; flex-direction: column; gap: 6px; }
131
+ .dshsync-title { margin: 0; font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary); }
132
+ .dshsync-subtitle { margin: 0; font-size: 13px; line-height: 1.6; color: var(--dsw-alias-label-secondary); }
133
+ .dshsync-card { display: flex; flex-direction: column; gap: 14px; padding: 16px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--dsw-alias-bg-layer-1); }
134
+ .dshsync-field { display: flex; flex-direction: column; gap: 6px; }
135
+ .dshsync-row { display: flex; gap: 10px; }
136
+ .dshsync-grow { flex: 1 1 auto; min-width: 0; }
137
+ .dshsync-branch { width: 170px; flex: 0 0 auto; }
138
+ .dshsync-check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-primary); cursor: pointer; }
139
+ .dshsync-check input { width: 15px; height: 15px; margin: 0; accent-color: var(--dsw-alias-brand-primary); }
140
+ .dshsync-field-hint { margin: 0; font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-secondary); }
141
+ .dshsync-interval-row { display: flex; align-items: center; gap: 8px; }
142
+ .dshsync-interval-input { width: 120px; }
143
+ .dshsync-unit { font-size: 12px; color: var(--dsw-alias-label-secondary); }
144
+ .dshsync-label { font-size: 12px; font-weight: 500; color: var(--dsw-alias-label-secondary); }
145
+ .dshsync-input { box-sizing: border-box; width: 100%; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; }
146
+ .dshsync-input::placeholder { color: var(--dsw-alias-state-idle-primary); }
147
+ .dshsync-input:focus { border-color: var(--dsw-alias-brand-primary); }
148
+ .dshsync-input:disabled { opacity: 0.6; }
149
+ .dshsync-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
150
+ .dshsync-btn { height: 32px; padding: 0 12px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 1; cursor: pointer; }
151
+ .dshsync-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-2); }
152
+ .dshsync-btn:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }
153
+ .dshsync-btn:disabled { opacity: 0.45; cursor: default; }
154
+ .dshsync-btn-primary { border-color: var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); font-weight: 600; }
155
+ .dshsync-btn-ghost { color: var(--dsw-alias-label-secondary); border-color: transparent; }
156
+ .dshsync-hint { margin: 0; font-size: 12px; color: var(--dsw-alias-state-warn-primary); }
157
+ .dshsync-notice { margin: 0; padding: 8px 10px; border-radius: 8px; background: var(--dsw-alias-bg-layer-2); font-size: 12px; line-height: 1.6; color: var(--dsw-alias-label-primary); white-space: pre-wrap; word-break: break-word; }
158
+ .dshsync-notice-ok { color: var(--dsw-alias-state-success-primary); }
159
+ .dshsync-notice-error { color: var(--dsw-alias-state-error-primary); }
160
+ .dshsync-notice-info { color: var(--dsw-alias-label-secondary); }
161
+ .dshsync-section-title { margin: 0; font-size: 13px; font-weight: 600; color: var(--dsw-alias-label-primary); }
162
+ .dshsync-grid { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 16px; margin: 0; font-size: 13px; }
163
+ .dshsync-k { margin: 0; color: var(--dsw-alias-label-secondary); }
164
+ .dshsync-v { margin: 0; color: var(--dsw-alias-label-primary); word-break: break-all; }
165
+ .dshsync-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--dsw-alias-label-secondary); }
166
+ .dshsync-commands { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
167
+ .dshsync-commands li { display: flex; gap: 8px; align-items: baseline; }
168
+ .dshsync-commands code { padding: 1px 5px; border-radius: 4px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
169
+ .dshsync-empty { margin: 0; font-size: 13px; color: var(--dsw-alias-label-secondary); }
170
+ `
171
+
172
+ function format(template, values) {
173
+ return String(template).replace(/\{(\w+)\}/g, (match, key) => (
174
+ Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match
175
+ ))
176
+ }
177
+
178
+ function errorText(error) {
179
+ if (error instanceof Error && error.message) return error.message
180
+ return String(error)
181
+ }
182
+
183
+ function timeText(value) {
184
+ if (!value) return ''
185
+ const date = new Date(value)
186
+ if (Number.isNaN(date.getTime())) return value
187
+ return date.toLocaleString()
188
+ }
189
+
190
+ function useLocaleTick(locale) {
191
+ const [, setVersion] = React.useState(0)
192
+ React.useEffect(() => {
193
+ if (!locale || typeof locale.subscribe !== 'function') return undefined
194
+ return locale.subscribe(() => setVersion((value) => value + 1))
195
+ }, [locale])
196
+ }
197
+
198
+ function createRpc(connection) {
199
+ const rpc = connection && connection.rpc
200
+ const call = async (method, args) => {
201
+ if (!rpc || typeof rpc.call !== 'function') {
202
+ throw new Error('Browser connection.rpc is unavailable.')
203
+ }
204
+ const response = await rpc.call('/api', `dsh-sync/${method}`, { args: args || {} })
205
+ if (!response || response.ok !== true) {
206
+ const detail = response && response.error
207
+ const message = typeof detail === 'string'
208
+ ? detail
209
+ : (detail && detail.message) || 'The host did not answer.'
210
+ throw new Error(message)
211
+ }
212
+ return response.value
213
+ }
214
+ return {
215
+ status: () => call('status'),
216
+ save: (remote, branch, autoPush, autoPushIntervalMinutes) => call('save', {
217
+ remote,
218
+ branch,
219
+ autoPush,
220
+ autoPushIntervalMinutes,
221
+ }),
222
+ test: (remote) => call('test', { remote }),
223
+ pull: () => call('pull'),
224
+ push: () => call('push'),
225
+ }
226
+ }
227
+
228
+ function Section(props) {
229
+ const { t, rpc, locale } = props
230
+ const [snapshot, setSnapshot] = React.useState(null)
231
+ const [remote, setRemote] = React.useState('')
232
+ const [branch, setBranch] = React.useState('main')
233
+ const [autoPush, setAutoPush] = React.useState(false)
234
+ const [intervalText, setIntervalText] = React.useState('10')
235
+ const [dirty, setDirty] = React.useState(false)
236
+ const [busy, setBusy] = React.useState('')
237
+ const [notice, setNotice] = React.useState(null)
238
+
239
+ useLocaleTick(locale)
240
+
241
+ React.useEffect(() => {
242
+ let alive = true
243
+ setBusy('load')
244
+ Promise.resolve()
245
+ .then(() => rpc.status())
246
+ .then((value) => {
247
+ if (!alive) return
248
+ setSnapshot(value)
249
+ setRemote(value.remote || '')
250
+ setBranch(value.branch || 'main')
251
+ setAutoPush(value.autoPush === true)
252
+ setIntervalText(String(value.autoPushIntervalMinutes ?? 10))
253
+ })
254
+ .catch((error) => {
255
+ if (alive) setNotice({ kind: 'error', text: `${t('loadFailed')}: ${errorText(error)}` })
256
+ })
257
+ .finally(() => {
258
+ if (alive) setBusy('')
259
+ })
260
+ return () => { alive = false }
261
+ }, [rpc])
262
+
263
+ const perform = async (name, task) => {
264
+ setBusy(name)
265
+ setNotice(null)
266
+ try {
267
+ const result = await task()
268
+ if (result && result.status) setSnapshot(result.status)
269
+ if (result && result.ok === false) {
270
+ setNotice({ kind: 'error', text: result.message || t('failed') })
271
+ return result
272
+ }
273
+ return result
274
+ } catch (error) {
275
+ setNotice({ kind: 'error', text: errorText(error) })
276
+ return undefined
277
+ } finally {
278
+ setBusy('')
279
+ }
280
+ }
281
+
282
+ const parseInterval = () => {
283
+ const value = Number(intervalText)
284
+ return Number.isInteger(value) && value >= 1 && value <= 1440 ? value : null
285
+ }
286
+
287
+ const applySettings = (settings) => {
288
+ if (!settings) return
289
+ if (typeof settings.remote === 'string') setRemote(settings.remote)
290
+ if (typeof settings.branch === 'string') setBranch(settings.branch)
291
+ if (typeof settings.autoPush === 'boolean') setAutoPush(settings.autoPush)
292
+ if (settings.autoPushIntervalMinutes !== undefined) {
293
+ setIntervalText(String(settings.autoPushIntervalMinutes))
294
+ }
295
+ }
296
+
297
+ const saveFirst = async () => {
298
+ if (!dirty) return true
299
+ const interval = parseInterval()
300
+ if (interval === null) {
301
+ setNotice({ kind: 'error', text: t('intervalInvalid') })
302
+ return false
303
+ }
304
+ setBusy('save')
305
+ try {
306
+ const saved = await rpc.save(remote.trim(), branch.trim(), autoPush, interval)
307
+ if (saved && saved.status) setSnapshot(saved.status)
308
+ if (saved && saved.ok === false) {
309
+ setNotice({ kind: 'error', text: saved.message || t('failed') })
310
+ return false
311
+ }
312
+ setDirty(false)
313
+ applySettings(saved && saved.settings)
314
+ return true
315
+ } catch (error) {
316
+ setNotice({ kind: 'error', text: errorText(error) })
317
+ return false
318
+ } finally {
319
+ setBusy('')
320
+ }
321
+ }
322
+
323
+ const onSave = async () => {
324
+ const interval = parseInterval()
325
+ if (interval === null) {
326
+ setNotice({ kind: 'error', text: t('intervalInvalid') })
327
+ return
328
+ }
329
+ const result = await perform('save', () => rpc.save(remote.trim(), branch.trim(), autoPush, interval))
330
+ if (!result || result.ok === false) return
331
+ setDirty(false)
332
+ applySettings(result.settings)
333
+ setNotice({ kind: 'ok', text: t('saved') })
334
+ }
335
+
336
+ const onTest = async () => {
337
+ const result = await perform('test', () => rpc.test(remote.trim()))
338
+ if (!result || result.ok === false) return
339
+ const wanted = branch.trim() || 'main'
340
+ const branches = Array.isArray(result.branches) ? result.branches : []
341
+ setNotice({
342
+ kind: branches.includes(wanted) ? 'ok' : 'info',
343
+ text: branches.includes(wanted)
344
+ ? format(t('testOk'), { branches: branches.length })
345
+ : format(t('testBranchMissing'), { branch: wanted }),
346
+ })
347
+ }
348
+
349
+ const onPull = async () => {
350
+ const saved = await saveFirst()
351
+ if (!saved) return
352
+ const result = await perform('pull', () => rpc.pull())
353
+ if (!result || result.ok === false) return
354
+ setNotice({
355
+ kind: 'ok',
356
+ text: format(t('pullDone'), {
357
+ sessions: result.addedSessions || 0,
358
+ files: result.addedFiles || 0,
359
+ skipped: result.skippedFiles || 0,
360
+ }),
361
+ })
362
+ }
363
+
364
+ const onPush = async () => {
365
+ const saved = await saveFirst()
366
+ if (!saved) return
367
+ const result = await perform('push', () => rpc.push())
368
+ if (!result || result.ok === false) return
369
+ const base = result.pushed
370
+ ? format(t('pushDone'), { sessions: result.sessions || 0, files: result.files || 0 })
371
+ : t('pushNoChanges')
372
+ setNotice({ kind: 'ok', text: result.warning ? `${base}\n${result.warning}` : base })
373
+ }
374
+
375
+ const onRefresh = async () => {
376
+ setBusy('refresh')
377
+ try {
378
+ const value = await rpc.status()
379
+ setSnapshot(value)
380
+ if (!dirty) {
381
+ setRemote(value.remote || '')
382
+ setBranch(value.branch || 'main')
383
+ setAutoPush(value.autoPush === true)
384
+ setIntervalText(String(value.autoPushIntervalMinutes ?? 10))
385
+ }
386
+ setNotice({ kind: 'info', text: t('refreshed') })
387
+ } catch (error) {
388
+ setNotice({ kind: 'error', text: errorText(error) })
389
+ } finally {
390
+ setBusy('')
391
+ }
392
+ }
393
+
394
+ const disabled = busy !== ''
395
+ const state = snapshot && snapshot.state ? snapshot.state : {}
396
+ const local = snapshot && snapshot.local ? snapshot.local : { sessions: 0, files: 0 }
397
+ const mirror = snapshot && snapshot.mirror ? snapshot.mirror : { initialized: false, sessions: 0, files: 0 }
398
+ const autoText = (() => {
399
+ if (!snapshot || snapshot.autoPush !== true) return t('autoOff')
400
+ if (!snapshot.remote) return t('autoNoRemote')
401
+ const minutes = snapshot.autoPushIntervalMinutes ?? 10
402
+ return snapshot.autoPushNextAt
403
+ ? format(t('autoNext'), { minutes, time: timeText(snapshot.autoPushNextAt) })
404
+ : format(t('autoOn'), { minutes })
405
+ })()
406
+
407
+ const row = (key, label, value, mono) => [
408
+ h('dt', { className: 'dshsync-k', key: `${key}-k` }, label),
409
+ h('dd', { className: `dshsync-v${mono ? ' dshsync-mono' : ''}`, key: `${key}-v` }, value),
410
+ ]
411
+
412
+ return h('div', { className: 'dshsync' },
413
+ h('style', { key: 'style' }, CSS),
414
+ h('header', { className: 'dshsync-header', key: 'header' },
415
+ h('h2', { className: 'dshsync-title' }, t('title')),
416
+ h('p', { className: 'dshsync-subtitle' }, t('subtitle')),
417
+ ),
418
+ h('section', { className: 'dshsync-card', key: 'config' },
419
+ h('div', { className: 'dshsync-row' },
420
+ h('div', { className: 'dshsync-field dshsync-grow' },
421
+ h('label', { className: 'dshsync-label', htmlFor: 'dshsync-remote' }, t('remoteLabel')),
422
+ h('input', {
423
+ id: 'dshsync-remote',
424
+ className: 'dshsync-input',
425
+ type: 'text',
426
+ value: remote,
427
+ placeholder: t('remotePlaceholder'),
428
+ spellCheck: false,
429
+ autoCapitalize: 'off',
430
+ autoCorrect: 'off',
431
+ disabled: busy === 'save',
432
+ onChange: (event) => {
433
+ setRemote(event.target.value)
434
+ setDirty(true)
435
+ },
436
+ }),
437
+ ),
438
+ h('div', { className: 'dshsync-field dshsync-branch' },
439
+ h('label', { className: 'dshsync-label', htmlFor: 'dshsync-branch' }, t('branchLabel')),
440
+ h('input', {
441
+ id: 'dshsync-branch',
442
+ className: 'dshsync-input',
443
+ type: 'text',
444
+ value: branch,
445
+ placeholder: t('branchPlaceholder'),
446
+ spellCheck: false,
447
+ autoCapitalize: 'off',
448
+ autoCorrect: 'off',
449
+ disabled: busy === 'save',
450
+ onChange: (event) => {
451
+ setBranch(event.target.value)
452
+ setDirty(true)
453
+ },
454
+ }),
455
+ ),
456
+ ),
457
+ h('div', { className: 'dshsync-field' },
458
+ h('label', { className: 'dshsync-check', htmlFor: 'dshsync-auto' },
459
+ h('input', {
460
+ id: 'dshsync-auto',
461
+ type: 'checkbox',
462
+ checked: autoPush,
463
+ disabled: busy === 'save',
464
+ onChange: (event) => {
465
+ setAutoPush(event.target.checked)
466
+ setDirty(true)
467
+ },
468
+ }),
469
+ h('span', null, t('autoLabel')),
470
+ ),
471
+ h('p', { className: 'dshsync-field-hint' }, t('autoHint')),
472
+ ),
473
+ h('div', { className: 'dshsync-field dshsync-interval' },
474
+ h('label', { className: 'dshsync-label', htmlFor: 'dshsync-interval' }, t('intervalLabel')),
475
+ h('div', { className: 'dshsync-interval-row' },
476
+ h('input', {
477
+ id: 'dshsync-interval',
478
+ className: 'dshsync-input dshsync-interval-input',
479
+ type: 'number',
480
+ min: 1,
481
+ max: 1440,
482
+ step: 1,
483
+ value: intervalText,
484
+ disabled: busy === 'save',
485
+ onChange: (event) => {
486
+ setIntervalText(event.target.value)
487
+ setDirty(true)
488
+ },
489
+ }),
490
+ h('span', { className: 'dshsync-unit' }, t('intervalUnit')),
491
+ ),
492
+ ),
493
+ h('div', { className: 'dshsync-actions' },
494
+ h('button', { type: 'button', className: 'dshsync-btn dshsync-btn-primary', onClick: onSave, disabled }, t('save')),
495
+ h('button', { type: 'button', className: 'dshsync-btn', onClick: onTest, disabled }, t('test')),
496
+ h('button', { type: 'button', className: 'dshsync-btn', onClick: onPull, disabled }, t('pull')),
497
+ h('button', { type: 'button', className: 'dshsync-btn', onClick: onPush, disabled }, t('push')),
498
+ h('button', { type: 'button', className: 'dshsync-btn dshsync-btn-ghost', onClick: onRefresh, disabled }, t('refresh')),
499
+ ),
500
+ dirty ? h('p', { className: 'dshsync-hint' }, t('unsaved')) : null,
501
+ notice ? h('p', {
502
+ className: `dshsync-notice dshsync-notice-${notice.kind}`,
503
+ role: 'status',
504
+ 'aria-live': 'polite',
505
+ }, notice.text) : null,
506
+ ),
507
+ h('section', { className: 'dshsync-card', key: 'status' },
508
+ h('h3', { className: 'dshsync-section-title' }, t('statusTitle')),
509
+ snapshot === null
510
+ ? h('p', { className: 'dshsync-empty' }, t('loading'))
511
+ : h('dl', { className: 'dshsync-grid' },
512
+ row('remote', t('remoteStatus'), snapshot.remote || t('notConfigured')),
513
+ row('branch', t('branchStatus'), snapshot.branch),
514
+ row('auto', t('autoStatus'), autoText),
515
+ row('local', t('localStatus'), `${local.sessions} / ${local.files}`),
516
+ row('mirror', t('mirrorStatus'), mirror.initialized
517
+ ? `${mirror.sessions} / ${mirror.files}`
518
+ : t('mirrorMissing')),
519
+ row('push', t('lastPush'), state.lastPushAt
520
+ ? `${timeText(state.lastPushAt)}${state.lastPushOk === false ? ' ✕' : ''}`
521
+ : t('never')),
522
+ row('autoPush', t('lastAutoPush'), state.lastAutoPushAt
523
+ ? `${timeText(state.lastAutoPushAt)}${state.lastAutoPushOk === false ? ' ✕' : ''}`
524
+ : t('never')),
525
+ row('pull', t('lastPull'), state.lastPullAt
526
+ ? `${timeText(state.lastPullAt)}${state.lastPullOk === false ? ' ✕' : ''}`
527
+ : t('never')),
528
+ row('sessionsRoot', t('dataDir'), snapshot.sessionsRoot, true),
529
+ row('repoDir', t('repoDir'), snapshot.repoDir, true),
530
+ ),
531
+ ),
532
+ h('section', { className: 'dshsync-card', key: 'commands' },
533
+ h('h3', { className: 'dshsync-section-title' }, t('commandsTitle')),
534
+ h('ul', { className: 'dshsync-commands' },
535
+ h('li', { key: 'status' }, h('code', null, '/sync'), h('span', null, t('cmdStatus'))),
536
+ h('li', { key: 'pull' }, h('code', null, '/sync pull'), h('span', null, t('cmdPull'))),
537
+ h('li', { key: 'push' }, h('code', null, '/sync push'), h('span', null, t('cmdPush'))),
538
+ h('li', { key: 'test' }, h('code', null, '/sync test'), h('span', null, t('cmdTest'))),
539
+ ),
540
+ ),
541
+ )
542
+ }
543
+
544
+ function apply(ctx) {
545
+ ctx.effect(
546
+ () => ctx.locale.register(NS, { zh: ZH, en: EN }),
547
+ `${NS}: locale dictionaries`,
548
+ )
549
+ const t = ctx.locale.bind(NS)
550
+ const connection = ctx.get('connection')
551
+ const rpc = createRpc(connection)
552
+ if (!connection || !connection.rpc) {
553
+ ctx.logger?.warn?.(`${NS}: connection.rpc is unavailable; the settings page cannot call the host`)
554
+ }
555
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
556
+ name: 'settings.section',
557
+ id: NS,
558
+ order: 55,
559
+ label: () => t('nav'),
560
+ }, function SyncSettingsPage(ownerProps) {
561
+ return h(Section, { ...ownerProps, t, rpc, locale: ctx.locale })
562
+ }))
563
+ }
564
+
565
+ return { inject: ['slots', 'locale', 'connection'], apply }
566
+ },
567
+ })
@@ -0,0 +1,8 @@
1
+ # `@code_tin/dsh-sync` bundle patch.
2
+ #
3
+ # One host row plus the browser half declared by `dsh.client` in package.json.
4
+ # The row id is the plugin's own so a higher-priority profile patch can override
5
+ # this row's `config` (there is none today) without renaming the plugin.
6
+ - insert:
7
+ - id: dsh-sync
8
+ name: '@code_tin/dsh-sync'
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Session Git Sync">
2
+ <rect width="64" height="64" rx="14" fill="#1f6feb"/>
3
+ <g fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
4
+ <path d="M21 16v23a6 6 0 0 0 6 6h14"/>
5
+ <path d="M43 16v8"/>
6
+ </g>
7
+ <circle cx="21" cy="13" r="5" fill="#ffffff"/>
8
+ <circle cx="43" cy="13" r="5" fill="#ffffff"/>
9
+ <circle cx="15" cy="47" r="5" fill="#ffffff"/>
10
+ <path d="M44 36a9 9 0 0 1 6 15" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round"/>
11
+ <path d="M52 44v7h-7" fill="none" stroke="#bcd7ff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
12
+ </svg>