diff --git a/CHANGELOG.md b/CHANGELOG.md index 19358afd3..b9d5f69ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [EE] Added mermaid diagram rendering to Ask Sourcebot answers, with pan/zoom, copy/export, in-thread deep links, and an interleaved right-panel view. [#1369](https://github.com/sourcebot-dev/sourcebot/pull/1369) - [EE] Added a context-window usage gauge to the Ask Sourcebot chat details, showing how much of the selected model's context window each turn occupies. Window sizes are resolved from the models.dev catalog. [#1370](https://github.com/sourcebot-dev/sourcebot/pull/1370) - Added language model input-modality and document capability resolution, automatically resolved from the models.dev catalog (falls back to text-only for uncatalogued/self-hosted models). [#1372](https://github.com/sourcebot-dev/sourcebot/pull/1372) +- Added rendered Markdown previews with a raw source toggle in the code browser. [#1382](https://github.com/sourcebot-dev/sourcebot/pull/1382) ### Fixed - Send anonymous server-side PostHog events as personless so unauthenticated requests don't inflate person counts. [#1367](https://github.com/sourcebot-dev/sourcebot/pull/1367) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.test.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.test.tsx new file mode 100644 index 000000000..b865da5b0 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { BlameViewToggle } from './blameViewToggle'; + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), +})); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mocks.push, + }), +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('BlameViewToggle', () => { + test('preserves source view when entering blame mode', () => { + render( + + ); + + fireEvent.click(screen.getByRole('radio', { name: 'View blame' })); + + expect(mocks.push).toHaveBeenCalledWith( + '/browse/github.com/sourcebot-dev/sourcebot@main/-/blob/README.md?blame=true&view=source' + ); + }); + + test('preserves source view when leaving blame mode', () => { + render( + + ); + + fireEvent.click(screen.getByRole('radio', { name: 'View source code' })); + + expect(mocks.push).toHaveBeenCalledWith( + '/browse/github.com/sourcebot-dev/sourcebot@main/-/blob/README.md?view=source' + ); + }); + + test('does not add a view query when rendered preview mode enters blame', () => { + render( + + ); + + fireEvent.click(screen.getByRole('radio', { name: 'View blame' })); + + expect(mocks.push).toHaveBeenCalledWith( + '/browse/github.com/sourcebot-dev/sourcebot@main/-/blob/README.md?blame=true' + ); + }); + + test('supports markdown-specific non-blame labels', () => { + render( + + ); + + expect(screen.getByRole('radio', { name: 'Preview rendered markdown' }).textContent).toBe('Preview'); + }); +}); diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx index a8a1d863b..844785db8 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/blameViewToggle.tsx @@ -2,16 +2,27 @@ import { useRouter } from "next/navigation"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { getBrowsePath } from "@/app/(app)/browse/hooks/utils"; +import { getBrowsePath, type BlobViewMode } from "@/app/(app)/browse/hooks/utils"; interface BlameViewToggleProps { repoName: string; revisionName?: string; path: string; blame: boolean; + viewMode?: BlobViewMode; + codeLabel?: string; + codeAriaLabel?: string; } -export const BlameViewToggle = ({ repoName, revisionName, path, blame }: BlameViewToggleProps) => { +export const BlameViewToggle = ({ + repoName, + revisionName, + path, + blame, + viewMode, + codeLabel = 'Code', + codeAriaLabel = 'View source code', +}: BlameViewToggleProps) => { const router = useRouter(); const handleValueChange = (value: string) => { @@ -27,6 +38,7 @@ export const BlameViewToggle = ({ repoName, revisionName, path, blame }: BlameVi path, pathType: 'blob', blame: value === 'blame', + viewMode: viewMode === 'source' ? 'source' : undefined, })); }; @@ -48,10 +60,10 @@ export const BlameViewToggle = ({ repoName, revisionName, path, blame }: BlameVi > - Code + {codeLabel} { +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(/