TimelineDebuggerTab.tsx18.1 KBView on GitHub
import { ChevronDown, ChevronRight, Copy, Check, Pause, Play, Trash2, X, Scissors } from 'lucide-react';
import React, { useSyncExternalStore, useState, useMemo, useEffect, useRef } from 'react';
import {
  subscribe,
  getVersion,
  readAll,
  clear,
  replaceAll,
  setMode,
  getMode,
  isMailTagged,
  isPaused,
  setPaused,
  isCaptureBodies,
  setCaptureBodies,
} from '@/modules/debugger/deepDebugger/buffer';
import {
  formatAsJson,
  formatAsMarkdown,
  entryHeadline,
  entryBody,
} from '@/modules/debugger/deepDebugger/format';
import type { TimelineEntry, TimelineSource } from '@/modules/debugger/deepDebugger/types';
import { cn } from '@/styles/stylingUtils';

const SOURCE_LABELS: Record<TimelineSource, string> = {
  'trpc.request': 'tRPC →',
  'trpc.response': 'tRPC ←',
  'trpc.error': 'tRPC ✕',
  zustand: 'zustand',
  error: 'error',
  perf: 'perf',
  sse: 'sse',
  freeze: 'freeze',
  note: 'note',
};

const SOURCE_COLORS: Record<TimelineSource, string> = {
  'trpc.request': 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800',
  'trpc.response': 'bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800',
  'trpc.error': 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800',
  zustand: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800',
  error: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800',
  perf: 'bg-purple-50 dark:bg-purple-950 border-purple-200 dark:border-purple-800',
  sse: 'bg-cyan-50 dark:bg-cyan-950 border-cyan-200 dark:border-cyan-800',
  freeze: 'bg-indigo-50 dark:bg-indigo-950 border-indigo-200 dark:border-indigo-800',
  note: 'bg-gray-50 dark:bg-gray-950 border-gray-200 dark:border-gray-800',
};

const ALL_SOURCES: TimelineSource[] = [
  'trpc.request',
  'trpc.response',
  'trpc.error',
  'zustand',
  'error',
  'perf',
  'sse',
  'freeze',
  'note',
];

function fmtTime(ts: number): string {
  const d = new Date(ts);
  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}

