fix(tui): keep @ autocomplete responsive in large trees closes #1278

This commit is contained in:
Mario Zechner
2026-03-26 15:43:51 +01:00
parent 56e27fef1d
commit 0406b41a46
6 changed files with 464 additions and 337 deletions

View File

@@ -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 });
}
}