"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"; 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", ); } /** * 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); // Tracks the brief window after the popover closes during which the cursor // is still hovering the trigger. Without this, Radix's Tooltip becomes // uncontrolled the moment `open` flips to false and pops back open from the // lingering hover. Holding `open={false}` for ~200ms lets the user move // away naturally and prevents the tooltip from racing back in. const [suppressTooltip, setSuppressTooltip] = useState(false); const suppressTimerRef = useRef | null>(null); const setOpen = useCallback((next: boolean) => { setOpenState((prev) => { if (prev && !next) { if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current); setSuppressTooltip(true); suppressTimerRef.current = setTimeout(() => { setSuppressTooltip(false); suppressTimerRef.current = null; }, 200); } return next; }); }, []); // Clear the suppression timer if the component unmounts while it's still // ticking, so the eventual `setSuppressTooltip(false)` doesn't fire on a // dead component. useEffect( () => () => { if (suppressTimerRef.current) { clearTimeout(suppressTimerRef.current); suppressTimerRef.current = null; } }, [], ); const hasValue = !!value; // Prefix only renders alongside a real value; an empty chip with placeholder // ("branch") doesn't need "current: " in front of it. const showPrefix = !!prefix && hasValue; const triggerButton = ( ); // A disabled