Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions e2e/markdown-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
}
60 changes: 30 additions & 30 deletions src/main/runtime/runtime-loopback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const start = Date.now()
/** Retry writes across native watcher startup, with a bound for broken delivery. */
async function waitForWatchEvent(pred: () => boolean, poke: () => Promise<void>): Promise<void> {
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))
}
}

Expand Down
27 changes: 27 additions & 0 deletions src/renderer/panels/EditorPanel.navigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>),
Expand Down Expand Up @@ -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<HTMLButtonElement>('[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 },
Expand Down
114 changes: 59 additions & 55 deletions src/renderer/panels/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 }) => <p className="leading-relaxed my-2">{children}</p>,
h1: ({ children }) => <h1 className="text-xl font-bold text-primary mt-6 mb-2 pb-1 border-b border-strong">{children}</h1>,
h2: ({ children }) => <h2 className="text-lg font-semibold text-primary mt-5 mb-2 pb-1 border-b border-strong">{children}</h2>,
h3: ({ children }) => <h3 className="text-[15px] font-semibold text-primary mt-4 mb-1">{children}</h3>,
h4: ({ children }) => <h4 className="text-[14px] font-semibold text-primary mt-3 mb-1">{children}</h4>,
ul: ({ children }) => <ul className="list-disc pl-5 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 space-y-1">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noreferrer"
className="text-agent underline decoration-agent/30 hover:decoration-agent">
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-3 border-strong pl-3 text-secondary italic my-2">
{children}
</blockquote>
),
hr: () => <hr className="border-subtle my-4" />,
strong: ({ children }) => <strong className="font-semibold text-primary">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
code: ({ className, children, ...props }) => {
const isBlock = /language-/.test(className ?? '')
if (isBlock) {
return (
<code className={`${className ?? ''} font-mono text-[12px] leading-snug`} {...props}>
{children}
</code>
)
}
return (
<code className="font-mono text-[12px] px-1 py-[1px] rounded bg-hover-strong text-primary" {...props}>
{children}
</code>
)
},
pre: MarkdownCodeBlock,
table: ({ children }) => (
<div className="overflow-x-auto my-3">
<table className="min-w-full text-[12px] border border-subtle rounded-md">{children}</table>
</div>
),
th: ({ children }) => (
<th className="text-left px-3 py-1.5 border-b border-subtle bg-surface-3 text-primary font-medium">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 border-b border-subtle align-top">{children}</td>
),
img: ({ src, alt }) => (
<img src={src} alt={alt ?? ''} className="max-w-full rounded-md my-2" />
),
}

function MarkdownPreview({ content }: { content: string }) {
return (
<div className="absolute inset-0 overflow-auto px-6 py-4">
<div className="max-w-3xl mx-auto prose-markdown space-y-3 [&>:first-child]:mt-0 text-[13px] text-primary leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => <p className="leading-relaxed my-2">{children}</p>,
h1: ({ children }) => <h1 className="text-xl font-bold text-primary mt-6 mb-2 pb-1 border-b border-strong">{children}</h1>,
h2: ({ children }) => <h2 className="text-lg font-semibold text-primary mt-5 mb-2 pb-1 border-b border-strong">{children}</h2>,
h3: ({ children }) => <h3 className="text-[15px] font-semibold text-primary mt-4 mb-1">{children}</h3>,
h4: ({ children }) => <h4 className="text-[14px] font-semibold text-primary mt-3 mb-1">{children}</h4>,
ul: ({ children }) => <ul className="list-disc pl-5 space-y-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 space-y-1">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noreferrer"
className="text-agent underline decoration-agent/30 hover:decoration-agent">
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-3 border-strong pl-3 text-secondary italic my-2">
{children}
</blockquote>
),
hr: () => <hr className="border-subtle my-4" />,
strong: ({ children }) => <strong className="font-semibold text-primary">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
code: ({ className, children, ...props }) => {
const isBlock = /language-/.test(className ?? '')
if (isBlock) {
return (
<code className={`${className ?? ''} font-mono text-[12px] leading-snug`} {...props}>
{children}
</code>
)
}
return (
<code className="font-mono text-[12px] px-1 py-[1px] rounded bg-hover-strong text-primary" {...props}>
{children}
</code>
)
},
pre: MarkdownCodeBlock,
table: ({ children }) => (
<div className="overflow-x-auto my-3">
<table className="min-w-full text-[12px] border border-subtle rounded-md">{children}</table>
</div>
),
th: ({ children }) => (
<th className="text-left px-3 py-1.5 border-b border-subtle bg-surface-3 text-primary font-medium">{children}</th>
),
td: ({ children }) => (
<td className="px-3 py-1.5 border-b border-subtle align-top">{children}</td>
),
img: ({ src, alt }) => (
<img src={src} alt={alt ?? ''} className="max-w-full rounded-md my-2" />
),
}}
components={markdownComponents}
>
{content}
</ReactMarkdown>
Expand Down
37 changes: 34 additions & 3 deletions src/renderer/panels/MarkdownCodeBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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(<ReactMarkdown components={{ pre: MarkdownCodeBlock }}>{content}</ReactMarkdown>))
Expand All @@ -34,10 +36,17 @@ beforeEach(() => {
root = createRoot(host)
mocks.render.mockReset().mockResolvedValue({ svg: '<svg><text>Diagram</text></svg>' })
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 () => {
Expand All @@ -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<void>(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)
})
Expand Down
Loading
Loading