openclacky 1.5.21 → 1.5.22
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/lib/clacky/billing/platform_billing.rb +3 -0
- data/lib/clacky/default_extensions/git/panels/git/view.js +1911 -398
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +199 -34
- data/lib/clacky/providers.rb +43 -17
- data/lib/clacky/search_config.rb +33 -11
- data/lib/clacky/server/git_panel.rb +360 -34
- data/lib/clacky/server/http_server.rb +145 -47
- data/lib/clacky/server/preview.rb +118 -17
- data/lib/clacky/server/scheduler.rb +81 -2
- data/lib/clacky/server/web_ui_controller.rb +35 -27
- data/lib/clacky/session_manager.rb +52 -5
- data/lib/clacky/utils/mac_app_detector.rb +8 -7
- data/lib/clacky/utils/model_pricing.rb +60 -2
- data/lib/clacky/utils/windows_app_detector.rb +3 -1
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +650 -96
- data/lib/clacky/web/app.js +59 -6
- data/lib/clacky/web/auth.js +1 -1
- data/lib/clacky/web/components/composer.js +107 -8
- data/lib/clacky/web/components/image-zoom.js +165 -0
- data/lib/clacky/web/components/model-picker.js +6 -4
- data/lib/clacky/web/components/notify.js +2 -2
- data/lib/clacky/web/core/aside.js +60 -3
- data/lib/clacky/web/features/billing/view.js +2 -2
- data/lib/clacky/web/features/channels/view.js +5 -2
- data/lib/clacky/web/features/extensions/view.js +2 -2
- data/lib/clacky/web/features/new-session/store.js +79 -2
- data/lib/clacky/web/features/new-session/view.js +496 -58
- data/lib/clacky/web/features/skills/store.js +1 -1
- data/lib/clacky/web/features/tasks/view.js +162 -51
- data/lib/clacky/web/features/workspace/store.js +13 -6
- data/lib/clacky/web/features/workspace/view.js +153 -163
- data/lib/clacky/web/i18n.js +238 -56
- data/lib/clacky/web/index.html +173 -157
- data/lib/clacky/web/projects.js +37 -4
- data/lib/clacky/web/sessions.js +181 -105
- data/lib/clacky/web/settings.js +26 -8
- data/lib/clacky/web/skills.js +2 -2
- data/lib/clacky/web/theme.js +17 -2
- data/lib/clacky/web/utils.js +17 -2
- data/lib/clacky/web/weixin-qr.html +2 -1
- data/lib/clacky/web/ws-dispatcher.js +8 -29
- metadata +2 -1
|
@@ -1,108 +1,308 @@
|
|
|
1
|
-
// ── Official panel: changes (git, made friendly)
|
|
1
|
+
// ── Official panel: changes (git review, made friendly) ───────────────────
|
|
2
2
|
//
|
|
3
|
-
// "改动 / Changes": a
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// "改动 / Changes": a Codex-style review surface for what the AI changed.
|
|
4
|
+
// Left pane is a collapsible file tree with per-file +/- counts; right pane is
|
|
5
|
+
// one continuous diff stream in status order, with dual line numbers, syntax
|
|
6
|
+
// highlighting (hljs) and collapsed gaps between hunks.
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// to
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// (first click arms a red "Discard?" button, second click fires POST
|
|
13
|
-
// /git/restore) - the same pattern the Time Machine uses for its
|
|
14
|
-
// destructive switch, no blocking dialog needed.
|
|
8
|
+
// There is deliberately no commit button: the agent writes better commit
|
|
9
|
+
// messages than a timestamp prompt can, and the Time Machine already covers
|
|
10
|
+
// "go back to that state". The only write path is a guarded single-file
|
|
11
|
+
// revert (two-step inline confirm, same pattern as the Time Machine's
|
|
12
|
+
// destructive switch).
|
|
15
13
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// Native DOM + textContent on all git output (paths, branch, diff) so nothing
|
|
21
|
-
// can inject. tab.badge tracks the number of changed files.
|
|
14
|
+
// Git jargon stays hidden: no porcelain codes, no ahead/behind. Raw output is
|
|
15
|
+
// rendered with textContent everywhere except the hljs spans, which the
|
|
16
|
+
// highlighter escapes itself.
|
|
22
17
|
// ───────────────────────────────────────────────────────────────────────────
|
|
23
18
|
|
|
24
19
|
(() => {
|
|
25
20
|
if (!window.Clacky || !Clacky.ext) return;
|
|
26
21
|
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
const
|
|
22
|
+
const FULL_CONTEXT = 999999;
|
|
23
|
+
const MAX_RENDER_LINES = 8000;
|
|
24
|
+
// Whole-stream budget, not per file. Comparing against a long-lived branch
|
|
25
|
+
// can mean 900 files; laying all of them out at once locks the tab up, so
|
|
26
|
+
// files past the budget render a one-line placeholder that loads on click.
|
|
27
|
+
const VIEW_BUDGET_LINES = 3000;
|
|
28
|
+
const SIDE_WIDTH_KEY = "clacky.git.sideWidth";
|
|
29
|
+
const LAYOUT_KEY = "clacky.git.diffLayout";
|
|
30
|
+
const TREE_KEY = "clacky.git.treeVisible";
|
|
31
|
+
const WRAP_KEY = "clacky.git.diffWrap";
|
|
32
|
+
// Below this diff-pane width two columns squeeze the code too hard, so the
|
|
33
|
+
// "auto" layout falls back to the single-column stream.
|
|
34
|
+
const SPLIT_MIN_WIDTH = 640;
|
|
35
|
+
const LAYOUT_MODES = ["auto", "split", "unified"];
|
|
36
|
+
const SIDE_MIN_W = 140;
|
|
37
|
+
const DIFF_MIN_W = 200;
|
|
38
|
+
const LANG_BY_EXT = {
|
|
39
|
+
js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "javascript",
|
|
40
|
+
ts: "typescript", tsx: "typescript", rb: "ruby", rake: "ruby", gemspec: "ruby",
|
|
41
|
+
py: "python", go: "go", rs: "rust", java: "java", kt: "kotlin", swift: "swift",
|
|
42
|
+
c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", m: "objectivec", mm: "objectivec",
|
|
43
|
+
cs: "csharp", php: "php", sh: "bash", bash: "bash", zsh: "bash",
|
|
44
|
+
yml: "yaml", yaml: "yaml", json: "json", md: "markdown", markdown: "markdown",
|
|
45
|
+
html: "xml", erb: "xml", vue: "xml", htm: "xml", xml: "xml",
|
|
46
|
+
css: "css", scss: "scss", less: "less", sql: "sql",
|
|
47
|
+
toml: "ini", ini: "ini", conf: "ini", cfg: "ini", env: "ini",
|
|
48
|
+
diff: "diff", patch: "diff", dockerfile: "dockerfile",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ICON_REFRESH = '<svg viewBox="0 0 24 24" width="15.6" height="15.6" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36L21 8"/><path d="M21 3v5h-5"/></svg>';
|
|
52
|
+
const ICON_COPY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
|
|
53
|
+
const ICON_UNFOLD = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"><path d="M8 9l4-4 4 4"/><path d="M16 15l-4 4-4-4"/></svg>';
|
|
54
|
+
const ICON_EXPAND_ALL = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M8.8 3.4v6.1"/><path d="M6.6 7.4l2.2 2.2 2.2-2.2"/><path d="M8.8 20.6v-6.1"/><path d="M6.6 16.6l2.2-2.2 2.2 2.2"/><path d="M15.2 8.5h6.1"/><path d="M15.2 15h3.3"/></svg>';
|
|
55
|
+
const ICON_COLLAPSE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 13l5-5 5 5"/><path d="M7 19l5-5 5 5"/></svg>';
|
|
56
|
+
const ICON_MORE = '<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><circle cx="5" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="19" cy="12" r="1.7"/></svg>';
|
|
57
|
+
const ICON_OPEN_WITH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4h6v6"/><path d="M20 4l-8 8"/><path d="M18 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5"/></svg>';
|
|
58
|
+
const ICON_REVERT = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-15-6.7L3 13"/></svg>';
|
|
59
|
+
const ICON_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
60
|
+
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12"/><path d="M7.5 11l4.5 4.5 4.5-4.5"/><path d="M5 20h14"/></svg>';
|
|
61
|
+
const ICON_ADD_TO_CHAT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h6"/><path d="M16 5h6"/><path d="M19 2v6"/></svg>';
|
|
62
|
+
const ICON_COPY_REL = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/><path d="M12 19l3-3"/></svg>';
|
|
63
|
+
const ICON_CHEVRON = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>';
|
|
64
|
+
const ICON_CARET_DOWN = '<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>';
|
|
65
|
+
const ICON_ARROW_RIGHT = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12h15"/><path d="M13 6l6 6-6 6"/></svg>';
|
|
66
|
+
const ICON_CHECK = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12.5l5 5L20 6.5"/></svg>';
|
|
67
|
+
const ICON_BRANCH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2.2"/><circle cx="6" cy="19" r="2.2"/><circle cx="18" cy="8" r="2.2"/><path d="M6 7.2v9.6"/><path d="M18 10.2c0 3.4-3 4.6-6 5.2"/></svg>';
|
|
68
|
+
const ICON_SEARCH = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M16 16l4.5 4.5"/></svg>';
|
|
69
|
+
// Toolbar companion to the small ICON_SEARCH above: the finder jumps between
|
|
70
|
+
// files, so the button gets a fuller magnifier that matches the other icons.
|
|
71
|
+
const ICON_FIND = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.95" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7.6"/><path d="m21 21-5-5"/></svg>';
|
|
72
|
+
const ICON_WRAP = '<svg viewBox="0 0 24 24" width="15.5" height="15.5" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 6h17"/><path d="M3.5 12h13.5a3 3 0 1 1 0 6h-4"/><path d="m15.5 15.8-2.2 2.2 2.2 2.2"/><path d="M3.5 18h4.5"/></svg>';
|
|
73
|
+
// Layout icon mirrors the actual panes with diff-coloured swatches, so the
|
|
74
|
+
// mode reads at a glance instead of from an abstract frame split by a line.
|
|
75
|
+
const ICON_LAYOUT_AUTO = '<svg viewBox="0 0 24 24" width="18.5" height="18.5"><clipPath id="cg-lay-auto"><rect x="3.4" y="4.4" width="17.2" height="15.2" rx="2.6"/></clipPath><g clip-path="url(#cg-lay-auto)"><rect x="3.4" y="4.4" width="17.2" height="15.2" fill="var(--cg-swatch-add)"/><polygon points="3.4,4.4 20.6,4.4 3.4,19.6" fill="var(--cg-swatch-del)"/></g><rect x="3.4" y="4.4" width="17.2" height="15.2" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/></svg>';
|
|
76
|
+
const ICON_LAYOUT_SPLIT = '<svg viewBox="0 0 24 24" width="18.5" height="18.5"><rect x="3.4" y="4.4" width="17.2" height="15.2" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="5.6" y="6.6" width="5.5" height="10.8" rx="1.4" fill="var(--cg-swatch-del)"/><rect x="12.9" y="6.6" width="5.5" height="10.8" rx="1.4" fill="var(--cg-swatch-add)"/></svg>';
|
|
77
|
+
const ICON_LAYOUT_UNIFIED = '<svg viewBox="0 0 24 24" width="18.5" height="18.5"><rect x="3.4" y="4.4" width="17.2" height="15.2" rx="2.6" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="5.6" y="6.5" width="12.8" height="4.3" rx="1.4" fill="var(--cg-swatch-del)"/><rect x="5.6" y="13.2" width="12.8" height="4.3" rx="1.4" fill="var(--cg-swatch-add)"/></svg>';
|
|
78
|
+
// Two stacked panes: the file tree in front of the diff stream.
|
|
79
|
+
const ICON_TREE = '<svg viewBox="0 0 24 24" width="17.6" height="17.6" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><rect x="3" y="7" width="13.8" height="12" rx="2.4"/><path d="M6.9 7V5.5A2.5 2.5 0 0 1 9.4 3h9.1A2.5 2.5 0 0 1 21 5.5v9.1a2.5 2.5 0 0 1-2.5 2.5h-1.7"/></svg>';
|
|
80
|
+
|
|
34
81
|
const t = (k, fallback) => {
|
|
35
82
|
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
36
83
|
return (v && v !== k) ? v : fallback;
|
|
37
84
|
};
|
|
85
|
+
const fill = (text, vars) => {
|
|
86
|
+
let out = text;
|
|
87
|
+
Object.keys(vars).forEach((k) => { out = out.split(`{{${k}}}`).join(String(vars[k])); });
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const LAYOUT_LABELS = {
|
|
92
|
+
auto: () => t("changes.layout.auto", "自动"),
|
|
93
|
+
split: () => t("changes.layout.split", "并排"),
|
|
94
|
+
unified: () => t("changes.layout.unified", "行内"),
|
|
95
|
+
};
|
|
38
96
|
|
|
39
97
|
if (!document.getElementById("changes-panel-style")) {
|
|
40
98
|
const style = document.createElement("style");
|
|
41
99
|
style.id = "changes-panel-style";
|
|
42
100
|
style.textContent = `
|
|
43
|
-
.changes-root { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
.
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
.
|
|
50
|
-
.
|
|
101
|
+
.changes-root { container-type: inline-size; --cg-swatch-add: #6dcf90; --cg-swatch-del: #f28f95; display: flex; flex-direction: column; flex: 1; min-height: 0; background: var(--color-bg-primary); }
|
|
102
|
+
.cg-bar { flex: none; display: flex; align-items: center; gap: 8px; padding: 0 8px 0 12px; height: 44px; border-bottom: 1px solid var(--color-border-primary); }
|
|
103
|
+
.cg-bar-stats { display: flex; align-items: center; gap: 7px; min-width: 0; font-family: ui-monospace, monospace; font-size: 12px; font-weight: 600; }
|
|
104
|
+
.cg-bar-add { color: var(--color-success, #2f9e44); }
|
|
105
|
+
.cg-bar-del { color: var(--color-error, #c92a2a); }
|
|
106
|
+
.cg-scope { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 9px 0 10px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-pill); background: transparent; color: var(--color-text-secondary); font-family: inherit; font-size: 12.5px; cursor: pointer; }
|
|
107
|
+
.cg-scope:hover, .cg-scope.is-open { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
108
|
+
.cg-scope-label { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
109
|
+
.cg-scope > .cg-caret { display: inline-flex; opacity: 0.65; }
|
|
110
|
+
/* The scope the diff is pinned to (a commit subject, say) rides inside
|
|
111
|
+
the pill; it is long, so it truncates before the +/- counts do. */
|
|
112
|
+
.cg-scope-subject { flex: 0 1 auto; min-width: 0; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-tertiary); font-weight: 400; }
|
|
113
|
+
.cg-scope > .cg-bar-add, .cg-scope > .cg-bar-del { flex: none; }
|
|
114
|
+
.cg-compare { flex: 0 1 auto; min-width: 0; font-size: 12.5px; }
|
|
115
|
+
.cg-compare .cg-scope-head, .cg-compare .cg-scope-base { min-width: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
116
|
+
.cg-compare .cg-compare-arrow { display: inline-flex; color: var(--color-text-tertiary); opacity: 0.8; }
|
|
117
|
+
.cg-scope-menu { position: fixed; z-index: 80; min-width: 196px; padding: 7px; border: 1px solid var(--color-border-primary); border-radius: 12px; background: var(--color-bg-primary); box-shadow: 0 14px 38px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06); }
|
|
118
|
+
.cg-scope-item { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px; border-radius: 7px; font-size: 13px; color: var(--color-text-primary); cursor: pointer; white-space: nowrap; }
|
|
119
|
+
.cg-scope-item:hover, .cg-scope-item.is-open { background: var(--color-bg-hover); }
|
|
120
|
+
.cg-scope-item .cg-scope-check { margin-left: auto; display: inline-flex; opacity: 0; color: var(--color-text-primary); }
|
|
121
|
+
.cg-scope-item.is-checked .cg-scope-check { opacity: 1; }
|
|
122
|
+
.cg-scope-item .cg-scope-arrow { margin-left: auto; display: inline-flex; color: var(--color-text-tertiary); }
|
|
123
|
+
.cg-scope-sep { height: 1px; margin: 6px 8px; background: var(--color-border-secondary); }
|
|
124
|
+
.cg-scope-sub { position: fixed; z-index: 81; width: 340px; max-height: 420px; display: flex; flex-direction: column; padding: 6px; border: 1px solid var(--color-border-primary); border-radius: 14px; background: var(--color-bg-primary); box-shadow: 0 18px 46px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.06); }
|
|
125
|
+
.cg-scope-search { flex: none; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 8px; color: var(--color-text-tertiary); }
|
|
126
|
+
.cg-scope-search input { flex: 1; min-width: 0; border: none; background: transparent; color: var(--color-text-primary); font-family: inherit; font-size: 13px; outline: none; }
|
|
127
|
+
.cg-scope-group { flex: none; padding: 8px 10px 6px; font-size: 11.5px; color: var(--color-text-tertiary); }
|
|
128
|
+
.cg-scope-list { flex: 1; min-height: 0; overflow: auto; }
|
|
129
|
+
.cg-scope-branch { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px; border-radius: 7px; font-size: 13px; color: var(--color-text-primary); cursor: pointer; }
|
|
130
|
+
.cg-scope-branch:hover, .cg-scope-branch.is-active { background: var(--color-bg-hover); }
|
|
131
|
+
.cg-scope-branch .cg-scope-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
132
|
+
.cg-scope-branch .cg-scope-remote { flex: none; font-size: 10.5px; color: var(--color-text-tertiary); }
|
|
133
|
+
.cg-scope-branch .cg-scope-check { display: inline-flex; opacity: 0; color: var(--color-text-primary); }
|
|
134
|
+
.cg-scope-branch.is-checked .cg-scope-check { opacity: 1; }
|
|
135
|
+
.cg-scope-commit { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px; border-radius: 7px; font-size: 13px; color: var(--color-text-primary); cursor: pointer; }
|
|
136
|
+
.cg-scope-commit:hover, .cg-scope-commit.is-checked { background: var(--color-bg-hover); }
|
|
137
|
+
.cg-scope-commit .cg-scope-subject { flex: 1; min-width: 0; max-width: none; color: var(--color-text-primary); }
|
|
138
|
+
.cg-scope-commit .cg-scope-sha { flex: none; font-family: ui-monospace, monospace; font-size: 11px; color: var(--color-text-tertiary); }
|
|
139
|
+
.cg-scope-commit .cg-scope-check { flex: none; display: inline-flex; opacity: 0; color: var(--color-text-primary); }
|
|
140
|
+
.cg-scope-commit.is-checked .cg-scope-check { opacity: 1; }
|
|
141
|
+
.cg-scope-empty { padding: 10px; font-size: 12px; color: var(--color-text-tertiary); }
|
|
142
|
+
/* Jump-to-file finder: a fixed panel under the toolbar button, sized so
|
|
143
|
+
a long path still shows its basename before the directories. */
|
|
144
|
+
.cg-find { position: fixed; z-index: 82; width: 420px; max-width: calc(100vw - 16px); display: flex; flex-direction: column; padding: 6px; border: 1px solid var(--color-border-primary); border-radius: 14px; background: var(--color-bg-primary); box-shadow: 0 18px 46px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.06); }
|
|
145
|
+
.cg-find-search { flex: none; display: flex; align-items: center; gap: 8px; height: 34px; padding: 0 8px; color: var(--color-text-tertiary); }
|
|
146
|
+
.cg-find-search input { flex: 1; min-width: 0; border: none; background: transparent; color: var(--color-text-primary); font-family: inherit; font-size: 13px; outline: none; }
|
|
147
|
+
.cg-find-list { flex: 1; min-height: 0; max-height: 320px; overflow: auto; }
|
|
148
|
+
.cg-find-item { display: flex; align-items: baseline; gap: 8px; padding: 5px 10px; border-radius: 7px; font-size: 13px; color: var(--color-text-primary); cursor: pointer; }
|
|
149
|
+
.cg-find-item.is-active { background: var(--color-bg-hover); }
|
|
150
|
+
.cg-find-name { flex: none; max-width: 62%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
151
|
+
.cg-find-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-tertiary); font-family: ui-monospace, monospace; font-size: 11.5px; }
|
|
152
|
+
.cg-find-empty { padding: 10px; font-size: 12px; color: var(--color-text-tertiary); }
|
|
153
|
+
.cg-bar-spacer { flex: 1; }
|
|
154
|
+
.cg-tools { flex: none; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 3px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-pill); }
|
|
155
|
+
.cg-icon-btn { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; padding: 0; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); cursor: pointer; }
|
|
156
|
+
.cg-icon-btn:hover { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
157
|
+
.cg-icon-btn.is-on { background: color-mix(in srgb, var(--color-text-primary) 8%, transparent); color: var(--color-text-primary); }
|
|
158
|
+
/* The pill only outlines the group; the buttons keep their own hover and
|
|
159
|
+
on-chip fills, exactly like the scope pill on the left of the bar. */
|
|
160
|
+
.cg-tools .cg-icon-btn { width: 24px; height: 24px; border-radius: 50%; }
|
|
51
161
|
.cg-body { flex: 1; min-height: 0; display: flex; }
|
|
52
|
-
.cg-
|
|
53
|
-
.
|
|
54
|
-
.cg-
|
|
55
|
-
.cg-
|
|
56
|
-
.
|
|
57
|
-
.cg-
|
|
58
|
-
.changes-
|
|
59
|
-
.
|
|
60
|
-
.
|
|
61
|
-
.
|
|
62
|
-
.
|
|
63
|
-
.
|
|
64
|
-
.
|
|
65
|
-
.
|
|
66
|
-
.
|
|
67
|
-
.
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
.cg-
|
|
73
|
-
.
|
|
74
|
-
.cg-
|
|
75
|
-
.cg-
|
|
76
|
-
.cg-
|
|
77
|
-
.cg-
|
|
78
|
-
.cg-
|
|
79
|
-
.cg-
|
|
80
|
-
.cg-
|
|
81
|
-
.cg-
|
|
82
|
-
.cg-
|
|
83
|
-
.cg-
|
|
84
|
-
.cg-
|
|
85
|
-
.cg-
|
|
86
|
-
|
|
87
|
-
.cg-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.
|
|
92
|
-
.
|
|
93
|
-
.
|
|
94
|
-
.
|
|
95
|
-
.
|
|
96
|
-
.
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
162
|
+
.cg-side { flex: none; width: 264px; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--color-border-primary); background: var(--color-bg-secondary, transparent); }
|
|
163
|
+
.cg-resizer { flex: none; position: relative; z-index: 3; width: 7px; margin-right: -7px; cursor: col-resize; touch-action: none; }
|
|
164
|
+
.cg-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 1px; background: transparent; transition: background-color var(--transition-base); }
|
|
165
|
+
.cg-resizer:hover::after, .cg-resizer.is-active::after { background: var(--color-accent-primary); }
|
|
166
|
+
.changes-root.is-resizing { cursor: col-resize; user-select: none; }
|
|
167
|
+
.changes-root.is-resizing .cg-stream { pointer-events: none; }
|
|
168
|
+
.changes-root.is-tree-hidden .cg-side,
|
|
169
|
+
.changes-root.is-tree-hidden .cg-resizer { display: none; }
|
|
170
|
+
.cg-side-head { flex: none; padding: 10px; border-bottom: 1px solid var(--color-border-secondary); }
|
|
171
|
+
.cg-filter { width: 100%; box-sizing: border-box; height: 30px; padding: 0 10px; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); background: var(--color-bg-primary); color: var(--color-text-primary); font-family: inherit; font-size: 12.5px; outline: none; }
|
|
172
|
+
.cg-filter::placeholder { color: var(--color-text-tertiary); }
|
|
173
|
+
.cg-filter:focus { border-color: var(--color-accent-primary); }
|
|
174
|
+
.cg-tree { flex: 1; min-height: 0; overflow: auto; padding: 6px 6px 16px; }
|
|
175
|
+
.cg-dir-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; color: var(--color-text-secondary); font-size: 12.5px; user-select: none; }
|
|
176
|
+
.cg-dir-row:hover { background: var(--color-bg-hover); }
|
|
177
|
+
.cg-dir-row .cg-caret { flex: none; width: 12px; height: 12px; display: inline-flex; align-items: center; justify-content: center; transition: transform 0.12s ease; }
|
|
178
|
+
.cg-dir-row.closed .cg-caret { transform: rotate(0deg); }
|
|
179
|
+
.cg-dir-row:not(.closed) .cg-caret { transform: rotate(90deg); }
|
|
180
|
+
.cg-dir-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; }
|
|
181
|
+
.cg-icon { flex: none; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; color: var(--color-text-secondary); }
|
|
182
|
+
.cg-icon svg { display: block; }
|
|
183
|
+
.cg-file-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12.5px; }
|
|
184
|
+
.cg-file-row:hover { background: var(--color-bg-hover); }
|
|
185
|
+
.cg-file-row.active { background: var(--color-accent-soft); }
|
|
186
|
+
.cg-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; color: var(--color-text-primary); }
|
|
187
|
+
.cg-file-row .cg-tag { flex: none; font-size: 10px; font-weight: 700; letter-spacing: 0.02em; }
|
|
188
|
+
.cg-tag.add { color: var(--color-success, #2f9e44); }
|
|
189
|
+
.cg-tag.del { color: var(--color-error, #c92a2a); }
|
|
190
|
+
.cg-tag.mod { color: var(--color-accent-primary); }
|
|
191
|
+
.cg-file-stat { flex: none; display: inline-flex; gap: 4px; font-family: ui-monospace, monospace; font-size: 11px; }
|
|
192
|
+
.cg-file-stat .a { color: var(--color-success, #2f9e44); }
|
|
193
|
+
.cg-file-stat .d { color: var(--color-error, #c92a2a); }
|
|
194
|
+
.cg-main { flex: 1; min-width: 0; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
|
195
|
+
.cg-stream { flex: 1; min-height: 0; overflow: auto; position: relative; }
|
|
196
|
+
/* left:0 keeps the header pinned while a long line is scrolled sideways. */
|
|
197
|
+
.cg-file-head { position: sticky; top: 0; left: 0; z-index: 2; display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--color-bg-secondary, var(--color-bg-primary)); border-bottom: 1px solid var(--color-border-secondary); box-shadow: 0 -1px 0 var(--color-border-primary); cursor: pointer; }
|
|
198
|
+
/* The top rule is a shadow, not a border: a stuck header's rule must be
|
|
199
|
+
clipped by the scroll box, otherwise it doubles with the toolbar's
|
|
200
|
+
bottom border when the pane is scrolled to a file. */
|
|
201
|
+
.cg-file-head-path { flex: 1; min-width: 0; font-family: ui-monospace, monospace; font-size: 11.5px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
202
|
+
.cg-file-head-stat { flex: none; display: inline-flex; gap: 5px; font-family: ui-monospace, monospace; font-size: 11px; }
|
|
203
|
+
.cg-file-head-stat .a { color: var(--color-success, #2f9e44); }
|
|
204
|
+
.cg-file-head-stat .d { color: var(--color-error, #c92a2a); }
|
|
205
|
+
.cg-revert { flex: none; display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 7px; border: none; border-radius: var(--radius-sm); background: transparent; color: var(--color-text-tertiary); font-size: 11px; cursor: pointer; opacity: 0; }
|
|
206
|
+
.cg-file:hover .cg-revert, .cg-revert.armed { opacity: 1; }
|
|
207
|
+
.cg-revert:hover { background: var(--color-error-bg); color: var(--color-error); }
|
|
208
|
+
.cg-revert.armed { background: var(--color-error, #c92a2a); color: #fff; font-weight: 600; }
|
|
209
|
+
.cg-head-more { flex: none; width: 22px; height: 22px; color: var(--color-text-secondary); }
|
|
210
|
+
.cg-head-more:hover, .cg-head-more.is-open { background: var(--color-bg-hover); color: var(--color-text-primary); }
|
|
211
|
+
.cg-fh-caret { flex: none; display: inline-flex; color: var(--color-text-tertiary); transform: rotate(90deg); transition: transform 0.12s ease; }
|
|
212
|
+
.cg-file.is-collapsed .cg-fh-caret { transform: none; }
|
|
213
|
+
.changes-root .cg-file.is-collapsed .cg-lines { display: none; }
|
|
214
|
+
.cg-lines { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11.5px; line-height: 1.55; padding-bottom: 2px; }
|
|
215
|
+
/* The add/del wash rides on a gradient rather than a background colour:
|
|
216
|
+
the unwrapped and pane rules below set an opaque background-color with
|
|
217
|
+
higher specificity, which would otherwise wipe the wash off the code. */
|
|
218
|
+
.cg-line { display: flex; align-items: flex-start; white-space: pre; overflow-wrap: normal; background-image: linear-gradient(var(--cg-row-tint, transparent), var(--cg-row-tint, transparent)); }
|
|
219
|
+
/* Unwrapped rows stretch to the widest line so tints span the full row
|
|
220
|
+
instead of stopping at the text. */
|
|
221
|
+
.changes-root:not(.is-wrap) .cg-lines:not(.is-cols) { width: max-content; min-width: 100%; }
|
|
222
|
+
.changes-root:not(.is-wrap) .cg-lines:not(.is-cols) > .cg-line { width: 100%; background-color: var(--color-bg-primary); }
|
|
223
|
+
/* Sticky gutters: the numbers stay put while long lines scroll under them.
|
|
224
|
+
The tint rides on a gradient over an opaque base, so the code behind
|
|
225
|
+
never shows through the row's translucent add/del wash. */
|
|
226
|
+
.changes-root:not(.is-wrap) .cg-lines:not(.is-cols) > .cg-line > .cg-ln { position: sticky; left: 0; z-index: 1; opacity: 1; background-color: var(--color-bg-primary); background-image: linear-gradient(var(--cg-row-tint, transparent), var(--cg-row-tint, transparent)); }
|
|
227
|
+
/* Side-by-side without wrapping keeps each pane at half the width and
|
|
228
|
+
scrolls its own long lines sideways, the way an editor diff behaves. */
|
|
229
|
+
.cg-cols { display: flex; align-items: stretch; }
|
|
230
|
+
.cg-col { flex: 1 1 0; min-width: 0; overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
|
|
231
|
+
.cg-col::-webkit-scrollbar { height: 6px; }
|
|
232
|
+
.cg-col::-webkit-scrollbar-thumb { background: var(--color-border-strong, rgba(127, 127, 127, 0.45)); border-radius: 3px; }
|
|
233
|
+
.cg-col::-webkit-scrollbar-track { background: transparent; }
|
|
234
|
+
.cg-col.old { border-right: 1px solid var(--color-border-secondary); }
|
|
235
|
+
.cg-col > .cg-line { width: max-content; min-width: 100%; background-color: var(--color-bg-primary); }
|
|
236
|
+
/* Sticky so the number stays put while long lines scroll under it. The
|
|
237
|
+
tint rides on a gradient over an opaque base for the same reason. */
|
|
238
|
+
.changes-root .cg-col > .cg-line > .cg-ln { position: sticky; left: 0; z-index: 1; opacity: 1; background-color: var(--color-bg-primary); background-image: linear-gradient(var(--cg-row-tint, transparent), var(--cg-row-tint, transparent)); }
|
|
239
|
+
/* Same reason as the fold strip below: a row only as wide as the visible
|
|
240
|
+
pane stops pinning its numbers once the pane scrolls past ~its own
|
|
241
|
+
width, so every row stretches to the full scroll width. */
|
|
242
|
+
.changes-root:not(.is-wrap) .cg-col > .cg-line { min-width: var(--cg-span-w, 100%); }
|
|
243
|
+
.changes-root.is-wrap .cg-col { overflow-x: visible; }
|
|
244
|
+
.changes-root.is-wrap .cg-line { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
245
|
+
.cg-line.add { --cg-row-tint: var(--color-success-soft, rgba(47, 158, 68, 0.12)); }
|
|
246
|
+
.cg-line.del { --cg-row-tint: var(--color-error-soft, rgba(201, 42, 42, 0.12)); }
|
|
247
|
+
/* Stretch so an empty gutter still tints over the full row height. */
|
|
248
|
+
.cg-ln { flex: none; align-self: stretch; width: 42px; padding: 0 6px; text-align: right; color: var(--color-text-tertiary); user-select: none; font-size: 10.5px; opacity: 0.75; }
|
|
249
|
+
.cg-code { flex: 1; min-width: 0; padding: 0 12px 0 6px; color: var(--color-text-primary); }
|
|
250
|
+
.cg-line.add .cg-ln, .cg-line.del .cg-ln { opacity: 0.9; }
|
|
251
|
+
.cg-line.split { white-space: normal; }
|
|
252
|
+
.cg-half { flex: 1 1 0; min-width: 0; display: flex; align-items: flex-start; white-space: pre; overflow-wrap: normal; }
|
|
253
|
+
.changes-root.is-wrap .cg-half { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
254
|
+
.changes-root:not(.is-wrap) .cg-half { min-width: 0; }
|
|
255
|
+
.cg-half.old { border-right: 1px solid var(--color-border-secondary); }
|
|
256
|
+
.cg-half .cg-ln { width: 36px; }
|
|
257
|
+
.cg-half.add { background: var(--color-success-soft, rgba(47, 158, 68, 0.12)); }
|
|
258
|
+
.cg-half.del { background: var(--color-error-soft, rgba(201, 42, 42, 0.12)); }
|
|
259
|
+
.cg-half.blank { align-self: stretch; min-height: 17.8px; background-color: var(--color-bg-secondary, rgba(127, 127, 127, 0.06)); background-image: repeating-linear-gradient(135deg, transparent 0 5px, var(--cg-hatch, rgba(127, 127, 127, 0.12)) 5px 6.3px); }
|
|
260
|
+
.cg-col > .cg-line.blank { min-height: 17.8px; background-color: var(--color-bg-secondary, rgba(127, 127, 127, 0.06)); background-image: repeating-linear-gradient(135deg, transparent 0 5px, var(--cg-hatch, rgba(127, 127, 127, 0.12)) 5px 6.3px); }
|
|
261
|
+
/* Only the arrow chip is pinned; the label rides along with the code so
|
|
262
|
+
the strip reads as part of the diff instead of an overlay. */
|
|
263
|
+
.cg-fold { display: flex; align-items: stretch; height: 18px; margin: 2px 0; color: var(--color-text-tertiary); font-size: 11px; font-family: ui-monospace, monospace; cursor: pointer; }
|
|
264
|
+
.cg-fold .cg-fold-gutter { position: sticky; left: 0; z-index: 1; flex: none; width: 42px; display: flex; align-items: center; justify-content: center; border-radius: 3px; background-color: var(--color-bg-primary); background-image: linear-gradient(rgba(127, 127, 127, 0.1), rgba(127, 127, 127, 0.1)); }
|
|
265
|
+
/* Sticky only travels inside its containing block, so strips and rows have
|
|
266
|
+
to span the scrollable content, not the visible pane — otherwise they
|
|
267
|
+
stop pinning once the pane scrolls past their own width. */
|
|
268
|
+
.changes-root:not(.is-wrap) .cg-fold { min-width: var(--cg-span-w, 0px); }
|
|
269
|
+
.cg-fold .cg-fold-label { flex: 1; min-width: 0; display: flex; align-items: center; padding: 0 10px; white-space: nowrap; background: rgba(127, 127, 127, 0.07); border-radius: 3px; }
|
|
270
|
+
.cg-fold:hover { color: var(--color-accent-primary); }
|
|
271
|
+
.cg-fold.is-ghost { visibility: hidden; }
|
|
272
|
+
.cg-note { padding: 10px 14px; color: var(--color-text-tertiary); font-size: 11.5px; }
|
|
273
|
+
.cg-lazy { padding: 10px 14px; margin: 4px 12px 8px; color: var(--color-text-secondary); font-size: 11.5px;
|
|
274
|
+
border: 1px dashed var(--color-border-default); border-radius: 6px; cursor: pointer; user-select: none; }
|
|
275
|
+
.cg-lazy:hover { color: var(--color-text-primary); border-color: var(--color-border-strong); }
|
|
276
|
+
.cg-lazy.is-static { cursor: default; }
|
|
277
|
+
.cg-empty, .cg-error { padding: 24px 16px; text-align: center; color: var(--color-text-tertiary); font-size: 12px; }
|
|
278
|
+
.cg-error { color: var(--color-error); }
|
|
279
|
+
.cg-side-empty { padding: 10px; color: var(--color-text-tertiary); font-size: 11.5px; }
|
|
280
|
+
@container (max-width: 900px) {
|
|
281
|
+
.cg-side { width: 220px; }
|
|
282
|
+
.cg-scope-label { max-width: 110px; }
|
|
283
|
+
.cg-scope-subject { max-width: 170px; }
|
|
284
|
+
.cg-compare .cg-scope-head, .cg-compare .cg-scope-base { max-width: 96px; }
|
|
285
|
+
}
|
|
286
|
+
@container (max-width: 700px) {
|
|
287
|
+
.cg-side { width: 190px; }
|
|
288
|
+
/* The pill keeps its content width - stretching it turns a short scope
|
|
289
|
+
name into a bar-wide capsule. It may still shrink when a commit
|
|
290
|
+
subject is long: that subject truncates instead of disappearing. */
|
|
291
|
+
.cg-scope { max-width: 100%; }
|
|
292
|
+
.cg-scope-subject { max-width: none; }
|
|
293
|
+
}
|
|
294
|
+
@container (max-width: 520px) {
|
|
295
|
+
.cg-side { width: 156px; }
|
|
296
|
+
.cg-compare .cg-scope-head, .cg-compare .cg-scope-base { max-width: 84px; }
|
|
297
|
+
}
|
|
298
|
+
@container (max-width: 460px) {
|
|
299
|
+
/* The bar wraps only when the pills and the tools no longer fit side
|
|
300
|
+
by side; the tools then drop below, still right-aligned. */
|
|
301
|
+
.cg-bar { height: auto; min-height: 38px; flex-wrap: wrap; padding-block: 6px; row-gap: 6px; }
|
|
302
|
+
.cg-bar-spacer { display: none; }
|
|
303
|
+
.cg-tools { margin-left: auto; }
|
|
304
|
+
.cg-scope-subject { max-width: 180px; }
|
|
305
|
+
.cg-compare .cg-scope-head, .cg-compare .cg-scope-base { max-width: 62px; }
|
|
106
306
|
}
|
|
107
307
|
`;
|
|
108
308
|
document.head.appendChild(style);
|
|
@@ -122,12 +322,122 @@
|
|
|
122
322
|
return node;
|
|
123
323
|
}
|
|
124
324
|
|
|
325
|
+
function fileIconEl(name) {
|
|
326
|
+
const view = window.Clacky && Clacky.WorkspaceView;
|
|
327
|
+
if (!view || !view.fileIcon) return null;
|
|
328
|
+
const span = el("span", { class: "cg-icon" });
|
|
329
|
+
span.innerHTML = view.fileIcon(name);
|
|
330
|
+
return span;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function folderIconEl(open) {
|
|
334
|
+
const view = window.Clacky && Clacky.WorkspaceView;
|
|
335
|
+
if (!view || !view.iconFolder) return null;
|
|
336
|
+
const span = el("span", { class: "cg-icon" });
|
|
337
|
+
span.innerHTML = open ? view.iconFolderOpen : view.iconFolder;
|
|
338
|
+
return span;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function clampSideWidth(width, bodyEl) {
|
|
342
|
+
const bodyW = bodyEl.getBoundingClientRect().width;
|
|
343
|
+
const max = Math.max(SIDE_MIN_W, bodyW - DIFF_MIN_W);
|
|
344
|
+
return Math.round(Math.min(Math.max(width, SIDE_MIN_W), max));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// The tree keeps a fixed pixel width once the user drags it; without a
|
|
348
|
+
// stored width it falls back to the container-query defaults.
|
|
349
|
+
function setupSideResizer(root, body, side) {
|
|
350
|
+
const handle = el("div", { class: "cg-resizer", role: "separator",
|
|
351
|
+
"aria-orientation": "vertical", tabindex: "0",
|
|
352
|
+
title: t("changes.resizeTree", "拖动调整文件树宽度(双击复位)") });
|
|
353
|
+
|
|
354
|
+
let startX = 0;
|
|
355
|
+
let startW = 0;
|
|
356
|
+
let desired = 0;
|
|
357
|
+
|
|
358
|
+
const stored = () => {
|
|
359
|
+
try { return parseInt(localStorage.getItem(SIDE_WIDTH_KEY), 10) || 0; } catch (_e) { return 0; }
|
|
360
|
+
};
|
|
361
|
+
const persist = (w) => {
|
|
362
|
+
desired = Math.round(w);
|
|
363
|
+
try { localStorage.setItem(SIDE_WIDTH_KEY, String(desired)); } catch (_e) { /* ignore */ }
|
|
364
|
+
};
|
|
365
|
+
const applyDesired = () => {
|
|
366
|
+
if (!desired) return;
|
|
367
|
+
side.style.width = clampSideWidth(desired, body) + "px";
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const onMove = (e) => {
|
|
371
|
+
side.style.width = clampSideWidth(startW + (e.clientX - startX), body) + "px";
|
|
372
|
+
};
|
|
373
|
+
const stop = () => {
|
|
374
|
+
handle.classList.remove("is-active");
|
|
375
|
+
root.classList.remove("is-resizing");
|
|
376
|
+
document.removeEventListener("pointermove", onMove);
|
|
377
|
+
document.removeEventListener("pointerup", stop);
|
|
378
|
+
document.removeEventListener("pointercancel", stop);
|
|
379
|
+
persist(parseFloat(side.style.width));
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
handle.addEventListener("pointerdown", (e) => {
|
|
383
|
+
if (e.button !== 0) return;
|
|
384
|
+
e.preventDefault();
|
|
385
|
+
startX = e.clientX;
|
|
386
|
+
startW = side.getBoundingClientRect().width;
|
|
387
|
+
handle.classList.add("is-active");
|
|
388
|
+
root.classList.add("is-resizing");
|
|
389
|
+
document.addEventListener("pointermove", onMove);
|
|
390
|
+
document.addEventListener("pointerup", stop);
|
|
391
|
+
document.addEventListener("pointercancel", stop);
|
|
392
|
+
handle.setPointerCapture(e.pointerId);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
handle.addEventListener("dblclick", () => {
|
|
396
|
+
desired = 0;
|
|
397
|
+
side.style.removeProperty("width");
|
|
398
|
+
try { localStorage.removeItem(SIDE_WIDTH_KEY); } catch (_e) { /* ignore */ }
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
handle.addEventListener("keydown", (e) => {
|
|
402
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
const base = side.getBoundingClientRect().width;
|
|
405
|
+
const next = clampSideWidth(base + (e.key === "ArrowLeft" ? -16 : 16), body);
|
|
406
|
+
persist(next);
|
|
407
|
+
applyDesired();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// The panel mounts before it is laid out, so the saved width is applied on
|
|
411
|
+
// the first observer tick — and re-clamped whenever the aside is resized.
|
|
412
|
+
desired = stored();
|
|
413
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
414
|
+
const ro = new ResizeObserver(() => {
|
|
415
|
+
if (!body.isConnected) { ro.disconnect(); return; }
|
|
416
|
+
if (body.getBoundingClientRect().width > 0) applyDesired();
|
|
417
|
+
});
|
|
418
|
+
ro.observe(body);
|
|
419
|
+
} else {
|
|
420
|
+
requestAnimationFrame(applyDesired);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return handle;
|
|
424
|
+
}
|
|
425
|
+
|
|
125
426
|
async function api(sessionId, action, opts) {
|
|
126
427
|
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/git/${action}`, opts);
|
|
127
428
|
return res.json();
|
|
128
429
|
}
|
|
129
430
|
|
|
130
|
-
|
|
431
|
+
function scopeQuery() {
|
|
432
|
+
const parts = [`scope=${encodeURIComponent(state.scope)}`];
|
|
433
|
+
if (state.scope === "branch" && state.base) {
|
|
434
|
+
parts.push(`base=${encodeURIComponent(state.base)}`);
|
|
435
|
+
} else if (state.scope === "committed" && state.commit) {
|
|
436
|
+
parts.push(`base=${encodeURIComponent(state.commit)}`);
|
|
437
|
+
}
|
|
438
|
+
return parts.join("&");
|
|
439
|
+
}
|
|
440
|
+
|
|
131
441
|
function classify(f) {
|
|
132
442
|
if (f.untracked) return "add";
|
|
133
443
|
const code = `${f.x || ""}${f.y || ""}`;
|
|
@@ -137,245 +447,1091 @@
|
|
|
137
447
|
}
|
|
138
448
|
|
|
139
449
|
const TAG_LABEL = {
|
|
140
|
-
add: () => t("changes.tag.add", "
|
|
141
|
-
mod: () => t("changes.tag.mod", "
|
|
142
|
-
del: () => t("changes.tag.del", "
|
|
450
|
+
add: () => t("changes.tag.add", "A"),
|
|
451
|
+
mod: () => t("changes.tag.mod", "M"),
|
|
452
|
+
del: () => t("changes.tag.del", "D"),
|
|
143
453
|
};
|
|
144
454
|
|
|
145
|
-
function
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const d = new Date();
|
|
152
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
153
|
-
const stamp = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
154
|
-
return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
|
|
455
|
+
function langFor(path) {
|
|
456
|
+
const base = path.split("/").pop().toLowerCase();
|
|
457
|
+
if (base === "dockerfile") return "dockerfile";
|
|
458
|
+
if (base === "gemfile" || base === "rakefile" || base === "podfile") return "ruby";
|
|
459
|
+
const ext = base.includes(".") ? base.split(".").pop() : "";
|
|
460
|
+
return LANG_BY_EXT[ext] || null;
|
|
155
461
|
}
|
|
156
462
|
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
function
|
|
161
|
-
if (
|
|
162
|
-
|
|
463
|
+
// One hljs pass per line: multi-line constructs (block comments, heredocs)
|
|
464
|
+
// lose their state, but it keeps rendering linear and predictable on files
|
|
465
|
+
// with thousands of changed lines.
|
|
466
|
+
function highlightLine(text, lang) {
|
|
467
|
+
if (!lang || !window.hljs || !hljs.getLanguage) return null;
|
|
468
|
+
if (!hljs.getLanguage(lang)) return null;
|
|
469
|
+
try {
|
|
470
|
+
return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
|
|
471
|
+
} catch (_e) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
163
474
|
}
|
|
164
475
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
476
|
+
// ── diff parsing ────────────────────────────────────────────────────────
|
|
477
|
+
// [{ oldStart:, oldCount:, newStart:, newCount:, lines: [...] }]
|
|
478
|
+
function parseHunks(text) {
|
|
479
|
+
const hunks = [];
|
|
480
|
+
let hunk = null;
|
|
481
|
+
let oldNo = 0;
|
|
482
|
+
let newNo = 0;
|
|
483
|
+
const raw = text.split("\n");
|
|
484
|
+
for (let i = 0; i < raw.length; i++) {
|
|
485
|
+
const line = raw[i];
|
|
486
|
+
if (line.startsWith("@@")) {
|
|
487
|
+
const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
488
|
+
if (!m) { hunk = null; continue; }
|
|
489
|
+
oldNo = parseInt(m[1], 10);
|
|
490
|
+
newNo = parseInt(m[3], 10);
|
|
491
|
+
hunk = {
|
|
492
|
+
oldStart: oldNo,
|
|
493
|
+
oldCount: m[2] === undefined ? 1 : parseInt(m[2], 10),
|
|
494
|
+
newStart: newNo,
|
|
495
|
+
newCount: m[4] === undefined ? 1 : parseInt(m[4], 10),
|
|
496
|
+
lines: [],
|
|
497
|
+
};
|
|
498
|
+
hunks.push(hunk);
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
if (!hunk) continue;
|
|
502
|
+
if (line.startsWith("\\")) continue;
|
|
503
|
+
if (line.startsWith("+")) {
|
|
504
|
+
hunk.lines.push({ type: "add", oldNo: null, newNo: newNo++, text: line.slice(1) });
|
|
505
|
+
} else if (line.startsWith("-")) {
|
|
506
|
+
hunk.lines.push({ type: "del", oldNo: oldNo++, newNo: null, text: line.slice(1) });
|
|
507
|
+
} else if (line.startsWith(" ")) {
|
|
508
|
+
hunk.lines.push({ type: "ctx", oldNo: oldNo++, newNo: newNo++, text: line.slice(1) });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return hunks;
|
|
170
512
|
}
|
|
171
513
|
|
|
172
|
-
// ── Panel state ─────────────────────────────────────────────────────────
|
|
173
|
-
// The skeleton is built once per mount; refresh() only updates the summary,
|
|
174
|
-
// the file list and the badge. `checked` / `selected` survive refreshes so a
|
|
175
|
-
// re-render (e.g. after the agent finishes a round) doesn't wipe the user's
|
|
176
|
-
// selection. Files that disappear drop out of the sets; new files start
|
|
177
|
-
// checked.
|
|
178
|
-
|
|
179
514
|
const state = {
|
|
180
515
|
sessionId: null,
|
|
181
|
-
files: [],
|
|
182
|
-
checked: new Set(),
|
|
183
|
-
allPaths: new Set(),
|
|
184
|
-
selected: null,
|
|
185
|
-
els: null,
|
|
186
516
|
ctx: null,
|
|
517
|
+
els: null,
|
|
518
|
+
files: [],
|
|
519
|
+
records: new Map(), // path -> { file, full, expanded }
|
|
520
|
+
collapsedDirs: new Set(),
|
|
521
|
+
filter: "",
|
|
522
|
+
activePath: null,
|
|
523
|
+
renderTruncated: 0,
|
|
524
|
+
budget: 0,
|
|
525
|
+
// Which slice of history the panel shows. `branch` compares the current
|
|
526
|
+
// HEAD against a branch the user picked in the compare pill; `committed`
|
|
527
|
+
// compares it against a commit picked from the menu. Both start out on the
|
|
528
|
+
// server's default base (the upstream) and keep their pick separately, so
|
|
529
|
+
// switching scopes never loses the other one.
|
|
530
|
+
scope: "worktree",
|
|
531
|
+
base: "",
|
|
532
|
+
commit: "",
|
|
533
|
+
branch: "",
|
|
534
|
+
branches: null,
|
|
535
|
+
commits: null,
|
|
536
|
+
note: null,
|
|
537
|
+
// Diff layout: "unified" (one column, the default), "split" (always
|
|
538
|
+
// side-by-side) or "auto" (side-by-side when the pane is wide enough).
|
|
539
|
+
layout: "unified",
|
|
540
|
+
// True while re-rendering bodies after a layout change, so the render
|
|
541
|
+
// budget is not charged twice for the same lines.
|
|
542
|
+
reflow: false,
|
|
543
|
+
// Long lines fold by default off: code keeps its real width and the pane
|
|
544
|
+
// scrolls sideways, which is what reading diffs with a mouse expects.
|
|
545
|
+
wrap: false,
|
|
546
|
+
treeVisible: true,
|
|
547
|
+
ro: null,
|
|
548
|
+
spanTimer: null,
|
|
187
549
|
};
|
|
188
550
|
|
|
189
|
-
|
|
190
|
-
|
|
551
|
+
// ── tree ────────────────────────────────────────────────────────────────
|
|
552
|
+
function buildTree(files) {
|
|
553
|
+
const root = { name: "", path: "", dirs: new Map(), files: [] };
|
|
554
|
+
files.forEach((f) => {
|
|
555
|
+
const parts = f.path.split("/");
|
|
556
|
+
let node = root;
|
|
557
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
558
|
+
const name = parts[i];
|
|
559
|
+
if (!node.dirs.has(name)) {
|
|
560
|
+
node.dirs.set(name, {
|
|
561
|
+
name: name,
|
|
562
|
+
path: parts.slice(0, i + 1).join("/"),
|
|
563
|
+
dirs: new Map(),
|
|
564
|
+
files: [],
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
node = node.dirs.get(name);
|
|
568
|
+
}
|
|
569
|
+
node.files.push(f);
|
|
570
|
+
});
|
|
571
|
+
return root;
|
|
191
572
|
}
|
|
192
573
|
|
|
193
|
-
function
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const total = state.files.length;
|
|
197
|
-
const n = checkedCount();
|
|
574
|
+
function matchesFilter(file) {
|
|
575
|
+
return !state.filter || file.path.toLowerCase().includes(state.filter);
|
|
576
|
+
}
|
|
198
577
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
578
|
+
// ── context menu ────────────────────────────────────────────────────────
|
|
579
|
+
function closeFileMenu() {
|
|
580
|
+
document.querySelectorAll(".cg-file-menu").forEach((n) => n.remove());
|
|
581
|
+
document.querySelectorAll(".cg-head-more.is-open").forEach((b) => b.classList.remove("is-open"));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function menuEntry(path) {
|
|
585
|
+
return { path: path, name: path.split("/").pop() || path };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function copyToClipboard(text) {
|
|
589
|
+
navigator.clipboard.writeText(text).then(() => Modal.toast(text, "info"));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async function copyAbsPath(path) {
|
|
593
|
+
const entry = menuEntry(path);
|
|
594
|
+
let target = Clacky.Workspace.absPath(path);
|
|
595
|
+
try {
|
|
596
|
+
target = await Clacky.Workspace.displayPath(entry);
|
|
597
|
+
} catch (_e) { /* fall back to the absolute path */ }
|
|
598
|
+
copyToClipboard(target);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function addToChat(path) {
|
|
602
|
+
if (typeof Sessions === "undefined" || typeof Sessions.attachWorkspaceFile !== "function") return;
|
|
603
|
+
const entry = menuEntry(path);
|
|
604
|
+
Sessions.attachWorkspaceFile({
|
|
605
|
+
type: "file",
|
|
606
|
+
name: entry.name,
|
|
607
|
+
path: Clacky.Workspace.absPath(path),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function revealInFinder(path) {
|
|
612
|
+
try {
|
|
613
|
+
await Clacky.Workspace.revealFile(menuEntry(path));
|
|
614
|
+
} catch (_e) {
|
|
615
|
+
Modal.toast(t("workspace.revealFailed", "无法打开文件位置"), "error");
|
|
209
616
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function downloadPath(path) {
|
|
620
|
+
const entry = menuEntry(path);
|
|
621
|
+
try {
|
|
622
|
+
const blob = await Clacky.Workspace.fetchFileBlob(entry);
|
|
623
|
+
const url = URL.createObjectURL(blob);
|
|
624
|
+
const a = el("a", { href: url, download: entry.name });
|
|
625
|
+
document.body.appendChild(a);
|
|
626
|
+
a.click();
|
|
627
|
+
a.remove();
|
|
628
|
+
URL.revokeObjectURL(url);
|
|
629
|
+
} catch (_e) {
|
|
630
|
+
Modal.toast(t("workspace.downloadFailed", "下载失败"), "error");
|
|
215
631
|
}
|
|
216
632
|
}
|
|
217
633
|
|
|
218
|
-
function
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (check) check.checked = state.checked.has(row.dataset.path);
|
|
634
|
+
function openInEditor(path) {
|
|
635
|
+
Clacky.Workspace.openWithDefault(menuEntry(path)).catch((err) => {
|
|
636
|
+
const reason = err && err.message ? ": " + err.message : "";
|
|
637
|
+
Modal.toast(t("workspace.openWithFailed", "无法用默认应用打开") + reason, "error");
|
|
223
638
|
});
|
|
224
639
|
}
|
|
225
640
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
641
|
+
// items: [action, icon, label, run, disabled]
|
|
642
|
+
function buildMenu(items) {
|
|
643
|
+
const menu = el("div", { class: "session-context-menu cg-file-menu" });
|
|
644
|
+
items.forEach(([action, icon, label, run, disabled]) => {
|
|
645
|
+
const item = el("div", {
|
|
646
|
+
class: "session-actions-menu-item" + (disabled ? " session-actions-menu-item--disabled" : ""),
|
|
647
|
+
"data-action": action
|
|
648
|
+
});
|
|
649
|
+
const iconEl = el("span", { class: "session-actions-menu-icon" });
|
|
650
|
+
iconEl.innerHTML = icon;
|
|
651
|
+
item.appendChild(iconEl);
|
|
652
|
+
item.appendChild(el("span", { class: "session-actions-menu-label", text: label }));
|
|
653
|
+
item.addEventListener("click", () => {
|
|
654
|
+
if (disabled) return;
|
|
655
|
+
closeFileMenu();
|
|
656
|
+
run();
|
|
657
|
+
});
|
|
658
|
+
menu.appendChild(item);
|
|
659
|
+
});
|
|
660
|
+
menu.addEventListener("contextmenu", (e) => e.preventDefault());
|
|
661
|
+
return menu;
|
|
232
662
|
}
|
|
233
663
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
664
|
+
// Drops the menu at a viewport point and keeps it inside the window.
|
|
665
|
+
// align "right" pins the menu's right edge to x (used by header buttons).
|
|
666
|
+
function placeMenu(menu, x, y, align) {
|
|
667
|
+
document.body.appendChild(menu);
|
|
668
|
+
menu.style.position = "fixed";
|
|
669
|
+
const w = menu.offsetWidth;
|
|
670
|
+
const h = menu.offsetHeight;
|
|
671
|
+
let left = align === "right" ? x - w : x;
|
|
672
|
+
let top = y;
|
|
673
|
+
left = Math.min(Math.max(8, left), Math.max(8, window.innerWidth - w - 8));
|
|
674
|
+
top = Math.min(Math.max(8, top), Math.max(8, window.innerHeight - h - 8));
|
|
675
|
+
menu.style.left = left + "px";
|
|
676
|
+
menu.style.top = top + "px";
|
|
677
|
+
setTimeout(() => {
|
|
678
|
+
document.addEventListener("click", closeFileMenu, { once: true });
|
|
679
|
+
}, 0);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function openFileMenu(ev, path, isDir) {
|
|
683
|
+
closeFileMenu();
|
|
684
|
+
|
|
685
|
+
const items = [];
|
|
686
|
+
if (!isDir) {
|
|
687
|
+
items.push(["addtochat", ICON_ADD_TO_CHAT, t("workspace.addToChat", "添加到聊天"), () => addToChat(path)]);
|
|
688
|
+
}
|
|
689
|
+
items.push(["reveal", ICON_FOLDER, t("workspace.revealInFinder", "打开所在文件夹"), () => revealInFinder(path)]);
|
|
690
|
+
items.push(["copypath", ICON_COPY, t("workspace.copyPath", "复制路径"), () => copyAbsPath(path)]);
|
|
691
|
+
items.push(["copyrelpath", ICON_COPY_REL, t("workspace.copyRelPath", "复制相对路径"), () => copyToClipboard(path)]);
|
|
692
|
+
if (!isDir) {
|
|
693
|
+
items.push(["download", ICON_DOWNLOAD, t("workspace.download", "下载"), () => downloadPath(path)]);
|
|
242
694
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
695
|
+
|
|
696
|
+
placeMenu(buildMenu(items), ev.clientX, ev.clientY);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function openHeadMenu(anchor, file) {
|
|
700
|
+
closeFileMenu();
|
|
701
|
+
const record = state.records.get(file.path);
|
|
702
|
+
const collapsed = !!(record && record.collapsed);
|
|
703
|
+
const foldLabel = collapsed
|
|
704
|
+
? t("changes.menu.expandFile", "展开文件")
|
|
705
|
+
: t("changes.menu.collapseFile", "折叠文件");
|
|
706
|
+
|
|
707
|
+
const items = [
|
|
708
|
+
["copypath", ICON_COPY, t("changes.menu.copyPath", "复制路径"), () => copyAbsPath(file.path)],
|
|
709
|
+
["openineditor", ICON_OPEN_WITH, t("changes.menu.openInEditor", "在编辑器中打开"), () => openInEditor(file.path)],
|
|
710
|
+
[collapsed ? "expand" : "collapse", collapsed ? ICON_UNFOLD : ICON_COLLAPSE, foldLabel,
|
|
711
|
+
() => setFileCollapsed(file.path, !collapsed)]
|
|
712
|
+
];
|
|
713
|
+
|
|
714
|
+
anchor.classList.add("is-open");
|
|
715
|
+
const r = anchor.getBoundingClientRect();
|
|
716
|
+
placeMenu(buildMenu(items), r.right, r.bottom + 4, "right");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function renderTree() {
|
|
720
|
+
const tree = state.els.tree;
|
|
721
|
+
tree.replaceChildren();
|
|
722
|
+
const filtered = state.files.filter(matchesFilter);
|
|
723
|
+
const root = buildTree(filtered);
|
|
724
|
+
state.els.sideEmpty.style.display = (state.files.length && !filtered.length) ? "" : "none";
|
|
725
|
+
if (!filtered.length) return;
|
|
726
|
+
|
|
727
|
+
// A path that is only a chain of single-child directories (`a/b/c/file.js`)
|
|
728
|
+
// is folded into one row, so a deep tree does not push the filename out of
|
|
729
|
+
// view. The row's path stays the deepest directory's path.
|
|
730
|
+
const foldChain = (dir) => {
|
|
731
|
+
let node = dir;
|
|
732
|
+
let label = dir.name;
|
|
733
|
+
while (node.files.length === 0 && node.dirs.size === 1) {
|
|
734
|
+
node = Array.from(node.dirs.values())[0];
|
|
735
|
+
label += "/" + node.name;
|
|
736
|
+
}
|
|
737
|
+
return { node: node, label: label };
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const walk = (node, depth) => {
|
|
741
|
+
const dirs = Array.from(node.dirs.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
742
|
+
dirs.forEach((dir) => {
|
|
743
|
+
const chain = foldChain(dir);
|
|
744
|
+
const closed = !state.filter && state.collapsedDirs.has(chain.node.path);
|
|
745
|
+
const row = el("div", { class: "cg-dir-row" + (closed ? " closed" : "") });
|
|
746
|
+
row.style.paddingLeft = `${8 + depth * 12}px`;
|
|
747
|
+
row.title = chain.node.path;
|
|
748
|
+
const caret = el("span", { class: "cg-caret" });
|
|
749
|
+
caret.innerHTML = ICON_CHEVRON;
|
|
750
|
+
row.appendChild(caret);
|
|
751
|
+
row.appendChild(folderIconEl(!closed));
|
|
752
|
+
row.appendChild(el("span", { class: "cg-dir-name", text: chain.label }));
|
|
753
|
+
row.addEventListener("click", () => {
|
|
754
|
+
if (state.filter) return;
|
|
755
|
+
if (state.collapsedDirs.has(chain.node.path)) state.collapsedDirs.delete(chain.node.path);
|
|
756
|
+
else state.collapsedDirs.add(chain.node.path);
|
|
757
|
+
renderTree();
|
|
758
|
+
});
|
|
759
|
+
row.addEventListener("contextmenu", (ev) => {
|
|
760
|
+
ev.preventDefault();
|
|
761
|
+
openFileMenu(ev, chain.node.path, true);
|
|
762
|
+
});
|
|
763
|
+
tree.appendChild(row);
|
|
764
|
+
if (!closed) walk(chain.node, depth + 1);
|
|
257
765
|
});
|
|
258
766
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
767
|
+
node.files
|
|
768
|
+
.slice()
|
|
769
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
770
|
+
.forEach((file) => {
|
|
771
|
+
const kind = classify(file);
|
|
772
|
+
const name = file.path.split("/").pop();
|
|
773
|
+
const row = el("div", { class: "cg-file-row" + (file.path === state.activePath ? " active" : "") });
|
|
774
|
+
row.style.paddingLeft = `${10 + depth * 12}px`;
|
|
775
|
+
row.dataset.path = file.path;
|
|
776
|
+
row.title = file.path;
|
|
777
|
+
row.appendChild(el("span", { class: `cg-tag ${kind}`, text: TAG_LABEL[kind]() }));
|
|
778
|
+
const icon = fileIconEl(name);
|
|
779
|
+
if (icon) row.appendChild(icon);
|
|
780
|
+
row.appendChild(el("span", { class: "cg-file-name", text: name }));
|
|
781
|
+
const stat = el("span", { class: "cg-file-stat" });
|
|
782
|
+
if (file.add) stat.appendChild(el("span", { class: "a", text: `+${file.add}` }));
|
|
783
|
+
if (file.del) stat.appendChild(el("span", { class: "d", text: `-${file.del}` }));
|
|
784
|
+
row.appendChild(stat);
|
|
785
|
+
row.addEventListener("click", () => {
|
|
786
|
+
focusFile(file.path);
|
|
787
|
+
});
|
|
788
|
+
row.addEventListener("contextmenu", (ev) => {
|
|
789
|
+
ev.preventDefault();
|
|
790
|
+
openFileMenu(ev, file.path, false);
|
|
791
|
+
});
|
|
792
|
+
tree.appendChild(row);
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
walk(root, 0);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ── jump to file ────────────────────────────────────────────────────────
|
|
799
|
+
// Codex-style finder over the files this panel is already showing, so it
|
|
800
|
+
// stays a shortcut into the diff stream rather than a second file browser.
|
|
801
|
+
let findEl = null;
|
|
802
|
+
let findAnchor = null;
|
|
803
|
+
let findRows = [];
|
|
804
|
+
let findOutside = null;
|
|
805
|
+
|
|
806
|
+
function closeFinder() {
|
|
807
|
+
if (findOutside) {
|
|
808
|
+
document.removeEventListener("click", findOutside);
|
|
809
|
+
findOutside = null;
|
|
810
|
+
}
|
|
811
|
+
if (!findEl) return;
|
|
812
|
+
findEl.remove();
|
|
813
|
+
findEl = null;
|
|
814
|
+
findRows = [];
|
|
815
|
+
if (findAnchor) findAnchor.classList.remove("is-on");
|
|
816
|
+
findAnchor = null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function splitPath(path) {
|
|
820
|
+
const cut = path.lastIndexOf("/");
|
|
821
|
+
return cut < 0
|
|
822
|
+
? { name: path, dir: "" }
|
|
823
|
+
: { name: path.slice(cut + 1), dir: path.slice(0, cut) };
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Subsequence match with a score: consecutive hits and word starts count
|
|
827
|
+
// double, so "gpv" ranks git_panel/view.js above unrelated paths.
|
|
828
|
+
function fuzzyScore(query, text) {
|
|
829
|
+
const lower = text.toLowerCase();
|
|
830
|
+
let score = 0;
|
|
831
|
+
let at = 0;
|
|
832
|
+
let streak = 0;
|
|
833
|
+
for (const ch of query) {
|
|
834
|
+
const idx = lower.indexOf(ch, at);
|
|
835
|
+
if (idx < 0) return 0;
|
|
836
|
+
streak = idx === at ? streak + 1 : 0;
|
|
837
|
+
score += 10 + streak * 6;
|
|
838
|
+
if (idx === 0 || "/_-.".includes(lower[idx - 1])) score += 12;
|
|
839
|
+
at = idx + 1;
|
|
840
|
+
}
|
|
841
|
+
return score;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// A hit on the basename beats a hit spread over the directories: typing
|
|
845
|
+
// "view" should offer view.js before library/views/index.js.
|
|
846
|
+
function scorePath(query, path) {
|
|
847
|
+
if (!query) return 1;
|
|
848
|
+
const name = splitPath(path).name;
|
|
849
|
+
const inName = fuzzyScore(query, name);
|
|
850
|
+
const inPath = fuzzyScore(query, path);
|
|
851
|
+
if (!inName && !inPath) return 0;
|
|
852
|
+
return Math.max(inPath, inName + 40) - path.length * 0.05;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function matchPaths(query, files) {
|
|
856
|
+
const q = query.trim().toLowerCase();
|
|
857
|
+
if (!q) return files.slice(0, 80);
|
|
858
|
+
return files
|
|
859
|
+
.map((path) => ({ path: path, score: scorePath(q, path) }))
|
|
860
|
+
.filter((hit) => hit.score > 0)
|
|
861
|
+
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
|
862
|
+
.slice(0, 80)
|
|
863
|
+
.map((hit) => hit.path);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function openChosenFile(path) {
|
|
867
|
+
focusFile(path);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Same idea as positionMenu but measured synchronously: requestAnimationFrame
|
|
871
|
+
// never fires while the tab is in the background, which left the panel
|
|
872
|
+
// overflowing the viewport.
|
|
873
|
+
function placeFinder(panel, anchor) {
|
|
874
|
+
const r = anchor.getBoundingClientRect();
|
|
875
|
+
const m = panel.getBoundingClientRect();
|
|
876
|
+
const width = m.width || 420;
|
|
877
|
+
const left = Math.max(8, Math.min(r.left, window.innerWidth - width - 8));
|
|
878
|
+
const below = r.bottom + 6;
|
|
879
|
+
const above = r.top - m.height - 6;
|
|
880
|
+
let top = below;
|
|
881
|
+
if (below + m.height > window.innerHeight - 8) {
|
|
882
|
+
top = above >= 8 ? above : Math.max(8, window.innerHeight - m.height - 8);
|
|
883
|
+
}
|
|
884
|
+
panel.style.left = left + "px";
|
|
885
|
+
panel.style.top = top + "px";
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function openFinder(anchor) {
|
|
889
|
+
const wasOpen = !!(findEl && findAnchor === anchor);
|
|
890
|
+
closeFinder();
|
|
891
|
+
if (wasOpen) return;
|
|
892
|
+
|
|
893
|
+
findAnchor = anchor;
|
|
894
|
+
anchor.classList.add("is-on");
|
|
895
|
+
|
|
896
|
+
const input = el("input", { type: "search",
|
|
897
|
+
placeholder: t("changes.find.placeholder", "跳转到文件") });
|
|
898
|
+
const icon = el("span", {});
|
|
899
|
+
icon.innerHTML = ICON_SEARCH;
|
|
900
|
+
const list = el("div", { class: "cg-find-list" });
|
|
901
|
+
const panel = el("div", { class: "cg-find" },
|
|
902
|
+
el("div", { class: "cg-find-search" }, icon, input), list);
|
|
903
|
+
panel.addEventListener("click", (ev) => ev.stopPropagation());
|
|
904
|
+
document.body.appendChild(panel);
|
|
905
|
+
findEl = panel;
|
|
906
|
+
// The listener is registered on the next tick so the click that opened the
|
|
907
|
+
// panel cannot reach it, and closeFinder removes it so a later click never
|
|
908
|
+
// closes a panel opened after an Enter/Escape dismissal.
|
|
909
|
+
setTimeout(() => {
|
|
910
|
+
if (findEl !== panel) return;
|
|
911
|
+
findOutside = closeFinder;
|
|
912
|
+
document.addEventListener("click", findOutside);
|
|
913
|
+
}, 0);
|
|
914
|
+
|
|
915
|
+
const changed = state.files.map((f) => f.path);
|
|
916
|
+
|
|
917
|
+
let active = 0;
|
|
918
|
+
const paint = () => {
|
|
919
|
+
list.replaceChildren();
|
|
920
|
+
if (!findRows.length) {
|
|
921
|
+
list.appendChild(el("div", { class: "cg-find-empty",
|
|
922
|
+
text: t("changes.find.empty", "没有匹配的文件") }));
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
findRows.forEach((path, i) => {
|
|
926
|
+
const parts = splitPath(path);
|
|
927
|
+
const row = el("div", { class: "cg-find-item" + (i === active ? " is-active" : "") });
|
|
928
|
+
row.appendChild(el("span", { class: "cg-find-name", text: parts.name }));
|
|
929
|
+
if (parts.dir) row.appendChild(el("span", { class: "cg-find-dir", text: parts.dir }));
|
|
930
|
+
// mousedown fires before the input blur and before the document click
|
|
931
|
+
// that closes the panel, so the pick never gets swallowed.
|
|
932
|
+
row.addEventListener("mousedown", (ev) => {
|
|
933
|
+
ev.preventDefault();
|
|
934
|
+
const picked = path;
|
|
935
|
+
closeFinder();
|
|
936
|
+
openChosenFile(picked);
|
|
937
|
+
});
|
|
938
|
+
row.addEventListener("mousemove", () => {
|
|
939
|
+
if (active === i) return;
|
|
940
|
+
active = i;
|
|
941
|
+
paint();
|
|
942
|
+
});
|
|
943
|
+
list.appendChild(row);
|
|
266
944
|
});
|
|
945
|
+
};
|
|
267
946
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
947
|
+
const refilter = () => {
|
|
948
|
+
findRows = matchPaths(input.value, changed);
|
|
949
|
+
active = 0;
|
|
950
|
+
paint();
|
|
951
|
+
placeFinder(panel, anchor);
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
input.addEventListener("input", refilter);
|
|
955
|
+
input.addEventListener("keydown", (ev) => {
|
|
956
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
957
|
+
ev.preventDefault();
|
|
958
|
+
if (!findRows.length) return;
|
|
959
|
+
const step = ev.key === "ArrowDown" ? 1 : -1;
|
|
960
|
+
active = (active + step + findRows.length) % findRows.length;
|
|
961
|
+
paint();
|
|
962
|
+
const row = list.children[active];
|
|
963
|
+
if (row && row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
|
|
964
|
+
} else if (ev.key === "Enter") {
|
|
965
|
+
ev.preventDefault();
|
|
966
|
+
const picked = findRows[active];
|
|
967
|
+
if (picked) {
|
|
968
|
+
closeFinder();
|
|
969
|
+
openChosenFile(picked);
|
|
970
|
+
}
|
|
971
|
+
} else if (ev.key === "Escape") {
|
|
972
|
+
ev.preventDefault();
|
|
973
|
+
ev.stopPropagation();
|
|
974
|
+
closeFinder();
|
|
975
|
+
}
|
|
274
976
|
});
|
|
275
|
-
|
|
977
|
+
setTimeout(() => input.focus(), 0);
|
|
978
|
+
|
|
979
|
+
refilter();
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// ── diff stream ─────────────────────────────────────────────────────────
|
|
983
|
+
function buildFileSection(file) {
|
|
984
|
+
const kind = classify(file);
|
|
985
|
+
const section = el("section", { class: "cg-file" });
|
|
986
|
+
section.dataset.path = file.path;
|
|
987
|
+
|
|
988
|
+
const revert = el("button", { class: "cg-revert", type: "button",
|
|
989
|
+
title: t("changes.restore.title", "撤销此文件的改动") });
|
|
990
|
+
revert.innerHTML = ICON_REVERT;
|
|
991
|
+
revert.addEventListener("click", (e) => {
|
|
992
|
+
e.stopPropagation();
|
|
993
|
+
onRevertClick(revert, file.path);
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
const more = el("button", { class: "cg-icon-btn cg-head-more", type: "button",
|
|
997
|
+
title: t("changes.menu.more", "更多"), "aria-label": t("changes.menu.more", "更多") });
|
|
998
|
+
more.innerHTML = ICON_MORE;
|
|
999
|
+
more.addEventListener("click", (e) => {
|
|
1000
|
+
e.stopPropagation();
|
|
1001
|
+
if (more.classList.contains("is-open")) {
|
|
1002
|
+
closeFileMenu();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
openHeadMenu(more, file);
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
const stat = el("span", { class: "cg-file-head-stat" });
|
|
1009
|
+
const head = el("div", { class: "cg-file-head" });
|
|
1010
|
+
const caret = el("span", { class: "cg-fh-caret" });
|
|
1011
|
+
caret.innerHTML = ICON_CHEVRON;
|
|
1012
|
+
head.appendChild(caret);
|
|
1013
|
+
const icon = fileIconEl(file.path.split("/").pop());
|
|
1014
|
+
if (icon) head.appendChild(icon);
|
|
1015
|
+
head.appendChild(el("span", { class: "cg-file-head-path", text: file.path }));
|
|
1016
|
+
head.appendChild(stat);
|
|
1017
|
+
head.appendChild(revert);
|
|
1018
|
+
head.appendChild(more);
|
|
1019
|
+
section.appendChild(head);
|
|
1020
|
+
section.appendChild(el("div", { class: "cg-lines" }));
|
|
1021
|
+
updateHeadStat(section, file);
|
|
1022
|
+
const record = state.records.get(file.path);
|
|
1023
|
+
section.classList.toggle("is-collapsed", !!(record && record.collapsed));
|
|
1024
|
+
|
|
1025
|
+
section.addEventListener("click", (e) => {
|
|
1026
|
+
if (e.target.closest(".cg-fold")) {
|
|
1027
|
+
expandFile(file.path);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (e.target.closest("button") || !e.target.closest(".cg-file-head")) return;
|
|
1031
|
+
if (String(window.getSelection() || "")) return;
|
|
1032
|
+
setFileCollapsed(file.path);
|
|
1033
|
+
});
|
|
1034
|
+
renderFileBody(section, file);
|
|
1035
|
+
return section;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function updateHeadStat(section, file) {
|
|
1039
|
+
const stat = section.querySelector(".cg-file-head-stat");
|
|
1040
|
+
if (!stat) return;
|
|
1041
|
+
stat.replaceChildren();
|
|
1042
|
+
stat.appendChild(el("span", { class: "a", text: `+${file.add}` }));
|
|
1043
|
+
stat.appendChild(el("span", { class: "d", text: `-${file.del}` }));
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// ── diff layout ─────────────────────────────────────────────────────────
|
|
1047
|
+
function effectiveLayout() {
|
|
1048
|
+
if (state.layout !== "auto") return state.layout;
|
|
1049
|
+
const pane = state.els && state.els.stream ? state.els.stream.clientWidth : 0;
|
|
1050
|
+
return pane >= SPLIT_MIN_WIDTH ? "split" : "unified";
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function applyLayoutButton() {
|
|
1054
|
+
const btn = state.els && state.els.layoutBtn;
|
|
1055
|
+
if (!btn) return;
|
|
1056
|
+
const split = effectiveLayout() === "split";
|
|
1057
|
+
btn.innerHTML = state.layout === "auto"
|
|
1058
|
+
? ICON_LAYOUT_AUTO
|
|
1059
|
+
: (split ? ICON_LAYOUT_SPLIT : ICON_LAYOUT_UNIFIED);
|
|
1060
|
+
// `auto` follows the pane width, so it must not look like a pinned choice.
|
|
1061
|
+
btn.classList.toggle("is-on", state.layout !== "auto");
|
|
1062
|
+
const hint = fill(t("changes.layout.hint", "差异视图布局:{{mode}}(点击切换)"), { mode: LAYOUT_LABELS[state.layout]() });
|
|
1063
|
+
btn.setAttribute("data-tooltip", hint);
|
|
1064
|
+
btn.setAttribute("aria-label", hint);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function reflowBodies() {
|
|
1068
|
+
if (!state.els || !state.els.stream) return;
|
|
1069
|
+
state.reflow = true;
|
|
1070
|
+
state.files.forEach((file) => {
|
|
1071
|
+
const section = state.els.stream.querySelector(`section.cg-file[data-path="${CSS.escape(file.path)}"]`);
|
|
1072
|
+
if (section) renderFileBody(section, file);
|
|
1073
|
+
});
|
|
1074
|
+
state.reflow = false;
|
|
1075
|
+
applyLayoutButton();
|
|
1076
|
+
scheduleSpan();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
// Measured after layout so strips and rows can span the real scroll width. A
|
|
1080
|
+
// timed hop rather than rAF: a background tab never paints, so rAF would
|
|
1081
|
+
// stall and everything sticky would keep the pane width.
|
|
1082
|
+
function scheduleSpan() {
|
|
1083
|
+
if (state.spanTimer) return;
|
|
1084
|
+
state.spanTimer = setTimeout(() => {
|
|
1085
|
+
state.spanTimer = null;
|
|
1086
|
+
syncSpan();
|
|
1087
|
+
}, 0);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
function syncSpan() {
|
|
1091
|
+
const stream = state.els && state.els.stream;
|
|
1092
|
+
if (!stream) return;
|
|
1093
|
+
const cols = stream.querySelectorAll(".cg-col");
|
|
1094
|
+
if (effectiveLayout() === "split") {
|
|
1095
|
+
stream.style.removeProperty("--cg-span-w");
|
|
1096
|
+
for (let i = 0; i < cols.length; i++) {
|
|
1097
|
+
cols[i].style.setProperty("--cg-span-w", cols[i].scrollWidth + "px");
|
|
1098
|
+
}
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
for (let i = 0; i < cols.length; i++) cols[i].style.removeProperty("--cg-span-w");
|
|
1102
|
+
stream.style.setProperty("--cg-span-w", stream.scrollWidth + "px");
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function setLayout(mode) {
|
|
1106
|
+
if (LAYOUT_MODES.indexOf(mode) < 0) return;
|
|
1107
|
+
state.layout = mode;
|
|
1108
|
+
try { localStorage.setItem(LAYOUT_KEY, mode); } catch (_e) { /* private mode */ }
|
|
1109
|
+
reflowBodies();
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function cycleLayout() {
|
|
1113
|
+
const next = LAYOUT_MODES[(LAYOUT_MODES.indexOf(state.layout) + 1) % LAYOUT_MODES.length];
|
|
1114
|
+
setLayout(next);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function applyWrapButton() {
|
|
1118
|
+
const btn = state.els && state.els.wrapBtn;
|
|
1119
|
+
if (!btn) return;
|
|
1120
|
+
btn.classList.toggle("is-on", state.wrap);
|
|
1121
|
+
const label = t("changes.wrap", "自动换行");
|
|
1122
|
+
btn.setAttribute("data-tooltip", label);
|
|
1123
|
+
btn.setAttribute("aria-label", label);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function setWrap(on) {
|
|
1127
|
+
const next = !!on;
|
|
1128
|
+
if (next === state.wrap) return;
|
|
1129
|
+
state.wrap = next;
|
|
1130
|
+
if (state.els && state.els.root) state.els.root.classList.toggle("is-wrap", state.wrap);
|
|
1131
|
+
try { localStorage.setItem(WRAP_KEY, state.wrap ? "1" : "0"); } catch (_e) { /* private mode */ }
|
|
1132
|
+
applyWrapButton();
|
|
1133
|
+
// Side-by-side swaps between paired rows and scrolling panes, so the
|
|
1134
|
+
// bodies have to be rebuilt rather than restyled.
|
|
1135
|
+
if (effectiveLayout() === "split") reflowBodies();
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function applyTreeButton() {
|
|
1139
|
+
const els = state.els;
|
|
1140
|
+
if (!els) return;
|
|
1141
|
+
if (els.root) els.root.classList.toggle("is-tree-hidden", !state.treeVisible);
|
|
1142
|
+
const btn = els.treeBtn;
|
|
1143
|
+
if (!btn) return;
|
|
1144
|
+
const label = state.treeVisible
|
|
1145
|
+
? t("changes.tree.hide", "收起文件树")
|
|
1146
|
+
: t("changes.tree.show", "展开文件树");
|
|
1147
|
+
btn.classList.toggle("is-on", state.treeVisible);
|
|
1148
|
+
btn.setAttribute("data-tooltip", label);
|
|
1149
|
+
btn.setAttribute("aria-label", label);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function setTreeVisible(visible) {
|
|
1153
|
+
state.treeVisible = !!visible;
|
|
1154
|
+
try { localStorage.setItem(TREE_KEY, state.treeVisible ? "1" : "0"); } catch (_e) { /* private mode */ }
|
|
1155
|
+
applyTreeButton();
|
|
276
1156
|
}
|
|
277
1157
|
|
|
278
|
-
function
|
|
279
|
-
const {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
1158
|
+
function codeSpan(line, lang) {
|
|
1159
|
+
const code = el("span", { class: "cg-code" });
|
|
1160
|
+
if (!line) return code;
|
|
1161
|
+
const html = highlightLine(line.text, lang);
|
|
1162
|
+
if (html != null) code.innerHTML = html;
|
|
1163
|
+
else code.textContent = line.text === "" ? " " : line.text;
|
|
1164
|
+
return code;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function splitHalf(line, side, lang) {
|
|
1168
|
+
const half = el("span", { class: `cg-half ${side}` + (line ? ` ${line.type}` : " blank") });
|
|
1169
|
+
const no = line ? (side === "old" ? line.oldNo : line.newNo) : null;
|
|
1170
|
+
half.appendChild(el("span", { class: "cg-ln", text: no == null ? "" : String(no) }));
|
|
1171
|
+
half.appendChild(codeSpan(line, lang));
|
|
1172
|
+
return half;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// One pane of a scrolling side-by-side pair: a plain row that can grow wider
|
|
1176
|
+
// than the pane, so the pane scrolls instead of squeezing the code.
|
|
1177
|
+
function diffSide(line, side, lang) {
|
|
1178
|
+
const row = el("div", { class: `cg-line ${side}` + (line ? ` ${line.type}` : " blank") });
|
|
1179
|
+
const no = line ? (side === "old" ? line.oldNo : line.newNo) : null;
|
|
1180
|
+
row.appendChild(el("span", { class: "cg-ln", text: no == null ? "" : String(no) }));
|
|
1181
|
+
if (line) row.appendChild(codeSpan(line, lang));
|
|
1182
|
+
return row;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function syncPaneScroll(cols) {
|
|
1186
|
+
const [a, b] = cols;
|
|
1187
|
+
let busy = false;
|
|
1188
|
+
const mirror = (from, to) => () => {
|
|
1189
|
+
if (busy) return;
|
|
1190
|
+
busy = true;
|
|
1191
|
+
to.scrollLeft = from.scrollLeft;
|
|
1192
|
+
busy = false;
|
|
1193
|
+
};
|
|
1194
|
+
a.addEventListener("scroll", mirror(a, b), { passive: true });
|
|
1195
|
+
b.addEventListener("scroll", mirror(b, a), { passive: true });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// A unified hunk is a flat run of ctx/del/add lines; side-by-side needs the
|
|
1199
|
+
// deleted block and the added block that follows it turned into rows.
|
|
1200
|
+
function splitPairs(hunkLines) {
|
|
1201
|
+
const rows = [];
|
|
1202
|
+
let i = 0;
|
|
1203
|
+
while (i < hunkLines.length) {
|
|
1204
|
+
if (hunkLines[i].type === "ctx") {
|
|
1205
|
+
rows.push([hunkLines[i], hunkLines[i]]);
|
|
1206
|
+
i++;
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
const dels = [];
|
|
1210
|
+
const adds = [];
|
|
1211
|
+
while (i < hunkLines.length && hunkLines[i].type === "del") dels.push(hunkLines[i++]);
|
|
1212
|
+
while (i < hunkLines.length && hunkLines[i].type === "add") adds.push(hunkLines[i++]);
|
|
1213
|
+
const n = Math.max(dels.length, adds.length);
|
|
1214
|
+
for (let k = 0; k < n; k++) rows.push([dels[k] || null, adds[k] || null]);
|
|
1215
|
+
}
|
|
1216
|
+
return rows;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function renderFileBody(section, file) {
|
|
1220
|
+
const lines = section.querySelector(".cg-lines");
|
|
1221
|
+
lines.replaceChildren();
|
|
1222
|
+
lines.classList.remove("is-cols");
|
|
1223
|
+
const record = state.records.get(file.path) || {};
|
|
1224
|
+
const full = record.full;
|
|
1225
|
+
const kind = classify(file);
|
|
1226
|
+
|
|
1227
|
+
if (file.binary) {
|
|
1228
|
+
lines.appendChild(el("div", { class: "cg-note", text: t("changes.diff.binary", "二进制文件,无法逐行对比。") }));
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
if (!file.diff || !file.diff.trim()) {
|
|
1232
|
+
if (record.lazyPending) {
|
|
1233
|
+
renderLazy(section, file);
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
lines.appendChild(el("div", { class: "cg-note",
|
|
1237
|
+
text: kind === "add" ? t("changes.diff.emptyNew", "空的文件。") : t("changes.diff.same", "这个文件的内容没有变化。") }));
|
|
283
1238
|
return;
|
|
284
1239
|
}
|
|
285
|
-
|
|
286
|
-
|
|
1240
|
+
|
|
1241
|
+
const hunks = parseHunks(file.diff);
|
|
1242
|
+
if (!hunks.length) {
|
|
1243
|
+
lines.appendChild(el("div", { class: "cg-note", text: t("changes.diff.same", "这个文件的内容没有变化。") }));
|
|
287
1244
|
return;
|
|
288
1245
|
}
|
|
289
|
-
|
|
290
|
-
const
|
|
1246
|
+
|
|
1247
|
+
const limit = (full || record.loaded) ? MAX_RENDER_LINES : Math.min(MAX_RENDER_LINES, state.budget);
|
|
1248
|
+
if (limit <= 0) {
|
|
1249
|
+
renderLazy(section, file);
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
const lang = langFor(file.path);
|
|
1254
|
+
const split = effectiveLayout() === "split";
|
|
1255
|
+
// Pane split without wrapping gets two independent panes so the divider
|
|
1256
|
+
// stays put; wrapping keeps the paired rows so both sides line up.
|
|
1257
|
+
const panes = split && !state.wrap;
|
|
1258
|
+
lines.classList.toggle("is-cols", panes);
|
|
1259
|
+
let prevEnd = 1;
|
|
1260
|
+
let rendered = 0;
|
|
291
1261
|
const frag = document.createDocumentFragment();
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
frag.appendChild(
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
let
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1262
|
+
let oldCol = null;
|
|
1263
|
+
let newCol = null;
|
|
1264
|
+
if (panes) {
|
|
1265
|
+
const cols = el("div", { class: "cg-cols" });
|
|
1266
|
+
oldCol = el("div", { class: "cg-col old" });
|
|
1267
|
+
newCol = el("div", { class: "cg-col new" });
|
|
1268
|
+
cols.appendChild(oldCol);
|
|
1269
|
+
cols.appendChild(newCol);
|
|
1270
|
+
frag.appendChild(cols);
|
|
1271
|
+
}
|
|
1272
|
+
const addFold = (count, ghost) => {
|
|
1273
|
+
const fold = el("div", { class: "cg-fold" + (ghost ? " is-ghost" : "") });
|
|
1274
|
+
const gutter = el("span", { class: "cg-fold-gutter" });
|
|
1275
|
+
gutter.innerHTML = ICON_UNFOLD;
|
|
1276
|
+
fold.appendChild(gutter);
|
|
1277
|
+
fold.appendChild(el("span", { class: "cg-fold-label",
|
|
1278
|
+
text: fill(t("changes.fold", "{{n}} 行未修改"), { n: count }) }));
|
|
1279
|
+
fold.title = t("changes.foldHint", "点击展开");
|
|
1280
|
+
return fold;
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
for (let h = 0; h < hunks.length; h++) {
|
|
1284
|
+
const hunk = hunks[h];
|
|
1285
|
+
const gap = hunk.oldStart - prevEnd;
|
|
1286
|
+
if (!full && gap > 0) {
|
|
1287
|
+
if (panes) {
|
|
1288
|
+
oldCol.appendChild(addFold(gap, false));
|
|
1289
|
+
newCol.appendChild(addFold(gap, true));
|
|
1290
|
+
} else {
|
|
1291
|
+
frag.appendChild(addFold(gap, false));
|
|
1292
|
+
}
|
|
319
1293
|
}
|
|
1294
|
+
if (split) {
|
|
1295
|
+
const pairs = splitPairs(hunk.lines);
|
|
1296
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
1297
|
+
if (rendered >= MAX_RENDER_LINES) break;
|
|
1298
|
+
if (panes) {
|
|
1299
|
+
oldCol.appendChild(diffSide(pairs[i][0], "old", lang));
|
|
1300
|
+
newCol.appendChild(diffSide(pairs[i][1], "new", lang));
|
|
1301
|
+
rendered++;
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
const row = el("div", { class: "cg-line split" });
|
|
1305
|
+
row.appendChild(splitHalf(pairs[i][0], "old", lang));
|
|
1306
|
+
row.appendChild(splitHalf(pairs[i][1], "new", lang));
|
|
1307
|
+
frag.appendChild(row);
|
|
1308
|
+
rendered++;
|
|
1309
|
+
}
|
|
1310
|
+
} else {
|
|
1311
|
+
for (let i = 0; i < hunk.lines.length; i++) {
|
|
1312
|
+
if (rendered >= MAX_RENDER_LINES) break;
|
|
1313
|
+
const line = hunk.lines[i];
|
|
1314
|
+
const row = el("div", { class: `cg-line ${line.type}` });
|
|
1315
|
+
// One gutter per row: after an insertion the old and new numbers drift
|
|
1316
|
+
// apart, so showing both reads as a duplicated column. The new number
|
|
1317
|
+
// is what the file actually has; deletions fall back to the old one.
|
|
1318
|
+
const no = line.newNo == null ? line.oldNo : line.newNo;
|
|
1319
|
+
row.appendChild(el("span", { class: "cg-ln", text: no == null ? "" : String(no) }));
|
|
1320
|
+
row.appendChild(codeSpan(line, lang));
|
|
1321
|
+
frag.appendChild(row);
|
|
1322
|
+
rendered++;
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
if (rendered >= MAX_RENDER_LINES) break;
|
|
1326
|
+
prevEnd = hunk.oldStart + hunk.oldCount;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
lines.appendChild(frag);
|
|
1330
|
+
if (panes && oldCol && newCol) syncPaneScroll([oldCol, newCol]);
|
|
1331
|
+
scheduleSpan();
|
|
1332
|
+
if (!full && !record.loaded && !state.reflow) state.budget -= rendered;
|
|
1333
|
+
if (file.truncated || rendered >= limit) {
|
|
1334
|
+
lines.appendChild(el("div", { class: "cg-note", text: t("changes.diff.truncated", "差异过大,仅显示前 {{n}} 行").replace("{{n}}", String(limit)) }));
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Placeholder for a file whose diff is withheld (too big a payload) or whose
|
|
1339
|
+
// slot fell past the render budget. Clicking pulls just this file.
|
|
1340
|
+
function renderLazy(section, file) {
|
|
1341
|
+
const lines = section.querySelector(".cg-lines");
|
|
1342
|
+
const record = state.records.get(file.path) || {};
|
|
1343
|
+
const canLoad = state.scope !== "lastTurn" || !!(record.raw && record.raw.trim());
|
|
1344
|
+
const box = el("div", { class: `cg-lazy${canLoad ? "" : " is-static"}`,
|
|
1345
|
+
text: canLoad
|
|
1346
|
+
? t("changes.diff.lazy", "改动较大,点按加载此文件的差异")
|
|
1347
|
+
: t("changes.diff.skipped", "差异过大,未在此处显示") });
|
|
1348
|
+
if (canLoad) {
|
|
1349
|
+
box.addEventListener("click", (e) => {
|
|
1350
|
+
e.stopPropagation();
|
|
1351
|
+
loadFileDiff(file.path);
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
lines.appendChild(box);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
async function loadFileDiff(path) {
|
|
1358
|
+
const record = state.records.get(path);
|
|
1359
|
+
if (!record || record.loading) return;
|
|
1360
|
+
const section = state.els.stream.querySelector(`section.cg-file[data-path="${CSS.escape(path)}"]`);
|
|
1361
|
+
if (!section) return;
|
|
1362
|
+
|
|
1363
|
+
if (state.scope === "lastTurn") {
|
|
1364
|
+
if (!record.raw || !record.raw.trim()) return;
|
|
1365
|
+
record.file.diff = record.raw;
|
|
1366
|
+
record.loaded = true;
|
|
1367
|
+
renderFileBody(section, record.file);
|
|
320
1368
|
return;
|
|
321
1369
|
}
|
|
322
|
-
|
|
323
|
-
|
|
1370
|
+
|
|
1371
|
+
record.loading = true;
|
|
1372
|
+
const box = section.querySelector(".cg-lazy");
|
|
1373
|
+
if (box) box.textContent = t("changes.diff.loading", "加载差异…");
|
|
1374
|
+
try {
|
|
1375
|
+
const res = await api(state.sessionId, `diff?file=${encodeURIComponent(path)}&context=3&${scopeQuery()}`);
|
|
1376
|
+
if (res && typeof res.diff === "string") {
|
|
1377
|
+
record.file.diff = res.diff;
|
|
1378
|
+
record.raw = res.diff;
|
|
1379
|
+
record.loaded = true;
|
|
1380
|
+
renderFileBody(section, record.file);
|
|
1381
|
+
}
|
|
1382
|
+
} catch (_e) {
|
|
1383
|
+
Modal.toast(t("changes.diff.fail", "读取差异失败"), "error");
|
|
1384
|
+
renderLazy(section, record.file);
|
|
1385
|
+
} finally {
|
|
1386
|
+
record.loading = false;
|
|
1387
|
+
}
|
|
324
1388
|
}
|
|
325
1389
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
1390
|
+
const EMPTY_BY_SCOPE = {
|
|
1391
|
+
lastTurn: () => t("changes.scope.emptyLastTurn", "上一轮没有改动记录。"),
|
|
1392
|
+
worktree: () => t("changes.empty", "自上次提交以来没有改动。"),
|
|
1393
|
+
unstaged: () => t("changes.scope.emptyUnstaged", "工作区没有未暂存的改动。"),
|
|
1394
|
+
staged: () => t("changes.scope.emptyStaged", "暂存区没有改动。"),
|
|
1395
|
+
committed: () => t("changes.scope.emptyCommitted", "当前分支相对基线没有提交。"),
|
|
1396
|
+
branch: () => t("changes.scope.emptyCommitted", "当前分支相对基线没有提交。"),
|
|
1397
|
+
};
|
|
1398
|
+
|
|
1399
|
+
function renderStream() {
|
|
1400
|
+
const stream = state.els.stream;
|
|
1401
|
+
stream.replaceChildren();
|
|
1402
|
+
state.records = new Map();
|
|
1403
|
+
state.renderTruncated = 0;
|
|
1404
|
+
state.budget = VIEW_BUDGET_LINES;
|
|
1405
|
+
if (!state.files.length) {
|
|
1406
|
+
const text = state.note === "noBase"
|
|
1407
|
+
? t("changes.scope.noBase", "没有可比较的基线分支。")
|
|
1408
|
+
: (EMPTY_BY_SCOPE[state.scope] || EMPTY_BY_SCOPE.worktree)();
|
|
1409
|
+
stream.appendChild(el("div", { class: "cg-empty", text }));
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
const frag = document.createDocumentFragment();
|
|
1413
|
+
state.files.forEach((file) => {
|
|
1414
|
+
state.records.set(file.path, { file: file, full: false, raw: file.diff,
|
|
1415
|
+
lazyPending: !!file.lazy });
|
|
1416
|
+
frag.appendChild(buildFileSection(file));
|
|
1417
|
+
});
|
|
1418
|
+
stream.appendChild(frag);
|
|
1419
|
+
updateActiveRow();
|
|
333
1420
|
}
|
|
334
1421
|
|
|
335
|
-
function
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1422
|
+
function focusFile(path) {
|
|
1423
|
+
const section = state.els.stream.querySelector(`section.cg-file[data-path="${CSS.escape(path)}"]`);
|
|
1424
|
+
state.activePath = path;
|
|
1425
|
+
if (section) {
|
|
1426
|
+
setFileCollapsed(path, false);
|
|
1427
|
+
// +1 lands the header just inside the scroll box so the stub of the
|
|
1428
|
+
// previous file's last row cannot show above it.
|
|
1429
|
+
state.els.stream.scrollTop = section.offsetTop + 1;
|
|
1430
|
+
updateActiveRow();
|
|
1431
|
+
if (section.querySelector(".cg-lazy")) loadFileDiff(path);
|
|
1432
|
+
}
|
|
1433
|
+
highlightTreeRow();
|
|
343
1434
|
}
|
|
344
1435
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
1436
|
+
function highlightTreeRow() {
|
|
1437
|
+
state.els.tree.querySelectorAll(".cg-file-row").forEach((row) => {
|
|
1438
|
+
row.classList.toggle("active", row.dataset.path === state.activePath);
|
|
1439
|
+
});
|
|
1440
|
+
}
|
|
349
1441
|
|
|
350
|
-
|
|
351
|
-
|
|
1442
|
+
function updateActiveRow() {
|
|
1443
|
+
const stream = state.els.stream;
|
|
1444
|
+
const sections = stream.querySelectorAll("section.cg-file");
|
|
1445
|
+
if (!sections.length) return;
|
|
1446
|
+
const atBottom = stream.scrollTop + stream.clientHeight >= stream.scrollHeight - 2;
|
|
1447
|
+
const top = stream.scrollTop + 1;
|
|
1448
|
+
let current = sections[0];
|
|
1449
|
+
if (atBottom) {
|
|
1450
|
+
// The last section can be shorter than the viewport, so it can never reach
|
|
1451
|
+
// the top edge; without this the row above would stay highlighted.
|
|
1452
|
+
current = sections[sections.length - 1];
|
|
1453
|
+
} else {
|
|
1454
|
+
for (let i = 0; i < sections.length; i++) {
|
|
1455
|
+
if (sections[i].offsetTop <= top + 30) current = sections[i];
|
|
1456
|
+
else break;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (current.dataset.path !== state.activePath) {
|
|
1460
|
+
state.activePath = current.dataset.path;
|
|
1461
|
+
highlightTreeRow();
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
352
1464
|
|
|
353
|
-
|
|
354
|
-
|
|
1465
|
+
async function expandFile(path) {
|
|
1466
|
+
const record = state.records.get(path);
|
|
1467
|
+
if (!record) return;
|
|
1468
|
+
if (record.full) return;
|
|
1469
|
+
// Turn snapshots only carry three lines of context; git cannot reproduce
|
|
1470
|
+
// them, so the collapsed gaps stay collapsed in that scope.
|
|
1471
|
+
if (state.scope === "lastTurn") return;
|
|
1472
|
+
const section = state.els.stream.querySelector(`section.cg-file[data-path="${CSS.escape(path)}"]`);
|
|
1473
|
+
if (!section) return;
|
|
1474
|
+
if (record.loading) return;
|
|
1475
|
+
const token = record.token || 0;
|
|
1476
|
+
record.loading = true;
|
|
355
1477
|
try {
|
|
356
|
-
const res = await api(sessionId,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
refresh(sessionId, ctx);
|
|
364
|
-
} else {
|
|
365
|
-
hint.textContent = res.error || t("changes.save.failed", "存档失败");
|
|
366
|
-
saveBtn.disabled = false;
|
|
1478
|
+
const res = await api(state.sessionId, `diff?file=${encodeURIComponent(path)}&context=${FULL_CONTEXT}&${scopeQuery()}`);
|
|
1479
|
+
// Cancelled by collapseAll while the request was in flight.
|
|
1480
|
+
if ((record.token || 0) !== token) return;
|
|
1481
|
+
if (res && typeof res.diff === "string") {
|
|
1482
|
+
record.file.diff = res.diff;
|
|
1483
|
+
record.full = true;
|
|
1484
|
+
renderFileBody(section, record.file);
|
|
367
1485
|
}
|
|
368
1486
|
} catch (_e) {
|
|
369
|
-
|
|
370
|
-
|
|
1487
|
+
Modal.toast(t("changes.diff.fail", "读取差异失败"), "error");
|
|
1488
|
+
} finally {
|
|
1489
|
+
record.loading = false;
|
|
371
1490
|
}
|
|
372
1491
|
}
|
|
373
1492
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
1493
|
+
function setExpandState(on) {
|
|
1494
|
+
const btn = state.els.expandBtn;
|
|
1495
|
+
if (!btn) return;
|
|
1496
|
+
btn.classList.toggle("is-on", on);
|
|
1497
|
+
const label = on ? t("changes.collapseAll", "收起全部") : t("changes.expandAll", "展开全部");
|
|
1498
|
+
btn.setAttribute("data-tooltip", label);
|
|
1499
|
+
btn.setAttribute("aria-label", label);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function expandAll() {
|
|
1503
|
+
setExpandState(true);
|
|
1504
|
+
state.records.forEach((record, path) => {
|
|
1505
|
+
if (record.full || record.lazyPending || !record.file.diff) return;
|
|
1506
|
+
expandFile(path);
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// Whole-section fold: the header stays as a one-line summary, the diff body
|
|
1511
|
+
// is hidden by CSS so the parsed rows are kept and unfold instantly.
|
|
1512
|
+
function setFileCollapsed(path, collapsed) {
|
|
1513
|
+
const record = state.records.get(path);
|
|
1514
|
+
if (!record) return;
|
|
1515
|
+
const next = collapsed == null ? !record.collapsed : !!collapsed;
|
|
1516
|
+
if (next === !!record.collapsed) return;
|
|
1517
|
+
record.collapsed = next;
|
|
1518
|
+
const section = state.els.stream.querySelector(`section.cg-file[data-path="${CSS.escape(path)}"]`);
|
|
1519
|
+
if (section) section.classList.toggle("is-collapsed", next);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function collapseAll() {
|
|
1523
|
+
setExpandState(false);
|
|
1524
|
+
state.records.forEach((record, path) => {
|
|
1525
|
+
record.token = (record.token || 0) + 1;
|
|
1526
|
+
if (!record.full) return;
|
|
1527
|
+
record.full = false;
|
|
1528
|
+
record.file.diff = record.raw;
|
|
1529
|
+
const section = state.els.stream.querySelector(`section.cg-file[data-path="${CSS.escape(path)}"]`);
|
|
1530
|
+
if (section) renderFileBody(section, record.file);
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
378
1533
|
|
|
1534
|
+
// ── revert (single file, destructive) ──────────────────────────────────
|
|
379
1535
|
let revertArmed = null;
|
|
380
1536
|
let revertTimer = null;
|
|
381
1537
|
let revertPrevHtml = null;
|
|
@@ -408,122 +1564,437 @@
|
|
|
408
1564
|
}
|
|
409
1565
|
|
|
410
1566
|
async function doRevert(path) {
|
|
411
|
-
const { hint } = state.els;
|
|
412
|
-
hint.textContent = t("changes.restore.doing", "正在撤销…");
|
|
413
|
-
let res;
|
|
414
1567
|
try {
|
|
415
|
-
res = await api(state.sessionId, "restore", {
|
|
1568
|
+
const res = await api(state.sessionId, "restore", {
|
|
416
1569
|
method: "POST",
|
|
417
1570
|
headers: { "Content-Type": "application/json" },
|
|
418
1571
|
body: JSON.stringify({ file: path }),
|
|
419
1572
|
});
|
|
1573
|
+
if (res.ok) {
|
|
1574
|
+
Modal.toast(fill(t("changes.restore.done", "已撤销:{{path}}"), { path: path }), "success");
|
|
1575
|
+
refresh(state.sessionId, state.ctx);
|
|
1576
|
+
} else {
|
|
1577
|
+
Modal.toast(res.error || t("changes.restore.failed", "撤销失败"), "error");
|
|
1578
|
+
}
|
|
420
1579
|
} catch (_e) {
|
|
421
|
-
|
|
422
|
-
return;
|
|
1580
|
+
Modal.toast(t("changes.restore.failed", "撤销失败"), "error");
|
|
423
1581
|
}
|
|
424
|
-
if (!res.ok) {
|
|
425
|
-
hint.textContent = res.error || t("changes.restore.failed", "撤销失败");
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
hint.textContent = t("changes.restore.done", "已撤销:{{path}}").replace("{{path}}", path);
|
|
429
|
-
refresh(state.sessionId, state.ctx);
|
|
430
1582
|
}
|
|
431
1583
|
|
|
1584
|
+
// ── toolbar actions ────────────────────────────────────────────────────
|
|
1585
|
+
// ── refresh ────────────────────────────────────────────────────────────
|
|
432
1586
|
function renderNotice(message, cls) {
|
|
433
1587
|
if (!state.els) return;
|
|
434
|
-
closeDiff();
|
|
435
|
-
state.els.h.textContent = "";
|
|
436
|
-
state.els.sub.textContent = "";
|
|
437
|
-
state.els.branchNote.style.display = "none";
|
|
438
|
-
state.els.listEl.replaceChildren(el("div", { class: cls, text: message }));
|
|
439
1588
|
state.files = [];
|
|
440
|
-
state.
|
|
441
|
-
state.
|
|
442
|
-
|
|
1589
|
+
state.records = new Map();
|
|
1590
|
+
state.els.stats.replaceChildren();
|
|
1591
|
+
state.els.tree.replaceChildren();
|
|
1592
|
+
state.els.stream.replaceChildren(el("div", { class: cls, text: message }));
|
|
443
1593
|
}
|
|
444
1594
|
|
|
445
1595
|
async function refresh(sessionId, ctx) {
|
|
446
1596
|
if (!state.els) return;
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
let
|
|
1597
|
+
closeScopeMenu();
|
|
1598
|
+
closeFinder();
|
|
1599
|
+
let data;
|
|
450
1600
|
try {
|
|
451
|
-
|
|
1601
|
+
data = await api(sessionId, `review?${scopeQuery()}`);
|
|
452
1602
|
} catch (_e) {
|
|
453
|
-
renderNotice(t("changes.error", "读取改动失败"), "
|
|
1603
|
+
renderNotice(t("changes.error", "读取改动失败"), "cg-error");
|
|
454
1604
|
return;
|
|
455
1605
|
}
|
|
456
|
-
if (!
|
|
1606
|
+
if (!data.repo) {
|
|
457
1607
|
if (ctx && ctx.setBadge) ctx.setBadge(null);
|
|
458
|
-
renderNotice(t("changes.noRepo", "这个项目还没有启用版本管理。"), "
|
|
1608
|
+
renderNotice(t("changes.noRepo", "这个项目还没有启用版本管理。"), "cg-empty");
|
|
459
1609
|
return;
|
|
460
1610
|
}
|
|
461
1611
|
|
|
462
1612
|
state.sessionId = sessionId;
|
|
463
|
-
|
|
1613
|
+
state.files = data.files || [];
|
|
1614
|
+
state.note = data.note || null;
|
|
1615
|
+
state.branch = data.branch || "";
|
|
1616
|
+
// A fresh "branch" scope has no pick yet: adopt the base the server
|
|
1617
|
+
// resolved (upstream / origin/main) so the compare pill shows a real name
|
|
1618
|
+
// instead of a placeholder. "committed" never does this - its base is the
|
|
1619
|
+
// upstream, not one of the commits listed in the menu.
|
|
1620
|
+
if (state.scope === "branch" && !state.base && data.base) state.base = data.base;
|
|
1621
|
+
if (ctx && ctx.setBadge) ctx.setBadge(state.files.length || null);
|
|
1622
|
+
|
|
1623
|
+
renderStats(data);
|
|
1624
|
+
renderTree();
|
|
1625
|
+
renderStream();
|
|
1626
|
+
|
|
1627
|
+
// The pill names the head of the unpushed range, so the list has to be
|
|
1628
|
+
// there even when the menu was never opened.
|
|
1629
|
+
if (state.scope === "committed" && !state.commits) {
|
|
1630
|
+
loadCommits().then(() => {
|
|
1631
|
+
if (state.sessionId === sessionId && state.scope === "committed") renderStats(data);
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
464
1635
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
.
|
|
1636
|
+
async function loadCommits() {
|
|
1637
|
+
if (state.commits) return state.commits;
|
|
1638
|
+
try {
|
|
1639
|
+
const data = await api(state.sessionId, "log?limit=60&unpushed=1");
|
|
1640
|
+
state.commits = (data && data.commits) || [];
|
|
1641
|
+
} catch (_e) {
|
|
1642
|
+
state.commits = [];
|
|
1643
|
+
}
|
|
1644
|
+
return state.commits;
|
|
1645
|
+
}
|
|
472
1646
|
|
|
473
|
-
|
|
1647
|
+
const SCOPE_LABELS = {
|
|
1648
|
+
lastTurn: () => t("changes.scope.lastTurn", "上一轮"),
|
|
1649
|
+
worktree: () => t("changes.scope.worktree", "未提交"),
|
|
1650
|
+
unstaged: () => t("changes.scope.unstaged", "未暂存"),
|
|
1651
|
+
staged: () => t("changes.scope.staged", "已暂存"),
|
|
1652
|
+
committed: () => t("changes.scope.committed", "已提交"),
|
|
1653
|
+
branch: () => t("changes.scope.branch", "分支"),
|
|
1654
|
+
};
|
|
474
1655
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
1656
|
+
// The pill always names the scope; what it is compared against rides in the
|
|
1657
|
+
// muted subject (a commit) or in the separate compare pill (a branch).
|
|
1658
|
+
function scopeLabel() {
|
|
1659
|
+
const fn = SCOPE_LABELS[state.scope] || SCOPE_LABELS.worktree;
|
|
1660
|
+
return fn();
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// What the pill names: the commit the user picked, or - with no pick - the
|
|
1664
|
+
// size of the unpushed range the counts add up, so the text and the numbers
|
|
1665
|
+
// never describe different things.
|
|
1666
|
+
function scopeSubject() {
|
|
1667
|
+
if (state.scope !== "committed") return "";
|
|
1668
|
+
const all = state.commits || [];
|
|
1669
|
+
if (!state.commit) {
|
|
1670
|
+
if (!all.length) return "";
|
|
1671
|
+
return fill(t("changes.scope.commitCount", "{{n}} 个提交"), { n: all.length });
|
|
483
1672
|
}
|
|
484
|
-
|
|
1673
|
+
const hit = all.find((c) => c.hash === state.commit);
|
|
1674
|
+
return hit ? hit.subject : state.commit.slice(0, 8);
|
|
1675
|
+
}
|
|
485
1676
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
1677
|
+
function renderStats(data) {
|
|
1678
|
+
const stats = state.els.stats;
|
|
1679
|
+
stats.replaceChildren();
|
|
1680
|
+
|
|
1681
|
+
const scopeBtn = el("button", { class: "cg-scope", type: "button",
|
|
1682
|
+
title: t("changes.scope.title", "查看范围") });
|
|
1683
|
+
scopeBtn.appendChild(el("span", { class: "cg-scope-label", text: scopeLabel() }));
|
|
1684
|
+
const caret = el("span", { class: "cg-caret" });
|
|
1685
|
+
caret.innerHTML = ICON_CARET_DOWN;
|
|
1686
|
+
scopeBtn.appendChild(caret);
|
|
1687
|
+
const subject = scopeSubject();
|
|
1688
|
+
if (subject) {
|
|
1689
|
+
scopeBtn.appendChild(el("span", { class: "cg-scope-subject", text: subject, title: subject }));
|
|
1690
|
+
}
|
|
1691
|
+
if (state.files.length) {
|
|
1692
|
+
scopeBtn.appendChild(el("span", { class: "cg-bar-add", text: `+${data.add || 0}` }));
|
|
1693
|
+
scopeBtn.appendChild(el("span", { class: "cg-bar-del", text: `−${data.del || 0}` }));
|
|
1694
|
+
}
|
|
1695
|
+
scopeBtn.addEventListener("click", (ev) => {
|
|
1696
|
+
ev.stopPropagation();
|
|
1697
|
+
openScopeMenu(scopeBtn);
|
|
1698
|
+
});
|
|
1699
|
+
stats.appendChild(scopeBtn);
|
|
1700
|
+
|
|
1701
|
+
const compare = comparePill(data);
|
|
1702
|
+
if (compare) stats.appendChild(compare);
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
// `head → base`: which pair of branches is on screen. Only the right-hand
|
|
1706
|
+
// side is a picker - the left one is whatever HEAD points at, so it stays
|
|
1707
|
+
// plain text rather than pretending to be selectable.
|
|
1708
|
+
function comparePill(data) {
|
|
1709
|
+
if (state.scope !== "branch") return null;
|
|
1710
|
+
const head = state.branch || "HEAD";
|
|
1711
|
+
const base = state.base || data.base || t("changes.compare.unset", "选择对比分支");
|
|
1712
|
+
const btn = el("button", { class: "cg-scope cg-compare", type: "button",
|
|
1713
|
+
title: t("changes.compare.title", "选择对比分支") });
|
|
1714
|
+
btn.appendChild(el("span", { class: "cg-scope-head", text: head }));
|
|
1715
|
+
const arrow = el("span", { class: "cg-compare-arrow" });
|
|
1716
|
+
arrow.innerHTML = ICON_ARROW_RIGHT;
|
|
1717
|
+
btn.appendChild(arrow);
|
|
1718
|
+
btn.appendChild(el("span", { class: "cg-scope-base", text: base }));
|
|
1719
|
+
const caret = el("span", { class: "cg-caret" });
|
|
1720
|
+
caret.innerHTML = ICON_CARET_DOWN;
|
|
1721
|
+
btn.appendChild(caret);
|
|
1722
|
+
btn.addEventListener("click", (ev) => {
|
|
1723
|
+
ev.stopPropagation();
|
|
1724
|
+
openRefMenu(btn, "branches", (name) => applyScope("branch", name));
|
|
1725
|
+
});
|
|
1726
|
+
return btn;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
// ── scope selector ──────────────────────────────────────────────────────
|
|
1730
|
+
// The pill above the file tree switches which slice of history is on screen.
|
|
1731
|
+
// "已提交" opens a second-level list of commits (the comparison base defaults
|
|
1732
|
+
// to the upstream); "分支" is a plain row - picking it swaps the toolbar to
|
|
1733
|
+
// the `head → base` compare pill, where the base is chosen.
|
|
1734
|
+
let scopeMenuEl = null;
|
|
1735
|
+
let refMenuEl = null;
|
|
1736
|
+
let refAnchor = null;
|
|
1737
|
+
let refSearch = "";
|
|
1738
|
+
|
|
1739
|
+
function closeRefMenu() {
|
|
1740
|
+
if (refMenuEl) {
|
|
1741
|
+
refMenuEl.remove();
|
|
1742
|
+
refMenuEl = null;
|
|
492
1743
|
}
|
|
1744
|
+
if (refAnchor) {
|
|
1745
|
+
refAnchor.classList.remove("is-open");
|
|
1746
|
+
refAnchor = null;
|
|
1747
|
+
}
|
|
1748
|
+
if (scopeMenuEl) {
|
|
1749
|
+
scopeMenuEl.querySelectorAll(".cg-scope-item.is-open").forEach((r) => r.classList.remove("is-open"));
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
493
1752
|
|
|
494
|
-
|
|
495
|
-
|
|
1753
|
+
function closeScopeMenu() {
|
|
1754
|
+
closeRefMenu();
|
|
1755
|
+
if (scopeMenuEl) {
|
|
1756
|
+
scopeMenuEl.remove();
|
|
1757
|
+
scopeMenuEl = null;
|
|
1758
|
+
}
|
|
1759
|
+
document.querySelectorAll(".cg-scope.is-open").forEach((b) => b.classList.remove("is-open"));
|
|
1760
|
+
}
|
|
496
1761
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1762
|
+
function scopeMenuItems() {
|
|
1763
|
+
return [
|
|
1764
|
+
{ scope: "lastTurn" },
|
|
1765
|
+
{ sep: true },
|
|
1766
|
+
{ scope: "worktree" },
|
|
1767
|
+
{ scope: "unstaged" },
|
|
1768
|
+
{ scope: "staged" },
|
|
1769
|
+
{ sep: true },
|
|
1770
|
+
{ scope: "committed", submenu: "commits" },
|
|
1771
|
+
{ scope: "branch" },
|
|
1772
|
+
];
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function positionMenu(menu, anchor) {
|
|
1776
|
+
const r = anchor.getBoundingClientRect();
|
|
1777
|
+
menu.style.top = r.bottom + 6 + "px";
|
|
1778
|
+
menu.style.left = r.left + "px";
|
|
1779
|
+
requestAnimationFrame(() => {
|
|
1780
|
+
const m = menu.getBoundingClientRect();
|
|
1781
|
+
if (m.right > window.innerWidth - 8) menu.style.left = Math.max(8, window.innerWidth - m.width - 8) + "px";
|
|
1782
|
+
if (m.bottom > window.innerHeight - 8) menu.style.top = Math.max(8, r.top - m.height - 6) + "px";
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
function openScopeMenu(anchor) {
|
|
1787
|
+
const wasOpen = anchor.classList.contains("is-open");
|
|
1788
|
+
closeScopeMenu();
|
|
1789
|
+
if (wasOpen) return;
|
|
1790
|
+
|
|
1791
|
+
anchor.classList.add("is-open");
|
|
1792
|
+
const menu = el("div", { class: "cg-scope-menu" });
|
|
1793
|
+
scopeMenuItems().forEach((item) => {
|
|
1794
|
+
if (item.sep) {
|
|
1795
|
+
menu.appendChild(el("div", { class: "cg-scope-sep" }));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
const row = el("div", { class: "cg-scope-item", "data-scope": item.scope });
|
|
1799
|
+
row.appendChild(el("span", { text: SCOPE_LABELS[item.scope]() }));
|
|
1800
|
+
if (item.submenu) {
|
|
1801
|
+
const arrow = el("span", { class: "cg-scope-arrow" });
|
|
1802
|
+
arrow.innerHTML = ICON_CHEVRON;
|
|
1803
|
+
row.appendChild(arrow);
|
|
1804
|
+
} else if (state.scope === item.scope) {
|
|
1805
|
+
row.classList.add("is-checked");
|
|
1806
|
+
const check = el("span", { class: "cg-scope-check" });
|
|
1807
|
+
check.innerHTML = ICON_CHECK;
|
|
1808
|
+
row.appendChild(check);
|
|
1809
|
+
}
|
|
1810
|
+
const openSub = () => {
|
|
1811
|
+
if (item.submenu === "commits") {
|
|
1812
|
+
openRefMenu(row, "commits", (hash) => applyScope("committed", hash), { side: menu });
|
|
1813
|
+
} else {
|
|
1814
|
+
closeRefMenu();
|
|
1815
|
+
}
|
|
1816
|
+
};
|
|
1817
|
+
row.addEventListener("mouseenter", () => {
|
|
1818
|
+
openSub();
|
|
1819
|
+
menu.querySelectorAll(".cg-scope-item").forEach((r) => {
|
|
1820
|
+
r.classList.toggle("is-open", r === row && !!item.submenu);
|
|
1821
|
+
});
|
|
1822
|
+
});
|
|
1823
|
+
row.addEventListener("click", (ev) => {
|
|
1824
|
+
ev.stopPropagation();
|
|
1825
|
+
if (item.submenu) openSub();
|
|
1826
|
+
else applyScope(item.scope, "");
|
|
1827
|
+
});
|
|
1828
|
+
menu.appendChild(row);
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
menu.addEventListener("click", (ev) => ev.stopPropagation());
|
|
1832
|
+
document.body.appendChild(menu);
|
|
1833
|
+
scopeMenuEl = menu;
|
|
1834
|
+
positionMenu(menu, anchor);
|
|
1835
|
+
setTimeout(() => document.addEventListener("click", closeScopeMenu, { once: true }), 0);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// The submenu hangs off the row that opened it: aligned to that row (not to
|
|
1839
|
+
// the top of the parent menu, which would look detached from the hovered
|
|
1840
|
+
// item) and flipped to the left when it would overflow the viewport.
|
|
1841
|
+
function positionSubmenu(sub, menu, anchorRow) {
|
|
1842
|
+
const m = menu.getBoundingClientRect();
|
|
1843
|
+
const r = (anchorRow || menu).getBoundingClientRect();
|
|
1844
|
+
sub.style.top = r.top + "px";
|
|
1845
|
+
sub.style.left = m.right + 6 + "px";
|
|
1846
|
+
requestAnimationFrame(() => {
|
|
1847
|
+
const s = sub.getBoundingClientRect();
|
|
1848
|
+
if (s.right > window.innerWidth - 8) sub.style.left = Math.max(8, m.left - s.width - 6) + "px";
|
|
1849
|
+
if (s.bottom > window.innerHeight - 8) sub.style.top = Math.max(8, window.innerHeight - s.height - 8) + "px";
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// A dropdown list of refs: branches when choosing a comparison base, commits
|
|
1854
|
+
// when pinning "committed" to something other than the upstream. Both share
|
|
1855
|
+
// the search box and row styling; only the row content differs.
|
|
1856
|
+
function renderRefList(listEl, kind, onPick) {
|
|
1857
|
+
listEl.replaceChildren();
|
|
1858
|
+
const q = refSearch.trim().toLowerCase();
|
|
1859
|
+
const all = kind === "branches" ? state.branches : state.commits;
|
|
1860
|
+
if (!all) {
|
|
1861
|
+
const text = kind === "branches"
|
|
1862
|
+
? t("changes.scope.loadingBranches", "正在读取分支…")
|
|
1863
|
+
: t("changes.scope.loadingCommits", "正在读取提交…");
|
|
1864
|
+
listEl.appendChild(el("div", { class: "cg-scope-empty", text: text }));
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
const rows = all.filter((item) => {
|
|
1868
|
+
const hay = kind === "branches" ? item.name : `${item.subject} ${item.short}`;
|
|
1869
|
+
return !q || hay.toLowerCase().includes(q);
|
|
1870
|
+
});
|
|
1871
|
+
if (!rows.length) {
|
|
1872
|
+
let text;
|
|
1873
|
+
if (kind === "branches") text = t("changes.scope.noBranches", "没有匹配的分支");
|
|
1874
|
+
else if (!all.length) text = t("changes.scope.noUnpushed", "没有未推送的提交");
|
|
1875
|
+
else text = t("changes.scope.noCommits", "没有匹配的提交");
|
|
1876
|
+
listEl.appendChild(el("div", { class: "cg-scope-empty", text: text }));
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
rows.forEach((item) => {
|
|
1880
|
+
if (kind === "branches") {
|
|
1881
|
+
const checked = state.scope === "branch" && state.base === item.name;
|
|
1882
|
+
const row = el("div", { class: "cg-scope-branch" + (checked ? " is-checked" : ""), "data-branch": item.name });
|
|
1883
|
+
const icon = el("span", {});
|
|
1884
|
+
icon.innerHTML = ICON_BRANCH;
|
|
1885
|
+
row.appendChild(icon);
|
|
1886
|
+
row.appendChild(el("span", { class: "cg-scope-name", text: item.name }));
|
|
1887
|
+
const check = el("span", { class: "cg-scope-check" });
|
|
1888
|
+
check.innerHTML = ICON_CHECK;
|
|
1889
|
+
row.appendChild(check);
|
|
1890
|
+
row.addEventListener("click", (ev) => {
|
|
1891
|
+
ev.stopPropagation();
|
|
1892
|
+
onPick(item.name);
|
|
1893
|
+
});
|
|
1894
|
+
listEl.appendChild(row);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Every row reviews that commit on its own: the panel answers "what did
|
|
1899
|
+
// this commit change", which is what picking a commit in the list means.
|
|
1900
|
+
const checked = state.commit === item.hash;
|
|
1901
|
+
const row = el("div", { class: "cg-scope-commit" + (state.scope === "committed" && checked ? " is-checked" : "") });
|
|
1902
|
+
row.appendChild(el("span", { class: "cg-scope-subject", text: item.subject, title: item.subject }));
|
|
1903
|
+
row.appendChild(el("span", { class: "cg-scope-sha", text: item.short }));
|
|
1904
|
+
const check = el("span", { class: "cg-scope-check" });
|
|
1905
|
+
check.innerHTML = ICON_CHECK;
|
|
1906
|
+
row.appendChild(check);
|
|
1907
|
+
row.addEventListener("click", (ev) => {
|
|
1908
|
+
ev.stopPropagation();
|
|
1909
|
+
onPick(item.hash);
|
|
1910
|
+
});
|
|
1911
|
+
listEl.appendChild(row);
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
async function openRefMenu(anchor, kind, onPick, opts) {
|
|
1916
|
+
const o = opts || {};
|
|
1917
|
+
const wasOpen = !!(refMenuEl && refMenuEl.dataset.kind === kind && anchor.classList.contains("is-open"));
|
|
1918
|
+
if (o.side) closeRefMenu();
|
|
1919
|
+
else closeScopeMenu();
|
|
1920
|
+
if (wasOpen) return;
|
|
1921
|
+
refSearch = "";
|
|
1922
|
+
|
|
1923
|
+
const sub = el("div", { class: "cg-scope-sub", "data-kind": kind });
|
|
1924
|
+
const searchWrap = el("div", { class: "cg-scope-search" });
|
|
1925
|
+
const searchIcon = el("span", {});
|
|
1926
|
+
searchIcon.innerHTML = ICON_SEARCH;
|
|
1927
|
+
searchWrap.appendChild(searchIcon);
|
|
1928
|
+
const placeholder = kind === "branches"
|
|
1929
|
+
? t("changes.scope.searchBranch", "搜索分支")
|
|
1930
|
+
: t("changes.scope.searchCommit", "搜索提交");
|
|
1931
|
+
const input = el("input", { type: "search", placeholder: placeholder });
|
|
1932
|
+
searchWrap.appendChild(input);
|
|
1933
|
+
const list = el("div", { class: "cg-scope-list" });
|
|
1934
|
+
const group = kind === "branches"
|
|
1935
|
+
? t("changes.scope.branch", "分支")
|
|
1936
|
+
: t("changes.scope.unpushedCommits", "未推送的提交");
|
|
1937
|
+
sub.appendChild(searchWrap);
|
|
1938
|
+
sub.appendChild(el("div", { class: "cg-scope-group", text: group }));
|
|
1939
|
+
sub.appendChild(list);
|
|
1940
|
+
sub.addEventListener("click", (ev) => ev.stopPropagation());
|
|
1941
|
+
document.body.appendChild(sub);
|
|
1942
|
+
refMenuEl = sub;
|
|
1943
|
+
refAnchor = anchor;
|
|
1944
|
+
anchor.classList.add("is-open");
|
|
1945
|
+
if (o.side) positionSubmenu(sub, o.side, anchor);
|
|
1946
|
+
else positionMenu(sub, anchor);
|
|
1947
|
+
setTimeout(() => document.addEventListener("click", closeScopeMenu, { once: true }), 0);
|
|
1948
|
+
|
|
1949
|
+
input.addEventListener("input", () => {
|
|
1950
|
+
refSearch = input.value;
|
|
1951
|
+
renderRefList(list, kind, onPick);
|
|
1952
|
+
});
|
|
1953
|
+
renderRefList(list, kind, onPick);
|
|
1954
|
+
setTimeout(() => input.focus(), 0);
|
|
1955
|
+
|
|
1956
|
+
const cached = kind === "branches" ? state.branches : state.commits;
|
|
1957
|
+
if (!cached) {
|
|
1958
|
+
if (kind === "branches") {
|
|
1959
|
+
try {
|
|
1960
|
+
const data = await api(state.sessionId, "branches");
|
|
1961
|
+
state.branches = (data && data.branches) || [];
|
|
1962
|
+
} catch (_e) {
|
|
1963
|
+
state.branches = [];
|
|
1964
|
+
}
|
|
500
1965
|
} else {
|
|
501
|
-
|
|
1966
|
+
await loadCommits();
|
|
502
1967
|
}
|
|
1968
|
+
if (refMenuEl === sub) renderRefList(list, kind, onPick);
|
|
503
1969
|
}
|
|
504
1970
|
}
|
|
505
1971
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
1972
|
+
function applyScope(scope, value) {
|
|
1973
|
+
state.scope = scope;
|
|
1974
|
+
if (scope === "branch") state.base = value || "";
|
|
1975
|
+
else if (scope === "committed") state.commit = value || "";
|
|
1976
|
+
state.note = null;
|
|
1977
|
+
closeScopeMenu();
|
|
1978
|
+
if (state.sessionId) refresh(state.sessionId, state.ctx);
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
// ── mount ──────────────────────────────────────────────────────────────
|
|
509
1982
|
let activeRoot = null;
|
|
510
1983
|
let activeBody = null;
|
|
511
1984
|
let activeSessionId = null;
|
|
512
1985
|
|
|
513
1986
|
async function refreshBadge(sessionId, ctx) {
|
|
514
1987
|
if (!ctx || !ctx.setBadge) return;
|
|
515
|
-
let
|
|
1988
|
+
let data;
|
|
516
1989
|
try {
|
|
517
|
-
|
|
1990
|
+
data = await api(sessionId, "review");
|
|
518
1991
|
} catch (_e) {
|
|
519
1992
|
ctx.setBadge(null);
|
|
520
1993
|
return;
|
|
521
1994
|
}
|
|
522
|
-
ctx.setBadge(
|
|
1995
|
+
ctx.setBadge(data.repo ? ((data.files || []).length || null) : null);
|
|
523
1996
|
}
|
|
524
1997
|
|
|
525
|
-
// One status fetch per change event: refresh the body if it's already open
|
|
526
|
-
// (refresh() also updates the badge), otherwise just update the badge.
|
|
527
1998
|
function onFilesChanged(sessionId, ctx) {
|
|
528
1999
|
if (activeSessionId === sessionId && activeRoot && activeBody) {
|
|
529
2000
|
refresh(sessionId, ctx);
|
|
@@ -535,80 +2006,122 @@
|
|
|
535
2006
|
Clacky.ext.ui.mount("session.aside", (container, ctx) => {
|
|
536
2007
|
if (!ctx || !ctx.sessionId) return;
|
|
537
2008
|
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
updateFoot();
|
|
2009
|
+
const stats = el("div", { class: "cg-bar-stats" });
|
|
2010
|
+
const wrapBtn = el("button", { class: "cg-icon-btn", type: "button" });
|
|
2011
|
+
wrapBtn.innerHTML = ICON_WRAP;
|
|
2012
|
+
wrapBtn.addEventListener("click", () => setWrap(!state.wrap));
|
|
2013
|
+
|
|
2014
|
+
const expandBtn = el("button", { class: "cg-icon-btn", type: "button",
|
|
2015
|
+
"data-tooltip": t("changes.expandAll", "展开全部"),
|
|
2016
|
+
"aria-label": t("changes.expandAll", "展开全部") });
|
|
2017
|
+
expandBtn.innerHTML = ICON_EXPAND_ALL;
|
|
2018
|
+
expandBtn.addEventListener("click", () => {
|
|
2019
|
+
if (expandBtn.classList.contains("is-on")) collapseAll();
|
|
2020
|
+
else expandAll();
|
|
551
2021
|
});
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
2022
|
+
|
|
2023
|
+
const refreshBtn = el("button", { class: "cg-icon-btn", type: "button",
|
|
2024
|
+
"data-tooltip": t("changes.refresh", "刷新"),
|
|
2025
|
+
"aria-label": t("changes.refresh", "刷新") });
|
|
2026
|
+
refreshBtn.innerHTML = ICON_REFRESH;
|
|
2027
|
+
refreshBtn.addEventListener("click", () => refresh(ctx.sessionId, ctx));
|
|
2028
|
+
|
|
2029
|
+
const findBtn = el("button", { class: "cg-icon-btn", type: "button",
|
|
2030
|
+
"data-tooltip": t("changes.find.title", "跳转到文件"),
|
|
2031
|
+
"aria-label": t("changes.find.title", "跳转到文件") });
|
|
2032
|
+
findBtn.innerHTML = ICON_FIND;
|
|
2033
|
+
findBtn.addEventListener("click", () => openFinder(findBtn));
|
|
2034
|
+
|
|
2035
|
+
const treeBtn = el("button", { class: "cg-icon-btn", type: "button" });
|
|
2036
|
+
treeBtn.innerHTML = ICON_TREE;
|
|
2037
|
+
treeBtn.addEventListener("click", () => setTreeVisible(!state.treeVisible));
|
|
2038
|
+
|
|
2039
|
+
const layoutBtn = el("button", { class: "cg-icon-btn", type: "button" });
|
|
2040
|
+
layoutBtn.addEventListener("click", cycleLayout);
|
|
2041
|
+
|
|
2042
|
+
const tools = el("div", { class: "cg-tools" },
|
|
2043
|
+
layoutBtn, wrapBtn, expandBtn, treeBtn, findBtn, refreshBtn);
|
|
2044
|
+
|
|
2045
|
+
const bar = el("div", { class: "cg-bar" },
|
|
2046
|
+
stats,
|
|
2047
|
+
el("span", { class: "cg-bar-spacer" }),
|
|
2048
|
+
tools);
|
|
2049
|
+
|
|
2050
|
+
const filter = el("input", { class: "cg-filter", type: "search",
|
|
2051
|
+
placeholder: t("changes.filter", "筛选文件…") });
|
|
2052
|
+
filter.addEventListener("input", () => {
|
|
2053
|
+
state.filter = filter.value.trim().toLowerCase();
|
|
2054
|
+
renderTree();
|
|
570
2055
|
});
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
const panel = el("div", { class: "changes-panel" },
|
|
582
|
-
el("div", { class: "changes-summary" }, h, sub, branchNote),
|
|
583
|
-
el("div", { class: "cg-body" }, listPane, diffPane),
|
|
584
|
-
el("div", { class: "changes-foot" }, saveBtn, hint));
|
|
585
|
-
saveBtn.addEventListener("click", () => doCommit(ctx.sessionId, ctx));
|
|
586
|
-
|
|
587
|
-
const root = el("div", { class: "changes-root", "data-panel": "changes" }, panel);
|
|
2056
|
+
const sideEmpty = el("div", { class: "cg-side-empty", text: t("changes.noMatch", "没有匹配的文件") });
|
|
2057
|
+
sideEmpty.style.display = "none";
|
|
2058
|
+
const tree = el("div", { class: "cg-tree" });
|
|
2059
|
+
const side = el("div", { class: "cg-side" },
|
|
2060
|
+
el("div", { class: "cg-side-head" }, filter), tree, sideEmpty);
|
|
2061
|
+
|
|
2062
|
+
const stream = el("div", { class: "cg-stream" });
|
|
2063
|
+
const main = el("div", { class: "cg-main" }, stream);
|
|
2064
|
+
const body = el("div", { class: "cg-body" }, side, main);
|
|
2065
|
+
const root = el("div", { class: "changes-root", "data-panel": "changes" }, bar, body);
|
|
588
2066
|
container.appendChild(root);
|
|
2067
|
+
body.insertBefore(setupSideResizer(root, body, side), main);
|
|
589
2068
|
|
|
2069
|
+
if (state.sessionId && state.sessionId !== ctx.sessionId) {
|
|
2070
|
+
state.scope = "worktree";
|
|
2071
|
+
state.base = "";
|
|
2072
|
+
state.commit = "";
|
|
2073
|
+
state.branch = "";
|
|
2074
|
+
state.branches = null;
|
|
2075
|
+
state.commits = null;
|
|
2076
|
+
}
|
|
590
2077
|
state.sessionId = ctx.sessionId;
|
|
591
2078
|
state.ctx = ctx;
|
|
592
|
-
state.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
2079
|
+
state.els = { root: root, stats: stats, tree: tree, stream: stream, sideEmpty: sideEmpty,
|
|
2080
|
+
expandBtn: expandBtn, layoutBtn: layoutBtn, treeBtn: treeBtn, wrapBtn: wrapBtn };
|
|
2081
|
+
|
|
2082
|
+
try {
|
|
2083
|
+
const saved = localStorage.getItem(LAYOUT_KEY);
|
|
2084
|
+
if (saved && LAYOUT_MODES.indexOf(saved) >= 0) state.layout = saved;
|
|
2085
|
+
state.treeVisible = localStorage.getItem(TREE_KEY) !== "0";
|
|
2086
|
+
state.wrap = localStorage.getItem(WRAP_KEY) === "1";
|
|
2087
|
+
} catch (_e) { /* private mode */ }
|
|
2088
|
+
applyLayoutButton();
|
|
2089
|
+
applyTreeButton();
|
|
2090
|
+
if (root) root.classList.toggle("is-wrap", state.wrap);
|
|
2091
|
+
applyWrapButton();
|
|
2092
|
+
|
|
2093
|
+
if (state.ro) state.ro.disconnect();
|
|
2094
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
2095
|
+
let lastSplit = effectiveLayout() === "split";
|
|
2096
|
+
state.ro = new ResizeObserver(() => {
|
|
2097
|
+
const now = effectiveLayout() === "split";
|
|
2098
|
+
if (now !== lastSplit) {
|
|
2099
|
+
lastSplit = now;
|
|
2100
|
+
reflowBodies();
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
state.ro.observe(stream);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
let scrollRaf = null;
|
|
2108
|
+
stream.addEventListener("scroll", () => {
|
|
2109
|
+
if (scrollRaf) return;
|
|
2110
|
+
scrollRaf = requestAnimationFrame(() => {
|
|
2111
|
+
scrollRaf = null;
|
|
2112
|
+
updateActiveRow();
|
|
2113
|
+
});
|
|
2114
|
+
});
|
|
598
2115
|
|
|
599
2116
|
activeRoot = root;
|
|
600
|
-
activeBody =
|
|
2117
|
+
activeBody = body;
|
|
601
2118
|
activeSessionId = ctx.sessionId;
|
|
602
2119
|
refresh(ctx.sessionId, ctx);
|
|
603
2120
|
}, {
|
|
604
2121
|
order: 30,
|
|
605
2122
|
tab: {
|
|
606
2123
|
id: "changes",
|
|
607
|
-
label: () => t("changes.tab"),
|
|
608
|
-
// Runs eagerly when the tab bar renders (before the user opens the tab):
|
|
609
|
-
// prime the badge, then keep it in sync as the agent finishes rounds or
|
|
610
|
-
// files are saved from the workspace viewer. The returned teardown is run
|
|
611
|
-
// on the next render pass / session switch.
|
|
2124
|
+
label: () => t("changes.tab", "改动"),
|
|
612
2125
|
onAttach(ctx) {
|
|
613
2126
|
if (!ctx || !ctx.sessionId) return;
|
|
614
2127
|
const sid = ctx.sessionId;
|
|
@@ -621,13 +2134,13 @@
|
|
|
621
2134
|
return () => {
|
|
622
2135
|
unsubscribeComplete();
|
|
623
2136
|
unsubscribeFileSaved();
|
|
624
|
-
restoreAsideWidth();
|
|
625
2137
|
if (activeSessionId === sid) {
|
|
626
2138
|
activeRoot = null;
|
|
627
2139
|
activeBody = null;
|
|
628
2140
|
activeSessionId = null;
|
|
629
2141
|
state.els = null;
|
|
630
|
-
state.
|
|
2142
|
+
state.records = new Map();
|
|
2143
|
+
state.files = [];
|
|
631
2144
|
}
|
|
632
2145
|
};
|
|
633
2146
|
},
|