@shiqyka/dsh-git-graph 0.0.0-stage → 0.26.1

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/web/app.js ADDED
@@ -0,0 +1,4374 @@
1
+ "use strict";
2
+ /* ===================== state ===================== */
3
+ let REPO = null;
4
+ let COMMITS = []; // [{hash, parents[], author, email, date, subject, refs[]}]
5
+ let BRANCHES = []; // [{refname, kind, name, sha, head}]
6
+ let WORKSTATUS = null; // {branch, staged[], unstaged[], untracked[], counts}
7
+ let wdDiffCache = new Map(); // workfile cache: "staged\0path" -> display text
8
+
9
+ /* ---- in-memory response cache ----------------------------------------------
10
+ * Scope: this document only. The graph runs in the plugin's own iframe
11
+ * (client.js renders /git-graph/index.html in an <iframe>), so a reload of the
12
+ * DSH page, closing the tab, or closing the plugin destroys the whole frame and
13
+ * every Map here with it. Nothing is written to localStorage/sessionStorage, so
14
+ * no stale commit data can survive a restart — that is the "clear it when the
15
+ * plugin closes" requirement, satisfied by construction rather than by cleanup
16
+ * code that could miss a path.
17
+ *
18
+ * Commit data is immutable (a hash always identifies the same commit), so these
19
+ * entries never need invalidation on their own. The only thing that can change
20
+ * is the working tree, and that uses wdDiffCache, which is cleared explicitly on
21
+ * every gitop/refresh.
22
+ */
23
+ const DETAIL_CACHE = new Map(); // hash -> {info, diff}
24
+ const FILEDIFF_CACHE = new Map(); // hash\0file -> filediff response
25
+ const FILELOG_CACHE = new Map(); // file -> filelog response
26
+ const PAGE_LIMIT = 400; // safety cap so a long session cannot grow unbounded
27
+ function cacheGet(map, key) {
28
+ const v = map.get(key);
29
+ if (v === undefined) return undefined;
30
+ // LRU touch: re-insert so the newest entries survive the eviction below
31
+ map.delete(key); map.set(key, v);
32
+ return v;
33
+ }
34
+ function cacheSet(map, key, val) {
35
+ map.set(key, val);
36
+ if (map.size > PAGE_LIMIT) {
37
+ // drop the oldest entry (Map keeps insertion order)
38
+ const oldest = map.keys().next();
39
+ if (!oldest.done) map.delete(oldest.value);
40
+ }
41
+ }
42
+ /** Drop every cached response. Called when cached data could have gone stale:
43
+ * repo switch, explicit refresh, and any successful gitop. */
44
+ function clearResponseCache() {
45
+ DETAIL_CACHE.clear();
46
+ FILEDIFF_CACHE.clear();
47
+ FILELOG_CACHE.clear();
48
+ }
49
+ function loadStored(key, def) {
50
+ try {
51
+ const v = localStorage.getItem(key);
52
+ return v === null ? def : JSON.parse(v);
53
+ } catch { return def; }
54
+ }
55
+ function saveStored(key, val) {
56
+ try { localStorage.setItem(key, JSON.stringify(val)); } catch { /* ignore */ }
57
+ }
58
+ let WD_SHOW = loadStored("gg2-wdshow", { staged: true, unstaged: true, untracked: true }); // group visibility toggles
59
+ let WD_FOLDED = loadStored("gg2-wdfolded", false); // whole panel collapsed
60
+ const WD_GROUP_FOLDED = new Set(loadStored("gg2-wdgroupfolded", [])); // folded group keys
61
+ const WD_SELECT = new Set(); // "groupKey\0path" of checkbox-selected rows
62
+ /* The one cursor. A mouse click and ↑/↓ both write this and nothing else.
63
+ *
64
+ * It used to be two cursors — `SELECTED` (hash, written by clicks) for the
65
+ * highlighted row, and `KEY_IDX` (index into COMMITS, written by ↑/↓) for the
66
+ * dashed outline. Clicking a row and then pressing ↓ restarted from the top of
67
+ * the list, because the keyboard cursor had never been told where the mouse
68
+ * went. Worse, KEY_IDX was an index into `COMMITS` but was computed from a
69
+ * *filtered row list*, so with a branch filter or a collapsed group the outline
70
+ * could sit on a commit that was not even the one last pressed.
71
+ *
72
+ * A hash is also the only stable identity here: indices shift under filtering,
73
+ * folding and pagination, so any index-based cursor needs re-syncing after
74
+ * every render. */
75
+ let SELECTED = null;
76
+ let SEARCH = "";
77
+ let FILTER = "all"; // "all" | "multi"
78
+ let HIGHLIGHT = null; // branch sha highlighted via legend click (others ghosted)
79
+ let SCALE = 1;
80
+ let PAN = { x: 0, y: 0 };
81
+ let drag = null;
82
+
83
+ const ROW_H = 40, LANE_W = 30, X0 = 26, Y0 = 46, NODE_R = 6;
84
+ const LANE_COLORS = [
85
+ "#58a6ff", "#3fb950", "#d29922", "#bc8cff", "#f778ba", "#39c5cf",
86
+ "#f85149", "#7ee787", "#ffa198", "#a5d6ff", "#e3b341", "#79c0ff"
87
+ ];
88
+ const AUTHOR_COLORS = [
89
+ "#f0883e", "#3fb950", "#d29922", "#bc8cff", "#f778ba", "#39c5cf",
90
+ "#58a6ff", "#7ee787", "#ffa198", "#a5d6ff", "#e3b341", "#f85149"
91
+ ];
92
+ function authorColor(name) {
93
+ let h = 0;
94
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
95
+ return AUTHOR_COLORS[h % AUTHOR_COLORS.length];
96
+ }
97
+
98
+ /* Branch color of a commit: HEAD branch green, other local branches in order,
99
+ * remotes grey, shared history follows the HEAD branch. Cached per render. */
100
+ const BRANCH_PALETTE = LANE_COLORS.filter((c) => c !== "#3fb950");
101
+ let BRANCH_COLOR_CACHE = null;
102
+ function branchColorOf(hash) {
103
+ if (BRANCH_COLOR_CACHE) return BRANCH_COLOR_CACHE.get(hash) || "#8b949e";
104
+ const cache = new Map();
105
+ const headB = BRANCHES.find((b) => b.head);
106
+ if (headB) {
107
+ const set = reachable(headB.sha);
108
+ for (const h of set) cache.set(h, "#3fb950");
109
+ }
110
+ const locals = BRANCHES.filter((b) => b.kind === "local" && !b.head);
111
+ for (let i = 0; i < locals.length; i++) {
112
+ const set = reachable(locals[i].sha);
113
+ const color = BRANCH_PALETTE[i % BRANCH_PALETTE.length];
114
+ for (const h of set) if (!cache.has(h)) cache.set(h, color);
115
+ }
116
+ const remotes = BRANCHES.filter((b) => b.kind === "remote");
117
+ for (const b of remotes) {
118
+ const set = reachable(b.sha);
119
+ for (const h of set) if (!cache.has(h)) cache.set(h, "#6e7681");
120
+ }
121
+ BRANCH_COLOR_CACHE = cache;
122
+ return cache.get(hash) || "#8b949e";
123
+ }
124
+
125
+ const $ = (id) => document.getElementById(id);
126
+ const byHash = () => new Map(COMMITS.map((c) => [c.hash, c]));
127
+
128
+ /* ===================== theme (8 style presets, ported from 拾光记) ===================== */
129
+ // themePref: 'auto' | 'dark' | 'light' | 'sakura' | 'ocean' | 'forest' | 'night' | 'candy' | 'business' | 'starry' | 'tech'
130
+ const THEMES = ["auto", "dark", "light", "sakura", "ocean", "forest", "night", "candy", "business", "starry", "tech"];
131
+ const THEME_LABELS = { auto: "自动", dark: "深色", light: "浅色", sakura: "🌸 樱花", ocean: "🌊 海洋", forest: "🌲 森林", night: "🌙 夜空", candy: "🍬 糖果", business: "💼 商务", starry: "✨ 星河", tech: "🤖 科技" };
132
+ const THEME_CLASSES = ["gg-theme-sakura", "gg-theme-ocean", "gg-theme-forest", "gg-theme-night", "gg-theme-candy", "gg-theme-business", "gg-theme-starry", "gg-theme-tech"];
133
+ /* Theme is stored as JSON like every other key (saveStored), but it used to be
134
+ * written raw (`localStorage.setItem("gg2-theme", themePref)`). Reading it back
135
+ * raw meant the JSON-quoted form `"ocean"` failed `THEMES.includes`, so the key
136
+ * was DELETED and the theme fell back to auto on the next load — the "switching
137
+ * tabs resets my settings" report. Accept both forms and normalise to one. */
138
+ function readStoredTheme() {
139
+ let raw = null;
140
+ try { raw = localStorage.getItem("gg2-theme"); } catch { return "auto"; }
141
+ if (!raw) return "auto";
142
+ let val = raw;
143
+ try { val = JSON.parse(raw); } catch { /* legacy: stored unquoted */ }
144
+ if (THEMES.includes(val)) return val;
145
+ // v0.8 removed violet/sunset presets; drop leftovers so they cannot keep
146
+ // poisoning the read (this is the only place they are cleared).
147
+ try { localStorage.removeItem("gg2-theme"); } catch { /* ignore */ }
148
+ return "auto";
149
+ }
150
+ let themePref = readStoredTheme();
151
+
152
+ function parentIsDark() {
153
+ try {
154
+ const d = window.parent.document.body.dataset.dsDarkTheme;
155
+ return d === undefined || (d !== "false" && d !== "");
156
+ } catch { return true; }
157
+ }
158
+ function currentStyleId() {
159
+ // which style preset is active ('' for auto/dark/light)
160
+ if (THEME_CLASSES.includes("gg-theme-" + themePref)) return themePref;
161
+ return "";
162
+ }
163
+ function applyTheme() {
164
+ const root = document.documentElement;
165
+ root.classList.remove("gg-light", ...THEME_CLASSES);
166
+ if (themePref === "auto") root.classList.toggle("gg-light", !parentIsDark());
167
+ else if (themePref === "light") root.classList.add("gg-light");
168
+ else if (themePref !== "dark") root.classList.add("gg-theme-" + themePref);
169
+ renderBgDecor();
170
+ // Full settings sync, not just the picker: the gear's "not at default" dot is
171
+ // derived from the theme too, and refreshing only the picker left the dot on
172
+ // screen after switching back to a default theme.
173
+ syncSettingsState();
174
+ }
175
+
176
+ /* decorative background layer, ported from 拾光记 renderBgDecor */
177
+ function renderBgDecor() {
178
+ const box = $("ggDeco");
179
+ if (!box) return;
180
+ box.innerHTML = "";
181
+ const style = currentStyleId();
182
+ if (!style) return; // auto/dark/light have no ambient decoration
183
+ const rnd = (a, b) => a + Math.random() * (b - a);
184
+ const add = (cls, st, txt) => {
185
+ const i = document.createElement("i");
186
+ i.className = cls;
187
+ Object.assign(i.style, st || {});
188
+ if (txt) i.textContent = txt;
189
+ box.appendChild(i);
190
+ return i;
191
+ };
192
+ if (style === "starry" || style === "night") {
193
+ const n = style === "starry" ? 56 : 36;
194
+ const sc = "#fff";
195
+ for (let i = 0; i < n; i++) {
196
+ const cls = "star" + (i % 6 === 0 ? " l" : i % 5 === 0 ? " glow" : i % 3 === 0 ? " s" : "");
197
+ add(cls, { left: rnd(0, 96) + "%", top: rnd(0, 94) + "%", animationDelay: rnd(0, 3.5) + "s", animationDuration: rnd(1.8, 4.5) + "s", background: sc, color: sc });
198
+ }
199
+ add("moon", { right: rnd(10, 34) + "px", top: rnd(56, 130) + "px" });
200
+ for (let i = 0; i < 3; i++) add("meteor", { right: rnd(4, 38) + "%", top: rnd(2, 20) + "%", animationDelay: rnd(1.2, 7) + "s" });
201
+ if (style === "starry") add("earth", { right: "-30px", bottom: "-58px" });
202
+ } else if (style === "sakura") {
203
+ for (let i = 0; i < 36; i++) {
204
+ const sz = i % 8 === 0 ? "xl" : i % 4 === 0 ? "l" : i % 5 === 0 ? "s" : "";
205
+ const pc = i % 3 === 0 ? { "--pc1": "#ffc1d8", "--pc2": "#ff8fb3" } : i % 3 === 1 ? { "--pc1": "#ffd6e5", "--pc2": "#ff9ec7" } : { "--pc1": "#ff9db8", "--pc2": "#f76b95" };
206
+ add("petal " + sz, { left: rnd(2, 95) + "%", top: rnd(0, 70) + "%", animationDelay: rnd(0, 9) + "s", animationDuration: rnd(6, 16) + "s", ...pc });
207
+ }
208
+ } else if (style === "ocean") {
209
+ add("wave", {});
210
+ add("wave w2", {});
211
+ for (let i = 0; i < 4; i++) add("sunbeam", { left: rnd(8, 72) + "%", animationDelay: rnd(0, 4) + "s", animationDuration: rnd(6, 9) + "s" });
212
+ for (let i = 0; i < 5; i++) add("ripple", { left: rnd(5, 90) + "%", bottom: rnd(8, 32) + "%", animationDelay: rnd(0, 5) + "s", animationDuration: rnd(5, 8) + "s" });
213
+ for (let i = 0; i < 28; i++) {
214
+ const sz = i % 4 === 0 ? "l" : i % 4 === 1 ? "s" : "";
215
+ add("bubble " + sz, { left: rnd(2, 96) + "%", animationDelay: rnd(0, 9) + "s", animationDuration: rnd(7, 15) + "s" });
216
+ }
217
+ for (let i = 0; i < 5; i++) add("fish" + (i === 2 || i === 4 ? " s" : ""), { top: rnd(52, 80) + "%", animationDelay: rnd(0, 6) + "s", animationDuration: rnd(12, 18) + "s" }, i % 2 ? "🐠" : "🐟");
218
+ } else if (style === "forest") {
219
+ add("tree xl", { left: rnd(-4, 14) + "%" });
220
+ add("tree", { left: rnd(72, 96) + "%" });
221
+ add("tree s", { left: rnd(30, 64) + "%" });
222
+ add("tree xs", { left: rnd(60, 86) + "%" });
223
+ add("grass", { left: rnd(0, 20) + "%" });
224
+ add("grass", { left: rnd(38, 60) + "%" });
225
+ add("grass", { left: rnd(74, 96) + "%" });
226
+ for (let i = 0; i < 12; i++) add("leaf", { left: rnd(2, 95) + "%", top: rnd(0, 60) + "%", animationDelay: rnd(0, 11) + "s", animationDuration: rnd(9, 17) + "s" });
227
+ for (let i = 0; i < 10; i++) add("firefly", { left: rnd(3, 95) + "%", top: rnd(6, 80) + "%", animationDelay: rnd(0, 6) + "s", animationDuration: rnd(5, 9) + "s" });
228
+ for (let i = 0; i < 2; i++) add("butterfly", { left: rnd(14, 78) + "%", top: rnd(14, 46) + "%", animationDelay: rnd(0, 5) + "s", animationDuration: rnd(9, 14) + "s" }, "🦋");
229
+ for (let i = 0; i < 4; i++) add("mushroom", { left: rnd(8, 88) + "%", bottom: rnd(2, 10) + "%", animationDelay: rnd(0, 3) + "s", animationDuration: rnd(4, 7) + "s" }, i % 2 ? "🍄" : "🌸");
230
+ } else if (style === "candy") {
231
+ const cs = ["#ffb74d", "#ff8fb3", "#7ec8e3", "#81c784", "#b39ddb", "#ff9a76", "#4fc3f7", "#f48fb1"];
232
+ for (let i = 0; i < 28; i++) add("dot" + (i % 4 === 0 ? " l" : ""), { left: rnd(2, 96) + "%", top: rnd(5, 93) + "%", background: cs[i % cs.length], animationDelay: rnd(0, 4) + "s", animationDuration: rnd(4, 8) + "s", width: rnd(7, 18) + "px", height: rnd(7, 18) + "px" });
233
+ add("rainbow", {});
234
+ const cb = ["#ff9a76", "#ff8fb3", "#7ec8e3", "#81c784", "#ffd54f", "#b39ddb"];
235
+ for (let i = 0; i < 18; i++) add("ribbon", { left: rnd(2, 94) + "%", background: cb[i % cb.length], animationDelay: rnd(0, 6) + "s", animationDuration: rnd(7, 12) + "s" });
236
+ for (let i = 0; i < 10; i++) add("lollipop", { left: rnd(4, 92) + "%", top: rnd(0, 40) + "%", animationDelay: rnd(0, 5) + "s", animationDuration: rnd(7, 12) + "s" }, "🍭");
237
+ } else if (style === "tech") {
238
+ for (let i = 0; i < 6; i++) add("signal" + (i ? " s" + (i % 3 + 1) : ""), { left: rnd(6, 74) + "%", top: rnd(6, 74) + "%" });
239
+ add("gear", { right: "22px", top: "16%" }, "⚙️");
240
+ add("gear g2", { left: "14px", bottom: "14%" }, "🔩");
241
+ for (let i = 0; i < 12; i++) {
242
+ add("circuit", { left: rnd(3, 50) + "%", top: rnd(8, 84) + "%", width: rnd(80, 200) + "px", animationDelay: rnd(0, 2.5) + "s" });
243
+ if (i % 2 === 0) add("circuit.v", { left: rnd(55, 95) + "%", top: rnd(6, 70) + "%", animationDelay: rnd(0, 3) + "s" });
244
+ }
245
+ for (let i = 0; i < 24; i++) {
246
+ add("circuit-node", { left: rnd(4, 93) + "%", top: rnd(5, 90) + "%", animationDelay: rnd(0, 1.8) + "s" });
247
+ if (i % 3 === 0) add("chip", { left: rnd(4, 93) + "%", top: rnd(5, 90) + "%", animationDelay: rnd(0, 3) + "s" });
248
+ }
249
+ } else if (style === "business") {
250
+ for (let i = 0; i < 14; i++) add("geo" + (i % 3 === 0 ? " circle" : ""), { left: rnd(4, 90) + "%", top: rnd(6, 88) + "%", animationDelay: rnd(0, 5) + "s", width: rnd(26, 46) + "px", height: rnd(26, 46) + "px" });
251
+ add("glass", { left: rnd(6, 20) + "%", top: rnd(12, 40) + "%" });
252
+ add("glass", { left: rnd(72, 88) + "%", top: rnd(16, 34) + "%" });
253
+ add("glass", { left: rnd(20, 40) + "%", top: rnd(58, 80) + "%" });
254
+ add("glass", { left: rnd(58, 70) + "%", top: rnd(60, 76) + "%" });
255
+ add("glass bar", { left: rnd(30, 58) + "%", top: rnd(26, 60) + "%" });
256
+ for (let i = 0; i < 12; i++) add("particle" + (i % 3 === 0 ? " line" : ""), { left: rnd(4, 92) + "%", top: rnd(8, 86) + "%", animationDelay: rnd(0, 4) + "s", width: rnd(5, 6) + "px", height: rnd(5, 6) + "px" });
257
+ }
258
+ }
259
+ function followParentTheme() {
260
+ try {
261
+ const parent = window.parent.document.body;
262
+ new MutationObserver(() => { if (themePref === "auto") applyTheme(); })
263
+ .observe(parent, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
264
+ } catch { /* cross-origin: keep default dark */ }
265
+ }
266
+
267
+ /* ===================== settings dialog =====================
268
+ * The per-repo view (grouped / flat) and the theme are both set-once, rarely
269
+ * revisited choices. They used to sit in the header as `▤ 平铺` and `◐ 主题`,
270
+ * which put two permanent buttons in the most expensive strip of the page for
271
+ * two controls a user touches once. They now live one level down, behind the
272
+ * gear, leaving the header for things you act on while reading history.
273
+ *
274
+ * "二级页面" here is a full dialog rather than a dropdown: the theme list is 11
275
+ * entries and needs the room, and a dropdown crowded next to the filter and
276
+ * date popovers would be a third layer of the same pattern. */
277
+ const THEME_SWATCHES = {
278
+ auto: "linear-gradient(135deg,#2b3440 50%,#dfe6ee 50%)",
279
+ dark: "#12171f",
280
+ light: "#f7f9fb",
281
+ sakura: "#ff9ec7",
282
+ ocean: "#4aa8d8",
283
+ forest: "#4caf50",
284
+ night: "#28284a",
285
+ candy: "#ffb74d",
286
+ business: "#7f9aa6",
287
+ starry: "#3a2a7a",
288
+ tech: "#00b0ff",
289
+ };
290
+ /* Plain names for the picker. THEME_LABELS carries the emoji as well, and using
291
+ * it here printed both a colour chip and an emoji for the same theme — two
292
+ * symbols for one meaning, and the emoji is the one that does not preview
293
+ * anything. The emoji survive in the tooltip and the README. */
294
+ const THEME_NAMES = {
295
+ auto: "自动", dark: "深色", light: "浅色", sakura: "樱花", ocean: "海洋",
296
+ forest: "森林", night: "夜空", candy: "糖果", business: "商务",
297
+ starry: "星河", tech: "科技",
298
+ };
299
+ let settingsBuilt = false;
300
+ function buildSettings() {
301
+ if (settingsBuilt) return;
302
+ settingsBuilt = true;
303
+ const box = $("stThemes");
304
+ for (const id of THEMES) {
305
+ const b = document.createElement("button");
306
+ b.type = "button";
307
+ b.className = "st-theme";
308
+ b.dataset.theme = id;
309
+ b.title = "主题:" + THEME_LABELS[id];
310
+ const sw = document.createElement("span");
311
+ sw.className = "sw";
312
+ sw.style.background = THEME_SWATCHES[id] || "#666";
313
+ const nm = document.createElement("span");
314
+ nm.className = "nm";
315
+ nm.textContent = THEME_NAMES[id] || id;
316
+ b.appendChild(sw);
317
+ b.appendChild(nm);
318
+ b.addEventListener("click", () => setTheme(id));
319
+ box.appendChild(b);
320
+ }
321
+ for (const b of $("stView").querySelectorAll("button")) {
322
+ b.addEventListener("click", () => setFlatMode(b.dataset.view === "flat"));
323
+ }
324
+ }
325
+ function syncThemePicker() {
326
+ const box = $("stThemes");
327
+ if (!box) return;
328
+ for (const b of box.children) b.classList.toggle("on", b.dataset.theme === themePref);
329
+ }
330
+ /* One setter for both entry points; the header has no theme control any more,
331
+ * so a stale `.on` here is the only way the picker could disagree with reality. */
332
+ function setTheme(id) {
333
+ if (!THEMES.includes(id) || id === themePref) return;
334
+ themePref = id;
335
+ // Use saveStored (JSON) like every other key — writing the raw string here was
336
+ // what made the read path reject its own value and fall back to auto.
337
+ saveStored("gg2-theme", themePref);
338
+ prefsSync({ "gg2-theme": themePref });
339
+ applyTheme();
340
+ }
341
+ function syncSettingsState() {
342
+ const seg = $("stView");
343
+ if (seg) for (const b of seg.querySelectorAll("button")) b.classList.toggle("on", (b.dataset.view === "flat") === FLAT_MODE);
344
+ syncThemePicker();
345
+ const gear = $("btnSettings");
346
+ if (gear) {
347
+ const custom = themePref !== "auto" && themePref !== "dark";
348
+ gear.classList.toggle("dot", custom || FLAT_MODE);
349
+ gear.title = "设置(视图、主题、快捷键)" + (custom || FLAT_MODE ? " · 有非默认项" : "");
350
+ }
351
+ }
352
+ function toggleSettings(force) {
353
+ const d = $("settingsDlg");
354
+ const show = force !== undefined ? force : !d.classList.contains("show");
355
+ if (show) { buildSettings(); syncSettingsState(); }
356
+ d.classList.toggle("show", show);
357
+ }
358
+ $("btnSettings").addEventListener("click", (ev) => { ev.stopPropagation(); toggleSettings(); });
359
+ $("stClose").addEventListener("click", () => toggleSettings(false));
360
+ $("stShortcut").addEventListener("click", () => { toggleSettings(false); toggleShortcutDlg(true); });
361
+ $("settingsDlg").addEventListener("click", (ev) => { if (ev.target === $("settingsDlg")) toggleSettings(false); });
362
+
363
+ /* ===================== api ===================== */
364
+ /* Server ops that mutate state. These must be sent as POST: a cross-site page
365
+ * can point an <img>/<a> at a GET URL and have the browser fire it, but it
366
+ * cannot make the browser issue a POST without a CORS preflight (which the
367
+ * server never grants). The server enforces the same list. */
368
+ const WRITE_OPS = new Set(["gitop", "autoupdate", "addrepo", "prefswrite"]);
369
+
370
+ async function api(op, extra = {}) {
371
+ const p = new URLSearchParams();
372
+ p.set("op", op);
373
+ if (REPO) p.set("repo", REPO);
374
+ // Graph fetches inherit the ordering of the current view unless the caller
375
+ // pinned one explicitly. Injected here rather than at each call site so the
376
+ // first page, "load more", and the light reload can never disagree — a
377
+ // mismatch would splice two different orderings into one list.
378
+ const params = (op === "graph" && extra.order === undefined)
379
+ ? { order: FLAT_MODE ? "date" : "topo", ...extra }
380
+ : extra;
381
+ for (const [k, v] of Object.entries(params)) {
382
+ if (Array.isArray(v)) for (const item of v) p.append(k, item);
383
+ else if (v !== undefined && v !== null && v !== "") p.set(k, v);
384
+ }
385
+ const init = WRITE_OPS.has(op)
386
+ ? {
387
+ method: "POST",
388
+ credentials: "same-origin",
389
+ headers: { "content-type": "application/x-www-form-urlencoded" },
390
+ }
391
+ : undefined;
392
+ const res = await fetch("/git-graph/api?" + p.toString(), init);
393
+ return res.json();
394
+ }
395
+
396
+ /* ===================== helpers ===================== */
397
+ function relTime(iso) {
398
+ try {
399
+ const t = new Date(iso).getTime();
400
+ const d = Date.now() - t;
401
+ if (isNaN(t)) return "";
402
+ if (d < 60e3) return "刚刚";
403
+ if (d < 3600e3) return Math.floor(d / 60e3) + " 分钟前";
404
+ if (d < 86400e3) return Math.floor(d / 3600e3) + " 小时前";
405
+ if (d < 30 * 86400e3) return Math.floor(d / 86400e3) + " 天前";
406
+ const m = new Date(t);
407
+ return (m.getFullYear() === new Date().getFullYear() ? "" : m.getFullYear() + "年") + (m.getMonth() + 1) + "月" + m.getDate() + "日";
408
+ } catch { return ""; }
409
+ }
410
+ function short7(h) { return h.slice(0, 7); }
411
+ function laneColor(l) { return LANE_COLORS[l % LANE_COLORS.length]; }
412
+
413
+ /**
414
+ * Normalize branch rows. Newer server splits fields with %09 (tab); an older
415
+ * running server emits literal %x1f inside one field — recover from that too.
416
+ */
417
+ function normalizeBranch(b) {
418
+ const raw = b.refname || "";
419
+ if (raw.includes("%x1f")) {
420
+ const parts = raw.split("%x1f");
421
+ const refname = parts[0];
422
+ return {
423
+ refname,
424
+ kind: refname.startsWith("refs/tags/") ? "tag" : refname.startsWith("refs/remotes/") ? "remote" : "local",
425
+ name: refname.replace(/^refs\/(heads|remotes|tags)\//, ""),
426
+ sha: parts[1] || "",
427
+ head: (parts[2] || "").trim() === "*",
428
+ };
429
+ }
430
+ return b;
431
+ }
432
+
433
+ /* ===================== layout ===================== */
434
+ function layout() {
435
+ // Classic git-graph lane assignment, processed oldest -> newest so a commit
436
+ // can continue the lane of its ALREADY-PLACED parent. Every commit of one
437
+ // first-parent chain therefore lands on the same lane: one straight vertical
438
+ // line per branch.
439
+ const childCount = new Map();
440
+ for (const c of COMMITS) {
441
+ for (const p of c.parents) childCount.set(p, (childCount.get(p) || 0) + 1);
442
+ }
443
+ const laneOf = new Map();
444
+ const lanes = []; // lane -> last commit hash | null (released)
445
+ for (let k = COMMITS.length - 1; k >= 0; k--) {
446
+ const c = COMMITS[k];
447
+ let chosen = -1;
448
+ // continue the lane of the first already-placed parent
449
+ for (const p of c.parents) {
450
+ const li = laneOf.get(p);
451
+ if (li !== undefined && lanes[li] === p) { chosen = li; break; }
452
+ }
453
+ if (chosen === -1) {
454
+ // reuse a released lane if possible
455
+ for (let li = 0; li < lanes.length; li++) {
456
+ if (lanes[li] === null) { chosen = li; break; }
457
+ }
458
+ if (chosen === -1) { chosen = lanes.length; lanes.push(null); }
459
+ }
460
+ laneOf.set(c.hash, chosen);
461
+ lanes[chosen] = c.hash;
462
+ // release a lane whose last commit now has every child placed
463
+ for (let li = 0; li < lanes.length; li++) {
464
+ const last = lanes[li];
465
+ if (last === null || last === c.hash) continue;
466
+ const kids = COMMITS.filter((k2) => k2.parents.includes(last));
467
+ if (kids.length > 0 && kids.every((k2) => laneOf.has(k2.hash))) lanes[li] = null;
468
+ }
469
+ }
470
+ return { laneOf, laneCount: lanes.length };
471
+ }
472
+
473
+ /** Set of commits reachable from tip (BFS over parents). */
474
+ function reachable(tip) {
475
+ const map = byHash();
476
+ const seen = new Set();
477
+ const stack = [tip];
478
+ while (stack.length) {
479
+ const h = stack.pop();
480
+ if (seen.has(h)) continue;
481
+ const c = map.get(h);
482
+ if (!c) continue;
483
+ seen.add(h);
484
+ for (const p of c.parents) stack.push(p);
485
+ }
486
+ return seen;
487
+ }
488
+
489
+ /* ===================== filter ===================== */
490
+ const CHECKED_SET = new Set(); // branch shas kept visible when FILTER === "multi"
491
+ /* Branch filter, stored as the list of branches the user turned OFF.
492
+ *
493
+ * Storing the *hidden* set (rather than the visible one) is what makes a newly
494
+ * created branch visible by default — it is simply absent from the blacklist.
495
+ * With the old "store the checked ones" scheme every new branch was invisible
496
+ * until manually ticked, which read as "my branches disappeared".
497
+ *
498
+ * `null` = nothing hidden = show everything (the default).
499
+ * Persisted on the server (see prefsSync) so it survives DSH port changes,
500
+ * which wipe localStorage because it is origin-scoped. */
501
+ let HIDDEN_REFS = loadStored("gg2-bfilter-hidden", null);
502
+ /* Legacy key from the "store visible" era. Read once for migration, then the
503
+ * new key takes over; see migrateLegacyFilter(). */
504
+ const LEGACY_VISIBLE_REFS = loadStored("gg2-bfilter", null);
505
+ /* Pagination: the first screen only needs a short list, and every extra page is
506
+ * an extra `git log` process (~200ms+ on Windows), so the initial fetch is kept
507
+ * small and later pages are bigger — scrolling down is already an explicit
508
+ * "I want more" gesture, so it can afford a larger batch than the first paint. */
509
+ const PAGE_INITIAL = 50; // commits loaded on first paint
510
+ const PAGE_MORE = 100; // commits loaded per scroll/load-more step
511
+ let LOADED = 0; // commits loaded so far
512
+ let TOTAL = 0; // total commits in the repo
513
+ let REPO_LIST = []; // available repos for the switcher
514
+ let ADDED_REPOS = loadStored("gg2-repos", []); // repos added by the user via "打开文件夹" (persisted)
515
+ let PICK_PATH = ""; // folder picker: currently browsed path
516
+ let PICK_PARENT = null; // folder picker: authoritative parent path from the server (null = at a root)
517
+ let PICK_ROOT = null; // folder picker: git root containing PICK_PATH (null = not in a repo)
518
+ function visibleSet() {
519
+ if (FILTER === "all") return null;
520
+ if (FILTER === "multi") {
521
+ // 严格语义:只显示「已勾选分支」可达的提交;全部不勾选 → 空列表(不回落全显)
522
+ const union = new Set();
523
+ for (const b of BRANCHES) {
524
+ if (b.kind === "tag") continue;
525
+ if (CHECKED_SET.has(b.sha)) {
526
+ const s = reachable(b.sha);
527
+ for (const h of s) union.add(h);
528
+ }
529
+ }
530
+ return union;
531
+ }
532
+ return reachable(FILTER);
533
+ }
534
+
535
+ /* ===================== graph render ===================== */
536
+ /* (long-chain folding removed — branch-group folding covers it) */
537
+
538
+ /* ===== branch-group rows: each branch (incl. the HEAD mainline) gets a foldable group header ===== */
539
+ let BRANCH_FOLDED = new Set(loadStored("gg2-brfolded", [])); // refnames of collapsed branch groups
540
+ let BRANCH_FOLD_DEFAULTED = false; // one-time: fold every branch group on very first load
541
+ /* Flat view: every commit on one continuous timeline, no branch group headers.
542
+ * Default OFF — the grouped view is the primary way to read the history ("what
543
+ * is on this branch"). Flat mode is the opt-in view that comes with the lane
544
+ * graph, because lanes can only stay continuous when nothing splits the rows. */
545
+ let FLAT_MODE = loadStored("gg2-flat", false);
546
+ /* Honours a stored value from an earlier build of this feature: flat used to be
547
+ * the default while it was being built, so a stale `true` would silently keep the
548
+ * lane view on. The flag is separate from `gg2-flat` so "the user chose flat" can
549
+ * be told apart from "flat was only ever the development default".
550
+ *
551
+ * It MUST also be persisted server-side (see prefsLoad): localStorage is
552
+ * origin-scoped and this plugin shares the DSH origin, so a web-port change wipes
553
+ * the flag, the one-time reset re-runs, and the user's view choice is silently
554
+ * reverted. */
555
+ const FLAT_DEFAULT_MIGRATED = loadStored("gg2-flat-reset", false);
556
+
557
+ function buildRows(vis) {
558
+ const rows = []; // {type:'commit', hash} | {type:'head', refname, name, tip, count, head?}
559
+ const seen = new Set();
560
+ const map = byHash();
561
+
562
+ // Flat mode: COMMITS is already in topo order, so emit it as-is. Still apply
563
+ // the branch filter (vis) — flat means "no grouping", not "no filtering".
564
+ if (FLAT_MODE) {
565
+ for (const c of COMMITS) {
566
+ if (!map.has(c.hash)) continue;
567
+ if (vis && !vis.has(c.hash)) continue;
568
+ rows.push({ type: "commit", hash: c.hash });
569
+ }
570
+ return rows;
571
+ }
572
+
573
+ const headB = BRANCHES.find((b) => b.head) || BRANCHES.find((b) => b.kind === "local");
574
+ // With an active branch filter, a branch whose tip is not visible must not
575
+ // mark its commits as seen — otherwise a visible branch that shares those
576
+ // commits (e.g. feature/x pointing at an ancestor of the hidden HEAD) would
577
+ // end up with an empty chain and its rows would vanish.
578
+ if (headB && !(vis && !vis.has(headB.sha))) {
579
+ const chain = [];
580
+ let cur = headB.sha;
581
+ while (cur && map.has(cur) && !seen.has(cur)) {
582
+ seen.add(cur);
583
+ chain.push(cur);
584
+ cur = map.get(cur).parents[0] || null;
585
+ }
586
+ if (chain.length) {
587
+ rows.push({ type: "head", refname: headB.refname, name: headB.name, tip: chain[0], count: chain.length, head: true });
588
+ if (!BRANCH_FOLDED.has(headB.refname)) {
589
+ for (const h of chain) rows.push({ type: "commit", hash: h });
590
+ }
591
+ }
592
+ }
593
+ for (const b of BRANCHES) {
594
+ if (b.kind !== "local" || (headB && b.refname === headB.refname)) continue;
595
+ if (vis && !vis.has(b.sha)) continue; // filtered-out branch: hide its group
596
+ const chain = [];
597
+ let cur = b.sha;
598
+ while (cur && map.has(cur) && !seen.has(cur)) {
599
+ seen.add(cur);
600
+ chain.push(cur);
601
+ const p = map.get(cur).parents[0] || null;
602
+ if (p && seen.has(p)) break; // merged into an already-shown commit
603
+ cur = p;
604
+ }
605
+ if (!chain.length) continue;
606
+ rows.push({ type: "head", refname: b.refname, name: b.name, tip: chain[0], count: chain.length });
607
+ if (!BRANCH_FOLDED.has(b.refname)) {
608
+ for (const h of chain) rows.push({ type: "commit", hash: h });
609
+ }
610
+ }
611
+ // remote branches: same treatment, so remote-only commits get their own
612
+ // foldable group instead of vanishing (or being buried in the leftovers).
613
+ // Even a fully-synced remote branch (chain empty) still gets a group header
614
+ // so origin/* is always visible; it just shows a "synced" hint when open.
615
+ for (const b of BRANCHES) {
616
+ if (b.kind !== "remote") continue;
617
+ if (vis && !vis.has(b.sha)) continue;
618
+ const chain = [];
619
+ let cur = b.sha;
620
+ while (cur && map.has(cur) && !seen.has(cur)) {
621
+ seen.add(cur);
622
+ chain.push(cur);
623
+ const p = map.get(cur).parents[0] || null;
624
+ if (p && seen.has(p)) break; // merged into an already-shown commit
625
+ cur = p;
626
+ }
627
+ const synced = !chain.length;
628
+ // label: "已同步" only when the remote tip equals a same-named local branch;
629
+ // otherwise the commits are all already shown under another group -> "已包含"
630
+ let syncedLabel = "已包含";
631
+ if (synced) {
632
+ const short = b.name.replace(/^[^/]+\//, ""); // origin/main -> main
633
+ const localTwin = BRANCHES.find((lb) => lb.kind === "local" && lb.name === short);
634
+ if (localTwin && localTwin.sha === b.sha) syncedLabel = "已同步";
635
+ }
636
+ rows.push({ type: "head", refname: b.refname, name: b.name, tip: b.sha, count: synced ? 0 : chain.length, synced, syncedLabel });
637
+ if (!synced && !BRANCH_FOLDED.has(b.refname)) {
638
+ for (const h of chain) rows.push({ type: "commit", hash: h });
639
+ }
640
+ }
641
+ // leftovers (should not happen with --all; with a filter they belong to
642
+ // hidden branches, so drop them unless they are inside the visible set)
643
+ for (const c of COMMITS) {
644
+ if (!seen.has(c.hash) && (!vis || vis.has(c.hash))) {
645
+ seen.add(c.hash);
646
+ rows.push({ type: "commit", hash: c.hash });
647
+ }
648
+ }
649
+ return rows;
650
+ }
651
+
652
+ /** Ctrl+B: collapse/expand every branch group at once. */
653
+ function toggleBranchGroups() {
654
+ const refs = BRANCHES.filter((b) => b.kind === "local" || b.kind === "remote").map((b) => b.refname);
655
+ if (!refs.length) return;
656
+ const allFolded = refs.every((r) => BRANCH_FOLDED.has(r));
657
+ for (const r of refs) {
658
+ if (allFolded) BRANCH_FOLDED.delete(r);
659
+ else BRANCH_FOLDED.add(r);
660
+ }
661
+ saveStored("gg2-brfolded", [...BRANCH_FOLDED]);
662
+ render();
663
+ }
664
+
665
+ /* ---- lane graph geometry ----------------------------------------------------
666
+ * Spacing tuned against a real 4-lane repo: rows are ~53px tall, so 30px per
667
+ * lane keeps the S-curves gently sloped instead of near-vertical. The gutter
668
+ * grows with the lane count and a single-lane repo still gets a slim gutter.
669
+ */
670
+ const LANE_STEP = 30;
671
+ const LANE_PAD = 22;
672
+ function graphGutterWidth() {
673
+ const layout = ensureLayout();
674
+ return Math.max(56, LANE_PAD * 2 + Math.max(1, layout.laneCount - 1) * LANE_STEP + 8);
675
+ }
676
+
677
+ /** Draw lanes + nodes for every visible commit row as ONE svg overlay.
678
+ * `rowEls` maps commit hash -> the row element, so the vertical positions come
679
+ * from the browser's real layout (row heights can differ when a message wraps
680
+ * or the detail panel pushes things around). */
681
+ function renderLaneGraph(list, rowEls) {
682
+ const old = list.querySelector("#laneSvg");
683
+ if (old) old.remove();
684
+ // Grouped mode has no gutter and no continuous timeline, so there is nothing
685
+ // to draw. Bail out before touching layout: this also keeps `ensureLayout`
686
+ // (a full O(n) pass) from running for a graph that will not be shown.
687
+ if (!FLAT_MODE) return;
688
+ if (!rowEls.size) return;
689
+
690
+ const layout = ensureLayout();
691
+ const width = graphGutterWidth();
692
+ const listRect = list.getBoundingClientRect();
693
+ const color = (i) => LANE_COLORS[i % LANE_COLORS.length];
694
+
695
+ // node centre in list-local coordinates
696
+ const nodePos = new Map(); // row index -> {x, y, hash}
697
+ for (const node of layout.nodes) {
698
+ const el = rowEls.get(node.hash);
699
+ if (!el) continue; // filtered out or not rendered
700
+ const r = el.getBoundingClientRect();
701
+ if (!r.height) continue;
702
+ nodePos.set(node.row, {
703
+ x: LANE_PAD + node.lane * LANE_STEP,
704
+ y: r.top - listRect.top + r.height / 2,
705
+ hash: node.hash,
706
+ lane: node.lane,
707
+ colour: node.colour,
708
+ });
709
+ }
710
+ if (!nodePos.size) return;
711
+
712
+ const height = list.scrollHeight;
713
+ const NS = "http://www.w3.org/2000/svg";
714
+ const svg = document.createElementNS(NS, "svg");
715
+ svg.id = "laneSvg";
716
+ svg.setAttribute("width", width);
717
+ svg.setAttribute("height", height);
718
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
719
+
720
+ // --- edges: each edge joins a row to the NEXT row, so y1/y2 come from the
721
+ // two neighbouring nodes. Edges whose endpoints are off-screen (a row hidden
722
+ // by a filter) are skipped rather than drawn across the gap.
723
+ for (const e of layout.edges) {
724
+ const a = nodePos.get(e.row);
725
+ const b = nodePos.get(e.row + 1);
726
+ if (!a || !b) continue;
727
+ const x1 = LANE_PAD + e.fromLane * LANE_STEP;
728
+ const x2 = LANE_PAD + e.toLane * LANE_STEP;
729
+ const y1 = a.y;
730
+ const y2 = b.y;
731
+ let d;
732
+ if (x1 === x2) {
733
+ d = `M ${x1} ${y1} L ${x2} ${y2}`;
734
+ } else {
735
+ // Smooth S-curve: the horizontal move happens near the vertical midpoint,
736
+ // which is what makes a fork read as "leaving the lane" rather than as a
737
+ // diagonal slash. Control points at 50% keep both ends tangent-vertical.
738
+ const span = y2 - y1;
739
+ const curve = Math.max(8, span * 0.5);
740
+ d = `M ${x1} ${y1} C ${x1} ${y1 + curve}, ${x2} ${y2 - curve}, ${x2} ${y2}`;
741
+ }
742
+ const shadow = document.createElementNS(NS, "path");
743
+ shadow.setAttribute("class", "lane-shadow");
744
+ shadow.setAttribute("d", d);
745
+ svg.appendChild(shadow);
746
+
747
+ const line = document.createElementNS(NS, "path");
748
+ line.setAttribute("class", "lane-line");
749
+ line.setAttribute("d", d);
750
+ line.setAttribute("stroke", color(e.colour));
751
+ if (e.missingParent) line.setAttribute("stroke-dasharray", "3 3");
752
+ svg.appendChild(line);
753
+ }
754
+
755
+ // --- nodes on top of the edges
756
+ for (const [, p] of nodePos) {
757
+ const c = byHash().get(p.hash);
758
+ const isHead = c && c.refs.some((r) => r.head);
759
+ const hit = document.createElementNS(NS, "circle");
760
+ hit.setAttribute("class", "lane-hit");
761
+ hit.setAttribute("cx", p.x);
762
+ hit.setAttribute("cy", p.y);
763
+ hit.setAttribute("r", "10");
764
+ hit.dataset.hash = p.hash;
765
+ hit.addEventListener("click", (ev) => { ev.stopPropagation(); selectCommit(p.hash); });
766
+ svg.appendChild(hit);
767
+
768
+ const node = document.createElementNS(NS, "circle");
769
+ node.setAttribute("class", "lane-node" + (isHead ? " head" : "") + (SELECTED === p.hash ? " sel" : ""));
770
+ node.setAttribute("cx", p.x);
771
+ node.setAttribute("cy", p.y);
772
+ node.setAttribute("r", SELECTED === p.hash ? "6.5" : "4.8");
773
+ node.setAttribute("fill", isHead ? "var(--bg2)" : color(p.colour));
774
+ if (isHead) node.setAttribute("stroke", color(p.colour));
775
+ svg.appendChild(node);
776
+ }
777
+
778
+ list.appendChild(svg);
779
+ }
780
+
781
+ /* ---- branch tips at the end of each lane ------------------------------------
782
+ * Where a branch's history leaves the visible list, pin its name to that point
783
+ * in the gutter. Without this the labels only exist inline on the row that
784
+ * happens to carry the ref, so a branch whose tip scrolled far away is
785
+ * invisible — and in flat mode the tip row is often not where the eye is.
786
+ *
787
+ * Rendered as absolutely-positioned HTML (not SVG text) so the pills reuse the
788
+ * normal .cl-ref styling and stay crisp at any zoom.
789
+ */
790
+ function render() {
791
+ BRANCH_COLOR_CACHE = null;
792
+ const map = byHash();
793
+ const vis = visibleSet();
794
+ const hlSet = HIGHLIGHT ? reachable(HIGHLIGHT) : null;
795
+ const ROWS = buildRows(vis);
796
+ const list = $("commitList");
797
+ list.innerHTML = "";
798
+
799
+ const commitRows = [];
800
+ let prevCommitEl = null;
801
+ let pendingHead = null; // group header waiting for its first surviving commit
802
+ const commitRowEls = new Map(); // hash -> row element (for lane geometry)
803
+
804
+ // Pre-compute, per branch group, how many of its commits survive the filters.
805
+ // This cannot walk ROWS: buildRows() omits commit rows for collapsed groups,
806
+ // so a filtered match hiding inside a collapsed group would be invisible here
807
+ // and the header (the only thing on screen) would be dropped. Walk each
808
+ // branch's first-parent chain directly instead.
809
+ // Counted per branch, NOT deduplicated across branches: a local branch and its
810
+ // remote twin reach the same commits, and both headers should stay visible.
811
+ const groupCounts = new Map(); // refname -> surviving commit count
812
+ if (filtersActive()) {
813
+ for (const b of BRANCHES) {
814
+ if (b.kind !== "local" && b.kind !== "remote") continue;
815
+ if (vis && !vis.has(b.sha)) continue;
816
+ let n = 0;
817
+ let cur = b.sha;
818
+ const local = new Set();
819
+ while (cur && map.has(cur) && !local.has(cur)) {
820
+ local.add(cur);
821
+ if (passesFilters(map.get(cur))) n++;
822
+ cur = map.get(cur).parents[0] || null;
823
+ }
824
+ groupCounts.set(b.refname, n);
825
+ }
826
+ }
827
+
828
+ for (let i = 0; i < ROWS.length; i++) {
829
+ const r = ROWS[i];
830
+ if (r.type === "head") {
831
+ // hide the whole group when its branch is filtered out
832
+ if (vis && !vis.has(r.tip)) continue;
833
+ if (filtersActive()) {
834
+ // Show the header only when this group still has matching commits.
835
+ // When expanded, the first surviving commit emits it (pendingHead) so
836
+ // it stays attached to its rows; when collapsed there are no commit
837
+ // rows at all, so emit it here.
838
+ if ((groupCounts.get(r.refname) || 0) === 0) continue;
839
+ if (BRANCH_FOLDED.has(r.refname)) {
840
+ list.appendChild(buildHeadRow(r));
841
+ } else {
842
+ pendingHead = pendingHead || r;
843
+ }
844
+ continue;
845
+ }
846
+ list.appendChild(buildHeadRow(r));
847
+ continue;
848
+ }
849
+ const c = map.get(r.hash);
850
+ if (!c) continue;
851
+ if (!passesFilters(c)) continue; // search / time range: hide it
852
+ if ((vis && !vis.has(c.hash)) || (hlSet && !hlSet.has(c.hash))) continue;
853
+ // first surviving commit of a group: emit its header now
854
+ if (pendingHead) { list.appendChild(buildHeadRow(pendingHead)); pendingHead = null; }
855
+ const row = buildCommitRow(c);
856
+ list.appendChild(row);
857
+ commitRowEls.set(c.hash, row);
858
+ commitRows.push(row);
859
+ if (prevCommitEl) prevCommitEl.classList.remove("last");
860
+ row.classList.add("last");
861
+ prevCommitEl = row;
862
+ }
863
+ // line joining: each row's graph line connects to the previous visible commit
864
+ for (let i = 0; i < commitRows.length; i++) {
865
+ const row = commitRows[i];
866
+ if (i === 0) row.classList.add("first");
867
+ if (commitRows.length === 1) row.classList.add("solo");
868
+ }
869
+
870
+ // Draw the lane graph once over the whole list. Runs after the rows are in the
871
+ // DOM because the geometry is read back from their real positions.
872
+ renderLaneGraph(list, commitRowEls);
873
+
874
+ // Empty result: distinguish "branch filter hid everything" from "search/date
875
+ // filter matched nothing", because the way back differs.
876
+ //
877
+ // commitRows === 0 alone does NOT mean "nothing to show": with every group
878
+ // collapsed the list legitimately holds only headers, and a filtered view may
879
+ // still have matching commits hidden inside collapsed groups. Flat mode has no
880
+ // collapsed groups, so there 0 rows really does mean empty.
881
+ //
882
+ // matchingTotal comes from groupCounts, which only exists in grouped mode;
883
+ // flat mode counts the surviving commits directly.
884
+ const anyCollapsed = !FLAT_MODE && BRANCHES.some(
885
+ (b) => (b.kind === "local" || b.kind === "remote") && BRANCH_FOLDED.has(b.refname),
886
+ );
887
+ const matchingTotal = filtersActive()
888
+ ? (FLAT_MODE
889
+ ? ROWS.filter((r) => r.type === "commit").length
890
+ : [...groupCounts.values()].reduce((a, b) => a + b, 0))
891
+ : null;
892
+ const nothingToShow = filtersActive()
893
+ ? matchingTotal === 0
894
+ : (commitRows.length === 0 && !anyCollapsed);
895
+ if (nothingToShow && FILTER === "multi") {
896
+ const hint = document.createElement("div");
897
+ hint.className = "cl-filter-empty";
898
+ hint.style.cssText = "color:var(--text3);text-align:center;padding:26px 12px 18px;font-size:12.5px;line-height:1.9";
899
+ const msg = document.createElement("div");
900
+ msg.textContent = hiddenCount() >= branchCount()
901
+ ? "所有分支都被隐藏了 —— 勾选任意分支即可显示其提交"
902
+ : "当前筛选下没有可显示的提交 —— 你隐藏的分支可能承载了全部历史";
903
+ hint.appendChild(msg);
904
+ const btn = document.createElement("button");
905
+ btn.textContent = "恢复全选";
906
+ btn.style.cssText = "margin-top:10px;height:28px;padding:0 16px;border:1px solid var(--border);border-radius:7px;background:var(--bg3);color:var(--text);cursor:pointer;font-size:12px";
907
+ btn.addEventListener("click", () => {
908
+ HIDDEN_REFS = [];
909
+ for (const b of document.querySelectorAll(".fBranch")) b.checked = true;
910
+ applyFilter();
911
+ });
912
+ hint.appendChild(btn);
913
+ list.appendChild(hint);
914
+ } else if (nothingToShow && filtersActive()) {
915
+ // Search / time range excluded every loaded commit.
916
+ const hint = document.createElement("div");
917
+ hint.className = "cl-filter-empty";
918
+ hint.style.cssText = "color:var(--text3);text-align:center;padding:26px 12px 18px;font-size:12.5px;line-height:1.9";
919
+ const msg = document.createElement("div");
920
+ const bits = [];
921
+ if (SEARCH) bits.push(`关键词「${SEARCH}」`);
922
+ if (DATE_FROM && DATE_TO) bits.push(`${DATE_FROM} ~ ${DATE_TO}`);
923
+ else if (DATE_FROM) bits.push(`${DATE_FROM} 之后`);
924
+ else if (DATE_TO) bits.push(`${DATE_TO} 之前`);
925
+ msg.textContent = `没有符合 ${bits.join(" + ")} 的提交`;
926
+ hint.appendChild(msg);
927
+ const btn = document.createElement("button");
928
+ btn.textContent = "清除筛选";
929
+ btn.style.cssText = "margin-top:10px;height:28px;padding:0 16px;border:1px solid var(--border);border-radius:7px;background:var(--bg3);color:var(--text);cursor:pointer;font-size:12px";
930
+ btn.addEventListener("click", clearFilters);
931
+ hint.appendChild(btn);
932
+ list.appendChild(hint);
933
+ }
934
+
935
+ // large-repo pagination: the next page is normally fetched by scrolling to the
936
+ // bottom (see the #graphWrap scroll handler); the button stays as a fallback
937
+ // for short viewports where the list never grows tall enough to scroll.
938
+ if (hasMore()) {
939
+ const more = document.createElement("div");
940
+ more.className = "cl-more";
941
+ const b = document.createElement("button");
942
+ // TOTAL is 0 while the background count is still in flight; don't print "x / 0"
943
+ b.textContent = TOTAL > 0
944
+ ? `加载更多(已显示 ${LOADED} / ${TOTAL})`
945
+ : `加载更多(已显示 ${LOADED})`;
946
+ b.addEventListener("click", async () => {
947
+ b.disabled = true;
948
+ b.textContent = "加载中…";
949
+ await loadMore();
950
+ });
951
+ more.appendChild(b);
952
+ list.appendChild(more);
953
+ }
954
+
955
+ renderLegend();
956
+ }
957
+
958
+ /* Is there plausibly another page? TOTAL === 0 means "not known yet" (the count
959
+ * is fetched in the background), which must NOT be treated as "nothing left" or
960
+ * the load-more button and scroll-loading would both disappear until it lands. */
961
+ function hasMore() {
962
+ if (PAGINATION_EXHAUSTED) return false;
963
+ if (TOTAL > 0) return TOTAL > LOADED;
964
+ return true;
965
+ }
966
+
967
+ /* Auto-pagination: fetch the next page when the user scrolls near the bottom.
968
+ * Uses the #graphWrap scroll box (that is the overflow container, so window
969
+ * scroll events never fire here).
970
+ *
971
+ * Only an actual scroll gesture loads more. In particular, do NOT fill up a list
972
+ * that is too short to scroll: branch groups are collapsed by default, so the
973
+ * first screen is only a handful of headers — auto-filling it would silently pull
974
+ * every page before the user asked, which is what pagination is meant to avoid.
975
+ * When the list cannot scroll, the "加载更多" button below it is the affordance. */
976
+ let AUTO_LOAD_QUEUED = false;
977
+ function maybeAutoLoad() {
978
+ if (AUTO_LOAD_QUEUED) return;
979
+ AUTO_LOAD_QUEUED = true;
980
+ requestAnimationFrame(() => {
981
+ AUTO_LOAD_QUEUED = false;
982
+ if (!hasMore()) return;
983
+ const wrap = $("graphWrap");
984
+ const sentinel = document.querySelector("#commitList .cl-more");
985
+ if (!wrap || !sentinel) return;
986
+ if (wrap.scrollHeight <= wrap.clientHeight + 4) return; // nothing to scroll yet
987
+ const wr = wrap.getBoundingClientRect();
988
+ const sr = sentinel.getBoundingClientRect();
989
+ // trigger when the load-more area is within 300px of the visible bottom
990
+ if (sr.top - wr.bottom < 300) loadMore();
991
+ });
992
+ }
993
+
994
+ /** Fetch the commit count in the background and update the "load more" label.
995
+ * Split out of the first graph page so the first paint does not wait for a
996
+ * second git spawn (each spawn costs ~870ms inside the DSH host process here). */
997
+ let TOTAL_PENDING = false;
998
+ /* A server that predates the `count` op answers unknown-op. Remember it once so
999
+ * we fall back to the old behaviour (ask graph to include the total) instead of
1000
+ * 400-ing on every refresh — and, more importantly, so `TOTAL` does not stay
1001
+ * stuck at the page size and silently hide every later commit. */
1002
+ let COUNT_OP_OK = true;
1003
+ async function refreshTotal() {
1004
+ if (TOTAL_PENDING) return;
1005
+ TOTAL_PENDING = true;
1006
+ try {
1007
+ if (COUNT_OP_OK) {
1008
+ const r = await api("count");
1009
+ if (r.ok && r.total > 0) {
1010
+ const before = TOTAL;
1011
+ TOTAL = r.total;
1012
+ // Only re-render when the label actually changes; a needless render()
1013
+ // would rebuild the whole list and reset the scroll position.
1014
+ if (TOTAL !== before) {
1015
+ render();
1016
+ renderStats();
1017
+ }
1018
+ return;
1019
+ }
1020
+ COUNT_OP_OK = false; // old host: fall through to the legacy path
1021
+ }
1022
+ // legacy host: the total only comes from a graph page that computes it
1023
+ const g = await api("graph", { limit: PAGE_INITIAL, skip: 0 });
1024
+ if (g.ok && g.total > 0) {
1025
+ const before = TOTAL;
1026
+ TOTAL = g.total;
1027
+ if (TOTAL !== before) { render(); renderStats(); }
1028
+ }
1029
+ } catch { /* ignore */ } finally {
1030
+ TOTAL_PENDING = false;
1031
+ }
1032
+ }
1033
+
1034
+ /** Fetch the next page of commits and re-render.
1035
+ * Guarded so a scroll burst cannot fire several overlapping pages: while a
1036
+ * fetch is in flight further calls return immediately. */
1037
+ let LOADING_MORE = false;
1038
+ /* Set when a page comes back with nothing new: the server says there is more but
1039
+ * the same commits are already loaded (e.g. a force-push landing mid-scroll).
1040
+ * Without this the auto-loader would re-request the same page forever. */
1041
+ let PAGINATION_EXHAUSTED = false;
1042
+ async function loadMore() {
1043
+ if (LOADING_MORE || !hasMore()) return;
1044
+ LOADING_MORE = true;
1045
+ try {
1046
+ // Pass the total we already know so the server can skip `rev-list --count`
1047
+ // on every later page (one git spawn instead of two).
1048
+ const g = await api("graph", { limit: PAGE_MORE, skip: LOADED, total: TOTAL || 0 });
1049
+ if (!g.ok) return;
1050
+ const map = byHash();
1051
+ const fresh = (g.commits || []).filter((c) => !map.has(c.hash));
1052
+ if (!fresh.length) {
1053
+ // Nothing new for this offset; stop auto-loading instead of spinning.
1054
+ PAGINATION_EXHAUSTED = true;
1055
+ return;
1056
+ }
1057
+ COMMITS.push(...fresh);
1058
+ LOADED = COMMITS.length;
1059
+ TOTAL = g.total || LOADED;
1060
+ BRANCH_COLOR_CACHE = null;
1061
+ render();
1062
+ } catch { /* ignore */ } finally {
1063
+ LOADING_MORE = false;
1064
+ }
1065
+ }
1066
+
1067
+ function buildCommitRow(c) {
1068
+ const row = document.createElement("div");
1069
+ // Single source of truth: SELECTED drives BOTH the background highlight and
1070
+ // the dashed outline, so "the row the keyboard continues from" and "the row
1071
+ // whose detail is open" can never drift apart.
1072
+ row.className = "cl-row" + (SELECTED === c.hash ? " sel" : "");
1073
+ row.dataset.hash = c.hash;
1074
+ const color = branchColorOf(c.hash);
1075
+
1076
+ // Lane gutter exists ONLY in flat mode: that is where one continuous SVG is
1077
+ // drawn across the whole list. In grouped mode the rows are split by branch
1078
+ // headers, so a lane drawing would necessarily break at every header — the
1079
+ // user asked for no graph there at all, rather than a broken-looking one.
1080
+ if (FLAT_MODE) {
1081
+ const graph = document.createElement("div");
1082
+ graph.className = "cl-graph";
1083
+ graph.style.width = graphGutterWidth() + "px";
1084
+ row.appendChild(graph);
1085
+ }
1086
+
1087
+ const body = document.createElement("div");
1088
+ body.className = "cl-body";
1089
+ const line1 = document.createElement("div");
1090
+ line1.className = "cl-line1";
1091
+ // Refs come FIRST: a branch/tag label is the shortest, most scannable part of
1092
+ // the row, so putting it at a fixed left position makes the column readable at
1093
+ // a glance. Trailing them after the subject pushed them to wherever the
1094
+ // message happened to end, which is far from the eye's left-to-right scan.
1095
+ if (c.refs.length) {
1096
+ const refs = document.createElement("span");
1097
+ refs.className = "cl-refs";
1098
+ for (const ref of c.refs) {
1099
+ const kind = ref.head ? "head" : ref.kind;
1100
+ const pill = document.createElement("span");
1101
+ pill.className = "cl-ref " + kind;
1102
+ pill.textContent = ref.name;
1103
+ refs.appendChild(pill);
1104
+ }
1105
+ line1.appendChild(refs);
1106
+ }
1107
+ const msg = document.createElement("span");
1108
+ msg.className = "cl-msg";
1109
+ msg.textContent = c.subject || "";
1110
+ msg.title = c.subject || "";
1111
+ line1.appendChild(msg);
1112
+ body.appendChild(line1);
1113
+ const line2 = document.createElement("div");
1114
+ line2.className = "cl-line2";
1115
+ const author = document.createElement("span");
1116
+ author.className = "cl-author";
1117
+ author.textContent = c.author;
1118
+ line2.appendChild(author);
1119
+ line2.appendChild(document.createTextNode(" · " + relTime(c.date) + " · "));
1120
+ const hash = document.createElement("span");
1121
+ hash.className = "cl-hash";
1122
+ hash.textContent = short7(c.hash);
1123
+ line2.appendChild(hash);
1124
+ body.appendChild(line2);
1125
+ row.appendChild(body);
1126
+
1127
+ row.addEventListener("click", (ev) => {
1128
+ ev.stopPropagation();
1129
+ selectCommit(c.hash);
1130
+ });
1131
+ row.addEventListener("contextmenu", (ev) => {
1132
+ ev.preventDefault();
1133
+ ev.stopPropagation();
1134
+ openContextMenu(ev.clientX, ev.clientY, c);
1135
+ });
1136
+ row.addEventListener("mouseenter", (ev) => showTip(ev, c));
1137
+ row.addEventListener("mousemove", (ev) => moveTip(ev));
1138
+ row.addEventListener("mouseleave", hideTip);
1139
+ return row;
1140
+ }
1141
+
1142
+ function buildHeadRow(r) {
1143
+ const row = document.createElement("div");
1144
+ const folded = BRANCH_FOLDED.has(r.refname);
1145
+ row.className = "cl-head" + (folded ? "" : " open");
1146
+ const caret = document.createElement("span");
1147
+ caret.className = "cl-caret";
1148
+ caret.textContent = "▶";
1149
+ row.appendChild(caret);
1150
+ const dot = document.createElement("span");
1151
+ dot.className = "cl-head-dot";
1152
+ dot.style.background = branchColorOf(r.tip);
1153
+ row.appendChild(dot);
1154
+ const name = document.createElement("span");
1155
+ name.className = "cl-head-name";
1156
+ name.textContent = r.name;
1157
+ row.appendChild(name);
1158
+ if (r.head) {
1159
+ const badge = document.createElement("span");
1160
+ badge.className = "cl-head-badge";
1161
+ badge.textContent = "HEAD";
1162
+ row.appendChild(badge);
1163
+ }
1164
+ const count = document.createElement("span");
1165
+ count.className = "cl-head-count";
1166
+ if (r.synced) {
1167
+ count.textContent = r.syncedLabel || "已同步";
1168
+ count.classList.add("cl-synced");
1169
+ } else {
1170
+ count.textContent = `${r.count} 个提交`;
1171
+ }
1172
+ row.appendChild(count);
1173
+ const tip = document.createElement("span");
1174
+ tip.className = "cl-head-tip";
1175
+ const map = byHash();
1176
+ const tipC = map.get(r.tip);
1177
+ if (tipC) tip.textContent = (tipC.subject || "") + " · " + relTime(tipC.date);
1178
+ row.appendChild(tip);
1179
+ row.addEventListener("click", (ev) => {
1180
+ ev.stopPropagation();
1181
+ if (BRANCH_FOLDED.has(r.refname)) BRANCH_FOLDED.delete(r.refname);
1182
+ else BRANCH_FOLDED.add(r.refname);
1183
+ saveStored("gg2-brfolded", [...BRANCH_FOLDED]);
1184
+ render();
1185
+ });
1186
+ row.addEventListener("contextmenu", (ev) => {
1187
+ ev.preventDefault();
1188
+ ev.stopPropagation();
1189
+ openBranchCtxMenu(ev.clientX, ev.clientY, r);
1190
+ });
1191
+ return row;
1192
+ }
1193
+
1194
+ function computeMainline(map) {
1195
+ // HEAD branch mainline: follow first-parent from the commit that carries HEAD ref
1196
+ const set = new Set();
1197
+ const headCommit = COMMITS.find((c) => c.refs.some((r) => r.head));
1198
+ let cur = headCommit ? headCommit.hash : COMMITS.length ? COMMITS[0].hash : null;
1199
+ while (cur && map.has(cur)) {
1200
+ set.add(cur);
1201
+ const c = map.get(cur);
1202
+ cur = c.parents[0] || null;
1203
+ }
1204
+ return set;
1205
+ }
1206
+
1207
+ function matches(c) {
1208
+ if (!SEARCH) return true; // no query = everything matches
1209
+ const q = SEARCH.toLowerCase();
1210
+ return (c.subject || "").toLowerCase().includes(q)
1211
+ || (c.author || "").toLowerCase().includes(q)
1212
+ || c.hash.toLowerCase().includes(q)
1213
+ || (c.email || "").toLowerCase().includes(q);
1214
+ }
1215
+
1216
+ /* ---- date range filter -----------------------------------------------------
1217
+ * A real date interval rather than a rolling "last N days" preset: the user
1218
+ * picks a start and/or an end date. Either side may be empty (open-ended), and
1219
+ * both empty means "no time limit". Dates are stored as plain YYYY-MM-DD so a
1220
+ * saved filter stays meaningful, while comparisons happen on local day
1221
+ * boundaries — a commit at 23:50 on the end date still belongs to that day. */
1222
+ let DATE_FROM = loadStored("gg2-datefrom", ""); // "" | "YYYY-MM-DD"
1223
+ let DATE_TO = loadStored("gg2-dateto", ""); // "" | "YYYY-MM-DD"
1224
+
1225
+ /** Milliseconds for the start of a local day (00:00:00.000). */
1226
+ function dayStart(ymd) {
1227
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd || "");
1228
+ if (!m) return NaN;
1229
+ return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0, 0).getTime();
1230
+ }
1231
+ /** Milliseconds for the end of a local day (23:59:59.999), so the whole day counts. */
1232
+ function dayEnd(ymd) {
1233
+ const s = dayStart(ymd);
1234
+ return Number.isNaN(s) ? NaN : s + 86400e3 - 1;
1235
+ }
1236
+ function withinDateRange(c) {
1237
+ if (!DATE_FROM && !DATE_TO) return true;
1238
+ const t = Date.parse(c.date || "");
1239
+ if (!Number.isFinite(t)) return true; // unparsable date: don't hide it
1240
+ if (DATE_FROM) {
1241
+ const lo = dayStart(DATE_FROM);
1242
+ if (Number.isFinite(lo) && t < lo) return false;
1243
+ }
1244
+ if (DATE_TO) {
1245
+ const hi = dayEnd(DATE_TO);
1246
+ if (Number.isFinite(hi) && t > hi) return false;
1247
+ }
1248
+ return true;
1249
+ }
1250
+
1251
+ /** Single source of truth for "should this commit be shown?".
1252
+ * Search and the date range are *filters* (non-matching commits are hidden),
1253
+ * not highlights — a query that merely tints rows leaves the user scrolling
1254
+ * through everything they did not ask for. */
1255
+ function passesFilters(c) {
1256
+ return matches(c) && withinDateRange(c);
1257
+ }
1258
+ function filtersActive() {
1259
+ return Boolean(SEARCH) || Boolean(DATE_FROM) || Boolean(DATE_TO);
1260
+ }
1261
+
1262
+ function renderLegend() {
1263
+ const lg = $("legend");
1264
+ lg.innerHTML = "";
1265
+ const items = [];
1266
+ const headBranch = BRANCHES.find((b) => b.head) || BRANCHES.find((b) => b.kind === "local");
1267
+ if (headBranch) items.push({ color: "#3fb950", label: "HEAD · " + headBranch.name, sha: headBranch.sha });
1268
+ const locals = BRANCHES.filter((b) => b.kind === "local" && !b.head);
1269
+ const remotes = BRANCHES.filter((b) => b.kind === "remote");
1270
+ const tags = BRANCHES.filter((b) => b.kind === "tag");
1271
+ for (let i = 0; i < locals.length; i++) items.push({ color: BRANCH_PALETTE[i % BRANCH_PALETTE.length], label: locals[i].name, sha: locals[i].sha });
1272
+ for (let i = 0; i < Math.min(3, remotes.length); i++) items.push({ color: "#6e7681", label: "⤴ " + remotes[i].name, sha: remotes[i].sha });
1273
+ if (tags.length) items.push({ color: "var(--yellow)", label: "● " + tags.length + " 个标签" });
1274
+ for (const it of items.slice(0, 8)) {
1275
+ const span = document.createElement("span");
1276
+ span.className = "lg" + (it.sha && HIGHLIGHT === it.sha ? " lg-on" : "");
1277
+ if (it.sha) {
1278
+ span.style.cursor = "pointer";
1279
+ span.title = "点击高亮该分支路径";
1280
+ span.addEventListener("click", (ev) => {
1281
+ ev.stopPropagation();
1282
+ HIGHLIGHT = HIGHLIGHT === it.sha ? null : it.sha;
1283
+ render();
1284
+ });
1285
+ }
1286
+ const i = document.createElement("i");
1287
+ i.style.background = it.color;
1288
+ span.appendChild(i);
1289
+ span.appendChild(document.createTextNode(it.label));
1290
+ lg.appendChild(span);
1291
+ }
1292
+ }
1293
+
1294
+ /* ===================== (branch tree removed: merged into the graph view;
1295
+ * branch listing/filtering lives in the ☑ 分支过滤 menu) ===================== */
1296
+
1297
+ /* ===================== lane layout (real git graph) =====================
1298
+ * Ported from the "reserved point" strategy used by VS Code Git Graph (and
1299
+ * implemented the same way in WhitePlusMS/dsh-git-graph). A branch owns a
1300
+ * continuous path down its own lane; a merge path reuses the point already
1301
+ * reserved by its target parent branch. Without reservations unrelated lanes
1302
+ * shift diagonally on every following row, which is what makes naive lane
1303
+ * assignment look wrong.
1304
+ *
1305
+ * Pure function: commits in -> { nodes, edges, laneCount } out. No DOM access,
1306
+ * so it can be unit-tested and reused by any renderer.
1307
+ *
1308
+ * `missingParent` marks an edge whose parent is outside the loaded window
1309
+ * (pagination!). The renderer draws those dashed instead of implying the
1310
+ * history ends there.
1311
+ */
1312
+ const LANE_NULL = -1;
1313
+
1314
+ function layoutGraph(commits) {
1315
+ const index = new Map();
1316
+ commits.forEach((c, i) => index.set(c.hash, i));
1317
+
1318
+ // Vertex state: parents resolved to indices, plus booking slots per lane.
1319
+ const vertices = commits.map((c, id) => ({
1320
+ id,
1321
+ commit: c,
1322
+ parents: (c.parents || []).map((p) => (index.has(p) ? index.get(p) : LANE_NULL)),
1323
+ connections: [], // lane -> { target, colour } reserved by a passing edge
1324
+ branch: undefined, // colour id once claimed
1325
+ lane: 0,
1326
+ nextLane: 0,
1327
+ nextParent: 0,
1328
+ }));
1329
+
1330
+ const branches = []; // { colour, edges[], end }
1331
+ const branchEnds = []; // colour -> last row it was used on
1332
+
1333
+ const nextPointOf = (v) => ({ lane: v.nextLane, row: v.id });
1334
+ const pointOf = (v) => ({ lane: v.lane, row: v.id });
1335
+
1336
+ const connectPoint = (v, target, colour) => {
1337
+ for (let lane = 0; lane < v.connections.length; lane++) {
1338
+ const c = v.connections[lane];
1339
+ if (c && c.target === target && c.colour === colour) return { lane, row: v.id };
1340
+ }
1341
+ return undefined;
1342
+ };
1343
+ const reservePoint = (v, lane, target, colour) => {
1344
+ if (lane !== v.nextLane) return;
1345
+ v.nextLane += 1;
1346
+ v.connections[lane] = { target, colour };
1347
+ };
1348
+ const addEdge = (br, from, to, missingParent) => {
1349
+ br.edges.push({
1350
+ fromLane: from.lane,
1351
+ toLane: to.lane,
1352
+ row: from.row,
1353
+ colour: br.colour,
1354
+ missingParent: Boolean(missingParent),
1355
+ });
1356
+ };
1357
+ const availableColour = (startAt) => {
1358
+ for (let colour = 0; colour < branchEnds.length; colour++) {
1359
+ const end = branchEnds[colour];
1360
+ if (end !== undefined && startAt > end) return colour;
1361
+ }
1362
+ branchEnds.push(0);
1363
+ return branchEnds.length - 1;
1364
+ };
1365
+
1366
+ function determinePath(startAt) {
1367
+ let row = startAt;
1368
+ let vertex = vertices[row];
1369
+ if (!vertex) return;
1370
+
1371
+ let parent = vertex.parents[vertex.nextParent];
1372
+ let lastPoint = vertex.branch === undefined ? nextPointOf(vertex) : pointOf(vertex);
1373
+
1374
+ // A merge can join a branch that already exists further down: follow that
1375
+ // branch until its reserved connection point shows up instead of opening a
1376
+ // new lane.
1377
+ if (
1378
+ parent !== undefined && parent !== LANE_NULL &&
1379
+ vertex.parents.length > 1 && vertex.branch !== undefined &&
1380
+ vertices[parent] && vertices[parent].branch !== undefined
1381
+ ) {
1382
+ const parentBranch = vertices[parent].branch;
1383
+ const targetBranch = branches[parentBranch];
1384
+ if (targetBranch === undefined) return;
1385
+ let found = false;
1386
+ for (row = startAt + 1; row < vertices.length; row++) {
1387
+ const cur = vertices[row];
1388
+ if (!cur) continue;
1389
+ const connected = connectPoint(cur, parent, parentBranch);
1390
+ const curPoint = connected || nextPointOf(cur);
1391
+ found = connected !== undefined;
1392
+ addEdge(targetBranch, lastPoint, curPoint, false);
1393
+ reservePoint(cur, curPoint.lane, parent, parentBranch);
1394
+ lastPoint = curPoint;
1395
+ if (found) { vertex.nextParent += 1; break; }
1396
+ }
1397
+ if (!found) vertex.nextParent += 1;
1398
+ return;
1399
+ }
1400
+
1401
+ const br = { colour: availableColour(startAt), edges: [], end: row };
1402
+ branches.push(br);
1403
+
1404
+ if (vertex.branch === undefined) {
1405
+ vertex.branch = br.colour;
1406
+ vertex.lane = lastPoint.lane;
1407
+ }
1408
+ reservePoint(vertex, lastPoint.lane, vertex.id, br.colour);
1409
+
1410
+ // Root commit: the lane ends here even if more history follows below.
1411
+ if (parent === undefined) {
1412
+ branchEnds[br.colour] = startAt;
1413
+ return;
1414
+ }
1415
+
1416
+ for (row = startAt + 1; row < vertices.length; row++) {
1417
+ const cur = vertices[row];
1418
+ if (!cur) continue;
1419
+ const curPoint = (parent === cur.id && cur.branch !== undefined) ? pointOf(cur) : nextPointOf(cur);
1420
+ addEdge(br, lastPoint, curPoint, parent === LANE_NULL);
1421
+ reservePoint(cur, curPoint.lane, parent ?? LANE_NULL, br.colour);
1422
+ lastPoint = curPoint;
1423
+
1424
+ if (parent === cur.id) {
1425
+ vertex.nextParent += 1;
1426
+ const parentAlreadyOnBranch = cur.branch !== undefined;
1427
+ if (!parentAlreadyOnBranch) {
1428
+ cur.branch = br.colour;
1429
+ cur.lane = curPoint.lane;
1430
+ }
1431
+ // Walk on from the parent: `vertex` becomes the parent, and its own
1432
+ // nextParent counter decides which of ITS parents to follow next.
1433
+ vertex = cur;
1434
+ parent = vertex.parents[vertex.nextParent];
1435
+ if (parent === undefined || parentAlreadyOnBranch) break;
1436
+ }
1437
+ }
1438
+ if (row >= vertices.length && parent === LANE_NULL) vertex.nextParent += 1;
1439
+ br.end = row;
1440
+ branchEnds[br.colour] = row;
1441
+ }
1442
+
1443
+ let row = 0;
1444
+ while (row < vertices.length) {
1445
+ const v = vertices[row];
1446
+ if (v && (v.branch === undefined || v.nextParent < v.parents.length)) {
1447
+ determinePath(row);
1448
+ } else {
1449
+ row += 1;
1450
+ }
1451
+ }
1452
+
1453
+ const nodes = vertices.map((v) => ({
1454
+ hash: v.commit.hash,
1455
+ lane: v.lane,
1456
+ row: v.id,
1457
+ colour: v.branch ?? 0,
1458
+ }));
1459
+ const edges = branches.flatMap((b) => b.edges);
1460
+ const laneCount = Math.max(
1461
+ 1,
1462
+ ...nodes.map((n) => n.lane + 1),
1463
+ ...edges.map((e) => Math.max(e.fromLane, e.toLane) + 1),
1464
+ );
1465
+ return { nodes, edges, laneCount };
1466
+ }
1467
+
1468
+ /* Cached layout for the current COMMITS array; invalidated on reload. */
1469
+ let CACHE_LAYOUT = null;
1470
+ let CACHE_LAYOUT_SIG = "";
1471
+ function ensureLayout() {
1472
+ const sig = COMMITS.length + ":" + (COMMITS[0] ? COMMITS[0].hash : "");
1473
+ if (CACHE_LAYOUT && CACHE_LAYOUT_SIG === sig) return CACHE_LAYOUT;
1474
+ CACHE_LAYOUT = layoutGraph(COMMITS);
1475
+ CACHE_LAYOUT_SIG = sig;
1476
+ return CACHE_LAYOUT;
1477
+ }
1478
+
1479
+ function getLaneOf(hash) {
1480
+ if (!CACHE_LAYOUT) return 0;
1481
+ const n = CACHE_LAYOUT.nodes.find((x) => x.hash === hash);
1482
+ return n ? n.lane : 0;
1483
+ }
1484
+
1485
+
1486
+ /* ===================== interactions ===================== */
1487
+ const tip = document.createElement("div");
1488
+ tip.className = "tooltip";
1489
+ tip.style.display = "none";
1490
+ document.body.appendChild(tip);
1491
+ function showTip(ev, c) {
1492
+ tip.innerHTML = "";
1493
+ const refs = c.refs.length ? c.refs.map((r) => r.name).join(", ") : "—";
1494
+ tip.appendChild(document.createTextNode(`${c.hash} ${c.subject}`));
1495
+ const sub = document.createElement("div");
1496
+ sub.className = "tt-sub";
1497
+ sub.textContent = `${c.author} · ${c.date} | refs: ${refs}`;
1498
+ tip.appendChild(sub);
1499
+ tip.style.display = "block";
1500
+ moveTip(ev);
1501
+ }
1502
+ function moveTip(ev) {
1503
+ tip.style.left = (ev.clientX + 14) + "px";
1504
+ tip.style.top = (ev.clientY + 12) + "px";
1505
+ }
1506
+ function hideTip() { tip.style.display = "none"; }
1507
+
1508
+ /* No left-drag panning and no wheel zoom: the graph scrolls naturally with the
1509
+ * mouse wheel (graphWrap is overflow:auto). SCALE/PAN stay fixed at defaults. */
1510
+
1511
+ /* ===================== detail ===================== */
1512
+ /* Detail panel width is user-resizable (splitter scrub), persisted in localStorage.
1513
+ DEFAULTS stay CSS-driven via --gg-detail-w so the stylesheet remains the source
1514
+ of truth; the stored value only overrides it once the user drags. */
1515
+ const DETAIL_W_DEFAULT = 430, DETAIL_W_MIN = 260;
1516
+ let DETAIL_W = (() => { // null = use CSS default
1517
+ const v = Number(loadStored("gg2-detailw", null));
1518
+ return Number.isFinite(v) && v > 0 ? v : null;
1519
+ })();
1520
+ function detailWMax() {
1521
+ const w = $("main").clientWidth || 0;
1522
+ // keep the graph usable: leave room for the commit list + workdir header buttons
1523
+ return Math.max(DETAIL_W_MIN, Math.min(1200, w - 520));
1524
+ }
1525
+ /** Suppress the width transition for one frame, so programmatic width changes
1526
+ * (restore / reset / window resize) don't animate. */
1527
+ function withoutDetailAnim(fn) {
1528
+ const el = $("detail");
1529
+ el.classList.add("no-anim");
1530
+ fn();
1531
+ // drop the flag on the next frame: within the same task the browser would
1532
+ // still coalesce both class changes and animate nothing.
1533
+ requestAnimationFrame(() => requestAnimationFrame(() => el.classList.remove("no-anim")));
1534
+ }
1535
+ function applyDetailWidth(px) {
1536
+ if (px === null) {
1537
+ document.documentElement.style.removeProperty("--gg-detail-w");
1538
+ DETAIL_W = null;
1539
+ return;
1540
+ }
1541
+ const v = Math.max(DETAIL_W_MIN, Math.min(detailWMax(), Math.round(px)));
1542
+ DETAIL_W = v;
1543
+ document.documentElement.style.setProperty("--gg-detail-w", v + "px");
1544
+ }
1545
+ /* Open/close helper: keeps #main.detail-open in sync (drives the splitter). */
1546
+ function setDetailOpen(open) {
1547
+ $("detail").classList.toggle("open", open);
1548
+ $("main").classList.toggle("detail-open", open);
1549
+ }
1550
+ async function selectCommit(hash, scroll = false) {
1551
+ SELECTED = hash;
1552
+ render();
1553
+ if (scroll) scrollToNode(hash);
1554
+ setDetailOpen(true);
1555
+ const d = $("dContent");
1556
+ // Re-opening a commit that was already viewed is instant: no git process at
1557
+ // all. Commit data is immutable, so a hit never needs revalidation.
1558
+ const hit = cacheGet(DETAIL_CACHE, hash);
1559
+ if (hit) { renderDetail(hit.info, hit.diff); return; }
1560
+ d.innerHTML = "";
1561
+ const loading = document.createElement("div");
1562
+ loading.className = "empty";
1563
+ loading.textContent = "加载详情…";
1564
+ d.appendChild(loading);
1565
+ try {
1566
+ const info = await api("show", { hash });
1567
+ if (!info.ok) { d.textContent = "加载失败: " + (info.error || "?"); return; }
1568
+ const firstParent = info.parents[0];
1569
+ const diff = firstParent ? await api("diff", { hash }) : null;
1570
+ // don't cache failures; a transient git error should be retryable
1571
+ if (info.ok) cacheSet(DETAIL_CACHE, hash, { info, diff });
1572
+ // the user may have clicked another commit while this was in flight
1573
+ if (SELECTED !== hash) return;
1574
+ renderDetail(info, diff);
1575
+ } catch (err) {
1576
+ d.textContent = "加载失败: " + err.message;
1577
+ }
1578
+ }
1579
+ function scrollToNode(hash) {
1580
+ const row = $("commitList").querySelector(`.cl-row[data-hash="${hash}"]`);
1581
+ if (row) row.scrollIntoView({ behavior: "smooth", block: "center" });
1582
+ }
1583
+ function renderDetail(info, diff) {
1584
+ const d = $("dContent");
1585
+ d.innerHTML = "";
1586
+ const meta = document.createElement("div");
1587
+ meta.className = "dmeta";
1588
+ const rows = [
1589
+ ["提交", info.hash],
1590
+ ["作者", `${info.author} <${info.email}>`],
1591
+ ["时间", info.date],
1592
+ ["父提交", (info.parents.length ? info.parents.join(", ") : "(root)")]
1593
+ ];
1594
+ for (const [k, v] of rows) {
1595
+ const b = document.createElement("b"); b.textContent = k;
1596
+ const s = document.createElement("span"); s.textContent = v;
1597
+ if (k === "提交") { s.className = "hash"; s.title = "点击复制"; s.addEventListener("click", () => navigator.clipboard?.writeText(v).catch(() => {})); }
1598
+ meta.appendChild(b); meta.appendChild(s);
1599
+ }
1600
+ d.appendChild(meta);
1601
+
1602
+ const body = document.createElement("div");
1603
+ body.className = "dbody";
1604
+ const msg = document.createElement("div");
1605
+ msg.className = "msg";
1606
+ if (info.subject) {
1607
+ const subj = document.createElement("span");
1608
+ subj.className = "subj";
1609
+ subj.textContent = info.subject;
1610
+ msg.appendChild(subj);
1611
+ }
1612
+ if (info.body) msg.appendChild(document.createTextNode("\n" + info.body));
1613
+ body.appendChild(msg);
1614
+
1615
+ if (info.stat) {
1616
+ const sec = document.createElement("div");
1617
+ sec.className = "section";
1618
+ sec.textContent = "变更统计(点击文件名看单个文件 diff)";
1619
+ body.appendChild(sec);
1620
+ const pre = document.createElement("pre");
1621
+ pre.className = "stat";
1622
+ const fileRow = document.createElement("div");
1623
+ fileRow.className = "stat-files";
1624
+ for (const line of String(info.stat).split("\n")) {
1625
+ const row = document.createElement("div");
1626
+ row.className = "sf-row";
1627
+ const m = line.match(/^\s*(.+?)\s*\|\s*(\d+)(.*)$/);
1628
+ if (m && !line.includes("files changed") && !line.includes("file changed") && !line.trim().startsWith("--")) {
1629
+ let fname = m[1].trim();
1630
+ // rename "a => b" keeps the new name
1631
+ if (fname.includes("=>")) fname = fname.split("=>")[1].trim();
1632
+ const fileSpan = document.createElement("span");
1633
+ fileSpan.className = "sf-file";
1634
+ fileSpan.textContent = fname;
1635
+ fileSpan.title = "点击查看该文件 diff";
1636
+ fileSpan.addEventListener("click", () => showFileDiff(info.hash, fname, row));
1637
+ const histSpan = document.createElement("span");
1638
+ histSpan.className = "sf-file sf-hist";
1639
+ histSpan.textContent = "📜";
1640
+ histSpan.title = "查看该文件的历史提交";
1641
+ histSpan.addEventListener("click", () => showFileHist(fname, row));
1642
+ const restSpan = document.createElement("span");
1643
+ restSpan.className = "sf-rest";
1644
+ restSpan.textContent = line.slice(line.indexOf("|"));
1645
+ row.appendChild(fileSpan);
1646
+ row.appendChild(histSpan);
1647
+ row.appendChild(restSpan);
1648
+ } else {
1649
+ row.textContent = line;
1650
+ }
1651
+ fileRow.appendChild(row);
1652
+ }
1653
+ body.appendChild(fileRow);
1654
+ body.appendChild(pre);
1655
+ }
1656
+ if (diff && diff.ok) {
1657
+ const sec = document.createElement("div");
1658
+ sec.className = "section";
1659
+ sec.textContent = diff.truncated ? "Diff(过长已截断)" : "Diff";
1660
+ body.appendChild(sec);
1661
+ if (diff.diff && diff.diff.trim()) {
1662
+ const pre = document.createElement("pre");
1663
+ pre.className = "diff";
1664
+ highlightDiff(pre, diff.diff);
1665
+ body.appendChild(pre);
1666
+ } else {
1667
+ const e = document.createElement("div");
1668
+ e.className = "empty";
1669
+ e.textContent = "(无差异内容)";
1670
+ body.appendChild(e);
1671
+ }
1672
+ }
1673
+ d.appendChild(body);
1674
+ }
1675
+ $("dClose").addEventListener("click", () => {
1676
+ setDetailOpen(false);
1677
+ SELECTED = null;
1678
+ render();
1679
+ });
1680
+
1681
+ /* ---------- splitter: drag to resize the detail panel ---------- */
1682
+ /* Pointer Events cover mouse + touch + pen; the grip is only hit-testable while
1683
+ the panel is open (CSS), so no extra state guard is needed before a drag. */
1684
+ (() => {
1685
+ const grip = $("detailGrip");
1686
+ let startX = 0, startW = 0, moved = false, dragging = false;
1687
+ const currentW = () => $("detail").getBoundingClientRect().width || DETAIL_W_DEFAULT;
1688
+ function onMove(ev) {
1689
+ if (!dragging) return;
1690
+ const dx = ev.clientX - startX;
1691
+ if (Math.abs(dx) > 1) moved = true;
1692
+ applyDetailWidth(startW - dx); // dragging left => wider panel
1693
+ }
1694
+ function endDrag() {
1695
+ if (!dragging) return;
1696
+ dragging = false;
1697
+ grip.classList.remove("dragging");
1698
+ document.body.classList.remove("gg-col-resize");
1699
+ // drop the no-anim flag on the next frame: removing it in the same task as
1700
+ // the last width change would let the transition animate from a stale width
1701
+ requestAnimationFrame(() => $("detail").classList.remove("no-anim"));
1702
+ window.removeEventListener("pointermove", onMove);
1703
+ window.removeEventListener("pointerup", endDrag);
1704
+ window.removeEventListener("pointercancel", endDrag);
1705
+ if (moved) saveStored("gg2-detailw", DETAIL_W);
1706
+ }
1707
+ grip.addEventListener("pointerdown", (ev) => {
1708
+ if (ev.button !== undefined && ev.button !== 0) return;
1709
+ ev.preventDefault();
1710
+ dragging = true;
1711
+ moved = false;
1712
+ startX = ev.clientX;
1713
+ startW = currentW();
1714
+ grip.classList.add("dragging");
1715
+ document.body.classList.add("gg-col-resize");
1716
+ $("detail").classList.add("no-anim");
1717
+ try { grip.setPointerCapture(ev.pointerId); } catch { /* ignore */ }
1718
+ window.addEventListener("pointermove", onMove);
1719
+ window.addEventListener("pointerup", endDrag);
1720
+ window.addEventListener("pointercancel", endDrag);
1721
+ });
1722
+ // double-click: back to the stylesheet default
1723
+ grip.addEventListener("dblclick", () => {
1724
+ withoutDetailAnim(() => applyDetailWidth(null));
1725
+ try { localStorage.removeItem("gg2-detailw"); } catch { /* ignore */ }
1726
+ });
1727
+ // keyboard affordance for accessibility (grip is focusable)
1728
+ grip.addEventListener("keydown", (ev) => {
1729
+ const step = ev.shiftKey ? 40 : 12;
1730
+ if (ev.key !== "ArrowLeft" && ev.key !== "ArrowRight") return;
1731
+ ev.preventDefault();
1732
+ withoutDetailAnim(() => applyDetailWidth(currentW() + (ev.key === "ArrowLeft" ? step : -step)));
1733
+ saveStored("gg2-detailw", DETAIL_W);
1734
+ });
1735
+ // keep the stored width legal when the window shrinks
1736
+ window.addEventListener("resize", () => {
1737
+ if (DETAIL_W !== null) withoutDetailAnim(() => applyDetailWidth(DETAIL_W));
1738
+ positionDateMenu(); // re-clamp an open date popover into the new viewport
1739
+ positionFilterMenu(); // same for the branch filter menu (narrow layouts)
1740
+ relayoutWdBody();
1741
+ });
1742
+ if (DETAIL_W !== null) withoutDetailAnim(() => applyDetailWidth(DETAIL_W)); // restore on load
1743
+ })();
1744
+
1745
+ /* Show a single file's diff inside the detail panel (async). */
1746
+ async function showFileDiff(hash, file, rowEl) {
1747
+ const d = $("dContent");
1748
+ // Toggling a block off must not cost a request: check first, fetch later.
1749
+ const existing = d.querySelector(".sf-diff-block[data-file='" + CSS.escape(file) + "']");
1750
+ if (existing) { existing.remove(); return; } // toggle off
1751
+ rowEl.classList.add("sf-loading");
1752
+ try {
1753
+ const key = hash + "\0" + file;
1754
+ let res = cacheGet(FILEDIFF_CACHE, key);
1755
+ if (!res) {
1756
+ res = await api("filediff", { hash, file });
1757
+ if (res.ok) cacheSet(FILEDIFF_CACHE, key, res);
1758
+ }
1759
+ rowEl.classList.remove("sf-loading");
1760
+ // the panel may have moved on to another commit while this was in flight
1761
+ if (SELECTED !== hash) return;
1762
+ const block = document.createElement("div");
1763
+ block.className = "sf-diff-block";
1764
+ block.dataset.file = file;
1765
+ const head = document.createElement("div");
1766
+ head.className = "sf-diff-head";
1767
+ const name = document.createElement("span");
1768
+ name.textContent = file;
1769
+ const close = document.createElement("span");
1770
+ close.className = "sf-diff-close";
1771
+ close.textContent = "✕";
1772
+ close.addEventListener("click", () => block.remove());
1773
+ head.appendChild(name);
1774
+ head.appendChild(close);
1775
+ block.appendChild(head);
1776
+ const pre = document.createElement("pre");
1777
+ pre.className = "diff";
1778
+ if (res.ok) {
1779
+ highlightDiff(pre, res.diff || "(无差异)");
1780
+ } else {
1781
+ pre.textContent = "加载失败: " + (res.error || "?");
1782
+ }
1783
+ block.appendChild(pre);
1784
+ d.appendChild(block);
1785
+ block.scrollIntoView({ behavior: "smooth", block: "nearest" });
1786
+ } catch (err) {
1787
+ rowEl.classList.remove("sf-loading");
1788
+ alert("加载失败: " + err.message);
1789
+ }
1790
+ }
1791
+
1792
+ /** File history: list every commit touching the file (--follow). */
1793
+ async function showFileHist(file, rowEl) {
1794
+ const d = $("dContent");
1795
+ // toggle-off before fetching, same as showFileDiff
1796
+ const existing = d.querySelector(".sf-hist-block[data-file='" + CSS.escape(file) + "']");
1797
+ if (existing) { existing.remove(); return; } // toggle off
1798
+ rowEl.classList.add("sf-loading");
1799
+ try {
1800
+ let res = cacheGet(FILELOG_CACHE, file);
1801
+ if (!res) {
1802
+ res = await api("filelog", { file });
1803
+ if (res.ok) cacheSet(FILELOG_CACHE, file, res);
1804
+ }
1805
+ rowEl.classList.remove("sf-loading");
1806
+ const block = document.createElement("div");
1807
+ block.className = "sf-diff-block sf-hist-block";
1808
+ block.dataset.file = file;
1809
+ const head = document.createElement("div");
1810
+ head.className = "sf-diff-head";
1811
+ const name = document.createElement("span");
1812
+ name.textContent = "📜 " + file + " 的历史提交";
1813
+ const close = document.createElement("span");
1814
+ close.className = "sf-diff-close";
1815
+ close.textContent = "✕";
1816
+ close.addEventListener("click", () => block.remove());
1817
+ head.appendChild(name);
1818
+ head.appendChild(close);
1819
+ block.appendChild(head);
1820
+ const list = document.createElement("div");
1821
+ if (!res.ok || !res.commits.length) {
1822
+ const e = document.createElement("div");
1823
+ e.className = "empty";
1824
+ e.textContent = res.error || "(该文件没有提交历史)";
1825
+ list.appendChild(e);
1826
+ } else {
1827
+ for (const c of res.commits) {
1828
+ const r = document.createElement("div");
1829
+ r.className = "sf-hist-row";
1830
+ const msg = document.createElement("span");
1831
+ msg.className = "sf-hist-msg";
1832
+ msg.textContent = c.subject || "";
1833
+ const meta = document.createElement("span");
1834
+ meta.className = "sf-hist-meta";
1835
+ meta.textContent = short7(c.hash) + " · " + relTime(c.date) + " · " + c.author;
1836
+ r.appendChild(msg);
1837
+ r.appendChild(meta);
1838
+ r.addEventListener("click", () => selectCommit(c.hash));
1839
+ list.appendChild(r);
1840
+ }
1841
+ }
1842
+ block.appendChild(list);
1843
+ d.appendChild(block);
1844
+ block.scrollIntoView({ behavior: "smooth", block: "nearest" });
1845
+ } catch (err) {
1846
+ rowEl.classList.remove("sf-loading");
1847
+ alert("加载失败: " + err.message);
1848
+ }
1849
+ }
1850
+
1851
+ /* ===================== workdir modal (VSCode-style) ===================== */
1852
+ /* ===================== workdir panel (persistent, top of the graph) ===================== */
1853
+ /** Fetch workstatus, then render the top panel + status line. */
1854
+ async function refreshWorkdir() {
1855
+ const panel = $("wdPanel");
1856
+ panel.innerHTML = "";
1857
+ const loading = document.createElement("div");
1858
+ loading.className = "wd-clean";
1859
+ loading.textContent = "加载工作区状态…";
1860
+ panel.appendChild(loading);
1861
+ try {
1862
+ const ws = await api("workstatus");
1863
+ if (!ws.ok) { panel.innerHTML = ""; return; }
1864
+ WORKSTATUS = ws;
1865
+ renderWorkdir();
1866
+ renderStatusLine();
1867
+ renderStats();
1868
+ } catch { /* ignore */ }
1869
+ }
1870
+
1871
+ function renderWorkdir() {
1872
+ const panel = $("wdPanel");
1873
+ panel.innerHTML = "";
1874
+ const ws = WORKSTATUS;
1875
+ const total = ws.counts.staged + ws.counts.unstaged + ws.counts.untracked;
1876
+
1877
+ const top = document.createElement("div");
1878
+ top.className = "wd-top";
1879
+ const caret = document.createElement("span");
1880
+ caret.className = "wd-caret";
1881
+ caret.textContent = "▶";
1882
+ top.appendChild(caret);
1883
+ const title = document.createElement("span");
1884
+ title.className = "wd-title";
1885
+ title.textContent = "未提交改动";
1886
+ top.appendChild(title);
1887
+ const counts = document.createElement("span");
1888
+ counts.className = "wd-counts";
1889
+ counts.textContent = `已暂存 ${ws.counts.staged} · 更改 ${ws.counts.unstaged} · 未跟踪 ${ws.counts.untracked}`;
1890
+ top.appendChild(counts);
1891
+ const sp = document.createElement("span");
1892
+ sp.className = "sp";
1893
+ top.appendChild(sp);
1894
+
1895
+ // working-tree write actions
1896
+ const stageAllBtn = document.createElement("button");
1897
+ stageAllBtn.type = "button";
1898
+ stageAllBtn.textContent = "全部暂存";
1899
+ stageAllBtn.title = "暂存所有未暂存/未跟踪的更改";
1900
+ stageAllBtn.addEventListener("click", (ev) => { ev.stopPropagation(); runGitOp("stage-all"); });
1901
+ top.appendChild(stageAllBtn);
1902
+
1903
+ const commitBtn = document.createElement("button");
1904
+ commitBtn.type = "button";
1905
+ commitBtn.textContent = "提交";
1906
+ commitBtn.title = "提交更改到本地仓库";
1907
+ commitBtn.addEventListener("click", async (ev) => {
1908
+ ev.stopPropagation();
1909
+ const total = WORKSTATUS ? WORKSTATUS.counts.staged + WORKSTATUS.counts.unstaged + WORKSTATUS.counts.untracked : 0;
1910
+ if (!total) { alert("没有可提交的改动"); return; }
1911
+ const r = await commitDialog();
1912
+ if (r && r.message) runGitOp("commit", { message: r.message, all: r.all ? 1 : 0 }, true);
1913
+ });
1914
+ top.appendChild(commitBtn);
1915
+ // disable write buttons while the working tree is clean
1916
+ if (total === 0) {
1917
+ stageAllBtn.disabled = true;
1918
+ commitBtn.disabled = true;
1919
+ stageAllBtn.title = "工作区没有可暂存的改动";
1920
+ commitBtn.title = "工作区没有可提交的改动";
1921
+ }
1922
+
1923
+ // group visibility dropdown (已暂存 / 更改 / 未跟踪)
1924
+ const filterWrap = document.createElement("div");
1925
+ filterWrap.className = "wd-filterWrap";
1926
+ const filterBtn = document.createElement("button");
1927
+ filterBtn.type = "button";
1928
+ filterBtn.textContent = "☑ 分组";
1929
+ filterBtn.title = "选择显示哪些分组";
1930
+ filterBtn.setAttribute("aria-haspopup", "true");
1931
+ filterBtn.setAttribute("aria-expanded", "false");
1932
+ const menu = document.createElement("div");
1933
+ menu.className = "wd-menu";
1934
+ filterBtn.addEventListener("click", (ev) => {
1935
+ ev.stopPropagation();
1936
+ setWdMenuOpen(menu, !menu.classList.contains("show"));
1937
+ });
1938
+ const mkToggle = (key, label) => {
1939
+ const lab = document.createElement("label");
1940
+ const cb = document.createElement("input");
1941
+ cb.type = "checkbox";
1942
+ cb.checked = WD_SHOW[key];
1943
+ cb.addEventListener("change", () => {
1944
+ WD_SHOW[key] = cb.checked;
1945
+ saveStored("gg2-wdshow", WD_SHOW);
1946
+ renderWdGroups(body);
1947
+ });
1948
+ lab.appendChild(cb);
1949
+ lab.appendChild(document.createTextNode(label));
1950
+ menu.appendChild(lab);
1951
+ };
1952
+ mkToggle("staged", "已暂存");
1953
+ mkToggle("unstaged", "更改");
1954
+ mkToggle("untracked", "未跟踪");
1955
+ menu.addEventListener("click", (ev) => ev.stopPropagation());
1956
+ filterWrap.appendChild(filterBtn);
1957
+ filterWrap.appendChild(menu);
1958
+ top.appendChild(filterWrap);
1959
+
1960
+ const refresh = document.createElement("button");
1961
+ refresh.type = "button";
1962
+ refresh.textContent = "↻ 刷新";
1963
+ refresh.title = "重新加载未提交改动";
1964
+ refresh.addEventListener("click", (ev) => { ev.stopPropagation(); refreshWorkdir(); });
1965
+ top.appendChild(refresh);
1966
+ // clicking anywhere else on the bar folds/unfolds the whole panel (like a branch group)
1967
+ top.addEventListener("click", () => {
1968
+ WD_FOLDED = !WD_FOLDED;
1969
+ saveStored("gg2-wdfolded", WD_FOLDED);
1970
+ panel.classList.toggle("folded", WD_FOLDED);
1971
+ });
1972
+ panel.appendChild(top);
1973
+
1974
+ // batch action bar (shown when rows are checked)
1975
+ const batch = document.createElement("div");
1976
+ batch.className = "wd-batch";
1977
+ batch.style.display = "none";
1978
+ panel.appendChild(batch);
1979
+
1980
+ const body = document.createElement("div");
1981
+ body.className = "wd-body";
1982
+ panel.appendChild(body);
1983
+ panel.classList.toggle("folded", WD_FOLDED);
1984
+ renderWdGroups(body);
1985
+ updateBatchBar();
1986
+ }
1987
+
1988
+ /** Render the three groups (staged / changes / untracked) into the panel body. */
1989
+ /** Rows shown before the file list starts scrolling.
1990
+ * 10 rows is about the height of the panel at a normal window size; past that
1991
+ * the list would push the commit graph down by a full screen. */
1992
+ const MAX_WD_ROWS = 10;
1993
+
1994
+ /** Cap the file list at MAX_WD_ROWS rows and let it scroll internally.
1995
+ * Measured from real offsets rather than a hardcoded row height: rows carry a
1996
+ * badge, a checkbox, variable padding and (when opened) a diff, and the group
1997
+ * heads between them take space too. Clamping at the top of row 11 keeps
1998
+ * exactly 10 rows visible however they are distributed across groups.
1999
+ * Collapsed groups are skipped — their rows measure 0 and would drag the cap
2000
+ * up to a nonsense value. */
2001
+ function layoutWdBody(body) {
2002
+ if (!body) return;
2003
+ body.classList.remove("scrolls");
2004
+ body.style.maxHeight = "";
2005
+ const rows = [...body.querySelectorAll(".wd-row")].filter((r) => r.offsetParent !== null);
2006
+ if (rows.length <= MAX_WD_ROWS) return;
2007
+ const bodyTop = body.getBoundingClientRect().top;
2008
+ const h = rows[MAX_WD_ROWS].getBoundingClientRect().top - bodyTop;
2009
+ if (!(h > 0)) return;
2010
+ body.classList.add("scrolls");
2011
+ body.style.maxHeight = Math.ceil(h) + "px";
2012
+ }
2013
+
2014
+ /** Rebuild the groups, then re-apply the row cap (the cap depends on what the
2015
+ * list now holds, so it cannot be set once at mount time). */
2016
+ /** Re-measure the cap for the mounted panel, if there is one.
2017
+ * Callers: window resize, and row/group toggles. Cheap enough to run
2018
+ * directly — it only reads layout, and it bails before touching the DOM when
2019
+ * the list is short. */
2020
+ function relayoutWdBody() {
2021
+ const body = $("wdPanel").querySelector(".wd-body");
2022
+ if (body) layoutWdBody(body);
2023
+ }
2024
+
2025
+ function renderWdGroups(body) {
2026
+ renderWdGroupsRows(body);
2027
+ layoutWdBody(body);
2028
+ }
2029
+
2030
+ function renderWdGroupsRows(body) {
2031
+ body.innerHTML = "";
2032
+ const ws = WORKSTATUS;
2033
+ const total = ws.counts.staged + ws.counts.unstaged + ws.counts.untracked;
2034
+ if (!total) {
2035
+ const clean = document.createElement("div");
2036
+ clean.className = "wd-clean";
2037
+ clean.textContent = "✓ 工作区干净";
2038
+ body.appendChild(clean);
2039
+ return;
2040
+ }
2041
+
2042
+ const groups = [
2043
+ { key: "staged", title: "已暂存", items: ws.staged, staged: "1" },
2044
+ { key: "unstaged", title: "更改", items: ws.unstaged, staged: "0" },
2045
+ { key: "untracked", title: "未跟踪", items: ws.untracked.map((f) => ({ path: f.path, code: "?" })), staged: "untracked" }
2046
+ ];
2047
+ for (const g of groups) {
2048
+ if (!WD_SHOW[g.key] || !g.items.length) continue;
2049
+ const wrap = document.createElement("div");
2050
+ wrap.className = "wd-group" + (WD_GROUP_FOLDED.has(g.key) ? " folded" : "");
2051
+ const gh = document.createElement("div");
2052
+ gh.className = "wd-head";
2053
+ const gcaret = document.createElement("span");
2054
+ gcaret.className = "wd-caret";
2055
+ gcaret.textContent = "▶";
2056
+ gh.appendChild(gcaret);
2057
+ gh.appendChild(document.createTextNode(g.title));
2058
+ const gn = document.createElement("span");
2059
+ gn.className = "wd-n";
2060
+ gn.textContent = "(" + g.items.length + ")";
2061
+ gh.appendChild(gn);
2062
+ gh.addEventListener("click", () => {
2063
+ if (WD_GROUP_FOLDED.has(g.key)) WD_GROUP_FOLDED.delete(g.key);
2064
+ else WD_GROUP_FOLDED.add(g.key);
2065
+ saveStored("gg2-wdgroupfolded", [...WD_GROUP_FOLDED]);
2066
+ wrap.classList.toggle("folded");
2067
+ // Folding a group changes which row is the 11th: re-measure the cap.
2068
+ layoutWdBody(body);
2069
+ });
2070
+ wrap.appendChild(gh);
2071
+ for (const it of g.items) {
2072
+ const row = document.createElement("div");
2073
+ row.className = "wd-row";
2074
+ const key = g.key + "\u0000" + it.path;
2075
+ const check = document.createElement("input");
2076
+ check.type = "checkbox";
2077
+ check.className = "wd-check";
2078
+ check.checked = WD_SELECT.has(key);
2079
+ check.title = "勾选后可批量操作";
2080
+ check.addEventListener("change", () => {
2081
+ if (check.checked) WD_SELECT.add(key);
2082
+ else WD_SELECT.delete(key);
2083
+ updateBatchBar();
2084
+ });
2085
+ check.addEventListener("click", (ev) => ev.stopPropagation());
2086
+ row.appendChild(check);
2087
+ const badge = document.createElement("span");
2088
+ badge.className = "wd-badge " + (it.code === "?" ? "q" : it.code);
2089
+ badge.textContent = it.code === "?" ? "?" : it.code;
2090
+ row.appendChild(badge);
2091
+ const path = document.createElement("span");
2092
+ path.className = "wd-path";
2093
+ path.textContent = it.path;
2094
+ path.title = (it.oldPath ? it.oldPath + " → " : "") + it.path;
2095
+ if (it.oldPath) {
2096
+ const old = document.createElement("span");
2097
+ old.className = "wd-old";
2098
+ old.textContent = " ← " + it.oldPath;
2099
+ path.appendChild(old);
2100
+ }
2101
+ row.appendChild(path);
2102
+ if (it.code !== "?" && it.binary) {
2103
+ const b = document.createElement("span");
2104
+ b.className = "wd-binary";
2105
+ b.textContent = "二进制";
2106
+ row.appendChild(b);
2107
+ } else if (it.code !== "?") {
2108
+ const c = document.createElement("span");
2109
+ c.className = "wd-counts";
2110
+ const a = document.createElement("span");
2111
+ a.className = "add";
2112
+ a.textContent = "+" + (it.added ?? 0);
2113
+ const d = document.createElement("span");
2114
+ d.className = "del";
2115
+ d.textContent = "−" + (it.deleted ?? 0);
2116
+ c.appendChild(a);
2117
+ c.appendChild(document.createTextNode(" "));
2118
+ c.appendChild(d);
2119
+ row.appendChild(c);
2120
+ }
2121
+ // per-row write actions
2122
+ const acts = document.createElement("span");
2123
+ acts.className = "wd-acts";
2124
+ const mkAct = (label, fn, danger) => {
2125
+ const b = document.createElement("button");
2126
+ b.type = "button";
2127
+ b.textContent = label;
2128
+ if (danger) b.className = "danger";
2129
+ b.addEventListener("click", (ev) => { ev.stopPropagation(); fn(); });
2130
+ acts.appendChild(b);
2131
+ };
2132
+ if (g.key === "staged") {
2133
+ mkAct("取消暂存", () => runGitOp("unstage", { file: it.path }));
2134
+ mkAct("还原", () => discardConfirm([it.path]), true);
2135
+ } else {
2136
+ mkAct("暂存", () => runGitOp("stage", { file: it.path }));
2137
+ if (g.key === "unstaged") mkAct("还原", () => discardConfirm([it.path]), true);
2138
+ }
2139
+ row.appendChild(acts);
2140
+ const diffBox = document.createElement("div");
2141
+ diffBox.className = "wd-diff";
2142
+ const pre = document.createElement("pre");
2143
+ pre.textContent = "加载中…";
2144
+ diffBox.appendChild(pre);
2145
+ row.appendChild(diffBox);
2146
+ row.addEventListener("click", () => {
2147
+ const open = row.classList.contains("open");
2148
+ if (open) { row.classList.remove("open"); return; }
2149
+ row.classList.add("open");
2150
+ loadWdFile(it, g.staged, pre);
2151
+ // Deliberately NOT re-measuring here. The row count is unchanged, so
2152
+ // the cap stays where it was and the panel keeps its height; the diff
2153
+ // simply scrolls inside the list. Re-measuring would inflate the cap
2154
+ // using the placeholder diff ("加载中…"), and then the real content
2155
+ // would land below a clip line that no longer matches anything.
2156
+ });
2157
+ wrap.appendChild(row);
2158
+ }
2159
+ body.appendChild(wrap);
2160
+ }
2161
+ }
2162
+
2163
+ /** Load one file's diff (or content for untracked) on demand; cached. */
2164
+ async function loadWdFile(item, staged, pre) {
2165
+ const key = staged + "\u0000" + item.path;
2166
+ const cached = wdDiffCache.get(key);
2167
+ if (cached) { renderWdText(pre, cached.text, cached.isDiff); return; }
2168
+ try {
2169
+ const res = await api("workfile", { file: item.path, staged, ...(item.oldPath ? { old: item.oldPath } : {}) });
2170
+ let text = "";
2171
+ let isDiff = false;
2172
+ if (!res.ok) {
2173
+ text = "加载失败: " + (res.error || "?");
2174
+ } else if (res.isDir) {
2175
+ text = "(未跟踪目录)";
2176
+ } else if (res.binary) {
2177
+ text = "(二进制文件,不显示内容)";
2178
+ } else if (res.content !== undefined) {
2179
+ text = res.content.trim() ? res.content : "(空文件)";
2180
+ if (res.truncated) text = "(内容过长已截断)\n" + text;
2181
+ } else {
2182
+ text = res.diff || "(无差异)";
2183
+ isDiff = true;
2184
+ if (res.truncated) text = "(diff 过长已截断)\n" + text;
2185
+ }
2186
+ wdDiffCache.set(key, { text, isDiff });
2187
+ renderWdText(pre, text, isDiff);
2188
+ } catch (err) {
2189
+ pre.textContent = "加载失败: " + err.message;
2190
+ }
2191
+ }
2192
+
2193
+ function renderWdText(pre, text, isDiff) {
2194
+ if (isDiff) highlightDiff(pre, text);
2195
+ else pre.textContent = text;
2196
+ }
2197
+
2198
+ /** Render unified-diff text into a <pre> with line-level coloring. */
2199
+ function highlightDiff(pre, text) {
2200
+ pre.textContent = "";
2201
+ if (!text) return;
2202
+ const lines = String(text).split("\n");
2203
+ const frag = document.createDocumentFragment();
2204
+ for (const line of lines) {
2205
+ const span = document.createElement("span");
2206
+ span.style.display = "block";
2207
+ span.textContent = line || " ";
2208
+ let cls = "";
2209
+ if (line.startsWith("@@")) cls = "df-hunk";
2210
+ else if (/^(diff --git|index |new file mode|deleted file mode|similarity index|rename from|rename to|copy from|copy to|old mode|new mode|Binary files)/.test(line)) cls = "df-head";
2211
+ else if (line.startsWith("+++") || line.startsWith("---")) cls = "df-meta";
2212
+ else if (line.startsWith("+")) cls = "df-add";
2213
+ else if (line.startsWith("-")) cls = "df-del";
2214
+ if (cls) span.className = cls;
2215
+ frag.appendChild(span);
2216
+ }
2217
+ pre.appendChild(frag);
2218
+ }
2219
+
2220
+ /** Escape text for safe interpolation into HTML.
2221
+ * File paths, tag names and branch names come from the repository, not from
2222
+ * this page, so they are untrusted: a repo may contain a file called
2223
+ * `x<img src=x onerror=...>.txt` (legal on Linux/macOS). Any string that ends
2224
+ * up inside an innerHTML template must go through this. */
2225
+ function escHtml(s) {
2226
+ return String(s)
2227
+ .replace(/&/g, "&amp;")
2228
+ .replace(/</g, "&lt;")
2229
+ .replace(/>/g, "&gt;")
2230
+ .replace(/"/g, "&quot;")
2231
+ .replace(/'/g, "&#39;");
2232
+ }
2233
+
2234
+ /** Confirm dialog (destructive actions). Resolves true/false.
2235
+ * `bodyHtml` is raw HTML: callers must pass escHtml() for any repo-derived
2236
+ * text they interpolate. */
2237
+ function confirmDialog(title, bodyHtml, danger, okLabel) {
2238
+ return new Promise((resolve) => {
2239
+ const d = $("ggDlg");
2240
+ $("ggDlgTitle").textContent = title;
2241
+ $("ggDlgBody").innerHTML = bodyHtml;
2242
+ const ok = $("ggDlgOk");
2243
+ ok.className = danger ? "danger" : "primary";
2244
+ ok.textContent = okLabel || (danger ? "确认还原" : "确认");
2245
+ const finish = (v) => {
2246
+ d.classList.remove("show");
2247
+ ok.onclick = null;
2248
+ $("ggDlgCancel").onclick = null;
2249
+ resolve(v);
2250
+ };
2251
+ ok.onclick = () => finish(true);
2252
+ $("ggDlgCancel").onclick = () => finish(false);
2253
+ d.classList.add("show");
2254
+ });
2255
+ }
2256
+
2257
+ /** Commit dialog: message input + include-all checkbox. Resolves {message, all} or null. */
2258
+ function commitDialog(paths) {
2259
+ return new Promise((resolve) => {
2260
+ const d = $("commitDlg");
2261
+ const body = $("commitDlgBody");
2262
+ body.innerHTML = "";
2263
+ const ws = WORKSTATUS;
2264
+ const p = document.createElement("div");
2265
+ if (paths && paths.length) {
2266
+ p.textContent = "将提交选中的 " + paths.length + " 个文件(其余改动不受影响)";
2267
+ } else {
2268
+ const parts = [];
2269
+ if (ws.counts.staged) parts.push("已暂存 " + ws.counts.staged + " 个文件");
2270
+ if (ws.counts.unstaged) parts.push("未暂存 " + ws.counts.unstaged + " 个文件");
2271
+ if (ws.counts.untracked) parts.push("未跟踪 " + ws.counts.untracked + " 个文件");
2272
+ p.textContent = "将提交" + (parts.length ? ":" + parts.join("、") : "(当前没有可提交的改动)");
2273
+ }
2274
+ body.appendChild(p);
2275
+ const ta = document.createElement("textarea");
2276
+ ta.placeholder = "提交信息(必填),例如:修复登录 bug";
2277
+ body.appendChild(ta);
2278
+ const aiRow = document.createElement("div");
2279
+ aiRow.className = "ai-row";
2280
+ const aiBtn = document.createElement("button");
2281
+ aiBtn.type = "button";
2282
+ aiBtn.className = "ai-btn";
2283
+ aiBtn.textContent = "✨ AI 生成提交信息";
2284
+ aiBtn.title = "根据当前改动自动生成(基于 diff 统计的启发式建议,可编辑后提交)";
2285
+ const aiTip = document.createElement("span");
2286
+ aiTip.className = "ai-tip";
2287
+ aiTip.textContent = "";
2288
+ aiRow.appendChild(aiBtn);
2289
+ aiRow.appendChild(aiTip);
2290
+ body.appendChild(aiRow);
2291
+ aiBtn.addEventListener("click", async () => {
2292
+ aiBtn.disabled = true;
2293
+ aiTip.textContent = "分析中…";
2294
+ try {
2295
+ const res = await api("suggestmsg");
2296
+ if (!res.ok) { aiTip.textContent = "生成失败: " + (res.error || "?"); return; }
2297
+ if (!res.candidates || !res.candidates.length) { aiTip.textContent = "当前没有可提交的改动"; return; }
2298
+ ta.value = res.candidates[0];
2299
+ ta.focus();
2300
+ aiTip.textContent = "已生成 " + res.candidates.length + " 条建议,可修改";
2301
+ } catch (err) {
2302
+ aiTip.textContent = "生成失败: " + err.message;
2303
+ } finally {
2304
+ aiBtn.disabled = false;
2305
+ }
2306
+ });
2307
+ const lab = document.createElement("label");
2308
+ lab.className = "ck";
2309
+ const ck = document.createElement("input");
2310
+ ck.type = "checkbox";
2311
+ ck.checked = true;
2312
+ lab.appendChild(ck);
2313
+ lab.appendChild(document.createTextNode("同时暂存所有未暂存/未跟踪的更改(相当于全部提交)"));
2314
+ body.appendChild(lab);
2315
+ if (paths && paths.length) lab.style.display = "none"; // selective commit uses only the picked files
2316
+ const finish = (v) => {
2317
+ d.classList.remove("show");
2318
+ $("commitDlgOk").onclick = null;
2319
+ $("commitDlgCancel").onclick = null;
2320
+ resolve(v);
2321
+ };
2322
+ $("commitDlgOk").onclick = () => {
2323
+ if (!ta.value.trim()) {
2324
+ ta.style.borderColor = "var(--red)";
2325
+ ta.focus();
2326
+ return; // keep the dialog open
2327
+ }
2328
+ finish({ message: ta.value.trim(), all: ck.checked });
2329
+ };
2330
+ $("commitDlgCancel").onclick = () => finish(null);
2331
+ d.classList.add("show");
2332
+ ta.focus();
2333
+ ta.addEventListener("keydown", (ev) => {
2334
+ if (ev.key === "Enter" && !ev.shiftKey) {
2335
+ ev.preventDefault();
2336
+ if (ta.value.trim()) $("commitDlgOk").click();
2337
+ }
2338
+ });
2339
+ });
2340
+ }
2341
+
2342
+ /* ---- batch actions (checkbox multi-select) ---- */
2343
+ function updateBatchBar() {
2344
+ const batch = $("wdPanel").querySelector(".wd-batch");
2345
+ if (!batch) return;
2346
+ batch.innerHTML = "";
2347
+ const n = WD_SELECT.size;
2348
+ batch.style.display = n ? "" : "none";
2349
+ if (!n) return;
2350
+ const info = document.createElement("span");
2351
+ info.className = "wd-batch-info";
2352
+ info.textContent = "已选 " + n + " 个文件";
2353
+ batch.appendChild(info);
2354
+ const selected = [...WD_SELECT].map((k) => {
2355
+ const i = k.indexOf("\u0000");
2356
+ return { group: k.slice(0, i), path: k.slice(i + 1) };
2357
+ });
2358
+ const mk = (label, fn, danger) => {
2359
+ const b = document.createElement("button");
2360
+ b.type = "button";
2361
+ b.textContent = label;
2362
+ if (danger) b.className = "danger";
2363
+ b.addEventListener("click", fn);
2364
+ batch.appendChild(b);
2365
+ };
2366
+ mk("暂存选中", () => batchOp("stage", selected, ["unstaged", "untracked"]));
2367
+ mk("取消暂存选中", () => batchOp("unstage", selected, ["staged"]));
2368
+ mk("还原选中", () => discardConfirm(selected.map((s) => s.path)), true);
2369
+ mk("忽略选中", () => ignoreConfirm(selected.map((s) => s.path)), true);
2370
+ mk("提交选中", () => commitSelected(selected));
2371
+ const clear = document.createElement("button");
2372
+ clear.type = "button";
2373
+ clear.textContent = "✕";
2374
+ clear.title = "清除选择";
2375
+ clear.addEventListener("click", () => {
2376
+ WD_SELECT.clear();
2377
+ renderWdGroups($("wdPanel").querySelector(".wd-body"));
2378
+ updateBatchBar();
2379
+ });
2380
+ batch.appendChild(clear);
2381
+ }
2382
+
2383
+ /** Run a batch action on the selected rows that belong to the given groups. */
2384
+ function batchOp(action, selected, groups) {
2385
+ const paths = selected.filter((s) => groups.includes(s.group)).map((s) => s.path);
2386
+ if (!paths.length) { alert("当前选择中没有可执行此操作的文件"); return; }
2387
+ WD_SELECT.clear();
2388
+ runGitOp(action, { file: paths });
2389
+ }
2390
+
2391
+ /** Ignore confirmation: add to .gitignore; tracked files are untracked (kept on disk). */
2392
+ async function ignoreConfirm(paths) {
2393
+ const ok = await confirmDialog(
2394
+ "忽略选中",
2395
+ "将把这些文件<b>加入 .gitignore</b>;已跟踪的文件会<b>取消跟踪(文件保留在磁盘上)</b>:" +
2396
+ "<div class='dlg-list'>" + paths.map(escHtml).join("\n") + "</div>",
2397
+ true
2398
+ );
2399
+ if (ok) { WD_SELECT.clear(); runGitOp("ignore", { file: paths }); }
2400
+ }
2401
+
2402
+ /** Commit only the selected files. */
2403
+ async function commitSelected(selected) {
2404
+ const paths = selected.map((s) => s.path);
2405
+ const r = await commitDialog(paths);
2406
+ if (r && r.message) {
2407
+ WD_SELECT.clear();
2408
+ runGitOp("commit", { message: r.message, file: paths }, true);
2409
+ }
2410
+ }
2411
+
2412
+ /** Discard confirmation for one or more paths. */
2413
+ async function discardConfirm(paths) {
2414
+ const ok = await confirmDialog(
2415
+ "还原更改",
2416
+ "以下文件将被<b>还原到最近一次提交(HEAD)</b>,当前所有修改(含已暂存)都会<b>永久丢失</b>且不可恢复:" +
2417
+ "<div class='dlg-list'>" + paths.map(escHtml).join("\n") + "</div>",
2418
+ true
2419
+ );
2420
+ if (ok) runGitOp("discard", { file: paths });
2421
+ }
2422
+
2423
+ // dialogs: click the backdrop (but not the box) to cancel; Esc handled in keydown
2424
+ $("ggDlg").addEventListener("click", (ev) => { if (ev.target === $("ggDlg")) $("ggDlgCancel").click(); });
2425
+ $("commitDlg").addEventListener("click", (ev) => { if (ev.target === $("commitDlg")) $("commitDlgCancel").click(); });
2426
+
2427
+ /** Run a write operation, then refresh the panel (+ graph for commits). */
2428
+ async function runGitOp(action, params, needGraph) {
2429
+ try {
2430
+ const res = await api("gitop", { action, ...params });
2431
+ if (!res.ok) { alert("操作失败: " + (res.error || "?")); return false; }
2432
+ WD_SELECT.clear();
2433
+ wdDiffCache.clear();
2434
+ clearResponseCache(); // a commit/tag/branch write can change what details show
2435
+ await refreshWorkdir();
2436
+ if (needGraph) await reloadGraphLight();
2437
+ return true;
2438
+ } catch (err) {
2439
+ alert("操作失败: " + err.message);
2440
+ return false;
2441
+ }
2442
+ }
2443
+
2444
+ /** Light reload of graph + branches (no workspace re-resolution). */
2445
+ async function reloadGraphLight() {
2446
+ try {
2447
+ const [graph, branches] = await Promise.all([
2448
+ api("graph", { limit: PAGE_INITIAL, skip: 0, count: 0 }),
2449
+ api("branches")
2450
+ ]);
2451
+ if (graph.ok) {
2452
+ COMMITS = graph.commits;
2453
+ // count=0 means graph.total is 0 here; keep the total we already knew
2454
+ // instead of collapsing it to the page length.
2455
+ TOTAL = graph.total || Math.max(TOTAL, COMMITS.length);
2456
+ }
2457
+ LOADED = COMMITS.length;
2458
+ PAGINATION_EXHAUSTED = false; // fresh page list: pagination may run again
2459
+ if (branches.ok) BRANCHES = branches.branches.map(normalizeBranch);
2460
+ BRANCH_COLOR_CACHE = null;
2461
+ if (!graph.total) refreshTotal();
2462
+ REFS_SIG = null; // we just reloaded: re-baseline the change-detection probe
2463
+ fillFilterMenu();
2464
+ SELECTED = null;
2465
+ setDetailOpen(false);
2466
+ render();
2467
+ renderStats();
2468
+ } catch { /* ignore */ }
2469
+ }
2470
+
2471
+ /* ===================== status line ===================== */
2472
+ /** Top stats bar: commits / branches / authors / uncommitted / tags. */
2473
+ function renderStats() {
2474
+ const bar = $("statsBar");
2475
+ if (!bar) return;
2476
+ const totalCommits = TOTAL || COMMITS.length;
2477
+ const authors = new Set(COMMITS.map((c) => c.author || "?")).size;
2478
+ const locals = BRANCHES.filter((b) => b.kind === "local").length;
2479
+ const remotes = BRANCHES.filter((b) => b.kind === "remote").length;
2480
+ const tags = BRANCHES.filter((b) => b.kind === "tag").length;
2481
+ const u = WORKSTATUS ? WORKSTATUS.counts.staged + WORKSTATUS.counts.unstaged + WORKSTATUS.counts.untracked : 0;
2482
+ bar.innerHTML = "";
2483
+ const mk = (label, val) => {
2484
+ const s = document.createElement("span");
2485
+ s.appendChild(document.createTextNode(label + " "));
2486
+ const n = document.createElement("span");
2487
+ n.className = "st-num";
2488
+ n.textContent = val;
2489
+ s.appendChild(n);
2490
+ bar.appendChild(s);
2491
+ };
2492
+ mk("提交", totalCommits);
2493
+ mk("本地分支", locals);
2494
+ mk("远程分支", remotes);
2495
+ mk("标签", tags);
2496
+ mk("作者", authors);
2497
+ mk("未提交", u);
2498
+ }
2499
+
2500
+ function renderStatusLine() {
2501
+ const ws = WORKSTATUS;
2502
+ const el = $("statusLine");
2503
+ el.innerHTML = "";
2504
+ const b = document.createElement("span");
2505
+ b.className = "branch";
2506
+ b.textContent = "分支: " + (ws.branch || "?");
2507
+ el.appendChild(b);
2508
+ const total = ws.counts.staged + ws.counts.unstaged + ws.counts.untracked;
2509
+ if (total > 0) {
2510
+ const dirty = document.createElement("span");
2511
+ dirty.className = "dirty";
2512
+ dirty.textContent = `◌ 未提交改动:已暂存 ${ws.counts.staged} · 更改 ${ws.counts.unstaged} · 未跟踪 ${ws.counts.untracked}`;
2513
+ dirty.addEventListener("click", () => {
2514
+ $("wdPanel").scrollIntoView({ behavior: "smooth", block: "start" });
2515
+ });
2516
+ el.appendChild(dirty);
2517
+ } else {
2518
+ const clean = document.createElement("span");
2519
+ clean.className = "clean";
2520
+ clean.textContent = "✓ 工作区干净";
2521
+ el.appendChild(clean);
2522
+ }
2523
+ // version badge pinned to the right end of the status bar
2524
+ const ver = document.createElement("span");
2525
+ ver.className = "gg-version";
2526
+ ver.id = "ggVersion";
2527
+ ver.title = "插件版本,点击检查更新";
2528
+ ver.textContent = "v" + (UPDATE_STATE && UPDATE_STATE.current ? UPDATE_STATE.current : "?");
2529
+ ver.addEventListener("click", () => checkForUpdate(false));
2530
+ el.appendChild(ver);
2531
+ }
2532
+
2533
+ /* ===================== load ===================== */
2534
+ const SID_PARAM = new URLSearchParams(location.search).get("sid") || ""; // hosting session id
2535
+ let SESSION_WS = ""; // resolved workspace path of the hosting session
2536
+ /* Follow the hosting session's working copy.
2537
+ *
2538
+ * The iframe's ?sid is fixed when the element is created, and DSH keeps the same
2539
+ * iframe element while the user switches working copies — the document is not
2540
+ * recreated, so without this poll the graph would keep showing the repo of
2541
+ * whichever workspace happened to be active when the tab was first opened.
2542
+ *
2543
+ * resolveSessionWs() already prefers the entry whose sessionId matches ?sid and
2544
+ * otherwise falls back to the most recently active session, so polling works
2545
+ * both when a sid is present and when the panel is opened standalone. */
2546
+ let WS_CHECK_BUSY = false;
2547
+ async function checkSessionWorkspace() {
2548
+ if (WS_CHECK_BUSY) return;
2549
+ if (document.hidden) return; // no point polling a hidden tab
2550
+ WS_CHECK_BUSY = true;
2551
+ try {
2552
+ const ws = await resolveSessionWs();
2553
+ if (ws && ws !== SESSION_WS) {
2554
+ SESSION_WS = ws;
2555
+ // A different working copy means a different repository. Cached responses
2556
+ // are keyed by commit hash / file path, which are unique per repository, so
2557
+ // they must go. The "last picked repo" memory is already keyed per
2558
+ // workspace, so it needs no clearing — and reloading with the new ws makes
2559
+ // the graph follow the switch.
2560
+ clearResponseCache();
2561
+ await load(ws);
2562
+ }
2563
+ } catch { /* ignore transient failures */ } finally {
2564
+ WS_CHECK_BUSY = false;
2565
+ }
2566
+ }
2567
+
2568
+ /**
2569
+ * Resolve the hosting session's cwd via the DSH internal session.list Remote.
2570
+ * Wire format (typert gateway): POST /api/session/<method>, message.method is
2571
+ * "namespace/method", and payload carries exactly one named "args" field
2572
+ * ({ args: { _request: {} } }). The old dotted form ("session.list") never
2573
+ * reaches the gateway table — it returns 404 and the workspace stays empty.
2574
+ */
2575
+ async function resolveSessionWs() {
2576
+ try {
2577
+ const res = await fetch("/api/session/list", {
2578
+ method: "POST",
2579
+ headers: { "content-type": "application/json" },
2580
+ body: JSON.stringify({
2581
+ type: "client-request",
2582
+ rpcId: "gg-" + Date.now(),
2583
+ method: "session/list",
2584
+ payload: { args: { _request: {} } }
2585
+ })
2586
+ });
2587
+ const j = await res.json();
2588
+ const items =
2589
+ (j && j.result && j.result.value && j.result.value.items) ||
2590
+ (j && j.result && j.result.value && Array.isArray(j.result.value) ? j.result.value : []) ||
2591
+ (j && Array.isArray(j.items) ? j.items : []);
2592
+ // Exact match first: the iframe carries the hosting session id (?sid=).
2593
+ if (SID_PARAM) {
2594
+ const entry = items.find((s) => s.sessionId === SID_PARAM);
2595
+ if (entry && entry.cwd) return entry.cwd;
2596
+ }
2597
+ // Fallback: no sid (page opened directly) or no match — pick the most
2598
+ // recently active non-blank session that has a cwd, so the graph still
2599
+ // lands on the latest conversation's workspace automatically.
2600
+ const latest = items
2601
+ .filter((s) => !s.blank && s.cwd)
2602
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))[0];
2603
+ if (latest && latest.cwd) return latest.cwd;
2604
+ return "";
2605
+ } catch (err) {
2606
+ console.warn("[git-graph] session/list failed:", err);
2607
+ return "";
2608
+ }
2609
+ }
2610
+
2611
+ /** 当前对话不是 git 仓库:显示空态,不显示图谱。 */
2612
+ function showEmpty(ws) {
2613
+ $("loading").classList.add("hidden");
2614
+ setDetailOpen(false);
2615
+ COMMITS = [];
2616
+ BRANCHES = [];
2617
+ $("wdPanel").innerHTML = "";
2618
+ $("commitList").innerHTML = "";
2619
+ $("legend").innerHTML = "";
2620
+ $("statsBar").innerHTML = "";
2621
+ $("repoLabel").textContent = "—";
2622
+ $("repoLabel").title = "";
2623
+ $("emptyPath").textContent = ws || "";
2624
+ $("emptyPath").title = ws || "";
2625
+ // 工作区路径已知时提供「在当前对话目录下查找 Git 仓库」一键扫描
2626
+ const scanBtn = $("btnScanDir");
2627
+ scanBtn.classList.toggle("gg-none", !ws);
2628
+ scanBtn.disabled = false;
2629
+ $("scanResults").innerHTML = "";
2630
+ $("esTitle").textContent = "当前对话区域不是 Git 仓库";
2631
+ $("esSub").textContent = "该对话的工作区没有 git 仓库。可以点击下方按钮浏览并打开其他文件夹的 Git 仓库。";
2632
+ $("emptyState").classList.remove("hidden");
2633
+ }
2634
+ function hideEmpty() {
2635
+ $("emptyState").classList.add("hidden");
2636
+ }
2637
+
2638
+ /* "Last repo picked" memory is scoped to the session's working copy.
2639
+ *
2640
+ * It used to be a single global key, which meant: switch the working copy from A
2641
+ * to B, and the graph would still open repo A (the remembered pick) as long as A
2642
+ * was anywhere in the allow-list — the graph looked like it ignored the switch.
2643
+ * Keying it by workspace keeps each working copy's own pick while guaranteeing
2644
+ * that a switch is followed. Returns the raw key so callers stay simple. */
2645
+ function curRepoKey() {
2646
+ return "gg2-currepo:" + (SESSION_WS || "");
2647
+ }
2648
+ function loadCurRepo() {
2649
+ return loadStored(curRepoKey(), "");
2650
+ }
2651
+ function saveCurRepo(v) {
2652
+ saveStored(curRepoKey(), v);
2653
+ }
2654
+
2655
+ async function load(wsOverride) {
2656
+ // A workspace switch and a user-triggered refresh can overlap; running two
2657
+ // loads at once would let the slower one win and paint the wrong repo.
2658
+ if (load.INFLIGHT) return;
2659
+ load.INFLIGHT = true;
2660
+ $("loading").classList.remove("hidden");
2661
+ hideEmpty();
2662
+ try {
2663
+ // Restore UI preferences from the server before the first render, so the
2664
+ // branch filter is already applied when the graph paints (localStorage may
2665
+ // have been wiped by a port change).
2666
+ if (!PREFS_LOADED) await prefsLoad();
2667
+ // wsOverride comes from checkSessionWorkspace(), which has already resolved
2668
+ // the new cwd; preferring it avoids a redundant (and potentially stale)
2669
+ // second resolution racing with the poller.
2670
+ if (wsOverride !== undefined) SESSION_WS = wsOverride;
2671
+ else if (!SESSION_WS) SESSION_WS = await resolveSessionWs();
2672
+ const ws = SESSION_WS;
2673
+ // 重新注册用户通过「打开文件夹」添加过的仓库(服务端内存态重启后重置)
2674
+ const saved = ADDED_REPOS.filter(Boolean);
2675
+ const repos = await api("repos", Object.assign({}, ws ? { ws } : {}, saved.length ? { add: saved } : {}));
2676
+ if (!repos.ok || !repos.repos.length) {
2677
+ // 没有任何可用仓库:显示空态并引导用「打开文件夹」添加
2678
+ showEmpty(SESSION_WS || "");
2679
+ $("esTitle").textContent = "尚未添加 Git 仓库";
2680
+ $("esSub").textContent = "当前没有可用的 Git 仓库。点击下方按钮(或顶部「📂 打开」)浏览磁盘,选择任意文件夹的 Git 仓库:";
2681
+ return;
2682
+ }
2683
+ // 当前对话在非 git 工作区:没有用户主动添加的仓库时显示空态(不回落配置仓库)
2684
+ if (SESSION_WS && !repos.wsIsGit && !saved.length) {
2685
+ showEmpty(SESSION_WS);
2686
+ return;
2687
+ }
2688
+ // 当前仓库:上次选择的 > 会话工作区根 > 用户添加的第一个 > 服务端默认
2689
+ const available = repos.repos;
2690
+ let current = repos.current;
2691
+ const last = loadCurRepo();
2692
+ if (last && available.includes(last)) current = last;
2693
+ else if (repos.wsIsGit && repos.wsRoot) current = repos.wsRoot;
2694
+ else if (saved.length) {
2695
+ const f = available.find((r) => r === saved[0]);
2696
+ if (f) current = f;
2697
+ }
2698
+ if (!current || !available.includes(current)) current = available[0];
2699
+ // Switching repos invalidates every cached response: keys are commit hashes
2700
+ // and file paths, which are unique per repository.
2701
+ if (current !== REPO) clearResponseCache();
2702
+ REPO = current;
2703
+ $("repoLabel").textContent = REPO;
2704
+ $("repoLabel").title = REPO;
2705
+ // repo switcher (hidden when only one repo); show the folder name, full path in title
2706
+ const sw = $("repoSwitch");
2707
+ REPO_LIST = available;
2708
+ sw.innerHTML = "";
2709
+ const usedShort = new Set();
2710
+ for (const r of REPO_LIST) {
2711
+ const o = document.createElement("option");
2712
+ const short = (r.split("/").pop() || r);
2713
+ if (usedShort.has(short)) { o.textContent = r; }
2714
+ else { usedShort.add(short); o.textContent = short; }
2715
+ o.title = r;
2716
+ o.value = r;
2717
+ if (r === REPO) o.selected = true;
2718
+ sw.appendChild(o);
2719
+ }
2720
+ sw.style.display = REPO_LIST.length > 1 ? "" : "none";
2721
+ const [graph, branches] = await Promise.all([
2722
+ api("graph", { limit: PAGE_INITIAL, skip: 0, count: 0 }),
2723
+ api("branches")
2724
+ ]);
2725
+ if (!graph.ok) {
2726
+ // 仓库当前不可用(配置失效/权限等):显示空态并引导用「打开文件夹」选择。
2727
+ $("loading").classList.add("hidden");
2728
+ showEmpty(SESSION_WS || "");
2729
+ $("esTitle").textContent = "当前仓库不可用";
2730
+ $("esSub").textContent = "无法读取仓库:" + (graph.error || "?") + "。可以点击下方按钮(或顶部「📂 打开」)打开其他文件夹的 Git 仓库:";
2731
+ return;
2732
+ }
2733
+ COMMITS = graph.commits;
2734
+ TOTAL = graph.total || COMMITS.length; // provisional; refreshTotal() fills in the real count
2735
+ LOADED = COMMITS.length;
2736
+ PAGINATION_EXHAUSTED = false; // fresh page list: pagination may run again
2737
+ BRANCHES = branches.ok ? branches.branches.map(normalizeBranch) : [];
2738
+ BRANCH_COLOR_CACHE = null;
2739
+ migrateLegacyFilter(); // old visible-list scheme -> hidden-list
2740
+ if (!graph.total) refreshTotal(); // first page rendered without waiting for the count
2741
+ // 首次加载(尚无任何折叠偏好存储时)默认折叠全部分支组:
2742
+ // 只显示分组头,点击组头才展开该组提交;仅执行一次,之后尊重用户手动状态。
2743
+ if (!BRANCH_FOLD_DEFAULTED && !localStorage.getItem("gg2-brfolded")) {
2744
+ BRANCH_FOLD_DEFAULTED = true;
2745
+ for (const b of BRANCHES) {
2746
+ if (b.kind === "local" || b.kind === "remote") BRANCH_FOLDED.add(b.refname);
2747
+ }
2748
+ saveStored("gg2-brfolded", [...BRANCH_FOLDED]);
2749
+ }
2750
+ fillFilterMenu();
2751
+ // Nothing is selected until the user clicks or presses ↑/↓. Seeding a cursor
2752
+ // here would draw the dashed box on the first row of every load, which reads
2753
+ // as "this row is selected" when nothing was selected.
2754
+ SELECTED = null;
2755
+ setDetailOpen(false);
2756
+ REFS_SIG = null; // full load: let the next poll re-baseline
2757
+ render();
2758
+ renderStats();
2759
+ refreshWorkdir();
2760
+ if (COMMITS.length) {
2761
+ setTimeout(() => scrollToNode(COMMITS[0].hash), 120);
2762
+ }
2763
+ // silent update check after the graph is usable
2764
+ setTimeout(() => checkForUpdate(true), 800);
2765
+ } catch (err) {
2766
+ $("loading").textContent = "加载失败: " + err.message;
2767
+ } finally {
2768
+ $("loading").classList.add("hidden");
2769
+ load.INFLIGHT = false;
2770
+ }
2771
+ }
2772
+
2773
+ /* ===================== 打开文件夹:浏览磁盘并添加任意 Git 仓库 ===================== */
2774
+ function pickCell(text) {
2775
+ const d = document.createElement("div");
2776
+ d.className = "pd-empty";
2777
+ d.textContent = text;
2778
+ return d;
2779
+ }
2780
+ /** 刷新文件夹选择列表(盘符视图或目录视图)。 */
2781
+ async function browseDir(path) {
2782
+ const list = $("pdList");
2783
+ list.innerHTML = "";
2784
+ list.appendChild(pickCell("加载中…"));
2785
+ $("pdOpen").disabled = true;
2786
+ try {
2787
+ const res = await api("listdir", { path: path || "" });
2788
+ list.innerHTML = "";
2789
+ if (!res || !res.ok) {
2790
+ PICK_ROOT = null;
2791
+ PICK_PARENT = null;
2792
+ list.appendChild(pickCell("加载失败:" + ((res && res.error) || "?")));
2793
+ $("pdHint").textContent = "";
2794
+ updatePickOpen();
2795
+ return;
2796
+ }
2797
+ PICK_PATH = res.path || path || "";
2798
+ PICK_PARENT = res.parent || null;
2799
+ PICK_ROOT = res.root || null;
2800
+ $("pdPath").textContent = res.path || (res.drives && res.drives.length ? "选择驱动器" : "");
2801
+ $("pdPath").title = res.path || "";
2802
+ $("pdUp").disabled = !PICK_PARENT;
2803
+ if (res.drives && res.drives.length) {
2804
+ for (const d of res.drives) {
2805
+ const row = document.createElement("div");
2806
+ row.className = "pd-item";
2807
+ const ico = document.createElement("span");
2808
+ ico.className = "pd-ico";
2809
+ ico.textContent = "💽";
2810
+ const nm = document.createElement("span");
2811
+ nm.className = "pd-name";
2812
+ nm.textContent = d;
2813
+ row.appendChild(ico);
2814
+ row.appendChild(nm);
2815
+ row.addEventListener("click", () => browseDir(d));
2816
+ list.appendChild(row);
2817
+ }
2818
+ } else {
2819
+ for (const d of (res.dirs || [])) {
2820
+ const row = document.createElement("div");
2821
+ row.className = "pd-item" + (d.name.startsWith(".") ? " pd-hidden" : "");
2822
+ const ico = document.createElement("span");
2823
+ ico.className = "pd-ico";
2824
+ ico.textContent = d.git ? "🗂️" : "📁";
2825
+ const nm = document.createElement("span");
2826
+ nm.className = "pd-name";
2827
+ nm.textContent = d.name;
2828
+ row.appendChild(ico);
2829
+ row.appendChild(nm);
2830
+ if (d.git) {
2831
+ const g = document.createElement("span");
2832
+ g.className = "pd-git";
2833
+ g.textContent = "git";
2834
+ row.appendChild(g);
2835
+ }
2836
+ row.addEventListener("click", () => browseDir(d.path));
2837
+ list.appendChild(row);
2838
+ }
2839
+ if (res.truncated) list.appendChild(pickCell("目录较多,仅显示前 500 个"));
2840
+ if (!res.readable) list.appendChild(pickCell("无法读取此目录(可能没有权限)"));
2841
+ if (!res.dirs.length && res.readable) list.appendChild(pickCell("此文件夹下没有子目录"));
2842
+ }
2843
+ updatePickOpen();
2844
+ } catch (err) {
2845
+ list.innerHTML = "";
2846
+ list.appendChild(pickCell("加载失败:" + err.message));
2847
+ PICK_ROOT = null;
2848
+ PICK_PARENT = null;
2849
+ updatePickOpen();
2850
+ }
2851
+ }
2852
+ function updatePickOpen() {
2853
+ const open = $("pdOpen");
2854
+ const hint = $("pdHint");
2855
+ open.disabled = !PICK_ROOT;
2856
+ hint.textContent = PICK_ROOT
2857
+ ? (PICK_ROOT === PICK_PATH ? "✓ 当前文件夹就是 Git 仓库" : "✓ 位于仓库内,将打开:" + PICK_ROOT)
2858
+ : "此文件夹不是 Git 仓库(可继续进入子目录)";
2859
+ hint.title = PICK_ROOT || "";
2860
+ }
2861
+ async function openDirPicker() {
2862
+ $("pickdirDlg").classList.add("show");
2863
+ $("pdList").innerHTML = "";
2864
+ PICK_PATH = "";
2865
+ PICK_PARENT = null;
2866
+ PICK_ROOT = null;
2867
+ $("pdUp").disabled = true;
2868
+ await browseDir("");
2869
+ }
2870
+ async function openPickPath() {
2871
+ if (!PICK_ROOT) return;
2872
+ const btn = $("pdOpen");
2873
+ btn.disabled = true;
2874
+ const orig = btn.textContent;
2875
+ btn.textContent = "打开中…";
2876
+ try {
2877
+ const res = await api("addrepo", { path: PICK_PATH });
2878
+ if (!res.ok) { alert("无法添加该仓库:" + (res.error || "?")); return; }
2879
+ if (!ADDED_REPOS.includes(res.repo)) {
2880
+ ADDED_REPOS.push(res.repo);
2881
+ saveStored("gg2-repos", ADDED_REPOS);
2882
+ }
2883
+ saveCurRepo(res.repo);
2884
+ $("pickdirDlg").classList.remove("show");
2885
+ if (REPO === res.repo) return; // 已在查看此仓库,仅关闭对话框
2886
+ wdDiffCache.clear();
2887
+ REPO = res.repo;
2888
+ await load();
2889
+ } catch (err) {
2890
+ alert("无法添加该仓库:" + err.message);
2891
+ } finally {
2892
+ btn.disabled = !PICK_ROOT;
2893
+ btn.textContent = orig;
2894
+ }
2895
+ }
2896
+ $("btnOpenDir").addEventListener("click", openDirPicker);
2897
+ $("btnOpenDirEmpty").addEventListener("click", openDirPicker);
2898
+
2899
+ /* ---- 空态一键扫描:在当前对话目录(及浅层子目录)中查找 Git 仓库 ---- */
2900
+ async function scanForRepos() {
2901
+ const btn = $("btnScanDir");
2902
+ const list = $("scanResults");
2903
+ if (!SESSION_WS) return;
2904
+ btn.disabled = true;
2905
+ const orig = btn.textContent;
2906
+ btn.textContent = "扫描中…";
2907
+ list.innerHTML = "";
2908
+ try {
2909
+ const res = await api("findrepos", { ws: SESSION_WS });
2910
+ if (!res.ok) { alert("扫描失败:" + (res.error || "?")); return; }
2911
+ const repos = res.repos || [];
2912
+ if (!repos.length) {
2913
+ const none = document.createElement("div");
2914
+ none.className = "es-scan-none";
2915
+ none.textContent = "(未找到 Git 仓库,可点击上方「📂 打开文件夹」手动浏览)";
2916
+ list.appendChild(none);
2917
+ return;
2918
+ }
2919
+ for (const r of repos) {
2920
+ const row = document.createElement("button");
2921
+ row.className = "es-scan-item";
2922
+ row.textContent = r;
2923
+ row.title = "打开 " + r;
2924
+ row.addEventListener("click", () => openScannedRepo(r));
2925
+ list.appendChild(row);
2926
+ }
2927
+ } catch (err) {
2928
+ alert("扫描失败:" + err.message);
2929
+ } finally {
2930
+ btn.disabled = false;
2931
+ btn.textContent = orig;
2932
+ }
2933
+ }
2934
+ async function openScannedRepo(repo) {
2935
+ const res = await api("addrepo", { path: repo });
2936
+ if (!res.ok) { alert("无法添加该仓库:" + (res.error || "?")); return; }
2937
+ if (!ADDED_REPOS.includes(res.repo)) {
2938
+ ADDED_REPOS.push(res.repo);
2939
+ saveStored("gg2-repos", ADDED_REPOS);
2940
+ }
2941
+ saveCurRepo(res.repo);
2942
+ wdDiffCache.clear();
2943
+ REPO = res.repo;
2944
+ await load();
2945
+ }
2946
+ $("btnScanDir").addEventListener("click", scanForRepos);
2947
+ $("pdClose").addEventListener("click", () => $("pickdirDlg").classList.remove("show"));
2948
+ $("pdUp").addEventListener("click", () => browseDir(PICK_PARENT || ""));
2949
+ $("pdOpen").addEventListener("click", openPickPath);
2950
+ $("pickdirDlg").addEventListener("click", (ev) => { if (ev.target === $("pickdirDlg")) $("pdClose").click(); });
2951
+
2952
+ /* ===================== server-backed prefs ===================== */
2953
+ /* localStorage is origin-scoped and the DSH web port changes between launches,
2954
+ * so anything kept there silently resets on a restart. UI preferences are
2955
+ * mirrored to the server (a small JSON next to the plugin) and re-read on load,
2956
+ * which is what makes the branch filter survive a port change.
2957
+ * localStorage stays the fast path; the server copy wins when they disagree. */
2958
+ let PREFS_LOADED = false;
2959
+ /* A host that predates the prefs ops answers 403/unknown-op. Remember that once
2960
+ * and fall back to localStorage-only, so an un-restarted host does not emit a
2961
+ * failed request on every filter change. */
2962
+ let PREFS_OP_OK = true;
2963
+ function prefsSync(patch) {
2964
+ if (!PREFS_LOADED || !PREFS_OP_OK) return; // do not clobber the server copy before we read it
2965
+ try {
2966
+ api("prefswrite", { set: JSON.stringify(patch) }).then((r) => {
2967
+ if (r && r.ok === false) PREFS_OP_OK = false;
2968
+ }).catch(() => { /* best effort */ });
2969
+ } catch { /* ignore */ }
2970
+ }
2971
+ async function prefsLoad() {
2972
+ try {
2973
+ const r = await api("prefs");
2974
+ if (!r.ok || !r.prefs) { PREFS_OP_OK = false; PREFS_LOADED = true; return false; }
2975
+ PREFS_OP_OK = true;
2976
+ const p = r.prefs;
2977
+ let changed = false;
2978
+
2979
+ // --- branch filter -------------------------------------------------------
2980
+ if (Array.isArray(p["gg2-bfilter"])) {
2981
+ HIDDEN_REFS = p["gg2-bfilter"];
2982
+ saveStored("gg2-bfilter-hidden", HIDDEN_REFS);
2983
+ changed = true;
2984
+ } else if (HIDDEN_REFS === null && LEGACY_VISIBLE_REFS !== null) {
2985
+ // Migrate the old "store the visible ones" scheme once: everything that
2986
+ // was not visibly checked becomes hidden.
2987
+ // Cannot compute without BRANCHES; deferred to migrateLegacyFilter().
2988
+ }
2989
+
2990
+ // --- date range -----------------------------------------------------------
2991
+ if (typeof p["gg2-datefrom"] === "string") {
2992
+ DATE_FROM = p["gg2-datefrom"];
2993
+ saveStored("gg2-datefrom", DATE_FROM);
2994
+ }
2995
+ if (typeof p["gg2-dateto"] === "string") {
2996
+ DATE_TO = p["gg2-dateto"];
2997
+ saveStored("gg2-dateto", DATE_TO);
2998
+ }
2999
+
3000
+ // --- flat mode ------------------------------------------------------------
3001
+ // Grouped is the default; flat is the opt-in view that carries the lane graph.
3002
+ //
3003
+ // The one-time "flat was the development default" reset used to be gated by a
3004
+ // flag that lived ONLY in localStorage. That store is origin-scoped, and the
3005
+ // plugin shares the DSH origin, so every web-port change wiped it — and the
3006
+ // reset then re-ran and overwrote the user's choice with `false`, on the
3007
+ // client and (once the guard was lifted) on the server. The flag is now
3008
+ // persisted server-side too, and an explicit `gg2-flat` on the server counts
3009
+ // as proof of a deliberate choice, so a real selection is never reset.
3010
+ const hasServerFlat = typeof p["gg2-flat"] === "boolean";
3011
+ const migrationDone = FLAT_DEFAULT_MIGRATED === true || p["gg2-flat-reset"] === true || hasServerFlat;
3012
+ const pending = {};
3013
+ if (!migrationDone) {
3014
+ FLAT_MODE = false;
3015
+ saveStored("gg2-flat", false);
3016
+ saveStored("gg2-flat-reset", true);
3017
+ pending["gg2-flat"] = false;
3018
+ pending["gg2-flat-reset"] = true;
3019
+ } else {
3020
+ if (FLAT_DEFAULT_MIGRATED !== true) saveStored("gg2-flat-reset", true);
3021
+ if (hasServerFlat) {
3022
+ FLAT_MODE = p["gg2-flat"];
3023
+ saveStored("gg2-flat", FLAT_MODE);
3024
+ }
3025
+ }
3026
+ syncFlatBtn();
3027
+
3028
+ // --- other remembered UI state ------------------------------------------
3029
+ for (const key of ["gg2-wdshow", "gg2-wdfolded", "gg2-wdgroupfolded", "gg2-brfolded"]) {
3030
+ if (p[key] !== undefined) { saveStored(key, p[key]); }
3031
+ }
3032
+ if (p["gg2-wdshow"] !== undefined) WD_SHOW = p["gg2-wdshow"];
3033
+ if (p["gg2-wdfolded"] !== undefined) WD_FOLDED = p["gg2-wdfolded"];
3034
+ if (Array.isArray(p["gg2-wdgroupfolded"])) {
3035
+ WD_GROUP_FOLDED.clear();
3036
+ for (const k of p["gg2-wdgroupfolded"]) WD_GROUP_FOLDED.add(k);
3037
+ }
3038
+ if (Array.isArray(p["gg2-brfolded"])) {
3039
+ BRANCH_FOLDED.clear();
3040
+ for (const k of p["gg2-brfolded"]) BRANCH_FOLDED.add(k);
3041
+ }
3042
+
3043
+ // --- theme ---------------------------------------------------------------
3044
+ // themePref was already computed from localStorage at parse time, but after a
3045
+ // port change that store is empty, so the server value has to be applied to
3046
+ // the *variable* as well — writing it to localStorage only (as this loop used
3047
+ // to) restored it for the NEXT load and left this one on auto.
3048
+ if (THEMES.includes(p["gg2-theme"])) {
3049
+ themePref = p["gg2-theme"];
3050
+ saveStored("gg2-theme", themePref);
3051
+ }
3052
+ applyTheme();
3053
+
3054
+ PREFS_LOADED = true;
3055
+ // Write the flat-migration marker once it is safe to do so: prefsSync refuses
3056
+ // to run before PREFS_LOADED, which is exactly why the old call site (inside
3057
+ // the migration branch) silently dropped it — the guard was still false.
3058
+ if (Object.keys(pending).length) prefsSync(pending);
3059
+ return changed;
3060
+ } catch {
3061
+ PREFS_LOADED = true; // never block startup on the pref store
3062
+ return false;
3063
+ }
3064
+ }
3065
+ /** One-time migration from the legacy visible-list scheme. Needs BRANCHES, so
3066
+ * it runs after the first branches fetch. */
3067
+ function migrateLegacyFilter() {
3068
+ if (HIDDEN_REFS !== null || LEGACY_VISIBLE_REFS === null) return;
3069
+ const visible = LEGACY_VISIBLE_REFS;
3070
+ const refs = BRANCHES.filter((b) => b.kind === "local" || b.kind === "remote").map((b) => b.refname);
3071
+ HIDDEN_REFS = refs.filter((r) => !visible.includes(r));
3072
+ saveStored("gg2-bfilter-hidden", HIDDEN_REFS);
3073
+ try { localStorage.removeItem("gg2-bfilter"); } catch { /* ignore */ }
3074
+ }
3075
+
3076
+ /* Counts used by the empty-filter hint. */
3077
+ function branchCount() {
3078
+ return BRANCHES.filter((b) => b.kind === "local" || b.kind === "remote").length;
3079
+ }
3080
+ function hiddenCount() {
3081
+ if (!HIDDEN_REFS || !HIDDEN_REFS.length) return 0;
3082
+ const refs = new Set(BRANCHES.map((b) => b.refname));
3083
+ return HIDDEN_REFS.filter((r) => refs.has(r)).length; // ignore refs that no longer exist
3084
+ }
3085
+
3086
+ /* ---- branch checkbox filter ---- */
3087
+ function fillFilterMenu() {
3088
+ const box = $("fmBranches");
3089
+ box.innerHTML = "";
3090
+ const groups = [
3091
+ { key: "local", title: "本地分支", items: BRANCHES.filter((b) => b.kind === "local") },
3092
+ { key: "remote", title: "远程分支", items: BRANCHES.filter((b) => b.kind === "remote") }
3093
+ ];
3094
+ for (const grp of groups) {
3095
+ if (!grp.items.length) continue;
3096
+ const title = document.createElement("div");
3097
+ title.className = "fm-group";
3098
+ title.textContent = grp.title;
3099
+ box.appendChild(title);
3100
+ for (const b of grp.items) {
3101
+ const label = document.createElement("label");
3102
+ const cb = document.createElement("input");
3103
+ cb.type = "checkbox";
3104
+ cb.className = "fBranch";
3105
+ cb.dataset.sha = b.sha;
3106
+ cb.dataset.ref = b.refname;
3107
+ // Restore from the hidden-list: only explicitly turned-off branches are
3108
+ // unchecked, so a branch created since the filter was saved shows up.
3109
+ cb.checked = !(HIDDEN_REFS && HIDDEN_REFS.includes(b.refname));
3110
+ const dot = document.createElement("span");
3111
+ dot.className = "fm-dot";
3112
+ dot.style.background = branchColorOf(b.sha);
3113
+ label.appendChild(cb);
3114
+ label.appendChild(dot);
3115
+ label.appendChild(document.createTextNode(b.name + (b.head ? " (HEAD)" : "")));
3116
+ label.addEventListener("click", (ev) => ev.stopPropagation());
3117
+ cb.addEventListener("change", applyFilter);
3118
+ box.appendChild(label);
3119
+ }
3120
+ }
3121
+ applyFilter();
3122
+ }
3123
+ function applyFilter() {
3124
+ const boxes = [...document.querySelectorAll(".fBranch")];
3125
+ // No checkboxes means BRANCHES is not available (the branches fetch failed, or
3126
+ // the repo has no refs yet). Deriving state from an empty DOM would conclude
3127
+ // "nothing is hidden" and persist `[]`, so one transient git failure would wipe
3128
+ // the saved selection from the server — the user's filter would be gone for
3129
+ // good. Render unfiltered for this paint (a branch filter cannot be evaluated
3130
+ // without branch data) and leave the stored list untouched so it re-applies
3131
+ // when the branches load.
3132
+ if (boxes.length === 0) {
3133
+ FILTER = "all";
3134
+ CHECKED_SET.clear();
3135
+ render();
3136
+ return;
3137
+ }
3138
+ const hidden = boxes.filter((b) => !b.checked).map((b) => b.dataset.ref);
3139
+ const anyHidden = hidden.length > 0;
3140
+ $("fAll").checked = !anyHidden;
3141
+ // Always record what is hidden (empty array = nothing hidden). Keeping an
3142
+ // explicit [] rather than collapsing to null preserves "user chose to hide
3143
+ // everything" as a distinct state from "never configured".
3144
+ const prev = HIDDEN_REFS;
3145
+ HIDDEN_REFS = hidden;
3146
+ FILTER = anyHidden ? "multi" : "all";
3147
+ CHECKED_SET.clear();
3148
+ if (anyHidden) {
3149
+ for (const b of boxes) if (b.checked) CHECKED_SET.add(b.dataset.sha);
3150
+ }
3151
+ // Only persist a real change. fillFilterMenu() calls this on every load, so
3152
+ // writing unconditionally meant a prefswrite on every page load.
3153
+ if (JSON.stringify(prev) !== JSON.stringify(hidden)) {
3154
+ saveStored("gg2-bfilter-hidden", HIDDEN_REFS);
3155
+ prefsSync({ "gg2-bfilter": HIDDEN_REFS }); // server copy survives port changes
3156
+ }
3157
+ render();
3158
+ }
3159
+ $("fAll").addEventListener("change", () => {
3160
+ const all = $("fAll").checked;
3161
+ for (const b of document.querySelectorAll(".fBranch")) b.checked = all;
3162
+ applyFilter();
3163
+ });
3164
+ $("btnFilter").addEventListener("click", (ev) => {
3165
+ ev.stopPropagation();
3166
+ const open = !$("filterMenu").classList.contains("show");
3167
+ setFilterMenuOpen(open);
3168
+ if (open) positionFilterMenu();
3169
+ });
3170
+ /* The filter menu is `position: absolute; right: 0` under its trigger, which
3171
+ * holds only while the trigger is far enough from the left edge. At ≤560px the
3172
+ * header wraps to two rows and `.hd-center` switches to `justify-content:
3173
+ * flex-start`, so the trigger sits near the left and a 200px menu pinned by its
3174
+ * right edge hangs off the screen (measured at 520px: left = -100, i.e. half the
3175
+ * menu unreachable). Clamp it into the viewport the same way the date panel is
3176
+ * clamped — but only when needed, so the normal case keeps the neat
3177
+ * right-aligned look. */
3178
+ function positionFilterMenu() {
3179
+ const menu = $("filterMenu");
3180
+ if (!menu.classList.contains("show")) return;
3181
+ menu.style.left = "";
3182
+ menu.style.right = "";
3183
+ const r = menu.getBoundingClientRect();
3184
+ const pad = 8;
3185
+ if (r.left >= pad && r.right <= innerWidth - pad) return;
3186
+ // The menu is absolutely positioned inside .filterWrap (its offsetParent), so
3187
+ // a viewport coordinate must be converted before it is written to `left`.
3188
+ // Writing the viewport value directly would shift it by the wrap's offset.
3189
+ const parent = menu.offsetParent;
3190
+ const px = parent ? parent.getBoundingClientRect().left : 0;
3191
+ const leftViewport = Math.min(Math.max(pad, r.left), Math.max(pad, innerWidth - pad - r.width));
3192
+ menu.style.left = Math.round(leftViewport - px) + "px";
3193
+ menu.style.right = "auto";
3194
+ }
3195
+
3196
+
3197
+ /* Flat / grouped view. Flat mode drops the branch group headers so the lane
3198
+ * graph runs continuously — a header row has no commit node, which would
3199
+ * otherwise leave a visible gap in every lane crossing it.
3200
+ *
3201
+ * The control now lives in Settings, but the two values are absolute
3202
+ * ("grouped" / "flat") rather than a toggle: the segmented control shows both
3203
+ * options at once, so a click means "be this", not "flip that". A toggle would
3204
+ * have needed the button to be rotated by every state change, and a mis-click
3205
+ * would then be indistinguishable from a double-click. */
3206
+ function setFlatMode(on) {
3207
+ on = Boolean(on);
3208
+ if (on !== FLAT_MODE) {
3209
+ FLAT_MODE = on;
3210
+ saveStored("gg2-flat", FLAT_MODE);
3211
+ prefsSync({ "gg2-flat": FLAT_MODE });
3212
+ render();
3213
+ }
3214
+ syncSettingsState();
3215
+ }
3216
+ /* Kept as the name every call site already uses (prefs restore, startup). */
3217
+ function syncFlatBtn() { syncSettingsState(); }
3218
+ /* Outside-click dismissal for the header/panel popovers is in ONE capture-phase
3219
+ * listener; see dismissPopovers() below the date panel, because closing the date
3220
+ * panel has to commit (or keep) its draft. */
3221
+ function setFilterMenuOpen(open) {
3222
+ $("filterMenu").classList.toggle("show", open);
3223
+ $("btnFilter").setAttribute("aria-expanded", open ? "true" : "false");
3224
+ }
3225
+ /** The wd-panel group menu is built per render, so it is closed by element
3226
+ * rather than by id; its trigger is the sibling button right before it. */
3227
+ function setWdMenuOpen(menu, open) {
3228
+ if (!menu) return;
3229
+ menu.classList.toggle("show", open);
3230
+ const btn = menu.previousElementSibling;
3231
+ if (btn && btn.tagName === "BUTTON") btn.setAttribute("aria-expanded", open ? "true" : "false");
3232
+ }
3233
+
3234
+
3235
+ /* ===================== events ===================== */
3236
+ /* Busy state for an icon-only button. `textContent = "拉取中…"` used to carry the
3237
+ * in-flight signal; that is no longer available because the button's only child
3238
+ * is now an <svg> and assigning textContent would delete it. The rotation of the
3239
+ * glyph plus `aria-busy` carries the same information without the text. */
3240
+ function setBusy(btn, busy, label) {
3241
+ btn.classList.toggle("busy", busy);
3242
+ btn.disabled = busy;
3243
+ btn.setAttribute("aria-busy", busy ? "true" : "false");
3244
+ if (label) btn.title = label;
3245
+ }
3246
+ $("btnRefresh").addEventListener("click", load);
3247
+
3248
+ /** Fetch remote refs (git fetch --all --prune), then refresh graph + branches. */
3249
+ $("btnFetch").addEventListener("click", async () => {
3250
+ const b = $("btnFetch");
3251
+ const idle = b.title;
3252
+ setBusy(b, true, "拉取中…");
3253
+ try {
3254
+ const res = await api("gitop", { action: "fetch" });
3255
+ if (!res.ok) { alert("拉取失败: " + (res.error || "?")); return; }
3256
+ await reloadGraphLight();
3257
+ refreshWorkdir();
3258
+ } catch (err) {
3259
+ alert("拉取失败: " + err.message);
3260
+ } finally {
3261
+ setBusy(b, false, idle);
3262
+ }
3263
+ });
3264
+
3265
+ /** Push the current branch to origin (confirm first). */
3266
+ $("btnPush").addEventListener("click", async () => {
3267
+ const ws = WORKSTATUS;
3268
+ const branch = ws && ws.branch ? ws.branch : "";
3269
+ const ok = await confirmDialog(
3270
+ "推送到远程",
3271
+ "将把当前分支 <b>" + escHtml(branch || "HEAD") + "</b> 推送到 <b>origin</b>(会影响云端仓库,请确认):",
3272
+ false, "确认推送"
3273
+ );
3274
+ if (!ok) return;
3275
+ const b = $("btnPush");
3276
+ const idle = b.title;
3277
+ setBusy(b, true, "推送中…");
3278
+ try {
3279
+ const res = await api("gitop", { action: "push", branch });
3280
+ if (!res.ok) { alert("推送失败: " + (res.error || "?")); return; }
3281
+ alert(res.summary || "推送成功");
3282
+ } catch (err) {
3283
+ alert("推送失败: " + err.message);
3284
+ } finally {
3285
+ setBusy(b, false, idle);
3286
+ }
3287
+ });
3288
+ $("repoSwitch").addEventListener("change", (ev) => {
3289
+ const v = ev.target.value;
3290
+ if (!v || v === REPO) return;
3291
+ REPO = v;
3292
+ saveCurRepo(v);
3293
+ $("repoLabel").textContent = v;
3294
+ $("repoLabel").title = v;
3295
+ wdDiffCache.clear();
3296
+ reloadGraphLight();
3297
+ refreshWorkdir();
3298
+ });
3299
+
3300
+ /* ---- self-update: check GitHub for a newer tag, offer one-click update ---- */
3301
+ let UPDATE_STATE = null; // {current, latest, hasUpdate, managed}
3302
+ async function checkForUpdate(silent) {
3303
+ const btn = $("btnUpdate");
3304
+ try {
3305
+ const res = await api("checkupdate");
3306
+ if (!res.ok) { if (!silent) alert("检查更新失败: " + (res.error || "?")); return; }
3307
+ UPDATE_STATE = res;
3308
+ // keep the version badge in the status bar in sync
3309
+ const ver = $("ggVersion");
3310
+ if (ver && res.current) ver.textContent = "v" + res.current;
3311
+ if (res.managed === "git") {
3312
+ // Plugin dir is a git checkout (link:/dev mount): never overwrite files
3313
+ // the user owns through git — upgrades go through `git pull`.
3314
+ btn.classList.add("gg-none");
3315
+ if (!silent) alert("插件以 git 工作区形式挂载,请在插件仓库目录执行 git pull 更新(当前 v" + (res.current || "?") + ")");
3316
+ return;
3317
+ }
3318
+ if (res.hasUpdate && res.latest) {
3319
+ // The badge used to grow to "🔄 新版本 vX.Y", which re-widened the right
3320
+ // group and shifted the centred filters every time a release landed.
3321
+ // An accent tint plus the version in the tooltip keeps the width fixed.
3322
+ btn.classList.remove("gg-none");
3323
+ btn.classList.add("has-upd");
3324
+ btn.title = "发现新版本 " + res.latest + "(当前 " + (res.current || "?") + "),点击一键更新";
3325
+ btn.setAttribute("aria-label", "发现新版本 " + res.latest + ",点击更新");
3326
+ } else if (!silent) {
3327
+ btn.classList.add("gg-none");
3328
+ alert("已是最新版本(" + (res.current || "?") + ")");
3329
+ }
3330
+ } catch (err) {
3331
+ if (!silent) alert("检查更新失败: " + err.message);
3332
+ }
3333
+ }
3334
+ $("btnUpdate").addEventListener("click", async () => {
3335
+ if (!UPDATE_STATE || !UPDATE_STATE.hasUpdate) { checkForUpdate(false); return; }
3336
+ const ok = await confirmDialog(
3337
+ "发现新版本",
3338
+ "当前版本 <b>" + escHtml(UPDATE_STATE.current || "?") + "</b>,最新版本 <b>" + escHtml(UPDATE_STATE.latest) +
3339
+ "</b>。将自动从 GitHub 下载并替换插件文件,<b>完成后需要重启 DeepSeek Harness 才能生效</b>。继续?",
3340
+ false, "立即更新"
3341
+ );
3342
+ if (!ok) return;
3343
+ const btn = $("btnUpdate");
3344
+ const idle = btn.title;
3345
+ setBusy(btn, true, "更新中…");
3346
+ try {
3347
+ const res = await api("autoupdate", { tag: UPDATE_STATE.latest });
3348
+ if (!res.ok) { alert("更新失败: " + (res.error || "?")); return; }
3349
+ alert("更新完成!请重启 DeepSeek Harness 使新版本生效。");
3350
+ btn.classList.add("gg-none");
3351
+ } catch (err) {
3352
+ alert("更新失败: " + err.message);
3353
+ } finally {
3354
+ setBusy(btn, false, idle);
3355
+ }
3356
+ });
3357
+
3358
+ let searchTimer = null;
3359
+ $("searchBox").addEventListener("input", (ev) => {
3360
+ clearTimeout(searchTimer);
3361
+ searchTimer = setTimeout(() => {
3362
+ SEARCH = ev.target.value.trim();
3363
+ updateSearchCount();
3364
+ render();
3365
+ }, 200);
3366
+ });
3367
+
3368
+ /* Match count reflects the *filtered* result set, matching what the list shows. */
3369
+ function updateSearchCount() {
3370
+ if (!filtersActive()) { $("searchCount").textContent = ""; return; }
3371
+ const n = COMMITS.filter(passesFilters).length;
3372
+ $("searchCount").textContent = `${n} 条匹配`;
3373
+ }
3374
+
3375
+ /** Reset search + date range (the "no results" escape hatch). */
3376
+ function clearFilters() {
3377
+ SEARCH = "";
3378
+ DATE_FROM = "";
3379
+ DATE_TO = "";
3380
+ $("searchBox").value = "";
3381
+ // The popover keeps its own draft; clearing the live state without it would
3382
+ // leave the chips showing a range that is no longer filtering anything.
3383
+ dFrom = "";
3384
+ dTo = "";
3385
+ dDirty = false;
3386
+ syncDateDraftUI();
3387
+ renderCalendar();
3388
+ saveStored("gg2-datefrom", "");
3389
+ saveStored("gg2-dateto", "");
3390
+ prefsSync({ "gg2-datefrom": "", "gg2-dateto": "" });
3391
+ updateSearchCount();
3392
+ render();
3393
+ }
3394
+
3395
+ /* ---- date range popover ---- */
3396
+ /* The range is edited as a DRAFT (dFrom/dTo) and only copied into the live
3397
+ * DATE_FROM/DATE_TO by applyDateRange(). The calendar grid writes to whichever
3398
+ * end `dEnd` names, so a two-click range ("从" then "到") needs no second
3399
+ * picker and no modal re-entry — the popover stays open across both clicks. */
3400
+ let dFrom = "";
3401
+ let dTo = "";
3402
+ let dEnd = "from"; // which chip the calendar writes
3403
+ let dView = { y: new Date().getFullYear(), m: new Date().getMonth() }; // first of the visible month
3404
+ let dDirty = false; // draft differs from applied
3405
+
3406
+ function openDateDraft() {
3407
+ dFrom = DATE_FROM || "";
3408
+ dTo = DATE_TO || "";
3409
+ dDirty = false;
3410
+ // Resume at the start of the current range, else at the newest month that can
3411
+ // actually hold commits; opening on an empty future month makes the picker
3412
+ // look broken.
3413
+ const now = new Date();
3414
+ let anchor = dFrom || dTo;
3415
+ if (!anchor) {
3416
+ const newest = /^(\d{4})-(\d{2})/.exec((COMMITS[0] && COMMITS[0].date) || "");
3417
+ anchor = newest ? newest[1] + "-" + newest[2] + "-01" : ymdLocal(now);
3418
+ }
3419
+ const mm = /^(\d{4})-(\d{2})/.exec(anchor);
3420
+ dView = mm
3421
+ ? { y: Number(mm[1]), m: Number(mm[2]) - 1 }
3422
+ : { y: now.getFullYear(), m: now.getMonth() };
3423
+ // Starting on "从" would overwrite the start whenever it is already set and the
3424
+ // end is not: the useful default is the end that is still open.
3425
+ dEnd = dFrom && !dTo ? "to" : "from";
3426
+ syncDateDraftUI();
3427
+ renderCalendar();
3428
+ }
3429
+
3430
+ function dateDraftChip() {
3431
+ const open = dEnd;
3432
+ $("dateFromChip").classList.toggle("active", open === "from");
3433
+ $("dateFromChip").setAttribute("aria-pressed", String(open === "from"));
3434
+ $("dateToChip").classList.toggle("active", open === "to");
3435
+ $("dateToChip").setAttribute("aria-pressed", String(open === "to"));
3436
+ }
3437
+
3438
+ function syncDateDraftUI() {
3439
+ // Display joins the stored YYYY-MM-DD into the shorter 2026/11/05: eight digits
3440
+ // and two separators in a 300px panel leaves no margin, and the slashes match
3441
+ // the calendar header.
3442
+ const fmt = (v) => (v ? v.replace(/-/g, "/") : "不限");
3443
+ $("dateFromVal").textContent = fmt(dFrom);
3444
+ $("dateToVal").textContent = fmt(dTo);
3445
+ $("dateFromChip").classList.toggle("empty", !dFrom);
3446
+ $("dateToChip").classList.toggle("empty", !dTo);
3447
+ dateDraftChip();
3448
+ // "应用" is the commit point, so it has to show whether there is anything to commit.
3449
+ $("dateApply").classList.toggle("primary", dDirty);
3450
+ $("dateApply").textContent = dDirty ? "应用" : "关闭";
3451
+ }
3452
+
3453
+ /** Set the end the calendar is editing; picking both ends in order needs no mode
3454
+ * switch, but an explicit click always wins over the automatic advance. */
3455
+ function setDateEnd(which) {
3456
+ dEnd = which;
3457
+ dateDraftChip();
3458
+ const v = which === "from" ? dFrom : dTo;
3459
+ const m = /^(\d{4})-(\d{2})/.exec(v);
3460
+ if (m) { dView = { y: Number(m[1]), m: Number(m[2]) - 1 }; renderCalendar(); }
3461
+ }
3462
+
3463
+ /** Clicking a day writes that day to the active end and advances to the other
3464
+ * one while the other is empty. Order is normalised on apply, so the user never
3465
+ * has to think about which one they clicked first. */
3466
+ function pickDay(ymd) {
3467
+ // renderCalendar() rebuilds the grid, which destroys the focused <button>. A
3468
+ // keyboard user pressing Enter on a day would lose focus to <body> and the
3469
+ // arrow keys would stop working, so re-apply focus to the same date.
3470
+ const hadFocus = document.activeElement && document.activeElement.classList
3471
+ && document.activeElement.classList.contains("dm-day");
3472
+ if (dEnd === "from") {
3473
+ dFrom = ymd;
3474
+ if (!dTo) { dEnd = "to"; } else if (dTo < dFrom) { dFrom = dTo; dTo = ymd; }
3475
+ } else {
3476
+ dTo = ymd;
3477
+ if (!dFrom) { dEnd = "from"; } else if (dTo < dFrom) { dTo = dFrom; dFrom = ymd; }
3478
+ }
3479
+ dDirty = dFrom !== (DATE_FROM || "") || dTo !== (DATE_TO || "");
3480
+ syncDateDraftUI();
3481
+ renderCalendar();
3482
+ if (hadFocus) {
3483
+ const el = $("dmGrid").querySelector(`[data-ymd="${ymd}"]`);
3484
+ if (el) { for (const b of $("dmGrid").querySelectorAll(".dm-day")) b.tabIndex = -1; el.tabIndex = 0; el.focus(); }
3485
+ }
3486
+ }
3487
+
3488
+ function renderCalendar() {
3489
+ const y = dView.y, m = dView.m;
3490
+ $("dmTitle").textContent = `${y} 年 ${m + 1} 月`;
3491
+ const grid = $("dmGrid");
3492
+ grid.innerHTML = "";
3493
+ const first = new Date(y, m, 1);
3494
+ const lead = (first.getDay() + 6) % 7; // Monday-first column index
3495
+ const start = new Date(y, m, 1 - lead);
3496
+ const todayYmd = ymdLocal(new Date());
3497
+ const lo = dFrom && dTo && dFrom > dTo ? dTo : dFrom;
3498
+ const hi = dFrom && dTo && dFrom > dTo ? dFrom : dTo;
3499
+ const frag = document.createDocumentFragment();
3500
+ // Roving tabindex: 42 day buttons would otherwise be 42 tab stops. Only the
3501
+ // active end (or today, when it is in view) is tabbable; arrow keys inside the
3502
+ // grid move focus and hand the tab stop over.
3503
+ const active = dEnd === "from" ? dFrom : dTo;
3504
+ let tabYmd = active && active.slice(0, 7) === `${y}-${String(m + 1).padStart(2, "0")}` ? active : "";
3505
+ if (!tabYmd && todayYmd.slice(0, 7) === `${y}-${String(m + 1).padStart(2, "0")}`) tabYmd = todayYmd;
3506
+ for (let i = 0; i < 42; i++) {
3507
+ const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
3508
+ const ymd = ymdLocal(d);
3509
+ const b = document.createElement("button");
3510
+ b.type = "button";
3511
+ b.className = "dm-day";
3512
+ b.textContent = String(d.getDate());
3513
+ b.dataset.ymd = ymd;
3514
+ b.tabIndex = -1;
3515
+ b.setAttribute("aria-label", `${d.getFullYear()} 年 ${d.getMonth() + 1} 月 ${d.getDate()} 日`);
3516
+ if (d.getMonth() !== m) b.classList.add("other");
3517
+ if (ymd === todayYmd) { b.classList.add("today"); b.setAttribute("aria-current", "date"); }
3518
+ if (lo && hi && ymd > lo && ymd < hi) b.classList.add("inrange");
3519
+ if (ymd === dFrom || ymd === dTo) { b.classList.add("sel"); b.setAttribute("aria-pressed", "true"); }
3520
+ if (!tabYmd && i === lead) tabYmd = ymd;
3521
+ frag.appendChild(b);
3522
+ }
3523
+ grid.appendChild(frag);
3524
+ const tab = grid.querySelector(`[data-ymd="${tabYmd || ""}"]`) || grid.querySelector(".dm-day");
3525
+ if (tab) tab.tabIndex = 0;
3526
+ }
3527
+
3528
+ function shiftMonth(delta) {
3529
+ const d = new Date(dView.y, dView.m + delta, 1);
3530
+ dView = { y: d.getFullYear(), m: d.getMonth() };
3531
+ renderCalendar();
3532
+ }
3533
+
3534
+ function ymdLocal(d) {
3535
+ const p = (n) => String(n).padStart(2, "0");
3536
+ return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate());
3537
+ }
3538
+ function fillDateInputs() {
3539
+ // Kept as the name the rest of the file calls (clearFilters, boot): refresh the
3540
+ // draft from the live state and repaint whatever is on screen.
3541
+ dFrom = DATE_FROM || "";
3542
+ dTo = DATE_TO || "";
3543
+ dDirty = false;
3544
+ syncDateDraftUI();
3545
+ renderCalendar();
3546
+ }
3547
+ function applyDateRange() {
3548
+ let from = dFrom, to = dTo;
3549
+ // Swap silently when the user picked them backwards; a rejection dialog here
3550
+ // would be more annoying than just honouring the obvious intent.
3551
+ if (from && to && dayStart(from) > dayEnd(to)) { const t = from; from = to; to = t; }
3552
+ DATE_FROM = from;
3553
+ DATE_TO = to;
3554
+ dFrom = from;
3555
+ dTo = to;
3556
+ dDirty = false;
3557
+ fillDateInputs();
3558
+ $("btnDate").classList.toggle("on", Boolean(DATE_FROM || DATE_TO));
3559
+ saveStored("gg2-datefrom", DATE_FROM);
3560
+ saveStored("gg2-dateto", DATE_TO);
3561
+ prefsSync({ "gg2-datefrom": DATE_FROM, "gg2-dateto": DATE_TO });
3562
+ setDateMenuOpen(false);
3563
+ updateSearchCount();
3564
+ render();
3565
+ }
3566
+ /* The panel is anchored under its trigger, but a 300 × 420px popover does not fit
3567
+ * on a phone in landscape, and the trigger moves when the header wraps. So it is
3568
+ * positioned explicitly (fixed coordinates, clamped into the viewport) instead of
3569
+ * relying on `right: 0; top: 32px`: below the trigger when there is room, above
3570
+ * it when there is not, and pinned with a scroll if neither side fits. */
3571
+ function positionDateMenu() {
3572
+ const menu = $("dateMenu");
3573
+ if (!menu.classList.contains("show")) return;
3574
+ const trigger = $("btnDate").getBoundingClientRect();
3575
+ menu.style.right = "auto";
3576
+ menu.style.maxHeight = "";
3577
+ menu.style.overflowY = "";
3578
+ const pad = 8;
3579
+ const w = menu.offsetWidth;
3580
+ const h = menu.offsetHeight;
3581
+ // Horizontal: right-align with the trigger, then clamp into the viewport.
3582
+ const left = Math.min(Math.max(trigger.right - w, pad), Math.max(pad, innerWidth - pad - w));
3583
+ const roomBelow = innerHeight - trigger.bottom - 6 - pad;
3584
+ const roomAbove = trigger.top - 6 - pad;
3585
+ let top;
3586
+ if (h <= roomBelow) top = trigger.bottom + 6;
3587
+ else if (h <= roomAbove) top = trigger.top - 6 - h;
3588
+ else if (h <= innerHeight - 2 * pad) top = Math.max(pad, Math.min(trigger.bottom + 6, innerHeight - pad - h));
3589
+ else {
3590
+ // Taller than the viewport (very short window): pin to the top and scroll.
3591
+ top = pad;
3592
+ menu.style.maxHeight = (innerHeight - 2 * pad) + "px";
3593
+ menu.style.overflowY = "auto";
3594
+ }
3595
+ menu.style.left = Math.round(left) + "px";
3596
+ menu.style.top = Math.round(top) + "px";
3597
+ }
3598
+ /* The date panel's `show` flag is mirrored onto the trigger's aria-expanded so
3599
+ * the panel's state is not colour-only. Every open/close path goes through
3600
+ * these two helpers — there used to be six bare classList calls, which is how
3601
+ * aria-expanded would have drifted out of sync. */
3602
+ function setDateMenuOpen(open) {
3603
+ $("dateMenu").classList.toggle("show", open);
3604
+ $("btnDate").setAttribute("aria-expanded", open ? "true" : "false");
3605
+ }
3606
+ $("btnDate").addEventListener("click", (ev) => {
3607
+ ev.stopPropagation();
3608
+ const wasOpen = $("dateMenu").classList.contains("show");
3609
+ if (wasOpen) {
3610
+ // Closing without applying must not silently keep the draft: the chips
3611
+ // would show dates the list is not filtered by.
3612
+ if (dDirty) applyDateRange(); else setDateMenuOpen(false);
3613
+ return;
3614
+ }
3615
+ openDateDraft();
3616
+ setDateMenuOpen(true);
3617
+ positionDateMenu();
3618
+ });
3619
+ $("dateMenu").addEventListener("click", (ev) => ev.stopPropagation());
3620
+ $("dateApply").addEventListener("click", () => {
3621
+ if (dDirty) applyDateRange(); else setDateMenuOpen(false);
3622
+ });
3623
+ $("dateClear").addEventListener("click", () => {
3624
+ dFrom = "";
3625
+ dTo = "";
3626
+ dEnd = "from";
3627
+ dDirty = Boolean(DATE_FROM || DATE_TO);
3628
+ syncDateDraftUI();
3629
+ renderCalendar();
3630
+ if (dDirty) applyDateRange(); else setDateMenuOpen(false);
3631
+ });
3632
+ $("dateFromChip").addEventListener("click", () => setDateEnd("from"));
3633
+ $("dateToChip").addEventListener("click", () => setDateEnd("to"));
3634
+ $("dmPrev").addEventListener("click", () => shiftMonth(-1));
3635
+ $("dmNext").addEventListener("click", () => shiftMonth(1));
3636
+ $("dmPrevY").addEventListener("click", () => shiftMonth(-12));
3637
+ $("dmNextY").addEventListener("click", () => shiftMonth(12));
3638
+ $("dmToday").addEventListener("click", () => {
3639
+ const d = new Date();
3640
+ dView = { y: d.getFullYear(), m: d.getMonth() };
3641
+ pickDay(ymdLocal(d));
3642
+ });
3643
+ $("dmGrid").addEventListener("click", (ev) => {
3644
+ const b = ev.target.closest(".dm-day");
3645
+ if (b) pickDay(b.dataset.ymd);
3646
+ });
3647
+ /* Arrow keys inside the grid move the focus (and the visible month when they
3648
+ * cross its edge) without picking a date — the same contract as a native
3649
+ * calendar. Picking stays on click/Enter/Space. */
3650
+ $("dmGrid").addEventListener("keydown", (ev) => {
3651
+ const cur = ev.target.closest(".dm-day");
3652
+ if (!cur) return;
3653
+ const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[ev.key];
3654
+ if (step === undefined) return;
3655
+ ev.preventDefault();
3656
+ const base = ev.target.dataset.ymd;
3657
+ const [y0, m0, d0] = base.split("-").map(Number);
3658
+ const next = new Date(y0, m0 - 1, d0 + step);
3659
+ if (next.getFullYear() !== dView.y || next.getMonth() !== dView.m) {
3660
+ dView = { y: next.getFullYear(), m: next.getMonth() };
3661
+ renderCalendar();
3662
+ }
3663
+ const ymd = ymdLocal(next);
3664
+ const el = $("dmGrid").querySelector(`[data-ymd="${ymd}"]`);
3665
+ if (el) { for (const b of $("dmGrid").querySelectorAll(".dm-day")) b.tabIndex = -1; el.tabIndex = 0; el.focus(); }
3666
+ });
3667
+ /* Relative presets fill BOTH ends, so they go straight through the draft and
3668
+ * need an explicit apply — the user still confirms, and the "swap reversed
3669
+ * range" logic stays in one place (applyDateRange). They use ymdLocal (defined
3670
+ * above) so the arithmetic stays on the local calendar: never toISOString(),
3671
+ * which is UTC and would be a day off east of Greenwich late in the evening. */
3672
+ for (const qb of document.querySelectorAll("#dateMenu .dm-quick button")) {
3673
+ qb.addEventListener("click", () => {
3674
+ const days = Number(qb.dataset.days) || 0;
3675
+ if (!days) return;
3676
+ const today = new Date();
3677
+ const from = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (days - 1));
3678
+ dFrom = ymdLocal(from);
3679
+ dTo = ymdLocal(today);
3680
+ dDirty = true;
3681
+ dView = { y: today.getFullYear(), m: today.getMonth() };
3682
+ dEnd = "from";
3683
+ syncDateDraftUI();
3684
+ renderCalendar();
3685
+ });
3686
+ }
3687
+ /* ---- outside-click dismissal for every popover -----------------------------
3688
+ * ONE listener, registered in the CAPTURE phase, on purpose.
3689
+ *
3690
+ * It used to be three separate bubble-phase `click` handlers (filter menu, wd
3691
+ * group menu, date panel) each doing `if (!ev.target.closest(...)) close()`.
3692
+ * Those never fired for a large part of the page, because the things the user
3693
+ * actually clicks next — a commit row, a branch group header, the "全部暂存"
3694
+ * button — call ev.stopPropagation() so their own click cannot be double-handled
3695
+ * by an ancestor. stopPropagation (unlike stopImmediatePropagation on the same
3696
+ * element) stops the event on its way UP, but capture runs on the way DOWN, from
3697
+ * window inward, before any row handler exists on the path. So the popover is
3698
+ * dismissed first, and the row still gets its click afterwards.
3699
+ *
3700
+ * Verified failure before this change (real Chrome, real commit rows):
3701
+ * open branch filter -> click a commit row => menu stayed open
3702
+ * open date panel -> click a commit row => panel stayed open
3703
+ * Worse, in the date case the draft was applied LATER, at whatever unrelated
3704
+ * click happened to reach document first — the list re-filtered against dates
3705
+ * the user had already walked away from.
3706
+ *
3707
+ * The rule for "inside": a click counts as inside its popover when it lands in
3708
+ * the popover itself OR on its trigger. The trigger must count, otherwise
3709
+ * pressing the trigger to toggle the panel closed would first be closed by this
3710
+ * listener and then immediately re-opened by the trigger's own handler.
3711
+ *
3712
+ * Why pointerdown and not click: a `disabled` <button> dispatches NO mouse and
3713
+ * NO click event at all — it only gets pointer events. The wd panel toolbar
3714
+ * disables "全部暂存" / "提交" whenever the worktree has nothing in that state,
3715
+ * so with a clean tree a click on them is silently swallowed and the filter menu
3716
+ * stayed open (real case: 全部暂存 is `disabled` on a clean worktree and produced
3717
+ * zero events, verified by instrumenting mousedown/mouseup/click at window
3718
+ * capture — the log stayed empty). pointerdown fires on disabled controls, and
3719
+ * dismissing on it is also the earlier, less surprising moment: the popover is
3720
+ * gone by the time the target reacts.
3721
+ *
3722
+ * pointerdown is paired with a click listener, because a keyboard-activated
3723
+ * click (Enter on a focused button) produces no pointerdown. No de-duplication
3724
+ * flag is needed for the menus: every branch is guarded by its own `.show`
3725
+ * class, so the second event finds nothing open and does nothing.
3726
+ *
3727
+ * The date draft is the one place where the two phases must behave differently,
3728
+ * and it is NOT a detail — it is a regression that was caught by testing:
3729
+ * committing the draft re-renders the commit list. If that happens during
3730
+ * pointerdown, every `.cl-row` is destroyed before mouseup, so mouseup lands on
3731
+ * a detached node, no click is synthesised, and the commit the user pressed is
3732
+ * silently NOT selected (measured: SELECTED stayed null and the detail panel
3733
+ * never opened). Scheduling the commit with setTimeout(0) from pointerdown is
3734
+ * just as broken: the timer runs before mouseup, so it has the same effect.
3735
+ *
3736
+ * What does work is committing during the CLICK phase: the event path is fixed
3737
+ * at dispatch time, so a row removed by the re-render still receives the click
3738
+ * that is already in flight (measured: the row's own listener ran, SELECTED was
3739
+ * correct). Hence:
3740
+ * - pointerdown: close the panel, remember that a commit is owed;
3741
+ * - click: commit it (this also covers keyboard activation, where the
3742
+ * panel is still open because no pointer sequence ran);
3743
+ * - pointerup: fallback for a press-drag-release that never produces a
3744
+ * click, scheduled a tick later so a real click wins first.
3745
+ * The filter and wd menus only toggle a class, so they close on pointerdown —
3746
+ * the earlier moment, which also means they are gone before the target reacts. */
3747
+ let dateDraftOwed = false;
3748
+ function commitOwedDateDraft() {
3749
+ if (!dateDraftOwed) return;
3750
+ dateDraftOwed = false;
3751
+ if (dDirty) applyDateRange();
3752
+ }
3753
+ function dismissPopovers(ev, phase) {
3754
+ const t = ev.target;
3755
+ if (!(t instanceof Node)) return;
3756
+
3757
+ if ($("filterMenu").classList.contains("show") && !t.closest(".filterWrap")) {
3758
+ setFilterMenuOpen(false);
3759
+ }
3760
+ const wdMenu = document.querySelector("#wdPanel .wd-menu.show");
3761
+ if (wdMenu && !t.closest(".wd-filterWrap")) setWdMenuOpen(wdMenu, false);
3762
+ if ($("dateMenu").classList.contains("show") && !t.closest(".dateWrap")) {
3763
+ // Closing by clicking outside keeps what the user picked: a draft that was
3764
+ // only ever visible in this panel would be lost silently otherwise.
3765
+ if (!dDirty) setDateMenuOpen(false);
3766
+ else if (phase === "click") applyDateRange();
3767
+ else { setDateMenuOpen(false); dateDraftOwed = true; }
3768
+ }
3769
+ if ($("ctxMenu").classList.contains("show") && !t.closest("#ctxMenu")) hideContextMenu();
3770
+ }
3771
+ window.addEventListener("pointerdown", (ev) => dismissPopovers(ev, "pointerdown"), true);
3772
+ window.addEventListener("click", (ev) => { dismissPopovers(ev, "click"); commitOwedDateDraft(); }, true);
3773
+ window.addEventListener("pointerup", () => { setTimeout(commitOwedDateDraft, 0); }, true);
3774
+
3775
+
3776
+ /* ---- context menu ---- */
3777
+ function openContextMenu(x, y, c) {
3778
+ const menu = $("ctxMenu");
3779
+ menu.innerHTML = "";
3780
+ const title = document.createElement("div");
3781
+ title.className = "cm-title";
3782
+ title.textContent = short7(c.hash) + " " + (c.subject || "");
3783
+ menu.appendChild(title);
3784
+ const mk = (label, key, fn) => {
3785
+ const item = document.createElement("div");
3786
+ item.className = "cm-item";
3787
+ const txt = document.createElement("span");
3788
+ txt.textContent = label;
3789
+ item.appendChild(txt);
3790
+ if (key) {
3791
+ const k = document.createElement("span");
3792
+ k.className = "cm-key";
3793
+ k.textContent = key;
3794
+ item.appendChild(k);
3795
+ }
3796
+ item.addEventListener("click", () => { hideContextMenu(); fn(); });
3797
+ menu.appendChild(item);
3798
+ };
3799
+ mk("复制提交哈希", "Ctrl+C", () => {
3800
+ navigator.clipboard?.writeText(c.hash).catch(() => {});
3801
+ });
3802
+ mk("复制提交消息", "", () => {
3803
+ navigator.clipboard?.writeText(c.subject || "").catch(() => {});
3804
+ });
3805
+ mk("复制 哈希 + 消息", "", () => {
3806
+ navigator.clipboard?.writeText(c.hash + " " + (c.subject || "")).catch(() => {});
3807
+ });
3808
+ mk("查看详情 / diff", "Enter", () => {
3809
+ selectCommit(c.hash);
3810
+ });
3811
+ mk("🏷️ 打标签…", "", () => openTagDlg(c.hash));
3812
+ mk("🌿 从此提交新建分支…", "", () => openBranchDlg(c.hash));
3813
+ mk("🌐 打开 GitHub 提交页", "", () => openGithubCommit(c.hash));
3814
+ mk("复制仓库路径", "", () => {
3815
+ navigator.clipboard?.writeText(REPO || "").catch(() => {});
3816
+ });
3817
+ mk("在文件管理器中打开", "", () => {
3818
+ api("reveal").catch(() => {});
3819
+ });
3820
+ menu.classList.add("show");
3821
+ const r = menu.getBoundingClientRect();
3822
+ menu.style.left = Math.min(x, window.innerWidth - r.width - 10) + "px";
3823
+ menu.style.top = Math.min(y, window.innerHeight - r.height - 10) + "px";
3824
+ }
3825
+ function hideContextMenu() {
3826
+ $("ctxMenu").classList.remove("show");
3827
+ }
3828
+ /* No `click` listener here: the context menu is dismissed by the single
3829
+ * capture-phase dismissPopovers() listener, for the same reason the other
3830
+ * popovers are. `blur` stays — leaving the window with a menu pinned to the
3831
+ * viewport should still close it, and blur does not bubble. */
3832
+ window.addEventListener("blur", hideContextMenu);
3833
+
3834
+ /* ---- branch-group context menu: switch / push / delete / fetch ---- */
3835
+ function openBranchCtxMenu(x, y, r) {
3836
+ const menu = $("ctxMenu");
3837
+ menu.innerHTML = "";
3838
+ const title = document.createElement("div");
3839
+ title.className = "cm-title";
3840
+ title.textContent = r.name;
3841
+ menu.appendChild(title);
3842
+ const mk = (label, key, fn) => {
3843
+ const item = document.createElement("div");
3844
+ item.className = "cm-item";
3845
+ const txt = document.createElement("span");
3846
+ txt.textContent = label;
3847
+ item.appendChild(txt);
3848
+ if (key) {
3849
+ const k = document.createElement("span");
3850
+ k.className = "cm-key";
3851
+ k.textContent = key;
3852
+ item.appendChild(k);
3853
+ }
3854
+ item.addEventListener("click", () => { hideContextMenu(); fn(); });
3855
+ menu.appendChild(item);
3856
+ };
3857
+ const isRemote = r.refname.startsWith("refs/remotes/");
3858
+ const branchName = isRemote
3859
+ ? r.refname.replace(/^refs\/remotes\/[^/]+\//, "")
3860
+ : r.refname.replace(/^refs\/heads\//, "");
3861
+ if (isRemote) {
3862
+ mk("⬇ 拉取远程更新", "", () => runFetch());
3863
+ mk("复制远程分支名", "", () => {
3864
+ navigator.clipboard?.writeText(r.refname.replace(/^refs\/remotes\//, "")).catch(() => {});
3865
+ });
3866
+ } else {
3867
+ mk("🔄 切换到该分支", "", () => checkoutBranch(branchName));
3868
+ mk("⬆ 推送该分支", "", () => pushBranch(branchName));
3869
+ mk("🗑️ 删除该分支", "", () => deleteBranch(branchName, !!r.head));
3870
+ }
3871
+ menu.classList.add("show");
3872
+ const rr = menu.getBoundingClientRect();
3873
+ menu.style.left = Math.min(x, window.innerWidth - rr.width - 10) + "px";
3874
+ menu.style.top = Math.min(y, window.innerHeight - rr.height - 10) + "px";
3875
+ }
3876
+
3877
+ async function runFetch() {
3878
+ const res = await api("gitop", { action: "fetch" });
3879
+ if (!res.ok) { alert("拉取失败: " + (res.error || "?")); return; }
3880
+ await reloadGraphLight();
3881
+ refreshWorkdir();
3882
+ }
3883
+
3884
+ async function checkoutBranch(name) {
3885
+ const ws = WORKSTATUS;
3886
+ if (ws && (ws.counts.staged || ws.counts.unstaged || ws.counts.untracked)) {
3887
+ const ok = await confirmDialog(
3888
+ "切换分支",
3889
+ "当前有<b>未提交的改动</b>,切换分支可能导致这些改动被带入或冲突。确定切换到 <b>" + escHtml(name) + "</b> 吗?",
3890
+ false, "确认切换"
3891
+ );
3892
+ if (!ok) return;
3893
+ }
3894
+ const res = await api("gitop", { action: "checkout", name });
3895
+ if (!res.ok) { alert("切换失败: " + (res.error || "?")); return; }
3896
+ await reloadGraphLight();
3897
+ refreshWorkdir();
3898
+ }
3899
+
3900
+ async function pushBranch(name) {
3901
+ const ok = await confirmDialog(
3902
+ "推送分支",
3903
+ "将把本地分支 <b>" + escHtml(name) + "</b> 推送到 <b>origin</b>(会影响云端仓库,请确认):",
3904
+ false, "确认推送"
3905
+ );
3906
+ if (!ok) return;
3907
+ const res = await api("gitop", { action: "push", branch: name });
3908
+ if (!res.ok) { alert("推送失败: " + (res.error || "?")); return; }
3909
+ alert(res.summary || "推送成功");
3910
+ }
3911
+
3912
+ async function deleteBranch(name, isHead) {
3913
+ if (isHead) { alert("不能删除当前所在的 HEAD 分支"); return; }
3914
+ const ok = await confirmDialog(
3915
+ "删除分支",
3916
+ "确定删除本地分支 <b>" + escHtml(name) + "</b>?(已合并的分支可删除;未合并的会失败)",
3917
+ true, "确认删除"
3918
+ );
3919
+ if (!ok) return;
3920
+ const res = await api("gitop", { action: "delete-branch", name });
3921
+ if (!res.ok) { alert("删除失败: " + (res.error || "?")); return; }
3922
+ await reloadGraphLight();
3923
+ refreshWorkdir();
3924
+ }
3925
+
3926
+ /* ---- tag dialog: create / rename / delete tags on one commit ---- */
3927
+ function openTagDlg(hash) {
3928
+ return new Promise((resolve) => {
3929
+ const d = $("tagDlg");
3930
+ const body = $("tagDlgBody");
3931
+ body.innerHTML = "";
3932
+ const p = document.createElement("div");
3933
+ p.innerHTML = "给提交 <b>" + short7(hash) + "</b> 创建或管理轻量标签(不会推送到远程):";
3934
+ body.appendChild(p);
3935
+ // existing tags on this commit
3936
+ const commit = COMMITS.find((c) => c.hash === hash);
3937
+ const myTags = (commit && Array.isArray(commit.refs) ? commit.refs : [])
3938
+ .filter((r) => r.kind === "tag").map((r) => r.name);
3939
+ const tagList = document.createElement("div");
3940
+ tagList.className = "tag-list";
3941
+ const rerenderTags = () => {
3942
+ tagList.innerHTML = "";
3943
+ if (!myTags.length) {
3944
+ const none = document.createElement("div");
3945
+ none.className = "tag-none";
3946
+ none.textContent = "该提交暂无标签";
3947
+ tagList.appendChild(none);
3948
+ return;
3949
+ }
3950
+ for (const t of myTags) {
3951
+ const row = document.createElement("div");
3952
+ row.className = "tag-row";
3953
+ const nm = document.createElement("span");
3954
+ nm.className = "tag-name";
3955
+ nm.textContent = t;
3956
+ row.appendChild(nm);
3957
+ const btns = document.createElement("span");
3958
+ btns.className = "tag-acts";
3959
+ const mkBtn = (label, title, fn) => {
3960
+ const b = document.createElement("button");
3961
+ b.type = "button";
3962
+ b.textContent = label;
3963
+ b.title = title;
3964
+ b.addEventListener("click", fn);
3965
+ btns.appendChild(b);
3966
+ };
3967
+ mkBtn("✎", "改名", () => {
3968
+ input.value = t;
3969
+ renameHint.textContent = "正在给 「" + t + "」 改名,输入新名字后点「改名」或回车";
3970
+ renameHint.style.display = "";
3971
+ renaming = t;
3972
+ $("tagDlgOk").textContent = "改名";
3973
+ input.focus();
3974
+ });
3975
+ mkBtn("✕", "删除", () => {
3976
+ const safe = escHtml(t);
3977
+ confirmDialog("删除标签", "确定删除标签 <b>" + safe + "</b>?(仅删除本地标签,不会推送远程)", true, "确认删除").then((ok) => {
3978
+ if (!ok) return;
3979
+ api("gitop", { action: "tag-delete", name: t }).then((res) => {
3980
+ if (!res.ok) { alert("删除标签失败: " + (res.error || "?")); return; }
3981
+ const i = myTags.indexOf(t);
3982
+ if (i >= 0) myTags.splice(i, 1);
3983
+ rerenderTags();
3984
+ wdDiffCache.clear();
3985
+ reloadGraphLight();
3986
+ });
3987
+ });
3988
+ });
3989
+ row.appendChild(btns);
3990
+ tagList.appendChild(row);
3991
+ }
3992
+ };
3993
+ body.appendChild(tagList);
3994
+ const input = document.createElement("input");
3995
+ input.type = "text";
3996
+ input.placeholder = "新标签名,例如 v1.0.0 / 正式版";
3997
+ input.style.cssText = "width:100%;margin-top:10px;padding:8px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--text);font-size:12.5px;outline:none";
3998
+ body.appendChild(input);
3999
+ const renameHint = document.createElement("div");
4000
+ renameHint.className = "tag-rename-hint";
4001
+ renameHint.style.display = "none";
4002
+ body.appendChild(renameHint);
4003
+ let renaming = null;
4004
+ rerenderTags();
4005
+ const finish = (v) => {
4006
+ d.classList.remove("show");
4007
+ $("tagDlgOk").onclick = null;
4008
+ $("tagDlgCancel").onclick = null;
4009
+ resolve(v);
4010
+ };
4011
+ const doOk = async () => {
4012
+ const val = input.value.trim();
4013
+ if (!val) {
4014
+ input.style.borderColor = "var(--red)";
4015
+ input.focus();
4016
+ return;
4017
+ }
4018
+ const res = renaming
4019
+ ? await api("gitop", { action: "tag-rename", name: renaming, newname: val, hash })
4020
+ : await api("gitop", { action: "tag", name: val, hash });
4021
+ if (!res.ok) { alert((renaming ? "改名失败" : "打标签失败") + ": " + (res.error || "?")); return; }
4022
+ if (renaming) {
4023
+ const i = myTags.indexOf(renaming);
4024
+ if (i >= 0) myTags[i] = val;
4025
+ renaming = null;
4026
+ renameHint.style.display = "none";
4027
+ input.value = "";
4028
+ $("tagDlgOk").textContent = "创建标签";
4029
+ rerenderTags();
4030
+ wdDiffCache.clear();
4031
+ reloadGraphLight();
4032
+ } else {
4033
+ finish(true);
4034
+ wdDiffCache.clear();
4035
+ await reloadGraphLight();
4036
+ }
4037
+ };
4038
+ $("tagDlgOk").onclick = doOk;
4039
+ $("tagDlgOk").textContent = "创建标签";
4040
+ $("tagDlgCancel").onclick = () => finish(false);
4041
+ d.classList.add("show");
4042
+ input.focus();
4043
+ input.addEventListener("keydown", (ev) => {
4044
+ if (ev.key === "Enter" && input.value.trim()) doOk();
4045
+ });
4046
+ });
4047
+ }
4048
+ $("tagDlg").addEventListener("click", (ev) => { if (ev.target === $("tagDlg")) $("tagDlgCancel").click(); });
4049
+
4050
+ /* ---- branch dialog (write: lightweight branch from commit) ---- */
4051
+ function openBranchDlg(hash) {
4052
+ return new Promise((resolve) => {
4053
+ const d = $("branchDlg");
4054
+ const body = $("branchDlgBody");
4055
+ body.innerHTML = "";
4056
+ const p = document.createElement("div");
4057
+ p.innerHTML = "在提交 <b>" + short7(hash) + "</b> 处创建一个新分支(不会切换、不会推送远程):";
4058
+ body.appendChild(p);
4059
+ const input = document.createElement("input");
4060
+ input.type = "text";
4061
+ input.placeholder = "分支名,例如 feature/login";
4062
+ input.style.cssText = "width:100%;margin-top:10px;padding:8px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--text);font-size:12.5px;outline:none";
4063
+ body.appendChild(input);
4064
+ const finish = (v) => {
4065
+ d.classList.remove("show");
4066
+ $("branchDlgOk").onclick = null;
4067
+ $("branchDlgCancel").onclick = null;
4068
+ resolve(v);
4069
+ };
4070
+ $("branchDlgOk").onclick = async () => {
4071
+ if (!input.value.trim()) {
4072
+ input.style.borderColor = "var(--red)";
4073
+ input.focus();
4074
+ return;
4075
+ }
4076
+ finish(true);
4077
+ const res = await api("gitop", { action: "branch", name: input.value.trim(), hash });
4078
+ if (!res.ok) { alert("创建分支失败: " + (res.error || "?")); return; }
4079
+ wdDiffCache.clear();
4080
+ await reloadGraphLight();
4081
+ };
4082
+ $("branchDlgCancel").onclick = () => finish(false);
4083
+ d.classList.add("show");
4084
+ input.focus();
4085
+ input.addEventListener("keydown", (ev) => {
4086
+ if (ev.key === "Enter" && input.value.trim()) $("branchDlgOk").click();
4087
+ });
4088
+ });
4089
+ }
4090
+ $("branchDlg").addEventListener("click", (ev) => { if (ev.target === $("branchDlg")) $("branchDlgCancel").click(); });
4091
+
4092
+ /* ---- open commit page on GitHub (via origin remote URL) ---- */
4093
+ async function openGithubCommit(hash) {
4094
+ const res = await api("remoteinfo");
4095
+ if (!res.ok || !res.web) { alert("未配置远程仓库(origin),无法打开 GitHub 提交页"); return; }
4096
+ window.open(res.web + "/commit/" + hash, "_blank");
4097
+ }
4098
+
4099
+ /* ---- shortcut panel (Ctrl+/) ---- */
4100
+ const SHORTCUTS = [
4101
+ ["↑ / ↓ 或 j / k", "从当前选中(含鼠标点选的)向上/下切换提交,并同步打开详情"],
4102
+ ["鼠标点击", "选中提交、打开详情;之后 ↑/↓ 从这里继续"],
4103
+ ["Enter", "重新打开当前选中提交的详情(方向键已自动打开,按习惯保留)"],
4104
+ ["Ctrl+C", "复制提交哈希"],
4105
+ ["Esc", "关闭弹窗 / 详情面板"],
4106
+ ["Ctrl+/", "打开 / 关闭本快捷键面板"],
4107
+ ["Ctrl+F", "聚焦搜索框"],
4108
+ ["Ctrl+B", "折叠 / 展开分支分组"],
4109
+ ["拖动详情分隔条", "调整提交详情面板宽度(双击恢复默认)"],
4110
+ ["双击提交", "复制提交哈希"],
4111
+ ];
4112
+ function fillShortcuts() {
4113
+ const box = $("shortcutDlgBody");
4114
+ if (box.dataset.filled) return;
4115
+ box.dataset.filled = "1";
4116
+ for (const [key, desc] of SHORTCUTS) {
4117
+ const row = document.createElement("div");
4118
+ row.className = "sc-row";
4119
+ const k = document.createElement("span");
4120
+ k.className = "sc-key";
4121
+ k.textContent = key;
4122
+ const d = document.createElement("span");
4123
+ d.textContent = desc;
4124
+ row.appendChild(k);
4125
+ row.appendChild(d);
4126
+ box.appendChild(row);
4127
+ }
4128
+ }
4129
+ function toggleShortcutDlg(force) {
4130
+ const d = $("shortcutDlg");
4131
+ const show = force !== undefined ? force : !d.classList.contains("show");
4132
+ if (show) fillShortcuts();
4133
+ d.classList.toggle("show", show);
4134
+ }
4135
+ $("shortcutDlgClose").addEventListener("click", () => toggleShortcutDlg(false));
4136
+ $("shortcutDlg").addEventListener("click", (ev) => { if (ev.target === $("shortcutDlg")) toggleShortcutDlg(false); });
4137
+
4138
+ /* ---- scroll-driven pagination ------------------------------------------------
4139
+ * #graphWrap is the scroll container (it has overflow:auto), so the listener goes
4140
+ * there — a window scroll listener would never fire inside the plugin iframe.
4141
+ * maybeAutoLoad() is throttled through requestAnimationFrame so a fast scroll
4142
+ * cannot queue multiple pages. */
4143
+ $("graphWrap").addEventListener("scroll", maybeAutoLoad, { passive: true });
4144
+
4145
+ /* ---- release the response cache when the plugin closes ---------------------
4146
+ * The cache lives in plain Maps owned by this document, so tearing the frame
4147
+ * down (tab close, page reload, plugin unmount) already frees it. `pagehide`
4148
+ * is fired for both unload and bfcache transitions; clearing here also matters
4149
+ * for the bfcache case, where the Maps would otherwise be resurrected with the
4150
+ * page and could hold data from a since-changed repository. */
4151
+ window.addEventListener("pagehide", clearResponseCache);
4152
+ window.addEventListener("beforeunload", clearResponseCache);
4153
+
4154
+ /* ---- auto refresh: poll workstatus + head while the page is visible ---- */
4155
+ let AUTO_REFRESHING = false;
4156
+ /* Last ref-tip signature seen by the auto-refresh tick. null = not sampled yet.
4157
+ * Reset to null after a full reload so the next tick just re-baselines. */
4158
+ let REFS_SIG = null;
4159
+ /* Does this server understand the `head` op? Detected once, so a not-yet-restarted
4160
+ * host falls back to the old graph probe instead of 400-ing on every tick. */
4161
+ let HEAD_OP_OK = true;
4162
+ /* Pre-`head` change probe (kept for hosts that have not been restarted yet). */
4163
+ async function pollGraphFallback() {
4164
+ const lg = await api("graph", { limit: 1, skip: 0 });
4165
+ if (lg.ok && lg.commits && lg.commits.length) {
4166
+ const newest = lg.commits[0].hash;
4167
+ const old = COMMITS.length ? COMMITS[0].hash : null;
4168
+ if (newest !== old || (lg.total || 0) !== TOTAL) await reloadGraphLight();
4169
+ }
4170
+ }
4171
+ async function autoRefreshTick() {
4172
+ if (document.hidden) return; // only refresh when the tab is visible
4173
+ if (AUTO_REFRESHING) return;
4174
+ if (!REPO) return; // empty state: no repo to poll (avoids 400 noise)
4175
+ AUTO_REFRESHING = true;
4176
+ try {
4177
+ // Working tree changes (cheap).
4178
+ const ws = await api("workstatus");
4179
+ if (ws.ok) {
4180
+ const before = JSON.stringify(WORKSTATUS ? { branch: WORKSTATUS.branch, counts: WORKSTATUS.counts } : null);
4181
+ const after = JSON.stringify({ branch: ws.branch, counts: ws.counts });
4182
+ if (before !== after) {
4183
+ wdDiffCache.clear();
4184
+ WORKSTATUS = ws;
4185
+ refreshWorkdir();
4186
+ renderStats();
4187
+ }
4188
+ }
4189
+ // New commits / branch moves: compare a lightweight ref-tip signature.
4190
+ // The old code called graph {limit:1, skip:0}, which still ran three git
4191
+ // processes (symbolic-ref + rev-list --count + log) — ~670ms per tick.
4192
+ // The `head` op answers the same question with a single for-each-ref.
4193
+ // A stale server (plugin not restarted yet) answers unknown-op; remember
4194
+ // that once and fall back, rather than 400-ing on every tick.
4195
+ if (HEAD_OP_OK) {
4196
+ const g = await api("head");
4197
+ if (g.ok) {
4198
+ const sig = g.sig || "";
4199
+ if (REFS_SIG === null) {
4200
+ REFS_SIG = sig; // first tick: just remember baseline
4201
+ } else if (sig !== REFS_SIG) {
4202
+ REFS_SIG = sig;
4203
+ await reloadGraphLight();
4204
+ }
4205
+ } else if (g.error === "unknown-op") {
4206
+ HEAD_OP_OK = false; // old host: stop asking, use the fallback
4207
+ await pollGraphFallback();
4208
+ }
4209
+ } else {
4210
+ await pollGraphFallback();
4211
+ }
4212
+ } catch { /* ignore transient failures */ }
4213
+ finally { AUTO_REFRESHING = false; }
4214
+ }
4215
+ setInterval(autoRefreshTick, 5000);
4216
+ /* Separate cadence: following the session's working copy is a different concern
4217
+ * from refreshing the current repo, and it must not be blocked by the
4218
+ * auto-refresh guard (a reload in flight would otherwise starve it). */
4219
+ setInterval(checkSessionWorkspace, 3000);
4220
+
4221
+ /* ---- keyboard navigation ----
4222
+ * The list is read back from the DOM rather than recomputed from buildRows().
4223
+ * buildRows() only knows about branch folding; render() additionally drops rows
4224
+ * via passesFilters() and the legend highlight. Recomputing here meant the
4225
+ * cursor could be moved onto a commit that had no row on screen (search filter
4226
+ * active, or a branch highlighted via the legend) — the arrow key appeared to
4227
+ * do nothing, or jumped two commits at once. Whatever is in the DOM *is* what
4228
+ * the user can see and step through. */
4229
+ function kbdVisibleList() {
4230
+ return [...$("commitList").querySelectorAll(".cl-row")].map((el) => el.dataset.hash);
4231
+ }
4232
+ window.addEventListener("keydown", (ev) => {
4233
+ if (ev.key === "Escape") {
4234
+ if ($("pickdirDlg").classList.contains("show")) { $("pdClose").click(); return; }
4235
+ if ($("dateMenu").classList.contains("show")) {
4236
+ // Escape discards the draft instead of applying it — the one place a
4237
+ // half-made range (a start with no end) can be abandoned.
4238
+ setDateMenuOpen(false);
4239
+ fillDateInputs();
4240
+ return;
4241
+ }
4242
+ // The two dropdowns had no Escape path at all: once open, only a mouse
4243
+ // click could dismiss them, so a keyboard user was stuck with the panel
4244
+ // covering the list (the date panel owns the arrow keys while open).
4245
+ if ($("filterMenu").classList.contains("show")) { setFilterMenuOpen(false); return; }
4246
+ const openWd = document.querySelector("#wdPanel .wd-menu.show");
4247
+ if (openWd) { setWdMenuOpen(openWd, false); return; }
4248
+ if ($("settingsDlg").classList.contains("show")) { toggleSettings(false); return; }
4249
+ if ($("shortcutDlg").classList.contains("show")) { toggleShortcutDlg(false); return; }
4250
+ if ($("ggDlg").classList.contains("show")) { $("ggDlgCancel").click(); return; }
4251
+ if ($("commitDlg").classList.contains("show")) { $("commitDlgCancel").click(); return; }
4252
+ if ($("tagDlg").classList.contains("show")) { $("tagDlgCancel").click(); return; }
4253
+ if ($("branchDlg").classList.contains("show")) { $("branchDlgCancel").click(); return; }
4254
+ if ($("ctxMenu").classList.contains("show")) { hideContextMenu(); return; }
4255
+ if ($("detail").classList.contains("open")) { $("dClose").click(); return; }
4256
+ }
4257
+ const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(ev.target && ev.target.tagName || "");
4258
+ // Ctrl+/ stays live inside a modal — it is the documented way to toggle the
4259
+ // shortcut panel, and that panel IS a modal.
4260
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey && ev.key === "/") {
4261
+ ev.preventDefault();
4262
+ toggleSettings(false);
4263
+ toggleShortcutDlg();
4264
+ return;
4265
+ }
4266
+ // A modal owns the keyboard: without this, ↑/↓ would scroll the commit list
4267
+ // and open commit details behind the settings dialog while the user is only
4268
+ // trying to reach its buttons.
4269
+ if ($("settingsDlg").classList.contains("show") || $("shortcutDlg").classList.contains("show")) return;
4270
+ // The date popover owns the arrow keys while it is open: the grid has its own
4271
+ // handler, and without this the same ↑/↓ would also step the commit selection
4272
+ // behind the panel (the list is still visible next to it).
4273
+ if ($("dateMenu").classList.contains("show")) return;
4274
+ // Ctrl+ shortcuts (global; not while typing in an input)
4275
+ if ((ev.ctrlKey || ev.metaKey) && !ev.altKey) {
4276
+ if (!typing) {
4277
+ if (ev.key === "f" || ev.key === "F") {
4278
+ ev.preventDefault();
4279
+ const sb = $("searchBox");
4280
+ if (sb) { sb.focus(); sb.select(); }
4281
+ return;
4282
+ }
4283
+ if (ev.key === "b" || ev.key === "B") {
4284
+ ev.preventDefault();
4285
+ toggleBranchGroups();
4286
+ return;
4287
+ }
4288
+ }
4289
+ }
4290
+ // skip when typing in the search box
4291
+ if (ev.target === $("searchBox")) return;
4292
+ const list = kbdVisibleList();
4293
+ if (!list.length) return;
4294
+ // Arrow keys move AND open the commit in one step. A cursor that only moved a
4295
+ // dashed outline forced a pointless Enter — in a history browser the thing you
4296
+ // are stepping through is the detail, not a selection to be confirmed.
4297
+ //
4298
+ // The step always starts from SELECTED, which the mouse writes too. Clicking
4299
+ // row 5 and pressing ↓ now lands on row 6, whatever the list length is and
4300
+ // whether or not a filter is active. `list.indexOf` may return -1 (the
4301
+ // selected commit is not currently rendered — filtered out, inside a
4302
+ // collapsed branch, or beyond the loaded page); in that case ↓ starts at the
4303
+ // top and ↑ at the bottom, so the key always produces a visible move rather
4304
+ // than doing nothing.
4305
+ //
4306
+ // selectCommit() renders and scrolls, so no render() call is needed here.
4307
+ const cur = SELECTED === null ? -1 : list.indexOf(SELECTED);
4308
+ if (ev.key === "ArrowDown" || ev.key === "j") {
4309
+ ev.preventDefault();
4310
+ const i = cur < 0 ? 0 : Math.min(list.length - 1, cur + 1);
4311
+ selectCommit(list[i], true);
4312
+ } else if (ev.key === "ArrowUp" || ev.key === "k") {
4313
+ ev.preventDefault();
4314
+ const i = cur < 0 ? list.length - 1 : Math.max(0, cur - 1);
4315
+ selectCommit(list[i], true);
4316
+ } else if (ev.key === "Enter") {
4317
+ // Kept as a no-op confirmation of the current row: harmless, and some people
4318
+ // still press it out of habit after arriving with the mouse.
4319
+ if (SELECTED) {
4320
+ ev.preventDefault();
4321
+ selectCommit(SELECTED);
4322
+ }
4323
+ }
4324
+ });
4325
+ window.addEventListener("dblclick", (ev) => {
4326
+ const node = ev.target.closest && ev.target.closest(".gg-node");
4327
+ if (!node) return;
4328
+ const hash = node.dataset.hash;
4329
+ navigator.clipboard?.writeText(hash).catch(() => {});
4330
+ });
4331
+
4332
+ /* Reflect the restored date filter in the UI before the first paint, so a saved
4333
+ * range is visible (button highlighted, inputs filled) rather than silent. */
4334
+ fillDateInputs();
4335
+ $("btnDate").classList.toggle("on", Boolean(DATE_FROM || DATE_TO));
4336
+ syncFlatBtn();
4337
+ followParentTheme();
4338
+ applyTheme();
4339
+ /* Prove the stylesheet really applied before dropping the boot-failure notice.
4340
+ * That notice covers one specific mismatch: index.html is read from disk on
4341
+ * every request, but the routes for app.css/app.js only exist in the newer
4342
+ * index.js — so after an update and before a restart both assets 404 and the
4343
+ * page shows an unstyled, inert skeleton. Testing a computed value is the only
4344
+ * honest check: `document.styleSheets.length` also counts a sheet that failed
4345
+ * to parse, and a 404 HTML body can get counted as a sheet by some browsers. */
4346
+ (function clearBootNotice() {
4347
+ /* Read a sentinel that only app.css defines. Testing colours was wrong: the
4348
+ gradient themes set --bg to a gradient on <html>, so <body> stays
4349
+ transparent there and every colour-based probe concluded "no stylesheet"
4350
+ — which made this notice appear on perfectly healthy loads in 8 of the 11
4351
+ themes. A custom property is either defined by the sheet or it is not. */
4352
+ const ready = getComputedStyle(document.documentElement)
4353
+ .getPropertyValue("--gg-page-ready").trim();
4354
+ const notice = $("bootFail");
4355
+ if (ready === "1") {
4356
+ if (notice) notice.remove();
4357
+ return;
4358
+ }
4359
+ /* Stylesheet missing: keep the notice, it is the entire message to the user.
4360
+ * It also needs re-pinning. The rule that makes it `position: fixed` lives in
4361
+ * that same missing stylesheet, so here it sits in normal flow — and this
4362
+ * script still runs the app, whose selectCommit()/detail scrolling carries the
4363
+ * notice off-screen (measured: scrollY 2312, notice top -2304, invisible).
4364
+ * Re-pin it through CSSOM: a `style` *attribute* would be blocked by
4365
+ * `style-src 'self'`, but CSSOM writes are not subject to that directive. */
4366
+ if (!notice) return;
4367
+ Object.assign(notice.style, {
4368
+ position: "fixed", top: "12px", left: "12px", right: "12px",
4369
+ zIndex: "2147483647", opacity: "1", margin: "0",
4370
+ padding: "14px 16px", borderRadius: "8px", boxSizing: "border-box",
4371
+ background: "#fffbe6", color: "#1c2430", border: "1px solid #d29922",
4372
+ });
4373
+ })();
4374
+ load();