From fa15a05c67388f8dede1698f5cdcb5ff47b36133 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 20:22:52 -0700 Subject: [PATCH] feat(editor): open fallback preview from unsaved Markdown drafts --- ...orContent.markdown-classification.test.tsx | 60 ++++++++++++++++--- .../src/components/editor/EditorContent.tsx | 5 +- .../editor/EditorEditFileSurface.tsx | 3 + .../editor/EditorMarkdownFileSurface.tsx | 13 ++++ .../src/components/editor/EditorPanel.tsx | 38 +++++++----- .../editor/EditorPanelShell.header.test.tsx | 14 ++++- .../components/editor/EditorPanelShell.tsx | 1 + .../editor/editor-panel-render-model.test.ts | 27 +++++++++ .../editor/editor-panel-render-model.ts | 56 +++++++++-------- .../editor/markdown-preview-controls.test.ts | 32 ++++++++++ .../editor/markdown-preview-controls.ts | 19 +++++- tests/e2e/markdown-fallback-preview.spec.ts | 56 +++++++++++++++++ 12 files changed, 272 insertions(+), 52 deletions(-) create mode 100644 tests/e2e/markdown-fallback-preview.spec.ts diff --git a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx index 23531fc95f0..4e46d0a1582 100644 --- a/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.markdown-classification.test.tsx @@ -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() + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 86527b25f29..2104b97c704 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -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 handleSaveForFile: (file: OpenFile, content: string) => Promise 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} diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx index a9e3f8ff856..07f421cec3d 100644 --- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx @@ -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 @@ -221,6 +223,7 @@ export function EditorEditFileSurface({ markdownAnnotationsEnabled={markdownAnnotationsEnabled} markdownDocuments={markdownDocuments} getMarkdownSourceLineOffset={getMarkdownSourceLineOffset} + onOpenMarkdownPreview={onOpenMarkdownPreview} handleContentChange={handleContentChange} handleDirtyStateHint={handleDirtyStateHint} monacoEditor={monacoEditor} diff --git a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx index 4db7c9b5045..b34c324ecc6 100644 --- a/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx +++ b/src/renderer/src/components/editor/EditorMarkdownFileSurface.tsx @@ -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({
{richFallbackMessage} + {onOpenMarkdownPreview ? ( + + ) : null} {isSizeFallback ? ( } +
+ ) })) 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') +}) diff --git a/src/renderer/src/components/editor/EditorPanelShell.tsx b/src/renderer/src/components/editor/EditorPanelShell.tsx index dd15231cefe..eadcb77e44e 100644 --- a/src/renderer/src/components/editor/EditorPanelShell.tsx +++ b/src/renderer/src/components/editor/EditorPanelShell.tsx @@ -163,6 +163,7 @@ export function EditorPanelShell({ showMarkdownFrontmatter={markdownFrontmatterVisible} onCloseMarkdownTableOfContents={onCloseMarkdownTableOfContents} markdownAnnotationsEnabled={markdownAnnotationsEnabled} + onOpenMarkdownPreview={model.canShowMarkdownPreview ? onOpenMarkdownPreview : undefined} /> = {}): 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) +}) diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 97b4492750e..0ccf9053c3f 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -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, diff --git a/src/renderer/src/components/editor/markdown-preview-controls.test.ts b/src/renderer/src/components/editor/markdown-preview-controls.test.ts index bd5ada9fd94..7289ee47896 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.test.ts @@ -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( diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts index fc6ef9f5c1b..878cd79a487 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -6,7 +6,18 @@ type MarkdownPreviewTarget = Pick & { 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'] diff --git a/tests/e2e/markdown-fallback-preview.spec.ts b/tests/e2e/markdown-fallback-preview.spec.ts new file mode 100644 index 00000000000..df93228af9a --- /dev/null +++ b/tests/e2e/markdown-fallback-preview.spec.ts @@ -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) + } +})