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:
committed by
GitHub
co-authored by
bashrusakh
parent
e0229917f8
commit
9c61c568aa
@@ -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,
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user