mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Fix worktree jump-palette filter persistence and search UX
- Persist filter state when filter model changes to prevent dropped IDs from silently re-activating - Fix result count to distinguish between query matches (all items) vs. empty list (capped sections) - Improve filter field options: use state for scroller to handle unmount/remount, clamp highlight index to valid range, only reset on query/field change, not re-ranks - Context-aware space key: allow toggle in listbox only, not in search input (preserves for typing) - Replace generic "Clear field" translation with field-specific strings to preserve capitalization in non-English languages
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -92,14 +92,19 @@ export function PaletteFilterFieldOptions({
|
||||
[group.options, selected, normalizedQuery, rankMode]
|
||||
)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(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<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(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}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -239,7 +248,7 @@ export function PaletteFilterFieldOptions({
|
||||
<div className="px-3 py-4 text-center text-[12px] text-muted-foreground">{emptyLabel}</div>
|
||||
) : (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
ref={setScrollEl}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label={group.heading}
|
||||
@@ -298,9 +307,7 @@ export function PaletteFilterFieldOptions({
|
||||
onClick={onClearField}
|
||||
className="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}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user