mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-07 23:54:22 +01:00
feat: add simple git diff preview modal
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GitCompareIcon,
|
||||
LoaderIcon,
|
||||
MenuIcon,
|
||||
PauseIcon,
|
||||
@@ -19,6 +20,7 @@ import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { useAliveTask } from "../hooks/useAliveTask";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { ConversationList } from "./conversationList/ConversationList";
|
||||
import { DiffModal } from "./diffModal";
|
||||
import { ResumeChat } from "./resumeChat/ResumeChat";
|
||||
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
|
||||
|
||||
@@ -51,6 +53,7 @@ export const SessionPageContent: FC<{
|
||||
const [previousConversationLength, setPreviousConversationLength] =
|
||||
useState(0);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自動スクロール処理
|
||||
@@ -177,6 +180,23 @@ export const SessionPageContent: FC<{
|
||||
getToolResult={getToolResult}
|
||||
/>
|
||||
|
||||
{isRunningTask && (
|
||||
<div className="flex justify-start items-center py-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.1s]"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.2s]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Claude Code is processing...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResumeChat
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
@@ -186,6 +206,22 @@ export const SessionPageContent: FC<{
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Diff Button */}
|
||||
<Button
|
||||
onClick={() => setIsDiffModalOpen(true)}
|
||||
className="fixed bottom-6 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
|
||||
size="lg"
|
||||
>
|
||||
<GitCompareIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
{/* Diff Modal */}
|
||||
<DiffModal
|
||||
projectId={projectId}
|
||||
isOpen={isDiffModalOpen}
|
||||
onOpenChange={setIsDiffModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, GitBranch, Loader2, RefreshCcwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useGitBranches, useGitCommits, useGitDiff } from "../../hooks/useGit";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import type { DiffModalProps, DiffSummary, GitRef } from "./types";
|
||||
|
||||
interface DiffSummaryProps {
|
||||
summary: DiffSummary;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffSummaryComponent: FC<DiffSummaryProps> = ({ summary, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
<span className="font-medium">
|
||||
<span className="hidden sm:inline">
|
||||
{summary.filesChanged} files changed
|
||||
</span>
|
||||
<span className="sm:hidden">{summary.filesChanged} files</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{summary.insertions > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
+{summary.insertions}
|
||||
</span>
|
||||
)}
|
||||
{summary.deletions > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
-{summary.deletions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RefSelectorProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: GitRef["name"]) => void;
|
||||
refs: GitRef[];
|
||||
}
|
||||
|
||||
const RefSelector: FC<RefSelectorProps> = ({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
refs,
|
||||
}) => {
|
||||
const id = useId();
|
||||
const getRefIcon = (type: GitRef["type"]) => {
|
||||
switch (type) {
|
||||
case "branch":
|
||||
return <GitBranch className="h-4 w-4" />;
|
||||
case "commit":
|
||||
return <span className="text-xs">📝</span>;
|
||||
case "working":
|
||||
return <span className="text-xs">🚧</span>;
|
||||
default:
|
||||
return <GitBranch className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="w-full sm:w-80">
|
||||
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent id={id}>
|
||||
{refs.map((ref) => (
|
||||
<SelectItem key={ref.name} value={ref.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getRefIcon(ref.type)}
|
||||
<span>{ref.displayName}</span>
|
||||
{ref.sha && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{ref.sha.substring(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiffModal: FC<DiffModalProps> = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
defaultCompareFrom = "HEAD",
|
||||
defaultCompareTo = "working",
|
||||
}) => {
|
||||
const [compareFrom, setCompareFrom] = useState(defaultCompareFrom);
|
||||
const [compareTo, setCompareTo] = useState(defaultCompareTo);
|
||||
|
||||
// API hooks
|
||||
const { data: branchesData, isLoading: isLoadingBranches } =
|
||||
useGitBranches(projectId);
|
||||
const { data: commitsData, isLoading: isLoadingCommits } =
|
||||
useGitCommits(projectId);
|
||||
const {
|
||||
mutate: getDiff,
|
||||
data: diffData,
|
||||
isPending: isDiffLoading,
|
||||
error: diffError,
|
||||
} = useGitDiff();
|
||||
|
||||
// Transform branches and commits data to GitRef format
|
||||
const gitRefs: GitRef[] =
|
||||
branchesData?.success && branchesData.data
|
||||
? [
|
||||
{
|
||||
name: "working" as const,
|
||||
type: "working" as const,
|
||||
displayName: "Uncommitted changes",
|
||||
},
|
||||
{
|
||||
name: "HEAD" as const,
|
||||
type: "commit" as const,
|
||||
displayName: "HEAD",
|
||||
},
|
||||
...branchesData.data.map((branch) => ({
|
||||
name: `branch:${branch.name}` as const,
|
||||
type: "branch" as const,
|
||||
displayName: branch.name + (branch.current ? " (current)" : ""),
|
||||
sha: branch.commit,
|
||||
})),
|
||||
// Add commits from current branch
|
||||
...(commitsData?.success && commitsData.data
|
||||
? commitsData.data.map((commit) => ({
|
||||
name: `commit:${commit.sha}` as const,
|
||||
type: "commit" as const,
|
||||
displayName: `${commit.message.substring(0, 50)}${commit.message.length > 50 ? "..." : ""}`,
|
||||
sha: commit.sha,
|
||||
}))
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
|
||||
const loadDiff = useCallback(() => {
|
||||
if (compareFrom && compareTo && compareFrom !== compareTo) {
|
||||
getDiff({
|
||||
projectId,
|
||||
fromRef: compareFrom,
|
||||
toRef: compareTo,
|
||||
});
|
||||
}
|
||||
}, [compareFrom, compareTo, getDiff, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && compareFrom && compareTo) {
|
||||
loadDiff();
|
||||
}
|
||||
}, [isOpen, compareFrom, compareTo, loadDiff]);
|
||||
|
||||
const handleCompare = () => {
|
||||
loadDiff();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] overflow-hidden flex flex-col px-2 md:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Changes</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||
<div className="flex flex-col sm:flex-row gap-3 flex-1">
|
||||
<RefSelector
|
||||
label="Compare from"
|
||||
value={compareFrom}
|
||||
onValueChange={setCompareFrom}
|
||||
refs={gitRefs.filter((ref) => ref.name !== "working")}
|
||||
/>
|
||||
<RefSelector
|
||||
label="Compare to"
|
||||
value={compareTo}
|
||||
onValueChange={setCompareTo}
|
||||
refs={gitRefs}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
disabled={
|
||||
isDiffLoading ||
|
||||
isLoadingBranches ||
|
||||
isLoadingCommits ||
|
||||
compareFrom === compareTo
|
||||
}
|
||||
className="sm:self-end w-full sm:w-auto"
|
||||
>
|
||||
{isDiffLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<RefreshCcwIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{diffError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">
|
||||
{diffError.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffData?.success && (
|
||||
<>
|
||||
<DiffSummaryComponent
|
||||
summary={{
|
||||
filesChanged: diffData.data.files.length,
|
||||
insertions: diffData.data.summary.totalAdditions,
|
||||
deletions: diffData.data.summary.totalDeletions,
|
||||
files: diffData.data.diffs.map((diff) => ({
|
||||
filename: diff.file.filePath,
|
||||
oldFilename: diff.file.oldPath,
|
||||
isNew: diff.file.status === "added",
|
||||
isDeleted: diff.file.status === "deleted",
|
||||
isRenamed: diff.file.status === "renamed",
|
||||
isBinary: false,
|
||||
hunks: diff.hunks,
|
||||
linesAdded: diff.file.additions,
|
||||
linesDeleted: diff.file.deletions,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-6">
|
||||
{diffData.data.diffs.map((diff) => (
|
||||
<DiffViewer
|
||||
key={diff.file.filePath}
|
||||
fileDiff={{
|
||||
filename: diff.file.filePath,
|
||||
oldFilename: diff.file.oldPath,
|
||||
isNew: diff.file.status === "added",
|
||||
isDeleted: diff.file.status === "deleted",
|
||||
isRenamed: diff.file.status === "renamed",
|
||||
isBinary: false,
|
||||
hunks: diff.hunks,
|
||||
linesAdded: diff.file.additions,
|
||||
linesDeleted: diff.file.deletions,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDiffLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading diff...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../../../../../../../components/ui/button";
|
||||
import type { DiffHunk, FileDiff } from "./types";
|
||||
|
||||
interface DiffViewerProps {
|
||||
fileDiff: FileDiff;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DiffHunkProps {
|
||||
hunk: DiffHunk;
|
||||
}
|
||||
|
||||
const DiffHunkComponent: FC<DiffHunkProps> = ({ hunk }) => {
|
||||
return (
|
||||
<div className="relative flex overflow-x-auto">
|
||||
{/* 行番号列(固定) */}
|
||||
<div className="flex-shrink-0 sticky left-0 z-10 bg-white dark:bg-gray-900">
|
||||
{/* 旧行番号列 */}
|
||||
<div className="float-left w-10 bg-gray-50 dark:bg-gray-800/50 border-r border-gray-200 dark:border-gray-700">
|
||||
{hunk.lines.map((line, index) => (
|
||||
<div
|
||||
key={`old-${line.oldLineNumber}-${index}`}
|
||||
className="px-2 py-1 text-sm text-gray-400 dark:text-gray-600 font-mono text-right h-[28px]"
|
||||
>
|
||||
{line.type !== "added" &&
|
||||
line.type !== "hunk" &&
|
||||
line.oldLineNumber
|
||||
? line.oldLineNumber
|
||||
: " "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 新行番号列 */}
|
||||
<div className="float-left w-10 bg-gray-50 dark:bg-gray-800/50 border-r border-gray-200 dark:border-gray-700">
|
||||
{hunk.lines.map((line, index) => (
|
||||
<div
|
||||
key={`new-${line.newLineNumber}-${index}`}
|
||||
className="px-2 py-1 text-sm text-gray-400 dark:text-gray-600 font-mono text-right h-[28px]"
|
||||
>
|
||||
{line.type !== "deleted" &&
|
||||
line.type !== "hunk" &&
|
||||
line.newLineNumber
|
||||
? line.newLineNumber
|
||||
: " "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* コンテンツ列(スクロール可能) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{hunk.lines.map((line, index) => (
|
||||
<div
|
||||
key={`content-${line.content}-${line.oldLineNumber}-${line.newLineNumber}-${index}`}
|
||||
className={cn("flex border-l-4", {
|
||||
"bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800/50 border-l-green-400":
|
||||
line.type === "added",
|
||||
"bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800/50 border-l-red-400":
|
||||
line.type === "deleted",
|
||||
"bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800/50 border-l-blue-400":
|
||||
line.type === "hunk",
|
||||
"bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 border-l-transparent":
|
||||
line.type === "unchanged",
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 px-2 py-1">
|
||||
<span className="font-mono text-sm whitespace-pre block">
|
||||
<span
|
||||
className={cn({
|
||||
"text-green-600 dark:text-green-400": line.type === "added",
|
||||
"text-red-600 dark:text-red-400": line.type === "deleted",
|
||||
"text-blue-600 dark:text-blue-400 font-medium":
|
||||
line.type === "hunk",
|
||||
"text-gray-400 dark:text-gray-600":
|
||||
line.type === "unchanged",
|
||||
})}
|
||||
>
|
||||
{line.type === "added"
|
||||
? "+"
|
||||
: line.type === "deleted"
|
||||
? "-"
|
||||
: line.type === "hunk"
|
||||
? ""
|
||||
: " "}
|
||||
</span>
|
||||
{line.content || " "}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileHeaderProps {
|
||||
fileDiff: FileDiff;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const FileHeader: FC<FileHeaderProps> = ({
|
||||
fileDiff,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const getFileStatusIcon = () => {
|
||||
if (fileDiff.isNew)
|
||||
return <span className="text-green-600 dark:text-green-400">A</span>;
|
||||
if (fileDiff.isDeleted)
|
||||
return <span className="text-red-600 dark:text-red-400">D</span>;
|
||||
if (fileDiff.isRenamed)
|
||||
return <span className="text-blue-600 dark:text-blue-400">R</span>;
|
||||
return <span className="text-gray-600 dark:text-gray-400">M</span>;
|
||||
};
|
||||
|
||||
const getFileStatusText = () => {
|
||||
if (fileDiff.isNew) return "added";
|
||||
if (fileDiff.isDeleted) return "deleted";
|
||||
if (fileDiff.isRenamed) return `renamed from ${fileDiff.oldFilename ?? ""}`;
|
||||
return "modified";
|
||||
};
|
||||
|
||||
const handleCopyFilename = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileDiff.filename);
|
||||
toast.success("ファイル名をコピーしました");
|
||||
} catch (err) {
|
||||
console.error("Failed to copy filename:", err);
|
||||
toast.error("ファイル名のコピーに失敗しました");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onToggleCollapse}
|
||||
className="w-full bg-gray-50 dark:bg-gray-800 px-4 py-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors min-h-[4rem]"
|
||||
>
|
||||
<div className="w-full space-y-1">
|
||||
{/* Row 1: icon, status, and stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-mono">
|
||||
{getFileStatusIcon()}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getFileStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{fileDiff.linesAdded > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
+{fileDiff.linesAdded}
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.linesDeleted > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
-{fileDiff.linesDeleted}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: filename with copy button */}
|
||||
<div className="w-full flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-black dark:text-white text-left truncate flex-1 min-w-0">
|
||||
{fileDiff.filename}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleCopyFilename}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 p-1 h-6 w-6 hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<CopyIcon className="w-3 h-3 text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{fileDiff.isBinary && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-left">
|
||||
Binary file (content not shown)
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiffViewer: FC<DiffViewerProps> = ({ fileDiff, className }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
if (fileDiff.isBinary) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-gray-200 dark:border-gray-700 rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FileHeader
|
||||
fileDiff={fileDiff}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||
Binary file cannot be displayed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-gray-200 dark:border-gray-700 rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FileHeader
|
||||
fileDiff={fileDiff}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
{fileDiff.hunks.map((hunk, index) => (
|
||||
<DiffHunkComponent
|
||||
key={`${hunk.oldStart}-${hunk.newStart}-${index}`}
|
||||
hunk={hunk}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// API response types for Git operations
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote?: string;
|
||||
commit: string;
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
}
|
||||
|
||||
export interface GitBranchesResponse {
|
||||
success: true;
|
||||
data: GitBranch[];
|
||||
}
|
||||
|
||||
export interface GitFileInfo {
|
||||
filePath: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "copied";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
oldPath?: string;
|
||||
}
|
||||
|
||||
export interface GitDiffLine {
|
||||
type: "added" | "deleted" | "unchanged" | "hunk";
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface GitDiffHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: GitDiffLine[];
|
||||
}
|
||||
|
||||
export interface GitFileDiff {
|
||||
file: GitFileInfo;
|
||||
hunks: GitDiffHunk[];
|
||||
}
|
||||
|
||||
export interface GitDiffSummary {
|
||||
totalFiles: number;
|
||||
totalAdditions: number;
|
||||
totalDeletions: number;
|
||||
}
|
||||
|
||||
export interface GitDiffResponse {
|
||||
success: true;
|
||||
data: {
|
||||
files: GitFileInfo[];
|
||||
diffs: GitFileDiff[];
|
||||
summary: GitDiffSummary;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code:
|
||||
| "NOT_A_REPOSITORY"
|
||||
| "BRANCH_NOT_FOUND"
|
||||
| "COMMAND_FAILED"
|
||||
| "PARSE_ERROR";
|
||||
message: string;
|
||||
command?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GitApiResponse =
|
||||
| GitBranchesResponse
|
||||
| GitDiffResponse
|
||||
| GitErrorResponse;
|
||||
@@ -0,0 +1,10 @@
|
||||
export { DiffModal } from "./DiffModal";
|
||||
export { DiffViewer } from "./DiffViewer";
|
||||
export type {
|
||||
DiffHunk,
|
||||
DiffLine,
|
||||
DiffModalProps,
|
||||
DiffSummary,
|
||||
FileDiff,
|
||||
GitRef,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface DiffLine {
|
||||
type: "added" | "deleted" | "unchanged" | "hunk" | "context";
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
// oldLines: number;
|
||||
newStart: number;
|
||||
// newLines: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
filename: string;
|
||||
oldFilename?: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
isRenamed: boolean;
|
||||
isBinary: boolean;
|
||||
hunks: DiffHunk[];
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
}
|
||||
|
||||
export interface GitRef {
|
||||
name: `branch:${string}` | `commit:${string}` | `HEAD` | "working";
|
||||
type: "branch" | "commit" | "head" | "working";
|
||||
sha?: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface DiffSummary {
|
||||
filesChanged: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
files: FileDiff[];
|
||||
}
|
||||
|
||||
export interface DiffModalProps {
|
||||
projectId: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultCompareFrom?: string;
|
||||
defaultCompareTo?: string;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { honoClient } from "@/lib/api/client";
|
||||
|
||||
export const useGitBranches = (projectId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["git", "branches", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].git.branches.$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch branches: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useGitCommits = (projectId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["git", "commits", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].git.commits.$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch commits: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useGitDiff = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
projectId,
|
||||
fromRef,
|
||||
toRef,
|
||||
}: {
|
||||
projectId: string;
|
||||
fromRef: string;
|
||||
toRef: string;
|
||||
}) => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].git.diff.$post({
|
||||
param: { projectId },
|
||||
json: { fromRef, toRef },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get diff: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user