@charara97/dsh-session-sync 0.0.0-stage → 1.0.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,1017 @@
1
+ /**
2
+ * dsh-chat-sync 浏览器端 bundle。
3
+ *
4
+ * 两个入口,同一套控件:
5
+ * - 设置 → 会话同步(settings.section):完整页面形态;
6
+ * - 侧栏底部按钮(sidebar.footer.action):打开 shell.overlay 里的浮层(可在设置里关掉)。
7
+ *
8
+ * 样式刻意对齐宿主自己的控件实现(dsh-client-ui-primitives 的
9
+ * Button / SegmentedControl / Checkbox / Input / settings-form fields):
10
+ * 同一套 radius / elevation / focus-ring 令牌与尺寸,明暗主题都跟着宿主走。
11
+ *
12
+ * 特别注意:`--dsw-alias-brand-primary` 是**填充色**(暗色主题下接近白色 #f9fafb),
13
+ * 它上面的文字必须用 `--dsw-alias-label-primary-foreground`,用 #fff 就是白底白字。
14
+ */
15
+ window.__ModuleLoader__.load({
16
+ id: '@charara97/dsh-session-sync',
17
+ factory: (require) => {
18
+ const React = require('react');
19
+ const e = React.createElement;
20
+ const { useState, useEffect, useRef, useCallback, useMemo } = React;
21
+
22
+ const NS = 'dsh-chat-sync';
23
+ const API = '/chat-sync/api/';
24
+
25
+ async function jget(p) {
26
+ const r = await fetch(API + p, { headers: { Accept: 'application/json' } });
27
+ return r.json();
28
+ }
29
+ async function jpost(p, body) {
30
+ const r = await fetch(API + p, {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify(body || {}),
34
+ });
35
+ return r.json();
36
+ }
37
+
38
+ /* ================= 跨槽位共享状态 ================= */
39
+ function makeStore(initial) {
40
+ const s = {
41
+ value: initial,
42
+ listeners: new Set(),
43
+ set(v) { s.value = v; for (const fn of s.listeners) { try { fn(v); } catch { /* ignore */ } } },
44
+ subscribe(fn) { s.listeners.add(fn); return () => s.listeners.delete(fn); },
45
+ };
46
+ return s;
47
+ }
48
+ const overlay = makeStore(false);
49
+ const sidebarPref = makeStore(true);
50
+
51
+ function useStore(store) {
52
+ const [v, setV] = useState(store.value);
53
+ useEffect(() => store.subscribe(setV), [store]);
54
+ return v;
55
+ }
56
+
57
+ /* ================= 样式(对齐宿主 primitives) ================= */
58
+ const CSS = `
59
+ .dcs-root{display:flex;flex-direction:column;gap:14px;font-size:13px;line-height:1.6;
60
+ color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}
61
+ .dcs-root *{box-sizing:border-box}
62
+
63
+ .dcs-head{display:flex;align-items:center;justify-content:space-between;gap:8px}
64
+ .dcs-h{font-size:15px;font-weight:600;line-height:1.4}
65
+
66
+ .dcs-card{background:var(--dsw-alias-bg-layer-2);border:0.5px solid var(--dsw-alias-border-l2);
67
+ border-radius:var(--dsw-radius-md);padding:12px 14px}
68
+
69
+ .dcs-sec{display:flex;flex-direction:column;gap:6px}
70
+ .dcs-lab{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary)}
71
+ .dcs-note{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
72
+ .dcs-warn{font-size:12px;line-height:1.6;color:var(--dsw-alias-state-warn-label,var(--dsw-alias-state-warn-primary))}
73
+ .dcs-err{font-size:12px;line-height:1.6;color:var(--dsw-alias-state-error-primary)}
74
+ .dcs-ok{font-size:12px;line-height:1.6;color:var(--dsw-alias-state-success-primary)}
75
+
76
+ /* Button:尺寸与变体照抄 primitives/Button.module.css,只换了类名前缀 */
77
+ .dcs-btn{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:4px;
78
+ height:28px;padding:0 10px;border:0;border-radius:var(--dsw-radius-sm);background:transparent;
79
+ color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:18px;cursor:pointer;white-space:nowrap}
80
+ .dcs-btn:disabled{cursor:not-allowed;opacity:.4}
81
+ .dcs-btn:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
82
+ .dcs-btn-ghost:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
83
+ .dcs-btn-ghost:active:not(:disabled){background:var(--dsw-alias-interactive-bg-active)}
84
+ .dcs-btn-outline{border:0.5px solid var(--dsw-alias-border-l3)}
85
+ .dcs-btn-outline:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
86
+ .dcs-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}
87
+ .dcs-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}
88
+ /* 宿主 Button 的 md 尺寸,用于页面里的主操作 */
89
+ .dcs-btn-md{height:36px;padding:0 14px;font-size:14px;line-height:22px;border-radius:var(--dsw-radius-md)}
90
+ .dcs-btn-danger{color:var(--dsw-alias-state-error-primary)}
91
+ .dcs-btn-danger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover-danger)}
92
+
93
+ .dcs-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
94
+ .dcs-spread{flex:1;min-width:0}
95
+
96
+ /* SegmentedControl:轨道 + 指示块,照抄 primitives/SegmentedControl.module.css */
97
+ .dcs-seg{position:relative;display:inline-grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:2px;padding:4px;
98
+ border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover);width:fit-content;max-width:100%}
99
+ .dcs-seg-ind{position:absolute;top:4px;left:4px;height:calc(100% - 8px);
100
+ width:calc((100% - 8px - 2px * (var(--dcs-n) - 1)) / var(--dcs-n));
101
+ border-radius:var(--dsw-radius-sm);background:var(--dsw-alias-bg-layer-1);
102
+ box-shadow:var(--dsw-elevation-soft);pointer-events:none;
103
+ transform:translateX(calc(var(--dcs-i) * (100% + 2px)));transition:transform 160ms ease}
104
+ .dcs-seg>button{position:relative;z-index:1;height:28px;padding:0 16px;border:0;border-radius:var(--dsw-radius-sm);
105
+ background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:13px;line-height:20px;
106
+ font-weight:500;white-space:nowrap;cursor:pointer;transition:color 120ms ease}
107
+ .dcs-seg>button:hover:not(:disabled),.dcs-seg>button[data-on="1"]{color:var(--dsw-alias-label-primary)}
108
+ .dcs-seg>button:disabled{cursor:default;opacity:.4}
109
+ .dcs-seg>button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}
110
+ @media (prefers-reduced-motion: reduce){.dcs-seg-ind,.dcs-seg>button{transition:none}}
111
+
112
+ /* Checkbox:照抄 primitives/Checkbox.module.css */
113
+ .dcs-check{display:inline-flex;align-items:center;gap:6px;font-size:13px;line-height:20px;
114
+ color:var(--dsw-alias-label-primary);cursor:pointer}
115
+ .dcs-check input{flex:0 0 auto;width:16px;height:16px;margin:0;accent-color:var(--dsw-alias-brand-primary);cursor:inherit}
116
+ .dcs-check input:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
117
+ .dcs-check:has(input:disabled){cursor:default;opacity:.5}
118
+
119
+ /* Input:照抄 settings-form/fields.module.css 的 .input */
120
+ .dcs-in{width:100%;height:34px;padding:0 12px;border:0.5px solid var(--dsw-alias-border-l4);
121
+ border-radius:var(--dsw-radius-md);background:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;
122
+ line-height:1.5;color:var(--dsw-alias-label-primary)}
123
+ .dcs-in::placeholder{color:var(--dsw-alias-label-dimmed)}
124
+ .dcs-in:focus-visible{outline:none;border-color:var(--dsw-alias-state-business-primary)}
125
+
126
+ .dcs-list{border:0.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-md);
127
+ background:var(--dsw-alias-bg-layer-3);max-height:220px;overflow:auto}
128
+ .dcs-item{display:flex;align-items:flex-start;gap:8px;padding:8px 10px;cursor:pointer;
129
+ border-top:0.5px solid var(--dsw-alias-border-l1)}
130
+ .dcs-item:first-child{border-top:0}
131
+ .dcs-item[data-static="1"]{cursor:default}
132
+ .dcs-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
133
+ .dcs-item[data-on="1"]{background:var(--dsw-alias-interactive-bg-active)}
134
+ .dcs-item input[type=radio],.dcs-item input[type=checkbox]{margin-top:2px;flex:0 0 auto;width:16px;height:16px;accent-color:var(--dsw-alias-brand-primary)}
135
+ .dcs-txt{flex:1;min-width:0}
136
+ .dcs-t1{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}
137
+ .dcs-t2{font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary);overflow:hidden;
138
+ text-overflow:ellipsis;white-space:nowrap;margin-top:1px}
139
+
140
+ /* Pill:照抄 primitives/Pill.module.css */
141
+ .dcs-pill{display:inline-flex;align-items:center;height:20px;padding:0 8px;border-radius:999px;font-size:11px;
142
+ line-height:18px;white-space:nowrap;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}
143
+ .dcs-pill.is-same{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-tertiary)}
144
+ .dcs-pill.is-differs{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-state-warn-primary));background:var(--dsw-alias-state-warn-tertiary)}
145
+ .dcs-pill.is-remote-only{color:var(--dsw-alias-state-business-primary);background:var(--dsw-alias-state-business-tertiary)}
146
+ .dcs-pill.is-local-only{color:var(--dsw-alias-label-tertiary);background:var(--dsw-alias-bg-layer-2)}
147
+
148
+ .dcs-kv{display:grid;grid-template-columns:auto 1fr;gap:3px 12px;margin:0;font-size:12.5px}
149
+ .dcs-kv dt{color:var(--dsw-alias-label-secondary);white-space:nowrap}
150
+ .dcs-kv dd{margin:0;min-width:0;overflow-wrap:anywhere;color:var(--dsw-alias-label-primary)}
151
+
152
+ .dcs-log{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.65;
153
+ background:var(--dsw-alias-markdown-code-block);border-radius:var(--dsw-radius-md);padding:10px 12px;
154
+ max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-primary)}
155
+ .dcs-log .l-warn{color:var(--dsw-alias-state-warn-label,var(--dsw-alias-state-warn-primary))}
156
+ .dcs-log .l-error{color:var(--dsw-alias-state-error-primary)}
157
+ .dcs-log .l-done{color:var(--dsw-alias-state-success-primary)}
158
+ .dcs-log .l-step{color:var(--dsw-alias-state-business-primary);font-weight:600}
159
+ .dcs-log .t{color:var(--dsw-alias-label-tertiary);margin-right:6px}
160
+
161
+ .dcs-cf{border-top:0.5px solid var(--dsw-alias-border-l2);padding-top:10px;margin-top:10px}
162
+ .dcs-cf:first-of-type{border-top:0;padding-top:0;margin-top:0}
163
+ .dcs-cf-h{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
164
+ .dcs-pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.6;
165
+ white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;max-height:150px;overflow-y:auto;
166
+ overflow-x:hidden;background:var(--dsw-alias-markdown-code-block);
167
+ border-radius:var(--dsw-radius-sm);padding:8px;margin:4px 0 0;color:var(--dsw-alias-label-secondary)}
168
+ /* 必须写 minmax(0,1fr):裸 1fr 的最小尺寸是 auto,内容一长(长路径、连排英文、
169
+ 无空格的 JSON)轨道就会被撑破容器,而且两列宽度还会不一致。
170
+ 再让子项 min-width:0,否则它同样拒绝收缩。 */
171
+ .dcs-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px}
172
+ .dcs-grid>*{min-width:0}
173
+ @media (max-width:600px){.dcs-grid{grid-template-columns:minmax(0,1fr)}}
174
+
175
+ .dcs-spin{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-border-l3);
176
+ border-top-color:var(--dsw-alias-label-primary);border-radius:50%;animation:dcs-rot .8s linear infinite}
177
+ @keyframes dcs-rot{to{transform:rotate(360deg)}}
178
+
179
+ /* 浮层用 bg-layer-1(宿主定义的「主要抬升表面」),配软投影。
180
+ shell.overlay 这一层本身是 pointer-events:none 的穿透层,条目必须自己开回来,
181
+ 否则面板点不动、点击会落到下面的应用上。 */
182
+ .dcs-overlay{position:fixed;left:12px;bottom:60px;width:460px;max-width:calc(100vw - 24px);min-width:0;max-height:78vh;
183
+ overflow:auto;z-index:60;pointer-events:auto;padding:16px;background:var(--dsw-alias-bg-layer-1);
184
+ border:0.5px solid var(--dsw-alias-border-l2);border-radius:var(--dsw-radius-lg);
185
+ box-shadow:var(--dsw-elevation-soft),0 12px 32px #00000040}
186
+
187
+ /* 侧栏底部按钮:这个槽位是「与设置并排的一行」,所以只能是一个内联胶囊,
188
+ 不能用 width:100%(会挤压「设置」)。 */
189
+ .dcs-side{display:inline-flex;align-items:center;gap:6px;flex:none;box-sizing:border-box;height:28px;
190
+ padding:0 10px;border:0.5px solid var(--dsw-alias-border-l4);border-radius:14px;background:transparent;
191
+ color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:20px;cursor:pointer;white-space:nowrap}
192
+ .dcs-side:hover{background:var(--dsw-alias-interactive-bg-hover)}
193
+ .dcs-side:active{background:var(--dsw-alias-interactive-bg-active)}
194
+ .dcs-side:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:2px}
195
+ .dcs-side-icon{display:inline-flex;width:14px;height:14px;align-items:center;justify-content:center;flex:0 0 auto}
196
+ .dcs-side.compact{justify-content:center;padding:0;width:28px}
197
+ `;
198
+
199
+ /* ================= 小部件 ================= */
200
+ function Segmented({ value, onChange, options, disabled }) {
201
+ const found = options.findIndex((o) => o.value === value);
202
+ const index = Math.max(0, found);
203
+ return e('div', {
204
+ className: 'dcs-seg',
205
+ role: 'tablist',
206
+ style: { '--dcs-n': options.length, '--dcs-i': index },
207
+ },
208
+ // 没选中任何一项时(例如冲突刚出现、还没裁决)不画指示块,
209
+ // 否则看起来像默认已经选了第一项。
210
+ found >= 0 ? e('div', { className: 'dcs-seg-ind', 'aria-hidden': true }) : null,
211
+ options.map((o) => e('button', {
212
+ key: o.value,
213
+ type: 'button',
214
+ role: 'tab',
215
+ 'aria-selected': value === o.value ? 'true' : 'false',
216
+ 'data-on': value === o.value ? '1' : '0',
217
+ disabled: Boolean(disabled),
218
+ onClick: () => onChange(o.value),
219
+ }, o.label)));
220
+ }
221
+
222
+ function Checkbox({ checked, onChange, disabled, children }) {
223
+ return e('label', { className: 'dcs-check' },
224
+ e('input', { type: 'checkbox', checked: Boolean(checked), disabled: Boolean(disabled), onChange: (ev) => onChange(ev.target.checked) }),
225
+ e('span', null, children));
226
+ }
227
+
228
+ function SyncIcon() {
229
+ return e('svg', {
230
+ viewBox: '0 0 16 16', width: 15, height: 15, fill: 'none', stroke: 'currentColor',
231
+ strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
232
+ },
233
+ e('path', { d: 'M4.5 2.5v11M2 5l2.5-2.5L7 5' }),
234
+ e('path', { d: 'M11.5 13.5v-11M9 11l2.5 2.5L14 11' }));
235
+ }
236
+
237
+ const STATE_LABEL = { same: '一致', differs: '与远端不同', 'remote-only': '仅远端', 'local-only': '仅本机' };
238
+
239
+ // 待裁决原因的文案(与宿主端 holdReasonText 保持一致的说法)
240
+ const REASON_LABEL = {
241
+ 'divergent-session-history': '会话双方均有新增内容,无法拼接',
242
+ 'header-mismatch': '会话表头不一致',
243
+ unreadable: '会话日志无法解析',
244
+ 'text-overlap': '文件双方修改同一位置',
245
+ 'binary-both-changed': '二进制文件双方均已修改',
246
+ 'no-common-base': '无共同历史,无法判定改动来源',
247
+ 'delete-local': '本机已删除,远端未修改',
248
+ 'remote-deleted': '远端已删除,本机未修改',
249
+ 'delete-modify': '本机已删除,远端已修改',
250
+ 'modify-delete': '远端已删除,本机已修改',
251
+ };
252
+ const DELETE_REASONS = ['delete-local', 'remote-deleted', 'delete-modify', 'modify-delete'];
253
+ const isDeleteReason = (r) => DELETE_REASONS.includes(r);
254
+ // 删除类的两种选择在不同状态下含义完全不同,所以每个原因给一组自己的说法,
255
+ // 免得「删除 / 保留」在不同场景里被理解成不同的事。
256
+ const DELETE_OPTIONS = {
257
+ 'delete-local': [{ value: 'delete', label: '同步删除到远端' }, { value: 'keep', label: '仅本机删除' }],
258
+ 'delete-modify': [{ value: 'delete', label: '保留删除' }, { value: 'keep', label: '采用远端版本' }],
259
+ 'modify-delete': [{ value: 'delete', label: '接受删除' }, { value: 'keep', label: '保留本机版本' }],
260
+ 'remote-deleted': [{ value: 'delete', label: '本机同时删除' }, { value: 'keep', label: '本机保留' }],
261
+ };
262
+
263
+ function fmtTime(ms) {
264
+ if (!ms) return '';
265
+ const d = new Date(ms);
266
+ const p = (n) => String(n).padStart(2, '0');
267
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
268
+ }
269
+ function fmtSize(n) {
270
+ if (!n) return '0 B';
271
+ const u = ['B', 'KB', 'MB', 'GB'];
272
+ let i = 0; let v = n;
273
+ while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
274
+ return (i ? v.toFixed(1) : String(Math.round(v))) + ' ' + u[i];
275
+ }
276
+
277
+ /* ================= 过程日志 ================= */
278
+ function LogView({ entries }) {
279
+ const ref = useRef(null);
280
+ useEffect(() => {
281
+ const el = ref.current;
282
+ if (el) el.scrollTop = el.scrollHeight;
283
+ }, [entries.length]);
284
+ if (!entries.length) return e('div', { className: 'dcs-log' }, '暂无记录');
285
+ return e('div', { className: 'dcs-log', ref },
286
+ entries.map((en) => e('div', { key: en.n, className: 'l-' + en.level },
287
+ e('span', { className: 't' }, new Date(en.t).toLocaleTimeString()),
288
+ en.text)));
289
+ }
290
+
291
+ /* ================= 冲突裁决 ================= */
292
+ function ConflictCard({ conflict, decisions, onDecide, onPreview, preview }) {
293
+ const choice = decisions[conflict.id];
294
+ const p = preview && preview.id === conflict.id ? preview.data : null;
295
+ const reason = REASON_LABEL[conflict.reason] || conflict.reason;
296
+ const options = isDeleteReason(conflict.reason)
297
+ ? DELETE_OPTIONS[conflict.reason] || [{ value: 'delete', label: '删除' }, { value: 'keep', label: '保留' }]
298
+ : [{ value: 'local', label: '保留本机' }, { value: 'remote', label: '采用远端' }, { value: 'both', label: '两边都留' }];
299
+ return e('div', { className: 'dcs-cf' },
300
+ e('div', { className: 'dcs-cf-h', title: conflict.path }, conflict.title || conflict.path),
301
+ e('div', { className: 'dcs-t2' },
302
+ `${conflict.repo === 'main' ? '主数据' : '工作区'} · ${reason} · ${conflict.path}`),
303
+ isDeleteReason(conflict.reason) ? e('div', { className: 'dcs-note' }, '删除类裁决的两种选择分别对应「删除」与「保持现状」,建议先查看两侧内容。') : null,
304
+ e('div', { className: 'dcs-row', style: { marginTop: 8 } },
305
+ e(Segmented, {
306
+ value: choice || '',
307
+ onChange: (v) => onDecide(conflict.id, v),
308
+ options,
309
+ }),
310
+ e('button', { className: 'dcs-btn dcs-btn-outline', type: 'button', onClick: () => onPreview(conflict) },
311
+ p ? '刷新对比' : '查看两侧内容'),
312
+ choice ? e('span', { className: 'dcs-ok' }, '已选') : e('span', { className: 'dcs-note' }, '未选')),
313
+ p && p.ok !== false && p.kind === 'session' ? e('div', { className: 'dcs-grid', style: { marginTop: 8 } },
314
+ e('div', null,
315
+ e('div', { className: 'dcs-lab' }, `本机(${p.local?.events ?? 0} 条事件)`),
316
+ e('div', { className: 'dcs-pre' }, (p.local?.messages || []).slice(-4).map((m) => `${m.role === 'user' ? '你' : '助手'}:${m.text}`).join('\n\n') || p.local?.reason || '(空)')),
317
+ e('div', null,
318
+ e('div', { className: 'dcs-lab' }, `远端(${p.remote?.events ?? 0} 条事件)`),
319
+ e('div', { className: 'dcs-pre' }, (p.remote?.messages || []).slice(-4).map((m) => `${m.role === 'user' ? '你' : '助手'}:${m.text}`).join('\n\n') || p.remote?.reason || '(空)'))) : null,
320
+ p && p.ok !== false && p.kind === 'text' ? e('div', { className: 'dcs-grid', style: { marginTop: 8 } },
321
+ e('div', null,
322
+ e('div', { className: 'dcs-lab' }, '本机'),
323
+ e('div', { className: 'dcs-pre' }, p.local ? (p.local.binary ? `(二进制 ${fmtSize(p.local.bytes)})` : p.local.text) : '(本机无此文件)')),
324
+ e('div', null,
325
+ e('div', { className: 'dcs-lab' }, '远端'),
326
+ e('div', { className: 'dcs-pre' }, p.remote ? (p.remote.binary ? `(二进制 ${fmtSize(p.remote.bytes)})` : p.remote.text) : '(未留存远端版本)'))) : null,
327
+ p && p.ok === false ? e('div', { className: 'dcs-err', style: { marginTop: 6 } }, String(p.error)) : null);
328
+ }
329
+
330
+ /* ================= 主面板 ================= */
331
+ function SyncPanel({ variant, t }) {
332
+ const [status, setStatus] = useState(null);
333
+ const [sessions, setSessions] = useState([]);
334
+ const [workspaces, setWorkspaces] = useState([]);
335
+ const [direction, setDirection] = useState('push');
336
+ const [scope, setScope] = useState('all');
337
+ const [selected, setSelected] = useState([]);
338
+ const [includeWorkspace, setIncludeWorkspace] = useState(false);
339
+ const [showSidebar, setShowSidebar] = useState(sidebarPref.value);
340
+ const [query, setQuery] = useState('');
341
+ const [entries, setEntries] = useState([]);
342
+ const [running, setRunning] = useState(false);
343
+ const [runSummary, setRunSummary] = useState(null);
344
+ const [pending, setPending] = useState([]);
345
+ const [decisions, setDecisions] = useState({});
346
+ const [preview, setPreview] = useState(null);
347
+ const [cfMsg, setCfMsg] = useState(''); // 冲突区的就地反馈:必须出现在用户正在看的地方
348
+ const [busyMsg, setBusyMsg] = useState('');
349
+ const [units, setUnits] = useState([]);
350
+ const [unitFilter, setUnitFilter] = useState('todo');
351
+ const [ignore, setIgnore] = useState({ sessions: [], workspaces: [], workspaceFiles: {} });
352
+ const [wsIgnore, setWsIgnore] = useState(null); // { workspaceId, title, path, exists, content, msg }
353
+ const [cfgForm, setCfgForm] = useState(null);
354
+ const [cfgMsg, setCfgMsg] = useState('');
355
+ const [showCfg, setShowCfg] = useState(false);
356
+ const sinceRef = useRef(0);
357
+ const pendingSeenRef = useRef(-1);
358
+
359
+ const refreshStatus = useCallback(async () => {
360
+ try {
361
+ const s = await jget('status');
362
+ setStatus(s);
363
+ if (s && Array.isArray(s.sessions)) setSessions(s.sessions);
364
+ if (s && Array.isArray(s.workspaces)) setWorkspaces(s.workspaces);
365
+ const pend = Array.isArray(s.pending) ? s.pending : [];
366
+ setPending(pend);
367
+ pendingSeenRef.current = pend.reduce((a, x) => a + (x.conflicts ? x.conflicts.length : 0), 0);
368
+ if (s && typeof s.showSidebarButton === 'boolean') { setShowSidebar(s.showSidebarButton); sidebarPref.set(s.showSidebarButton); }
369
+ if (s && s.current && typeof s.current.running === 'boolean') setRunning(s.current.running);
370
+ if (s && s.lastRun && !s.lastRun.running) setRunSummary(s.lastRun);
371
+ return s;
372
+ } catch (err) {
373
+ setStatus({ ok: false, error: String((err && err.message) || err) });
374
+ return null;
375
+ }
376
+ }, []);
377
+
378
+ const refreshUnits = useCallback(async () => {
379
+ try {
380
+ const r = await jget('units');
381
+ if (!r || r.ok === false) return;
382
+ setUnits(Array.isArray(r.units) ? r.units : []);
383
+ if (r.ignore) setIgnore(r.ignore);
384
+ } catch { /* 下一轮再试 */ }
385
+ }, []);
386
+
387
+ const saveIgnore = useCallback(async (patch) => {
388
+ const r = await jpost('ignore', patch);
389
+ if (r && r.ok) { setIgnore(r.ignore); refreshUnits(); }
390
+ else setBusyMsg('忽略设置保存失败:' + ((r && r.error) || '未知错误'));
391
+ return r;
392
+ }, [refreshUnits]);
393
+
394
+ const toggleIgnoreSession = useCallback(async (id) => {
395
+ const cur = Array.isArray(ignore.sessions) ? ignore.sessions : [];
396
+ const next = cur.includes(id) ? cur.filter((x) => x !== id) : cur.concat([id]);
397
+ await saveIgnore({ sessions: next });
398
+ }, [ignore, saveIgnore]);
399
+
400
+ const toggleIgnoreWorkspace = useCallback(async (id) => {
401
+ const cur = Array.isArray(ignore.workspaces) ? ignore.workspaces : [];
402
+ const next = cur.includes(id) ? cur.filter((x) => x !== id) : cur.concat([id]);
403
+ await saveIgnore({ workspaces: next });
404
+ }, [ignore, saveIgnore]);
405
+
406
+ const forceUnit = useCallback(async (u, mode) => {
407
+ setBusyMsg('');
408
+ const r = await jpost('force', { path: u.path, mode });
409
+ if (r && r.ok) {
410
+ setBusyMsg(mode === 'pull'
411
+ ? `已以远端版本覆盖本机:${u.title || u.path}`
412
+ : `已将本机版本交付至远端:${u.title || u.path}`);
413
+ } else {
414
+ setBusyMsg('强制覆盖失败:' + ((r && r.error) || '未知错误'));
415
+ }
416
+ refreshStatus();
417
+ refreshUnits();
418
+ }, [refreshStatus, refreshUnits]);
419
+
420
+ const openWsIgnore = useCallback(async (ws) => {
421
+ const r = await jget('workspace/ignore?workspaceId=' + encodeURIComponent(ws.id));
422
+ if (!r || r.ok === false) { setBusyMsg('读取忽略文件失败:' + ((r && r.error) || '未知错误')); return; }
423
+ setWsIgnore({ workspaceId: ws.id, title: r.title || ws.title, path: r.path, exists: r.exists, content: r.content || '', msg: '' });
424
+ }, []);
425
+
426
+ const createWsIgnore = useCallback(async (ws) => {
427
+ const r = await jpost('workspace/ignore', { workspaceId: ws.id, create: true });
428
+ if (!r || r.ok === false) { setBusyMsg('创建忽略文件失败:' + ((r && r.error) || '未知错误')); return; }
429
+ setWsIgnore({ workspaceId: ws.id, title: ws.title || ws.id, path: r.path, exists: true, content: r.content || '', msg: '已创建,编辑后保存即可。' });
430
+ }, []);
431
+
432
+ const saveWsIgnore = useCallback(async () => {
433
+ if (!wsIgnore) return;
434
+ const r = await jpost('workspace/ignore', { workspaceId: wsIgnore.workspaceId, content: wsIgnore.content });
435
+ if (!r || r.ok === false) { setWsIgnore({ ...wsIgnore, msg: '保存失败:' + ((r && r.error) || '未知错误') }); return; }
436
+ setWsIgnore({ ...wsIgnore, path: r.path, exists: true, msg: '已保存,下次同步该工作区时生效。' });
437
+ }, [wsIgnore]);
438
+
439
+ const alignNow = useCallback(async () => {
440
+ setBusyMsg('');
441
+ const r = await jpost('align', {});
442
+ if (r && r.ok) {
443
+ setBusyMsg(r.upToDate
444
+ ? (r.ahead ? `本机已包含远端全部内容(领先 ${r.ahead} 个提交,均为本机此前交付的记录)。` : '本机与远端一致。')
445
+ : '已快进至远端。');
446
+ } else {
447
+ setBusyMsg('重新对齐失败:' + ((r && r.error) || '未知错误'));
448
+ }
449
+ refreshStatus();
450
+ refreshUnits();
451
+ }, [refreshStatus, refreshUnits]);
452
+
453
+ const refreshLight = useCallback(async () => {
454
+ try {
455
+ const s = await jget('ping');
456
+ if (!s || s.ok === false) return;
457
+ setRunning(Boolean(s.running));
458
+ if (s.lastRun && !s.lastRun.running) setRunSummary(s.lastRun);
459
+ if (typeof s.showSidebarButton === 'boolean') { setShowSidebar(s.showSidebarButton); sidebarPref.set(s.showSidebarButton); }
460
+ if (typeof s.pendingCount === 'number') {
461
+ if (s.pendingCount !== pendingSeenRef.current) {
462
+ pendingSeenRef.current = s.pendingCount;
463
+ if (s.pendingCount > 0) refreshStatus();
464
+ }
465
+ }
466
+ } catch { /* 下一轮再试 */ }
467
+ }, [refreshStatus]);
468
+
469
+ // 打开时拉一次全量;空闲只走轻量心跳(运行中由 progress 轮询驱动)
470
+ useEffect(() => {
471
+ refreshStatus();
472
+ refreshUnits();
473
+ const id = setInterval(() => { if (!running) refreshLight(); }, 5000);
474
+ return () => clearInterval(id);
475
+ }, [refreshStatus, refreshLight, refreshUnits, running]);
476
+
477
+ // 每次同步结束、以及初次挂载后,重算逐单元状态
478
+ useEffect(() => { if (!running) { refreshUnits(); refreshStatus(); } }, [running, refreshUnits, refreshStatus]);
479
+
480
+ const pullProgress = useCallback(async () => {
481
+ try {
482
+ const r = await jget('progress?since=' + sinceRef.current);
483
+ if (!r || r.ok === false) return;
484
+ if (r.entries && r.entries.length) {
485
+ sinceRef.current = r.next;
486
+ setEntries((prev) => prev.concat(r.entries));
487
+ }
488
+ if (typeof r.running === 'boolean') setRunning(r.running);
489
+ if (r.summary) setRunSummary(r.summary);
490
+ } catch { /* 轮询失败下一轮再试 */ }
491
+ }, []);
492
+
493
+ useEffect(() => {
494
+ if (!running) return undefined;
495
+ pullProgress();
496
+ const id = setInterval(pullProgress, 500);
497
+ return () => clearInterval(id);
498
+ }, [running, pullProgress]);
499
+
500
+ const startRun = useCallback(async (dir) => {
501
+ setBusyMsg('');
502
+ setRunSummary(null);
503
+ setEntries([]);
504
+ sinceRef.current = 0;
505
+ if (scope === 'session' && !selected.length) { setBusyMsg('请先选择会话,或将范围改为「全部会话」。'); return; }
506
+ const r = await jpost('run', {
507
+ direction: dir,
508
+ scope,
509
+ sessionIds: scope === 'session' ? selected : [],
510
+ includeWorkspace,
511
+ reason: 'web',
512
+ });
513
+ if (!r || r.ok === false) { setBusyMsg((r && r.error) || '无法开始同步。'); return; }
514
+ setRunning(true);
515
+ setEntries([{ n: 0, t: Date.now(), level: 'step', text: `开始:${dir === 'push' ? '推送' : '取回'}(${scope === 'all' ? '全部会话' : '所选会话'})` }]);
516
+ }, [scope, selected, includeWorkspace]);
517
+
518
+ const saveConfig = useCallback(async () => {
519
+ setCfgMsg('');
520
+ const r = await jpost('config', {
521
+ remote: cfgForm.remote,
522
+ branch: cfgForm.branch,
523
+ proxy: cfgForm.proxy,
524
+ autoRepo: cfgForm.autoRepo,
525
+ repoName: cfgForm.repoName,
526
+ workspaceBase: cfgForm.workspaceBase || '',
527
+ showSidebarButton: showSidebar,
528
+ });
529
+ if (r && r.ok) { setCfgMsg('已保存到 ' + r.configPath); sidebarPref.set(showSidebar); refreshStatus(); }
530
+ else setCfgMsg('保存失败:' + ((r && r.error) || '未知错误'));
531
+ }, [cfgForm, showSidebar, refreshStatus]);
532
+
533
+ const toggleSidebar = useCallback(async (next) => {
534
+ setShowSidebar(next);
535
+ sidebarPref.set(next);
536
+ const r = await jpost('config', { showSidebarButton: next });
537
+ if (!r || r.ok === false) {
538
+ setBusyMsg('侧栏按钮设置保存失败:' + ((r && r.error) || '未知错误'));
539
+ sidebarPref.set(!next);
540
+ setShowSidebar(!next);
541
+ }
542
+ }, []);
543
+
544
+ useEffect(() => {
545
+ if (cfgForm || !status) return;
546
+ setCfgForm({
547
+ remote: status.remote || '',
548
+ branch: status.branch || 'main',
549
+ proxy: status.proxy || '',
550
+ autoRepo: status.autoRepo !== false,
551
+ repoName: status.repoName || 'dsh-sync',
552
+ workspaceBase: status.workspaceBase || '',
553
+ });
554
+ }, [status, cfgForm]);
555
+
556
+ const openPreview = useCallback(async (conflict) => {
557
+ const owner = pending.find((p) => p.conflicts.some((c) => c.id === conflict.id));
558
+ if (!owner) return;
559
+ const r = await jpost('pending/preview', { pendingId: owner.id, conflictId: conflict.id });
560
+ setPreview({ id: conflict.id, data: r });
561
+ }, [pending]);
562
+
563
+ const applyDecisions = useCallback(async () => {
564
+ setCfMsg('');
565
+ const chosen = pending.flatMap((p) => p.conflicts.filter((c) => decisions[c.id]));
566
+ if (!chosen.length) {
567
+ setCfMsg('请先选择处理方式,或点击「其余全部保留本机」。');
568
+ return;
569
+ }
570
+ let applied = 0;
571
+ let remaining = 0;
572
+ for (const p of pending) {
573
+ const ds = p.conflicts.filter((c) => decisions[c.id]).map((c) => ({ id: c.id, choice: decisions[c.id] }));
574
+ if (!ds.length) continue;
575
+ const r = await jpost('pending/resolve', { pendingId: p.id, decisions: ds });
576
+ if (!r || r.ok === false) {
577
+ setCfMsg('裁决失败:' + ((r && r.error) || '未知错误'));
578
+ refreshStatus();
579
+ refreshUnits();
580
+ return;
581
+ }
582
+ applied += ds.length;
583
+ remaining += Number(r.remaining) || 0;
584
+ }
585
+ setDecisions({});
586
+ setPreview(null);
587
+ setCfMsg(remaining
588
+ ? `已应用 ${applied} 项,剩余 ${remaining} 项待裁决。`
589
+ : `已应用 ${applied} 项,全部处理完毕。`);
590
+ refreshStatus();
591
+ refreshUnits();
592
+ }, [pending, decisions, refreshStatus]);
593
+
594
+ const discardPending = useCallback(async () => {
595
+ for (const p of pending) await jpost('pending/discard', { pendingId: p.id });
596
+ setDecisions({});
597
+ setPreview(null);
598
+ setCfMsg('已放弃本次待裁决记录(本机文件未改动)。');
599
+ refreshStatus();
600
+ refreshUnits();
601
+ }, [pending, refreshStatus]);
602
+
603
+ const filtered = useMemo(() => {
604
+ const q = query.trim().toLowerCase();
605
+ if (!q) return sessions;
606
+ return sessions.filter((s) => (s.title || '').toLowerCase().includes(q)
607
+ || (s.cwd || '').toLowerCase().includes(q)
608
+ || (s.id || '').toLowerCase().includes(q));
609
+ }, [sessions, query]);
610
+
611
+ const wsName = useCallback((id) => {
612
+ const w = workspaces.find((x) => x.id === id);
613
+ return w ? w.title : (id ? id.slice(0, 8) : '未分组');
614
+ }, [workspaces]);
615
+
616
+ const targetWorkspaces = useMemo(() => {
617
+ if (scope === 'all') return workspaces;
618
+ const owner = new Map(sessions.map((s) => [s.id, s.workspaceId]));
619
+ const wanted = new Set(selected.map((id) => owner.get(id)).filter(Boolean));
620
+ return workspaces.filter((w) => wanted.has(w.id));
621
+ }, [scope, selected, sessions, workspaces]);
622
+
623
+ const filteredUnits = useMemo(
624
+ () => (unitFilter === 'all' ? units : units.filter((u) => u.state !== 'A1')),
625
+ [units, unitFilter],
626
+ );
627
+ const decisionCount = units.filter((u) => u.needsDecision).length;
628
+ const diffCount = units.filter((u) => u.state !== 'A1' && u.state !== 'IGNORED').length;
629
+
630
+ const conflictCount = pending.reduce((a, p) => a + p.conflicts.length, 0);
631
+ const chosenCount = pending.reduce((a, p) => a + p.conflicts.filter((c) => decisions[c.id]).length, 0);
632
+ const repo = status && status.repo;
633
+ const res = (runSummary && runSummary.result) || null;
634
+
635
+ return e('div', { className: variant === 'overlay' ? 'dcs-root dcs-overlay' : 'dcs-root' },
636
+ e('style', null, CSS),
637
+
638
+ // —— 页头 ——
639
+ e('div', { className: 'dcs-head' },
640
+ e('div', { className: 'dcs-h' }, t('title')),
641
+ variant === 'overlay'
642
+ ? e('button', { className: 'dcs-btn dcs-btn-ghost', type: 'button', onClick: () => overlay.set(false) }, '关闭')
643
+ : null),
644
+
645
+ // —— 状态卡 ——
646
+ e('div', { className: 'dcs-card' },
647
+ e('dl', { className: 'dcs-kv' },
648
+ e('dt', null, '远端'), e('dd', { title: (status && status.remote) || '' }, (status && status.remote) || '(未配置,首次推送时自动创建私有仓库)'),
649
+ e('dt', null, '分支'), e('dd', null, (status && status.branch) || 'main'),
650
+ e('dt', null, '本地'), e('dd', null, repo && repo.exists
651
+ ? `${repo.head || '(无提交)'}${repo.remoteHead ? ` · 领先 ${repo.ahead} / 落后 ${repo.behind}` : ''} · 未提交 ${repo.dirty} 项`
652
+ : '本地仓库尚未初始化'),
653
+ e('dt', null, '数据'), e('dd', null, `本机会话 ${(status && status.localSessionCount) || 0} · 远端会话 ${(status && status.remoteSessionCount) || 0} · 工作区 ${workspaces.length}`),
654
+ e('dt', null, '环境'), e('dd', null, `${status && status.gitMissing ? '找不到 git' : 'git 正常'} · ${status && status.ghAvailable ? 'gh 已登录' : 'gh 不可用(跳过自动建仓与私有性校验)'}`),
655
+ e('dt', null, '与远端'), e('dd', null, `领先 ${(repo && repo.ahead) || 0} / 落后 ${(repo && repo.behind) || 0};本机只会落后、不会分叉,落后项包含本机此前交付的提交`),
656
+ e('dt', null, '单元'), e('dd', null, diffCount ? `${diffCount} 个条目与远端不同,其中 ${decisionCount} 个待裁决` : '所有条目均与远端一致')),
657
+ e('div', { className: 'dcs-row', style: { marginTop: 10 } },
658
+ e('button', { className: 'dcs-btn dcs-btn-outline', type: 'button', onClick: () => setShowCfg((v) => !v) }, showCfg ? '收起' : '连接设置'),
659
+ e('button', { className: 'dcs-btn dcs-btn-outline', type: 'button', onClick: refreshStatus }, '刷新状态'),
660
+ e('button', { className: 'dcs-btn dcs-btn-outline', type: 'button', disabled: running, title: '仅执行快进,不产生合并提交', onClick: alignNow }, '重新对齐(快进)')),
661
+ showCfg && cfgForm ? e('div', { style: { marginTop: 12 } },
662
+ e('div', { className: 'dcs-sec' },
663
+ e('div', { className: 'dcs-lab' }, '仓库地址'),
664
+ e('input', { className: 'dcs-in', value: cfgForm.remote, placeholder: 'https://github.com/你的账号/dsh-sync', onChange: (ev) => setCfgForm({ ...cfgForm, remote: ev.target.value }) })),
665
+ e('div', { className: 'dcs-grid', style: { marginTop: 8 } },
666
+ e('div', { className: 'dcs-sec' },
667
+ e('div', { className: 'dcs-lab' }, '分支'),
668
+ e('input', { className: 'dcs-in', value: cfgForm.branch, onChange: (ev) => setCfgForm({ ...cfgForm, branch: ev.target.value }) })),
669
+ e('div', { className: 'dcs-sec' },
670
+ e('div', { className: 'dcs-lab' }, '自动建仓的仓库名'),
671
+ e('input', { className: 'dcs-in', value: cfgForm.repoName, onChange: (ev) => setCfgForm({ ...cfgForm, repoName: ev.target.value }) }))),
672
+ e('div', { className: 'dcs-sec', style: { marginTop: 8 } },
673
+ e('div', { className: 'dcs-lab' }, 'Git 代理(可选)'),
674
+ e('input', { className: 'dcs-in', value: cfgForm.proxy, placeholder: 'http://127.0.0.1:7890', onChange: (ev) => setCfgForm({ ...cfgForm, proxy: ev.target.value }) })),
675
+ e('div', { className: 'dcs-sec', style: { marginTop: 8 } },
676
+ e('div', { className: 'dcs-lab' }, '工作区统一目录(可选)'),
677
+ e('input', { className: 'dcs-in', value: cfgForm.workspaceBase || '', placeholder: '留空则按登记表路径;必要时按本机主目录改写', onChange: (ev) => setCfgForm({ ...cfgForm, workspaceBase: ev.target.value }) })),
678
+ e('div', { style: { marginTop: 10 } },
679
+ e(Checkbox, { checked: cfgForm.autoRepo, onChange: (v) => setCfgForm({ ...cfgForm, autoRepo: v }) }, '远端为空时允许 gh 自动创建或复用私有仓库')),
680
+ e('div', { className: 'dcs-row', style: { marginTop: 12 } },
681
+ e('button', { className: 'dcs-btn dcs-btn-primary', type: 'button', onClick: saveConfig }, '保存连接设置')),
682
+ cfgMsg ? e('div', { className: cfgMsg.startsWith('已保存') ? 'dcs-ok' : 'dcs-err', style: { marginTop: 8 } }, cfgMsg) : null) : null),
683
+
684
+ // —— 操作 ——
685
+ e('div', { className: 'dcs-sec' },
686
+ e('div', { className: 'dcs-lab' }, '操作'),
687
+ e(Segmented, {
688
+ value: direction,
689
+ disabled: running,
690
+ onChange: setDirection,
691
+ options: [
692
+ { value: 'push', label: '推送(本机 → 远端)' },
693
+ { value: 'pull', label: '取回(远端 → 本机)' },
694
+ ],
695
+ }),
696
+ e('div', { className: 'dcs-note' },
697
+ direction === 'push'
698
+ ? '将本机改动提交并上传至 GitHub 私有仓库。'
699
+ : '将远端内容取回本机;双方均已修改且无法自动合并的条目需人工裁决。')),
700
+
701
+ // —— 范围 ——
702
+ e('div', { className: 'dcs-sec' },
703
+ e('div', { className: 'dcs-lab' }, '范围'),
704
+ e(Segmented, {
705
+ value: scope,
706
+ disabled: running,
707
+ onChange: (v) => { setScope(v); if (v === 'all') setSelected([]); },
708
+ options: [
709
+ { value: 'all', label: '全部会话' },
710
+ { value: 'session', label: '单个会话' },
711
+ ],
712
+ }),
713
+ scope === 'session' ? e('div', { className: 'dcs-sec', style: { marginTop: 4 } },
714
+ e('input', {
715
+ className: 'dcs-in',
716
+ placeholder: '搜索标题 / 目录 / ID',
717
+ value: query,
718
+ onChange: (ev) => setQuery(ev.target.value),
719
+ }),
720
+ e('div', { className: 'dcs-list' },
721
+ filtered.length === 0
722
+ ? e('div', { className: 'dcs-item', 'data-static': '1' }, e('span', { className: 'dcs-note' }, '无匹配会话'))
723
+ : filtered.map((s) => {
724
+ const on = selected.includes(s.id);
725
+ return e('label', { key: s.id, className: 'dcs-item', 'data-on': on ? '1' : '0' },
726
+ e('input', { type: 'radio', name: 'dcs-session', checked: on, onChange: () => setSelected([s.id]) }),
727
+ e('div', { className: 'dcs-txt' },
728
+ e('div', { className: 'dcs-t1' }, s.title || '(无标题会话)'),
729
+ e('div', { className: 'dcs-t2' }, `${wsName(s.workspaceId)} · ${fmtTime(s.mtime)} · ${fmtSize(s.size)}`)),
730
+ e('span', { className: 'dcs-pill is-' + s.state }, STATE_LABEL[s.state] || s.state));
731
+ }))) : null),
732
+
733
+ // —— 逐单元状态 ——
734
+ e('div', { className: 'dcs-sec' },
735
+ e('div', { className: 'dcs-row' },
736
+ e('div', { className: 'dcs-lab' }, '条目状态'),
737
+ e('div', { className: 'dcs-spread' }),
738
+ e(Segmented, {
739
+ value: unitFilter,
740
+ onChange: setUnitFilter,
741
+ options: [
742
+ { value: 'todo', label: '未同步' },
743
+ { value: 'all', label: '全部' },
744
+ ],
745
+ })),
746
+ filteredUnits.length === 0
747
+ ? e('div', { className: 'dcs-note' }, units.length ? '所有条目均与远端一致。' : '暂无可比较数据,请先执行一次推送以建立基线。')
748
+ : e('div', { className: 'dcs-list', style: { maxHeight: 220 } },
749
+ filteredUnits.map((u) => e('div', { key: u.path, className: 'dcs-item', 'data-static': '1' },
750
+ e('div', { className: 'dcs-txt' },
751
+ e('div', { className: 'dcs-t1' }, u.title || u.path.split('/').pop()),
752
+ e('div', { className: 'dcs-t2', title: u.path }, `${u.stateLabel} · ${u.path}`)),
753
+ u.sessionId ? e('button', {
754
+ className: 'dcs-btn dcs-btn-outline',
755
+ type: 'button',
756
+ disabled: running,
757
+ title: u.muted ? '取消忽略,重新纳入同步' : '忽略:不同步,远端保持不变',
758
+ onClick: () => toggleIgnoreSession(u.sessionId),
759
+ }, u.muted ? '取消忽略' : '忽略') : null,
760
+ e('button', {
761
+ className: 'dcs-btn dcs-btn-outline',
762
+ type: 'button',
763
+ disabled: running || u.state === 'A1',
764
+ title: '无条件以远端版本覆盖本机',
765
+ onClick: () => forceUnit(u, 'pull'),
766
+ }, '用远端覆盖'),
767
+ e('button', {
768
+ className: 'dcs-btn dcs-btn-outline',
769
+ type: 'button',
770
+ disabled: running || u.state === 'A1',
771
+ title: '无条件以本机版本覆盖远端',
772
+ onClick: () => forceUnit(u, 'push'),
773
+ }, '用本机覆盖'),
774
+ e('span', { className: 'dcs-pill' + (u.needsDecision ? ' is-differs' : '') }, u.stateLabel)))),
775
+ e('div', { className: 'dcs-note' },
776
+ '被忽略的会话不参与同步,其落后状态不影响其他条目交付。'
777
+ + '「用远端覆盖 / 用本机覆盖」跳过合并判断,无条件采用所选一侧。')),
778
+
779
+ // —— 工作区 ——
780
+ e('div', { className: 'dcs-sec' },
781
+ e(Checkbox, { checked: includeWorkspace, disabled: running, onChange: setIncludeWorkspace }, '同时同步工作区里的项目文件'),
782
+ e('div', { className: 'dcs-note' },
783
+ includeWorkspace
784
+ ? `涉及 ${targetWorkspaces.length} 个工作区(node_modules、.git 等已排除)`
785
+ : '仅同步会话与设置。'),
786
+ includeWorkspace && targetWorkspaces.length ? e('div', { className: 'dcs-list', style: { maxHeight: 180 } },
787
+ targetWorkspaces.map((w) => e('div', { key: w.id, className: 'dcs-item', 'data-static': '1' },
788
+ e('div', { className: 'dcs-txt' },
789
+ e('div', { className: 'dcs-t1' }, w.title || w.id),
790
+ e('div', { className: 'dcs-t2', title: w.path || '' },
791
+ (w.path || '(无路径)')
792
+ + (w.pathSource === 'override' ? ' · 本机指定' : w.pathSource === 'remapped' ? ' · 按本机主目录改写' : w.pathSource === 'base' ? ' · 统一目录' : '')
793
+ + (w.exists ? '' : ' · 本机不存在,取回时创建'))),
794
+ e('button', {
795
+ className: 'dcs-btn dcs-btn-outline',
796
+ type: 'button',
797
+ disabled: running,
798
+ title: '编辑工作区根目录下的 .dsh-chat-sync-ignore(本机专属,不参与同步)',
799
+ onClick: () => openWsIgnore(w),
800
+ }, '忽略规则'),
801
+ e('button', {
802
+ className: 'dcs-btn dcs-btn-outline',
803
+ type: 'button',
804
+ disabled: running,
805
+ title: (ignore.workspaces || []).includes(w.id) ? '取消忽略此工作区' : '忽略此工作区(不参与同步)',
806
+ onClick: () => toggleIgnoreWorkspace(w.id),
807
+ }, (ignore.workspaces || []).includes(w.id) ? '取消忽略' : '忽略'),
808
+ e('button', {
809
+ className: 'dcs-btn dcs-btn-outline',
810
+ type: 'button',
811
+ disabled: running,
812
+ onClick: () => {
813
+ const next = window.prompt(`把工作区「${w.title || w.id}」在本机的目录改成:\n(留空表示恢复默认)`, w.path || '');
814
+ if (next === null) return;
815
+ jpost('workspace/path', { workspaceId: w.id, path: next }).then((r) => {
816
+ setBusyMsg(r && r.ok ? '' : '更新路径失败:' + ((r && r.error) || '未知错误'));
817
+ refreshStatus();
818
+ });
819
+ },
820
+ }, '更改路径')))) : null),
821
+
822
+ // —— 工作区忽略规则编辑器 ——
823
+ wsIgnore ? e('div', { className: 'dcs-card' },
824
+ e('div', { className: 'dcs-row' },
825
+ e('div', { className: 'dcs-lab' }, `「${wsIgnore.title}」的忽略规则`),
826
+ e('div', { className: 'dcs-spread' }),
827
+ e('button', { className: 'dcs-btn dcs-btn-ghost', type: 'button', onClick: () => setWsIgnore(null) }, '关闭')),
828
+ e('div', { className: 'dcs-note', style: { marginTop: 4 } },
829
+ wsIgnore.exists ? `文件:${wsIgnore.path}(每行一条,gitignore 语法;本机专属,不参与同步)` : `文件不存在:${wsIgnore.path}`),
830
+ wsIgnore.exists
831
+ ? e('textarea', {
832
+ className: 'dcs-in',
833
+ style: { height: 150, padding: 8, marginTop: 8, fontFamily: 'ui-monospace,Menlo,Consolas,monospace', fontSize: 11.5, lineHeight: 1.6, resize: 'vertical' },
834
+ value: wsIgnore.content,
835
+ spellCheck: false,
836
+ onChange: (ev) => setWsIgnore({ ...wsIgnore, content: ev.target.value }),
837
+ })
838
+ : e('div', { className: 'dcs-note', style: { marginTop: 8 } }, '点击「创建忽略文件」将在工作区根目录生成带说明的模板,之后可自行编辑。'),
839
+ e('div', { className: 'dcs-row', style: { marginTop: 8 } },
840
+ wsIgnore.exists
841
+ ? e('button', { className: 'dcs-btn dcs-btn-primary', type: 'button', disabled: running, onClick: saveWsIgnore }, '保存忽略规则')
842
+ : e('button', { className: 'dcs-btn dcs-btn-primary', type: 'button', disabled: running, onClick: () => createWsIgnore({ id: wsIgnore.workspaceId, title: wsIgnore.title }) }, '创建忽略文件'),
843
+ e('button', { className: 'dcs-btn dcs-btn-outline', type: 'button', disabled: running, onClick: () => openWsIgnore({ id: wsIgnore.workspaceId, title: wsIgnore.title }) }, '重新读取')),
844
+ wsIgnore.msg ? e('div', { className: wsIgnore.msg.startsWith('已') ? 'dcs-ok' : 'dcs-err', style: { marginTop: 6 } }, wsIgnore.msg) : null) : null,
845
+
846
+ // —— 侧栏按钮 ——
847
+ e('div', { className: 'dcs-sec' },
848
+ e(Checkbox, { checked: showSidebar, disabled: running, onChange: toggleSidebar }, '在侧栏左下角显示同步按钮'),
849
+ e('div', { className: 'dcs-note' }, '关闭后仅保留「设置 → 会话同步」入口。')),
850
+
851
+ // —— 开始 ——
852
+ e('div', { className: 'dcs-row' },
853
+ e('button', {
854
+ className: 'dcs-btn dcs-btn-primary dcs-btn-md',
855
+ type: 'button',
856
+ disabled: running,
857
+ onClick: () => startRun(direction),
858
+ }, running ? '同步中…' : (direction === 'push' ? '开始推送' : '开始取回')),
859
+ running ? e('span', { className: 'dcs-spin' }) : null,
860
+ runSummary && !running
861
+ ? e('span', { className: runSummary.error ? 'dcs-err' : 'dcs-ok' }, runSummary.error ? ('未完成:' + runSummary.error) : '完成')
862
+ : null),
863
+ busyMsg ? e('div', { className: 'dcs-err' }, busyMsg) : null,
864
+
865
+ // —— 过程 ——
866
+ e('div', { className: 'dcs-sec' },
867
+ e('div', { className: 'dcs-lab' }, `过程${running ? '(进行中)' : entries.length ? '' : '(未执行)'}`),
868
+ e(LogView, { entries })),
869
+
870
+ // —— 上次结果 ——
871
+ runSummary && !running ? e('div', { className: 'dcs-card' },
872
+ e('div', { className: 'dcs-lab' },
873
+ `上次操作:${runSummary.direction === 'push' ? '推送' : runSummary.direction === 'pull' ? '取回' : runSummary.direction} · ${runSummary.scope === 'all' ? '全部会话' : runSummary.scope === 'session' ? '单个会话' : runSummary.scope} · ${Math.round((runSummary.durationMs || 0) / 1000)}s`),
874
+ e('div', { className: runSummary.error ? 'dcs-err' : 'dcs-ok', style: { marginTop: 4 } }, runSummary.error || '成功'),
875
+ res && res.applied && res.applied.length
876
+ ? e('div', { className: 'dcs-note', style: { marginTop: 4 } }, `写入 ${res.applied.length} 个文件;自动合并 ${(res.merged || []).length} 个`)
877
+ : null,
878
+ res && res.pushed && res.pushed.length
879
+ ? e('div', { className: 'dcs-note', style: { marginTop: 4 } }, '已推送至远端。')
880
+ : null,
881
+ res && (res.applied || []).some((x) => x.added)
882
+ ? e('div', { className: 'dcs-warn', style: { marginTop: 4 } }, '本次取回包含新会话,需刷新 DSH 后才会出现在侧栏(必要时重启)。')
883
+ : null) : null,
884
+
885
+ // —— 冲突裁决 ——
886
+ conflictCount ? e('div', { className: 'dcs-card' },
887
+ e('div', { className: 'dcs-row' },
888
+ e('div', { className: 'dcs-lab' }, `待裁决冲突(${conflictCount} 项)`),
889
+ e('div', { className: 'dcs-spread' }),
890
+ e('span', { className: chosenCount ? 'dcs-pill is-differs' : 'dcs-pill' }, `已选 ${chosenCount} / ${conflictCount}`)),
891
+ e('div', { className: 'dcs-note', style: { marginBottom: 10 } },
892
+ '以下条目双方均已修改,自动合并会丢失内容,故暂停于此。本机文件未改动。可分批裁决。'),
893
+ pending.flatMap((p) => p.conflicts.map((c) => e(ConflictCard, {
894
+ key: c.id,
895
+ conflict: c,
896
+ decisions,
897
+ onDecide: (id, v) => setDecisions((prev) => ({ ...prev, [id]: v })),
898
+ onPreview: openPreview,
899
+ preview,
900
+ }))),
901
+ e('div', { className: 'dcs-row', style: { marginTop: 12 } },
902
+ e('button', {
903
+ className: 'dcs-btn dcs-btn-primary',
904
+ type: 'button',
905
+ disabled: running || !chosenCount,
906
+ onClick: applyDecisions,
907
+ }, chosenCount ? `应用所选 ${chosenCount} 项` : '应用所选项目'),
908
+ e('button', {
909
+ className: 'dcs-btn dcs-btn-outline',
910
+ type: 'button',
911
+ disabled: running,
912
+ onClick: () => {
913
+ const all = {};
914
+ for (const p of pending) for (const c of p.conflicts) all[c.id] = 'local';
915
+ setDecisions(all);
916
+ setCfMsg('已将全部冲突标记为「保留本机」,请点击「应用所选项目」。');
917
+ },
918
+ }, '其余全部保留本机'),
919
+ e('button', { className: 'dcs-btn dcs-btn-danger', type: 'button', disabled: running, onClick: discardPending }, '放弃本次裁决记录')),
920
+ cfMsg ? e('div', { className: cfMsg.startsWith('已应用') || cfMsg.startsWith('已把') ? 'dcs-ok' : 'dcs-err', style: { marginTop: 8 } }, cfMsg) : null,
921
+ e('div', { className: 'dcs-note', style: { marginTop: 8 } },
922
+ '「两边都留」:会话将把远端版本导入为新会话(不覆盖本机原会话);其他文件另存为 <文件名>.remote-<时间>。')) : null,
923
+
924
+ status && !status.remote && !(repo && repo.remoteHead)
925
+ ? e('div', { className: 'dcs-note' }, '尚未配置远端仓库。点击「开始推送」将通过 gh 自动创建或复用私有仓库。')
926
+ : null);
927
+ }
928
+
929
+ /* ================= 侧栏按钮 ================= */
930
+ function SidebarButton(props) {
931
+ const { t } = props;
932
+ const open = useStore(overlay);
933
+ const visible = useStore(sidebarPref);
934
+ const [running, setRunning] = useState(false);
935
+ useEffect(() => {
936
+ let alive = true;
937
+ const tick = async () => {
938
+ try {
939
+ const s = await jget('ping');
940
+ if (!alive) return;
941
+ setRunning(Boolean(s && s.running));
942
+ if (s && typeof s.showSidebarButton === 'boolean') sidebarPref.set(s.showSidebarButton);
943
+ } catch { /* ignore */ }
944
+ };
945
+ tick();
946
+ const id = setInterval(tick, running ? 1500 : 6000);
947
+ return () => { alive = false; clearInterval(id); };
948
+ }, [running]);
949
+ if (!visible) return null;
950
+ const wide = props.wide !== false;
951
+ return e('button', {
952
+ className: wide ? 'dcs-side' : 'dcs-side compact',
953
+ type: 'button',
954
+ title: t('openPanel'),
955
+ 'aria-expanded': open ? 'true' : 'false',
956
+ onClick: () => overlay.set(!open),
957
+ },
958
+ e('span', { className: 'dcs-side-icon' }, running ? e('span', { className: 'dcs-spin' }) : e(SyncIcon, null)),
959
+ wide ? e('span', null, running ? t('running') : t('title')) : null);
960
+ }
961
+
962
+ function OverlayHost(props) {
963
+ const open = useStore(overlay);
964
+ if (!open) return null;
965
+ return e(SyncPanel, { variant: 'overlay', t: props.t });
966
+ }
967
+
968
+ function SettingsSection(props) {
969
+ return e(SyncPanel, { variant: 'page', t: props.t });
970
+ }
971
+
972
+ /* ================= locale ================= */
973
+ const DICT = {
974
+ zh: { title: '会话同步', openPanel: '打开会话同步面板', running: '同步中…' },
975
+ en: { title: 'Chat Sync', openPanel: 'Open the chat sync panel', running: 'Syncing…' },
976
+ };
977
+
978
+ return {
979
+ name: 'dsh-chat-sync',
980
+ inject: ['slots', 'locale'],
981
+ apply(ctx) {
982
+ let t = (k) => k;
983
+ try {
984
+ const off1 = ctx.locale.register(NS, 'zh', DICT.zh);
985
+ const off2 = ctx.locale.register(NS, 'en', DICT.en);
986
+ t = ctx.locale.bind(NS);
987
+ ctx.effect(() => () => { try { off1(); } catch { /* ignore */ } try { off2(); } catch { /* ignore */ } });
988
+ } catch { /* 词典注册失败就退回 key 本身 */ }
989
+
990
+ // 先问一次宿主:侧栏按钮要不要显示(默认显示)
991
+ jget('ping').then((s) => {
992
+ if (s && typeof s.showSidebarButton === 'boolean') sidebarPref.set(s.showSidebarButton);
993
+ }).catch(() => {});
994
+
995
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
996
+ name: 'sidebar.footer.action',
997
+ id: 'dsh-chat-sync-button',
998
+ order: 30,
999
+ label: () => t('title'),
1000
+ }, (props) => e(SidebarButton, { ...props, t })));
1001
+
1002
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
1003
+ name: 'shell.overlay',
1004
+ id: 'dsh-chat-sync-overlay',
1005
+ order: 200,
1006
+ }, (props) => e(OverlayHost, { ...props, t })));
1007
+
1008
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
1009
+ name: 'settings.section',
1010
+ id: 'dsh-chat-sync-section',
1011
+ order: 410,
1012
+ label: () => t('title'),
1013
+ }, (props) => e(SettingsSection, { ...props, t })));
1014
+ },
1015
+ };
1016
+ },
1017
+ });