Archived
fix: improve file mention suggestions without ripgrep
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
+33
-4
@@ -931,14 +931,42 @@ 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");
|
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}`);
|
for (const line of lines.slice(0, 3)) console.log(` ${line}`);
|
||||||
if (lines.length > 3) console.log(" ...");
|
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.");
|
||||||
}
|
}
|
||||||
return !failed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function printPathSetupAdvice(): void {
|
function printPathSetupAdvice(): void {
|
||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user