"use client"; import { useState, useMemo, useCallback } from "react"; import { IconGitPullRequest, IconCloudUpload, IconChevronDown, IconChevronRight, IconLoader2, } from "@tabler/icons-react"; import { useMultiSelect } from "@/hooks/use-multi-select"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@kandev/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { FileInfo } from "@/lib/state/store"; import { FileRow, BulkActionBar } from "./changes-panel-file-row"; import { type CommitItem } from "./commit-row"; import { groupByRepositoryName } from "@/lib/group-by-repo"; import { CommitsRepoGroup, RepoGroupItem } from "./changes-panel-repo-groups"; import { PRFilesGroupedList } from "./changes-panel-pr-files"; // --- Timeline visual components --- type ChangedFile = { path: string; status: FileInfo["status"]; staged: boolean; plus: number | undefined; minus: number | undefined; oldPath: string | undefined; /** Repository this file belongs to in multi-repo workspaces; empty for single-repo. */ repositoryName?: string; }; // 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", action: "bg-muted-foreground/25", } as const; function TimelineDot({ color }: { color: string }) { return
; } function TimelineSection({ dotColor, label, count, action, isLast, children, collapsible = true, defaultCollapsed = false, "data-testid": testId, }: { dotColor: string; label?: string; count?: number; action?: React.ReactNode; isLast?: boolean; children?: React.ReactNode; collapsible?: boolean; defaultCollapsed?: boolean; "data-testid"?: string; }) { const [collapsed, setCollapsed] = useState(defaultCollapsed); const canCollapse = collapsible && !!label; return (
{/* Vertical line + dot */}
{!isLast &&
}
{/* Content */}
{/* Header */} {label && (
{canCollapse ? ( ) : ( {label} {typeof count === "number" && ( ({count}) )} )} {action}
)} {/* Children (file list, buttons, etc.) */} {!collapsed && children}
); } // --- Commits section --- type CommitsSectionProps = { commits: CommitItem[]; isLast: boolean; onOpenCommitDetail?: (sha: string, repo?: string) => 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; }; // Commits grouping shares the helper above with files — see @/lib/group-by-repo. export function CommitsSection({ commits, isLast, onOpenCommitDetail, onRevertCommit, onAmendCommit, onResetToCommit, onRepoPush, onRepoCreatePR, repoDisplayName, repoBaseBranch, perRepoStatus, prByRepo, }: CommitsSectionProps) { // Always group by repo. Single-repo shows up as one group with an empty // repository_name; the group header still renders with a display label and // per-repo buttons (Push, Create PR) so the UX is uniform across modes. const groups = groupByRepositoryName(commits, (c) => c.repository_name); const aheadByRepo = new Map((perRepoStatus ?? []).map((s) => [s.repository_name, s.ahead])); return (
    {groups.map((g) => ( ))}
); } // --- Action buttons section (Create PR / Push) --- type ActionButtonsSectionProps = { onOpenPRDialog: () => void; onPush: () => void; onForcePush: () => void; isLoading: boolean; loadingOperation: string | null; aheadCount: number; canPush: boolean; canCreatePR: boolean; existingPrUrl?: string; isLast?: boolean; /** Hide the Push button (per-repo Push is rendered elsewhere in multi-repo). */ hidePush?: boolean; }; export function ActionButtonsSection({ onOpenPRDialog, onPush, onForcePush, isLoading, loadingOperation, aheadCount, canPush, canCreatePR, existingPrUrl, isLast = true, hidePush = false, }: ActionButtonsSectionProps) { const prExists = !!existingPrUrl; const createPrDisabled = !canCreatePR || prExists || isLoading; const pushDisabled = !canPush || isLoading; const isPushing = loadingOperation === "push"; const isCreatingPR = loadingOperation === "create_pr"; let pushTooltip: string | null = null; if (isLoading) pushTooltip = "A git operation is in progress"; else if (!canPush) pushTooltip = "No commits ahead of remote"; return (
{prExists && A pull request already exists for this task} {!hidePush && ( Force Push (with lease) {pushTooltip && {pushTooltip}} )}
); } // --- File list sections (Unstaged / Staged) --- type FileListSectionProps = { variant: "unstaged" | "staged"; files: ChangedFile[]; pendingStageFiles: Set; isLast: boolean; actionLabel: string; isActionLoading?: boolean; onAction: () => void; secondaryActionLabel?: string; isSecondaryActionLoading?: boolean; onSecondaryAction?: () => void; onOpenDiff: (path: string) => void; onEditFile: (path: 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; }; /** * 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) => void; onEditFile: (path: 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; }; function FileListBody(props: FileListBodyProps) { const { variant, files, pendingStageFiles, multiSelect } = props; 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) => ( ); return (
    {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)} /> ))}
); } export function FileListSection(props: FileListSectionProps) { const { variant, files, pendingStageFiles, isLast, 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; the bottom-of-section global // buttons would be redundant, so we skip them. const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape" && hasSelection) multiSelect.clearSelection(); }, [hasSelection, multiSelect], ); return ( {files.length > 0 && ( )} {files.length > 0 && hasSelection && (
)}
); } // --- PR files section (read-only, from GitHub PR diff) --- export type PRChangedFile = { path: 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[]; isLast: boolean; onOpenDiff: (path: string) => void; /** Maps a repository_name to a human-readable label (used for the per-repo header). */ repoDisplayName?: (repositoryName: string) => string | undefined; }; export function PRFilesSection({ files, isLast, onOpenDiff, repoDisplayName, }: 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 ); }