feat(editor): Shiki syntax highlighting in the file editor (CodeMirror)

Bring the CodeMirror file editor up to the same rich highlighting as the Shiki
file view, so toggling edit <-> view is visually consistent. lezer collapses far
more tokens than TextMate (import/from/const are all "keyword"), so a theme
remap can't reach parity — instead, project real Shiki tokens onto decorations.

- Worker: add highlightTokens — tokenize with an arbitrary registered TextMate
  theme and return per-line styled runs with offsets. The theme object ships to
  the worker once per name; later calls send only the name.
- New shikiHighlight CodeMirror extension: a StateField of mark decorations
  built from worker tokens. Re-tokenizes on a short idle (off the keystroke
  path) and maps decorations through edits so colors persist while typing.
- flexokiTheme: add { syntaxColors: false } to keep only the editor UI theme,
  so the lezer highlighter doesn't compete with the Shiki decorations.
- FilesView: enable Shiki highlighting (same language resolver as the file view
  → identical language) and drop lezer token colors when it's active. lezer
  language stays on for indentation/folding/brackets.
This commit is contained in:
Bohdan Triapitsyn
2026-06-16 01:08:54 +03:00
parent c22e1cbb15
commit 782982cdd3
6 changed files with 232 additions and 5 deletions
@@ -51,6 +51,10 @@ self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {
queue = queue.then(() => highlight(request)).catch(() => {});
return;
}
if (request.type === 'highlightTokens') {
queue = queue.then(() => highlightTokens(request)).catch(() => {});
return;
}
queue = queue.then(() => highlightLines(request)).catch(() => {});
};
@@ -83,6 +87,27 @@ async function highlight(request: Extract<MarkdownWorkerRequest, { type: 'highli
}
}
async function highlightTokens(request: Extract<MarkdownWorkerRequest, { type: 'highlightTokens' }>): Promise<void> {
try {
const instance = await ensureHighlighter();
if (request.theme && !instance.getLoadedThemes().includes(request.themeName)) {
// Cast: a resolved TextMate theme object from the app theme registry.
await instance.loadTheme(request.theme as Parameters<typeof instance.loadTheme>[0]);
}
const lang = await resolveLanguage(instance, request.lang);
const { tokens } = instance.codeToTokens(request.code, {
lang: lang as BundledLanguage,
theme: request.themeName,
});
const lines = tokens.map((line) =>
line.map((token) => [token.content.length, token.color ?? '', token.fontStyle ?? 0] as [number, string, number]),
);
post({ type: 'highlightTokens', id: request.id, lines });
} catch (error) {
post({ type: 'error', id: request.id, message: error instanceof Error ? error.message : String(error) });
}
}
async function highlightLines(request: Extract<MarkdownWorkerRequest, { type: 'highlightLines' }>): Promise<void> {
try {
const instance = await ensureHighlighter();
@@ -4,6 +4,11 @@
// ready-to-splice Shiki HTML. The theme is dependency-free and imported inside
// the worker directly, so it is not sent over postMessage.
// A single styled run inside a line: [length, color, fontStyleBits].
// `color` is '' for default-foreground runs; fontStyleBits is Shiki's FontStyle
// bitmask (1=italic, 2=bold, 4=underline).
export type MarkdownTokenRun = [length: number, color: string, fontStyle: number];
export type MarkdownWorkerRequest =
| { type: 'init' }
// Highlight a whole block to ready-to-splice Shiki `<pre>` HTML.
@@ -11,9 +16,15 @@ export type MarkdownWorkerRequest =
// Highlight a whole block but return per-line inner HTML (one entry per line),
// so per-line layouts (diffs, gutters, virtualization) tokenize in ONE call
// instead of one worker round-trip per line.
| { type: 'highlightLines'; id: number; code: string; lang: string };
| { type: 'highlightLines'; id: number; code: string; lang: string }
// Tokenize with an arbitrary registered theme and return per-line styled runs
// with offsets — for building CodeMirror decorations. `theme` (a resolved
// TextMate theme object) is sent only the first time a theme name is used;
// afterwards only `themeName` is sent and the worker reuses the loaded theme.
| { type: 'highlightTokens'; id: number; code: string; lang: string; themeName: string; theme?: unknown };
export type MarkdownWorkerResponse =
| { type: 'highlight'; id: number; html: string }
| { type: 'highlightLines'; id: number; lines: string[] }
| { type: 'highlightTokens'; id: number; lines: MarkdownTokenRun[][] }
| { type: 'error'; id: number; message: string };
@@ -1,5 +1,5 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
import type { MarkdownTokenRun, MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-worker-protocol';
// Main-thread client for the markdown Shiki worker. Moves syntax tokenization
// off the UI thread: a closed code block is shipped to the worker, which returns
@@ -12,10 +12,14 @@ type PendingResolver = (response: MarkdownWorkerResponse | null) => void;
let worker: Worker | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
// Theme names whose full definition we've already shipped to the live worker, so
// repeat tokenization sends only the name (not the whole theme object) again.
const sentThemes = new Set<string>();
const failAll = (): void => {
pending.forEach((resolve) => resolve(null));
pending.clear();
sentThemes.clear();
worker?.terminate();
worker = undefined;
};
@@ -68,3 +72,31 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
const response = await request((id) => ({ type: 'highlightLines', id, code, lang }));
return response?.type === 'highlightLines' ? response.lines : null;
};
/**
* Tokenize `code` with the given resolved TextMate theme and return per-line
* styled runs with offsets — for building CodeMirror decorations that match the
* Shiki file view exactly. The full theme object is shipped only the first time
* a theme name is seen by the live worker. Resolves to `null` on failure.
*/
export const highlightTokensInWorker = async (
code: string,
lang: string,
themeName: string,
theme: unknown,
): Promise<MarkdownTokenRun[][] | null> => {
const needsTheme = !sentThemes.has(themeName);
const response = await request((id) => ({
type: 'highlightTokens',
id,
code,
lang,
themeName,
...(needsTheme ? { theme } : {}),
}));
if (response?.type === 'highlightTokens') {
sentThemes.add(themeName);
return response.lines;
}
return null;
};
+16 -2
View File
@@ -29,6 +29,8 @@ import { JsonTreeView } from '@/components/ui/JsonTreeView';
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
import { languageByExtension, loadLanguageByExtension } from '@/lib/codemirror/languageByExtension';
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
import { shikiHighlightExtension } from '@/lib/codemirror/shikiHighlight';
import { getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry';
import { File as PierreFile } from '@pierre/diffs/react';
import {
Dialog,
@@ -2824,11 +2826,23 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return [createFlexokiCodeMirrorTheme(currentTheme)];
}
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
// Shiki token colors (worker-backed) match the Shiki file view exactly.
// Same language resolver as the view, so both agree on the language. When
// Shiki is the color source, drop the lezer token colors to avoid a
// competing highlighter (keep the lezer language for indentation/folding).
const shikiLanguage = getLanguageFromExtension(selectedFile.path);
const extensions = [createFlexokiCodeMirrorTheme(currentTheme, shikiLanguage ? { syntaxColors: false } : undefined)];
const language = staticLanguageExtension ?? dynamicLanguageExtension;
if (language) {
extensions.push(language);
}
if (shikiLanguage) {
extensions.push(shikiHighlightExtension({
language: shikiLanguage,
themeName: currentTheme.metadata.id,
theme: getResolvedShikiTheme(currentTheme),
}));
}
if (wrapLines) {
extensions.push(EditorView.lineWrapping);
}
@@ -2939,7 +2953,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}, [pdfAssetAuthKey, t]);
const isPdfAssetAuthLoading = Boolean(pdfAssetAuthKey && pdfAssetAuthReadyKey !== pdfAssetAuthKey);
const imageSrc = selectedFile?.path && isSelectedImage
? (runtime.isDesktop
? (isSelectedSvg