feat(web): markdown Source | Preview toggle in session file pane (#957)

* feat(web): markdown Source | Preview toggle in session file pane

Add Source | Preview toggle for .md/.mdx files in the session file route,
defaulting to preview with localStorage persistence. Reuse chat markdown
pipeline via MarkdownRenderer standalone mode (no assistant-ui thread).

Includes unit tests, Playwright smoke, and e2e fixture.

Closes tiann/hapi#954

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): cast standalone MarkdownRenderer components for react-markdown

Soup verify gate: defaultComponents merge type is wider than
react-markdown Components; standalone file-pane path needs explicit cast.

* fix(web): route file-pane markdown fences through SyntaxHighlighter

Standalone file preview now mirrors chat code-block rendering: fenced
blocks use SyntaxHighlighter and MARKDOWN_COMPONENTS_BY_LANGUAGE (mermaid
included) without requiring ThreadPrimitive context.

Addresses HAPI Bot Major on tiann/hapi#957.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): detect fenced vs inline code in standalone markdown preview

Move block detection to the pre override (react-markdown v10 does not pass
inline to custom code components). Add inline-code regression test.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-11 11:06:38 +08:00
committed by GitHub
co-authored by Cursor
parent 018bcb8eaa
commit a82dd49049
12 changed files with 540 additions and 32 deletions
+98
View File
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { I18nProvider } from '@/lib/i18n-context'
import { encodeBase64 } from '@/lib/utils'
import FilePage from './file'
const goBackMock = vi.fn()
const sampleMarkdown = '# Heading\n\n| Col A | Col B |\n| --- | --- |\n| one | two |'
const filePath = 'docs/README.md'
const encodedPath = encodeBase64(filePath)
const encodedContent = encodeBase64(sampleMarkdown)
vi.mock('@tanstack/react-router', () => ({
useParams: () => ({ sessionId: 'session-1' }),
useSearch: () => ({
path: encodedPath,
staged: undefined,
}),
}))
vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({
api: {
getGitDiffFile: vi.fn(async () => ({ success: true, stdout: '' })),
readSessionFile: vi.fn(async () => ({
success: true,
content: encodedContent,
})),
},
}),
}))
vi.mock('@/hooks/useAppGoBack', () => ({
useAppGoBack: () => goBackMock,
}))
vi.mock('@/hooks/useCopyToClipboard', () => ({
useCopyToClipboard: () => ({
copied: false,
copy: vi.fn(),
}),
}))
vi.mock('@/lib/shiki', () => ({
langAlias: { md: 'markdown' },
useShikiHighlighter: (content: string) => content,
}))
vi.mock('@/components/MarkdownRenderer', () => ({
MarkdownRenderer: (props: { content: string }) => (
<div data-testid="markdown-preview">{props.content}</div>
),
}))
function renderWithProviders() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<I18nProvider>
<FilePage />
</I18nProvider>
</QueryClientProvider>
)
}
describe('FilePage markdown preview', () => {
beforeEach(() => {
vi.clearAllMocks()
window.localStorage.clear()
})
it('renders markdown preview by default and toggles to source', async () => {
renderWithProviders()
await waitFor(() => {
expect(screen.getByTestId('markdown-preview')).toHaveTextContent('# Heading')
})
expect(screen.getByRole('button', { name: 'Preview' })).toHaveClass('opacity-80')
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
await waitFor(() => {
expect(screen.getByRole('code')).toHaveTextContent('# Heading')
})
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
await waitFor(() => {
expect(screen.getByTestId('markdown-preview')).toBeInTheDocument()
})
})
})
+88 -31
View File
@@ -13,6 +13,13 @@ import { langAlias, useShikiHighlighter } from '@/lib/shiki'
import { useTranslation } from '@/lib/use-translation'
import { decodeBase64 } from '@/lib/utils'
import { ImagePreview } from '@/components/ImagePreview'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import {
getInitialMarkdownPreviewMode,
isMarkdownFile,
persistMarkdownPreviewMode,
type MarkdownPreviewMode,
} from '@/lib/file-markdown-preview'
const MAX_COPYABLE_FILE_BYTES = 1_000_000
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
@@ -193,6 +200,7 @@ export default function FilePage() {
const filePath = useMemo(() => decodePath(encodedPath), [encodedPath])
const fileName = filePath.split('/').pop() || filePath || t('file.page.fallbackName')
const imageMimeType = useMemo(() => resolveImageMimeType(filePath), [filePath])
const markdownFile = useMemo(() => isMarkdownFile(filePath), [filePath])
const diffQuery = useQuery({
queryKey: queryKeys.gitFileDiff(sessionId, filePath, staged),
@@ -234,7 +242,12 @@ export default function FilePage() {
: null
const language = useMemo(() => imageMimeType ? undefined : resolveLanguage(filePath), [filePath, imageMimeType])
const highlighted = useShikiHighlighter(imageMimeType ? '' : decodedContent, language)
const [markdownMode, setMarkdownMode] = useState<MarkdownPreviewMode>(getInitialMarkdownPreviewMode)
const showMarkdownSource = !markdownFile || markdownMode === 'source'
const highlighted = useShikiHighlighter(
imageMimeType || (markdownFile && !showMarkdownSource) ? '' : decodedContent,
language
)
const contentSizeBytes = useMemo(
() => (decodedContent ? getUtf8ByteLength(decodedContent) : 0),
[decodedContent]
@@ -248,6 +261,11 @@ export default function FilePage() {
const [displayMode, setDisplayMode] = useState<'diff' | 'file'>('diff')
const setMarkdownPreviewMode = (mode: MarkdownPreviewMode) => {
setMarkdownMode(mode)
persistMarkdownPreviewMode(mode)
}
useEffect(() => {
if (imageMimeType) {
setDisplayMode('file')
@@ -313,23 +331,46 @@ export default function FilePage() {
</div>
</div>
{diffContent ? (
{diffContent || (markdownFile && displayMode === 'file') ? (
<div className="bg-[var(--app-bg)]">
<div className="mx-auto w-full max-w-content px-3 py-2 flex items-center gap-2 border-b border-[var(--app-divider)]">
<button
type="button"
onClick={() => setDisplayMode('diff')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'diff' ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.diff')}
</button>
<button
type="button"
onClick={() => setDisplayMode('file')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'file' ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.file')}
</button>
{diffContent ? (
<>
<button
type="button"
onClick={() => setDisplayMode('diff')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'diff' ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.diff')}
</button>
<button
type="button"
onClick={() => setDisplayMode('file')}
className={`rounded px-3 py-1 text-xs font-semibold ${displayMode === 'file' ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.file')}
</button>
</>
) : null}
{markdownFile && displayMode === 'file' ? (
<>
{diffContent ? <span className="mx-1 h-4 w-px bg-[var(--app-divider)]" aria-hidden="true" /> : null}
<button
type="button"
onClick={() => setMarkdownPreviewMode('source')}
className={`rounded px-3 py-1 text-xs font-semibold ${showMarkdownSource ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.source')}
</button>
<button
type="button"
onClick={() => setMarkdownPreviewMode('preview')}
className={`rounded px-3 py-1 text-xs font-semibold ${!showMarkdownSource ? 'bg-[var(--app-button)] text-[var(--app-button-text)] opacity-80' : 'bg-[var(--app-subtle-bg)] text-[var(--app-hint)]'}`}
>
{t('file.page.tab.preview')}
</button>
</>
) : null}
</div>
</div>
) : null}
@@ -364,21 +405,37 @@ export default function FilePage() {
</div>
) : (
decodedContent ? (
<div className="relative">
{canCopyContent ? (
<button
type="button"
onClick={() => copyContent(decodedContent)}
className="absolute right-2 top-2 z-10 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title={t('file.page.copyContent')}
>
{contentCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
<pre className="shiki overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 pr-8 text-xs font-mono">
<code>{highlighted ?? decodedContent}</code>
</pre>
</div>
markdownFile && !showMarkdownSource ? (
<div className="markdown-content relative">
{canCopyContent ? (
<button
type="button"
onClick={() => copyContent(decodedContent)}
className="absolute right-2 top-2 z-10 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title={t('file.page.copyContent')}
>
{contentCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
<MarkdownRenderer content={decodedContent} standalone />
</div>
) : (
<div className="relative">
{canCopyContent ? (
<button
type="button"
onClick={() => copyContent(decodedContent)}
className="absolute right-2 top-2 z-10 rounded p-1 text-[var(--app-hint)] hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] transition-colors"
title={t('file.page.copyContent')}
>
{contentCopied ? <CheckIcon className="h-3.5 w-3.5" /> : <CopyIcon className="h-3.5 w-3.5" />}
</button>
) : null}
<pre className="shiki overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 pr-8 text-xs font-mono">
<code>{highlighted ?? decodedContent}</code>
</pre>
</div>
)
) : (
<div className="text-sm text-[var(--app-hint)]">{t('file.page.empty')}</div>
)