"use client"; import { useState, useMemo, useCallback } from "react"; import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { useMultiSelect } from "@/hooks/use-multi-select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; import type { FileInfo } from "@/lib/state/store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { useAppStore } from "@/components/state-provider"; import { FileRow, BulkActionBar } from "./changes-panel-file-row"; import { ChangesTree, RepoTreeGroup } from "./changes-panel-tree"; import type { ChangedFile } from "./changes-panel-helpers"; import { type CommitItem } from "./commit-row"; import { groupByRepositoryName, isSingleRepoGroup } from "@/lib/group-by-repo"; import { CommitsGroupActions, CommitsRepoGroup, FileSectionActions, RepoGroupItem, } from "./changes-panel-repo-groups"; import { PRFilesGroupedList } from "./changes-panel-pr-files"; import type { CommitDetailTarget, OpenDiffOptions } from "./changes-diff-target"; // --- Timeline visual components --- // Per-repo grouping is shared with the Review dialog — see @/lib/group-by-repo. // --- Timeline section dot colors --- const DOT_COLORS = { unstaged: "bg-yellow-500", staged: "bg-emerald-500", commits: "bg-blue-500", pr: "bg-purple-500", } as const; function TimelineDot({ color }: { color: string }) { return
; } function TimelineSection({ dotColor, label, count, action, children, collapsible = true, defaultCollapsed = false, "data-testid": testId, }: { dotColor: string; label?: string; count?: number; action?: React.ReactNode; children?: React.ReactNode; collapsible?: boolean; defaultCollapsed?: boolean; "data-testid"?: string; }) { const [collapsed, setCollapsed] = useState(defaultCollapsed); // Git data arrives in separate async store updates, so `defaultCollapsed` // (derived from which section is first-visible) can flip after mount. Re-sync // to it whenever it changes — but stop once the user has manually toggled, so // their choice is never clobbered by a later data update. Adjusting state // during render (storing the previous prop in state) is the React-recommended // pattern and avoids both the setState-in-effect and ref-during-render lint // rules. const [userToggled, setUserToggled] = useState(false); const [prevDefaultCollapsed, setPrevDefaultCollapsed] = useState(defaultCollapsed); if (prevDefaultCollapsed !== defaultCollapsed) { setPrevDefaultCollapsed(defaultCollapsed); if (!userToggled) setCollapsed(defaultCollapsed); } const canCollapse = collapsible && !!label; return (
{/* Vertical line + dot */}
{/* Content */}
{/* Header */} {label && (
{canCollapse ? ( ) : ( {label} {typeof count === "number" && ( ({count}) )} )} {action}
)} {/* Children (file list, buttons, etc.) */} {!collapsed && children}
); } // --- Commits section --- type CommitsSectionProps = { commits: CommitItem[]; onOpenCommitDetail?: (target: CommitDetailTarget) => void; // Handlers receive the commit's repository_name so amend/revert/reset land // in the right git repo. The empty string routes to the workspace root for // single-repo workspaces. onRevertCommit?: (sha: string, repo?: string) => void; onAmendCommit?: (currentMessage: string, repo?: string) => void; onResetToCommit?: (sha: string, repo?: string) => void; /** Per-repo Push button rendered in each commits group header. */ onRepoPush?: (repo: string) => void; /** Per-repo Create PR button rendered in each commits group header. */ onRepoCreatePR?: (repo: string) => void; /** Maps a repository_name to its display label (used for the empty single-repo case). */ repoDisplayName?: (repositoryName: string) => string | undefined; /** The session's base branch — passed through to the per-repo PR dialog. */ repoBaseBranch?: string; /** Per-repo branch / ahead / behind summary; used for the "ahead" indicator. */ perRepoStatus?: Array<{ repository_name: string; ahead: number }>; /** Existing PR URL keyed by repository_name; "" key for single-repo. */ prByRepo?: Record; /** Initial collapse state. Defaults to collapsed; the panel expands it when it is the first visible section. */ defaultCollapsed?: boolean; }; // Commits grouping shares the helper above with files — see @/lib/group-by-repo. export function CommitsSection({ commits, onOpenCommitDetail, onRevertCommit, onAmendCommit, onResetToCommit, onRepoPush, onRepoCreatePR, repoDisplayName, repoBaseBranch, perRepoStatus, prByRepo, defaultCollapsed = true, }: CommitsSectionProps) { const groups = groupByRepositoryName(commits, (c) => c.repository_name); const aheadByRepo = new Map((perRepoStatus ?? []).map((s) => [s.repository_name, s.ahead])); // Single-repo: drop the per-repo sub-header (CommitsRepoGroup with // showHeader=false renders flat) and lift the Push / PR buttons into the // section header. const isSingleRepo = isSingleRepoGroup(groups); const sectionAction = isSingleRepo ? ( e.stopPropagation()} /> ) : undefined; return (
    {groups.map((g) => ( ))}
); } // --- File list sections (Unstaged / Staged) --- type FileListSectionProps = { variant: "unstaged" | "staged"; files: ChangedFile[]; pendingStageFiles: Set; actionLabel: string; isActionLoading?: boolean; onAction: () => void; secondaryActionLabel?: string; isSecondaryActionLoading?: boolean; onSecondaryAction?: () => void; onOpenDiff: (path: string, options?: OpenDiffOptions) => void; onEditFile: (path: string, repo?: string) => void; // Multi-repo: handlers receive the file's repositoryName so each per-file op // hits the right git repo. Same-named files across repos collide by path. onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; onDiscard: (path: string, repo?: string) => void; onBulkStage?: (paths: string[]) => void; onBulkUnstage?: (paths: string[]) => void; onBulkDiscard?: (paths: string[]) => void; // Per-repo action handlers shown inline with each repo group header. Always // rendered, including single-repo (with one entry pre-selected) — the empty // `repo` argument routes ops to the workspace root in that case. onRepoAction?: (repo: string) => void; onRepoSecondaryAction?: (repo: string) => void; /** Maps a repository_name to its display label; called per group header. */ repoDisplayName?: (repositoryName: string) => string | undefined; /** Disable section and per-repository actions while another git operation runs. */ disabled?: boolean; }; /** * Renders the body of a file-list section. For multi-repo workspaces it * groups files by repository under per-repo subheaders; single-repo * workspaces fall back to a flat list (no header) so the existing UI is * unchanged. */ type FileListBodyProps = { variant: "unstaged" | "staged"; files: ChangedFile[]; pendingStageFiles: Set; multiSelect: ReturnType; onKeyDown: (e: React.KeyboardEvent) => void; onOpenDiff: (path: string, options?: OpenDiffOptions) => void; onEditFile: (path: string, repo?: string) => void; onStage: (path: string, repo?: string) => void; onUnstage: (path: string, repo?: string) => void; onDiscard: (path: string, repo?: string) => void; /** Per-repo group header actions; primary = Stage all (unstaged) / Commit (staged). */ onRepoAction?: (repo: string) => void; onRepoSecondaryAction?: (repo: string) => void; primaryLabel: string; secondaryLabel?: string; /** Maps a repository_name to its display label (e.g. "" → workspace primary repo name). */ repoDisplayName?: (repositoryName: string) => string | undefined; disabled?: boolean; }; function FileListBody(props: FileListBodyProps) { const { files, pendingStageFiles, multiSelect } = props; // Multi-repo nuance: activeFilePath carries the path but not repo; same path // in two repos will light up both rows. Matches existing routing limit noted // in FileRowProps comments. const activeFilePath = useDockviewStore((s) => s.activeFilePath); const layout = useAppStore((s) => s.userSettings.changesPanelLayout); const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repositoryName), [files]); // Per-repo collapsed state: keyed by repositoryName. Default expanded; // setting an entry to true collapses that group. Persists across re-renders // for the lifetime of this section instance (resets on unmount). const [collapsedRepos, setCollapsedRepos] = useState>(() => new Set()); const toggleRepo = useCallback((name: string) => { setCollapsedRepos((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); else next.add(name); return next; }); }, []); const renderRow = (file: ChangedFile) => ( ); // Single-repo: drop the per-repo sub-header. The action buttons (Stage all // / Commit / Unstage all) move up to the section header — see FileListSection. const isSingleRepo = isSingleRepoGroup(groups); if (layout === "tree") { return ( ); } return ( ); } type FileListBranchProps = FileListBodyProps & { groups: ReturnType>; isSingleRepo: boolean; collapsedRepos: Set; toggleRepo: (name: string) => void; }; function TreeFileListBody(props: FileListBranchProps) { const { variant, groups, isSingleRepo, collapsedRepos, toggleRepo, pendingStageFiles, multiSelect, } = props; return (
{isSingleRepo ? ( ) : ( groups.map((group) => ( toggleRepo(group.repositoryName)} onOpenDiff={props.onOpenDiff} onEditFile={props.onEditFile} onStage={props.onStage} onUnstage={props.onUnstage} onDiscard={props.onDiscard} primaryLabel={props.primaryLabel} secondaryLabel={props.secondaryLabel} onRepoAction={props.onRepoAction} onRepoSecondaryAction={props.onRepoSecondaryAction} disabled={props.disabled} multiSelect={multiSelect} /> )) )}
); } function FlatFileListBody( props: FileListBranchProps & { renderRow: (file: ChangedFile) => React.ReactNode }, ) { const { variant, groups, isSingleRepo, collapsedRepos, toggleRepo, renderRow } = props; return (
    {isSingleRepo ? groups[0].items.map(renderRow) : groups.map((group) => ( toggleRepo(group.repositoryName)} renderRow={renderRow} primaryLabel={props.primaryLabel} secondaryLabel={props.secondaryLabel} onRepoAction={props.onRepoAction} onRepoSecondaryAction={props.onRepoSecondaryAction} displayName={props.repoDisplayName?.(group.repositoryName)} disabled={props.disabled} /> ))}
); } export function FileListSection(props: FileListSectionProps) { const { variant, files, pendingStageFiles, onOpenDiff, onEditFile, onStage, onUnstage, onDiscard, } = props; const dotColor = variant === "unstaged" ? DOT_COLORS.unstaged : DOT_COLORS.staged; const label = variant === "unstaged" ? "Unstaged" : "Staged"; const filePaths = useMemo(() => files.map((f) => f.path), [files]); const multiSelect = useMultiSelect({ items: filePaths }); const hasSelection = multiSelect.selectedPaths.size > 0; // Multi-repo when any file has a repositoryName. Per-repo group headers // own the action buttons in this case; in single-repo we render them in // the section header instead. Compute via the shared helper against the // grouped output so the three sites (commits + file-list body + this // section) stay in sync. const groups = useMemo(() => groupByRepositoryName(files, (f) => f.repositoryName), [files]); const isSingleRepo = files.length > 0 && isSingleRepoGroup(groups); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && hasSelection) multiSelect.clearSelection(); }, [hasSelection, multiSelect], ); return ( ) : undefined } > {files.length > 0 && ( )} {files.length > 0 && hasSelection && (
)}
); } // --- PR files section (read-only, from GitHub PR diff) --- export type PRChangedFile = { path: string; /** Stable owner/repo/number identity for the PR that produced this row. */ prKey?: string; status: FileInfo["status"]; plus: number | undefined; minus: number | undefined; oldPath: string | undefined; /** * The repository the file belongs to. Empty string for single-repo tasks * (the section header alone is enough). Multi-repo tasks stamp this so * `PRFilesSection` can group rows under per-repo subheaders, mirroring * how the Commits and Changes sections render. */ repository_name?: string; }; type PRFilesSectionProps = { files: PRChangedFile[]; onOpenDiff: (path: string, options?: OpenDiffOptions) => void; /** Maps a repository_name to a human-readable label (used for the per-repo header). */ repoDisplayName?: (repositoryName: string) => string | undefined; /** Initial collapse state. Defaults to collapsed; the panel expands it when it is the first visible section. */ defaultCollapsed?: boolean; }; export function PRFilesSection({ files, onOpenDiff, repoDisplayName, defaultCollapsed = true, }: PRFilesSectionProps) { return ( {files.length > 0 && ( )} ); } // --- Review progress bar --- type ReviewProgressBarProps = { reviewedCount: number; totalFileCount: number; onOpenReview?: () => void; }; export function ReviewProgressBar({ reviewedCount, totalFileCount, onOpenReview, }: ReviewProgressBarProps) { const progressPercent = totalFileCount > 0 ? (reviewedCount / totalFileCount) * 100 : 0; if (totalFileCount <= 0) return null; return (
{reviewedCount}/{totalFileCount} reviewed
{reviewedCount} of {totalFileCount} files reviewed ); }