diff --git a/CHANGELOG.md b/CHANGELOG.md
index d487b5b8c..ec0c850f0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Upgraded `brace-expansion` to `^1.1.17`/`^2.1.3`/`^5.0.8`. [#1527](https://github.com/sourcebot-dev/sourcebot/pull/1527)
+- The browse file-tree panel now shows a clear "This repository is empty." (root) or "This path has no contents in this revision." (sub-path) message when the folder contents are empty, and a dedicated "Repository not found." message for 404s, instead of the generic "Error loading tree preview" or a blank scroll area. The sub-path message is intentionally ambiguous because `git ls-tree` returns empty output for both empty directories and paths that don't exist in the tree, so we can't tell the cases apart at this layer. [#1530](https://github.com/sourcebot-dev/sourcebot/pull/1530)
## [5.1.5] - 2026-07-31
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.test.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.test.tsx
new file mode 100644
index 000000000..32c449eef
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.test.tsx
@@ -0,0 +1,46 @@
+import { describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { SidebarProvider } from '@/components/ui/sidebar';
+
+// Stub the file-tree item component so the test only exercises the
+// empty-state branch. The real component pulls in `usePathname` and
+// a router context that's heavy to mock for an empty-state assertion.
+vi.mock('@/app/(app)/browse/components/fileTreeItemComponent', () => ({
+ FileTreeItemComponent: () =>
,
+}));
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/browse/github.com/foo/bar@main/-/tree/src',
+}));
+
+vi.mock('@/hooks/use-mobile', () => ({
+ useIsMobile: () => false,
+}));
+
+const { PureTreePreviewPanel } = await import('./pureTreePreviewPanel');
+
+const wrap = (ui: React.ReactNode) => (
+
+
+
+);
+
+describe('PureTreePreviewPanel empty state (issue #1530)', () => {
+ test('renders an honest empty-state message when the items array is empty', () => {
+ // The empty-state message is rendered inside the same
+ // ScrollArea as the items, so a real "directory" sub-path
+ // (path != '') still shows the path header above the panel
+ // and the empty-state copy below. The PureTree component
+ // doesn't know about the path; the caller decides whether
+ // to render the full panel (path != '') or the full-panel
+ // "This repository is empty" copy (path == '').
+ //
+ // We can't tell from `[]` alone whether the directory is
+ // empty or the path doesn't exist in this revision
+ // (git ls-tree returns empty output for both cases), so the
+ // message is intentionally ambiguous. Bugbot finding on PR
+ // #1531.
+ render(wrap());
+ expect(screen.getByText('This path has no contents in this revision.')).toBeTruthy();
+ });
+});
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx
index 3f7c0f473..32d426885 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx
@@ -20,22 +20,34 @@ export const PureTreePreviewPanel = ({ items }: PureTreePreviewPanelProps) => {
className="flex flex-col p-0.5"
ref={scrollAreaRef}
>
- {items.map((item) => (
-
- ))}
+ {items.length === 0 ? (
+ // We can't tell from `[]` alone whether the directory
+ // is empty or the path doesn't exist in this revision
+ // (git ls-tree returns empty output for both cases). The
+ // message is intentionally ambiguous — the user can
+ // verify the path by looking at the repo on the code
+ // host. Bugbot finding on PR #1531.
+
+ This path has no contents in this revision.
+
+ ) : (
+ items.map((item) => (
+
+ ))
+ )}
)
}
\ No newline at end of file
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx
index 400c488f5..662a509cd 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx
@@ -12,6 +12,14 @@ export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePre
const repoInfoResponse = await getRepoInfoByName(repoName);
if (isServiceError(repoInfoResponse)) {
+ // A 404 means the user hit a typo in a repo name (or a config
+ // that's no longer indexed). A different status code is an
+ // actual system error. Showing the same generic message for
+ // both confuses users — the typo case looks like a sync
+ // problem. See issue #1530.
+ if (repoInfoResponse.errorCode === 'NOT_FOUND') {
+ return Repository not found.
;
+ }
return Error loading tree preview
}
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.test.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.test.tsx
new file mode 100644
index 000000000..5f3d5131c
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.test.tsx
@@ -0,0 +1,89 @@
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+// Stub the folder-contents client and the path header so the test
+// only exercises the empty-state branches in treePreviewPanelClient.
+// The folder-contents client makes a real network call; the path
+// header pulls in router context. Both are irrelevant for the
+// empty-state assertions.
+vi.mock('@/app/api/(client)/client', () => ({
+ getFolderContents: vi.fn(),
+}));
+
+vi.mock('@/app/(app)/components/pathHeader', () => ({
+ PathHeader: () => ,
+}));
+
+vi.mock('@/components/ui/separator', () => ({
+ Separator: () =>
,
+}));
+
+vi.mock('@/components/ui/skeleton', () => ({
+ Skeleton: () => ,
+}));
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/browse/github.com/foo/bar@main/-/tree/src',
+}));
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: vi.fn(),
+}));
+
+vi.mock('react-hotkeys-hook', () => ({
+ useHotkeys: vi.fn(),
+}));
+
+const mockQueryState = vi.hoisted(() => ({
+ current: { data: undefined as unknown, isPending: false, isError: false },
+}));
+
+vi.mock('@tanstack/react-query', () => ({
+ useQuery: () => mockQueryState.current,
+}));
+
+const { TreePreviewPanelClient } = await import('./treePreviewPanelClient');
+
+const baseRepo = { name: 'github.com/foo/bar', codeHostType: 'github' as const };
+
+const renderClient = (opts: { path: string; revisionName?: string }) => {
+ return render(
+
+ );
+};
+
+describe('TreePreviewPanelClient empty-state branches (issue #1530)', () => {
+ afterEach(() => {
+ mockQueryState.current = { data: undefined, isPending: false, isError: false };
+ });
+
+ test('renders the "This repository is empty." message at the root when the response is []', () => {
+ // The root-path case takes the full panel (no PathHeader, no
+ // Separator). The "directory" copy from PureTreePreviewPanel
+ // is NOT used here — the root case is more emphatic and
+ // accurate.
+ mockQueryState.current = { data: [], isPending: false, isError: false };
+ renderClient({ path: '' });
+ expect(screen.getByText('This repository is empty.')).toBeTruthy();
+ expect(screen.queryByTestId('path-header')).toBeNull();
+ expect(screen.queryByTestId('separator')).toBeNull();
+ });
+
+ test('renders the "Error loading tree preview" message on a non-404 service error', () => {
+ // The folder-contents client returns the service error
+ // object for any non-NOT_FOUND error. Bugbot finding on
+ // PR #1531: the non-404 case keeps the existing copy.
+ mockQueryState.current = {
+ data: { statusCode: 500, errorCode: 'UNEXPECTED_ERROR', message: 'boom' },
+ isPending: false,
+ isError: false,
+ };
+ renderClient({ path: 'src' });
+ expect(screen.getByText('Error loading tree preview')).toBeTruthy();
+ });
+});
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.tsx
index afb3f888b..99937ab6e 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.tsx
@@ -39,6 +39,22 @@ export const TreePreviewPanelClient = ({ path, repoName, revisionName, repo }: T
return Error loading tree preview
}
+ // Distinguish "the repository has no files" from "this sub-directory
+ // has no files" at the root path. The latter is handled inside
+ // `PureTreePreviewPanel` with a "This directory is empty." copy;
+ // the former is a stronger message and avoids rendering a path
+ // header for a non-existent root. See issue #1530.
+ if (folderContentsResponse.length === 0 && path === '') {
+ return (
+
+
This repository is empty.
+
+ Once a commit lands on the default branch, its files will appear here.
+
+
+ );
+ }
+
return (
<>