mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
committed by
Neil
parent
c1365740ea
commit
f4c3a593e4
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user