mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(tui): More flexile autocomplete for paths
This commit is contained in:
@@ -4,7 +4,12 @@ import { homedir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { fuzzyFilter } from "./fuzzy.js";
|
||||
|
||||
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
|
||||
const PATH_TOKEN_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
|
||||
const AUTOCOMPLETE_BOUNDARY_PUNCTUATION = new Set(["(", ")", "[", "]", "{", "}", "<", ">", ",", ";"]);
|
||||
|
||||
export function isAutocompleteTokenBoundary(char: string | undefined): boolean {
|
||||
return char === undefined || PATH_TOKEN_DELIMITERS.has(char) || AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(char);
|
||||
}
|
||||
|
||||
function toDisplayPath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
@@ -44,13 +49,37 @@ function buildFdPathQuery(query: string): string {
|
||||
|
||||
function findLastDelimiter(text: string): number {
|
||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||||
if (PATH_DELIMITERS.has(text[i] ?? "")) {
|
||||
if (PATH_TOKEN_DELIMITERS.has(text[i] ?? "")) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isExplicitPathPrefixStart(prefix: string): boolean {
|
||||
return prefix.startsWith(".") || prefix.startsWith("/") || prefix.startsWith("~/") || prefix === "~";
|
||||
}
|
||||
|
||||
function findPathPrefixStartInToken(token: string): number {
|
||||
let tokenStart = 0;
|
||||
while (AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[tokenStart] ?? "")) {
|
||||
tokenStart += 1;
|
||||
}
|
||||
|
||||
for (let i = tokenStart; i < token.length; i += 1) {
|
||||
if (!AUTOCOMPLETE_BOUNDARY_PUNCTUATION.has(token[i] ?? "")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = token.slice(i + 1);
|
||||
if (isExplicitPathPrefixStart(suffix)) {
|
||||
tokenStart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return tokenStart;
|
||||
}
|
||||
|
||||
function findUnclosedQuoteStart(text: string): number | null {
|
||||
let inQuotes = false;
|
||||
let quoteStart = -1;
|
||||
@@ -68,7 +97,7 @@ function findUnclosedQuoteStart(text: string): number | null {
|
||||
}
|
||||
|
||||
function isTokenStart(text: string, index: number): boolean {
|
||||
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
|
||||
return index === 0 || isAutocompleteTokenBoundary(text[index - 1]);
|
||||
}
|
||||
|
||||
function extractQuotedPrefix(text: string): string | null {
|
||||
@@ -465,9 +494,13 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
|
||||
const lastDelimiterIndex = findLastDelimiter(text);
|
||||
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
|
||||
const token = text.slice(tokenStart);
|
||||
|
||||
if (text[tokenStart] === "@") {
|
||||
return text.slice(tokenStart);
|
||||
for (let i = 0; i < token.length; i += 1) {
|
||||
const absoluteIndex = tokenStart + i;
|
||||
if (token[i] === "@" && isTokenStart(text, absoluteIndex)) {
|
||||
return text.slice(absoluteIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -481,7 +514,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
|
||||
const lastDelimiterIndex = findLastDelimiter(text);
|
||||
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
|
||||
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
|
||||
const token = text.slice(tokenStart);
|
||||
const pathPrefixStart = tokenStart + findPathPrefixStartInToken(token);
|
||||
const pathPrefix = text.slice(pathPrefixStart);
|
||||
|
||||
// For forced extraction (Tab key), always return something
|
||||
if (forceExtract) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AutocompleteProvider, AutocompleteSuggestions } from "../autocomplete.js";
|
||||
import {
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
isAutocompleteTokenBoundary,
|
||||
} from "../autocomplete.js";
|
||||
import { getKeybindings } from "../keybindings.js";
|
||||
import { decodePrintableKey, matchesKey } from "../keys.js";
|
||||
import { KillRing } from "../kill-ring.js";
|
||||
@@ -1059,7 +1063,7 @@ export class Editor implements Component, Focusable {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
||||
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
||||
if (isAutocompleteTokenBoundary(charBeforeSymbol)) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
@@ -1072,7 +1076,7 @@ export class Editor implements Component, Focusable {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
// Check if we're in a symbol-based completion context like @ or #
|
||||
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
||||
else if (this.isInSymbolAutocompleteContext(textBeforeCursor)) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
@@ -1252,7 +1256,7 @@ export class Editor implements Component, Focusable {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
// Symbol-based completion context like @ or #
|
||||
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
||||
else if (this.isInSymbolAutocompleteContext(textBeforeCursor)) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
@@ -1616,7 +1620,7 @@ export class Editor implements Component, Focusable {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
// Symbol-based completion context like @ or #
|
||||
else if (textBeforeCursor.match(/(?:^|[\s])[@#][^\s]*$/)) {
|
||||
else if (this.isInSymbolAutocompleteContext(textBeforeCursor)) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
@@ -2052,6 +2056,20 @@ export class Editor implements Component, Focusable {
|
||||
return this.isSlashMenuAllowed() && textBeforeCursor.trimStart().startsWith("/");
|
||||
}
|
||||
|
||||
private isInSymbolAutocompleteContext(textBeforeCursor: string): boolean {
|
||||
const atIndex = textBeforeCursor.lastIndexOf("@");
|
||||
const hashIndex = textBeforeCursor.lastIndexOf("#");
|
||||
const tokenStart = Math.max(atIndex, hashIndex);
|
||||
if (tokenStart === -1) return false;
|
||||
if (tokenStart > 0 && !isAutocompleteTokenBoundary(textBeforeCursor[tokenStart - 1])) return false;
|
||||
|
||||
const token = textBeforeCursor.slice(tokenStart);
|
||||
if (token.startsWith('@"')) {
|
||||
return !token.slice(2).includes('"');
|
||||
}
|
||||
return !/\s/.test(token);
|
||||
}
|
||||
|
||||
// Autocomplete methods
|
||||
/**
|
||||
* Find the best autocomplete item index for the given prefix.
|
||||
@@ -2176,8 +2194,7 @@ export class Editor implements Component, Focusable {
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const isSymbolAutocompleteContext = /(?:^|[ \t])(?:@(?:"[^"]*|[^\s]*)|#[^\s]*)$/.test(textBeforeCursor);
|
||||
return isSymbolAutocompleteContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
||||
return this.isInSymbolAutocompleteContext(textBeforeCursor) ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
||||
}
|
||||
|
||||
private async runAutocompleteRequest(
|
||||
|
||||
@@ -471,6 +471,49 @@ describe("CombinedAutocompleteProvider", () => {
|
||||
const values = result?.items.map((item) => item.value);
|
||||
assert.ok(values?.includes("./src/"), `Expected ./src/ in ${JSON.stringify(values)}`);
|
||||
});
|
||||
|
||||
test("completes ./ paths after punctuation", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"update.sh": "#!/bin/bash",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
const line = "(./up";
|
||||
const result = await getSuggestions(provider, [line], 0, line.length, true);
|
||||
|
||||
assert.notEqual(result, null, "Should return suggestions for ./ path after punctuation");
|
||||
assert.strictEqual(result?.prefix, "./up");
|
||||
const item = result?.items.find((entry) => entry.value === "./update.sh");
|
||||
assert.ok(item, "Should find update.sh suggestion");
|
||||
|
||||
const applied = provider.applyCompletion([line], 0, line.length, item!, result!.prefix);
|
||||
assert.strictEqual(applied.lines[0], "(./update.sh");
|
||||
});
|
||||
|
||||
test("does not split path prefixes containing punctuation", async () => {
|
||||
setupFolder(baseDir, {
|
||||
files: {
|
||||
"foo(bar).txt": "content",
|
||||
"name[part].txt": "content",
|
||||
},
|
||||
});
|
||||
|
||||
const provider = new CombinedAutocompleteProvider([], baseDir);
|
||||
|
||||
const parenLine = "./foo(";
|
||||
const parenResult = await getSuggestions(provider, [parenLine], 0, parenLine.length, true);
|
||||
assert.notEqual(parenResult, null, "Should return suggestions for path containing parentheses");
|
||||
assert.strictEqual(parenResult?.prefix, "./foo(");
|
||||
assert.ok(parenResult?.items.some((item) => item.value === "./foo(bar).txt"));
|
||||
|
||||
const bracketLine = "./name[";
|
||||
const bracketResult = await getSuggestions(provider, [bracketLine], 0, bracketLine.length, true);
|
||||
assert.notEqual(bracketResult, null, "Should return suggestions for path containing brackets");
|
||||
assert.strictEqual(bracketResult?.prefix, "./name[");
|
||||
assert.ok(bracketResult?.items.some((item) => item.value === "./name[part].txt"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoted path completion", () => {
|
||||
|
||||
@@ -2154,6 +2154,42 @@ describe("Editor component", () => {
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||
});
|
||||
|
||||
it("triggers @ autocomplete after punctuation while typing", async () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
let suggestionCalls = 0;
|
||||
let requestedText = "";
|
||||
|
||||
const mockProvider: AutocompleteProvider = {
|
||||
getSuggestions: async (lines, _cursorLine, cursorCol) => {
|
||||
suggestionCalls += 1;
|
||||
requestedText = (lines[0] || "").slice(0, cursorCol);
|
||||
return {
|
||||
items: [{ value: "@main.ts", label: "main.ts" }],
|
||||
prefix: "@mai",
|
||||
};
|
||||
},
|
||||
applyCompletion,
|
||||
};
|
||||
|
||||
editor.setAutocompleteProvider(mockProvider);
|
||||
|
||||
editor.handleInput("(");
|
||||
editor.handleInput("@");
|
||||
editor.handleInput("m");
|
||||
editor.handleInput("a");
|
||||
editor.handleInput("i");
|
||||
|
||||
assert.strictEqual(suggestionCalls, 0);
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await flushAutocomplete();
|
||||
|
||||
assert.strictEqual(suggestionCalls, 1);
|
||||
assert.strictEqual(requestedText, "(@mai");
|
||||
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||
});
|
||||
|
||||
it("debounces # autocomplete while typing", async () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
let suggestionCalls = 0;
|
||||
|
||||
Reference in New Issue
Block a user