@shiqyka/dsh-git-graph 0.0.0-stage → 0.26.2
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/LICENSE +21 -0
- package/README.md +139 -2
- package/README_EN.md +140 -0
- package/client.js +64 -0
- package/cordis.patch.yml +21 -0
- package/index.js +1545 -0
- package/package.json +64 -4
- package/web/app.css +1194 -0
- package/web/app.js +4374 -0
- package/web/index.html +258 -0
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, "&")
|
|
2228
|
+
.replace(/</g, "<")
|
|
2229
|
+
.replace(/>/g, ">")
|
|
2230
|
+
.replace(/"/g, """)
|
|
2231
|
+
.replace(/'/g, "'");
|
|
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();
|