<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>lupa inspector — perfected</title>
<style>
:root {
--bg: #1e1e2e;
--surface: #181825;
--surface2: #313244;
--accent: #cba6f7;
--green: #a6e3a1;
--red: #f38ba8;
--yellow: #f9e2af;
--text: #cdd6f4;
--subtext: #a6adc8;
--border: #45475a;
--font: 'JetBrains Mono', 'Fira Code', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 13px; }
header {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
header h1 { font-size: 16px; color: var(--accent); letter-spacing: 0.5px; }
.pill { background: var(--surface2); border-radius: 20px; padding: 3px 10px; font-size: 11px; color: var(--subtext); }
.spacer { flex: 1; }
#search {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 6px; padding: 5px 10px;
font-family: var(--font); font-size: 12px; width: 240px;
outline: none;
}
#search:focus { border-color: var(--accent); }
.tabs { display: flex; gap: 2px; padding: 8px 16px; background: var(--surface); border-bottom: 1px solid var(--border); }
.tab { padding: 5px 14px; border-radius: 6px; cursor: pointer; color: var(--subtext); font-size: 12px; }
.tab.active { background: var(--accent); color: var(--bg); font-weight: bold; }
#main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.snapshot-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; overflow: hidden;
}
.snapshot-header {
display: flex; align-items: center; gap: 8px; padding: 8px 12px;
background: var(--surface2); cursor: pointer; user-select: none;
}
.snapshot-header:hover { background: var(--border); }
.snap-label { color: var(--accent); font-weight: bold; }
.snap-meta { color: var(--subtext); font-size: 11px; }
.snap-toggle { margin-left: auto; color: var(--subtext); }
.snapshot-body { padding: 12px; display: none; }
.snapshot-body.open { display: block; }
.tree {
white-space: pre-wrap; line-height: 1.85; font-size: 12px;
tab-size: 2;
}
.tree .hl { background: rgba(249,226,175,0.35); border-radius: 3px; padding: 0 2px; }
.t-type { color: #c9a0ff; font-weight: 600; }
.t-key { color: #89b4fa; }
.t-str { color: #a6e3a1; }
.t-str-q { color: #6c7086; }
.t-num { color: #fab387; }
.t-true { color: #a6e3a1; font-style: italic; }
.t-false { color: #f38ba8; font-style: italic; }
.t-kw-none { color: #6c7086; font-style: italic; }
.t-kw-some { color: #89dceb; }
.t-kw-ok { color: #a6e3a1; font-weight: 600; }
.t-kw-err { color: #f38ba8; font-weight: 600; }
.t-range { color: #89dceb; }
.t-arrow { color: #585b70; }
.t-paren { color: #585b70; }
.t-brace { color: #6c7086; }
.t-bracket { color: #7f849c; }
.t-comma { color: #585b70; }
.t-colon { color: #585b70; }
.t-ref { color: #89dceb; opacity: 0.9; }
.diff-line { white-space: pre; font-size: 12px; line-height: 1.7; padding: 0 8px; display: block; }
.diff-line.ins { background: rgba(166,227,161,0.12); color: var(--green); }
.diff-line.del { background: rgba(243,139,168,0.12); color: var(--red); text-decoration: line-through; }
.diff-line.eq { color: var(--subtext); }
.diff-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.diff-header { display: flex; gap: 8px; align-items: center; padding: 8px 12px; background: var(--surface2); }
.diff-body { padding: 12px; overflow-x: auto; }
.empty { text-align: center; padding: 48px; color: var(--subtext); }
.badge-new { background: var(--green); color: var(--bg); border-radius: 4px; padding: 1px 6px; font-size: 10px; }
.badge-diff { background: var(--yellow); color: var(--bg); border-radius: 4px; padding: 1px 6px; font-size: 10px; }
#refresh-btn {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 6px; padding: 4px 10px;
cursor: pointer; font-family: var(--font); font-size: 12px;
}
#refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>lupa</h1>
<span class="pill" id="snap-count">0 snapshots</span>
<span class="pill" id="diff-count">0 diffs</span>
<span class="spacer"></span>
<input id="search" type="text" placeholder="filter fields / values…" oninput="applyFilter()">
<button id="refresh-btn" onclick="loadAll()">↻ refresh</button>
</header>
<div class="tabs">
<div class="tab active" onclick="switchTab('snapshots')">Snapshots</div>
<div class="tab" onclick="switchTab('diffs')">Diffs</div>
</div>
<div id="main"></div>
<script>
let allSnapshots = [];
let allDiffs = [];
let currentTab = 'snapshots';
const openState = {};
let ws = null;
let reconnectTimer = null;
function connectWS() {
if (reconnectTimer) clearTimeout(reconnectTimer);
const port = parseInt(location.port || '7777', 10) + 1;
ws = new WebSocket(`ws://${location.hostname}:${port}`);
ws.onopen = () => {
};
ws.onmessage = (e) => {
try {
const ev = JSON.parse(e.data);
if (ev.kind === 'snapshot') {
allSnapshots.push(ev.data);
document.getElementById('snap-count').textContent = `${allSnapshots.length} snapshot${allSnapshots.length !== 1 ? 's' : ''}`;
if (currentTab === 'snapshots') render();
} else if (ev.kind === 'diff') {
allDiffs.push(ev.data);
document.getElementById('diff-count').textContent = `${allDiffs.length} diff${allDiffs.length !== 1 ? 's' : ''}`;
if (currentTab === 'diffs') render();
}
} catch(err) {}
};
ws.onclose = () => { reconnectTimer = setTimeout(connectWS, 2000); };
ws.onerror = () => ws && ws.close();
}
async function loadAll() {
try {
const [snaps, diffs] = await Promise.all([
fetch('/api/snapshots').then(r => r.json()).catch(() => []),
fetch('/api/diffs').then(r => r.json()).catch(() => [])
]);
allSnapshots = snaps;
allDiffs = diffs;
document.getElementById('snap-count').textContent = `${snaps.length} snapshot${snaps.length !== 1 ? 's' : ''}`;
document.getElementById('diff-count').textContent = `${diffs.length} diff${diffs.length !== 1 ? 's' : ''}`;
render();
} catch(e) {
document.getElementById('main').innerHTML = `<div class="empty">⚠️ Cannot reach lupa server.<br><small>${e.message}</small></div>`;
}
}
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach((t, i) => {
t.classList.toggle('active', (i === 0 && tab === 'snapshots') || (i === 1 && tab === 'diffs'));
});
render();
}
function applyFilter() { render(); }
function escHtml(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function rustHighlight(raw) {
let s = escHtml(raw);
s = s.replace(/"(.*?)"/g, (_, content) => `<span class="t-str-q">"</span><span class="t-str">${content}</span><span class="t-str-q">"</span>`);
s = s.replace(/&(?:\'[a-zA-Z_][a-zA-Z0-9_]*)?(?=\s*(?:mut\s+)?[A-Za-z\[])/g, m => `<span class="t-ref">${m}</span>`);
s = s.replace(/\btrue\b/g, '<span class="t-true">true</span>');
s = s.replace(/\bfalse\b/g, '<span class="t-false">false</span>');
s = s.replace(/\bNone\b/g, '<span class="t-kw-none">None</span>');
s = s.replace(/\bSome\b/g, '<span class="t-kw-some">Some</span>');
s = s.replace(/\bOk\b/g, '<span class="t-kw-ok">Ok</span>');
s = s.replace(/\bErr\b/g, '<span class="t-kw-err">Err</span>');
s = s.replace(/\.\.=?/g, m => `<span class="t-range">${m}</span>`);
s = s.replace(/->/g, '<span class="t-arrow">-></span>');
s = s.replace(/(?<=[:\[\(,\s]|^)(-?\d+(?:\.\d+)?)(?=[,\)\]}\s\n]|$)/g, m => `<span class="t-num">${m}</span>`);
s = s.replace(/(?<=[:\[\(,\s])(-?0x[0-9a-fA-F]+)/g, m => `<span class="t-num">${m}</span>`);
s = s.replace(/\b([a-z_][a-zA-Z0-9_]*)(:)(?=\s)/g, (_, name, colon) => `<span class="t-key">${name}</span><span class="t-colon">${colon}</span>`);
s = s.replace(/\b([A-Z][a-zA-Z0-9_]+)\b/g, m => `<span class="t-type">${m}</span>`);
s = s.replace(/([{}])/g, m => `<span class="t-brace">${m}</span>`);
s = s.replace(/([\[\]])/g, m => `<span class="t-bracket">${m}</span>`);
s = s.replace(/([\(\)])/g, m => `<span class="t-paren">${m}</span>`);
s = s.replace(/,(?![^<]*>)/g, '<span class="t-comma">,</span>');
s = s.replace(/:(?![^<]*>)/g, '<span class="t-colon">:</span>');
return s;
}
function highlight(text, query) {
let highlighted = rustHighlight(text);
if (!query) return highlighted;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escapedQuery, 'gi');
return highlighted.replace(/(&[a-z]+;|<\/?[a-z][^>]*>|[^<&]+)/gi, part => {
if (part.startsWith('&') || part.startsWith('<')) return part;
return part.replace(re, '<span class="hl">$&</span>');
});
}
function filteredBody(repr, q) {
if (!q) return repr;
const query = q.toLowerCase();
const lines = repr.split('\n');
const keep = new Array(lines.length).fill(false);
const indent = l => (l.match(/^\s*/) || [''])[0].length;
const isCloser = l => /^[\]\}]/.test(l.trim());
for (let i = 0; i < lines.length; i++) {
if (!lines[i].toLowerCase().includes(query)) continue;
keep[i] = true;
const si = indent(lines[i]);
for (let j = i+1; j < lines.length; j++) {
const li = indent(lines[j]);
if (li > si) { keep[j] = true; continue; }
if (li === si && isCloser(lines[j])) keep[j] = true;
break;
}
for (let j = i-1, ci = si; j >= 0; j--) {
if (!lines[j].trim()) continue;
const li = indent(lines[j]);
if (li < ci) { keep[j] = true; ci = li; }
if (li === 0) break;
}
}
for (let i = 0; i < lines.length; i++) {
if (!keep[i]) continue;
const line = lines[i].trim();
if (!line.endsWith('{') && !line.endsWith('[')) continue;
const si = indent(lines[i]);
for (let j = i+1; j < lines.length; j++) {
const li = indent(lines[j]);
if (li > si) continue;
if (li === si && isCloser(lines[j])) keep[j] = true;
break;
}
}
return lines.filter((_, i) => keep[i]).join('\n');
}
function isOpen(i) {
if (i in openState) return openState[i];
return i === allSnapshots.length - 1;
}
function renderDemo() {
const demo = `User {
id: 42,
name: "Alice",
email: "alice@example.com",
age: 28,
role: Guest,
address: Address {
city: "Berlin",
country: "DE",
zip: "10115",
},
tags: [
"newcomer",
],
scores: {
"reputation": 10.0,
"activity": 0.0,
},
active: true,
}`;
return `<div class="snapshot-card" data-idx="-1">
<div class="snapshot-header" onclick="toggle(this)">
<span class="snap-label">✨ demo: User</span>
<span class="badge-new">PREVIEW</span>
<span class="snap-meta">example.rs:12</span>
<span class="snap-toggle">▼</span>
</div>
<div class="snapshot-body open"><div class="tree">${highlight(demo, '')}</div></div>
</div><div class="empty" style="margin-top:8px;">⚡ No real snapshots yet. Call <code>inspect!(value)</code> in Rust.</div>`;
}
function render() {
const query = document.getElementById('search').value.trim();
const main = document.getElementById('main');
if (currentTab === 'snapshots') {
if (!allSnapshots.length) {
main.innerHTML = renderDemo();
return;
}
main.innerHTML = allSnapshots.map((s, i) => {
const open = isOpen(i);
const body = filteredBody(s.debug_repr, query);
const isNew = i === allSnapshots.length - 1;
return `<div class="snapshot-card" data-idx="${i}">
<div class="snapshot-header" onclick="toggle(this)">
<span class="snap-label">${escHtml(s.label)}</span>
${isNew ? '<span class="badge-new">NEW</span>' : ''}
<span class="snap-meta">${escHtml(s.file)}:${s.line}</span>
<span class="snap-meta">${new Date(s.timestamp_ms).toLocaleTimeString()}</span>
<span class="snap-toggle">${open ? '▼' : '▶'}</span>
</div>
<div class="snapshot-body ${open ? 'open' : ''}">
<div class="tree">${highlight(body, query)}</div>
</div>
</div>`;
}).join('');
} else {
if (!allDiffs.length) {
main.innerHTML = '<div class="empty">No diffs yet.<br><small>Use <code>snapshot_diff!(old, new)</code></small></div>';
return;
}
main.innerHTML = allDiffs.map(d => {
const lines = d.chunks.map(c => {
const cls = c.tag === 'Insert' ? 'ins' : c.tag === 'Delete' ? 'del' : 'eq';
const pfx = c.tag === 'Insert' ? '+ ' : c.tag === 'Delete' ? '- ' : ' ';
return `<span class="diff-line ${cls}">${pfx}${escHtml(c.content)}</span>`;
}).join('');
return `<div class="diff-card">
<div class="diff-header">
<span class="badge-diff">DIFF</span>
<span class="snap-label">${escHtml(d.old.label)}</span>
<span class="snap-meta">→</span>
<span class="snap-label">${escHtml(d.new.label)}</span>
</div>
<div class="diff-body">${lines}</div>
</div>`;
}).join('');
}
}
function toggle(header) {
const card = header.closest('.snapshot-card');
if (!card) return;
const body = header.nextElementSibling;
const arrow = header.querySelector('.snap-toggle');
const idx = card.dataset.idx !== '-1' ? parseInt(card.dataset.idx, 10) : null;
body.classList.toggle('open');
const nowOpen = body.classList.contains('open');
arrow.innerHTML = nowOpen ? '▼' : '▶';
if (idx !== null && !isNaN(idx)) openState[idx] = nowOpen;
}
window.toggle = toggle;
window.switchTab = switchTab;
window.loadAll = loadAll;
window.applyFilter = applyFilter;
loadAll();
connectWS();
</script>
</body>
</html>