source-code-mgmt 1.1.1 → 1.8.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.
Files changed (4) hide show
  1. package/README.md +103 -22
  2. package/lib/client.js +969 -132
  3. package/lib/index.js +706 -59
  4. package/package.json +23 -7
package/lib/client.js CHANGED
@@ -1,10 +1,22 @@
1
1
  /**
2
2
  * source-code-mgmt — browser half (client.js).
3
3
  *
4
- * Classic-script plugin bundle (no build step): registers into the sidebar
5
- * footer via the official `sidebar.footer.action` list slot with a negative
6
- * order so the trigger renders at the bottom of the left rail (above other
7
- * footer entries). Clicking the trigger opens a "源代码管理" panel with:
4
+ * Classic-script plugin bundle (no build step). On activation it detects
5
+ * whether dsh-better-sidebar is installed (a single in-memory `ctx.get`,
6
+ * zero I/O does not slow DSH startup):
7
+ *
8
+ * - branch A: dsh-better-sidebar installed → register「代码管理」as a new
9
+ * sidebar Tab page through `ctx.betterSidebar.registerTab`.
10
+ * - branch B: not installed → register a「代码管理」button into the DSH
11
+ * `conversation.session.header.utilities` slot (the right-aligned list that
12
+ * holds "Session log"), so it sits beside "Session log" with the same pill
13
+ * look and 8px gap; clicking opens a RIGHT-side integrated panel (pushing
14
+ * #root left, dsh-better-sidebar style) holding the existing panel.
15
+ * If the `slots` service is unavailable, it falls back to a floating
16
+ * top-right button opening the same right-side panel.
17
+ *
18
+ * The old left-rail bottom button (`sidebar.footer.action` slot) is removed.
19
+ * Either way clicking the entry opens a "源代码管理" panel with:
8
20
  *
9
21
  * 1. 环境检查 — git / gh presence & version
10
22
  * 2. SSH — key presence, ed25519 generation, github.com ssh config, test
@@ -63,20 +75,60 @@ window.__ModuleLoader__.load({
63
75
  }
64
76
 
65
77
  // ---------- preload cache ----------
66
- // DSH 打开(插件激活)时就预先检测并缓存,点开「代码管理」面板直接秒显,
67
- // 不再每次点击重新加载。
78
+ // 只在打开插件时按需拉取仓库状态;DSH 打开(插件激活)时只预取静态的 env / ssh /
79
+ // workspaces / 默认工作区,绝不联网同步仓库——避免打开 DSH 就 fetch、并把可能过期的
80
+ // 数据缓存起来,导致打开/重开面板时显示旧状态(如旧的「无改动」)。
68
81
  const cache = {
69
82
  env: null,
70
83
  ssh: null,
71
84
  defDir: null,
72
85
  repo: null,
86
+ // 各工作区的最近一次结果(dir -> repo 状态),仅作展示历史,不再用于「秒显无刷新」。
87
+ reposByDir: {},
88
+ // 单文件改动 diff 缓存("dir\u0000path" -> diff 文本),点击「查看」秒出。
89
+ diffs: {},
73
90
  workspaces: [],
74
91
  customDirs: [],
75
92
  ready: false,
76
93
  };
77
94
  let preloadPromise = null;
78
95
 
79
- /** 预取 env / ssh / default-dir / repo / workspaces,结果写入 cache。可并发安全。 */
96
+ /** 清空某个目录(或全部)的 diff 缓存,避免切目录后残留上一个目录的文件 diff。 */
97
+ function clearDiffCache(dir) {
98
+ if (!dir) { cache.diffs = {}; return }
99
+ const prefix = dir + "\u0000"
100
+ for (const key of Object.keys(cache.diffs)) {
101
+ if (key.startsWith(prefix)) delete cache.diffs[key]
102
+ }
103
+ }
104
+
105
+ /** 并发上限 N 地预取一批改动文件的 diff,结果写入 cache.diffs,用于点击「查看」秒出。
106
+ * 纯本地 git 读取(host 端 /repo-diff),并发低、不阻塞主流程,后台慢慢填。 */
107
+ async function prefetchDiffs(dir, files, limit) {
108
+ if (!dir || !Array.isArray(files) || files.length === 0) return;
109
+ let i = 0
110
+ const worker = async () => {
111
+ while (i < files.length) {
112
+ const f = files[i++]
113
+ if (!f || typeof f !== "object") continue
114
+ const key = dir + "\u0000" + f.path
115
+ if (cache.diffs[key] !== undefined) continue
116
+ try {
117
+ // untracked 新文件 git 不跟踪,无 diff,直接标记为空。
118
+ if (f.type === "untracked") { cache.diffs[key] = ""; continue }
119
+ const r = await jget("/repo-diff?dir=" + encodeURIComponent(dir) + "&path=" + encodeURIComponent(f.path)).catch(() => null)
120
+ cache.diffs[key] = (r && typeof r.diff === "string") ? r.diff : null
121
+ } catch { cache.diffs[key] = null }
122
+ }
123
+ }
124
+ const n = Math.max(1, Math.min(limit || 3, files.length || 1))
125
+ const workers = []
126
+ for (let k = 0; k < n; k++) workers.push(worker())
127
+ await Promise.all(workers)
128
+ }
129
+
130
+ /** 预取 env / ssh / default-dir / workspaces(仅静态资源),结果写入 cache。可并发安全。
131
+ * 注意:仓库状态不在此预取(需要用户打开面板/切换工作区时才按需联网获取,并显示刷新中)。 */
80
132
  function preload() {
81
133
  if (!preloadPromise) {
82
134
  preloadPromise = (async () => {
@@ -97,7 +149,6 @@ window.__ModuleLoader__.load({
97
149
  }
98
150
  if (def && def.dir) {
99
151
  cache.defDir = def.dir;
100
- cache.repo = await jget("/repo?dir=" + encodeURIComponent(def.dir)).catch(() => null);
101
152
  }
102
153
  cache.ready = true;
103
154
  } catch {
@@ -107,6 +158,7 @@ window.__ModuleLoader__.load({
107
158
  }
108
159
  return preloadPromise;
109
160
  }
161
+
110
162
  /** 忽略缓存强制重新拉取某个资源并更新 cache。 */
111
163
  async function refreshCache(key) {
112
164
  if (key === "env") cache.env = await jget("/env").catch(() => cache.env);
@@ -125,9 +177,6 @@ window.__ModuleLoader__.load({
125
177
  // ---------- small presentational bits ----------
126
178
  const h = React.createElement;
127
179
 
128
- function Dot({ color }) {
129
- return h("span", { style: { color, marginRight: 4 } }, "●");
130
- }
131
180
  function Field({ label, value, children }) {
132
181
  return h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 } },
133
182
  h("span", { style: { color: T.secondary, fontSize: 13, width: 84, flexShrink: 0 } }, label),
@@ -159,20 +208,59 @@ window.__ModuleLoader__.load({
159
208
  },
160
209
  }, label);
161
210
  }
162
- function Box({ title, children }) {
211
+ function Box({ title, badge, defaultCollapsed, collapsible, titleExtra, collapsed: controlledCollapsed, onToggle, children }) {
212
+ // 每个部分默认可折叠:标题行右侧一个「折叠/展开」按钮,点击收起只显示标题。
213
+ // defaultCollapsed 控制初始是否收起(如①环境检查全就绪时默认折叠)。
214
+ // titleExtra:标题行里标题与折叠按钮之间的额外内容(如②的平台切换下拉),
215
+ // 折叠时仍可见、可交互(切换平台会由调用方触发展开)。
216
+ // 受控模式:传入 collapsed/onToggle 时由父组件接管折叠状态(用于「切换标题行控件
217
+ // 后自动展开」);否则用内部 state。
218
+ const [inner, setInner] = useState(!!defaultCollapsed);
219
+ const collapsed = controlledCollapsed !== undefined ? !!controlledCollapsed : inner;
220
+ const setCollapsed = controlledCollapsed !== undefined ? onToggle : setInner;
221
+ const canCollapse = collapsible !== false;
163
222
  return h("div", { style: { border: "1px solid " + T.border, borderRadius: 12, padding: 14 } },
164
- h("div", { style: { fontSize: 13, fontWeight: 600, color: T.label, marginBottom: 10 } }, title),
165
- children
223
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
224
+ h("span", { style: { fontSize: 13, fontWeight: 600, color: T.label, flexShrink: 0, minWidth: 0 } }, title),
225
+ titleExtra || null,
226
+ badge ? h("span", { style: { fontSize: 12, color: T.success, flexShrink: 0, whiteSpace: "nowrap" } }, badge) : null,
227
+ canCollapse ? h("span", { style: { flex: 1 } }) : null,
228
+ canCollapse ? h("button", {
229
+ type: "button",
230
+ title: collapsed ? "展开" : "折叠",
231
+ "aria-label": collapsed ? "展开该部分" : "折叠该部分",
232
+ onClick: () => setCollapsed(!collapsed),
233
+ style: collapseBtnStyle(),
234
+ }, collapsed ? "▸" : "▾") : null
235
+ ),
236
+ collapsed ? null : h("div", { style: { marginTop: 10 } }, children)
166
237
  );
167
238
  }
239
+ // 折叠/展开按钮样式(标题行右侧的小圆角按钮)。
240
+ function collapseBtnStyle() {
241
+ return {
242
+ appearance: "none", border: "none", background: "transparent",
243
+ color: T.secondary, cursor: "pointer", fontSize: 14, lineHeight: 1,
244
+ width: 24, height: 24, borderRadius: 6, flexShrink: 0, padding: 0,
245
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
246
+ };
247
+ }
168
248
  function pre(code) {
169
249
  return h("pre", { style: { whiteSpace: "pre-wrap", fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", fontSize: 12, lineHeight: 1.6, color: T.secondary, margin: 0 } }, code);
170
250
  }
171
251
 
172
252
  // ---------- section 1: 环境检查 ----------
253
+ // 每类工具缺失时给出安装命令 + 「一键安装」按钮(best-effort,走 host 自动选包管理器)。
254
+ const INSTALL_HINTS = {
255
+ git: "winget install --id Git.Git -e",
256
+ gh: "winget install --id GitHub.cli -e",
257
+ ssh: "Add-WindowsCapability -Online -Name OpenSSH.Client",
258
+ };
173
259
  function EnvSection() {
174
260
  const [env, setEnv] = useState(() => cache.env);
175
261
  const [err, setErr] = useState(null);
262
+ const [installing, setInstalling] = useState(null); // 'git'|'gh'|'ssh'|null
263
+ const [installResult, setInstallResult] = useState(null);
176
264
  const load = useCallback(async () => {
177
265
  setErr(null);
178
266
  // 先读缓存(DSH 打开时已预取),再后台刷新保持最新
@@ -182,16 +270,54 @@ window.__ModuleLoader__.load({
182
270
  }, []);
183
271
  useEffect(() => { void preload().then(load); }, [load]);
184
272
 
185
- return h(Box, { title: "① 环境检查" },
273
+ // 一键安装:调用 host 选定包管理器安装缺失工具,成功后重新检测。
274
+ const doInstall = useCallback(async (tool) => {
275
+ setInstalling(tool); setInstallResult(null);
276
+ try {
277
+ const r = await jpost("/install-tool", { tool });
278
+ setInstallResult(r);
279
+ if (r.ok) void load();
280
+ } catch (e) { setInstallResult({ ok: false, tool, error: String(e) }); }
281
+ finally { setInstalling(null); }
282
+ }, [load]);
283
+
284
+ // 是否所有工具都就绪(git / gh / ssh 均已安装)——就绪时该部分默认折叠,
285
+ // 只显示「① 环境检查」标题 + 「均存在」提示;否则默认展开让用户看到缺什么。
286
+ const allPresent = !!(cache.env && cache.env.git && cache.env.git.installed
287
+ && cache.env.gh && cache.env.gh.installed
288
+ && cache.env.ssh && cache.env.ssh.installed);
289
+ const winOs = (env && env.platform === "win32");
290
+
291
+ // 渲染单个工具行;缺失时带安装命令 + 安装按钮。
292
+ const toolRow = (tool, label, installed, version) => {
293
+ const hint = INSTALL_HINTS[tool] || ""
294
+ const status = installed
295
+ ? h("span", { style: { color: T.label, fontSize: 13 } }, "✅ " + (version || "已安装"))
296
+ : h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
297
+ h("span", { style: { color: T.danger, fontSize: 13 } }, "❌ 未安装"),
298
+ h("button", { type: "button", title: "复制安装命令", disabled: installing !== null, style: changeBtnStyle(), onClick: (e) => { e.stopPropagation(); void copyText(hint); } }, "复制安装命令"),
299
+ h("button", { type: "button", title: "一键安装(会自动选包管理器)", disabled: installing !== null, style: { ...changeBtnStyle(), color: T.brand, fontWeight: 600 }, onClick: (e) => { e.stopPropagation(); void doInstall(tool); } }, installing === tool ? "安装中…" : "安装")
300
+ )
301
+ const feedback = installResult && installResult.tool === tool
302
+ ? (installResult.ok
303
+ ? h("div", { style: { marginTop: 4, color: T.success, fontSize: 12 } }, "✅ " + (installResult.command || "已安装") + " 执行成功" + (installResult.needElevation ? "(如未生效需以管理员身份重试)" : ""))
304
+ : h("div", { style: { marginTop: 4, color: T.danger, fontSize: 12 } }, "❌ " + (installResult.error || "安装失败") + (installResult.command ? ":" + installResult.command : "")))
305
+ : null
306
+ return h("div", { key: tool, style: { marginTop: 4 } },
307
+ h(Field, { label }, status),
308
+ feedback
309
+ )
310
+ }
311
+
312
+ return h(Box, { title: "① 环境检查", badge: allPresent ? "✅ 均存在" : null, defaultCollapsed: !!allPresent },
186
313
  h(Field, { label: "操作系统" }, h("span", { style: { color: T.label, fontSize: 13 } }, env ? (env.platformLabel || env.platform) : "...")),
187
314
  env ? h(React.Fragment, null,
188
- h(Field, { label: "Git", value: env.git.installed ? "✅ " + env.git.version : "❌ 未安装" }),
189
- h(Field, { label: "GitHub CLI", value: env.gh.installed ? "✅ " + env.gh.version : "❌ 未安装" }),
190
- h(Field, { label: "SSH", value: env.ssh && env.ssh.installed ? "已找到" : "❌ 未找到" })
315
+ toolRow("git", "Git", env.git.installed, env.git.version),
316
+ toolRow("gh", "GitHub CLI", env.gh.installed, env.gh.version),
317
+ toolRow("ssh", "SSH", !!(env.ssh && env.ssh.installed), env.ssh && env.ssh.installed ? "已找到" : null)
191
318
  ) : h(Field, { label: "检测中", value: err ? "失败: " + err : "…" }),
192
- (!env || !env.git.installed || !env.gh.installed) ? h("div", { style: { marginTop: 8 } },
193
- h("span", { style: { color: T.warn, fontSize: 12 } },
194
- "未安装工具请先安装 Git for Windows 和 GitHub CLI,然后重启。" + (err ? "(" + err + ")" : ""))
319
+ (!env || !env.git.installed || !env.gh.installed || !(env.ssh && env.ssh.installed)) ? h("div", { style: { marginTop: 8, fontSize: 12, color: T.secondary, lineHeight: 1.6 } },
320
+ "未找到的工具可用上方「安装」按钮一键安装(" + (winOs ? "Windows winget / 内置功能" : "用系统包管理器") + ",可能需管理员权限);也可手动复制安装命令执行,安装后点「重新检查」。"
195
321
  ) : h(Btn, { label: "重新检查", onClick: load, tone: "ghost" })
196
322
  );
197
323
  }
@@ -205,6 +331,8 @@ window.__ModuleLoader__.load({
205
331
  // 平台状态由 ScmPanel 共享(②里选择,③跟随)
206
332
  const prov = provider || "github";
207
333
  const setProv = setProvider || (() => {});
334
+ // ② 默认折叠;在标题行也能切平台,切换时自动展开(因为要做后续配置)。
335
+ const [collapsed, setCollapsed] = useState(true);
208
336
 
209
337
  const load = useCallback(async () => {
210
338
  setErr(null);
@@ -232,23 +360,30 @@ window.__ModuleLoader__.load({
232
360
  : !!(ssh && ssh.sshGitHubConfigured);
233
361
  const providerHost = prov === "gitee" ? "gitee.com" : "github.com";
234
362
 
235
- return h(Box, { title: "② SSH 密钥与连接" },
236
- // 平台选择(默认 GitHub),③代码管理跟随
237
- h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 10 } },
238
- h("span", { style: { color: T.secondary, fontSize: 13, width: 84, flexShrink: 0 } }, "平台"),
239
- h("select", {
240
- value: prov,
241
- onChange: (e) => setProv(e.target.value),
242
- title: "选择代码托管平台:GitHub Gitee(默认 GitHub),③代码管理会跟随切换",
243
- style: { font: "inherit", fontSize: 13, padding: "6px 10px", border: "1px solid " + T.border, borderRadius: 8, background: T.layer1, color: T.label, outline: "none", cursor: "pointer" },
244
- },
245
- h("option", { value: "github" }, "GitHub(默认)"),
246
- h("option", { value: "gitee" }, "Gitee"),
247
- )
248
- ),
249
- // key status
363
+ // 标题行的平台切换下拉:折叠时也可见、可交互;切换后自动展开(要做配置)。
364
+ const titleExtra = h("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0 } },
365
+ h("span", { style: { fontSize: 12, color: T.secondary } }, "平台"),
366
+ h("select", {
367
+ value: prov,
368
+ onChange: (e) => { setProv(e.target.value); setCollapsed(false); },
369
+ title: "选择代码托管平台:GitHub Gitee(默认 GitHub),③代码管理会跟随切换",
370
+ style: { font: "inherit", fontSize: 12, padding: "3px 6px", border: "1px solid " + T.border, borderRadius: 6, background: T.layer1, color: T.label, outline: "none", cursor: "pointer" },
371
+ },
372
+ h("option", { value: "github" }, "GitHub(默认)"),
373
+ h("option", { value: "gitee" }, "Gitee"),
374
+ )
375
+ );
376
+
377
+ return h(Box, {
378
+ title: "② SSH 密钥与连接",
379
+ defaultCollapsed: true,
380
+ collapsed,
381
+ onToggle: (v) => setCollapsed(v),
382
+ titleExtra,
383
+ },
384
+ // key status(显示实际检测到的密钥名,可能不是 id_ed25519)
250
385
  h(Field, { label: "密钥" }, h("span", { style: { color: T.label, fontSize: 13 } },
251
- ssh ? (ssh.hasKey ? "✅ id_ed25519 已生成" : "⚠️ 未生成") : "…")
386
+ ssh ? (ssh.hasKey ? "✅ " + (ssh.keyBase || "id_ed25519") + " 已生成" : "⚠️ 未生成") : "…")
252
387
  ),
253
388
  h(Field, { label: "GH 登录" }, h("span", { style: { color: T.label, fontSize: 13 } },
254
389
  ssh ? (ssh.ghLoggedIn ? "✅ " + (ssh.ghAccount || "已登录") : "⚠️ 未登录") : "…")
@@ -289,6 +424,13 @@ window.__ModuleLoader__.load({
289
424
  const [dirDraft, setDirDraft] = useState(() => cache.defDir ?? "");
290
425
  const [repo, setRepo] = useState(() => cache.repo);
291
426
  const [busy, setBusy] = useState(false);
427
+ // 「推送暂存」进行中标记:把已暂存的内容用填写的(或自动生成的)信息提交后推送。
428
+ const [pushStagedBusy, setPushStagedBusy] = useState(false);
429
+ // 刷新状态(联网同步)进行中的提示状态,避免刷新时界面闪成「未加载文件夹」。
430
+ const [refreshing, setRefreshing] = useState(false);
431
+ // 切换平台(② 里 GitHub/Gitee)时,③ 需要重新拉取对应平台的仓库状态;此标记在拉取期间为真,
432
+ // 用于显示「切换平台,正在重新检测…」提示——避免保留原内容几秒而让用户以为没反应。
433
+ const [switching, setSwitching] = useState(false);
292
434
  const [result, setResult] = useState(null);
293
435
  const [msg, setMsg] = useState(null);
294
436
  const [err, setErr] = useState(null);
@@ -303,8 +445,30 @@ window.__ModuleLoader__.load({
303
445
  const [giteeTokenBusy, setGiteeTokenBusy] = useState(false);
304
446
  // 自定义目录(用户手动选择的非 DSH 工作区,持久化于插件本地),用于给下拉项加 X 删除
305
447
  const [customDirs, setCustomDirs] = useState(() => cache.customDirs || []);
306
- // 详情弹窗:'changes'(改动文件列表)| 'sync'(同步差异)| null(关闭)
448
+ // 详情弹窗:'changes'(改动文件列表)| 'sync'(同步差异)| 'branches'(分支切换)
449
+ // | 'history'(提交历史)| null(关闭)
307
450
  const [detail, setDetail] = useState(null);
451
+ // 改动详情里被展开显示 diff 的文件下标集合(点击文件名展开/收起内容)
452
+ const [expandedDiffs, setExpandedDiffs] = useState(() => new Set());
453
+ // 按需加载的 diff:{ [index]: { loading, diff, error } },点开文件行时才请求 /repo-diff
454
+ const [diffs, setDiffs] = useState({});
455
+ // 提交信息输入框 + 暂存/取消暂存进行中标记
456
+ const [commitMsg, setCommitMsg] = useState("");
457
+ const [stageBusy, setStageBusy] = useState(false);
458
+ // 分支切换弹窗数据 + 进行中标记
459
+ const [branches, setBranches] = useState([]);
460
+ const [branchCurrent, setBranchCurrent] = useState("");
461
+ const [branchBusy, setBranchBusy] = useState(false);
462
+ // 提交历史弹窗数据 + 进行中标记;historyDetail 存放某条 commit 的并排 diff
463
+ const [commits, setCommits] = useState([]);
464
+ const [commitsLoading, setCommitsLoading] = useState(false);
465
+ const [historyBusy, setHistoryBusy] = useState(false);
466
+ // historyDetail:{ hash, loading, diff, error },点某提交时按需拉 /commit-diff
467
+ const [historyDetail, setHistoryDetail] = useState(null);
468
+ // 提交行右键/更多菜单:当前打开的 commit(hashFull)或 null
469
+ const [commitMenu, setCommitMenu] = useState(null);
470
+ // 复制成功的即时提示(菜单里短暂显示「已复制」)
471
+ const [copiedTip, setCopiedTip] = useState(null);
308
472
  // 仓库名称强制 = 文件夹名(不允许手动填写)
309
473
  const repoName = (repo && repo.defaultRepoName) || "";
310
474
  // 目录选择器状态
@@ -317,16 +481,48 @@ window.__ModuleLoader__.load({
317
481
  const provRef = useRef(prov);
318
482
  provRef.current = prov;
319
483
 
320
- const loadRepo = useCallback(async (d, keepRepo) => {
484
+ // 竞态保护:loadRepo 是异步的(await 网络),首次挂载的预加载请求可能与
485
+ // 用户手动切换工作区/平台的请求并发。若旧请求后返回,会把它对应的目录
486
+ // 内容(并 setDir 改回旧目录)覆盖到界面上,表现为「切换后过一会儿又跳回
487
+ // 切换前的目录」。用自增 token 保证只有最新一次调用才允许写入状态。
488
+ const loadSeq = useRef(0);
489
+
490
+ // 可见性默认取值:优先用当前仓库的实际私有/公开状态;当该工作区没有远程
491
+ // (属于当前账号/平台的远程、即即将新建的仓库)时,默认选「公开」。供
492
+ // loadRepo 的各个返回路径(网络 / 快速缓存 / 预取秒显)统一调用。
493
+ const syncVisibility = useCallback((r) => {
494
+ if (r && r.repoExists === true && (r.visibility === "public" || r.visibility === "private")) {
495
+ setVisibility(r.visibility);
496
+ } else if (r && !r.hasRemote) {
497
+ setVisibility("public");
498
+ }
499
+ }, []);
500
+
501
+ const loadRepo = useCallback(async (d, keepRepo, full) => {
321
502
  // 注意:不能在这里无条件清空 err —— 操作回调(推送/拉取/对齐等)会先
322
503
  // setErr/setMsg 再调 loadRepo 刷新状态,此处清空会立刻抹掉刚设置的
323
504
  // 结果/错误提示,造成「点了按钮没有任何反馈」。需要清空 err 的调用方
324
505
  // (切换目录 / 切换平台 / 刷新状态)自行清空。
325
- if (!keepRepo) setRepo(null);
506
+ // full=true 时才让 host 端执行 git fetch 联网同步(用于「刷新状态」及
507
+ // 推送/拉取/对齐等操作后的刷新);full 模式自动显示「刷新中…」提示,
508
+ // 提示只在最新一次调用归来时关闭。**始终走网络获取最新仓库状态,绝不命中
509
+ // 缓存秒显**——否则打开/重开/切换工作区会显示旧数据(如旧的「无改动」)。
510
+ const doFull = full === true;
511
+ const mySeq = ++loadSeq.current;
512
+ if (doFull) setRefreshing(true);
513
+ const dirty = !keepRepo;
514
+ if (dirty) setRepo(null);
515
+ const curProv = provRef.current || "github";
326
516
  try {
327
- const p = provRef.current || "github";
328
- const r = await jget("/repo?dir=" + encodeURIComponent(d) + "&provider=" + encodeURIComponent(p));
517
+ const p = curProv;
518
+ const r = await jget("/repo?dir=" + encodeURIComponent(d) + "&provider=" + encodeURIComponent(p) + (doFull ? "&full=1" : ""));
519
+ // 竞态保护:若期间又发起了更新的 loadRepo(序列号更大),丢弃本次过期结果,
520
+ // 避免旧请求后到时把界面刷回旧目录。
521
+ if (mySeq !== loadSeq.current) return;
329
522
  setRepo(r);
523
+ // 切换仓库/刷新后清空旧的改动 diff 缓存与展开状态,避免残留上一个目录的内容。
524
+ setExpandedDiffs(new Set());
525
+ setDiffs({});
330
526
  // 默认选中当前工作区:/repo 在未传 dir 时返回当前工作区路径,
331
527
  // 把返回的实际目录同步到输入框和当前选中目录。
332
528
  if (r && r.dir) {
@@ -334,34 +530,39 @@ window.__ModuleLoader__.load({
334
530
  setDir(r.dir);
335
531
  cache.defDir = r.dir;
336
532
  }
337
- // 缓存最新 repo 状态
533
+ // 记录最新 repo 状态(仅作展示历史,绝不再用于「无刷新秒显」)。
338
534
  cache.repo = r;
339
- // 仓库已存在且能读到实际可见性时,让下拉框默认选中实际状态,
340
- // 这样「修改仓库状态」按钮只在用户主动切换时出现。
341
- if (r && r.repoExists === true && (r.visibility === "public" || r.visibility === "private")) {
342
- setVisibility(r.visibility);
343
- }
535
+ if (r && r.dir) cache.reposByDir[r.dir] = r;
536
+ // 默认可见性:优先当前仓库实际状态;无远程(即将新建的仓库)默认「公开」。
537
+ syncVisibility(r);
538
+ // 后台预取本目录所有改动文件的 diff,点击「查看」时秒出。
539
+ // 先清掉上次残留的缓存(文件集刚可能变化),再并发拉取。
540
+ clearDiffCache(r && r.dir);
541
+ if (!hasBetterSidebar) void prefetchDiffs(r && r.dir, r && r.changedFiles, 3);
344
542
  if (r && !r.isGitRepo) setErr(r.error || "该目录不是 git 仓库");
345
543
  } catch (e) { setErr(String(e)); }
544
+ finally {
545
+ // 只有本次是最新调用时才收起「刷新中…」提示,避免旧请求失败把提示提前关掉。
546
+ if (doFull && mySeq === loadSeq.current) setRefreshing(false);
547
+ }
346
548
  }, []);
347
549
 
348
550
  useEffect(() => {
349
- // 首次加载:先看缓存(DSH 打开时已预取当前工作区),秒显;
350
- // 无缓存则 preload(取默认工作区)后加载。
351
- if (cache.repo) {
352
- setRepo(cache.repo);
353
- if (cache.defDir) { setDir(cache.defDir); setDirDraft(cache.defDir); }
354
- }
551
+ // 首次加载:只预取静态的 env/ssh/workspaces/默认目录;仓库状态一律在面板打开后
552
+ // 通过 loadRepo(..., true) 联网获取最新(并显示「刷新中…」),绝不命中可能过期的
553
+ // 缓存——这样关闭面板再打开、或切换工作区,都会重新同步,不会停留在旧的「无改动」。
355
554
  if (cache.workspaces && cache.workspaces.length) setWorkspaces(cache.workspaces);
356
555
  if (cache.customDirs && cache.customDirs.length) setCustomDirs(cache.customDirs);
357
556
  void preload().then(() => {
358
557
  if (cache.workspaces && cache.workspaces.length) setWorkspaces(cache.workspaces);
359
558
  if (cache.customDirs && cache.customDirs.length) setCustomDirs(cache.customDirs);
360
- // 默认工作区:优先 defDir,否则第一个工作区
361
559
  const d = cache.defDir
362
560
  || (cache.workspaces && cache.workspaces[0])
363
561
  || dir || "";
364
- if (d) { setDir(d); setDirDraft(d); loadRepo(d); }
562
+ if (!d) return;
563
+ setDir(d); setDirDraft(d);
564
+ // 始终 full 同步(联网 fetch + 显示刷新中),获取当前工作区的最新状态。
565
+ loadRepo(d, true, true);
365
566
  });
366
567
  // eslint-disable-next-line react-hooks/exhaustive-deps
367
568
  }, []);
@@ -387,7 +588,7 @@ window.__ModuleLoader__.load({
387
588
  setGiteeToken("");
388
589
  setGiteeTokenMsg("已保存 Gitee 令牌(账号:" + (r.owner || "?") + ")");
389
590
  // 令牌就绪后刷新仓库,让同名检测等按 Gitee 生效
390
- loadRepo(dir || cache.defDir || "", true);
591
+ loadRepo(dir || cache.defDir || "", true, true);
391
592
  } else {
392
593
  setGiteeConfigured(false);
393
594
  setGiteeTokenErr(r.error || "保存令牌失败");
@@ -404,7 +605,7 @@ window.__ModuleLoader__.load({
404
605
  setGiteeOwnerName("");
405
606
  setGiteeToken("");
406
607
  setGiteeTokenMsg(r.ok ? "已清除 Gitee 令牌" : "清除失败");
407
- loadRepo(dir || cache.defDir || "", true);
608
+ loadRepo(dir || cache.defDir || "", true, true);
408
609
  } catch (e) { setGiteeTokenErr("清除令牌失败:" + e); }
409
610
  finally { setGiteeTokenBusy(false); }
410
611
  }, [dir, loadRepo]);
@@ -414,7 +615,11 @@ window.__ModuleLoader__.load({
414
615
  if (prov === "gitee") void loadGiteeTokenStatus();
415
616
  // 切换平台等同切换检测目标,清掉旧的错误提示,避免残留上一个平台的报错。
416
617
  setErr(null);
417
- if (dir || cache.defDir) loadRepo(dir || cache.defDir || "", true);
618
+ if (dir || cache.defDir) {
619
+ // 显示「切换平台,正在重新检测…」提示,避免切换后旧内容停留几秒看起来像没变化。
620
+ setSwitching(true);
621
+ loadRepo(dir || cache.defDir || "", true, true).finally(() => setSwitching(false));
622
+ }
418
623
  // eslint-disable-next-line react-hooks/exhaustive-deps
419
624
  }, [prov]);
420
625
 
@@ -425,7 +630,7 @@ window.__ModuleLoader__.load({
425
630
  setResult(r);
426
631
  if (r.ok) setMsg("已推送");
427
632
  else setErr(r.error || r.pushError || "推送失败");
428
- void loadRepo(dir, true);
633
+ void loadRepo(dir, true, true);
429
634
  } catch (e) { setErr("推送失败:" + e); }
430
635
  finally { setBusy(false); }
431
636
  }, [dir, loadRepo]);
@@ -441,7 +646,7 @@ window.__ModuleLoader__.load({
441
646
  setResult(r);
442
647
  if (r.ok) setMsg("已拉取远程更新(本地已对齐到远程最新状态)");
443
648
  else setErr(r.error || "拉取失败");
444
- void loadRepo(dir, true);
649
+ void loadRepo(dir, true, true);
445
650
  } catch (e) { setErr("拉取失败:" + e); }
446
651
  finally { setBusy(false); }
447
652
  }, [dir, loadRepo]);
@@ -453,7 +658,7 @@ window.__ModuleLoader__.load({
453
658
  setResult(r);
454
659
  if (r.ok) setMsg("已拉取远程更新并推送更改");
455
660
  else setErr(r.error || "合并推送失败");
456
- void loadRepo(dir, true);
661
+ void loadRepo(dir, true, true);
457
662
  } catch (e) { setErr("合并推送失败:" + e); }
458
663
  finally { setBusy(false); }
459
664
  }, [dir, loadRepo]);
@@ -466,7 +671,7 @@ window.__ModuleLoader__.load({
466
671
  setResult(r);
467
672
  if (r.ok) setMsg("已强制推送,远程已更新为本地状态");
468
673
  else setErr(r.error || "强制推送失败");
469
- void loadRepo(dir, true);
674
+ void loadRepo(dir, true, true);
470
675
  } catch (e) { setErr("强制推送失败:" + e); }
471
676
  finally { setBusy(false); }
472
677
  }, [dir, loadRepo]);
@@ -479,7 +684,7 @@ window.__ModuleLoader__.load({
479
684
  setResult(r);
480
685
  if (r.ok) setMsg("已强制拉取远程更新");
481
686
  else setErr(r.error || "强制拉取失败");
482
- void loadRepo(dir, true);
687
+ void loadRepo(dir, true, true);
483
688
  } catch (e) { setErr("强制拉取失败:" + e); }
484
689
  finally { setBusy(false); }
485
690
  }, [dir, loadRepo]);
@@ -495,7 +700,7 @@ window.__ModuleLoader__.load({
495
700
  setResult(r);
496
701
  if (r.ok) setMsg("已把仓库设置为「" + (target === "public" ? "公开" : "私有") + "」");
497
702
  else setErr(r.error || "修改可见性失败");
498
- void loadRepo(dir, true);
703
+ void loadRepo(dir, true, true);
499
704
  } catch (e) { setErr("修改可见性失败:" + e); }
500
705
  finally { setBusy(false); }
501
706
  }, [dir, repo, visibility, loadRepo]);
@@ -509,7 +714,7 @@ window.__ModuleLoader__.load({
509
714
  setResult(r);
510
715
  if (r.ok) setMsg("仓库已创建并推送:" + (r.url || name) + (r.provider === "gitee" ? "(Gitee)" : ""));
511
716
  else setErr(r.error || "创建失败");
512
- void loadRepo(dir, true);
717
+ void loadRepo(dir, true, true);
513
718
  } catch (e) { setErr("创建失败:" + e); }
514
719
  finally { setBusy(false); }
515
720
  }, [dir, repo, visibility, loadRepo]);
@@ -547,7 +752,7 @@ window.__ModuleLoader__.load({
547
752
  // 切换到新目录,清空上一个目录的操作结果/提示。
548
753
  setResult(null); setMsg(null); setErr(null);
549
754
  setPickOpen(false);
550
- loadRepo(r.dir);
755
+ loadRepo(r.dir, true, true);
551
756
  } else {
552
757
  setPickErr(r.error || "添加目录失败");
553
758
  }
@@ -573,7 +778,7 @@ window.__ModuleLoader__.load({
573
778
  if (dir === path) {
574
779
  const next = (r.workspaces && r.workspaces[0]) || "";
575
780
  setDir(next); setDirDraft(next);
576
- if (next) loadRepo(next); else setRepo(null);
781
+ if (next) loadRepo(next, true, true); else setRepo(null);
577
782
  }
578
783
  } else {
579
784
  setErr(r.error || "删除失败");
@@ -591,7 +796,7 @@ window.__ModuleLoader__.load({
591
796
  setResult(r);
592
797
  if (r.ok) setMsg("已强制对齐到远程分支");
593
798
  else setErr(r.error || "强制对齐失败");
594
- void loadRepo(dir);
799
+ void loadRepo(dir, true, true);
595
800
  } catch (e) { setErr("强制对齐失败:" + e); }
596
801
  finally { setBusy(false); }
597
802
  }, [dir, loadRepo]);
@@ -604,15 +809,176 @@ window.__ModuleLoader__.load({
604
809
  setResult(r);
605
810
  if (r.ok) setMsg(r.alreadyRepo ? "已是 git 仓库" : "已创建 git 仓库,请自行拉取或推送");
606
811
  else setErr(r.error || "创建 git 失败");
607
- void loadRepo(dir);
812
+ void loadRepo(dir, true, true);
608
813
  } catch (e) { setErr("创建 git 失败:" + e); }
609
814
  finally { setBusy(false); }
610
815
  }, [dir, loadRepo]);
611
816
 
817
+ // ---- 本地 Git 工作流:选择性暂存 / 提交 / 分支 / 历史 / revert / cherry-pick ----
818
+
819
+ // 暂存某文件(path 空 = 全部),成功后刷新 repo 状态。
820
+ const doStage = useCallback(async (path) => {
821
+ setStageBusy(true); setErr(null);
822
+ try {
823
+ const r = await jpost("/stage", { dir: dir || undefined, path: path || undefined });
824
+ if (r.ok) {
825
+ // 暂存会改变文件的 staged 状态:清掉该目录的缓存快照,强制走网络路径
826
+ // 重新拉取最新 repo(含最新「已暂存/未暂存」标记),并清 diff 展开状态。
827
+ if (dir) delete cache.reposByDir[dir];
828
+ loadRepo(dir, true, true);
829
+ } else setErr(r.error || "暂存失败");
830
+ } catch (e) { setErr("暂存失败:" + e); }
831
+ finally { setStageBusy(false); }
832
+ }, [dir, loadRepo]);
833
+
834
+ // 取消暂存某文件(path 空 = 全部)。
835
+ const doUnstage = useCallback(async (path) => {
836
+ setStageBusy(true); setErr(null);
837
+ try {
838
+ const r = await jpost("/unstage", { dir: dir || undefined, path: path || undefined });
839
+ if (r.ok) {
840
+ if (dir) delete cache.reposByDir[dir];
841
+ loadRepo(dir, true, true);
842
+ } else setErr(r.error || "取消暂存失败");
843
+ } catch (e) { setErr("取消暂存失败:" + e); }
844
+ finally { setStageBusy(false); }
845
+ }, [dir, loadRepo]);
846
+
847
+ // 用自定义信息提交暂存区;成功后刷新并提示。
848
+ const doCommit = useCallback(async () => {
849
+ const name = repoName || (repo && repo.dir ? String(repo.dir).split(/[\\/]/).filter(Boolean).pop() : "");
850
+ const message = commitMsg.trim() || ("chore: update " + name);
851
+ setBusy(true); setMsg(null); setErr(null); setResult(null);
852
+ try {
853
+ const r = await jpost("/commit", { dir: dir || undefined, message });
854
+ setResult(r);
855
+ if (r.ok) {
856
+ setMsg("已提交 " + r.staged + " 个文件" + (r.hash ? "(" + r.hash + ")" : ""));
857
+ setCommitMsg("");
858
+ void loadRepo(dir, true, true);
859
+ } else setErr(r.error || "提交失败");
860
+ } catch (e) { setErr("提交失败:" + e); }
861
+ finally { setBusy(false); }
862
+ }, [dir, repo, commitMsg, repoName, loadRepo]);
863
+
864
+ // 「推送暂存」:把已暂存的内容用填写的(或自动生成的)信息提交后推送远程。
865
+ // 与「推送更改」不同:它只提交暂存区、沿用自定义信息,不自动 add 全部改动。
866
+ const doPushStaged = useCallback(async () => {
867
+ setPushStagedBusy(true); setMsg(null); setErr(null); setResult(null);
868
+ try {
869
+ const r = await jpost("/push-staged", { dir: dir || undefined, message: commitMsg.trim() });
870
+ setResult(r);
871
+ if (r.ok) {
872
+ const info = r.committed
873
+ ? "已提交「" + (r.message || "(自动生成)") + "」并推送" + (r.commitHash ? "(" + r.commitHash + ")" : "")
874
+ : "已推送本地已有的提交";
875
+ setMsg(info);
876
+ setCommitMsg("");
877
+ } else setErr(r.error || r.pushError || "推送失败");
878
+ // 推送成功后刷新状态(full 联网同步,显示「刷新中…」)。
879
+ void loadRepo(dir, true, true);
880
+ } catch (e) { setErr("推送失败:" + e); }
881
+ finally { setPushStagedBusy(false); }
882
+ }, [dir, commitMsg, loadRepo]);
883
+
884
+ // 打开分支切换弹窗。
885
+ const openBranches = useCallback(async () => {
886
+ setDetail("branches"); setBranchBusy(true); setBranches([]);
887
+ try {
888
+ const r = await jpost("/branches", { dir: dir || undefined });
889
+ if (r.ok) { setBranches(r.branches || []); setBranchCurrent(r.current || ""); }
890
+ else setErr(r.error || "读取分支失败");
891
+ } catch (e) { setErr("读取分支失败:" + e); }
892
+ finally { setBranchBusy(false); }
893
+ }, [dir]);
894
+
895
+ // 切换到某分支。
896
+ const doCheckout = useCallback(async (branch) => {
897
+ if (branch === branchCurrent) { setDetail(null); return; }
898
+ if (!window.confirm("切换到分支「" + branch + "」?(若当前有未提交改动,git 会拒绝切换)")) return;
899
+ setBranchBusy(true); setMsg(null); setErr(null);
900
+ try {
901
+ const r = await jpost("/checkout", { dir: dir || undefined, branch });
902
+ if (r.ok) {
903
+ setMsg("已切换到分支 " + branch);
904
+ setDetail(null);
905
+ void loadRepo(dir, true, true);
906
+ } else setErr(r.error || "切换分支失败");
907
+ } catch (e) { setErr("切换分支失败:" + e); }
908
+ finally { setBranchBusy(false); }
909
+ }, [dir, branchCurrent, loadRepo]);
910
+
911
+ // 打开提交历史弹窗。
912
+ const openHistory = useCallback(async () => {
913
+ setDetail("history"); setCommitsLoading(true); setCommits([]); setHistoryDetail(null);
914
+ try {
915
+ const r = await jpost("/log", { dir: dir || undefined, count: 30 });
916
+ if (r.ok) setCommits(r.commits || []);
917
+ else setErr(r.error || "读取历史失败");
918
+ } catch (e) { setErr("读取历史失败:" + e); }
919
+ finally { setCommitsLoading(false); }
920
+ }, [dir]);
921
+
922
+ // 对某提交执行 revert(改写历史,需确认)。
923
+ const doRevert = useCallback(async (hash, subject) => {
924
+ if (!window.confirm("revert 提交「" + (subject || hash) + "」——会生成一个反向提交,期间可能产生冲突,确定继续?")) return;
925
+ setHistoryBusy(true); setMsg(null); setErr(null);
926
+ try {
927
+ const r = await jpost("/revert", { dir: dir || undefined, hash });
928
+ if (r.ok) {
929
+ setMsg("已 revert 提交 " + hash);
930
+ setDetail(null);
931
+ void loadRepo(dir, true, true);
932
+ } else setErr(r.error || "revert 失败");
933
+ } catch (e) { setErr("revert 失败:" + e); }
934
+ finally { setHistoryBusy(false); }
935
+ }, [dir, loadRepo]);
936
+
937
+ // 对某提交执行 cherry-pick(改写历史,需确认)。
938
+ const doCherryPick = useCallback(async (hash, subject) => {
939
+ if (!window.confirm("cherry-pick 提交「" + (subject || hash) + "」到当前分支——期间可能产生冲突,确定继续?")) return;
940
+ setHistoryBusy(true); setMsg(null); setErr(null);
941
+ try {
942
+ const r = await jpost("/cherrypick", { dir: dir || undefined, hash });
943
+ if (r.ok) {
944
+ setMsg("已 cherry-pick 提交 " + hash);
945
+ setDetail(null);
946
+ void loadRepo(dir, true, true);
947
+ } else setErr(r.error || "cherry-pick 失败");
948
+ } catch (e) { setErr("cherry-pick 失败:" + e); }
949
+ finally { setHistoryBusy(false); }
950
+ }, [dir, loadRepo]);
951
+
952
+ // 打开某提交的并排 diff(复用 /commit-diff)。
953
+ const openCommitDiff = useCallback(async (hash) => {
954
+ if (historyDetail && historyDetail.hash === hash) { setHistoryDetail(null); return; }
955
+ setHistoryDetail({ hash, loading: true, diff: "", error: null });
956
+ try {
957
+ const r = await jpost("/commit-diff", { dir: dir || undefined, hash });
958
+ setHistoryDetail({ hash, loading: false, diff: (r && r.diff) || "", error: r && !r.ok ? (r.error || "读取失败") : null });
959
+ } catch (e) {
960
+ setHistoryDetail({ hash, loading: false, diff: "", error: String(e) });
961
+ }
962
+ }, [dir, historyDetail]);
963
+
964
+ // 复制提交相关信息(短哈希 / 完整哈希 / 提交信息),成功后短暂提示并关闭菜单。
965
+ const copyCommit = useCallback(async (kind, commit) => {
966
+ let text = "";
967
+ if (kind === "short") text = commit.hash;
968
+ else if (kind === "full") text = commit.hashFull;
969
+ else text = commit.subject || "";
970
+ const ok = await copyText(text);
971
+ setCommitMenu(null);
972
+ if (ok) {
973
+ setCopiedTip("已复制" + (kind === "short" ? "短哈希" : kind === "full" ? "完整哈希" : "提交信息"));
974
+ window.setTimeout(() => setCopiedTip(null), 1600);
975
+ } else {
976
+ setErr("复制失败:请手动复制");
977
+ }
978
+ }, []);
979
+
612
980
  return h(Box, { title: "③ 代码管理" },
613
981
  // 平台提示 + Gitee 令牌(仅 gitee 模式)
614
- h("div", { style: { marginBottom: 10, fontSize: 12, color: T.secondary, lineHeight: 1.6 } },
615
- "当前平台:<" + (prov === "gitee" ? "Gitee" : "GitHub") + ">(在 ② SSH 里切换,③ 的同名检测 / 新建仓库 / 可见性会跟随)。"),
616
982
  prov === "gitee" ? h("div", { style: { border: "1px solid " + T.border, borderRadius: 10, padding: 10, marginBottom: 10, background: T.layer1 } },
617
983
  h("div", { style: { fontSize: 12, fontWeight: 600, color: T.label, marginBottom: 6 } },
618
984
  "Gitee 私人令牌(OpenAPI,需 projects 权限)"),
@@ -650,7 +1016,8 @@ window.__ModuleLoader__.load({
650
1016
  setResult(null); setMsg(null); setErr(null);
651
1017
  setDir(v);
652
1018
  setDirDraft(v);
653
- loadRepo(v);
1019
+ // full 同步:联网获取切换后工作区的最新状态,并显示「刷新中…」。
1020
+ loadRepo(v, true, true);
654
1021
  },
655
1022
  title: "选择 DSH 已登记的工作区文件夹",
656
1023
  style: { flex: 1, font: "inherit", fontSize: 12, padding: "6px 10px", border: "1px solid " + T.border, borderRadius: 8, background: T.layer1, color: T.label, outline: "none", cursor: "pointer" },
@@ -665,7 +1032,7 @@ window.__ModuleLoader__.load({
665
1032
  ),
666
1033
  dir && customDirs && customDirs.includes(dir) ? h("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 10, alignItems: "center" } },
667
1034
  h("span", { style: { color: T.secondary, fontSize: 12, whiteSpace: "nowrap" } }, "自定义目录:"),
668
- h("span", { key: dir, style: { display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", border: "1px solid " + T.border, borderRadius: 12, fontSize: 12, background: T.layer1, color: T.label, cursor: "pointer" }, title: dir, onClick: () => { setResult(null); setMsg(null); setErr(null); setDir(dir); setDirDraft(dir); loadRepo(dir); } },
1035
+ h("span", { key: dir, style: { display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 8px", border: "1px solid " + T.border, borderRadius: 12, fontSize: 12, background: T.layer1, color: T.label, cursor: "pointer" }, title: dir, onClick: () => { setResult(null); setMsg(null); setErr(null); setDir(dir); setDirDraft(dir); loadRepo(dir, true, true); } },
669
1036
  String(dir).split(/[\\/]/).filter(Boolean).pop() || dir,
670
1037
  h("span", {
671
1038
  role: "button", "aria-label": "删除该目录记录",
@@ -677,7 +1044,23 @@ window.__ModuleLoader__.load({
677
1044
  ) : null,
678
1045
  repo && repo.isGitRepo ? h("div", { style: { marginBottom: 10 } },
679
1046
  h(Field, { label: "目录", value: repo.dir }),
680
- h(Field, { label: "分支", value: repo.branch }),
1047
+ h(Field, { label: "分支" }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
1048
+ h("span", { style: { color: T.label, fontSize: 13 } }, repo.branch || "(无)"),
1049
+ !hasBetterSidebar ? h(React.Fragment, null,
1050
+ repo.branch ? h("button", {
1051
+ type: "button",
1052
+ onClick: openBranches,
1053
+ title: "切换分支",
1054
+ style: changeBtnStyle(),
1055
+ }, "切换") : null,
1056
+ h("button", {
1057
+ type: "button",
1058
+ onClick: openHistory,
1059
+ title: "查看提交历史",
1060
+ style: changeBtnStyle(),
1061
+ }, "历史")
1062
+ ) : null
1063
+ )),
681
1064
  h(Field, { label: "远程", value: repo.remoteUrl || "(无)" }),
682
1065
  h(Field, { label: "改动" }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
683
1066
  h("span", { style: { color: T.label, fontSize: 13 } }, repo.dirty ? repo.dirtyCount + " 个文件" : "无"),
@@ -691,9 +1074,12 @@ window.__ModuleLoader__.load({
691
1074
  )),
692
1075
  h(Field, { label: "同步" }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
693
1076
  h("span", { style: { color: T.label, fontSize: 13 } },
694
- (repo.ahead > 0 ? "本地领先 " + repo.ahead + " 提交" : "") +
695
- (repo.ahead > 0 && repo.behind > 0 ? "、落后 " + repo.behind + " 提交" : (repo.behind > 0 ? "落后 " + repo.behind + " 提交" : "")) +
696
- ((repo.ahead === 0 && repo.behind === 0) ? "与远程一致" : "")
1077
+ // 没有(属于当前账号/平台的)远程时不做同步判断,避免误显示「与远程一致」
1078
+ !repo.hasRemote
1079
+ ? "(无)"
1080
+ : (repo.ahead > 0 ? "本地领先 " + repo.ahead + " 提交" : "") +
1081
+ (repo.ahead > 0 && repo.behind > 0 ? "、落后 " + repo.behind + " 提交" : (repo.behind > 0 ? "落后 " + repo.behind + " 提交" : "")) +
1082
+ ((repo.ahead === 0 && repo.behind === 0) ? "与远程一致" : "")
697
1083
  ),
698
1084
  (repo.ahead > 0 || repo.behind > 0) ? h("button", {
699
1085
  type: "button",
@@ -750,11 +1136,46 @@ window.__ModuleLoader__.load({
750
1136
  btns.push(h("span", { key: "synced", style: { color: T.success, fontSize: 13 } }, "✓ 已是最新"));
751
1137
  }
752
1138
  }
1139
+ // 刷新/切换平台进行中给出明确提示,让用户知道正在联网同步或重新检测。
1140
+ const refreshingTip = refreshing
1141
+ ? h("div", { style: { color: T.brand, fontSize: 12, marginTop: 8 } }, "⟳ 正在刷新状态,联网同步 GitHub/Gitee 最新数据…")
1142
+ : switching
1143
+ ? h("div", { style: { color: T.brand, fontSize: 12, marginTop: 8 } }, "⟳ 切换平台,正在重新检测「" + (prov === "gitee" ? "Gitee" : "GitHub") + "」仓库状态…")
1144
+ : null
753
1145
  return h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" } },
754
1146
  btns,
755
- h(Btn, { label: "刷新状态", onClick: () => { setResult(null); setMsg(null); setErr(null); loadRepo(dir); }, disabled: busy })
1147
+ // 「推送暂存」:放在「推送更改」与「刷新状态」之间,按需显示——
1148
+ // 有远程且「本地领先有提交」或「有已暂存的改动」时才出现(未装 better-sidebar 时)。
1149
+ (!hasBetterSidebar && repo && repo.hasRemote
1150
+ && (repo.ahead > 0 || ((repo.changedFiles || []).some((f) => f.staged)))) ? h(Btn, {
1151
+ key: "push-staged",
1152
+ label: pushStagedBusy ? "推送中…" : "推送暂存",
1153
+ onClick: doPushStaged,
1154
+ tone: "primary", noBg: true,
1155
+ disabled: busy || pushStagedBusy || !dir,
1156
+ title: "把已暂存的内容用填写的(或自动生成的)信息提交后推送到远程;不会自动暂存其他未暂存的改动",
1157
+ }) : null,
1158
+ // keepRepo=true 保留旧内容避免闪烁;full=true 触发 loadRepo 的「刷新中…」提示 + 联网同步。
1159
+ h(Btn, {
1160
+ label: refreshing ? "刷新中…" : "刷新状态",
1161
+ onClick: () => { setResult(null); setMsg(null); setErr(null); loadRepo(dir, true, true); },
1162
+ disabled: busy || refreshing,
1163
+ }),
1164
+ refreshingTip,
756
1165
  );
757
1166
  })(),
1167
+ // 提交信息输入框 + 「提交」(仅 git 仓库且有改动时、且未装 better-sidebar 时显示)。
1168
+ !hasBetterSidebar && repo && repo.isGitRepo && repo.dirty ? h("div", { style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center" } },
1169
+ h("input", {
1170
+ type: "text", value: commitMsg,
1171
+ placeholder: "提交信息(留空则用默认)",
1172
+ title: "填写提交信息后点「提交」;留空则自动生成(chore: update <文件夹名>)",
1173
+ onChange: (e) => setCommitMsg(e.target.value),
1174
+ onKeyDown: (e) => { if (e.key === "Enter") void doCommit(); },
1175
+ style: { flex: 1, font: "inherit", fontSize: 12, padding: "6px 10px", border: "1px solid " + T.border, borderRadius: 8, background: T.layer1, color: T.label, outline: "none" },
1176
+ }),
1177
+ h(Btn, { label: "提交", onClick: doCommit, tone: "primary", noBg: true, disabled: busy || stageBusy || repo.dirtyCount <= 0 || !dir }),
1178
+ ) : null,
758
1179
  h("div", { style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center" } },
759
1180
  h("input", {
760
1181
  type: "text", value: repoName, readOnly: true,
@@ -810,20 +1231,137 @@ window.__ModuleLoader__.load({
810
1231
  detail ? ReactDOM.createPortal(
811
1232
  h("div", { style: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center" }, role: "presentation" },
812
1233
  h("div", { style: { position: "absolute", inset: 0, background: T.mask }, "aria-hidden": "true", onClick: () => setDetail(null) }),
813
- h("div", { style: { position: "relative", zIndex: 1, width: 520, maxWidth: "calc(100vw - 48px)", maxHeight: "calc(100vh - 100px)", display: "flex", flexDirection: "column", background: "var(--dsw-alias-bg-layer-3, #fff)", border: "1px solid " + T.border, borderRadius: 14, padding: 18, color: T.label, boxShadow: "var(--dsw-overlay-shadow, 0 12px 32px rgba(0,0,0,.35))" }, role: "dialog", "aria-modal": "true", "aria-label": "查看详情" },
1234
+ h("div", { style: { position: "relative", zIndex: 1, width: detail === "changes" ? 700 : 520, maxWidth: "calc(100vw - 48px)", maxHeight: "calc(100vh - 100px)", display: "flex", flexDirection: "column", background: "var(--dsw-alias-bg-layer-3, #fff)", border: "1px solid " + T.border, borderRadius: 14, padding: 18, color: T.label, boxShadow: "var(--dsw-overlay-shadow, 0 12px 32px rgba(0,0,0,.35))" }, role: "dialog", "aria-modal": "true", "aria-label": "查看详情" },
814
1235
  h("div", { style: { display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 10 } },
815
1236
  h("div", { style: { fontSize: 15, fontWeight: 600, flex: 1 } },
816
- detail === "changes" ? "改动文件" : "与远程同步差异"),
1237
+ detail === "changes" ? "改动文件"
1238
+ : detail === "branches" ? "切换分支"
1239
+ : detail === "history" ? "提交历史"
1240
+ : "与远程同步差异"),
817
1241
  h("button", { type: "button", style: closeBtnStyle(), "aria-label": "关闭", onClick: () => setDetail(null) }, "✕")
818
1242
  ),
819
1243
  h("div", { style: { flex: 1, overflow: "auto" } },
820
- detail === "changes" ? (
1244
+ detail === "changes" ? h(React.Fragment, null,
1245
+ hasBetterSidebar ? h("div", { style: { marginBottom: 10, padding: "8px 10px", borderRadius: 8, background: T.layer1, color: T.secondary, fontSize: 12, lineHeight: 1.6 } },
1246
+ "已安装 dsh-better-sidebar,此处只列改动文件列表;具体改动内容请到 dsh-better-sidebar 的「源代码管理 / Git 面板」查看。")
1247
+ : null,
821
1248
  repo && repo.changedFiles && repo.changedFiles.length > 0
822
- ? repo.changedFiles.map((f, i) => h("div", { key: i, style: { display: "flex", gap: 8, alignItems: "baseline", fontSize: 13, lineHeight: 1.8, borderBottom: "1px solid " + T.border, padding: "3px 2px" } },
823
- h("span", { style: { color: typeColor(f.type), fontSize: 11, flexShrink: 0, width: 52 } }, typeLabel(f.type)),
824
- h("span", { style: { color: T.label, wordBreak: "break-all", flex: 1 } }, f.path)
825
- ))
1249
+ ? repo.changedFiles.map((f, i) => {
1250
+ // untracked 新文件 git 不跟踪,没有 diff,不可展开。
1251
+ // 未装 better-sidebar 才能展开 diff(装了它由它的 Git 面板负责,避免重复)。
1252
+ const canHaveDiff = !hasBetterSidebar && f.type !== 'untracked'
1253
+ const open = hasBetterSidebar ? false : expandedDiffs.has(i)
1254
+ const d = diffs[i] || {}
1255
+ const onClick = canHaveDiff ? async () => {
1256
+ const next = new Set(expandedDiffs)
1257
+ if (next.has(i)) { next.delete(i); setExpandedDiffs(next); return }
1258
+ next.add(i); setExpandedDiffs(next)
1259
+ // 优先用预取缓存(点击「查看」秒出);未命中才按需请求并写入缓存。
1260
+ if (!diffs[i]) {
1261
+ const key = (repo.dir || "") + "\u0000" + f.path
1262
+ const cached = cache.diffs[key]
1263
+ // 命中且非失败标记(null=上次预取失败),才秒出;否则重新请求。
1264
+ if (cached !== undefined && cached !== null) {
1265
+ setDiffs(prev => ({ ...prev, [i]: { loading: false, diff: cached, error: null } }))
1266
+ return
1267
+ }
1268
+ setDiffs(prev => ({ ...prev, [i]: { loading: true, diff: '' } }))
1269
+ try {
1270
+ const r = await jget("/repo-diff?dir=" + encodeURIComponent(repo.dir || "") + "&path=" + encodeURIComponent(f.path))
1271
+ const diff = (r && r.diff) || ''
1272
+ cache.diffs[key] = diff
1273
+ setDiffs(prev => ({ ...prev, [i]: { loading: false, diff } }))
1274
+ } catch (e) {
1275
+ cache.diffs[key] = null
1276
+ setDiffs(prev => ({ ...prev, [i]: { loading: false, diff: '', error: String(e) } }))
1277
+ }
1278
+ }
1279
+ } : undefined
1280
+ return h("div", { key: i },
1281
+ h("div", {
1282
+ style: {
1283
+ display: "flex", gap: 8, alignItems: "center",
1284
+ fontSize: 13, lineHeight: 1.8,
1285
+ borderBottom: "1px solid " + T.border, padding: "3px 2px",
1286
+ cursor: canHaveDiff ? "pointer" : "default",
1287
+ },
1288
+ title: canHaveDiff ? (open ? "点击收起" : "点击查看改动内容") : "新文件(untracked)暂无内容 diff",
1289
+ onClick: onClick,
1290
+ },
1291
+ h("span", { style: { color: typeColor(f.type), fontSize: 11, flexShrink: 0, width: 52 } }, typeLabel(f.type)),
1292
+ h("span", { style: { color: T.label, wordBreak: "break-all", flex: 1 } }, f.path),
1293
+ !hasBetterSidebar ? h(React.Fragment, null,
1294
+ h("span", { style: { fontSize: 11, flexShrink: 0, color: f.staged ? T.success : T.secondary } }, f.staged ? "已暂存" : "未暂存"),
1295
+ h("button", {
1296
+ type: "button",
1297
+ title: f.staged ? "取消暂存" : "暂存",
1298
+ disabled: stageBusy,
1299
+ style: changeBtnStyle(),
1300
+ onClick: (e) => { e.stopPropagation(); if (f.staged) void doUnstage(f.path); else void doStage(f.path); },
1301
+ }, f.staged ? "取消暂存" : "暂存"),
1302
+ canHaveDiff ? h("span", { style: { color: T.brand, fontSize: 11, flexShrink: 0 } },
1303
+ d.loading ? "加载中…" : (open ? "▾ 收起" : "▸ 查看"))
1304
+ : null
1305
+ ) : null
1306
+ ),
1307
+ open && canHaveDiff ? (
1308
+ d.loading ? h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "正在加载改动内容…")
1309
+ : d.error ? h("div", { style: { fontSize: 12, color: T.danger, padding: "4px 8px" } }, "❌ " + d.error)
1310
+ : d.diff ? renderSideBySideDiff(d.diff) : h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "(无内容差异)")
1311
+ ) : null
1312
+ );
1313
+ })
826
1314
  : h("div", { style: { color: T.secondary, fontSize: 13 } }, "当前没有改动。")
1315
+ ) : detail === "branches" ? (
1316
+ branchBusy ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "正在读取分支…")
1317
+ : branches.length === 0 ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "(无分支)")
1318
+ : branches.map((b) =>
1319
+ h("div", { key: b, style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 2px", borderBottom: "1px solid " + T.border } },
1320
+ h("span", { style: { flex: 1, color: b === branchCurrent ? T.success : T.label, fontSize: 13, fontWeight: b === branchCurrent ? 600 : 400 } },
1321
+ b + (b === branchCurrent ? "(当前)" : "")),
1322
+ b !== branchCurrent ? h(Btn, { label: "切换", onClick: () => doCheckout(b), disabled: historyBusy || branchBusy, noBg: true, tone: "primary" }) : null
1323
+ )
1324
+ )
1325
+ ) : detail === "history" ? (
1326
+ commitsLoading ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "正在读取历史…")
1327
+ : commits.length === 0 ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "(暂无提交)")
1328
+ : commits.map((c) => {
1329
+ const hd = historyDetail && historyDetail.hash === c.hash ? historyDetail : null
1330
+ const menuOpen = commitMenu === c.hashFull
1331
+ // 菜单项 hover 高亮。
1332
+ const hoverProps = () => ({
1333
+ onMouseEnter: (e) => { e.currentTarget.style.background = T.hover; },
1334
+ onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; },
1335
+ })
1336
+ return h("div", { key: c.hashFull, style: { borderBottom: "1px solid " + T.border, padding: "6px 2px", position: "relative" }, onContextMenu: (e) => { e.preventDefault(); setCommitMenu(c.hashFull); } },
1337
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
1338
+ h("span", { style: { fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", fontSize: 12, color: T.brand, flexShrink: 0 } }, c.hash),
1339
+ h("span", { style: { flex: 1, color: T.label, fontSize: 13, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.subject || ""),
1340
+ h("button", { type: "button", title: "更多操作(右键也可打开)", disabled: historyBusy, style: changeBtnStyle(), onClick: (e) => { e.stopPropagation(); setCommitMenu(menuOpen ? null : c.hashFull); } }, "⋯")
1341
+ ),
1342
+ h("div", { style: { fontSize: 11, color: T.secondary, marginTop: 2 } },
1343
+ c.author + (c.date ? " · " + c.date : "")),
1344
+ menuOpen ? h("div", { style: menuPanelStyle() },
1345
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => { setCommitMenu(null); openCommitDiff(c.hash); } },
1346
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "▸"), "查看提交差异"),
1347
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => copyCommit("short", c) },
1348
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "⧉"), "复制短哈希"),
1349
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => copyCommit("full", c) },
1350
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "⧉"), "复制完整哈希"),
1351
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => copyCommit("msg", c) },
1352
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "⧉"), "复制提交信息"),
1353
+ h("div", { ...hoverProps(), style: menuItemStyle({ danger: true }), onClick: () => { setCommitMenu(null); doRevert(c.hash, c.subject); } },
1354
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.danger } }, "↩"), "还原此提交"),
1355
+ h("div", { ...hoverProps(), style: menuItemStyle({ danger: true }), onClick: () => { setCommitMenu(null); doCherryPick(c.hash, c.subject); } },
1356
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.danger } }, "↪"), "拾取此提交")
1357
+ ) : null,
1358
+ hd ? (
1359
+ hd.loading ? h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "正在加载提交 diff…")
1360
+ : hd.error ? h("div", { style: { fontSize: 12, color: T.danger, padding: "4px 8px" } }, "❌ " + hd.error)
1361
+ : hd.diff ? renderSideBySideDiff(hd.diff) : h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "(无内容差异)")
1362
+ ) : null
1363
+ )
1364
+ })
827
1365
  ) : (
828
1366
  h("div", null,
829
1367
  repo && (repo.ahead > 0 || repo.behind > 0) ? h("div", null,
@@ -870,6 +1408,10 @@ window.__ModuleLoader__.load({
870
1408
  )
871
1409
  ),
872
1410
  document.body
1411
+ ) : null,
1412
+ copiedTip ? ReactDOM.createPortal(
1413
+ h("div", { style: { position: "fixed", top: 16, left: "50%", transform: "translateX(-50%)", zIndex: 2147483000, padding: "6px 14px", borderRadius: 8, fontSize: 13, color: "#fff", background: "var(--dsw-alias-state-success-primary, #22c55e)", boxShadow: "var(--dsw-overlay-shadow, 0 4px 12px rgba(0,0,0,.3))" } }, "✅ " + copiedTip),
1414
+ document.body
873
1415
  ) : null
874
1416
  );
875
1417
  }
@@ -888,6 +1430,49 @@ window.__ModuleLoader__.load({
888
1430
  };
889
1431
  }
890
1432
 
1433
+ /** 提交行「更多操作」下拉面板样式(右对齐、向上弹出,避免在弹窗底部被裁)。 */
1434
+ function menuPanelStyle() {
1435
+ return {
1436
+ position: "absolute", right: 0, bottom: "calc(100% + 4px)", zIndex: 5,
1437
+ width: 180, padding: "4px 0",
1438
+ background: "var(--dsw-alias-bg-layer-3, #fff)",
1439
+ border: "1px solid " + T.border, borderRadius: 10,
1440
+ boxShadow: "var(--dsw-overlay-shadow, 0 8px 24px rgba(0,0,0,.3))",
1441
+ };
1442
+ }
1443
+ /** 下拉菜单单项样式。 */
1444
+ function menuItemStyle({ danger } = {}) {
1445
+ return {
1446
+ display: "flex", alignItems: "center", gap: 8,
1447
+ padding: "7px 12px", fontSize: 13, cursor: "pointer",
1448
+ color: danger ? T.danger : T.label,
1449
+ };
1450
+ }
1451
+
1452
+ /** 复制文本到剪贴板(优先异步 Clipboard API,回退 execCommand)。 */
1453
+ function copyText(text) {
1454
+ const str = String(text == null ? "" : text);
1455
+ try {
1456
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1457
+ return navigator.clipboard.writeText(str).then(() => true).catch(() => fallbackCopy(str));
1458
+ }
1459
+ } catch {}
1460
+ return Promise.resolve(fallbackCopy(str));
1461
+ }
1462
+ function fallbackCopy(str) {
1463
+ try {
1464
+ const ta = document.createElement("textarea");
1465
+ ta.value = str;
1466
+ ta.style.position = "fixed";
1467
+ ta.style.opacity = "0";
1468
+ document.body.appendChild(ta);
1469
+ ta.select();
1470
+ const ok = document.execCommand("copy");
1471
+ ta.remove();
1472
+ return ok;
1473
+ } catch { return false; }
1474
+ }
1475
+
891
1476
  /** Chinese label for a changed-file status type. */
892
1477
  function typeLabel(t) {
893
1478
  return { untracked: "新增", added: "新增", deleted: "删除", renamed: "重命名", modified: "修改" }[t] || "修改";
@@ -897,14 +1482,95 @@ window.__ModuleLoader__.load({
897
1482
  return { untracked: T.success, added: T.success, deleted: T.danger, renamed: T.warn, modified: T.brand }[t] || T.label;
898
1483
  }
899
1484
 
1485
+ /**
1486
+ * Render a unified git diff as colored lines: 删除行红色带 -,新增行绿色带 +,
1487
+ * 其余(上下文/文件头/@@ 行)为灰色。逐行解析,保持 monospace 等宽。
1488
+ */
1489
+ function renderDiff(diff) {
1490
+ if (!diff || typeof diff !== "string") return null;
1491
+ const lines = diff.replace(/\r\n/g, "\n").split("\n");
1492
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
1493
+ const out = [];
1494
+ for (let i = 0; i < lines.length; i++) {
1495
+ const line = lines[i];
1496
+ let color = T.secondary;
1497
+ if (line.startsWith("+") && !line.startsWith("+++")) color = T.success;
1498
+ else if (line.startsWith("-") && !line.startsWith("---")) color = T.danger;
1499
+ out.push(h("pre", { key: i, style: { margin: 0, padding: "0 6px", fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", fontSize: 12, lineHeight: 1.6, color, whiteSpace: "pre-wrap", wordBreak: "break-all", background: color === T.success ? "rgba(34,197,94,.10)" : color === T.danger ? "rgba(239,68,68,.10)" : "transparent" } }, line));
1500
+ }
1501
+ return h("div", { style: { background: "rgba(0,0,0,.06)", borderRadius: 8, padding: "6px 2px", margin: "4px 0 8px", maxHeight: 260, overflow: "auto" } }, out);
1502
+ }
1503
+
1504
+ /**
1505
+ * 把 unified diff 文本解析成「并排」行对(左=旧/删除,右=新/新增)。
1506
+ * 按 `@@` 块 + `+`/`-`/` ` 前缀配对:删除行与新增行在同一行左右并排显示,
1507
+ * 上下文行左右相同;`` 与文件头(---/+++ /diff)跳过。
1508
+ * @returns {Array<{ left: string|null, right: string|null, oldNo?: string, newNo?: string, type: 'del'|'add'|'ctx' }>}
1509
+ */
1510
+ function parseUnifiedDiff(diff) {
1511
+ if (!diff || typeof diff !== "string") return [];
1512
+ const lines = diff.replace(/\r\n/g, "\n").split("\n");
1513
+ const rows = [];
1514
+ let oldNo = 0, newNo = 0;
1515
+ for (let i = 0; i < lines.length; i++) {
1516
+ const line = lines[i];
1517
+ // 跳过文件头 / 块头。
1518
+ if (line.startsWith("@@")) {
1519
+ const m = /-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?/.exec(line);
1520
+ if (m) { oldNo = parseInt(m[1], 10) || 1; newNo = parseInt(m[2], 10) || 1; }
1521
+ continue;
1522
+ }
1523
+ if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff ") || line.startsWith("index ")) continue;
1524
+ if (line.startsWith("\\")) continue; // ""
1525
+ const c = line.charAt(0);
1526
+ if (c === "-") {
1527
+ rows.push({ left: line.slice(1), right: "", type: "del", oldNo: oldNo++, newNo: "" });
1528
+ } else if (c === "+") {
1529
+ rows.push({ left: "", right: line.slice(1), type: "add", oldNo: "", newNo: newNo++ });
1530
+ } else if (c === " ") {
1531
+ rows.push({ left: line.slice(1), right: line.slice(1), type: "ctx", oldNo: oldNo++, newNo: newNo++ });
1532
+ } else {
1533
+ // 非 diff 行(如无前缀段):当上下文处理。
1534
+ rows.push({ left: line, right: line, type: "ctx", oldNo: oldNo++, newNo: newNo++ });
1535
+ }
1536
+ }
1537
+ return rows;
1538
+ }
1539
+
1540
+ /** 并排 diff 渲染器:删除行红底居左、新增行绿底居右,上下文两列相同。 */
1541
+ function renderSideBySideDiff(diff) {
1542
+ const rows = parseUnifiedDiff(diff);
1543
+ if (rows.length === 0) return renderDiff(diff);
1544
+ const mono = { fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", fontSize: 12, lineHeight: 1.55, whiteSpace: "pre-wrap", wordBreak: "break-all" };
1545
+ const cellBg = { del: "rgba(239,68,68,.12)", add: "rgba(34,197,94,.12)", ctx: "transparent" };
1546
+ const gutter = { ...mono, fontSize: 10, lineHeight: 1.55, color: T.secondary, textAlign: "right", padding: "0 6px", whiteSpace: "nowrap", wordBreak: "normal", opacity: 0.7 };
1547
+ return h("div", { style: { border: "1px solid " + T.border, borderRadius: 8, overflow: "hidden", margin: "4px 0 8px", maxHeight: 280, overflowY: "auto", background: "rgba(0,0,0,.04)" } },
1548
+ h("div", { style: { display: "flex", borderBottom: "1px solid " + T.border, position: "sticky", top: 0, background: "var(--dsw-alias-bg-layer-3, #fff)", zIndex: 1 } },
1549
+ h("div", { style: { flex: 1, padding: "3px 8px", fontSize: 12, fontWeight: 600, color: T.danger } }, "旧版本"),
1550
+ h("div", { style: { flex: 1, padding: "3px 8px", fontSize: 12, fontWeight: 600, color: T.success } }, "新版本")
1551
+ ),
1552
+ rows.map((r, i) =>
1553
+ h("div", { key: i, style: { display: "flex", background: cellBg[r.type] || "transparent", borderBottom: r.type === "ctx" ? "none" : "1px solid rgba(128,128,128,.1)" } },
1554
+ h("div", { style: { width: 34, flexShrink: 0, ...gutter } }, r.oldNo || ""),
1555
+ h("div", { style: { flex: 1, padding: "0 0 0 2px", color: r.type === "del" ? T.danger : T.label } }, h("pre", { style: { margin: 0, ...mono } }, r.left || " ")),
1556
+ h("div", { style: { width: 34, flexShrink: 0, ...gutter } }, r.newNo || ""),
1557
+ h("div", { style: { flex: 1, padding: "0 0 0 2px", color: r.type === "add" ? T.success : r.type === "del" ? "rgba(128,128,128,.6)" : T.label } }, h("pre", { style: { margin: 0, ...mono } }, r.right || " "))
1558
+ )
1559
+ )
1560
+ );
1561
+ }
1562
+
900
1563
  // ---------- the main panel ----------
901
- function ScmPanel({ onClose }) {
1564
+ // `variant` 决定布局:'drawer'(右侧栏集成面板,显示关闭按钮、填充容器)与 'tab'
1565
+ // (作为 dsh-better-sidebar 侧边栏 Tab 内容,填充容器、隐藏关闭按钮)。
1566
+ function ScmPanel({ onClose, variant }) {
902
1567
  // 代码托管平台选择在②里操作、在③里跟随:lift 到面板级别共享。
903
1568
  const [provider, setProvider] = useState("github");
904
- return h("div", { style: panelStyle(T), role: "dialog", "aria-modal": "true", "aria-label": "源代码管理" },
1569
+ const embedded = variant === "tab" || variant === "drawer";
1570
+ return h("div", { style: panelStyle(variant), role: "dialog", "aria-modal": embedded ? undefined : "true", "aria-label": "源代码管理" },
905
1571
  h("div", { style: { display: "flex", alignItems: "flex-start", gap: 12 } },
906
1572
  h("h2", { style: { margin: 0, fontSize: 16, fontWeight: 600, lineHeight: 1.4, flex: 1 } }, "源代码管理"),
907
- h("button", { type: "button", style: closeBtnStyle(T), "aria-label": "关闭", onClick: onClose }, "✕")
1573
+ variant === "tab" ? null : h("button", { type: "button", style: closeBtnStyle(T), "aria-label": "关闭", onClick: onClose }, "✕")
908
1574
  ),
909
1575
  h("p", { style: { margin: "6px 0 14px", color: T.secondary, fontSize: 12, lineHeight: 1.6 } },
910
1576
  "按顺序完成:①环境检查 → ②SSH 密钥与连接 → ③代码管理。推送会自动忽略 >100MB 的文件(" + (provider === "gitee" ? "Gitee" : "GitHub") + " 限制)并说明原因。"),
@@ -916,7 +1582,18 @@ window.__ModuleLoader__.load({
916
1582
  );
917
1583
  }
918
1584
 
919
- function panelStyle() {
1585
+ function panelStyle(variant) {
1586
+ // 'tab' / 'drawer':填充宿主容器(不设固定宽高、无边框阴影圆角),
1587
+ // 由外层(dsh-better-sidebar 的 Tab 区或本插件的右侧面板)负责尺寸与表面。
1588
+ if (variant === "tab" || variant === "drawer") {
1589
+ return {
1590
+ position: "relative", display: "flex", flexDirection: "column", gap: 4,
1591
+ width: "100%", maxWidth: "100%", height: "100%", maxHeight: "100%",
1592
+ boxSizing: "border-box", overflow: "auto",
1593
+ padding: variant === "tab" ? 16 : 20, borderRadius: 0,
1594
+ background: "transparent", border: "none", color: T.label,
1595
+ };
1596
+ }
920
1597
  return {
921
1598
  position: "relative", zIndex: 1, display: "flex", flexDirection: "column", gap: 4,
922
1599
  width: 620, maxWidth: "calc(100vw - 48px)",
@@ -930,19 +1607,35 @@ window.__ModuleLoader__.load({
930
1607
  return { appearance: "none", border: "none", background: "transparent", color: T.secondary, cursor: "pointer", fontSize: 18, lineHeight: 1, padding: "2px 6px", borderRadius: 6 };
931
1608
  }
932
1609
 
933
- function overlayStyle() {
934
- return { position: "fixed", inset: 0, zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" };
1610
+ /** 注入的布局推挤样式(仅一次):右侧面板展开时把 #root 往左推,形成「集成侧边栏」,
1611
+ * 与 dsh-better-sidebar 右侧面板同一机制(margin + width calc)。 */
1612
+ const LAYOUT_CSS_ID = "source-code-mgmt-layout";
1613
+ let layoutCssInjected = false;
1614
+ function ensureLayoutCss() {
1615
+ if (layoutCssInjected) return;
1616
+ layoutCssInjected = true;
1617
+ const tag = document.createElement("style");
1618
+ tag.id = LAYOUT_CSS_ID;
1619
+ tag.setAttribute("data-source-code-mgmt-css", "");
1620
+ tag.textContent =
1621
+ // 推挤 #root:右侧面板占据布局而非浮在内容上方(VSCode 侧边栏手感),
1622
+ // 用 calc(100% - var) 避免桌面壳把 #root 设成 width:100% 时的加性溢出。
1623
+ "#root {" +
1624
+ " margin-right: var(--scm-push, 0px);" +
1625
+ " width: calc(100% - var(--scm-push, 0px));" +
1626
+ " transition: margin-right var(--ds-transition-duration-slow, .25s) var(--ds-ease-in-out, ease)," +
1627
+ " width var(--ds-transition-duration-slow, .25s) var(--ds-ease-in-out, ease);" +
1628
+ " }\n" +
1629
+ "body[data-source-code-mgmt-dragging] #root { transition: none; }\n" +
1630
+ "body[data-source-code-mgmt-dragging] { cursor: col-resize; user-select: none; }\n";
1631
+ document.head.appendChild(tag);
935
1632
  }
936
- function maskStyle() {
937
- return { position: "absolute", inset: 0, background: T.mask, backdropFilter: "var(--dsw-mask-blur, blur(4px))" };
938
- }
939
-
940
- // ---------- the sidebar footer trigger ----------
941
- function ScmTrigger({ wide }) {
942
- const [open, setOpen] = useState(false);
943
1633
 
944
- const icon = h("svg", {
945
- viewBox: "0 0 16 16", width: wide ? 16 : 18, height: wide ? 16 : 18,
1634
+ /** 共享的「仓库 / 分支」小图标:既用于左轨按钮,也用作侧边栏 Tab 图标。 */
1635
+ function repoIcon(size) {
1636
+ const s = size || 18;
1637
+ return h("svg", {
1638
+ viewBox: "0 0 16 16", width: s, height: s,
946
1639
  fill: "none", stroke: "currentColor", strokeWidth: 1.5,
947
1640
  strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
948
1641
  },
@@ -950,55 +1643,199 @@ window.__ModuleLoader__.load({
950
1643
  h("rect", { x: "1.5", y: "2.5", width: "9", height: "11", rx: "1.5" }),
951
1644
  h("path", { d: "M14 6.5v3.5a2 2 0 0 1-2 2H5.5" })
952
1645
  );
1646
+ }
953
1647
 
954
- const wideStyle = {
955
- display: "inline-flex", alignItems: "center", gap: 6,
956
- height: 30, padding: "0 12px", border: "none", borderRadius: 15,
957
- background: "transparent", color: T.secondary, cursor: "pointer",
958
- fontSize: 13, lineHeight: 1,
1648
+ // ---------- branch B:右上角 header 入口 + 右侧集成面板 ----------
1649
+ // 未安装 dsh-better-sidebar 时使用:把「代码管理」按钮注册进 DSH
1650
+ // conversation.session.header.utilities 槽位(Session log 所在的右对齐列表),
1651
+ // 因此它天然出现在 Session log 旁、样式一致的胶囊按钮、间距 8px 不挤在一起;
1652
+ // 点击后在右侧展开一个 dsh-better-sidebar 外观的固定面板(推挤 #root),复用 ScmPanel。
1653
+ const SCM_PANEL_W = 620;
1654
+
1655
+ /** Session log 同款胶囊按钮样式(描边、圆角 18、透明底),放在 header utilities 列表里。 */
1656
+ function headerBtnStyle() {
1657
+ return {
1658
+ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 4,
1659
+ height: 32, padding: "6px 12px",
1660
+ border: "1px solid " + T.border, borderRadius: 18,
1661
+ color: T.label, background: "transparent",
1662
+ font: "inherit", fontSize: 13, fontWeight: 400, lineHeight: 1,
1663
+ cursor: "pointer", whiteSpace: "nowrap",
959
1664
  };
960
- const railStyle = {
961
- display: "inline-flex", alignItems: "center", justifyContent: "center",
962
- width: 36, height: 36, border: "none", borderRadius: "50%", padding: 0,
963
- background: "transparent", color: T.secondary, cursor: "pointer",
1665
+ }
1666
+
1667
+ /** 右侧面板的最小 / 最大宽度(拖拽改宽时钳制,防止拖得过窄或超出视口)。 */
1668
+ const SCM_PANEL_MIN = 360;
1669
+ const SCM_PANEL_MAX_FRAC = 0.92; // 拖拽上限:视口宽度的 92%
1670
+
1671
+ /** 右侧集成面板拖拽改宽:拖动面板左边缘调整宽度(像 dsh-better-sidebar)。要点:
1672
+ * 拖拽期间逐帧直接写 DOM(面板 style.width + --scm-push CSS 变量),避免每帧触发
1673
+ * React 重渲染造成卡顿;在 pointerup 时把最终宽度提交进 state,重开面板仍保持。 */
1674
+ function makeResizeHandler(getPanelEl, getW, onCommit) {
1675
+ return (e) => {
1676
+ e.preventDefault();
1677
+ const startX = e.clientX;
1678
+ const startW = getW();
1679
+ let lastW = startW;
1680
+ const body = document.body;
1681
+ body.setAttribute("data-source-code-mgmt-dragging", "");
1682
+ const onMove = (ev) => {
1683
+ // 面板贴右(right:0),往左拖 = 变宽;增量 = 起点X - 当前X。
1684
+ let w = startW + (startX - ev.clientX);
1685
+ const max = Math.round(window.innerWidth * SCM_PANEL_MAX_FRAC);
1686
+ w = Math.round(Math.max(SCM_PANEL_MIN, Math.min(max, w)));
1687
+ lastW = w;
1688
+ const el = getPanelEl();
1689
+ if (el) el.style.width = w + "px";
1690
+ document.documentElement.style.setProperty("--scm-push", w + "px");
1691
+ };
1692
+ const onUp = () => {
1693
+ body.removeAttribute("data-source-code-mgmt-dragging");
1694
+ window.removeEventListener("pointermove", onMove);
1695
+ window.removeEventListener("pointerup", onUp);
1696
+ if (lastW !== startW) onCommit(lastW);
1697
+ };
1698
+ window.addEventListener("pointermove", onMove);
1699
+ window.addEventListener("pointerup", onUp);
964
1700
  };
1701
+ }
965
1702
 
1703
+ /** 右上角「代码管理」按钮 + 点击后打开的右侧集成面板(dsh-better-sidebar 外观,
1704
+ * 可拖拽左边缘调整宽度)。 */
1705
+ function HeaderScmAction() {
1706
+ const [open, setOpen] = useState(false);
1707
+ const [panelW, setPanelW] = useState(SCM_PANEL_W);
1708
+ const panelRef = useRef(null);
1709
+ useEffect(() => { ensureLayoutCss(); }, []);
1710
+ // 右侧面板打开时推挤 #root(margin-right),关闭时归零。宽度跟随 panelW。
1711
+ useEffect(() => {
1712
+ document.documentElement.style.setProperty("--scm-push", open ? panelW + "px" : "0px");
1713
+ return () => { document.documentElement.style.removeProperty("--scm-push"); };
1714
+ }, [open, panelW]);
1715
+ // 拖拽改宽处理器(面板左边缘的拖拽条)。
1716
+ const onResize = makeResizeHandler(
1717
+ () => panelRef.current,
1718
+ () => panelW,
1719
+ (w) => setPanelW(w),
1720
+ );
966
1721
  return h(React.Fragment, null,
967
- h("button", {
968
- type: "button",
969
- style: wide ? wideStyle : railStyle,
970
- title: "源代码管理",
971
- "aria-label": "源代码管理",
972
- onClick: () => setOpen(true),
973
- onMouseEnter: (e) => { e.currentTarget.style.background = T.hover; e.currentTarget.style.color = T.label; },
974
- onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.secondary; },
975
- },
976
- icon,
977
- wide ? h("span", { style: { fontSize: 13 } }, "代码管理") : null
978
- ),
1722
+ h("button", { type: "button", style: headerBtnStyle(), title: "源代码管理", "aria-label": "源代码管理", onClick: () => setOpen(true) },
1723
+ repoIcon(14), h("span", { style: { fontSize: 13 } }, "代码管理")),
979
1724
  open ? ReactDOM.createPortal(
980
- h("div", { style: overlayStyle(), role: "presentation" },
981
- h("div", { style: maskStyle(), "aria-hidden": "true", onClick: () => setOpen(false) }),
982
- h(ScmPanel, { onClose: () => setOpen(false) })
1725
+ h("div", { style: { position: "fixed", inset: 0, zIndex: 1000, pointerEvents: "none" }, role: "presentation" },
1726
+ h("div", { ref: panelRef, style: {
1727
+ position: "absolute", top: 0, right: 0, bottom: 0, width: panelW + "px",
1728
+ boxSizing: "border-box", overflow: "visible", pointerEvents: "auto",
1729
+ background: "var(--dsw-alias-bg-layer-1, rgba(128,128,128,.08))",
1730
+ borderLeft: "1px solid " + T.border,
1731
+ boxShadow: "var(--dsw-overlay-shadow, 0 12px 32px rgba(0,0,0,.35))",
1732
+ } },
1733
+ // 左边缘拖拽条(改宽)
1734
+ h("div", {
1735
+ onPointerDown: onResize,
1736
+ title: "拖动调整面板宽度",
1737
+ "aria-label": "拖动调整面板宽度",
1738
+ style: {
1739
+ position: "absolute", top: 0, bottom: 0, left: -3, width: 7,
1740
+ cursor: "col-resize", pointerEvents: "auto", zIndex: 1,
1741
+ },
1742
+ }),
1743
+ h(ScmPanel, { variant: "drawer", onClose: () => setOpen(false) })
1744
+ )
983
1745
  ),
984
1746
  document.body
985
1747
  ) : null
986
1748
  );
987
1749
  }
988
1750
 
1751
+ /** 挂载一个经典脚本 React 根(React 18 用 createRoot,老版本回退 render)。 */
1752
+ function mountClientRoot(container, element) {
1753
+ try {
1754
+ if (ReactDOM.createRoot) {
1755
+ const root = ReactDOM.createRoot(container);
1756
+ root.render(element);
1757
+ return root;
1758
+ }
1759
+ if (ReactDOM.render) {
1760
+ ReactDOM.render(element, container);
1761
+ return { unmount: () => ReactDOM.unmountComponentAtNode(container) };
1762
+ }
1763
+ } catch (e) { console.error("[source-code-mgmt] mount failed:", e); }
1764
+ return null;
1765
+ }
1766
+
989
1767
  // ---------- cordis plugin body ----------
990
- const inject = ["slots"];
1768
+ // 不写死 inject:better-sidebar 是可选集成,未安装时绝不能因为缺服务而让本
1769
+ // 插件报错。改在 apply 里用 ctx.get('betterSidebar') 判空——一次性属性读取,
1770
+ // 零 I/O、零网络,毫秒级,不影响 DSH 启动速度。
1771
+ const inject = [];
1772
+
1773
+ // 是否已安装(并激活)dsh-better-sidebar:决定「③代码管理」是否隐藏本插件自带的
1774
+ // 本地 Git 工作流(暂存/提交/分支/历史/并排 diff)——这些能力 better-sidebar 的
1775
+ // Git 面板已覆盖,装了它就不重复展示。React 组件通过这个模块级标记读取。
1776
+ let hasBetterSidebar = false;
991
1777
 
992
1778
  function apply(ctx) {
993
1779
  // DSH 打开(插件激活)时就预取环境/SSH/默认工作区/仓库状态,
994
1780
  // 点开「代码管理」面板时直接使用缓存,无需重新加载。
995
1781
  void preload();
996
- if (!ctx.slots || typeof ctx.slots.inject !== "function") return;
997
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
998
- name: "sidebar.footer.action",
999
- id: PLUGIN_ID,
1000
- order: -100,
1001
- }, ScmTrigger));
1782
+
1783
+ // better-sidebar 是可选集成:若本插件先于它激活,第一次读取会拿到 undefined;
1784
+ // 这里用一次性重试保证无论激活顺序如何,最终都能正确落到分支 A(注册 Tab)。
1785
+ let entryUnmount = null;
1786
+ hasBetterSidebar = false;
1787
+
1788
+ const tryRegisterTab = () => {
1789
+ const bs = typeof ctx.get === "function" ? ctx.get("betterSidebar") : undefined;
1790
+ if (!bs || typeof bs.registerTab !== "function") return false;
1791
+ // 分支 A:已安装 dsh-better-sidebar —— 把「代码管理」注册成它的侧边栏新 Tab
1792
+ // 页面。ctx.effect 保证 HMR / 插件卸载时自动注销该 Tab。
1793
+ hasBetterSidebar = true;
1794
+ ctx.effect(() => bs.registerTab({
1795
+ id: PLUGIN_ID,
1796
+ title: "代码管理",
1797
+ icon: (size) => repoIcon(size),
1798
+ order: 50,
1799
+ single: true,
1800
+ component: () => h(ScmPanel, { variant: "tab" }),
1801
+ }));
1802
+ // 若此前已挂载了分支 B 的 header 入口(降级路径),立即拆除它。
1803
+ if (entryUnmount) { entryUnmount(); entryUnmount = null; }
1804
+ return true;
1805
+ };
1806
+
1807
+ if (tryRegisterTab()) return;
1808
+
1809
+ // 分支 B:此刻未检测到 dsh-better-sidebar —— 把「代码管理」按钮注册进 DSH 的
1810
+ // conversation.session.header.utilities 槽位(Session log 所在的右对齐列表),
1811
+ // 因此它天然出现在 Session log 旁、间距 8px 不挤在一起;点击后 HeaderScmAction
1812
+ // 打开右侧 dsh-better-sidebar 外观的集成面板(推挤 #root)。
1813
+ const slots = typeof ctx.get === "function" ? ctx.get("slots") : undefined;
1814
+ if (slots && typeof slots.inject === "function") {
1815
+ ctx.effect(() => slots.inject('conversation.session.header.utilities', () => slots.register({
1816
+ name: 'conversation.session.header.utilities',
1817
+ id: 'source-code-mgmt',
1818
+ order: 200,
1819
+ }, HeaderScmAction)), 'source-code-mgmt: header utility');
1820
+ } else if (ReactDOM) {
1821
+ // slots 服务不可用(极少数环境)——降级:右上角浮动按钮 + 右侧面板。
1822
+ ctx.effect(() => {
1823
+ const hostEl = document.createElement("div");
1824
+ hostEl.setAttribute("data-source-code-mgmt-entry", "");
1825
+ document.body.appendChild(hostEl);
1826
+ const root = mountClientRoot(hostEl, h(HeaderScmAction));
1827
+ entryUnmount = () => {
1828
+ try { if (root && typeof root.unmount === "function") root.unmount(); } catch {}
1829
+ hostEl.remove();
1830
+ };
1831
+ return () => { if (entryUnmount) { entryUnmount(); entryUnmount = null; } };
1832
+ });
1833
+ }
1834
+
1835
+ // 兜底重试一次:better-sidebar 若在本插件之后激活,延迟补注册 Tab 并拆除 header 入口。
1836
+ // 单次 setTimeout,无轮询、零 I/O,几乎不耗资源;插件卸载时清除。
1837
+ const retryTimer = window.setTimeout(() => { tryRegisterTab(); }, 1500);
1838
+ ctx.effect(() => () => window.clearTimeout(retryTimer));
1002
1839
  }
1003
1840
 
1004
1841
  exports.name = PLUGIN_ID;