mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user