Archived
fix: handle spaced file suggestions
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Keep all-file prompt suggestions active while typing file names with spaces, and include git-tracked/untracked matches when broad all-file scans miss them.
|
||||||
@@ -8,6 +8,7 @@ import { customElement, property, query, state } from "lit/decorators.js";
|
|||||||
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
import { api, type FileSuggestion, type SessionStatus, type SlashCommand } from "../api";
|
||||||
import { inputModeForDraft } from "../inputModes";
|
import { inputModeForDraft } from "../inputModes";
|
||||||
import { machineSessionKey } from "../machineKeys";
|
import { machineSessionKey } from "../machineKeys";
|
||||||
|
import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions";
|
||||||
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage";
|
||||||
import { promptEditorStyles, type CompletionItem } from "./shared";
|
import { promptEditorStyles, type CompletionItem } from "./shared";
|
||||||
import "./AutocompleteMenu";
|
import "./AutocompleteMenu";
|
||||||
@@ -193,7 +194,7 @@ export class PromptEditor extends LitElement {
|
|||||||
this.completions = files
|
this.completions = files
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const insertText = fileInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
|
const insertText = fileCompletionInsertText(file.path, trigger.quoted === true, file.path.endsWith("/") ? trigger.allPrefix : undefined);
|
||||||
return {
|
return {
|
||||||
kind: "file",
|
kind: "file",
|
||||||
replaceFrom: trigger.from,
|
replaceFrom: trigger.from,
|
||||||
@@ -206,30 +207,8 @@ export class PromptEditor extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private currentTrigger(): { kind: "command" | "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean } | undefined {
|
private currentTrigger(): PromptCompletionTrigger | undefined {
|
||||||
const cursor = this.editor?.state.selection.main.head ?? this.draft.length;
|
return detectPromptCompletionTrigger(this.draft, this.editor?.state.selection.main.head ?? this.draft.length);
|
||||||
const beforeCursor = this.draft.slice(0, cursor);
|
|
||||||
const quotedTrigger = this.currentQuotedTrigger(beforeCursor, cursor);
|
|
||||||
if (quotedTrigger !== undefined) return quotedTrigger;
|
|
||||||
|
|
||||||
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
|
||||||
const token = beforeCursor.slice(tokenStart);
|
|
||||||
const beforeToken = beforeCursor.slice(0, tokenStart);
|
|
||||||
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("!@")) 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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("\"");
|
|
||||||
if (quoteStart === -1) return undefined;
|
|
||||||
const prefix = beforeCursor.slice(0, quoteStart);
|
|
||||||
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: 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private moveCompletion(delta: number): boolean {
|
private moveCompletion(delta: number): boolean {
|
||||||
@@ -301,12 +280,6 @@ function draftStorageKey(machineId: unknown, sessionId: unknown): string | undef
|
|||||||
return machineSessionKey(machineId, sessionId);
|
return machineSessionKey(machineId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
|
||||||
const prefix = allPrefix ?? "@";
|
|
||||||
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
|
||||||
return `${prefix}"${path}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptySlashCommands(): SlashCommand[] {
|
function emptySlashCommands(): SlashCommand[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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 @ A FILE")).toEqual({ kind: "file" });
|
||||||
expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" });
|
expect(inputModeForDraft("open !@vendor/file.ts")).toEqual({ kind: "file" });
|
||||||
expect(inputModeForDraft("!@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" });
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { detectPromptCompletionTrigger } from "./promptCompletions";
|
||||||
|
|
||||||
export type InputMode =
|
export type InputMode =
|
||||||
| { kind: "normal" }
|
| { kind: "normal" }
|
||||||
| { kind: "command" }
|
| { kind: "command" }
|
||||||
@@ -9,7 +11,7 @@ export function inputModeForDraft(draft: string): InputMode {
|
|||||||
if (trimmed.startsWith("!@")) return { kind: "file" };
|
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 (detectPromptCompletionTrigger(draft)?.kind === "file") return { kind: "file" };
|
||||||
return { kind: "normal" };
|
return { kind: "normal" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,13 +24,3 @@ function currentToken(draft: string): string {
|
|||||||
return draft.slice(tokenStart);
|
return draft.slice(tokenStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFileCompletionContext(draft: string): boolean {
|
|
||||||
const token = currentToken(draft);
|
|
||||||
if (token.startsWith("@") || token.startsWith("!@")) return true;
|
|
||||||
const tokenStart = draft.length - token.length;
|
|
||||||
if (draft.slice(0, tokenStart).endsWith("@ ")) return true;
|
|
||||||
const quoteStart = draft.lastIndexOf("\"");
|
|
||||||
if (quoteStart === -1) return false;
|
|
||||||
const prefix = draft.slice(0, quoteStart);
|
|
||||||
return prefix.endsWith("@") || prefix.endsWith("@ ") || prefix.endsWith("!@");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { detectPromptCompletionTrigger, fileCompletionInsertText } from "./promptCompletions";
|
||||||
|
|
||||||
|
describe("detectPromptCompletionTrigger", () => {
|
||||||
|
it("keeps all-file suggestions active when an @ space query contains spaces", () => {
|
||||||
|
expect(detectPromptCompletionTrigger("open @ A FILE")).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
query: "A FILE",
|
||||||
|
from: 5,
|
||||||
|
to: 13,
|
||||||
|
fileScope: "all",
|
||||||
|
allPrefix: "@ ",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps !@ all-file suggestions active when the query contains spaces", () => {
|
||||||
|
expect(detectPromptCompletionTrigger("open !@A FILE")).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
query: "A FILE",
|
||||||
|
from: 5,
|
||||||
|
to: 13,
|
||||||
|
fileScope: "all",
|
||||||
|
allPrefix: "!@",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects quoted all-file and tracked-file queries", () => {
|
||||||
|
expect(detectPromptCompletionTrigger("open @ \"A F")).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
query: "A F",
|
||||||
|
from: 5,
|
||||||
|
to: 11,
|
||||||
|
fileScope: "all",
|
||||||
|
allPrefix: "@ ",
|
||||||
|
quoted: true,
|
||||||
|
});
|
||||||
|
expect(detectPromptCompletionTrigger("open @\"src/main")).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
query: "src/main",
|
||||||
|
from: 5,
|
||||||
|
to: 15,
|
||||||
|
fileScope: "tracked",
|
||||||
|
quoted: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects normal tracked file and leading slash command queries", () => {
|
||||||
|
expect(detectPromptCompletionTrigger("open @src/main")).toEqual({
|
||||||
|
kind: "file",
|
||||||
|
query: "src/main",
|
||||||
|
from: 5,
|
||||||
|
to: 14,
|
||||||
|
fileScope: "tracked",
|
||||||
|
});
|
||||||
|
expect(detectPromptCompletionTrigger("/model")).toEqual({ kind: "command", query: "model", from: 0, to: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fileCompletionInsertText", () => {
|
||||||
|
it("quotes completed file paths that contain spaces", () => {
|
||||||
|
expect(fileCompletionInsertText("A FILE", false)).toBe('@"A FILE"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves all-file prefixes for directories so completion can continue in that scope", () => {
|
||||||
|
expect(fileCompletionInsertText("dir with space/", false, "@ ")).toBe('@ "dir with space/"');
|
||||||
|
expect(fileCompletionInsertText("vendor/", false, "!@")).toBe("!@vendor/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export type PromptCompletionTrigger =
|
||||||
|
| { kind: "command"; query: string; from: number; to: number }
|
||||||
|
| { kind: "file"; query: string; from: number; to: number; fileScope?: "tracked" | "all" | undefined; allPrefix?: "@ " | "!@" | undefined; quoted?: boolean };
|
||||||
|
|
||||||
|
export function detectPromptCompletionTrigger(draft: string, cursor = draft.length): PromptCompletionTrigger | undefined {
|
||||||
|
const beforeCursor = draft.slice(0, cursor);
|
||||||
|
const quotedTrigger = currentQuotedTrigger(beforeCursor, cursor);
|
||||||
|
if (quotedTrigger !== undefined) return quotedTrigger;
|
||||||
|
|
||||||
|
const allFileTrigger = currentUnquotedAllFileTrigger(beforeCursor, cursor);
|
||||||
|
if (allFileTrigger !== undefined) return allFileTrigger;
|
||||||
|
|
||||||
|
const tokenStart = Math.max(beforeCursor.lastIndexOf(" "), beforeCursor.lastIndexOf("\n")) + 1;
|
||||||
|
const token = beforeCursor.slice(tokenStart);
|
||||||
|
const beforeToken = beforeCursor.slice(0, tokenStart);
|
||||||
|
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("!@")) 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileCompletionInsertText(path: string, quoted: boolean, allPrefix?: "@ " | "!@"): string {
|
||||||
|
const prefix = allPrefix ?? "@";
|
||||||
|
if (!quoted && !path.includes(" ")) return `${prefix}${path}`;
|
||||||
|
return `${prefix}"${path}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentQuotedTrigger(beforeCursor: string, cursor: number): PromptCompletionTrigger | undefined {
|
||||||
|
const quoteStart = beforeCursor.lastIndexOf("\"");
|
||||||
|
if (quoteStart === -1) return undefined;
|
||||||
|
const prefix = beforeCursor.slice(0, quoteStart);
|
||||||
|
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: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUnquotedAllFileTrigger(beforeCursor: string, cursor: number): PromptCompletionTrigger | undefined {
|
||||||
|
const lineStart = beforeCursor.lastIndexOf("\n") + 1;
|
||||||
|
const line = beforeCursor.slice(lineStart);
|
||||||
|
const atSpaceIndex = lastTokenBoundarySequence(line, "@ ");
|
||||||
|
const bangAtIndex = lastTokenBoundarySequence(line, "!@");
|
||||||
|
const prefixStartInLine = Math.max(atSpaceIndex, bangAtIndex);
|
||||||
|
if (prefixStartInLine === -1) return undefined;
|
||||||
|
|
||||||
|
const allPrefix: "@ " | "!@" = prefixStartInLine === bangAtIndex ? "!@" : "@ ";
|
||||||
|
const from = lineStart + prefixStartInLine;
|
||||||
|
const queryStart = from + allPrefix.length;
|
||||||
|
return { kind: "file", query: beforeCursor.slice(queryStart), from, to: cursor, fileScope: "all", allPrefix };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastTokenBoundarySequence(text: string, sequence: string): number {
|
||||||
|
for (let index = text.lastIndexOf(sequence); index >= 0; index = text.lastIndexOf(sequence, index - 1)) {
|
||||||
|
if (index === 0 || isWhitespace(text[index - 1])) return index;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWhitespace(value: string | undefined): boolean {
|
||||||
|
return value === " " || value === "\t";
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ describe("file suggestions", () => {
|
|||||||
const deps: FileSuggestionDependencies = {
|
const deps: FileSuggestionDependencies = {
|
||||||
execFile: (file, args) => {
|
execFile: (file, args) => {
|
||||||
calls.push({ file, args });
|
calls.push({ file, args });
|
||||||
if (file === "git" && args.join(" ") === "ls-files") return Promise.resolve({ stdout: "src/app.ts\nREADME.md\n" });
|
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0README.md\0" });
|
||||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -32,7 +32,7 @@ describe("file suggestions", () => {
|
|||||||
{ path: "README.md", kind: "tracked" },
|
{ path: "README.md", kind: "tracked" },
|
||||||
{ path: "src/app.ts", kind: "tracked" },
|
{ path: "src/app.ts", kind: "tracked" },
|
||||||
]);
|
]);
|
||||||
expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]);
|
expect(calls).toEqual([{ file: "git", args: ["ls-files", "-z"] }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("asks ripgrep for hidden and ignored files in all-file scope", async () => {
|
it("asks ripgrep for hidden and ignored files in all-file scope", async () => {
|
||||||
@@ -40,6 +40,7 @@ describe("file suggestions", () => {
|
|||||||
const deps: FileSuggestionDependencies = {
|
const deps: FileSuggestionDependencies = {
|
||||||
execFile: (file, args) => {
|
execFile: (file, args) => {
|
||||||
calls.push({ file, args });
|
calls.push({ file, args });
|
||||||
|
if (file === "git") return Promise.reject(new Error("not a git repository"));
|
||||||
return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" });
|
return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -48,10 +49,57 @@ describe("file suggestions", () => {
|
|||||||
{ path: "node_modules/pkg/", kind: "other" },
|
{ path: "node_modules/pkg/", kind: "other" },
|
||||||
{ path: "node_modules/pkg/index.js", kind: "other" },
|
{ path: "node_modules/pkg/index.js", kind: "other" },
|
||||||
]);
|
]);
|
||||||
expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]);
|
expect(calls).toEqual([
|
||||||
|
{ file: "git", args: ["ls-files", "-z"] },
|
||||||
|
{ file: "git", args: ["ls-files", "--others", "--exclude-standard", "-z"] },
|
||||||
|
{ file: "rg", args: ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => {
|
it("keeps git untracked files in all-file scope when the broad scan misses them", async () => {
|
||||||
|
const deps: FileSuggestionDependencies = {
|
||||||
|
execFile: (file, args) => {
|
||||||
|
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "src/app.ts\0" });
|
||||||
|
if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") return Promise.resolve({ stdout: "MD PRojects here.md\0" });
|
||||||
|
if (file === "rg") return Promise.resolve({ stdout: "src/app.ts\n" });
|
||||||
|
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(listFileSuggestions("/repo", "MD PRojects", { scope: "all" }, deps)).resolves.toEqual([
|
||||||
|
{ path: "MD PRojects here.md", kind: "untracked" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks basename matches before deeper incidental path matches", async () => {
|
||||||
|
const deps: FileSuggestionDependencies = {
|
||||||
|
execFile: (file, args) => {
|
||||||
|
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: "klingit-go/cli/cmd/dev/main.go\0MD PRojects here.md\0" });
|
||||||
|
if (file === "git" && args.join(" ") === "ls-files --others --exclude-standard -z") return Promise.resolve({ stdout: "" });
|
||||||
|
if (file === "rg") return Promise.resolve({ stdout: "" });
|
||||||
|
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestions = await listFileSuggestions("/repo", "MD", { scope: "all" }, deps);
|
||||||
|
|
||||||
|
expect(suggestions[0]).toEqual({ path: "MD PRojects here.md", kind: "tracked" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves git filenames without trimming whitespace", async () => {
|
||||||
|
const deps: FileSuggestionDependencies = {
|
||||||
|
execFile: (file, args) => {
|
||||||
|
if (file === "git" && args.join(" ") === "ls-files -z") return Promise.resolve({ stdout: " leading.md\0" });
|
||||||
|
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(listFileSuggestions("/repo", " leading", { scope: "tracked" }, deps)).resolves.toEqual([
|
||||||
|
{ path: " leading.md", kind: "tracked" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a bounded filesystem scan without non-git directory exclusions when git and rg are unavailable", async () => {
|
||||||
const root = await tempWorkspace();
|
const root = await tempWorkspace();
|
||||||
await mkdir(join(root, "src"), { recursive: true });
|
await mkdir(join(root, "src"), { recursive: true });
|
||||||
await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
|
await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { ClientFileSuggestion } from "../types.js";
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const commandMaxBuffer = 1024 * 1024 * 8;
|
const commandMaxBuffer = 1024 * 1024 * 8;
|
||||||
const maxFilesystemFallbackPaths = 20_000;
|
const maxFilesystemFallbackPaths = 20_000;
|
||||||
|
const maxFileSuggestions = 80;
|
||||||
|
|
||||||
interface ExecFileOptions {
|
interface ExecFileOptions {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -30,11 +31,10 @@ export async function listFileSuggestions(cwd: string, query = "", options: File
|
|||||||
const normalizedQuery = normalizeFileQuery(query);
|
const normalizedQuery = normalizeFileQuery(query);
|
||||||
const exec = deps.execFile ?? execFileAsync;
|
const exec = deps.execFile ?? execFileAsync;
|
||||||
const files = await listFilesForScope(cwd, options.scope, exec);
|
const files = await listFilesForScope(cwd, options.scope, exec);
|
||||||
return files
|
return rankFileSuggestions(
|
||||||
.filter((file) => options.kind === undefined || file.kind === options.kind)
|
files.filter((file) => options.kind === undefined || file.kind === options.kind),
|
||||||
.filter((file) => normalizedQuery === "" || file.path.toLowerCase().includes(normalizedQuery))
|
normalizedQuery,
|
||||||
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
).slice(0, maxFileSuggestions);
|
||||||
.slice(0, 80);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
|
export async function listPathSuggestions(cwd: string, prefix = ""): Promise<ClientFileSuggestion[]> {
|
||||||
@@ -61,31 +61,39 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise<Cli
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): 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 === "all") return listAllFiles(cwd, exec);
|
||||||
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => 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));
|
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||||
return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked");
|
return withDirectories(nulRecords(await git(cwd, ["ls-files", "-z"], exec)), "tracked");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
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"], exec),
|
git(cwd, ["ls-files", "-z"], exec),
|
||||||
git(cwd, ["ls-files", "--others", "--exclude-standard"], exec),
|
git(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], exec),
|
||||||
]);
|
]);
|
||||||
return [
|
return [
|
||||||
...withDirectories(lines(tracked), "tracked"),
|
...withDirectories(nulRecords(tracked), "tracked"),
|
||||||
...withDirectories(lines(untracked), "untracked"),
|
...withDirectories(nulRecords(untracked), "untracked"),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listAllFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||||
|
const [gitFiles, plainFiles] = await Promise.all([
|
||||||
|
listGitFiles(cwd, exec).catch((): ClientFileSuggestion[] => []),
|
||||||
|
listPlainFiles(cwd, exec, true),
|
||||||
|
]);
|
||||||
|
return mergeSuggestions(gitFiles, plainFiles);
|
||||||
|
}
|
||||||
|
|
||||||
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||||
try {
|
try {
|
||||||
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"];
|
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore", "--glob", "!.git", "--glob", "!.git/**"] : ["--files"];
|
||||||
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
|
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
|
||||||
return withDirectories(lines(stdout), "other");
|
return withDirectories(textLines(stdout), "other");
|
||||||
} catch {
|
} catch {
|
||||||
return withDirectories(await filesystemFiles(cwd), "other");
|
return withDirectories(await filesystemFiles(cwd), "other");
|
||||||
}
|
}
|
||||||
@@ -113,6 +121,7 @@ async function collectFilesystemFiles(cwd: string, relativeDirectory: string, pa
|
|||||||
if (paths.length >= maxFilesystemFallbackPaths) return;
|
if (paths.length >= maxFilesystemFallbackPaths) return;
|
||||||
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
|
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === ".git") continue;
|
||||||
await collectFilesystemFiles(cwd, relativePath, paths, true);
|
await collectFilesystemFiles(cwd, relativePath, paths, true);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -135,11 +144,88 @@ async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestion
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFileQuery(query: string): string {
|
function normalizeFileQuery(query: string): string {
|
||||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").toLowerCase();
|
return query.replace(/^!@/, "").replace(/^@\s?/, "").replace(/^"/, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function lines(text: string): string[] {
|
function rankFileSuggestions(files: ClientFileSuggestion[], normalizedQuery: string): ClientFileSuggestion[] {
|
||||||
return text.split("\n").map((line) => line.trim()).filter(Boolean);
|
if (normalizedQuery === "") return [...files].sort(compareFileSuggestions);
|
||||||
|
return files
|
||||||
|
.map((file) => ({ file, score: fileSuggestionScore(file.path, normalizedQuery) }))
|
||||||
|
.filter(({ score }) => score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score || kindRank(a.file.kind) - kindRank(b.file.kind) || pathDepth(a.file.path) - pathDepth(b.file.path) || compareFileSuggestions(a.file, b.file))
|
||||||
|
.map(({ file }) => file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSuggestionScore(path: string, normalizedQuery: string): number {
|
||||||
|
const normalizedPath = normalizeSuggestionPathForSearch(path);
|
||||||
|
const name = displayBasename(normalizedPath);
|
||||||
|
if (normalizedPath === normalizedQuery) return 1000;
|
||||||
|
if (name === normalizedQuery) return 980;
|
||||||
|
if (name.startsWith(normalizedQuery)) return 900;
|
||||||
|
if (normalizedPath.startsWith(normalizedQuery)) return 850;
|
||||||
|
if (name.includes(normalizedQuery)) return 750;
|
||||||
|
if (normalizedPath.includes(normalizedQuery)) return 650;
|
||||||
|
|
||||||
|
const tokens = normalizedQuery.split(/\s+/u).filter(Boolean);
|
||||||
|
if (tokens.length > 1 && tokens.every((token) => normalizedPath.includes(token))) {
|
||||||
|
return 550 + tokens.filter((token) => name.includes(token)).length * 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSubsequence(normalizedQuery, normalizedPath) ? 200 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSuggestionPathForSearch(path: string): string {
|
||||||
|
const lower = path.toLowerCase();
|
||||||
|
return lower.endsWith("/") ? lower.slice(0, -1) : lower;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayBasename(path: string): string {
|
||||||
|
return path.split("/").filter(Boolean).at(-1) ?? path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubsequence(needle: string, haystack: string): boolean {
|
||||||
|
let haystackIndex = 0;
|
||||||
|
for (const char of needle) {
|
||||||
|
haystackIndex = haystack.indexOf(char, haystackIndex);
|
||||||
|
if (haystackIndex === -1) return false;
|
||||||
|
haystackIndex += char.length;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareFileSuggestions(a: ClientFileSuggestion, b: ClientFileSuggestion): number {
|
||||||
|
return Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindRank(kind: ClientFileSuggestion["kind"]): number {
|
||||||
|
switch (kind) {
|
||||||
|
case "tracked": return 0;
|
||||||
|
case "untracked": return 1;
|
||||||
|
case "other": return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathDepth(path: string): number {
|
||||||
|
return path.split("/").filter(Boolean).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textLines(text: string): string[] {
|
||||||
|
return text.split("\n").map((line) => line.endsWith("\r") ? line.slice(0, -1) : line).filter((line) => line !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nulRecords(text: string): string[] {
|
||||||
|
return text.split("\0").filter((record) => record !== "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSuggestions(primary: ClientFileSuggestion[], secondary: ClientFileSuggestion[]): ClientFileSuggestion[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: ClientFileSuggestion[] = [];
|
||||||
|
for (const suggestion of [...primary, ...secondary]) {
|
||||||
|
if (seen.has(suggestion.path)) continue;
|
||||||
|
seen.add(suggestion.path);
|
||||||
|
merged.push(suggestion);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withDirectories(paths: string[], kind: ClientFileSuggestion["kind"]): ClientFileSuggestion[] {
|
function withDirectories(paths: string[], kind: ClientFileSuggestion["kind"]): ClientFileSuggestion[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user