Files
backnotprop__plannotator/packages/review-editor/components/WorktreePicker.tsx
Ivan Giuliani b075677033 fix(review): show the full branch name when hovering the worktree switcher (#1223)
On a long branch name you can't tell which branch you're on from the
worktree switcher button because it truncates its label to keep the
toolbar compact. The trigger already had a native title, but it never
showed the branch. On the current checkout it just said "Main
repository", while on a worktree it showed the path rather than the
branch, and the dropdown rows had no title at all.

The full name is now always one hover away because we put the full
branch label into the button's title, add the worktree path when one is
active, and give each dropdown row a title for both its name and its
path.
2026-08-06 18:39:06 -07:00

191 lines
6.6 KiB
TypeScript

import React, { useMemo, useRef, useState } from 'react';
import { Popover } from '@base-ui/react/popover';
import type { WorktreeInfo } from '@plannotator/shared/types';
interface WorktreePickerProps {
worktrees: WorktreeInfo[];
activeWorktreePath: string | null;
currentBranch?: string;
onSelect: (path: string | null) => void;
disabled?: boolean;
}
const MAIN_REPO = null;
export const WorktreePicker: React.FC<WorktreePickerProps> = ({
worktrees,
activeWorktreePath,
currentBranch,
onSelect,
disabled,
}) => {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const searchRef = useRef<HTMLInputElement>(null);
const mainLabel = currentBranch || 'Main repo';
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return worktrees;
return worktrees.filter((wt) => {
const branch = (wt.branch || '').toLowerCase();
const path = wt.path.toLowerCase();
return branch.includes(q) || path.includes(q);
});
}, [worktrees, query]);
const mainMatchesQuery = !query.trim() || mainLabel.toLowerCase().includes(query.trim().toLowerCase());
const handleSelect = (path: string | null) => {
onSelect(path);
setOpen(false);
setQuery('');
};
const active = activeWorktreePath
? worktrees.find((wt) => wt.path === activeWorktreePath)
: null;
const activeLabel = active
? (active.branch || active.path.split('/').pop() || 'worktree')
: mainLabel;
const isCustom = activeWorktreePath !== null;
return (
<Popover.Root
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) setQuery('');
}}
>
<Popover.Trigger
render={
<button
type="button"
disabled={disabled}
title={active ? `${activeLabel} — ${active.path}` : mainLabel}
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded text-xs font-medium transition-colors focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed ${
isCustom
? 'bg-primary/10 border border-primary/30 text-foreground'
: 'bg-muted border border-transparent text-foreground'
}`}
/>
}
>
<span className="truncate flex-1 text-left">{activeLabel}</span>
{isCustom && (
<span className="text-[10px] uppercase tracking-wide opacity-60 flex-shrink-0">
worktree
</span>
)}
<svg
className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner side="bottom" align="start" sideOffset={4} className="z-50">
<Popover.Popup
className="w-72 bg-popover text-popover-foreground border border-border rounded shadow-lg overflow-hidden origin-[var(--transform-origin)] transition-opacity data-starting-style:opacity-0 data-ending-style:opacity-0"
initialFocus={() => {
// Only override the default focus when the search input is
// actually rendered — otherwise arrow keys would bubble out to
// the file-tree nav. For short worktree lists, returning null
// falls back to Base UI's default focus (the popup's first
// tabbable); undefined would mean "do nothing" and leave focus
// on the trigger.
return searchRef.current;
}}
>
{worktrees.length > 3 && (
<div className="p-2 border-b border-border/50">
<input
ref={searchRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search worktrees…"
className="w-full px-2 py-1.5 bg-muted rounded text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
)}
<div className="max-h-72 overflow-y-auto py-1">
{mainMatchesQuery && (
<WorktreeRow
label={mainLabel}
sublabel={null}
isSelected={activeWorktreePath === MAIN_REPO}
onClick={() => handleSelect(MAIN_REPO)}
/>
)}
{filtered.length > 0 && (
<>
{mainMatchesQuery && (
<div className="h-px bg-border/50 mx-2 my-1" />
)}
<div className="px-3 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Worktrees
</div>
{filtered.map((wt) => (
<WorktreeRow
key={wt.path}
label={wt.branch || wt.path.split('/').pop() || 'worktree'}
sublabel={wt.path}
isSelected={wt.path === activeWorktreePath}
onClick={() => handleSelect(wt.path)}
/>
))}
</>
)}
{!mainMatchesQuery && filtered.length === 0 && (
<div className="px-3 py-2 text-xs text-muted-foreground">
No worktrees match.
</div>
)}
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
};
interface WorktreeRowProps {
label: string;
sublabel: string | null;
isSelected: boolean;
onClick: () => void;
}
const WorktreeRow: React.FC<WorktreeRowProps> = ({ label, sublabel, isSelected, onClick }) => (
<button
type="button"
onClick={onClick}
className={`w-full flex items-center gap-2 mx-1 px-2 py-1.5 text-xs text-left rounded hover:bg-muted focus:outline-none focus:bg-muted ${
isSelected ? 'text-foreground font-medium' : 'text-foreground/80'
}`}
>
<span className="w-3 flex-shrink-0">
{isSelected && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</span>
<div className="min-w-0 flex-1">
<div className="truncate" title={label}>{label}</div>
{sublabel && (
<div className="truncate text-[10px] text-muted-foreground" title={sublabel}>{sublabel}</div>
)}
</div>
</button>
);