diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 84f0be2f..7a7f55b5 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -447,6 +447,42 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { return path; } + private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null { + const slashIndex = rawQuery.lastIndexOf("/"); + if (slashIndex === -1) { + return null; + } + + const displayBase = rawQuery.slice(0, slashIndex + 1); + const query = rawQuery.slice(slashIndex + 1); + + let baseDir: string; + if (displayBase.startsWith("~/")) { + baseDir = this.expandHomePath(displayBase); + } else if (displayBase.startsWith("/")) { + baseDir = displayBase; + } else { + baseDir = join(this.basePath, displayBase); + } + + try { + if (!statSync(baseDir).isDirectory()) { + return null; + } + } catch { + return null; + } + + return { baseDir, query, displayBase }; + } + + private scopedPathForDisplay(displayBase: string, relativePath: string): string { + if (displayBase === "/") { + return `/${relativePath}`; + } + return `${displayBase}${relativePath}`; + } + // Get file/directory suggestions for a given path prefix private getFileSuggestions(prefix: string): AutocompleteItem[] { try { @@ -610,13 +646,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { } try { - const entries = walkDirectoryWithFd(this.basePath, this.fdPath, query, 100); + const scopedQuery = this.resolveScopedFuzzyQuery(query); + const fdBaseDir = scopedQuery?.baseDir ?? this.basePath; + const fdQuery = scopedQuery?.query ?? query; + const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100); // Score entries const scoredEntries = entries .map((entry) => ({ ...entry, - score: query ? this.scoreEntry(entry.path, query, entry.isDirectory) : 1, + score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1, })) .filter((entry) => entry.score > 0); @@ -629,8 +668,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { for (const { path: entryPath, isDirectory } of topEntries) { // fd already includes trailing / for directories const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath; + const displayPath = scopedQuery + ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash) + : pathWithoutSlash; const entryName = basename(pathWithoutSlash); - const value = buildCompletionValue(entryPath, { + const completionPath = isDirectory ? `${displayPath}/` : displayPath; + const value = buildCompletionValue(completionPath, { isDirectory, isAtPrefix: true, isQuotedPrefix: options.isQuotedPrefix, @@ -639,7 +682,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider { suggestions.push({ value, label: entryName + (isDirectory ? "/" : ""), - description: pathWithoutSlash, + description: displayPath, }); } diff --git a/packages/tui/test/autocomplete.test.ts b/packages/tui/test/autocomplete.test.ts index 6713c51f..6f06b89a 100644 --- a/packages/tui/test/autocomplete.test.ts +++ b/packages/tui/test/autocomplete.test.ts @@ -107,14 +107,20 @@ describe("CombinedAutocompleteProvider", () => { }); describe("fd @ file suggestions", { skip: !isFdInstalled }, () => { + let rootDir = ""; let baseDir = ""; + let outsideDir = ""; beforeEach(() => { - baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-")); + rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-")); + baseDir = join(rootDir, "cwd"); + outsideDir = join(rootDir, "outside"); + mkdirSync(baseDir, { recursive: true }); + mkdirSync(outsideDir, { recursive: true }); }); afterEach(() => { - rmSync(baseDir, { recursive: true, force: true }); + rmSync(rootDir, { recursive: true, force: true }); }); test("returns all files and folders for empty @ query", () => { @@ -231,6 +237,25 @@ describe("CombinedAutocompleteProvider", () => { assert.ok(!values?.includes("@src/utils/helpers.ts")); }); + test("scopes fuzzy search to relative directories and searches recursively", () => { + setupFolder(outsideDir, { + files: { + "nested/alpha.ts": "export {};", + "nested/deeper/also-alpha.ts": "export {};", + "nested/deeper/zzz.ts": "export {};", + }, + }); + + const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath()); + const line = "@../outside/a"; + const result = provider.getSuggestions([line], 0, line.length); + + const values = result?.items.map((item) => item.value); + assert.ok(values?.includes("@../outside/nested/alpha.ts")); + assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts")); + assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts")); + }); + test("quotes paths with spaces for @ suggestions", () => { setupFolder(baseDir, { dirs: ["my folder"],