Compare commits

...
Author SHA1 Message Date
l0ng-ai 5fd0cdf949 feat(search): list directories in Go to File and Search Everywhere
Go to File and the Files section of Search Everywhere listed only files, so a
folder could not be found by name. Directories from the index's folder search
now rank with the files (a file first on equal scores), with a folder icon, and
choosing one selects it in the Project view. A file type filter or a
File.kt:42 query still lists files only.
2026-11-12 00:53:39 +08:00
5 changed files with 85 additions and 8 deletions

No files matched your search

@@ -83,6 +83,44 @@ describe("SeController", () => {
expect(deps.cancel).toHaveBeenCalled();
});
it("lists directories with the files, by score, files first on a tie", async () => {
const { c, last } = setup({
findFiles: async () => [
{ ...fm("src/util.ts"), score: 5 },
{ ...fm("lib/utils/a.ts"), score: 2 },
],
findDirs: async () => [
{ path: "/p/src/utils", relPath: "src/utils/", score: 5, indices: [4, 5, 6, 7] },
{ path: "/p/lib/utils", relPath: "lib/utils/", score: 3, indices: [] },
],
});
c.run({ query: "util", tab: "files", includeNonProject: false, extra: {} });
await flush();
const items = last().sections.get("files")!.items;
expect(items.map((i) => i.key)).toEqual([
"f:/p/src/util.ts",
"d:/p/src/utils",
"d:/p/lib/utils",
"f:/p/lib/utils/a.ts",
]);
expect(items[1]).toMatchObject({
kind: "file",
folder: true,
name: "utils",
dir: "src",
nameIndices: [0, 1, 2, 3],
});
// A `File.kt:42` query asks for a file.
c.run({ query: "util:3", tab: "files", includeNonProject: false, extra: {} });
await flush();
expect(
last()
.sections.get("files")!
.items.every((i) => !("folder" in i) || !i.folder),
).toBe(true);
});
it("parses File.kt:42 and opens files at the line", async () => {
const findFiles = vi.fn(async () => [fm("File.kt")]);
const { c, last } = setup({ findFiles });
@@ -313,6 +313,8 @@ function SearchEverywhereDialog() {
return;
}
case "file":
if (row.folder) return closeThen(() => void executeAction("navigate.selectInProjectView", row.path));
return closeThen(() => void openResult(row, { side }));
case "symbol":
case "text":
return closeThen(() => void openResult(row, { side }));
@@ -448,7 +450,8 @@ function SearchEverywhereDialog() {
useEffect(() => {
if (nothingInProject) widenSearchEverywhere(searchQuery);
}, [nothingInProject, searchQuery]);
const canSplit = activeRow?.kind === "file" || activeRow?.kind === "symbol" || activeRow?.kind === "text";
const canSplit =
(activeRow?.kind === "file" && !activeRow.folder) || activeRow?.kind === "symbol" || activeRow?.kind === "text";
const onTabKey = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
@@ -708,7 +711,7 @@ function describe(row: SeRow | undefined): string {
function previewOf(row: SeRow | undefined): { path: string; line: number; highlights: PreviewHighlight[] } | null {
switch (row?.kind) {
case "file":
return { path: row.path, line: row.line ?? 0, highlights: [] };
return row.folder ? null : { path: row.path, line: row.line ?? 0, highlights: [] };
case "symbol":
return {
path: row.path,
@@ -780,7 +783,16 @@ function ResultRow({
</span>
);
case "file":
return <FileRow path={row.path} name={row.name} dir={row.dir} indices={row.nameIndices} line={row.line} />;
return (
<FileRow
path={row.path}
name={row.name}
dir={row.dir}
indices={row.nameIndices}
line={row.line}
folder={row.folder}
/>
);
case "symbol":
return (
<>
@@ -911,18 +923,20 @@ function FileRow({
dir,
indices,
line,
folder,
}: {
path: string;
name: string;
dir: string;
indices: number[];
line: number | null;
folder?: boolean;
}) {
const deco = useFileDecoration(path);
const { icon } = useFilePresentation(path);
return (
<>
<Icon name={icon} size={16} className={styles.icon} />
<Icon name={folder ? "folder" : icon} size={16} className={styles.icon} />
<span className={styles.main}>
<span className={styles.name} style={deco.color ? { color: deco.color } : undefined}>
<HighlightedText text={name} indices={indices} tone="strong" />
@@ -935,8 +949,9 @@ function FileRow({
}
type NavigationRow = Extract<SeRow, { kind: "file" | "symbol" | "text" }>;
/** A row that opens in an editor (a directory selects in the Project view instead). */
function isNavigationRow(row: SeRow): row is NavigationRow {
return row.kind === "file" || row.kind === "symbol" || row.kind === "text";
return (row.kind === "file" && !row.folder) || row.kind === "symbol" || row.kind === "text";
}
function openResult(row: NavigationRow, options: Parameters<typeof openLocation>[2]): Promise<void> {
if (row.kind === "file")
+18 -3
View File
@@ -26,6 +26,7 @@ import {
TAB_LIMIT,
actionItem,
fileItem,
folderItem,
matchActions,
matchSettings,
mergeActionResults,
@@ -40,6 +41,8 @@ import {
export interface SeDeps {
findFiles(query: string, limit: number): Promise<FileMatch[]>;
/** Directories by name or path (`relPath` ending in `/`), listed with the files. */
findDirs?(query: string, limit: number): Promise<FileMatch[]>;
findSymbols(query: string, filter: SymbolFilter, limit: number): Promise<SymbolMatch[]>;
/** Optional language-server symbols; rejects with `unavailable` when no server. */
workspaceSymbols?(request: RequestId, query: string): Promise<WorkspaceSymbol[]>;
@@ -253,12 +256,24 @@ export class SeController {
? (path: string) => (!scope || scope(path)) && (!filter || passesFilter(filter, path))
: null;
// Over-fetch so a narrowed scope still fills the section.
const fetched = await this.deps.findFiles(text, keep ? limit * 4 + 1 : limit + 1);
const matches = keep ? fetched.filter((m) => keep(m.path)) : fetched;
const want = keep ? limit * 4 + 1 : limit + 1;
// Directories rank with the files (equal scores: the file first); a file type filter
// and a `File.kt:42` query ask for files only.
const withDirs = !!this.deps.findDirs && !!text && !filter && q.line === null;
const [fetched, dirs] = await Promise.all([
this.deps.findFiles(text, want),
withDirs ? this.deps.findDirs!(text, want).catch(() => []) : [],
]);
const merged = dirs.length
? [...fetched.map((m) => ({ m, folder: false })), ...dirs.map((m) => ({ m, folder: true }))].sort(
(a, b) => b.m.score - a.m.score || Number(a.folder) - Number(b.folder),
)
: fetched.map((m) => ({ m, folder: false }));
const matches = keep ? merged.filter(({ m }) => keep(m.path)) : merged;
this.set(
id,
gen,
matches.slice(0, limit).map((m) => fileItem(m, q.line, q.col)),
matches.slice(0, limit).map(({ m, folder }) => (folder ? folderItem(m) : fileItem(m, q.line, q.col))),
matches.length > limit,
false,
);
+1
View File
@@ -12,6 +12,7 @@ import { recentActions } from "./store";
export function ipcDeps(ctx: ContextKeys): SeDeps {
return {
findFiles: (q, limit) => call(commands.findFiles(q, limit)),
findDirs: (q, limit) => call(commands.findDirs(q, limit)),
findSymbols: (q, filter, limit) => call(commands.findSymbols(q, filter, limit)),
workspaceSymbols: (request, q) => call(commands.lspWorkspaceSymbols(request, q)),
searchText: async (request, query, onFile) => {
+8
View File
@@ -76,6 +76,8 @@ export interface FileItem {
nameIndices: number[];
line: number | null;
col: number | null;
/** A directory: choosing it selects it in the Project view. */
folder?: boolean;
}
export interface SymbolItem {
@@ -169,6 +171,12 @@ export function fileItem(
};
}
/** A directory match (`relPath` ends in `/`), listed with the files as IDEA's Go to File does. */
export function folderItem(m: Pick<FileMatch, "path" | "relPath" | "indices">): FileItem {
const rel = m.relPath.replace(/\/$/, "");
return { ...fileItem({ ...m, relPath: rel }), key: `d:${m.path}`, folder: true };
}
export function symbolItem(m: SymbolMatch): SymbolItem {
return {
kind: "symbol",