fix(tui): keep @ autocomplete responsive in large trees closes #1278
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "child_process";
|
||||
import { spawn } from "child_process";
|
||||
import { readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
@@ -121,12 +121,13 @@ function buildCompletionValue(
|
||||
}
|
||||
|
||||
// Use fd to walk directory tree (fast, respects .gitignore)
|
||||
function walkDirectoryWithFd(
|
||||
async function walkDirectoryWithFd(
|
||||
baseDir: string,
|
||||
fdPath: string,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
): Array<{ path: string; isDirectory: boolean }> {
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<{ path: string; isDirectory: boolean }>> {
|
||||
const args = [
|
||||
"--base-directory",
|
||||
baseDir,
|
||||
@@ -146,41 +147,69 @@ function walkDirectoryWithFd(
|
||||
".git/**",
|
||||
];
|
||||
|
||||
// Add query as pattern if provided
|
||||
if (query) {
|
||||
args.push(buildFdPathQuery(query));
|
||||
}
|
||||
|
||||
const result = spawnSync(fdPath, args, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const displayLine = toDisplayPath(line);
|
||||
const hasTrailingSeparator = displayLine.endsWith("/");
|
||||
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
|
||||
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
|
||||
continue;
|
||||
return await new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// fd outputs directories with trailing /
|
||||
const isDirectory = hasTrailingSeparator;
|
||||
results.push({
|
||||
path: displayLine,
|
||||
isDirectory,
|
||||
const child = spawn(fdPath, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
let stdout = "";
|
||||
let resolved = false;
|
||||
|
||||
return results;
|
||||
const finish = (results: Array<{ path: string; isDirectory: boolean }>) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(results);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (child.exitCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
};
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
child.stdout.setEncoding("utf-8");
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.on("error", () => {
|
||||
finish([]);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (signal.aborted || code !== 0 || !stdout) {
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split("\n").filter(Boolean);
|
||||
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const displayLine = toDisplayPath(line);
|
||||
const hasTrailingSeparator = displayLine.endsWith("/");
|
||||
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
|
||||
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
path: displayLine,
|
||||
isDirectory: hasTrailingSeparator,
|
||||
});
|
||||
}
|
||||
|
||||
finish(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface AutocompleteItem {
|
||||
@@ -197,6 +226,11 @@ export interface SlashCommand {
|
||||
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
||||
}
|
||||
|
||||
export interface AutocompleteSuggestions {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
||||
}
|
||||
|
||||
export interface AutocompleteProvider {
|
||||
// Get autocomplete suggestions for current text/cursor position
|
||||
// Returns null if no suggestions available
|
||||
@@ -204,10 +238,8 @@ export interface AutocompleteProvider {
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
||||
} | null;
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null>;
|
||||
|
||||
// Apply the selected item
|
||||
// Returns the new text and cursor position
|
||||
@@ -240,19 +272,22 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
this.fdPath = fdPath;
|
||||
}
|
||||
|
||||
getSuggestions(
|
||||
async getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// 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 });
|
||||
const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
|
||||
isQuotedPrefix,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
@@ -261,13 +296,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for slash commands
|
||||
if (textBeforeCursor.startsWith("/")) {
|
||||
if (!options.force && textBeforeCursor.startsWith("/")) {
|
||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
// No space yet - complete command names with fuzzy matching
|
||||
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
||||
const prefix = textBeforeCursor.slice(1);
|
||||
const commandItems = this.commands.map((cmd) => ({
|
||||
name: "name" in cmd ? cmd.name : cmd.value,
|
||||
label: "name" in cmd ? cmd.name : cmd.label,
|
||||
@@ -286,57 +319,42 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
items: filtered,
|
||||
prefix: textBeforeCursor,
|
||||
};
|
||||
} else {
|
||||
// Space found - complete command arguments
|
||||
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
||||
|
||||
const command = this.commands.find((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
return name === commandName;
|
||||
});
|
||||
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
||||
return null; // No argument completion for this command
|
||||
}
|
||||
|
||||
const argumentSuggestions = command.getArgumentCompletions(argumentText);
|
||||
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: argumentSuggestions,
|
||||
prefix: argumentText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for file paths - triggered by Tab or if we detect a path pattern
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
|
||||
const commandName = textBeforeCursor.slice(1, spaceIndex);
|
||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
|
||||
|
||||
if (pathMatch !== null) {
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
const command = this.commands.find((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
return name === commandName;
|
||||
});
|
||||
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we have an exact match that is a directory
|
||||
// In that case, we might want to return suggestions for the directory content instead
|
||||
// But only if the prefix ends with /
|
||||
if (suggestions.length === 1 && suggestions[0]?.value === pathMatch && !pathMatch.endsWith("/")) {
|
||||
// Exact match found (e.g. user typed "src" and "src/" is the only match)
|
||||
// We still return it so user can select it and add /
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
const argumentSuggestions = command.getArgumentCompletions(argumentText);
|
||||
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
items: argumentSuggestions,
|
||||
prefix: argumentText,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);
|
||||
if (pathMatch === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
@@ -684,9 +702,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
|
||||
// Fuzzy file search using fd (fast, respects .gitignore)
|
||||
private getFuzzyFileSuggestions(query: string, options: { isQuotedPrefix: boolean }): AutocompleteItem[] {
|
||||
if (!this.fdPath) {
|
||||
// fd not available, return empty results
|
||||
private async getFuzzyFileSuggestions(
|
||||
query: string,
|
||||
options: { isQuotedPrefix: boolean; signal: AbortSignal },
|
||||
): Promise<AutocompleteItem[]> {
|
||||
if (!this.fdPath || options.signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -694,9 +714,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
const scopedQuery = this.resolveScopedFuzzyQuery(query);
|
||||
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
|
||||
const fdQuery = scopedQuery?.query ?? query;
|
||||
const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100);
|
||||
const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
|
||||
if (options.signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Score entries
|
||||
const scoredEntries = entries
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
@@ -704,14 +726,11 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}))
|
||||
.filter((entry) => entry.score > 0);
|
||||
|
||||
// Sort by score (descending) and take top 20
|
||||
scoredEntries.sort((a, b) => b.score - a.score);
|
||||
const topEntries = scoredEntries.slice(0, 20);
|
||||
|
||||
// Build suggestions
|
||||
const suggestions: AutocompleteItem[] = [];
|
||||
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)
|
||||
@@ -737,35 +756,6 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Force file completion (called on Tab key) - always returns suggestions
|
||||
getForceFileSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Don't trigger if we're typing a slash command at the start of the line
|
||||
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Force extract path prefix - this will always return something
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
|
||||
if (pathMatch !== null) {
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we should trigger file completion (called on Tab key)
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import type { AutocompleteProvider, AutocompleteSuggestions, CombinedAutocompleteProvider } from "../autocomplete.js";
|
||||
import { getKeybindings } from "../keybindings.js";
|
||||
import { decodeKittyPrintable, matchesKey } from "../keys.js";
|
||||
import { KillRing } from "../kill-ring.js";
|
||||
@@ -212,6 +212,8 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
||||
maxPrimaryColumnWidth: 32,
|
||||
};
|
||||
|
||||
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
||||
|
||||
export class Editor implements Component, Focusable {
|
||||
private state: EditorState = {
|
||||
lines: [""],
|
||||
@@ -241,6 +243,11 @@ export class Editor implements Component, Focusable {
|
||||
private autocompleteState: "regular" | "force" | null = null;
|
||||
private autocompletePrefix: string = "";
|
||||
private autocompleteMaxVisible: number = 5;
|
||||
private autocompleteAbort?: AbortController;
|
||||
private autocompleteDebounceTimer?: ReturnType<typeof setTimeout>;
|
||||
private autocompleteRequestTask: Promise<void> = Promise.resolve();
|
||||
private autocompleteStartToken: number = 0;
|
||||
private autocompleteRequestId: number = 0;
|
||||
|
||||
// Paste tracking for large pastes
|
||||
private pastes: Map<number, string> = new Map();
|
||||
@@ -316,6 +323,7 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
||||
this.cancelAutocomplete();
|
||||
this.autocompleteProvider = provider;
|
||||
}
|
||||
|
||||
@@ -922,6 +930,7 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
setText(text: string): void {
|
||||
this.cancelAutocomplete();
|
||||
this.lastAction = null;
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
const normalized = this.normalizeText(text);
|
||||
@@ -939,6 +948,7 @@ export class Editor implements Component, Focusable {
|
||||
*/
|
||||
insertTextAtCursor(text: string): void {
|
||||
if (!text) return;
|
||||
this.cancelAutocomplete();
|
||||
this.pushUndoSnapshot();
|
||||
this.lastAction = null;
|
||||
this.historyIndex = -1;
|
||||
@@ -1065,6 +1075,7 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
private handlePaste(pastedText: string): void {
|
||||
this.cancelAutocomplete();
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
@@ -1120,6 +1131,7 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
private addNewLine(): void {
|
||||
this.cancelAutocomplete();
|
||||
this.historyIndex = -1; // Exit history browsing mode
|
||||
this.lastAction = null;
|
||||
|
||||
@@ -1155,6 +1167,7 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
private submitValue(): void {
|
||||
this.cancelAutocomplete();
|
||||
const result = this.expandPasteMarkers(this.state.lines.join("\n")).trim();
|
||||
|
||||
this.state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
||||
@@ -2019,10 +2032,34 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
private tryTriggerAutocomplete(explicitTab: boolean = false): void {
|
||||
this.requestAutocomplete({ force: false, explicitTab });
|
||||
}
|
||||
|
||||
private handleTabCompletion(): void {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
// Check if we should trigger file completion on Tab
|
||||
if (explicitTab) {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
|
||||
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
||||
this.handleSlashCommandCompletion();
|
||||
} else {
|
||||
this.forceFileAutocomplete(true);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlashCommandCompletion(): void {
|
||||
this.requestAutocomplete({ force: false, explicitTab: true });
|
||||
}
|
||||
|
||||
private forceFileAutocomplete(explicitTab: boolean = false): void {
|
||||
this.requestAutocomplete({ force: true, explicitTab });
|
||||
}
|
||||
|
||||
private requestAutocomplete(options: { force: boolean; explicitTab: boolean }): void {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
if (options.force) {
|
||||
const provider = this.autocompleteProvider as CombinedAutocompleteProvider;
|
||||
const shouldTrigger =
|
||||
!provider.shouldTriggerFileCompletion ||
|
||||
@@ -2032,139 +2069,162 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
this.cancelAutocompleteRequest();
|
||||
const startToken = ++this.autocompleteStartToken;
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
||||
|
||||
// If typed prefix exactly matches one of the suggestions, select that item
|
||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
||||
if (bestMatchIndex >= 0) {
|
||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
||||
}
|
||||
|
||||
this.autocompleteState = "regular";
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
private handleTabCompletion(): void {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
|
||||
// Check if we're in a slash command context
|
||||
if (this.isInSlashCommandContext(beforeCursor) && !beforeCursor.trimStart().includes(" ")) {
|
||||
this.handleSlashCommandCompletion();
|
||||
} else {
|
||||
this.forceFileAutocomplete(true);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSlashCommandCompletion(): void {
|
||||
this.tryTriggerAutocomplete(true);
|
||||
}
|
||||
|
||||
/*
|
||||
https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/559322883
|
||||
17 this job fails with https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19
|
||||
536643416/job/55932288317 havea look at .gi
|
||||
*/
|
||||
private forceFileAutocomplete(explicitTab: boolean = false): void {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
// Check if provider supports force file suggestions via runtime check
|
||||
const provider = this.autocompleteProvider as {
|
||||
getForceFileSuggestions?: CombinedAutocompleteProvider["getForceFileSuggestions"];
|
||||
};
|
||||
if (typeof provider.getForceFileSuggestions !== "function") {
|
||||
this.tryTriggerAutocomplete(true);
|
||||
const debounceMs = this.getAutocompleteDebounceMs(options);
|
||||
if (debounceMs > 0) {
|
||||
this.autocompleteDebounceTimer = setTimeout(() => {
|
||||
this.autocompleteDebounceTimer = undefined;
|
||||
void this.startAutocompleteRequest(startToken, options);
|
||||
}, debounceMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = provider.getForceFileSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
void this.startAutocompleteRequest(startToken, options);
|
||||
}
|
||||
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
// If there's exactly one suggestion, apply it immediately
|
||||
if (explicitTab && suggestions.items.length === 1) {
|
||||
const item = suggestions.items[0]!;
|
||||
this.pushUndoSnapshot();
|
||||
this.lastAction = null;
|
||||
const result = this.autocompleteProvider.applyCompletion(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
item,
|
||||
suggestions.prefix,
|
||||
);
|
||||
this.state.lines = result.lines;
|
||||
this.state.cursorLine = result.cursorLine;
|
||||
this.setCursorCol(result.cursorCol);
|
||||
if (this.onChange) this.onChange(this.getText());
|
||||
private async startAutocompleteRequest(
|
||||
startToken: number,
|
||||
options: { force: boolean; explicitTab: boolean },
|
||||
): Promise<void> {
|
||||
const previousTask = this.autocompleteRequestTask;
|
||||
this.autocompleteRequestTask = (async () => {
|
||||
await previousTask;
|
||||
if (startToken !== this.autocompleteStartToken || !this.autocompleteProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
||||
const controller = new AbortController();
|
||||
this.autocompleteAbort = controller;
|
||||
const requestId = ++this.autocompleteRequestId;
|
||||
const snapshotText = this.getText();
|
||||
const snapshotLine = this.state.cursorLine;
|
||||
const snapshotCol = this.state.cursorCol;
|
||||
|
||||
// If typed prefix exactly matches one of the suggestions, select that item
|
||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
||||
if (bestMatchIndex >= 0) {
|
||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
||||
}
|
||||
|
||||
this.autocompleteState = "force";
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
await this.runAutocompleteRequest(requestId, controller, snapshotText, snapshotLine, snapshotCol, options);
|
||||
})();
|
||||
await this.autocompleteRequestTask;
|
||||
}
|
||||
|
||||
private cancelAutocomplete(): void {
|
||||
private getAutocompleteDebounceMs(options: { force: boolean; explicitTab: boolean }): number {
|
||||
if (options.explicitTab || options.force) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const isAttachmentContext = /(?:^|[ \t])@(?:"[^"]*|[^\s]*)$/.test(textBeforeCursor);
|
||||
return isAttachmentContext ? ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS : 0;
|
||||
}
|
||||
|
||||
private async runAutocompleteRequest(
|
||||
requestId: number,
|
||||
controller: AbortController,
|
||||
snapshotText: string,
|
||||
snapshotLine: number,
|
||||
snapshotCol: number,
|
||||
options: { force: boolean; explicitTab: boolean },
|
||||
): Promise<void> {
|
||||
if (!this.autocompleteProvider) return;
|
||||
|
||||
const suggestions = await this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
{ signal: controller.signal, force: options.force },
|
||||
);
|
||||
|
||||
if (!this.isAutocompleteRequestCurrent(requestId, controller, snapshotText, snapshotLine, snapshotCol)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.autocompleteAbort = undefined;
|
||||
|
||||
if (!suggestions || suggestions.items.length === 0) {
|
||||
this.cancelAutocomplete();
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.force && options.explicitTab && suggestions.items.length === 1) {
|
||||
const item = suggestions.items[0]!;
|
||||
this.pushUndoSnapshot();
|
||||
this.lastAction = null;
|
||||
const result = this.autocompleteProvider.applyCompletion(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
item,
|
||||
suggestions.prefix,
|
||||
);
|
||||
this.state.lines = result.lines;
|
||||
this.state.cursorLine = result.cursorLine;
|
||||
this.setCursorCol(result.cursorCol);
|
||||
if (this.onChange) this.onChange(this.getText());
|
||||
this.tui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
this.applyAutocompleteSuggestions(suggestions, options.force ? "force" : "regular");
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
private isAutocompleteRequestCurrent(
|
||||
requestId: number,
|
||||
controller: AbortController,
|
||||
snapshotText: string,
|
||||
snapshotLine: number,
|
||||
snapshotCol: number,
|
||||
): boolean {
|
||||
return (
|
||||
!controller.signal.aborted &&
|
||||
requestId === this.autocompleteRequestId &&
|
||||
this.getText() === snapshotText &&
|
||||
this.state.cursorLine === snapshotLine &&
|
||||
this.state.cursorCol === snapshotCol
|
||||
);
|
||||
}
|
||||
|
||||
private applyAutocompleteSuggestions(suggestions: AutocompleteSuggestions, state: "regular" | "force"): void {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
||||
|
||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
||||
if (bestMatchIndex >= 0) {
|
||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
||||
}
|
||||
|
||||
this.autocompleteState = state;
|
||||
}
|
||||
|
||||
private cancelAutocompleteRequest(): void {
|
||||
this.autocompleteStartToken += 1;
|
||||
if (this.autocompleteDebounceTimer) {
|
||||
clearTimeout(this.autocompleteDebounceTimer);
|
||||
this.autocompleteDebounceTimer = undefined;
|
||||
}
|
||||
this.autocompleteAbort?.abort();
|
||||
this.autocompleteAbort = undefined;
|
||||
}
|
||||
|
||||
private clearAutocompleteUi(): void {
|
||||
this.autocompleteState = null;
|
||||
this.autocompleteList = undefined;
|
||||
this.autocompletePrefix = "";
|
||||
}
|
||||
|
||||
private cancelAutocomplete(): void {
|
||||
this.cancelAutocompleteRequest();
|
||||
this.clearAutocompleteUi();
|
||||
}
|
||||
|
||||
public isShowingAutocomplete(): boolean {
|
||||
return this.autocompleteState !== null;
|
||||
}
|
||||
|
||||
private updateAutocomplete(): void {
|
||||
if (!this.autocompleteState || !this.autocompleteProvider) return;
|
||||
|
||||
if (this.autocompleteState === "force") {
|
||||
this.forceFileAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const suggestions = this.autocompleteProvider.getSuggestions(
|
||||
this.state.lines,
|
||||
this.state.cursorLine,
|
||||
this.state.cursorCol,
|
||||
);
|
||||
if (suggestions && suggestions.items.length > 0) {
|
||||
this.autocompletePrefix = suggestions.prefix;
|
||||
// Always create new SelectList to ensure update
|
||||
this.autocompleteList = this.createAutocompleteList(suggestions.prefix, suggestions.items);
|
||||
|
||||
// If typed prefix exactly matches one of the suggestions, select that item
|
||||
const bestMatchIndex = this.getBestAutocompleteMatchIndex(suggestions.items, suggestions.prefix);
|
||||
if (bestMatchIndex >= 0) {
|
||||
this.autocompleteList.setSelectedIndex(bestMatchIndex);
|
||||
}
|
||||
} else {
|
||||
this.cancelAutocomplete();
|
||||
}
|
||||
this.requestAutocomplete({ force: this.autocompleteState === "force", explicitTab: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
export {
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
CombinedAutocompleteProvider,
|
||||
type SlashCommand,
|
||||
} from "./autocomplete.js";
|
||||
|
||||
Reference in New Issue
Block a user