"use client"; import { IconCloudDownload, IconEye, IconChevronDown, IconGitBranch, IconGitCherryPick, IconGitMerge, IconArrowRight, IconLoader2, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuLabel, DropdownMenuSeparator, } from "@kandev/ui/dropdown-menu"; import { PanelHeaderBarSplit } from "./panel-primitives"; import { BaseBranchPicker } from "./base-branch-picker"; type PerRepoStatus = { repository_name: string; branch: string | null; ahead: number; behind: number; hasStaged: boolean; hasUnstaged: boolean; }; type BranchRow = { repoLabel: string | null; branch: string; baseBranch: string; /** Name agentctl emits for this repo (= worktree dir basename). Empty for * single-repo workspaces; passed to the BaseBranchPicker so it can resolve * the task_repositories row to PATCH. */ repositoryName: string; }; /** * Builds per-repo rows for the branch hover card. Returns [] for single-repo * workspaces (callers fall back to the single-row layout); otherwise one row * per named repo with that repo's task base_branch (or the workspace-level * fallback when none was recorded). */ function buildBranchRows( perRepoStatus: PerRepoStatus[], baseBranchByRepo: Record | undefined, baseBranchFallback: string, repoDisplayName: ((name: string) => string | undefined) | undefined, ): BranchRow[] { const named = perRepoStatus.filter((s) => s.repository_name !== "" && s.branch); if (named.length <= 1) return []; return named.map((s) => ({ repoLabel: repoDisplayName?.(s.repository_name) || s.repository_name, branch: s.branch ?? "", baseBranch: baseBranchByRepo?.[s.repository_name] || baseBranchFallback, repositoryName: s.repository_name, })); } function BranchRowView({ repoLabel, branch, baseBranch, repositoryName, taskId, }: BranchRow & { taskId: string | null }) { return (
{repoLabel && ( {repoLabel} )} {branch}
); } function BranchHoverCard({ displayBranch, baseBranchDisplay, rows, taskId, }: { displayBranch: string; baseBranchDisplay: string; /** When non-empty, the card renders one row per repo instead of the single * workspace-level pair. Single-repo workspaces leave this undefined. */ rows?: BranchRow[]; /** Active task id, plumbed into BaseBranchPicker for the PATCH call. */ taskId: string | null; }) { const isMulti = rows && rows.length > 0; const headerLabel = isMulti ? "Your branches:" : "Your code lives in:"; const trailerLabel = "comparing against:"; return (
{headerLabel} {trailerLabel}
{isMulti ? (
{rows!.map((row) => ( ))}
) : ( )}
); } function PullTriggerContent({ behindCount, isPulling, isRebasing, }: { behindCount: number; isPulling: boolean; isRebasing: boolean; }) { const isPullRelated = isPulling || isRebasing; let label: string; if (isPulling) label = "Pulling…"; else if (isRebasing) label = "Rebasing…"; else label = "Pull"; return ( <> {isPullRelated ? ( ) : ( )} {label} {behindCount > 0 && !isPullRelated && ( {behindCount} )} {!isPullRelated && } ); } function PullDropdown({ behindCount, isLoading, loadingOperation, repoNames, perRepoStatus, onRepoPull, onRepoRebase, onRepoMerge, repoDisplayName, }: { behindCount: number; isLoading: boolean; loadingOperation: string | null; /** Always non-empty (single-repo includes the empty-name entry). */ repoNames: string[]; perRepoStatus: PerRepoStatus[]; onRepoPull: (repo: string) => void; onRepoRebase: (repo: string) => void; onRepoMerge: (repo: string) => void; /** Maps a repository_name to its display label. */ repoDisplayName?: (repositoryName: string) => string | undefined; }) { const isPulling = loadingOperation === "pull"; const isRebasing = loadingOperation === "rebase"; // For single-repo (empty repo entry), the trigger label uses the global // behindCount; for multi-repo we show the per-repo behinds inside the menu // labels and the trigger summarises with the max. const triggerBehind = perRepoStatus.length > 0 ? Math.max(behindCount, ...perRepoStatus.map((s) => s.behind)) : behindCount; return ( ); } function PerRepoPullMenu({ repoNames, perRepoStatus, onRepoPull, onRepoRebase, onRepoMerge, repoDisplayName, }: { repoNames: string[]; perRepoStatus: PerRepoStatus[]; onRepoPull: (repo: string) => void; onRepoRebase: (repo: string) => void; onRepoMerge: (repo: string) => void; repoDisplayName?: (repositoryName: string) => string | undefined; }) { const statusByName = new Map(perRepoStatus.map((s) => [s.repository_name, s])); return ( <> {repoNames.map((repo, idx) => { const s = statusByName.get(repo); const behind = s?.behind ?? 0; const label = repoDisplayName?.(repo) || repo || "Repository"; return (
{idx > 0 && } {label} {behind > 0 && ( {behind} behind )} onRepoPull(repo)} className="cursor-pointer text-xs gap-2" > Pull onRepoRebase(repo)} className="cursor-pointer text-xs gap-2" > Rebase onRepoMerge(repo)} className="cursor-pointer text-xs gap-2" > Merge
); })} ); } function ChangesPanelHeaderLeft({ showDiffReview, onOpenDiffAll, onOpenReview, }: { showDiffReview: boolean; onOpenDiffAll?: () => void; onOpenReview?: () => void; }) { if (!showDiffReview) return null; return ( <> ); } export function ChangesPanelHeader({ hasChanges, hasCommits, hasPRFiles, displayBranch, baseBranchDisplay, baseBranchByRepo, behindCount, isLoading, loadingOperation, onOpenDiffAll, onOpenReview, repoNames, perRepoStatus, onRepoPull, onRepoRebase, onRepoMerge, repoDisplayName, taskId, }: { hasChanges: boolean; hasCommits: boolean; hasPRFiles?: boolean; displayBranch: string | null; baseBranchDisplay: string; /** Per-repo merge target, keyed by repository_name. Undefined entries fall * back to baseBranchDisplay. Empty/missing for single-repo workspaces. */ baseBranchByRepo?: Record; behindCount: number; isLoading: boolean; loadingOperation: string | null; onOpenDiffAll?: () => void; onOpenReview?: () => void; /** Always non-empty (single-repo includes the empty-name entry). */ repoNames: string[]; perRepoStatus: PerRepoStatus[]; onRepoPull: (repo: string) => void; onRepoRebase: (repo: string) => void; onRepoMerge: (repo: string) => void; repoDisplayName?: (repositoryName: string) => string | undefined; /** Active task id; piped into the base-branch picker so it can resolve * the right task_repositories row to PATCH. Null while task data is * hydrating — the picker falls back to a static label. */ taskId: string | null; }) { const branchRows = buildBranchRows( perRepoStatus, baseBranchByRepo, baseBranchDisplay, repoDisplayName, ); const showDiffReview = hasChanges || hasCommits || !!hasPRFiles; return ( } right={ <> {(displayBranch || branchRows.length > 0) && ( )} } /> ); }