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>
);
}