semantic-code-review 0.24.2__py3-none-any.whl
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.
- semantic_code_review/__init__.py +5 -0
- semantic_code_review/augment/__init__.py +3 -0
- semantic_code_review/augment/agents.py +95 -0
- semantic_code_review/augment/console.py +440 -0
- semantic_code_review/augment/extra_review.py +237 -0
- semantic_code_review/augment/fold_summary.py +447 -0
- semantic_code_review/augment/hunks.py +264 -0
- semantic_code_review/augment/mcp_server.py +156 -0
- semantic_code_review/augment/overview.py +233 -0
- semantic_code_review/augment/pass_.py +171 -0
- semantic_code_review/augment/pipeline.py +571 -0
- semantic_code_review/augment/progress.py +223 -0
- semantic_code_review/augment/prompts.py +93 -0
- semantic_code_review/augment/schemas.py +408 -0
- semantic_code_review/augment/tools.py +475 -0
- semantic_code_review/augment/trace_adapter.py +369 -0
- semantic_code_review/backends/__init__.py +95 -0
- semantic_code_review/backends/_cli_driver.py +571 -0
- semantic_code_review/backends/anthropic_sdk.py +56 -0
- semantic_code_review/backends/base.py +104 -0
- semantic_code_review/backends/claude_cli.py +421 -0
- semantic_code_review/backends/google_sdk.py +73 -0
- semantic_code_review/backends/openai_compat.py +42 -0
- semantic_code_review/cache/__init__.py +3 -0
- semantic_code_review/cache/store.py +87 -0
- semantic_code_review/cli/__init__.py +70 -0
- semantic_code_review/cli/_shared.py +157 -0
- semantic_code_review/cli/augment.py +74 -0
- semantic_code_review/cli/config_cmd.py +266 -0
- semantic_code_review/cli/fetch.py +33 -0
- semantic_code_review/cli/lint.py +27 -0
- semantic_code_review/cli/pr.py +98 -0
- semantic_code_review/cli/review.py +104 -0
- semantic_code_review/cli/runs_cmd.py +17 -0
- semantic_code_review/cli/show.py +19 -0
- semantic_code_review/cli/strip.py +20 -0
- semantic_code_review/config.py +563 -0
- semantic_code_review/config_template.py +154 -0
- semantic_code_review/fetch/__init__.py +61 -0
- semantic_code_review/fetch/anchor.py +244 -0
- semantic_code_review/fetch/github.py +229 -0
- semantic_code_review/fetch/github_comments.py +404 -0
- semantic_code_review/fetch/local.py +443 -0
- semantic_code_review/fetch/run_source.py +70 -0
- semantic_code_review/format/__init__.py +3 -0
- semantic_code_review/format/emit.py +163 -0
- semantic_code_review/format/lint.py +74 -0
- semantic_code_review/format/parse.py +574 -0
- semantic_code_review/format/sidecar.py +19 -0
- semantic_code_review/format/strip.py +20 -0
- semantic_code_review/git_ops.py +252 -0
- semantic_code_review/paths.py +61 -0
- semantic_code_review/review/__init__.py +3 -0
- semantic_code_review/review/comments.py +176 -0
- semantic_code_review/review/github.py +284 -0
- semantic_code_review/review/github_graphql.py +433 -0
- semantic_code_review/review/pr_flow.py +299 -0
- semantic_code_review/review/runner.py +407 -0
- semantic_code_review/review/server.py +1043 -0
- semantic_code_review/structural/__init__.py +41 -0
- semantic_code_review/structural/diff.py +102 -0
- semantic_code_review/structural/parse.py +330 -0
- semantic_code_review/structural/symbols.py +43 -0
- semantic_code_review/viewer/__init__.py +3 -0
- semantic_code_review/viewer/assets/annotations.ts +547 -0
- semantic_code_review/viewer/assets/boot.ts +273 -0
- semantic_code_review/viewer/assets/comment_store.ts +121 -0
- semantic_code_review/viewer/assets/comments.ts +624 -0
- semantic_code_review/viewer/assets/console.ts +426 -0
- semantic_code_review/viewer/assets/console_render.ts +213 -0
- semantic_code_review/viewer/assets/console_selection.ts +102 -0
- semantic_code_review/viewer/assets/data_store.ts +156 -0
- semantic_code_review/viewer/assets/file_rows.ts +49 -0
- semantic_code_review/viewer/assets/folds.ts +560 -0
- semantic_code_review/viewer/assets/index.html +62 -0
- semantic_code_review/viewer/assets/post_modal.ts +383 -0
- semantic_code_review/viewer/assets/progress.ts +138 -0
- semantic_code_review/viewer/assets/render.ts +937 -0
- semantic_code_review/viewer/assets/sidebar.ts +429 -0
- semantic_code_review/viewer/assets/sse.ts +78 -0
- semantic_code_review/viewer/assets/text_highlight.ts +215 -0
- semantic_code_review/viewer/assets/types.d.ts +388 -0
- semantic_code_review/viewer/assets/vendor/LICENSE +29 -0
- semantic_code_review/viewer/assets/vendor/VENDOR.md +55 -0
- semantic_code_review/viewer/assets/vendor/github-dark.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/github.min.css +10 -0
- semantic_code_review/viewer/assets/vendor/highlight.min.js +1244 -0
- semantic_code_review/viewer/assets/vendor/mermaid.LICENSE +21 -0
- semantic_code_review/viewer/assets/vendor/mermaid.min.js +3587 -0
- semantic_code_review/viewer/assets/vendor/refresh.sh +65 -0
- semantic_code_review/viewer/assets/viewer.css +1202 -0
- semantic_code_review/viewer/assets/viewer.js +10588 -0
- semantic_code_review/viewer/build_json.py +546 -0
- semantic_code_review/viewer/hunk_layout.py +471 -0
- semantic_code_review-0.24.2.dist-info/METADATA +348 -0
- semantic_code_review-0.24.2.dist-info/RECORD +100 -0
- semantic_code_review-0.24.2.dist-info/WHEEL +5 -0
- semantic_code_review-0.24.2.dist-info/entry_points.txt +2 -0
- semantic_code_review-0.24.2.dist-info/licenses/LICENSE +21 -0
- semantic_code_review-0.24.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// Sidebar / grouping axes.
|
|
2
|
+
//
|
|
3
|
+
// Owns the #group-sidebar DOM tree and the multi-axis state behind
|
|
4
|
+
// it: a Themes axis populated by the overview LLM pass (refreshed
|
|
5
|
+
// in place when the `overview` SSE event arrives) and a Files axis
|
|
6
|
+
// derived deterministically from DATA.files.
|
|
7
|
+
//
|
|
8
|
+
// Filter semantics: one active pill at a time across all axes;
|
|
9
|
+
// clicking a pill toggles visibility on the per-file body via
|
|
10
|
+
// applyGroupFilter. The "ungrouped" visual tell is anchored to the
|
|
11
|
+
// themes axis (every hunk lives in a file, so the files axis has
|
|
12
|
+
// no useful "ungrouped" signal). Active pill is persisted in
|
|
13
|
+
// localStorage as `<axis>:<id>`.
|
|
14
|
+
|
|
15
|
+
import { Annotations } from "./annotations";
|
|
16
|
+
import { Comments } from "./comments";
|
|
17
|
+
|
|
18
|
+
type AxisId = "themes" | "files" | "symbols";
|
|
19
|
+
|
|
20
|
+
interface SidebarAxis {
|
|
21
|
+
id: AxisId;
|
|
22
|
+
label: string;
|
|
23
|
+
groups: GroupBlock[];
|
|
24
|
+
byId: Record<string, GroupBlock>;
|
|
25
|
+
hunkCount: Record<string, number>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ActivePill {
|
|
29
|
+
axis: AxisId;
|
|
30
|
+
id: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const THEMES_AXIS: SidebarAxis = {
|
|
34
|
+
id: "themes", label: "Themes",
|
|
35
|
+
groups: [], byId: Object.create(null), hunkCount: Object.create(null),
|
|
36
|
+
};
|
|
37
|
+
const FILES_AXIS: SidebarAxis = {
|
|
38
|
+
id: "files", label: "Files",
|
|
39
|
+
groups: [], byId: Object.create(null), hunkCount: Object.create(null),
|
|
40
|
+
};
|
|
41
|
+
// Deterministic tree-sitter symbol delta (ADR 0001). Like the Files
|
|
42
|
+
// axis it's structural — populated from DATA at boot, never refreshed
|
|
43
|
+
// by an SSE pass. Rendered as a nested class ▸ method tree (slice 5).
|
|
44
|
+
const SYMBOLS_AXIS: SidebarAxis = {
|
|
45
|
+
id: "symbols", label: "Symbols",
|
|
46
|
+
groups: [], byId: Object.create(null), hunkCount: Object.create(null),
|
|
47
|
+
};
|
|
48
|
+
const AXES: SidebarAxis[] = [THEMES_AXIS, FILES_AXIS, SYMBOLS_AXIS];
|
|
49
|
+
|
|
50
|
+
let _data: ViewerData | null = null;
|
|
51
|
+
let _activePill: ActivePill | null = null;
|
|
52
|
+
let _lsKey = "scr-active-group:local";
|
|
53
|
+
// Notified with the focused symbol's name (or null) whenever the active
|
|
54
|
+
// pill changes — boot points this at Render.setSymbolSearch. Kept as an
|
|
55
|
+
// injected callback rather than a direct import so the sidebar doesn't
|
|
56
|
+
// take a cyclic dependency on render.
|
|
57
|
+
let _onActivePillChange: ((symbolName: string | null) => void) | null = null;
|
|
58
|
+
// Collapsed symbol-tree nodes, by pill id. In-memory only (the tree is
|
|
59
|
+
// expanded by default; collapse is a transient view preference that
|
|
60
|
+
// survives re-renders within a session but not a reload).
|
|
61
|
+
const _collapsedSymbols = new Set<string>();
|
|
62
|
+
|
|
63
|
+
/** Populate axes from the initial viewer data + restore any active
|
|
64
|
+
* pill from localStorage. Idempotent (call again after DATA mutates
|
|
65
|
+
* in a way the in-place refreshers don't cover). */
|
|
66
|
+
function init(
|
|
67
|
+
data: ViewerData,
|
|
68
|
+
opts?: { onActivePillChange?: (symbolName: string | null) => void },
|
|
69
|
+
): void {
|
|
70
|
+
_data = data;
|
|
71
|
+
if (opts && opts.onActivePillChange) _onActivePillChange = opts.onActivePillChange;
|
|
72
|
+
_lsKey =
|
|
73
|
+
"scr-active-group:"
|
|
74
|
+
+ (data.pr && data.pr.head_sha ? data.pr.head_sha : "local");
|
|
75
|
+
|
|
76
|
+
// Themes axis: refresh in place so any references the rest of
|
|
77
|
+
// the viewer holds to THEMES_AXIS.* arrays stay live.
|
|
78
|
+
refreshThemes(data.groups || []);
|
|
79
|
+
rebuildFilesAxis();
|
|
80
|
+
rebuildSymbolsAxis();
|
|
81
|
+
|
|
82
|
+
// Restore the active pill across axes. Legacy entries are bare
|
|
83
|
+
// ids (themes axis); new entries are `<axis>:<id>`.
|
|
84
|
+
try {
|
|
85
|
+
const saved = localStorage.getItem(_lsKey);
|
|
86
|
+
if (saved) {
|
|
87
|
+
let axisId: ActivePill["axis"] = "themes";
|
|
88
|
+
let pillId = saved;
|
|
89
|
+
if (saved.includes(":")) {
|
|
90
|
+
const parts = saved.split(":", 2);
|
|
91
|
+
axisId = parts[0] as ActivePill["axis"];
|
|
92
|
+
pillId = parts[1];
|
|
93
|
+
}
|
|
94
|
+
const axis = AXES.find((a) => a.id === axisId);
|
|
95
|
+
if (axis && axis.byId[pillId]) _activePill = { axis: axisId, id: pillId };
|
|
96
|
+
}
|
|
97
|
+
} catch (_) { /* localStorage may be unavailable */ }
|
|
98
|
+
// Seed the symbol search now (init runs before Render.init in boot):
|
|
99
|
+
// the emit's repaint finds no cells yet, but it sets `_symbolSearch` in
|
|
100
|
+
// render so Render.init's first paint highlights a restored Symbols pill.
|
|
101
|
+
_emitActivePill();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Rebuild the themes axis from a fresh list of groups. Mutates the
|
|
105
|
+
* THEMES_AXIS arrays in place so existing live references survive
|
|
106
|
+
* (the original sidebar bug from commit e800632 was forgetting to
|
|
107
|
+
* do exactly this). */
|
|
108
|
+
function refreshThemes(groups: GroupBlock[]): void {
|
|
109
|
+
THEMES_AXIS.groups.length = 0;
|
|
110
|
+
for (const k of Object.keys(THEMES_AXIS.byId)) delete THEMES_AXIS.byId[k];
|
|
111
|
+
for (const k of Object.keys(THEMES_AXIS.hunkCount)) delete THEMES_AXIS.hunkCount[k];
|
|
112
|
+
for (const g of groups) {
|
|
113
|
+
THEMES_AXIS.groups.push(g);
|
|
114
|
+
THEMES_AXIS.byId[g.id] = g;
|
|
115
|
+
for (const hid of g.hunk_ids || []) {
|
|
116
|
+
THEMES_AXIS.hunkCount[hid] = (THEMES_AXIS.hunkCount[hid] || 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Derive the by-file axis from DATA.files. One pill per file with
|
|
122
|
+
* hunks; pill ids `BF<fi>` (distinct ID space from themes' `G<i>`).
|
|
123
|
+
* Skipped files (zero hunks) get no pill. */
|
|
124
|
+
function rebuildFilesAxis(): void {
|
|
125
|
+
if (!_data) return;
|
|
126
|
+
FILES_AXIS.groups.length = 0;
|
|
127
|
+
for (const k of Object.keys(FILES_AXIS.byId)) delete FILES_AXIS.byId[k];
|
|
128
|
+
for (const k of Object.keys(FILES_AXIS.hunkCount)) delete FILES_AXIS.hunkCount[k];
|
|
129
|
+
for (let fi = 0; fi < (_data.files || []).length; fi++) {
|
|
130
|
+
const f = _data.files[fi];
|
|
131
|
+
if (!f.hunks || f.hunks.length === 0) continue;
|
|
132
|
+
const hunk_ids = f.hunks.map((h) => h.id);
|
|
133
|
+
const g: GroupBlock = {
|
|
134
|
+
id: `BF${fi}`,
|
|
135
|
+
title: _shortenPath(f.path),
|
|
136
|
+
rationale: f.path,
|
|
137
|
+
hunk_ids,
|
|
138
|
+
};
|
|
139
|
+
FILES_AXIS.groups.push(g);
|
|
140
|
+
FILES_AXIS.byId[g.id] = g;
|
|
141
|
+
for (const hid of hunk_ids) {
|
|
142
|
+
FILES_AXIS.hunkCount[hid] = (FILES_AXIS.hunkCount[hid] || 0) + 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Load the symbols axis from DATA.symbols — pre-built server-side as a
|
|
148
|
+
* forest of GroupBlock nodes (class ▸ method; a parent's hunk_ids is its
|
|
149
|
+
* subtree union). `groups` holds the roots for the tree render; `byId`
|
|
150
|
+
* flattens every node (roots and descendants alike) so active-pill
|
|
151
|
+
* lookup and restore resolve any node by id. Pill ids `SY<i>` are a
|
|
152
|
+
* distinct ID space from themes/files. The delta is deterministic, so
|
|
153
|
+
* unlike themes there's no in-place refresh: what boot ships is final. */
|
|
154
|
+
function rebuildSymbolsAxis(): void {
|
|
155
|
+
SYMBOLS_AXIS.groups.length = 0;
|
|
156
|
+
for (const k of Object.keys(SYMBOLS_AXIS.byId)) delete SYMBOLS_AXIS.byId[k];
|
|
157
|
+
for (const k of Object.keys(SYMBOLS_AXIS.hunkCount)) delete SYMBOLS_AXIS.hunkCount[k];
|
|
158
|
+
const register = (g: GroupBlock): void => {
|
|
159
|
+
SYMBOLS_AXIS.byId[g.id] = g;
|
|
160
|
+
for (const hid of g.hunk_ids || []) {
|
|
161
|
+
SYMBOLS_AXIS.hunkCount[hid] = (SYMBOLS_AXIS.hunkCount[hid] || 0) + 1;
|
|
162
|
+
}
|
|
163
|
+
for (const c of g.children || []) register(c);
|
|
164
|
+
};
|
|
165
|
+
for (const g of (_data && _data.symbols) || []) {
|
|
166
|
+
SYMBOLS_AXIS.groups.push(g);
|
|
167
|
+
register(g);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Render the sidebar's pill rows. One section per populated axis,
|
|
172
|
+
* a "Show all" button above them that clears any active pill. */
|
|
173
|
+
function render(): void {
|
|
174
|
+
const sidebar = document.getElementById("group-sidebar");
|
|
175
|
+
if (!sidebar) return;
|
|
176
|
+
sidebar.innerHTML = "";
|
|
177
|
+
const populated = AXES.filter((a) => a.groups.length > 0);
|
|
178
|
+
if (populated.length === 0) {
|
|
179
|
+
sidebar.classList.add("empty");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
sidebar.classList.remove("empty");
|
|
183
|
+
|
|
184
|
+
const showAll = _el("button", "group-btn group-btn-all", "Show all");
|
|
185
|
+
showAll.title = "Clear filter — show every hunk";
|
|
186
|
+
if (_activePill === null) showAll.classList.add("active");
|
|
187
|
+
showAll.addEventListener("click", () => setActivePill(null));
|
|
188
|
+
sidebar.appendChild(showAll);
|
|
189
|
+
|
|
190
|
+
for (const axis of populated) {
|
|
191
|
+
const section = _el("div", "group-axis");
|
|
192
|
+
section.dataset.axis = axis.id;
|
|
193
|
+
const header = _el("div", "group-axis-header");
|
|
194
|
+
header.appendChild(_el("h3", null, axis.label));
|
|
195
|
+
section.appendChild(header);
|
|
196
|
+
if (axis.id === "symbols") {
|
|
197
|
+
// Nested class ▸ method tree (ADR 0001 slice 5).
|
|
198
|
+
for (const g of axis.groups) section.appendChild(_symbolNode(axis, g));
|
|
199
|
+
} else {
|
|
200
|
+
const commentCounts = axis.id === "files" ? _commentCountsByFilePath() : null;
|
|
201
|
+
for (const g of axis.groups) section.appendChild(_pillButton(axis, g, commentCounts));
|
|
202
|
+
}
|
|
203
|
+
sidebar.appendChild(section);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** One pill button for a group. Shared by the flat axes and the symbols
|
|
208
|
+
* tree's per-node row. `commentCounts` (Files axis only) appends the
|
|
209
|
+
* unresolved/total badge. */
|
|
210
|
+
function _pillButton(
|
|
211
|
+
axis: SidebarAxis,
|
|
212
|
+
g: GroupBlock,
|
|
213
|
+
commentCounts: Record<string, CommentCounts> | null,
|
|
214
|
+
): HTMLElement {
|
|
215
|
+
const btn = _el("button", "group-btn");
|
|
216
|
+
btn.dataset.axis = axis.id;
|
|
217
|
+
btn.dataset.pillId = g.id;
|
|
218
|
+
btn.appendChild(_el("span", "group-btn-label", g.title));
|
|
219
|
+
btn.appendChild(_el("span", "group-btn-count", String((g.hunk_ids || []).length)));
|
|
220
|
+
if (commentCounts) {
|
|
221
|
+
// Files axis only — Themes axis is keyed by hunks, not paths,
|
|
222
|
+
// so there's no single "comments per pill" mapping to surface.
|
|
223
|
+
const cc = commentCounts[g.rationale] || { total: 0, unresolved: 0 };
|
|
224
|
+
const badge = _renderCommentCountBadge(cc);
|
|
225
|
+
if (badge) btn.appendChild(badge);
|
|
226
|
+
}
|
|
227
|
+
if (g.rationale) btn.title = g.rationale;
|
|
228
|
+
if (_isActivePill(axis.id, g.id)) btn.classList.add("active");
|
|
229
|
+
btn.addEventListener("click", () => {
|
|
230
|
+
setActivePill(
|
|
231
|
+
_isActivePill(axis.id, g.id) ? null : { axis: axis.id, id: g.id },
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
return btn;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Render one symbol-tree node: an optional expand/collapse toggle, the
|
|
238
|
+
* node's own filtering pill, and (recursively) its children indented
|
|
239
|
+
* beneath it. Ancestors are shown for context even when unchanged;
|
|
240
|
+
* clicking the pill filters to the subtree union, clicking a leaf to
|
|
241
|
+
* just that symbol's hunks. */
|
|
242
|
+
function _symbolNode(axis: SidebarAxis, g: GroupBlock): HTMLElement {
|
|
243
|
+
const node = _el("div", "group-tree-node");
|
|
244
|
+
const row = _el("div", "group-tree-row");
|
|
245
|
+
const kids = g.children || [];
|
|
246
|
+
let childWrap: HTMLElement | null = null;
|
|
247
|
+
if (kids.length > 0) {
|
|
248
|
+
const collapsed = _collapsedSymbols.has(g.id);
|
|
249
|
+
const toggle = _el("button", "group-tree-toggle", collapsed ? "▸" : "▾");
|
|
250
|
+
toggle.title = collapsed ? "Expand" : "Collapse";
|
|
251
|
+
toggle.addEventListener("click", (e) => {
|
|
252
|
+
e.stopPropagation();
|
|
253
|
+
const nowCollapsed = !_collapsedSymbols.has(g.id);
|
|
254
|
+
if (nowCollapsed) _collapsedSymbols.add(g.id);
|
|
255
|
+
else _collapsedSymbols.delete(g.id);
|
|
256
|
+
toggle.textContent = nowCollapsed ? "▸" : "▾";
|
|
257
|
+
toggle.title = nowCollapsed ? "Expand" : "Collapse";
|
|
258
|
+
if (childWrap) childWrap.style.display = nowCollapsed ? "none" : "";
|
|
259
|
+
});
|
|
260
|
+
row.appendChild(toggle);
|
|
261
|
+
} else {
|
|
262
|
+
// Keep leaves aligned with parents' pills under the toggle column.
|
|
263
|
+
row.appendChild(_el("span", "group-tree-toggle group-tree-toggle-leaf"));
|
|
264
|
+
}
|
|
265
|
+
row.appendChild(_pillButton(axis, g, null));
|
|
266
|
+
node.appendChild(row);
|
|
267
|
+
if (kids.length > 0) {
|
|
268
|
+
childWrap = _el("div", "group-tree-children");
|
|
269
|
+
if (_collapsedSymbols.has(g.id)) childWrap.style.display = "none";
|
|
270
|
+
for (const c of kids) childWrap.appendChild(_symbolNode(axis, c));
|
|
271
|
+
node.appendChild(childWrap);
|
|
272
|
+
}
|
|
273
|
+
return node;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _isActivePill(axisId: string, pillId: string): boolean {
|
|
277
|
+
return _activePill !== null
|
|
278
|
+
&& _activePill.axis === axisId
|
|
279
|
+
&& _activePill.id === pillId;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function setActivePill(pill: ActivePill | null): void {
|
|
283
|
+
_activePill = pill;
|
|
284
|
+
try {
|
|
285
|
+
if (pill === null) localStorage.removeItem(_lsKey);
|
|
286
|
+
else localStorage.setItem(_lsKey, `${pill.axis}:${pill.id}`);
|
|
287
|
+
} catch (_) { /* ignore */ }
|
|
288
|
+
document.querySelectorAll(".group-btn").forEach(
|
|
289
|
+
(b) => b.classList.remove("active"),
|
|
290
|
+
);
|
|
291
|
+
if (pill === null) {
|
|
292
|
+
const all = document.querySelector(".group-btn-all");
|
|
293
|
+
if (all) all.classList.add("active");
|
|
294
|
+
} else {
|
|
295
|
+
const sel = `.group-btn[data-axis="${pill.axis}"][data-pill-id="${pill.id}"]`;
|
|
296
|
+
const btn = document.querySelector(sel);
|
|
297
|
+
if (btn) btn.classList.add("active");
|
|
298
|
+
}
|
|
299
|
+
applyFilter();
|
|
300
|
+
_emitActivePill();
|
|
301
|
+
Annotations.reflowAll();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Name to search-highlight across the diff for the active pill: the
|
|
305
|
+
* focused symbol's bare name, or null for any non-Symbols pill / no
|
|
306
|
+
* pill. Wired to Render.setSymbolSearch by boot. */
|
|
307
|
+
function _activeSymbolName(): string | null {
|
|
308
|
+
if (_activePill === null || _activePill.axis !== "symbols") return null;
|
|
309
|
+
const g = SYMBOLS_AXIS.byId[_activePill.id];
|
|
310
|
+
return g ? g.title : null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function _emitActivePill(): void {
|
|
314
|
+
_onActivePillChange?.(_activeSymbolName());
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _activePillHunkIds(): Set<string> | null {
|
|
318
|
+
if (_activePill === null) return null;
|
|
319
|
+
const axis = AXES.find((a) => a.id === _activePill!.axis);
|
|
320
|
+
if (!axis) return null;
|
|
321
|
+
const g = axis.byId[_activePill.id];
|
|
322
|
+
return g ? new Set(g.hunk_ids || []) : new Set<string>();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Walk every .hunk in the file body, tag `.ungrouped` for hunks no
|
|
326
|
+
* themes-axis group claims, and toggle visibility based on the
|
|
327
|
+
* active pill. Files with no visible hunks hide too — keeps the
|
|
328
|
+
* filtered view tidy. */
|
|
329
|
+
function applyFilter(): void {
|
|
330
|
+
const activeIds = _activePillHunkIds();
|
|
331
|
+
document.querySelectorAll(".file").forEach((fileEl) => {
|
|
332
|
+
let visible = 0;
|
|
333
|
+
fileEl.querySelectorAll(".hunk").forEach((hunkEl) => {
|
|
334
|
+
const hid = (hunkEl as HTMLElement).dataset.id || "";
|
|
335
|
+
const inAnyGroup = (THEMES_AXIS.hunkCount[hid] || 0) > 0;
|
|
336
|
+
hunkEl.classList.toggle("ungrouped", !inAnyGroup);
|
|
337
|
+
const show = activeIds === null ? true : activeIds.has(hid);
|
|
338
|
+
(hunkEl as HTMLElement).style.display = show ? "" : "none";
|
|
339
|
+
if (show) visible++;
|
|
340
|
+
});
|
|
341
|
+
(fileEl as HTMLElement).style.display =
|
|
342
|
+
visible === 0 && activeIds !== null ? "none" : "";
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface CommentCounts { total: number; unresolved: number }
|
|
347
|
+
|
|
348
|
+
/** Tally comment threads per file path.
|
|
349
|
+
*
|
|
350
|
+
* "Threads" are counted as the root comments — i.e. those whose
|
|
351
|
+
* ``in_reply_to_id`` is null/absent. A local reply to an ingested
|
|
352
|
+
* root carries the parent's id; the parent (the ingested root) is
|
|
353
|
+
* what gets counted. This matches how the discussion is grouped in
|
|
354
|
+
* the diff view: one annotation block per thread.
|
|
355
|
+
*
|
|
356
|
+
* "Unresolved" mirrors the resolved-thread fold: a thread is
|
|
357
|
+
* resolved iff its root's ``thread_resolved`` is true. Pure-local
|
|
358
|
+
* threads (no upstream) default to unresolved — the reviewer hasn't
|
|
359
|
+
* told us otherwise. */
|
|
360
|
+
function _commentCountsByFilePath(): Record<string, CommentCounts> {
|
|
361
|
+
const out: Record<string, CommentCounts> = Object.create(null);
|
|
362
|
+
for (const c of Comments.getAll()) {
|
|
363
|
+
if (c.in_reply_to_id) continue; // only thread roots
|
|
364
|
+
if (!c.file) continue;
|
|
365
|
+
const bucket = (out[c.file] ||= { total: 0, unresolved: 0 });
|
|
366
|
+
bucket.total += 1;
|
|
367
|
+
if (!c.thread_resolved) bucket.unresolved += 1;
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function _renderCommentCountBadge(cc: CommentCounts): HTMLElement | null {
|
|
373
|
+
if (cc.total === 0) return null;
|
|
374
|
+
const badge = _el(
|
|
375
|
+
"span",
|
|
376
|
+
"group-btn-comments" + (cc.unresolved > 0 ? " has-unresolved" : ""),
|
|
377
|
+
`${cc.unresolved}/${cc.total}`,
|
|
378
|
+
);
|
|
379
|
+
badge.title = cc.unresolved > 0
|
|
380
|
+
? `${cc.unresolved} unresolved of ${cc.total} thread${cc.total === 1 ? "" : "s"}`
|
|
381
|
+
: `${cc.total} thread${cc.total === 1 ? "" : "s"} — all resolved`;
|
|
382
|
+
return badge;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Re-paint just the comment-count badges on existing Files-axis pills.
|
|
386
|
+
* Boot wires this to Comments' onChange so the badges stay in sync
|
|
387
|
+
* with the store without re-rendering the whole sidebar. */
|
|
388
|
+
function refreshFileCommentCounts(): void {
|
|
389
|
+
const sidebar = document.getElementById("group-sidebar");
|
|
390
|
+
if (!sidebar) return;
|
|
391
|
+
const filesSection = sidebar.querySelector('[data-axis="files"]');
|
|
392
|
+
if (!filesSection) return;
|
|
393
|
+
const counts = _commentCountsByFilePath();
|
|
394
|
+
filesSection.querySelectorAll<HTMLElement>(".group-btn").forEach((btn) => {
|
|
395
|
+
const pillId = btn.dataset.pillId || "";
|
|
396
|
+
const group = FILES_AXIS.byId[pillId];
|
|
397
|
+
if (!group) return;
|
|
398
|
+
const cc = counts[group.rationale] || { total: 0, unresolved: 0 };
|
|
399
|
+
const existing = btn.querySelector(".group-btn-comments");
|
|
400
|
+
if (existing) existing.remove();
|
|
401
|
+
const fresh = _renderCommentCountBadge(cc);
|
|
402
|
+
if (fresh) btn.appendChild(fresh);
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function _shortenPath(path: string): string {
|
|
407
|
+
if (!path) return "";
|
|
408
|
+
if (path.length <= 28) return path;
|
|
409
|
+
const idx = path.lastIndexOf("/");
|
|
410
|
+
return idx >= 0 ? path.slice(idx + 1) : path;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function _el(tag: string, className: string | null, text?: string): HTMLElement {
|
|
414
|
+
const n = document.createElement(tag);
|
|
415
|
+
if (className) n.className = className;
|
|
416
|
+
if (text !== undefined) n.textContent = text;
|
|
417
|
+
return n;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export const Sidebar = {
|
|
421
|
+
init,
|
|
422
|
+
render,
|
|
423
|
+
refreshThemes,
|
|
424
|
+
rebuildFilesAxis,
|
|
425
|
+
rebuildSymbolsAxis,
|
|
426
|
+
refreshFileCommentCounts,
|
|
427
|
+
setActivePill,
|
|
428
|
+
applyFilter,
|
|
429
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Server-Sent Events wiring.
|
|
2
|
+
//
|
|
3
|
+
// Opens the /events stream on the review server, parses each
|
|
4
|
+
// per-type JSON payload, and dispatches to typed handlers the
|
|
5
|
+
// caller registers. The wire format is one EventSource frame per
|
|
6
|
+
// pipeline phase (overview-start, overview, overview-failed,
|
|
7
|
+
// hunk-start, hunk, fold-summary, done); payload shapes live in
|
|
8
|
+
// `types.d.ts`. Replay on reconnect is handled by the browser's
|
|
9
|
+
// EventSource implementation (it sends Last-Event-ID automatically;
|
|
10
|
+
// the server replays from its buffer).
|
|
11
|
+
|
|
12
|
+
interface SseHandlers {
|
|
13
|
+
overviewStart?: () => void;
|
|
14
|
+
overviewFailed?: () => void;
|
|
15
|
+
overview?: (payload: SseOverviewEvent) => void;
|
|
16
|
+
hunkStart?: (payload: SseHunkStartEvent) => void;
|
|
17
|
+
hunk?: (payload: SseHunkEvent) => void;
|
|
18
|
+
foldSummary?: (payload: SseFoldSummaryEvent) => void;
|
|
19
|
+
done?: (payload: SseDoneEvent) => void;
|
|
20
|
+
consoleDelta?: (payload: SseConsoleDeltaEvent) => void;
|
|
21
|
+
consoleTool?: (payload: SseConsoleToolEvent) => void;
|
|
22
|
+
consoleDone?: (payload: SseConsoleDoneEvent) => void;
|
|
23
|
+
consoleError?: (payload: SseConsoleErrorEvent) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Subscribe to `<endpoint>/events`. Returns the EventSource so
|
|
27
|
+
* callers can close it explicitly if needed (rare — the connection
|
|
28
|
+
* stays open for the review session). Returns `null` if the browser
|
|
29
|
+
* doesn't support EventSource or the connection couldn't be opened.
|
|
30
|
+
*/
|
|
31
|
+
function connect(endpoint: string, handlers: SseHandlers): EventSource | null {
|
|
32
|
+
// Empty `endpoint` is valid — it means "same origin" and the
|
|
33
|
+
// resulting `/events` URL is relative to the page. Only the
|
|
34
|
+
// browser's missing EventSource (jsdom without our stub) bails.
|
|
35
|
+
if (typeof EventSource === "undefined") return null;
|
|
36
|
+
let es: EventSource;
|
|
37
|
+
try {
|
|
38
|
+
es = new EventSource(endpoint + "/events");
|
|
39
|
+
} catch (_) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Frames without a JSON body (the server sends no `data:` for
|
|
44
|
+
// overview-start / overview-failed) just fire the handler.
|
|
45
|
+
const wireBare = (type: string, fn: () => void): void => {
|
|
46
|
+
es.addEventListener(type, fn);
|
|
47
|
+
};
|
|
48
|
+
// Frames with a JSON body: parse + dispatch with the typed
|
|
49
|
+
// payload. JSON.parse errors are swallowed so a corrupt frame
|
|
50
|
+
// doesn't take down the whole channel.
|
|
51
|
+
const wireJson = <T>(type: string, fn: (payload: T) => void): void => {
|
|
52
|
+
es.addEventListener(type, (e: MessageEvent) => {
|
|
53
|
+
let parsed: T;
|
|
54
|
+
try {
|
|
55
|
+
parsed = JSON.parse(e.data) as T;
|
|
56
|
+
} catch (_) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
fn(parsed);
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (handlers.overviewStart) wireBare("overview-start", handlers.overviewStart);
|
|
64
|
+
if (handlers.overviewFailed) wireBare("overview-failed", handlers.overviewFailed);
|
|
65
|
+
if (handlers.overview) wireJson<SseOverviewEvent>("overview", handlers.overview);
|
|
66
|
+
if (handlers.hunkStart) wireJson<SseHunkStartEvent>("hunk-start", handlers.hunkStart);
|
|
67
|
+
if (handlers.hunk) wireJson<SseHunkEvent>("hunk", handlers.hunk);
|
|
68
|
+
if (handlers.foldSummary) wireJson<SseFoldSummaryEvent>("fold-summary", handlers.foldSummary);
|
|
69
|
+
if (handlers.done) wireJson<SseDoneEvent>("done", handlers.done);
|
|
70
|
+
if (handlers.consoleDelta) wireJson<SseConsoleDeltaEvent>("console-delta", handlers.consoleDelta);
|
|
71
|
+
if (handlers.consoleTool) wireJson<SseConsoleToolEvent>("console-tool", handlers.consoleTool);
|
|
72
|
+
if (handlers.consoleDone) wireJson<SseConsoleDoneEvent>("console-done", handlers.consoleDone);
|
|
73
|
+
if (handlers.consoleError) wireJson<SseConsoleErrorEvent>("console-error", handlers.consoleError);
|
|
74
|
+
return es;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const Sse = { connect };
|
|
78
|
+
export type { SseHandlers };
|