import { layoutCommitGraph } from "./graph-layout.mjs"; import { detectShell, formatShellCommand } from "./shell-quote.mjs"; const SVG_NS = "http://www.w3.org/2000/svg"; const token = new URLSearchParams(location.search).get("token"); const shell = detectShell(navigator.userAgentData?.platform || navigator.platform); const elements = { breadcrumbs: document.querySelector("#breadcrumbs"), empty: document.querySelector("#empty-state"), githubStatus: document.querySelector("#github-status"), graph: document.querySelector("#graph"), graphScroll: document.querySelector("#graph-scroll"), inspector: document.querySelector("#inspector"), loading: document.querySelector("#loading"), refresh: document.querySelector("#refresh-button"), repoPath: document.querySelector("#repo-path"), toast: document.querySelector("#toast"), viewBranches: document.querySelector("#view-branches"), viewWorktrees: document.querySelector("#view-worktrees"), zoomIn: document.querySelector("#zoom-in"), zoomOut: document.querySelector("#zoom-out"), zoomReset: document.querySelector("#zoom-reset"), }; const state = { snapshot: null, current: { type: "repository", id: "repository", label: "Repository" }, breadcrumbs: [], selected: null, commits: new Map(), branchReloadTimer: null, branchRequestId: 0, branchGraph: null, branchGraphRequestId: 0, historyGeneration: 0, eventsStopped: false, repositoryView: "worktrees", zoom: 1, }; async function api(path, options = {}) { const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", "x-git-worktree-token": token, ...(options.headers || {}), }, }); const data = await response.json(); if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); return data; } function post(path, data) { return api(path, { method: "POST", body: JSON.stringify(data) }); } function showToast(message) { elements.toast.textContent = message; elements.toast.classList.add("visible"); clearTimeout(showToast.timer); showToast.timer = setTimeout(() => elements.toast.classList.remove("visible"), 2200); } function formatDate(value) { if (!value) return "Unknown"; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", }).format(date); } function shortPath(value) { if (!value) return "Unassigned"; const parts = value.replace(/\\/g, "/").split("/"); return parts.length > 3 ? `…/${parts.slice(-3).join("/")}` : value; } function svgElement(name, attributes = {}) { const element = document.createElementNS(SVG_NS, name); for (const [key, value] of Object.entries(attributes)) element.setAttribute(key, value); return element; } function nodeForId(id) { if (id === "repository") return { type: "repository", value: state.snapshot.repository }; const worktree = state.snapshot.worktrees.find((item) => item.id === id); if (worktree) return { type: "worktree", value: worktree }; const branch = state.snapshot.branches.find((item) => item.id === id); if (branch) return { type: "branch", value: branch }; for (const page of state.commits.values()) { const commit = page.commits.find((item) => `commit:${item.sha}` === id); if (commit) return { type: "commit", value: commit }; } return null; } function labelFor(node) { if (node.type === "repository") return node.value.name; if (node.type === "worktree") return node.value.name; if (node.type === "branch") return node.value.name; return node.value.shortSha; } function metaFor(node) { if (node.type === "repository") { return `${state.snapshot.worktrees.length} worktrees · ${state.snapshot.branches.length} branches`; } if (node.type === "worktree") { if (node.value.virtual) return `${node.value.branchIds.length} branches`; return node.value.current ? "Current worktree" : shortPath(node.value.path); } if (node.type === "branch") { if (node.value.detached) return node.value.sha?.slice(0, 8); if (state.repositoryView === "branches" && node.value.defaultTracking) { const { ahead, behind } = node.value.defaultTracking; const badges = [ node.value.worktrees.length ? `${node.value.worktrees.length} WT` : null, node.value.pullRequests.length ? `${node.value.pullRequests.length} PR` : null, ].filter(Boolean); return `+${ahead} / -${behind} vs default${badges.length ? ` · ${badges.join(" · ")}` : ""}`; } const { ahead, behind } = node.value.tracking; return `${ahead} ahead · ${behind} behind`; } return `${node.value.author.name} · ${formatDate(node.value.committedAt)}`; } function graphChildren() { if (state.current.type === "repository") { return state.repositoryView === "branches" ? state.snapshot.branches.filter((branch) => !branch.detached).map((value) => ({ type: "branch", value })) : state.snapshot.worktrees.map((value) => ({ type: "worktree", value })); } if (state.current.type === "worktree") { const worktree = state.snapshot.worktrees.find((item) => item.id === state.current.id); return (worktree?.branchIds || []).map((id) => { const value = state.snapshot.branches.find((branch) => branch.id === id); return value ? { type: "branch", value } : null; }).filter(Boolean); } if (state.current.type === "branch") { return (state.commits.get(state.current.id)?.commits || []).map((value) => ({ type: "commit", value })); } return []; } function currentNode() { return nodeForId(state.current.id); } function nodeId(node) { return node.type === "commit" ? `commit:${node.value.sha}` : node.value.id; } function repositoryCrumb() { return { type: "repository", id: "repository", label: state.snapshot.repository.name }; } function pathForNode(node) { const root = repositoryCrumb(); if (node.type === "repository") return [root]; if (node.type === "worktree") { return [root, { type: "worktree", id: node.value.id, label: labelFor(node) }]; } if (node.type === "branch") { if (state.repositoryView === "branches") { return [root, { type: "branch", id: node.value.id, label: labelFor(node) }]; } const worktree = state.snapshot.worktrees.find((item) => item.branchIds.includes(node.value.id)); const branch = { type: "branch", id: node.value.id, label: labelFor(node) }; return worktree ? [root, { type: "worktree", id: worktree.id, label: worktree.name }, branch] : [root, branch]; } return state.breadcrumbs; } function addText(group, className, x, y, text, maxLength = 34) { const label = String(text || ""); const clipped = label.length > maxLength ? `${label.slice(0, maxLength - 1)}…` : label; const element = svgElement("text", { class: className, x, y }); element.textContent = clipped; group.append(element); } function renderNode(node, x, y, width, isParent = false) { const id = nodeId(node); const group = svgElement("g", { class: `node ${node.type}${node.value.dirty ? " dirty" : ""}${state.selected?.id === id ? " selected" : ""}`, role: "button", tabindex: "0", "aria-pressed": state.selected?.id === id ? "true" : "false", "aria-label": `${node.type}: ${labelFor(node)}. ${metaFor(node)}`, transform: `translate(${x} ${y})`, }); const height = isParent ? 98 : 88; group.append(svgElement("rect", { class: "node-card", width, height, rx: 11 })); group.append(svgElement("rect", { class: "node-accent", width: 5, height, rx: 3 })); addText(group, "node-type", 18, 23, node.type.toUpperCase()); addText(group, "node-label", 18, 48, labelFor(node), isParent ? 42 : 30); addText(group, "node-meta", 18, 69, metaFor(node), isParent ? 52 : 34); if (node.type === "commit") addText(group, "node-meta", 18, 80, node.value.subject, 34); const activate = () => selectAndDrill(node); group.addEventListener("click", activate); group.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); return { group, height }; } function renderGraph() { if (state.current.type === "repository" && state.repositoryView === "branches") { renderCombinedBranchGraph(); return; } if (state.current.type === "branch") { renderBranchCommitGraph(); return; } const parent = currentNode(); const children = graphChildren(); elements.graph.replaceChildren(); elements.empty.hidden = true; if (!parent) return; const childWidth = 250; const gapX = 28; const columns = Math.min(Math.max(children.length, 1), 4); const contentWidth = Math.max(900, columns * childWidth + (columns - 1) * gapX + 120); const rows = Math.max(1, Math.ceil(children.length / columns)); const contentHeight = Math.max(560, 230 + rows * 130); elements.graph.setAttribute("viewBox", `0 0 ${contentWidth} ${contentHeight}`); elements.graph.style.width = `${contentWidth * state.zoom}px`; elements.graph.style.height = `${contentHeight * state.zoom}px`; const parentWidth = 300; const parentX = (contentWidth - parentWidth) / 2; const parentY = 55; const edgeLayer = svgElement("g", { class: "edge-layer", "aria-hidden": "true" }); const nodeLayer = svgElement("g", { class: "node-layer" }); elements.graph.append(edgeLayer, nodeLayer); const renderedParent = renderNode(parent, parentX, parentY, parentWidth, true); nodeLayer.append(renderedParent.group); if (!children.length) { elements.empty.textContent = state.snapshot.repository.empty ? "This repository has no commits yet." : "No child nodes are available at this level."; elements.empty.hidden = false; return; } function graphPath(from, to, top, middle, bottom, kind) { const startX = 28 + from * 22; const endX = 28 + to * 22; if (kind === "merge-parent") { return `M ${startX} ${middle} C ${startX} ${middle + 10}, ${endX} ${bottom - 10}, ${endX} ${bottom}`; } if (startX === endX) return `M ${startX} ${top} L ${endX} ${bottom}`; return `M ${startX} ${top} C ${startX} ${middle}, ${endX} ${middle}, ${endX} ${bottom}`; } function relativeTime(value) { const elapsed = Date.now() - new Date(value).getTime(); const minutes = Math.max(0, Math.floor(elapsed / 60000)); if (minutes < 1) return "now"; if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); if (days < 30) return `${days}d`; return formatDate(value); } function renderCombinedBranchGraph() { renderCommitLaneGraph(state.branchGraph, { loadingMessage: "Loading combined branch history…", emptyMessage: "No commits are reachable from local branches.", loadLabel: "Load 100 more", onLoadMore: loadMoreBranchGraph, onRetry: () => loadBranchGraph(true), }); } function renderBranchCommitGraph() { const branch = state.snapshot.branches.find((candidate) => candidate.id === state.current.id); const page = state.commits.get(state.current.id); const decoratedPage = page && branch ? { ...page, commits: page.commits.map((commit) => commit.sha === branch.sha ? { ...commit, refs: [{ id: branch.id, name: branch.name, worktreeCount: branch.worktrees.length, pullRequestCount: branch.pullRequests.length, default: Boolean(branch.isDefault), }], } : commit), } : page; renderCommitLaneGraph(decoratedPage, { loadingMessage: `Loading commits unique to ${branch?.name || "branch"}…`, emptyMessage: page?.comparisonUnavailable ? "Unique commits are unavailable because no remote default branch could be resolved." : "No commits are unique to this branch.", loadLabel: "Load 50 more", onLoadMore: loadMoreCommits, onRetry: () => loadBranchCommits(state.current.id), }); } function renderCommitLaneGraph(page, options) { elements.graph.replaceChildren(); elements.empty.replaceChildren(); elements.empty.classList.remove("has-action"); elements.empty.hidden = true; if (!page) { elements.empty.textContent = options.loadingMessage; elements.empty.hidden = false; return; } if (page.error) { const message = document.createElement("span"); message.textContent = `Could not load commit history: ${page.error}`; const retry = document.createElement("button"); retry.type = "button"; retry.className = "action-button"; retry.textContent = "Retry"; retry.addEventListener("click", options.onRetry); elements.empty.append(message, retry); elements.empty.classList.add("has-action"); elements.empty.hidden = false; return; } if (!page.commits.length) { elements.empty.textContent = options.emptyMessage; elements.empty.hidden = false; return; } const { rows, maxLanes } = layoutCommitGraph(page.commits); const rowHeight = 44; const topPadding = 18; const maxVisibleBadges = 3; const badgeGap = 8; const subjectWidth = 72 * 7; const timeColumnWidth = 96; const laneAreaWidth = Math.max(92, 36 + maxLanes * 22); const badgeWidthFor = (name) => Math.min(190, 22 + name.length * 7); const rowBadges = rows.map((row) => { const refs = row.commit.refs || []; const visible = refs.slice(0, maxVisibleBadges); const overflow = refs.length - visible.length; const badges = visible.map((ref) => ({ ref, width: badgeWidthFor(ref.name) })); if (overflow > 0) badges.push({ overflow, width: badgeWidthFor(`+${overflow} more`) }); const total = badges.reduce((sum, badge) => sum + badge.width + badgeGap, 0); return { badges, total }; }); const widestRow = rowBadges.reduce((max, { total }) => Math.max(max, total), 0); const contentWidth = Math.max( 980, elements.graphScroll.clientWidth || 980, laneAreaWidth + widestRow + subjectWidth + timeColumnWidth, ); const contentHeight = topPadding * 2 + rows.length * rowHeight + (page.nextOffset !== null ? 58 : 0); elements.graph.setAttribute("viewBox", `0 0 ${contentWidth} ${contentHeight}`); elements.graph.style.width = `${contentWidth * state.zoom}px`; elements.graph.style.height = `${contentHeight * state.zoom}px`; const lineLayer = svgElement("g", { class: "commit-line-layer", "aria-hidden": "true" }); const rowLayer = svgElement("g", { class: "commit-row-layer" }); elements.graph.append(lineLayer, rowLayer); rows.forEach((row, index) => { const top = topPadding + index * rowHeight; const middle = top + rowHeight / 2; const bottom = top + rowHeight; row.transitions.forEach((transition) => { const path = svgElement("path", { class: `commit-lane ${transition.kind}`, d: graphPath(transition.from, transition.to, top, middle, bottom, transition.kind), stroke: transition.color, }); lineLayer.append(path); }); const selectedRow = state.selected?.id === `commit:${row.commit.sha}`; // The row button and branch badges are siblings so no interactive role nests inside another. const group = svgElement("g", { class: `commit-row${selectedRow ? " selected" : ""}` }); const rowButton = svgElement("g", { class: "commit-row-button", role: "button", tabindex: "0", "aria-pressed": selectedRow ? "true" : "false", "aria-label": `${row.commit.subject}, ${row.commit.author.name}, ${formatDate(row.commit.committedAt)}`, }); rowButton.append(svgElement("rect", { class: "commit-row-hit", x: 0, y: top, width: contentWidth, height: rowHeight, })); rowButton.append(svgElement("circle", { class: "commit-dot", cx: 28 + row.laneIndex * 22, cy: middle, r: row.commit.parents.length > 1 ? 6 : 5, fill: row.color, })); group.append(rowButton); const badgeLayer = svgElement("g", { class: "commit-row-badges" }); group.append(badgeLayer); let textX = laneAreaWidth; for (const { ref, overflow, width: badgeWidth } of rowBadges[index].badges) { if (overflow) { const hidden = (row.commit.refs || []).slice(maxVisibleBadges).map((item) => item.name); const badge = svgElement("g", { class: "ref-badge overflow" }); const title = svgElement("title"); title.textContent = hidden.join(", "); badge.append(title, svgElement("rect", { x: textX, y: middle - 11, width: badgeWidth, height: 22, rx: 11, })); addText(badge, "ref-badge-text", textX + 10, middle + 4, `+${overflow} more`, 24); badgeLayer.append(badge); textX += badgeWidth + badgeGap; continue; } const badge = svgElement("g", { class: `ref-badge${ref.default ? " default" : ""}`, role: "button", tabindex: "0", "aria-label": `Open branch ${ref.name}`, }); badge.append(svgElement("rect", { x: textX, y: middle - 11, width: badgeWidth, height: 22, rx: 11, })); addText(badge, "ref-badge-text", textX + 10, middle + 4, ref.name, 24); const openBranch = (event) => { event.stopPropagation(); const branch = state.snapshot.branches.find((candidate) => candidate.id === ref.id); if (branch) selectAndDrill({ type: "branch", value: branch }); }; badge.addEventListener("click", openBranch); badge.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openBranch(event); } }); badgeLayer.append(badge); textX += badgeWidth + badgeGap; } addText(rowButton, "commit-subject", textX, middle - 2, row.commit.subject, 72); addText(rowButton, "commit-author", textX, middle + 15, row.commit.author.name, 32); addText(rowButton, "commit-time", contentWidth - 72, middle + 4, relativeTime(row.commit.committedAt), 18); const activate = () => selectAndDrill({ type: "commit", value: row.commit }); rowButton.addEventListener("click", activate); rowButton.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); activate(); } }); rowLayer.append(group); }); if (page.nextOffset !== null) { const foreignObject = svgElement("foreignObject", { x: contentWidth / 2 - 70, y: contentHeight - 50, width: 140, height: 44, }); const button = document.createElement("button"); button.className = "load-more"; button.type = "button"; button.textContent = options.loadLabel; button.addEventListener("click", options.onLoadMore); foreignObject.append(button); rowLayer.append(foreignObject); } } const firstRowCount = Math.min(children.length, columns); const firstRowWidth = firstRowCount * childWidth + (firstRowCount - 1) * gapX; const firstRowStart = (contentWidth - firstRowWidth) / 2; children.forEach((node, index) => { const row = Math.floor(index / columns); const itemsInRow = Math.min(columns, children.length - row * columns); const rowWidth = itemsInRow * childWidth + (itemsInRow - 1) * gapX; const rowStart = row === 0 ? firstRowStart : (contentWidth - rowWidth) / 2; const column = index % columns; const x = rowStart + column * (childWidth + gapX); const y = 225 + row * 130; const parentCenterX = contentWidth / 2; const childCenterX = x + childWidth / 2; const edge = svgElement("path", { class: "edge", d: `M ${parentCenterX} ${parentY + renderedParent.height} C ${parentCenterX} ${y - 50}, ${childCenterX} ${y - 50}, ${childCenterX} ${y}`, }); edgeLayer.append(edge); nodeLayer.append(renderNode(node, x, y, childWidth).group); }); } function renderBreadcrumbs() { elements.breadcrumbs.replaceChildren(); state.breadcrumbs.forEach((crumb, index) => { if (index) { const separator = document.createElement("span"); separator.className = "crumb-separator"; separator.textContent = "›"; elements.breadcrumbs.append(separator); } const button = document.createElement("button"); button.type = "button"; button.className = "crumb"; button.textContent = crumb.label; button.title = crumb.label; button.addEventListener("click", () => navigateTo(index)); elements.breadcrumbs.append(button); }); } function detailRow(term, value, mono = false) { const wrapper = document.createElement("div"); wrapper.className = "detail-row"; const dt = document.createElement("dt"); dt.textContent = term; const dd = document.createElement("dd"); if (mono) dd.className = "mono"; dd.textContent = value ?? "—"; wrapper.append(dt, dd); return wrapper; } function actionButton(label, handler, primary = false) { const button = document.createElement("button"); button.type = "button"; button.className = `action-button${primary ? " primary" : ""}`; button.textContent = label; button.addEventListener("click", handler); return button; } function safeGitHubUrl(value) { try { const url = new URL(value); return url.protocol === "https:" && url.hostname === "github.com" ? url.href : null; } catch { return null; } } async function copyText(value) { await navigator.clipboard.writeText(value); showToast("Copied to clipboard"); } async function copyCommand(parts) { await navigator.clipboard.writeText(formatShellCommand(parts, shell)); showToast(`Copied ${shell === "powershell" ? "PowerShell" : "POSIX shell"} command`); } const mobileLayout = window.matchMedia("(max-width: 560px)"); function syncInspectorVisibility() { // When the mobile overlay is translated off-screen it must also leave the accessibility tree and tab order. const hidden = mobileLayout.matches && !elements.inspector.classList.contains("has-selection"); elements.inspector.inert = hidden; elements.inspector.setAttribute("aria-hidden", String(hidden)); } function focusSelectedGraphControl() { const target = elements.graph.querySelector('[aria-pressed="true"]') || elements.graph.querySelector('[tabindex="0"]') || elements.viewWorktrees; target?.focus(); } function closeInspector() { const hadFocus = elements.inspector.contains(document.activeElement); elements.inspector.classList.remove("has-selection"); syncInspectorVisibility(); if (hadFocus || mobileLayout.matches) focusSelectedGraphControl(); } function renderInspector(node, details = null, { open = false } = {}) { if (!node) return; const content = document.createElement("div"); content.className = "inspector-content"; const close = document.createElement("button"); close.type = "button"; close.className = "inspector-close"; close.setAttribute("aria-label", "Close details"); close.textContent = "×"; close.addEventListener("click", closeInspector); const eyebrow = document.createElement("div"); eyebrow.className = "eyebrow"; eyebrow.textContent = node.type; const title = document.createElement("h2"); title.textContent = node.type === "commit" && details ? details.subject : labelFor(node); const summary = document.createElement("p"); summary.className = "summary"; summary.textContent = metaFor(node); const list = document.createElement("dl"); list.className = "detail-list"; const actions = document.createElement("div"); actions.className = "actions"; const askStatus = document.createElement("p"); askStatus.className = "action-status"; askStatus.dataset.role = "ask-status"; if (node.type === "repository") { list.append( detailRow("Root", node.value.root, true), detailRow("HEAD", node.value.head?.slice(0, 12) || "No commits", true), detailRow("Working tree", node.value.dirty ? `${node.value.changedFiles.length} changed files` : "Clean"), detailRow("Remote", node.value.remote?.raw || "No origin remote", true), detailRow("GitHub", state.snapshot.github.message), ); actions.append( actionButton("Copy path", () => copyText(node.value.root)), actionButton("Copy status command", () => copyCommand(["git", "-C", node.value.root, "status"])), actionButton("Ask Copilot", (event) => askCopilot({ id: "repository" }, event.currentTarget), true), ); if (node.value.changedFiles.length) appendFiles(content, node.value.changedFiles, "Changed files"); } else if (node.type === "worktree") { list.append( detailRow("Path", node.value.path || "Virtual branch group", true), detailRow("Branch", node.value.branch || (node.value.detached ? "Detached HEAD" : "Multiple")), detailRow("HEAD", node.value.head?.slice(0, 12) || "—", true), detailRow("State", [ node.value.current ? "current" : null, node.value.locked ? "locked" : null, node.value.prunable ? "prunable" : null, ].filter(Boolean).join(", ") || "available"), ); if (node.value.path) { actions.append( actionButton("Copy path", () => copyText(node.value.path)), actionButton("Copy status command", () => copyCommand(["git", "-C", node.value.path, "status"])), ); } actions.append(actionButton( "Ask Copilot", (event) => askCopilot({ id: node.value.id }, event.currentTarget), true, )); } else if (node.type === "branch") { list.append( detailRow("Reference", node.value.ref, true), detailRow("HEAD", node.value.sha?.slice(0, 12), true), detailRow("Upstream", node.value.upstream || "Not configured", true), detailRow("Tracking", `${node.value.tracking.ahead} ahead · ${node.value.tracking.behind} behind`), detailRow( "vs default branch", node.value.defaultTracking ? `${node.value.defaultTracking.ahead} unique · ${node.value.defaultTracking.behind} behind` : "Unavailable", ), detailRow("Updated", formatDate(node.value.updatedAt)), detailRow("Worktrees", node.value.worktrees.join(", ") || "Not checked out", true), ); actions.append( actionButton("Copy branch", () => copyText(node.value.name)), actionButton("Copy log command", () => copyCommand( ["git", "log", "--oneline", "-50", "--end-of-options", node.value.name, "--"], )), actionButton("Ask Copilot", (event) => askCopilot({ id: node.value.id }, event.currentTarget), true), ); appendPullRequests(content, node.value.pullRequests); } else { const commit = details || node.value; list.append( detailRow("Commit", commit.sha, true), detailRow("Author", `${commit.author.name} <${commit.author.email}>`), detailRow("Authored", formatDate(commit.authoredAt)), detailRow("Committed", formatDate(commit.committedAt)), detailRow("Parents", commit.parents.join(", ") || "Root commit", true), ); actions.append( actionButton("Copy SHA", () => copyText(commit.sha)), actionButton("Copy show command", () => copyText(`git show ${commit.sha}`)), actionButton("Ask Copilot", (event) => askCopilot({ sha: commit.sha }, event.currentTarget), true), ); const githubUrl = safeGitHubUrl(commit.githubUrl); if (githubUrl) actions.append(actionButton("Open on GitHub", () => window.open(githubUrl, "_blank", "noopener"))); if (commit.body) { const heading = document.createElement("h3"); heading.className = "section-title"; heading.textContent = "Message"; const body = document.createElement("p"); body.className = "summary"; body.textContent = commit.body; content.append(heading, body); } if (commit.files) appendFiles(content, commit.files, "Changed files"); } content.prepend(close, eyebrow, title, summary, list, actions, askStatus); elements.inspector.replaceChildren(content); // Only an explicit selection opens the mobile overlay; snapshot refreshes keep it closed. if (open) elements.inspector.classList.add("has-selection"); syncInspectorVisibility(); } function appendFiles(content, files, title) { const heading = document.createElement("h3"); heading.className = "section-title"; heading.textContent = title; const list = document.createElement("ul"); list.className = "file-list"; files.forEach((file) => { const item = document.createElement("li"); const status = document.createElement("span"); status.className = "file-status"; status.textContent = file.status.trim() || "?"; const path = document.createElement("span"); path.className = "mono"; path.textContent = file.path; item.append(status, path); list.append(item); }); content.append(heading, list); } function appendPullRequests(content, pullRequests) { if (!pullRequests?.length) return; const heading = document.createElement("h3"); heading.className = "section-title"; heading.textContent = "Pull requests"; const list = document.createElement("ul"); list.className = "pr-list"; pullRequests.forEach((pullRequest) => { const item = document.createElement("li"); const url = safeGitHubUrl(pullRequest.url); if (url) { const link = document.createElement("a"); link.className = "pr-link"; link.href = url; link.target = "_blank"; link.rel = "noopener"; link.textContent = `#${pullRequest.number} ${pullRequest.title}`; item.append(link); } else { item.textContent = `#${pullRequest.number} ${pullRequest.title}`; } const stateLabel = document.createElement("span"); stateLabel.className = "badge"; stateLabel.textContent = pullRequest.isDraft ? "Draft" : pullRequest.state; item.append(" ", stateLabel); list.append(item); }); content.append(heading, list); } async function selectAndDrill(node) { const id = nodeId(node); state.selected = { type: node.type, id }; renderGraph(); if (node.type === "commit") { renderInspector(node, null, { open: true }); try { const details = await post("/api/commit", { sha: node.value.sha }); if (state.selected?.id === id) renderInspector(node, details, { open: true }); } catch (error) { showToast(error.message); } return; } renderInspector(node, null, { open: true }); const path = pathForNode(node); state.breadcrumbs = path; state.current = path.at(-1); renderBreadcrumbs(); renderGraph(); if (node.type === "branch" && !state.commits.has(id)) await loadBranchCommits(id); } function navigateTo(index) { state.breadcrumbs = state.breadcrumbs.slice(0, index + 1); state.current = state.breadcrumbs.at(-1); const node = currentNode(); state.selected = node ? { type: node.type, id: nodeId(node) } : null; renderBreadcrumbs(); renderGraph(); renderInspector(node); } async function loadMoreCommits() { const branchId = state.current.id; const page = state.commits.get(branchId); if (!page || page.nextOffset === null) return; const requestId = ++state.branchRequestId; const generation = state.historyGeneration; try { const next = await post("/api/commits", { branchId, offset: page.nextOffset }); if ( state.current.id !== branchId || state.branchRequestId !== requestId || state.historyGeneration !== generation ) return; state.commits.set(branchId, { ...next, commits: [...page.commits, ...next.commits], offset: 0, }); renderGraph(); } catch (error) { // Keep the already-loaded page so history and the load-more cursor survive a transient failure. if (state.branchRequestId === requestId && state.historyGeneration === generation) { showToast(`Could not load more commits: ${error.message}`); } } } async function loadBranchGraph(reset = false) { const requestId = ++state.branchGraphRequestId; const generation = state.historyGeneration; const offset = reset ? 0 : state.branchGraph?.nextOffset; if (offset === null) return; elements.loading.hidden = false; try { const page = await post("/api/graph", { offset: offset || 0 }); if ( state.branchGraphRequestId !== requestId || state.historyGeneration !== generation || state.repositoryView !== "branches" ) return; state.branchGraph = reset || !state.branchGraph ? page : { ...page, commits: [...state.branchGraph.commits, ...page.commits] }; renderGraph(); } catch (error) { if (state.branchGraphRequestId === requestId && state.historyGeneration === generation) { if (reset || !state.branchGraph) { state.branchGraph = { commits: [], nextOffset: null, error: error.message }; renderGraph(); showToast(error.message); } else { // Preserve the cached pages; the load-more button stays available for retry. showToast(`Could not load more commits: ${error.message}`); } } } finally { if (state.branchGraphRequestId === requestId) elements.loading.hidden = true; } } function loadMoreBranchGraph() { return loadBranchGraph(false); } async function askCopilot(payload, button) { const status = elements.inspector.querySelector('[data-role="ask-status"]'); const originalLabel = button?.textContent || "Ask Copilot"; if (button) { button.disabled = true; button.textContent = "Sending…"; } if (status) { status.className = "action-status pending"; status.textContent = "Sending this selection to the current Copilot chat…"; } try { await post("/api/ask", payload); if (button) button.textContent = "Sent ✓"; if (status) { status.className = "action-status success"; status.textContent = "Sent to chat. Copilot will respond in the conversation."; } showToast("Sent to the current Copilot chat"); } catch (error) { if (button) button.textContent = "Try again"; if (status) { status.className = "action-status error"; status.textContent = `Could not send: ${error.message}`; } showToast(error.message); } finally { if (button) button.disabled = false; if (button?.textContent === "Sending…") button.textContent = originalLabel; } } function updateHeader() { elements.repoPath.textContent = state.snapshot.repository.root; elements.repoPath.title = state.snapshot.repository.root; elements.githubStatus.textContent = state.snapshot.github.status === "ready" ? `${state.snapshot.github.pullRequestCount} GitHub PRs` : "Local Git only"; elements.githubStatus.classList.toggle("ready", state.snapshot.github.status === "ready"); elements.githubStatus.title = state.snapshot.github.message; } function applySnapshot(snapshot, preserveNavigation = false) { state.historyGeneration++; state.branchRequestId++; state.branchGraphRequestId++; clearTimeout(state.branchReloadTimer); state.snapshot = snapshot; state.commits.clear(); state.branchGraph = null; if (!preserveNavigation || !nodeForId(state.current.id)) { state.current = repositoryCrumb(); state.breadcrumbs = [state.current]; } else { state.breadcrumbs = pathForNode(nodeForId(state.current.id)); state.current = state.breadcrumbs.at(-1); } state.selected = { type: state.current.type, id: state.current.id }; updateHeader(); renderBreadcrumbs(); renderGraph(); renderInspector(currentNode()); elements.loading.hidden = true; if (state.current.type === "branch") scheduleVisibleBranchReload(); if (state.current.type === "repository" && state.repositoryView === "branches") loadBranchGraph(true); } function scheduleVisibleBranchReload() { clearTimeout(state.branchReloadTimer); const branchId = state.current.id; state.branchReloadTimer = setTimeout(() => loadBranchCommits(branchId), 75); } async function loadBranchCommits(branchId) { const requestId = ++state.branchRequestId; const generation = state.historyGeneration; elements.loading.hidden = false; try { const page = await post("/api/commits", { branchId, offset: 0 }); if ( state.current.id !== branchId || state.branchRequestId !== requestId || state.historyGeneration !== generation ) return; state.commits.set(branchId, page); renderGraph(); } catch (error) { if ( state.current.id === branchId && state.branchRequestId === requestId && state.historyGeneration === generation ) { state.commits.set(branchId, { commits: [], nextOffset: null, error: error.message }); renderGraph(); showToast(error.message); } } finally { if (state.branchRequestId === requestId) elements.loading.hidden = true; } } async function refresh() { elements.refresh.classList.add("busy"); elements.refresh.disabled = true; try { applySnapshot(await post("/api/refresh", {}), true); showToast("Repository refreshed"); } catch (error) { showToast(error.message); } finally { elements.refresh.classList.remove("busy"); elements.refresh.disabled = false; } } async function connectEvents() { let retryDelay = 500; while (!state.eventsStopped) { try { const response = await fetch("/api/events", { headers: { "x-git-worktree-token": token }, }); if (!response.ok || !response.body) throw new Error("Event stream unavailable."); retryDelay = 500; const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!state.eventsStopped) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let boundary; while ((boundary = buffer.indexOf("\n\n")) >= 0) { const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const event = block.match(/^event: (.+)$/m)?.[1]; const data = block.match(/^data: (.+)$/m)?.[1]; if (!event || !data) continue; const payload = JSON.parse(data); if (event === "snapshot") applySnapshot(payload, true); if (event === "focus") { const node = nodeForId(payload.nodeId); if (node) selectAndDrill(node); } } } } catch { // Retry because agent-triggered refresh and focus actions depend on SSE. } if (!state.eventsStopped) { await new Promise((resolve) => setTimeout(resolve, retryDelay)); retryDelay = Math.min(retryDelay * 2, 5000); } } } function setZoom(value) { state.zoom = Math.min(1.5, Math.max(0.6, value)); elements.zoomReset.textContent = `${Math.round(state.zoom * 100)}%`; renderGraph(); } function setRepositoryView(view) { state.repositoryView = view; state.current = repositoryCrumb(); state.breadcrumbs = [state.current]; state.selected = { type: "repository", id: "repository" }; elements.viewWorktrees.classList.toggle("active", view === "worktrees"); elements.viewWorktrees.setAttribute("aria-pressed", String(view === "worktrees")); elements.viewBranches.classList.toggle("active", view === "branches"); elements.viewBranches.setAttribute("aria-pressed", String(view === "branches")); renderBreadcrumbs(); renderGraph(); renderInspector(currentNode()); if (view === "branches") loadBranchGraph(true); } elements.refresh.addEventListener("click", refresh); elements.viewWorktrees.addEventListener("click", () => setRepositoryView("worktrees")); elements.viewBranches.addEventListener("click", () => setRepositoryView("branches")); elements.zoomIn.addEventListener("click", () => setZoom(state.zoom + 0.1)); elements.zoomOut.addEventListener("click", () => setZoom(state.zoom - 0.1)); elements.zoomReset.addEventListener("click", () => setZoom(1)); mobileLayout.addEventListener("change", syncInspectorVisibility); elements.inspector.addEventListener("keydown", (event) => { if (event.key === "Escape" && mobileLayout.matches && elements.inspector.classList.contains("has-selection")) { event.preventDefault(); closeInspector(); } }); syncInspectorVisibility(); window.addEventListener("pagehide", () => { state.eventsStopped = true; clearTimeout(state.branchReloadTimer); }); try { if (!token) throw new Error("Canvas capability token is missing."); applySnapshot(await api("/api/snapshot")); connectEvents(); } catch (error) { elements.loading.hidden = true; elements.empty.hidden = false; elements.empty.textContent = error.message; }