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,33 @@
|
||||
/*
|
||||
* Playwright smoke for issue #954 — markdown Source | Preview toggle in
|
||||
* the session file pane. Drives the Vite fixture that mounts the same
|
||||
* MarkdownRenderer + toggle affordance as production `file.tsx`.
|
||||
*/
|
||||
|
||||
import { test, expect } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const SCREENSHOT_PATH = path.resolve(
|
||||
process.env.HOME ?? '',
|
||||
'coding/hapi/localdocs/playwright-runs/954-file-md-preview.png'
|
||||
)
|
||||
|
||||
test.describe('file markdown preview e2e', () => {
|
||||
test('preview renders heading and table; source shows raw markdown', async ({ page }) => {
|
||||
await page.goto('/e2e-fixtures/file-md-preview-fixture.html')
|
||||
await expect(page.getByTestId('file-md-preview-fixture')).toBeVisible()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible()
|
||||
await expect(page.getByRole('cell', { name: 'general' })).toBeVisible()
|
||||
await expect(page.locator('.aui-md-codeblock')).toBeVisible()
|
||||
|
||||
await page.getByTestId('markdown-mode-source').click()
|
||||
await expect(page.getByTestId('markdown-source-view')).toContainText('# Teams and channels')
|
||||
await expect(page.getByRole('heading', { name: 'Teams and channels' })).toHaveCount(0)
|
||||
|
||||
await page.getByTestId('markdown-mode-preview').click()
|
||||
await expect(page.getByRole('heading', { name: 'Teams and channels' })).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: SCREENSHOT_PATH, fullPage: true })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const PORT = 5179
|
||||
const PORT = Number(process.env.PLAYWRIGHT_WEB_PORT ?? 5179)
|
||||
const BASE_URL = `http://localhost:${PORT}`
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HAPI file markdown preview e2e fixture</title>
|
||||
<style>
|
||||
html { background: #fff }
|
||||
html[data-theme="dark"] { background: #1c1c1e; color-scheme: dark }
|
||||
body { margin: 0; padding: 16px; font-family: system-ui, sans-serif }
|
||||
#root { max-width: 720px; margin: 0 auto }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./file-md-preview-fixture.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Standalone Vite-served fixture for the file-pane markdown Source |
|
||||
* Preview Playwright smoke. Mounts the same toggle + MarkdownRenderer
|
||||
* path as `file.tsx` without the HAPI auth / git / socket stack.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import '../src/index.css'
|
||||
import { I18nProvider } from '../src/lib/i18n-context'
|
||||
import { MarkdownRenderer } from '../src/components/MarkdownRenderer'
|
||||
import {
|
||||
getInitialMarkdownPreviewMode,
|
||||
persistMarkdownPreviewMode,
|
||||
type MarkdownPreviewMode,
|
||||
} from '../src/lib/file-markdown-preview'
|
||||
import { useTranslation } from '../src/lib/use-translation'
|
||||
|
||||
const SAMPLE_MARKDOWN = `# Teams and channels
|
||||
|
||||
| Channel | Purpose |
|
||||
| --- | --- |
|
||||
| general | Day-to-day coordination |
|
||||
| incidents | Outage response |
|
||||
|
||||
\`\`\`ts
|
||||
export const ok = true
|
||||
\`\`\`
|
||||
|
||||
> Preview uses the same markdown pipeline as chat.
|
||||
`
|
||||
|
||||
function FileMarkdownPreviewFixture() {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = React.useState<MarkdownPreviewMode>(() => getInitialMarkdownPreviewMode())
|
||||
const showSource = mode === 'source'
|
||||
|
||||
const setMarkdownPreviewMode = (next: MarkdownPreviewMode) => {
|
||||
setMode(next)
|
||||
persistMarkdownPreviewMode(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="file-md-preview-fixture" className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="markdown-mode-source"
|
||||
onClick={() => setMarkdownPreviewMode('source')}
|
||||
className={`rounded px-3 py-1 text-xs font-semibold ${showSource ? '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"
|
||||
data-testid="markdown-mode-preview"
|
||||
onClick={() => setMarkdownPreviewMode('preview')}
|
||||
className={`rounded px-3 py-1 text-xs font-semibold ${!showSource ? '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>
|
||||
</div>
|
||||
{showSource ? (
|
||||
<pre
|
||||
data-testid="markdown-source-view"
|
||||
className="overflow-auto rounded-md bg-[var(--app-code-bg)] p-3 text-xs font-mono"
|
||||
>
|
||||
<code>{SAMPLE_MARKDOWN}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div data-testid="markdown-preview-view" className="markdown-content">
|
||||
<MarkdownRenderer content={SAMPLE_MARKDOWN} standalone />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById('root')
|
||||
if (rootEl) {
|
||||
ReactDOM.createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<I18nProvider>
|
||||
<FileMarkdownPreviewFixture />
|
||||
</I18nProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { I18nProvider } from '@/lib/i18n-context'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
|
||||
describe('MarkdownRenderer', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders fenced code blocks with the shared syntax highlighter shell in standalone mode', () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<MarkdownRenderer standalone content={'```ts\nexport const ok = true\n```'} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(document.querySelector('.aui-md-codeblock')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders inline code without the fenced-code shell in standalone mode', () => {
|
||||
render(
|
||||
<I18nProvider>
|
||||
<MarkdownRenderer standalone content={'Use `npm test` here.'} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
expect(document.querySelector('.aui-md-codeblock')).toBeFalsy()
|
||||
expect(document.querySelector('.aui-md-code')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { MarkdownTextPrimitiveProps } from '@assistant-ui/react-markdown'
|
||||
import { MarkdownTextPrimitive } from '@assistant-ui/react-markdown'
|
||||
import { TextMessagePartProvider } from '@assistant-ui/react'
|
||||
import { Children, isValidElement, useMemo, type ComponentPropsWithoutRef, type ComponentType } from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import {
|
||||
MARKDOWN_PLUGINS,
|
||||
MARKDOWN_PLUGINS_WITH_BREAKS,
|
||||
@@ -11,6 +13,8 @@ import {
|
||||
denyOnlyTransform,
|
||||
UriConfirmProvider,
|
||||
} from '@/components/assistant-ui/markdown-text'
|
||||
import { SyntaxHighlighter } from '@/components/assistant-ui/shiki-highlighter'
|
||||
import type { CodeHeaderProps, SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
@@ -18,6 +22,73 @@ interface MarkdownRendererProps {
|
||||
components?: MarkdownTextPrimitiveProps['components']
|
||||
className?: string
|
||||
preserveSingleLineBreaks?: boolean
|
||||
/** Render outside assistant-ui thread context (file pane, fixtures). */
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
function StandaloneCode(props: ComponentPropsWithoutRef<'code'>) {
|
||||
const Code = defaultComponents.code!
|
||||
return <Code {...props} />
|
||||
}
|
||||
|
||||
function StandalonePre(props: ComponentPropsWithoutRef<'pre'>) {
|
||||
const child = Children.toArray(props.children)[0]
|
||||
if (!isValidElement<ComponentPropsWithoutRef<'code'>>(child)) {
|
||||
const Pre = defaultComponents.pre!
|
||||
return <Pre {...props} />
|
||||
}
|
||||
|
||||
const className = String(child.props.className ?? '')
|
||||
const language = /language-(\w+)/.exec(className)?.[1] ?? 'unknown'
|
||||
const code = String(child.props.children ?? '').replace(/\n$/, '')
|
||||
const Highlighter: ComponentType<SyntaxHighlighterProps> =
|
||||
MARKDOWN_COMPONENTS_BY_LANGUAGE[language as keyof typeof MARKDOWN_COMPONENTS_BY_LANGUAGE]?.SyntaxHighlighter
|
||||
?? SyntaxHighlighter
|
||||
const CodeHeader = defaultComponents.CodeHeader as ComponentType<CodeHeaderProps>
|
||||
const Pre = defaultComponents.pre!
|
||||
const Code = defaultComponents.code!
|
||||
|
||||
return (
|
||||
<>
|
||||
<CodeHeader language={language} code={code} />
|
||||
<Highlighter language={language} code={code} components={{ Pre, Code }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function StandaloneMarkdownContent(props: MarkdownRendererProps) {
|
||||
const mergedComponents = props.components
|
||||
? { ...defaultComponents, ...props.components }
|
||||
: defaultComponents
|
||||
|
||||
const {
|
||||
pre: _pre,
|
||||
code: _code,
|
||||
SyntaxHighlighter: _sh,
|
||||
CodeHeader: _header,
|
||||
...componentsRest
|
||||
} = mergedComponents as typeof mergedComponents & Record<string, unknown>
|
||||
|
||||
const components = useMemo<Components>(() => ({
|
||||
...(componentsRest as Components),
|
||||
pre: StandalonePre,
|
||||
code: StandaloneCode,
|
||||
}), [componentsRest])
|
||||
|
||||
return (
|
||||
<UriConfirmProvider>
|
||||
<div className={cn(MARKDOWN_CLASSNAME, props.className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={props.preserveSingleLineBreaks ? MARKDOWN_PLUGINS_WITH_BREAKS : MARKDOWN_PLUGINS}
|
||||
rehypePlugins={MARKDOWN_REHYPE_PLUGINS}
|
||||
components={components}
|
||||
urlTransform={denyOnlyTransform}
|
||||
>
|
||||
{props.content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</UriConfirmProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownContent(props: MarkdownRendererProps) {
|
||||
@@ -42,5 +113,8 @@ function MarkdownContent(props: MarkdownRendererProps) {
|
||||
}
|
||||
|
||||
export function MarkdownRenderer(props: MarkdownRendererProps) {
|
||||
if (props.standalone) {
|
||||
return <StandaloneMarkdownContent {...props} />
|
||||
}
|
||||
return <MarkdownContent {...props} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_MARKDOWN_PREVIEW_MODE,
|
||||
MARKDOWN_PREVIEW_MODE_STORAGE_KEY,
|
||||
getInitialMarkdownPreviewMode,
|
||||
isMarkdownFile,
|
||||
persistMarkdownPreviewMode,
|
||||
} from './file-markdown-preview'
|
||||
|
||||
describe('file-markdown-preview helpers', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('detects markdown file extensions', () => {
|
||||
expect(isMarkdownFile('README.md')).toBe(true)
|
||||
expect(isMarkdownFile('docs/guide/page.mdx')).toBe(true)
|
||||
expect(isMarkdownFile('src/file.ts')).toBe(false)
|
||||
expect(isMarkdownFile('noext')).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to preview when storage is missing or invalid', () => {
|
||||
expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
|
||||
window.localStorage.setItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, 'nope')
|
||||
expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
|
||||
})
|
||||
|
||||
it('reads and persists a valid preview mode', () => {
|
||||
persistMarkdownPreviewMode('source')
|
||||
expect(getInitialMarkdownPreviewMode()).toBe('source')
|
||||
|
||||
persistMarkdownPreviewMode('preview')
|
||||
expect(window.localStorage.getItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)).toBeNull()
|
||||
expect(getInitialMarkdownPreviewMode()).toBe(DEFAULT_MARKDOWN_PREVIEW_MODE)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
export type MarkdownPreviewMode = 'source' | 'preview'
|
||||
|
||||
export const MARKDOWN_PREVIEW_MODE_STORAGE_KEY = 'hapi.filePreview.markdownMode.v1'
|
||||
export const DEFAULT_MARKDOWN_PREVIEW_MODE: MarkdownPreviewMode = 'preview'
|
||||
|
||||
function isBrowser(): boolean {
|
||||
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
||||
}
|
||||
|
||||
function safeGetItem(key: string): string | null {
|
||||
if (!isBrowser()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeSetItem(key: string, value: string): void {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
function safeRemoveItem(key: string): void {
|
||||
if (!isBrowser()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function isMarkdownFile(path: string): boolean {
|
||||
const parts = path.split('.')
|
||||
if (parts.length <= 1) {
|
||||
return false
|
||||
}
|
||||
const ext = parts[parts.length - 1]?.toLowerCase()
|
||||
return ext === 'md' || ext === 'mdx'
|
||||
}
|
||||
|
||||
function parseMarkdownPreviewMode(raw: string | null): MarkdownPreviewMode {
|
||||
if (raw === 'source' || raw === 'preview') {
|
||||
return raw
|
||||
}
|
||||
return DEFAULT_MARKDOWN_PREVIEW_MODE
|
||||
}
|
||||
|
||||
export function getInitialMarkdownPreviewMode(): MarkdownPreviewMode {
|
||||
return parseMarkdownPreviewMode(safeGetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY))
|
||||
}
|
||||
|
||||
export function persistMarkdownPreviewMode(mode: MarkdownPreviewMode): void {
|
||||
if (mode === DEFAULT_MARKDOWN_PREVIEW_MODE) {
|
||||
safeRemoveItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY)
|
||||
return
|
||||
}
|
||||
safeSetItem(MARKDOWN_PREVIEW_MODE_STORAGE_KEY, mode)
|
||||
}
|
||||
@@ -336,6 +336,8 @@ export default {
|
||||
'file.page.download': 'Download file',
|
||||
'file.page.tab.diff': 'Diff',
|
||||
'file.page.tab.file': 'File',
|
||||
'file.page.tab.source': 'Source',
|
||||
'file.page.tab.preview': 'Preview',
|
||||
'file.page.missingPath': 'No file path provided.',
|
||||
'file.page.binary': 'This looks like a binary file. It cannot be displayed.',
|
||||
'file.page.imagePreviewAlt': 'Image preview for {name}',
|
||||
|
||||
@@ -340,6 +340,8 @@ export default {
|
||||
'file.page.download': '下载文件',
|
||||
'file.page.tab.diff': 'Diff',
|
||||
'file.page.tab.file': '文件',
|
||||
'file.page.tab.source': '源码',
|
||||
'file.page.tab.preview': '预览',
|
||||
'file.page.missingPath': '未提供文件路径。',
|
||||
'file.page.binary': '该文件看起来是二进制文件,无法显示。',
|
||||
'file.page.imagePreviewAlt': '{name} 图片预览',
|
||||
|
||||
@@ -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,9 +331,11 @@ 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)]">
|
||||
{diffContent ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDisplayMode('diff')}
|
||||
@@ -330,6 +350,27 @@ export default function FilePage() {
|
||||
>
|
||||
{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,6 +405,21 @@ export default function FilePage() {
|
||||
</div>
|
||||
) : (
|
||||
decodedContent ? (
|
||||
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
|
||||
@@ -379,6 +435,7 @@ export default function FilePage() {
|
||||
<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