fix: debounce @ file autocomplete to prevent TUI freeze on large codebases (#448)

The synchronous fuzzyFind() native call blocks the event loop during
@ file autocomplete. On large codebases (e.g. Java projects with deep
directory trees), each call can take seconds. Since updateAutocomplete()
was called on every keystroke while autocomplete was active, rapid typing
would cascade into dozens of blocking searches — freezing the TUI for
minutes. This made it appear that arrow keys caused the freeze, when
the actual cause was accumulated backlog from processing buffered input.

Debounce all @ file reference autocomplete paths (character input,
backspace, forward delete, and re-trigger after cancellation) with a
150ms delay so only the final keystroke triggers the expensive search.
Slash command autocomplete remains synchronous since it's cheap.
This commit is contained in:
Flux Labs 2026-03-14 23:56:22 -05:00
parent 16364c7dba
commit 8b7b8bc651

View file

@ -150,6 +150,12 @@ export class Editor implements Component, Focusable {
private autocompletePrefix: string = "";
private autocompleteMaxVisible: number = 5;
// Debounce for @ file autocomplete to prevent blocking the event loop
// with synchronous fuzzyFind calls on every keystroke
private autocompleteDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private lastAutocompleteLookupPrefix: string | null = null;
private static readonly AUTOCOMPLETE_DEBOUNCE_MS = 150;
// Paste tracking for large pastes
private pastes: Map<number, string> = new Map();
private pasteCounter: number = 0;
@ -965,9 +971,10 @@ export class Editor implements Component, Focusable {
if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// Check if we're in an @ file reference context
// Check if we're in an @ file reference context (debounce to avoid
// blocking the event loop with synchronous fuzzyFind on every keystroke)
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
this.debouncedTriggerAutocomplete();
}
}
} else {
@ -975,6 +982,23 @@ export class Editor implements Component, Focusable {
}
}
/**
* Debounced version of tryTriggerAutocomplete for @ file reference context.
* Prevents synchronous fuzzyFind calls from blocking the event loop on every keystroke.
*/
private debouncedTriggerAutocomplete(): void {
if (this.autocompleteDebounceTimer) {
clearTimeout(this.autocompleteDebounceTimer);
this.autocompleteDebounceTimer = null;
}
this.autocompleteDebounceTimer = setTimeout(() => {
this.autocompleteDebounceTimer = null;
this.tryTriggerAutocomplete();
this.tui.requestRender();
}, Editor.AUTOCOMPLETE_DEBOUNCE_MS);
}
private handlePaste(pastedText: string): void {
this.historyIndex = -1; // Exit history browsing mode
this.lastAction = null;
@ -1133,9 +1157,9 @@ export class Editor implements Component, Focusable {
if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// @ file reference context
// @ file reference context (debounced to avoid blocking event loop)
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
this.debouncedTriggerAutocomplete();
}
}
}
@ -1440,9 +1464,9 @@ export class Editor implements Component, Focusable {
if (this.isInSlashCommandContext(textBeforeCursor)) {
this.tryTriggerAutocomplete();
}
// @ file reference context
// @ file reference context (debounced to avoid blocking event loop)
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.tryTriggerAutocomplete();
this.debouncedTriggerAutocomplete();
}
}
}
@ -2020,6 +2044,15 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
this.autocompleteState = null;
this.autocompleteList = undefined;
this.autocompletePrefix = "";
this.clearAutocompleteDebounce();
}
private clearAutocompleteDebounce(): void {
if (this.autocompleteDebounceTimer) {
clearTimeout(this.autocompleteDebounceTimer);
this.autocompleteDebounceTimer = null;
}
this.lastAutocompleteLookupPrefix = null;
}
public isShowingAutocomplete(): boolean {
@ -2034,6 +2067,38 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
return;
}
// Check if we're in an @ file reference context — these trigger expensive
// synchronous fuzzyFind calls that block the event loop. Debounce them so
// rapid typing doesn't cascade into dozens of blocking searches.
const currentLine = this.state.lines[this.state.cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
if (this.autocompletePrefix.startsWith("@") || textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
this.debouncedUpdateAutocompleteSuggestions();
return;
}
this.applyAutocompleteSuggestions();
}
private debouncedUpdateAutocompleteSuggestions(): void {
// Clear any pending debounce
if (this.autocompleteDebounceTimer) {
clearTimeout(this.autocompleteDebounceTimer);
this.autocompleteDebounceTimer = null;
}
this.autocompleteDebounceTimer = setTimeout(() => {
this.autocompleteDebounceTimer = null;
// Guard: autocomplete may have been cancelled during debounce wait
if (!this.autocompleteState || !this.autocompleteProvider) return;
this.applyAutocompleteSuggestions();
this.tui.requestRender();
}, Editor.AUTOCOMPLETE_DEBOUNCE_MS);
}
private applyAutocompleteSuggestions(): void {
if (!this.autocompleteProvider) return;
const suggestions = this.autocompleteProvider.getSuggestions(
this.state.lines,
this.state.cursorLine,