diff --git a/packages/review-tutor/src/page-script.ts b/packages/review-tutor/src/page-script.ts index 296defc..fcb5413 100644 --- a/packages/review-tutor/src/page-script.ts +++ b/packages/review-tutor/src/page-script.ts @@ -67,7 +67,13 @@ export const pageScript = String.raw` composerSelectionKey = null, historyEntries = [], historyIndex = 0, - activeHistoryBadge = null; + activeHistoryBadge = null, + structureSnapshot = null, + structureError = null, + structureInputId = null, + structureStatus = "idle", + structureRequestSequence = 0, + selectedConnection = null; const learningBadgeGroups = new Map(); const foreignActiveIds = new Set(); let railCollapsed = sessionStorage.getItem("reviewTutorRailCollapsed") === "1"; @@ -370,28 +376,263 @@ export const pageScript = String.raw` updateAnswerVisibility(); } const diffControls = ["files", "previous-file", "next-file", "select-lines", "open-composer", "totals"]; + function structureStatusLabel(status) { + return status === "modified" ? "edited" : status; + } + function structureKindLabel(kind) { + return kind === "reexport" ? "re-export" : kind === "dynamic-import" ? "dynamic" : kind; + } + function resetStructure() { + structureRequestSequence++; + structureSnapshot = null; + structureError = null; + structureInputId = null; + structureStatus = "idle"; + selectedConnection = null; + element("structure-content").replaceChildren(); + } + function clearStructureLanding() { + document.querySelector(".diff-row.structure-landing")?.classList.remove("structure-landing"); + } + function normalizedLine(text) { + return String(text || "").trim().replace(/\s+/g, " "); + } + function jumpToEvidence(evidence, edgeStatus, evidenceIndex, allEvidence) { + clearStructureLanding(); + const fileIndex = files.findIndex((file) => file.path === evidence.path); + if (fileIndex < 0) { + announce(evidence.path + " is not in the diff view."); + return; + } + let preferredKind = edgeStatus === "added" + ? "addition" + : edgeStatus === "removed" + ? "deletion" + : null; + if (edgeStatus === "modified") { + const identical = allEvidence.length > 1 && allEvidence.every((item) => normalizedLine(item.text) === normalizedLine(allEvidence[0].text)); + preferredKind = identical || evidenceIndex > 0 ? "addition" : "deletion"; + } + const matches = []; + const evidenceText = normalizedLine(evidence.text); + files[fileIndex].lines.forEach((line, rowIndex) => { + const lineText = normalizedLine(line.text); + if (rowSelectable(line) && line.selectLine === evidence.line && evidenceText && (lineText === evidenceText || lineText.startsWith(evidenceText))) + matches.push({ line, rowIndex }); + }); + const target = preferredKind + ? matches.find((match) => match.line.kind === preferredKind) || matches[0] + : matches[0]; + setView("diff"); + scrollFile(fileIndex); + if (!target) { + const head = element("file-" + fileIndex)?.querySelector(".file-head"); + head?.focus({ preventScroll: true }); + announce("Line " + evidence.line + " is not in the diff view."); + return; + } + const control = rowControl({ fileIndex, rowIndex: target.rowIndex }); + const row = rowNode({ fileIndex, rowIndex: target.rowIndex }); + row?.classList.add("structure-landing"); + row?.scrollIntoView({ block: "center" }); + control?.focus({ preventScroll: true }); + } + function renderStructure() { + selectedConnection = null; + const container = element("structure-content"); + container.replaceChildren(); + if (!currentSource || structureStatus === "loading") { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = currentSource ? "Analyzing structure…" : "Load a source to begin reviewing."; + container.append(empty); + return; + } + if (structureStatus === "error") { + const card = document.createElement("div"); + card.className = "structure-error-card"; + const message = document.createElement("p"); + message.id = "structure-error"; + message.textContent = structureError; + const retry = document.createElement("button"); + retry.id = "structure-retry"; + retry.className = "ghost"; + retry.type = "button"; + retry.textContent = "Retry"; + retry.addEventListener("click", () => { + structureStatus = "idle"; + ensureStructure(); + element("structure-title")?.focus(); + }); + card.append(message, retry); + container.append(card); + return; + } + if (!structureSnapshot) return; + const snapshot = structureSnapshot; + const comparison = document.createElement("p"); + comparison.id = "structure-comparison"; + comparison.className = "structure-comparison"; + comparison.textContent = snapshot.comparison.from + " → " + snapshot.comparison.to; + container.append(comparison); + const disclosureItems = [ + ...snapshot.comparison.reasons.map((reason) => ({ reason })), + ...snapshot.limits.omitted, + ]; + if (snapshot.comparison.partial || snapshot.limits.truncated) { + const notice = document.createElement("aside"); + notice.id = "structure-partial"; + notice.className = "structure-partial"; + const sentence = document.createElement("p"); + sentence.textContent = "Structure analysis is partial; some connections may be missing."; + const details = document.createElement("details"); + const summary = document.createElement("summary"); + summary.textContent = disclosureItems.length + " " + (disclosureItems.length === 1 ? "reason" : "reasons"); + const reasons = document.createElement("ul"); + for (const item of disclosureItems) { + const reason = document.createElement("li"); + reason.textContent = (item.path ? item.path + ": " : "") + item.reason; + reasons.append(reason); + } + details.append(summary, reasons); + notice.append(sentence, details); + container.append(notice); + } + if (!snapshot.edges.length) + container.append(makeSpan("structure-zero", "No connections among changed files. Unchanged neighbours are outside this view.")); + snapshot.files.forEach((file, fileIndex) => { + const connections = snapshot.edges.filter((edge) => edge.from === file.path); + if (!connections.length && !(!file.analyzed && file.reason)) return; + const group = document.createElement("section"); + group.className = "structure-file"; + const headingId = "structure-file-" + fileIndex; + group.setAttribute("aria-labelledby", headingId); + const head = document.createElement("div"); + head.className = "structure-file-head file-head"; + const heading = document.createElement("h3"); + heading.id = headingId; + heading.className = "structure-file-path"; + heading.textContent = file.path; + heading.title = file.path; + const status = makeSpan("structure-file-status status-chip status-" + file.status, file.status); + head.append(heading, status); + group.append(head); + if (file.renamedFrom) + group.append(makeSpan("structure-file-note", "renamed from " + file.renamedFrom)); + if (!file.analyzed && file.reason) + group.append(makeSpan("structure-file-note", file.reason)); + const list = document.createElement("div"); + list.className = "connection-list"; + connections.forEach((edge, edgeIndex) => { + const key = fileIndex + "-" + edgeIndex; + const evidenceId = "connection-evidence-" + key; + const row = document.createElement("button"); + row.type = "button"; + row.className = "connection-row"; + row.dataset.status = edge.status; + row.setAttribute("aria-expanded", "false"); + row.setAttribute("aria-controls", evidenceId); + row.append( + makeSpan("connection-kind", structureKindLabel(edge.kind)), + makeSpan("connection-target", edge.to), + ); + if (edge.typeOnly) row.append(makeSpan("connection-type", "type")); + row.append(makeSpan("connection-status status-chip status-" + edge.status, structureStatusLabel(edge.status))); + const evidenceList = document.createElement("ul"); + evidenceList.id = evidenceId; + evidenceList.className = "connection-evidence"; + evidenceList.hidden = true; + edge.evidence.forEach((evidence, evidenceIndex) => { + const item = document.createElement("li"); + const code = document.createElement("span"); + code.className = "evidence-code"; + code.textContent = evidence.line + " " + evidence.text; + const open = document.createElement("button"); + open.type = "button"; + open.className = "ghost open-in-diff"; + open.textContent = "Open in Diff"; + open.addEventListener("click", () => jumpToEvidence(evidence, edge.status, evidenceIndex, edge.evidence)); + item.append(code, open); + evidenceList.append(item); + }); + row.addEventListener("click", () => { + const expanded = row.getAttribute("aria-expanded") === "true"; + if (selectedConnection && selectedConnection !== row) { + selectedConnection.classList.remove("selected"); + selectedConnection.setAttribute("aria-expanded", "false"); + element(selectedConnection.getAttribute("aria-controls")).hidden = true; + } + selectedConnection = row; + row.classList.add("selected"); + row.setAttribute("aria-expanded", String(!expanded)); + evidenceList.hidden = expanded; + }); + list.append(row, evidenceList); + }); + if (connections.length) group.append(list); + container.append(group); + }); + } + function ensureStructure() { + if (!currentSource) { + renderStructure(); + return; + } + if (structureInputId === currentSource.id && structureStatus !== "idle") return; + const inputId = currentSource.id; + const sequence = ++structureRequestSequence; + structureInputId = inputId; + structureStatus = "loading"; + renderStructure(); + announce("Analyzing structure…"); + api("/api/structure").then((snapshot) => { + if (sequence !== structureRequestSequence || currentSource?.id !== inputId) return; + if (snapshot?.inputId !== inputId) { + structureError = "Structure analysis did not match the current source."; + structureSnapshot = null; + structureStatus = "error"; + renderStructure(); + announce(structureError); + return; + } + structureSnapshot = snapshot; + structureError = null; + structureStatus = "loaded"; + renderStructure(); + announce("Structure analysis complete."); + }).catch((error) => { + if (sequence !== structureRequestSequence || currentSource?.id !== inputId) return; + structureError = error instanceof Error ? error.message : String(error); + structureSnapshot = null; + structureStatus = "error"; + renderStructure(); + announce("Structure analysis failed: " + structureError); + }); + } function setView(view, focusPanel = false) { - activeView = view === "log" ? "log" : "diff"; - if (activeView === "log") hideInlineComposer(); + activeView = ["diff", "structure", "log"].includes(view) ? view : "diff"; + if (activeView !== "diff") hideInlineComposer(); element("diff").hidden = activeView !== "diff"; + element("structure-section").hidden = activeView !== "structure"; element("log-section").hidden = activeView !== "log"; for (const id of diffControls) element(id).hidden = activeView !== "diff"; element("mobile-ask").hidden = activeView !== "diff"; - for (const name of ["diff", "log"]) { + for (const name of ["diff", "structure", "log"]) { const tab = element("view-" + name); const selected = name === activeView; tab.setAttribute("aria-selected", String(selected)); tab.tabIndex = selected ? 0 : -1; } - announce(activeView === "log" ? "Learning log view." : "Diff view."); - if (focusPanel) (activeView === "log" ? element("log-title") : element("view-diff")).focus(); + announce(activeView === "log" ? "Learning log view." : activeView === "structure" ? "Structure view." : "Diff view."); + if (activeView === "structure") ensureStructure(); + if (focusPanel) (activeView === "log" ? element("log-title") : activeView === "structure" ? element("structure-title") : element("view-diff")).focus(); } function handleViewKey(event) { if (!["ArrowLeft", "ArrowRight", "Home", "End", "Enter", " "].includes(event.key)) return; - const tabs = [element("view-diff"), element("view-log")]; + const tabs = [element("view-diff"), element("view-structure"), element("view-log")]; const current = tabs.indexOf(event.currentTarget); if (["Enter", " "].includes(event.key)) { - setView(event.currentTarget.id === "view-log" ? "log" : "diff"); + setView(event.currentTarget.id.replace("view-", "")); } else { const target = event.key === "Home" ? tabs[0] @@ -400,6 +641,7 @@ export const pageScript = String.raw` : event.key === "ArrowLeft" ? tabs[Math.max(0, current - 1)] : tabs[Math.min(tabs.length - 1, current + 1)]; + tabs.forEach((tab) => { tab.tabIndex = tab === target ? 0 : -1; }); target.focus(); } event.preventDefault(); @@ -511,6 +753,7 @@ export const pageScript = String.raw` function chooseRow(fileIndex, rowIndex, extend, confirm) { const file = files[fileIndex], row = file.lines[rowIndex]; if (!rowSelectable(row)) return; + clearStructureLanding(); clearError(); const previousAnchor = selectionAnchor; if (!extend || !selectionAnchor || selectionAnchor.fileIndex !== fileIndex || file.lines[selectionAnchor.rowIndex].block !== row.block) @@ -828,6 +1071,7 @@ export const pageScript = String.raw` article.id = "file-" + fileIndex; const head = document.createElement("div"); head.className = "file-head"; + head.tabIndex = -1; const disclosure = document.createElement("button"); disclosure.textContent = "▾"; disclosure.setAttribute("aria-label", "Collapse " + file.path); @@ -1513,6 +1757,7 @@ export const pageScript = String.raw` } function acceptSource(source) { currentSource = source; + resetStructure(); const parsed = parseUnifiedDiff(source.content, source.label); files = parsed.files; preambleText = parsed.preamble; @@ -1530,8 +1775,8 @@ export const pageScript = String.raw` } element("kind").addEventListener("change", updateSourceFields); element("model").addEventListener("change", updateThinking); - for (const id of ["view-diff", "view-log"]) { - element(id).addEventListener("click", () => setView(id === "view-log" ? "log" : "diff")); + for (const id of ["view-diff", "view-structure", "view-log"]) { + element(id).addEventListener("click", () => setView(id.replace("view-", ""))); element(id).addEventListener("keydown", handleViewKey); } element("history-previous").addEventListener("click", () => renderHistoryEntry(Math.max(0, historyIndex - 1))); diff --git a/packages/review-tutor/src/page-styles.ts b/packages/review-tutor/src/page-styles.ts index 2f134ff..58d3440 100644 --- a/packages/review-tutor/src/page-styles.ts +++ b/packages/review-tutor/src/page-styles.ts @@ -18,7 +18,7 @@ button:hover:not(:disabled) { border-color:rgba(255,255,255,.28)} button:disabled { cursor:not-allowed;opacity:.48} -button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible { +button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,summary:focus-visible,[tabindex]:focus-visible { outline:2px solid rgba(255,122,26,.6);outline-offset:2px} input,select { width:100%;height:var(--control-h);padding:0 10px} @@ -105,7 +105,7 @@ display:none} .diff-scroll { overflow:auto;min-height:0;scroll-behavior:smooth} .empty { -padding:56px 24px;color:var(--muted)} +display:block;margin:0;padding:56px 24px;color:var(--muted)} .diff-preamble { margin:14px 14px 4px;padding:10px 12px;border:1px solid var(--hairline);border-radius:var(--radius-card);background:var(--surface-1);color:var(--muted);font:11px/1.6 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} .file { @@ -134,6 +134,8 @@ background:rgba(60,190,125,.07);box-shadow:inset 2px 0 var(--green)} background:rgba(220,80,95,.07);box-shadow:inset 2px 0 var(--rose)} .diff-row.selected { background:rgba(255,122,26,.11);box-shadow:inset 2px 0 var(--ember)} +.diff-row.structure-landing { +box-shadow:inset 2px 0 var(--ember)} .file.plain .diff-row { grid-template-columns:52px 24px minmax(0,1fr)} .file.plain .line-no:nth-child(2) { @@ -286,6 +288,64 @@ content:"";display:inline-block;width:7px;height:1em;background:var(--ember);ver position:fixed;top:calc(var(--topbar-h) + 10px);left:50%;translate:-50% 0;z-index:60;max-width:min(560px,calc(100vw - 24px));padding:10px 14px;border:1px solid rgba(231,123,134,.4);border-radius:var(--radius-control);background:var(--surface-2);color:#ff9ca5;white-space:pre-wrap;box-shadow:0 6px 24px rgba(0,0,0,.5)} .error:empty { display:none} +.structure-section { +contain:layout;min-width:0;padding:14px 14px 100px;overflow-x:hidden} +.structure-comparison { +margin:0 0 10px;color:var(--subtle);font:12px var(--font-mono)} +.structure-partial { +margin:0 0 14px;padding:10px 12px;border:1px solid var(--amber);border-radius:var(--radius-card);background:var(--surface-1);color:var(--subtle)} +.structure-partial p { +margin:0} +.structure-partial details { +margin-top:6px} +.structure-partial summary { +width:max-content;max-width:100%;color:var(--amber);cursor:pointer;font:600 11px var(--font-mono)} +.structure-partial ul { +margin:7px 0 0;padding-left:20px;overflow-wrap:anywhere} +.structure-error-card { +display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--rose);border-radius:var(--radius-card);background:var(--surface-1)} +.structure-error-card p { +flex:1;min-width:0;margin:0;color:var(--rose);overflow-wrap:anywhere} +.structure-zero { +display:block;padding:24px 0;color:var(--muted)} +.structure-file { +min-width:0;border:1px solid var(--hairline);border-radius:var(--radius-card);overflow:clip;background:var(--surface-1)} +.structure-file + .structure-file { +margin-top:10px} +.structure-file-head { +position:static;min-width:0} +.structure-file-path { +min-width:0;margin:0;font:12px var(--font-mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.status-chip,.connection-kind,.connection-type { +flex:none;color:var(--muted);font:600 10px var(--font-mono);letter-spacing:.1em;text-transform:uppercase} +.status-added { +color:var(--green)} +.status-removed { +color:var(--rose)} +.status-modified { +color:var(--amber)} +.structure-file-note { +display:block;padding:6px 14px;border-bottom:1px solid var(--hairline);color:var(--muted);font:11px var(--font-mono);overflow-wrap:anywhere} +.connection-list { +min-width:0} +.connection-row { +width:100%;min-width:0;min-height:32px;display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;align-items:center;gap:9px;padding:3px 12px;border:0;border-radius:0;background:transparent;text-align:left;white-space:normal} +.connection-row:hover:not(:disabled) { +background:var(--surface-2)} +.connection-row.selected { +position:relative;z-index:1;outline:1px solid var(--ember);outline-offset:-1px;background:var(--surface-2)} +.connection-target { +min-width:0;color:var(--text);font:12px var(--font-mono);overflow-wrap:anywhere} +.connection-evidence { +margin:0;padding:0;list-style:none;border-top:1px solid var(--hairline);background:var(--surface)} +.connection-evidence li { +min-width:0;display:flex;align-items:center;gap:12px;padding:7px 12px 7px 22px;border-left:2px solid var(--hairline-strong)} +.connection-evidence li + li { +border-top:1px solid var(--hairline)} +.evidence-code { +flex:1;min-width:0;color:var(--subtle);font:12px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} +.open-in-diff { +flex:none} .log-section { contain:layout;padding:20px 14px 100px;border-top:1px solid var(--hairline);scroll-margin-top:8px} .log-head { @@ -377,9 +437,29 @@ column-gap:2px} flex:1 1 100%} .toolbar .totals { margin-left:0} +.structure-file-path { +overflow:visible;text-overflow:clip;white-space:normal;overflow-wrap:anywhere} +.connection-row { +grid-template-columns:auto auto auto minmax(0,1fr);grid-template-areas:"kind type status ." "target target target target";gap:2px 8px;padding-top:5px;padding-bottom:5px} +.connection-kind { +grid-area:kind} +.connection-target { +grid-area:target} +.connection-type { +grid-area:type} +.connection-status { +grid-area:status} +.connection-evidence li { +align-items:stretch;flex-direction:column;padding-left:12px} +.open-in-diff { +width:100%;min-height:var(--control-h-touch)} } .diff-scroll { overflow:visible} +.structure-section { +padding-left:8px;padding-right:8px} +.connection-row { +min-height:var(--control-h-touch)} .file-head { top:var(--filehead-top);min-height:var(--control-h-touch);height:var(--control-h-touch)} .diff-row { @@ -412,6 +492,10 @@ top:6px} display:block;position:fixed;z-index:30;left:12px;right:12px;bottom:12px;min-height:48px;box-shadow:0 4px 18px #000;background:var(--ember);color:#160a02;border-color:var(--ember);font-weight:700} .mobile-ask:disabled { opacity:1} +.structure-error-card button,.structure-partial summary,.open-in-diff { +min-height:var(--control-h-touch)} +.structure-partial summary { +padding:8px 4px} .source-actions .primary { min-height:var(--control-h-touch)} .field select,.field input,.field textarea { diff --git a/packages/review-tutor/src/page.ts b/packages/review-tutor/src/page.ts index 393fc23..0af82c4 100644 --- a/packages/review-tutor/src/page.ts +++ b/packages/review-tutor/src/page.ts @@ -36,12 +36,12 @@ export const pageHtml = `
- +Select code. Ask anything.
Shift-click line numbers to extend the selection. Select + to ask.
Load a source and enter a question.
Load a source to begin reviewing.
Load a source to begin reviewing.