fix(editor): improve code language search and prevent stale highlighting (#15593)

## 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
Whitewater
2026-09-19 18:48:05 +08:00
committed by GitHub
co-authored by DarkSky DarkSky
parent fb515c6d79
commit f26301a9fb
5 changed files with 113 additions and 31 deletions
@@ -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<HighlighterCore> | null = null;
private static readonly _languagePromises = new Map<string, Promise<void>>();
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<void> => {
@@ -40,6 +40,8 @@ import { codeBlockStyles } from './styles.js';
export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel> {
static override styles = codeBlockStyles;
private _highlightRequestId = 0;
private _inlineRangeProvider: InlineRangeProvider | null = null;
private readonly _localPreview$ = signal<boolean | null>(null);
@@ -118,6 +120,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
}
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<CodeBlockModel>
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,
@@ -43,15 +43,20 @@ export class FilterableListComponent<Props = unknown> extends WithDisposable(
}
private _filterItems() {
const searchFilter = !this._filterText
const query = this._filterText.toLowerCase();
const matchRank = (item: FilterableListItem<Props>) => {
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<Props = unknown> 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;
});
}
@@ -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');
+75 -1
View File
@@ -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<void>(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) => {