source-code-mgmt 1.1.0 → 1.5.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 +144 -64
  2. package/lib/client.js +897 -122
  3. package/lib/index.js +643 -59
  4. package/package.json +38 -22
package/lib/client.js CHANGED
@@ -1,10 +1,18 @@
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 → render a floating button pinned to the top
11
+ * right; clicking expands a right-side drawer (like better-sidebar's
12
+ * right rail) holding the existing panel.
13
+ *
14
+ * The old left-rail bottom button (`sidebar.footer.action` slot) is removed.
15
+ * Either way clicking the entry opens a "源代码管理" panel with:
8
16
  *
9
17
  * 1. 环境检查 — git / gh presence & version
10
18
  * 2. SSH — key presence, ed25519 generation, github.com ssh config, test
@@ -70,12 +78,50 @@ window.__ModuleLoader__.load({
70
78
  ssh: null,
71
79
  defDir: null,
72
80
  repo: null,
81
+ // 各工作区的 full 同步结果(dir -> repo 状态),打开 DSH 时预取,切换工作区可秒显。
82
+ reposByDir: {},
83
+ // 单文件改动 diff 缓存("dir\u0000path" -> diff 文本),点击「查看」秒出。
84
+ diffs: {},
73
85
  workspaces: [],
74
86
  customDirs: [],
75
87
  ready: false,
76
88
  };
77
89
  let preloadPromise = null;
78
90
 
91
+ /** 清空某个目录(或全部)的 diff 缓存,避免切目录后残留上一个目录的文件 diff。 */
92
+ function clearDiffCache(dir) {
93
+ if (!dir) { cache.diffs = {}; return }
94
+ const prefix = dir + "\u0000"
95
+ for (const key of Object.keys(cache.diffs)) {
96
+ if (key.startsWith(prefix)) delete cache.diffs[key]
97
+ }
98
+ }
99
+
100
+ /** 并发上限 N 地预取一批改动文件的 diff,结果写入 cache.diffs,用于点击「查看」秒出。
101
+ * 纯本地 git 读取(host 端 /repo-diff),并发低、不阻塞主流程,后台慢慢填。 */
102
+ async function prefetchDiffs(dir, files, limit) {
103
+ if (!dir || !Array.isArray(files) || files.length === 0) return;
104
+ let i = 0
105
+ const worker = async () => {
106
+ while (i < files.length) {
107
+ const f = files[i++]
108
+ if (!f || typeof f !== "object") continue
109
+ const key = dir + "\u0000" + f.path
110
+ if (cache.diffs[key] !== undefined) continue
111
+ try {
112
+ // untracked 新文件 git 不跟踪,无 diff,直接标记为空。
113
+ if (f.type === "untracked") { cache.diffs[key] = ""; continue }
114
+ const r = await jget("/repo-diff?dir=" + encodeURIComponent(dir) + "&path=" + encodeURIComponent(f.path)).catch(() => null)
115
+ cache.diffs[key] = (r && typeof r.diff === "string") ? r.diff : null
116
+ } catch { cache.diffs[key] = null }
117
+ }
118
+ }
119
+ const n = Math.max(1, Math.min(limit || 3, files.length || 1))
120
+ const workers = []
121
+ for (let k = 0; k < n; k++) workers.push(worker())
122
+ await Promise.all(workers)
123
+ }
124
+
79
125
  /** 预取 env / ssh / default-dir / repo / workspaces,结果写入 cache。可并发安全。 */
80
126
  function preload() {
81
127
  if (!preloadPromise) {
@@ -97,7 +143,19 @@ window.__ModuleLoader__.load({
97
143
  }
98
144
  if (def && def.dir) {
99
145
  cache.defDir = def.dir;
100
- cache.repo = await jget("/repo?dir=" + encodeURIComponent(def.dir)).catch(() => null);
146
+ }
147
+ // 打开 DSH 时即对所有工作区做一次完整同步(full=1 会 git fetch 联网),
148
+ // 这样点开「代码管理」或切换任意工作区都直接显示已同步的最新状态,无需再等。
149
+ // 用并发上限 5 控制,避免一次性对大量仓库同时打满网络。
150
+ const dirs = Array.from(new Set([
151
+ ...(cache.workspaces || []),
152
+ ...(cache.customDirs || []),
153
+ ...(def && def.dir ? [def.dir] : []),
154
+ ].filter(Boolean)))
155
+ await syncAllRepos(dirs, 5)
156
+ // 默认工作区的同步结果作为面板首次秒显内容。
157
+ if (def && def.dir && cache.reposByDir[def.dir]) {
158
+ cache.repo = cache.reposByDir[def.dir];
101
159
  }
102
160
  cache.ready = true;
103
161
  } catch {
@@ -107,6 +165,24 @@ window.__ModuleLoader__.load({
107
165
  }
108
166
  return preloadPromise;
109
167
  }
168
+
169
+ /** 并发上限 N 地对所有目录做 full 同步,结果写入 cache.reposByDir[dir]。 */
170
+ async function syncAllRepos(dirs, limit) {
171
+ let i = 0
172
+ const worker = async () => {
173
+ while (i < dirs.length) {
174
+ const d = dirs[i++]
175
+ try {
176
+ const r = await jget("/repo?dir=" + encodeURIComponent(d) + "&full=1").catch(() => null)
177
+ if (r) cache.reposByDir[d] = r
178
+ } catch { /* 单个失败不影响其他 */ }
179
+ }
180
+ }
181
+ const workers = []
182
+ const n = Math.max(1, Math.min(limit || 5, dirs.length || 1))
183
+ for (let k = 0; k < n; k++) workers.push(worker())
184
+ await Promise.all(workers)
185
+ }
110
186
  /** 忽略缓存强制重新拉取某个资源并更新 cache。 */
111
187
  async function refreshCache(key) {
112
188
  if (key === "env") cache.env = await jget("/env").catch(() => cache.env);
@@ -125,9 +201,6 @@ window.__ModuleLoader__.load({
125
201
  // ---------- small presentational bits ----------
126
202
  const h = React.createElement;
127
203
 
128
- function Dot({ color }) {
129
- return h("span", { style: { color, marginRight: 4 } }, "●");
130
- }
131
204
  function Field({ label, value, children }) {
132
205
  return h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginBottom: 6 } },
133
206
  h("span", { style: { color: T.secondary, fontSize: 13, width: 84, flexShrink: 0 } }, label),
@@ -159,20 +232,59 @@ window.__ModuleLoader__.load({
159
232
  },
160
233
  }, label);
161
234
  }
162
- function Box({ title, children }) {
235
+ function Box({ title, badge, defaultCollapsed, collapsible, titleExtra, collapsed: controlledCollapsed, onToggle, children }) {
236
+ // 每个部分默认可折叠:标题行右侧一个「折叠/展开」按钮,点击收起只显示标题。
237
+ // defaultCollapsed 控制初始是否收起(如①环境检查全就绪时默认折叠)。
238
+ // titleExtra:标题行里标题与折叠按钮之间的额外内容(如②的平台切换下拉),
239
+ // 折叠时仍可见、可交互(切换平台会由调用方触发展开)。
240
+ // 受控模式:传入 collapsed/onToggle 时由父组件接管折叠状态(用于「切换标题行控件
241
+ // 后自动展开」);否则用内部 state。
242
+ const [inner, setInner] = useState(!!defaultCollapsed);
243
+ const collapsed = controlledCollapsed !== undefined ? !!controlledCollapsed : inner;
244
+ const setCollapsed = controlledCollapsed !== undefined ? onToggle : setInner;
245
+ const canCollapse = collapsible !== false;
163
246
  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
247
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" } },
248
+ h("span", { style: { fontSize: 13, fontWeight: 600, color: T.label, flexShrink: 0, minWidth: 0 } }, title),
249
+ titleExtra || null,
250
+ badge ? h("span", { style: { fontSize: 12, color: T.success, flexShrink: 0, whiteSpace: "nowrap" } }, badge) : null,
251
+ canCollapse ? h("span", { style: { flex: 1 } }) : null,
252
+ canCollapse ? h("button", {
253
+ type: "button",
254
+ title: collapsed ? "展开" : "折叠",
255
+ "aria-label": collapsed ? "展开该部分" : "折叠该部分",
256
+ onClick: () => setCollapsed(!collapsed),
257
+ style: collapseBtnStyle(),
258
+ }, collapsed ? "▸" : "▾") : null
259
+ ),
260
+ collapsed ? null : h("div", { style: { marginTop: 10 } }, children)
166
261
  );
167
262
  }
263
+ // 折叠/展开按钮样式(标题行右侧的小圆角按钮)。
264
+ function collapseBtnStyle() {
265
+ return {
266
+ appearance: "none", border: "none", background: "transparent",
267
+ color: T.secondary, cursor: "pointer", fontSize: 14, lineHeight: 1,
268
+ width: 24, height: 24, borderRadius: 6, flexShrink: 0, padding: 0,
269
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
270
+ };
271
+ }
168
272
  function pre(code) {
169
273
  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
274
  }
171
275
 
172
276
  // ---------- section 1: 环境检查 ----------
277
+ // 每类工具缺失时给出安装命令 + 「一键安装」按钮(best-effort,走 host 自动选包管理器)。
278
+ const INSTALL_HINTS = {
279
+ git: "winget install --id Git.Git -e",
280
+ gh: "winget install --id GitHub.cli -e",
281
+ ssh: "Add-WindowsCapability -Online -Name OpenSSH.Client",
282
+ };
173
283
  function EnvSection() {
174
284
  const [env, setEnv] = useState(() => cache.env);
175
285
  const [err, setErr] = useState(null);
286
+ const [installing, setInstalling] = useState(null); // 'git'|'gh'|'ssh'|null
287
+ const [installResult, setInstallResult] = useState(null);
176
288
  const load = useCallback(async () => {
177
289
  setErr(null);
178
290
  // 先读缓存(DSH 打开时已预取),再后台刷新保持最新
@@ -182,16 +294,54 @@ window.__ModuleLoader__.load({
182
294
  }, []);
183
295
  useEffect(() => { void preload().then(load); }, [load]);
184
296
 
185
- return h(Box, { title: "① 环境检查" },
297
+ // 一键安装:调用 host 选定包管理器安装缺失工具,成功后重新检测。
298
+ const doInstall = useCallback(async (tool) => {
299
+ setInstalling(tool); setInstallResult(null);
300
+ try {
301
+ const r = await jpost("/install-tool", { tool });
302
+ setInstallResult(r);
303
+ if (r.ok) void load();
304
+ } catch (e) { setInstallResult({ ok: false, tool, error: String(e) }); }
305
+ finally { setInstalling(null); }
306
+ }, [load]);
307
+
308
+ // 是否所有工具都就绪(git / gh / ssh 均已安装)——就绪时该部分默认折叠,
309
+ // 只显示「① 环境检查」标题 + 「均存在」提示;否则默认展开让用户看到缺什么。
310
+ const allPresent = !!(cache.env && cache.env.git && cache.env.git.installed
311
+ && cache.env.gh && cache.env.gh.installed
312
+ && cache.env.ssh && cache.env.ssh.installed);
313
+ const winOs = (env && env.platform === "win32");
314
+
315
+ // 渲染单个工具行;缺失时带安装命令 + 安装按钮。
316
+ const toolRow = (tool, label, installed, version) => {
317
+ const hint = INSTALL_HINTS[tool] || ""
318
+ const status = installed
319
+ ? h("span", { style: { color: T.label, fontSize: 13 } }, "✅ " + (version || "已安装"))
320
+ : h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
321
+ h("span", { style: { color: T.danger, fontSize: 13 } }, "❌ 未安装"),
322
+ h("button", { type: "button", title: "复制安装命令", disabled: installing !== null, style: changeBtnStyle(), onClick: (e) => { e.stopPropagation(); void copyText(hint); } }, "复制安装命令"),
323
+ h("button", { type: "button", title: "一键安装(会自动选包管理器)", disabled: installing !== null, style: { ...changeBtnStyle(), color: T.brand, fontWeight: 600 }, onClick: (e) => { e.stopPropagation(); void doInstall(tool); } }, installing === tool ? "安装中…" : "安装")
324
+ )
325
+ const feedback = installResult && installResult.tool === tool
326
+ ? (installResult.ok
327
+ ? h("div", { style: { marginTop: 4, color: T.success, fontSize: 12 } }, "✅ " + (installResult.command || "已安装") + " 执行成功" + (installResult.needElevation ? "(如未生效需以管理员身份重试)" : ""))
328
+ : h("div", { style: { marginTop: 4, color: T.danger, fontSize: 12 } }, "❌ " + (installResult.error || "安装失败") + (installResult.command ? ":" + installResult.command : "")))
329
+ : null
330
+ return h("div", { key: tool, style: { marginTop: 4 } },
331
+ h(Field, { label }, status),
332
+ feedback
333
+ )
334
+ }
335
+
336
+ return h(Box, { title: "① 环境检查", badge: allPresent ? "✅ 均存在" : null, defaultCollapsed: !!allPresent },
186
337
  h(Field, { label: "操作系统" }, h("span", { style: { color: T.label, fontSize: 13 } }, env ? (env.platformLabel || env.platform) : "...")),
187
338
  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 ? "已找到" : "❌ 未找到" })
339
+ toolRow("git", "Git", env.git.installed, env.git.version),
340
+ toolRow("gh", "GitHub CLI", env.gh.installed, env.gh.version),
341
+ toolRow("ssh", "SSH", !!(env.ssh && env.ssh.installed), env.ssh && env.ssh.installed ? "已找到" : null)
191
342
  ) : 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 + ")" : ""))
343
+ (!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 } },
344
+ "未找到的工具可用上方「安装」按钮一键安装(" + (winOs ? "Windows winget / 内置功能" : "用系统包管理器") + ",可能需管理员权限);也可手动复制安装命令执行,安装后点「重新检查」。"
195
345
  ) : h(Btn, { label: "重新检查", onClick: load, tone: "ghost" })
