refactor(search): unify dropdown filtering on the shared ranked matcher

Branch, project, agent, model, provider, stash, SSH-host, skill-catalog
and archive filters each had their own toLowerCase().includes (or no
ordering at all); the git branch and gitmoji pickers also let cmdk
re-filter and reorder on top of the manual filter, silently dropping
rows. All of them now go through rankByQuery/matchesRankQuery: results
are relevance-ordered, multi-word queries match in any order, matching
ignores punctuation, and cmdk filtering is disabled where the ranked
list is already final. rankBranchesForQuery keeps relevance order
instead of re-sorting matches alphabetically; the model picker now also
matches model ids.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 15:00:52 +03:00
parent 081056e6e5
commit b8716fe808
19 changed files with 145 additions and 262 deletions
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon';
@@ -67,7 +68,7 @@ export function ArchiveView(): React.ReactNode {
// while not searching.
const filteredSessions = React.useMemo(() => {
if (normalizedQuery) {
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]);
}
if (selectedDirectory === null) return sortedSessions;
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
@@ -8,6 +8,7 @@ import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeD
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
import {
DropdownMenu,
@@ -1953,12 +1954,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
}
if (!branchBase) {
const searchTerm = basePickerSearch.trim().toLowerCase();
const candidateBranches = (branches?.all ?? [])
const eligibleBranches = (branches?.all ?? [])
.map((name: string) => name.replace(/^remotes\//, ''))
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
.filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm))
.sort();
const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Icon name="git-branch" className="size-6 text-muted-foreground" />
+4 -13
View File
@@ -3,6 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -2585,7 +2586,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
<DialogHeader className="px-4 pt-4">
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
<Command className="h-[420px]" shouldFilter={false}>
<CommandInput
placeholder={t('gitView.gitmoji.searchPlaceholder')}
value={gitmojiSearch}
@@ -2594,18 +2596,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
<CommandList>
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
<CommandGroup>
{(gitmojiEmojis.length === 0
? []
: gitmojiEmojis.filter((entry) => {
const term = gitmojiSearch.trim().toLowerCase();
if (!term) return true;
return (
entry.emoji.includes(term) ||
entry.code.toLowerCase().includes(term) ||
entry.description.toLowerCase().includes(term)
);
})
).map((entry) => (
{rankByQuery(gitmojiEmojis, gitmojiSearch, (entry) => [entry.code, entry.description, entry.emoji]).map((entry) => (
<CommandItem
key={entry.code}
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { toast } from '@/components/ui';
import { Input } from '@/components/ui/input';
@@ -214,13 +215,10 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
const MAX_VISIBLE = 5;
const filteredGroups = React.useMemo(() => {
if (!searchQuery.trim()) return groups;
const query = searchQuery.toLowerCase();
return groups.filter(group =>
group.name.toLowerCase().includes(query)
);
}, [searchQuery, groups]);
const filteredGroups = React.useMemo(
() => rankByQuery(groups, searchQuery, (group) => [group.name]),
[searchQuery, groups],
);
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
const remainingCount = filteredGroups.length - MAX_VISIBLE;
@@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
type OperationType = 'merge' | 'rebase';
@@ -94,22 +95,19 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
// Filter branches based on search
const filteredLocal = React.useMemo(() => {
const term = branchSearch.toLowerCase();
const remoteBranchNames = new Set(
remoteBranches
.map((branch) => branch.slice(branch.indexOf('/') + 1))
.filter(Boolean)
);
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
if (!term) return filtered;
return filtered.filter((b) => b.toLowerCase().includes(term));
const candidates = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
return rankByQuery(candidates, branchSearch, (branch) => [branch]);
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
const filteredRemote = React.useMemo(() => {
const term = branchSearch.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [branchSearch, remoteBranches]);
const filteredRemote = React.useMemo(
() => rankByQuery(remoteBranches, branchSearch, (branch) => [branch]),
[branchSearch, remoteBranches]
);
const resolveDefaultBranch = React.useCallback(() => {
if (!defaultTargetBranch) return null;
@@ -321,7 +319,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
sideOffset={6}
className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
<Command className="h-full min-h-0" shouldFilter={false}>
<CommandInput
ref={searchInputRef}
placeholder={t('gitView.branch.searchPlaceholder')}
@@ -17,6 +17,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { GitRemote } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
interface BranchInfo {
@@ -78,17 +79,15 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
[newBranchName]
);
const filteredLocal = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return localBranches;
return localBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, localBranches]);
const filteredLocal = React.useMemo(
() => rankByQuery(localBranches, search, (branch) => [branch]),
[search, localBranches]
);
const filteredRemote = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, remoteBranches]);
const filteredRemote = React.useMemo(
() => rankByQuery(remoteBranches, search, (branch) => [branch]),
[search, remoteBranches]
);
const handleCheckout = (branch: string) => {
if (branch === currentBranch) {
@@ -184,7 +183,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
</Tooltip>
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
<Command className="h-full min-h-0">
{/* Filtering and ordering are owned by rankByQuery above; cmdk's own
filter would re-filter and reorder the already-ranked rows. */}
<Command className="h-full min-h-0" shouldFilter={false}>
<CommandInput
placeholder={t('gitView.branch.searchPlaceholder')}
value={search}
@@ -19,6 +19,7 @@ import { Icon } from "@/components/icon/Icon";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { getGitCommitSummaries } from '@/lib/gitApi';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import {
@@ -66,8 +67,14 @@ export const IntegrateCommitsSection: React.FC<{
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const [branchSearch, setBranchSearch] = React.useState('');
const searchInputRef = React.useRef<HTMLInputElement>(null);
const filteredBranches = React.useMemo(
() => rankByQuery(localBranches, branchSearch, (branch) => [branch]),
[localBranches, branchSearch]
);
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
React.useEffect(() => {
setTargetBranch(defaultTargetBranch);
@@ -380,10 +387,13 @@ export const IntegrateCommitsSection: React.FC<{
align="end"
className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
<Command className="h-full min-h-0" shouldFilter={false}>
<CommandInput
ref={searchInputRef}
placeholder={t('gitView.branch.searchPlaceholder')}
value={branchSearch}
onValueChange={setBranchSearch}
onKeyDown={(event) => event.stopPropagation()}
/>
<CommandList
@@ -393,7 +403,7 @@ export const IntegrateCommitsSection: React.FC<{
>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
<CommandGroup heading={t('gitView.branch.localBranches')}>
{localBranches.map((branch) => (
{filteredBranches.map((branch) => (
<CommandItem
key={branch}
value={branch}
@@ -401,6 +411,7 @@ export const IntegrateCommitsSection: React.FC<{
setTargetBranch(branch);
persistTarget(branch);
setBranchDropdownOpen(false);
setBranchSearch('');
}}
>
{branch}
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
@@ -69,11 +70,10 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
};
}, [directory, open, stashes]);
const filtered = React.useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return stashes;
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
}, [query, stashes]);
const filtered = React.useMemo(
() => rankByQuery(stashes, query, (stash) => [stash.message, stash.ref, stash.relativeTime]),
[query, stashes],
);
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
await load();