| |
| 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; |
| } |
|
|