Files
awesome-copilot/extensions/pr-review/client.js
T

369 lines
16 KiB
JavaScript

/* All provider and model text is rendered as text, never HTML. */
const $ = (id) => document.getElementById(id);
const token = location.pathname.split("/")[1];
let state = null;
let selection = null;
let selectedGroup = null;
let renderedKey = "";
let renderedAnswers = "";
let renderedGroups = "";
let activeRequests = 0;
let connectionError = "";
let fileGeneration = 0;
let localError = "";
let refreshing = false;
let selectedDiffLines = null;
function captureDiffSelection() {
selectedDiffLines = null;
const highlighted = window.getSelection();
if (highlighted && !highlighted.isCollapsed && highlighted.rangeCount === 1) {
const range = highlighted.getRangeAt(0);
const parent = (node) => node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
const diff = parent(range.startContainer)?.closest(".diff");
if (diff && diff === parent(range.endContainer)?.closest(".diff")) {
const rows = [...diff.querySelectorAll(".diff-line")].filter((row) =>
range.intersectsNode(row.querySelector(".code")));
if (rows.length) selectedDiffLines = {
filename: diff.dataset.filename,
hunkIndex: Number(diff.dataset.hunkIndex),
startLine: Number(rows[0].dataset.hunkLine),
endLine: Number(rows.at(-1).dataset.hunkLine),
lines: rows.map((row) =>
`old ${row.dataset.oldLine || "-"}, new ${row.dataset.newLine || "-"}: ${row.querySelector(".code").textContent}`),
};
}
}
for (const control of document.querySelectorAll(".add-selected-lines")) {
control.disabled = !selectedDiffLines || control.dataset.filename !== selectedDiffLines.filename ||
Number(control.dataset.hunkIndex) !== selectedDiffLines.hunkIndex;
}
}
function addSelectedLines(filename, hunkIndex) {
const highlighted = selectedDiffLines;
if (!highlighted || highlighted.filename !== filename || highlighted.hunkIndex !== hunkIndex) {
throw new Error("Highlight lines within this diff hunk first.");
}
select({
kind: "lines", filename, hunkIndex,
startLine: highlighted.startLine, endLine: highlighted.endLine,
}, true, highlighted.lines.join("\n"));
}
function element(tag, text, className) {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
}
function button(text, handler, className) {
const node = element("button", text, className);
node.type = "button";
node.addEventListener("click", () => run(handler));
return node;
}
async function api(route, input) {
const options = input === undefined ? {} : {
method: "POST",
headers: { "Content-Type": "application/json", "X-PR-Review": token },
body: JSON.stringify(input),
};
const response = await fetch(route, options);
const data = await response.json();
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
async function run(handler) {
activeRequests++;
localError = "";
updateStatus();
try {
await handler();
await refresh();
} catch (error) {
localError = error.message;
} finally {
activeRequests--;
updateStatus();
}
}
function updateStatus() {
const pending = state?.pending;
const busy = Boolean(pending || activeRequests);
$("load-button").disabled = busy;
$("ask-button").disabled = busy || !selection;
$("pr-url").disabled = busy;
$("stop-button").hidden = !pending;
$("stop-button").disabled = false;
$("load-button").textContent = state?.snapshot ? "Load / refresh PR" : "Load PR";
const error = connectionError || localError || state?.error;
$("error").hidden = !error;
$("error").textContent = error || "";
if (pending) {
const staged = pending.kind === "load" && state.expectedFileCount !== undefined
? ` ${state.stagedFileCount}/${state.expectedFileCount} files received.` : "";
$("status").textContent = pending.kind === "load"
? `Loading the full PR diff and grouping changes.${staged} Check chat for permission requests.`
: "Waiting for Copilot to answer. Check chat for permission requests.";
if (state.snapshot && pending.kind === "load") $("status").textContent += " The previous snapshot remains visible.";
} else {
$("status").textContent = state?.snapshot
? `Snapshot from ${new Date(state.snapshot.loadedAt).toLocaleString()}. Not live; reload to detect new commits.`
: "Paste a PR link to begin.";
}
}
function selectionLabel(value) {
if (!value) return "Select a group, file, or lines.";
if (value.kind === "group") {
return `Group: ${state.snapshot.groups.find((group) => group.id === value.groupId)?.title || value.groupId}`;
}
if (value.kind === "lines") return `Selected lines: ${value.filename} (hunk ${value.hunkIndex + 1}, diff rows ${value.startLine}-${value.endLine})`;
return `${value.kind === "hunk" ? `Hunk ${value.hunkIndex + 1}` : "File"}: ${value.filename}`;
}
function select(value, focus = true, preview = "") {
selection = value;
$("selection-label").textContent = selectionLabel(value);
$("selected-lines").textContent = preview;
$("selected-lines").hidden = !preview;
updateStatus();
if (focus) {
$("questions").scrollIntoView({ behavior: "smooth", block: "nearest" });
$("question").focus({ preventScroll: true });
}
}
function sourceLink(label, url) {
const node = element("a", label);
node.href = url;
node.target = "_blank";
node.rel = "noopener noreferrer";
return node;
}
function renderOverview() {
const snapshot = state.snapshot;
const meta = snapshot.metadata;
const title = element("h2");
title.append(sourceLink(meta.title, state.url));
const info = element("p", `${meta.author} | ${snapshot.files.length} files | ${snapshot.groups.length} groups`, "subtle");
const revision = element("p", `Base ${meta.baseSha.slice(0, 12)} / Head ${meta.headSha.slice(0, 12)}`, "subtle");
$("overview").replaceChildren(title, info, revision, element("p", snapshot.summary, "summary"));
}
function renderGroups() {
const key = JSON.stringify([state.url, selectedGroup, state.snapshot.groups]);
if (key === renderedGroups) return;
renderedGroups = key;
$("groups").replaceChildren();
for (const group of state.snapshot.groups) {
const node = button("", async () => {
selectedGroup = group.id;
renderGroups();
renderChanges();
select({ kind: "group", groupId: group.id }, false);
}, `group-button${group.id === selectedGroup ? " active" : ""}`);
node.setAttribute("aria-pressed", String(group.id === selectedGroup));
node.append(element("span", group.title, "group-title"),
element("span", `${group.files.length} files${group.reviewed ? " | Reviewed locally" : ""}`, "group-count"));
$("groups").append(node);
}
}
function renderChanges() {
selectedDiffLines = null;
fileGeneration++;
const generation = fileGeneration;
const group = state.snapshot.groups.find((item) => item.id === selectedGroup);
$("changes").replaceChildren();
if (!group) {
$("changes").append(element("p", "This PR has no changed files."));
return;
}
const header = element("div", undefined, "card");
const heading = element("div", undefined, "group-heading");
heading.append(element("h2", group.title),
button("Ask about group", async () => select({ kind: "group", groupId: group.id })));
const marked = element("label", undefined, "reviewed-control");
const checkbox = element("input");
checkbox.type = "checkbox";
checkbox.checked = group.reviewed;
checkbox.addEventListener("change", () => run(async () => {
try {
await api("mark", { groupId: group.id, reviewed: checkbox.checked, headSha: state.snapshot.metadata.headSha });
} catch (error) {
checkbox.checked = !checkbox.checked;
throw error;
}
}));
marked.append(checkbox, element("span", "Reviewed locally"));
header.append(heading, element("p", group.rationale, "rationale"), marked);
$("changes").append(header);
const files = new Map(state.snapshot.files.map((file) => [file.filename, file]));
for (const name of group.files) {
const file = files.get(name);
const details = element("details", undefined, "file");
const summary = element("summary", name);
summary.append(element("span", `${file.status} +${file.additions} -${file.deletions}`, "stats"));
const tools = element("div", undefined, "file-heading");
tools.append(button("Ask about file", async () => select({ kind: "file", filename: name })));
if (file.status !== "removed") {
const parsed = new URL(state.url);
const repo = parsed.pathname.split("/").slice(0, 3).join("/");
const path = name.split("/").map(encodeURIComponent).join("/");
tools.append(sourceLink("View pinned source", `${parsed.origin}${repo}/blob/${state.snapshot.metadata.headSha}/${path}`));
}
const content = element("div");
details.append(summary, tools, element("p", file.patchNote, "file-note"), content);
if (file.previousFilename) details.append(element("p", `Renamed from ${file.previousFilename}`, "file-note"));
let loaded = false;
let inflight = false;
details.addEventListener("toggle", async () => {
if (!details.open || loaded || inflight) return;
inflight = true;
content.replaceChildren(element("p", "Loading patch...", "file-note"));
try {
const data = await api(`file?name=${encodeURIComponent(name)}&head=${state.snapshot.metadata.headSha}`);
if (generation !== fileGeneration) return;
renderDiff(content, data);
loaded = true;
} catch (error) {
content.replaceChildren(element("p", `${error.message} Close and reopen this file to retry.`, "answer-error"));
} finally {
inflight = false;
}
});
$("changes").append(details);
}
}
function renderDiff(container, file) {
container.replaceChildren();
if (!file.patch) {
container.append(element("p", "No text patch is available. Ask Copilot for source evidence if needed.", "file-note"));
return;
}
const hunks = [];
for (const line of file.patch.split("\n")) {
if (line.startsWith("@@ ") || !hunks.length) hunks.push([]);
hunks.at(-1).push(line);
}
hunks.forEach((lines, hunkIndex) => {
const match = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(lines[0]);
if (!match) return;
const hunk = element("section", undefined, "hunk");
const header = element("div", undefined, "hunk-header");
const addLines = button("Add selected lines", async () => addSelectedLines(file.filename, hunkIndex), "add-selected-lines");
addLines.disabled = true;
addLines.dataset.filename = file.filename;
addLines.dataset.hunkIndex = String(hunkIndex);
addLines.addEventListener("mousedown", (event) => event.preventDefault());
header.append(element("code", lines[0]), addLines);
const diff = element("div", undefined, "diff");
diff.dataset.filename = file.filename;
diff.dataset.hunkIndex = String(hunkIndex);
let oldLine = Number(match[1]);
let newLine = Number(match[2]);
for (const line of lines.slice(1)) {
const add = line.startsWith("+");
const remove = line.startsWith("-");
const context = line.startsWith(" ");
const row = element("div", undefined, `diff-line${add ? " addition" : remove ? " deletion" : ""}`);
const oldNumber = (remove || context) && oldLine !== null ? String(oldLine++) : "";
const newNumber = (add || context) && newLine !== null ? String(newLine++) : "";
row.dataset.oldLine = oldNumber;
row.dataset.newLine = newNumber;
row.dataset.hunkLine = String(diff.children.length + 1);
row.append(element("span", oldNumber, "line-number"),
element("span", newNumber, "line-number"), element("span", line, "code"));
diff.append(row);
}
hunk.append(header, diff);
container.append(hunk);
});
}
function renderAnswers() {
const key = JSON.stringify(state.questions);
if (key === renderedAnswers) return;
renderedAnswers = key;
$("answers").replaceChildren();
for (const item of [...state.questions].reverse()) {
const card = element("article", undefined, "answer");
card.append(element("h3", item.question),
element("p", `${selectionLabel(item.selection)} | Head ${item.headSha?.slice(0, 12)}`, "answer-context"),
element("div", item.answer || item.error || "Waiting for Copilot...", item.error ? "answer-error" : "answer-body"));
if (item.headSha !== state.snapshot.metadata.headSha) card.append(element("p", "This answer refers to an older snapshot.", "answer-context"));
$("answers").append(card);
}
}
async function refresh() {
if (refreshing) return;
refreshing = true;
try {
const next = await api("state");
connectionError = "";
const previousUrl = state?.url;
state = next;
if (state.url && document.activeElement !== $("pr-url")) $("pr-url").value = state.url;
$("empty").hidden = Boolean(state.snapshot);
$("review").hidden = !state.snapshot;
if (state.snapshot) {
const key = `${state.url}:${state.snapshot.loadedAt}`;
if (key !== renderedKey) {
renderedKey = key;
renderedAnswers = "";
selectedGroup = state.snapshot.groups[0]?.id ?? null;
selection = selectedGroup ? { kind: "group", groupId: selectedGroup } : null;
renderOverview();
renderChanges();
select(selection, false);
}
const checkbox = $("changes").querySelector('input[type="checkbox"]');
const group = state.snapshot.groups.find((item) => item.id === selectedGroup);
if (checkbox && group && activeRequests === 0) checkbox.checked = group.reviewed;
renderGroups();
renderAnswers();
} else if (previousUrl !== state.url) {
renderedKey = "";
selection = null;
}
} catch (error) {
connectionError = `Cannot reach PR Review: ${error.message}. If extensions were reloaded, reopen this canvas.`;
} finally {
refreshing = false;
updateStatus();
}
}
$("load-form").addEventListener("submit", (event) => {
event.preventDefault();
run(() => api("load", { url: $("pr-url").value }));
});
$("ask-form").addEventListener("submit", (event) => {
event.preventDefault();
run(async () => {
await api("ask", { question: $("question").value, selection, headSha: state.snapshot.metadata.headSha });
$("question").value = "";
});
});
$("stop-button").addEventListener("click", () => run(() => api("stop", { requestId: state.pending.id })));
const themeObserver = new MutationObserver(() => {
const mode = document.documentElement.getAttribute("data-color-mode");
if (mode === "dark" || mode === "light") document.documentElement.setAttribute("data-theme", mode);
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-color-mode"] });
refresh();
setInterval(() => { if (!document.hidden) refresh(); }, 2000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
document.addEventListener("selectionchange", captureDiffSelection);