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:
@@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|||||||
import { getProjectLabel, normalizePath } from './mobilePaths';
|
import { getProjectLabel, normalizePath } from './mobilePaths';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -188,11 +189,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj
|
|||||||
return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null;
|
return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => {
|
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean =>
|
||||||
if (!query) return true;
|
matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query);
|
||||||
const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase();
|
|
||||||
return haystack.includes(query);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MobileProjectIcon: React.FC<{
|
const MobileProjectIcon: React.FC<{
|
||||||
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
|
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
|
||||||
@@ -1355,7 +1353,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
const filteredNodes = React.useMemo(() => {
|
const filteredNodes = React.useMemo(() => {
|
||||||
if (!normalizedQuery) return projectNodes;
|
if (!normalizedQuery) return projectNodes;
|
||||||
return projectNodes.filter((node) => {
|
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) =>
|
return node.buckets.some((bucket) =>
|
||||||
bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)),
|
bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)),
|
||||||
);
|
);
|
||||||
@@ -1385,8 +1383,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
|
|||||||
|
|
||||||
const searchProjectMatches = React.useMemo(() => {
|
const searchProjectMatches = React.useMemo(() => {
|
||||||
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
|
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
|
||||||
return projectsMeta
|
return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path])
|
||||||
.filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery))
|
|
||||||
.map((project) => ({
|
.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
sessionCount: sessions.filter((session) => {
|
sessionCount: sessions.filter((session) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||||
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
|
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
|
||||||
|
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
|
||||||
|
|
||||||
type FileInfo = ProjectFileSearchHit;
|
type FileInfo = ProjectFileSearchHit;
|
||||||
@@ -95,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const queryLower = normalizedSearchQuery.toLowerCase();
|
|
||||||
const mapped = ordered
|
const mapped = ordered
|
||||||
.filter((filePath) => {
|
.filter((filePath) => {
|
||||||
if (seen.has(filePath)) return false;
|
if (seen.has(filePath)) return false;
|
||||||
seen.add(filePath);
|
seen.add(filePath);
|
||||||
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
|
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
|
||||||
if (!queryLower) return true;
|
return matchesRankQuery([relative], normalizedSearchQuery);
|
||||||
return relative.toLowerCase().includes(queryLower);
|
|
||||||
})
|
})
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((filePath) => {
|
.map((filePath) => {
|
||||||
@@ -256,21 +255,15 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const visibleAgents = getVisibleAgents();
|
const visibleAgents = getVisibleAgents();
|
||||||
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
|
const subagents = visibleAgents
|
||||||
const filtered = visibleAgents
|
|
||||||
.filter((agent) => agent.mode && agent.mode !== 'primary')
|
.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) => ({
|
.map((agent) => ({
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
description: agent.description,
|
description: agent.description,
|
||||||
mode: agent.mode,
|
mode: agent.mode,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setAgents(filtered);
|
setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
|
||||||
}, [getVisibleAgents, searchQuery]);
|
}, [getVisibleAgents, searchQuery]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
|
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
|
||||||
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
|
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
|
||||||
import { getEditModeColors } from '@/lib/permissions/editModeColors';
|
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 { useContextStore } from '@/stores/contextStore';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
@@ -528,13 +529,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
|
|
||||||
const sortedAndFilteredAgents = React.useMemo(() => {
|
const sortedAndFilteredAgents = React.useMemo(() => {
|
||||||
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
if (!agentSearchQuery.trim()) {
|
return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
return sorted.filter((agent) =>
|
|
||||||
fuzzyMatch(agent.name, agentSearchQuery) ||
|
|
||||||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
|
|
||||||
);
|
|
||||||
}, [selectableDesktopAgents, agentSearchQuery]);
|
}, [selectableDesktopAgents, agentSearchQuery]);
|
||||||
|
|
||||||
const defaultAgentName = React.useMemo(() => {
|
const defaultAgentName = React.useMemo(() => {
|
||||||
@@ -580,38 +575,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
|
|||||||
return result;
|
return result;
|
||||||
}, [providers, hiddenModels]);
|
}, [providers, hiddenModels]);
|
||||||
|
|
||||||
const normalizeModelSearchValue = React.useCallback((value: string) => {
|
const matchesModelSearch = React.useCallback(
|
||||||
const lower = value.toLowerCase().trim();
|
(candidate: string, query: string) => matchesRankQuery([candidate], query),
|
||||||
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 currentModelForMetadata = currentModelId
|
const currentModelForMetadata = currentModelId
|
||||||
? models.find((model: ProviderModel) => model.id === currentModelId)
|
? models.find((model: ProviderModel) => model.id === currentModelId)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||||
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
|
||||||
import type { Theme } from '@/types/theme';
|
import type { Theme } from '@/types/theme';
|
||||||
@@ -264,13 +265,7 @@ export function MobileDraftTargetSheets(
|
|||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{projects
|
{rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path])
|
||||||
.filter((project) => {
|
|
||||||
const needle = query.trim().toLowerCase();
|
|
||||||
if (!needle) return true;
|
|
||||||
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|
|
||||||
|| project.path.toLowerCase().includes(needle);
|
|
||||||
})
|
|
||||||
.map((project) => (
|
.map((project) => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -304,8 +299,7 @@ export function MobileDraftTargetSheets(
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{(() => {
|
{(() => {
|
||||||
const needle = query.trim().toLowerCase();
|
const matches = (label: string) => matchesRankQuery([label], query);
|
||||||
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
|
|
||||||
const selectedValue = selectedDirectory
|
const selectedValue = selectedDirectory
|
||||||
?? branchItems[0]?.value
|
?? branchItems[0]?.value
|
||||||
?? normalizePath(selectedProject.path)
|
?? normalizePath(selectedProject.path)
|
||||||
@@ -349,8 +343,7 @@ export function MobileDraftTargetSheets(
|
|||||||
{t('chat.chatInput.worktreeNew')}
|
{t('chat.chatInput.worktreeNew')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{worktreeBranchOptions
|
{rankByQuery(worktreeBranchOptions, query, (option) => [option.label])
|
||||||
.filter((option) => matches(option.label))
|
|
||||||
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
|
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
|
||||||
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
|
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
|
||||||
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
|
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy }
|
|||||||
import { CSS as DndCSS } from '@dnd-kit/utilities';
|
import { CSS as DndCSS } from '@dnd-kit/utilities';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@@ -455,18 +456,18 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
|||||||
return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID);
|
return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID);
|
||||||
}, [hiddenModels]);
|
}, [hiddenModels]);
|
||||||
|
|
||||||
const matchesQuery = React.useCallback((modelName: string, providerName: string) => {
|
const matchesQuery = React.useCallback(
|
||||||
const query = searchQuery.trim().toLowerCase();
|
(modelName: string, providerName: string, modelID?: string) =>
|
||||||
if (!query) return true;
|
matchesRankQuery([modelName, modelID, providerName], searchQuery),
|
||||||
return modelName.toLowerCase().includes(query) || providerName.toLowerCase().includes(query);
|
[searchQuery],
|
||||||
}, [searchQuery]);
|
);
|
||||||
|
|
||||||
const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => {
|
const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => {
|
||||||
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
|
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
|
||||||
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
||||||
if (isHidden(providerID, modelID)) return false;
|
if (isHidden(providerID, modelID)) return false;
|
||||||
const providerName = providerById.get(providerID)?.name || providerID;
|
const providerName = providerById.get(providerID)?.name || providerID;
|
||||||
return matchesQuery(getModelDisplayName(model), providerName);
|
return matchesQuery(getModelDisplayName(model), providerName, modelID);
|
||||||
}), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]);
|
}), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]);
|
||||||
|
|
||||||
const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => {
|
const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => {
|
||||||
@@ -474,7 +475,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
|||||||
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
|
||||||
if (isHidden(providerID, modelID)) return false;
|
if (isHidden(providerID, modelID)) return false;
|
||||||
const providerName = providerById.get(providerID)?.name || providerID;
|
const providerName = providerById.get(providerID)?.name || providerID;
|
||||||
return matchesQuery(getModelDisplayName(model), providerName);
|
return matchesQuery(getModelDisplayName(model), providerName, modelID);
|
||||||
}), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]);
|
}), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]);
|
||||||
|
|
||||||
const orderedProviders = React.useMemo(() => {
|
const orderedProviders = React.useMemo(() => {
|
||||||
@@ -495,7 +496,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
|
|||||||
const modelID = typeof model.id === 'string' ? model.id : '';
|
const modelID = typeof model.id === 'string' ? model.id : '';
|
||||||
if (!modelID || isHidden(provider.id, modelID)) return false;
|
if (!modelID || isHidden(provider.id, modelID)) return false;
|
||||||
if (isModelAllowed && !isModelAllowed(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 };
|
return { ...provider, models: filteredModels };
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||||
@@ -603,15 +604,9 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
|
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
|
||||||
{(() => {
|
{(() => {
|
||||||
const query = providerSearchQuery.toLowerCase();
|
|
||||||
const customLabel = t('settings.providers.page.custom.optionLabel');
|
const customLabel = t('settings.providers.page.custom.optionLabel');
|
||||||
const customMatches = !query
|
const customMatches = matchesRankQuery([customLabel, 'other', 'custom'], providerSearchQuery);
|
||||||
|| customLabel.toLowerCase().includes(query)
|
const filtered = rankByQuery(unconnectedProviders, providerSearchQuery, (p) => [p.name || p.id, p.id]);
|
||||||
|| 'other'.includes(query)
|
|
||||||
|| 'custom'.includes(query);
|
|
||||||
const filtered = unconnectedProviders.filter(p => {
|
|
||||||
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
|
|
||||||
});
|
|
||||||
if (filtered.length === 0 && !customMatches) {
|
if (filtered.length === 0 && !customMatches) {
|
||||||
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
|
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
|
||||||
}
|
}
|
||||||
@@ -792,13 +787,10 @@ export const ProvidersPage: React.FC = () => {
|
|||||||
? t('settings.providers.page.auth.useReconnectHint')
|
? t('settings.providers.page.auth.useReconnectHint')
|
||||||
: t('settings.providers.page.auth.incompleteHint');
|
: t('settings.providers.page.auth.incompleteHint');
|
||||||
|
|
||||||
const filteredModels = providerModels.filter((model) => {
|
const filteredModels = rankByQuery(providerModels, modelQuery, (model) => [
|
||||||
const name = typeof model?.name === 'string' ? model.name : '';
|
typeof model?.name === 'string' ? model.name : '',
|
||||||
const id = typeof model?.id === 'string' ? model.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);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
|
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -1255,13 +1256,10 @@ export const RemoteInstancesPage: React.FC = () => {
|
|||||||
[createImportedInstance],
|
[createImportedInstance],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredImportCandidates = React.useMemo(() => {
|
const filteredImportCandidates = React.useMemo(
|
||||||
const query = sshHostSearch.trim().toLowerCase();
|
() => rankByQuery(importCandidates, sshHostSearch, (candidate) => [candidate.host, candidate.sshCommand]),
|
||||||
if (!query) return importCandidates;
|
[importCandidates, sshHostSearch],
|
||||||
return importCandidates.filter((candidate) => {
|
);
|
||||||
return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query);
|
|
||||||
});
|
|
||||||
}, [importCandidates, sshHostSearch]);
|
|
||||||
|
|
||||||
// Opening a ready instance means pointing this window at the forwarded local
|
// Opening a ready instance means pointing this window at the forwarded local
|
||||||
// URL — the same navigation the host switcher performs after its own connect.
|
// URL — the same navigation the host switcher performs after its own connect.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
|
||||||
@@ -265,14 +266,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
|
|||||||
const isSearching = search.trim().length > 0;
|
const isSearching = search.trim().length > 0;
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
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) {
|
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) {
|
if (!selectedSourceId) {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
@@ -67,7 +68,7 @@ export function ArchiveView(): React.ReactNode {
|
|||||||
// while not searching.
|
// while not searching.
|
||||||
const filteredSessions = React.useMemo(() => {
|
const filteredSessions = React.useMemo(() => {
|
||||||
if (normalizedQuery) {
|
if (normalizedQuery) {
|
||||||
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
|
return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]);
|
||||||
}
|
}
|
||||||
if (selectedDirectory === null) return sortedSessions;
|
if (selectedDirectory === null) return sortedSessions;
|
||||||
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
|
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 { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -1953,12 +1954,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!branchBase) {
|
if (!branchBase) {
|
||||||
const searchTerm = basePickerSearch.trim().toLowerCase();
|
const eligibleBranches = (branches?.all ?? [])
|
||||||
const candidateBranches = (branches?.all ?? [])
|
|
||||||
.map((name: string) => name.replace(/^remotes\//, ''))
|
.map((name: string) => name.replace(/^remotes\//, ''))
|
||||||
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
|
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
|
||||||
.filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm))
|
|
||||||
.sort();
|
.sort();
|
||||||
|
const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
<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" />
|
<Icon name="git-branch" className="size-6 text-muted-foreground" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
|||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||||
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
|
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
|
||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
@@ -2585,7 +2586,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
<DialogHeader className="px-4 pt-4">
|
<DialogHeader className="px-4 pt-4">
|
||||||
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
|
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Command className="h-[420px]">
|
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
|
||||||
|
<Command className="h-[420px]" shouldFilter={false}>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder={t('gitView.gitmoji.searchPlaceholder')}
|
placeholder={t('gitView.gitmoji.searchPlaceholder')}
|
||||||
value={gitmojiSearch}
|
value={gitmojiSearch}
|
||||||
@@ -2594,18 +2596,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
|
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{(gitmojiEmojis.length === 0
|
{rankByQuery(gitmojiEmojis, gitmojiSearch, (entry) => [entry.code, entry.description, entry.emoji]).map((entry) => (
|
||||||
? []
|
|
||||||
: 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) => (
|
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={entry.code}
|
key={entry.code}
|
||||||
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
|
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -214,13 +215,10 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
|
|||||||
|
|
||||||
const MAX_VISIBLE = 5;
|
const MAX_VISIBLE = 5;
|
||||||
|
|
||||||
const filteredGroups = React.useMemo(() => {
|
const filteredGroups = React.useMemo(
|
||||||
if (!searchQuery.trim()) return groups;
|
() => rankByQuery(groups, searchQuery, (group) => [group.name]),
|
||||||
const query = searchQuery.toLowerCase();
|
[searchQuery, groups],
|
||||||
return groups.filter(group =>
|
);
|
||||||
group.name.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}, [searchQuery, groups]);
|
|
||||||
|
|
||||||
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
|
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
|
||||||
const remainingCount = filteredGroups.length - 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 { Icon } from "@/components/icon/Icon";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
|
||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type OperationType = 'merge' | 'rebase';
|
type OperationType = 'merge' | 'rebase';
|
||||||
@@ -94,22 +95,19 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
|
|
||||||
// Filter branches based on search
|
// Filter branches based on search
|
||||||
const filteredLocal = React.useMemo(() => {
|
const filteredLocal = React.useMemo(() => {
|
||||||
const term = branchSearch.toLowerCase();
|
|
||||||
const remoteBranchNames = new Set(
|
const remoteBranchNames = new Set(
|
||||||
remoteBranches
|
remoteBranches
|
||||||
.map((branch) => branch.slice(branch.indexOf('/') + 1))
|
.map((branch) => branch.slice(branch.indexOf('/') + 1))
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
);
|
);
|
||||||
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
|
const candidates = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
|
||||||
if (!term) return filtered;
|
return rankByQuery(candidates, branchSearch, (branch) => [branch]);
|
||||||
return filtered.filter((b) => b.toLowerCase().includes(term));
|
|
||||||
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
|
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
|
||||||
|
|
||||||
const filteredRemote = React.useMemo(() => {
|
const filteredRemote = React.useMemo(
|
||||||
const term = branchSearch.toLowerCase();
|
() => rankByQuery(remoteBranches, branchSearch, (branch) => [branch]),
|
||||||
if (!term) return remoteBranches;
|
[branchSearch, remoteBranches]
|
||||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
);
|
||||||
}, [branchSearch, remoteBranches]);
|
|
||||||
|
|
||||||
const resolveDefaultBranch = React.useCallback(() => {
|
const resolveDefaultBranch = React.useCallback(() => {
|
||||||
if (!defaultTargetBranch) return null;
|
if (!defaultTargetBranch) return null;
|
||||||
@@ -321,7 +319,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
|
|||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden"
|
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
|
<CommandInput
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import type { GitRemote } from '@/lib/api/types';
|
import type { GitRemote } from '@/lib/api/types';
|
||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
interface BranchInfo {
|
interface BranchInfo {
|
||||||
@@ -78,17 +79,15 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
[newBranchName]
|
[newBranchName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredLocal = React.useMemo(() => {
|
const filteredLocal = React.useMemo(
|
||||||
const term = search.toLowerCase();
|
() => rankByQuery(localBranches, search, (branch) => [branch]),
|
||||||
if (!term) return localBranches;
|
[search, localBranches]
|
||||||
return localBranches.filter((b) => b.toLowerCase().includes(term));
|
);
|
||||||
}, [search, localBranches]);
|
|
||||||
|
|
||||||
const filteredRemote = React.useMemo(() => {
|
const filteredRemote = React.useMemo(
|
||||||
const term = search.toLowerCase();
|
() => rankByQuery(remoteBranches, search, (branch) => [branch]),
|
||||||
if (!term) return remoteBranches;
|
[search, remoteBranches]
|
||||||
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
|
);
|
||||||
}, [search, remoteBranches]);
|
|
||||||
|
|
||||||
const handleCheckout = (branch: string) => {
|
const handleCheckout = (branch: string) => {
|
||||||
if (branch === currentBranch) {
|
if (branch === currentBranch) {
|
||||||
@@ -184,7 +183,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
|
<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
|
<CommandInput
|
||||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||||
value={search}
|
value={search}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useInputStore } from '@/sync/input-store';
|
import { useInputStore } from '@/sync/input-store';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import { getGitCommitSummaries } from '@/lib/gitApi';
|
import { getGitCommitSummaries } from '@/lib/gitApi';
|
||||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||||
import {
|
import {
|
||||||
@@ -66,8 +67,14 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||||
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
|
||||||
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
|
||||||
|
const [branchSearch, setBranchSearch] = React.useState('');
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const filteredBranches = React.useMemo(
|
||||||
|
() => rankByQuery(localBranches, branchSearch, (branch) => [branch]),
|
||||||
|
[localBranches, branchSearch]
|
||||||
|
);
|
||||||
|
|
||||||
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
|
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setTargetBranch(defaultTargetBranch);
|
setTargetBranch(defaultTargetBranch);
|
||||||
@@ -380,10 +387,13 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
align="end"
|
align="end"
|
||||||
className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden"
|
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
|
<CommandInput
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
placeholder={t('gitView.branch.searchPlaceholder')}
|
placeholder={t('gitView.branch.searchPlaceholder')}
|
||||||
|
value={branchSearch}
|
||||||
|
onValueChange={setBranchSearch}
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<CommandList
|
<CommandList
|
||||||
@@ -393,7 +403,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
>
|
>
|
||||||
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
|
||||||
<CommandGroup heading={t('gitView.branch.localBranches')}>
|
<CommandGroup heading={t('gitView.branch.localBranches')}>
|
||||||
{localBranches.map((branch) => (
|
{filteredBranches.map((branch) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={branch}
|
key={branch}
|
||||||
value={branch}
|
value={branch}
|
||||||
@@ -401,6 +411,7 @@ export const IntegrateCommitsSection: React.FC<{
|
|||||||
setTargetBranch(branch);
|
setTargetBranch(branch);
|
||||||
persistTarget(branch);
|
persistTarget(branch);
|
||||||
setBranchDropdownOpen(false);
|
setBranchDropdownOpen(false);
|
||||||
|
setBranchSearch('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{branch}
|
{branch}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { rankByQuery } from '@/lib/search/fuzzySearch';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -69,11 +70,10 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
|
|||||||
};
|
};
|
||||||
}, [directory, open, stashes]);
|
}, [directory, open, stashes]);
|
||||||
|
|
||||||
const filtered = React.useMemo(() => {
|
const filtered = React.useMemo(
|
||||||
const normalized = query.trim().toLowerCase();
|
() => rankByQuery(stashes, query, (stash) => [stash.message, stash.ref, stash.relativeTime]),
|
||||||
if (!normalized) return stashes;
|
[query, stashes],
|
||||||
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
|
);
|
||||||
}, [query, stashes]);
|
|
||||||
|
|
||||||
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
|
||||||
await load();
|
await load();
|
||||||
|
|||||||
@@ -41,54 +41,6 @@ export function matchesFuzzyQuery(
|
|||||||
return fuse.search(query).length > 0;
|
return fuse.search(query).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFuzzyMatchMask<T>(
|
|
||||||
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
|
* Score-sorted fuzzy ranking. Strict (low threshold), prioritizes substring
|
||||||
* matches (especially prefix matches), and returns the top N.
|
* 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);
|
return tokens.every((token) => scoreRankToken(token, rankFields) !== RANK_TOKEN_MISS);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partitionByFuzzyQuery<T>(
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { partitionByFuzzyQuery } from "@/lib/search/fuzzySearch";
|
import { rankByQuery } from "@/lib/search/fuzzySearch";
|
||||||
|
|
||||||
export interface RankedBranchGroups {
|
export interface RankedBranchGroups {
|
||||||
matching: Array<{
|
matching: Array<{
|
||||||
@@ -26,42 +26,19 @@ export function rankBranchesForQuery(args: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const localPartition = partitionByFuzzyQuery(localBranches, normalizedQuery, (branch) => branch);
|
// Rank local and remote branches together so the order reflects match
|
||||||
const remotePartition = partitionByFuzzyQuery(remoteBranches, normalizedQuery, (branch) => branch);
|
// quality (an exact or prefix match lands first), not the source group or
|
||||||
const matching: RankedBranchGroups['matching'] = [];
|
// the alphabet.
|
||||||
const otherLocal = localPartition.other;
|
const candidates: RankedBranchGroups['matching'] = [
|
||||||
const otherRemote = remotePartition.other;
|
...localBranches.map((branch) => ({ label: branch, value: branch, source: 'local' as const })),
|
||||||
|
...remoteBranches.map((branch) => ({ label: branch, value: `remotes/${branch}`, source: 'remote' as const })),
|
||||||
for (const branch of localPartition.matching) {
|
];
|
||||||
matching.push({
|
const matching = rankByQuery(candidates, normalizedQuery, (branch) => [branch.label]);
|
||||||
label: branch,
|
const matched = new Set(matching);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matching,
|
matching,
|
||||||
otherLocal,
|
otherLocal: candidates.filter((entry) => entry.source === 'local' && !matched.has(entry)).map((entry) => entry.label),
|
||||||
otherRemote,
|
otherRemote: candidates.filter((entry) => entry.source === 'remote' && !matched.has(entry)).map((entry) => entry.label),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user