earl 0.5.2

AI-safe CLI for AI agents
"use client";

import { formatDistanceToNow } from "date-fns";
import {
  CheckCircle2,
  ChevronDown,
  ChevronUp,
  GitCompareArrows,
  History,
  Inbox,
  Play,
  Trash2,
  XCircle,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { HistoryEntry } from "@/lib/types";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface HistoryDrawerProps {
  entries: HistoryEntry[];
  onClear: () => void;
  onReplay: (entry: HistoryEntry) => void;
}

// ---------------------------------------------------------------------------
// Diff helpers
// ---------------------------------------------------------------------------

interface DiffLine {
  text: string;
  type: "added" | "removed" | "unchanged";
}

/**
 * Simple line-by-line diff: split each JSON string by newlines and compare.
 * Returns an array of diff lines with their type.
 */
function computeDiff(previous: string, current: string): DiffLine[] {
  const prevLines = previous.split("\n");
  const currLines = current.split("\n");
  const result: DiffLine[] = [];

  const maxLen = Math.max(prevLines.length, currLines.length);

  for (let i = 0; i < maxLen; i++) {
    const prev = i < prevLines.length ? prevLines[i] : undefined;
    const curr = i < currLines.length ? currLines[i] : undefined;

    if (prev === curr) {
      result.push({ type: "unchanged", text: curr ?? "" });
    } else {
      if (prev !== undefined) {
        result.push({ type: "removed", text: prev });
      }
      if (curr !== undefined) {
        result.push({ type: "added", text: curr });
      }
    }
  }

  return result;
}

function diffLinePrefix(type: DiffLine["type"]): string {
  if (type === "added") {
    return "+";
  }
  if (type === "removed") {
    return "-";
  }
  return " ";
}

/** Serialise a history entry's response to a comparable string. */
function serialiseResponse(entry: HistoryEntry): string {
  if (entry.error) {
    try {
      return JSON.stringify(entry.error, null, 2);
    } catch {
      return String(entry.error);
    }
  }
  if (entry.response) {
    try {
      return JSON.stringify(entry.response, null, 2);
    } catch {
      return String(entry.response);
    }
  }
  return entry.responseSummary;
}

// ---------------------------------------------------------------------------
// Status badge for each entry
// ---------------------------------------------------------------------------

function EntryStatusBadge({ status }: { status: number | null }) {
  if (status === null) {
    return (
      <Badge className="gap-1 text-[0.6rem]" variant="destructive">
        <XCircle className="size-2.5" />
        Error
      </Badge>
    );
  }

  if (status >= 200 && status < 300) {
    return (
      <Badge
        className="gap-1 bg-emerald-500/20 text-[0.6rem] text-emerald-400"
        variant="secondary"
      >
        <CheckCircle2 className="size-2.5" />
        {status}
      </Badge>
    );
  }

  return (
    <Badge
      className="gap-1 bg-amber-500/20 text-[0.6rem] text-amber-400"
      variant="secondary"
    >
      {status}
    </Badge>
  );
}

// ---------------------------------------------------------------------------
// Diff view
// ---------------------------------------------------------------------------

function DiffView({
  previous,
  current,
}: {
  previous: HistoryEntry;
  current: HistoryEntry;
}) {
  const lines = useMemo(
    () => computeDiff(serialiseResponse(previous), serialiseResponse(current)),
    [previous, current]
  );

  return (
    <div className="mt-2 max-h-40 overflow-auto rounded-md border border-border bg-muted/30 p-2 font-mono text-[0.65rem] leading-relaxed">
      {lines.map((line, i) => (
        <div
          className={cn(
            "whitespace-pre-wrap px-1",
            line.type === "added" && "bg-emerald-500/20 text-emerald-300",
            line.type === "removed" && "bg-red-500/20 text-red-300",
            line.type === "unchanged" && "text-muted-foreground"
          )}
          key={`${line.type}-${String(i)}`}
        >
          <span className="mr-2 select-none text-muted-foreground/50">
            {diffLinePrefix(line.type)}
          </span>
          {line.text}
        </div>
      ))}
    </div>
  );
}

// ---------------------------------------------------------------------------
// Single history entry row
// ---------------------------------------------------------------------------

function HistoryEntryRow({
  entry,
  previousEntry,
  onReplay,
}: {
  entry: HistoryEntry;
  previousEntry: HistoryEntry | undefined;
  onReplay: (entry: HistoryEntry) => void;
}) {
  const [showDiff, setShowDiff] = useState(false);

  const relativeTime = useMemo(
    () => formatDistanceToNow(entry.timestamp, { addSuffix: true }),
    [entry.timestamp]
  );

  const handleClick = useCallback(() => {
    onReplay(entry);
  }, [entry, onReplay]);

  const handleReplay = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      onReplay(entry);
    },
    [entry, onReplay]
  );

  const handleToggleDiff = useCallback((e: React.MouseEvent) => {
    e.stopPropagation();
    setShowDiff((prev) => !prev);
  }, []);

  return (
    <button
      className="group w-full cursor-pointer border-border/50 border-b last:border-b-0"
      onClick={handleClick}
      type="button"
    >
      <div className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors group-hover:bg-muted/50">
        {/* Command name */}
        <span className="flex-1 truncate font-medium font-mono text-foreground">
          {entry.command}
        </span>

        {/* Status badge */}
        <EntryStatusBadge status={entry.status} />

        {/* Relative timestamp */}
        <span className="shrink-0 text-[0.6rem] text-muted-foreground">
          {relativeTime}
        </span>

        {/* Actions */}
        <span className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
          {previousEntry && (
            <button
              aria-label="Toggle diff"
              className="inline-flex size-5 items-center justify-center rounded-sm hover:bg-muted"
              onClick={handleToggleDiff}
              type="button"
            >
              <GitCompareArrows className="size-3" />
            </button>
          )}
          <button
            aria-label="Replay"
            className="inline-flex size-5 items-center justify-center rounded-sm hover:bg-muted"
            onClick={handleReplay}
            type="button"
          >
            <Play className="size-3" />
          </button>
        </span>
      </div>

      {/* Diff view */}
      {showDiff && previousEntry && (
        <div className="px-3 pb-2">
          <DiffView current={entry} previous={previousEntry} />
        </div>
      )}
    </button>
  );
}

// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------

export function HistoryDrawer({
  entries,
  onReplay,
  onClear,
}: HistoryDrawerProps) {
  const [open, setOpen] = useState(false);

  /**
   * Build a map from command name to the previous entry of the same command,
   * so we can show diffs between consecutive executions of the same command.
   */
  const previousEntryMap = useMemo(() => {
    const map = new Map<string, HistoryEntry>();
    const result = new Map<string, HistoryEntry>();

    // entries are newest-first; iterate in reverse for chronological order
    for (let i = entries.length - 1; i >= 0; i--) {
      const entry = entries[i];
      if (!entry) {
        continue;
      }
      const prev = map.get(entry.command);
      if (prev) {
        result.set(entry.id, prev);
      }
      map.set(entry.command, entry);
    }

    return result;
  }, [entries]);

  return (
    <Collapsible onOpenChange={setOpen} open={open}>
      <div className="border-border border-t bg-card">
        {/* Toggle trigger */}
        <CollapsibleTrigger className="flex w-full items-center gap-2 px-3 py-2 font-medium text-muted-foreground text-xs transition-colors hover:text-foreground">
          <History className="size-3.5" />
          <span>History</span>
          {entries.length > 0 && (
            <Badge className="ml-1 text-[0.6rem]" variant="secondary">
              {entries.length}
            </Badge>
          )}
          <span className="flex-1" />
          {open ? (
            <ChevronDown className="size-3.5" />
          ) : (
            <ChevronUp className="size-3.5" />
          )}
        </CollapsibleTrigger>

        {/* Collapsible content */}
        <CollapsibleContent>
          {entries.length === 0 ? (
            /* Empty state */
            <div className="flex flex-col items-center gap-2 px-3 py-6 text-muted-foreground text-xs">
              <Inbox className="size-5" />
              <span>No requests yet</span>
            </div>
          ) : (
            <div className="relative max-h-[200px] overflow-y-auto">
              {/* Entry list */}
              <div>
                {entries.map((entry) => (
                  <HistoryEntryRow
                    entry={entry}
                    key={entry.id}
                    onReplay={onReplay}
                    previousEntry={previousEntryMap.get(entry.id)}
                  />
                ))}
              </div>

              {/* Clear history — sticky at bottom */}
              <div className="sticky bottom-0 flex justify-center border-border/50 border-t bg-card px-3 py-1.5">
                <Button
                  className="text-muted-foreground hover:text-destructive"
                  onClick={onClear}
                  size="sm"
                  variant="ghost"
                >
                  <Trash2 className="size-3" />
                  Clear history
                </Button>
              </div>
            </div>
          )}
        </CollapsibleContent>
      </div>
    </Collapsible>
  );
}