From b8716fe808d4ed8c2c9b9f85a6bad14102b057ed Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 15:00:52 +0300 Subject: [PATCH] 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. --- packages/ui/src/apps/MobileSessionsSheet.tsx | 13 ++-- .../chat/FileMentionAutocomplete.tsx | 15 ++-- .../ui/src/components/chat/ModelControls.tsx | 47 ++----------- .../chat/composer/ui/DraftTargetSelectors.tsx | 15 ++-- .../model-picker/ModelPickerList.tsx | 17 ++--- .../sections/providers/ProvidersPage.tsx | 22 ++---- .../remote-instances/RemoteInstancesPage.tsx | 12 ++-- .../skills/catalog/SkillsCatalogPage.tsx | 13 ++-- .../ui/src/components/views/ArchiveView.tsx | 3 +- packages/ui/src/components/views/DiffView.tsx | 6 +- packages/ui/src/components/views/GitView.tsx | 17 ++--- .../agent-manager/AgentManagerSidebar.tsx | 12 ++-- .../views/git/BranchIntegrationSection.tsx | 19 +++--- .../components/views/git/BranchSelector.tsx | 23 ++++--- .../views/git/IntegrateCommitsSection.tsx | 15 +++- .../components/views/git/StashesDialog.tsx | 10 +-- packages/ui/src/lib/search/fuzzySearch.ts | 68 ------------------- .../ui/src/lib/worktrees/branchSearch.test.ts | 33 +++++++++ packages/ui/src/lib/worktrees/branchSearch.ts | 47 ++++--------- 19 files changed, 145 insertions(+), 262 deletions(-) create mode 100644 packages/ui/src/lib/worktrees/branchSearch.test.ts diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 43a28f32..a64d94f0 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { getProjectLabel, normalizePath } from './mobilePaths'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useI18n } from '@/lib/i18n'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { cn } from '@/lib/utils'; import { @@ -188,11 +189,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null; }; -const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => { - if (!query) return true; - const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase(); - return haystack.includes(query); -}; +const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => + matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query); const MobileProjectIcon: React.FC<{ project: Pick; @@ -1355,7 +1353,7 @@ export const MobileSessionsSheet: React.FC = ({ open, const filteredNodes = React.useMemo(() => { if (!normalizedQuery) return projectNodes; return projectNodes.filter((node) => { - if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true; + if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true; return node.buckets.some((bucket) => bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)), ); @@ -1385,8 +1383,7 @@ export const MobileSessionsSheet: React.FC = ({ open, const searchProjectMatches = React.useMemo(() => { if (!normalizedQuery) return [] as Array; - return projectsMeta - .filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery)) + return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path]) .map((project) => ({ ...project, sessionCount: sessions.filter((session) => { diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 963e8c46..5258b132 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -15,6 +15,7 @@ import { useI18n } from '@/lib/i18n'; import { useUIStore } from '@/stores/useUIStore'; import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight'; import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip'; type FileInfo = ProjectFileSearchHit; @@ -95,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef typeof value === 'string' && value.length > 0); const seen = new Set(); - const queryLower = normalizedSearchQuery.toLowerCase(); const mapped = ordered .filter((filePath) => { if (seen.has(filePath)) return false; seen.add(filePath); const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath; - if (!queryLower) return true; - return relative.toLowerCase().includes(queryLower); + return matchesRankQuery([relative], normalizedSearchQuery); }) .slice(0, 6) .map((filePath) => { @@ -256,21 +255,15 @@ export const FileMentionAutocomplete = React.forwardRef { const visibleAgents = getVisibleAgents(); - const normalizedQuery = (searchQuery ?? '').trim().toLowerCase(); - const filtered = visibleAgents + const subagents = visibleAgents .filter((agent) => agent.mode && agent.mode !== 'primary') - .filter((agent) => { - if (!normalizedQuery) return true; - const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase(); - return haystack.includes(normalizedQuery); - }) .map((agent) => ({ name: agent.name, description: agent.description, mode: agent.mode, })) .sort((a, b) => a.name.localeCompare(b.name)); - setAgents(filtered); + setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description])); }, [getVisibleAgents, searchQuery]); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index a290fde8..7a953090 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device'; import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata'; import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay'; import { getEditModeColors } from '@/lib/permissions/editModeColors'; -import { cn, fuzzyMatch } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { useContextStore } from '@/stores/contextStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -528,13 +529,7 @@ export const ModelControls: React.FC = ({ const sortedAndFilteredAgents = React.useMemo(() => { const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name)); - if (!agentSearchQuery.trim()) { - return sorted; - } - return sorted.filter((agent) => - fuzzyMatch(agent.name, agentSearchQuery) || - (agent.description && fuzzyMatch(agent.description, agentSearchQuery)) - ); + return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]); }, [selectableDesktopAgents, agentSearchQuery]); const defaultAgentName = React.useMemo(() => { @@ -580,38 +575,10 @@ export const ModelControls: React.FC = ({ return result; }, [providers, hiddenModels]); - const normalizeModelSearchValue = React.useCallback((value: string) => { - const lower = value.toLowerCase().trim(); - const compact = lower.replace(/[^a-z0-9]/g, ''); - const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean); - return { lower, compact, tokens }; - }, []); - - const matchesModelSearch = React.useCallback((candidate: string, query: string) => { - const normalizedQuery = normalizeModelSearchValue(query); - if (!normalizedQuery.lower) { - return true; - } - - const normalizedCandidate = normalizeModelSearchValue(candidate); - if (normalizedCandidate.lower.includes(normalizedQuery.lower)) { - return true; - } - - if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) { - return true; - } - - if (normalizedQuery.tokens.length === 0) { - return false; - } - - return normalizedQuery.tokens.every((queryToken) => - normalizedCandidate.tokens.some((candidateToken) => - candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken) - ) - ); - }, [normalizeModelSearchValue]); + const matchesModelSearch = React.useCallback( + (candidate: string, query: string) => matchesRankQuery([candidate], query), + [], + ); const currentModelForMetadata = currentModelId ? models.find((model: ProviderModel) => model.id === currentModelId) diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index c6fba04c..886aad6e 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import { useI18n } from '@/lib/i18n'; +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; import type { Theme } from '@/types/theme'; @@ -264,13 +265,7 @@ export function MobileDraftTargetSheets( className="h-9" />
- {projects - .filter((project) => { - const needle = query.trim().toLowerCase(); - if (!needle) return true; - return getProjectDisplayLabel(project).toLowerCase().includes(needle) - || project.path.toLowerCase().includes(needle); - }) + {rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path]) .map((project) => (
- {worktreeBranchOptions - .filter((option) => matches(option.label)) + {rankByQuery(worktreeBranchOptions, query, (option) => [option.label]) .map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))} {selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '') ? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current') diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index 132f8fa2..4a277737 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -12,6 +12,7 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } import { CSS as DndCSS } from '@dnd-kit/utilities'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; +import { matchesRankQuery } from '@/lib/search/fuzzySearch'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -455,18 +456,18 @@ export const ModelPickerList: React.FC = ({ return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID); }, [hiddenModels]); - const matchesQuery = React.useCallback((modelName: string, providerName: string) => { - const query = searchQuery.trim().toLowerCase(); - if (!query) return true; - return modelName.toLowerCase().includes(query) || providerName.toLowerCase().includes(query); - }, [searchQuery]); + const matchesQuery = React.useCallback( + (modelName: string, providerName: string, modelID?: string) => + matchesRankQuery([modelName, modelID, providerName], searchQuery), + [searchQuery], + ); const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => { if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false; if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false; const providerName = providerById.get(providerID)?.name || providerID; - return matchesQuery(getModelDisplayName(model), providerName); + return matchesQuery(getModelDisplayName(model), providerName, modelID); }), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]); const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => { @@ -474,7 +475,7 @@ export const ModelPickerList: React.FC = ({ if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false; if (isHidden(providerID, modelID)) return false; const providerName = providerById.get(providerID)?.name || providerID; - return matchesQuery(getModelDisplayName(model), providerName); + return matchesQuery(getModelDisplayName(model), providerName, modelID); }), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]); const orderedProviders = React.useMemo(() => { @@ -495,7 +496,7 @@ export const ModelPickerList: React.FC = ({ const modelID = typeof model.id === 'string' ? model.id : ''; if (!modelID || isHidden(provider.id, modelID)) return false; if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false; - return matchesQuery(getModelDisplayName(model), provider.name || provider.id); + return matchesQuery(getModelDisplayName(model), provider.name || provider.id, modelID); }); return { ...provider, models: filteredModels }; }) diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index a84bb4fd..9ca24ca5 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -1,3 +1,4 @@ +import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; @@ -603,15 +604,9 @@ export const ProvidersPage: React.FC = () => { {(() => { - const query = providerSearchQuery.toLowerCase(); const customLabel = t('settings.providers.page.custom.optionLabel'); - const customMatches = !query - || customLabel.toLowerCase().includes(query) - || 'other'.includes(query) - || 'custom'.includes(query); - const filtered = unconnectedProviders.filter(p => { - return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query); - }); + const customMatches = matchesRankQuery([customLabel, 'other', 'custom'], providerSearchQuery); + const filtered = rankByQuery(unconnectedProviders, providerSearchQuery, (p) => [p.name || p.id, p.id]); if (filtered.length === 0 && !customMatches) { return

{t('settings.providers.page.connect.noProvidersFound')}

; } @@ -792,13 +787,10 @@ export const ProvidersPage: React.FC = () => { ? t('settings.providers.page.auth.useReconnectHint') : t('settings.providers.page.auth.incompleteHint'); - const filteredModels = providerModels.filter((model) => { - const name = typeof model?.name === 'string' ? model.name : ''; - const id = typeof model?.id === 'string' ? model.id : ''; - const query = modelQuery.trim().toLowerCase(); - if (!query) return true; - return name.toLowerCase().includes(query) || id.toLowerCase().includes(query); - }); + const filteredModels = rankByQuery(providerModels, modelQuery, (model) => [ + typeof model?.name === 'string' ? model.name : '', + typeof model?.id === 'string' ? model.id : '', + ]); if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) { return ( diff --git a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx index 558bb2ca..4bc0c087 100644 --- a/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx +++ b/packages/ui/src/components/sections/remote-instances/RemoteInstancesPage.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import QRCode from 'qrcode'; import { Button } from '@/components/ui/button'; @@ -1255,13 +1256,10 @@ export const RemoteInstancesPage: React.FC = () => { [createImportedInstance], ); - const filteredImportCandidates = React.useMemo(() => { - const query = sshHostSearch.trim().toLowerCase(); - if (!query) return importCandidates; - return importCandidates.filter((candidate) => { - return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query); - }); - }, [importCandidates, sshHostSearch]); + const filteredImportCandidates = React.useMemo( + () => rankByQuery(importCandidates, sshHostSearch, (candidate) => [candidate.host, candidate.sshCommand]), + [importCandidates, sshHostSearch], + ); // Opening a ready instance means pointing this window at the forwarded local // URL — the same navigation the host switcher performs after its own connect. diff --git a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx index 621aa2fa..40db43a5 100644 --- a/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx +++ b/packages/ui/src/components/sections/skills/catalog/SkillsCatalogPage.tsx @@ -1,3 +1,4 @@ +import { rankByQuery } from '@/lib/search/fuzzySearch'; import React from 'react'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -265,14 +266,12 @@ export const SkillsCatalogPage: React.FC = ({ mode, onMo const isSearching = search.trim().length > 0; const filtered = React.useMemo(() => { - const q = search.trim().toLowerCase(); - const matches = (item: SkillsCatalogItem) => - item.skillName.toLowerCase().includes(q) - || (item.description || '').toLowerCase().includes(q) - || (item.frontmatterName || '').toLowerCase().includes(q); - if (isSearching) { - return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches)); + return rankByQuery( + sources.flatMap((src) => itemsBySource[src.id] || []), + search, + (item) => [item.skillName, item.frontmatterName, item.description], + ); } if (!selectedSourceId) { return []; diff --git a/packages/ui/src/components/views/ArchiveView.tsx b/packages/ui/src/components/views/ArchiveView.tsx index b5f6b1c1..12c2b495 100644 --- a/packages/ui/src/components/views/ArchiveView.tsx +++ b/packages/ui/src/components/views/ArchiveView.tsx @@ -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 ?? []; diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 896208b7..28a15721 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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 = ({ } 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 (
diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 28fcf9f1..008550e6 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -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 = ({ isActive }) => { {t('gitView.gitmoji.title')} - + {/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */} + = ({ isActive }) => { {t('gitView.gitmoji.empty')} - {(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) => ( handleSelectGitmoji(entry.emoji, entry.code)} diff --git a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx index def5b964..58565c47 100644 --- a/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx +++ b/packages/ui/src/components/views/agent-manager/AgentManagerSidebar.tsx @@ -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 = ({ 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; diff --git a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx index 0348d3be..71a330bc 100644 --- a/packages/ui/src/components/views/git/BranchIntegrationSection.tsx +++ b/packages/ui/src/components/views/git/BranchIntegrationSection.tsx @@ -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 = // 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 = sideOffset={6} className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" > - + {/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */} + = ({ [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 = ({ - + {/* Filtering and ordering are owned by rankByQuery above; cmdk's own + filter would re-filter and reorder the already-ranked rows. */} + s.currentSessionId); const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false); + const [branchSearch, setBranchSearch] = React.useState(''); const searchInputRef = React.useRef(null); + const filteredBranches = React.useMemo( + () => rankByQuery(localBranches, branchSearch, (branch) => [branch]), + [localBranches, branchSearch] + ); + const [targetBranch, setTargetBranch] = React.useState(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" > - + {/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */} + event.stopPropagation()} /> {t('gitView.branch.empty')} - {localBranches.map((branch) => ( + {filteredBranches.map((branch) => ( {branch} diff --git a/packages/ui/src/components/views/git/StashesDialog.tsx b/packages/ui/src/components/views/git/StashesDialog.tsx index 979a6fa4..b3753e82 100644 --- a/packages/ui/src/components/views/git/StashesDialog.tsx +++ b/packages/ui/src/components/views/git/StashesDialog.tsx @@ -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 = ({ }; }, [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(); diff --git a/packages/ui/src/lib/search/fuzzySearch.ts b/packages/ui/src/lib/search/fuzzySearch.ts index 6d809201..52e669b2 100644 --- a/packages/ui/src/lib/search/fuzzySearch.ts +++ b/packages/ui/src/lib/search/fuzzySearch.ts @@ -41,54 +41,6 @@ export function matchesFuzzyQuery( return fuse.search(query).length > 0; } -function getFuzzyMatchMask( - items: T[], - query: string, - getText: (item: T) => string, - options?: FuzzySearchOptions -): boolean[] { - if (!query) { - return items.map(() => true); - } - - const mergedOptions = { ...DEFAULT_FUZZY_OPTIONS, ...options }; - const queryLower = query.toLowerCase(); - const matches = new Array(items.length).fill(false); - const fuzzyCandidateTexts: string[] = []; - const fuzzyCandidateIndices: number[] = []; - - for (let i = 0; i < items.length; i++) { - const target = getText(items[i]); - if (!target) { - continue; - } - - if (mergedOptions.preferSubstring && target.toLowerCase().includes(queryLower)) { - matches[i] = true; - continue; - } - - fuzzyCandidateTexts.push(target); - fuzzyCandidateIndices.push(i); - } - - if (fuzzyCandidateTexts.length === 0) { - return matches; - } - - const fuse = new Fuse(fuzzyCandidateTexts, { - threshold: mergedOptions.threshold, - distance: mergedOptions.distance, - ignoreLocation: mergedOptions.ignoreLocation, - }); - - for (const result of fuse.search(query)) { - matches[fuzzyCandidateIndices[result.refIndex]] = true; - } - - return matches; -} - /** * Score-sorted fuzzy ranking. Strict (low threshold), prioritizes substring * matches (especially prefix matches), and returns the top N. @@ -271,23 +223,3 @@ export function matchesRankQuery( return tokens.every((token) => scoreRankToken(token, rankFields) !== RANK_TOKEN_MISS); } -export function partitionByFuzzyQuery( - items: T[], - query: string, - getText: (item: T) => string, - options?: FuzzySearchOptions -): { matching: T[]; other: T[] } { - const matches = getFuzzyMatchMask(items, query, getText, options); - const matching: T[] = []; - const other: T[] = []; - - for (let i = 0; i < items.length; i++) { - if (matches[i]) { - matching.push(items[i]); - continue; - } - other.push(items[i]); - } - - return { matching, other }; -} diff --git a/packages/ui/src/lib/worktrees/branchSearch.test.ts b/packages/ui/src/lib/worktrees/branchSearch.test.ts new file mode 100644 index 00000000..99a1fee6 --- /dev/null +++ b/packages/ui/src/lib/worktrees/branchSearch.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; + +import { rankBranchesForQuery } from './branchSearch'; + +describe('rankBranchesForQuery', () => { + test('empty query keeps everything in the other groups', () => { + const result = rankBranchesForQuery({ localBranches: ['main'], remoteBranches: ['origin/dev'], query: ' ' }); + expect(result.matching).toEqual([]); + expect(result.otherLocal).toEqual(['main']); + expect(result.otherRemote).toEqual(['origin/dev']); + }); + + test('orders matches by relevance, not alphabetically', () => { + const result = rankBranchesForQuery({ + localBranches: ['aaa-fix-scroll', 'fix/scroll', 'main'], + remoteBranches: ['origin/fix/scroll-old'], + query: 'fix', + }); + expect(result.matching[0]).toEqual({ label: 'fix/scroll', value: 'fix/scroll', source: 'local' }); + expect(result.matching.map((entry) => entry.label)).toEqual([ + 'fix/scroll', + 'aaa-fix-scroll', + 'origin/fix/scroll-old', + ]); + expect(result.otherLocal).toEqual(['main']); + expect(result.otherRemote).toEqual([]); + }); + + test('remote matches carry the remotes/ checkout value', () => { + const result = rankBranchesForQuery({ localBranches: [], remoteBranches: ['origin/feat/x'], query: 'feat' }); + expect(result.matching[0].value).toBe('remotes/origin/feat/x'); + }); +}); diff --git a/packages/ui/src/lib/worktrees/branchSearch.ts b/packages/ui/src/lib/worktrees/branchSearch.ts index 3bf564cf..d08953f4 100644 --- a/packages/ui/src/lib/worktrees/branchSearch.ts +++ b/packages/ui/src/lib/worktrees/branchSearch.ts @@ -1,4 +1,4 @@ -import { partitionByFuzzyQuery } from "@/lib/search/fuzzySearch"; +import { rankByQuery } from "@/lib/search/fuzzySearch"; export interface RankedBranchGroups { matching: Array<{ @@ -26,42 +26,19 @@ export function rankBranchesForQuery(args: { }; } - const localPartition = partitionByFuzzyQuery(localBranches, normalizedQuery, (branch) => branch); - const remotePartition = partitionByFuzzyQuery(remoteBranches, normalizedQuery, (branch) => branch); - const matching: RankedBranchGroups['matching'] = []; - const otherLocal = localPartition.other; - const otherRemote = remotePartition.other; - - for (const branch of localPartition.matching) { - matching.push({ - label: branch, - value: branch, - source: 'local', - }); - } - - for (const branch of remotePartition.matching) { - matching.push({ - label: branch, - value: `remotes/${branch}`, - source: 'remote', - }); - } - - matching.sort((a, b) => { - const byLabel = a.label.localeCompare(b.label, undefined, { sensitivity: 'accent' }); - if (byLabel !== 0) { - return byLabel; - } - if (a.source !== b.source) { - return a.source.localeCompare(b.source); - } - return a.value.localeCompare(b.value); - }); + // Rank local and remote branches together so the order reflects match + // quality (an exact or prefix match lands first), not the source group or + // the alphabet. + const candidates: RankedBranchGroups['matching'] = [ + ...localBranches.map((branch) => ({ label: branch, value: branch, source: 'local' as const })), + ...remoteBranches.map((branch) => ({ label: branch, value: `remotes/${branch}`, source: 'remote' as const })), + ]; + const matching = rankByQuery(candidates, normalizedQuery, (branch) => [branch.label]); + const matched = new Set(matching); return { matching, - otherLocal, - otherRemote, + otherLocal: candidates.filter((entry) => entry.source === 'local' && !matched.has(entry)).map((entry) => entry.label), + otherRemote: candidates.filter((entry) => entry.source === 'remote' && !matched.has(entry)).map((entry) => entry.label), }; }