Directories and files were rendered as fixed category blocks, so an exact file match sat below unrelated directories. Merge both result sets and rank them with the shared fuzzy scorer against the full relative path. Multi-word queries now match tokens in any order (longest token queries the server, the rest filter client-side), and path truncation keeps the parent segments next to the file name so index.md-heavy trees stay distinguishable.
61 lines
2.0 KiB
TypeScript
61 lines
2.0 KiB
TypeScript
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
|
|
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
|
|
|
export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' };
|
|
|
|
export const tokenizeMentionQuery = (query: string): string[] =>
|
|
(query ?? '')
|
|
.trim()
|
|
.replace(/^\.\//, '')
|
|
.replace(/^\/+/, '')
|
|
.toLowerCase()
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
/**
|
|
* The opencode file search takes a single term, so multi-word queries send the
|
|
* most selective (longest) token and the remaining tokens filter client-side.
|
|
*/
|
|
export const mentionServerQuery = (query: string): string => {
|
|
const tokens = tokenizeMentionQuery(query);
|
|
if (tokens.length === 0) {
|
|
return '';
|
|
}
|
|
return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
|
|
};
|
|
|
|
/**
|
|
* Merge directory and file hits into one list ranked by match quality against
|
|
* the full relative path. Multi-token queries require every token to appear
|
|
* somewhere in the path, in any order.
|
|
*/
|
|
export function rankFileMentionResults(
|
|
files: ProjectFileSearchHit[],
|
|
directories: ProjectFileSearchHit[],
|
|
query: string,
|
|
limit = 20,
|
|
): FileMentionHit[] {
|
|
const merged: FileMentionHit[] = [
|
|
...directories.map((hit) => ({ ...hit, kind: 'directory' as const })),
|
|
...files.map((hit) => ({ ...hit, kind: 'file' as const })),
|
|
];
|
|
|
|
const tokens = tokenizeMentionQuery(query);
|
|
if (tokens.length === 0) {
|
|
return merged.slice(0, limit);
|
|
}
|
|
|
|
const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name;
|
|
const candidates = tokens.length === 1
|
|
? merged
|
|
: merged.filter((hit) => {
|
|
const haystack = pathOf(hit).toLowerCase();
|
|
return tokens.every((token) => haystack.includes(token));
|
|
});
|
|
|
|
const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
|
|
return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map(
|
|
(scored) => scored.item,
|
|
);
|
|
}
|