diff options
| author | soryu <soryu@soryu.co> | 2026-01-26 22:12:57 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-01-26 22:12:57 +0000 |
| commit | d1f5dadb549d499c5aeee9cacf6c9aa0a233c198 (patch) | |
| tree | a47e3d68a6b25bc39044a52b63099a199dce677d /makima/frontend/src/components | |
| parent | bc1ce8013bc36a1585be05b928f2386ab56529c2 (diff) | |
| download | soryu-d1f5dadb549d499c5aeee9cacf6c9aa0a233c198.tar.gz soryu-d1f5dadb549d499c5aeee9cacf6c9aa0a233c198.zip | |
Add local-only mode for contracts with patch export support (#34)
* Add local_only flag to contracts database and models
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* Skip automatic completion actions in local_only mode
Add `local_only` flag to contracts that prevents automatic completion
actions (branch, merge, pr) from executing when tasks complete. This
allows users to manually handle code changes via patch files or other
means when operating in local-only mode.
Changes:
- Add `local_only` field to Contract model and request types
- Add database migration for the new column
- Add `local_only` parameter to SpawnTask command in both state.rs and
daemon protocol.rs
- Modify task manager to skip completion action execution when
`local_only` is true, with appropriate logging
- Pass `local_only` flag through all task spawning paths:
- mesh_supervisor.rs (task spawn, retry, resume)
- mesh.rs (task start, reassign, continue)
- mesh_chat.rs (run task)
- contract_chat.rs (run task)
- Update repository create/update functions to handle `local_only`
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* Implement core patch export system
Add functionality to create uncompressed, human-readable git patches
for export. This enables users to generate patches that can be manually
applied or shared, without the compression used for internal checkpoints.
Changes:
- Add ExportPatchResult struct with patch content, file count, and line stats
- Add create_export_patch() function that generates diffs against a base SHA
- Add get_head_sha() utility function
- Add parse_diff_stat() helper to extract line counts from git output
- Add CreateExportPatch command to daemon protocol
- Add ExportPatchCreated response message to protocol
- Add handler in task manager to process export patch requests
- Add server-side handling to broadcast patch results to UI
The export patch system automatically finds the merge-base when no base
SHA is provided, trying upstream tracking branch first, then common
default branches (origin/main, origin/master, main, master).
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* Task completion checkpoint
* Add GitActionsPanel frontend component
* Add WorktreeFilesPanel and PatchesListPanel components
* Add local-only mode toggle to contract creation
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
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> + ); +} |
