/** * The branch picker popover: searchable local branch list with the current * branch checked, the dirtiness line, switch feedback (success/error), and * the footer flows (create branch / Git graph). * @module dsh-git-graph/client/chips/BranchPopover */ import { useEffect, useMemo, useRef, useState } from 'react' import { IconBranchOutlineRegular, IconCheckOutlineMedium, IconSearchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { BranchesView, SwitchResult } from '../../core/types.ts' import type { GitGraphKey } from '../locales.ts' import { errorMessage } from './error-copy.ts' import { cx, Backdrop } from './Chip.tsx' import css from './context.module.css' /** Props of the branch picker popover. */ export interface BranchPopoverProps { view: BranchesView /** Workspace-level switch verb; resolves to a stable git error on rejection. */ onSwitch: (branch: string) => Promise /** Fired after a successful switch (the owner refetches its status). */ onSwitched: () => void /** Open the create-branch dialog. */ onCreate: () => void /** Open the Git graph panel. */ onGraph: () => void /** Open the create-worktree (new isolated session) dialog. */ onCreateWorktree: () => void /** Open the worktree manager. */ onManageWorktrees: () => void /** Close the popover (backdrop / after a successful switch). */ onClose: () => void t: Translate /** Open downward from the official hero row (the default opens upward from the dock row). */ hero?: boolean } /** How long the success notice stays before the popover closes itself. */ const SUCCESS_DISMISS_MS = 900 /** * The branch picker popover. * @param props - see {@link BranchPopoverProps}. */ export function BranchPopover({ view, onSwitch, onSwitched, onCreate, onGraph, onCreateWorktree, onManageWorktrees, onClose, t, hero = false }: BranchPopoverProps) { const [query, setQuery] = useState('') const [pending, setPending] = useState(null) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const dismissTimer = useRef | undefined>(undefined) // Tooltip state for long branch names: first hover needs a 500ms dwell, // once shown, switching items shows immediately; leaving resets the dwell. const [tipReadyName, setTipReadyName] = useState(null) const [tipActive, setTipActive] = useState(false) const [tipDir, setTipDir] = useState<'up' | 'down'>('up') const tipTimer = useRef | undefined>(undefined) useEffect(() => () => { if (dismissTimer.current !== undefined) clearTimeout(dismissTimer.current) if (tipTimer.current !== undefined) clearTimeout(tipTimer.current) }, []) const filtered = useMemo(() => { const needle = query.trim().toLowerCase() if (needle === '') return view.branches return view.branches.filter(branch => branch.name.toLowerCase().includes(needle)) }, [view.branches, query]) const switchTo = (branch: string): void => { if (pending !== null) return setPending(branch) setError(null) setSuccess(null) void onSwitch(branch).then((result) => { if (result.ok) { onSwitched() setSuccess(t('toast.switchSuccess', { branchName: result.branch })) dismissTimer.current = setTimeout(onClose, SUCCESS_DISMISS_MS) return } setError(errorMessage(result.error, t)) }).finally(() => { setPending(null) }) } return ( <>
{ setQuery(event.target.value) }} placeholder={t('branch.search')} autoFocus />
{view.dirtyFiles > 0 &&
{t('branch.dirty', { count: view.dirtyFiles })}
}
{ // Leaving the whole list resets the instant-handoff state, so // moving between items keeps the bubble immediate while leaving // the popover re-arms the dwell threshold. if (tipTimer.current !== undefined) { clearTimeout(tipTimer.current) tipTimer.current = undefined } setTipActive(false) setTipReadyName(null) }} > {filtered.length === 0 ?
{t('branch.empty')}
: filtered.map(branch => ( ))}
{success !== null &&
{success}
} {error !== null &&
{error}
}
) }