feat(editor): offer the markdown preview when rich mode falls back

Reference links, footnotes, unsupported HTML, and oversized files park
markdown edit tabs in Source mode with a banner, but the dedicated
read-only preview was reachable only through a menu item and a
shortcut. Add Preview to the view toggle and to the banner for exactly
this case, reusing the existing preview tab instead of duplicating it.
This commit is contained in:
Frederic Barthelemy
2026-09-18 04:32:01 -07:00
committed by Neil
parent c1365740ea
commit f4c3a593e4
10 changed files with 187 additions and 49 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'
@@ -97,12 +97,14 @@ function renderEditPath({
content,
language = 'markdown',
viewMode = 'rich',
mode = 'edit'
mode = 'edit',
onOpenMarkdownPreview
}: {
content: string
language?: 'markdown' | 'typescript'
viewMode?: 'source' | 'rich' | 'preview'
mode?: 'edit' | 'markdown-preview'
onOpenMarkdownPreview?: () => void
}) {
const activeFile = openFile(language, mode)
const fileContents = {
@@ -145,6 +147,7 @@ function renderEditPath({
handleSave={vi.fn()}
handleSaveForFile={vi.fn()}
reloadContent={vi.fn()}
onOpenMarkdownPreview={onOpenMarkdownPreview}
/>
)
@@ -211,12 +214,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 +235,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 +307,35 @@ 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' })
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') {
@@ -163,6 +163,7 @@ export function EditorPanelShell({
showMarkdownFrontmatter={markdownFrontmatterVisible}
onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents}
markdownAnnotationsEnabled={markdownAnnotationsEnabled}
onOpenMarkdownPreview={onOpenMarkdownPreview}
/>
</Suspense>
<UntitledFileRenameDialog
@@ -62,6 +62,22 @@ 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({
editorDrafts: { '/repo/README.md': '[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(
@@ -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']