summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/mesh/PatchesListPanel.tsx
blob: b63d7e782c503880c9ece52c581da8cbb7376d5d (plain) (tree)











































































































































































































































                                                                                                                                                                                                   
import { useState, useEffect, useCallback } from "react";
import type { PatchSummary } from "../../lib/api";
import { listTaskPatches } from "../../lib/api";

interface PatchesListPanelProps {
  taskId: string;
  contractId: string;
}

/** Format a date for display */
function formatDate(dateStr: string): string {
  const date = new Date(dateStr);
  return date.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  });
}

/** Copy text to clipboard and show feedback */
async function copyToClipboard(text: string): Promise<boolean> {
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    // Fallback for older browsers
    const textArea = document.createElement("textarea");
    textArea.value = text;
    textArea.style.position = "fixed";
    textArea.style.left = "-999999px";
    textArea.style.top = "-999999px";
    document.body.appendChild(textArea);
    textArea.select();
    try {
      document.execCommand("copy");
      return true;
    } catch {
      return false;
    } finally {
      document.body.removeChild(textArea);
    }
  }
}

export function PatchesListPanel({ taskId, contractId }: PatchesListPanelProps) {
  const [patches, setPatches] = useState<PatchSummary[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [copiedPatchId, setCopiedPatchId] = useState<string | null>(null);
  const [expandedPatchId, setExpandedPatchId] = useState<string | null>(null);

  const fetchPatches = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);
      const patchList = await listTaskPatches(taskId, contractId);
      setPatches(patchList);
    } catch (e) {
      console.error("Failed to fetch patches:", e);
      setError(e instanceof Error ? e.message : "Failed to fetch patches");
    } finally {
      setLoading(false);
    }
  }, [taskId, contractId]);

  useEffect(() => {
    fetchPatches();
  }, [fetchPatches]);

  const handleCopyApplyCommand = useCallback(async (patch: PatchSummary) => {
    // Generate the apply command for this patch
    const command = `makima patch apply ${patch.id}`;
    const success = await copyToClipboard(command);
    if (success) {
      setCopiedPatchId(patch.id);
      setTimeout(() => setCopiedPatchId(null), 2000);
    }
  }, []);

  const handleViewPatch = useCallback((patchId: string) => {
    setExpandedPatchId(expandedPatchId === patchId ? null : patchId);
  }, [expandedPatchId]);

  if (loading) {
    return (
      <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
        <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase mb-2">
          Exported Patches
        </div>
        <div className="font-mono text-xs text-[#555] animate-pulse">
          Loading patches...
        </div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
        <div className="flex items-center justify-between mb-2">
          <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
            Exported Patches
          </div>
          <button
            onClick={fetchPatches}
            className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
          >
            Retry
          </button>
        </div>
        <div className="font-mono text-xs text-red-400">
          {error}
        </div>
      </div>
    );
  }

  if (patches.length === 0) {
    return (
      <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3">
        <div className="flex items-center justify-between mb-2">
          <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
            Exported Patches
          </div>
          <button
            onClick={fetchPatches}
            className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
          >
            Refresh
          </button>
        </div>
        <div className="font-mono text-xs text-[#555]">
          No patches exported yet
        </div>
      </div>
    );
  }

  return (
    <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)]">
      {/* Header */}
      <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.1)]">
        <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
          Exported Patches
        </div>
        <div className="flex items-center gap-2">
          <span className="font-mono text-[10px] text-[#75aafc]">
            {patches.length} patch{patches.length !== 1 ? "es" : ""}
          </span>
          <button
            onClick={fetchPatches}
            className="font-mono text-[10px] text-[#555] hover:text-[#75aafc] transition-colors"
            title="Refresh"
          >
            <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
            </svg>
          </button>
        </div>
      </div>

      {/* Patch list */}
      <div className="divide-y divide-[rgba(117,170,252,0.1)]">
        {patches.map((patch) => (
          <div
            key={patch.id}
            className="p-3"
          >
            {/* Patch header */}
            <div className="flex items-start justify-between gap-2 mb-2">
              <div className="flex-1 min-w-0">
                <div className="font-mono text-sm text-[#dbe7ff] truncate" title={patch.name}>
                  {patch.name}
                </div>
                <div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1 font-mono text-[10px] text-[#555]">
                  <span title={new Date(patch.createdAt).toLocaleString()}>
                    {formatDate(patch.createdAt)}
                  </span>
                  <span>
                    {patch.filesCount} file{patch.filesCount !== 1 ? "s" : ""}
                  </span>
                  <span className="text-green-400">+{patch.linesAdded}</span>
                  <span className="text-red-400">-{patch.linesRemoved}</span>
                </div>
              </div>
            </div>

            {/* Action buttons */}
            <div className="flex flex-wrap gap-2">
              <button
                onClick={() => handleViewPatch(patch.id)}
                className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors"
              >
                {expandedPatchId === patch.id ? "Hide" : "View"}
              </button>
              <button
                onClick={() => handleCopyApplyCommand(patch)}
                className={`px-2 py-1 font-mono text-[10px] border transition-colors ${
                  copiedPatchId === patch.id
                    ? "text-green-400 border-green-400/30 bg-green-400/10"
                    : "text-[#9bc3ff] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)]"
                }`}
              >
                {copiedPatchId === patch.id ? "Copied!" : "Copy Apply Command"}
              </button>
            </div>

            {/* Expanded patch content */}
            {expandedPatchId === patch.id && patch.description && (
              <div className="mt-3 p-2 bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.1)]">
                <pre className="font-mono text-[10px] text-[#75aafc] whitespace-pre-wrap overflow-x-auto">
                  {patch.description}
                </pre>
              </div>
            )}

            {/* Expanded - show files if available */}
            {expandedPatchId === patch.id && patch.files && patch.files.length > 0 && (
              <div className="mt-2">
                <div className="font-mono text-[10px] text-[#555] mb-1">Changed files:</div>
                <div className="space-y-0.5">
                  {patch.files.map((file, idx) => (
                    <div key={idx} className="font-mono text-[10px] text-[#75aafc] pl-2">
                      {file}
                    </div>
                  ))}
                </div>
              </div>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}