feat(editor): open fallback preview from unsaved Markdown drafts

This commit is contained in:
Neil
2026-09-18 20:22:52 -07:00
parent e238aa0e72
commit fa15a05c67
12 changed files with 272 additions and 52 deletions
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { FileContent } from './editor-panel-content-types'
@@ -95,18 +95,22 @@ function openFile(
function renderEditPath({
content,
savedContent = '# Saved',
language = 'markdown',
viewMode = 'rich',
mode = 'edit'
mode = 'edit',
onOpenMarkdownPreview
}: {
content: string
savedContent?: string
language?: 'markdown' | 'typescript'
viewMode?: 'source' | 'rich' | 'preview'
mode?: 'edit' | 'markdown-preview'
onOpenMarkdownPreview?: () => void
}) {
const activeFile = openFile(language, mode)
const fileContents = {
[activeFile.id]: { content: '# Saved', isBinary: false }
[activeFile.id]: { content: savedContent, isBinary: false }
}
const editorDrafts = { [activeFile.id]: content }
const model = getEditorPanelRenderModel({
@@ -145,6 +149,7 @@ function renderEditPath({
handleSave={vi.fn()}
handleSaveForFile={vi.fn()}
reloadContent={vi.fn()}
onOpenMarkdownPreview={onOpenMarkdownPreview}
/>
)
@@ -211,12 +216,6 @@ describe('inline Markdown render classification', () => {
})
it.each([
{
name: 'source Markdown',
args: { content: '# Source', viewMode: 'source' as const },
expectedView: 'source',
canExport: false
},
{
name: 'Markdown preview',
args: { content: '# Preview', mode: 'markdown-preview' as const },
@@ -238,6 +237,15 @@ describe('inline Markdown render classification', () => {
expect(classifiers.exceedsSizeLimit).not.toHaveBeenCalled()
})
it('scans source Markdown edit tabs too, so the toggle knows whether rich mode would fall back', () => {
const result = renderEditPath({ content: '# Source', viewMode: 'source' as const })
expect(result.view.container.innerHTML).toContain('data-editor-view="source"')
expect(result.model.canExportMarkdownToPdf).toBe(false)
expect(classifiers.getUnsupportedMessage).toHaveBeenCalledTimes(1)
expect(classifiers.exceedsSizeLimit).toHaveBeenCalledTimes(1)
})
it.each([
{ name: 'Changes mode', args: { isChangesMode: true } },
{ name: 'content that is still loading', args: { includeFileContent: false } },
@@ -301,4 +309,38 @@ describe('inline Markdown render classification', () => {
expect(oversized.view.getByText(/File is larger than the .* rich editing limit/)).toBeTruthy()
expect(oversized.view.getByText('Open anyway')).toBeTruthy()
})
it('offers the Preview toggle once rich mode falls back to source for this content', () => {
const fallback = renderEditPath({
content: '[reference]: https://example.com',
savedContent: '[reference]: https://example.com'
})
expect(fallback.model.availableEditorToggleModes).toEqual([
'source',
'rich',
'preview',
'changes'
])
const normal = renderEditPath({ content: '# Ordinary content' })
expect(normal.model.availableEditorToggleModes).toEqual(['source', 'rich', 'changes'])
})
it('opens the preview tab from the fallback banner action', () => {
const onOpenMarkdownPreview = vi.fn()
const { view } = renderEditPath({
content: '[reference]: https://example.com',
onOpenMarkdownPreview
})
fireEvent.click(view.getByText('Open preview'))
expect(onOpenMarkdownPreview).toHaveBeenCalledTimes(1)
})
it('hides the fallback banner preview action when no handler is provided', () => {
const { view } = renderEditPath({ content: '[reference]: https://example.com' })
expect(view.queryByText('Open preview')).toBeNull()
})
})
@@ -61,7 +61,8 @@ export function EditorContent({
handleDirtyStateHint,
handleSave,
handleSaveForFile,
reloadContent
reloadContent,
onOpenMarkdownPreview
}: {
activeFile: OpenFile
viewStateScopeId: string
@@ -90,6 +91,7 @@ export function EditorContent({
handleSave: (content: string) => Promise<boolean>
handleSaveForFile: (file: OpenFile, content: string) => Promise<boolean>
reloadContent: (file: OpenFile) => void
onOpenMarkdownPreview?: () => void
}): React.JSX.Element {
const editorViewStateKey =
viewStateScopeId === activeFile.id
@@ -253,6 +255,7 @@ export function EditorContent({
markdownDocuments={markdownDocuments}
getConflictNavigation={getConflictNavigation}
getMarkdownSourceLineOffset={getMarkdownSourceLineOffset}
onOpenMarkdownPreview={onOpenMarkdownPreview}
handleContentChange={handleContentChange}
handleDirtyStateHint={handleDirtyStateHint}
handleSave={handleSave}
@@ -51,6 +51,7 @@ export function EditorEditFileSurface({
markdownDocuments,
getConflictNavigation,
getMarkdownSourceLineOffset,
onOpenMarkdownPreview,
handleContentChange,
handleDirtyStateHint,
handleSave,
@@ -82,6 +83,7 @@ export function EditorEditFileSurface({
markdownDocuments: MarkdownDocumentsController
getConflictNavigation: (file: OpenFile, content: string) => EditorConflictNavigation | undefined
getMarkdownSourceLineOffset: (frontMatterRaw: string) => number
onOpenMarkdownPreview?: () => void
handleContentChange: (content: string) => void
handleDirtyStateHint: (dirty: boolean) => void
handleSave: (content: string) => Promise<boolean>
@@ -221,6 +223,7 @@ export function EditorEditFileSurface({
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
markdownDocuments={markdownDocuments}
getMarkdownSourceLineOffset={getMarkdownSourceLineOffset}
onOpenMarkdownPreview={onOpenMarkdownPreview}
handleContentChange={handleContentChange}
handleDirtyStateHint={handleDirtyStateHint}
monacoEditor={monacoEditor}
@@ -25,6 +25,7 @@ export function EditorMarkdownFileSurface({
markdownAnnotationsEnabled,
markdownDocuments,
getMarkdownSourceLineOffset,
onOpenMarkdownPreview,
handleContentChange,
handleDirtyStateHint,
monacoEditor
@@ -41,6 +42,7 @@ export function EditorMarkdownFileSurface({
markdownAnnotationsEnabled: boolean
markdownDocuments: MarkdownDocumentsController
getMarkdownSourceLineOffset: (frontMatterRaw: string) => number
onOpenMarkdownPreview?: () => void
handleContentChange: (content: string) => void
handleDirtyStateHint: (dirty: boolean) => void
monacoEditor: React.JSX.Element
@@ -68,6 +70,17 @@ export function EditorMarkdownFileSurface({
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-3 border-b border-border/60 bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<span className="min-w-0 flex-1">{richFallbackMessage}</span>
{onOpenMarkdownPreview ? (
<Button
type="button"
variant="outline"
size="xs"
className="shrink-0"
onClick={onOpenMarkdownPreview}
>
{translate('editor.richMarkdown.openPreview', 'Open preview')}
</Button>
) : null}
{isSizeFallback ? (
<Button
type="button"
@@ -251,21 +251,6 @@ function EditorPanelInner({
setMarkdownViewMode(activeFile.filePath, preferredMarkdownViewMode)
}
}
const handleEditorToggleChange = (next: EditorToggleValue): void => {
const fileId = activeFile.id
if (activeFile.mode === 'diff' && model.isMarkdown && next === 'rich') {
handleOpenDiffTargetFile('rich')
return
}
if (next === 'changes') {
setEditorViewMode(fileId, 'changes')
return
}
setEditorViewMode(fileId, 'edit')
if (next !== 'edit') {
setMarkdownViewMode(fileId, next)
}
}
const handleOpenMarkdownPreview = (): void => {
openMarkdownPreview(
{
@@ -278,6 +263,29 @@ function EditorPanelInner({
{ sourceFileId: activeFile.id }
)
}
const handleEditorToggleChange = (next: EditorToggleValue): void => {
const fileId = activeFile.id
if (activeFile.mode === 'diff' && model.isMarkdown && next === 'rich') {
handleOpenDiffTargetFile('rich')
return
}
if (next === 'changes') {
setEditorViewMode(fileId, 'changes')
return
}
// Why: the toggle only offers 'preview' as a fallback affordance (rich mode
// couldn't render this content); it opens the existing dedicated preview
// tab rather than a real edit-tab render mode, since 'edit' tabs never
// render read-only preview inline (see getMarkdownRenderMode).
if (next === 'preview') {
handleOpenMarkdownPreview()
return
}
setEditorViewMode(fileId, 'edit')
if (next !== 'edit') {
setMarkdownViewMode(fileId, next)
}
}
const handleOpenContainingFolder = (): void => {
// Why: virtual editor tabs use synthetic ids instead of on-disk paths.
if (activeFile.mode === 'check-details') {
@@ -10,7 +10,11 @@ vi.mock('./EditorPanelHeader', () => ({
}))
vi.mock('./EditorContent', () => ({
EditorContent: () => <div data-editor-content />
EditorContent: ({ onOpenMarkdownPreview }: { onOpenMarkdownPreview?: () => void }) => (
<div data-editor-content>
{onOpenMarkdownPreview && <button onClick={onOpenMarkdownPreview}>Open preview</button>}
</div>
)
}))
vi.mock('./UntitledFileRenameDialog', () => ({
@@ -48,7 +52,7 @@ function renderShell(file: OpenFile, isCombinedDiff = false): string {
availableEditorToggleModes: [],
effectiveToggleValue: 'edit',
canOpenPreviewToSide: false,
canShowMarkdownPreview: false,
canShowMarkdownPreview: file.language === 'markdown' && file.mode === 'edit',
canShowMarkdownTableOfContents: false,
isMarkdownTableOfContentsDisabled: false,
shouldShowMarkdownExportAction: false,
@@ -122,3 +126,9 @@ describe('EditorPanelShell path header', () => {
expect(renderShell(openFile('edit'))).toContain('data-editor-content')
})
})
it('offers Markdown preview through the shell even when HTML side preview is unavailable', () => {
const file = { ...openFile('edit'), language: 'markdown' }
expect(renderShell(file)).toContain('Open preview')
expect(renderShell(openFile('edit'))).not.toContain('Open preview')
})
@@ -163,6 +163,7 @@ export function EditorPanelShell({
showMarkdownFrontmatter={markdownFrontmatterVisible}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
onOpenMarkdownPreview={model.canShowMarkdownPreview ? onOpenMarkdownPreview : undefined}
/>
</Suspense>
<UntitledFileRenameDialog
@@ -62,6 +62,24 @@ function htmlFile(overrides: Partial<OpenFile> = {}): OpenFile {
}
}
describe('getEditorPanelRenderModel rich-mode fallback toggle', () => {
it('offers Preview once rich mode falls back for this content', () => {
const model = renderModel({
fileContents: {
'/repo/README.md': textContent({ content: '[reference]: https://example.com' })
}
})
expect(model.availableEditorToggleModes).toEqual(['source', 'rich', 'preview', 'changes'])
})
it('omits Preview for ordinary markdown content', () => {
const model = renderModel({})
expect(model.availableEditorToggleModes).toEqual(['source', 'rich', 'changes'])
})
})
describe('getEditorPanelRenderModel HTML preview affordance', () => {
it('enables preview for HTML edit tabs', () => {
expect(renderModel({ activeFile: htmlFile(), fileContents: {} }).canOpenPreviewToSide).toBe(
@@ -249,3 +267,12 @@ describe('getEditorPanelRenderModel markdown export affordance', () => {
).toBe(false)
})
})
it('offers the live Markdown preview while a fallback draft is unsaved', () => {
const model = renderModel({
editorDrafts: { '/repo/README.md': '[reference]: https://example.com' }
})
expect(model.availableEditorToggleModes).toContain('preview')
expect(model.canShowMarkdownPreview).toBe(true)
expect(model.canOpenPreviewToSide).toBe(false)
})
@@ -105,26 +105,12 @@ export function getEditorPanelRenderModel({
markdownViewModes.includes(storedMarkdownViewMode)
? storedMarkdownViewMode
: defaultMarkdownViewMode
const editorToggleModes = getEditorToggleModes({
language: viewerLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource
})
const isBinaryEditSurface =
activeFile.mode === 'edit' && fileContents[activeFile.id]?.isBinary === true
const availableEditorToggleModes =
isBinaryEditSurface || !canUseChangesModeForFile(activeFile)
? editorToggleModes.filter((mode) => mode !== 'changes')
: editorToggleModes
const effectiveToggleValue: EditorToggleValue = isChangesMode
? 'changes'
: hasViewModeToggle
? mdViewMode
: 'edit'
const inlineMarkdownContent =
activeFile.mode === 'edit'
? (editorDrafts[activeFile.id] ?? fileContents[activeFile.id]?.content ?? null)
: null
const isBinaryEditSurface =
activeFile.mode === 'edit' && fileContents[activeFile.id]?.isBinary === true
const shouldShowMarkdownExportAction =
viewerLanguage === 'markdown' &&
(activeFile.mode === 'edit' || activeFile.mode === 'markdown-preview')
@@ -139,16 +125,38 @@ export function getEditorPanelRenderModel({
!inlineFileContent.loadError &&
activeFile.conflict?.kind !== 'conflict-placeholder' &&
activeFile.conflict?.conflictStatus !== 'unresolved'
// Why: classified once per content change (cached) so both the toggle's
// fallback-preview affordance and the inline banner below agree on whether
// rich mode would fall back, without duplicating the eligibility scan. Gated
// on the same guards as the inline renderer so unrenderable content (binary,
// load error, unresolved conflict, Changes mode) is never scanned.
const richModeEligibility = canRenderInlineMarkdown
? getCachedMarkdownRichModeEligibility({
content: inlineMarkdownContent,
sizeOverridden: markdownRichModeSizeOverridden
})
: null
const richModeUnsupportedMessage = richModeEligibility?.unsupportedMessage ?? null
const richModeFallsBackToSource =
richModeEligibility !== null &&
(richModeEligibility.exceedsSizeLimit || richModeUnsupportedMessage !== null)
const editorToggleModes = getEditorToggleModes({
language: viewerLanguage,
mode: activeFile.mode,
diffSource: activeFile.diffSource,
richModeFallsBackToSource
})
const availableEditorToggleModes =
isBinaryEditSurface || !canUseChangesModeForFile(activeFile)
? editorToggleModes.filter((mode) => mode !== 'changes')
: editorToggleModes
const effectiveToggleValue: EditorToggleValue = isChangesMode
? 'changes'
: hasViewModeToggle
? mdViewMode
: 'edit'
let inlineMarkdownRenderState: MarkdownRenderState | null = null
if (canRenderInlineMarkdown) {
const shouldClassifyRichMode = mdViewMode === 'rich'
const richModeEligibility = shouldClassifyRichMode
? getCachedMarkdownRichModeEligibility({
content: inlineMarkdownContent,
sizeOverridden: markdownRichModeSizeOverridden
})
: null
const richModeUnsupportedMessage = richModeEligibility?.unsupportedMessage ?? null
inlineMarkdownRenderState = {
renderMode: getMarkdownRenderMode({
exceedsRichModeSizeLimit: richModeEligibility?.exceedsSizeLimit ?? false,
@@ -46,6 +46,38 @@ describe('getMarkdownViewModes', () => {
})
})
describe('getEditorToggleModes rich-mode fallback', () => {
it('offers Preview alongside Source and Rich when rich mode falls back for this content', () => {
expect(
getEditorToggleModes({
language: 'markdown',
mode: 'edit',
richModeFallsBackToSource: true
})
).toEqual(['source', 'rich', 'preview', 'changes'])
})
it('omits Preview when rich mode renders normally', () => {
expect(
getEditorToggleModes({
language: 'markdown',
mode: 'edit',
richModeFallsBackToSource: false
})
).toEqual(['source', 'rich', 'changes'])
})
it('does not add Preview for non-markdown languages even when the flag is set', () => {
expect(
getEditorToggleModes({
language: 'mermaid',
mode: 'edit',
richModeFallsBackToSource: true
})
).toEqual(['source', 'rich', 'changes'])
})
})
describe('markdown preview helpers', () => {
it('defaults markdown edit tabs to rich mode', () => {
expect(
@@ -6,7 +6,18 @@ type MarkdownPreviewTarget = Pick<OpenFile, 'mode' | 'diffSource'> & {
language: string
}
type EditorToggleTarget = MarkdownPreviewTarget & {
// Why: only markdown edit tabs have a dedicated read-only preview tab to
// route to, so this only affects the markdown edit toggle set.
richModeFallsBackToSource?: boolean
}
const MARKDOWN_EDIT_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const MARKDOWN_EDIT_TOGGLE_MODES_WITH_PREVIEW_FALLBACK = [
'source',
'rich',
'preview'
] as const satisfies readonly EditorToggleValue[]
const MARKDOWN_DIFF_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const MERMAID_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
const CSV_VIEW_MODES = ['source', 'rich'] as const satisfies readonly MarkdownViewMode[]
@@ -21,7 +32,7 @@ const NO_VIEW_MODES = [] as const satisfies readonly MarkdownViewMode[]
// Edit | Changes.
const CODE_EDIT_TOGGLE_MODES = ['edit', 'changes'] as const satisfies readonly EditorToggleValue[]
export function getEditorToggleModes(target: MarkdownPreviewTarget): readonly EditorToggleValue[] {
export function getEditorToggleModes(target: EditorToggleTarget): readonly EditorToggleValue[] {
if (target.mode !== 'edit') {
return getMarkdownViewModes(target)
}
@@ -30,6 +41,12 @@ export function getEditorToggleModes(target: MarkdownPreviewTarget): readonly Ed
// which is noisy and currently invalid for restored external notebooks.
return NOTEBOOK_VIEW_MODES
}
// Why: when rich mode would fall back to Source for this content, the
// toggle offers the dedicated read-only preview tab instead of leaving
// Preview undiscoverable behind a menu item and a shortcut.
if (target.language === 'markdown' && target.richModeFallsBackToSource) {
return [...MARKDOWN_EDIT_TOGGLE_MODES_WITH_PREVIEW_FALLBACK, 'changes']
}
const languageModes = getMarkdownViewModes(target)
if (languageModes.length > 0) {
return [...languageModes, 'changes']
@@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs'
import { test, expect } from './helpers/orca-app'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
cleanupMarkdownFixture,
createMarkdownFixture,
getActiveWorktreeContext,
openMarkdownFixture
} from './helpers/markdown-editor-fixture'
const SOURCE = '# Saved title\n\n[Reference][id]\n\n[id]: https://example.com\n'
test('fallback preview shows the unsaved draft', async ({ orcaPage }, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const context = await getActiveWorktreeContext(orcaPage)
const file = await createMarkdownFixture(
context,
'markdown-preview',
'draft',
testInfo.workerIndex,
SOURCE
)
try {
await openMarkdownFixture(orcaPage, context, file)
const openPreview = orcaPage.getByRole('button', { name: 'Open preview', exact: true })
await expect(openPreview).toBeVisible()
const monaco = orcaPage.locator('.monaco-editor').first()
await monaco.click()
await orcaPage.keyboard.press('ControlOrMeta+Home')
await orcaPage.keyboard.press('ControlOrMeta+A')
await orcaPage.keyboard.insertText(SOURCE.replace('Saved title', 'Unsaved draft'))
await expect(monaco).toContainText('Unsaved draft')
await expect(openPreview).toBeVisible()
expect(readFileSync(file, 'utf8')).toBe(SOURCE)
await testInfo.attach('before-preview', {
body: await orcaPage.screenshot({ path: testInfo.outputPath('before-preview.png') }),
contentType: 'image/png'
})
await openPreview.click()
await expect(
orcaPage.getByRole('heading', { name: 'Unsaved draft', exact: true })
).toBeVisible()
await expect(orcaPage.getByRole('link', { name: 'Reference', exact: true })).toHaveAttribute(
'href',
'https://example.com'
)
expect(readFileSync(file, 'utf8')).toBe(SOURCE)
await testInfo.attach('after-preview', {
body: await orcaPage.screenshot({ path: testInfo.outputPath('after-preview.png') }),
contentType: 'image/png'
})
} finally {
await cleanupMarkdownFixture(file)
}
})