From 9c61c568aaf5451ebd90a1309c894760a2d062e8 Mon Sep 17 00:00:00 2001 From: Leonid <127580858+bashrusakh@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:46:41 +1100 Subject: [PATCH] feat(command-palette): add projects to existing fuzzy search (#2063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../ui/src/components/ui/CommandPalette.tsx | 94 +++++++++++++++---- .../ui/commandPaletteFilesState.test.ts | 19 ++++ .../components/ui/commandPaletteFilesState.ts | 28 ++++++ 3 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/components/ui/commandPaletteFilesState.test.ts create mode 100644 packages/ui/src/components/ui/commandPaletteFilesState.ts diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 43b6cd3a..e7e8fc75 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -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([]); - 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 = () => { ); } + if (groupKey === 'projects' && visibleProjects.length > 0) { + return ( + + {visibleProjects.map((project) => { + const displayName = project.displayName; + return ( + handleOpenProject(project.id, project.path)} + > + + {displayName} + + {project.path} + + + ); + })} + + ); + } return null; })} - {hasQuery && isSearchingFiles && visibleFiles.length === 0 ? ( + {isFileSearchStale ? (
{t('commandPalette.empty.searchingFiles')}
diff --git a/packages/ui/src/components/ui/commandPaletteFilesState.test.ts b/packages/ui/src/components/ui/commandPaletteFilesState.test.ts new file mode 100644 index 00000000..d542ef73 --- /dev/null +++ b/packages/ui/src/components/ui/commandPaletteFilesState.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/ui/commandPaletteFilesState.ts b/packages/ui/src/components/ui/commandPaletteFilesState.ts new file mode 100644 index 00000000..ac5a9b15 --- /dev/null +++ b/packages/ui/src/components/ui/commandPaletteFilesState.ts @@ -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 = ( + 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, + }); +};