@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.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # dsh-plugin-dsh-sync
2
+
3
+ 一个小型 git 同步系统,让多个 dsh 副本通过一个**私有 GitCode 仓库**同步 skills / sessions / settings / plugins。每个实例只能以**分支 → PR → 合并**的形式提交变更,冲突因此显化为一个待合并的 PR,而不是静默覆盖。
4
+
5
+
6
+ ## 安装
7
+
8
+ ```bash
9
+ dsh plugin --profile web add @weibaohui/dsh-sync -w
10
+ ```
11
+
12
+ 装完重启 `dsh web` 即生效。
13
+
14
+ ## 发版(维护者)
15
+
16
+ ```bash
17
+ npm version patch # bump + commit + tag
18
+ git push --follow-tags
19
+ gh release create vX.Y.Z --generate-notes # 创建 Release 触发自动发布到 npm
20
+ ```
21
+
22
+ 发布由 GitHub Actions 完成(Release published 触发;打 tag 不发布),走 npm Trusted Publishing 免 token。
23
+
24
+ ## 设计要点
25
+
26
+ - **影子工作树**:`$DSH_HOME/dsh-sync/repo` 镜像选定的 live 根目录。不在 `~/.dsh` 直接开 git(里面混着凭证、profiles 的 node_modules)。push = fetch `origin/main` → shadow reset 到基线 → 覆盖 live 快照 → 建分支 → commit → push → 建 PR。
27
+ - **三向算法**:live 目录是本机真相源,shadow+remote main 是合并账本。push 把本机增量做成 PR;pull 只把"本地没动过"的远端变更写回 live(本地动过的留给下个 push)。冲突由 PR 兜底。
28
+ - **PR 即冲突闸口**:GitCode v5 REST(`POST /pulls`、`GET /pulls/:n`、`PUT /pulls/:n/merge`)。可合并则 squash 合并;冲突则留 PR 开着。
29
+ - **冲突 = AI action button**:冲突时不机器硬合,也不纯人工挂起,而是 UI 冒出「AI 解决冲突」按钮,点击后启动一个 in-process agent(复用 skills-management share 的 `agents.create + followup` 通道),读 token、分析两边、改影子仓库、合并 PR。确定性的事(fetch/branch/commit/push)走 git CLI;只有需要语义判断的冲突才召唤 AI。
30
+ - **私仓强制**:`settings.yaml` 整文件同步、**不脱敏**(含各 provider token)。前提是仓库必须 private——保存配置时调 `GET /repos/{owner}/{repo}` 校验 `private===true`,公共仓库直接拒绝。不代建仓库,用户自行到 gitcode.com 建私有仓库。
31
+ - **token 只写不回读**:复用 skills-market 的 schemastery settings 模式,namespace `dsh-sync`,HTTP 只回 `hasToken`。
32
+
33
+ ## 四类同步开关
34
+
35
+ | 开关 | 默认 | 同步内容 |
36
+ |---|---|---|
37
+ | `syncSkills` | 开 | `~/.dsh/skills` + `~/.agents/skills`(软链解引用)+ `~/.agents/.skill-lock.json` |
38
+ | `syncSessions` | **关** | `~/.dsh/sessions/**/*.session.jsonl.zstd`(写一次即不变,零冲突;默认关因体积大) |
39
+ | `syncSettings` | 开 | `~/.dsh/settings.yaml` 整文件 |
40
+ | `syncPlugins` | 开 | `~/.dsh/profiles/*/` 下 `package.json`/`cordis.patch.yml`/`pnpm-lock.yaml`/`pnpm-workspace.yaml`(排除 node_modules、.dsh-market、cordis.yml 产物) |
41
+
42
+ ## 配置步骤
43
+
44
+ 1. 在 gitcode.com 建一个**私有**空仓库(如 `my-dsh-sync`)。
45
+ 2. 生成一个有该仓库读写权限的 access token。
46
+ 3. dsh 侧栏点「同步」→ ⚙ 同步设置:填仓库地址、token、勾选要同步的类别。
47
+ 4. 保存(私仓校验通过后)→ 立即同步。首次会全量上传。
48
+
49
+ ## 已知局限
50
+
51
+ - **plugins 跨机复现依赖 npm 包**:`cordis.patch.yml` 里 insert 的本地绝对路径插件(如 `dsh-plugin-hermes-prompt`)在另一台机器路径不存在,只能靠 `pnpm install` 拉 npm 包形式的插件。pull 下 profile 声明后需手动 `pnpm install` + 重启。
52
+ - **settings 生效需重启**:运行中的 dsh 不一定热读 settings.yaml,pull 下来的 provider/UI 偏好要重启 dsh 才生效。token 类同生态共享无影响。
53
+ - **多 profile 并发**:tui + web 同机双进程跑同一 `$DSH_HOME`,靠 `~/.dsh/dsh-sync/.lock`(O_EXCL + 死 pid 回收)串行化。
54
+ - **`mergeable` 字段**:GitCode 文档动态渲染未显式列出,骨架按 Gitee/GitCode v5 兼容字段处理;联调时若该字段缺失,降级为"尝试合并、409 视为冲突"。
55
+
56
+ ## 文件结构
57
+
58
+ ```
59
+ dsh-sync/
60
+ ├── package.json # main: src/index.js, exports["./client"]: client/bundle.js
61
+ ├── cordis.patch.yml # host-plane insert (id: dsh-sync)
62
+ ├── src/index.js # 宿主:schemastery + settings + 私仓校验 + git 引擎 + 三向算法 + 冲突 action job + HTTP API + 调度
63
+ ├── client/index.js # UI:状态面板 + token + 四开关 + 同步触发 + 冲突 action button
64
+ ├── client/bundle.js # 由 scripts/build-client.mjs 生成
65
+ ├── scripts/build-client.mjs
66
+ └── test/sync.test.mjs # 本地 bare 仓库 + mock fetch 跑完整 push 流程
67
+ ```
68
+
69
+ ## 开发
70
+
71
+ ```sh
72
+ npm run check # node --check src + client
73
+ npm test # node --test test/*.test.mjs(8 项,含端到端 push)
74
+ npm run build:client # 重新生成 client/bundle.js
75
+ ```
76
+
77
+ ## 复用的 skills-management 基础设施
78
+
79
+ schemastery 加载、settings register + hasToken 保密、`gitExec`/`authedUrl`、单飞去重、`ctx.effect` + `setInterval` 调度、slots + fullscreen overlay + 页内 dialog、in-process agent 事件泵——全部沿用 skills-management 已验收的实现。
@@ -0,0 +1,528 @@
1
+ /* Generated from client/index.js by scripts/build-client.mjs — do not edit by hand.
2
+ * Regenerate with: npm run build:client
3
+ */
4
+ window.__ModuleLoader__.load({
5
+ id: "dsh-plugin-dsh-sync",
6
+ factory: (require) => {
7
+ var module = { exports: {} }
8
+ var exports = module.exports
9
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" })
10
+ var React = require("react")
11
+ /**
12
+ * dsh-plugin-dsh-sync - Browser half.
13
+ *
14
+ * Single surface: the host settings page section. (The sidebar footer entry
15
+ * was removed by user decision — settings is the only entrance.) All
16
+ * interactive controls are host primitives (@deepseek-ai/dsh-client-ui-
17
+ * primitives); all colors come from the ui-theme `--dsw-*` token layers so
18
+ * light/dark follows the shell; all copy comes from the locale registry
19
+ * (`zh`/`en`). The client never sees the access token in cleartext — only
20
+ * `hasToken`.
21
+ */
22
+
23
+ // React is a loader platform module. Under plain Node (contract tests) a
24
+ // minimal createElement/hook shim keeps the source loadable for assertions.
25
+ let __React = null
26
+ try { __React = require('react') } catch {}
27
+ if (!__React || typeof __React.createElement !== 'function') {
28
+ __React = {
29
+ createElement(type, props, ...kids) {
30
+ return { type, props: props || {}, kids: kids.flat(9).filter(k => k !== null && k !== undefined && k !== false && k !== true || true) }
31
+ },
32
+ useState(init) { const v = [typeof init === 'function' ? init() : init]; return [v[0], x => { v[0] = typeof x === 'function' ? x(v[0]) : x }] },
33
+ useEffect() {}, useMemo(fn) { return fn() }, useRef(v = null) { return { current: v } },
34
+ }
35
+ }
36
+ const { createElement: h, useState, useEffect, useMemo, useRef } = __React
37
+
38
+ // Platform module — always present in the loader's seeded require table.
39
+ // Under plain Node (tests) it is absent; a tagged-element shim keeps the
40
+ // tree structurally testable while every real surface ships primitives.
41
+ let P = null
42
+ try { P = require('@deepseek-ai/dsh-client-ui-primitives') } catch {}
43
+
44
+ // NOTE: no class components in this module. A `class X extends
45
+ // React.Component` error boundary defined here silently killed rendering in
46
+ // the plugin loader — render-time crashes are handled by the try/catch inside
47
+ // SettingsSection and recorded into globalThis.__skErrors instead.
48
+
49
+ /** Idempotent stylesheet injection. */
50
+ function ensureStyles() {
51
+ if (typeof document === 'undefined' || document.getElementById('dshsync-styles')) return
52
+ const holder = document.createElement('div')
53
+ holder.id = 'dshsync-styles'
54
+ holder.style.display = 'none'
55
+ holder.innerHTML = STYLE
56
+ document.head.appendChild(holder)
57
+ }
58
+
59
+ const prim = (name) => P && P[name]
60
+ ? P[name]
61
+ : function Shim(props) {
62
+ const { children, ...rest } = props
63
+ return h('button', { ...rest, 'data-p-shim': name }, children)
64
+ }
65
+
66
+ // Sessions service (client runtime): opens the conflict run's conversation
67
+ // in the real UI. Resolved through dynamic ctx.inject; absence degrades the
68
+ // 打开对话 button to hidden.
69
+ let sessionsApi = null
70
+ const sessionsSvc = () => sessionsApi
71
+
72
+ // ── Locale ───────────────────────────────────────────────────────────────
73
+
74
+ const NS = 'dshSync'
75
+
76
+ const ZH = {
77
+ title: '同步',
78
+ syncNow: '立即同步',
79
+ syncing: '同步中,可能需要一分钟…',
80
+ syncDone: '同步完成',
81
+ syncFailed: '同步失败',
82
+ save: '保存',
83
+ saved: '设置已保存',
84
+ repoUrlLabel: '仓库地址',
85
+ branchLabel: '分支',
86
+ instanceLabel: '实例 ID',
87
+ lastSyncLabel: '上次同步',
88
+ dirLabel: '本地镜像目录',
89
+ gitMissing: '未检测到 git',
90
+ repoMissing: '尚未初始化,点「立即同步」',
91
+ notConfigured: '未配置仓库地址或访问令牌',
92
+ tokenLabel: 'GitCode 访问令牌',
93
+ tokenConfigured: '已配置',
94
+ tokenHint: '需有目标私有仓库的读写权限',
95
+ clearToken: '清除',
96
+ autoSyncLabel: '定时自动同步',
97
+ syncOnStartupLabel: '启动时同步',
98
+ intervalLabel: '同步间隔(分钟)',
99
+ conflictModeLabel: '冲突处理',
100
+ conflictModeAi: 'AI 处理(推荐)',
101
+ conflictModeManual: '人工网页合并',
102
+ conflictModeHint: '两台机器改了同一文件时:AI 模式弹出按钮由 AI 自动解决;人工模式 PR 挂起等你去 gitcode.com 合并',
103
+ toggleSkills: '技能 skills',
104
+ toggleSessions: '会话 sessions',
105
+ toggleSettings: '设置 settings',
106
+ togglePlugins: '插件 plugins',
107
+ groupHint: '勾选要同步的类别(取消勾选不同步),首次开启会全量上传',
108
+ conflictTitle: '解决同步冲突',
109
+ conflictHint: '检测到未合并的同步 PR(两台机器改了同一文件)。点击下方按钮,AI 会读取本机令牌、分析两边改动、解决冲突并合并 PR。',
110
+ conflictPending: '有未解决的冲突 PR',
111
+ resolveBtn: 'AI 解决冲突',
112
+ openChat: '打开对话',
113
+ running: '执行中…(可能需要几分钟)',
114
+ runDone: '解决完成',
115
+ runFailed: '执行失败',
116
+ outputLabel: '输出',
117
+ repoUrlPlaceholder: 'https://gitcode.com/<owner>/<repo>.git',
118
+ operationFailed: '操作失败',
119
+ }
120
+
121
+ const EN = {
122
+ title: 'Sync',
123
+ syncNow: 'Sync now',
124
+ syncing: 'Syncing, may take a minute…',
125
+ syncDone: 'Sync complete',
126
+ syncFailed: 'Sync failed',
127
+ save: 'Save',
128
+ saved: 'Settings saved',
129
+ repoUrlLabel: 'Repository URL',
130
+ branchLabel: 'Branch',
131
+ instanceLabel: 'Instance ID',
132
+ lastSyncLabel: 'Last sync',
133
+ dirLabel: 'Local mirror dir',
134
+ gitMissing: 'git not found',
135
+ repoMissing: 'Not initialized — hit Sync now',
136
+ notConfigured: 'Repo URL or access token not configured',
137
+ tokenLabel: 'GitCode access token',
138
+ tokenConfigured: 'configured',
139
+ tokenHint: 'Needs read/write on the target private repo',
140
+ clearToken: 'Clear',
141
+ autoSyncLabel: 'Auto sync on schedule',
142
+ syncOnStartupLabel: 'Sync on startup',
143
+ intervalLabel: 'Interval (minutes)',
144
+ conflictModeLabel: 'Conflict handling',
145
+ conflictModeAi: 'AI resolve (recommended)',
146
+ conflictModeManual: 'Manual web merge',
147
+ 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',
148
+ toggleSkills: 'Skills',
149
+ toggleSessions: 'Sessions',
150
+ toggleSettings: 'Settings',
151
+ togglePlugins: 'Plugins',
152
+ groupHint: 'Toggle categories to sync (off = skipped); first enable uploads the full set',
153
+ conflictTitle: 'Resolve sync conflict',
154
+ 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.',
155
+ conflictPending: 'Unresolved conflict PR',
156
+ resolveBtn: 'AI resolve conflict',
157
+ openChat: 'Open chat',
158
+ running: 'Running… (may take minutes)',
159
+ runDone: 'Resolved',
160
+ runFailed: 'Run failed',
161
+ outputLabel: 'Output',
162
+ repoUrlPlaceholder: 'https://gitcode.com/<owner>/<repo>.git',
163
+ operationFailed: 'Operation failed',
164
+ }
165
+
166
+ // ── Pure helpers ────────────────────────────────────────────────────────
167
+
168
+ function substituteParams(template, params) {
169
+ let out = template
170
+ for (const [key, value] of Object.entries(params)) {
171
+ out = out.split(`{{${key}}}`).join(String(value))
172
+ }
173
+ return out
174
+ }
175
+
176
+ const API = '/dsh-sync/api'
177
+
178
+ function formatTime(iso) {
179
+ if (!iso) return '-'
180
+ try {
181
+ const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
182
+ if (days > 30) return new Date(iso).toLocaleDateString()
183
+ if (days > 0) return days + 'd'
184
+ const hours = Math.floor((Date.now() - new Date(iso).getTime()) / 3600000)
185
+ if (hours > 0) return hours + 'h'
186
+ const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000)
187
+ if (minutes > 0) return minutes + 'm'
188
+ return 'now'
189
+ } catch { return '-' }
190
+ }
191
+
192
+ // ── Token-based stylesheet (light/dark adaptive by construction) ────────
193
+
194
+ const STYLE = `<style>
195
+ .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)}
196
+ .sk-body{display:flex;flex-direction:column;gap:14px}
197
+ .sk-toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
198
+ .sk-spacer{flex:1}
199
+ .sk-hint{color:var(--dsw-alias-label-secondary)}
200
+ .sk-dir{color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-font-xs-13,12px)}
201
+ .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}
202
+ .sk-tag.accent{color:var(--dsw-alias-state-business-primary);border-color:var(--dsw-alias-state-business-primary)}
203
+ .sk-tag.danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
204
+ .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)}
205
+ .sk-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
206
+ .sk-toggles{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
207
+ .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}
208
+ .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)}
209
+ .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}
210
+ @keyframes dshspin{to{transform:rotate(360deg)}}
211
+ .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}
212
+ .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)}
213
+ .sk-dlg h3{margin:0 0 12px;font-size:15px}
214
+ .sk-dlg-foot{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
215
+ .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}
216
+ .sk-btn:hover{border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}
217
+ .sk-btn:disabled{opacity:.5;cursor:not-allowed}
218
+ .sk-btn-primary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-label-primary-inverted,#fff)}
219
+ .sk-btn-primary:hover{filter:brightness(1.08);background:var(--dsw-alias-state-business-primary)}
220
+ .sk-btn-sm{min-height:28px;padding:4px 12px;font-size:12.5px;min-width:64px}
221
+ .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}
222
+ .sk-input:focus{border-color:var(--dsw-alias-state-business-primary)}
223
+ .sk-input::placeholder{color:var(--dsw-alias-label-tertiary)}
224
+ .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)}
225
+ </style>`
226
+
227
+ // ── Fetch layer ─────────────────────────────────────────────────────────
228
+
229
+ async function getJson(url) {
230
+ const r = await fetch(url)
231
+ if (!r.ok) throw new Error('HTTP ' + r.status)
232
+ return r.json()
233
+ }
234
+
235
+ // ── Small building blocks ────────────────────────────────────────────────
236
+
237
+ const Tag = ({ tone, children }) =>
238
+ h('span', { className: 'sk-tag' + (tone ? ' ' + tone : '') }, children)
239
+
240
+ function ButtonLite({ primary, danger, small, children, ...rest }) {
241
+ const cls = 'sk-btn' + (primary ? ' sk-btn-primary' : '') + (danger ? ' sk-btn-primary' : '') + (small ? ' sk-btn-sm' : '')
242
+ if (prim('Button')) {
243
+ return h(P.Button, { variant: primary || danger ? 'primary' : 'outline', size: small ? 'sm' : 'md', className: cls, ...rest }, children)
244
+ }
245
+ return h('button', { className: cls, ...rest }, children)
246
+ }
247
+
248
+ /** In-page dialog: fixed backdrop inside the settings page stacking context. */
249
+ function SkDialog({ title, onClose, footer, children, wide }) {
250
+ return h('div', { className: 'sk-dlg-backdrop', onClick: onClose },
251
+ h('div', { className: 'sk-dlg', style: wide ? { maxWidth: 920, width: '92vw' } : undefined,
252
+ onClick: e => e.stopPropagation() },
253
+ title && h('h3', null, title),
254
+ children,
255
+ footer && h('div', { className: 'sk-dlg-foot' }, footer)))
256
+ }
257
+
258
+ function InToast({ text }) {
259
+ return h('div', { className: 'sk-toast' }, text)
260
+ }
261
+
262
+ // ── Conflict-resolution dialog: AI action button + streamed output ──
263
+ // (mirrors the skills-management share-run polling pattern)
264
+
265
+ function ConflictDialog({ t, pending, onClose, onToast }) {
266
+ const [job, setJob] = useState(null)
267
+ const [busy, setBusy] = useState(false)
268
+ useEffect(() => {
269
+ if (job === null || job.status !== 'running') return
270
+ const timer = setInterval(() => {
271
+ getJson(API + '/conflict/run?id=' + encodeURIComponent(job.jobId))
272
+ .then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
273
+ .catch(() => {})
274
+ }, 2000)
275
+ if (typeof timer.unref === 'function') timer.unref()
276
+ return () => clearInterval(timer)
277
+ }, [job && job.status])
278
+ const doRun = async () => {
279
+ setBusy(true)
280
+ try {
281
+ const r = await fetch(API + '/conflict/run', {
282
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
283
+ body: JSON.stringify({ branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
284
+ })
285
+ const d = await r.json().catch(() => ({}))
286
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
287
+ setJob({ jobId: d.jobId, status: 'running', output: '', code: null })
288
+ } catch (e) { onToast(t('runFailed') + ': ' + e.message) } finally { setBusy(false) }
289
+ }
290
+ const openChat = () => {
291
+ try {
292
+ const svc = sessionsSvc()
293
+ if (svc && typeof svc.open === 'function' && job && job.sessionId) svc.open(job.sessionId)
294
+ } catch {}
295
+ }
296
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
297
+ h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
298
+ return h(SkDialog, { title: t('conflictTitle'), onClose, wide: true },
299
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
300
+ h('div', { className: 'sk-hint' }, t('conflictHint')),
301
+ pending && h('div', null,
302
+ row('PR', '#' + (pending.prNumber || '-')),
303
+ row('Branch', pending.branch || '-')),
304
+ job !== null && h('div', null,
305
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
306
+ t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
307
+ h('pre', { className: 'sk-card', style: { maxHeight: 220, margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, overflow: 'auto' } },
308
+ job.output || '…')),
309
+ h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
310
+ job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: openChat }, t('openChat')),
311
+ h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
312
+ job !== null && job.status === 'running' ? t('running') : t('resolveBtn')))))
313
+ }
314
+
315
+ // ── Settings section: the single entrance (host settings page section) ──
316
+
317
+ function SettingsSection({ t }) {
318
+ const [status, setStatus] = useState(null)
319
+ const [busy, setBusy] = useState(false)
320
+ const [conflictOpen, setConflictOpen] = useState(false)
321
+ const [toastText, setToastText] = useState(null)
322
+ const [repoUrl, setRepoUrl] = useState('')
323
+ const [branch, setBranch] = useState('')
324
+ const [token, setToken] = useState('')
325
+ const [intervalMinutes, setIntervalMinutes] = useState(30)
326
+ const [autoSync, setAutoSync] = useState(true)
327
+ const [syncOnStartup, setSyncOnStartup] = useState(false)
328
+ const [conflictMode, setConflictMode] = useState('ai')
329
+ const [g, setG] = useState({ skills: true, sessions: false, settings: true, plugins: true })
330
+
331
+ const onToast = (text, ms = 3000) => { setToastText(text); setTimeout(() => setToastText(null), ms) }
332
+ const refresh = () => getJson(API + '/status').then(d => {
333
+ setStatus(d)
334
+ setRepoUrl(d.repoUrl)
335
+ setBranch(d.branch)
336
+ setIntervalMinutes(d.intervalMinutes)
337
+ setAutoSync(d.autoSync)
338
+ setSyncOnStartup(d.syncOnStartup)
339
+ setConflictMode(d.conflictMode)
340
+ setG({ skills: d.syncSkills, sessions: d.syncSessions, settings: d.syncSettings, plugins: d.syncPlugins })
341
+ }).catch(() => {})
342
+ useEffect(() => {
343
+ refresh()
344
+ const timer = setInterval(refresh, 15000)
345
+ if (typeof timer.unref === 'function') timer.unref()
346
+ return () => clearInterval(timer)
347
+ }, [])
348
+
349
+ const doSync = async () => {
350
+ setBusy(true)
351
+ try {
352
+ const r = await fetch(API + '/sync', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
353
+ const d = await r.json().catch(() => ({}))
354
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
355
+ onToast(t('syncDone'), 2600)
356
+ } catch (e) { onToast(t('syncFailed') + ': ' + e.message, 4000) }
357
+ finally { setBusy(false); refresh() }
358
+ }
359
+ const putSettings = async (patch) => {
360
+ const r = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
361
+ const d = await r.json().catch(() => ({}))
362
+ if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
363
+ return d
364
+ }
365
+ const doSave = async () => {
366
+ try {
367
+ const patch = { repoUrl, branch, intervalMinutes, autoSync, syncOnStartup, conflictMode, syncSkills: g.skills, syncSessions: g.sessions, syncSettings: g.settings, syncPlugins: g.plugins }
368
+ if (token !== '') patch.token = token
369
+ await putSettings(patch)
370
+ setToken('')
371
+ onToast(t('saved'), 2200)
372
+ refresh()
373
+ } catch (e) { onToast(e.message || t('operationFailed'), 4000) }
374
+ }
375
+ const doClearToken = async () => {
376
+ try { await putSettings({ token: null }); onToast(t('saved'), 2200); refresh() }
377
+ catch (e) { onToast(e.message || t('operationFailed'), 4000) }
378
+ }
379
+
380
+ let body
381
+ try {
382
+ const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
383
+ h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
384
+ const toggle = (key, label) => h('label', { key, className: 'sk-toggle' + (g[key] ? ' on' : '') },
385
+ h('input', { type: 'checkbox', checked: g[key], onChange: e => setG(prev => ({ ...prev, [key]: e.target.checked })) }), label)
386
+ body = status === null
387
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: 10, padding: 24, color: 'var(--dsw-alias-label-secondary)' } },
388
+ h('div', { className: 'sk-spin' }), '…')
389
+ : h('div', { className: 'sk-body' },
390
+ status.gitAvailable === false && h('div', { className: 'sk-tag danger' }, t('gitMissing')),
391
+ (!status.repoUrl || !status.hasToken) && h('div', { className: 'sk-tag accent' }, t('notConfigured')),
392
+ status.pendingConflict && h('div', { className: 'sk-card', style: { borderColor: 'var(--dsw-alias-state-error-primary)' } },
393
+ h('div', { className: 'sk-head' },
394
+ h(Tag, { tone: 'danger' }, t('conflictPending')),
395
+ h('span', { className: 'sk-spacer' }),
396
+ h(ButtonLite, { primary: true, small: true, onClick: () => setConflictOpen(true) }, t('resolveBtn'))),
397
+ h('div', { className: 'sk-hint' }, t('conflictHint'))),
398
+ h('div', { className: 'sk-card' },
399
+ h('div', { className: 'sk-head' },
400
+ h('span', { className: 'sk-dir' }, t('instanceLabel')),
401
+ h('span', { className: 'sk-hint' }, status.instanceId || '-'),
402
+ h('span', { className: 'sk-spacer' }),
403
+ status.syncing && h(Tag, { tone: 'accent' }, t('syncing'))),
404
+ row(t('repoUrlLabel'), status.repoUrl || '-'),
405
+ row(t('branchLabel'), status.branch || '-'),
406
+ row(t('dirLabel'), status.dir),
407
+ row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing'))),
408
+ h('div', null,
409
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('groupHint')),
410
+ h('div', { className: 'sk-toggles' },
411
+ toggle('skills', t('toggleSkills')), toggle('sessions', t('toggleSessions')),
412
+ toggle('settings', t('toggleSettings')), toggle('plugins', t('togglePlugins')))),
413
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
414
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
415
+ h('input', { type: 'checkbox', checked: autoSync, onChange: e => setAutoSync(e.target.checked) }), t('autoSyncLabel')),
416
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
417
+ h('input', { type: 'checkbox', checked: syncOnStartup, onChange: e => setSyncOnStartup(e.target.checked) }), t('syncOnStartupLabel')),
418
+ h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, color: 'var(--dsw-alias-label-secondary)', fontSize: 13 } },
419
+ 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 } }))),
420
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
421
+ h('input', { className: 'sk-input', value: repoUrl, onChange: e => setRepoUrl(e.target.value), placeholder: t('repoUrlPlaceholder'), style: { width: '100%' } }),
422
+ h('input', { className: 'sk-input', value: branch, onChange: e => setBranch(e.target.value), placeholder: t('branchLabel'), style: { width: '100%' } }),
423
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
424
+ h('input', { className: 'sk-input', type: 'password', value: token, onChange: e => setToken(e.target.value),
425
+ placeholder: status && status.hasToken ? `${t('tokenLabel')} · ${t('tokenConfigured')}` : t('tokenLabel'), style: { flex: 1 } }),
426
+ status && status.hasToken && h(ButtonLite, { onClick: doClearToken }, t('clearToken'))),
427
+ h('div', { className: 'sk-dir' }, t('tokenHint'))),
428
+ h('div', null,
429
+ h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('conflictModeLabel')),
430
+ h('div', { className: 'sk-toggles' },
431
+ h('label', { className: 'sk-toggle' + (conflictMode === 'ai' ? ' on' : '') },
432
+ h('input', { type: 'radio', checked: conflictMode === 'ai', onChange: () => setConflictMode('ai') }), t('conflictModeAi')),
433
+ h('label', { className: 'sk-toggle' + (conflictMode === 'manual' ? ' on' : '') },
434
+ h('input', { type: 'radio', checked: conflictMode === 'manual', onChange: () => setConflictMode('manual') }), t('conflictModeManual'))),
435
+ h('div', { className: 'sk-dir', style: { marginTop: 4 } }, t('conflictModeHint'))),
436
+ h('div', { className: 'sk-toolbar' },
437
+ h(ButtonLite, { onClick: doSave }, t('save')),
438
+ h('span', { className: 'sk-spacer' }),
439
+ h(ButtonLite, { primary: true, disabled: busy || status.syncing, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
440
+ } catch (renderErr) {
441
+ ;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
442
+ body = h('div', { className: 'sk-card', style: { color: 'var(--dsw-alias-state-error-primary)' } },
443
+ '\u26A0\uFE0F ' + String((renderErr && renderErr.message) || renderErr))
444
+ }
445
+
446
+ return h('div', { className: 'sk-page' },
447
+ h('div', { className: 'sk-body' }, body),
448
+ conflictOpen && status && status.pendingConflict && h(ConflictDialog, {
449
+ t, pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
450
+ }),
451
+ toastText && h(InToast, { text: toastText }),
452
+ )
453
+ }
454
+
455
+ function SettingsSlotComponent(props) {
456
+ useEffect(ensureStyles, [])
457
+ return h(SettingsSection, { t: props.__t })
458
+ }
459
+
460
+ // ── Plugin plane contract ────────────────────────────────────────────────
461
+
462
+ const CLIENT_NAME = 'dsh-plugin-dsh-sync'
463
+
464
+ module.exports = {
465
+ name: CLIENT_NAME,
466
+ inject: ['slots', 'locale'],
467
+ __internals: { NS, ZH, EN, substituteParams, formatTime },
468
+ __boot(container, opts = {}) {
469
+ ensureStyles()
470
+ let t = opts.t || ((key, vars) => {
471
+ let out = EN[key] ?? key
472
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
473
+ return out
474
+ })
475
+ const root = require('react-dom/client').createRoot(container)
476
+ root.render(h(SettingsSection, { t }))
477
+ return root
478
+ },
479
+ apply(ctx) {
480
+ try {
481
+ if (typeof ctx.inject === 'function') {
482
+ ctx.inject(['sessions'], (scope) => {
483
+ const svc = scope && scope.sessions
484
+ if (svc && typeof svc.open === 'function') sessionsApi = svc
485
+ })
486
+ }
487
+ } catch {}
488
+ let t = (key, vars) => {
489
+ let out = EN[key] ?? key
490
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
491
+ return out
492
+ }
493
+ try {
494
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
495
+ ctx.locale.register(NS, 'zh', ZH)
496
+ ctx.locale.register(NS, 'en', EN)
497
+ const bound = typeof ctx.locale.bind === 'function' ? ctx.locale.bind(NS) : null
498
+ if (bound) {
499
+ t = (key, vars) => {
500
+ let out = bound(key) || key
501
+ if (vars) for (const [k, v] of Object.entries(vars)) out = out.split('{' + k + '}').join(String(v))
502
+ return out
503
+ }
504
+ globalThis.__dshSyncLocaleLive = true
505
+ }
506
+ }
507
+ } catch (e) { try { console.error('[dsh-sync] locale init:', e) } catch {} }
508
+ ctx.effect(() => {
509
+ try {
510
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
511
+ name: 'settings.section',
512
+ id: CLIENT_NAME,
513
+ order: 95,
514
+ locale: NS,
515
+ // resolveSlotLabel 调用 label() 不传参;官方模式是自带绑定翻译的闭包
516
+ label: () => t('title'),
517
+ inject: () => ({}),
518
+ }, function SettingsSectionSlot() {
519
+ return h(SettingsSlotComponent, { __t: t })
520
+ }))
521
+ } catch (e) { (globalThis.__skErrors = globalThis.__skErrors || []).push('settings:' + (e && e.message)); throw e }
522
+ }, 'dsh-sync: settings section')
523
+ },
524
+ }
525
+
526
+ return module.exports
527
+ }
528
+ })