From ff7dfb633b449df39c23a50a18cfa7e6fa673c0f Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 22 Sep 2026 17:38:39 +0200 Subject: [PATCH 1/2] fix(editor): preserve Markdown copy and scrollbar interactions --- e2e/markdown-preview.spec.ts | 50 ++++++++ .../panels/EditorPanel.navigation.test.tsx | 27 +++++ src/renderer/panels/EditorPanel.tsx | 114 +++++++++--------- .../panels/MarkdownCodeBlock.test.tsx | 37 +++++- src/renderer/panels/MarkdownCodeBlock.tsx | 33 +++-- 5 files changed, 192 insertions(+), 69 deletions(-) create mode 100644 e2e/markdown-preview.spec.ts diff --git a/e2e/markdown-preview.spec.ts b/e2e/markdown-preview.spec.ts new file mode 100644 index 00000000..0e3bf665 --- /dev/null +++ b/e2e/markdown-preview.spec.ts @@ -0,0 +1,50 @@ +import { test, expect } from '@playwright/test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { launchApp, closeApp } from './fixtures/electron-app' +import { openTrustedWorkspace } from './fixtures/workspace' + +for (const container of ['plain', 'list', 'quote'] as const) { + test(`Markdown code in ${container} keeps pointer interactions and copies to the native clipboard`, async () => { + const root = mkdtempSync(path.join(tmpdir(), 'cate-markdown-')) + const source = 'cate panel list ' + 'long-command-argument '.repeat(30) + const fence = '```sh\n' + source + '\n```\n' + const markdown = container === 'list' ? '1. Commands\n\n' + fence.split('\n').map(line => ' ' + line).join('\n') + : container === 'quote' ? fence.split('\n').map(line => '> ' + line).join('\n') : fence + writeFileSync(path.join(root, 'preview.md'), '# Markdown\n\n' + markdown) + const app = await launchApp({ empty: true }) + try { + const page = app.mainWindow + await openTrustedWorkspace(page, root) + await page.evaluate(() => window.__cateE2E!.createPanel('canvas')) + await page.locator('[data-canvas-panel-id]').waitFor() + const nodeId = await page.evaluate(() => window.__cateE2E!.createEditor({ x: 40, y: 40 })) + const node = page.locator(`[data-node-id="${nodeId}"]`) + await node.getByText('preview.md', { exact: true }).click() + const pre = node.locator('pre') + await expect(pre).toContainText(source) + const copy = node.getByRole('button', { name: 'Copy code', exact: true }) + await pre.hover() + await copy.click() + await expect.poll(() => app.electronApp.evaluate(({ clipboard }) => clipboard.readText())).toBe(source + '\n') + + // Force a measurable native scrollbar even on macOS with overlay scrollbars. + await page.addStyleTag({ content: 'pre::-webkit-scrollbar { height: 14px; } pre::-webkit-scrollbar-thumb { background: #888; }' }) + for (const zoom of [1, 0.65, 1.8]) { + await page.evaluate(zoom => window.__cateE2E!.setZoom(zoom), zoom) + await pre.evaluate(el => { el.scrollLeft = 0 }) + const box = await pre.boundingBox() + if (!box) throw new Error('Missing code block') + await page.mouse.move(box.x + 10 * zoom, box.y + box.height - 7 * zoom) + await page.mouse.down() + await page.mouse.move(box.x + 100 * zoom, box.y + box.height - 7 * zoom, { steps: 10 }) + await page.mouse.up() + await expect.poll(() => pre.evaluate(el => el.scrollLeft), { message: `Scrollbar drag at zoom ${zoom}` }).toBeGreaterThan(0) + } + } finally { + await closeApp(app.electronApp) + rmSync(root, { recursive: true, force: true }) + } + }) +} diff --git a/src/renderer/panels/EditorPanel.navigation.test.tsx b/src/renderer/panels/EditorPanel.navigation.test.tsx index b8cc5e93..9f4062f0 100644 --- a/src/renderer/panels/EditorPanel.navigation.test.tsx +++ b/src/renderer/panels/EditorPanel.navigation.test.tsx @@ -10,6 +10,7 @@ import { DockStoreProvider } from '../stores/DockStoreContext' import { registerWorkspaceDockStore, releaseWorkspaceDockStore } from '../lib/workspace/dockRegistry' import { useNavigationPanels } from '../docking/useNavigationPanels' import { useUIStore } from '../stores/uiStore' +import { useActivePanelStore } from '../lib/activePanel' const h = vi.hoisted(() => ({ open: null as null | ((paths: string[], mode?: 'dock' | 'canvas') => Promise), @@ -141,6 +142,32 @@ it.each(['md', 'mdx'])('opens %s in preview by default and keeps the source togg await act(async () => preview.click()) expect(useAppStore.getState().getWorkspace('test')!.panels.editor.markdownPreview).toBe(true) }) +it.each([ + '```sh\ncate panel list\n```', + '1. Commands\n\n ```sh\n cate panel list\n ```', + '> ```sh\n> cate panel list\n> ```', +])('preserves Markdown controls during pointer focus changes: %s', async (content) => { + vi.mocked(window.electronAPI.fsReadFile).mockResolvedValue(content) + await mount('/test/readme.md') + const pre = host.querySelector('pre')! + const button = host.querySelector('[aria-label="Copy code"]')! + pre.scrollLeft = 40 + button.focus() + const previousActive = useActivePanelStore.getState().activePanelId + try { + // Canvas pointerdown and mousedown change the active panel while a press + // is in progress. Replacing DOM here cancels clicks and scrollbar drags. + for (const panelId of ['canvas', 'editor']) { + await act(async () => useActivePanelStore.getState().setActivePanel(panelId)) + expect(host.querySelector('pre')).toBe(pre) + expect(host.querySelector('[aria-label="Copy code"]')).toBe(button) + expect(document.activeElement).toBe(button) + expect(pre.scrollLeft).toBe(40) + } + } finally { + await act(async () => useActivePanelStore.getState().setActivePanel(previousActive)) + } +}) it('switches the open relative file between worktrees and explains missing files', async () => { h.worktrees = [ { id: 'main', path: '/test', branch: 'main', isPrimary: true, isOrphan: false }, diff --git a/src/renderer/panels/EditorPanel.tsx b/src/renderer/panels/EditorPanel.tsx index f77ce958..ff61be2f 100644 --- a/src/renderer/panels/EditorPanel.tsx +++ b/src/renderer/panels/EditorPanel.tsx @@ -12,7 +12,7 @@ import { ChevronDown, ChevronLeft, ChevronRight, Copy, ExternalLink, FolderOpen, import { perfCount, useRenderCount } from '../lib/perf/perfClient' import log from '../lib/logger' import * as monaco from 'monaco-editor' -import ReactMarkdown from 'react-markdown' +import ReactMarkdown, { type Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import MarkdownCodeBlock from './MarkdownCodeBlock' import type { EditorPanelProps } from './types' @@ -957,66 +957,70 @@ export default function EditorPanel({ // Markdown preview renderer // ----------------------------------------------------------------------------- +// Keep renderer identities stable: focus updates must not replace pressed buttons +// or scroll containers, including code blocks nested inside lists and quotes. +const markdownComponents: Components = { + p: ({ children }) =>

