mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 16:01:05 +00:00
parent
52532c7c00
commit
dc8539a001
3 changed files with 236 additions and 75 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
# Changelog
|
# 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.3] - 2026-01-29
|
||||||
|
|
||||||
## [0.50.2] - 2026-01-29
|
## [0.50.2] - 2026-01-29
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,86 @@ import { homedir } from "os";
|
||||||
import { basename, dirname, join } from "path";
|
import { basename, dirname, join } from "path";
|
||||||
import { fuzzyFilter } from "./fuzzy.js";
|
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)
|
// Use fd to walk directory tree (fast, respects .gitignore)
|
||||||
function walkDirectoryWithFd(
|
function walkDirectoryWithFd(
|
||||||
baseDir: string,
|
baseDir: string,
|
||||||
|
|
@ -118,17 +198,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
const currentLine = lines[cursorLine] || "";
|
const currentLine = lines[cursorLine] || "";
|
||||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||||
|
|
||||||
// Check for @ file reference (fuzzy search) - must be after a space or at start
|
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
|
||||||
const atMatch = textBeforeCursor.match(/(?:^|[\s])(@[^\s]*)$/);
|
const atPrefix = this.extractAtPrefix(textBeforeCursor);
|
||||||
if (atMatch) {
|
if (atPrefix) {
|
||||||
const prefix = atMatch[1] ?? "@"; // The @... part
|
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
|
||||||
const query = prefix.slice(1); // Remove the @
|
const suggestions = this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix: isQuotedPrefix });
|
||||||
const suggestions = this.getFuzzyFileSuggestions(query);
|
|
||||||
if (suggestions.length === 0) return null;
|
if (suggestions.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: suggestions,
|
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
|
// Extract @ prefix for fuzzy file suggestions
|
||||||
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
|
private extractAtPrefix(text: string): string | null {
|
||||||
// Check for @ file attachment syntax first
|
const quotedPrefix = extractQuotedPrefix(text);
|
||||||
const atMatch = text.match(/@([^\s]*)$/);
|
if (quotedPrefix?.startsWith('@"')) {
|
||||||
if (atMatch) {
|
return quotedPrefix;
|
||||||
return atMatch[0]; // Return the full @path pattern
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple approach: find the last whitespace/delimiter and extract the word after it
|
const lastDelimiterIndex = findLastDelimiter(text);
|
||||||
// This avoids catastrophic backtracking from nested quantifiers
|
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
|
||||||
const lastDelimiterIndex = Math.max(
|
|
||||||
text.lastIndexOf(" "),
|
|
||||||
text.lastIndexOf("\t"),
|
|
||||||
text.lastIndexOf('"'),
|
|
||||||
text.lastIndexOf("'"),
|
|
||||||
text.lastIndexOf("="),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
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);
|
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
|
||||||
|
|
||||||
// For forced extraction (Tab key), always return something
|
// For forced extraction (Tab key), always return something
|
||||||
|
|
@ -338,39 +424,34 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
try {
|
try {
|
||||||
let searchDir: string;
|
let searchDir: string;
|
||||||
let searchPrefix: string;
|
let searchPrefix: string;
|
||||||
let expandedPrefix = prefix;
|
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
|
||||||
let isAtPrefix = false;
|
let expandedPrefix = rawPrefix;
|
||||||
|
|
||||||
// Handle @ file attachment prefix
|
|
||||||
if (prefix.startsWith("@")) {
|
|
||||||
isAtPrefix = true;
|
|
||||||
expandedPrefix = prefix.slice(1); // Remove the @
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle home directory expansion
|
// Handle home directory expansion
|
||||||
if (expandedPrefix.startsWith("~")) {
|
if (expandedPrefix.startsWith("~")) {
|
||||||
expandedPrefix = this.expandHomePath(expandedPrefix);
|
expandedPrefix = this.expandHomePath(expandedPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const isRootPrefix =
|
||||||
expandedPrefix === "" ||
|
rawPrefix === "" ||
|
||||||
expandedPrefix === "./" ||
|
rawPrefix === "./" ||
|
||||||
expandedPrefix === "../" ||
|
rawPrefix === "../" ||
|
||||||
expandedPrefix === "~" ||
|
rawPrefix === "~" ||
|
||||||
expandedPrefix === "~/" ||
|
rawPrefix === "~/" ||
|
||||||
expandedPrefix === "/" ||
|
rawPrefix === "/" ||
|
||||||
prefix === "@"
|
(isAtPrefix && rawPrefix === "");
|
||||||
) {
|
|
||||||
|
if (isRootPrefix) {
|
||||||
// Complete from specified position
|
// Complete from specified position
|
||||||
if (prefix.startsWith("~") || expandedPrefix === "/") {
|
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||||
searchDir = expandedPrefix;
|
searchDir = expandedPrefix;
|
||||||
} else {
|
} else {
|
||||||
searchDir = join(this.basePath, expandedPrefix);
|
searchDir = join(this.basePath, expandedPrefix);
|
||||||
}
|
}
|
||||||
searchPrefix = "";
|
searchPrefix = "";
|
||||||
} else if (expandedPrefix.endsWith("/")) {
|
} else if (rawPrefix.endsWith("/")) {
|
||||||
// If prefix ends with /, show contents of that directory
|
// If prefix ends with /, show contents of that directory
|
||||||
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||||
searchDir = expandedPrefix;
|
searchDir = expandedPrefix;
|
||||||
} else {
|
} else {
|
||||||
searchDir = join(this.basePath, expandedPrefix);
|
searchDir = join(this.basePath, expandedPrefix);
|
||||||
|
|
@ -380,7 +461,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
// Split into directory and file prefix
|
// Split into directory and file prefix
|
||||||
const dir = dirname(expandedPrefix);
|
const dir = dirname(expandedPrefix);
|
||||||
const file = basename(expandedPrefix);
|
const file = basename(expandedPrefix);
|
||||||
if (prefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
|
||||||
searchDir = dir;
|
searchDir = dir;
|
||||||
} else {
|
} else {
|
||||||
searchDir = join(this.basePath, dir);
|
searchDir = join(this.basePath, dir);
|
||||||
|
|
@ -409,58 +490,46 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
|
|
||||||
let relativePath: string;
|
let relativePath: string;
|
||||||
const name = entry.name;
|
const name = entry.name;
|
||||||
|
const displayPrefix = rawPrefix;
|
||||||
|
|
||||||
// Handle @ prefix path construction
|
if (displayPrefix.endsWith("/")) {
|
||||||
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 prefix ends with /, append entry to the prefix
|
// If prefix ends with /, append entry to the prefix
|
||||||
relativePath = prefix + name;
|
relativePath = displayPrefix + name;
|
||||||
} else if (prefix.includes("/")) {
|
} else if (displayPrefix.includes("/")) {
|
||||||
// Preserve ~/ format for home directory paths
|
// Preserve ~/ format for home directory paths
|
||||||
if (prefix.startsWith("~/")) {
|
if (displayPrefix.startsWith("~/")) {
|
||||||
const homeRelativeDir = prefix.slice(2); // Remove ~/
|
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
|
||||||
const dir = dirname(homeRelativeDir);
|
const dir = dirname(homeRelativeDir);
|
||||||
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
|
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
|
||||||
} else if (prefix.startsWith("/")) {
|
} else if (displayPrefix.startsWith("/")) {
|
||||||
// Absolute path - construct properly
|
// Absolute path - construct properly
|
||||||
const dir = dirname(prefix);
|
const dir = dirname(displayPrefix);
|
||||||
if (dir === "/") {
|
if (dir === "/") {
|
||||||
relativePath = `/${name}`;
|
relativePath = `/${name}`;
|
||||||
} else {
|
} else {
|
||||||
relativePath = `${dir}/${name}`;
|
relativePath = `${dir}/${name}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
relativePath = join(dirname(prefix), name);
|
relativePath = join(dirname(displayPrefix), name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For standalone entries, preserve ~/ if original prefix was ~/
|
// For standalone entries, preserve ~/ if original prefix was ~/
|
||||||
if (prefix.startsWith("~")) {
|
if (displayPrefix.startsWith("~")) {
|
||||||
relativePath = `~/${name}`;
|
relativePath = `~/${name}`;
|
||||||
} else {
|
} else {
|
||||||
relativePath = name;
|
relativePath = name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
|
||||||
|
const value = buildCompletionValue(pathValue, {
|
||||||
|
isDirectory,
|
||||||
|
isAtPrefix,
|
||||||
|
isQuotedPrefix,
|
||||||
|
});
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
value: isDirectory ? `${relativePath}/` : relativePath,
|
value,
|
||||||
label: name + (isDirectory ? "/" : ""),
|
label: name + (isDirectory ? "/" : ""),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -506,7 +575,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fuzzy file search using fd (fast, respects .gitignore)
|
// Fuzzy file search using fd (fast, respects .gitignore)
|
||||||
private getFuzzyFileSuggestions(query: string): AutocompleteItem[] {
|
private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] {
|
||||||
if (!this.fdPath) {
|
if (!this.fdPath) {
|
||||||
// fd not available, return empty results
|
// fd not available, return empty results
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -533,9 +602,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||||
// fd already includes trailing / for directories
|
// fd already includes trailing / for directories
|
||||||
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
|
||||||
const entryName = basename(pathWithoutSlash);
|
const entryName = basename(pathWithoutSlash);
|
||||||
|
const value = buildCompletionValue(entryPath, {
|
||||||
|
isDirectory,
|
||||||
|
isAtPrefix: true,
|
||||||
|
isQuotedPrefix: options.isQuotedPrefix,
|
||||||
|
});
|
||||||
|
|
||||||
suggestions.push({
|
suggestions.push({
|
||||||
value: `@${entryPath}`,
|
value,
|
||||||
label: entryName + (isDirectory ? "/" : ""),
|
label: entryName + (isDirectory ? "/" : ""),
|
||||||
description: pathWithoutSlash,
|
description: pathWithoutSlash,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -230,5 +230,86 @@ describe("CombinedAutocompleteProvider", () => {
|
||||||
assert.ok(values?.includes("@src/components/Button.tsx"));
|
assert.ok(values?.includes("@src/components/Button.tsx"));
|
||||||
assert.ok(!values?.includes("@src/utils/helpers.ts"));
|
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"'));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue