From 3520e8eb41e9206e312bf66dee3a353a56a4a437 Mon Sep 17 00:00:00 2001 From: BAEK'space <112856532+100space@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:32:54 +0900 Subject: [PATCH] fix: highlight bash fences in Markdown source mode (#20592) * fix: highlight bash fences in Markdown source mode * refactor: trim shell fence alias registration Drop the speculative exports and document the alias-resolution rationale in one WHY comment; the idempotency guard stays. --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../register-shell-markdown-aliases.test.ts | 32 ++++++++ .../register-shell-markdown-aliases.ts | 24 ++++++ src/renderer/src/lib/monaco-setup.ts | 2 + .../markdown-source-bash-highlighting.spec.ts | 78 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts create mode 100644 src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.ts create mode 100644 tests/e2e/markdown-source-bash-highlighting.spec.ts diff --git a/src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts b/src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts new file mode 100644 index 00000000000..d977b4322e5 --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest' +import { registerShellMarkdownAliases } from './register-shell-markdown-aliases' + +function createMonacoMock(aliases: string[] = ['Shell', 'sh']) { + return { + languages: { + getLanguages: vi.fn(() => [{ id: 'shell', aliases }]), + register: vi.fn() + } + } +} + +describe('registerShellMarkdownAliases', () => { + it('registers bash alongside the built-in shell aliases', () => { + const monaco = createMonacoMock() + + registerShellMarkdownAliases(monaco) + + expect(monaco.languages.register).toHaveBeenCalledWith({ + id: 'shell', + aliases: ['Shell', 'sh', 'bash'] + }) + }) + + it('does not register the alias again when Monaco already exposes it', () => { + const monaco = createMonacoMock(['Shell', 'sh', 'Bash']) + + registerShellMarkdownAliases(monaco) + + expect(monaco.languages.register).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.ts b/src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.ts new file mode 100644 index 00000000000..1cad43f1dfd --- /dev/null +++ b/src/renderer/src/lib/monaco-languages/register-shell-markdown-aliases.ts @@ -0,0 +1,24 @@ +import type * as Monaco from 'monaco-editor' + +type MonacoModule = typeof Monaco + +// Why: Monaco resolves Markdown fences by alias (never extension) and its shell +// language declares `bash` only as an extension, so ```bash rendered plain while +// ```sh highlighted. Re-registering id 'shell' merges the alias and keeps the +// built-in tokenizer; `Shell` stays first because Monaco uses the first alias as +// the language's display name. +export function registerShellMarkdownAliases(monaco: { + languages: Pick +}): void { + const bashAlreadyRegistered = monaco.languages + .getLanguages() + .some( + ({ id, aliases }) => + id === 'shell' && aliases?.some((alias) => alias.toLowerCase() === 'bash') + ) + if (bashAlreadyRegistered) { + return + } + + monaco.languages.register({ id: 'shell', aliases: ['Shell', 'sh', 'bash'] }) +} diff --git a/src/renderer/src/lib/monaco-setup.ts b/src/renderer/src/lib/monaco-setup.ts index 523ecf8e77b..57a42fc230f 100644 --- a/src/renderer/src/lib/monaco-setup.ts +++ b/src/renderer/src/lib/monaco-setup.ts @@ -10,6 +10,7 @@ import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' import { registerAstroLanguage } from './monaco-languages/register-astro' import { registerJsonlLanguage } from './monaco-languages/register-jsonl' import { registerNimLanguage } from './monaco-languages/register-nim' +import { registerShellMarkdownAliases } from './monaco-languages/register-shell-markdown-aliases' import { registerSvelteLanguage } from './monaco-languages/register-svelte' import { registerVueLanguage } from './monaco-languages/register-vue' import { installMonacoDelayerCancellationGuard } from './monaco-delayer-cancellation-guard' @@ -79,6 +80,7 @@ registerSvelteLanguage(monaco) registerAstroLanguage(monaco) registerNimLanguage(monaco) registerJsonlLanguage(monaco) +registerShellMarkdownAliases(monaco) installMonacoDelayerCancellationGuard() installMonacoDiffEditorDisposalGuard(monaco) installMonacoPeekReferencesPreviewOptions() diff --git a/tests/e2e/markdown-source-bash-highlighting.spec.ts b/tests/e2e/markdown-source-bash-highlighting.spec.ts new file mode 100644 index 00000000000..33b0e0ed82e --- /dev/null +++ b/tests/e2e/markdown-source-bash-highlighting.spec.ts @@ -0,0 +1,78 @@ +import type { Locator, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-editor-fixture' + +const MARKDOWN = `\`\`\`bash +printf '%s\\n' "build complete" # bash-highlight-marker +\`\`\` + +\`\`\`sh +printf '%s\\n' "build complete" # shell-control-marker +\`\`\` +` + +async function switchToSourceMode(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + if (!state.activeFileId) { + throw new Error('No active editor file') + } + state.setMarkdownViewMode(state.activeFileId, 'source') + }) +} + +async function distinctLeafTokenColors(line: Locator): Promise { + return line.locator('span').evaluateAll((spans) => { + const colors = spans + .filter((span) => span.childElementCount === 0 && span.textContent?.trim()) + .map((span) => window.getComputedStyle(span).color) + return new Set(colors).size + }) +} + +test('highlights bash and sh fences in Markdown Source mode', async ({ orcaPage }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + '.orca-e2e-markdown-source-highlighting', + 'bash-and-sh', + testInfo.workerIndex, + MARKDOWN + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await switchToSourceMode(orcaPage) + + const monaco = orcaPage.locator('.monaco-editor').first() + await expect(monaco).toBeVisible({ timeout: 25_000 }) + + for (const marker of ['bash-highlight-marker', 'shell-control-marker']) { + const line = monaco.locator('.view-line').filter({ hasText: marker }) + await expect(line).toHaveCount(1) + await expect + .poll(() => distinctLeafTokenColors(line), { + message: `${marker} should render with distinct shell token colors` + }) + .toBeGreaterThan(1) + } + } finally { + await cleanupMarkdownFixture(filePath) + } +})