Files
byre/web/dist/changes.js
T

260 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { get } from "./api.js";
import { errorText, h, replace } from "./dom.js";
import { onEach, pinSnapshot, snapshots, state } from "./state.js";
const statusLetter = {
added: "A",
modified: "M",
deleted: "D",
renamed: "R",
type_changed: "T",
};
const open = new Set();
let range = "main";
let pinRange = "step";
const emptyText = {
main: "No changes from the default branch.",
turn: "No changes this turn.",
step: "This step changed nothing.",
};
function turnStart() {
const ev = state.transcript.findLast((e) => e.kind === "user.message" && typeof e.payload["head"] === "string");
return ev?.payload["head"];
}
export function mountChanges(root, compact = false) {
const head = h("header", { class: "changes-header" });
const list = h("div", { class: "changes-list" });
const diffs = new Map();
let changes;
let error = "";
let loading = false;
let again = false;
let timer;
let disposed = false;
let loadedSince;
let target = 0;
const pinned = () => (compact ? undefined : state.pinned);
const mode = () => (compact ? "main" : pinned() !== undefined ? pinRange : range);
const load = async () => {
const taskId = state.selectedTaskId;
if (!taskId) {
changes = undefined;
render();
return;
}
const snapshot = pinned();
const since = snapshot === undefined && mode() === "turn" ? turnStart() : undefined;
if (snapshot === undefined && mode() === "turn" && !since) {
changes = undefined;
error = "No turn has started in this task yet.";
render();
return;
}
if (loading) {
again = true;
return;
}
loading = true;
const started = target;
renderHead();
try {
const query = snapshot === undefined ? { since } : { snapshot, step: pinRange === "step" };
const fresh = await get("/api/tasks/{taskId}/changes", { path: { taskId }, query });
if (started === target) {
loadedSince = since;
if (fresh.tree !== changes?.tree || fresh.base !== changes?.base) {
diffs.clear();
}
changes = fresh;
error = "";
}
}
catch (err) {
if (started === target) {
error = errorText(err);
}
}
finally {
loading = false;
}
if (disposed) {
return;
}
render();
if (again) {
again = false;
void load();
}
};
const retarget = () => {
target++;
clearTimeout(timer);
changes = undefined;
diffs.clear();
error = "";
render();
void load();
};
const soon = () => {
clearTimeout(timer);
timer = setTimeout(() => void load(), 1500);
};
const toggle = () => {
if (compact) {
return null;
}
if (pinned() !== undefined) {
const button = (r, label) => {
const b = h("button", { type: "button", class: r === pinRange ? "active" : "" }, label);
b.onclick = () => {
if (pinRange === r) {
return;
}
pinRange = r;
retarget();
};
return b;
};
const live = h("button", { type: "button", title: "Back to the live worktree" }, "Live");
live.onclick = () => pinSnapshot(undefined);
return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("step", "This step"), live);
}
const button = (r, label) => {
const b = h("button", { type: "button", class: r === range ? "active" : "" }, label);
b.onclick = () => {
if (range === r) {
return;
}
range = r;
retarget();
};
return b;
};
return h("nav", { class: "range-tabs" }, button("main", "Since main"), button("turn", "This turn"));
};
const where = () => {
const n = pinned();
if (n === undefined) {
return null;
}
const snaps = snapshots();
const snap = snaps.find((s) => s.n === n);
const step = (to, label) => {
const b = h("button", { type: "button", class: "snap-step", disabled: !snaps.some((s) => s.n === to) }, label);
b.onclick = () => pinSnapshot(to);
return b;
};
return h("div", { class: "snap-where" }, `snapshot ${n}`, snap ? ` · ${snap.trigger} · ${new Date(snap.created_at).toLocaleTimeString([], { hour12: false })}` : "", " · ", step(n - 1, `← #${n - 1}`), " ", step(n + 1, `#${n + 1} →`));
};
const renderHead = () => {
if (!changes) {
replace(head, h("div", { class: "changes-where" }, where(), h("span", { class: "changes-summary" }, error || (state.selectedTaskId ? "Loading changes…" : "No task selected."))), toggle());
return;
}
const adds = changes.files.reduce((n, f) => n + f.additions, 0);
const dels = changes.files.reduce((n, f) => n + f.deletions, 0);
replace(head, h("div", { class: "changes-where" }, where(), h("span", { class: "changes-summary" }, `${changes.files.length} ${changes.files.length === 1 ? "file" : "files"} `, h("span", { class: "adds" }, `+${adds}`), " ", h("span", { class: "dels" }, `−${dels}`), h("span", { class: "base", title: changes.base }, ` vs ${changes.base.slice(0, 8)}`), error && h("span", { class: "error-text" }, ` ${error}`))), toggle());
};
const fileRow = (c, f) => {
const body = h("div", { class: "file-diff" });
const details = h("details", { class: `file ${f.status}`, open: open.has(f.path) }, h("summary", {}, h("span", { class: `status ${f.status}`, title: f.status.replace("_", " ") }, statusLetter[f.status]), h("span", { class: "file-path" }, f.old_path ? `${f.old_path} → ${f.path}` : f.path), f.binary ? h("span", { class: "binary" }, "binary") : h("span", { class: "counts" }, h("span", { class: "adds" }, `+${f.additions}`), " ", h("span", { class: "dels" }, `−${f.deletions}`))), body);
const show = async () => {
const cached = diffs.get(f.path);
if (cached !== undefined) {
replace(body, renderPatch(cached));
return;
}
replace(body, h("div", { class: "hint" }, "loading…"));
try {
const d = await get("/api/tasks/{taskId}/diff", {
path: { taskId: state.selectedTaskId },
query: { base: c.base, tree: c.tree, path: f.path, old_path: f.old_path },
});
diffs.set(f.path, d.patch);
replace(body, renderPatch(d.patch));
}
catch (err) {
replace(body, h("div", { class: "error-text" }, errorText(err)));
}
};
details.ontoggle = () => {
if (details.open) {
open.add(f.path);
void show();
}
else {
open.delete(f.path);
}
};
if (details.open) {
void show();
}
return details;
};
const render = () => {
renderHead();
if (compact) {
return;
}
if (!state.selectedTaskId) {
replace(list, h("div", { class: "empty" }, "No task selected."));
return;
}
if (!changes) {
replace(list, h("div", { class: "empty" }, error ? "" : "Loading…"));
return;
}
if (changes.files.length === 0) {
replace(list, h("div", { class: "empty" }, emptyText[mode()]));
return;
}
const c = changes;
const files = [...c.files].sort((a, b) => a.path.localeCompare(b.path));
replace(list, ...files.map((f) => fileRow(c, f)));
};
const offs = [
onEach(["selection"], retarget),
onEach(["pin"], () => {
if (!compact) {
retarget();
}
}),
onEach(["transcript"], (fresh) => {
if (pinned() !== undefined) {
if (fresh.some((ev) => ev.kind === "snapshot.created")) {
renderHead();
}
return;
}
if (mode() === "turn" && turnStart() !== loadedSince) {
void load();
return;
}
if (fresh.some((ev) => ev.kind === "agent.tool_result" || ev.kind === "turn.ended")) {
soon();
}
}),
];
replace(root, compact ? h("div", { class: "changes-view compact" }, head) : h("div", { class: "changes-view" }, head, list));
render();
void load();
return () => {
disposed = true;
clearTimeout(timer);
for (const off of offs) {
off();
}
};
}
function renderPatch(patch) {
const lines = patch.split("\n");
const start = lines.findIndex((l) => l.startsWith("@@"));
const body = start === -1 ? [] : lines.slice(start);
if (body.length === 0) {
return h("div", { class: "hint" }, "No textual changes.");
}
return h("pre", { class: "diff" }, ...body.map((l) => {
const cls = l.startsWith("@@") ? "hunk" : l.startsWith("+") ? "add" : l.startsWith("-") ? "del" : l.startsWith("\\") ? "meta" : "ctx";
return h("span", { class: `line ${cls}` }, l || " ");
}));
}
//# sourceMappingURL=changes.js.map