From 6eeb568a984247922cc33b5c5904a617a1c38a39 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 05:54:01 +0530 Subject: [PATCH 1/6] Show a useful browse 404 for missing files Render a dedicated missing-file state when the browse file-source API returns FILE_NOT_FOUND, preserving the active revision in the return-to-root link. Constraint: Prior closed PR #837 lost branch context and introduced a client-side component; this keeps the new panel server-rendered and revision-aware. Rejected: Keeping the raw service-error text | users need a browse-specific recovery path for deleted, moved, or branch-specific files. Confidence: high Scope-risk: narrow Directive: Keep other file-source service errors on the existing diagnostic error path; only FILE_NOT_FOUND should render this panel. Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web test 'src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx' Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web lint Not-tested: Manual browser navigation to a missing blob path. --- .../codePreviewPanel.test.tsx | 70 +++++++++++++++++++ .../codePreviewPanel/codePreviewPanel.tsx | 27 +++++-- .../codePreviewPanel/fileNotFoundPanel.tsx | 62 ++++++++++++++++ 3 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/fileNotFoundPanel.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx new file mode 100644 index 000000000..c6923241b --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { ErrorCode } from '@/lib/errorCodes'; + +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('./pureCodePreviewPanel', () => ({ + PureCodePreviewPanel: () =>
Code preview
, +})); + +vi.mock('@/ee/features/codeNav/components/symbolHoverPopup', () => ({ + SymbolHoverPopup: () => null, +})); + +import { CodePreviewPanel } from './codePreviewPanel'; + +afterEach(() => { + cleanup(); +}); + +describe('CodePreviewPanel', () => { + 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', + }); + }); + + test('renders a browse 404 when the requested file does not exist', async () => { + mocks.getFileSource.mockResolvedValue({ + statusCode: 404, + errorCode: ErrorCode.FILE_NOT_FOUND, + message: 'File "src/missing.ts" not found in repository "github.com/sourcebot-dev/sourcebot"', + }); + + render(await CodePreviewPanel({ + path: 'src/missing.ts', + repoName: 'github.com/sourcebot-dev/sourcebot', + revisionName: 'feature-branch', + })); + + expect(screen.queryByText('File not found')).toBeTruthy(); + expect(screen.queryAllByText(/src\/missing\.ts/).length).toBeGreaterThan(0); + expect(screen.queryByText(/Error loading file source/)).toBeNull(); + expect(screen.getByRole('link', { name: 'Return to repository root' }).getAttribute('href')).toBe( + '/browse/github.com/sourcebot-dev/sourcebot@feature-branch/-/tree' + ); + }); +}); 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..c9ee3314a 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 @@ -3,6 +3,7 @@ import { PathHeader } from "@/app/(app)/components/pathHeader"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ErrorCode } from "@/lib/errorCodes"; import { cn, getCodeHostInfoForRepo, isServiceError, truncateSha } from "@/lib/utils"; import { X } from "lucide-react"; import Image from "next/image"; @@ -10,6 +11,7 @@ import Link from "next/link"; import { getBrowsePath } from "../../../hooks/utils"; import { BlameAgeLegend } from "./blameAgeLegend"; import { BlameViewToggle } from "./blameViewToggle"; +import { FileNotFoundPanel } from "./fileNotFoundPanel"; import { PureCodePreviewPanel } from "./pureCodePreviewPanel"; import { getFileBlame, getFileSource } from '@/features/git'; @@ -54,14 +56,29 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe : Promise.resolve(undefined), ]); - if (isServiceError(fileSourceResponse)) { - return
Error loading file source: {fileSourceResponse.message}
- } - if (isServiceError(repoInfoResponse)) { return
Error loading repo info: {repoInfoResponse.message}
} + if (isServiceError(fileSourceResponse)) { + if (fileSourceResponse.errorCode === ErrorCode.FILE_NOT_FOUND) { + return ( + + ); + } + + return
Error loading file source: {fileSourceResponse.message}
+ } + if (blameResponse !== undefined && isServiceError(blameResponse)) { return
Error loading blame: {blameResponse.message}
} @@ -188,4 +205,4 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe /> ) -} \ No newline at end of file +} diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/fileNotFoundPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/fileNotFoundPanel.tsx new file mode 100644 index 000000000..568a5c946 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/fileNotFoundPanel.tsx @@ -0,0 +1,62 @@ +import { PathHeader } from "@/app/(app)/components/pathHeader"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { FileQuestion } from "lucide-react"; +import Link from "next/link"; +import { getBrowsePath } from "../../../hooks/utils"; +import type { CodeHostType } from "@sourcebot/db"; + +interface FileNotFoundPanelProps { + path: string; + repoName: string; + revisionName?: string; + repo: { + codeHostType: CodeHostType; + displayName?: string; + externalWebUrl?: string; + }; +} + +export const FileNotFoundPanel = ({ path, repoName, revisionName, repo }: FileNotFoundPanelProps) => { + return ( + <> +
+ +
+ +
+
+ +
+
+

File not found

+

+ The path {path} does not exist + {revisionName ? <> at {revisionName} : null}. +

+
+ +
+ + ); +} From 17427376984a178cb34548cd62fcc85daf42f465 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 05:54:46 +0530 Subject: [PATCH 2/6] Record browse missing-file state in changelog Constraint: Sourcebot requires a follow-up changelog entry for each PR under the matching Unreleased section. Confidence: high Scope-risk: narrow Directive: Keep this commit scoped to the changelog entry for PR #1381. Tested: Not run; changelog-only follow-up. Not-tested: Runtime tests unchanged from the implementation commit. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0725af08..90262b8a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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) - [EE] Fixed Ask Sourcebot mermaid diagrams overflowing their container by contain-fitting them to both width and height, and made revealing a diagram from the answer jump it into view instantly to avoid over/undershooting. [#1373](https://github.com/sourcebot-dev/sourcebot/pull/1373) +- Show a clearer missing-file state in the code browser when a blob path no longer exists. [#1381](https://github.com/sourcebot-dev/sourcebot/pull/1381) ## [5.0.4] - 2026-06-18 From 47c30836fe9e10ec8acf75c88d313cd14c6ad2e2 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Sun, 28 Jun 2026 07:19:51 +0530 Subject: [PATCH 3/6] Keep missing-file preview recovery anchored Split the ref used for failed content lookup from the ref used for browse navigation, so preview-mode 404s keep their normal return path. Constraint: Existing previewRef semantics fetch content at the preview ref while keeping the surrounding browse context at revisionName. Rejected: Reusing contentRef for all missing-file panel navigation | that sends preview 404 recovery links to the commit root instead of the active browse revision. Confidence: high Scope-risk: narrow Directive: Keep missing-file copy tied to the failed content ref, but keep navigation tied to the browse revision. Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web test 'src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx' Tested: node .yarn/releases/yarn-4.7.0.cjs workspace @sourcebot/web lint Tested: git diff --check origin/main...HEAD Not-tested: Manual browser navigation through a real deleted-file preview link. --- .../codePreviewPanel.test.tsx | 37 ++++++++++- .../codePreviewPanel/codePreviewPanel.tsx | 4 +- .../codePreviewPanel/fileNotFoundPanel.tsx | 65 +++++++++++++++++-- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx index c6923241b..c53a5d7aa 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx @@ -1,5 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { TooltipProvider } from '@/components/ui/tooltip'; import { ErrorCode } from '@/lib/errorCodes'; const mocks = vi.hoisted(() => ({ @@ -31,6 +32,14 @@ vi.mock('@/ee/features/codeNav/components/symbolHoverPopup', () => ({ import { CodePreviewPanel } from './codePreviewPanel'; +const renderCodePreviewPanel = async (props: Parameters[0]) => { + return render( + + {await CodePreviewPanel(props)} + + ); +}; + afterEach(() => { cleanup(); }); @@ -54,11 +63,11 @@ describe('CodePreviewPanel', () => { message: 'File "src/missing.ts" not found in repository "github.com/sourcebot-dev/sourcebot"', }); - render(await CodePreviewPanel({ + await renderCodePreviewPanel({ path: 'src/missing.ts', repoName: 'github.com/sourcebot-dev/sourcebot', revisionName: 'feature-branch', - })); + }); expect(screen.queryByText('File not found')).toBeTruthy(); expect(screen.queryAllByText(/src\/missing\.ts/).length).toBeGreaterThan(0); @@ -67,4 +76,28 @@ describe('CodePreviewPanel', () => { '/browse/github.com/sourcebot-dev/sourcebot@feature-branch/-/tree' ); }); + + test('keeps preview 404 navigation anchored to the browse revision', async () => { + mocks.getFileSource.mockResolvedValue({ + statusCode: 404, + errorCode: ErrorCode.FILE_NOT_FOUND, + message: 'File "src/missing.ts" not found in repository "github.com/sourcebot-dev/sourcebot"', + }); + + await renderCodePreviewPanel({ + path: 'src/missing.ts', + repoName: 'github.com/sourcebot-dev/sourcebot', + revisionName: 'main', + previewRef: 'abc123def456', + }); + + expect(screen.queryByText('File not found')).toBeTruthy(); + expect(screen.getAllByText('abc123def456').length).toBeGreaterThan(0); + expect(screen.getByRole('link', { name: 'Return to repository root' }).getAttribute('href')).toBe( + '/browse/github.com/sourcebot-dev/sourcebot@main/-/tree' + ); + expect(screen.getByRole('link', { name: 'Close preview' }).getAttribute('href')).toBe( + '/browse/github.com/sourcebot-dev/sourcebot@main/-/blob/src%2Fmissing.ts' + ); + }); }); 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 c9ee3314a..534e0664d 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 @@ -66,7 +66,9 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe { +export const FileNotFoundPanel = ({ + path, + repoName, + browseRevisionName, + missingRevisionName, + previewRef, + repo, +}: FileNotFoundPanelProps) => { return ( <>
@@ -29,10 +40,52 @@ export const FileNotFoundPanel = ({ path, repoName, revisionName, repo }: FileNo displayName: repo.displayName, externalWebUrl: repo.externalWebUrl, }} - revisionName={revisionName} + revisionName={browseRevisionName} />
+ {previewRef && ( +
+ + Previewing file at revision{" "} + + {truncateSha(previewRef)} + + + + + + + Close preview + +
+ )}
@@ -41,14 +94,14 @@ export const FileNotFoundPanel = ({ path, repoName, revisionName, repo }: FileNo

File not found

The path {path} does not exist - {revisionName ? <> at {revisionName} : null}. + {missingRevisionName ? <> at {missingRevisionName} : null}.

File not found

-

- The path {path} does not exist - {missingRevisionName ? <> at {missingRevisionName} : null}. +

+ The path {path} does not exist + {missingRevisionName ? <> at {missingRevisionName} : null}.