diff --git a/package.json b/package.json index 6ffb430..b365661 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6badbfa..8ba52a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9b74311..0aa6bf9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ {children} + ); diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx index e60c5eb..c630681 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx @@ -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(null); // 自動スクロール処理 @@ -177,6 +180,23 @@ export const SessionPageContent: FC<{ getToolResult={getToolResult} /> + {isRunningTask && ( +
+
+
+
+
+
+
+
+
+

+ Claude Code is processing... +

+
+
+ )} + + + {/* Fixed Diff Button */} + + + {/* Diff Modal */} + ); }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx new file mode 100644 index 0000000..a122b68 --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx @@ -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 = ({ summary, className }) => { + return ( +
+
+
+ + + + {summary.filesChanged} files changed + + {summary.filesChanged} files + +
+
+ {summary.insertions > 0 && ( + + +{summary.insertions} + + )} + {summary.deletions > 0 && ( + + -{summary.deletions} + + )} +
+
+
+ ); +}; + +interface RefSelectorProps { + label: string; + value: string; + onValueChange: (value: GitRef["name"]) => void; + refs: GitRef[]; +} + +const RefSelector: FC = ({ + label, + value, + onValueChange, + refs, +}) => { + const id = useId(); + const getRefIcon = (type: GitRef["type"]) => { + switch (type) { + case "branch": + return ; + case "commit": + return 📝; + case "working": + return 🚧; + default: + return ; + } + }; + + return ( +
+ + +
+ ); +}; + +export const DiffModal: FC = ({ + 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 ( + + + + Preview Changes + + +
+
+ ref.name !== "working")} + /> + +
+ +
+ + {diffError && ( +
+

+ {diffError.message} +

+
+ )} + + {diffData?.success && ( + <> + ({ + 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, + })), + }} + /> + +
+ {diffData.data.diffs.map((diff) => ( + + ))} +
+ + )} + + {isDiffLoading && ( +
+
+ +

+ Loading diff... +

+
+
+ )} +
+
+ ); +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffViewer.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffViewer.tsx new file mode 100644 index 0000000..227a7a5 --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffViewer.tsx @@ -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 = ({ hunk }) => { + return ( +
+ {/* 行番号列(固定) */} +
+ {/* 旧行番号列 */} +
+ {hunk.lines.map((line, index) => ( +
+ {line.type !== "added" && + line.type !== "hunk" && + line.oldLineNumber + ? line.oldLineNumber + : " "} +
+ ))} +
+ {/* 新行番号列 */} +
+ {hunk.lines.map((line, index) => ( +
+ {line.type !== "deleted" && + line.type !== "hunk" && + line.newLineNumber + ? line.newLineNumber + : " "} +
+ ))} +
+
+ + {/* コンテンツ列(スクロール可能) */} +
+ {hunk.lines.map((line, index) => ( +
+
+ + + {line.type === "added" + ? "+" + : line.type === "deleted" + ? "-" + : line.type === "hunk" + ? "" + : " "} + + {line.content || " "} + +
+
+ ))} +
+
+ ); +}; + +interface FileHeaderProps { + fileDiff: FileDiff; + isCollapsed: boolean; + onToggleCollapse: () => void; +} + +const FileHeader: FC = ({ + fileDiff, + isCollapsed, + onToggleCollapse, +}) => { + const getFileStatusIcon = () => { + if (fileDiff.isNew) + return A; + if (fileDiff.isDeleted) + return D; + if (fileDiff.isRenamed) + return R; + return M; + }; + + 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 ( + + + + {fileDiff.isBinary && ( +
+ Binary file (content not shown) +
+ )} + + ); +}; + +export const DiffViewer: FC = ({ fileDiff, className }) => { + const [isCollapsed, setIsCollapsed] = useState(false); + + const toggleCollapse = () => { + setIsCollapsed(!isCollapsed); + }; + + if (fileDiff.isBinary) { + return ( +
+ + {!isCollapsed && ( +
+ Binary file cannot be displayed +
+ )} +
+ ); + } + + return ( +
+ + {!isCollapsed && ( +
+ {fileDiff.hunks.map((hunk, index) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/api-types.ts b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/api-types.ts new file mode 100644 index 0000000..2841e4c --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/api-types.ts @@ -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; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/index.ts b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/index.ts new file mode 100644 index 0000000..9596f32 --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/index.ts @@ -0,0 +1,10 @@ +export { DiffModal } from "./DiffModal"; +export { DiffViewer } from "./DiffViewer"; +export type { + DiffHunk, + DiffLine, + DiffModalProps, + DiffSummary, + FileDiff, + GitRef, +} from "./types"; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/types.ts b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/types.ts new file mode 100644 index 0000000..fc89126 --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/types.ts @@ -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; +} diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts new file mode 100644 index 0000000..865d3e2 --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts @@ -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(); + }, + }); +}; diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..274e0d9 --- /dev/null +++ b/src/components/ui/select.tsx @@ -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) { + return ; +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return ; +} + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + span]:line-clamp-1", + className, + )} + {...props} + > + {children} + + + + + ); +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ); +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + + {children} + + ); +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +}; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..f96a98d --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -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 ( + + ); +}; + +export { Toaster }; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index dece864..193e68e 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -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 }); diff --git a/src/server/service/git/getBranches.ts b/src/server/service/git/getBranches.ts new file mode 100644 index 0000000..688ed25 --- /dev/null +++ b/src/server/service/git/getBranches.ts @@ -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> { + // Get all branches with verbose information + const result = await executeGitCommand(["branch", "-vv", "--all"], cwd); + + if (!result.success) { + return result as GitResult; + } + + try { + const lines = parseLines(result.data); + const branches: GitBranch[] = []; + const seenBranches = new Set(); + + 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> { + const result = await executeGitCommand(["branch", "--show-current"], cwd); + + if (!result.success) { + return result as GitResult; + } + + 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> { + const result = await executeGitCommand( + ["rev-parse", "--verify", branchName], + cwd, + ); + + return { + success: true, + data: result.success, + }; +} diff --git a/src/server/service/git/getCommits.ts b/src/server/service/git/getCommits.ts new file mode 100644 index 0000000..b430d01 --- /dev/null +++ b/src/server/service/git/getCommits.ts @@ -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> { + // 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; + } + + 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"}`, + }, + }; + } +} diff --git a/src/server/service/git/getDiff.ts b/src/server/service/git/getDiff.ts new file mode 100644 index 0000000..b4510b2 --- /dev/null +++ b/src/server/service/git/getDiff.ts @@ -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, +): 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> => { + 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> { + return getDiff(cwd, baseBranch, targetBranch); +} diff --git a/src/server/service/git/getStatus.ts b/src/server/service/git/getStatus.ts new file mode 100644 index 0000000..6c79d3b --- /dev/null +++ b/src/server/service/git/getStatus.ts @@ -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> { + // Get porcelain status for consistent parsing + const statusResult = await executeGitCommand( + ["status", "--porcelain=v1", "-b"], + cwd, + ); + + if (!statusResult.success) { + return statusResult as GitResult; + } + + 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> { + const statusResult = await getStatus(cwd); + + if (!statusResult.success) { + return statusResult as GitResult; + } + + 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> { + const statusResult = await getStatus(cwd); + + if (!statusResult.success) { + return statusResult as GitResult; + } + + const { staged, unstaged, untracked } = statusResult.data; + const isClean = + staged.length === 0 && unstaged.length === 0 && untracked.length === 0; + + return { + success: true, + data: isClean, + }; +} diff --git a/src/server/service/git/index.ts b/src/server/service/git/index.ts new file mode 100644 index 0000000..da9c24b --- /dev/null +++ b/src/server/service/git/index.ts @@ -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"; diff --git a/src/server/service/git/types.ts b/src/server/service/git/types.ts new file mode 100644 index 0000000..8d478ae --- /dev/null +++ b/src/server/service/git/types.ts @@ -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 = + | { + success: true; + data: T; + } + | { + success: false; + error: GitError; + }; diff --git a/src/server/service/git/utils.ts b/src/server/service/git/utils.ts new file mode 100644 index 0000000..c332a6c --- /dev/null +++ b/src/server/service/git/utils.ts @@ -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> { + 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"; + } +}