Julien Simon
Replace copy button with a save button
dbc2d72
/* --- DOM refs --- */
const form = document.getElementById("review-form");
const urlInput = document.getElementById("url-input");
const submitBtn = document.getElementById("submit-btn");
const inputError = document.getElementById("input-error");
const metaEl = document.getElementById("meta");
const metaRepo = document.getElementById("meta-repo");
const metaPath = document.getElementById("meta-path");
const metaBranch = document.getElementById("meta-branch");
const tabsEl = document.getElementById("tabs");
const tabContent = document.getElementById("tab-content");
const streamError = document.getElementById("stream-error");
const tabButtons = document.querySelectorAll(".tab");
const brutalityBtns = document.querySelectorAll(".brutality-btn");
const issueBadge = document.getElementById("issue-badge");
const reviewMetrics = document.getElementById("review-metrics");
const saveBtn = document.getElementById("save-btn");
const toast = document.getElementById("toast");
const GITHUB_BLOB_RE = /^https?:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[^/]+\/.+$/;
/* --- Section parsing --- */
const SECTION_KEYS = ["summary", "quality", "performance", "security", "suggestions", "verdicts"];
// Maps heading text (lowercased) to our section key
const HEADING_MAP = {
summary: "summary",
"code quality": "quality",
performance: "performance",
security: "security",
suggestions: "suggestions",
verdicts: "verdicts",
};
function parseSections(markdown) {
const sections = {};
let currentKey = null;
for (const line of markdown.split("\n")) {
const m = line.match(/^## (.+)$/);
if (m) {
const title = m[1].trim().toLowerCase();
const matched = Object.entries(HEADING_MAP).find(([kw]) => title.includes(kw));
if (matched) {
currentKey = matched[1];
sections[currentKey] = "";
continue;
}
}
if (currentKey) {
sections[currentKey] = (sections[currentKey] || "") + line + "\n";
}
}
return sections;
}
// Detect which section the model is currently writing to (last matched heading)
function detectCurrentSection(markdown) {
let last = null;
for (const line of markdown.split("\n")) {
const m = line.match(/^## (.+)$/);
if (m) {
const title = m[1].trim().toLowerCase();
const matched = Object.entries(HEADING_MAP).find(([kw]) => title.includes(kw));
if (matched) last = matched[1];
}
}
return last;
}
/* --- Diff-aware marked renderer --- */
function escapeHtml(str) {
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function isDiffContent(text) {
const lines = text.split("\n");
let diffLines = 0;
for (const l of lines) {
if (l.startsWith("+") || l.startsWith("-") || l.startsWith("@@")) diffLines++;
}
return diffLines >= 2;
}
function renderDiffBlock(text) {
const lines = text.split("\n").map((line) => {
const escaped = escapeHtml(line);
if (line.startsWith("+++") || line.startsWith("---")) {
return `<span class="diff-line diff-info">${escaped}</span>`;
}
if (line.startsWith("+")) {
return `<span class="diff-line diff-add">${escaped}</span>`;
}
if (line.startsWith("-")) {
return `<span class="diff-line diff-del">${escaped}</span>`;
}
if (line.startsWith("@@")) {
return `<span class="diff-line diff-info">${escaped}</span>`;
}
return `<span class="diff-line diff-neutral">${escaped}</span>`;
});
return `<pre class="diff-block"><code>${lines.join("")}</code></pre>`;
}
const renderer = {
code({ text, lang, language }) {
const codeLang = (lang || language || "").toLowerCase().trim();
// Render as diff if tagged as diff OR if content looks like a diff
if (codeLang === "diff" || (!codeLang && isDiffContent(text))) {
return renderDiffBlock(text);
}
const langClass = codeLang ? ` class="language-${escapeHtml(codeLang)}"` : "";
return `<pre><code${langClass}>${escapeHtml(text)}</code></pre>`;
},
};
marked.use({ renderer });
function renderMarkdown(md) {
return DOMPurify.sanitize(marked.parse(md));
}
/* --- Tab management --- */
let activeTab = "summary";
let manualSwitch = false;
let currentSections = {};
let brutalityLevel = "standard";
let fullMarkdown = "";
tabButtons.forEach((btn) => {
btn.addEventListener("click", () => {
manualSwitch = true;
setActiveTab(btn.dataset.section);
renderActiveTab();
});
});
function setActiveTab(section) {
activeTab = section;
tabButtons.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.section === section);
});
}
function renderActiveTab() {
const md = currentSections[activeTab] || "";
if (md.trim()) {
tabContent.innerHTML = renderMarkdown(md);
tabContent.classList.remove("tab-content-empty");
} else {
tabContent.textContent = "Waiting for content\u2026";
tabContent.classList.add("tab-content-empty");
}
}
function updateTabs(sections, currentStreamSection) {
currentSections = sections;
// Mark tabs that have content + which one is streaming
tabButtons.forEach((btn) => {
const key = btn.dataset.section;
btn.classList.toggle("has-content", !!sections[key]?.trim());
btn.classList.toggle("streaming", key === currentStreamSection);
});
// Auto-switch to the section currently being written (unless user clicked a tab)
if (!manualSwitch && currentStreamSection) {
setActiveTab(currentStreamSection);
}
renderActiveTab();
}
/* --- Brutality selector --- */
brutalityBtns.forEach((btn) => {
btn.addEventListener("click", () => {
brutalityBtns.forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
brutalityLevel = btn.dataset.level;
});
});
/* --- Issue badge --- */
function countIssuePriorities(markdown) {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
const pattern = /\[CRITICAL\]|\[HIGH\]|\[MEDIUM\]|\[LOW\]/gi;
const matches = markdown.match(pattern) || [];
for (const m of matches) {
const level = m.slice(1, -1).toLowerCase();
if (counts[level] !== undefined) counts[level]++;
}
return counts;
}
function renderIssueBadge(counts) {
const total = counts.critical + counts.high + counts.medium + counts.low;
if (total === 0) {
issueBadge.hidden = true;
return;
}
const items = [
{ level: "critical", label: "Critical", count: counts.critical },
{ level: "high", label: "High", count: counts.high },
{ level: "medium", label: "Medium", count: counts.medium },
{ level: "low", label: "Low", count: counts.low },
];
// Clear existing content
issueBadge.textContent = "";
for (const item of items) {
if (item.count === 0) continue;
const span = document.createElement("span");
span.className = "badge-item";
const dot = document.createElement("span");
dot.className = `badge-dot ${item.level}`;
const countEl = document.createElement("span");
countEl.className = "badge-count";
countEl.textContent = item.count;
const labelEl = document.createElement("span");
labelEl.className = "badge-label";
labelEl.textContent = item.label;
span.appendChild(dot);
span.appendChild(countEl);
span.appendChild(labelEl);
issueBadge.appendChild(span);
}
issueBadge.hidden = false;
}
/* --- Review metrics --- */
function computeMetrics(markdown, totalLines, elapsedMs) {
// Count ```diff blocks (concrete fixes)
const diffBlocks = (markdown.match(/```diff/g) || []).length;
// Count total issues by priority tag
const issueCount = (markdown.match(/\[CRITICAL\]|\[HIGH\]|\[MEDIUM\]|\[LOW\]/gi) || []).length;
return {
issues: issueCount,
fixes: diffBlocks,
totalLines,
elapsedSec: (elapsedMs / 1000).toFixed(1),
};
}
function renderMetrics(m) {
reviewMetrics.textContent = "";
const items = [
{ value: m.issues, label: "issues found" },
{ value: m.fixes, label: "concrete fixes" },
{ value: m.totalLines.toLocaleString(), label: "lines analyzed" },
{ value: `${m.elapsedSec}s`, label: "review time" },
];
for (const item of items) {
const span = document.createElement("span");
span.className = "metric-item";
const val = document.createElement("span");
val.className = "metric-value";
val.textContent = item.value;
const lbl = document.createElement("span");
lbl.className = "metric-label";
lbl.textContent = item.label;
span.appendChild(val);
span.appendChild(lbl);
reviewMetrics.appendChild(span);
}
reviewMetrics.hidden = false;
}
/* --- Copy to clipboard --- */
function buildMarkdownExport() {
const sectionTitles = {
summary: "## Summary",
quality: "## Code Quality",
performance: "## Performance",
security: "## Security",
suggestions: "## Suggestions",
verdicts: "## Verdicts",
};
let output = `# Code Review: ${metaRepo.textContent}\n`;
output += `**File:** ${metaPath.textContent} | **Branch:** ${metaBranch.textContent}\n\n`;
for (const key of SECTION_KEYS) {
const content = currentSections[key]?.trim();
if (content) {
output += `${sectionTitles[key]}\n${content}\n\n`;
}
}
return output.trim();
}
saveBtn.addEventListener("click", () => {
const markdown = buildMarkdownExport();
const filename = `${metaRepo.textContent.replace(/\//g, '-')}-${metaPath.textContent.split('/').pop()}-${brutalityLevel}.md`;
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast();
});
function showToast() {
toast.hidden = false;
// Force reflow before adding class for animation
toast.offsetHeight;
toast.classList.add("show");
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => { toast.hidden = true; }, 300);
}, 2000);
}
/* --- Review flow --- */
let currentSource = null;
form.addEventListener("submit", (e) => {
e.preventDefault();
startReview();
});
/* --- Sample buttons --- */
document.querySelectorAll(".sample-btn").forEach((btn) => {
btn.addEventListener("click", () => {
urlInput.value = btn.dataset.url;
startReview();
});
});
function startReview() {
const url = urlInput.value.trim();
// Reset
inputError.hidden = true;
streamError.hidden = true;
metaEl.hidden = true;
tabsEl.hidden = true;
tabContent.textContent = "";
manualSwitch = false;
setActiveTab("summary");
tabButtons.forEach((btn) => {
btn.classList.remove("has-content", "streaming");
});
issueBadge.hidden = true;
reviewMetrics.hidden = true;
saveBtn.hidden = true;
fullMarkdown = "";
if (!url) { showInputError("Please enter a GitHub file URL."); return; }
if (!GITHUB_BLOB_RE.test(url)) {
showInputError("URL must be a GitHub blob URL (e.g. https://github.com/owner/repo/blob/main/file.js).");
return;
}
if (currentSource) { currentSource.close(); currentSource = null; }
setLoading(true);
let markdown = "";
let reviewStartTime = performance.now();
let fileTotalLines = 0;
const apiUrl = `/api/review?url=${encodeURIComponent(url)}&brutality=${encodeURIComponent(brutalityLevel)}`;
const source = new EventSource(apiUrl);
currentSource = source;
source.addEventListener("meta", (e) => {
const data = JSON.parse(e.data);
metaRepo.textContent = `${data.owner}/${data.repo}`;
metaPath.textContent = data.path;
metaBranch.textContent = data.branch;
fileTotalLines = data.lines || 0;
metaEl.hidden = false;
tabsEl.hidden = false;
tabContent.classList.add("is-streaming");
});
source.addEventListener("content", (e) => {
const data = JSON.parse(e.data);
markdown += data.text;
const sections = parseSections(markdown);
const current = detectCurrentSection(markdown);
updateTabs(sections, current);
});
source.addEventListener("done", () => {
cleanup();
fullMarkdown = markdown;
// Final render with no streaming section
const sections = parseSections(markdown);
updateTabs(sections, null);
// Show badge, metrics, and copy button
const counts = countIssuePriorities(markdown);
renderIssueBadge(counts);
const elapsed = performance.now() - reviewStartTime;
const metrics = computeMetrics(markdown, fileTotalLines, elapsed);
renderMetrics(metrics);
saveBtn.hidden = false;
});
source.addEventListener("error", (e) => {
if (e.data) {
const data = JSON.parse(e.data);
showStreamError(data.message);
} else {
showStreamError("Connection lost. Please try again.");
}
cleanup();
});
function cleanup() {
source.close();
currentSource = null;
setLoading(false);
tabContent.classList.remove("is-streaming");
tabButtons.forEach((btn) => btn.classList.remove("streaming"));
}
}
/* --- Helpers --- */
function setLoading(on) {
submitBtn.disabled = on;
submitBtn.textContent = on ? "Reviewing\u2026" : "Review Code";
document.body.classList.toggle("loading", on);
}
function showInputError(msg) {
inputError.textContent = msg;
inputError.hidden = false;
}
function showStreamError(msg) {
streamError.textContent = msg;
streamError.hidden = false;
}