"use client"; import { memo, useMemo } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore } from "@/components/state-provider"; import { useSessionGit } from "@/hooks/domains/session/use-session-git"; import { useSessionFileReviews } from "@/hooks/use-session-file-reviews"; import { hashDiff, normalizeDiffContent } from "@/components/review/types"; import type { FileInfo } from "@/lib/state/store"; import { useToast } from "@/components/toast-provider"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; import { DiscardDialog, CommitDialog, PRDialog } from "./changes-panel-dialogs"; import { ChangesPanelHeader } from "./changes-panel-header"; import { FileListSection, CommitsSection, ActionButtonsSection, ReviewProgressBar, } from "./changes-panel-timeline"; import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; type ChangesPanelProps = { onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; onOpenCommitDetail?: (sha: string) => void; onOpenDiffAll?: () => void; onOpenReview?: () => void; }; // Maps FileInfo (store) to the display format expected by FileListSection type ChangedFile = { path: string; status: FileInfo["status"]; staged: boolean; plus: number | undefined; minus: number | undefined; oldPath: string | undefined; }; function mapToChangedFiles(files: FileInfo[]): ChangedFile[] { return files.map((file) => ({ path: file.path, status: file.status, staged: file.staged, plus: file.additions, minus: file.deletions, oldPath: file.old_path, })); } type CumulativeDiffFiles = Record< string, { diff?: string; status?: string; additions?: number; deletions?: number } >; function collectReviewPaths( uncommittedFiles: FileInfo[], cumulativeDiffFiles: CumulativeDiffFiles | undefined, ): Set { const paths = new Set(); for (const file of uncommittedFiles) { if (file.diff && normalizeDiffContent(file.diff)) paths.add(file.path); } if (cumulativeDiffFiles) { for (const [path, file] of Object.entries(cumulativeDiffFiles)) { if (paths.has(path)) continue; if (file.diff && normalizeDiffContent(file.diff)) paths.add(path); } } return paths; } function getDiffForPath( path: string, uncommittedFiles: FileInfo[], cumulativeDiffFiles: CumulativeDiffFiles | undefined, ): string { const uncommitted = uncommittedFiles.find((f) => f.path === path); if (uncommitted?.diff) return normalizeDiffContent(uncommitted.diff); const cumDiff = cumulativeDiffFiles?.[path]?.diff; if (cumDiff) return normalizeDiffContent(cumDiff); return ""; } function computeReviewProgress( uncommittedFiles: FileInfo[], cumulativeDiff: { files?: CumulativeDiffFiles } | null, reviews: Map, ) { const cumulativeDiffFiles = cumulativeDiff?.files; const paths = collectReviewPaths(uncommittedFiles, cumulativeDiffFiles); let reviewed = 0; for (const path of paths) { const state = reviews.get(path); if (!state?.reviewed) continue; const diffContent = getDiffForPath(path, uncommittedFiles, cumulativeDiffFiles); if (diffContent && state.diffHash && state.diffHash !== hashDiff(diffContent)) continue; reviewed++; } return { reviewedCount: reviewed, totalFileCount: paths.size }; } function computeStagedStats(stagedFiles: FileInfo[]) { let additions = 0; let deletions = 0; for (const file of stagedFiles) { additions += file.additions || 0; deletions += file.deletions || 0; } return { stagedFileCount: stagedFiles.length, stagedAdditions: additions, stagedDeletions: deletions, }; } function getBaseBranchDisplay(baseBranch: string | undefined): string { return baseBranch ? baseBranch.replace(/^origin\//, "") : "main"; } function useChangesPanelStoreData() { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const taskTitle = useAppStore((state) => { if (!state.tasks.activeTaskId) return undefined; return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title; }); const baseBranch = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.base_branch : undefined, ); const existingPrUrl = useAppStore((state) => { const taskId = state.tasks.activeTaskId; if (!taskId) return undefined; return state.taskPRs.byTaskId[taskId]?.pr_url ?? undefined; }); return { activeSessionId, taskTitle, baseBranch, existingPrUrl }; } type ChangesPanelBodyProps = { hasAnything: boolean; hasUnstaged: boolean; hasStaged: boolean; hasCommits: boolean; canPush: boolean; canCreatePR: boolean; existingPrUrl: string | undefined; unstagedFiles: ChangedFile[]; stagedFiles: ChangedFile[]; commits: ReturnType["commits"]; pendingStageFiles: Set; reviewedCount: number; totalFileCount: number; aheadCount: number; isLoading: boolean; dialogs: ReturnType; onOpenDiffFile: (path: string) => void; onEditFile: (path: string) => void; onOpenCommitDetail?: (sha: string) => void; onOpenReview?: () => void; onRevertCommit?: (sha: string) => void; onStageAll: () => void; onStage: (path: string) => Promise; onUnstage: (path: string) => Promise; onPush: () => void; onForcePush: () => void; stagedFileCount: number; stagedAdditions: number; stagedDeletions: number; displayBranch: string | null; baseBranch: string | undefined; }; function ChangesPanelDialogsSection({ dialogs, isLoading, stagedFileCount, stagedAdditions, stagedDeletions, displayBranch, baseBranch, }: Pick< ChangesPanelBodyProps, | "dialogs" | "isLoading" | "stagedFileCount" | "stagedAdditions" | "stagedDeletions" | "displayBranch" | "baseBranch" >) { return ( <> ); } function ChangesPanelTimeline( props: Pick< ChangesPanelBodyProps, | "hasAnything" | "hasUnstaged" | "hasStaged" | "hasCommits" | "canPush" | "canCreatePR" | "existingPrUrl" | "unstagedFiles" | "stagedFiles" | "commits" | "pendingStageFiles" | "aheadCount" | "isLoading" | "dialogs" | "onOpenDiffFile" | "onEditFile" | "onOpenCommitDetail" | "onRevertCommit" | "onStageAll" | "onStage" | "onUnstage" | "onPush" | "onForcePush" >, ) { if (!props.hasAnything) { return (
Your changed files will appear here
); } const showStaged = props.hasUnstaged || props.hasStaged; const showCommits = props.hasStaged || props.hasCommits; return (
{props.hasUnstaged && ( )} {showStaged && ( )} {showCommits && ( )} {showCommits && ( )}
); } function ChangesPanelBody(props: ChangesPanelBodyProps) { return (
); } const ChangesPanel = memo(function ChangesPanel({ onOpenDiffFile, onEditFile, onOpenCommitDetail, onOpenDiffAll, onOpenReview, }: ChangesPanelProps) { const isArchived = useIsTaskArchived(); const { activeSessionId, taskTitle, baseBranch, existingPrUrl } = useChangesPanelStoreData(); const git = useSessionGit(activeSessionId); const { toast } = useToast(); const { reviews } = useSessionFileReviews(activeSessionId); const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]); const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]); const stagedFiles = useMemo(() => mapToChangedFiles(git.stagedFiles), [git.stagedFiles]); const { reviewedCount, totalFileCount } = useMemo( () => computeReviewProgress(git.allFiles, git.cumulativeDiff, reviews), [git.allFiles, git.cumulativeDiff, reviews], ); const { stagedFileCount, stagedAdditions, stagedDeletions } = useMemo( () => computeStagedStats(git.stagedFiles), [git.stagedFiles], ); const { handleGitOperation, handlePull, handleRebase, handlePush, handleForcePush, handleRevertCommit, } = useChangesGitHandlers(git, toast, baseBranch); const dialogs = useChangesDialogHandlers(git, toast, handleGitOperation, taskTitle, baseBranch); if (isArchived) return ; return ( git.stage([path]).then(() => undefined)} onUnstage={(path) => git.unstage([path]).then(() => undefined)} onPush={handlePush} onForcePush={handleForcePush} stagedFileCount={stagedFileCount} stagedAdditions={stagedAdditions} stagedDeletions={stagedDeletions} displayBranch={git.branch} baseBranch={baseBranch} /> ); }); export { ChangesPanel };