mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
I mean, it's at a good place rn...
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user