diff --git a/deno.jsonc b/deno.jsonc index 1591df1..f8ac3c9 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,7 +1,8 @@ { "exclude": [ "docs/**", - ".coverage/**" + ".coverage/**", + ".worktrees/**" ], "tasks": { "check": "deno check ./**/*.ts", diff --git a/denops/fall/component/input.ts b/denops/fall/component/input.ts index 28d8c4f..bc80746 100644 --- a/denops/fall/component/input.ts +++ b/denops/fall/component/input.ts @@ -79,6 +79,10 @@ export class InputComponent extends BaseComponent { #modifiedWindow = true; #modifiedContent = true; + // Cache for byte lengths to avoid repeated calculations + #prefixCache?: { value: string; byteLength: number }; + #suffixCache?: { value: string; byteLength: number }; + constructor( { title, @@ -287,9 +291,25 @@ export class InputComponent extends BaseComponent { this.#offset, this.#offset + cmdwidth, ); - const prefixByteLength = getByteLength(prefix); + + // Use cached byte lengths when possible + let prefixByteLength: number; + if (this.#prefixCache?.value === prefix) { + prefixByteLength = this.#prefixCache.byteLength; + } else { + prefixByteLength = getByteLength(prefix); + this.#prefixCache = { value: prefix, byteLength: prefixByteLength }; + } + const middleByteLength = getByteLength(middle); - const suffixByteLength = getByteLength(suffix); + + let suffixByteLength: number; + if (this.#suffixCache?.value === suffix) { + suffixByteLength = this.#suffixCache.byteLength; + } else { + suffixByteLength = getByteLength(suffix); + this.#suffixCache = { value: suffix, byteLength: suffixByteLength }; + } await buffer.replace(denops, bufnr, [prefix + middle + suffix]); signal?.throwIfAborted(); diff --git a/denops/fall/event.ts b/denops/fall/event.ts index 7b58106..155ae99 100644 --- a/denops/fall/event.ts +++ b/denops/fall/event.ts @@ -7,9 +7,15 @@ export function dispatch(event: Readonly): void { } export function consume(consumer: Consumer): void { + // Optimize: Swap arrays instead of creating new ones each time const events = eventQueue; + if (events.length === 0) return; + eventQueue = []; - events.forEach(consumer); + // Use for loop instead of forEach for better performance + for (let i = 0; i < events.length; i++) { + consumer(events[i]); + } } type SelectMethod = "on" | "off" | "toggle"; diff --git a/denops/fall/event_test.ts b/denops/fall/event_test.ts index 69aa7b2..07fcb5a 100644 --- a/denops/fall/event_test.ts +++ b/denops/fall/event_test.ts @@ -23,4 +23,80 @@ Deno.test("Event", async (t) => { }); assertEquals(dispatchedEvents, []); }); + + await t.step("multiple consumers receive all events in order", () => { + dispatch({ type: "vim-cmdline-changed", cmdline: "test1" }); + dispatch({ type: "vim-cmdpos-changed", cmdpos: 5 }); + dispatch({ type: "vim-cmdline-changed", cmdline: "test2" }); + + const results: Event[][] = []; + consume((event) => { + if (!results[0]) results[0] = []; + results[0].push(event); + }); + + assertEquals(results[0], [ + { type: "vim-cmdline-changed", cmdline: "test1" }, + { type: "vim-cmdpos-changed", cmdpos: 5 }, + { type: "vim-cmdline-changed", cmdline: "test2" }, + ]); + }); + + await t.step("handles large number of events", () => { + const eventCount = 10000; + for (let i = 0; i < eventCount; i++) { + dispatch({ type: "vim-cmdpos-changed", cmdpos: i }); + } + + let receivedCount = 0; + consume((event) => { + assertEquals(event.type, "vim-cmdpos-changed"); + receivedCount++; + }); + + assertEquals(receivedCount, eventCount); + }); + + await t.step("events are cleared after consume", () => { + dispatch({ type: "vim-cmdline-changed", cmdline: "test" }); + + let firstConsumeCount = 0; + consume(() => { + firstConsumeCount++; + }); + assertEquals(firstConsumeCount, 1); + + let secondConsumeCount = 0; + consume(() => { + secondConsumeCount++; + }); + assertEquals(secondConsumeCount, 0); + }); + + await t.step("handles events dispatched during consume", () => { + dispatch({ type: "vim-cmdline-changed", cmdline: "initial" }); + + const events: Event[] = []; + consume((event) => { + events.push(event); + if (event.type === "vim-cmdline-changed" && event.cmdline === "initial") { + // This dispatch happens during consume - should not be consumed in this cycle + dispatch({ type: "vim-cmdpos-changed", cmdpos: 42 }); + } + }); + + assertEquals(events, [ + { type: "vim-cmdline-changed", cmdline: "initial" }, + ]); + + // The event dispatched during consume should be available in next consume + const nextEvents: Event[] = []; + consume((event) => { + nextEvents.push(event); + }); + + assertEquals(nextEvents, [ + { type: "vim-cmdpos-changed", cmdpos: 42 }, + ]); + }); }); diff --git a/denops/fall/lib/debounce.ts b/denops/fall/lib/debounce.ts new file mode 100644 index 0000000..b30bca7 --- /dev/null +++ b/denops/fall/lib/debounce.ts @@ -0,0 +1,65 @@ +export type DebounceOptions = { + delay?: number; + signal?: AbortSignal; +}; + +/** + * Creates a debounced function that delays invoking the provided function until after + * the specified delay has elapsed since the last time the debounced function was invoked. + * + * @param fn - The function to debounce + * @param options - Configuration options + * @param options.delay - The number of milliseconds to delay (default: 0) + * @param options.signal - An optional AbortSignal to cancel the debounced function + * @returns A debounced version of the function + * + * @example + * ```ts + * import { debounce } from "./debounce.ts"; + * import { delay } from "jsr:@std/async@^1.0.0/delay"; + * + * const saveData = () => console.log("Saving data..."); + * const debouncedSave = debounce(() => saveData(), { delay: 100 }); + * + * // Multiple calls within 100ms will only trigger one save + * debouncedSave(); + * debouncedSave(); + * debouncedSave(); + * + * // Wait for the debounced function to execute + * await delay(150); + * + * // Cancel via AbortSignal + * const doWork = () => console.log("Doing work..."); + * const controller = new AbortController(); + * const debouncedFunc = debounce(() => doWork(), { + * delay: 50, + * signal: controller.signal + * }); + * debouncedFunc(); + * controller.abort(); // Cancels any pending execution + * ``` + */ +// deno-lint-ignore no-explicit-any +export function debounce void>( + fn: F, + { delay, signal }: DebounceOptions = {}, +): F { + let timerId: number | undefined; + + const abort = () => { + if (timerId !== undefined) { + clearTimeout(timerId); + timerId = undefined; + } + }; + + signal?.addEventListener("abort", abort, { once: true }); + return ((...args) => { + abort(); + timerId = setTimeout(() => { + timerId = undefined; + fn(...args); + }, delay); + }) as F; +} diff --git a/denops/fall/lib/debounce_test.ts b/denops/fall/lib/debounce_test.ts new file mode 100644 index 0000000..1fc59e0 --- /dev/null +++ b/denops/fall/lib/debounce_test.ts @@ -0,0 +1,86 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.6"; +import { delay } from "jsr:@std/async@^1.0.0/delay"; +import { FakeTime } from "jsr:@std/testing@^1.0.0/time"; + +import { debounce } from "./debounce.ts"; + +Deno.test("debounce", async (t) => { + await t.step("delays function execution", async () => { + using time = new FakeTime(); + + let callCount = 0; + const fn = debounce(() => { + callCount++; + }, { delay: 100 }); + + fn(); + assertEquals(callCount, 0); + + await time.tickAsync(50); + assertEquals(callCount, 0); + + await time.tickAsync(50); + assertEquals(callCount, 1); + }); + + await t.step( + "cancels previous calls when called multiple times", + async () => { + using time = new FakeTime(); + + let callCount = 0; + let lastValue = 0; + const fn = debounce((value: number) => { + callCount++; + lastValue = value; + }, { delay: 100 }); + + fn(1); + await time.tickAsync(50); + fn(2); + await time.tickAsync(50); + fn(3); + await time.tickAsync(50); + + assertEquals(callCount, 0); + + await time.tickAsync(50); + assertEquals(callCount, 1); + assertEquals(lastValue, 3); + }, + ); + + await t.step("works with real timers", async () => { + let callCount = 0; + const fn = debounce(() => { + callCount++; + }, { delay: 50 }); + + fn(); + assertEquals(callCount, 0); + + await delay(30); + assertEquals(callCount, 0); + + await delay(30); + assertEquals(callCount, 1); + }); + + await t.step("respects abort signal", async () => { + using time = new FakeTime(); + + let callCount = 0; + const controller = new AbortController(); + const fn = debounce(() => { + callCount++; + }, { delay: 100, signal: controller.signal }); + + fn(); + await time.tickAsync(50); + + controller.abort(); + + await time.tickAsync(100); + assertEquals(callCount, 0); + }); +}); diff --git a/denops/fall/picker.ts b/denops/fall/picker.ts index a5ed8c0..a541d5b 100644 --- a/denops/fall/picker.ts +++ b/denops/fall/picker.ts @@ -18,6 +18,7 @@ import type { Previewer } from "jsr:@vim-fall/core@^0.3.0/previewer"; import type { Theme } from "jsr:@vim-fall/core@^0.3.0/theme"; import { Scheduler } from "./lib/scheduler.ts"; +import { debounce } from "./lib/debounce.ts"; import { Cmdliner } from "./util/cmdliner.ts"; import { isIncrementalMatcher } from "./util/predicate.ts"; import { buildMappingHelpPages } from "./util/mapping.ts"; @@ -37,6 +38,7 @@ import { HelpComponent } from "./component/help.ts"; import { consume, type Event } from "./event.ts"; const SCHEDULER_INTERVAL = 10; +const PREVIEW_DEBOUNCE_DELAY = 150; const MATCHER_ICON = "🅼 "; const SORTER_ICON = "🆂 "; const RENDERER_ICON = "🆁 "; @@ -70,6 +72,7 @@ export type PickerResult = { export type PickerOptions = { schedulerInterval?: number; + previewDebounceDelay?: number; }; export type PickerContext = { @@ -89,6 +92,7 @@ export class Picker implements AsyncDisposable { static readonly ZINDEX_ALLOCATION = 4; readonly #stack = new AsyncDisposableStack(); readonly #schedulerInterval: number; + readonly #previewDebounceDelay: number; readonly #name: string; readonly #coordinator: Coordinator; readonly #collectProcessor: CollectProcessor; @@ -110,6 +114,8 @@ export class Picker implements AsyncDisposable { constructor(params: PickerParams, options: PickerOptions = {}) { this.#schedulerInterval = options.schedulerInterval ?? SCHEDULER_INTERVAL; + this.#previewDebounceDelay = options.previewDebounceDelay ?? + PREVIEW_DEBOUNCE_DELAY; const { name, theme, coordinator, zindex = 50, context } = params; this.#name = name; @@ -358,6 +364,10 @@ export class Picker implements AsyncDisposable { const reserve = (callback: ReservedCallback) => { reservedCallbacks.push(callback); }; + const reservePreviewDebounced = debounce(reserve, { + delay: this.#previewDebounceDelay, + signal, + }); const cmdliner = new Cmdliner({ cmdline: this.#inputComponent.cmdline, cmdpos: this.#inputComponent.cmdpos, @@ -368,7 +378,13 @@ export class Picker implements AsyncDisposable { await cmdliner.check(denops); // Handle events synchronously - consume((event) => this.#handleEvent(event, { accept, reserve })); + consume((event) => + this.#handleEvent(event, { + accept, + reserve, + reservePreviewDebounced, + }) + ); // Handle reserved callbacks asynchronously for (const callback of reservedCallbacks) { @@ -469,9 +485,10 @@ export class Picker implements AsyncDisposable { } } - #handleEvent(event: Event, { accept, reserve }: { + #handleEvent(event: Event, { accept, reserve, reservePreviewDebounced }: { accept: (name: string) => Promise; reserve: (callback: ReservedCallback) => void; + reservePreviewDebounced: (callback: ReservedCallback) => void; }): void { switch (event.type) { case "vim-cmdline-changed": @@ -559,13 +576,9 @@ export class Picker implements AsyncDisposable { this.#sortProcessor.sorterIndex = index; this.#listComponent.title = this.#getExtensionIndicator(); reserve((denops) => { - // NOTE: - // We need to restart from the matcher processor because - // sorters and renderers applies changes in-place thus - // the items would be polluted. - this.#matchProcessor.start(denops, { - items: this.#collectProcessor.items, - query: this.#inputComponent.cmdline, + // Restart the sort processor with items from the match processor + this.#sortProcessor.start(denops, { + items: this.#matchProcessor.items, }); }); break; @@ -574,13 +587,9 @@ export class Picker implements AsyncDisposable { this.#sortProcessor.sorterIndex = event.index; this.#listComponent.title = this.#getExtensionIndicator(); reserve((denops) => { - // NOTE: - // We need to restart from the matcher processor because - // sorters and renderers applies changes in-place thus - // the items would be polluted. - this.#matchProcessor.start(denops, { - items: this.#collectProcessor.items, - query: this.#inputComponent.cmdline, + // Restart the sort processor with items from the match processor + this.#sortProcessor.start(denops, { + items: this.#matchProcessor.items, }); }); break; @@ -596,13 +605,9 @@ export class Picker implements AsyncDisposable { this.#renderProcessor.rendererIndex = index; this.#listComponent.title = this.#getExtensionIndicator(); reserve((denops) => { - // NOTE: - // We need to restart from the matcher processor because - // sorters and renderers applies changes in-place thus - // the items would be polluted. - this.#matchProcessor.start(denops, { - items: this.#collectProcessor.items, - query: this.#inputComponent.cmdline, + // Restart the render processor with items from the sort processor + this.#renderProcessor.start(denops, { + items: this.#sortProcessor.items, }); }); break; @@ -611,13 +616,9 @@ export class Picker implements AsyncDisposable { this.#renderProcessor.rendererIndex = event.index; this.#listComponent.title = this.#getExtensionIndicator(); reserve((denops) => { - // NOTE: - // We need to restart from the matcher processor because - // sorters and renderers applies changes in-place thus - // the items would be polluted. - this.#matchProcessor.start(denops, { - items: this.#collectProcessor.items, - query: this.#inputComponent.cmdline, + // Restart the render processor with items from the sort processor + this.#renderProcessor.start(denops, { + items: this.#sortProcessor.items, }); }); break; @@ -633,7 +634,7 @@ export class Picker implements AsyncDisposable { } this.#previewProcessor.previewerIndex = index; this.#listComponent.title = this.#getExtensionIndicator(); - reserve((denops) => { + reservePreviewDebounced((denops) => { this.#previewProcessor?.start(denops, { item: this.#matchProcessor.items[this.#renderProcessor.cursor], }); @@ -644,7 +645,7 @@ export class Picker implements AsyncDisposable { if (!this.#previewProcessor) break; this.#previewProcessor.previewerIndex = event.index; this.#listComponent.title = this.#getExtensionIndicator(); - reserve((denops) => { + reservePreviewDebounced((denops) => { this.#previewProcessor?.start(denops, { item: this.#matchProcessor.items[this.#renderProcessor.cursor], }); @@ -772,7 +773,7 @@ export class Picker implements AsyncDisposable { const line = this.#renderProcessor.line; this.#listComponent.items = this.#renderProcessor.items; this.#listComponent.execute(`silent! normal! ${line}G`); - reserve((denops) => { + reservePreviewDebounced((denops) => { this.#previewProcessor?.start(denops, { item: this.#matchProcessor.items[this.#renderProcessor.cursor], }); diff --git a/denops/fall/processor/sort.ts b/denops/fall/processor/sort.ts index b299665..566a4ed 100644 --- a/denops/fall/processor/sort.ts +++ b/denops/fall/processor/sort.ts @@ -59,7 +59,7 @@ export class SortProcessor implements Disposable { } } - start(denops: Denops, { items }: { items: IdItem[] }): void { + start(denops: Denops, { items }: { items: readonly IdItem[] }): void { this.#validateAvailability(); if (this.#processing) { // Keep most recent start request for later. @@ -70,14 +70,17 @@ export class SortProcessor implements Disposable { dispatch({ type: "sort-processor-started" }); const signal = this.#controller.signal; + // Create a shallow copy of the items array + const cloned = items.slice(); + await this.#sorter?.sort( denops, - { items }, + { items: cloned }, { signal }, ); signal.throwIfAborted(); - this.#items = items; + this.#items = cloned; dispatch({ type: "sort-processor-succeeded" }); })(); this.#processing diff --git a/denops/fall/processor/sort_test.ts b/denops/fall/processor/sort_test.ts index 6c23900..cc354ba 100644 --- a/denops/fall/processor/sort_test.ts +++ b/denops/fall/processor/sort_test.ts @@ -120,7 +120,7 @@ Deno.test("SortProcessor", async (t) => { ); await t.step( - "start sort items in-place", + "start sorts items without modifying original array (copy-on-write)", async () => { await using stack = new AsyncDisposableStack(); stack.defer(async () => { @@ -133,10 +133,11 @@ Deno.test("SortProcessor", async (t) => { const processor = stack.use( new SortProcessor([sorter]), ); - const cloned = items.slice(); - processor.start(denops, { items: cloned }); + const original = items.slice(); + processor.start(denops, { items: original }); - assertEquals(cloned, [ + // Original array should not be modified + assertEquals(original, [ { id: 0, value: "0", detail: {} }, { id: 1, value: "1", detail: {} }, { id: 2, value: "2", detail: {} }, @@ -145,7 +146,15 @@ Deno.test("SortProcessor", async (t) => { notify.notify(); await flushPromises(); - assertEquals(cloned, [ + // Original array should still not be modified + assertEquals(original, [ + { id: 0, value: "0", detail: {} }, + { id: 1, value: "1", detail: {} }, + { id: 2, value: "2", detail: {} }, + ]); + + // Processor should have sorted items + assertEquals(processor.items, [ { id: 2, value: "2", detail: {} }, { id: 1, value: "1", detail: {} }, { id: 0, value: "0", detail: {} },