|
|
|
|
|
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 GITHUB_BLOB_RE = /^https?:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[^/]+\/.+$/; |
|
|
|
|
|
|
|
|
|
|
|
const SECTION_KEYS = ["summary", "quality", "performance", "security", "suggestions", "verdicts"]; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(str) { |
|
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
|
|
} |
|
|
|
|
|
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(); |
|
|
|
|
|
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)); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let activeTab = "summary"; |
|
|
let manualSwitch = false; |
|
|
let currentSections = {}; |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
tabButtons.forEach((btn) => { |
|
|
const key = btn.dataset.section; |
|
|
btn.classList.toggle("has-content", !!sections[key]?.trim()); |
|
|
btn.classList.toggle("streaming", key === currentStreamSection); |
|
|
}); |
|
|
|
|
|
|
|
|
if (!manualSwitch && currentStreamSection) { |
|
|
setActiveTab(currentStreamSection); |
|
|
} |
|
|
|
|
|
renderActiveTab(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
let currentSource = null; |
|
|
|
|
|
form.addEventListener("submit", (e) => { |
|
|
e.preventDefault(); |
|
|
startReview(); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
document.querySelectorAll(".sample-btn").forEach((btn) => { |
|
|
btn.addEventListener("click", () => { |
|
|
urlInput.value = btn.dataset.url; |
|
|
startReview(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
function startReview() { |
|
|
const url = urlInput.value.trim(); |
|
|
|
|
|
|
|
|
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"); |
|
|
}); |
|
|
|
|
|
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 = ""; |
|
|
const source = new EventSource(`/api/review?url=${encodeURIComponent(url)}`); |
|
|
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; |
|
|
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(); |
|
|
|
|
|
const sections = parseSections(markdown); |
|
|
updateTabs(sections, null); |
|
|
}); |
|
|
|
|
|
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")); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|