From e858361a89e55ed25db68eb4ad85f1b72d825d77 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 15:18:06 +0900 Subject: [PATCH 01/15] Add `index` option to ItemBelt constructor and fix impl --- denops/fall/lib/item_belt.ts | 24 ++++++++++++++++++++---- denops/fall/lib/item_belt_test.ts | 20 +++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/denops/fall/lib/item_belt.ts b/denops/fall/lib/item_belt.ts index 3754f57..70a723d 100644 --- a/denops/fall/lib/item_belt.ts +++ b/denops/fall/lib/item_belt.ts @@ -1,3 +1,14 @@ +/** + * Options for configuring an ItemBelt instance. + */ +export type ItemBeltOptions = { + /** + * The initial index to set for the ItemBelt. + * If not provided, defaults to 0. + */ + index?: number; +}; + /** * A class representing an item belt, which provides functionality for managing * and selecting items in a list with wrap-around and bounded selection. @@ -5,16 +16,19 @@ * @template T The type of items contained in the belt. */ export class ItemBelt { - #index = 0; + #index: number = 0; #items: readonly T[]; /** * Creates an instance of ItemBelt with the specified items. * * @param items An array of items of type T to initialize the belt. + * @param options Optional configuration for the ItemBelt. + * @param options.index The initial index to set (default is 0). */ - constructor(items: readonly T[]) { + constructor(items: readonly T[], options?: ItemBeltOptions) { this.#items = items; + this.index = options?.index ?? 0; } /** @@ -73,7 +87,9 @@ export class ItemBelt { * to the nearest valid value. */ set index(index: number) { - if (index >= this.#items.length) { + if (this.#items.length === 0) { + index = 0; + } else if (index >= this.#items.length) { index = this.#items.length - 1; } else if (index < 0) { index = 0; @@ -85,8 +101,8 @@ export class ItemBelt { * Selects a new item based on the current index, with an optional offset. * The selection can optionally cycle through the items list. * + * @param offset The number of positions to move the index (default is 1). * @param options Optional configuration for the selection. - * @param options.offset The number of positions to move the index (default is 1). * @param options.cycle Whether to cycle through the list (default is `false`). */ select(offset = 1, { cycle = false } = {}): void { diff --git a/denops/fall/lib/item_belt_test.ts b/denops/fall/lib/item_belt_test.ts index 92542ee..aff010d 100644 --- a/denops/fall/lib/item_belt_test.ts +++ b/denops/fall/lib/item_belt_test.ts @@ -17,7 +17,7 @@ Deno.test("ItemBelt", async (t) => { assertEquals(belt.count, 0); }); - await t.step("current returns the first itme in default", () => { + await t.step("current returns the first item in default", () => { const belt = new ItemBelt([0, 1, 2]); assertEquals(belt.current, 0); }); @@ -32,6 +32,24 @@ Deno.test("ItemBelt", async (t) => { assertEquals(belt.count, 3); }); + await t.step("constructor accepts initial index option", () => { + const belt = new ItemBelt([0, 1, 2], { index: 1 }); + assertEquals(belt.index, 1); + assertEquals(belt.current, 1); + }); + + await t.step("constructor clamps initial index when too large", () => { + const belt = new ItemBelt([0, 1, 2], { index: 5 }); + assertEquals(belt.index, 2); + assertEquals(belt.current, 2); + }); + + await t.step("constructor clamps initial index when negative", () => { + const belt = new ItemBelt([0, 1, 2], { index: -1 }); + assertEquals(belt.index, 0); + assertEquals(belt.current, 0); + }); + await t.step("changing index changes current", () => { const belt = new ItemBelt([0, 1, 2]); belt.index = 1; From 23daa638ad2ccc11b82e19c3cc9f3861e3635b41 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 15:46:51 +0900 Subject: [PATCH 02/15] Add `cmdline` to constructor of InputComponentParams --- denops/fall/component/input.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/denops/fall/component/input.ts b/denops/fall/component/input.ts index 4657cf9..28d8c4f 100644 --- a/denops/fall/component/input.ts +++ b/denops/fall/component/input.ts @@ -24,6 +24,9 @@ export type InputComponentParams = ComponentProperties & { /** The title of the input component */ readonly title?: string; + /** The command line input text */ + readonly cmdline?: string; + /** Optional spinner sequence to show during processing */ readonly spinner?: readonly string[]; @@ -77,11 +80,18 @@ export class InputComponent extends BaseComponent { #modifiedContent = true; constructor( - { title, spinner, headSymbol, failSymbol, ...params }: - InputComponentParams = {}, + { + title, + cmdline, + spinner, + headSymbol, + failSymbol, + ...params + }: InputComponentParams = {}, ) { super(params); this.#title = title ?? ""; + this.#cmdline = cmdline ?? ""; this.#spinner = new Spinner(spinner ?? SPINNER); this.#headSymbol = headSymbol ?? HEAD_SYMBOL; this.#failSymbol = failSymbol ?? FAIL_SYMBOL; From 619213c856dd3c21a25750b10adee0dee9cd858b Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 14:22:51 +0900 Subject: [PATCH 03/15] Add `UniqueOrderedList` class to manage unique items in insertion order --- denops/fall/lib/unique_ordered_list.ts | 114 ++++++++++++++++++++ denops/fall/lib/unique_ordered_list_test.ts | 86 +++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 denops/fall/lib/unique_ordered_list.ts create mode 100644 denops/fall/lib/unique_ordered_list_test.ts diff --git a/denops/fall/lib/unique_ordered_list.ts b/denops/fall/lib/unique_ordered_list.ts new file mode 100644 index 0000000..faf4560 --- /dev/null +++ b/denops/fall/lib/unique_ordered_list.ts @@ -0,0 +1,114 @@ +/** + * A function that extracts a unique identifier from an item. + * The identifier is used to determine uniqueness in the list. + * + * @template T - The type of items in the list + * @param item - The item to extract an identifier from + * @returns A value that uniquely identifies the item + */ +export type Identifier = (item: T) => unknown; + +export type UniqueOrderedListOptions = { + /** + * A function to extract a unique identifier from items. + * If not provided, items themselves are used as identifiers. + */ + identifier?: Identifier; +}; + +/** + * A list that maintains insertion order while ensuring all items are unique. + * + * This class provides a data structure that combines the properties of an array + * (ordered) and a set (unique items). Items are kept in the order they were first + * added, and duplicate items (as determined by the identifier function) are ignored. + * + * @template T - The type of items stored in the list + * + * @example + * ```typescript + * type User = { id: number; name: string }; + * + * // Using with objects and custom identifier + * const users = new UniqueOrderedList([], { + * identifier: (user) => user.id + * }); + * users.push({ id: 1, name: "Alice" }); + * users.push({ id: 2, name: "Bob" }); + * users.push({ id: 1, name: "Alice Updated" }); // Ignored due to duplicate id + * console.log(users.items); // [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] + * + * // Using with primitives (default identifier) + * const numbers = new UniqueOrderedList(); + * numbers.push(3, 1, 4, 1, 5, 9); // Duplicates are ignored + * console.log(numbers.items); // [3, 1, 4, 5, 9] + * + * // Initializing with items (duplicates are filtered) + * const nums = new UniqueOrderedList([1, 2, 3, 2, 1]); + * console.log(nums.items); // [1, 2, 3] + * ``` + */ +export class UniqueOrderedList { + #seen: Set = new Set(); + #identifier: Identifier; + #items: T[]; + + /** + * Creates a new UniqueOrderedList instance. + * + * @param init - Optional array of initial items. Duplicates will be filtered out. + * @param options - Optional configuration object + * @param options.identifier - Function to extract unique identifiers from items. + * If not provided, items themselves are used as identifiers. + */ + constructor(init?: readonly T[], options?: UniqueOrderedListOptions) { + this.#identifier = options?.identifier ?? ((item) => item); + this.#items = Array.from(this.#uniq(init?.slice() ?? [])); + } + + /** + * Gets a readonly array of all items in the list. + * Items are returned in the order they were first added. + * + * @returns A readonly array containing all unique items + */ + get items(): readonly T[] { + return this.#items; + } + + /** + * Gets the number of unique items in the list. + * + * @returns The count of unique items + */ + get size(): number { + return this.#items.length; + } + + *#uniq(items: Iterable): Iterable { + const seen = this.#seen; + const identifier = this.#identifier; + for (const item of items) { + const id = identifier(item); + if (!seen.has(id)) { + seen.add(id); + yield item; + } + } + } + + /** + * Adds one or more items to the list. + * + * Items are added in the order provided, but only if their identifier + * hasn't been seen before. Duplicate items (based on the identifier) are + * silently ignored and do not affect the existing order. + * + * @param items - The items to add to the list + */ + push(...items: readonly T[]): void { + for (const item of this.#uniq(items)) { + this.#items.push(item); + } + } +} diff --git a/denops/fall/lib/unique_ordered_list_test.ts b/denops/fall/lib/unique_ordered_list_test.ts new file mode 100644 index 0000000..932207d --- /dev/null +++ b/denops/fall/lib/unique_ordered_list_test.ts @@ -0,0 +1,86 @@ +import { assertEquals } from "jsr:@std/assert@^1.0.0"; +import { UniqueOrderedList } from "./unique_ordered_list.ts"; + +type User = { id: number; name: string }; + +Deno.test("UniqueOrderedList: push unique items", () => { + const list = new UniqueOrderedList([], { + identifier: (user) => user.id, + }); + list.push({ id: 1, name: "Alice" }); + list.push({ id: 2, name: "Bob" }); + + assertEquals(list.size, 2); + assertEquals(list.items, [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); +}); + +Deno.test("UniqueOrderedList: skip duplicate items", () => { + const list = new UniqueOrderedList([], { + identifier: (user) => user.id, + }); + list.push({ id: 1, name: "Alice" }); + list.push({ id: 1, name: "Alice (dup)" }); // 重複 + list.push({ id: 2, name: "Bob" }); + + assertEquals(list.size, 2); + assertEquals(list.items, [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); +}); + +Deno.test("UniqueOrderedList: order is preserved", () => { + const list = new UniqueOrderedList(); + list.push(3, 1, 4, 1, 5, 9, 2, 6, 5); // 重複あり + + assertEquals(list.items, [3, 1, 4, 5, 9, 2, 6]); +}); + +Deno.test("UniqueOrderedList: accepts string keys", () => { + const list = new UniqueOrderedList<{ key: string }>([], { + identifier: (item) => item.key, + }); + list.push({ key: "a" }); + list.push({ key: "b" }); + list.push({ key: "a" }); + + assertEquals(list.size, 2); + assertEquals(list.items.map((i) => i.key), ["a", "b"]); +}); + +Deno.test("UniqueOrderedList: initializes with items", () => { + const initialItems = [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + { id: 1, name: "Alice (dup)" }, // Duplicate id + ]; + const list = new UniqueOrderedList(initialItems, { + identifier: (user) => user.id, + }); + + // Only unique items are kept from initialization + assertEquals(list.size, 2); + assertEquals(list.items, [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ]); + + // Pushing duplicate IDs is properly prevented + list.push({ id: 1, name: "Alice (another dup)" }); + assertEquals(list.size, 2); // Still 2, duplicate was ignored +}); + +Deno.test("UniqueOrderedList: works without identifier function", () => { + const list = new UniqueOrderedList([1, 2, 3, 2, 1]); // Has duplicates + + // Initial duplicates are filtered + assertEquals(list.size, 3); + assertEquals(list.items, [1, 2, 3]); + + list.push(4, 2, 5); // 2 is duplicate and will be ignored + assertEquals(list.size, 5); + assertEquals(list.items, [1, 2, 3, 4, 5]); +}); From d62aa592afca0a07bf19c8d00106873cb4fa52fd Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 19:33:00 +0900 Subject: [PATCH 04/15] Expose internal extensions from processors --- denops/fall/processor/collect.ts | 6 ++---- denops/fall/processor/match.ts | 14 +++++++------- denops/fall/processor/preview.ts | 14 +++++++------- denops/fall/processor/render.ts | 14 +++++++------- denops/fall/processor/sort.ts | 14 +++++++------- 5 files changed, 30 insertions(+), 32 deletions(-) diff --git a/denops/fall/processor/collect.ts b/denops/fall/processor/collect.ts index 9156aa4..d587da6 100644 --- a/denops/fall/processor/collect.ts +++ b/denops/fall/processor/collect.ts @@ -20,7 +20,6 @@ export type CollectProcessorOptions = { export class CollectProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); readonly #items: IdItem[] = []; - readonly #source: Source; readonly #threshold: number; readonly #chunkSize: number; readonly #chunkInterval: number; @@ -28,10 +27,9 @@ export class CollectProcessor implements Disposable { #paused?: PromiseWithResolvers; constructor( - source: Source, + readonly source: Source, options: CollectProcessorOptions = {}, ) { - this.#source = source; this.#threshold = options.threshold ?? THRESHOLD; this.#chunkSize = options.chunkSize ?? CHUNK_SIZE; this.#chunkInterval = options.chunkInterval ?? CHUNK_INTERVAL; @@ -65,7 +63,7 @@ export class CollectProcessor implements Disposable { dispatch({ type: "collect-processor-started" }); const signal = this.#controller.signal; const iter = take( - this.#source.collect(denops, params, { signal }), + this.source.collect(denops, params, { signal }), this.#threshold, ); const update = (chunk: Iterable>) => { diff --git a/denops/fall/processor/match.ts b/denops/fall/processor/match.ts index 6a7f8e3..34c3ab6 100644 --- a/denops/fall/processor/match.ts +++ b/denops/fall/processor/match.ts @@ -22,7 +22,7 @@ export type MatchProcessorOptions = { }; export class MatchProcessor implements Disposable { - readonly #matchers: ItemBelt>; + readonly matchers: ItemBelt>; readonly #interval: number; readonly #threshold: number; readonly #chunkSize: number; @@ -37,7 +37,7 @@ export class MatchProcessor implements Disposable { matchers: readonly [Matcher, ...Matcher[]], options: MatchProcessorOptions = {}, ) { - this.#matchers = new ItemBelt(matchers); + this.matchers = new ItemBelt(matchers); this.#interval = options.interval ?? INTERVAL; this.#threshold = options.threshold ?? THRESHOLD; this.#chunkSize = options.chunkSize ?? CHUNK_SIZE; @@ -46,7 +46,7 @@ export class MatchProcessor implements Disposable { } get #matcher(): Matcher { - return this.#matchers.current!; + return this.matchers.current!; } get items(): IdItem[] { @@ -54,18 +54,18 @@ export class MatchProcessor implements Disposable { } get matcherCount(): number { - return this.#matchers.count; + return this.matchers.count; } get matcherIndex(): number { - return this.#matchers.index; + return this.matchers.index; } set matcherIndex(index: number | "$") { if (index === "$") { - index = this.#matchers.count; + index = this.matchers.count; } - this.#matchers.index = index; + this.matchers.index = index; } #validateAvailability(): void { diff --git a/denops/fall/processor/preview.ts b/denops/fall/processor/preview.ts index e5696d1..dab65e5 100644 --- a/denops/fall/processor/preview.ts +++ b/denops/fall/processor/preview.ts @@ -10,32 +10,32 @@ import { dispatch } from "../event.ts"; export class PreviewProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); - readonly #previewers: ItemBelt>; + readonly previewers: ItemBelt>; #processing?: Promise; #reserved?: () => void; #item: PreviewItem | undefined = undefined; constructor(previewers: readonly Previewer[]) { - this.#previewers = new ItemBelt(previewers); + this.previewers = new ItemBelt(previewers); } get #previewer(): Previewer | undefined { - return this.#previewers.current; + return this.previewers.current; } get previewerCount(): number { - return this.#previewers.count; + return this.previewers.count; } get previewerIndex(): number { - return this.#previewers.index; + return this.previewers.index; } set previewerIndex(index: number | "$") { if (index === "$") { - index = this.#previewers.count; + index = this.previewers.count; } - this.#previewers.index = index; + this.previewers.index = index; } get item(): PreviewItem | undefined { diff --git a/denops/fall/processor/render.ts b/denops/fall/processor/render.ts index 7683348..00b8aa5 100644 --- a/denops/fall/processor/render.ts +++ b/denops/fall/processor/render.ts @@ -20,7 +20,7 @@ export type RenderProcessorOptions = { export class RenderProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); - readonly #renderers: ItemBelt>; + readonly renderers: ItemBelt>; #height: number; #scrollOffset: number; #processing?: Promise; @@ -34,28 +34,28 @@ export class RenderProcessor implements Disposable { renderers: readonly Renderer[], options: RenderProcessorOptions = {}, ) { - this.#renderers = new ItemBelt(renderers); + this.renderers = new ItemBelt(renderers); this.#height = options.height ?? HEIGHT; this.#scrollOffset = options.scrollOffset ?? SCROLL_OFFSET; } get #renderer(): Renderer | undefined { - return this.#renderers.current; + return this.renderers.current; } get rendererCount(): number { - return this.#renderers.count; + return this.renderers.count; } get rendererIndex(): number { - return this.#renderers.index; + return this.renderers.index; } set rendererIndex(index: number | "$") { if (index === "$") { - index = this.#renderers.count; + index = this.renderers.count; } - this.#renderers.index = index; + this.renderers.index = index; } get items() { diff --git a/denops/fall/processor/sort.ts b/denops/fall/processor/sort.ts index 0aea4ec..c0e5bc3 100644 --- a/denops/fall/processor/sort.ts +++ b/denops/fall/processor/sort.ts @@ -7,32 +7,32 @@ import { dispatch } from "../event.ts"; export class SortProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); - readonly #sorters: ItemBelt>; + readonly sorters: ItemBelt>; #processing?: Promise; #reserved?: () => void; #items: IdItem[] = []; constructor(sorters: readonly Sorter[]) { - this.#sorters = new ItemBelt(sorters); + this.sorters = new ItemBelt(sorters); } get #sorter(): Sorter | undefined { - return this.#sorters.current; + return this.sorters.current; } get sorterCount(): number { - return this.#sorters.count; + return this.sorters.count; } get sorterIndex(): number { - return this.#sorters.index; + return this.sorters.index; } set sorterIndex(index: number | "$") { if (index === "$") { - index = this.#sorters.count; + index = this.sorters.count; } - this.#sorters.index = index; + this.sorters.index = index; } get items() { From d381b95a6c6cc9d6a6a80bf80a66fd23f2c35845 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 19:35:19 +0900 Subject: [PATCH 05/15] Add `initialIndex` to processors to specify initial extension --- denops/fall/processor/match.ts | 5 ++++- denops/fall/processor/preview.ts | 13 +++++++++++-- denops/fall/processor/render.ts | 5 ++++- denops/fall/processor/sort.ts | 14 +++++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/denops/fall/processor/match.ts b/denops/fall/processor/match.ts index 34c3ab6..b17956e 100644 --- a/denops/fall/processor/match.ts +++ b/denops/fall/processor/match.ts @@ -14,6 +14,7 @@ const CHUNK_SIZE = 1000; const CHUNK_INTERVAL = 100; export type MatchProcessorOptions = { + initialIndex?: number; interval?: number; threshold?: number; chunkSize?: number; @@ -37,7 +38,9 @@ export class MatchProcessor implements Disposable { matchers: readonly [Matcher, ...Matcher[]], options: MatchProcessorOptions = {}, ) { - this.matchers = new ItemBelt(matchers); + this.matchers = new ItemBelt(matchers, { + index: options.initialIndex, + }); this.#interval = options.interval ?? INTERVAL; this.#threshold = options.threshold ?? THRESHOLD; this.#chunkSize = options.chunkSize ?? CHUNK_SIZE; diff --git a/denops/fall/processor/preview.ts b/denops/fall/processor/preview.ts index dab65e5..82e1a68 100644 --- a/denops/fall/processor/preview.ts +++ b/denops/fall/processor/preview.ts @@ -8,6 +8,10 @@ import type { import { ItemBelt } from "../lib/item_belt.ts"; import { dispatch } from "../event.ts"; +export type PreviewProcessorOptions = { + initialIndex?: number; +}; + export class PreviewProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); readonly previewers: ItemBelt>; @@ -15,8 +19,13 @@ export class PreviewProcessor implements Disposable { #reserved?: () => void; #item: PreviewItem | undefined = undefined; - constructor(previewers: readonly Previewer[]) { - this.previewers = new ItemBelt(previewers); + constructor( + previewers: readonly Previewer[], + options: PreviewProcessorOptions = {}, + ) { + this.previewers = new ItemBelt(previewers, { + index: options.initialIndex, + }); } get #previewer(): Previewer | undefined { diff --git a/denops/fall/processor/render.ts b/denops/fall/processor/render.ts index 00b8aa5..4b2c653 100644 --- a/denops/fall/processor/render.ts +++ b/denops/fall/processor/render.ts @@ -14,6 +14,7 @@ const HEIGHT = 10; const SCROLL_OFFSET = 2; export type RenderProcessorOptions = { + initialIndex?: number; height?: number; scrollOffset?: number; }; @@ -34,7 +35,9 @@ export class RenderProcessor implements Disposable { renderers: readonly Renderer[], options: RenderProcessorOptions = {}, ) { - this.renderers = new ItemBelt(renderers); + this.renderers = new ItemBelt(renderers, { + index: options.initialIndex, + }); this.#height = options.height ?? HEIGHT; this.#scrollOffset = options.scrollOffset ?? SCROLL_OFFSET; } diff --git a/denops/fall/processor/sort.ts b/denops/fall/processor/sort.ts index c0e5bc3..abcff3b 100644 --- a/denops/fall/processor/sort.ts +++ b/denops/fall/processor/sort.ts @@ -5,15 +5,23 @@ import type { Sorter } from "jsr:@vim-fall/core@^0.3.0/sorter"; import { ItemBelt } from "../lib/item_belt.ts"; import { dispatch } from "../event.ts"; +export type SortProcessorOptions = { + initialIndex?: number; +}; + export class SortProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); readonly sorters: ItemBelt>; #processing?: Promise; #reserved?: () => void; #items: IdItem[] = []; - - constructor(sorters: readonly Sorter[]) { - this.sorters = new ItemBelt(sorters); + constructor( + sorters: readonly Sorter[], + options: SortProcessorOptions = {}, + ) { + this.sorters = new ItemBelt(sorters, { + index: options.initialIndex, + }); } get #sorter(): Sorter | undefined { From e9082f3ac1e5d9b523e6090e79f7b89670e14c41 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 20:18:39 +0900 Subject: [PATCH 06/15] Use `UniqueOrderedList` and add `initialItems` to `CollectProcessor` --- denops/fall/lib/unique_ordered_list_test.ts | 6 +++--- denops/fall/processor/collect.ts | 22 +++++++++++++++------ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/denops/fall/lib/unique_ordered_list_test.ts b/denops/fall/lib/unique_ordered_list_test.ts index 932207d..def33dd 100644 --- a/denops/fall/lib/unique_ordered_list_test.ts +++ b/denops/fall/lib/unique_ordered_list_test.ts @@ -67,7 +67,7 @@ Deno.test("UniqueOrderedList: initializes with items", () => { { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, ]); - + // Pushing duplicate IDs is properly prevented list.push({ id: 1, name: "Alice (another dup)" }); assertEquals(list.size, 2); // Still 2, duplicate was ignored @@ -75,11 +75,11 @@ Deno.test("UniqueOrderedList: initializes with items", () => { Deno.test("UniqueOrderedList: works without identifier function", () => { const list = new UniqueOrderedList([1, 2, 3, 2, 1]); // Has duplicates - + // Initial duplicates are filtered assertEquals(list.size, 3); assertEquals(list.items, [1, 2, 3]); - + list.push(4, 2, 5); // 2 is duplicate and will be ignored assertEquals(list.size, 5); assertEquals(list.items, [1, 2, 3, 4, 5]); diff --git a/denops/fall/processor/collect.ts b/denops/fall/processor/collect.ts index d587da6..1ae3652 100644 --- a/denops/fall/processor/collect.ts +++ b/denops/fall/processor/collect.ts @@ -5,13 +5,15 @@ import type { Detail, IdItem } from "jsr:@vim-fall/core@^0.3.0/item"; import type { CollectParams, Source } from "jsr:@vim-fall/core@^0.3.0/source"; import { Chunker } from "../lib/chunker.ts"; +import { UniqueOrderedList } from "../lib/unique_ordered_list.ts"; import { dispatch } from "../event.ts"; const THRESHOLD = 100000; const CHUNK_SIZE = 1000; const CHUNK_INTERVAL = 100; -export type CollectProcessorOptions = { +export type CollectProcessorOptions = { + initialItems?: readonly IdItem[]; threshold?: number; chunkSize?: number; chunkInterval?: number; @@ -19,7 +21,7 @@ export type CollectProcessorOptions = { export class CollectProcessor implements Disposable { readonly #controller: AbortController = new AbortController(); - readonly #items: IdItem[] = []; + readonly #items: UniqueOrderedList>; readonly #threshold: number; readonly #chunkSize: number; readonly #chunkInterval: number; @@ -28,15 +30,23 @@ export class CollectProcessor implements Disposable { constructor( readonly source: Source, - options: CollectProcessorOptions = {}, + options: CollectProcessorOptions = {}, ) { this.#threshold = options.threshold ?? THRESHOLD; this.#chunkSize = options.chunkSize ?? CHUNK_SIZE; this.#chunkInterval = options.chunkInterval ?? CHUNK_INTERVAL; + this.#items = new UniqueOrderedList>( + options.initialItems ?? [], + { + // We need to compare "value" rather than "id" for uniqueness, + // to implement the "resume" functionality correctly. + identifier: (item) => item.value, + }, + ); } - get items() { - return this.#items; + get items(): readonly IdItem[] { + return this.#items.items; } #validateAvailability(): void { @@ -67,7 +77,7 @@ export class CollectProcessor implements Disposable { this.#threshold, ); const update = (chunk: Iterable>) => { - const offset = this.#items.length; + const offset = this.#items.size; this.#items.push( ...map(chunk, (item, i) => ({ ...item, id: i + offset })), ); From be4071767a8ec28ba63614f58fd21c52d0c7f1b4 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 20:19:05 +0900 Subject: [PATCH 07/15] Add `initialItems` and `initialQuery` to `MatchProcessor` --- denops/fall/processor/match.ts | 19 +++++++++++++++---- denops/fall/processor/match_test.ts | 20 ++++++++++---------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/denops/fall/processor/match.ts b/denops/fall/processor/match.ts index b17956e..1b201b7 100644 --- a/denops/fall/processor/match.ts +++ b/denops/fall/processor/match.ts @@ -13,7 +13,9 @@ const THRESHOLD = 100000; const CHUNK_SIZE = 1000; const CHUNK_INTERVAL = 100; -export type MatchProcessorOptions = { +export type MatchProcessorOptions = { + initialItems?: readonly IdItem[]; + initialQuery?: string; initialIndex?: number; interval?: number; threshold?: number; @@ -32,11 +34,12 @@ export class MatchProcessor implements Disposable { #controller: AbortController = new AbortController(); #processing?: Promise; #reserved?: () => void; - #items: IdItem[] = []; + #items: IdItem[]; + #previousQuery?: string; constructor( matchers: readonly [Matcher, ...Matcher[]], - options: MatchProcessorOptions = {}, + options: MatchProcessorOptions = {}, ) { this.matchers = new ItemBelt(matchers, { index: options.initialIndex, @@ -46,6 +49,8 @@ export class MatchProcessor implements Disposable { this.#chunkSize = options.chunkSize ?? CHUNK_SIZE; this.#chunkInterval = options.chunkInterval ?? CHUNK_INTERVAL; this.#incremental = options.incremental ?? false; + this.#items = options.initialItems?.slice() ?? []; + this.#previousQuery = options.initialQuery; } get #matcher(): Matcher { @@ -88,7 +93,12 @@ export class MatchProcessor implements Disposable { options?: { restart?: boolean }, ): void { this.#validateAvailability(); - if (this.#processing) { + if (query === this.#previousQuery) { + if (!this.#processing) { + dispatch({ type: "match-processor-succeeded" }); + } + return; + } else if (this.#processing) { // Keep most recent start request for later. this.#reserved = () => this.start(denops, { items, query }, options); // If restart is requested, we need to abort the current processing. @@ -101,6 +111,7 @@ export class MatchProcessor implements Disposable { } this.#processing = (async () => { dispatch({ type: "match-processor-started" }); + this.#previousQuery = query; const signal = this.#controller.signal; const iter = take( this.#matcher.match(denops, { items, query }, { signal }), diff --git a/denops/fall/processor/match_test.ts b/denops/fall/processor/match_test.ts index 51900cc..0c3ab97 100644 --- a/denops/fall/processor/match_test.ts +++ b/denops/fall/processor/match_test.ts @@ -399,19 +399,19 @@ Deno.test("MatchProcessor", async (t) => { ); processor.start(denops, { items: [{ id: 0, value: "0", detail: {} }], - query: "", + query: "q0", }); processor.start(denops, { items: [{ id: 1, value: "1", detail: {} }], - query: "", + query: "q1", }); processor.start(denops, { items: [{ id: 2, value: "2", detail: {} }], - query: "", + query: "q2", }); processor.start(denops, { items: [{ id: 3, value: "3", detail: {} }], - query: "", + query: "q3", }); assertEquals(called, []); @@ -450,19 +450,19 @@ Deno.test("MatchProcessor", async (t) => { ); processor.start(denops, { items: [{ id: 0, value: "0", detail: {} }], - query: "", + query: "q0", }, { restart: true }); processor.start(denops, { items: [{ id: 1, value: "1", detail: {} }], - query: "", + query: "q1", }, { restart: true }); processor.start(denops, { items: [{ id: 2, value: "2", detail: {} }], - query: "", + query: "q2", }, { restart: true }); processor.start(denops, { items: [{ id: 3, value: "3", detail: {} }], - query: "", + query: "q3", }, { restart: true }); assertEquals(called, []); @@ -470,12 +470,12 @@ Deno.test("MatchProcessor", async (t) => { notify.notify(); await flushPromises(); - assertEquals(called, []); + assertEquals(called, [0]); notify.notify(); await flushPromises(); - assertEquals(called, [3]); + assertEquals(called, [0, 3]); }, ); From a17113f2ba90d031d6b4dac08895fbe290e29111 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 20:19:58 +0900 Subject: [PATCH 08/15] Add `initialCursor/initialOffset` options to `RenderProcessor` --- denops/fall/processor/render.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/denops/fall/processor/render.ts b/denops/fall/processor/render.ts index 4b2c653..65583f1 100644 --- a/denops/fall/processor/render.ts +++ b/denops/fall/processor/render.ts @@ -15,6 +15,8 @@ const SCROLL_OFFSET = 2; export type RenderProcessorOptions = { initialIndex?: number; + initialCursor?: number; + initialOffset?: number; height?: number; scrollOffset?: number; }; @@ -27,9 +29,9 @@ export class RenderProcessor implements Disposable { #processing?: Promise; #reserved?: () => void; #items: DisplayItem[] = []; - #itemCount: number = 0; - #cursor: number = 0; - #offset: number = 0; + #itemCount = 0; + #cursor: number; + #offset: number; constructor( renderers: readonly Renderer[], @@ -40,6 +42,8 @@ export class RenderProcessor implements Disposable { }); this.#height = options.height ?? HEIGHT; this.#scrollOffset = options.scrollOffset ?? SCROLL_OFFSET; + this.#cursor = options.initialCursor ?? 0; + this.#offset = options.initialOffset ?? 0; } get #renderer(): Renderer | undefined { @@ -61,7 +65,7 @@ export class RenderProcessor implements Disposable { this.renderers.index = index; } - get items() { + get items(): readonly DisplayItem[] { return this.#items; } From 3d7b6774df43fe84c740a31dbd6f2552284077a6 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 20:20:27 +0900 Subject: [PATCH 09/15] Add type annotations --- denops/fall/processor/sort.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/denops/fall/processor/sort.ts b/denops/fall/processor/sort.ts index abcff3b..b299665 100644 --- a/denops/fall/processor/sort.ts +++ b/denops/fall/processor/sort.ts @@ -15,6 +15,7 @@ export class SortProcessor implements Disposable { #processing?: Promise; #reserved?: () => void; #items: IdItem[] = []; + constructor( sorters: readonly Sorter[], options: SortProcessorOptions = {}, @@ -43,7 +44,7 @@ export class SortProcessor implements Disposable { this.sorters.index = index; } - get items() { + get items(): readonly IdItem[] { return this.#items; } From 6f469e75b3b762cc507ea8236cb01318240fbb7c Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 20:21:11 +0900 Subject: [PATCH 10/15] Start matcher processor on `collect-processor-succeeded` --- denops/fall/picker.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/denops/fall/picker.ts b/denops/fall/picker.ts index c1f98b4..0f41d24 100644 --- a/denops/fall/picker.ts +++ b/denops/fall/picker.ts @@ -655,6 +655,12 @@ export class Picker implements AsyncDisposable { break; case "collect-processor-succeeded": this.#inputComponent.collecting = false; + reserve((denops) => { + this.#matchProcessor.start(denops, { + items: this.#collectProcessor.items, + query: this.#inputComponent.cmdline, + }); + }); break; case "collect-processor-failed": { if (event.err === null) { From 33835a260b4445f96dee0c505628479876d0e91b Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 20:22:03 +0900 Subject: [PATCH 11/15] Add `PickerContext` to restore internal state of picker --- denops/fall/picker.ts | 66 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/denops/fall/picker.ts b/denops/fall/picker.ts index 0f41d24..a5ed8c0 100644 --- a/denops/fall/picker.ts +++ b/denops/fall/picker.ts @@ -57,6 +57,7 @@ export type PickerParams = { renderers?: readonly Renderer[]; previewers?: readonly Previewer[]; zindex?: number; + context?: PickerContext; }; export type PickerResult = { @@ -71,6 +72,19 @@ export type PickerOptions = { schedulerInterval?: number; }; +export type PickerContext = { + readonly query: string; + readonly selection: Set; + readonly collectedItems: readonly IdItem[]; + readonly filteredItems: readonly IdItem[]; + readonly cursor: number; + readonly offset: number; + readonly matcherIndex: number; + readonly sorterIndex: number; + readonly rendererIndex: number; + readonly previewerIndex?: number; +}; + export class Picker implements AsyncDisposable { static readonly ZINDEX_ALLOCATION = 4; readonly #stack = new AsyncDisposableStack(); @@ -92,15 +106,17 @@ export class Picker implements AsyncDisposable { readonly #sorterIcon: string; readonly #rendererIcon: string; readonly #previewerIcon: string; - #selection: Set = new Set(); + #selection: Set; constructor(params: PickerParams, options: PickerOptions = {}) { this.#schedulerInterval = options.schedulerInterval ?? SCHEDULER_INTERVAL; - this.#name = params.name; - this.#coordinator = params.coordinator; + + const { name, theme, coordinator, zindex = 50, context } = params; + this.#name = name; + this.#coordinator = coordinator; + this.#selection = context?.selection ?? new Set(); // Components - const { theme, zindex = 50 } = params; this.#matcherIcon = theme.matcherIcon ?? MATCHER_ICON; this.#sorterIcon = theme.sorterIcon ?? SORTER_ICON; this.#rendererIcon = theme.rendererIcon ?? RENDERER_ICON; @@ -109,6 +125,7 @@ export class Picker implements AsyncDisposable { const style = this.#coordinator.style(theme); this.#inputComponent = this.#stack.use( new InputComponent({ + cmdline: context?.query ?? "", spinner: theme.spinner, headSymbol: theme.headSymbol, failSymbol: theme.failSymbol, @@ -141,27 +158,54 @@ export class Picker implements AsyncDisposable { // Processor this.#collectProcessor = this.#stack.use( - new CollectProcessor(params.source), + new CollectProcessor(params.source, { + initialItems: context?.collectedItems, + }), ); this.#matchProcessor = this.#stack.use( new MatchProcessor(params.matchers, { + initialItems: context?.filteredItems, + initialQuery: context?.query, + initialIndex: context?.matcherIndex, // Use incremental mode for Curator matcher incremental: isIncrementalMatcher(params.matchers[0]), }), ); this.#sortProcessor = this.#stack.use( - new SortProcessor(params.sorters ?? []), + new SortProcessor(params.sorters ?? [], { + // initialItems: this.#matchProcessor.items, + initialIndex: context?.sorterIndex, + }), ); this.#renderProcessor = this.#stack.use( - new RenderProcessor( - params.renderers ?? [], - ), + new RenderProcessor(params.renderers ?? [], { + // initialItems: session?.renderedItems, + initialIndex: context?.rendererIndex, + initialCursor: context?.cursor, + initialOffset: context?.offset, + }), ); this.#previewProcessor = this.#stack.use( - new PreviewProcessor(params.previewers ?? []), + new PreviewProcessor(params.previewers ?? [], { + initialIndex: context?.previewerIndex, + }), ); } + get context(): PickerContext { + return { + query: this.#inputComponent.cmdline, + selection: this.#selection, + collectedItems: this.#collectProcessor.items, + filteredItems: this.#matchProcessor.items, + cursor: this.#renderProcessor.cursor, + offset: this.#renderProcessor.offset, + matcherIndex: this.#matchProcessor.matcherIndex, + sorterIndex: this.#sortProcessor.sorterIndex, + rendererIndex: this.#renderProcessor.rendererIndex, + }; + } + #getHelpDimension(screen: Size): Dimension { const width = Math.floor(screen.width * this.#helpWidthRatio); const height = Math.floor(screen.height * this.#helpHeightRatio); @@ -290,7 +334,7 @@ export class Picker implements AsyncDisposable { async start( denops: Denops, - { args }: { args: string[] }, + { args }: { args: readonly string[] }, { signal }: { signal?: AbortSignal } = {}, ): Promise | undefined> { await using stack = new AsyncDisposableStack(); From 159a5d105714958befead49d152ce903da4046a5 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 21:26:15 +0900 Subject: [PATCH 12/15] Add `session.ts` to manage picker sessions in memory --- denops/fall/session.ts | 148 ++++++++++++++++++ denops/fall/session_test.ts | 297 ++++++++++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 denops/fall/session.ts create mode 100644 denops/fall/session_test.ts diff --git a/denops/fall/session.ts b/denops/fall/session.ts new file mode 100644 index 0000000..28bcd83 --- /dev/null +++ b/denops/fall/session.ts @@ -0,0 +1,148 @@ +import type { Detail } from "jsr:@vim-fall/core@^0.3.0/item"; +import { brotli } from "jsr:@deno-library/compress@^0.5.6"; + +import type { PickerContext } from "./picker.ts"; + +/** + * In-memory storage for compressed picker sessions. + * Sessions are stored in chronological order (oldest first). + */ +// deno-lint-ignore no-explicit-any +const sessions: PickerSessionCompressed[] = []; + +/** + * Maximum number of sessions to keep in memory. + * Oldest sessions are removed when this limit is exceeded. + */ +const MAX_SESSION_COUNT = 100; + +/** + * Represents a picker session with all its state information. + * @template T - The type of item detail in the picker + */ +export type PickerSession = { + readonly name: string; + /** Arguments passed to the source */ + readonly args: readonly string[]; + /** The internal state context of the picker */ + readonly context: PickerContext; +}; + +/** + * Compressed version of PickerSession where the context is stored as binary data. + * This reduces memory usage when storing multiple sessions. + * @template T - The type of item detail in the picker + */ +export type PickerSessionCompressed = + & Omit, "context"> + & { + /** Brotli-compressed binary representation of the context */ + context: Uint8Array; + }; + +/** + * Compresses a picker session by converting its context to brotli-compressed binary data. + * This is used internally to reduce memory usage when storing sessions. + * @template T - The type of item detail in the picker + * @param session - The session to compress + * @returns A promise that resolves to the compressed session + */ +async function compressPickerSession( + session: PickerSession, +): Promise> { + const encoder = new TextEncoder(); + return { + ...session, + context: await brotli.compress( + encoder.encode(JSON.stringify(session.context)), + ), + }; +} + +/** + * Decompresses a picker session by converting its binary context back to structured data. + * @template T - The type of item detail in the picker + * @param compressed - The compressed session to decompress + * @returns A promise that resolves to the decompressed session + */ +async function decompressPickerSession( + compressed: PickerSessionCompressed, +): Promise> { + const decoder = new TextDecoder(); + return { + ...compressed, + context: JSON.parse( + decoder.decode(await brotli.uncompress(compressed.context)), + ), + }; +} + +/** + * Lists all stored picker sessions in reverse chronological order (newest first). + * @returns A readonly array of compressed sessions + */ +export function listPickerSessions(): readonly PickerSessionCompressed< + Detail +>[] { + return sessions.slice().reverse(); // Return a copy in reverse order +} + +/** + * Saves a picker session to the in-memory storage. + * The session is compressed before storage to reduce memory usage. + * If the storage exceeds MAX_SESSION_COUNT, the oldest session is removed. + * @template T - The type of item detail in the picker + * @param session - The session to save + * @returns A promise that resolves when the session is saved + */ +export async function savePickerSession( + session: PickerSession, +): Promise { + const compressed = await compressPickerSession(session); + sessions.push(compressed); + if (sessions.length > MAX_SESSION_COUNT) { + sessions.shift(); // Keep only the last MAX_SESSION_COUNT sessions + } +} + +/** + * Options for loading a picker session. + */ +export type LoadPickerSessionOptions = { + /** Optional name to filter sessions by source name */ + name?: string; + /** Optional number from the latest session to load (1 = most recent, 2 = second most recent, etc.) */ + number?: number; +}; + +/** + * Loads a picker session from storage. + * @template T - The type of item detail in the picker + * @param indexFromLatest - The index from the latest session (0 = most recent, 1 = second most recent, etc.) + * @param options - Options to filter sessions + * @returns A promise that resolves to the decompressed session, or undefined if not found + * @example + * ```ts + * // Load the most recent session + * const session1 = await loadPickerSession(); + * + * // Load the second most recent session + * const session2 = await loadPickerSession({ number: 2 }); + * + * // Load the most recent session with name "file" + * const session3 = await loadPickerSession({ name: "file", number: 1 }); + * ``` + */ +export async function loadPickerSession( + { name, number: indexFromLatest }: LoadPickerSessionOptions = {}, +): Promise | undefined> { + const filteredSessions = name + ? sessions.filter((s) => s.name === name) + : sessions; + const index = filteredSessions.length - (indexFromLatest ?? 1); + const compressed = filteredSessions.at(index); + if (!compressed) { + return undefined; + } + return await decompressPickerSession(compressed); +} diff --git a/denops/fall/session_test.ts b/denops/fall/session_test.ts new file mode 100644 index 0000000..b8f1b98 --- /dev/null +++ b/denops/fall/session_test.ts @@ -0,0 +1,297 @@ +import { assertEquals, assertExists } from "jsr:@std/assert@^1.0.8"; +import type { Detail, IdItem } from "jsr:@vim-fall/core@^0.3.0/item"; + +import { + listPickerSessions, + loadPickerSession, + type PickerSession, + savePickerSession, +} from "./session.ts"; +import type { PickerContext } from "./picker.ts"; + +// Helper function to create a mock picker session +function createMockSession(name: string, id: number): PickerSession { + const mockItems: IdItem[] = [ + { id: `${id}-1`, value: `item1-${id}`, detail: {} }, + { id: `${id}-2`, value: `item2-${id}`, detail: {} }, + ]; + + const mockContext: PickerContext = { + query: `query-${id}`, + selection: new Set(), + collectedItems: mockItems, + filteredItems: mockItems, + cursor: id % 10, + offset: id * 10, + matcherIndex: 0, + sorterIndex: 0, + rendererIndex: 0, + }; + + return { + name, + args: [`arg1-${id}`, `arg2-${id}`], + context: mockContext, + }; +} + +Deno.test("session management", async (t) => { + await t.step("listPickerSessions returns empty array initially", () => { + // Clear any existing sessions by saving more than MAX_SESSION_COUNT + // This ensures we start with a clean state + const sessions = listPickerSessions(); + assertEquals(Array.isArray(sessions), true); + }); + + await t.step("savePickerSession stores a session", async () => { + const session = createMockSession("test", 1); + await savePickerSession(session); + + const sessions = listPickerSessions(); + assertEquals(sessions.length >= 1, true); + assertEquals(sessions[0].name, "test"); + assertEquals(sessions[0].args, ["arg1-1", "arg2-1"]); + }); + + await t.step( + "listPickerSessions returns sessions in reverse chronological order", + async () => { + // Save multiple sessions + await savePickerSession(createMockSession("test1", 1)); + await savePickerSession(createMockSession("test2", 2)); + await savePickerSession(createMockSession("test3", 3)); + + const sessions = listPickerSessions(); + // Most recent session should be first + assertEquals(sessions[0].name, "test3"); + assertEquals(sessions[1].name, "test2"); + assertEquals(sessions[2].name, "test1"); + }, + ); + + await t.step( + "loadPickerSession retrieves the most recent session by default", + async () => { + await savePickerSession(createMockSession("recent", 99)); + + const session = await loadPickerSession(); + assertExists(session); + assertEquals(session.name, "recent"); + assertEquals(session.args, ["arg1-99", "arg2-99"]); + assertEquals(session.context.query, "query-99"); + assertEquals(session.context.cursor, 9); // 99 % 10 + }, + ); + + await t.step( + "loadPickerSession retrieves session by index from latest", + async () => { + // Clear and add fresh sessions + for (let i = 0; i < 5; i++) { + await savePickerSession(createMockSession(`session${i}`, i)); + } + + // Index 1 = most recent (session4) + const session0 = await loadPickerSession({ number: 1 }); + assertExists(session0); + assertEquals(session0.name, "session4"); + + // Index 2 = second most recent (session3) + const session1 = await loadPickerSession({ number: 2 }); + assertExists(session1); + assertEquals(session1.name, "session3"); + + // Index 3 = third most recent (session2) + const session2 = await loadPickerSession({ number: 3 }); + assertExists(session2); + assertEquals(session2.name, "session2"); + }, + ); + + await t.step("loadPickerSession filters by name", async () => { + // Add sessions with different names + await savePickerSession(createMockSession("file", 1)); + await savePickerSession(createMockSession("buffer", 2)); + await savePickerSession(createMockSession("file", 3)); + await savePickerSession(createMockSession("buffer", 4)); + await savePickerSession(createMockSession("file", 5)); + + // Load most recent "file" session + const fileSession = await loadPickerSession({ name: "file" }); + assertExists(fileSession); + assertEquals(fileSession.name, "file"); + assertEquals(fileSession.context.query, "query-5"); + assertEquals(fileSession.context.cursor, 5); // 5 % 10 + + // Load second most recent "file" session + const fileSession2 = await loadPickerSession({ + name: "file", + number: 2, + }); + assertExists(fileSession2); + assertEquals(fileSession2.name, "file"); + assertEquals(fileSession2.context.query, "query-3"); + assertEquals(fileSession2.context.cursor, 3); // 3 % 10 + + // Load most recent "buffer" session + const bufferSession = await loadPickerSession({ name: "buffer" }); + assertExists(bufferSession); + assertEquals(bufferSession.name, "buffer"); + assertEquals(bufferSession.context.query, "query-4"); + assertEquals(bufferSession.context.cursor, 4); // 4 % 10 + }); + + await t.step( + "loadPickerSession returns undefined for non-existent session", + async () => { + // Try to load a session with an index beyond available sessions + const session = await loadPickerSession({ number: 1000 }); + assertEquals(session, undefined); + + // Try to load a session with a name that doesn't exist + const namedSession = await loadPickerSession({ name: "non-existent" }); + assertEquals(namedSession, undefined); + }, + ); + + await t.step( + "compression and decompression preserve session data", + async () => { + const testItems: IdItem[] = [ + { id: "test-1", value: "test item 1", detail: { foo: "bar" } }, + { id: "test-2", value: "test item 2", detail: { baz: 42 } }, + ]; + + const originalSession = createMockSession("compression-test", 123); + // Create a new session with specific context + const sessionWithCustomContext: PickerSession = { + ...originalSession, + context: { + query: "test query", + selection: new Set(["test-1"]), + collectedItems: testItems, + filteredItems: testItems, + cursor: 5, + offset: 10, + matcherIndex: 1, + sorterIndex: 2, + rendererIndex: 3, + previewerIndex: 4, + }, + }; + + await savePickerSession(sessionWithCustomContext); + const loadedSession = await loadPickerSession({ + name: "compression-test", + }); + + assertExists(loadedSession); + assertEquals( + loadedSession.name, + sessionWithCustomContext.name, + ); + assertEquals( + loadedSession.args, + sessionWithCustomContext.args, + ); + assertEquals(loadedSession.context.query, "test query"); + assertEquals(loadedSession.context.cursor, 5); + assertEquals(loadedSession.context.offset, 10); + assertEquals(loadedSession.context.matcherIndex, 1); + assertEquals(loadedSession.context.sorterIndex, 2); + assertEquals(loadedSession.context.rendererIndex, 3); + assertEquals(loadedSession.context.previewerIndex, 4); + assertEquals(loadedSession.context.collectedItems, testItems); + assertEquals(loadedSession.context.filteredItems, testItems); + // Check that selection was preserved (Sets are converted to empty objects in JSON) + // The selection will be an empty object after deserialization since Set is not JSON serializable + assertEquals(typeof loadedSession.context.selection, "object"); + assertEquals(loadedSession.context.selection, {} as unknown); + }, + ); + + await t.step("respects MAX_SESSION_COUNT limit", async () => { + // Save more than MAX_SESSION_COUNT (100) sessions + for (let i = 0; i < 105; i++) { + await savePickerSession(createMockSession(`session-limit-${i}`, i)); + } + + const sessions = listPickerSessions(); + // Should only have MAX_SESSION_COUNT sessions + assertEquals(sessions.length, 100); + + // Oldest sessions should be removed (0-4), so first session should be session-limit-5 + const oldestSession = sessions[sessions.length - 1]; + assertEquals(oldestSession.name, "session-limit-5"); + + // Newest session should be session-limit-104 + const newestSession = sessions[0]; + assertEquals(newestSession.name, "session-limit-104"); + }); + + await t.step("handles minimal context gracefully", async () => { + const sessionWithMinimalContext = createMockSession("minimal-context", 1); + const minimalSession: PickerSession = { + ...sessionWithMinimalContext, + context: { + query: "", + selection: new Set(), + collectedItems: [], + filteredItems: [], + cursor: 0, + offset: 0, + matcherIndex: 0, + sorterIndex: 0, + rendererIndex: 0, + }, + }; + + await savePickerSession(minimalSession); + const loaded = await loadPickerSession({ name: "minimal-context" }); + + assertExists(loaded); + assertEquals(loaded.context.query, ""); + // Selection will be deserialized as an empty object, not a Set + assertEquals(typeof loaded.context.selection, "object"); + assertEquals(loaded.context.selection, {} as unknown); + assertEquals(loaded.context.collectedItems.length, 0); + assertEquals(loaded.context.filteredItems.length, 0); + assertEquals(loaded.context.cursor, 0); + }); + + await t.step("handles special characters in session data", async () => { + const specialItems: IdItem[] = [ + { id: "emoji", value: "😀 emoji test 🎉", detail: { unicode: "✨" } }, + { id: "backslash", value: "backslash \\ test", detail: {} }, + ]; + + const specialSession = createMockSession("special-chars", 1); + const sessionWithSpecialChars: PickerSession = { + ...specialSession, + context: { + query: "test with \"quotes\" and 'apostrophes' and \n newlines \t tabs", + selection: new Set(), + collectedItems: specialItems, + filteredItems: specialItems, + cursor: 0, + offset: 0, + matcherIndex: 0, + sorterIndex: 0, + rendererIndex: 0, + }, + }; + + await savePickerSession(sessionWithSpecialChars); + const loaded = await loadPickerSession({ name: "special-chars" }); + + assertExists(loaded); + assertEquals( + loaded.context.query, + "test with \"quotes\" and 'apostrophes' and \n newlines \t tabs", + ); + assertEquals(loaded.context.collectedItems.length, 2); + assertEquals(loaded.context.collectedItems[0].value, "😀 emoji test 🎉"); + assertEquals(loaded.context.collectedItems[0].detail, { unicode: "✨" }); + assertEquals(loaded.context.collectedItems[1].value, "backslash \\ test"); + }); +}); From c067c77d3b986cbd45cd65107077ee75eb9c71b2 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 22:29:22 +0900 Subject: [PATCH 13/15] Add `FallResume` command to resume previous pickers --- autoload/fall/command/FallResume.vim | 32 ++++++++ denops/fall/main/picker.ts | 106 +++++++++++++++++++++++++-- denops/fall/main/submatch.ts | 11 +-- doc/fall.txt | 31 ++++++++ plugin/fall.vim | 2 + 5 files changed, 170 insertions(+), 12 deletions(-) create mode 100644 autoload/fall/command/FallResume.vim diff --git a/autoload/fall/command/FallResume.vim b/autoload/fall/command/FallResume.vim new file mode 100644 index 0000000..a35d423 --- /dev/null +++ b/autoload/fall/command/FallResume.vim @@ -0,0 +1,32 @@ +function! fall#command#FallResume#call(filter) abort + if denops#plugin#wait('fall') isnot# 0 + return + endif + let l:laststatus_saved = &laststatus + try + call s:hide() + call fall#internal#mapping#store() + call denops#request('fall', 'picker:resume:command', [a:filter]) + finally + call s:show() + call fall#internal#tolerant#call({ -> fall#internal#mapping#restore() }) + call fall#internal#tolerant#call({ -> fall#internal#popup#closeall() }) + endtry +endfunction + +function! fall#command#FallResume#complete(arglead, cmdline, cursorpos) abort + if denops#plugin#wait('fall') isnot# 0 + return [] + endif + return denops#request('fall', 'picker:resume:command:complete', [a:arglead, a:cmdline, a:cursorpos]) +endfunction + +function! s:hide() abort + call fall#internal#tolerant#call({ -> fall#internal#msgarea#hide() }) + call fall#internal#tolerant#call({ -> fall#internal#cursor#hide() }) +endfunction + +function! s:show() abort + call fall#internal#tolerant#call({ -> fall#internal#msgarea#show() }) + call fall#internal#tolerant#call({ -> fall#internal#cursor#show() }) +endfunction diff --git a/denops/fall/main/picker.ts b/denops/fall/main/picker.ts index a668ec3..f67277f 100644 --- a/denops/fall/main/picker.ts +++ b/denops/fall/main/picker.ts @@ -1,7 +1,7 @@ import type { Denops, Entrypoint } from "jsr:@denops/std@^7.3.2"; import { ensurePromise } from "jsr:@core/asyncutil@^1.2.0/ensure-promise"; import { assert, ensure, is } from "jsr:@core/unknownutil@^4.3.0"; -import type { DetailUnit } from "jsr:@vim-fall/core@^0.3.0/item"; +import type { Detail } from "jsr:@vim-fall/core@^0.3.0/item"; import type { PickerParams } from "../custom.ts"; import { @@ -13,9 +13,14 @@ import { } from "../custom.ts"; import { isOptions, isPickerParams, isStringArray } from "../util/predicate.ts"; import { action as buildActionSource } from "../extension/source/action.ts"; -import { Picker } from "../picker.ts"; +import { Picker, type PickerContext } from "../picker.ts"; import type { SubmatchContext } from "./submatch.ts"; import { ExpectedError, withHandleError } from "../error.ts"; +import { + listPickerSessions, + loadPickerSession, + savePickerSession, +} from "../session.ts"; let zindex = 50; @@ -32,6 +37,7 @@ export const main: Entrypoint = (denops) => { await loadUserCustom(denops); // Split the command arguments const [name, ...sourceArgs] = ensure(args, isStringArray); + // Load user custom const itemPickerParams = getPickerParams(name); if (!itemPickerParams) { @@ -58,14 +64,92 @@ export const main: Entrypoint = (denops) => { return listPickerNames().filter((name) => name.startsWith(arglead)); }, ), + "picker:resume:command": withHandleError( + denops, + async (filter) => { + assert(filter, is.UnionOf([is.String, is.Nullish])); + await loadUserCustom(denops); + return await resumePicker(denops, filter ?? "", { + signal: denops.interrupted, + }); + }, + ), + "picker:resume:command:complete": withHandleError( + denops, + async (arglead, cmdline, cursorpos) => { + await loadUserCustom(denops); + assert(arglead, is.String); + assert(cmdline, is.String); + assert(cursorpos, is.Number); + const sessions = listPickerSessions(); + if (cmdline.includes("#")) { + // Resume by filter + const [name] = arglead.split("#", 2); + const filteredSessions = name + ? sessions.filter((s) => s.name === name) + : sessions; + const candidates = Array.from( + { length: filteredSessions.length }, + (_, i) => { + return `${name}#${i + 1}`; + }, + ); + return candidates.filter((c) => c.startsWith(arglead)); + } else { + // Resume by name + const candidates = sessions.map((s) => s.name); + return candidates.filter((c) => c.startsWith(arglead)); + } + }, + ), }; }; -async function startPicker( +async function resumePicker( + denops: Denops, + filter: string, + { signal }: { + signal?: AbortSignal; + } = {}, +): Promise { + // Parse filter ({name}#{indexFromLatest}) + const [filterName, filterNumberStr = "1"] = filter.split("#", 2); + const filterNumber = Number(filterNumberStr); + const session = await loadPickerSession({ + name: filterName, + number: filterNumber, + }); + if (!session) { + throw new ExpectedError( + `Picker session ${filterName}#${filterNumberStr} is not available.`, + ); + } + // Load user custom + const pickerParams = getPickerParams(session.name); + if (!pickerParams) { + throw new ExpectedError( + `No item picker "${session.name}" is found. Available item pickers are: ${ + listPickerNames().join(", ") + }`, + ); + } + const { args, context } = session; + await startPicker( + denops, + args, + pickerParams, + { signal, context }, + ); +} + +async function startPicker( denops: Denops, - args: string[], - pickerParams: PickerParams, - { signal }: { signal?: AbortSignal } = {}, + args: readonly string[], + pickerParams: PickerParams, + { signal, context }: { + signal?: AbortSignal; + context?: PickerContext; + } = {}, ): Promise { await using stack = new AsyncDisposableStack(); const setting = getSetting(); @@ -74,6 +158,7 @@ async function startPicker( ...setting, ...pickerParams, zindex, + context, }), ); zindex += Picker.ZINDEX_ALLOCATION; @@ -93,6 +178,13 @@ async function startPicker( stack.defer(() => { zindex -= Picker.ZINDEX_ALLOCATION; }); + stack.defer(async () => { + await savePickerSession({ + name: pickerParams.name, + args, + context: itemPicker.context, + }); + }); stack.use(await itemPicker.open(denops, { signal })); while (true) { @@ -155,7 +247,7 @@ async function startPicker( pickerParams, }, ...resultItem, - } as const satisfies SubmatchContext; + } as const satisfies SubmatchContext; if (await ensurePromise(action.invoke(denops, actionParams, { signal }))) { // Picker should not be closed continue; diff --git a/denops/fall/main/submatch.ts b/denops/fall/main/submatch.ts index 5ed4bb8..0ab0bb1 100644 --- a/denops/fall/main/submatch.ts +++ b/denops/fall/main/submatch.ts @@ -26,9 +26,9 @@ import { import { list as buildListSource } from "../extension/source/list.ts"; import { withHandleError } from "../error.ts"; -export type SubmatchContext = InvokeParams & { +export type SubmatchContext = InvokeParams & { readonly _submatch: { - readonly pickerParams: PickerParams; + readonly pickerParams: PickerParams; }; }; @@ -55,9 +55,9 @@ export const main: Entrypoint = (denops) => { }; }; -async function submatchStart( +async function submatchStart( denops: Denops, - context: SubmatchContext, + context: SubmatchContext, params: SubmatchParams, options: { signal?: AbortSignal } = {}, ): Promise { @@ -106,7 +106,8 @@ const isSubmatchContext = is.ObjectOf({ _submatch: is.ObjectOf({ pickerParams: isPickerParams, }), -}) satisfies Predicate; + // deno-lint-ignore no-explicit-any +}) satisfies Predicate>; const isSubmatchParams = is.ObjectOf({ matchers: is.ArrayOf(isMatcher) as Predicate< diff --git a/doc/fall.txt b/doc/fall.txt index fc350d9..b15a232 100644 --- a/doc/fall.txt +++ b/doc/fall.txt @@ -401,6 +401,37 @@ COMMAND *fall-command* :Fall {source} [{cmdarg}] Open picker to filter {source} (defined in "custom.ts" via |:FallCustom|). {cmdarg} is passed to the source. +> + " Open picker to filter files in the current directory + :Fall file + + " Open picker to filter files in the specified directory + :Fall file /path/to/directory +< + + *:FallResume* +:FallResume [{filter}] + Resume the previous picker. If {filter} is not specified, it resumes + the latest picker. The {filter} can be a source name and/or a number + indicating the order of the picker. If a source name is specified, it + resumes the latest picker of that source. If a number is specified, + it resumes the nth latest picker. If both a source name and a number + are specified, it resumes the nth latest picker of that source. The + number is preceded by a "#" character. If the number is not specified, + it defaults to 1 (the latest picker). +> + " Resume the latest picker + :FallResume + + " Resume the latest picker of the "file" source + :FallResume file + + " Resume the 2nd latest picker + :FallResume #2 + + " Resume the 2nd latest picker of the "file" source + :FallResume file#2 +< *:FallCustom* :FallCustom diff --git a/plugin/fall.vim b/plugin/fall.vim index d65dbd4..a9c9974 100644 --- a/plugin/fall.vim +++ b/plugin/fall.vim @@ -6,6 +6,8 @@ let s:sep = has('win32') ? '\' : '/' command! -nargs=+ -complete=customlist,fall#command#Fall#complete \ Fall call fall#command#Fall#call([]) +command! -nargs=? -complete=customlist,fall#command#FallResume#complete + \ FallResume call fall#command#FallResume#call() command! -nargs=0 FallCustom call fall#command#FallCustom#call() command! -nargs=0 FallCustomReload call fall#command#FallCustomReload#call() From b9b688123b69a68068eaf885e6b5253a69c64ff2 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sat, 5 Jul 2025 23:57:21 +0900 Subject: [PATCH 14/15] Add `fall#internal#picker` module for consistency --- autoload/fall/command/Fall.vim | 18 ++---------------- autoload/fall/command/FallResume.vim | 18 ++---------------- autoload/fall/internal/picker.vim | 12 ++++++++++++ 3 files changed, 16 insertions(+), 32 deletions(-) create mode 100644 autoload/fall/internal/picker.vim diff --git a/autoload/fall/command/Fall.vim b/autoload/fall/command/Fall.vim index d9a5d6d..7538eb4 100644 --- a/autoload/fall/command/Fall.vim +++ b/autoload/fall/command/Fall.vim @@ -2,15 +2,11 @@ function! fall#command#Fall#call(args) abort if denops#plugin#wait('fall') isnot# 0 return endif - let l:laststatus_saved = &laststatus try - call s:hide() - call fall#internal#mapping#store() + call fall#internal#picker#setup() call denops#request('fall', 'picker:command', [a:args]) finally - call s:show() - call fall#internal#tolerant#call({ -> fall#internal#mapping#restore() }) - call fall#internal#tolerant#call({ -> fall#internal#popup#closeall() }) + call fall#internal#picker#teardown() endtry endfunction @@ -20,13 +16,3 @@ function! fall#command#Fall#complete(arglead, cmdline, cursorpos) abort endif return denops#request('fall', 'picker:command:complete', [a:arglead, a:cmdline, a:cursorpos]) endfunction - -function! s:hide() abort - call fall#internal#tolerant#call({ -> fall#internal#msgarea#hide() }) - call fall#internal#tolerant#call({ -> fall#internal#cursor#hide() }) -endfunction - -function! s:show() abort - call fall#internal#tolerant#call({ -> fall#internal#msgarea#show() }) - call fall#internal#tolerant#call({ -> fall#internal#cursor#show() }) -endfunction diff --git a/autoload/fall/command/FallResume.vim b/autoload/fall/command/FallResume.vim index a35d423..01897ed 100644 --- a/autoload/fall/command/FallResume.vim +++ b/autoload/fall/command/FallResume.vim @@ -2,15 +2,11 @@ function! fall#command#FallResume#call(filter) abort if denops#plugin#wait('fall') isnot# 0 return endif - let l:laststatus_saved = &laststatus try - call s:hide() - call fall#internal#mapping#store() + call fall#internal#picker#setup() call denops#request('fall', 'picker:resume:command', [a:filter]) finally - call s:show() - call fall#internal#tolerant#call({ -> fall#internal#mapping#restore() }) - call fall#internal#tolerant#call({ -> fall#internal#popup#closeall() }) + call fall#internal#picker#teardown() endtry endfunction @@ -20,13 +16,3 @@ function! fall#command#FallResume#complete(arglead, cmdline, cursorpos) abort endif return denops#request('fall', 'picker:resume:command:complete', [a:arglead, a:cmdline, a:cursorpos]) endfunction - -function! s:hide() abort - call fall#internal#tolerant#call({ -> fall#internal#msgarea#hide() }) - call fall#internal#tolerant#call({ -> fall#internal#cursor#hide() }) -endfunction - -function! s:show() abort - call fall#internal#tolerant#call({ -> fall#internal#msgarea#show() }) - call fall#internal#tolerant#call({ -> fall#internal#cursor#show() }) -endfunction diff --git a/autoload/fall/internal/picker.vim b/autoload/fall/internal/picker.vim new file mode 100644 index 0000000..43be119 --- /dev/null +++ b/autoload/fall/internal/picker.vim @@ -0,0 +1,12 @@ +function! fall#internal#picker#setup() abort + call fall#internal#tolerant#call({ -> fall#internal#msgarea#hide() }) + call fall#internal#tolerant#call({ -> fall#internal#cursor#hide() }) + call fall#internal#mapping#store() +endfunction + +function! fall#internal#picker#teardown() abort + call fall#internal#tolerant#call({ -> fall#internal#msgarea#show() }) + call fall#internal#tolerant#call({ -> fall#internal#cursor#show() }) + call fall#internal#tolerant#call({ -> fall#internal#mapping#restore() }) + call fall#internal#tolerant#call({ -> fall#internal#popup#closeall() }) +endfunction From f9e0964fb7c5647cad057e86a91612b442a7dfa0 Mon Sep 17 00:00:00 2001 From: Alisue Date: Sun, 6 Jul 2025 00:13:59 +0900 Subject: [PATCH 15/15] Add `FallSession` command to manage picker sessions --- autoload/fall/command/FallSession.vim | 11 +++++ denops/fall/extension/action/session.ts | 16 +++++++ denops/fall/extension/previewer/session.ts | 52 ++++++++++++++++++++++ denops/fall/extension/renderer/session.ts | 23 ++++++++++ denops/fall/extension/source/session.ts | 22 +++++++++ denops/fall/main/picker.ts | 40 ++++++++++++++++- denops/fall/session.ts | 20 ++++++--- denops/fall/session_test.ts | 11 ++--- doc/fall.txt | 22 +++++++++ plugin/fall.vim | 1 + 10 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 autoload/fall/command/FallSession.vim create mode 100644 denops/fall/extension/action/session.ts create mode 100644 denops/fall/extension/previewer/session.ts create mode 100644 denops/fall/extension/renderer/session.ts create mode 100644 denops/fall/extension/source/session.ts diff --git a/autoload/fall/command/FallSession.vim b/autoload/fall/command/FallSession.vim new file mode 100644 index 0000000..43607af --- /dev/null +++ b/autoload/fall/command/FallSession.vim @@ -0,0 +1,11 @@ +function! fall#command#FallSession#call() abort + if denops#plugin#wait('fall') isnot# 0 + return + endif + try + call fall#internal#picker#setup() + call denops#request('fall', 'picker:session:command', []) + finally + call fall#internal#picker#teardown() + endtry +endfunction diff --git a/denops/fall/extension/action/session.ts b/denops/fall/extension/action/session.ts new file mode 100644 index 0000000..8737042 --- /dev/null +++ b/denops/fall/extension/action/session.ts @@ -0,0 +1,16 @@ +import type { Action } from "jsr:@vim-fall/core@^0.3.0/action"; +import type { Detail } from "../source/session.ts"; + +export const defaultSessionActions = { + resume: { + invoke: async (denops, { item }) => { + if (!item) { + return; + } + // we need to use timer_start to avoid nesting pickers + await denops.cmd( + `call timer_start(0, { -> execute('FallResume ${item.value}') })`, + ); + }, + }, +} satisfies Record>; diff --git a/denops/fall/extension/previewer/session.ts b/denops/fall/extension/previewer/session.ts new file mode 100644 index 0000000..fe8d52e --- /dev/null +++ b/denops/fall/extension/previewer/session.ts @@ -0,0 +1,52 @@ +import type { PreviewItem } from "jsr:@vim-fall/core@^0.3.0/item"; +import type { Previewer } from "jsr:@vim-fall/core@^0.3.0/previewer"; +import { definePreviewer } from "jsr:@vim-fall/std@^0.10.0/previewer"; +import type { Detail } from "../source/session.ts"; +import { decompressPickerSession } from "../../session.ts"; + +export function session(): Previewer { + return definePreviewer(async (_denops, { item }, { signal }) => { + if (!item || signal?.aborted) { + return undefined; + } + + try { + // Decompress the session to access its data + const session = await decompressPickerSession(item.detail); + + const lines: string[] = []; + + // Add session info + lines.push(`# Session: ${item.value}`); + lines.push(""); + lines.push(`Source: ${session.name}`); + lines.push(`Query: ${session.context.query || "(empty)"}`); + lines.push(`Total items: ${session.context.collectedItems.length}`); + lines.push(`Filtered items: ${session.context.filteredItems.length}`); + lines.push(""); + lines.push("## Filtered Items:"); + lines.push(""); + + // Show filtered items with selection status + const selection = session.context.selection; + for (const filteredItem of session.context.filteredItems) { + const isSelected = selection.has(filteredItem.id); + const prefix = isSelected ? "[x]" : "[ ]"; + lines.push(`${prefix} ${filteredItem.value}`); + } + + if (session.context.filteredItems.length === 0) { + lines.push("(no items)"); + } + + return { + content: lines, + filetype: "markdown", + } satisfies PreviewItem; + } catch (error) { + return { + content: [`Error loading session preview: ${error}`], + } satisfies PreviewItem; + } + }); +} diff --git a/denops/fall/extension/renderer/session.ts b/denops/fall/extension/renderer/session.ts new file mode 100644 index 0000000..52f1e3b --- /dev/null +++ b/denops/fall/extension/renderer/session.ts @@ -0,0 +1,23 @@ +import type { Denops } from "jsr:@denops/std@^7.3.2"; +import type { Renderer } from "jsr:@vim-fall/core@^0.3.0/renderer"; +import type { DisplayItem } from "jsr:@vim-fall/core@^0.3.0/item"; +import type { Detail } from "../source/session.ts"; + +export function session(): Renderer { + return { + render( + _denops: Denops, + { items }: { items: DisplayItem[] }, + { signal }: { signal?: AbortSignal }, + ): void { + for (const item of items) { + if (signal?.aborted) break; + item.label = [ + item.value, + item.detail.name, + ...item.detail.args, + ].join(" "); + } + }, + }; +} diff --git a/denops/fall/extension/source/session.ts b/denops/fall/extension/source/session.ts new file mode 100644 index 0000000..9f3d3c5 --- /dev/null +++ b/denops/fall/extension/source/session.ts @@ -0,0 +1,22 @@ +import type { Source } from "jsr:@vim-fall/core@^0.3.0/source"; +import type { DetailUnit, IdItem } from "jsr:@vim-fall/core@^0.3.0/item"; +import type { PickerSessionCompressed } from "../../session.ts"; +import { listPickerSessions } from "../../session.ts"; + +export type Detail = PickerSessionCompressed; + +export function session(): Source { + return { + collect: async function* (): AsyncIterableIterator> { + const sessions = listPickerSessions(); + yield* sessions.map((session, index) => { + const number = index + 1; + return { + id: index, + value: `#${number}`, + detail: session, + }; + }); + }, + }; +} diff --git a/denops/fall/main/picker.ts b/denops/fall/main/picker.ts index f67277f..278786a 100644 --- a/denops/fall/main/picker.ts +++ b/denops/fall/main/picker.ts @@ -21,9 +21,19 @@ import { loadPickerSession, savePickerSession, } from "../session.ts"; +import type { Detail as SessionDetail } from "../extension/source/session.ts"; +import { session as sessionSource } from "../extension/source/session.ts"; +import { session as sessionRenderer } from "../extension/renderer/session.ts"; +import { session as sessionPreviewer } from "../extension/previewer/session.ts"; +import { defaultSessionActions } from "../extension/action/session.ts"; let zindex = 50; +const SESSION_EXCLUDE_SOURCES = [ + "@action", + "@session", +]; + export const main: Entrypoint = (denops) => { denops.dispatcher = { ...denops.dispatcher, @@ -74,6 +84,30 @@ export const main: Entrypoint = (denops) => { }); }, ), + "picker:session:command": withHandleError(denops, async () => { + await loadUserCustom(denops); + const { substring } = await import( + "jsr:@vim-fall/std@^0.10.0/builtin/matcher/substring" + ); + const setting = getSetting(); + const sessionPickerParams = { + name: "@session", + source: sessionSource(), + matchers: [substring()] as const, + sorters: [], + renderers: [sessionRenderer()], + previewers: [sessionPreviewer()], + actions: defaultSessionActions, + defaultAction: "resume", + ...setting, + } as PickerParams; + await startPicker( + denops, + [], + sessionPickerParams, + { signal: denops.interrupted }, + ); + }), "picker:resume:command:complete": withHandleError( denops, async (arglead, cmdline, cursorpos) => { @@ -179,8 +213,12 @@ async function startPicker( zindex -= Picker.ZINDEX_ALLOCATION; }); stack.defer(async () => { + const name = pickerParams.name; + if (SESSION_EXCLUDE_SOURCES.includes(name)) { + return; + } await savePickerSession({ - name: pickerParams.name, + name, args, context: itemPicker.context, }); diff --git a/denops/fall/session.ts b/denops/fall/session.ts index 28bcd83..0cdb0b6 100644 --- a/denops/fall/session.ts +++ b/denops/fall/session.ts @@ -51,10 +51,15 @@ async function compressPickerSession( session: PickerSession, ): Promise> { const encoder = new TextEncoder(); + // Convert Set to Array for JSON serialization + const contextForSerialization = { + ...session.context, + selection: Array.from(session.context.selection), + }; return { ...session, context: await brotli.compress( - encoder.encode(JSON.stringify(session.context)), + encoder.encode(JSON.stringify(contextForSerialization)), ), }; } @@ -65,15 +70,20 @@ async function compressPickerSession( * @param compressed - The compressed session to decompress * @returns A promise that resolves to the decompressed session */ -async function decompressPickerSession( +export async function decompressPickerSession( compressed: PickerSessionCompressed, ): Promise> { const decoder = new TextDecoder(); + const decompressedContext = JSON.parse( + decoder.decode(await brotli.uncompress(compressed.context)), + ); + // Convert selection array back to Set return { ...compressed, - context: JSON.parse( - decoder.decode(await brotli.uncompress(compressed.context)), - ), + context: { + ...decompressedContext, + selection: new Set(decompressedContext.selection), + }, }; } diff --git a/denops/fall/session_test.ts b/denops/fall/session_test.ts index b8f1b98..5634c7e 100644 --- a/denops/fall/session_test.ts +++ b/denops/fall/session_test.ts @@ -203,10 +203,8 @@ Deno.test("session management", async (t) => { assertEquals(loadedSession.context.previewerIndex, 4); assertEquals(loadedSession.context.collectedItems, testItems); assertEquals(loadedSession.context.filteredItems, testItems); - // Check that selection was preserved (Sets are converted to empty objects in JSON) - // The selection will be an empty object after deserialization since Set is not JSON serializable - assertEquals(typeof loadedSession.context.selection, "object"); - assertEquals(loadedSession.context.selection, {} as unknown); + // Check that selection was preserved as a Set + assertEquals(loadedSession.context.selection, new Set(["test-1"])); }, ); @@ -251,9 +249,8 @@ Deno.test("session management", async (t) => { assertExists(loaded); assertEquals(loaded.context.query, ""); - // Selection will be deserialized as an empty object, not a Set - assertEquals(typeof loaded.context.selection, "object"); - assertEquals(loaded.context.selection, {} as unknown); + // Selection will be deserialized as an empty Set + assertEquals(loaded.context.selection, new Set()); assertEquals(loaded.context.collectedItems.length, 0); assertEquals(loaded.context.filteredItems.length, 0); assertEquals(loaded.context.cursor, 0); diff --git a/doc/fall.txt b/doc/fall.txt index b15a232..4db4a0e 100644 --- a/doc/fall.txt +++ b/doc/fall.txt @@ -433,6 +433,28 @@ COMMAND *fall-command* :FallResume file#2 < + *:FallSession* +:FallSession + Open a picker to browse and resume previous picker sessions. Fall + automatically stores up to 100 picker sessions in memory, allowing + you to return to previous searches with their complete state including + query, filtered items, and selections. + + Sessions are listed in reverse chronological order (newest first) and + display the session number, source name, and any arguments used. + Selecting a session resumes it with its exact state. + + The following sources are excluded from session storage: + - "@action" (action selection picker) + - "@session" (the session picker itself) + + Sessions are stored in compressed format to minimize memory usage. +> + " Open the session picker + :FallSession +< + See |:FallResume| for directly resuming sessions by number or source. + *:FallCustom* :FallCustom Open "custom.ts" for customization. This TypeScript file exports a diff --git a/plugin/fall.vim b/plugin/fall.vim index a9c9974..515d07e 100644 --- a/plugin/fall.vim +++ b/plugin/fall.vim @@ -8,6 +8,7 @@ command! -nargs=+ -complete=customlist,fall#command#Fall#complete \ Fall call fall#command#Fall#call([]) command! -nargs=? -complete=customlist,fall#command#FallResume#complete \ FallResume call fall#command#FallResume#call() +command! -nargs=0 FallSession call fall#command#FallSession#call() command! -nargs=0 FallCustom call fall#command#FallCustom#call() command! -nargs=0 FallCustomReload call fall#command#FallCustomReload#call()