diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19358afd3..bdaf89492 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 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)
- Passed Zoekt index parameters via argv to preserve revision names with punctuation. [#1376](https://github.com/sourcebot-dev/sourcebot/pull/1376)
+- 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
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..28f185c6a
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.test.tsx
@@ -0,0 +1,134 @@
+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(() => ({
+ 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, revisionName }: { path: string; revisionName?: string }) => (
+
Path: {path}; Revision: {revisionName ?? 'default'}
+ ),
+}));
+
+vi.mock('./pureCodePreviewPanel', () => ({
+ PureCodePreviewPanel: () => Code preview
,
+}));
+
+vi.mock('@/ee/features/codeNav/components/symbolHoverPopup', () => ({
+ SymbolHoverPopup: () => null,
+}));
+
+import { CodePreviewPanel } from './codePreviewPanel';
+
+const renderCodePreviewPanel = async (props: Parameters[0]) => {
+ return render(
+
+ {await CodePreviewPanel(props)}
+
+ );
+};
+
+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"',
+ });
+
+ 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);
+ 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'
+ );
+ });
+
+ 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.getByText('src/missing.ts').className).toContain('break-all');
+ expect(screen.getAllByText('abc123def456').some((element) => element.className.includes('break-all'))).toBe(true);
+ 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'
+ );
+ });
+
+ test('keeps successful preview header anchored to the browse revision', async () => {
+ mocks.getFileSource.mockResolvedValue({
+ source: 'const value = 1;\n',
+ language: 'typescript',
+ });
+
+ await renderCodePreviewPanel({
+ path: 'src/index.ts',
+ repoName: 'github.com/sourcebot-dev/sourcebot',
+ revisionName: 'main',
+ previewRef: 'abc123def456',
+ });
+
+ expect(mocks.getFileSource).toHaveBeenCalledWith({
+ path: 'src/index.ts',
+ repo: 'github.com/sourcebot-dev/sourcebot',
+ ref: 'abc123def456',
+ }, { source: 'sourcebot-web-client' });
+ expect(screen.getByTestId('path-header').textContent).toBe('Path: src/index.ts; Revision: main');
+
+ const closePreviewLink = screen.getByRole('link', { name: 'Close preview' });
+ expect(closePreviewLink.getAttribute('href')).toBe(
+ '/browse/github.com/sourcebot-dev/sourcebot@main/-/blob/src%2Findex.ts'
+ );
+ expect(closePreviewLink.closest('button')).toBeNull();
+ });
+});
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..200c80864 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,31 @@ 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}
}
@@ -96,7 +115,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
displayName: repoInfoResponse.displayName,
externalWebUrl: repoInfoResponse.externalWebUrl,
}}
- revisionName={contentRef}
+ revisionName={revisionName}
/>
{fileWebUrl && (
@@ -154,7 +173,7 @@ 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..55142ea5b
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/fileNotFoundPanel.tsx
@@ -0,0 +1,115 @@
+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 { truncateSha } from "@/lib/utils";
+import { FileQuestion, X } 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;
+ browseRevisionName?: string;
+ missingRevisionName?: string;
+ previewRef?: string;
+ repo: {
+ codeHostType: CodeHostType;
+ displayName?: string;
+ externalWebUrl?: string;
+ };
+}
+
+export const FileNotFoundPanel = ({
+ path,
+ repoName,
+ browseRevisionName,
+ missingRevisionName,
+ previewRef,
+ repo,
+}: FileNotFoundPanelProps) => {
+ return (
+ <>
+
+
+ {previewRef && (
+
+
+ Previewing file at revision{" "}
+
+ {truncateSha(previewRef)}
+
+
+
+
+
+
+ Close preview
+
+
+ )}
+
+
+
+
+
+
File not found
+
+ The path {path} does not exist
+ {missingRevisionName ? <> at {missingRevisionName}> : null}.
+
+
+
+
+ >
+ );
+}