196
346
  );
197
347
  }
@@ -205,6 +355,8 @@ window.__ModuleLoader__.load({
205
355
  // 平台状态由 ScmPanel 共享(②里选择,③跟随)
206
356
  const prov = provider || "github";
207
357
  const setProv = setProvider || (() => {});
358
+ // ② 默认折叠;在标题行也能切平台,切换时自动展开(因为要做后续配置)。
359
+ const [collapsed, setCollapsed] = useState(true);
208
360
 
209
361
  const load = useCallback(async () => {
210
362
  setErr(null);
@@ -232,23 +384,30 @@ window.__ModuleLoader__.load({
232
384
  : !!(ssh && ssh.sshGitHubConfigured);
233
385
  const providerHost = prov === "gitee" ? "gitee.com" : "github.com";
234
386
 
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
387
+ // 标题行的平台切换下拉:折叠时也可见、可交互;切换后自动展开(要做配置)。
388
+ const titleExtra = h("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0 } },
389
+ h("span", { style: { fontSize: 12, color: T.secondary } }, "平台"),
390
+ h("select", {
391
+ value: prov,
392
+ onChange: (e) => { setProv(e.target.value); setCollapsed(false); },
393
+ title: "选择代码托管平台:GitHub Gitee(默认 GitHub),③代码管理会跟随切换",
394
+ 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" },
395
+ },
396
+ h("option", { value: "github" }, "GitHub(默认)"),
397
+ h("option", { value: "gitee" }, "Gitee"),
398
+ )
399
+ );
400
+
401
+ return h(Box, {
402
+ title: "② SSH 密钥与连接",
403
+ defaultCollapsed: true,
404
+ collapsed,
405
+ onToggle: (v) => setCollapsed(v),
406
+ titleExtra,
407
+ },
408
+ // key status(显示实际检测到的密钥名,可能不是 id_ed25519)
250
409
  h(Field, { label: "密钥" }, h("span", { style: { color: T.label, fontSize: 13 } },
251
- ssh ? (ssh.hasKey ? "✅ id_ed25519 已生成" : "⚠️ 未生成") : "…")
410
+ ssh ? (ssh.hasKey ? "✅ " + (ssh.keyBase || "id_ed25519") + " 已生成" : "⚠️ 未生成") : "…")
252
411
  ),
253
412
  h(Field, { label: "GH 登录" }, h("span", { style: { color: T.label, fontSize: 13 } },
254
413
  ssh ? (ssh.ghLoggedIn ? "✅ " + (ssh.ghAccount || "已登录") : "⚠️ 未登录") : "…")
@@ -289,6 +448,11 @@ window.__ModuleLoader__.load({
289
448
  const [dirDraft, setDirDraft] = useState(() => cache.defDir ?? "");
290
449
  const [repo, setRepo] = useState(() => cache.repo);
291
450
  const [busy, setBusy] = useState(false);
451
+ // 刷新状态(联网同步)进行中的提示状态,避免刷新时界面闪成「未加载文件夹」。
452
+ const [refreshing, setRefreshing] = useState(false);
453
+ // 切换平台(② 里 GitHub/Gitee)时,③ 需要重新拉取对应平台的仓库状态;此标记在拉取期间为真,
454
+ // 用于显示「切换平台,正在重新检测…」提示——避免保留原内容几秒而让用户以为没反应。
455
+ const [switching, setSwitching] = useState(false);
292
456
  const [result, setResult] = useState(null);
293
457
  const [msg, setMsg] = useState(null);
294
458
  const [err, setErr] = useState(null);
@@ -303,8 +467,30 @@ window.__ModuleLoader__.load({
303
467
  const [giteeTokenBusy, setGiteeTokenBusy] = useState(false);
304
468
  // 自定义目录(用户手动选择的非 DSH 工作区,持久化于插件本地),用于给下拉项加 X 删除
305
469
  const [customDirs, setCustomDirs] = useState(() => cache.customDirs || []);
306
- // 详情弹窗:'changes'(改动文件列表)| 'sync'(同步差异)| null(关闭)
470
+ // 详情弹窗:'changes'(改动文件列表)| 'sync'(同步差异)| 'branches'(分支切换)
471
+ // | 'history'(提交历史)| null(关闭)
307
472
  const [detail, setDetail] = useState(null);
473
+ // 改动详情里被展开显示 diff 的文件下标集合(点击文件名展开/收起内容)
474
+ const [expandedDiffs, setExpandedDiffs] = useState(() => new Set());
475
+ // 按需加载的 diff:{ [index]: { loading, diff, error } },点开文件行时才请求 /repo-diff
476
+ const [diffs, setDiffs] = useState({});
477
+ // 提交信息输入框 + 暂存/取消暂存进行中标记
478
+ const [commitMsg, setCommitMsg] = useState("");
479
+ const [stageBusy, setStageBusy] = useState(false);
480
+ // 分支切换弹窗数据 + 进行中标记
481
+ const [branches, setBranches] = useState([]);
482
+ const [branchCurrent, setBranchCurrent] = useState("");
483
+ const [branchBusy, setBranchBusy] = useState(false);
484
+ // 提交历史弹窗数据 + 进行中标记;historyDetail 存放某条 commit 的并排 diff
485
+ const [commits, setCommits] = useState([]);
486
+ const [commitsLoading, setCommitsLoading] = useState(false);
487
+ const [historyBusy, setHistoryBusy] = useState(false);
488
+ // historyDetail:{ hash, loading, diff, error },点某提交时按需拉 /commit-diff
489
+ const [historyDetail, setHistoryDetail] = useState(null);
490
+ // 提交行右键/更多菜单:当前打开的 commit(hashFull)或 null
491
+ const [commitMenu, setCommitMenu] = useState(null);
492
+ // 复制成功的即时提示(菜单里短暂显示「已复制」)
493
+ const [copiedTip, setCopiedTip] = useState(null);
308
494
  // 仓库名称强制 = 文件夹名(不允许手动填写)
309
495
  const repoName = (repo && repo.defaultRepoName) || "";
310
496
  // 目录选择器状态
@@ -317,13 +503,61 @@ window.__ModuleLoader__.load({
317
503
  const provRef = useRef(prov);
318
504
  provRef.current = prov;
319
505
 
320
- const loadRepo = useCallback(async (d, keepRepo) => {
321
- setErr(null);
322
- if (!keepRepo) setRepo(null);
506
+ // 竞态保护:loadRepo 是异步的(await 网络),首次挂载的预加载请求可能与
507
+ // 用户手动切换工作区/平台的请求并发。若旧请求后返回,会把它对应的目录
508
+ // 内容(并 setDir 改回旧目录)覆盖到界面上,表现为「切换后过一会儿又跳回
509
+ // 切换前的目录」。用自增 token 保证只有最新一次调用才允许写入状态。
510
+ const loadSeq = useRef(0);
511
+
512
+ // 可见性默认取值:优先用当前仓库的实际私有/公开状态;当该工作区没有远程
513
+ // (属于当前账号/平台的远程、即即将新建的仓库)时,默认选「公开」。供
514
+ // loadRepo 的各个返回路径(网络 / 快速缓存 / 预取秒显)统一调用。
515
+ const syncVisibility = useCallback((r) => {
516
+ if (r && r.repoExists === true && (r.visibility === "public" || r.visibility === "private")) {
517
+ setVisibility(r.visibility);
518
+ } else if (r && !r.hasRemote) {
519
+ setVisibility("public");
520
+ }
521
+ }, []);
522
+
523
+ const loadRepo = useCallback(async (d, keepRepo, full) => {
524
+ // 注意:不能在这里无条件清空 err —— 操作回调(推送/拉取/对齐等)会先
525
+ // setErr/setMsg 再调 loadRepo 刷新状态,此处清空会立刻抹掉刚设置的
526
+ // 结果/错误提示,造成「点了按钮没有任何反馈」。需要清空 err 的调用方
527
+ // (切换目录 / 切换平台 / 刷新状态)自行清空。
528
+ // full=true 时才让 host 端执行 git fetch 联网同步(用于「刷新状态」及
529
+ // 推送/拉取/对齐等操作后的刷新);默认 false(快速模式):跳过 fetch,
530
+ // 秒出本地状态。full 模式自动显示「刷新中…」提示,提示只在最新一次调用归来时关闭。
531
+ const doFull = full === true;
532
+ const mySeq = ++loadSeq.current;
533
+ if (doFull) setRefreshing(true);
534
+ const dirty = !keepRepo;
535
+ if (dirty) setRepo(null);
536
+ // 快速模式且预取已同步过该目录:直接秒显缓存,不再等网络(打开 DSH 时已全部 full 同步)。
537
+ // 注意:缓存是按目录存的,但内容随平台(github/gitee)不同。只有缓存的 provider 与
538
+ // 当前平台一致才可秒显;否则跳过缓存,走网络重新拉取对应平台的仓库状态,避免切换
539
+ // 平台后仍显示上一个平台的检测内容。
540
+ const curProv = provRef.current || "github";
541
+ if (!doFull && cache.reposByDir[d] && cache.reposByDir[d].provider === curProv) {
542
+ const cached = cache.reposByDir[d];
543
+ if (mySeq === loadSeq.current) {
544
+ setRepo(cached);
545
+ if (cached.dir) { setDirDraft(cached.dir); setDir(cached.dir); }
546
+ syncVisibility(cached);
547
+ if (!hasBetterSidebar) void prefetchDiffs(cached.dir, cached.changedFiles, 3);
548
+ }
549
+ return;
550
+ }
323
551
  try {
324
- const p = provRef.current || "github";
325
- const r = await jget("/repo?dir=" + encodeURIComponent(d) + "&provider=" + encodeURIComponent(p));
552
+ const p = curProv;
553
+ const r = await jget("/repo?dir=" + encodeURIComponent(d) + "&provider=" + encodeURIComponent(p) + (doFull ? "&full=1" : ""));
554
+ // 竞态保护:若期间又发起了更新的 loadRepo(序列号更大),丢弃本次过期结果,
555
+ // 避免旧请求后到时把界面刷回旧目录。
556
+ if (mySeq !== loadSeq.current) return;
326
557
  setRepo(r);
558
+ // 切换仓库/刷新后清空旧的改动 diff 缓存与展开状态,避免残留上一个目录的内容。
559
+ setExpandedDiffs(new Set());
560
+ setDiffs({});
327
561
  // 默认选中当前工作区:/repo 在未传 dir 时返回当前工作区路径,
328
562
  // 把返回的实际目录同步到输入框和当前选中目录。
329
563
  if (r && r.dir) {
@@ -333,32 +567,46 @@ window.__ModuleLoader__.load({
333
567
  }
334
568
  // 缓存最新 repo 状态
335
569
  cache.repo = r;
336
- // 仓库已存在且能读到实际可见性时,让下拉框默认选中实际状态,
337
- // 这样「修改仓库状态」按钮只在用户主动切换时出现。
338
- if (r && r.repoExists === true && (r.visibility === "public" || r.visibility === "private")) {
339
- setVisibility(r.visibility);
340
- }
570
+ // 按目录缓存(含 provider),下次同一平台可用快速缓存秒显;切平台时因 provider
571
+ // 不匹配会跳过缓存重新拉取,避免显示上个平台的检测内容。
572
+ if (r && r.dir) cache.reposByDir[r.dir] = r;
573
+ // 默认可见性:优先当前仓库实际状态;无远程(即将新建的仓库)默认「公开」。
574
+ syncVisibility(r);
575
+ // 后台预取本目录所有改动文件的 diff,点击「查看」时秒出。
576
+ // 先清掉上次残留的缓存(文件集刚可能变化),再并发拉取。
577
+ clearDiffCache(r && r.dir);
578
+ if (!hasBetterSidebar) void prefetchDiffs(r && r.dir, r && r.changedFiles, 3);
341
579
  if (r && !r.isGitRepo) setErr(r.error || "该目录不是 git 仓库");
342
580
  } catch (e) { setErr(String(e)); }
581
+ finally {
582
+ // 只有本次是最新调用时才收起「刷新中…」提示,避免旧请求失败把提示提前关掉。
583
+ if (doFull && mySeq === loadSeq.current) setRefreshing(false);
584
+ }
343
585
  }, []);
344
586
 
345
587
  useEffect(() => {
346
- // 首次加载:先看缓存(DSH 打开时已预取当前工作区),秒显;
347
- // 无缓存则 preload(取默认工作区)后加载。
348
- if (cache.repo) {
349
- setRepo(cache.repo);
350
- if (cache.defDir) { setDir(cache.defDir); setDirDraft(cache.defDir); }
351
- }
588
+ // 首次加载:优先用 DSH 打开时已 full 同步好的 cache.repo 秒显;
589
+ // 无论预加载是否已在此时完成,都走 preload()(幂等),在 .then 里
590
+ // 直接用预取结果,避免「先显示(未加载文件夹)空白再等一会儿」。
352
591
  if (cache.workspaces && cache.workspaces.length) setWorkspaces(cache.workspaces);
353
592
  if (cache.customDirs && cache.customDirs.length) setCustomDirs(cache.customDirs);
354
593
  void preload().then(() => {
355
594
  if (cache.workspaces && cache.workspaces.length) setWorkspaces(cache.workspaces);
356
595
  if (cache.customDirs && cache.customDirs.length) setCustomDirs(cache.customDirs);
357
- // 默认工作区:优先 defDir,否则第一个工作区
358
596
  const d = cache.defDir
359
597
  || (cache.workspaces && cache.workspaces[0])
360
598
  || dir || "";
361
- if (d) { setDir(d); setDirDraft(d); loadRepo(d); }
599
+ if (!d) return;
600
+ setDir(d); setDirDraft(d);
601
+ if (cache.repo) {
602
+ // 预加载已做 full 同步,直接秒显,不再发重复请求。
603
+ setRepo(cache.repo);
604
+ syncVisibility(cache.repo);
605
+ if (!hasBetterSidebar) void prefetchDiffs(cache.repo.dir, cache.repo.changedFiles, 3);
606
+ } else {
607
+ // 兜底:预加载未拿到(如请求失败),发一次 full 同步刷新。
608
+ loadRepo(d, true, true);
609
+ }
362
610
  });
363
611
  // eslint-disable-next-line react-hooks/exhaustive-deps
364
612
  }, []);
@@ -409,7 +657,13 @@ window.__ModuleLoader__.load({
409
657
  // 平台切换:gitee 时读取令牌状态;无论切到哪都刷新仓库(跟随平台检测/显示)
410
658
  useEffect(() => {
411
659
  if (prov === "gitee") void loadGiteeTokenStatus();
412
- if (dir || cache.defDir) loadRepo(dir || cache.defDir || "", true);
660
+ // 切换平台等同切换检测目标,清掉旧的错误提示,避免残留上一个平台的报错。
661
+ setErr(null);
662
+ if (dir || cache.defDir) {
663
+ // 显示「切换平台,正在重新检测…」提示,避免切换后旧内容停留几秒看起来像没变化。
664
+ setSwitching(true);
665
+ loadRepo(dir || cache.defDir || "", true).finally(() => setSwitching(false));
666
+ }
413
667
  // eslint-disable-next-line react-hooks/exhaustive-deps
414
668
  }, [prov]);
415
669
 
@@ -420,19 +674,23 @@ window.__ModuleLoader__.load({
420
674
  setResult(r);
421
675
  if (r.ok) setMsg("已推送");
422
676
  else setErr(r.error || r.pushError || "推送失败");
423
- void loadRepo(dir, true);
677
+ void loadRepo(dir, true, true);
424
678
  } catch (e) { setErr("推送失败:" + e); }
425
679
  finally { setBusy(false); }
426
680
  }, [dir, loadRepo]);
427
681
 
428
682
  const pull = useCallback(async () => {
683
+ // 「拉取更新」在“本地干净 + 远程有更新”状态出现:本地没有任何改动/提交,
684
+ // reset --hard 不会丢失任何内容,因此直接复用「强制对齐」的实现
685
+ // (git fetch + git reset --hard origin/<branch>),且无需弹确认框,
686
+ // 等同于把本地快速前进到远程最新状态。
429
687
  setBusy(true); setMsg(null); setErr(null); setResult(null);
430
688
  try {
431
- const r = await jpost("/pull", { dir: dir || undefined });
689
+ const r = await jpost("/align", { dir: dir || undefined });
432
690
  setResult(r);
433
- if (r.ok) setMsg(r.upToDate ? "已是最新,无需拉取" : "拉取成功,已更新到最新");
691
+ if (r.ok) setMsg("已拉取远程更新(本地已对齐到远程最新状态)");
434
692
  else setErr(r.error || "拉取失败");
435
- void loadRepo(dir, true);
693
+ void loadRepo(dir, true, true);
436
694
  } catch (e) { setErr("拉取失败:" + e); }
437
695
  finally { setBusy(false); }
438
696
  }, [dir, loadRepo]);
@@ -444,7 +702,7 @@ window.__ModuleLoader__.load({
444
702
  setResult(r);
445
703
  if (r.ok) setMsg("已拉取远程更新并推送更改");
446
704
  else setErr(r.error || "合并推送失败");
447
- void loadRepo(dir, true);
705
+ void loadRepo(dir, true, true);
448
706
  } catch (e) { setErr("合并推送失败:" + e); }
449
707
  finally { setBusy(false); }
450
708
  }, [dir, loadRepo]);
@@ -457,7 +715,7 @@ window.__ModuleLoader__.load({
457
715
  setResult(r);
458
716
  if (r.ok) setMsg("已强制推送,远程已更新为本地状态");
459
717
  else setErr(r.error || "强制推送失败");
460
- void loadRepo(dir, true);
718
+ void loadRepo(dir, true, true);
461
719
  } catch (e) { setErr("强制推送失败:" + e); }
462
720
  finally { setBusy(false); }
463
721
  }, [dir, loadRepo]);
@@ -470,7 +728,7 @@ window.__ModuleLoader__.load({
470
728
  setResult(r);
471
729
  if (r.ok) setMsg("已强制拉取远程更新");
472
730
  else setErr(r.error || "强制拉取失败");
473
- void loadRepo(dir, true);
731
+ void loadRepo(dir, true, true);
474
732
  } catch (e) { setErr("强制拉取失败:" + e); }
475
733
  finally { setBusy(false); }
476
734
  }, [dir, loadRepo]);
@@ -486,7 +744,7 @@ window.__ModuleLoader__.load({
486
744
  setResult(r);
487
745
  if (r.ok) setMsg("已把仓库设置为「" + (target === "public" ? "公开" : "私有") + "」");
488
746
  else setErr(r.error || "修改可见性失败");
489
- void loadRepo(dir, true);
747
+ void loadRepo(dir, true, true);
490
748
  } catch (e) { setErr("修改可见性失败:" + e); }
491
749
  finally { setBusy(false); }
492
750
  }, [dir, repo, visibility, loadRepo]);
@@ -500,7 +758,7 @@ window.__ModuleLoader__.load({
500
758
  setResult(r);
501
759
  if (r.ok) setMsg("仓库已创建并推送:" + (r.url || name) + (r.provider === "gitee" ? "(Gitee)" : ""));
502
760
  else setErr(r.error || "创建失败");
503
- void loadRepo(dir, true);
761
+ void loadRepo(dir, true, true);
504
762
  } catch (e) { setErr("创建失败:" + e); }
505
763
  finally { setBusy(false); }
506
764
  }, [dir, repo, visibility, loadRepo]);
@@ -600,10 +858,151 @@ window.__ModuleLoader__.load({
600
858
  finally { setBusy(false); }
601
859
  }, [dir, loadRepo]);
602
860
 
861
+ // ---- 本地 Git 工作流:选择性暂存 / 提交 / 分支 / 历史 / revert / cherry-pick ----
862
+
863
+ // 暂存某文件(path 空 = 全部),成功后刷新 repo 状态。
864
+ const doStage = useCallback(async (path) => {
865
+ setStageBusy(true); setErr(null);
866
+ try {
867
+ const r = await jpost("/stage", { dir: dir || undefined, path: path || undefined });
868
+ if (r.ok) {
869
+ // 暂存会改变文件的 staged 状态:清掉该目录的缓存快照,强制走网络路径
870
+ // 重新拉取最新 repo(含最新「已暂存/未暂存」标记),并清 diff 展开状态。
871
+ if (dir) delete cache.reposByDir[dir];
872
+ loadRepo(dir, true);
873
+ } else setErr(r.error || "暂存失败");
874
+ } catch (e) { setErr("暂存失败:" + e); }
875
+ finally { setStageBusy(false); }
876
+ }, [dir, loadRepo]);
877
+
878
+ // 取消暂存某文件(path 空 = 全部)。
879
+ const doUnstage = useCallback(async (path) => {
880
+ setStageBusy(true); setErr(null);
881
+ try {
882
+ const r = await jpost("/unstage", { dir: dir || undefined, path: path || undefined });
883
+ if (r.ok) {
884
+ if (dir) delete cache.reposByDir[dir];
885
+ loadRepo(dir, true);
886
+ } else setErr(r.error || "取消暂存失败");
887
+ } catch (e) { setErr("取消暂存失败:" + e); }
888
+ finally { setStageBusy(false); }
889
+ }, [dir, loadRepo]);
890
+
891
+ // 用自定义信息提交暂存区;成功后刷新并提示。
892
+ const doCommit = useCallback(async () => {
893
+ const name = repoName || (repo && repo.dir ? String(repo.dir).split(/[\\/]/).filter(Boolean).pop() : "");
894
+ const message = commitMsg.trim() || ("chore: update " + name);
895
+ setBusy(true); setMsg(null); setErr(null); setResult(null);
896
+ try {
897
+ const r = await jpost("/commit", { dir: dir || undefined, message });
898
+ setResult(r);
899
+ if (r.ok) {
900
+ setMsg("已提交 " + r.staged + " 个文件" + (r.hash ? "(" + r.hash + ")" : ""));
901
+ setCommitMsg("");
902
+ void loadRepo(dir, true, true);
903
+ } else setErr(r.error || "提交失败");
904
+ } catch (e) { setErr("提交失败:" + e); }
905
+ finally { setBusy(false); }
906
+ }, [dir, repo, commitMsg, repoName, loadRepo]);
907
+
908
+ // 打开分支切换弹窗。
909
+ const openBranches = useCallback(async () => {
910
+ setDetail("branches"); setBranchBusy(true); setBranches([]);
911
+ try {
912
+ const r = await jpost("/branches", { dir: dir || undefined });
913
+ if (r.ok) { setBranches(r.branches || []); setBranchCurrent(r.current || ""); }
914
+ else setErr(r.error || "读取分支失败");
915
+ } catch (e) { setErr("读取分支失败:" + e); }
916
+ finally { setBranchBusy(false); }
917
+ }, [dir]);
918
+
919
+ // 切换到某分支。
920
+ const doCheckout = useCallback(async (branch) => {
921
+ if (branch === branchCurrent) { setDetail(null); return; }
922
+ if (!window.confirm("切换到分支「" + branch + "」?(若当前有未提交改动,git 会拒绝切换)")) return;
923
+ setBranchBusy(true); setMsg(null); setErr(null);
924
+ try {
925
+ const r = await jpost("/checkout", { dir: dir || undefined, branch });
926
+ if (r.ok) {
927
+ setMsg("已切换到分支 " + branch);
928
+ setDetail(null);
929
+ void loadRepo(dir, true, true);
930
+ } else setErr(r.error || "切换分支失败");
931
+ } catch (e) { setErr("切换分支失败:" + e); }
932
+ finally { setBranchBusy(false); }
933
+ }, [dir, branchCurrent, loadRepo]);
934
+
935
+ // 打开提交历史弹窗。
936
+ const openHistory = useCallback(async () => {
937
+ setDetail("history"); setCommitsLoading(true); setCommits([]); setHistoryDetail(null);
938
+ try {
939
+ const r = await jpost("/log", { dir: dir || undefined, count: 30 });
940
+ if (r.ok) setCommits(r.commits || []);
941
+ else setErr(r.error || "读取历史失败");
942
+ } catch (e) { setErr("读取历史失败:" + e); }
943
+ finally { setCommitsLoading(false); }
944
+ }, [dir]);
945
+
946
+ // 对某提交执行 revert(改写历史,需确认)。
947
+ const doRevert = useCallback(async (hash, subject) => {
948
+ if (!window.confirm("revert 提交「" + (subject || hash) + "」——会生成一个反向提交,期间可能产生冲突,确定继续?")) return;
949
+ setHistoryBusy(true); setMsg(null); setErr(null);
950
+ try {
951
+ const r = await jpost("/revert", { dir: dir || undefined, hash });
952
+ if (r.ok) {
953
+ setMsg("已 revert 提交 " + hash);
954
+ setDetail(null);
955
+ void loadRepo(dir, true, true);
956
+ } else setErr(r.error || "revert 失败");
957
+ } catch (e) { setErr("revert 失败:" + e); }
958
+ finally { setHistoryBusy(false); }
959
+ }, [dir, loadRepo]);
960
+
961
+ // 对某提交执行 cherry-pick(改写历史,需确认)。
962
+ const doCherryPick = useCallback(async (hash, subject) => {
963
+ if (!window.confirm("cherry-pick 提交「" + (subject || hash) + "」到当前分支——期间可能产生冲突,确定继续?")) return;
964
+ setHistoryBusy(true); setMsg(null); setErr(null);
965
+ try {
966
+ const r = await jpost("/cherrypick", { dir: dir || undefined, hash });
967
+ if (r.ok) {
968
+ setMsg("已 cherry-pick 提交 " + hash);
969
+ setDetail(null);
970
+ void loadRepo(dir, true, true);
971
+ } else setErr(r.error || "cherry-pick 失败");
972
+ } catch (e) { setErr("cherry-pick 失败:" + e); }
973
+ finally { setHistoryBusy(false); }
974
+ }, [dir, loadRepo]);
975
+
976
+ // 打开某提交的并排 diff(复用 /commit-diff)。
977
+ const openCommitDiff = useCallback(async (hash) => {
978
+ if (historyDetail && historyDetail.hash === hash) { setHistoryDetail(null); return; }
979
+ setHistoryDetail({ hash, loading: true, diff: "", error: null });
980
+ try {
981
+ const r = await jpost("/commit-diff", { dir: dir || undefined, hash });
982
+ setHistoryDetail({ hash, loading: false, diff: (r && r.diff) || "", error: r && !r.ok ? (r.error || "读取失败") : null });
983
+ } catch (e) {
984
+ setHistoryDetail({ hash, loading: false, diff: "", error: String(e) });
985
+ }
986
+ }, [dir, historyDetail]);
987
+
988
+ // 复制提交相关信息(短哈希 / 完整哈希 / 提交信息),成功后短暂提示并关闭菜单。
989
+ const copyCommit = useCallback(async (kind, commit) => {
990
+ let text = "";
991
+ if (kind === "short") text = commit.hash;
992
+ else if (kind === "full") text = commit.hashFull;
993
+ else text = commit.subject || "";
994
+ const ok = await copyText(text);
995
+ setCommitMenu(null);
996
+ if (ok) {
997
+ setCopiedTip("已复制" + (kind === "short" ? "短哈希" : kind === "full" ? "完整哈希" : "提交信息"));
998
+ window.setTimeout(() => setCopiedTip(null), 1600);
999
+ } else {
1000
+ setErr("复制失败:请手动复制");
1001
+ }
1002
+ }, []);
1003
+
603
1004
  return h(Box, { title: "③ 代码管理" },
604
1005
  // 平台提示 + Gitee 令牌(仅 gitee 模式)
605
- h("div", { style: { marginBottom: 10, fontSize: 12, color: T.secondary, lineHeight: 1.6 } },
606
- "当前平台:<" + (prov === "gitee" ? "Gitee" : "GitHub") + ">(在 ② SSH 里切换,③ 的同名检测 / 新建仓库 / 可见性会跟随)。"),
607
1006
  prov === "gitee" ? h("div", { style: { border: "1px solid " + T.border, borderRadius: 10, padding: 10, marginBottom: 10, background: T.layer1 } },
608
1007
  h("div", { style: { fontSize: 12, fontWeight: 600, color: T.label, marginBottom: 6 } },
609
1008
  "Gitee 私人令牌(OpenAPI,需 projects 权限)"),
@@ -668,7 +1067,23 @@ window.__ModuleLoader__.load({
668
1067
  ) : null,
669
1068
  repo && repo.isGitRepo ? h("div", { style: { marginBottom: 10 } },
670
1069
  h(Field, { label: "目录", value: repo.dir }),
671
- h(Field, { label: "分支", value: repo.branch }),
1070
+ h(Field, { label: "分支" }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
1071
+ h("span", { style: { color: T.label, fontSize: 13 } }, repo.branch || "(无)"),
1072
+ !hasBetterSidebar ? h(React.Fragment, null,
1073
+ repo.branch ? h("button", {
1074
+ type: "button",
1075
+ onClick: openBranches,
1076
+ title: "切换分支",
1077
+ style: changeBtnStyle(),
1078
+ }, "切换") : null,
1079
+ h("button", {
1080
+ type: "button",
1081
+ onClick: openHistory,
1082
+ title: "查看提交历史",
1083
+ style: changeBtnStyle(),
1084
+ }, "历史")
1085
+ ) : null
1086
+ )),
672
1087
  h(Field, { label: "远程", value: repo.remoteUrl || "(无)" }),
673
1088
  h(Field, { label: "改动" }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
674
1089
  h("span", { style: { color: T.label, fontSize: 13 } }, repo.dirty ? repo.dirtyCount + " 个文件" : "无"),
@@ -682,9 +1097,12 @@ window.__ModuleLoader__.load({
682
1097
  )),
683
1098
  h(Field, { label: "同步" }, h("span", { style: { display: "inline-flex", alignItems: "center", gap: 8 } },
684
1099
  h("span", { style: { color: T.label, fontSize: 13 } },
685
- (repo.ahead > 0 ? "本地领先 " + repo.ahead + " 提交" : "") +
686
- (repo.ahead > 0 && repo.behind > 0 ? "、落后 " + repo.behind + " 提交" : (repo.behind > 0 ? "落后 " + repo.behind + " 提交" : "")) +
687
- ((repo.ahead === 0 && repo.behind === 0) ? "与远程一致" : "")
1100
+ // 没有(属于当前账号/平台的)远程时不做同步判断,避免误显示「与远程一致」
1101
+ !repo.hasRemote
1102
+ ? "(无)"
1103
+ : (repo.ahead > 0 ? "本地领先 " + repo.ahead + " 提交" : "") +
1104
+ (repo.ahead > 0 && repo.behind > 0 ? "、落后 " + repo.behind + " 提交" : (repo.behind > 0 ? "落后 " + repo.behind + " 提交" : "")) +
1105
+ ((repo.ahead === 0 && repo.behind === 0) ? "与远程一致" : "")
688
1106
  ),
689
1107
  (repo.ahead > 0 || repo.behind > 0) ? h("button", {
690
1108
  type: "button",
@@ -719,30 +1137,57 @@ window.__ModuleLoader__.load({
719
1137
  btns.push(h(Btn, { key: "init", label: "创建 Git", onClick: initGit, tone: "primary", noBg: true, disabled: isBlocked, title: "仅初始化 git 仓库,不拉取不推送,由你决定下一步" }));
720
1138
  } else if (hasRemote) {
721
1139
  if (localChanges && remoteUpdates) {
722
- // 本地和远程都有更新 -> 三按钮:合并推送 / 强制推送 / 强制拉取
723
- btns.push(h(Btn, { key: "mp", label: "拉取更新并推送更改", onClick: mergePush, tone: "primary", noBg: true, disabled: isBlocked }));
724
- btns.push(h(Btn, { key: "fp", label: "强制推送", onClick: forcePush, disabled: isBlocked, title: "用本地版本覆盖远程(远程非本地更改被丢弃)" }));
725
- btns.push(h(Btn, { key: "fpl", label: "强制拉取", onClick: forcePull, disabled: isBlocked, title: "强制并入远程更新" }));
1140
+ // 本地和远程都有更新:原名展示「拉取更新并推送更改 / 强制推送 /
1141
+ // 强制拉取」三按钮,但这三种操作在此场景下容易因未提交改动、
1142
+ // 分支保护等原因失败且行为难预测,故不再展示——该状态唯一的
1143
+ // 操作按钮是「强制对齐」(fetch + reset --hard,本地完全重置为远程)。
1144
+ btns.push(h("span", { key: "both-note", style: { color: T.warn, fontSize: 12 } },
1145
+ "本地有改动且远程有更新,可直接用「强制对齐」将本地重置为远程状态"));
1146
+ if (repo && repo.isGitRepo) {
1147
+ btns.push(h(Btn, { key: "align", label: "强制对齐", onClick: align, disabled: isBlocked, title: "本地完全重置为远程分支(丢弃本地差异),解决“文件相同仍显示同步差异”的情况" }));
1148
+ }
726
1149
  } else if (localChanges) {
727
1150
  // 只有本地有更改 -> 只显示推送(正常 push)
728
1151
  btns.push(h(Btn, { key: "push", label: "推送更改", onClick: push, tone: "primary", noBg: true, disabled: isBlocked || repo?.repoExists === false }));
729
1152
  } else if (remoteUpdates) {
730
- // 只有远程有更新 -> 只显示拉取(正常 pull)
1153
+ // 只有远程有更新(本地干净)-> 只显示「拉取更新」。
1154
+ // 实现复用「强制对齐」逻辑(fetch + reset --hard):本地干净
1155
+ // 无改动可丢,reset 等同快速前进到远程最新,直接执行不弹确认。
731
1156
  btns.push(h(Btn, { key: "pull", label: "拉取更新", onClick: pull, disabled: isBlocked || repo?.repoExists !== true }));
732
1157
  } else {
733
1158
  // 完全同步 -> 无按钮,显示已是最新
734
1159
  btns.push(h("span", { key: "synced", style: { color: T.success, fontSize: 13 } }, "✓ 已是最新"));
735
1160
  }
736
- // 有远程时总是提供「强制对齐」作为兜底(本地完全重置为远程状态)
737
- if (repo && repo.isGitRepo) {
738
- btns.push(h(Btn, { key: "align", label: "强制对齐", onClick: align, disabled: isBlocked, title: "本地完全重置为远程分支(丢弃本地差异),解决“文件相同仍显示同步差异”的情况" }));
739
- }
740
1161
  }
1162
+ // 刷新/切换平台进行中给出明确提示,让用户知道正在联网同步或重新检测。
1163
+ const refreshingTip = refreshing
1164
+ ? h("div", { style: { color: T.brand, fontSize: 12, marginTop: 8 } }, "⟳ 正在刷新状态,联网同步 GitHub/Gitee 最新数据…")
1165
+ : switching
1166
+ ? h("div", { style: { color: T.brand, fontSize: 12, marginTop: 8 } }, "⟳ 切换平台,正在重新检测「" + (prov === "gitee" ? "Gitee" : "GitHub") + "」仓库状态…")
1167
+ : null
741
1168
  return h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" } },
742
1169
  btns,
743
- h(Btn, { label: "刷新状态", onClick: () => { setResult(null); setMsg(null); setErr(null); loadRepo(dir); }, disabled: busy })
1170
+ // keepRepo=true 保留旧内容避免闪烁;full=true 触发 loadRepo 的「刷新中…」提示 + 联网同步。
1171
+ h(Btn, {
1172
+ label: refreshing ? "刷新中…" : "刷新状态",
1173
+ onClick: () => { setResult(null); setMsg(null); setErr(null); loadRepo(dir, true, true); },
1174
+ disabled: busy || refreshing,
1175
+ }),
1176
+ refreshingTip,
744
1177
  );
745
1178
  })(),
1179
+ // 提交信息输入框 + 「提交」(仅 git 仓库且有改动时、且未装 better-sidebar 时显示)。
1180
+ !hasBetterSidebar && repo && repo.isGitRepo && repo.dirty ? h("div", { style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center" } },
1181
+ h("input", {
1182
+ type: "text", value: commitMsg,
1183
+ placeholder: "提交信息(留空则用默认)",
1184
+ title: "填写提交信息后点「提交」;留空则自动生成(chore: update <文件夹名>)",
1185
+ onChange: (e) => setCommitMsg(e.target.value),
1186
+ onKeyDown: (e) => { if (e.key === "Enter") void doCommit(); },
1187
+ 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" },
1188
+ }),
1189
+ h(Btn, { label: "提交", onClick: doCommit, tone: "primary", noBg: true, disabled: busy || stageBusy || repo.dirtyCount <= 0 || !dir }),
1190
+ ) : null,
746
1191
  h("div", { style: { display: "flex", gap: 8, marginTop: 10, alignItems: "center" } },
747
1192
  h("input", {
748
1193
  type: "text", value: repoName, readOnly: true,
@@ -798,20 +1243,137 @@ window.__ModuleLoader__.load({
798
1243
  detail ? ReactDOM.createPortal(
799
1244
  h("div", { style: { position: "fixed", inset: 0, zIndex: 2000, display: "flex", alignItems: "center", justifyContent: "center" }, role: "presentation" },
800
1245
  h("div", { style: { position: "absolute", inset: 0, background: T.mask }, "aria-hidden": "true", onClick: () => setDetail(null) }),
801
- 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": "查看详情" },
1246
+ 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": "查看详情" },
802
1247
  h("div", { style: { display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 10 } },
803
1248
  h("div", { style: { fontSize: 15, fontWeight: 600, flex: 1 } },
804
- detail === "changes" ? "改动文件" : "与远程同步差异"),
1249
+ detail === "changes" ? "改动文件"
1250
+ : detail === "branches" ? "切换分支"
1251
+ : detail === "history" ? "提交历史"
1252
+ : "与远程同步差异"),
805
1253
  h("button", { type: "button", style: closeBtnStyle(), "aria-label": "关闭", onClick: () => setDetail(null) }, "✕")
806
1254
  ),
807
1255
  h("div", { style: { flex: 1, overflow: "auto" } },
808
- detail === "changes" ? (
1256
+ detail === "changes" ? h(React.Fragment, null,
1257
+ hasBetterSidebar ? h("div", { style: { marginBottom: 10, padding: "8px 10px", borderRadius: 8, background: T.layer1, color: T.secondary, fontSize: 12, lineHeight: 1.6 } },
1258
+ "已安装 dsh-better-sidebar,此处只列改动文件列表;具体改动内容请到 dsh-better-sidebar 的「源代码管理 / Git 面板」查看。")
1259
+ : null,
809
1260
  repo && repo.changedFiles && repo.changedFiles.length > 0
810
- ? 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" } },
811
- h("span", { style: { color: typeColor(f.type), fontSize: 11, flexShrink: 0, width: 52 } }, typeLabel(f.type)),
812
- h("span", { style: { color: T.label, wordBreak: "break-all", flex: 1 } }, f.path)
813
- ))
1261
+ ? repo.changedFiles.map((f, i) => {
1262
+ // untracked 新文件 git 不跟踪,没有 diff,不可展开。
1263
+ // 未装 better-sidebar 才能展开 diff(装了它由它的 Git 面板负责,避免重复)。
1264
+ const canHaveDiff = !hasBetterSidebar && f.type !== 'untracked'
1265
+ const open = hasBetterSidebar ? false : expandedDiffs.has(i)
1266
+ const d = diffs[i] || {}
1267
+ const onClick = canHaveDiff ? async () => {
1268
+ const next = new Set(expandedDiffs)
1269
+ if (next.has(i)) { next.delete(i); setExpandedDiffs(next); return }
1270
+ next.add(i); setExpandedDiffs(next)
1271
+ // 优先用预取缓存(点击「查看」秒出);未命中才按需请求并写入缓存。
1272
+ if (!diffs[i]) {
1273
+ const key = (repo.dir || "") + "\u0000" + f.path
1274
+ const cached = cache.diffs[key]
1275
+ // 命中且非失败标记(null=上次预取失败),才秒出;否则重新请求。
1276
+ if (cached !== undefined && cached !== null) {
1277
+ setDiffs(prev => ({ ...prev, [i]: { loading: false, diff: cached, error: null } }))
1278
+ return
1279
+ }
1280
+ setDiffs(prev => ({ ...prev, [i]: { loading: true, diff: '' } }))
1281
+ try {
1282
+ const r = await jget("/repo-diff?dir=" + encodeURIComponent(repo.dir || "") + "&path=" + encodeURIComponent(f.path))
1283
+ const diff = (r && r.diff) || ''
1284
+ cache.diffs[key] = diff
1285
+ setDiffs(prev => ({ ...prev, [i]: { loading: false, diff } }))
1286
+ } catch (e) {
1287
+ cache.diffs[key] = null
1288
+ setDiffs(prev => ({ ...prev, [i]: { loading: false, diff: '', error: String(e) } }))
1289
+ }
1290
+ }
1291
+ } : undefined
1292
+ return h("div", { key: i },
1293
+ h("div", {
1294
+ style: {
1295
+ display: "flex", gap: 8, alignItems: "center",
1296
+ fontSize: 13, lineHeight: 1.8,
1297
+ borderBottom: "1px solid " + T.border, padding: "3px 2px",
1298
+ cursor: canHaveDiff ? "pointer" : "default",
1299
+ },
1300
+ title: canHaveDiff ? (open ? "点击收起" : "点击查看改动内容") : "新文件(untracked)暂无内容 diff",
1301
+ onClick: onClick,
1302
+ },
1303
+ h("span", { style: { color: typeColor(f.type), fontSize: 11, flexShrink: 0, width: 52 } }, typeLabel(f.type)),
1304
+ h("span", { style: { color: T.label, wordBreak: "break-all", flex: 1 } }, f.path),
1305
+ !hasBetterSidebar ? h(React.Fragment, null,
1306
+ h("span", { style: { fontSize: 11, flexShrink: 0, color: f.staged ? T.success : T.secondary } }, f.staged ? "已暂存" : "未暂存"),
1307
+ h("button", {
1308
+ type: "button",
1309
+ title: f.staged ? "取消暂存" : "暂存",
1310
+ disabled: stageBusy,
1311
+ style: changeBtnStyle(),
1312
+ onClick: (e) => { e.stopPropagation(); if (f.staged) void doUnstage(f.path); else void doStage(f.path); },
1313
+ }, f.staged ? "取消暂存" : "暂存"),
1314
+ canHaveDiff ? h("span", { style: { color: T.brand, fontSize: 11, flexShrink: 0 } },
1315
+ d.loading ? "加载中…" : (open ? "▾ 收起" : "▸ 查看"))
1316
+ : null
1317
+ ) : null
1318
+ ),
1319
+ open && canHaveDiff ? (
1320
+ d.loading ? h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "正在加载改动内容…")
1321
+ : d.error ? h("div", { style: { fontSize: 12, color: T.danger, padding: "4px 8px" } }, "❌ " + d.error)
1322
+ : d.diff ? renderSideBySideDiff(d.diff) : h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "(无内容差异)")
1323
+ ) : null
1324
+ );
1325
+ })
814
1326
  : h("div", { style: { color: T.secondary, fontSize: 13 } }, "当前没有改动。")
1327
+ ) : detail === "branches" ? (
1328
+ branchBusy ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "正在读取分支…")
1329
+ : branches.length === 0 ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "(无分支)")
1330
+ : branches.map((b) =>
1331
+ h("div", { key: b, style: { display: "flex", alignItems: "center", gap: 8, padding: "6px 2px", borderBottom: "1px solid " + T.border } },
1332
+ h("span", { style: { flex: 1, color: b === branchCurrent ? T.success : T.label, fontSize: 13, fontWeight: b === branchCurrent ? 600 : 400 } },
1333
+ b + (b === branchCurrent ? "(当前)" : "")),
1334
+ b !== branchCurrent ? h(Btn, { label: "切换", onClick: () => doCheckout(b), disabled: historyBusy || branchBusy, noBg: true, tone: "primary" }) : null
1335
+ )
1336
+ )
1337
+ ) : detail === "history" ? (
1338
+ commitsLoading ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "正在读取历史…")
1339
+ : commits.length === 0 ? h("div", { style: { color: T.secondary, fontSize: 13 } }, "(暂无提交)")
1340
+ : commits.map((c) => {
1341
+ const hd = historyDetail && historyDetail.hash === c.hash ? historyDetail : null
1342
+ const menuOpen = commitMenu === c.hashFull
1343
+ // 菜单项 hover 高亮。
1344
+ const hoverProps = () => ({
1345
+ onMouseEnter: (e) => { e.currentTarget.style.background = T.hover; },
1346
+ onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; },
1347
+ })
1348
+ return h("div", { key: c.hashFull, style: { borderBottom: "1px solid " + T.border, padding: "6px 2px", position: "relative" }, onContextMenu: (e) => { e.preventDefault(); setCommitMenu(c.hashFull); } },
1349
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
1350
+ h("span", { style: { fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", fontSize: 12, color: T.brand, flexShrink: 0 } }, c.hash),
1351
+ h("span", { style: { flex: 1, color: T.label, fontSize: 13, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, c.subject || ""),
1352
+ h("button", { type: "button", title: "更多操作(右键也可打开)", disabled: historyBusy, style: changeBtnStyle(), onClick: (e) => { e.stopPropagation(); setCommitMenu(menuOpen ? null : c.hashFull); } }, "⋯")
1353
+ ),
1354
+ h("div", { style: { fontSize: 11, color: T.secondary, marginTop: 2 } },
1355
+ c.author + (c.date ? " · " + c.date : "")),
1356
+ menuOpen ? h("div", { style: menuPanelStyle() },
1357
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => { setCommitMenu(null); openCommitDiff(c.hash); } },
1358
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "▸"), "查看提交差异"),
1359
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => copyCommit("short", c) },
1360
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "⧉"), "复制短哈希"),
1361
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => copyCommit("full", c) },
1362
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "⧉"), "复制完整哈希"),
1363
+ h("div", { ...hoverProps(), style: menuItemStyle(), onClick: () => copyCommit("msg", c) },
1364
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.secondary } }, "⧉"), "复制提交信息"),
1365
+ h("div", { ...hoverProps(), style: menuItemStyle({ danger: true }), onClick: () => { setCommitMenu(null); doRevert(c.hash, c.subject); } },
1366
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.danger } }, "↩"), "还原此提交"),
1367
+ h("div", { ...hoverProps(), style: menuItemStyle({ danger: true }), onClick: () => { setCommitMenu(null); doCherryPick(c.hash, c.subject); } },
1368
+ h("span", { style: { width: 16, fontSize: 11, textAlign: "center", color: T.danger } }, "↪"), "拾取此提交")
1369
+ ) : null,
1370
+ hd ? (
1371
+ hd.loading ? h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "正在加载提交 diff…")
1372
+ : hd.error ? h("div", { style: { fontSize: 12, color: T.danger, padding: "4px 8px" } }, "❌ " + hd.error)
1373
+ : hd.diff ? renderSideBySideDiff(hd.diff) : h("div", { style: { fontSize: 12, color: T.secondary, padding: "4px 8px" } }, "(无内容差异)")
1374
+ ) : null
1375
+ )
1376
+ })
815
1377
  ) : (
816
1378
  h("div", null,
817
1379
  repo && (repo.ahead > 0 || repo.behind > 0) ? h("div", null,
@@ -858,6 +1420,10 @@ window.__ModuleLoader__.load({
858
1420
  )
859
1421
  ),
860
1422
  document.body
1423
+ ) : null,
1424
+ copiedTip ? ReactDOM.createPortal(
1425
+ 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),
1426
+ document.body
861
1427
  ) : null
862
1428
  );
863
1429
  }
@@ -876,6 +1442,49 @@ window.__ModuleLoader__.load({
876
1442
  };
877
1443
  }
878
1444
 
1445
+ /** 提交行「更多操作」下拉面板样式(右对齐、向上弹出,避免在弹窗底部被裁)。 */
1446
+ function menuPanelStyle() {
1447
+ return {
1448
+ position: "absolute", right: 0, bottom: "calc(100% + 4px)", zIndex: 5,
1449
+ width: 180, padding: "4px 0",
1450
+ background: "var(--dsw-alias-bg-layer-3, #fff)",
1451
+ border: "1px solid " + T.border, borderRadius: 10,
1452
+ boxShadow: "var(--dsw-overlay-shadow, 0 8px 24px rgba(0,0,0,.3))",
1453
+ };
1454
+ }
1455
+ /** 下拉菜单单项样式。 */
1456
+ function menuItemStyle({ danger } = {}) {
1457
+ return {
1458
+ display: "flex", alignItems: "center", gap: 8,
1459
+ padding: "7px 12px", fontSize: 13, cursor: "pointer",
1460
+ color: danger ? T.danger : T.label,
1461
+ };
1462
+ }
1463
+
1464
+ /** 复制文本到剪贴板(优先异步 Clipboard API,回退 execCommand)。 */
1465
+ function copyText(text) {
1466
+ const str = String(text == null ? "" : text);
1467
+ try {
1468
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1469
+ return navigator.clipboard.writeText(str).then(() => true).catch(() => fallbackCopy(str));
1470
+ }
1471
+ } catch {}
1472
+ return Promise.resolve(fallbackCopy(str));
1473
+ }
1474
+ function fallbackCopy(str) {
1475
+ try {
1476
+ const ta = document.createElement("textarea");
1477
+ ta.value = str;
1478
+ ta.style.position = "fixed";
1479
+ ta.style.opacity = "0";
1480
+ document.body.appendChild(ta);
1481
+ ta.select();
1482
+ const ok = document.execCommand("copy");
1483
+ ta.remove();
1484
+ return ok;
1485
+ } catch { return false; }
1486
+ }
1487
+
879
1488
  /** Chinese label for a changed-file status type. */
880
1489
  function typeLabel(t) {
881
1490
  return { untracked: "新增", added: "新增", deleted: "删除", renamed: "重命名", modified: "修改" }[t] || "修改";
@@ -885,14 +1494,95 @@ window.__ModuleLoader__.load({
885
1494
  return { untracked: T.success, added: T.success, deleted: T.danger, renamed: T.warn, modified: T.brand }[t] || T.label;
886
1495
  }
887
1496
 
1497
+ /**
1498
+ * Render a unified git diff as colored lines: 删除行红色带 -,新增行绿色带 +,
1499
+ * 其余(上下文/文件头/@@ 行)为灰色。逐行解析,保持 monospace 等宽。
1500
+ */
1501
+ function renderDiff(diff) {
1502
+ if (!diff || typeof diff !== "string") return null;
1503
+ const lines = diff.replace(/\r\n/g, "\n").split("\n");
1504
+ if (lines.length && lines[lines.length - 1] === "") lines.pop();
1505
+ const out = [];
1506
+ for (let i = 0; i < lines.length; i++) {
1507
+ const line = lines[i];
1508
+ let color = T.secondary;
1509
+ if (line.startsWith("+") && !line.startsWith("+++")) color = T.success;
1510
+ else if (line.startsWith("-") && !line.startsWith("---")) color = T.danger;
1511
+ 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));
1512
+ }
1513
+ return h("div", { style: { background: "rgba(0,0,0,.06)", borderRadius: 8, padding: "6px 2px", margin: "4px 0 8px", maxHeight: 260, overflow: "auto" } }, out);
1514
+ }
1515
+
1516
+ /**
1517
+ * 把 unified diff 文本解析成「并排」行对(左=旧/删除,右=新/新增)。
1518
+ * 按 `@@` 块 + `+`/`-`/` ` 前缀配对:删除行与新增行在同一行左右并排显示,
1519
+ * 上下文行左右相同;`` 与文件头(---/+++ /diff)跳过。
1520
+ * @returns {Array<{ left: string|null, right: string|null, oldNo?: string, newNo?: string, type: 'del'|'add'|'ctx' }>}
1521
+ */
1522
+ function parseUnifiedDiff(diff) {
1523
+ if (!diff || typeof diff !== "string") return [];
1524
+ const lines = diff.replace(/\r\n/g, "\n").split("\n");
1525
+ const rows = [];
1526
+ let oldNo = 0, newNo = 0;
1527
+ for (let i = 0; i < lines.length; i++) {
1528
+ const line = lines[i];
1529
+ // 跳过文件头 / 块头。
1530
+ if (line.startsWith("@@")) {
1531
+ const m = /-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?/.exec(line);
1532
+ if (m) { oldNo = parseInt(m[1], 10) || 1; newNo = parseInt(m[2], 10) || 1; }
1533
+ continue;
1534
+ }
1535
+ if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff ") || line.startsWith("index ")) continue;
1536
+ if (line.startsWith("\\")) continue; // ""
1537
+ const c = line.charAt(0);
1538
+ if (c === "-") {
1539
+ rows.push({ left: line.slice(1), right: "", type: "del", oldNo: oldNo++, newNo: "" });
1540
+ } else if (c === "+") {
1541
+ rows.push({ left: "", right: line.slice(1), type: "add", oldNo: "", newNo: newNo++ });
1542
+ } else if (c === " ") {
1543
+ rows.push({ left: line.slice(1), right: line.slice(1), type: "ctx", oldNo: oldNo++, newNo: newNo++ });
1544
+ } else {
1545
+ // 非 diff 行(如无前缀段):当上下文处理。
1546
+ rows.push({ left: line, right: line, type: "ctx", oldNo: oldNo++, newNo: newNo++ });
1547
+ }
1548
+ }
1549
+ return rows;
1550
+ }
1551
+
1552
+ /** 并排 diff 渲染器:删除行红底居左、新增行绿底居右,上下文两列相同。 */
1553
+ function renderSideBySideDiff(diff) {
1554
+ const rows = parseUnifiedDiff(diff);
1555
+ if (rows.length === 0) return renderDiff(diff);
1556
+ const mono = { fontFamily: "var(--ds-font-family-code, ui-monospace, monospace)", fontSize: 12, lineHeight: 1.55, whiteSpace: "pre-wrap", wordBreak: "break-all" };
1557
+ const cellBg = { del: "rgba(239,68,68,.12)", add: "rgba(34,197,94,.12)", ctx: "transparent" };
1558
+ const gutter = { ...mono, fontSize: 10, lineHeight: 1.55, color: T.secondary, textAlign: "right", padding: "0 6px", whiteSpace: "nowrap", wordBreak: "normal", opacity: 0.7 };
1559
+ 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)" } },
1560
+ h("div", { style: { display: "flex", borderBottom: "1px solid " + T.border, position: "sticky", top: 0, background: "var(--dsw-alias-bg-layer-3, #fff)", zIndex: 1 } },
1561
+ h("div", { style: { flex: 1, padding: "3px 8px", fontSize: 12, fontWeight: 600, color: T.danger } }, "旧版本"),
1562
+ h("div", { style: { flex: 1, padding: "3px 8px", fontSize: 12, fontWeight: 600, color: T.success } }, "新版本")
1563
+ ),
1564
+ rows.map((r, i) =>
1565
+ h("div", { key: i, style: { display: "flex", background: cellBg[r.type] || "transparent", borderBottom: r.type === "ctx" ? "none" : "1px solid rgba(128,128,128,.1)" } },
1566
+ h("div", { style: { width: 34, flexShrink: 0, ...gutter } }, r.oldNo || ""),
1567
+ 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 || " ")),
1568
+ h("div", { style: { width: 34, flexShrink: 0, ...gutter } }, r.newNo || ""),
1569
+ 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 || " "))
1570
+ )
1571
+ )
1572
+ );
1573
+ }
1574
+
888
1575
  // ---------- the main panel ----------
