// ───────────────────────────────────────────── // DOM REFS // ───────────────────────────────────────────── const $ = (id) => document.getElementById(id); const els = { repoInput: $('repoInput'), analyseBtn: $('analyseBtn'), landingPage: $('landingPage'), processingPage: $('processingPage'), chatPage: $('chatPage'), stepsContainer: $('stepsContainer'), procSubtitle: $('procSubtitle'), footer: $('footer'), chatInput: $('chatInput'), sendBtn: $('sendBtn'), chatMessages: $('chatMessages'), warningBanner: $('warningBanner'), dismissWarning: $('dismissWarning'), statusDot: $('statusDot'), statusLabel: $('statusLabel'), settingsBtn: $('settingsBtn'), settingsLabel: $('settingsLabel'), settingsModal: $('settingsModal'), settingsForm: $('settingsForm'), settingsClose: $('settingsClose'), settingsError: $('settingsError'), providerSelect: $('providerSelect'), modelInput: $('modelInput'), apiKeyInput: $('apiKeyInput'), rememberKey: $('rememberKey'), clearKeyBtn: $('clearKeyBtn'), keyHelpLink: $('keyHelpLink'), }; let IS_BIG_REPO = false; let activeStepBox = null; let hadError = false; let hadWarning = false; // ───────────────────────────────────────────── // MARKED.JS SETUP // ───────────────────────────────────────────── if (typeof marked !== 'undefined') { marked.setOptions({ breaks: true, gfm: true }); } // LLM output can be steered by whatever is inside the analysed repo (prompt // injection), so rendered markdown is ALWAYS sanitized before hitting the DOM. if (typeof DOMPurify !== 'undefined') { DOMPurify.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'A') { node.setAttribute('target', '_blank'); node.setAttribute('rel', 'noopener noreferrer nofollow'); } }); } function safeMarkdown(mdText) { if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') return null; return DOMPurify.sanitize(marked.parse(mdText), { USE_PROFILES: { html: true }, FORBID_TAGS: ['style', 'form', 'input', 'button', 'textarea', 'select', 'iframe', 'object', 'embed', 'img', 'svg', 'math'], FORBID_ATTR: ['style'], }); } // ───────────────────────────────────────────── // SAFE STORAGE // Browser storage can throw or be blocked (private mode, or third-party // iframes — Hugging Face shows the app inside one), so everything is mirrored // in memory and the app keeps working for the current page view. // ───────────────────────────────────────────── const memoryStore = {}; const store = { get(area, key) { try { const v = window[area].getItem(key); if (v !== null) return v; } catch { /* fall through */ } return memoryStore[area + ':' + key] ?? null; }, set(area, key, val) { memoryStore[area + ':' + key] = val; try { window[area].setItem(key, val); } catch { /* memory only */ } }, del(area, key) { delete memoryStore[area + ':' + key]; try { window[area].removeItem(key); } catch { /* ignore */ } }, }; // ───────────────────────────────────────────── // LLM SETTINGS (bring your own key) // ───────────────────────────────────────────── const KEY_HELP = { google: 'https://aistudio.google.com/app/apikey', openai: 'https://platform.openai.com/api-keys', anthropic: 'https://console.anthropic.com/settings/keys', }; const KEY_PATTERN = /^[A-Za-z0-9_\-.]{20,300}$/; const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_\-.:/]{0,99}$/; let PROVIDER_INFO = {}; let SERVER_KEY_AVAILABLE = false; function loadLlmSettings() { const fromLocal = store.get('localStorage', 'gitread.llm'); const raw = fromLocal || store.get('sessionStorage', 'gitread.llm'); let cfg = {}; try { cfg = raw ? JSON.parse(raw) : {}; } catch { cfg = {}; } return { provider: cfg.provider || 'google', model: cfg.model || '', apiKey: cfg.apiKey || '', remember: Boolean(fromLocal), }; } function saveLlmSettings(cfg) { const data = JSON.stringify({ provider: cfg.provider, model: cfg.model, apiKey: cfg.apiKey }); store.del('localStorage', 'gitread.llm'); store.del('sessionStorage', 'gitread.llm'); store.set(cfg.remember ? 'localStorage' : 'sessionStorage', 'gitread.llm', data); refreshSettingsButton(); } function refreshSettingsButton() { const cfg = loadLlmSettings(); const hasKey = Boolean(cfg.apiKey); els.settingsBtn.classList.toggle('needs-key', !hasKey && !SERVER_KEY_AVAILABLE); const label = (PROVIDER_INFO[cfg.provider] && PROVIDER_INFO[cfg.provider].label) || cfg.provider; els.settingsLabel.textContent = hasKey ? label : 'Add API key'; } function updateProviderHints() { const p = els.providerSelect.value; els.keyHelpLink.href = KEY_HELP[p] || '#'; const def = PROVIDER_INFO[p] && PROVIDER_INFO[p].default_model; els.modelInput.placeholder = def ? `Default: ${def}` : ''; } function showSettingsError(message) { els.settingsError.textContent = message || ''; els.settingsError.classList.toggle('hidden', !message); } function openSettings(message) { const cfg = loadLlmSettings(); els.providerSelect.value = cfg.provider; els.modelInput.value = cfg.model; els.apiKeyInput.value = cfg.apiKey; els.rememberKey.checked = cfg.remember; updateProviderHints(); showSettingsError(message); els.settingsModal.classList.remove('hidden'); (cfg.apiKey ? els.providerSelect : els.apiKeyInput).focus(); } function closeSettings() { els.settingsModal.classList.add('hidden'); els.apiKeyInput.value = ''; } els.settingsBtn.addEventListener('click', () => openSettings()); els.settingsClose.addEventListener('click', closeSettings); els.settingsModal.addEventListener('click', (e) => { if (e.target === els.settingsModal) closeSettings(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !els.settingsModal.classList.contains('hidden')) closeSettings(); }); els.providerSelect.addEventListener('change', updateProviderHints); els.settingsForm.addEventListener('submit', (e) => { e.preventDefault(); const apiKey = els.apiKeyInput.value.trim(); const model = els.modelInput.value.trim(); if (apiKey && !KEY_PATTERN.test(apiKey)) return showSettingsError("That API key doesn't look valid."); if (model && !MODEL_PATTERN.test(model)) return showSettingsError('Invalid model name.'); saveLlmSettings({ provider: els.providerSelect.value, model, apiKey, remember: els.rememberKey.checked }); closeSettings(); }); els.clearKeyBtn.addEventListener('click', () => { store.del('localStorage', 'gitread.llm'); store.del('sessionStorage', 'gitread.llm'); els.apiKeyInput.value = ''; els.modelInput.value = ''; els.rememberKey.checked = false; refreshSettingsButton(); }); async function loadProviders() { try { const res = await fetch('/api/providers'); if (res.ok) { const data = await res.json(); PROVIDER_INFO = data.providers || {}; SERVER_KEY_AVAILABLE = Boolean(data.server_key_available); } } catch { /* offline — keep defaults */ } refreshSettingsButton(); } loadProviders(); // ───────────────────────────────────────────── // SESSION + API HELPERS // Each visitor gets their own server-side session (own repo, own chat). // ───────────────────────────────────────────── async function errorDetail(res) { try { const data = await res.json(); if (data && typeof data.detail === 'string') return data.detail; } catch { /* not JSON */ } return `Request failed (HTTP ${res.status})`; } async function newSession() { const res = await fetch('/api/session', { method: 'POST' }); if (!res.ok) throw new Error(await errorDetail(res)); const { token } = await res.json(); store.set('sessionStorage', 'gitread.session', token); return token; } async function getSessionToken() { return store.get('sessionStorage', 'gitread.session') || newSession(); } async function apiPost(path, body, { retryOnExpired = true } = {}) { const token = await getSessionToken(); const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body), }); if (res.status === 401) { store.del('sessionStorage', 'gitread.session'); if (retryOnExpired) { await newSession(); return apiPost(path, body, { retryOnExpired: false }); } } return res; } // ───────────────────────────────────────────── // UTILITIES // ───────────────────────────────────────────── const delay = (ms) => new Promise((r) => setTimeout(r, ms)); function setStatus(mode, label) { els.statusDot.className = 'status-dot ' + mode; els.statusLabel.textContent = label; } function showPage(pageEl) { ['landingPage', 'processingPage', 'chatPage'].forEach((id) => { $(id).classList.remove('active'); }); pageEl.classList.add('active'); } function formatTime(d) { return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } // ───────────────────────────────────────────── // AUTO-GROW TEXTAREA // ───────────────────────────────────────────── els.chatInput.addEventListener('input', () => { els.chatInput.style.height = 'auto'; els.chatInput.style.height = Math.min(els.chatInput.scrollHeight, 160) + 'px'; }); // ───────────────────────────────────────────── // COLLAPSE A COMPLETED STEP BOX // ───────────────────────────────────────────── function collapseBox(box, status) { return new Promise((resolve) => { // Lock height at current value so we can animate down const currentH = box.offsetHeight; box.style.height = currentH + 'px'; box.style.overflow = 'hidden'; // Badge text const badge = document.createElement('span'); badge.className = 'step-collapsed-badge'; badge.textContent = status === 'ERROR' ? '· Error' : status === 'WARNING' ? '· Warning' : '· Done'; box.querySelector('.step-title').appendChild(badge); // Hide sub-items right away so they don't flash during resize const subItems = box.querySelector('.sub-items'); if (subItems) subItems.style.opacity = '0'; // Animate to collapsed height next frame requestAnimationFrame(() => { requestAnimationFrame(() => { box.style.height = '46px'; box.style.paddingTop = '0'; box.style.paddingBottom = '0'; }); }); let settled = false; const finish = () => { if (settled) return; settled = true; box.removeEventListener('transitionend', handler); // Reveal the badge now that height is settled const b = box.querySelector('.step-collapsed-badge'); if (b) b.classList.add('visible'); resolve(); }; function handler(e) { if (e.propertyName === 'height') finish(); } box.addEventListener('transitionend', handler); // Background tabs don't run transitions, so never wait on transitionend alone // (otherwise the progress UI freezes while the user is in another tab) setTimeout(finish, 800); }); } // ───────────────────────────────────────────── // LANDING → PROCESSING // ───────────────────────────────────────────── els.analyseBtn.addEventListener('click', startAnalysis); els.repoInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') startAnalysis(); }); // Welcome chip clicks document.querySelectorAll('.welcome-chip').forEach((chip) => { chip.addEventListener('click', () => { els.chatInput.value = chip.textContent; els.chatInput.focus(); }); }); function startAnalysis() { const repoUrl = els.repoInput.value.trim(); if (!repoUrl) return; setStatus('active', 'analysing'); showPage(els.processingPage); els.stepsContainer.innerHTML = ''; activeStepBox = null; IS_BIG_REPO = false; hadError = false; hadWarning = false; connectToBackend(repoUrl); } // ───────────────────────────────────────────── // BACKEND: INIT-REPO STREAM // ───────────────────────────────────────────── async function connectToBackend(repoUrl) { try { const response = await apiPost('/api/init-repo', { url: repoUrl }); if (!response.ok) throw new Error(await errorDetail(response)); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop(); for (const event of lines) { if (!event.trim()) continue; const dataLine = event.split('\n')[0]; if (!dataLine.startsWith('data: ')) continue; try { const data = JSON.parse(dataLine.slice(6).trim()); await processBackendEvent(data); } catch (e) { console.error('Parse error:', e); } } } } catch (error) { console.error('Fetch error:', error); if (activeStepBox) { await finishActiveBox(activeStepBox, 'ERROR'); } else { const errorBox = createNewStepBox(`Error: ${error.message}`); await finishActiveBox(errorBox, 'ERROR'); } showProcessingAction('error'); } } async function processBackendEvent(data) { const { status, task } = data; if (status === 'FINISHED') { if (activeStepBox) await finishActiveBox(activeStepBox, 'SUCCESS'); if (hadError) { showProcessingAction('error'); } else if (hadWarning) { showProcessingAction('warning'); } else { await delay(700); setStatus('chat', 'ready'); transitionToChat(); } return; } // Live progress for long steps (e.g. embedding): update the current box in place if (status === 'PROGRESS') { if (activeStepBox) { const titleText = activeStepBox.querySelector('.title-text'); if (titleText) titleText.textContent = task; } if (els.procSubtitle) els.procSubtitle.textContent = task; return; } if (status === 'START') { if (activeStepBox) await finishActiveBox(activeStepBox, 'SUCCESS'); activeStepBox = createNewStepBox(task); if (els.procSubtitle) els.procSubtitle.textContent = task; } if (status === 'WARNING' && task && task.includes('Repo is large')) { IS_BIG_REPO = true; } if (status === 'WARNING') hadWarning = true; if (status === 'ERROR') hadError = true; if (els.procSubtitle && task && status !== 'START') els.procSubtitle.textContent = task; if (status === 'SUCCESS' || status === 'WARNING' || status === 'ERROR') { if (activeStepBox) { const titleText = activeStepBox.querySelector('.title-text'); if (titleText) titleText.textContent = task; await finishActiveBox(activeStepBox, status); activeStepBox = null; } else { const box = createNewStepBox(task); await finishActiveBox(box, status); } } } // ───────────────────────────────────────────── // PROCESSING ACTION BUTTON (error / warning gate) // ───────────────────────────────────────────── function showProcessingAction(type) { // Remove any existing action card const existing = els.stepsContainer.querySelector('.proc-action-card'); if (existing) existing.remove(); const card = document.createElement('div'); card.className = `proc-action-card ${type === 'error' ? 'proc-action-error' : 'proc-action-warning'}`; if (type === 'error') { card.innerHTML = `
Analysis failed
An error occurred while analysing this repository. Please check the URL and try again.
Analysis complete with warnings
Some steps raised warnings. Results may be partial, but you can still explore the repository.
blocks with header + copy button
bubble.querySelectorAll('pre').forEach((pre) => {
if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrap')) return; // already wrapped
const codeEl = pre.querySelector('code');
const rawLang = codeEl ? codeEl.className.replace(/^language-/, '').trim() : '';
const lang = rawLang || 'code';
const wrap = document.createElement('div');
wrap.className = 'code-block-wrap';
const header = document.createElement('div');
header.className = 'code-block-header';
const langLabel = document.createElement('span');
langLabel.className = 'code-lang-label';
langLabel.textContent = lang;
const copyBtn = document.createElement('button');
copyBtn.className = 'code-copy-btn';
copyBtn.innerHTML = `Copy`;
copyBtn.addEventListener('click', () => {
const content = codeEl ? codeEl.textContent : pre.textContent;
navigator.clipboard.writeText(content).then(() => {
copyBtn.querySelector('span').textContent = 'Copied!';
setTimeout(() => { copyBtn.querySelector('span').textContent = 'Copy'; }, 1800);
}).catch(() => {});
});
header.appendChild(langLabel);
header.appendChild(copyBtn);
pre.parentNode.insertBefore(wrap, pre);
wrap.appendChild(header);
wrap.appendChild(pre);
});
}
function buildCopyResponseBtn(markdownText, bubble) {
const btn = document.createElement('button');
btn.className = 'msg-copy-btn';
btn.innerHTML = ``;
btn.addEventListener('click', () => {
// Copy plain text from the rendered bubble
navigator.clipboard.writeText(bubble.innerText).then(() => {
btn.querySelector('span').textContent = 'Copied!';
setTimeout(() => { btn.querySelector('span').textContent = 'Copy response'; }, 1800);
}).catch(() => {});
});
return btn;
}
// ─────────────────────────────────────────────
// MESSAGE UI HELPERS
// ─────────────────────────────────────────────
function appendUserMessage(text) {
const group = document.createElement('div');
group.className = 'msg-group user';
group.innerHTML = `
You
${escapeHtml(text)}
`;
els.chatMessages.appendChild(group);
group.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
function createBotBubble(container) {
const bubble = document.createElement('div');
bubble.className = 'msg-bubble bot';
container.appendChild(bubble);
return bubble;
}
// ─────────────────────────────────────────────
// THINKING UI — Collapsible (Gemini-style)
// ─────────────────────────────────────────────
function createThinkingUI(container) {
const wrap = document.createElement('div');
wrap.className = 'thinking-wrap';
wrap.innerHTML = `
`;
container.appendChild(wrap);
// Toggle click
const toggle = wrap.querySelector('.thinking-toggle');
const panel = wrap.querySelector('.thinking-steps-panel');
toggle.addEventListener('click', () => {
const isOpen = panel.classList.contains('open');
panel.classList.toggle('open', !isOpen);
toggle.classList.toggle('open', !isOpen);
toggle.setAttribute('aria-expanded', String(!isOpen));
});
return {
wrap,
toggle,
panel,
inner: wrap.querySelector('.thinking-steps-inner'),
};
}
function addThinkingStep(inner, text, previousStepEl) {
// Mark previous as done
if (previousStepEl) {
previousStepEl.classList.remove('current');
previousStepEl.classList.add('done-step');
}
const step = document.createElement('div');
step.className = 'thinking-step current';
// Preserve newlines from backend
const formatted = escapeHtml(text).replace(/\n/g, '
');
step.innerHTML = `${formatted}`;
inner.appendChild(step);
// Scroll the panel
inner.scrollTop = inner.scrollHeight;
return step;
}
function updateThinkingToggleLabel(wrap, count, isDone) {
const label = wrap.querySelector('.thinking-label');
const spinner = wrap.querySelector('.thinking-spinner');
const toggle = wrap.querySelector('.thinking-toggle');
if (isDone) {
label.textContent = `Thought for ${count} step${count !== 1 ? 's' : ''}`;
spinner.classList.add('done');
spinner.innerHTML = ``;
toggle.classList.remove('active');
} else {
label.textContent = `Thinking… (${count} step${count !== 1 ? 's' : ''})`;
}
}
function closeThinkingPanel(wrap) {
const panel = wrap.querySelector('.thinking-steps-panel');
const toggle = wrap.querySelector('.thinking-toggle');
// Auto-collapse once done so the focus is on the answer
panel.classList.remove('open');
toggle.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
// ─────────────────────────────────────────────
// ESCAPE
// ─────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}