"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Badge } from "@kandev/ui/badge"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@kandev/ui/command"; import type { Branch } from "@/lib/types/http"; import { BranchRefreshButton } from "@/components/branch-refresh-button"; import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container"; import { useTooltipMountGate } from "@/hooks/use-tooltip-mount-gate"; export type PillOption = { value: string; label: string; keywords?: string[]; renderLabel?: () => React.ReactNode; }; /** * `Pill` wraps cmdk's `Command` / `CommandInput` / `CommandList`. Its popover * body only supports cmdk children (`CommandItem`, etc.) — keyboard nav and * focus are routed through cmdk. If you need a popover with mixed content * (search list + a free-form ``, banners, etc.), build a custom * `Popover` from `@kandev/ui/popover` instead of warping `Pill`. */ type PillProps = { icon: React.ReactNode; value: string; placeholder: string; options: PillOption[]; onSelect: (value: string) => void; disabled?: boolean; /** When provided alongside `disabled`, surfaces a tooltip explaining why. */ disabledReason?: string; searchPlaceholder: string; emptyMessage: string; testId?: string; /** Optional refresh action rendered next to the search input. */ onRefresh?: () => void; /** Show the refresh icon as spinning + disabled while a refresh is in flight. */ refreshing?: boolean; /** * Render without its own border/bg so the pill blends into a wrapping * grouped container (used by RepoChip to draw one rectangle around * repo + branch + remove). */ flat?: boolean; /** Optional cmdk scorer override. Branch pickers pass `scoreBranch`. */ filter?: (value: string, search: string, keywords?: string[]) => number; /** Optional hover tooltip for truncated labels or extra context. */ tooltip?: string; /** * Optional muted prefix shown before the value in the trigger button. * Used by the branch chip to distinguish "current: " (no-op), * "will switch to: " (destructive) and "from: " (worktree * base) without depending on the user reading a tooltip. */ prefix?: string; }; /** Returns the active-state hover classes for the pill trigger button. */ function pillActiveClass(flat: boolean): string { if (flat) return "hover:bg-muted/60 cursor-pointer"; return "hover:bg-muted hover:border-border cursor-pointer"; } function PillCommandList({ options, onSelect, setOpen, emptyMessage, }: { options: PillOption[]; onSelect: (value: string) => void; setOpen: (open: boolean) => void; emptyMessage: string; }) { return ( {emptyMessage} {options.map((option) => ( { onSelect(option.value); setOpen(false); }} > {option.renderLabel ? option.renderLabel() : option.label} ))} ); } /** * Builds the className for the pill trigger button. Extracted so the inline * trigger JSX stays compact (the Pill function is right at the complexity cap). */ function pillTriggerClass(disabled: boolean, flat: boolean, hasValue: boolean): string { return cn( "h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs", flat ? "bg-transparent" : "border border-border/60 bg-muted/30", disabled ? "opacity-50 cursor-not-allowed" : pillActiveClass(flat), !hasValue && "text-muted-foreground", ); } function DisabledPillTooltip({ open, onOpenChange, triggerButton, disabledReason, }: { open: boolean; onOpenChange: (open: boolean) => void; triggerButton: React.ReactNode; disabledReason: string; }) { return ( {disabledReason} ); } function PillPopoverContent({ filter, searchPlaceholder, onRefresh, refreshing, options, onSelect, setOpen, emptyMessage, portalContainer, }: { filter?: PillProps["filter"]; searchPlaceholder: string; onRefresh?: () => void; refreshing?: boolean; options: PillOption[]; onSelect: (value: string) => void; setOpen: (open: boolean) => void; emptyMessage: string; portalContainer: HTMLElement | null; }) { return (
{onRefresh && }
); } function renderPillTriggerButton({ icon, value, placeholder, disabled, flat, hasValue, testId, prefix, }: Pick & { hasValue: boolean; }): React.ReactElement { const showPrefix = !!prefix && hasValue; return ( ); } /** * Compact pill trigger that opens a popover with a search list. Auto-widths * to its content (no `w-full`, no chevron) so multiple pills can sit on one * line without overlapping or stretching to fill the row. */ export function Pill({ icon, value, placeholder, options, onSelect, disabled = false, disabledReason, searchPlaceholder, emptyMessage, testId, onRefresh, refreshing, flat = false, filter, tooltip, prefix, }: PillProps) { const [open, setOpenState] = useState(false); const { tooltipOpenState, handleTooltipOpenChange, closeTooltip } = useTooltipMountGate(); const portalContainer = useTaskCreateDialogPopoverContainer(); const [suppressTooltip, setSuppressTooltip] = useState(false); const suppressTimerRef = useRef | null>(null); const setOpen = useCallback( (next: boolean) => { if (next) closeTooltip(); setOpenState((prev) => { if (prev && !next) { if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current); setSuppressTooltip(true); suppressTimerRef.current = setTimeout(() => { setSuppressTooltip(false); suppressTimerRef.current = null; }, 200); } return next; }); }, [closeTooltip], ); useEffect( () => () => { if (suppressTimerRef.current) { clearTimeout(suppressTimerRef.current); suppressTimerRef.current = null; } }, [], ); const hasValue = !!value; const triggerButton = renderPillTriggerButton({ icon, value, placeholder, disabled, flat, hasValue, testId, prefix, }); // Disabled buttons swallow pointer/focus events, so the wrapper owns tooltip // focus while the button stays disabled. if (disabled && disabledReason && !open) { return ( ); } const popover = ( {tooltip ? {triggerButton} : triggerButton} ); if (!tooltip) return popover; // Suppress the hover tooltip while the popover is open, then briefly after // close so lingering hover cannot immediately reopen it. const tooltipOpen = open || suppressTooltip ? false : tooltipOpenState; return ( {popover} {tooltip} ); } // --- Branch utilities --- // Conventional default branches surfaced at the top of the dropdown when no // search term is active. cmdk preserves option order on empty queries, so a // stable sort here lifts main/master/develop above feature branches. const PREFERRED_BRANCH_NAMES = ["main", "master", "develop"]; function branchPriority(b: Branch): number { const idx = PREFERRED_BRANCH_NAMES.indexOf(b.name); if (idx === -1) return PREFERRED_BRANCH_NAMES.length; return idx; } export function sortBranches(branches: Branch[]): Branch[] { return [...branches].sort((a, b) => { const pa = branchPriority(a); const pb = branchPriority(b); if (pa !== pb) return pa - pb; // Within the same priority bucket, locals before remotes — matches the // auto-select preference (`main` over `origin/main`). if (a.type !== b.type) return a.type === "local" ? -1 : 1; return 0; }); } const BRANCH_SEGMENT_RE = /[/_.\-\s]+/; export function buildBranchKeywords(name: string, remote?: string): string[] { const out = new Set(); out.add(name); const leafIdx = name.lastIndexOf("/"); if (leafIdx >= 0) out.add(name.slice(leafIdx + 1)); for (const seg of name.split(BRANCH_SEGMENT_RE)) { if (seg) out.add(seg); } if (remote) out.add(remote); return Array.from(out); } export function branchToOption(b: Branch): PillOption { // Remote branches keep their "origin/" prefix so they're distinguishable // from local branches with the same short name (e.g. "main" vs "origin/main"). // Without the prefix, the dropdown shows two indistinguishable rows. const display = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name; // `||` (not `??`) so an empty-string `remote` falls back too. Provider-backed // workspace repos (URL-added) list branches without a tracking remote, so the // backend sends `remote: ""`; `??` would render an invisible empty badge. const badge = b.type === "local" ? "local" : b.remote || "remote"; return { value: display, label: display, keywords: buildBranchKeywords(b.name, b.remote), renderLabel: () => ( {display} {badge} ), }; } export function computeBranchPlaceholder( hasRepo: boolean, loading: boolean, optionCount: number, ): string { if (!hasRepo) return "branch"; if (loading) return "loading…"; if (optionCount === 0) return "no branches"; return "branch"; }