{children}

, + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + h4: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + a: ({ href, children }) => ( + + {children} + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () =>
    , + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + code: ({ className, children, ...props }) => { + const isBlock = /language-/.test(className ?? '') + if (isBlock) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, + pre: MarkdownCodeBlock, + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => ( + {children} + ), + img: ({ src, alt }) => ( + {alt + ), +} + function MarkdownPreview({ content }: { content: string }) { return (

    {children}

    , - h1: ({ children }) =>

    {children}

    , - h2: ({ children }) =>

    {children}

    , - h3: ({ children }) =>

    {children}

    , - h4: ({ children }) =>

    {children}

    , - ul: ({ children }) =>
      {children}
    , - ol: ({ children }) =>
      {children}
    , - li: ({ children }) =>
  • {children}
  • , - a: ({ href, children }) => ( - - {children} - - ), - blockquote: ({ children }) => ( -
    - {children} -
    - ), - hr: () =>
    , - strong: ({ children }) => {children}, - em: ({ children }) => {children}, - code: ({ className, children, ...props }) => { - const isBlock = /language-/.test(className ?? '') - if (isBlock) { - return ( - - {children} - - ) - } - return ( - - {children} - - ) - }, - pre: MarkdownCodeBlock, - table: ({ children }) => ( -
    - {children}
    -
    - ), - th: ({ children }) => ( - {children} - ), - td: ({ children }) => ( - {children} - ), - img: ({ src, alt }) => ( - {alt - ), - }} + components={markdownComponents} > {content}
    diff --git a/src/renderer/panels/MarkdownCodeBlock.test.tsx b/src/renderer/panels/MarkdownCodeBlock.test.tsx index aa8e6eb9..d23fff99 100644 --- a/src/renderer/panels/MarkdownCodeBlock.test.tsx +++ b/src/renderer/panels/MarkdownCodeBlock.test.tsx @@ -6,6 +6,7 @@ import MarkdownCodeBlock from './MarkdownCodeBlock' const mocks = vi.hoisted(() => ({ render: vi.fn(), initialize: vi.fn(), loaded: vi.fn(), + writeText: vi.fn(), themeChanged: null as null | ((theme: { type: string }) => void), })) vi.mock('mermaid', () => { @@ -23,6 +24,7 @@ vi.mock('../ui/Tooltip', () => ({ Tooltip: ({ children }: any) => children })) let host: HTMLDivElement let root: Root +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard') const fence = (source: string, language = 'mermaid') => `\`\`\`${language}\n${source}\n\`\`\`` async function show(content: string) { await act(async () => root.render({content})) @@ -34,10 +36,17 @@ beforeEach(() => { root = createRoot(host) mocks.render.mockReset().mockResolvedValue({ svg: 'Diagram' }) mocks.initialize.mockClear() + mocks.writeText.mockReset().mockResolvedValue(undefined) + vi.stubGlobal('electronAPI', { terminalClipboardWrite: mocks.writeText }) + // Clipboard writes can be unavailable to the browser renderer in Electron. + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) }) afterEach(async () => { await act(async () => root.unmount()) host.remove() + vi.unstubAllGlobals() + if (originalClipboard) Object.defineProperty(navigator, 'clipboard', originalClipboard) + else Reflect.deleteProperty(navigator, 'clipboard') }) it('keeps ordinary fenced and inline code without loading Mermaid', async () => { @@ -47,16 +56,38 @@ it('keeps ordinary fenced and inline code without loading Mermaid', async () => expect(mocks.loaded).not.toHaveBeenCalled() }) +it('copies the exact code through the native clipboard and waits for success', async () => { + let finish!: () => void + mocks.writeText.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + const source = 'cate panel list\n echo "Grüße <>&"' + await show(fence(source, 'sh')) + const button = host.querySelector('button')! + await act(async () => button.click()) + expect(mocks.writeText).toHaveBeenCalledWith(source + '\n') + expect(button.getAttribute('aria-label')).toBe('Copy code') + await act(async () => finish()) + expect(button.getAttribute('aria-label')).toBe('Copied') +}) + +it('reports a failed copy and allows retrying', async () => { + mocks.writeText.mockRejectedValueOnce(new Error('Clipboard unavailable')) + await show(fence('cate panel list', 'sh')) + const button = host.querySelector('button')! + await act(async () => button.click()) + expect(button.getAttribute('aria-label')).toBe('Copy failed. Try again') + await act(async () => button.click()) + expect(mocks.writeText).toHaveBeenCalledTimes(2) + expect(button.getAttribute('aria-label')).toBe('Copied') +}) + it('renders Mermaid outside pre, keeps copyable source, and skips unchanged diagrams', async () => { const source = 'graph LR; A-->B' await show('Original prose\n\n' + fence(source)) expect(host.querySelector('[role="img"] svg')).not.toBeNull() expect(host.querySelector('pre svg')).toBeNull() expect(mocks.initialize).toHaveBeenCalledWith(expect.objectContaining({ securityLevel: 'strict', startOnLoad: false })) - const writeText = vi.fn().mockResolvedValue(undefined) - Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) await act(async () => (host.querySelector('[aria-label="Copy code"]') as HTMLButtonElement).click()) - expect(writeText).toHaveBeenCalledWith(source + '\n') + expect(mocks.writeText).toHaveBeenCalledWith(source + '\n') await show('Changed prose\n\n' + fence(source)) expect(mocks.render).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/panels/MarkdownCodeBlock.tsx b/src/renderer/panels/MarkdownCodeBlock.tsx index 3995955c..b90b01e6 100644 --- a/src/renderer/panels/MarkdownCodeBlock.tsx +++ b/src/renderer/panels/MarkdownCodeBlock.tsx @@ -1,14 +1,18 @@ -import { useRef, useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import type { ExtraProps } from 'react-markdown' import { Check, Copy } from 'lucide-react' import { Tooltip } from '../ui/Tooltip' import { MermaidBlock } from './MermaidBlock' +import log from '../lib/logger' /** Fenced code block with a hover copy button, matching the agent chat's * "Copy code" affordance (#373). */ export default function MarkdownCodeBlock({ children, node }: { children?: ReactNode } & ExtraProps) { const preRef = useRef(null) - const [copied, setCopied] = useState(false) + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle') + const resetTimer = useRef>() + useEffect(() => () => clearTimeout(resetTimer.current), []) + const copyLabel = copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Copy failed. Try again' : 'Copy code' const code = node?.children[0] if (code?.type === 'element' && code.tagName === 'code' && Array.isArray(code.properties.className) && code.properties.className.includes('language-mermaid')) { @@ -23,22 +27,29 @@ export default function MarkdownCodeBlock({ children, node }: { children?: React > {children} - +
    ) } - From 80fd35fa5899220d854528a89c099f63a52718b9 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 22 Sep 2026 17:57:08 +0200 Subject: [PATCH 2/2] test: wait for watcher events across runtime rebuilds --- src/main/runtime/runtime-loopback.test.ts | 60 +++++++++++------------ 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/main/runtime/runtime-loopback.test.ts b/src/main/runtime/runtime-loopback.test.ts index f1d36395..7f383d29 100644 --- a/src/main/runtime/runtime-loopback.test.ts +++ b/src/main/runtime/runtime-loopback.test.ts @@ -301,39 +301,39 @@ describe('runtime loopback (real daemon capabilities over the wire)', () => { const events: string[] = [] const unsub = remote.file.watch(safe, (changedPath) => { events.push(path.basename(changedPath)) }) - await new Promise((r) => setTimeout(r, 300)) // let chokidar attach - - // Baseline: a write under the watched root produces an event. - await fs.writeFile(path.join(rootDir, 'before.txt'), 'one\n') - await waitFor(() => events.includes('before.txt'), 2000) - - // Live exclusion change: this closes + recreates the active watcher. - await remote.setExclusions(['whatever']) - await new Promise((r) => setTimeout(r, 300)) // let the rebuilt watcher attach - events.length = 0 - - // The rebuilt watcher is live and keeps delivering events for kept files. - await fs.writeFile(path.join(rootDir, 'after.txt'), 'two\n') - await waitFor(() => events.includes('after.txt'), 2000) - - // Unsubscribe stops events even right after a rebuild (registry handled it). - unsub() - await new Promise((r) => setTimeout(r, 200)) - events.length = 0 - await fs.writeFile(path.join(rootDir, 'post-unsub.txt'), 'three\n') - await new Promise((r) => setTimeout(r, 400)) - expect(events).not.toContain('post-unsub.txt') - - await remote.setExclusions([]) - }) + try { + // Native subscriptions attach asynchronously. Keep changing the file until + // an event arrives instead of assuming a fixed sleep means it is ready. + await waitForWatchEvent(() => events.includes('before.txt'), () => + fs.appendFile(path.join(rootDir, 'before.txt'), 'one\n')) + + // Live exclusion change: this recreates the active watcher. + await remote.setExclusions(['whatever']) + events.length = 0 + await waitForWatchEvent(() => events.includes('after.txt'), () => + fs.appendFile(path.join(rootDir, 'after.txt'), 'two\n')) + + // Unsubscribe stops events even right after a rebuild (registry handled it). + unsub() + await flush() + events.length = 0 + await fs.writeFile(path.join(rootDir, 'post-unsub.txt'), 'three\n') + await new Promise((r) => setTimeout(r, 400)) + expect(events).not.toContain('post-unsub.txt') + } finally { + unsub() + await remote.setExclusions([]) + } + }, 15_000) }) -/** Poll a predicate until true or the timeout elapses. */ -async function waitFor(pred: () => boolean, timeoutMs: number): Promise { - const start = Date.now() +/** Retry writes across native watcher startup, with a bound for broken delivery. */ +async function waitForWatchEvent(pred: () => boolean, poke: () => Promise): Promise { + const deadline = Date.now() + 5000 while (!pred()) { - if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out') - await new Promise((r) => setTimeout(r, 25)) + if (Date.now() >= deadline) throw new Error('waitForWatchEvent timed out') + await poke() + await new Promise((r) => setTimeout(r, 300)) } }