889
- function ScmPanel({ onClose }) {
1576
+ // `variant` 决定布局:'drawer'(右侧栏抽屉,显示关闭按钮)与 'tab'
1577
+ // (作为 dsh-better-sidebar 侧边栏 Tab 内容,填充容器、隐藏关闭按钮)。
1578
+ function ScmPanel({ onClose, variant }) {
890
1579
  // 代码托管平台选择在②里操作、在③里跟随:lift 到面板级别共享。
891
1580
  const [provider, setProvider] = useState("github");
892
- return h("div", { style: panelStyle(T), role: "dialog", "aria-modal": "true", "aria-label": "源代码管理" },
1581
+ const embedded = variant === "tab" || variant === "drawer";
1582
+ return h("div", { style: panelStyle(variant), role: "dialog", "aria-modal": embedded ? undefined : "true", "aria-label": "源代码管理" },
893
1583
  h("div", { style: { display: "flex", alignItems: "flex-start", gap: 12 } },
894
1584
  h("h2", { style: { margin: 0, fontSize: 16, fontWeight: 600, lineHeight: 1.4, flex: 1 } }, "源代码管理"),
895
- h("button", { type: "button", style: closeBtnStyle(T), "aria-label": "关闭", onClick: onClose }, "✕")
1585
+ embedded ? null : h("button", { type: "button", style: closeBtnStyle(T), "aria-label": "关闭", onClick: onClose }, "✕")
896
1586
  ),
897
1587
  h("p", { style: { margin: "6px 0 14px", color: T.secondary, fontSize: 12, lineHeight: 1.6 } },
898
1588
  "按顺序完成:①环境检查 → ②SSH 密钥与连接 → ③代码管理。推送会自动忽略 >100MB 的文件(" + (provider === "gitee" ? "Gitee" : "GitHub") + " 限制)并说明原因。"),
@@ -904,7 +1594,18 @@ window.__ModuleLoader__.load({
904
1594
  );
905
1595
  }
906
1596
 
907
- function panelStyle() {
1597
+ function panelStyle(variant) {
1598
+ // 'tab' / 'drawer':填充宿主容器(不设固定宽高、无边框阴影圆角),由外层
1599
+ // (dsh-better-sidebar 的 Tab 区或右侧抽屉)负责尺寸与表面。
1600
+ if (variant === "tab" || variant === "drawer") {
1601
+ return {
1602
+ position: "relative", display: "flex", flexDirection: "column", gap: 4,
1603
+ width: "100%", maxWidth: "100%", height: "100%", maxHeight: "100%",
1604
+ boxSizing: "border-box", overflow: "auto",
1605
+ padding: variant === "tab" ? 16 : 20, borderRadius: 0,
1606
+ background: "transparent", border: "none", color: T.label,
1607
+ };
1608
+ }
908
1609
  return {
909
1610
  position: "relative", zIndex: 1, display: "flex", flexDirection: "column", gap: 4,
910
1611
  width: 620, maxWidth: "calc(100vw - 48px)",
@@ -918,19 +1619,15 @@ window.__ModuleLoader__.load({
918
1619
  return { appearance: "none", border: "none", background: "transparent", color: T.secondary, cursor: "pointer", fontSize: 18, lineHeight: 1, padding: "2px 6px", borderRadius: 6 };
919
1620
  }
920
1621
 
921
- function overlayStyle() {
922
- return { position: "fixed", inset: 0, zIndex: 1000, display: "flex", alignItems: "center", justifyContent: "center" };
923
- }
924
1622
  function maskStyle() {
925
1623
  return { position: "absolute", inset: 0, background: T.mask, backdropFilter: "var(--dsw-mask-blur, blur(4px))" };
926
1624
  }
927
1625
 
928
- // ---------- the sidebar footer trigger ----------
929
- function ScmTrigger({ wide }) {
930
- const [open, setOpen] = useState(false);
931
-
932
- const icon = h("svg", {
933
- viewBox: "0 0 16 16", width: wide ? 16 : 18, height: wide ? 16 : 18,
1626
+ /** 共享的「仓库 / 分支」小图标:既用于右上角浮动按钮,也用作侧边栏 Tab 图标。 */
1627
+ function repoIcon(size) {
1628
+ const s = size || 18;
1629
+ return h("svg", {
1630
+ viewBox: "0 0 16 16", width: s, height: s,
934
1631
  fill: "none", stroke: "currentColor", strokeWidth: 1.5,
935
1632
  strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true",
936
1633
  },
@@ -938,55 +1635,133 @@ window.__ModuleLoader__.load({
938
1635
  h("rect", { x: "1.5", y: "2.5", width: "9", height: "11", rx: "1.5" }),
939
1636
  h("path", { d: "M14 6.5v3.5a2 2 0 0 1-2 2H5.5" })
940
1637
  );
1638
+ }
941
1639
 
942
- const wideStyle = {
1640
+ /** 右上角浮动按钮样式(未安装 dsh-better-sidebar 时的入口)。 */
1641
+ function floatBtnStyle() {
1642
+ return {
1643
+ position: "fixed", top: 12, right: 16, zIndex: 2147483000,
943
1644
  display: "inline-flex", alignItems: "center", gap: 6,
944
- height: 30, padding: "0 12px", border: "none", borderRadius: 15,
945
- background: "transparent", color: T.secondary, cursor: "pointer",
1645
+ height: 32, padding: "0 12px", border: "none", borderRadius: 16,
1646
+ background: T.brand, color: "#fff", cursor: "pointer",
946
1647
  fontSize: 13, lineHeight: 1,
1648
+ boxShadow: "var(--dsw-overlay-shadow, 0 4px 12px rgba(0,0,0,.3))",
947
1649
  };
948
- const railStyle = {
949
- display: "inline-flex", alignItems: "center", justifyContent: "center",
950
- width: 36, height: 36, border: "none", borderRadius: "50%", padding: 0,
951
- background: "transparent", color: T.secondary, cursor: "pointer",
1650
+ }
1651
+
1652
+ /** 右侧栏抽屉外层样式:右对齐、全高、固定宽度。 */
1653
+ function drawerStyle() {
1654
+ return {
1655
+ position: "relative", zIndex: 1, height: "100%", width: 640, maxWidth: "100vw",
1656
+ marginLeft: "auto", display: "flex", flexDirection: "column",
1657
+ boxSizing: "border-box", padding: 0,
1658
+ background: "var(--dsw-alias-bg-layer-3, #fff)", color: T.label,
1659
+ borderLeft: "1px solid " + T.border,
1660
+ boxShadow: "var(--dsw-overlay-shadow, 0 12px 32px rgba(0,0,0,.35))",
952
1661
  };
1662
+ }
953
1663
 
1664
+ // ---------- branch B:右上角浮动按钮 + 右侧抽屉入口 ----------
1665
+ // 未安装 dsh-better-sidebar 时使用:一个紧贴右上角的浮动按钮,点击后展开
1666
+ // 一个右侧栏抽屉(形态类似 dsh-better-sidebar 的右栏),内容放现有 ScmPanel。
1667
+ function ScmFloatingEntry() {
1668
+ const [open, setOpen] = useState(false);
954
1669
  return h(React.Fragment, null,
955
1670
  h("button", {
956
1671
  type: "button",
957
- style: wide ? wideStyle : railStyle,
1672
+ style: floatBtnStyle(),
958
1673
  title: "源代码管理",
959
1674
  "aria-label": "源代码管理",
960
1675
  onClick: () => setOpen(true),
961
- onMouseEnter: (e) => { e.currentTarget.style.background = T.hover; e.currentTarget.style.color = T.label; },
962
- onMouseLeave: (e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = T.secondary; },
963
- },
964
- icon,
965
- wide ? h("span", { style: { fontSize: 13 } }, "代码管理") : null
966
- ),
1676
+ }, repoIcon(16), h("span", { style: { fontSize: 13 } }, "代码管理")),
967
1677
  open ? ReactDOM.createPortal(
968
- h("div", { style: overlayStyle(), role: "presentation" },
1678
+ h("div", { style: { position: "fixed", inset: 0, zIndex: 1000 }, role: "presentation" },
969
1679
  h("div", { style: maskStyle(), "aria-hidden": "true", onClick: () => setOpen(false) }),
970
- h(ScmPanel, { onClose: () => setOpen(false) })
1680
+ h("div", { style: drawerStyle() },
1681
+ h(ScmPanel, { variant: "drawer", onClose: () => setOpen(false) })
1682
+ )
971
1683
  ),
972
1684
  document.body
973
1685
  ) : null
974
1686
  );
975
1687
  }
976
1688
 
1689
+ /** 挂载一个经典脚本 React 根(React 18 用 createRoot,老版本回退 render)。 */
1690
+ function mountClientRoot(container, element) {
1691
+ try {
1692
+ if (ReactDOM.createRoot) {
1693
+ const root = ReactDOM.createRoot(container);
1694
+ root.render(element);
1695
+ return root;
1696
+ }
1697
+ if (ReactDOM.render) {
1698
+ ReactDOM.render(element, container);
1699
+ return { unmount: () => ReactDOM.unmountComponentAtNode(container) };
1700
+ }
1701
+ } catch (e) { console.error("[source-code-mgmt] mount failed:", e); }
1702
+ return null;
1703
+ }
1704
+
977
1705
  // ---------- cordis plugin body ----------
978
- const inject = ["slots"];
1706
+ // 不写死 inject:better-sidebar 是可选集成,未安装时绝不能因为缺服务而让本
1707
+ // 插件报错。改在 apply 里用 ctx.get('betterSidebar') 判空——一次性属性读取,
1708
+ // 零 I/O、零网络,毫秒级,不影响 DSH 启动速度。
1709
+ const inject = [];
1710
+
1711
+ // 是否已安装(并激活)dsh-better-sidebar:决定「③代码管理」是否隐藏本插件自带的
1712
+ // 本地 Git 工作流(暂存/提交/分支/历史/并排 diff)——这些能力 better-sidebar 的
1713
+ // Git 面板已覆盖,装了它就不重复展示。React 组件通过这个模块级标记读取。
1714
+ let hasBetterSidebar = false;
979
1715
 
980
1716
  function apply(ctx) {
981
1717
  // DSH 打开(插件激活)时就预取环境/SSH/默认工作区/仓库状态,
982
1718
  // 点开「代码管理」面板时直接使用缓存,无需重新加载。
983
1719
  void preload();
984
- if (!ctx.slots || typeof ctx.slots.inject !== "function") return;
985
- ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
986
- name: "sidebar.footer.action",
987
- id: PLUGIN_ID,
988
- order: -100,
989
- }, ScmTrigger));
1720
+
1721
+ // better-sidebar 是可选集成:若本插件先于它激活,第一次读取会拿到 undefined;
1722
+ // 这里用一次性重试保证无论激活顺序如何,最终都能正确落到分支 A(注册 Tab)。
1723
+ let entryUnmount = null;
1724
+ hasBetterSidebar = false;
1725
+
1726
+ const tryRegisterTab = () => {
1727
+ const bs = typeof ctx.get === "function" ? ctx.get("betterSidebar") : undefined;
1728
+ if (!bs || typeof bs.registerTab !== "function") return false;
1729
+ // 分支 A:已安装 dsh-better-sidebar —— 把「代码管理」注册成它的侧边栏新 Tab
1730
+ // 页面。ctx.effect 保证 HMR / 插件卸载时自动注销该 Tab。
1731
+ hasBetterSidebar = true;
1732
+ ctx.effect(() => bs.registerTab({
1733
+ id: PLUGIN_ID,
1734
+ title: "代码管理",
1735
+ icon: (size) => repoIcon(size),
1736
+ order: 50,
1737
+ single: true,
1738
+ component: () => h(ScmPanel, { variant: "tab" }),
1739
+ }));
1740
+ // 若此前已挂载了分支 B 的浮动按钮,立即拆除它。
1741
+ if (entryUnmount) { entryUnmount(); entryUnmount = null; }
1742
+ return true;
1743
+ };
1744
+
1745
+ if (tryRegisterTab()) return;
1746
+
1747
+ // 分支 B:此刻未检测到 dsh-better-sidebar —— 右上角浮动按钮 + 右侧栏抽屉。
1748
+ if (!ReactDOM) return;
1749
+ ctx.effect(() => {
1750
+ const hostEl = document.createElement("div");
1751
+ hostEl.setAttribute("data-source-code-mgmt-entry", "");
1752
+ document.body.appendChild(hostEl);
1753
+ const root = mountClientRoot(hostEl, h(ScmFloatingEntry));
1754
+ entryUnmount = () => {
1755
+ try { if (root && typeof root.unmount === "function") root.unmount(); } catch {}
1756
+ hostEl.remove();
1757
+ };
1758
+ return () => { if (entryUnmount) { entryUnmount(); entryUnmount = null; } };
1759
+ });
1760
+
1761
+ // 兜底重试一次:better-sidebar 若在本插件之后激活,延迟补注册 Tab 并拆除浮动按钮。
1762
+ // 单次 setTimeout,无轮询、零 I/O,几乎不耗资源;插件卸载时清除。
1763
+ const retryTimer = window.setTimeout(() => { tryRegisterTab(); }, 1500);
1764
+ ctx.effect(() => () => window.clearTimeout(retryTimer));
990
1765
  }
991
1766
 
992
1767
  exports.name = PLUGIN_ID;