From 63ff5cda063b8b50b0b534bdaa2fd9b89f532392 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sun, 6 Jul 2025 21:52:33 +0900 Subject: [PATCH 1/5] chore: exclude .worktrees directory from deno test and check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- deno.jsonc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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", From 960b56274ed92dfcfba4b9147806a937c02144a0 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sun, 6 Jul 2025 21:31:47 +0900 Subject: [PATCH 2/5] perf: add byte length caching for input component prefix/suffix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache byte length calculations for prefix and suffix strings in InputComponent to avoid repeated getByteLength() calls during rendering. This improves performance when the same prefix/suffix values are used repeatedly. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- denops/fall/component/input.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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(); From 3196dfbb4adda8ef9fa76a1616a7a8ba5425138f Mon Sep 17 00:00:00 2001 From: Alisue Date: Sun, 6 Jul 2025 21:32:21 +0900 Subject: [PATCH 3/5] perf: optimize event queue consumption with early return and for loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace forEach with for loop for better performance and add early return when event queue is empty. Also add comprehensive tests for event handling including edge cases for large event counts and events dispatched during consumption. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- denops/fall/event.ts | 8 ++++- denops/fall/event_test.ts | 76 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) 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 }, + ]); + }); }); From 3ee27a6fad94639f463c287481666a672467abc2 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sun, 6 Jul 2025 21:34:27 +0900 Subject: [PATCH 4/5] perf: optimize processor pipeline to avoid redundant reprocessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modify picker event handlers to restart only the affected processor instead of restarting from the matcher. When sorter changes, restart sort processor with matcher output. When renderer changes, restart render processor with sorter output. This avoids redundant matching operations. Also implement copy-on-write for sort processor to prevent modifying the input array, ensuring pipeline integrity. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- denops/fall/picker.ts | 40 +++++++++--------------------- denops/fall/processor/sort.ts | 9 ++++--- denops/fall/processor/sort_test.ts | 19 ++++++++++---- 3 files changed, 32 insertions(+), 36 deletions(-) diff --git a/denops/fall/picker.ts b/denops/fall/picker.ts index a5ed8c0..873edfe 100644 --- a/denops/fall/picker.ts +++ b/denops/fall/picker.ts @@ -559,13 +559,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 +570,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 +588,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 +599,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; 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: {} }, From 3bb15e4dbc7414edac514e957c1c24c73a2bbec4 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sun, 6 Jul 2025 21:46:26 +0900 Subject: [PATCH 5/5] perf: add debounce for preview updates to reduce unnecessary renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- denops/fall/lib/debounce.ts | 65 ++++++++++++++++++++++++ denops/fall/lib/debounce_test.ts | 86 ++++++++++++++++++++++++++++++++ denops/fall/picker.ts | 27 ++++++++-- 3 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 denops/fall/lib/debounce.ts create mode 100644 denops/fall/lib/debounce_test.ts 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 873edfe..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": @@ -617,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], }); @@ -628,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], }); @@ -756,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], });