{
+export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef, blame, viewMode = 'rendered' }: CodePreviewPanelProps) => {
const contentRef = previewRef ?? revisionName;
const [fileSourceResponse, repoInfoResponse, blameResponse] = await Promise.all([
@@ -72,6 +75,15 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
: source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
const byteSize = Buffer.byteLength(source, 'utf-8');
const fileSize = formatFileSize(byteSize);
+ const isMarkdown = fileSourceResponse.language === 'Markdown';
+ const isMarkdownPreviewAvailable = isMarkdown && !blame && !previewRef;
+ const shouldRenderMarkdown = isMarkdownPreviewAvailable && viewMode === 'rendered';
+ const nonBlameViewLabel = isMarkdown
+ ? viewMode === 'source' ? 'Source' : 'Preview'
+ : 'Code';
+ const nonBlameViewAriaLabel = isMarkdown
+ ? viewMode === 'source' ? 'View raw markdown source' : 'Preview rendered markdown'
+ : 'View source code';
const codeHostInfo = getCodeHostInfoForRepo({
codeHostType: repoInfoResponse.codeHostType,
@@ -119,11 +131,25 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
{!previewRef && (
+ {isMarkdownPreviewAvailable && (
+ <>
+
+
+ >
+ )}
{lineCount.toLocaleString()} lines ยท {fileSize}
@@ -167,6 +193,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
revisionName,
path,
pathType: 'blob',
+ viewMode: viewMode === 'source' ? 'source' : undefined,
})}
aria-label="Close preview"
>
@@ -178,14 +205,24 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
)}
-
+ {shouldRenderMarkdown ? (
+
+ ) : (
+
+ )}
>
)
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx
new file mode 100644
index 000000000..5afc676d2
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx
@@ -0,0 +1,397 @@
+import { cleanup, render, screen } from '@testing-library/react';
+import { TooltipProvider } from '@/components/ui/tooltip';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ getRepoInfoByName: vi.fn(),
+ getFileSource: vi.fn(),
+ getFileBlame: vi.fn(),
+}));
+
+vi.mock('@/actions', () => ({
+ getRepoInfoByName: mocks.getRepoInfoByName,
+}));
+
+vi.mock('@/features/git', () => ({
+ getFileSource: mocks.getFileSource,
+ getFileBlame: mocks.getFileBlame,
+}));
+
+vi.mock('@/app/(app)/components/pathHeader', () => ({
+ PathHeader: ({ path }: { path: string }) => Path: {path}
,
+}));
+
+vi.mock('./blameViewToggle', () => ({
+ BlameViewToggle: ({ codeLabel }: { codeLabel?: string }) => (
+ {codeLabel}
+ ),
+}));
+
+vi.mock('./pureCodePreviewPanel', () => ({
+ PureCodePreviewPanel: ({ source, blame }: { source: string; blame?: unknown }) => (
+ {source}
+ ),
+}));
+
+import { CodePreviewPanel } from './codePreviewPanel';
+import { MarkdownPreviewPanel } from './markdownPreviewPanel';
+
+afterEach(() => {
+ cleanup();
+});
+
+const renderWithTooltipProvider = (ui: React.ReactNode) => render(
+ {ui}
+);
+
+describe('CodePreviewPanel markdown preview', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mocks.getRepoInfoByName.mockResolvedValue({
+ name: 'github.com/sourcebot-dev/sourcebot',
+ displayName: 'sourcebot-dev/sourcebot',
+ codeHostType: 'github',
+ externalWebUrl: 'https://github.com/sourcebot-dev/sourcebot',
+ });
+ mocks.getFileBlame.mockResolvedValue({
+ ranges: [],
+ commits: {},
+ });
+ });
+
+ test('renders markdown files as markdown by default', async () => {
+ mocks.getFileSource.mockResolvedValue({
+ source: '# Project README\n\n- fast search\n- code intelligence',
+ language: 'Markdown',
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ repoCodeHostType: 'github',
+ repoDisplayName: 'sourcebot-dev/sourcebot',
+ repoExternalWebUrl: 'https://github.com/sourcebot-dev/sourcebot',
+ webUrl: 'https://sourcebot.example.com/browse/github.com/sourcebot-dev/sourcebot/-/blob/README.md',
+ });
+
+ render(await CodePreviewPanel({
+ path: 'README.md',
+ repoName: 'github.com/sourcebot-dev/sourcebot',
+ revisionName: 'main',
+ }));
+
+ expect(screen.queryByRole('heading', { name: 'Project README' })).toBeTruthy();
+ expect(screen.queryByText('fast search')).toBeTruthy();
+ expect(screen.queryByTestId('raw-source')).toBeNull();
+ expect(screen.getByTestId('blame-view-toggle').textContent).toBe('Preview');
+ });
+
+ test('keeps raw source view available for markdown files', async () => {
+ mocks.getFileSource.mockResolvedValue({
+ source: '# Project README\n\n- fast search\n- code intelligence',
+ language: 'Markdown',
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ repoCodeHostType: 'github',
+ repoDisplayName: 'sourcebot-dev/sourcebot',
+ repoExternalWebUrl: 'https://github.com/sourcebot-dev/sourcebot',
+ webUrl: 'https://sourcebot.example.com/browse/github.com/sourcebot-dev/sourcebot/-/blob/README.md',
+ });
+
+ render(await CodePreviewPanel({
+ path: 'README.md',
+ repoName: 'github.com/sourcebot-dev/sourcebot',
+ revisionName: 'main',
+ viewMode: 'source',
+ }));
+
+ expect(screen.queryByRole('heading', { name: 'Project README' })).toBeNull();
+ expect(screen.queryByTestId('raw-source')?.textContent).toContain('# Project README');
+ expect(screen.getByTestId('blame-view-toggle').textContent).toBe('Source');
+ });
+
+ test('keeps markdown in raw source view when blame is enabled', async () => {
+ mocks.getFileSource.mockResolvedValue({
+ source: '# Project README\n\n- fast search',
+ language: 'Markdown',
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ repoCodeHostType: 'github',
+ repoDisplayName: 'sourcebot-dev/sourcebot',
+ repoExternalWebUrl: 'https://github.com/sourcebot-dev/sourcebot',
+ webUrl: 'https://sourcebot.example.com/browse/github.com/sourcebot-dev/sourcebot/-/blob/README.md',
+ });
+
+ render(await CodePreviewPanel({
+ path: 'README.md',
+ repoName: 'github.com/sourcebot-dev/sourcebot',
+ revisionName: 'main',
+ blame: true,
+ }));
+
+ expect(screen.queryByRole('heading', { name: 'Project README' })).toBeNull();
+ expect(screen.queryByTestId('raw-source')?.textContent).toContain('# Project README');
+ expect(mocks.getFileBlame).toHaveBeenCalledWith({
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ ref: 'main',
+ }, { source: 'sourcebot-web-client' });
+ });
+
+ test('keeps markdown in raw source view when previewing another revision', async () => {
+ mocks.getFileSource.mockResolvedValue({
+ source: '# Previous README',
+ language: 'Markdown',
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ repoCodeHostType: 'github',
+ repoDisplayName: 'sourcebot-dev/sourcebot',
+ repoExternalWebUrl: 'https://github.com/sourcebot-dev/sourcebot',
+ webUrl: 'https://sourcebot.example.com/browse/github.com/sourcebot-dev/sourcebot/-/blob/README.md',
+ });
+
+ renderWithTooltipProvider(await CodePreviewPanel({
+ path: 'README.md',
+ repoName: 'github.com/sourcebot-dev/sourcebot',
+ revisionName: 'main',
+ previewRef: 'abc123456789',
+ }));
+
+ expect(screen.queryByRole('heading', { name: 'Previous README' })).toBeNull();
+ expect(screen.queryByTestId('raw-source')?.textContent).toContain('# Previous README');
+ expect(mocks.getFileSource).toHaveBeenCalledWith({
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ ref: 'abc123456789',
+ }, { source: 'sourcebot-web-client' });
+ });
+
+ test('preserves source view when closing a revision preview', async () => {
+ mocks.getFileSource.mockResolvedValue({
+ source: '# Previous README',
+ language: 'Markdown',
+ path: 'README.md',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ repoCodeHostType: 'github',
+ repoDisplayName: 'sourcebot-dev/sourcebot',
+ repoExternalWebUrl: 'https://github.com/sourcebot-dev/sourcebot',
+ webUrl: 'https://sourcebot.example.com/browse/github.com/sourcebot-dev/sourcebot/-/blob/README.md',
+ });
+
+ renderWithTooltipProvider(await CodePreviewPanel({
+ path: 'README.md',
+ repoName: 'github.com/sourcebot-dev/sourcebot',
+ revisionName: 'main',
+ previewRef: 'abc123456789',
+ viewMode: 'source',
+ }));
+
+ expect(screen.getByLabelText('Close preview').getAttribute('href')).toBe(
+ '/browse/github.com/sourcebot-dev/sourcebot@main/-/blob/README.md?view=source'
+ );
+ });
+
+ test('does not mount raw html from repository markdown', () => {
+ const { container } = render(
+ alert("xss")\n\n
'}
+ repoName="github.com/sourcebot-dev/sourcebot"
+ revisionName="main"
+ path="README.md"
+ />
+ );
+
+ expect(container.querySelector('script')).toBeNull();
+ expect(container.querySelector('img')).toBeNull();
+ expect(screen.queryByText(/