fix: improve file mention suggestions without ripgrep

This commit is contained in:
Federico Jaramillo Martinez
2026-05-31 20:04:59 +02:00
parent 1ae28d8f59
commit fdd2cf2390
12 changed files with 247 additions and 41 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep chat file mention suggestions working on installations that do not have ripgrep available, add an all-file `@` mention mode, stop hiding directories in the file explorer, and report optional ripgrep availability in `pi-web doctor`.
+3 -2
View File
@@ -121,8 +121,9 @@
<h2>What does <code>pi-web doctor</code> check?</h2> <h2>What does <code>pi-web doctor</code> check?</h2>
<p> <p>
It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi It checks whether the service shell and native service environment can find Node 22+, npm, Pi, and the Pi
Web binaries. It also prints installed and running PI WEB versions when available, and reports user service Web binaries. It also prints installed and running PI WEB versions when available, reports optional ripgrep
lingering when relevant for server-style installs. availability for faster all-file <code>@</code>-mention suggestions, uses a bounded filesystem fallback when
ripgrep is unavailable, and reports user service lingering when relevant for server-style installs.
</p> </p>
<p> <p>
If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and If something works in your terminal but fails in doctor, treat that as a login-shell PATH mismatch and
+35 -6
View File
@@ -931,16 +931,44 @@ function runChecks(checks: Check[]): boolean {
const ok = result.status === 0; const ok = result.status === 0;
failed ||= !ok; failed ||= !ok;
console.log(`${ok ? "✓" : "✗"} ${label}`); console.log(`${ok ? "✓" : "✗"} ${label}`);
const output = (result.stdout || result.stderr).trim(); printCheckOutput(result.stdout || result.stderr);
if (output !== "") {
const lines = output.split("\n");
for (const line of lines.slice(0, 3)) console.log(` ${line}`);
if (lines.length > 3) console.log(" ...");
}
} }
return !failed; return !failed;
} }
function printCheckOutput(output: string): void {
const trimmed = output.trim();
if (trimmed === "") return;
const lines = trimmed.split("\n");
for (const line of lines.slice(0, 3)) console.log(` ${line}`);
if (lines.length > 3) console.log(" ...");
}
function optionalDoctorChecks(): Check[] {
const shell = serviceShellLabel();
const backend = currentServiceBackend();
const checks: Check[] = [[`${shell} can find optional ripgrep (rg)`, serviceShellCommand(commandCheck("rg"))]];
if (backend?.kind === "systemd") checks.push([`systemd user ${shell} can find optional ripgrep (rg)`, systemdUserServiceShellCommand(commandCheck("rg"))]);
return checks;
}
function printOptionalDoctorChecks(): void {
let missingOptionalTool = false;
for (const [label, command] of optionalDoctorChecks()) {
const [bin, ...args] = command;
if (bin === undefined) continue;
const result = capture(bin, args);
const ok = result.status === 0;
missingOptionalTool ||= !ok;
console.log(`${ok ? "✓" : "!"} ${label}`);
printCheckOutput(result.stdout || result.stderr);
}
if (missingOptionalTool) {
console.log(" Install ripgrep, or make rg visible to the service shell, for faster all-file @ suggestions.");
console.log(" PI WEB falls back to a bounded filesystem scan when rg is unavailable.");
}
}
function printPathSetupAdvice(): void { function printPathSetupAdvice(): void {
const shell = detectServiceShell(); const shell = detectServiceShell();
console.log("\nPATH setup advice:"); console.log("\nPATH setup advice:");
@@ -969,6 +997,7 @@ async function doctor(): Promise<void> {
await printPiWebVersionReport(); await printPiWebVersionReport();
console.log("\nDoctor checks:"); console.log("\nDoctor checks:");
const ok = runChecks(doctorChecks()); const ok = runChecks(doctorChecks());
printOptionalDoctorChecks();
const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck(); const nodePtySpawnHelperOk = printNodePtyDarwinSpawnHelperCheck();
if (supportsSystemdUserServices()) { if (supportsSystemdUserServices()) {
+13 -1
View File
@@ -133,8 +133,20 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
export interface FileSuggestionQueryOptions {
kind?: FileSuggestion["kind"] | undefined;
mode?: "file" | "path" | undefined;
scope?: "tracked" | "all" | undefined;
}
export const filesApi = { export const filesApi = {
files: (cwd: string, query: string, kind?: FileSuggestion["kind"], mode?: "file" | "path") => request(`/api/files?cwd=${encodeURIComponent(cwd)}&q=${encodeURIComponent(query)}${kind !== undefined ? `&kind=${encodeURIComponent(kind)}` : ""}${mode !== undefined ? `&mode=${encodeURIComponent(mode)}` : ""}`, arrayOf(parseFileSuggestion)), files: (cwd: string, query: string, options: FileSuggestionQueryOptions = {}) => {
const params = new URLSearchParams({ cwd, q: query });
if (options.kind !== undefined) params.set("kind", options.kind);
if (options.mode !== undefined) params.set("mode", options.mode);
if (options.scope !== undefined) params.set("scope", options.scope);
return request(`/api/files?${params.toString()}`, arrayOf(parseFileSuggestion));
},
}; };
export const gitApi = { export const gitApi = {
+13 -11
View File
@@ -110,7 +110,7 @@ export class PromptEditor extends LitElement {
syntaxHighlighting(defaultHighlightStyle, { fallback: true }), syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))), EditorView.contentAttributes.of((view) => inputAssistanceContentAttributes(view.state.sliceDoc(0, view.state.selection.main.head))),
placeholder("Message pi... Use / for commands, @ for files"), placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"),
this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.editableCompartment.of(EditorView.editable.of(!this.disabled)),
this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
@@ -182,12 +182,12 @@ export class PromptEditor extends LitElement {
...(command.description === undefined ? {} : { description: command.description }), ...(command.description === undefined ? {} : { description: command.description }),
})); }));
} else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") { } else if (trigger.kind === "file" && this.cwd !== undefined && this.cwd !== "") {
const files = await api.files(this.cwd, trigger.query, trigger.fileKind, trigger.fileMode).catch(emptyFileSuggestions); const files = await api.files(this.cwd, trigger.query, { scope: trigger.fileScope }).catch(emptyFileSuggestions);
if (version !== this.requestVersion) return; if (version !== this.requestVersion) return;
this.completions = files this.completions = files
.slice(0, 12) .slice(0, 12)
.map((file) => { .map((file) => {
const insertText = fileInsertText(file.path, trigger.fileMode === "path", trigger.quoted === true); const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
return { return {
kind: "file", kind: "file",
replaceFrom: trigger.from, replaceFrom: trigger.from,
@@ -200,7 +200,7 @@ export class PromptEditor extends LitElement {
} }
} }
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileKind?: FileSuggestion["kind"]; fileMode?: "file" | "path"; quoted?: boolean } | undefined { private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined {
const cursor = this.editor?.state.selection.main.head ?? this.draft.length; const cursor = this.editor?.state.selection.main.head ?? this.draft.length;
const beforeCursor = this.draft.slice(0, cursor); const beforeCursor = this.draft.slice(0, cursor);
const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor); const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor);
@@ -209,18 +209,20 @@ export class PromptEditor extends LitElement {
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1; const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
const token = beforeCursor.slice(tokenStart); const token = beforeCursor.slice(tokenStart);
const beforeToken = beforeCursor.slice(0, tokenStart); const beforeToken = beforeCursor.slice(0, tokenStart);
if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart, to: cursor, fileMode: "path" }; if (beforeToken.endsWith("@ ")) return { kind: "file", query: token, from: tokenStart - 2, to: cursor, fileScope: "all", allPrefix: "@ " };
if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor }; if (token.startsWith("/") && tokenStart === 0) return { kind: "command", query: token.slice(1), from: tokenStart, to: cursor };
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor }; if (token.startsWith("!@")) return { kind: "file", query: token.slice(2), from: tokenStart, to: cursor, fileScope: "all", allPrefix: "!@" };
if (token.startsWith("@")) return { kind: "file", query: token.slice(1), from: tokenStart, to: cursor, fileScope: "tracked" };
return undefined; return undefined;
} }
private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileMode?: "file" | "path"; quoted: true } | undefined { private currentQuotedTrigger(beforeCursor: string, cursor: number): { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted: true } | undefined {
const quoteStart = beforeCursor.lastIndexOf("\""); const quoteStart = beforeCursor.lastIndexOf("\"");
if (quoteStart === -1) return undefined; if (quoteStart === -1) return undefined;
const prefix = beforeCursor.slice(0, quoteStart); const prefix = beforeCursor.slice(0, quoteStart);
if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, quoted: true }; if (prefix.endsWith("!@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "!@", quoted: true };
if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: quoteStart, to: cursor, fileMode: "path", quoted: true }; if (prefix.endsWith("@")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 1, to: cursor, fileScope: "tracked", quoted: true };
if (prefix.endsWith("@ ")) return { kind: "file", query: beforeCursor.slice(quoteStart + 1), from: prefix.length - 2, to: cursor, fileScope: "all", allPrefix: "@ ", quoted: true };
return undefined; return undefined;
} }
@@ -286,8 +288,8 @@ export class PromptEditor extends LitElement {
static override styles = promptEditorStyles; static override styles = promptEditorStyles;
} }
function fileInsertText(path: string, pathMode: boolean, quoted: boolean): string { function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
const prefix = pathMode ? "" : "@"; const prefix = allPrefix ?? "@";
if (!quoted && !path.includes(" ")) return `${prefix}${path}`; if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
return `${prefix}"${path}"`; return `${prefix}"${path}"`;
} }
+3
View File
@@ -17,7 +17,10 @@ describe("inputModeForDraft", () => {
it("detects file completion contexts", () => { it("detects file completion contexts", () => {
expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @src/main.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ ")).toEqual({ kind: "file" });
expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("!@vendor/file.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open @ \"src/main.ts")).toEqual({ kind: "file" }); expect(inputModeForDraft("open @ \"src/main.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open !@\"vendor/file.ts")).toEqual({ kind: "file" });
expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" }); expect(inputModeForDraft("open \"src/main.ts")).toEqual({ kind: "normal" });
}); });
}); });
+3 -2
View File
@@ -6,6 +6,7 @@ export type InputMode =
export function inputModeForDraft(draft: string): InputMode { export function inputModeForDraft(draft: string): InputMode {
const trimmed = draft.trimStart(); const trimmed = draft.trimStart();
if (trimmed.startsWith("!@")) return { kind: "file" };
if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") }; if (trimmed.startsWith("!")) return { kind: "shell", excludeFromContext: trimmed.startsWith("!!") };
if (currentToken(draft).startsWith("/")) return { kind: "command" }; if (currentToken(draft).startsWith("/")) return { kind: "command" };
if (isFileCompletionContext(draft)) return { kind: "file" }; if (isFileCompletionContext(draft)) return { kind: "file" };
@@ -23,11 +24,11 @@ function currentToken(draft: string): string {
function isFileCompletionContext(draft: string): boolean { function isFileCompletionContext(draft: string): boolean {
const token = currentToken(draft); const token = currentToken(draft);
if (token.startsWith("@")) return true; if (token.startsWith("@") || token.startsWith("!@")) return true;
const tokenStart = draft.length - token.length; const tokenStart = draft.length - token.length;
if (draft.slice(0, tokenStart).endsWith("@ ")) return true; if (draft.slice(0, tokenStart).endsWith("@ ")) return true;
const quoteStart = draft.lastIndexOf("\""); const quoteStart = draft.lastIndexOf("\"");
if (quoteStart === -1) return false; if (quoteStart === -1) return false;
const prefix = draft.slice(0, quoteStart); const prefix = draft.slice(0, quoteStart);
return prefix.endsWith("@") || prefix.endsWith("@ "); return prefix.endsWith("@") || prefix.endsWith("@ ") || prefix.endsWith("!@");
} }
+2 -2
View File
@@ -84,11 +84,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
registerGitRoutes(app, projects, workspaces); registerGitRoutes(app, projects, workspaces);
registerTerminalProxyRoutes(app, projects, workspaces); registerTerminalProxyRoutes(app, projects, workspaces);
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>("/api/files", async (request, reply) => { app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>("/api/files", async (request, reply) => {
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
try { try {
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? ""); if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind); return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
} catch (error) { } catch (error) {
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
} }
@@ -0,0 +1,75 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
const temporaryRoots: string[] = [];
async function tempWorkspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "pi-web-files-"));
temporaryRoots.push(root);
return root;
}
afterEach(async () => {
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe("file suggestions", () => {
it("uses tracked git files for tracked-scope suggestions", async () => {
const calls: { file: string; args: string[] }[] = [];
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
calls.push({ file, args });
if (file === "git" && args.join(" ") === "ls-files") return Promise.resolve({ stdout: "src/app.ts\nREADME.md\n" });
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
},
};
await expect(listFileSuggestions("/repo", "", { scope: "tracked" }, deps)).resolves.toEqual([
{ path: "src/", kind: "tracked" },
{ path: "README.md", kind: "tracked" },
{ path: "src/app.ts", kind: "tracked" },
]);
expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]);
});
it("asks ripgrep for hidden and ignored files in all-file scope", async () => {
const calls: { file: string; args: string[] }[] = [];
const deps: FileSuggestionDependencies = {
execFile: (file, args) => {
calls.push({ file, args });
return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" });
},
};
await expect(listFileSuggestions("/repo", "pkg", { scope: "all" }, deps)).resolves.toEqual([
{ path: "node_modules/pkg/", kind: "other" },
{ path: "node_modules/pkg/index.js", kind: "other" },
]);
expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]);
});
it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => {
const root = await tempWorkspace();
await mkdir(join(root, "src"), { recursive: true });
await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
await writeFile(join(root, "README.md"), "hello");
await writeFile(join(root, "src", "app.ts"), "export {};\n");
await writeFile(join(root, "node_modules", "pkg", "index.js"), "module.exports = {};\n");
const deps: FileSuggestionDependencies = {
execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
};
await expect(listFileSuggestions(root, "", { scope: "all" }, deps)).resolves.toEqual([
{ path: "node_modules/", kind: "other" },
{ path: "node_modules/pkg/", kind: "other" },
{ path: "src/", kind: "other" },
{ path: "node_modules/pkg/index.js", kind: "other" },
{ path: "README.md", kind: "other" },
{ path: "src/app.ts", kind: "other" },
]);
});
});
+88 -12
View File
@@ -5,13 +5,32 @@ import { promisify } from "node:util";
import type { ClientFileSuggestion } from "../types.js"; import type { ClientFileSuggestion } from "../types.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const commandMaxBuffer = 1024 * 1024 * 8;
const maxFilesystemFallbackPaths = 20_000;
export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise<ClientFileSuggestion[]> { interface ExecFileOptions {
const normalizedQuery = query.replace(/^@/, "").toLowerCase(); cwd: string;
const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd)); maxBuffer: number;
}
export type FileSuggestionScope = "tracked" | "all";
export interface FileSuggestionOptions {
kind?: ClientFileSuggestion["kind"] | undefined;
scope?: FileSuggestionScope | undefined;
}
export interface FileSuggestionDependencies {
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
}
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
const normalizedQuery = normalizeFileQuery(query);
const exec = deps.execFile ?? execFileAsync;
const files = await listFilesForScope(cwd, options.scope, exec);
return files return files
.filter((file) => !kind || file.kind === kind) .filter((file) => options.kind === undefined || file.kind === options.kind)
.filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery)) .filter((file) => normalizedQuery === "" || file.path.toLowerCase().includes(normalizedQuery))
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path)) .sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
.slice(0, 80); .slice(0, 80);
} }
@@ -39,10 +58,20 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise<Cli
.slice(0, 80); .slice(0, 80);
} }
async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> { async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
if (scope === "all") return listPlainFiles(cwd, exec, true);
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
}
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked");
}
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
const [tracked, untracked] = await Promise.all([ const [tracked, untracked] = await Promise.all([
git(cwd, ["ls-files"]), git(cwd, ["ls-files"], exec),
git(cwd, ["ls-files", "--others", "--exclude-standard"]), git(cwd, ["ls-files", "--others", "--exclude-standard"], exec),
]); ]);
return [ return [
...withDirectories(lines(tracked), "tracked"), ...withDirectories(lines(tracked), "tracked"),
@@ -50,16 +79,63 @@ async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
]; ];
} }
async function listPlainFiles(cwd: string): Promise<ClientFileSuggestion[]> { async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 }); try {
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"];
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
return withDirectories(lines(stdout), "other"); return withDirectories(lines(stdout), "other");
} catch {
return withDirectories(await filesystemFiles(cwd), "other");
}
} }
async function git(cwd: string, args: string[]): Promise<string> { async function filesystemFiles(cwd: string): Promise<string[]> {
const { stdout } = await execFileAsync("git", args, { cwd, maxBuffer: 1024 * 1024 * 8 }); const paths: string[] = [];
await collectFilesystemFiles(cwd, "", paths, false);
return paths;
}
async function collectFilesystemFiles(cwd: string, relativeDirectory: string, paths: string[], optionalDirectory: boolean): Promise<void> {
if (paths.length >= maxFilesystemFallbackPaths) return;
const absoluteDirectory = relativeDirectory === "" ? cwd : join(cwd, relativeDirectory);
let entries;
try {
entries = await readdir(absoluteDirectory, { withFileTypes: true });
} catch (error) {
if (optionalDirectory) return;
throw error;
}
entries.sort((a, b) => Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name));
for (const entry of entries) {
if (paths.length >= maxFilesystemFallbackPaths) return;
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
if (entry.isDirectory()) {
await collectFilesystemFiles(cwd, relativePath, paths, true);
continue;
}
if (entry.isFile() || await isSymlinkedFile(cwd, relativePath, entry.isSymbolicLink())) paths.push(relativePath);
}
}
async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink: boolean): Promise<boolean> {
if (!symbolicLink) return false;
try {
return (await stat(join(cwd, relativePath))).isFile();
} catch {
return false;
}
}
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
const { stdout } = await exec("git", args, { cwd, maxBuffer: commandMaxBuffer });
return stdout; return stdout;
} }
function normalizeFileQuery(query: string): string {
return query.replace(/^!@/, "").replace(/^@\s?/, "").toLowerCase();
}
function lines(text: string): string[] { function lines(text: string): string[] {
return text.split("\n").map((line) => line.trim()).filter(Boolean); return text.split("\n").map((line) => line.trim()).filter(Boolean);
} }
@@ -17,7 +17,7 @@ afterEach(async () => {
}); });
describe("listWorkspaceTree", () => { describe("listWorkspaceTree", () => {
it("lists visible entries with directories first, sorted by name", async () => { it("lists entries with directories first, sorted by name", async () => {
const root = await tempWorkspace(); const root = await tempWorkspace();
await mkdir(join(root, "z-dir")); await mkdir(join(root, "z-dir"));
await mkdir(join(root, "a-dir")); await mkdir(join(root, "a-dir"));
@@ -32,7 +32,9 @@ describe("listWorkspaceTree", () => {
expect(tree.path).toBe(""); expect(tree.path).toBe("");
expect(tree.truncated).toBe(false); expect(tree.truncated).toBe(false);
expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([ expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([
[".git", "directory"],
["a-dir", "directory"], ["a-dir", "directory"],
["node_modules", "directory"],
["z-dir", "directory"], ["z-dir", "directory"],
["a.txt", "file"], ["a.txt", "file"],
["b.txt", "file"], ["b.txt", "file"],
+3 -3
View File
@@ -11,11 +11,11 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
if (!stat.isDirectory()) throw new Error("Path is not a directory"); if (!stat.isDirectory()) throw new Error("Path is not a directory");
const dirents = await readdir(target, { withFileTypes: true }); const dirents = await readdir(target, { withFileTypes: true });
const visible = dirents.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules").sort((a, b) => { const sorted = dirents.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
const selected = visible.slice(0, MAX_ENTRIES); const selected = sorted.slice(0, MAX_ENTRIES);
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => { const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
const absolute = join(target, entry.name); const absolute = join(target, entry.name);
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`; const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
@@ -24,5 +24,5 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() }; return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
})); }));
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: visible.length > selected.length }; return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
} }