"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