fix(tui): support quoted paths with spaces in autocomplete

Fixes #1077
This commit is contained in:
Mario Zechner 2026-01-30 00:11:12 +01:00
parent 52532c7c00
commit dc8539a001
3 changed files with 236 additions and 75 deletions

View file

@ -1,5 +1,11 @@
# Changelog
## [Unreleased]
### Fixed
- Fixed autocomplete for paths with spaces by supporting quoted path tokens ([#1077](https://github.com/badlogic/pi-mono/issues/1077))
## [0.50.3] - 2026-01-29
## [0.50.2] - 2026-01-29

View file

@ -4,6 +4,86 @@ import { homedir } from "os";
import { basename, dirname, join } from "path";
import { fuzzyFilter } from "./fuzzy.js";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
return i;
}
}
return -1;
}
function findUnclosedQuoteStart(text: string): number | null {
let inQuotes = false;
let quoteStart = -1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '"') {
inQuotes = !inQuotes;
if (inQuotes) {
quoteStart = i;
}
}
}
return inQuotes ? quoteStart : null;
}
function isTokenStart(text: string, index: number): boolean {
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
}
function extractQuotedPrefix(text: string): string | null {
const quoteStart = findUnclosedQuoteStart(text);
if (quoteStart === null) {
return null;
}
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
if (!isTokenStart(text, quoteStart - 1)) {
return null;
}
return text.slice(quoteStart - 1);
}
if (!isTokenStart(text, quoteStart)) {
return null;
}
return text.slice(quoteStart);
}
function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
if (prefix.startsWith('@"')) {
return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
}
if (prefix.startsWith('"')) {
return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
}
if (prefix.startsWith("@")) {
return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
}
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
}
function buildCompletionValue(
path: string,
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
): string {
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
const prefix = options.isAtPrefix ? "@" : "";
if (!needsQuotes) {
return `${prefix}${path}`;
}
const openQuote = `${prefix}"`;
const closeQuote = options.isDirectory ? "" : '"';
return `${openQuote}${path}${closeQuote}`;
}
// Use fd to walk directory tree (fast, respects .gitignore)
function walkDirectoryWithFd(
baseDir: string,
@ -118,17 +198,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Check for @ file reference (fuzzy search) - must be after a space or at start
const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/);
if (atMatch) {
const prefix = atMatch[1] ?? "@"; // The @... part
const query = prefix.slice(1); // Remove the @
const suggestions = this.getFuzzyFileSuggestions(query);
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
const atPrefix = this.extractAtPrefix(textBeforeCursor);
if (atPrefix) {
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
const suggestions = this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix: isQuotedPrefix });
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: prefix,
prefix: atPrefix,
};
}
@ -281,24 +360,31 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
};
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
// Check for @ file attachment syntax first
const atMatch = text.match(/@([^\s]*)$/);
if (atMatch) {
return atMatch[0]; // Return the full @path pattern
// Extract @ prefix for fuzzy file suggestions
private extractAtPrefix(text: string): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix?.startsWith('@"')) {
return quotedPrefix;
}
// Simple approach: find the last whitespace/delimiter and extract the word after it
// This avoids catastrophic backtracking from nested quantifiers
const lastDelimiterIndex = Math.max(
text.lastIndexOf(" "),
text.lastIndexOf("\t"),
text.lastIndexOf('"'),
text.lastIndexOf("'"),
text.lastIndexOf("="),
);
const lastDelimiterIndex = findLastDelimiter(text);
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
if (text[tokenStart] === "@") {
return text.slice(tokenStart);
}
return null;
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
// For forced extraction (Tab key), always return something
@ -338,39 +424,34 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
try {
let searchDir: string;
let searchPrefix: string;
let expandedPrefix = prefix;
let isAtPrefix = false;
// Handle @ file attachment prefix
if (prefix.startsWith("@")) {
isAtPrefix = true;
expandedPrefix = prefix.slice(1); // Remove the @
}
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
let expandedPrefix = rawPrefix;
// Handle home directory expansion
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.expandHomePath(expandedPrefix);
}
if (
expandedPrefix === "" ||
expandedPrefix === "./" ||
expandedPrefix === "../" ||
expandedPrefix === "~" ||
expandedPrefix === "~/" ||
expandedPrefix === "/" ||
prefix === "@"
) {
const isRootPrefix =
rawPrefix === "" ||
rawPrefix === "./" ||
rawPrefix === "../" ||
rawPrefix === "~" ||
rawPrefix === "~/" ||
rawPrefix === "/" ||
(isAtPrefix && rawPrefix === "");
if (isRootPrefix) {
// Complete from specified position
if (prefix.startsWith("~") || expandedPrefix === "/") {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else if (expandedPrefix.endsWith("/")) {
} else if (rawPrefix.endsWith("/")) {
// If prefix ends with /, show contents of that directory
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
@ -380,7 +461,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
// Split into directory and file prefix
const dir = dirname(expandedPrefix);
const file = basename(expandedPrefix);
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = dir;
} else {
searchDir = join(this.basePath, dir);
@ -409,58 +490,46 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
let relativePath: string;
const name = entry.name;
const displayPrefix = rawPrefix;
// Handle @ prefix path construction
if (isAtPrefix) {
const pathWithoutAt = expandedPrefix;
if (pathWithoutAt.endsWith("/")) {
relativePath = `@${pathWithoutAt}${name}`;
} else if (pathWithoutAt.includes("/")) {
if (pathWithoutAt.startsWith("~/")) {
const homeRelativeDir = pathWithoutAt.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = `@~/${dir === "." ? name : join(dir, name)}`;
} else {
relativePath = `@${join(dirname(pathWithoutAt), name)}`;
}
} else {
if (pathWithoutAt.startsWith("~")) {
relativePath = `@~/${name}`;
} else {
relativePath = `@${name}`;
}
}
} else if (prefix.endsWith("/")) {
if (displayPrefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = prefix + name;
} else if (prefix.includes("/")) {
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/")) {
// Preserve ~/ format for home directory paths
if (prefix.startsWith("~/")) {
const homeRelativeDir = prefix.slice(2); // Remove ~/
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
} else if (prefix.startsWith("/")) {
} else if (displayPrefix.startsWith("/")) {
// Absolute path - construct properly
const dir = dirname(prefix);
const dir = dirname(displayPrefix);
if (dir === "/") {
relativePath = `/${name}`;
} else {
relativePath = `${dir}/${name}`;
}
} else {
relativePath = join(dirname(prefix), name);
relativePath = join(dirname(displayPrefix), name);
}
} else {
// For standalone entries, preserve ~/ if original prefix was ~/
if (prefix.startsWith("~")) {
if (displayPrefix.startsWith("~")) {
relativePath = `~/${name}`;
} else {
relativePath = name;
}
}
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,
isAtPrefix,
isQuotedPrefix,
});
suggestions.push({
value: isDirectory ? `${relativePath}/` : relativePath,
value,
label: name + (isDirectory ? "/" : ""),
});
}
@ -506,7 +575,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
}
// Fuzzy file search using fd (fast, respects .gitignore)
private getFuzzyFileSuggestions(query: string): AutocompleteItem[] {
private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] {
if (!this.fdPath) {
// fd not available, return empty results
return [];
@ -533,9 +602,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
// fd already includes trailing / for directories
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
const entryName = basename(pathWithoutSlash);
const value = buildCompletionValue(entryPath, {
isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value: `@${entryPath}`,
value,
label: entryName + (isDirectory ? "/" : ""),
description: pathWithoutSlash,
});

View file

@ -230,5 +230,86 @@ describe("CombinedAutocompleteProvider", () => {
assert.ok(values?.includes("@src/components/Button.tsx"));
assert.ok(!values?.includes("@src/utils/helpers.ts"));
});
test("quotes paths with spaces for @ suggestions", () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@my";
const result = provider.getSuggestions([line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/'));
});
test("continues autocomplete inside quoted @ paths", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = '@"my folder/';
const result = provider.getSuggestions([line], 0, line.length);
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/test.txt"'));
assert.ok(values?.includes('@"my folder/other.txt"'));
});
});
describe("quoted path completion", () => {
let baseDir = "";
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
});
afterEach(() => {
rmSync(baseDir, { recursive: true, force: true });
});
test("quotes paths with spaces for direct completion", () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "my";
const result = provider.getForceFileSuggestions([line], 0, line.length);
assert.notEqual(result, null, "Should return suggestions for path completion");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/'));
});
test("continues completion inside quoted paths", () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/';
const result = provider.getForceFileSuggestions([line], 0, line.length);
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/test.txt"'));
assert.ok(values?.includes('"my folder/other.txt"'));
});
});
});