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 { useI18n } from '@/lib/i18n';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState';
|
||||||
|
|
||||||
type CommandEntry = {
|
type CommandEntry = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -87,6 +88,7 @@ export const CommandPalette: React.FC = () => {
|
|||||||
const activeSessions = useGlobalSessionsStore((s) => s.activeSessions);
|
const activeSessions = useGlobalSessionsStore((s) => s.activeSessions);
|
||||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||||
const activeProject = useProjectsStore((s) => s.getActiveProject());
|
const activeProject = useProjectsStore((s) => s.getActiveProject());
|
||||||
|
const projects = useProjectsStore((s) => s.projects);
|
||||||
const effectiveDirectory = useEffectiveDirectory();
|
const effectiveDirectory = useEffectiveDirectory();
|
||||||
const searchFiles = useFileSearchStore((s) => s.searchFiles);
|
const searchFiles = useFileSearchStore((s) => s.searchFiles);
|
||||||
const { files: filesApi, git: gitApi } = useRuntimeAPIs();
|
const { files: filesApi, git: gitApi } = useRuntimeAPIs();
|
||||||
@@ -317,21 +319,27 @@ export const CommandPalette: React.FC = () => {
|
|||||||
// File search
|
// File search
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const [fileResults, setFileResults] = React.useState<FileHit[]>([]);
|
const [fileResults, setFileResults] = React.useState<FileHit[]>([]);
|
||||||
const [isSearchingFiles, setIsSearchingFiles] = React.useState(false);
|
const [fileResultsKey, setFileResultsKey] = React.useState('');
|
||||||
|
|
||||||
|
const fileSearchKey = buildCommandPaletteFileSearchKey(currentRoot, trimmedQuery);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isCommandPaletteOpen) {
|
if (!isCommandPaletteOpen) {
|
||||||
setFileResults([]);
|
setFileResults([]);
|
||||||
setIsSearchingFiles(false);
|
setFileResultsKey('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!currentRoot || trimmedQuery.length === 0) {
|
if (!fileSearchKey) {
|
||||||
setFileResults([]);
|
setFileResults([]);
|
||||||
setIsSearchingFiles(false);
|
setFileResultsKey('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentRoot) {
|
||||||
|
setFileResults([]);
|
||||||
|
setFileResultsKey('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setIsSearchingFiles(true);
|
|
||||||
void searchFiles(currentRoot, trimmedQuery, 10, { type: 'file' })
|
void searchFiles(currentRoot, trimmedQuery, 10, { type: 'file' })
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@@ -342,17 +350,18 @@ export const CommandPalette: React.FC = () => {
|
|||||||
relativePath: file.relativePath,
|
relativePath: file.relativePath,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
setFileResultsKey(fileSearchKey);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setFileResults([]);
|
if (!cancelled) {
|
||||||
})
|
setFileResults([]);
|
||||||
.finally(() => {
|
setFileResultsKey(fileSearchKey);
|
||||||
if (!cancelled) setIsSearchingFiles(false);
|
}
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [isCommandPaletteOpen, currentRoot, trimmedQuery, searchFiles]);
|
}, [isCommandPaletteOpen, currentRoot, trimmedQuery, fileSearchKey, searchFiles]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Filter visible items
|
// Filter visible items
|
||||||
@@ -384,32 +393,47 @@ export const CommandPalette: React.FC = () => {
|
|||||||
}, [sortedActiveSessions, liveTrimmed, hasQuery]);
|
}, [sortedActiveSessions, liveTrimmed, hasQuery]);
|
||||||
|
|
||||||
const scoredFiles = React.useMemo(() => {
|
const scoredFiles = React.useMemo(() => {
|
||||||
if (!hasQuery || fileResults.length === 0) return [];
|
if (!isCommandPaletteOpen) return [];
|
||||||
// Server already ranked by relevance; compute a comparable client score on
|
return scoreCommandPaletteFiles(fileResults, trimmedQuery, fileSearchKey, fileResultsKey);
|
||||||
// basename so we can decide file group placement vs sessions/commands.
|
}, [isCommandPaletteOpen, fileResults, fileResultsKey, fileSearchKey, trimmedQuery]);
|
||||||
return scoreByFuzzyQuery(fileResults, liveTrimmed, (f) => f.name, {
|
|
||||||
limit: 10,
|
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,
|
threshold: 0.4,
|
||||||
});
|
});
|
||||||
}, [fileResults, liveTrimmed, hasQuery]);
|
}, [projects, liveTrimmed, hasQuery]);
|
||||||
|
|
||||||
const visibleCommands = scoredCommands.map((x) => x.item);
|
const visibleCommands = scoredCommands.map((x) => x.item);
|
||||||
const visibleSettings = scoredSettings.map((x) => x.item);
|
const visibleSettings = scoredSettings.map((x) => x.item);
|
||||||
const visibleSessions = scoredSessions.map((x) => x.item);
|
const visibleSessions = scoredSessions.map((x) => x.item);
|
||||||
const visibleFiles = hasQuery ? scoredFiles.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'];
|
if (!hasQuery) return ['commands', 'sessions'];
|
||||||
const best = (arr: { score: number }[]): number => (arr.length ? arr[0].score : Infinity);
|
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: 'commands', score: best(scoredCommands) },
|
||||||
{ key: 'settings', score: best(scoredSettings) },
|
{ key: 'settings', score: best(scoredSettings) },
|
||||||
{ key: 'sessions', score: best(scoredSessions) },
|
{ key: 'sessions', score: best(scoredSessions) },
|
||||||
{ key: 'files', score: best(scoredFiles) },
|
{ key: 'files', score: best(scoredFiles) },
|
||||||
|
{ key: 'projects', score: best(scoredProjects) },
|
||||||
];
|
];
|
||||||
groups.sort((a, b) => a.score - b.score);
|
groups.sort((a, b) => a.score - b.score);
|
||||||
return groups.map((g) => g.key);
|
return groups.map((g) => g.key);
|
||||||
}, [hasQuery, scoredCommands, scoredSettings, scoredSessions, scoredFiles]);
|
}, [hasQuery, scoredCommands, scoredSettings, scoredSessions, scoredFiles, scoredProjects]);
|
||||||
|
|
||||||
const handleOpenSession = React.useCallback(
|
const handleOpenSession = React.useCallback(
|
||||||
(session: Session) => {
|
(session: Session) => {
|
||||||
@@ -433,6 +457,14 @@ export const CommandPalette: React.FC = () => {
|
|||||||
[currentRoot, filesApi, openContextFile, close],
|
[currentRoot, filesApi, openContextFile, close],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenProject = React.useCallback(
|
||||||
|
(projectId: string, projectPath: string) => {
|
||||||
|
close();
|
||||||
|
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath });
|
||||||
|
},
|
||||||
|
[close, openNewSessionDraft],
|
||||||
|
);
|
||||||
|
|
||||||
const shortcut = React.useCallback(
|
const shortcut = React.useCallback(
|
||||||
(actionId: string) =>
|
(actionId: string) =>
|
||||||
formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)),
|
formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)),
|
||||||
@@ -538,10 +570,32 @@ export const CommandPalette: React.FC = () => {
|
|||||||
</CommandGroup>
|
</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;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{hasQuery && isSearchingFiles && visibleFiles.length === 0 ? (
|
{isFileSearchStale ? (
|
||||||
<div className="px-3 py-2 typography-meta text-muted-foreground">
|
<div className="px-3 py-2 typography-meta text-muted-foreground">
|
||||||
{t('commandPalette.empty.searchingFiles')}
|
{t('commandPalette.empty.searchingFiles')}
|
||||||
</div>
|
</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