diff --git a/.gitignore b/.gitignore index 4182e47..7e04d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ dist/* # claude code .claude/settings.local.json + +# speckit +specs diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx index 623fd35..c29bab8 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx @@ -3,7 +3,9 @@ import { FileText, GitBranch, Loader2, RefreshCcwIcon } from "lucide-react"; import type { FC } from "react"; import { useCallback, useEffect, useId, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Select, @@ -12,8 +14,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; -import { useGitBranches, useGitCommits, useGitDiff } from "../../hooks/useGit"; +import { + useCommitAndPush, + useCommitFiles, + useGitBranches, + useGitCommits, + useGitDiff, + usePushCommits, +} from "../../hooks/useGit"; import { DiffViewer } from "./DiffViewer"; import type { DiffModalProps, DiffSummary, GitRef } from "./types"; @@ -123,9 +133,18 @@ export const DiffModal: FC = ({ defaultCompareFrom = "HEAD", defaultCompareTo = "working", }) => { + const commitMessageId = useId(); const [compareFrom, setCompareFrom] = useState(defaultCompareFrom); const [compareTo, setCompareTo] = useState(defaultCompareTo); + // File selection state (FR-002: all selected by default) + const [selectedFiles, setSelectedFiles] = useState>( + new Map(), + ); + + // Commit message state + const [commitMessage, setCommitMessage] = useState(""); + // API hooks const { data: branchesData, isLoading: isLoadingBranches } = useGitBranches(projectId); @@ -137,6 +156,9 @@ export const DiffModal: FC = ({ isPending: isDiffLoading, error: diffError, } = useGitDiff(); + const commitMutation = useCommitFiles(projectId); + const pushMutation = usePushCommits(projectId); + const commitAndPushMutation = useCommitAndPush(projectId); // Transform branches and commits data to GitRef format const gitRefs: GitRef[] = @@ -180,6 +202,17 @@ export const DiffModal: FC = ({ } }, [compareFrom, compareTo, getDiff, projectId]); + // Initialize file selection when diff data changes (FR-002: all selected by default) + useEffect(() => { + if (diffData?.success && diffData.data.files.length > 0) { + const initialSelection = new Map( + diffData.data.files.map((file) => [file.filePath, true]), + ); + console.log("[DiffModal] Initializing file selection:", initialSelection); + setSelectedFiles(initialSelection); + } + }, [diffData]); + useEffect(() => { if (isOpen && compareFrom && compareTo) { loadDiff(); @@ -190,6 +223,147 @@ export const DiffModal: FC = ({ loadDiff(); }; + // File selection handlers + const handleToggleFile = (filePath: string) => { + setSelectedFiles((prev) => { + const next = new Map(prev); + const newValue = !prev.get(filePath); + next.set(filePath, newValue); + return next; + }); + }; + + const handleSelectAll = () => { + if (diffData?.success && diffData.data.files.length > 0) { + setSelectedFiles( + new Map(diffData.data.files.map((file) => [file.filePath, true])), + ); + } + }; + + const handleDeselectAll = () => { + if (diffData?.success && diffData.data.files.length > 0) { + setSelectedFiles( + new Map(diffData.data.files.map((file) => [file.filePath, false])), + ); + } + }; + + // Commit handler + const handleCommit = async () => { + const selected = Array.from(selectedFiles.entries()) + .filter(([_, isSelected]) => isSelected) + .map(([path]) => path); + + console.log( + "[DiffModal.handleCommit] Selected files state:", + selectedFiles, + ); + console.log("[DiffModal.handleCommit] Filtered selected files:", selected); + console.log( + "[DiffModal.handleCommit] Total files:", + diffData?.success ? diffData.data.files.length : 0, + ); + + try { + const result = await commitMutation.mutateAsync({ + files: selected, + message: commitMessage, + }); + + console.log("[DiffModal.handleCommit] Commit result:", result); + + if (result.success) { + toast.success( + `Committed ${result.filesCommitted} files (${result.commitSha.slice(0, 7)})`, + ); + setCommitMessage(""); // Reset message + // Reload diff to show updated state + loadDiff(); + } else { + toast.error(result.error, { description: result.details }); + } + } catch (_error) { + console.error("[DiffModal.handleCommit] Error:", _error); + toast.error("Failed to commit"); + } + }; + + // Push handler + const handlePush = async () => { + try { + const result = await pushMutation.mutateAsync(); + + console.log("[DiffModal.handlePush] Push result:", result); + + if (result.success) { + toast.success(`Pushed to ${result.remote}/${result.branch}`); + } else { + toast.error(result.error, { description: result.details }); + } + } catch (_error) { + console.error("[DiffModal.handlePush] Error:", _error); + toast.error("Failed to push"); + } + }; + + // Commit and Push handler + const handleCommitAndPush = async () => { + const selected = Array.from(selectedFiles.entries()) + .filter(([_, isSelected]) => isSelected) + .map(([path]) => path); + + console.log("[DiffModal.handleCommitAndPush] Selected files:", selected); + + try { + const result = await commitAndPushMutation.mutateAsync({ + files: selected, + message: commitMessage, + }); + + console.log("[DiffModal.handleCommitAndPush] Result:", result); + + if (result.success) { + toast.success(`Committed and pushed (${result.commitSha.slice(0, 7)})`); + setCommitMessage(""); // Reset message + // Reload diff to show updated state + loadDiff(); + } else if ( + result.success === false && + "commitSucceeded" in result && + result.commitSucceeded + ) { + // Partial failure: commit succeeded, push failed + toast.warning( + `Committed (${result.commitSha?.slice(0, 7)}), but push failed: ${result.error}`, + { + action: { + label: "Retry Push", + onClick: handlePush, + }, + }, + ); + setCommitMessage(""); // Reset message since commit succeeded + // Reload diff to show updated state (commit succeeded) + loadDiff(); + } else { + toast.error(result.error, { description: result.details }); + } + } catch (_error) { + console.error("[DiffModal.handleCommitAndPush] Error:", _error); + toast.error("Failed to commit and push"); + } + }; + + // Validation + const selectedCount = Array.from(selectedFiles.values()).filter( + Boolean, + ).length; + const isCommitDisabled = + selectedCount === 0 || + commitMessage.trim().length === 0 || + commitMutation.isPending; + return ( @@ -259,6 +433,138 @@ export const DiffModal: FC = ({ className="mb-3" /> + {/* Commit UI Section */} + {compareTo === "working" && ( +
+ {/* File selection controls */} +
+
+ + + + {selectedCount} / {diffData.data.files.length} files + selected + +
+
+ + {/* File list with checkboxes */} +
+ {diffData.data.files.map((file) => ( +
+ handleToggleFile(file.filePath)} + disabled={commitMutation.isPending} + /> + +
+ ))} +
+ + {/* Commit message input */} +
+ +