From c68cb6765ead8e3c7b539b72388795ccd95d63ed Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 06:08:21 +0530 Subject: [PATCH 1/8] Make Markdown browsing match reader expectations Render Markdown blobs by default while preserving an explicit raw source mode, so README-style files are readable without losing the existing CodeMirror path. Constraint: Issue #794 asks for rendered Markdown with a source option similar to GitHub.\nRejected: Always using CodeMirror for Markdown | Keeps documentation-style files hard to read and leaves no rendered mode.\nRejected: Enabling raw HTML rendering | Avoids expanding the browse page XSS surface for the first slice.\nConfidence: high\nScope-risk: moderate\nDirective: Keep raw source mode available when extending Markdown previews.\nTested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web test 'src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx' 'src/app/(app)/browse/hooks/utils.test.ts'\nTested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web lint\nTested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web build\nTested: git diff --check\nNot-tested: Live browser screenshot against a running indexed repository. --- .../codePreviewPanel/codePreviewPanel.tsx | 43 ++++++--- .../codePreviewPanel/markdownPreview.test.tsx | 95 +++++++++++++++++++ .../codePreviewPanel/markdownPreviewPanel.tsx | 32 +++++++ .../codePreviewPanel/markdownViewToggle.tsx | 60 ++++++++++++ .../src/app/(app)/browse/[...path]/page.tsx | 6 +- .../src/app/(app)/browse/hooks/utils.test.ts | 26 ++++- .../web/src/app/(app)/browse/hooks/utils.ts | 8 ++ 7 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreviewPanel.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownViewToggle.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx index 7209abdab..050b3cd68 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx @@ -7,9 +7,11 @@ import { cn, getCodeHostInfoForRepo, isServiceError, truncateSha } from "@/lib/u import { X } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; -import { getBrowsePath } from "../../../hooks/utils"; +import { type BlobViewMode, getBrowsePath } from "../../../hooks/utils"; import { BlameAgeLegend } from "./blameAgeLegend"; import { BlameViewToggle } from "./blameViewToggle"; +import { MarkdownPreviewPanel } from "./markdownPreviewPanel"; +import { MarkdownViewToggle } from "./markdownViewToggle"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; import { getFileBlame, getFileSource } from '@/features/git'; @@ -33,9 +35,10 @@ interface CodePreviewPanelProps { // When true, fetch blame data alongside the file source and pass it to // the editor so the blame gutter can render. blame?: boolean; + viewMode?: BlobViewMode; } -export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef, blame }: CodePreviewPanelProps) => { +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,9 @@ 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 codeHostInfo = getCodeHostInfoForRepo({ codeHostType: repoInfoResponse.codeHostType, @@ -119,6 +125,17 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe {!previewRef && (
+ {isMarkdownPreviewAvailable && ( + <> + + + + )}
)} - + {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..5be7b2839 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx @@ -0,0 +1,95 @@ +import { cleanup, render, screen } from '@testing-library/react'; +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: () =>
Blame toggle
, +})); + +vi.mock('./pureCodePreviewPanel', () => ({ + PureCodePreviewPanel: ({ source }: { source: string }) =>
{source}
, +})); + +import { CodePreviewPanel } from './codePreviewPanel'; + +afterEach(() => { + cleanup(); +}); + +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(undefined); + }); + + 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(); + }); + + 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'); + }); +}); diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreviewPanel.tsx new file mode 100644 index 000000000..772a26a4d --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreviewPanel.tsx @@ -0,0 +1,32 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +interface MarkdownPreviewPanelProps { + source: string; +} + +export const MarkdownPreviewPanel = ({ source }: MarkdownPreviewPanelProps) => { + return ( + +
*:first-child]:mt-0" + )} + > + + {source} + +
+
+ ); +} diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownViewToggle.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownViewToggle.tsx new file mode 100644 index 000000000..aa8a10d3e --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownViewToggle.tsx @@ -0,0 +1,60 @@ +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { getBrowsePath, type BlobViewMode } from "../../../hooks/utils"; +import Link from "next/link"; + +interface MarkdownViewToggleProps { + repoName: string; + revisionName?: string; + path: string; + viewMode: BlobViewMode; +} + +export const MarkdownViewToggle = ({ repoName, revisionName, path, viewMode }: MarkdownViewToggleProps) => { + const baseItemClass = "w-auto min-w-0 px-3"; + + return ( + + + + Preview + + + + + Source + + + + ); +} diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index a051e5bb2..8f1665b22 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -1,5 +1,5 @@ import { Suspense } from "react"; -import { getBrowseParamsFromPathParam } from "../hooks/utils"; +import { BLOB_VIEW_QUERY_PARAM, getBrowseParamsFromPathParam } from "../hooks/utils"; import { CodePreviewPanel } from "./components/codePreviewPanel/codePreviewPanel"; import { FocusedCommitDiffPanel } from "./components/commitDiffPanel/focusedCommitDiffPanel"; import { FullCommitDiffPanel } from "./components/commitDiffPanel/fullCommitDiffPanel"; @@ -92,6 +92,7 @@ interface BrowsePageProps { ref?: string; diff?: string; blame?: string; + view?: string; }>; } @@ -113,6 +114,7 @@ export default async function BrowsePage(props: BrowsePageProps) { const previewRef = searchParams.ref || undefined; const isDiffMode = searchParams.diff === 'true'; const isBlameMode = searchParams.blame === 'true'; + const blobViewMode = searchParams[BLOB_VIEW_QUERY_PARAM] === 'source' ? 'source' : 'rendered'; return (
@@ -138,6 +140,7 @@ export default async function BrowsePage(props: BrowsePageProps) { revisionName={revisionName} previewRef={previewRef} blame={isBlameMode} + viewMode={blobViewMode} /> ) ) : browseProps.pathType === 'commits' ? ( @@ -166,4 +169,3 @@ export default async function BrowsePage(props: BrowsePageProps) {
) } - diff --git a/packages/web/src/app/(app)/browse/hooks/utils.test.ts b/packages/web/src/app/(app)/browse/hooks/utils.test.ts index a50214c82..838d69fcd 100644 --- a/packages/web/src/app/(app)/browse/hooks/utils.test.ts +++ b/packages/web/src/app/(app)/browse/hooks/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getBrowseParamsFromPathParam } from './utils'; +import { getBrowseParamsFromPathParam, getBrowsePath } from './utils'; describe('getBrowseParamsFromPathParam', () => { describe('tree paths', () => { @@ -211,4 +211,26 @@ describe('getBrowseParamsFromPathParam', () => { }).toThrow(); }); }); -}); \ No newline at end of file +}); + +describe('getBrowsePath', () => { + it('adds source view mode for blob paths', () => { + expect(getBrowsePath({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'main', + path: 'README.md', + pathType: 'blob', + viewMode: 'source', + })).toBe('/browse/github.com/sourcebot-dev/zoekt@main/-/blob/README.md?view=source'); + }); + + it('omits rendered view mode because it is the default', () => { + expect(getBrowsePath({ + repoName: 'github.com/sourcebot-dev/zoekt', + revisionName: 'main', + path: 'README.md', + pathType: 'blob', + viewMode: 'rendered', + })).toBe('/browse/github.com/sourcebot-dev/zoekt@main/-/blob/README.md'); + }); +}); diff --git a/packages/web/src/app/(app)/browse/hooks/utils.ts b/packages/web/src/app/(app)/browse/hooks/utils.ts index 7ea1863c6..3eb9af0c7 100644 --- a/packages/web/src/app/(app)/browse/hooks/utils.ts +++ b/packages/web/src/app/(app)/browse/hooks/utils.ts @@ -4,6 +4,8 @@ export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange'; export const PREVIEW_REF_QUERY_PARAM = 'ref'; export const DIFF_QUERY_PARAM = 'diff'; export const BLAME_QUERY_PARAM = 'blame'; +export const BLOB_VIEW_QUERY_PARAM = 'view'; +export type BlobViewMode = 'rendered' | 'source'; export type BrowseHighlightRange = { start: { lineNumber: number; column: number; }; @@ -31,6 +33,8 @@ type BlobProps = BaseProps & { diff?: boolean; // When true, render blame annotations alongside the file source. blame?: boolean; + // Markdown files render by default. Set to source to show the raw file. + viewMode?: BlobViewMode; } type TreeProps = BaseProps & { @@ -172,6 +176,10 @@ export const getBrowsePath = (props: BrowseProps) => { params.set(BLAME_QUERY_PARAM, 'true'); } + if (pathType === 'blob' && props.viewMode === 'source') { + params.set(BLOB_VIEW_QUERY_PARAM, 'source'); + } + if (setBrowseState) { params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState)); } From a93e0fa7afd10b6d0e903031b8fef1873b22cd86 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 06:09:21 +0530 Subject: [PATCH 2/8] Record Markdown preview support for release notes Add the required Unreleased changelog entry after the draft PR number is known. Constraint: Sourcebot contribution rules require every PR to include a changelog follow-up commit.\nConfidence: high\nScope-risk: narrow\nDirective: Keep this as the PR-number-specific follow-up commit.\nTested: git diff --check\nNot-tested: Full test suite not rerun for changelog-only text change. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0725af08..d923cd7d5 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) From d859d061501c449ed635a079493f2acee1c00677 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 08:35:49 +0530 Subject: [PATCH 3/8] Harden repository markdown browse previews Repository-authored Markdown is untrusted browser content, so rendered browse previews must preserve existing source-navigation semantics while avoiding automatic remote asset loads. This keeps rendered links useful, turns images into explicit non-fetching links, and forces source mode for line-highlight URLs. Constraint: Browse markdown rendering is now the default path for Markdown blobs. Rejected: Auto-rendering markdown images | it can trigger third-party or local-network requests from viewer browsers. Confidence: high Scope-risk: narrow Directive: Keep repository Markdown rendering conservative unless a hardened asset proxy/raw-file pipeline is added. Tested: yarn workspace @sourcebot/web test src/app/(app)/browse/[...path]/components/codePreviewPanel/markdownPreview.test.tsx src/app/(app)/browse/[...path]/page.test.tsx src/app/(app)/browse/hooks/utils.test.ts; yarn workspace @sourcebot/web lint; yarn workspace @sourcebot/web build; yarn workspace @sourcebot/web test; git diff --check --cached Not-tested: Browser screenshot of rendered Markdown preview --- .../codePreviewPanel/codePreviewPanel.tsx | 7 +- .../codePreviewPanel/markdownPreview.test.tsx | 133 +++++++++++++++++- .../codePreviewPanel/markdownPreviewPanel.tsx | 109 +++++++++++++- .../app/(app)/browse/[...path]/page.test.tsx | 65 +++++++++ .../src/app/(app)/browse/[...path]/page.tsx | 6 +- 5 files changed, 313 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/page.test.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx index 050b3cd68..eb731aa18 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx @@ -196,7 +196,12 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe )} {shouldRenderMarkdown ? ( - + ) : ( ({ @@ -25,15 +26,22 @@ vi.mock('./blameViewToggle', () => ({ })); vi.mock('./pureCodePreviewPanel', () => ({ - PureCodePreviewPanel: ({ source }: { source: string }) =>
{source}
, + 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(); @@ -44,7 +52,10 @@ describe('CodePreviewPanel markdown preview', () => { codeHostType: 'github', externalWebUrl: 'https://github.com/sourcebot-dev/sourcebot', }); - mocks.getFileBlame.mockResolvedValue(undefined); + mocks.getFileBlame.mockResolvedValue({ + ranges: [], + commits: {}, + }); }); test('renders markdown files as markdown by default', async () => { @@ -92,4 +103,122 @@ describe('CodePreviewPanel markdown preview', () => { expect(screen.queryByRole('heading', { name: 'Project README' })).toBeNull(); expect(screen.queryByTestId('raw-source')?.textContent).toContain('# Project README'); }); + + 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('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(/