diff options
Diffstat (limited to 'makima/frontend/src/components')
6 files changed, 655 insertions, 4 deletions
diff --git a/makima/frontend/src/components/contracts/ContractDetail.tsx b/makima/frontend/src/components/contracts/ContractDetail.tsx index 90b6967..6e31c84 100644 --- a/makima/frontend/src/components/contracts/ContractDetail.tsx +++ b/makima/frontend/src/components/contracts/ContractDetail.tsx @@ -177,6 +177,11 @@ export function ContractDetail({ > {statusConfig[contract.status].label} </span> + {contract.localOnly && ( + <span className="px-2 py-0.5 font-mono text-[10px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10"> + Local-Only + </span> + )} </div> {contract.description && ( <p className="font-mono text-sm text-[#9bc3ff] mb-3"> diff --git a/makima/frontend/src/components/contracts/ContractList.tsx b/makima/frontend/src/components/contracts/ContractList.tsx index ebde497..98f8ff6 100644 --- a/makima/frontend/src/components/contracts/ContractList.tsx +++ b/makima/frontend/src/components/contracts/ContractList.tsx @@ -127,11 +127,18 @@ export function ContractList({ `} > <div className="flex items-start justify-between gap-2 mb-2"> - <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> - {contract.name} - </h3> + <div className="flex items-center gap-2 min-w-0"> + <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> + {contract.name} + </h3> + {contract.localOnly && ( + <span className="px-1.5 py-0.5 font-mono text-[9px] uppercase text-amber-400 border border-amber-400/30 bg-amber-400/10 shrink-0"> + Local + </span> + )} + </div> <span - className={`text-[10px] font-mono uppercase ${ + className={`text-[10px] font-mono uppercase shrink-0 ${ statusColors[contract.status] }`} > diff --git a/makima/frontend/src/components/mesh/GitActionsPanel.tsx b/makima/frontend/src/components/mesh/GitActionsPanel.tsx new file mode 100644 index 0000000..be2e06d --- /dev/null +++ b/makima/frontend/src/components/mesh/GitActionsPanel.tsx @@ -0,0 +1,181 @@ +import { useState, useCallback } from "react"; +import { exportTaskPatch, pushTaskBranch, createTaskPR, type ExportPatchResponse } from "../../lib/api"; + +interface GitActionsPanelProps { + taskId: string; + isLocalOnly: boolean; + taskStatus: string; +} + +interface ToastMessage { + type: "success" | "error" | "info"; + message: string; +} + +export function GitActionsPanel({ + taskId, + isLocalOnly, + taskStatus, +}: GitActionsPanelProps) { + const [isExporting, setIsExporting] = useState(false); + const [isPushing, setIsPushing] = useState(false); + const [isCreatingPR, setIsCreatingPR] = useState(false); + const [toast, setToast] = useState<ToastMessage | null>(null); + const [exportedPatch, setExportedPatch] = useState<ExportPatchResponse | null>(null); + + // Only show for completed tasks + if (taskStatus !== "done") return null; + + const showToast = (type: ToastMessage["type"], message: string) => { + setToast({ type, message }); + setTimeout(() => setToast(null), 5000); + }; + + const handleExportPatch = useCallback(async () => { + setIsExporting(true); + try { + const result = await exportTaskPatch(taskId); + setExportedPatch(result); + showToast("success", `Patch generated: ${result.fileName}`); + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to generate patch"); + } finally { + setIsExporting(false); + } + }, [taskId]); + + const handlePushBranch = useCallback(async () => { + if (isLocalOnly) { + showToast("error", "Push disabled in local-only mode"); + return; + } + setIsPushing(true); + try { + const result = await pushTaskBranch(taskId); + showToast("success", `Branch pushed: ${result.branchName}`); + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to push branch"); + } finally { + setIsPushing(false); + } + }, [taskId, isLocalOnly]); + + const handleCreatePR = useCallback(async () => { + if (isLocalOnly) { + showToast("error", "PR creation disabled in local-only mode"); + return; + } + setIsCreatingPR(true); + try { + const result = await createTaskPR(taskId); + if (result.prUrl) { + showToast("success", `PR created: ${result.prUrl}`); + // Open PR in new tab + window.open(result.prUrl, "_blank", "noopener,noreferrer"); + } else { + showToast("success", "PR creation initiated"); + } + } catch (e) { + showToast("error", e instanceof Error ? e.message : "Failed to create PR"); + } finally { + setIsCreatingPR(false); + } + }, [taskId, isLocalOnly]); + + return ( + <div className="space-y-3"> + {/* Section Header */} + <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase"> + Git Actions + </div> + + {/* Local-only mode alert */} + {isLocalOnly && ( + <div className="bg-yellow-400/10 border border-yellow-400/30 px-3 py-2 font-mono text-xs text-yellow-400 flex items-center gap-2"> + <span className="w-1.5 h-1.5 bg-yellow-400 rounded-full" /> + Contract is in local-only mode. Push/PR actions disabled. + </div> + )} + + {/* Toast notification */} + {toast && ( + <div + className={`px-3 py-2 font-mono text-xs border ${ + toast.type === "success" + ? "bg-green-400/10 border-green-400/30 text-green-400" + : toast.type === "error" + ? "bg-red-400/10 border-red-400/30 text-red-400" + : "bg-blue-400/10 border-blue-400/30 text-blue-400" + }`} + > + {toast.message} + </div> + )} + + {/* Action buttons */} + <div className="flex flex-wrap gap-2"> + <button + onClick={handleExportPatch} + disabled={isExporting} + className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + > + {isExporting ? "Generating..." : "Generate Patch"} + </button> + + <button + onClick={handlePushBranch} + disabled={isPushing || isLocalOnly} + className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ + isLocalOnly + ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed" + : "text-cyan-400 border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10" + }`} + title={isLocalOnly ? "Disabled in local-only mode" : "Push changes to remote branch"} + > + {isPushing ? "Pushing..." : "Push Remote Branch"} + </button> + + <button + onClick={handleCreatePR} + disabled={isCreatingPR || isLocalOnly} + className={`px-3 py-1.5 font-mono text-xs border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${ + isLocalOnly + ? "text-[#555] border-[rgba(117,170,252,0.15)] cursor-not-allowed" + : "text-green-400 border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10" + }`} + title={isLocalOnly ? "Disabled in local-only mode" : "Create pull request"} + > + {isCreatingPR ? "Creating PR..." : "Create PR"} + </button> + </div> + + {/* Export result info */} + {exportedPatch && ( + <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2"> + <div className="font-mono text-xs text-[#555]">Exported Patch</div> + <div className="font-mono text-xs text-[#75aafc] break-all"> + <span className="text-[#555]">File:</span> {exportedPatch.fileName} + </div> + {exportedPatch.filePath && ( + <div className="font-mono text-xs text-[#75aafc] break-all"> + <span className="text-[#555]">Path:</span> {exportedPatch.filePath} + </div> + )} + {exportedPatch.patchSize && ( + <div className="font-mono text-xs text-[#75aafc]"> + <span className="text-[#555]">Size:</span> {formatBytes(exportedPatch.patchSize)} + </div> + )} + </div> + )} + </div> + ); +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} diff --git a/makima/frontend/src/components/mesh/PatchesListPanel.tsx b/makima/frontend/src/components/mesh/PatchesListPanel.tsx new file mode 100644 index 0000000..b63d7e7 --- /dev/null +++ b/makima/frontend/src/components/mesh/PatchesListPanel.tsx @@ -0,0 +1,236 @@ +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> + ); +} diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx index 8936d28..fdcc58b 100644 --- a/makima/frontend/src/components/mesh/TaskDetail.tsx +++ b/makima/frontend/src/components/mesh/TaskDetail.tsx @@ -7,6 +7,9 @@ import { PRPreview } from "./PRPreview"; import { InlineSubtaskEditor } from "./InlineSubtaskEditor"; import { DirectoryInput } from "./DirectoryInput"; import { BranchTaskModal } from "./BranchTaskModal"; +import { GitActionsPanel } from "./GitActionsPanel"; +import { WorktreeFilesPanel } from "./WorktreeFilesPanel"; +import { PatchesListPanel } from "./PatchesListPanel"; interface TaskDetailProps { task: TaskWithSubtasks; @@ -36,6 +39,8 @@ interface TaskDetailProps { fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>; /** For supervisor tasks: all tasks in the contract (excluding the supervisor itself) */ contractTasks?: TaskSummary[]; + /** Whether the contract is in local-only mode (no push/PR) */ + isLocalOnly?: boolean; } function formatDate(dateStr: string): string { @@ -119,6 +124,7 @@ export function TaskDetail({ onAutoMerge, fetchSubtasks, contractTasks, + isLocalOnly = false, }: TaskDetailProps) { const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(task.name); @@ -879,6 +885,25 @@ export function TaskDetail({ )} </div> )} + + {/* Git Actions Panel for manual git operations */} + {task.status === "done" && ( + <GitActionsPanel + taskId={task.id} + isLocalOnly={isLocalOnly} + taskStatus={task.status} + /> + )} + + {/* Worktree Files Panel - show changed files in the worktree */} + {(task.status === "done" || task.status === "failed" || task.status === "merged" || task.status === "running") && ( + <WorktreeFilesPanel taskId={task.id} /> + )} + + {/* Patches List Panel - show exported patches for this task */} + {task.contractId && (task.status === "done" || task.status === "failed" || task.status === "merged") && ( + <PatchesListPanel taskId={task.id} contractId={task.contractId} /> + )} </div> {/* Overlay Diff Modal */} diff --git a/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx new file mode 100644 index 0000000..b529588 --- /dev/null +++ b/makima/frontend/src/components/mesh/WorktreeFilesPanel.tsx @@ -0,0 +1,197 @@ +import { useState, useEffect, useCallback } from "react"; +import type { WorktreeInfo } from "../../lib/api"; +import { getWorktreeInfo } from "../../lib/api"; + +interface WorktreeFilesPanelProps { + taskId: string; +} + +/** Get status badge styling based on file status */ +function getStatusStyle(status: string): { color: string; bgColor: string; label: string } { + switch (status) { + case "M": + case "modified": + return { color: "text-yellow-400", bgColor: "bg-yellow-400/10", label: "M" }; + case "A": + case "added": + return { color: "text-green-400", bgColor: "bg-green-400/10", label: "A" }; + case "D": + case "deleted": + return { color: "text-red-400", bgColor: "bg-red-400/10", label: "D" }; + case "R": + case "renamed": + return { color: "text-cyan-400", bgColor: "bg-cyan-400/10", label: "R" }; + case "C": + case "copied": + return { color: "text-purple-400", bgColor: "bg-purple-400/10", label: "C" }; + case "U": + case "unmerged": + return { color: "text-orange-400", bgColor: "bg-orange-400/10", label: "U" }; + case "?": + case "untracked": + return { color: "text-[#555]", bgColor: "bg-[#555]/10", label: "?" }; + default: + return { color: "text-[#9bc3ff]", bgColor: "bg-[rgba(117,170,252,0.1)]", label: status.charAt(0).toUpperCase() }; + } +} + +export function WorktreeFilesPanel({ taskId }: WorktreeFilesPanelProps) { + const [worktreeInfo, setWorktreeInfo] = useState<WorktreeInfo | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const [expanded, setExpanded] = useState(false); + + const fetchWorktreeInfo = useCallback(async () => { + try { + setLoading(true); + setError(null); + const info = await getWorktreeInfo(taskId); + setWorktreeInfo(info); + } catch (e) { + console.error("Failed to fetch worktree info:", e); + setError(e instanceof Error ? e.message : "Failed to fetch worktree info"); + } finally { + setLoading(false); + } + }, [taskId]); + + useEffect(() => { + fetchWorktreeInfo(); + }, [fetchWorktreeInfo]); + + 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"> + Worktree Changes + </div> + <div className="font-mono text-xs text-[#555] animate-pulse"> + Loading worktree info... + </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"> + Worktree Changes + </div> + <button + onClick={fetchWorktreeInfo} + 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 (!worktreeInfo || worktreeInfo.files.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"> + Worktree Changes + </div> + <button + onClick={fetchWorktreeInfo} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + Refresh + </button> + </div> + <div className="font-mono text-xs text-[#555]"> + No changes in worktree + </div> + </div> + ); + } + + const { stats, files } = worktreeInfo; + const displayFiles = expanded ? files : files.slice(0, 10); + + 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"> + Worktree Changes + </div> + <div className="flex items-center gap-3"> + {/* Stats */} + <div className="flex items-center gap-2 font-mono text-[10px]"> + <span className="text-[#75aafc]"> + {stats.filesChanged} file{stats.filesChanged !== 1 ? "s" : ""} + </span> + <span className="text-green-400">+{stats.insertions}</span> + <span className="text-red-400">-{stats.deletions}</span> + </div> + <button + onClick={fetchWorktreeInfo} + 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> + + {/* File list */} + <div className="divide-y divide-[rgba(117,170,252,0.05)]"> + {displayFiles.map((file) => { + const statusStyle = getStatusStyle(file.status); + return ( + <div + key={file.path} + className="flex items-center gap-2 px-3 py-1.5 hover:bg-[rgba(117,170,252,0.03)]" + > + {/* Status badge */} + <span + className={`w-5 h-5 flex items-center justify-center font-mono text-[10px] font-medium ${statusStyle.color} ${statusStyle.bgColor} border border-current/20`} + title={file.status} + > + {statusStyle.label} + </span> + + {/* File path */} + <span className="flex-1 font-mono text-xs text-[#dbe7ff] truncate" title={file.path}> + {file.path} + </span> + + {/* Line stats */} + <div className="flex items-center gap-1.5 font-mono text-[10px] shrink-0"> + {file.linesAdded > 0 && ( + <span className="text-green-400">+{file.linesAdded}</span> + )} + {file.linesRemoved > 0 && ( + <span className="text-red-400">-{file.linesRemoved}</span> + )} + </div> + </div> + ); + })} + </div> + + {/* Show more/less button */} + {files.length > 10 && ( + <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)]"> + <button + onClick={() => setExpanded(!expanded)} + className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] transition-colors" + > + {expanded ? `Show less` : `Show ${files.length - 10} more files...`} + </button> + </div> + )} + </div> + ); +} |
