feat: add simple git diff preview modal

This commit is contained in:
d-kimsuon
2025-09-07 14:21:42 +09:00
parent 7fafb183f0
commit c5688310b6
20 changed files with 2085 additions and 0 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -0,0 +1,10 @@
export { DiffModal } from "./DiffModal";
export { DiffViewer } from "./DiffViewer";
export type {
DiffHunk,
DiffLine,
DiffModalProps,
DiffSummary,
FileDiff,
GitRef,
} from "./types";

View File

@@ -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;
}

View File

@@ -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();
},
});
};