repo-vis/web/src/app.js
Fam Zheng 398ae64ed9 perf: lazy-load file content and fix oversized tile labels
- Server now returns metadata-only tree on initial load (no file content
  in the JSON payload); content is served on-demand via the new
  GET /api/repos/{key}/file?path=... endpoint
- Cache still stores full content; strip_content() runs in-memory before
  the response is sent
- Frontend fetches file content lazily in _fetchContent() when a tile
  enters the LOD view, preventing a massive upfront JSON download for
  large repos (e.g. claude code)
- computeColorRanges() is now deferred to first _showCode() call instead
  of running synchronously for every file during load()
- Cap label fontSize at 5 world units to prevent giant text on large tiles
2026-04-07 10:37:31 +01:00

183 lines
4.8 KiB
JavaScript

import { computeLayout } from "./layout.js";
import { RepoRenderer } from "./renderer.js";
const landing = document.getElementById("landing");
const loading = document.getElementById("loading");
const loadingText = document.getElementById("loading-text");
const viewport = document.getElementById("viewport");
const controlsHint = document.getElementById("controls-hint");
const gitUrlInput = document.getElementById("git-url");
const btnClone = document.getElementById("btn-clone");
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const historyEl = document.getElementById("history");
const historyList = document.getElementById("history-list");
function showLoading(msg) {
landing.style.display = "none";
loading.classList.add("active");
loadingText.textContent = msg;
}
function showVisualization() {
loading.classList.remove("active");
viewport.classList.add("active");
controlsHint.classList.add("active");
}
function showError(msg) {
loading.classList.remove("active");
landing.style.display = "";
alert(msg);
}
async function visualize(tree, repoName, cacheKey) {
showLoading("Building layout...");
// Wait for fonts to load so canvas renders them correctly
await document.fonts.ready;
await new Promise((r) => setTimeout(r, 50));
const { leaves, totalWidth, totalHeight } = computeLayout(tree);
if (leaves.length === 0) {
showError("No source files found in repository.");
return;
}
showLoading(`Rendering ${leaves.length} files...`);
await new Promise((r) => setTimeout(r, 50));
showVisualization();
document.getElementById("osd-info").classList.add("active");
const renderer = new RepoRenderer(viewport, repoName || tree.name, cacheKey);
await renderer.load(leaves, totalWidth, totalHeight);
}
// --- History ---
async function loadHistory() {
try {
const res = await fetch("/api/repos");
if (!res.ok) return;
const repos = await res.json();
if (repos.length === 0) return;
historyEl.classList.add("has-items");
historyList.innerHTML = "";
for (const repo of repos) {
const item = document.createElement("div");
item.className = "history-item";
item.innerHTML = `
<span class="name">${escapeHtml(repo.name)}</span>
<span class="meta">${repo.file_count} files</span>
`;
item.addEventListener("click", () => loadCachedRepo(repo.cache_key, repo.name));
historyList.appendChild(item);
}
} catch {
// ignore
}
}
async function loadCachedRepo(key, name) {
showLoading(`Loading ${name}...`);
try {
const res = await fetch(`/api/repos/${key}`);
if (!res.ok) throw new Error("Cache expired");
const { cache_key, tree } = await res.json();
await visualize(tree, name, cache_key);
} catch (err) {
showError(err.message);
}
}
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Load history on page load
loadHistory();
// --- Git clone ---
btnClone.addEventListener("click", async () => {
const url = gitUrlInput.value.trim();
if (!url) return;
btnClone.disabled = true;
showLoading("Cloning repository...");
try {
const res = await fetch("/api/scan-git", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Clone failed");
}
const { cache_key, tree } = await res.json();
await visualize(tree, undefined, cache_key);
} catch (err) {
showError(err.message);
} finally {
btnClone.disabled = false;
}
});
gitUrlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") btnClone.click();
});
// --- Zip upload ---
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file) uploadZip(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) uploadZip(fileInput.files[0]);
});
async function uploadZip(file) {
showLoading("Uploading and scanning zip...");
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/scan-zip", {
method: "POST",
body: form,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Upload failed");
}
const { cache_key, tree } = await res.json();
await visualize(tree, undefined, cache_key);
} catch (err) {
showError(err.message);
}
}