I mean, it's at a good place rn...

This commit is contained in:
Renn F
2026-04-20 15:10:54 +02:00
parent 0023c25d60
commit 8e201901c0
264 changed files with 36484 additions and 748 deletions
@@ -0,0 +1,236 @@
"use client";
import { useState } from "react";
import { GitStatusResponse } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import {
GitCommit,
Upload,
GitPullRequest,
RefreshCw,
ArrowUp,
} from "lucide-react";
interface GitActionsPanelProps {
status: GitStatusResponse | undefined;
projectSlug: string;
taskId: string;
agentId: string;
onCommit: (message: string) => void;
onPush: (force?: boolean) => void;
onCreatePR: (title: string, body: string) => void;
isCommitting: boolean;
isPushing: boolean;
isCreatingPR: boolean;
}
export function GitActionsPanel({
status,
projectSlug,
taskId,
agentId: _agentId,
onCommit,
onPush,
onCreatePR,
isCommitting,
isPushing,
isCreatingPR,
}: GitActionsPanelProps) {
void _agentId; // Reserved for future use
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [showPRDialog, setShowPRDialog] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const [prTitle, setPrTitle] = useState("");
const [prBody, setPrBody] = useState("");
const hasStagedChanges = (status?.staged_files.length ?? 0) > 0;
const hasUnpushedCommits = (status?.ahead ?? 0) > 0;
const canPush = hasUnpushedCommits;
const canCreatePR = hasUnpushedCommits || status?.current_branch !== "main";
const handleCommit = () => {
if (commitMessage.trim()) {
onCommit(commitMessage.trim());
setShowCommitDialog(false);
setCommitMessage("");
}
};
const handleCreatePR = () => {
if (prTitle.trim()) {
onCreatePR(prTitle.trim(), prBody);
setShowPRDialog(false);
setPrTitle("");
setPrBody("");
}
};
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Git Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Commit Action */}
<Dialog open={showCommitDialog} onOpenChange={setShowCommitDialog}>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant={hasStagedChanges ? "default" : "outline"}
disabled={!hasStagedChanges}
>
<GitCommit className="h-4 w-4 mr-2" />
Commit Changes
{hasStagedChanges && (
<Badge variant="secondary" className="ml-auto">
{status?.staged_files.length} files
</Badge>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Commit Changes</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Commit Message</label>
<Textarea
placeholder="Describe your changes..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
/>
</div>
<div className="text-sm text-muted-foreground">
<p>Staged files: {status?.staged_files.length}</p>
<ul className="mt-1 text-xs font-mono max-h-24 overflow-auto">
{status?.staged_files.slice(0, 5).map((f) => (
<li key={f} className="truncate">
{f}
</li>
))}
{(status?.staged_files.length ?? 0) > 5 && (
<li>... and {(status?.staged_files.length ?? 0) - 5} more</li>
)}
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCommitDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={!commitMessage.trim() || isCommitting}
>
{isCommitting && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
Commit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Push Action */}
<Button
className="w-full justify-start"
variant={canPush ? "default" : "outline"}
disabled={!canPush || isPushing}
onClick={() => onPush(false)}
>
{isPushing ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Upload className="h-4 w-4 mr-2" />
)}
Push to Remote
{status?.ahead !== undefined && status.ahead > 0 && (
<Badge variant="secondary" className="ml-auto">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead}
</Badge>
)}
</Button>
{/* Create PR Action */}
<Dialog open={showPRDialog} onOpenChange={setShowPRDialog}>
<DialogTrigger asChild>
<Button
className="w-full justify-start"
variant="outline"
disabled={!canCreatePR}
>
<GitPullRequest className="h-4 w-4 mr-2" />
Create Pull Request
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Pull Request</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline">{status?.current_branch}</Badge>
<span></span>
<Badge variant="outline">main</Badge>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<Input
placeholder="PR title..."
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Textarea
placeholder="Describe your changes..."
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
rows={6}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPRDialog(false)}>
Cancel
</Button>
<Button
onClick={handleCreatePR}
disabled={!prTitle.trim() || isCreatingPR}
>
{isCreatingPR && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
Create PR
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Status Summary */}
{status && (
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
<p>Project: {projectSlug}</p>
<p>Branch: {status.current_branch}</p>
{taskId && <p>Task: {taskId.slice(0, 8)}...</p>}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,224 @@
"use client";
import { useState } from "react";
import { GitBranchListResponse, BranchType } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
import {
GitBranch,
Check,
Cloud,
Plus,
RefreshCw,
} from "lucide-react";
interface GitBranchPanelProps {
branches: GitBranchListResponse | undefined;
isLoading: boolean;
onCheckout: (branch: string) => void;
onCreateBranch: (branchType: BranchType, taskId: string) => void;
isCheckingOut: boolean;
isCreating: boolean;
}
export function GitBranchPanel({
branches,
isLoading,
onCheckout,
onCreateBranch,
isCheckingOut,
isCreating,
}: GitBranchPanelProps) {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newBranchType, setNewBranchType] = useState<BranchType>("feature");
const [taskId, setTaskId] = useState("");
const handleCreateBranch = () => {
if (taskId.trim()) {
onCreateBranch(newBranchType, taskId.trim());
setShowCreateDialog(false);
setTaskId("");
}
};
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</CardContent>
</Card>
);
}
if (!branches) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<GitBranch className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No branches available</p>
</CardContent>
</Card>
);
}
const localBranches = branches.branches.filter((b) => !b.is_remote);
const remoteBranches = branches.branches.filter((b) => b.is_remote);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Branches
</span>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="h-3 w-3 mr-1" />
New
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Task Branch</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Branch Type</label>
<Select
value={newBranchType}
onValueChange={(v) => setNewBranchType(v as BranchType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="feature">Feature</SelectItem>
<SelectItem value="bug">Bug Fix</SelectItem>
<SelectItem value="chore">Chore</SelectItem>
<SelectItem value="docs">Documentation</SelectItem>
<SelectItem value="hotfix">Hotfix</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Task ID</label>
<Input
placeholder="Enter task ID..."
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowCreateDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleCreateBranch}
disabled={!taskId.trim() || isCreating}
>
{isCreating && <RefreshCw className="h-4 w-4 mr-2 animate-spin" />}
Create Branch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-64">
<div className="space-y-3">
{/* Local Branches */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1">
Local ({localBranches.length})
</h4>
<div className="space-y-0.5">
{localBranches.map((branch) => (
<button
key={branch.name}
onClick={() => !branch.is_current && onCheckout(branch.name)}
disabled={branch.is_current || isCheckingOut}
className={
"w-full flex items-center justify-between px-2 py-1.5 rounded text-sm text-left transition-colors " +
(branch.is_current
? "bg-primary/10 text-primary"
: "hover:bg-muted")
}
>
<div className="flex items-center gap-2 min-w-0">
{branch.is_current && (
<Check className="h-3 w-3 text-primary shrink-0" />
)}
<span className="truncate font-mono text-xs">
{branch.name}
</span>
</div>
{branch.last_commit && (
<span className="text-xs text-muted-foreground font-mono shrink-0">
{branch.last_commit.slice(0, 7)}
</span>
)}
</button>
))}
</div>
</div>
{/* Remote Branches */}
{remoteBranches.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 flex items-center gap-1">
<Cloud className="h-3 w-3" />
Remote ({remoteBranches.length})
</h4>
<div className="space-y-0.5">
{remoteBranches.map((branch) => (
<button
key={branch.name}
onClick={() => onCheckout(branch.name)}
disabled={isCheckingOut}
className="w-full flex items-center justify-between px-2 py-1.5 rounded text-sm text-left transition-colors hover:bg-muted"
>
<span className="truncate font-mono text-xs text-muted-foreground">
{branch.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useCallback, Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useProjects } from "@/hooks/use-projects";
import {
useGitStatus,
useGitLog,
useGitBranches,
useGitDiff,
useGitOperations,
} from "@/hooks/use-git";
import { BranchType } from "@/types/git";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OfflineState } from "@/components/ui/offline-state";
import { GitStatusPanel } from "./git-status-panel";
import { GitBranchPanel } from "./git-branch-panel";
import { GitLogPanel } from "./git-log-panel";
import { GitDiffViewer } from "./git-diff-viewer";
import { GitActionsPanel } from "./git-actions-panel";
import { GitBranch, RefreshCw, FolderGit2 } from "lucide-react";
import { toast } from "sonner";
function GitBrowserContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL
const projectSlug = searchParams.get("project") || "";
const taskId = searchParams.get("task") || "";
// Fetch projects
const { data: projects, isLoading: loadingProjects, error: projectsError, refetch: refetchProjects } = useProjects();
// Git hooks - only enabled when project is selected
const { data: status, isLoading: loadingStatus, refetch: refetchStatus } = useGitStatus(projectSlug, taskId, !!projectSlug);
const { data: log, isLoading: loadingLog, refetch: refetchLog } = useGitLog(projectSlug, 20, undefined, !!projectSlug);
const { data: branches, isLoading: loadingBranches, refetch: refetchBranches } = useGitBranches(projectSlug, true, !!projectSlug);
const { data: stagedDiff, isLoading: loadingStagedDiff } = useGitDiff(projectSlug, true, undefined, !!projectSlug);
const { data: unstagedDiff, isLoading: loadingUnstagedDiff } = useGitDiff(projectSlug, false, undefined, !!projectSlug);
// Git operations
const { commit, push, createBranch, checkout, createPR } = useGitOperations();
// Update URL params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/git?${query}` : "/git");
},
[router, searchParams]
);
const handleProjectChange = useCallback(
(slug: string) => {
updateParams({ project: slug || null, task: null });
},
[updateParams]
);
const handleRefresh = () => {
refetchStatus();
refetchLog();
refetchBranches();
};
// Operation handlers
// Note: agent_id is "ceo" because this panel is used by the CEO
const handleCheckout = async (branch: string) => {
try {
await checkout.mutateAsync({
project_slug: projectSlug,
branch,
agent_id: "ceo",
});
toast.success(`Checked out ${branch}`);
} catch {
toast.error("Failed to checkout branch");
}
};
const handleCreateBranch = async (branchType: BranchType, branchTaskId: string) => {
try {
const result = await createBranch.mutateAsync({
project_slug: projectSlug,
task_id: branchTaskId,
branch_type: branchType,
agent_id: "ceo",
});
toast.success(`Created branch ${result.branch_name}`);
} catch {
toast.error("Failed to create branch");
}
};
const handleCommit = async (message: string) => {
try {
const result = await commit.mutateAsync({
project_slug: projectSlug,
message,
task_id: taskId || "manual",
agent_id: "ceo",
});
toast.success(`Committed: ${result.commit_hash.slice(0, 7)}`);
} catch {
toast.error("Failed to commit");
}
};
const handlePush = async (force?: boolean) => {
try {
const result = await push.mutateAsync({
project_slug: projectSlug,
task_id: taskId || "manual",
agent_id: "ceo",
force,
});
toast.success(`Pushed ${result.commits_pushed} commits to ${result.branch}`);
} catch {
toast.error("Failed to push");
}
};
const handleCreatePR = async (title: string, body: string) => {
try {
const result = await createPR.mutateAsync({
project_slug: projectSlug,
task_id: taskId || "manual",
title,
body,
agent_id: "ceo",
});
toast.success(
<span>
Created PR #{result.pr_number}:{" "}
<a href={result.pr_url} target="_blank" rel="noopener noreferrer" className="underline">
View
</a>
</span>
);
} catch {
toast.error("Failed to create PR");
}
};
// Check offline
const isOffline = projectsError && (
projectsError.message?.includes("Network Error") ||
(projectsError as { code?: string })?.code === "ERR_NETWORK"
);
if (isOffline) {
return (
<OfflineState
title="Cannot Connect to Git Service"
description="Start the RoboCo orchestrator to access git operations."
onRetry={() => refetchProjects()}
/>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Git Operations</h1>
<p className="text-muted-foreground">
Manage repositories, branches, and commits
</p>
</div>
<div className="flex items-center gap-2">
{/* Project Selector */}
<Select value={projectSlug} onValueChange={handleProjectChange}>
<SelectTrigger className="w-64">
<FolderGit2 className="h-4 w-4 mr-2" />
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{loadingProjects ? (
<div className="p-2">
<Skeleton className="h-8 w-full" />
</div>
) : (
projects?.map((p) => (
<SelectItem key={p.id} value={p.slug}>
{p.name}
</SelectItem>
))
)}
</SelectContent>
</Select>
{projectSlug && (
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
)}
</div>
</div>
{/* No Project Selected */}
{!projectSlug && (
<Card>
<CardContent className="py-16 text-center">
<GitBranch className="h-16 w-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Select a Project</h3>
<p className="text-sm text-muted-foreground">
Choose a project from the dropdown to view git status and perform operations
</p>
</CardContent>
</Card>
)}
{/* Git Dashboard */}
{projectSlug && (
<div className="grid grid-cols-12 gap-6">
{/* Left Column - Status & Actions */}
<div className="col-span-12 lg:col-span-3 space-y-4">
<GitStatusPanel status={status} isLoading={loadingStatus} />
<GitActionsPanel
status={status}
projectSlug={projectSlug}
taskId={taskId}
agentId="pm"
onCommit={handleCommit}
onPush={handlePush}
onCreatePR={handleCreatePR}
isCommitting={commit.isPending}
isPushing={push.isPending}
isCreatingPR={createPR.isPending}
/>
</div>
{/* Middle Column - Branches & Log */}
<div className="col-span-12 lg:col-span-4 space-y-4">
<GitBranchPanel
branches={branches}
isLoading={loadingBranches}
onCheckout={handleCheckout}
onCreateBranch={handleCreateBranch}
isCheckingOut={checkout.isPending}
isCreating={createBranch.isPending}
/>
<GitLogPanel log={log} isLoading={loadingLog} />
</div>
{/* Right Column - Diff Viewer */}
<div className="col-span-12 lg:col-span-5">
<GitDiffViewer
stagedDiff={stagedDiff}
unstagedDiff={unstagedDiff}
isLoadingStaged={loadingStagedDiff}
isLoadingUnstaged={loadingUnstagedDiff}
/>
</div>
</div>
)}
</div>
);
}
// Loading skeleton
function GitBrowserSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
<Skeleton className="h-10 w-64" />
</div>
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-3 space-y-4">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-40 w-full" />
</div>
<div className="col-span-12 lg:col-span-4 space-y-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-80 w-full" />
</div>
<div className="col-span-12 lg:col-span-5">
<Skeleton className="h-96 w-full" />
</div>
</div>
</div>
);
}
// Wrap in Suspense for useSearchParams
export function GitBrowser() {
return (
<Suspense fallback={<GitBrowserSkeleton />}>
<GitBrowserContent />
</Suspense>
);
}
@@ -0,0 +1,121 @@
"use client";
import { GitDiffResponse } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FileCode, FileDiff } from "lucide-react";
interface GitDiffViewerProps {
stagedDiff: GitDiffResponse | undefined;
unstagedDiff: GitDiffResponse | undefined;
isLoadingStaged: boolean;
isLoadingUnstaged: boolean;
}
function DiffContent({ diff, isLoading }: { diff: GitDiffResponse | undefined; isLoading: boolean }) {
if (isLoading) {
return (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
);
}
if (!diff || !diff.diff) {
return (
<div className="p-8 text-center text-muted-foreground">
<FileDiff className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No changes to display</p>
</div>
);
}
// Parse diff and colorize
const lines = diff.diff.split("\n");
return (
<ScrollArea className="h-96">
<pre className="p-4 text-xs font-mono leading-relaxed">
{lines.map((line, i) => {
let className = "";
if (line.startsWith("+") && !line.startsWith("+++")) {
className = "bg-green-500/10 text-green-700 dark:text-green-400";
} else if (line.startsWith("-") && !line.startsWith("---")) {
className = "bg-red-500/10 text-red-700 dark:text-red-400";
} else if (line.startsWith("@@")) {
className = "bg-blue-500/10 text-blue-700 dark:text-blue-400";
} else if (line.startsWith("diff") || line.startsWith("index")) {
className = "text-muted-foreground font-semibold";
}
return (
<div
key={i}
className={`px-2 -mx-2 whitespace-pre ${className}`}
>
{line || " "}
</div>
);
})}
</pre>
</ScrollArea>
);
}
export function GitDiffViewer({
stagedDiff,
unstagedDiff,
isLoadingStaged,
isLoadingUnstaged,
}: GitDiffViewerProps) {
const stagedCount = stagedDiff?.files_changed || 0;
const unstagedCount = unstagedDiff?.files_changed || 0;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<Tabs defaultValue="unstaged">
<div className="px-4 border-b">
<TabsList className="h-9">
<TabsTrigger value="unstaged" className="text-xs gap-1">
Working Directory
{unstagedCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
{unstagedCount}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="staged" className="text-xs gap-1">
Staged
{stagedCount > 0 && (
<Badge variant="secondary" className="h-4 px-1 text-[10px]">
{stagedCount}
</Badge>
)}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="unstaged" className="m-0">
<DiffContent diff={unstagedDiff} isLoading={isLoadingUnstaged} />
</TabsContent>
<TabsContent value="staged" className="m-0">
<DiffContent diff={stagedDiff} isLoading={isLoadingStaged} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { GitLogResponse, CommitInfo } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { GitCommit, User, Calendar } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
interface GitLogPanelProps {
log: GitLogResponse | undefined;
isLoading: boolean;
onSelectCommit?: (commit: CommitInfo) => void;
selectedHash?: string;
}
export function GitLogPanel({
log,
isLoading,
onSelectCommit,
selectedHash,
}: GitLogPanelProps) {
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full shrink-0" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</CardContent>
</Card>
);
}
if (!log || log.commits.length === 0) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<GitCommit className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No commits found</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<GitCommit className="h-4 w-4" />
Commit History
<Badge variant="secondary" className="ml-auto text-xs">
{log.branch}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-80">
<div className="p-4 space-y-0">
{log.commits.map((commit, index) => (
<button
key={commit.hash}
onClick={() => onSelectCommit?.(commit)}
className={
"w-full text-left p-3 rounded-lg transition-colors relative " +
(selectedHash === commit.hash
? "bg-primary/10"
: "hover:bg-muted")
}
>
{/* Timeline line */}
{index < log.commits.length - 1 && (
<div className="absolute left-6 top-10 bottom-0 w-0.5 bg-border" />
)}
<div className="flex gap-3">
{/* Commit dot */}
<div className="relative z-10">
<div
className={
"h-4 w-4 rounded-full border-2 mt-0.5 " +
(index === 0
? "bg-primary border-primary"
: "bg-background border-muted-foreground")
}
/>
</div>
{/* Commit info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug line-clamp-2">
{commit.message}
</p>
<Badge
variant="outline"
className="font-mono text-xs shrink-0"
>
{commit.short_hash}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
{commit.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDistanceToNow(new Date(commit.date), {
addSuffix: true,
})}
</span>
</div>
</div>
</div>
</button>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
@@ -0,0 +1,170 @@
"use client";
import { GitStatusResponse } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
GitBranch,
FileCode,
FilePlus,
FileX,
ArrowUp,
ArrowDown,
CheckCircle,
} from "lucide-react";
interface GitStatusPanelProps {
status: GitStatusResponse | undefined;
isLoading: boolean;
}
export function GitStatusPanel({ status, isLoading }: GitStatusPanelProps) {
if (isLoading) {
return (
<Card>
<CardHeader className="pb-2">
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
);
}
if (!status) {
return (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<GitBranch className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No git status available</p>
</CardContent>
</Card>
);
}
const hasChanges = status.staged_files.length > 0 ||
status.unstaged_files.length > 0 ||
status.untracked_files.length > 0;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center justify-between">
<span className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Repository Status
</span>
{!hasChanges && (
<Badge variant="outline" className="text-green-600">
<CheckCircle className="h-3 w-3 mr-1" />
Clean
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Branch Info */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{status.current_branch}</span>
</div>
{(status.ahead > 0 || status.behind > 0) && (
<div className="flex items-center gap-2">
{status.ahead > 0 && (
<Badge variant="secondary" className="text-xs">
<ArrowUp className="h-3 w-3 mr-1" />
{status.ahead} ahead
</Badge>
)}
{status.behind > 0 && (
<Badge variant="secondary" className="text-xs">
<ArrowDown className="h-3 w-3 mr-1" />
{status.behind} behind
</Badge>
)}
</div>
)}
</div>
{/* File Changes */}
{hasChanges && (
<ScrollArea className="h-48">
<div className="space-y-3">
{/* Staged Files */}
{status.staged_files.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-green-600 uppercase tracking-wider mb-1">
Staged ({status.staged_files.length})
</h4>
<div className="space-y-0.5">
{status.staged_files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-sm py-0.5 px-2 rounded hover:bg-muted"
>
<FileCode className="h-3.5 w-3.5 text-green-600" />
<span className="truncate font-mono text-xs">{file}</span>
</div>
))}
</div>
</div>
)}
{/* Unstaged Files */}
{status.unstaged_files.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-orange-600 uppercase tracking-wider mb-1">
Modified ({status.unstaged_files.length})
</h4>
<div className="space-y-0.5">
{status.unstaged_files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-sm py-0.5 px-2 rounded hover:bg-muted"
>
<FileX className="h-3.5 w-3.5 text-orange-600" />
<span className="truncate font-mono text-xs">{file}</span>
</div>
))}
</div>
</div>
)}
{/* Untracked Files */}
{status.untracked_files.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-blue-600 uppercase tracking-wider mb-1">
Untracked ({status.untracked_files.length})
</h4>
<div className="space-y-0.5">
{status.untracked_files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-sm py-0.5 px-2 rounded hover:bg-muted"
>
<FilePlus className="h-3.5 w-3.5 text-blue-600" />
<span className="truncate font-mono text-xs">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
</ScrollArea>
)}
{/* No Changes */}
{!hasChanges && (
<div className="text-center py-4 text-muted-foreground text-sm">
Working directory is clean
</div>
)}
</CardContent>
</Card>
);
}
+6
View File
@@ -0,0 +1,6 @@
export { GitBrowser } from "./git-browser";
export { GitStatusPanel } from "./git-status-panel";
export { GitBranchPanel } from "./git-branch-panel";
export { GitLogPanel } from "./git-log-panel";
export { GitDiffViewer } from "./git-diff-viewer";
export { GitActionsPanel } from "./git-actions-panel";