From 71ecd0b37885ae9b040d3de1361ce071386eecd3 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Tue, 8 Sep 2026 11:39:00 +0200 Subject: [PATCH 1/2] actionWidget: fix: resume cancelled IME filter requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e4e70d7c-fbe2-487b-a6c5-177af2abae2f --- .../actionWidget/browser/actionList.ts | 5 ++- .../test/browser/actionList.test.ts | 36 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 5aae13df8f207b..b58fa247bff55c 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1078,9 +1078,8 @@ export class ActionListWidget extends Disposable { // Filter once the composition commits instead. const onFilterValueChanged = () => { const value = this._filterInput!.value; - // `compositionend` and the `input` event that follows it both land here (and browsers - // disagree on their order), so only filter when the text actually changed. - if (this._imeSessionInProgress || value === this._filterText) { + // Restart cancelled compositions without duplicating the trailing input event's live request. + if (this._imeSessionInProgress || value === this._filterText && !this._filterCts.value?.token.isCancellationRequested) { return; } this._filterText = value; diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 54058f21c11d94..27d72bb63abd2b 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -866,6 +866,42 @@ suite('ActionListWidget', () => { }, { typed: [], prevented: [] }); }); + test('restarts a cancelled dynamic filter when IME composition leaves the query unchanged', async () => { + const first = new DeferredPromise[]>(); + const second = new DeferredPromise[]>(); + const filters: string[] = []; + const tokens: CancellationToken[] = []; + const widget = createActionListWidget(disposables, { + onFilter: (filter, token) => { + filters.push(filter); + tokens.push(token); + return filters.length === 1 ? first.p : second.p; + }, + }); + + typeFilter(widget, 'release'); + widget.filterInput!.dispatchEvent(new Event('compositionstart')); + typeFilter(widget, 'releases'); + widget.filterInput!.value = 'release'; + widget.filterInput!.dispatchEvent(new Event('compositionend')); + typeFilter(widget, 'release'); + await first.complete([action('release-stale-result')]); + await second.complete([action('release-fresh-result')]); + await timeout(0); + + assert.deepStrictEqual({ + filters, + cancelled: tokens.map(token => token.isCancellationRequested), + staleVisible: widget.domNode.textContent?.includes('release-stale-result'), + freshVisible: widget.domNode.textContent?.includes('release-fresh-result'), + }, { + filters: ['release', 'release'], + cancelled: [true, false], + staleVisible: false, + freshVisible: true, + }); + }); + test('batches row width writes before reading layout', () => { const widget = createActionListWidget(disposables, { items: [ From 66fb14d02a916676638641c6e0c21749253487a6 Mon Sep 17 00:00:00 2001 From: ulugbekna Date: Thu, 10 Sep 2026 17:59:31 +0200 Subject: [PATCH 2/2] actionWidget: fix: retry only interrupted IME searches Release settled filter cancellation state without disturbing newer requests. Preserve retries for interrupted composition while avoiding repeat searches after completed success or failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e4e70d7c-fbe2-487b-a6c5-177af2abae2f --- .../actionWidget/browser/actionList.ts | 7 +- .../test/browser/actionList.test.ts | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index b58fa247bff55c..885223a339e632 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -1274,7 +1274,12 @@ export class ActionListWidget extends Disposable { } this._allMenuItems = [...items]; this._applyFilter(true); - }).catch(() => { /* best-effort */ }); + }).catch(() => { /* best-effort */ }).finally(() => { + // Retain cancelled requests so an interrupted IME search can restart with unchanged text. + if (this._filterCts.value === cts && !cts.token.isCancellationRequested) { + this._filterCts.clear(); + } + }); } private _applyFilter(skipTextFilter = false, fireLayout = true, focusItemId?: string): void { diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 27d72bb63abd2b..494d148fee122e 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -902,6 +902,70 @@ suite('ActionListWidget', () => { }); }); + for (const outcome of ['completed', 'failed'] as const) { + test(`does not retry a ${outcome} dynamic filter when IME composition leaves the query unchanged`, async () => { + const result = new DeferredPromise[]>(); + const filters: string[] = []; + const tokens: CancellationToken[] = []; + const widget = createActionListWidget(disposables, { + onFilter: (filter, token) => { + filters.push(filter); + tokens.push(token); + return result.p; + }, + }); + typeFilter(widget, 'release'); + if (outcome === 'completed') { + await result.complete([action('release-result')]); + } else { + await result.error(new Error('Search failed')); + } + await timeout(0); + const rows = getVisibleRowText(widget); + widget.filterInput!.dispatchEvent(new Event('compositionstart')); + typeFilter(widget, 'releases'); + widget.filterInput!.value = 'release'; + widget.filterInput!.dispatchEvent(new Event('compositionend')); + typeFilter(widget, 'release'); + await timeout(0); + + assert.deepStrictEqual({ + filters, + cancelled: tokens.map(token => token.isCancellationRequested), + rows: getVisibleRowText(widget), + }, { filters: ['release'], cancelled: [false], rows }); + }); + } + + test('settling a superseded dynamic filter preserves IME retry for the pending request', async () => { + const first = new DeferredPromise[]>(); + const second = new DeferredPromise[]>(); + const retry = new DeferredPromise[]>(); + const filters: string[] = []; + const widget = createActionListWidget(disposables, { + onFilter: filter => { + filters.push(filter); + return filters.length === 1 ? first.p : filters.length === 2 ? second.p : retry.p; + }, + }); + typeFilter(widget, 'old'); + typeFilter(widget, 'release'); + await first.complete([action('old-result')]); + await timeout(0); + widget.filterInput!.dispatchEvent(new Event('compositionstart')); + widget.filterInput!.dispatchEvent(new Event('compositionend')); + typeFilter(widget, 'release'); + await second.complete([action('release-stale-result')]); + await retry.complete([action('release-fresh-result')]); + await timeout(0); + + assert.deepStrictEqual({ + filters, + staleVisible: widget.domNode.textContent?.includes('release-stale-result'), + freshVisible: widget.domNode.textContent?.includes('release-fresh-result'), + }, { filters: ['old', 'release', 'release'], staleVisible: false, freshVisible: true }); + }); + test('batches row width writes before reading layout', () => { const widget = createActionListWidget(disposables, { items: [