diff --git a/denops/ddu/app.ts b/denops/ddu/app.ts index 5e348f4..dffbacc 100644 --- a/denops/ddu/app.ts +++ b/denops/ddu/app.ts @@ -63,8 +63,7 @@ export const main: Entrypoint = (denops: Denops) => { actions: [], }; const lock = new Lock(0); - let queuedName: string | null = null; - let queuedRedrawOption: RedrawOption | null = null; + const uiRedrawLock = new Lock(0); const checkDdu = (name: string) => { if (!ddus[name]) { @@ -82,7 +81,7 @@ export const main: Entrypoint = (denops: Denops) => { }; const getDdu = (name: string) => { if (!checkDdu(name)) { - ddus[name].push(new Ddu(getLoader(name))); + ddus[name].push(new Ddu(getLoader(name), uiRedrawLock)); } return ddus[name].slice(-1)[0]; @@ -90,7 +89,7 @@ export const main: Entrypoint = (denops: Denops) => { const pushDdu = (name: string) => { checkDdu(name); - ddus[name].push(new Ddu(getLoader(name))); + ddus[name].push(new Ddu(getLoader(name), uiRedrawLock)); return ddus[name].slice(-1)[0]; }; @@ -358,57 +357,49 @@ export const main: Entrypoint = (denops: Denops) => { return items; }, async redraw(arg1: unknown, arg2: unknown): Promise { - queuedName = ensure(arg1, is.String) as string; - queuedRedrawOption = ensure(arg2, is.Record) as RedrawOption; + const name = ensure(arg1, is.String) as string; + const opt = ensure(arg2, is.Record) as RedrawOption; - // NOTE: must be locked - await lock.lock(async () => { - while (queuedName !== null) { - const name = queuedName; - const opt = queuedRedrawOption; - queuedName = null; - queuedRedrawOption = null; - - const ddu = getDdu(name); - const loader = getLoader(name); - - if (opt?.check && !(await ddu.checkUpdated(denops))) { - // Mtime check failed - continue; - } + const ddu = getDdu(name); + const loader = getLoader(name); + let signal = ddu.cancelled; - if (opt?.input !== undefined) { - await ddu.setInput(denops, opt.input); - } + if (opt?.check && !(await ddu.checkUpdated(denops))) { + // Mtime check failed + return; + } - // Check volatile sources - const volatiles = ddu.getSourceArgs().map( - (sourceArgs, index) => sourceArgs[0].volatile ? index : -1, - ).filter((index) => index >= 0); + if (opt?.input !== undefined) { + await ddu.setInput(denops, opt.input); + } - if (volatiles.length > 0 || opt?.method === "refreshItems") { - await ddu.refresh( - denops, - opt?.method === "refreshItems" ? [] : volatiles, - ); - } else if (opt?.method === "uiRedraw") { - await ddu.uiRedraw(denops); - } else { - await ddu.redraw(denops); - } - await ddu.restoreTree(denops); - - if (opt?.searchItem) { - await uiSearchItem( - denops, - loader, - ddu.getContext(), - ddu.getOptions(), - opt.searchItem, - ); - } + if (opt?.method === "refreshItems") { + signal = await ddu.refresh(denops, [], { restoreTree: true }); + } else { + // Check volatile sources + const volatiles = ddu.getSourceArgs().map( + (sourceArgs, index) => sourceArgs[0].volatile ? index : -1, + ).filter((index) => index >= 0); + + if (volatiles.length > 0) { + signal = await ddu.refresh(denops, volatiles, { restoreTree: true }); + } else if (opt?.method === "uiRedraw") { + await ddu.restoreTree(denops, { preventRedraw: true, signal }); + await ddu.uiRedraw(denops, { signal }); + } else { + await ddu.redraw(denops, { restoreTree: true, signal }); } - }); + } + + if (opt?.searchItem && !signal.aborted) { + await uiSearchItem( + denops, + loader, + ddu.getContext(), + ddu.getOptions(), + opt.searchItem, + ); + } }, async redrawTree( arg1: unknown, diff --git a/denops/ddu/ddu.ts b/denops/ddu/ddu.ts index ebc6819..cb74ade 100644 --- a/denops/ddu/ddu.ts +++ b/denops/ddu/ddu.ts @@ -25,9 +25,17 @@ import { defaultSourceOptions } from "./base/source.ts"; import type { BaseSource } from "./base/source.ts"; import type { Loader } from "./loader.ts"; import { convertUserString, printError, treePath2Filename } from "./utils.ts"; -import type { AvailableSourceInfo, GatherStateAbortable } from "./state.ts"; -import { GatherState } from "./state.ts"; -import { isRefreshTarget } from "./state.ts"; +import type { + AvailableSourceInfo, + GatherStateAbortable, + GatherStateAbortReason, +} from "./state.ts"; +import { + GatherState, + isRefreshTarget, + QuitAbortReason, + RefreshAbortReason, +} from "./state.ts"; import { callColumns, callFilters, @@ -51,7 +59,7 @@ import * as fn from "jsr:@denops/std@~7.4.0/function"; import { assertEquals } from "jsr:@std/assert@~1.0.2/equals"; import { equal } from "jsr:@std/assert@~1.0.2/equal"; import { basename } from "jsr:@std/path@~1.0.2/basename"; -import { Lock } from "jsr:@core/asyncutil@~1.2.0/lock"; +import type { Lock } from "jsr:@core/asyncutil@~1.2.0/lock"; import { SEPARATOR as pathsep } from "jsr:@std/path@~1.0.2/constants"; type RedrawOptions = { @@ -60,9 +68,12 @@ type RedrawOptions = { * item's states reset to gathered. */ restoreItemState?: boolean; + restoreTree?: boolean; signal?: AbortSignal; }; +type RefreshOptions = Omit; + export class Ddu { #loader: Loader; readonly #gatherStates = new Map(); @@ -76,7 +87,8 @@ export class Ddu { #aborter = new AbortController() as & Omit & GatherStateAbortable; - readonly #uiRedrawLock = new Lock(0); + readonly #uiRedrawLock: Lock; + #waitCancelComplete = Promise.resolve(); #waitRedrawComplete?: Promise; #scheduledRedrawOptions?: Required; #startTime = 0; @@ -84,8 +96,13 @@ export class Ddu { #items: DduItem[] = []; readonly #expandedItems: Map = new Map(); - constructor(loader: Loader) { + constructor(loader: Loader, uiRedrawLock: Lock) { this.#loader = loader; + this.#uiRedrawLock = uiRedrawLock; + } + + get cancelled(): AbortSignal { + return this.#aborter.signal; } async start( @@ -93,7 +110,7 @@ export class Ddu { context: Context, options: DduOptions, userOptions: UserOptions, - ): Promise { + ): Promise { const prevContext = { ...this.#context }; const { signal: prevSignal } = this.#aborter; @@ -179,10 +196,7 @@ export class Ddu { // UI Redraw only // NOTE: Enable done to redraw UI properly this.#context.done = true; - await this.uiRedraw( - denops, - { signal: this.#aborter.signal }, - ); + await this.uiRedraw(denops); this.#context.doneUi = true; return; } @@ -234,7 +248,7 @@ export class Ddu { .#createAvailableSourceStream(denops, { initialize: true }) .tee(); const [gatherStates] = availableSources - .pipeThrough(this.#createGatherStateTransformer(denops, signal)) + .pipeThrough(this.#createGatherStateTransformer(denops)) .tee(); // Wait until initialized all sources. Source onInit() must be called before UI. @@ -278,26 +292,30 @@ export class Ddu { async refresh( denops: Denops, refreshIndexes: number[] = [], - ): Promise { + opts?: RefreshOptions, + ): Promise { this.#startTime = Date.now(); this.#context.done = false; await this.cancelToRefresh(refreshIndexes); + this.#resetAborter(); // NOTE: Get the signal after the aborter is reset. const { signal } = this.#aborter; // Initialize UI window if (this.#checkSync()) { - /* no await */ this.redraw(denops); + /* no await */ this.redraw(denops, { ...opts, signal }); } const [gatherStates] = this .#createAvailableSourceStream(denops, { indexes: refreshIndexes }) - .pipeThrough(this.#createGatherStateTransformer(denops, signal)) + .pipeThrough(this.#createGatherStateTransformer(denops)) .tee(); - await this.#refreshSources(denops, gatherStates); + await this.#refreshSources(denops, gatherStates, { ...opts, signal }); + + return signal; } #createAvailableSourceStream( @@ -354,7 +372,6 @@ export class Ddu { #createGatherStateTransformer( denops: Denops, - signal: AbortSignal, ): TransformStream { return new TransformStream({ transform: (sourceInfo, controller) => { @@ -367,7 +384,6 @@ export class Ddu { sourceOptions, sourceParams, 0, - { signal }, ); this.#gatherStates.set(sourceIndex, state); @@ -379,9 +395,9 @@ export class Ddu { async #refreshSources( denops: Denops, gatherStates: ReadableStream, - opts?: { signal?: AbortSignal }, + opts?: RedrawOptions, ): Promise { - const { signal = this.#aborter.signal } = opts ?? {}; + const redrawOpts = { signal: this.#aborter.signal, ...opts }; const refreshErrorHandler = new AbortController(); const refreshedSources: Promise[] = []; @@ -389,7 +405,7 @@ export class Ddu { new WritableStream({ write: (state) => { refreshedSources.push( - this.#refreshItems(denops, state).catch((e) => { + this.#refreshItems(denops, state, redrawOpts).catch((e) => { refreshErrorHandler.abort(e); }), ); @@ -401,15 +417,21 @@ export class Ddu { { signal: refreshErrorHandler.signal }, ); - if (!this.#context.done) { - await this.redraw(denops, { signal }); + if (redrawOpts.signal.aborted) { + // Redraw is aborted, so do nothing + } else if (!this.#context.done) { + await this.redraw(denops, redrawOpts); } else { await this.#waitRedrawComplete; } } - async #refreshItems(denops: Denops, state: GatherState): Promise { - const { sourceInfo: { sourceOptions }, itemsStream, signal } = state; + async #refreshItems( + denops: Denops, + state: GatherState, + opts?: { signal?: AbortSignal }, + ): Promise { + const { sourceInfo: { sourceOptions }, itemsStream } = state; await callOnRefreshItemsHooks( denops, @@ -432,7 +454,7 @@ export class Ddu { } if (this.#checkSync() && newItems.length > 0) { - /* no await */ this.redraw(denops, { signal }); + /* no await */ this.redraw(denops, opts); } } } @@ -484,10 +506,9 @@ export class Ddu { itemLevel: number, opts?: { parent?: DduItem; - signal?: AbortSignal; }, ): GatherState { - const { parent, signal = this.#aborter.signal } = opts ?? {}; + const { parent } = opts ?? {}; const itemTransformer = new TransformStream({ transform: (chunk, controller) => { @@ -512,7 +533,6 @@ export class Ddu { sourceParams, }, itemTransformer.readable, - { signal }, ); // Process from stream generation to termination. @@ -532,7 +552,7 @@ export class Ddu { // Wait until the stream closes. await itemsStream.pipeTo(itemTransformer.writable); } catch (e: unknown) { - if (state.signal.aborted && e === state.signal.reason) { + if (state.cancelled.aborted && e === state.cancelled.reason) { // Aborted by signal, so do nothing. } else { // Show error message @@ -547,9 +567,10 @@ export class Ddu { redraw( denops: Denops, opts?: RedrawOptions, - ): Promise { + ): Promise { const newOpts = { restoreItemState: false, + restoreTree: false, signal: this.#aborter.signal, ...opts, }; @@ -558,12 +579,10 @@ export class Ddu { // Already redrawing, so adding to schedule const prevOpts: RedrawOptions = this.#scheduledRedrawOptions ?? {}; this.#scheduledRedrawOptions = { + ...newOpts, // Override with true restoreItemState: prevOpts.restoreItemState || newOpts.restoreItemState, - // Merge all signals - signal: prevOpts.signal && newOpts.signal !== prevOpts.signal - ? AbortSignal.any([newOpts.signal, prevOpts.signal]) - : prevOpts.signal ?? newOpts.signal, + restoreTree: prevOpts.restoreTree || newOpts.restoreTree, }; } else { // Start redraw @@ -592,14 +611,13 @@ export class Ddu { async #redrawInternal( denops: Denops, - { restoreItemState, signal }: Required, + { restoreItemState, restoreTree, signal }: Required, ): Promise { if (signal.aborted) { return; } - // Update current input - await this.setInput(denops, this.#input); + // Update current context this.#context.doneUi = false; this.#context.maxItems = 0; @@ -753,6 +771,10 @@ export class Ddu { } })); + if (restoreTree) { + this.restoreTree(denops, { preventRedraw: true, signal }); + } + if (this.#context.done && this.#options.profile) { await printError( denops, @@ -848,7 +870,9 @@ export class Ddu { quit() { // NOTE: quitted flag must be called after ui.quit(). this.#quitted = true; - this.#aborter.abort({ reason: "quit" }); + const reason = new QuitAbortReason(); + this.#aborter.abort(reason); + /* no await */ this.#cancelGatherStates([], reason); this.#context.done = true; } @@ -860,27 +884,32 @@ export class Ddu { async cancelToRefresh( refreshIndexes: number[] = [], ): Promise { - this.#aborter.abort({ reason: "cancelToRefresh", refreshIndexes }); - - await Promise.all( - [...this.#gatherStates] - .map(([sourceIndex, state]) => { - if (isRefreshTarget(sourceIndex, refreshIndexes)) { - this.#gatherStates.delete(sourceIndex); - return state.waitDone; - } - }), - ); + const reason = new RefreshAbortReason(refreshIndexes); + this.#aborter.abort(reason); + await this.#cancelGatherStates(refreshIndexes, reason); + } - this.#resetAborter(); + #cancelGatherStates( + sourceIndexes: number[], + reason: GatherStateAbortReason, + ): Promise { + const promises = [...this.#gatherStates] + .filter(([sourceIndex]) => isRefreshTarget(sourceIndex, sourceIndexes)) + .map(([sourceIndex, state]) => { + this.#gatherStates.delete(sourceIndex); + state.cancel(reason); + return state.waitDone; + }); + this.#waitCancelComplete = Promise.all([ + this.#waitCancelComplete, + ...promises, + ]).then(() => {}); + return this.#waitCancelComplete; } #resetAborter() { if (!this.#quitted && this.#aborter.signal.aborted) { this.#aborter = new AbortController(); - for (const state of this.#gatherStates.values()) { - state.resetSignal(this.#aborter.signal); - } } } @@ -1057,12 +1086,10 @@ export class Ddu { // Restore quitted flag before refresh and redraw this.#resetQuitted(); - await this.refresh(denops); - - if (searchPath.length <= 0) { + await this.refresh(denops, [], { // NOTE: If searchPath exists, expandItems() is executed. - await this.restoreTree(denops); - } + restoreTree: searchPath.length <= 0, + }); } else if (uiOptions.persist || flags & ActionFlags.Persist) { // Restore quitted flag before refresh and redraw this.#resetQuitted(); @@ -1103,9 +1130,12 @@ export class Ddu { async expandItems( denops: Denops, items: ExpandItem[], - opts?: { signal?: AbortSignal }, + opts?: { + preventRedraw?: boolean; + signal?: AbortSignal; + }, ): Promise { - const { signal = this.#aborter.signal } = opts ?? {}; + const { preventRedraw, signal = this.#aborter.signal } = opts ?? {}; for (const item of items.sort((a, b) => a.item.__level - b.item.__level)) { const maxLevel = item.maxLevel && item.maxLevel < 0 ? -1 @@ -1124,7 +1154,9 @@ export class Ddu { ); } - await this.uiRedraw(denops, { signal }); + if (!preventRedraw && !signal.aborted) { + await this.uiRedraw(denops, { signal }); + } } async expandItem( @@ -1194,7 +1226,7 @@ export class Ddu { sourceOptions, sourceParams, parent.__level + 1, - { parent, signal }, + { parent }, ); await state.readAll(); @@ -1719,6 +1751,10 @@ export class Ddu { async restoreTree( denops: Denops, + opts?: { + preventRedraw?: boolean; + signal?: AbortSignal; + }, ): Promise { // NOTE: Check expandedItems are exists in this.#items const checkItems: Map = new Map(); @@ -1734,7 +1770,7 @@ export class Ddu { return; } - await this.expandItems(denops, restoreItems); + await this.expandItems(denops, restoreItems, opts); } async #filterItems( diff --git a/denops/ddu/ext.ts b/denops/ddu/ext.ts index 5ba0e58..e551083 100644 --- a/denops/ddu/ext.ts +++ b/denops/ddu/ext.ts @@ -47,6 +47,7 @@ import type { BaseKind } from "./base/kind.ts"; import type { BaseSource } from "./base/source.ts"; import type { BaseUi } from "./base/ui.ts"; import type { Loader } from "./loader.ts"; +import type { BaseAbortReason } from "./state.ts"; import { convertUserString, printError } from "./utils.ts"; import type { Denops } from "jsr:@denops/std@~7.4.0"; @@ -925,7 +926,7 @@ export async function uiRedraw< } try { - if (signal.aborted) { + if ((signal.reason as BaseAbortReason)?.type === "quit") { await ui.quit({ denops, context, @@ -953,7 +954,7 @@ export async function uiRedraw< }); // NOTE: ddu may be quitted after redraw - if (signal.aborted) { + if ((signal.reason as BaseAbortReason)?.type === "quit") { await ui.quit({ denops, context, diff --git a/denops/ddu/state.ts b/denops/ddu/state.ts index 0538e50..ba84f6d 100644 --- a/denops/ddu/state.ts +++ b/denops/ddu/state.ts @@ -1,9 +1,6 @@ import type { BaseParams, DduItem, SourceOptions } from "./types.ts"; import type { BaseSource } from "./base/source.ts"; -import { is } from "jsr:@core/unknownutil@~4.3.0/is"; -import { maybe } from "jsr:@core/unknownutil@~4.3.0/maybe"; - export type AvailableSourceInfo< Params extends BaseParams = BaseParams, UserData extends unknown = unknown, @@ -14,14 +11,29 @@ export type AvailableSourceInfo< sourceParams: Params; }; -type GatherStateAbortReason = - | { - reason: "quit"; +export type BaseAbortReason = { + readonly type: string; +}; + +export class QuitAbortReason extends Error implements BaseAbortReason { + override name = "QuitAbortReason"; + readonly type = "quit"; +} + +export class RefreshAbortReason extends Error implements BaseAbortReason { + override name = "RefreshAbortReason"; + readonly type = "cancelToRefresh"; + readonly refreshIndexes: readonly number[]; + + constructor(refreshIndexes: number[] = []) { + super(); + this.refreshIndexes = refreshIndexes; } - | { - reason: "cancelToRefresh"; - refreshIndexes: number[]; - }; +} + +export type GatherStateAbortReason = + | QuitAbortReason + | RefreshAbortReason; export type GatherStateAbortable = { abort(reason: GatherStateAbortReason): void; @@ -37,60 +49,15 @@ export class GatherState< #isDone = false; readonly #waitDone = Promise.withResolvers(); readonly #aborter = new AbortController(); - #resetParentSignal?: AbortController; constructor( sourceInfo: AvailableSourceInfo, itemsStream: ReadableStream, - options?: { - signal?: AbortSignal; - }, ) { - const { signal: parentSignal } = options ?? {}; this.sourceInfo = sourceInfo; - this.#chainAbortSignal(parentSignal); this.itemsStream = this.#processItemsStream(itemsStream); } - resetSignal(signal?: AbortSignal): void { - // Do nothing if already aborted. - if (!this.#aborter.signal.aborted) { - this.#chainAbortSignal(signal); - } - } - - #chainAbortSignal(parentSignal?: AbortSignal): void { - this.#resetParentSignal?.abort(); - if (parentSignal == null) { - return; - } - - const abortIfTarget = () => { - const reason = maybe( - parentSignal.reason, - is.ObjectOf({ reason: is.String }), - ) as GatherStateAbortReason | undefined; - if ( - reason?.reason !== "cancelToRefresh" || - isRefreshTarget(this.sourceInfo.sourceIndex, reason.refreshIndexes) - ) { - this.#aborter.abort(parentSignal.reason); - } - }; - - if (parentSignal.aborted) { - abortIfTarget(); - } else { - this.#resetParentSignal = new AbortController(); - parentSignal.addEventListener("abort", () => abortIfTarget(), { - signal: AbortSignal.any([ - this.#aborter.signal, - this.#resetParentSignal.signal, - ]), - }); - } - } - #processItemsStream( itemsStream: ReadableStream, ): ReadableStream { @@ -135,10 +102,14 @@ export class GatherState< return this.#waitDone.promise; } - get signal(): AbortSignal { + get cancelled(): AbortSignal { return this.#aborter.signal; } + cancel(reason?: unknown): void { + this.#aborter.abort(reason); + } + async readAll(): Promise { if (this.itemsStream != null) { await Array.fromAsync(this.itemsStream);