diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index b90e3cb175a..c4df84abad3 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -511,6 +511,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { () => reconcilePaletteFilter(rawFilter, filterModel), [rawFilter, filterModel] ) + // Why persist the prune: otherwise a dropped id lives on in rawFilter and + // silently re-activates — with no chip on screen — if its host or project + // comes back. Same-reference return on a no-op keeps this from re-rendering. + useEffect(() => { + setRawFilter((current) => reconcilePaletteFilter(current, filterModel)) + }, [filterModel]) const filterActive = isPaletteFilterActive(filter) const hostFilterActive = filter.hostIds.length > 0 const filterPredicate = useMemo( @@ -1795,7 +1801,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { // No-op: focus handled in the visible effect before Radix; exists only to satisfy the prop API. }, []) - const resultCount = selectableItems.length + // Why the split: on a query the render cap hides matches, so announcing the + // visible slice under-reports "results found". The empty-query list renders + // only its two capped sections, so there the visible count is the truth. + const resultCount = hasQuery + ? worktreeItems.length + projectTargetItems.length + middleItems.length + openTabItems.length + : selectableItems.length const emptyState = (() => { // Why: a filter is the most likely reason a familiar query returns nothing, // so name it before any other explanation and point at the way out. diff --git a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx index 34c6b2e1707..0d29e0399d7 100644 --- a/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx +++ b/src/renderer/src/components/cmd-j/PaletteFilterFieldOptions.tsx @@ -92,14 +92,19 @@ export function PaletteFilterFieldOptions({ [group.options, selected, normalizedQuery, rankMode] ) - const scrollRef = useRef(null) + // State, not a ref: the scroller unmounts on an empty list, and the wheel + // listener has to re-attach to the node that replaces it. + const [scrollEl, setScrollEl] = useState(null) const inputRef = useRef(null) - const [activeIndex, setActiveIndex] = useState(0) + const [highlightIndex, setHighlightIndex] = useState(0) - // Reset highlight when the ranked list identity changes (search/selection). + // Why not the ranked list identity: a toggle re-ranks, so that would yank the + // cursor back to the top mid multi-select. Only a new query or field moves it. useEffect(() => { - setActiveIndex(0) - }, [ranked.ordered]) + setHighlightIndex(0) + }, [normalizedQuery, group.field]) + // The stored index can outlive the rows it pointed at when the list shrinks. + const activeIndex = Math.min(highlightIndex, Math.max(0, ranked.ordered.length - 1)) useEffect(() => { inputRef.current?.focus() @@ -107,7 +112,7 @@ export function PaletteFilterFieldOptions({ const virtualizer = useVirtualizer({ count: ranked.ordered.length, - getScrollElement: () => scrollRef.current, + getScrollElement: () => scrollEl, estimateSize: () => FILTER_OPTION_ROW_HEIGHT, overscan: 8, getItemKey: (index) => ranked.ordered[index]?.id ?? index @@ -115,7 +120,7 @@ export function PaletteFilterFieldOptions({ // Same wheel workaround as CommandList: Radix remove-scroll cancels wheel on portaled content. useEffect(() => { - const el = scrollRef.current + const el = scrollEl if (!el) { return } @@ -128,24 +133,24 @@ export function PaletteFilterFieldOptions({ } el.addEventListener('wheel', onWheel, { passive: false }) return () => el.removeEventListener('wheel', onWheel) - }, []) + }, [scrollEl]) const moveActive = useCallback( (delta: number) => { if (ranked.ordered.length === 0) { return } - setActiveIndex((current) => { - const next = Math.max(0, Math.min(ranked.ordered.length - 1, current + delta)) - virtualizer.scrollToIndex(next, { align: 'auto' }) - return next - }) + const next = Math.max(0, Math.min(ranked.ordered.length - 1, activeIndex + delta)) + virtualizer.scrollToIndex(next, { align: 'auto' }) + setHighlightIndex(next) }, - [ranked.ordered.length, virtualizer] + [activeIndex, ranked.ordered.length, virtualizer] ) const handleListKeyDown = useCallback( - (event: React.KeyboardEvent) => { + // Space toggles only from the listbox; in the search input it stays a + // typeable character, and labels like "Delta Host" need it. + (event: React.KeyboardEvent, allowSpaceToToggle = true) => { if (event.key === 'ArrowDown') { event.preventDefault() moveActive(1) @@ -156,7 +161,7 @@ export function PaletteFilterFieldOptions({ moveActive(-1) return } - if (event.key === 'Enter' || event.key === ' ') { + if (event.key === 'Enter' || (allowSpaceToToggle && event.key === ' ')) { const option = ranked.ordered[activeIndex] if (!option) { return @@ -176,6 +181,12 @@ export function PaletteFilterFieldOptions({ group.field === 'host' ? translate('worktreeJumpPalette.filter.noHosts', 'No matching hosts') : translate('worktreeJumpPalette.filter.noProjects', 'No matching projects') + // Why a dedicated string per field: lowercasing a translated heading breaks in + // languages that capitalize nouns mid-sentence (German "Projekte"). + const clearLabel = + group.field === 'host' + ? translate('worktreeJumpPalette.filter.clearHosts', 'Clear hosts') + : translate('worktreeJumpPalette.filter.clearProjects', 'Clear projects') const canSelectAll = ranked.unselectedCount > 0 const canClear = ranked.selectedCount > 0 @@ -209,7 +220,7 @@ export function PaletteFilterFieldOptions({ ref={inputRef} value={optionQuery} onChange={(event) => onOptionQueryChange(event.target.value)} - onKeyDown={handleListKeyDown} + onKeyDown={(event) => handleListKeyDown(event, false)} placeholder={searchPlaceholder} className="h-9 w-full bg-transparent text-[13px] outline-none placeholder:text-muted-foreground" aria-label={searchPlaceholder} @@ -229,9 +240,7 @@ export function PaletteFilterFieldOptions({ onClick={onClearField} className="shrink-0 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground" > - {translate('worktreeJumpPalette.filter.clearField', 'Clear {{value0}}', { - value0: group.heading.toLowerCase() - })} + {clearLabel} ) : null} @@ -239,7 +248,7 @@ export function PaletteFilterFieldOptions({
{emptyLabel}
) : (
- {translate('worktreeJumpPalette.filter.clearField', 'Clear {{value0}}', { - value0: group.heading.toLowerCase() - })} + {clearLabel} ) : null}
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c39cb20b437..9fd0d7378c8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -132,7 +132,8 @@ "moreOptions": "{{value0}} more - keep typing to narrow", "selectAllMatching": "Select all matching ({{value0}})", "selectedCollapsed": "{{value0}} selected — remove via chips or Clear", - "clearField": "Clear {{value0}}", + "clearHosts": "Clear hosts", + "clearProjects": "Clear projects", "back": "Back", "ariaActive": "Filter: {{value0}} active." }