From f26301a9fb5996c4686ea9b701840566673154e4 Mon Sep 17 00:00:00 2001 From: Whitewater Date: Sat, 19 Sep 2026 18:48:05 +0800 Subject: [PATCH] fix(editor): improve code language search and prevent stale highlighting (#15593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Search language IDs, display labels, and aliases case-insensitively, preserving prefix matching and the existing result order. - Ignore pending grammar-load results when the selected language has changed, preventing highlighting from returning after switching to Plain Text. Related to #15492: display-label matching will also make “Plain Text” searchable by its full name, while the highlighting fix prevents pending language loads from restoring highlighting after switching to Plain Text. ## Validation Code-block E2E suite (7 passed), scoped TypeScript checks, lint, and formatting checks passed. Both focused regression tests fail on unmodified `canary` and pass with the fixes. ## Summary by CodeRabbit - **New Features** - Code block language search now matches display names, IDs, aliases, and labels without regard to letter case. - Search results prioritize matches at the beginning of language names and aliases over label-only matches. - **Bug Fixes** - Prevented outdated syntax highlighting from appearing after a code block language is changed or cleared while loading. - Prevented duplicate language-loading requests during concurrent highlighting updates. --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> Co-authored-by: DarkSky --- .../blocks/code/src/code-block-service.ts | 16 ++++ .../affine/blocks/code/src/code-block.ts | 8 +- .../components/src/filterable-list/index.ts | 24 ++++-- .../e2e/blocksuite/code/crud.spec.ts | 20 ----- tests/blocksuite/e2e/code/crud.spec.ts | 76 ++++++++++++++++++- 5 files changed, 113 insertions(+), 31 deletions(-) diff --git a/blocksuite/affine/blocks/code/src/code-block-service.ts b/blocksuite/affine/blocks/code/src/code-block-service.ts index d348b14ccb..5ac5429be5 100644 --- a/blocksuite/affine/blocks/code/src/code-block-service.ts +++ b/blocksuite/affine/blocks/code/src/code-block-service.ts @@ -6,6 +6,7 @@ import { createHighlighterCore, createOnigurumaEngine, type HighlighterCore, + type LanguageInput, type MaybeGetter, } from 'shiki'; import getWasm from 'shiki/wasm'; @@ -22,6 +23,7 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { // Singleton highlighter instance private static _sharedHighlighter: HighlighterCore | null = null; private static _highlighterPromise: Promise | null = null; + private static readonly _languagePromises = new Map>(); private static _refCount = 0; private _darkThemeKey: string | undefined; @@ -36,6 +38,20 @@ export class CodeBlockHighlighter extends LifeCycleWatcher { : this._lightThemeKey; } + loadLanguage(lang: string, input: LanguageInput) { + const highlighter = this.highlighter$.value; + if (!highlighter) return Promise.resolve(); + + let promise = CodeBlockHighlighter._languagePromises.get(lang); + if (!promise) { + promise = highlighter.loadLanguage(input).finally(() => { + CodeBlockHighlighter._languagePromises.delete(lang); + }); + CodeBlockHighlighter._languagePromises.set(lang, promise); + } + return promise; + } + private readonly _loadTheme = async ( highlighter: HighlighterCore ): Promise => { diff --git a/blocksuite/affine/blocks/code/src/code-block.ts b/blocksuite/affine/blocks/code/src/code-block.ts index ed8be4e45f..0535a891a9 100644 --- a/blocksuite/affine/blocks/code/src/code-block.ts +++ b/blocksuite/affine/blocks/code/src/code-block.ts @@ -40,6 +40,8 @@ import { codeBlockStyles } from './styles.js'; export class CodeBlockComponent extends CaptionedBlockComponent { static override styles = codeBlockStyles; + private _highlightRequestId = 0; + private _inlineRangeProvider: InlineRangeProvider | null = null; private readonly _localPreview$ = signal(null); @@ -118,6 +120,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent } private _updateHighlightTokens() { + const requestId = ++this._highlightRequestId; const modelLang = this.model.props.language$.value; if (modelLang === null) { this.highlightTokens$.value = []; @@ -148,9 +151,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent const loadedLanguages = highlighter.getLoadedLanguages(); if (!loadedLanguages.includes(lang)) { - highlighter - .loadLanguage(langImport) + this.highlighter + .loadLanguage(lang, langImport) .then(() => { + if (requestId !== this._highlightRequestId) return; this.highlightTokens$.value = highlighter.codeToTokensBase(code, { lang, theme, diff --git a/blocksuite/affine/components/src/filterable-list/index.ts b/blocksuite/affine/components/src/filterable-list/index.ts index ba77b74774..aebb865fc6 100644 --- a/blocksuite/affine/components/src/filterable-list/index.ts +++ b/blocksuite/affine/components/src/filterable-list/index.ts @@ -43,15 +43,20 @@ export class FilterableListComponent extends WithDisposable( } private _filterItems() { - const searchFilter = !this._filterText + const query = this._filterText.toLowerCase(); + const matchRank = (item: FilterableListItem) => { + if (!query) return 0; + if ( + item.name.toLowerCase().startsWith(query) || + item.aliases?.some(alias => alias.toLowerCase().startsWith(query)) + ) { + return 0; + } + return item.label?.toLowerCase().startsWith(query) ? 1 : -1; + }; + const searchFilter = !query ? this.options.items - : this.options.items.filter( - item => - item.name.startsWith(this._filterText.toLowerCase()) || - item.aliases?.some(alias => - alias.startsWith(this._filterText.toLowerCase()) - ) - ); + : this.options.items.filter(item => matchRank(item) !== -1); return searchFilter.sort((a, b) => { const isActiveA = this.options.active?.(a); const isActiveB = this.options.active?.(b); @@ -59,6 +64,9 @@ export class FilterableListComponent extends WithDisposable( if (isActiveA && !isActiveB) return -1; if (!isActiveA && isActiveB) return 1; + const rankDiff = matchRank(a) - matchRank(b); + if (rankDiff) return rankDiff; + return this.listFilter?.(a, b) ?? 0; }); } diff --git a/tests/affine-local/e2e/blocksuite/code/crud.spec.ts b/tests/affine-local/e2e/blocksuite/code/crud.spec.ts index 0bd0e40628..a1481ef90a 100644 --- a/tests/affine-local/e2e/blocksuite/code/crud.spec.ts +++ b/tests/affine-local/e2e/blocksuite/code/crud.spec.ts @@ -19,26 +19,6 @@ test.describe('Code Block Autocomplete Operations', () => { }); }); -test.describe('Code Block Language Selector', () => { - test('switch language and back to plain text', async ({ page }) => { - await initCodeBlockByOneStep(page); - const code = page.locator('affine-code'); - - await code.hover({ - position: { x: 155, y: 65 }, - }); - await page.getByTestId('lang-button').click(); - await page.getByRole('button', { name: 'Rust' }).click(); - - await expect(page.getByTestId('lang-button')).toHaveText('Rust'); - - await page.getByTestId('lang-button').click(); - await page.getByRole('button', { name: 'Plain Text' }).click(); - - await expect(page.getByTestId('lang-button')).toHaveText('Plain Text'); - }); -}); - test.describe('Code Block Preview', () => { test('enable html preview', async ({ page }) => { const code = page.locator('affine-code'); diff --git a/tests/blocksuite/e2e/code/crud.spec.ts b/tests/blocksuite/e2e/code/crud.spec.ts index e0b24a25fb..7eea78fe53 100644 --- a/tests/blocksuite/e2e/code/crud.spec.ts +++ b/tests/blocksuite/e2e/code/crud.spec.ts @@ -1,3 +1,4 @@ +import type { CodeBlockComponent } from '@blocksuite/affine/blocks/code'; import { expect } from '@playwright/test'; import { updateBlockType } from '../utils/actions/block.js'; @@ -160,7 +161,18 @@ test('change code language can work', async ({ page }, testInfo) => { const locator = codeBlockController.langList; await expect(locator).toBeVisible(); - await type(page, 'rust'); + for (const [query, label] of [ + ['AsSeMbLy', 'Assembly'], + ['TS-TAGS', 'TypeScript with Tags'], + ['rS', 'Rust'], + ]) { + await codeBlockController.langFilterInput.fill(query); + await expect( + locator.getByRole('button', { name: label, exact: true }) + ).toBeVisible(); + } + + await codeBlockController.langFilterInput.fill('rust'); await page.click( '.affine-filterable-list > .items-container > icon-button:nth-child(1)' ); @@ -183,6 +195,68 @@ test('change code language can work', async ({ page }, testInfo) => { await pressEnter(page); await expect(locator).toBeHidden(); await expect(codeBlockController.languageButton).toHaveText('TypeScript'); + + await codeBlockController.clickLanguageButton(); + await locator.getByRole('button', { name: 'Plain Text' }).click(); + await expect(codeBlockController.languageButton).toHaveText('Plain Text'); +}); + +test('ignore a pending highlight after switching to plain text', async ({ + page, +}) => { + await enterPlaygroundRoom(page); + await initEmptyCodeBlockState(page); + await focusRichText(page); + await type(page, 'const answer = 42;'); + + const code = getCodeBlock(page).codeBlock; + await expect + .poll(() => + code.evaluate( + (block: CodeBlockComponent) => !!block.highlighter.highlighter$.value + ) + ) + .toBe(true); + + const result = await code.evaluate(async (block: CodeBlockComponent) => { + const highlighter = block.highlighter.highlighter$.value!; + const rust = block.langs.find(lang => lang.id === 'rust')!; + await highlighter.loadLanguage(rust.import); + + const originalGetLoadedLanguages = highlighter.getLoadedLanguages; + const originalLoadLanguage = highlighter.loadLanguage; + let finishLoading!: () => void; + let loadRequests = 0; + const pendingLoad = new Promise(resolve => { + finishLoading = resolve; + }); + try { + highlighter.getLoadedLanguages = () => []; + highlighter.loadLanguage = () => { + loadRequests++; + return pendingLoad; + }; + block.model.props.language$.value = 'rust'; + block.model.props.language$.value = null; + finishLoading(); + await pendingLoad; + await Promise.resolve(); + + return { + loadRequests, + language: block.model.props.language, + tokens: block.highlightTokens$.value, + }; + } finally { + highlighter.getLoadedLanguages = originalGetLoadedLanguages; + highlighter.loadLanguage = originalLoadLanguage; + } + }); + expect(result).toEqual({ + loadRequests: 1, + language: null, + tokens: [], + }); }); test('duplicate code block', async ({ page }, testInfo) => {