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

@@ -44,6 +44,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.85.5",
@@ -53,6 +54,8 @@
"jotai": "^2.13.1",
"lucide-react": "^0.542.0",
"next": "15.5.2",
"next-themes": "^0.4.6",
"parse-git-diff": "^0.0.19",
"prexit": "^2.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -60,6 +63,7 @@
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.6",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"ulid": "^3.0.1",
"zod": "^4.1.5"

108
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@radix-ui/react-hover-card':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.12)(react@19.1.1)
@@ -56,6 +59,12 @@ importers:
next:
specifier: 15.5.2
version: 15.5.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
parse-git-diff:
specifier: ^0.0.19
version: 0.0.19
prexit:
specifier: ^2.3.0
version: 2.3.0
@@ -77,6 +86,9 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -842,6 +854,9 @@ packages:
resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==}
engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -1072,6 +1087,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -1175,6 +1203,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -2517,6 +2558,12 @@ packages:
resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.5.2:
resolution: {integrity: sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -2618,6 +2665,9 @@ packages:
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
parse-git-diff@0.0.19:
resolution: {integrity: sha512-oh3giwKzsPlOhekiDDyd/pfFKn04IZoTjEThquhfKigwiUHymiP/Tp6AN5nGIwXQdWuBTQvz9AaRdN5TBsJ8MA==}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
@@ -2883,6 +2933,12 @@ packages:
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -3800,6 +3856,8 @@ snapshots:
'@phun-ky/typeof@1.2.8': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
@@ -4026,6 +4084,35 @@ snapshots:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
aria-hidden: 1.2.6
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
@@ -4110,6 +4197,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.12
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/rect@1.1.1': {}
'@rollup/rollup-android-arm-eabi@4.49.0':
@@ -5567,6 +5663,11 @@ snapshots:
dependencies:
type-fest: 2.19.0
next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
next@15.5.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
'@next/env': 15.5.2
@@ -5714,6 +5815,8 @@ snapshots:
is-decimal: 2.0.1
is-hexadecimal: 2.0.1
parse-git-diff@0.0.19: {}
parse-ms@4.0.0: {}
parse-path@7.1.0:
@@ -6078,6 +6181,11 @@ snapshots:
ip-address: 10.0.1
smart-buffer: 4.2.0
sonner@2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
source-map-js@1.2.1: {}
source-map-support@0.5.21:

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "../components/ui/sonner";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import { ServerEventsProvider } from "./components/ServerEventsProvider";
@@ -47,6 +48,7 @@ export default async function RootLayout({
<ServerEventsProvider>{children}</ServerEventsProvider>
</QueryClientProviderWrapper>
</RootErrorBoundary>
<Toaster position="top-right" />
</body>
</html>
);

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

View File

@@ -0,0 +1,175 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value {...props} />;
}
function SelectTrigger({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,25 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -12,6 +12,9 @@ import { getEventBus } from "../service/events/EventBus";
import { getFileWatcher } from "../service/events/fileWatcher";
import { sseEventResponse } from "../service/events/sseEventResponse";
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
import { getBranches } from "../service/git/getBranches";
import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList";
import { getProject } from "../service/project/getProject";
import { getProjects } from "../service/project/getProjects";
@@ -202,6 +205,81 @@ export const routes = (app: HonoAppType) => {
});
})
.get("/projects/:projectId/git/branches", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getBranches(project.meta.projectPath);
return c.json(result);
} catch (error) {
console.error("Get branches error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get branches" }, 500);
}
})
.get("/projects/:projectId/git/commits", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getCommits(project.meta.projectPath);
return c.json(result);
} catch (error) {
console.error("Get commits error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get commits" }, 500);
}
})
.post(
"/projects/:projectId/git/diff",
zValidator(
"json",
z.object({
fromRef: z.string().min(1, "fromRef is required"),
toRef: z.string().min(1, "toRef is required"),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { fromRef, toRef } = c.req.valid("json");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getDiff(
project.meta.projectPath,
fromRef,
toRef,
);
return c.json(result);
} catch (error) {
console.error("Get diff error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get diff" }, 500);
}
},
)
.get("/mcp/list", async (c) => {
const { servers } = await getMcpList();
return c.json({ servers });

View File

@@ -0,0 +1,130 @@
import type { GitBranch, GitResult } from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Get all branches (local and remote) in the repository
*/
export async function getBranches(
cwd: string,
): Promise<GitResult<GitBranch[]>> {
// Get all branches with verbose information
const result = await executeGitCommand(["branch", "-vv", "--all"], cwd);
if (!result.success) {
return result as GitResult<GitBranch[]>;
}
try {
const lines = parseLines(result.data);
const branches: GitBranch[] = [];
const seenBranches = new Set<string>();
for (const line of lines) {
// Parse branch line format: " main abc1234 [origin/main: ahead 1] Commit message"
const match = line.match(
/^(\*?\s*)([^\s]+)\s+([a-f0-9]+)(?:\s+\[([^\]]+)\])?\s*(.*)/,
);
if (!match) continue;
const [, prefix, name, commit, tracking] = match;
if (!prefix || !name || !commit) continue;
const current = prefix.includes("*");
// Skip remote tracking branches if we already have the local branch
const cleanName = name.replace("remotes/origin/", "");
if (name.startsWith("remotes/origin/") && seenBranches.has(cleanName)) {
continue;
}
// Parse tracking information
let remote: string | undefined;
let ahead: number | undefined;
let behind: number | undefined;
if (tracking) {
const remoteMatch = tracking.match(/^([^:]+)/);
if (remoteMatch?.[1]) {
remote = remoteMatch[1];
}
const aheadMatch = tracking.match(/ahead (\d+)/);
const behindMatch = tracking.match(/behind (\d+)/);
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
}
branches.push({
name: cleanName,
current,
remote,
commit,
ahead,
behind,
});
seenBranches.add(cleanName);
}
return {
success: true,
data: branches,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse branch information: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get current branch name
*/
export async function getCurrentBranch(
cwd: string,
): Promise<GitResult<string>> {
const result = await executeGitCommand(["branch", "--show-current"], cwd);
if (!result.success) {
return result as GitResult<string>;
}
const currentBranch = result.data.trim();
if (!currentBranch) {
return {
success: false,
error: {
code: "COMMAND_FAILED",
message: "Could not determine current branch (possibly detached HEAD)",
},
};
}
return {
success: true,
data: currentBranch,
};
}
/**
* Check if a branch exists
*/
export async function branchExists(
cwd: string,
branchName: string,
): Promise<GitResult<boolean>> {
const result = await executeGitCommand(
["rev-parse", "--verify", branchName],
cwd,
);
return {
success: true,
data: result.success,
};
}

View File

@@ -0,0 +1,51 @@
import type { GitCommit, GitResult } from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Get the last 20 commits from the current branch
*/
export async function getCommits(cwd: string): Promise<GitResult<GitCommit[]>> {
// Get commits with oneline format and limit to 20
const result = await executeGitCommand(
["log", "--oneline", "-n", "20", "--format=%H|%s|%an|%ad", "--date=iso"],
cwd,
);
if (!result.success) {
return result as GitResult<GitCommit[]>;
}
try {
const lines = parseLines(result.data);
const commits: GitCommit[] = [];
for (const line of lines) {
// Parse commit line format: "sha|message|author|date"
const parts = line.split("|");
if (parts.length < 4) continue;
const [sha, message, author, date] = parts;
if (!sha || !message || !author || !date) continue;
commits.push({
sha: sha.trim(),
message: message.trim(),
author: author.trim(),
date: date.trim(),
});
}
return {
success: true,
data: commits,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse commit information: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}

View File

@@ -0,0 +1,283 @@
import parseGitDiff, {
type AnyChunk,
type AnyFileChange,
} from "parse-git-diff";
import type {
GitComparisonResult,
GitDiff,
GitDiffFile,
GitDiffHunk,
GitDiffLine,
GitResult,
} from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Convert parse-git-diff file change to GitDiffFile
*/
function convertToGitDiffFile(
fileChange: AnyFileChange,
fileStats: Map<string, { additions: number; deletions: number }>,
): GitDiffFile {
let filePath: string;
let status: GitDiffFile["status"];
let oldPath: string | undefined;
switch (fileChange.type) {
case "AddedFile":
filePath = fileChange.path;
status = "added";
break;
case "DeletedFile":
filePath = fileChange.path;
status = "deleted";
break;
case "RenamedFile":
filePath = fileChange.pathAfter;
oldPath = fileChange.pathBefore;
status = "renamed";
break;
case "ChangedFile":
filePath = fileChange.path;
status = "modified";
break;
default:
// Fallback for any unknown types
filePath = "";
status = "modified";
}
// Get stats from numstat
const stats = fileStats.get(filePath) ||
fileStats.get(oldPath || "") || { additions: 0, deletions: 0 };
return {
filePath,
status,
additions: stats.additions,
deletions: stats.deletions,
oldPath,
};
}
/**
* Convert parse-git-diff chunk to GitDiffHunk
*/
function convertToGitDiffHunk(chunk: AnyChunk): GitDiffHunk {
if (chunk.type !== "Chunk") {
// For non-standard chunks, return empty hunk
return {
oldStart: 0,
oldCount: 0,
newStart: 0,
newCount: 0,
header: "",
lines: [],
};
}
const lines: GitDiffLine[] = [];
for (const change of chunk.changes) {
let line: GitDiffLine;
switch (change.type) {
case "AddedLine":
line = {
type: "added",
content: change.content,
newLineNumber: change.lineAfter,
};
break;
case "DeletedLine":
line = {
type: "deleted",
content: change.content,
oldLineNumber: change.lineBefore,
};
break;
case "UnchangedLine":
line = {
type: "context",
content: change.content,
oldLineNumber: change.lineBefore,
newLineNumber: change.lineAfter,
};
break;
case "MessageLine":
// This is likely a hunk header or context line
line = {
type: "context",
content: change.content,
};
break;
default:
// Fallback for unknown line types
line = {
type: "context",
content: "",
};
}
lines.push(line);
}
return {
oldStart: chunk.fromFileRange.start,
oldCount: chunk.fromFileRange.lines,
newStart: chunk.toFileRange.start,
newCount: chunk.toFileRange.lines,
header: `@@ -${chunk.fromFileRange.start},${chunk.fromFileRange.lines} +${chunk.toFileRange.start},${chunk.toFileRange.lines} @@${chunk.context ? ` ${chunk.context}` : ""}`,
lines,
};
}
const extractRef = (refText: string) => {
const [group, ref] = refText.split(":");
if (group === undefined || ref === undefined) {
if (refText === "HEAD") {
return "HEAD";
}
if (refText === "working") {
return undefined;
}
throw new Error(`Invalid ref text: ${refText}`);
}
return ref;
};
/**
* Get Git diff between two references (branches, commits, tags)
*/
export const getDiff = async (
cwd: string,
fromRefText: string,
toRefText: string,
): Promise<GitResult<GitComparisonResult>> => {
const fromRef = extractRef(fromRefText);
const toRef = extractRef(toRefText);
if (fromRef === toRef) {
return {
success: true,
data: {
diffs: [],
files: [],
summary: {
totalFiles: 0,
totalAdditions: 0,
totalDeletions: 0,
},
},
};
}
if (fromRef === undefined) {
throw new Error(`Invalid fromRef: ${fromRefText}`);
}
const commandArgs = toRef === undefined ? [fromRef] : [fromRef, toRef];
// Get diff with numstat for file statistics
const numstatResult = await executeGitCommand(
["diff", "--numstat", ...commandArgs],
cwd,
);
if (!numstatResult.success) {
return numstatResult;
}
// Get diff with full content
const diffResult = await executeGitCommand(
["diff", "--unified=5", ...commandArgs],
cwd,
);
if (!diffResult.success) {
return diffResult;
}
try {
// Parse numstat output to get file statistics
const fileStats = new Map<
string,
{ additions: number; deletions: number }
>();
const numstatLines = parseLines(numstatResult.data);
for (const line of numstatLines) {
const parts = line.split("\t");
if (parts.length >= 3 && parts[0] && parts[1] && parts[2]) {
const additions = parts[0] === "-" ? 0 : parseInt(parts[0], 10);
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10);
const filePath = parts[2];
fileStats.set(filePath, { additions, deletions });
}
}
// Parse diff output using parse-git-diff
const parsedDiff = parseGitDiff(diffResult.data);
const files: GitDiffFile[] = [];
const diffs: GitDiff[] = [];
let totalAdditions = 0;
let totalDeletions = 0;
for (const fileChange of parsedDiff.files) {
// Convert to GitDiffFile format
const file = convertToGitDiffFile(fileChange, fileStats);
files.push(file);
// Convert chunks to hunks
const hunks: GitDiffHunk[] = [];
for (const chunk of fileChange.chunks) {
const hunk = convertToGitDiffHunk(chunk);
hunks.push(hunk);
}
diffs.push({
file,
hunks,
});
totalAdditions += file.additions;
totalDeletions += file.deletions;
}
return {
success: true,
data: {
files,
diffs,
summary: {
totalFiles: files.length,
totalAdditions,
totalDeletions,
},
},
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse diff: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
};
/**
* Compare between two branches (shorthand for getDiff)
*/
export async function compareBranches(
cwd: string,
baseBranch: string,
targetBranch: string,
): Promise<GitResult<GitComparisonResult>> {
return getDiff(cwd, baseBranch, targetBranch);
}

View File

@@ -0,0 +1,172 @@
import type { GitDiffFile, GitResult, GitStatus } from "./types";
import {
executeGitCommand,
getFileStatus,
parseLines,
parseStatusLine,
} from "./utils";
/**
* Get git status information including staged, unstaged, and untracked files
*/
export async function getStatus(cwd: string): Promise<GitResult<GitStatus>> {
// Get porcelain status for consistent parsing
const statusResult = await executeGitCommand(
["status", "--porcelain=v1", "-b"],
cwd,
);
if (!statusResult.success) {
return statusResult as GitResult<GitStatus>;
}
try {
const lines = parseLines(statusResult.data);
const staged: GitDiffFile[] = [];
const unstaged: GitDiffFile[] = [];
const untracked: string[] = [];
const conflicted: string[] = [];
let branch = "HEAD";
let ahead = 0;
let behind = 0;
for (const line of lines) {
// Parse branch line
if (line.startsWith("##")) {
const branchMatch = line.match(/^##\s+(.+?)(?:\.\.\.|$)/);
if (branchMatch?.[1]) {
branch = branchMatch[1];
}
const aheadMatch = line.match(/ahead (\d+)/);
const behindMatch = line.match(/behind (\d+)/);
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
continue;
}
// Parse file status lines
const { status, filePath, oldPath } = parseStatusLine(line);
const indexStatus = status[0]; // Staged changes
const workTreeStatus = status[1]; // Unstaged changes
// Handle conflicts (both index and work tree have changes)
if (
indexStatus === "U" ||
workTreeStatus === "U" ||
(indexStatus !== " " &&
indexStatus !== "?" &&
workTreeStatus !== " " &&
workTreeStatus !== "?")
) {
conflicted.push(filePath);
continue;
}
// Handle staged changes (index status)
if (indexStatus !== " " && indexStatus !== "?") {
staged.push({
filePath,
status: getFileStatus(`${indexStatus} `),
additions: 0, // We don't have line counts from porcelain status
deletions: 0,
oldPath,
});
}
// Handle unstaged changes (work tree status)
if (workTreeStatus !== " " && workTreeStatus !== "?") {
if (workTreeStatus === "?") {
untracked.push(filePath);
} else {
unstaged.push({
filePath,
status: getFileStatus(` ${workTreeStatus}`),
additions: 0,
deletions: 0,
oldPath,
});
}
}
// Handle untracked files
if (status === "??") {
untracked.push(filePath);
}
}
return {
success: true,
data: {
branch,
ahead,
behind,
staged,
unstaged,
untracked,
conflicted,
},
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse git status: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get uncommitted changes (both staged and unstaged)
*/
export async function getUncommittedChanges(
cwd: string,
): Promise<GitResult<GitDiffFile[]>> {
const statusResult = await getStatus(cwd);
if (!statusResult.success) {
return statusResult as GitResult<GitDiffFile[]>;
}
const { staged, unstaged } = statusResult.data;
const allChanges = [...staged, ...unstaged];
// Remove duplicates (files that are both staged and unstaged)
const uniqueChanges = allChanges.reduce((acc: GitDiffFile[], change) => {
const existing = acc.find((c) => c.filePath === change.filePath);
if (!existing) {
acc.push(change);
}
return acc;
}, [] as GitDiffFile[]);
return {
success: true,
data: uniqueChanges,
};
}
/**
* Check if the working directory is clean (no uncommitted changes)
*/
export async function isWorkingDirectoryClean(
cwd: string,
): Promise<GitResult<boolean>> {
const statusResult = await getStatus(cwd);
if (!statusResult.success) {
return statusResult as GitResult<boolean>;
}
const { staged, unstaged, untracked } = statusResult.data;
const isClean =
staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
return {
success: true,
data: isClean,
};
}

View File

@@ -0,0 +1,32 @@
// Git service utilities for claude-code-viewer
// Provides comprehensive Git operations including branch management, diff generation, and status checking
export * from "./getBranches";
// Re-export main functions for convenience
export { branchExists, getBranches, getCurrentBranch } from "./getBranches";
export * from "./getCommits";
export { getCommits } from "./getCommits";
export * from "./getDiff";
export { compareBranches, getDiff } from "./getDiff";
export * from "./getStatus";
export {
getStatus,
getUncommittedChanges,
isWorkingDirectoryClean,
} from "./getStatus";
// Types re-export for convenience
export type {
GitBranch,
GitCommit,
GitComparisonResult,
GitDiff,
GitDiffFile,
GitDiffHunk,
GitDiffLine,
GitError,
GitResult,
GitStatus,
} from "./types";
export * from "./types";
export * from "./utils";
export { executeGitCommand, isGitRepository } from "./utils";

View File

@@ -0,0 +1,85 @@
export type GitBranch = {
name: string;
current: boolean;
remote?: string;
commit: string;
ahead?: number;
behind?: number;
};
export type GitCommit = {
sha: string;
message: string;
author: string;
date: string;
};
export type GitDiffFile = {
filePath: string;
status: "added" | "modified" | "deleted" | "renamed" | "copied";
additions: number;
deletions: number;
oldPath?: string; // For renamed files
};
export type GitDiffHunk = {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
header: string;
lines: GitDiffLine[];
};
export type GitDiffLine = {
type: "context" | "added" | "deleted";
content: string;
oldLineNumber?: number;
newLineNumber?: number;
};
export type GitDiff = {
file: GitDiffFile;
hunks: GitDiffHunk[];
};
export type GitComparisonResult = {
files: GitDiffFile[];
diffs: GitDiff[];
summary: {
totalFiles: number;
totalAdditions: number;
totalDeletions: number;
};
};
export type GitStatus = {
branch: string;
ahead: number;
behind: number;
staged: GitDiffFile[];
unstaged: GitDiffFile[];
untracked: string[];
conflicted: string[];
};
export type GitError = {
code:
| "NOT_A_REPOSITORY"
| "BRANCH_NOT_FOUND"
| "COMMAND_FAILED"
| "PARSE_ERROR";
message: string;
command?: string;
stderr?: string;
};
export type GitResult<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: GitError;
};

View File

@@ -0,0 +1,142 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { promisify } from "node:util";
import type { GitError, GitResult } from "./types";
const execFileAsync = promisify(execFile);
/**
* Execute a git command in the specified directory
*/
export async function executeGitCommand(
args: string[],
cwd: string,
): Promise<GitResult<string>> {
try {
// Check if the directory exists and contains a git repository
if (!existsSync(cwd)) {
return {
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${cwd}`,
command: `git ${args.join(" ")}`,
},
};
}
if (!existsSync(resolve(cwd, ".git"))) {
return {
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${cwd}`,
command: `git ${args.join(" ")}`,
},
};
}
const { stdout } = await execFileAsync("git", args, {
cwd,
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs
timeout: 30000, // 30 second timeout
});
return {
success: true,
data: stdout,
};
} catch (error: unknown) {
const err = error as { code?: string; stderr?: string; message?: string };
let errorCode: GitError["code"] = "COMMAND_FAILED";
let errorMessage = err.message || "Unknown git command error";
if (err.stderr) {
if (err.stderr.includes("not a git repository")) {
errorCode = "NOT_A_REPOSITORY";
errorMessage = "Not a git repository";
} else if (err.stderr.includes("unknown revision")) {
errorCode = "BRANCH_NOT_FOUND";
errorMessage = "Branch or commit not found";
}
}
return {
success: false,
error: {
code: errorCode,
message: errorMessage,
command: `git ${args.join(" ")}`,
stderr: err.stderr,
},
};
}
}
/**
* Check if a directory is a git repository
*/
export function isGitRepository(cwd: string): boolean {
return existsSync(cwd) && existsSync(resolve(cwd, ".git"));
}
/**
* Safely parse git command output that might be empty
*/
export function parseLines(output: string): string[] {
return output
.trim()
.split("\n")
.filter((line) => line.trim() !== "");
}
/**
* Parse git status porcelain output
*/
export function parseStatusLine(line: string): {
status: string;
filePath: string;
oldPath?: string;
} {
const status = line.slice(0, 2);
const filePath = line.slice(3);
// Handle renamed files (R old -> new)
if (status.startsWith("R")) {
const parts = filePath.split(" -> ");
return {
status,
filePath: parts[1] || filePath,
oldPath: parts[0],
};
}
return { status, filePath };
}
/**
* Convert git status code to readable status
*/
export function getFileStatus(
statusCode: string,
): "added" | "modified" | "deleted" | "renamed" | "copied" {
const firstChar = statusCode[0];
switch (firstChar) {
case "A":
return "added";
case "M":
return "modified";
case "D":
return "deleted";
case "R":
return "renamed";
case "C":
return "copied";
default:
return "modified";
}
}