export const TimelineDebuggerTab: React.FC = () => {
  const version = useSyncExternalStore(subscribe, getVersion, () => 0);
  const entries = useMemo(() => readAll(), [version]);
  const [mode, setLocalMode] = useState(getMode());
  const [paused, setLocalPaused] = useState(isPaused());
  const [captureBodies, setLocalCaptureBodies] = useState(isCaptureBodies());
  const [filterText, setFilterText] = useState('');
  const [enabledSources, setEnabledSources] = useState<Set<TimelineSource>>(new Set(ALL_SOURCES));
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
  const [copiedKey, setCopiedKey] = useState<string | null>(null);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [copyFromInput, setCopyFromInput] = useState('');
  const scrollRef = useRef<HTMLDivElement>(null);

  // `mode` is a view filter: the buffer captures everything; in `mail` mode we
  // hide non-mail entries here (and from copy/export, which operate on
  // `filtered`). Switching to `all` reveals what was captured all along.
  const filtered = useMemo(() => {
    const text = filterText.trim().toLowerCase();
    return entries.filter((e) => {
      if (mode === 'mail' && !isMailTagged(e)) return false;
      if (!enabledSources.has(e.source)) return false;
      if (!text) return true;
      const hay = `${e.source} ${entryHeadline(e)}`.toLowerCase();
      return hay.includes(text);
    });
  }, [entries, enabledSources, filterText, mode]);

  useEffect(() => {
    if (scrollRef.current && !paused) {
      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
    }
  }, [filtered.length, paused]);

  const toggleExpanded = (id: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };

  const toggleSource = (s: TimelineSource) => {
    setEnabledSources((prev) => {
      const next = new Set(prev);
      if (next.has(s)) next.delete(s);
      else next.add(s);
      return next;
    });
  };

  const copyToClipboard = async (text: string, key=[redacted] => {
    try {
      await navigator.clipboard.writeText(text);
      setCopiedKey(key);
      setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), 1500);
    } catch {
      // ignore
    }
  };

  const copyAllMarkdown = () => copyToClipboard(formatAsMarkdown(filtered, { mode }), 'all-md');
  const copyAllJson = () => copyToClipboard(formatAsJson(filtered), 'all-json');

  const selectedIndex = useMemo(() => {
    if (!selectedId) return -1;
    return filtered.findIndex((e) => e.id === selectedId);
  }, [filtered, selectedId]);

  const copyFromIndex = (idx: number, fmt: 'md' | 'json') => {
    if (idx < 0 || idx >= filtered.length) return;
    const slice = filtered.slice(idx);
    const text = fmt === 'md' ? formatAsMarkdown(slice, { mode }) : formatAsJson(slice);
    copyToClipboard(text, `from-${idx}-${fmt}`);
  };

  const copyToIndex = (idx: number, fmt: 'md' | 'json') => {
    if (idx < 0 || idx >= filtered.length) return;
    const slice = filtered.slice(0, idx + 1);
    const text = fmt === 'md' ? formatAsMarkdown(slice, { mode }) : formatAsJson(slice);
    copyToClipboard(text, `to-${idx}-${fmt}`);
  };

  const deleteBeforeSelected = () => {
    if (!selectedId) return;
    const fullIdx = entries.findIndex((e) => e.id === selectedId);
    if (fullIdx < 0) return;
    replaceAll(entries.slice(fullIdx));
    setSelectedId(null);
  };

  const deleteAfterSelected = () => {
    if (!selectedId) return;
    const fullIdx = entries.findIndex((e) => e.id === selectedId);
    if (fullIdx < 0) return;
    replaceAll(entries.slice(0, fullIdx + 1));
    setSelectedId(null);
  };

  const handleCopyFromInput = (fmt: 'md' | 'json') => {
    const n = parseInt(copyFromInput, 10);
    if (!Number.isFinite(n) || n < 1 || n > filtered.length) return;
    copyFromIndex(n - 1, fmt);
  };

  return (
    <div className="flex h-full flex-col">
      {/* Toolbar */}
      <div className="flex flex-col gap-1 border-b border-gray-200 px-2 py-1.5 dark:border-gray-700">
        <div className="flex items-center gap-2">
          <div className="flex overflow-hidden rounded border border-gray-300 dark:border-gray-600">
            <button
              onClick={() => {
                setMode('mail');
                setLocalMode('mail');
              }}
              className={cn(
                'px-2 py-0.5 text-xs',
                mode === 'mail'
                  ? 'bg-blue-500 text-white'
                  : 'bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300',
              )}
            >
              Mail
            </button>
            <button
              onClick={() => {
                setMode('all');
                setLocalMode('all');
              }}
              className={cn(
                'px-2 py-0.5 text-xs',
                mode === 'all'
                  ? 'bg-blue-500 text-white'
                  : 'bg-white text-gray-700 dark:bg-gray-800 dark:text-gray-300',
              )}
              title="Capture everything (higher overhead)"
            >
              All
            </button>
          </div>

          <input
            type="text"
            value={filterText}
            onChange={(e) => setFilterText(e.target.value)}
            placeholder="Filter…"
            className="flex-1 rounded border border-gray-300 px-2 py-0.5 text-xs dark:border-gray-600 dark:bg-gray-800"
          />

          <button
            onClick={() => {
              const np = !paused;
              setPaused(np);
              setLocalPaused(np);
            }}
            className="rounded p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
            title={paused ? 'Resume capture' : 'Pause capture'}
          >
            {paused ? <Play className="h-3 w-3" /> : <Pause className="h-3 w-3" />}
          </button>
          <button
            onClick={clear}
            className="rounded p-1 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
            title="Clear timeline"
          >
            <Trash2 className="h-3 w-3" />
          </button>
          <button
            onClick={copyAllMarkdown}
            className="flex items-center gap-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
            title="Copy as Markdown for LLM"
          >
            {copiedKey === 'all-md' ? (
              <Check className="h-3 w-3 text-green-600" />
            ) : (
              <Copy className="h-3 w-3" />
            )}
            MD
          </button>
          <button
            onClick={copyAllJson}
            className="flex items-center gap-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
          >
            {copiedKey === 'all-json' ? (
              <Check className="h-3 w-3 text-green-600" />
            ) : (
              <Copy className="h-3 w-3" />
            )}
            JSON
          </button>
        </div>

        <div className="flex flex-wrap items-center gap-1">
          <label className="flex items-center gap-1 text-[10px] text-gray-600 dark:text-gray-400" title="Capture email bodies (PII!). Required for some mail.send debugging.">
            <input
              type="checkbox"
              checked={captureBodies}
              onChange={(e) => {
                setCaptureBodies(e.target.checked);
                setLocalCaptureBodies(e.target.checked);
              }}
              className="h-3 w-3"
            />
            bodies
          </label>
          <span className="mx-1 text-gray-300 dark:text-gray-700">|</span>
          {ALL_SOURCES.map((s) => (
            <button
              key={s}
              onClick={() => toggleSource(s)}
              className={cn(
                'rounded px-1.5 py-0.5 text-[10px] transition-colors',
                enabledSources.has(s)
                  ? 'bg-gray-200 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
                  : 'bg-gray-50 text-gray-400 dark:bg-gray-900 dark:text-gray-600',
              )}
            >
              {SOURCE_LABELS[s]}
            </button>
          ))}
          <span className="ml-auto text-[10px] text-gray-500 dark:text-gray-400">
            {filtered.length} / {entries.length}
          </span>
        </div>

        {/* Range actions */}
        <div className="flex flex-wrap items-center gap-1">
          <span className="text-[10px] text-gray-500 dark:text-gray-400">Copy from #</span>
          <input
            type="number"
            min={1}
            max={filtered.length}
            value={copyFromInput}
            onChange={(e) => setCopyFromInput(e.target.value)}
            placeholder="N"
            className="w-14 rounded border border-gray-300 px-1 py-0.5 text-[10px] dark:border-gray-600 dark:bg-gray-800"
          />
          <button
            onClick={() => handleCopyFromInput('md')}
            className="rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
            title="Copy entries from #N onward as Markdown"
          >
            MD
          </button>
          <button
            onClick={() => handleCopyFromInput('json')}
            className="rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
            title="Copy entries from #N onward as JSON"
          >
            JSON
          </button>

          {selectedIndex >= 0 ? (
            <>
              <span className="mx-1 text-gray-300 dark:text-gray-700">|</span>
              <span className="rounded bg-yellow-100 px-1.5 py-0.5 text-[10px] font-medium text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
                Selected #{selectedIndex + 1}
              </span>
              <button
                onClick={() => copyFromIndex(selectedIndex, 'md')}
                className="flex items-center gap-0.5 rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
                title="Copy from selected onward as Markdown"
              >
                {copiedKey === `from-${selectedIndex}-md` ? (
                  <Check className="h-3 w-3 text-green-600" />
                ) : (
                  <Copy className="h-3 w-3" />
                )}
                from
              </button>
              <button
                onClick={() => copyToIndex(selectedIndex, 'md')}
                className="flex items-center gap-0.5 rounded border border-gray-300 px-1.5 py-0.5 text-[10px] hover:bg-gray-100 dark:border-gray-600 dark:hover:bg-gray-800"
                title="Copy up to selected as Markdown"
              >
                {copiedKey === `to-${selectedIndex}-md` ? (
                  <Check className="h-3 w-3 text-green-600" />
                ) : (
                  <Copy className="h-3 w-3" />
                )}
                to
              </button>
              <button
                onClick={deleteBeforeSelected}
                className="flex items-center gap-0.5 rounded border border-red-300 px-1.5 py-0.5 text-[10px] text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950"
                title="Delete all entries before selected"
              >
                <Scissors className="h-3 w-3" />
                del before
              </button>
              <button
                onClick={deleteAfterSelected}
                className="flex items-center gap-0.5 rounded border border-red-300 px-1.5 py-0.5 text-[10px] text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950"
                title="Delete all entries after selected"
              >
                <Scissors className="h-3 w-3" />
                del after
              </button>
              <button
                onClick={() => setSelectedId(null)}
                className="rounded p-0.5 hover:bg-gray-100 dark:hover:bg-gray-800"
                title="Clear selection"
              >
                <X className="h-3 w-3" />
              </button>
            </>
          ) : (
            <span className="text-[10px] italic text-gray-400 dark:text-gray-500">
              click #N on a row to select
            </span>
          )}
        </div>
      </div>

      {/* Entries */}
      <div ref={scrollRef} className="flex-1 space-y-1 overflow-y-auto p-2">
        {filtered.length === 0 ? (
          <div className="py-4 text-center text-xs text-gray-500 dark:text-gray-400">
            No entries match. Interact with mail or toggle "All" mode.
          </div>
        ) : (
          filtered.map((entry, idx) => (
            <TimelineEntryRow
              key=[redacted]
              entry={entry}
              index={idx + 1}
              selected={selectedId === entry.id}
              onSelect={() =>
                setSelectedId((prev) => (prev === entry.id ? null : entry.id))
              }
              expanded={expanded.has(entry.id)}
              onToggle={() => toggleExpanded(entry.id)}
              onCopy={copyToClipboard}
              copiedKey=[redacted]
            />
          ))
        )}
      </div>
    </div>
  );
};

