lupa 0.1.1

Interactive object inspector for Rust — web UI + TUI + snapshot diffing
Documentation
<!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;
  }

  /* search highlight */
  .tree .hl { background: rgba(249,226,175,0.35); border-radius: 3px; padding: 0 2px; }

  /* Rust Debug tokens — rich, distinct */
  .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 view */
  .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 = () => {
    // connection established silently
  };
  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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// ---------- Robust Rust syntax highlighter ----------
function rustHighlight(raw) {
  let s = escHtml(raw);
  s = s.replace(/&quot;(.*?)&quot;/g, (_, content) => `<span class="t-str-q">&quot;</span><span class="t-str">${content}</span><span class="t-str-q">&quot;</span>`);
  s = s.replace(/&amp;(?:\'[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">-&gt;</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>