fix(tui): keep @ autocomplete responsive in large trees closes #1278
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user