feat(command-palette): add projects to existing fuzzy search (#2063)

* feat(command-palette): add projects to existing fuzzy search

Adds projects to the existing command palette search — same single-input
fuzzy search that already covers sessions, files, settings, and commands.
Projects are scored alongside everything else by scoreByFuzzyQuery, and
the best-matching result appears first regardless of type.

Selecting a project opens a new session draft with the project pre-selected.

Closes #976

* fix(command-palette): keep file search tied to debounced query

---------

Co-authored-by: bashrusakh <bashrusakh@users.noreply.github.com>
This commit is contained in:
Leonid
2026-07-11 14:46:41 +03:00
committed by GitHub
co-authored by bashrusakh
parent e0229917f8
commit 9c61c568aa
3 changed files with 121 additions and 20 deletions
@@ -41,6 +41,7 @@ import { truncatePathMiddle } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState';
type CommandEntry = {
id: string;
@@ -87,6 +88,7 @@ export const CommandPalette: React.FC = () => {
const activeSessions = useGlobalSessionsStore((s) => s.activeSessions);
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
const activeProject = useProjectsStore((s) => s.getActiveProject());
const projects = useProjectsStore((s) => s.projects);
const effectiveDirectory = useEffectiveDirectory();
const searchFiles = useFileSearchStore((s) => s.searchFiles);
const { files: filesApi, git: gitApi } = useRuntimeAPIs();
@@ -317,21 +319,27 @@ export const CommandPalette: React.FC = () => {
// File search
// ---------------------------------------------------------------------------
const [fileResults, setFileResults] = React.useState<FileHit[]>([]);
const [isSearchingFiles, setIsSearchingFiles] = React.useState(false);
const [fileResultsKey, setFileResultsKey] = React.useState('');
const fileSearchKey = buildCommandPaletteFileSearchKey(currentRoot, trimmedQuery);
React.useEffect(() => {
if (!isCommandPaletteOpen) {
setFileResults([]);
setIsSearchingFiles(false);
setFileResultsKey('');
return;
}
if (!currentRoot || trimmedQuery.length === 0) {
if (!fileSearchKey) {
setFileResults([]);
setIsSearchingFiles(false);
setFileResultsKey('');
return;
}
if (!currentRoot) {
setFileResults([]);
setFileResultsKey('');
return;
}
let cancelled = false;
setIsSearchingFiles(true);
void searchFiles(currentRoot, trimmedQuery, 10, { type: 'file' })
.then((results) => {
if (cancelled) return;
@@ -342,17 +350,18 @@ export const CommandPalette: React.FC = () => {
relativePath: file.relativePath,
})),
);
setFileResultsKey(fileSearchKey);
})
.catch(() => {
if (!cancelled) setFileResults([]);
})
.finally(() => {
if (!cancelled) setIsSearchingFiles(false);
if (!cancelled) {
setFileResults([]);
setFileResultsKey(fileSearchKey);
}
});
return () => {
cancelled = true;
};
}, [isCommandPaletteOpen, currentRoot, trimmedQuery, searchFiles]);
}, [isCommandPaletteOpen, currentRoot, trimmedQuery, fileSearchKey, searchFiles]);
// ---------------------------------------------------------------------------
// Filter visible items
@@ -384,32 +393,47 @@ export const CommandPalette: React.FC = () => {
}, [sortedActiveSessions, liveTrimmed, hasQuery]);
const scoredFiles = React.useMemo(() => {
if (!hasQuery || fileResults.length === 0) return [];
// Server already ranked by relevance; compute a comparable client score on
// basename so we can decide file group placement vs sessions/commands.
return scoreByFuzzyQuery(fileResults, liveTrimmed, (f) => f.name, {
limit: 10,
if (!isCommandPaletteOpen) return [];
return scoreCommandPaletteFiles(fileResults, trimmedQuery, fileSearchKey, fileResultsKey);
}, [isCommandPaletteOpen, fileResults, fileResultsKey, fileSearchKey, trimmedQuery]);
const isFileSearchStale = isCommandPaletteOpen && fileSearchKey.length > 0 && fileResultsKey !== fileSearchKey;
// ---------------------------------------------------------------------------
// Projects
// ---------------------------------------------------------------------------
const scoredProjects = React.useMemo(() => {
if (!hasQuery) return [];
const projectEntries = projects.map((project) => ({
...project,
displayName: project.label || project.path.split('/').pop() || project.path,
searchText: `${project.label || ''} ${project.path}`,
}));
return scoreByFuzzyQuery(projectEntries, liveTrimmed, (p) => p.searchText, {
limit: 7,
threshold: 0.4,
});
}, [fileResults, liveTrimmed, hasQuery]);
}, [projects, liveTrimmed, hasQuery]);
const visibleCommands = scoredCommands.map((x) => x.item);
const visibleSettings = scoredSettings.map((x) => x.item);
const visibleSessions = scoredSessions.map((x) => x.item);
const visibleFiles = hasQuery ? scoredFiles.map((x) => x.item) : [];
const visibleProjects = hasQuery ? scoredProjects.map((x) => x.item) : [];
const groupOrder = React.useMemo<('commands' | 'settings' | 'sessions' | 'files')[]>(() => {
const groupOrder = React.useMemo<('commands' | 'settings' | 'sessions' | 'files' | 'projects')[]>(() => {
if (!hasQuery) return ['commands', 'sessions'];
const best = (arr: { score: number }[]): number => (arr.length ? arr[0].score : Infinity);
const groups: { key: 'commands' | 'settings' | 'sessions' | 'files'; score: number }[] = [
const groups: { key: 'commands' | 'settings' | 'sessions' | 'files' | 'projects'; score: number }[] = [
{ key: 'commands', score: best(scoredCommands) },
{ key: 'settings', score: best(scoredSettings) },
{ key: 'sessions', score: best(scoredSessions) },
{ key: 'files', score: best(scoredFiles) },
{ key: 'projects', score: best(scoredProjects) },
];
groups.sort((a, b) => a.score - b.score);
return groups.map((g) => g.key);
}, [hasQuery, scoredCommands, scoredSettings, scoredSessions, scoredFiles]);
}, [hasQuery, scoredCommands, scoredSettings, scoredSessions, scoredFiles, scoredProjects]);
const handleOpenSession = React.useCallback(
(session: Session) => {
@@ -433,6 +457,14 @@ export const CommandPalette: React.FC = () => {
[currentRoot, filesApi, openContextFile, close],
);
const handleOpenProject = React.useCallback(
(projectId: string, projectPath: string) => {
close();
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath });
},
[close, openNewSessionDraft],
);
const shortcut = React.useCallback(
(actionId: string) =>
formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)),
@@ -538,10 +570,32 @@ export const CommandPalette: React.FC = () => {
</CommandGroup>
);
}
if (groupKey === 'projects' && visibleProjects.length > 0) {
return (
<CommandGroup key="projects">
{visibleProjects.map((project) => {
const displayName = project.displayName;
return (
<CommandItem
key={`project:${project.id}`}
value={`project:${project.id}`}
onSelect={() => handleOpenProject(project.id, project.path)}
>
<Icon name="folder" className="mr-2 h-4 w-4" />
<span className="truncate">{displayName}</span>
<span className="ml-auto inline-flex items-center text-muted-foreground typography-meta truncate max-w-[160px]">
{project.path}
</span>
</CommandItem>
);
})}
</CommandGroup>
);
}
return null;
})}
{hasQuery && isSearchingFiles && visibleFiles.length === 0 ? (
{isFileSearchStale ? (
<div className="px-3 py-2 typography-meta text-muted-foreground">
{t('commandPalette.empty.searchingFiles')}
</div>
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'bun:test';
import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState';
describe('commandPaletteFilesState', () => {
test('does not build a file search key without a root or query', () => {
expect(buildCommandPaletteFileSearchKey(null, 'alpha')).toBe('');
expect(buildCommandPaletteFileSearchKey('/project', '')).toBe('');
});
test('hides stale file results until the debounced search key catches up', () => {
const fileResults = [{ name: 'alpha.ts', path: '/project/alpha.ts', relativePath: 'alpha.ts' }];
const freshKey = buildCommandPaletteFileSearchKey('/project', 'alpha');
const staleKey = buildCommandPaletteFileSearchKey('/project', 'alp');
expect(scoreCommandPaletteFiles(fileResults, 'alpha', freshKey, staleKey)).toEqual([]);
expect(scoreCommandPaletteFiles(fileResults, 'alpha', freshKey, freshKey)).toHaveLength(1);
});
});
@@ -0,0 +1,28 @@
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
export const buildCommandPaletteFileSearchKey = (
currentRoot: string | null,
trimmedQuery: string,
): string => {
if (!currentRoot || trimmedQuery.length === 0) {
return '';
}
return JSON.stringify([currentRoot, trimmedQuery]);
};
export const scoreCommandPaletteFiles = <T extends { name: string }>(
fileResults: T[],
trimmedQuery: string,
fileSearchKey: string,
fileResultsKey: string,
): { item: T; score: number }[] => {
if (!fileSearchKey || fileResultsKey !== fileSearchKey || fileResults.length === 0) {
return [];
}
return scoreByFuzzyQuery(fileResults, trimmedQuery, (file) => file.name, {
limit: 10,
threshold: 0.4,
});
};