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.
- package/README.md +103 -22
- package/lib/client.js +969 -132
- package/lib/index.js +706 -59
- 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)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
/**
|
|
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: {
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
h("
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
},
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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'(同步差异)|
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
533
|
+
// 记录最新 repo 状态(仅作展示历史,绝不再用于「无刷新秒显」)。
|
|
338
534
|
cache.repo = r;
|
|
339
|
-
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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: "分支",
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
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) =>
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
934
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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:
|
|
981
|
-
h("div", {
|
|
982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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;
|