interface RowProps {
  entry: TimelineEntry;
  index: number;
  selected: boolean;
  onSelect: () => void;
  expanded: boolean;
  onToggle: () => void;
  onCopy: (text: string, key=[redacted] => void;
  copiedKey=[redacted] | null;
}

const TimelineEntryRow: React.FC<RowProps> = ({
  entry,
  index,
  selected,
  onSelect,
  expanded,
  onToggle,
  onCopy,
  copiedKey,
}) => {
  // Expanded view shows the compact diff (changed leaves only); the copy
  // button below grabs the full before/after snapshot for deeper context.
  const body = entryBody(entry, { compact: true });
  const fullBody = entryBody(entry);
  const headline = entryHeadline(entry);
  return (
    <div
      className={cn(
        'rounded border transition-all',
        SOURCE_COLORS[entry.source],
        selected && 'ring-2 ring-yellow-500 dark:ring-yellow-400',
      )}
    >
      <div
        className="flex cursor-pointer items-center gap-1.5 px-2 py-1"
        onClick={onToggle}
      >
        {expanded ? (
          <ChevronDown className="h-3 w-3 shrink-0" />
        ) : (
          <ChevronRight className="h-3 w-3 shrink-0" />
        )}
        <button
          onClick={(e) => {
            e.stopPropagation();
            onSelect();
          }}
          className={cn(
            'min-w-[28px] shrink-0 rounded px-1 py-0.5 font-mono text-[10px] tabular-nums transition-colors',
            selected
              ? 'bg-yellow-400 text-yellow-950 hover:bg-yellow-500 dark:bg-yellow-500 dark:text-yellow-950'
              : 'bg-white/60 text-gray-600 hover:bg-yellow-100 dark:bg-black/30 dark:text-gray-400 dark:hover:bg-yellow-900',
          )}
          title={selected ? 'Click to deselect' : 'Click to select as range anchor'}
        >
          #{index}
        </button>
        <span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">
          {fmtTime(entry.ts)}
        </span>
        <span className="rounded bg-white/60 px-1 py-0.5 text-[10px] dark:bg-black/30">
          {SOURCE_LABELS[entry.source]}
        </span>
        <span className="flex-1 truncate font-mono text-xs">{headline}</span>
        <button
          onClick={(e) => {
            e.stopPropagation();
            onCopy(`### ${fmtTime(entry.ts)} [${entry.source}] ${headline}\n\`\`\`json\n${fullBody}\n\`\`\``, entry.id);
          }}
          className="rounded p-0.5 hover:bg-white/60 dark:hover:bg-black/30"
          title="Copy entry (full snapshot)"
        >
          {copiedKey === entry.id ? (
            <Check className="h-3 w-3 text-green-600" />
          ) : (
            <Copy className="h-3 w-3" />
          )}
        </button>
      </div>
      {expanded && (
        <pre className="max-h-80 overflow-auto border-t border-black/10 bg-white/50 p-2 text-[11px] dark:border-white/10 dark:bg-black/30">
          {body}
        </pre>
      )}
    </div>
  );
};