@weibaohui/dsh-sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,514 @@
1
+ /**
2
+ * dsh-plugin-dsh-sync - Browser half.
3
+ *
4
+ * Single surface: the host settings page section. (The sidebar footer entry
5
+ * was removed by user decision — settings is the only entrance.) All
6
+ * interactive controls are host primitives (@deepseek-ai/dsh-client-ui-
7
+ * primitives); all colors come from the ui-theme `--dsw-*` token layers so
8
+ * light/dark follows the shell; all copy comes from the locale registry
9
+ * (`zh`/`en`). The client never sees the access token in cleartext — only
10
+ * `hasToken`.
11
+ */
12
+
13
+ // React is a loader platform module. Under plain Node (contract tests) a
14
+ // minimal createElement/hook shim keeps the source loadable for assertions.
15
+ let __React = null
16
+ try { __React = require('react') } catch {}
17
+ if (!__React || typeof __React.createElement !== 'function') {
18
+ __React = {
19
+ createElement(type, props, ...kids) {
20
+ return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true || true) }
21
+ },
22
+ useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
23
+ useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
24
+ }
25
+ }
26
+ const { createElement: h, useState, useEffect, useMemo, useRef } = __React
27
+
28
+ // Platform module — always present in the loader's seeded require table.
29
+ // Under plain Node (tests) it is absent; a tagged-element shim keeps the
30
+ // tree structurally testable while every real surface ships primitives.
31
+ let P = null
32
+ try { P = require('@deepseek-ai/dsh-client-ui-primitives') } catch {}
33
+
34
+ // NOTE: no class components in this module. A `class X extends
35
+ // React.Component` error boundary defined here silently killed rendering in
36
+ // the plugin loader — render-time crashes are handled by the try/catch inside
37
+ // SettingsSection and recorded into globalThis.__skErrors instead.
38
+
39
+ /** Idempotent stylesheet injection. */
40
+ function ensureStyles() {
41
+ if (typeof document === 'undefined' || document.getElementById('dshsync-styles')) return
42
+ const holder = document.createElement('div')
43
+ holder.id = 'dshsync-styles'
44
+ holder.style.display = 'none'
45
+ holder.innerHTML = STYLE
46
+ document.head.appendChild(holder)
47
+ }
48
+
49
+ const prim = (name) => P && P[name]
50
+ ? P[name]
51
+ : function Shim(props) {
52
+ const { children, ...rest } = props
53
+ return h('button', { ...rest, 'data-p-shim': name }, children)
54
+ }
55
+
56
+ // Sessions service (client runtime): opens the conflict run's conversation
57
+ // in the real UI. Resolved through dynamic ctx.inject; absence degrades the
58
+ // 打开对话 button to hidden.
59
+ let sessionsApi = null
60
+ const sessionsSvc = () => sessionsApi
61
+
62
+ // ── Locale ───────────────────────────────────────────────────────────────
63
+
64
+ const NS = 'dshSync'
65
+
66
+ const ZH = {
67
+ title: '同步',
68
+ syncNow: '立即同步',
69
+ syncing: '同步中,可能需要一分钟…',
70
+ syncDone: '同步完成',
71
+ syncFailed: '同步失败',
72
+ save: '保存',
73
+ saved: '设置已保存',
74
+ repoUrlLabel: '仓库地址',
75
+ branchLabel: '分支',
76
+ instanceLabel: '实例 ID',
77
+ lastSyncLabel: '上次同步',
78
+ dirLabel: '本地镜像目录',
79
+ gitMissing: '未检测到 git',
80
+ repoMissing: '尚未初始化,点「立即同步」',
81
+ notConfigured: '未配置仓库地址或访问令牌',
82
+ tokenLabel: 'GitCode 访问令牌',
83
+ tokenConfigured: '已配置',
84
+ tokenHint: '需有目标私有仓库的读写权限',
85
+ clearToken: '清除',
86
+ autoSyncLabel: '定时自动同步',
87
+ syncOnStartupLabel: '启动时同步',
88
+ intervalLabel: '同步间隔(分钟)',
89
+ conflictModeLabel: '冲突处理',
90
+ conflictModeAi: 'AI 处理(推荐)',
91
+ conflictModeManual: '人工网页合并',
92
+ conflictModeHint: '两台机器改了同一文件时:AI 模式弹出按钮由 AI 自动解决;人工模式 PR 挂起等你去 gitcode.com 合并',
93
+ toggleSkills: '技能 skills',
94
+ toggleSessions: '会话 sessions',
95
+ toggleSettings: '设置 settings',
96
+ togglePlugins: '插件 plugins',
97
+ groupHint: '勾选要同步的类别(取消勾选不同步),首次开启会全量上传',
98
+ conflictTitle: '解决同步冲突',
99
+ conflictHint: '检测到未合并的同步 PR(两台机器改了同一文件)。点击下方按钮,AI 会读取本机令牌、分析两边改动、解决冲突并合并 PR。',
100
+ conflictPending: '有未解决的冲突 PR',
101
+ resolveBtn: 'AI 解决冲突',
102
+ openChat: '打开对话',
103
+ running: '执行中…(可能需要几分钟)',
104
+ runDone: '解决完成',
105
+ runFailed: '执行失败',
106
+ outputLabel: '输出',
107
+ repoUrlPlaceholder: 'https://gitcode.com/<owner>/<repo>.git',
108
+ operationFailed: '操作失败',
109
+ }
110
+
111
+ const EN = {
112
+ title: 'Sync',
113
+ syncNow: 'Sync now',
114
+ syncing: 'Syncing, may take a minute…',
115
+ syncDone: 'Sync complete',
116
+ syncFailed: 'Sync failed',
117
+ save: 'Save',
118
+ saved: 'Settings saved',
119
+ repoUrlLabel: 'Repository URL',
120
+ branchLabel: 'Branch',
121
+ instanceLabel: 'Instance ID',
122
+ lastSyncLabel: 'Last sync',
123
+ dirLabel: 'Local mirror dir',
124
+ gitMissing: 'git not found',
125
+ repoMissing: 'Not initialized — hit Sync now',
126
+ notConfigured: 'Repo URL or access token not configured',
127
+ tokenLabel: 'GitCode access token',
128
+ tokenConfigured: 'configured',
129
+ tokenHint: 'Needs read/write on the target private repo',
130
+ clearToken: 'Clear',
131
+ autoSyncLabel: 'Auto sync on schedule',
132
+ syncOnStartupLabel: 'Sync on startup',
133
+ intervalLabel: 'Interval (minutes)',
134
+ conflictModeLabel: 'Conflict handling',
135
+ conflictModeAi: 'AI resolve (recommended)',
136
+ conflictModeManual: 'Manual web merge',
137
+ conflictModeHint: 'When two machines edit the same file: AI mode shows a button the AI resolves; manual mode leaves the PR open for you to merge on gitcode.com',
138
+ toggleSkills: 'Skills',
139
+ toggleSessions: 'Sessions',
140
+ toggleSettings: 'Settings',
141
+ togglePlugins: 'Plugins',
142
+ groupHint: 'Toggle categories to sync (off = skipped); first enable uploads the full set',
143
+ conflictTitle: 'Resolve sync conflict',
144
+ conflictHint: 'An unmerged sync PR exists (two machines edited the same file). Click below: the AI reads the local token, analyzes both sides, resolves the conflict and merges the PR.',
145
+ conflictPending: 'Unresolved conflict PR',
146
+ resolveBtn: 'AI resolve conflict',
147
+ openChat: 'Open chat',
148
+ running: 'Running… (may take minutes)',
149
+ runDone: 'Resolved',
150
+ runFailed: 'Run failed',
151
+ outputLabel: 'Output',
152
+ repoUrlPlaceholder: 'https://gitcode.com/<owner>/<repo>.git',
153
+ operationFailed: 'Operation failed',
154
+ }
155
+
156
+ // ── Pure helpers ────────────────────────────────────────────────────────
157
+
158
+ function substituteParams(template, params) {
159
+ let out = template
160
+ for (const [key, value] of Object.entries(params)) {
161
+ out = out.split(`{{${key}}}`).join(String(value))
162
+ }
163
+ return out
164
+ }
165
+
166
+ const API = '/dsh-sync/api'
167
+
168
+ function formatTime(iso) {
169
+ if (!iso) return '-'
170
+ try {
171
+ const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
172
+ if (days > 30) return new Date(iso).toLocaleDateString()
173
+ if (days > 0) return days + 'd'
174
+ const hours = Math.floor((Date.now() - new Date(iso).getTime()) / 3600000)
175
+ if (hours > 0) return hours + 'h'
176
+ const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
177
+ if (minutes > 0) return minutes + 'm'
178
+ return 'now'
179
+ } catch { return '-' }
180
+ }
181
+
182
+ // ── Token-based stylesheet (light/dark adaptive by construction) ────────
183
+
184
+ const STYLE = `<style>
185
+ .sk-page{position:relative;display:flex;flex-direction:column;gap:14px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:var(--dsw-font-sm-14,14px)}
186
+ .sk-body{display:flex;flex-direction:column;gap:14px}
187
+ .sk-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
188
+ .sk-spacer{flex:1}
189
+ .sk-hint{color:var(--dsw-alias-label-secondary)}
190
+ .sk-dir{color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-font-xs-13,12px)}
191
+ .sk-tag{display:inline-flex;align-items:center;padding:2px 9px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);font-size:11.5px}
192
+ .sk-tag.accent{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
193
+ .sk-tag.danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
194
+ .sk-card{display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
195
+ .sk-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
196
+ .sk-toggles{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
197
+ .sk-toggle{display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:13px;cursor:pointer}
198
+ .sk-toggle.on{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-interactive-bg-hover)}
199
+ .sk-spin{width:22px;height:22px;border-radius:50%;border:3px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-brand-primary,var(--dsw-alias-state-business-primary));animation:dshspin .7s linear infinite}
200
+ @keyframes dshspin{to{transform:rotate(360deg)}}
201
+ .sk-dlg-backdrop{position:fixed;inset:0;z-index:30;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px}
202
+ .sk-dlg{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;min-width:340px;max-width:640px;max-height:82vh;overflow:auto;padding:18px;box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
203
+ .sk-dlg h3{margin:0 0 12px;font-size:15px}
204
+ .sk-dlg-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
205
+ .sk-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:32px;padding:6px 16px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;cursor:pointer;font-family:var(--dsw-font-family);white-space:nowrap}
206
+ .sk-btn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
207
+ .sk-btn:disabled{opacity:.5;cursor:not-allowed}
208
+ .sk-btn-primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}
209
+ .sk-btn-primary:hover{filter:brightness(1.08);background:var(--dsw-alias-state-business-primary)}
210
+ .sk-btn-sm{min-height:28px;padding:4px 12px;font-size:12.5px;min-width:64px}
211
+ .sk-input{min-height:32px;padding:6px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;font-family:var(--dsw-font-family);outline:none;box-sizing:border-box}
212
+ .sk-input:focus{border-color:var(--dsw-alias-state-business-primary)}
213
+ .sk-input::placeholder{color:var(--dsw-alias-label-tertiary)}
214
+ .sk-toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);z-index:40;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l2);border-radius:999px;padding:8px 18px;font-size:13px;box-shadow:var(--dsw-shadow-lv2)}
215
+ </style>`
216
+
217
+ // ── Fetch layer ─────────────────────────────────────────────────────────
218
+
219
+ async function getJson(url) {
220
+ const r = await fetch(url)
221
+ if (!r.ok) throw new Error('HTTP ' + r.status)
222
+ return r.json()
223
+ }
224
+
225
+ // ── Small building blocks ────────────────────────────────────────────────
226
+
227
+ const Tag = ({ tone, children }) =>
228
+ h('span', { className: 'sk-tag' + (tone ? ' ' + tone : '') }, children)
229
+
230
+ function ButtonLite({ primary, danger, small, children, ...rest }) {
231
+ const cls = 'sk-btn' + (primary ? ' sk-btn-primary' : '') + (danger ? ' sk-btn-primary' : '') + (small ? ' sk-btn-sm' : '')
232
+ if (prim('Button')) {
233
+ return h(P.Button, { variant: primary || danger ? 'primary' : 'outline', size: small ? 'sm' : 'md', className: cls, ...rest }, children)
234
+ }
235
+ return h('button', { className: cls, ...rest }, children)
236
+ }
237
+
238
+ /** In-page dialog: fixed backdrop inside the settings page stacking context. */
239
+ function SkDialog({ title, onClose, footer, children, wide }) {
240
+ return h('div', { className: 'sk-dlg-backdrop', onClick: onClose },
241
+ h('div', { className: 'sk-dlg', style: wide ? { maxWidth: 920, width: '92vw' } : undefined,
242
+ onClick: e => e.stopPropagation() },
243
+ title && h('h3', null, title),
244
+ children,
245
+ footer && h('div', { className: 'sk-dlg-foot' }, footer)))
246
+ }
247
+
248
+ function InToast({ text }) {
249
+ return h('div', { className: 'sk-toast' }, text)
250
+ }
251
+
252
+ // ── Conflict-resolution dialog: AI action button + streamed output ──
253
+ // (mirrors the skills-management share-run polling pattern)
254
+
255
+ function ConflictDialog({ t, pending, onClose, onToast }) {
256
+ const [job, setJob] = useState(null)
257
+ const [busy, setBusy] = useState(false)
258
+ useEffect(() => {
259
+ if (job === null || job.status !== 'running') return
260
+ const timer = setInterval(() => {
261
+ getJson(API + '/conflict/run?id=' + encodeURIComponent(job.jobId))
262
+ .then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
263
+ .catch(() => {})
264
+ }, 2000)
265
+ if (typeof timer.unref === 'function') timer.unref()
266
+ return () => clearInterval(timer)
267
+ }, [job && job.status])
268
+ const doRun = async () => {
269
+ setBusy(true)
270
+ try {
271
+ const r = await fetch(API + '/conflict/run', {
272
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
274
+ })
275
+ const d = await r.json().catch(() => ({}))
276
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
277
+ setJob({ jobId: d.jobId, status: 'running', output: '', code: null })
278
+ } catch (e) { onToast(t('runFailed') + ': ' + e.message) } finally { setBusy(false) }
279
+ }
280
+ const openChat = () => {
281
+ try {
282
+ const svc = sessionsSvc()
283
+ if (svc && typeof svc.open === 'function' && job && job.sessionId) svc.open(job.sessionId)
284
+ } catch {}
285
+ }
286
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
287
+ h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
288
+ return h(SkDialog, { title: t('conflictTitle'), onClose, wide: true },
289
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
290
+ h('div', { className: 'sk-hint' }, t('conflictHint')),
291
+ pending && h('div', null,
292
+ row('PR', '#' + (pending.prNumber || '-')),
293
+ row('Branch', pending.branch || '-')),
294
+ job !== null && h('div', null,
295
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
296
+ t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
297
+ h('pre', { className: 'sk-card', style: { maxHeight: 220, margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, overflow: 'auto' } },
298
+ job.output || '…')),
299
+ h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
300
+ job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: openChat }, t('openChat')),
301
+ h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
302
+ job !== null && job.status === 'running' ? t('running') : t('resolveBtn')))))
303
+ }
304
+
305
+ // ── Settings section: the single entrance (host settings page section) ──
306
+
307
+ function SettingsSection({ t }) {
308
+ const [status, setStatus] = useState(null)
309
+ const [busy, setBusy] = useState(false)
310
+ const [conflictOpen, setConflictOpen] = useState(false)
311
+ const [toastText, setToastText] = useState(null)
312
+ const [repoUrl, setRepoUrl] = useState('')
313
+ const [branch, setBranch] = useState('')
314
+ const [token, setToken] = useState('')
315
+ const [intervalMinutes, setIntervalMinutes] = useState(30)
316
+ const [autoSync, setAutoSync] = useState(true)
317
+ const [syncOnStartup, setSyncOnStartup] = useState(false)
318
+ const [conflictMode, setConflictMode] = useState('ai')
319
+ const [g, setG] = useState({ skills: true, sessions: false, settings: true, plugins: true })
320
+
321
+ const onToast = (text, ms = 3000) => { setToastText(text); setTimeout(() => setToastText(null), ms) }
322
+ const refresh = () => getJson(API + '/status').then(d => {
323
+ setStatus(d)
324
+ setRepoUrl(d.repoUrl)
325
+ setBranch(d.branch)
326
+ setIntervalMinutes(d.intervalMinutes)
327
+ setAutoSync(d.autoSync)
328
+ setSyncOnStartup(d.syncOnStartup)
329
+ setConflictMode(d.conflictMode)
330
+ setG({ skills: d.syncSkills, sessions: d.syncSessions, settings: d.syncSettings, plugins: d.syncPlugins })
331
+ }).catch(() => {})
332
+ useEffect(() => {
333
+ refresh()
334
+ const timer = setInterval(refresh, 15000)
335
+ if (typeof timer.unref === 'function') timer.unref()
336
+ return () => clearInterval(timer)
337
+ }, [])
338
+
339
+ const doSync = async () => {
340
+ setBusy(true)
341
+ try {
342
+ const r = await fetch(API + '/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
343
+ const d = await r.json().catch(() => ({}))
344
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
345
+ onToast(t('syncDone'), 2600)
346
+ } catch (e) { onToast(t('syncFailed') + ': ' + e.message, 4000) }
347
+ finally { setBusy(false); refresh() }
348
+ }
349
+ const putSettings = async (patch) => {
350
+ const r = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
351
+ const d = await r.json().catch(() => ({}))
352
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
353
+ return d
354
+ }
355
+ const doSave = async () => {
356
+ try {
357
+ const patch = { repoUrl, branch, intervalMinutes, autoSync, syncOnStartup, conflictMode, syncSkills: g.skills, syncSessions: g.sessions, syncSettings: g.settings, syncPlugins: g.plugins }
358
+ if (token !== '') patch.token = token
359
+ await putSettings(patch)
360
+ setToken('')
361
+ onToast(t('saved'), 2200)
362
+ refresh()
363
+ } catch (e) { onToast(e.message || t('operationFailed'), 4000) }
364
+ }
365
+ const doClearToken = async () => {
366
+ try { await putSettings({ token: null }); onToast(t('saved'), 2200); refresh() }
367
+ catch (e) { onToast(e.message || t('operationFailed'), 4000) }
368
+ }
369
+
370
+ let body
371
+ try {
372
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
373
+ h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
374
+ const toggle = (key, label) => h('label', { key, className: 'sk-toggle' + (g[key] ? ' on' : '') },
375
+ h('input', { type: 'checkbox', checked: g[key], onChange: e => setG(prev => ({ ...prev, [key]: e.target.checked })) }), label)
376
+ body = status === null
377
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, padding: 24, color: 'var(--dsw-alias-label-secondary)' } },
378
+ h('div', { className: 'sk-spin' }), '…')
379
+ : h('div', { className: 'sk-body' },
380
+ status.gitAvailable === false && h('div', { className: 'sk-tag danger' }, t('gitMissing')),
381
+ (!status.repoUrl || !status.hasToken) && h('div', { className: 'sk-tag accent' }, t('notConfigured')),
382
+ status.pendingConflict && h('div', { className: 'sk-card', style: { borderColor: 'var(--dsw-alias-state-error-primary)' } },
383
+ h('div', { className: 'sk-head' },
384
+ h(Tag, { tone: 'danger' }, t('conflictPending')),
385
+ h('span', { className: 'sk-spacer' }),
386
+ h(ButtonLite, { primary: true, small: true, onClick: () => setConflictOpen(true) }, t('resolveBtn'))),
387
+ h('div', { className: 'sk-hint' }, t('conflictHint'))),
388
+ h('div', { className: 'sk-card' },
389
+ h('div', { className: 'sk-head' },
390
+ h('span', { className: 'sk-dir' }, t('instanceLabel')),
391
+ h('span', { className: 'sk-hint' }, status.instanceId || '-'),
392
+ h('span', { className: 'sk-spacer' }),
393
+ status.syncing && h(Tag, { tone: 'accent' }, t('syncing'))),
394
+ row(t('repoUrlLabel'), status.repoUrl || '-'),
395
+ row(t('branchLabel'), status.branch || '-'),
396
+ row(t('dirLabel'), status.dir),
397
+ row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing'))),
398
+ h('div', null,
399
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('groupHint')),
400
+ h('div', { className: 'sk-toggles' },
401
+ toggle('skills', t('toggleSkills')), toggle('sessions', t('toggleSessions')),
402
+ toggle('settings', t('toggleSettings')), toggle('plugins', t('togglePlugins')))),
403
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
404
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
405
+ h('input', { type: 'checkbox', checked: autoSync, onChange: e => setAutoSync(e.target.checked) }), t('autoSyncLabel')),
406
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
407
+ h('input', { type: 'checkbox', checked: syncOnStartup, onChange: e => setSyncOnStartup(e.target.checked) }), t('syncOnStartupLabel')),
408
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
409
+ t('intervalLabel'), h('input', { className: 'sk-input', type: 'number', min: 5, value: intervalMinutes, onChange: e => setIntervalMinutes(Math.max(1, Number(e.target.value) || 30)), style: { width: 80 } }))),
410
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
411
+ h('input', { className: 'sk-input', value: repoUrl, onChange: e => setRepoUrl(e.target.value), placeholder: t('repoUrlPlaceholder'), style: { width: '100%' } }),
412
+ h('input', { className: 'sk-input', value: branch, onChange: e => setBranch(e.target.value), placeholder: t('branchLabel'), style: { width: '100%' } }),
413
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
414
+ h('input', { className: 'sk-input', type: 'password', value: token, onChange: e => setToken(e.target.value),
415
+ placeholder: status && status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
416
+ status && status.hasToken && h(ButtonLite, { onClick: doClearToken }, t('clearToken'))),
417
+ h('div', { className: 'sk-dir' }, t('tokenHint'))),
418
+ h('div', null,
419
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('conflictModeLabel')),
420
+ h('div', { className: 'sk-toggles' },
421
+ h('label', { className: 'sk-toggle' + (conflictMode === 'ai' ? ' on' : '') },
422
+ h('input', { type: 'radio', checked: conflictMode === 'ai', onChange: () => setConflictMode('ai') }), t('conflictModeAi')),
423
+ h('label', { className: 'sk-toggle' + (conflictMode === 'manual' ? ' on' : '') },
424
+ h('input', { type: 'radio', checked: conflictMode === 'manual', onChange: () => setConflictMode('manual') }), t('conflictModeManual'))),
425
+ h('div', { className: 'sk-dir', style: { marginTop: 4 } }, t('conflictModeHint'))),
426
+ h('div', { className: 'sk-toolbar' },
427
+ h(ButtonLite, { onClick: doSave }, t('save')),
428
+ h('span', { className: 'sk-spacer' }),
429
+ h(ButtonLite, { primary: true, disabled: busy || status.syncing, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
430
+ } catch (renderErr) {
431
+ ;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
432
+ body = h('div', { className: 'sk-card', style: { color: 'var(--dsw-alias-state-error-primary)' } },
433
+ '\u26A0\uFE0F ' + String((renderErr && renderErr.message) || renderErr))
434
+ }
435
+
436
+ return h('div', { className: 'sk-page' },
437
+ h('div', { className: 'sk-body' }, body),
438
+ conflictOpen && status && status.pendingConflict && h(ConflictDialog, {
439
+ t, pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
440
+ }),
441
+ toastText && h(InToast, { text: toastText }),
442
+ )
443
+ }
444
+
445
+ function SettingsSlotComponent(props) {
446
+ useEffect(ensureStyles, [])
447
+ return h(SettingsSection, { t: props.__t })
448
+ }
449
+
450
+ // ── Plugin plane contract ────────────────────────────────────────────────
451
+
452
+ const CLIENT_NAME = 'dsh-plugin-dsh-sync'
453
+
454
+ module.exports = {
455
+ name: CLIENT_NAME,
456
+ inject: ['slots', 'locale'],
457
+ __internals: { NS, ZH, EN, substituteParams, formatTime },
458
+ __boot(container, opts = {}) {
459
+ ensureStyles()
460
+ let t = opts.t || ((key, vars) => {
461
+ let out = EN[key] ?? key
462
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
463
+ return out
464
+ })
465
+ const root = require('react-dom/client').createRoot(container)
466
+ root.render(h(SettingsSection, { t }))
467
+ return root
468
+ },
469
+ apply(ctx) {
470
+ try {
471
+ if (typeof ctx.inject === 'function') {
472
+ ctx.inject(['sessions'], (scope) => {
473
+ const svc = scope && scope.sessions
474
+ if (svc && typeof svc.open === 'function') sessionsApi = svc
475
+ })
476
+ }
477
+ } catch {}
478
+ let t = (key, vars) => {
479
+ let out = EN[key] ?? key
480
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
481
+ return out
482
+ }
483
+ try {
484
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
485
+ ctx.locale.register(NS, 'zh', ZH)
486
+ ctx.locale.register(NS, 'en', EN)
487
+ const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
488
+ if (bound) {
489
+ t = (key, vars) => {
490
+ let out = bound(key) || key
491
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
492
+ return out
493
+ }
494
+ globalThis.__dshSyncLocaleLive = true
495
+ }
496
+ }
497
+ } catch (e) { try { console.error('[dsh-sync] locale init:', e) } catch {} }
498
+ ctx.effect(() => {
499
+ try {
500
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
501
+ name: 'settings.section',
502
+ id: CLIENT_NAME,
503
+ order: 95,
504
+ locale: NS,
505
+ // resolveSlotLabel 调用 label() 不传参;官方模式是自带绑定翻译的闭包
506
+ label: () => t('title'),
507
+ inject: () => ({}),
508
+ }, function SettingsSectionSlot() {
509
+ return h(SettingsSlotComponent, { __t: t })
510
+ }))
511
+ } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('settings:' + (e && e.message)); throw e }
512
+ }, 'dsh-sync: settings section')
513
+ },
514
+ }
@@ -0,0 +1,12 @@
1
+ # dsh-plugin-dsh-sync bundle patch: contributes the plugin row into the host
2
+ # composition when this package is installed via `dsh plugin add`.
3
+ #
4
+ # The plugin is HOST-PLANE: it owns a private git working tree under
5
+ # $DSH_HOME/dsh-sync and reconciles it with a private GitCode repository,
6
+ # serving its HTTP API over a webServer route. Its client half registers a
7
+ # full-page sync status surface plus a settings page. It must therefore land
8
+ # in the host composition (this insert), never inside an agent preset.
9
+
10
+ - insert:
11
+ - id: dsh-sync
12
+ name: '@weibaohui/dsh-sync'
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@weibaohui/dsh-sync",
3
+ "version": "0.1.0",
4
+ "description": "DeepSeek Harness plugin: a small git-based sync system for multiple dsh replicas. Mirrors skills / sessions / settings / plugins into a private GitCode repository and reconciles replicas through a branch → PR → merge flow, so conflicts surface as pull requests instead of silent overwrites.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "dsh",
8
+ "deepseek-harness",
9
+ "cordis",
10
+ "plugin",
11
+ "sync",
12
+ "git",
13
+ "gitcode",
14
+ "dsh-plugin"
15
+ ],
16
+ "main": "src/index.js",
17
+ "exports": {
18
+ ".": "./src/index.js",
19
+ "./client": "./client/bundle.js",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "platform": "web"
28
+ }
29
+ },
30
+ "files": [
31
+ "src",
32
+ "client",
33
+ "cordis.patch.yml"
34
+ ],
35
+ "scripts": {
36
+ "check": "node --check src/index.js && node --check client/index.js",
37
+ "test": "node --test test/*.test.mjs",
38
+ "build:client": "node scripts/build-client.mjs",
39
+ "prepublishOnly": "npm run build:client && npm run check && npm test"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.5"
43
+ },
44
+ "dependencies": {},
45
+ "devDependencies": {
46
+ "@deepseek-ai/schemastery": "^3.18.1"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/weibaohui/dsh-sync.git"
51
+ },
52
+ "homepage": "https://github.com/weibaohui/dsh-sync#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/weibaohui/dsh-sync/issues"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }