diff --git a/CHANGELOG.md b/CHANGELOG.md
index a88f196a1..b54760680 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Made the backend worker API address configurable via the `WORKER_API_URL` environment variable (default `http://localhost:3060`) instead of being hardcoded. [#1409](https://github.com/sourcebot-dev/sourcebot/pull/1409)
- [EE] Disabled `DELETE /api/ee/user` while SCIM provisioning is enabled, and switched it to an org-scoped membership removal (with last-owner protection) instead of a global account delete. [#1425](https://github.com/sourcebot-dev/sourcebot/pull/1425)
- [EE] Unified the `GET /api/ee/user` and `GET /api/ee/users` response shapes behind a shared mapper; the single-user endpoint is now scoped to org membership, and both include role, membership status, and last activity. [#1425](https://github.com/sourcebot-dev/sourcebot/pull/1425)
+- Browse blob, tree, and commit pages now fetch file sources, folder contents, and diffs client-side via API routes instead of embedding them in the server-rendered page, keeping documents small and event-loop pressure low for large files and commits. [#1426](https://github.com/sourcebot-dev/sourcebot/pull/1426)
### Added
- Added per-step token cost tracking and estimated tool call token usage to Ask Sourcebot chat history. [#1353](https://github.com/sourcebot-dev/sourcebot/pull/1353)
@@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [EE] Added text file attachments to Ask Sourcebot, letting users attach text/code/config files to a chat message via the paperclip button, drag-and-drop, or paste, with large pastes auto-converted to attachments. [#1374](https://github.com/sourcebot-dev/sourcebot/pull/1374)
- [EE] Added image attachments to Ask Sourcebot, letting users attach images to a chat message when the selected model supports image input. [#1375](https://github.com/sourcebot-dev/sourcebot/pull/1375)
- Added deployment system resource stats (CPU cores + cgroup quota, host + container memory, disk, load average) to the service ping, so resource issues can be diagnosed more quickly. [#1424](https://github.com/sourcebot-dev/sourcebot/pull/1424)
+- Added a `robots.txt` that disallows crawlers, with an allowlist for link-preview bots so shared links keep their OpenGraph previews. [#1426](https://github.com/sourcebot-dev/sourcebot/pull/1426)
### 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/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx
index 7209abdab..e3f64d0d7 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
@@ -1,27 +1,6 @@
import { getRepoInfoByName } from "@/actions";
-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 { cn, getCodeHostInfoForRepo, isServiceError, truncateSha } from "@/lib/utils";
-import { X } from "lucide-react";
-import Image from "next/image";
-import Link from "next/link";
-import { getBrowsePath } from "../../../hooks/utils";
-import { BlameAgeLegend } from "./blameAgeLegend";
-import { BlameViewToggle } from "./blameViewToggle";
-import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
-import { getFileBlame, getFileSource } from '@/features/git';
-
-const formatFileSize = (bytes: number): string => {
- if (bytes < 1024) {
- return `${bytes} B`;
- }
- if (bytes < 1024 * 1024) {
- return `${(bytes / 1024).toFixed(1)} KB`;
- }
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
-};
+import { isServiceError } from "@/lib/utils";
+import { CodePreviewPanelClient } from "./codePreviewPanelClient";
interface CodePreviewPanelProps {
path: string;
@@ -36,156 +15,25 @@ interface CodePreviewPanelProps {
}
export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef, blame }: CodePreviewPanelProps) => {
- const contentRef = previewRef ?? revisionName;
-
- const [fileSourceResponse, repoInfoResponse, blameResponse] = await Promise.all([
- getFileSource({
- path,
- repo: repoName,
- ref: contentRef,
- }, { source: 'sourcebot-web-client' }),
- getRepoInfoByName(repoName),
- blame
- ? getFileBlame({
- path,
- repo: repoName,
- ref: contentRef,
- }, { source: 'sourcebot-web-client' })
- : Promise.resolve(undefined),
- ]);
-
- if (isServiceError(fileSourceResponse)) {
- return
Error loading file source: {fileSourceResponse.message}
- }
+ const repoInfoResponse = await getRepoInfoByName(repoName);
if (isServiceError(repoInfoResponse)) {
return Error loading repo info: {repoInfoResponse.message}
}
- if (blameResponse !== undefined && isServiceError(blameResponse)) {
- return Error loading blame: {blameResponse.message}
- }
-
- const source = fileSourceResponse.source;
- const lineCount = source.length === 0
- ? 0
- : source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
- const byteSize = Buffer.byteLength(source, 'utf-8');
- const fileSize = formatFileSize(byteSize);
-
- const codeHostInfo = getCodeHostInfoForRepo({
- codeHostType: repoInfoResponse.codeHostType,
- name: repoInfoResponse.name,
- displayName: repoInfoResponse.displayName,
- externalWebUrl: repoInfoResponse.externalWebUrl,
- });
-
- // @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. THis
- // will break if the default branch is not main.
- const fileWebUrl = repoInfoResponse.codeHostType === "azuredevops" && fileSourceResponse.externalWebUrl ?
- fileSourceResponse.externalWebUrl.replace("version=GBHEAD", "version=GBmain") : fileSourceResponse.externalWebUrl;
-
return (
- <>
-
-
- {!previewRef && (
-
-
-
- {lineCount.toLocaleString()} lines · {fileSize}
-
- {blame && (
- <>
-
-
- >
- )}
-
- )}
- {previewRef && (
-
-
- Previewing file at revision{" "}
-
- {truncateSha(previewRef)}
-
-
-
-
-
-
-
-
-
-
- Close preview
-
-
- )}
-
- >
+
)
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx
new file mode 100644
index 000000000..6368bacf1
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanelClient.tsx
@@ -0,0 +1,228 @@
+'use client';
+
+import { getFileBlame, getFileSource } from "@/app/api/(client)/client";
+import { PathHeader } from "@/app/(app)/components/pathHeader";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn, getCodeHostInfoForRepo, isServiceError, truncateSha } from "@/lib/utils";
+import { useQuery } from "@tanstack/react-query";
+import { Loader2, X } from "lucide-react";
+import Image from "next/image";
+import Link from "next/link";
+import { ComponentProps } from "react";
+import { getBrowsePath } from "../../../hooks/utils";
+import { BlameAgeLegend } from "./blameAgeLegend";
+import { BlameViewToggle } from "./blameViewToggle";
+import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
+
+const formatFileSize = (bytes: number): string => {
+ if (bytes < 1024) {
+ return `${bytes} B`;
+ }
+ if (bytes < 1024 * 1024) {
+ return `${(bytes / 1024).toFixed(1)} KB`;
+ }
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
+};
+
+interface CodePreviewPanelClientProps {
+ path: string;
+ repoName: string;
+ revisionName?: string;
+ previewRef?: string;
+ blame?: boolean;
+ repo: ComponentProps['repo'];
+}
+
+export const CodePreviewPanelClient = ({ path, repoName, revisionName, previewRef, blame, repo }: CodePreviewPanelClientProps) => {
+ const contentRef = previewRef ?? revisionName;
+
+ const { data: fileSourceResponse, isPending: isFileSourcePending } = useQuery({
+ queryKey: ['fileSource', repoName, contentRef ?? null, path],
+ queryFn: () => getFileSource({
+ path,
+ repo: repoName,
+ ...(contentRef ? { ref: contentRef } : {}),
+ }),
+ });
+
+ const { data: blameResponse, isPending: isBlamePending } = useQuery({
+ queryKey: ['fileBlame', repoName, contentRef ?? null, path],
+ queryFn: () => getFileBlame({
+ path,
+ repo: repoName,
+ ...(contentRef ? { ref: contentRef } : {}),
+ }),
+ enabled: !!blame,
+ });
+
+ const codeHostInfo = getCodeHostInfoForRepo({
+ codeHostType: repo.codeHostType,
+ name: repo.name,
+ displayName: repo.displayName,
+ externalWebUrl: repo.externalWebUrl,
+ });
+
+ const loadedFile = fileSourceResponse && !isServiceError(fileSourceResponse) ? fileSourceResponse : undefined;
+
+ // @todo: this is a hack to support linking to files for ADO. ADO doesn't support web urls with HEAD so we replace it with main. This
+ // will break if the default branch is not main.
+ const fileWebUrl = loadedFile
+ ? (repo.codeHostType === "azuredevops" && loadedFile.externalWebUrl
+ ? loadedFile.externalWebUrl.replace("version=GBHEAD", "version=GBmain")
+ : loadedFile.externalWebUrl)
+ : undefined;
+
+ // Line/size stats are derived from the fetched file, so they only appear
+ // once it has loaded. The surrounding toolbar (blame toggle) does not wait.
+ const lineCount = loadedFile === undefined
+ ? undefined
+ : loadedFile.source.length === 0
+ ? 0
+ : loadedFile.source.split('\n').length - (loadedFile.source.endsWith('\n') ? 1 : 0);
+ const fileSize = loadedFile === undefined
+ ? undefined
+ : formatFileSize(new TextEncoder().encode(loadedFile.source).length);
+
+ // The path header and separator are derived entirely from props, so they
+ // render immediately. Only the body below depends on the fetched source,
+ // so the loading / error states live here rather than replacing the whole
+ // panel.
+ const renderBody = () => {
+ if (isFileSourcePending || (blame && isBlamePending)) {
+ return (
+
+
+ Loading...
+
+ );
+ }
+
+ if (!fileSourceResponse || isServiceError(fileSourceResponse)) {
+ return (
+
+ Error loading file source: {isServiceError(fileSourceResponse) ? fileSourceResponse.message : 'No response received'}
+
+ );
+ }
+
+ if (blameResponse !== undefined && isServiceError(blameResponse)) {
+ return (
+
+ Error loading blame: {blameResponse.message}
+
+ );
+ }
+
+ return (
+ <>
+ {previewRef && (
+
+
+ Previewing file at revision{" "}
+
+ {truncateSha(previewRef)}
+
+
+
+
+
+
+
+
+
+
+ Close preview
+
+
+ )}
+
+ >
+ );
+ };
+
+ return (
+ <>
+
+
+ {!previewRef && (
+
+
+ {isFileSourcePending ? (
+
+ ) : lineCount !== undefined ? (
+
+ {lineCount.toLocaleString()} lines · {fileSize}
+
+ ) : null}
+ {blame && (
+ <>
+
+
+ >
+ )}
+
+ )}
+ {renderBody()}
+ >
+ )
+}
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanel.tsx
index 308ab9f6c..8cc463fa6 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanel.tsx
@@ -1,25 +1,6 @@
import { getRepoInfoByName } from "@/actions";
-import { PathHeader } from "@/app/(app)/components/pathHeader";
-import { Button } from "@/components/ui/button";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { getCommit, getDiff } from "@/features/git";
import { isServiceError } from "@/lib/utils";
-import { format, formatDistanceToNow } from "date-fns";
-import { X } from "lucide-react";
-import Link from "next/link";
-import { formatAuthorsText, getCommitAuthors } from "../../../components/commitAuthors";
-import { AuthorsAvatarGroup } from "../../../components/commitParts";
-import { getBrowsePath } from "../../../hooks/utils";
-import { computeChangeCounts, DiffStat } from "./diffStat";
-import { FileStatus, getFileStatus, StatusBadge } from "./fileStatus";
-import { LightweightDiffViewer } from "./lightweightDiffViewer";
-
-const FILE_STATUS_LABELS: Record = {
- added: 'Added',
- modified: 'Modified',
- deleted: 'Deleted',
- renamed: 'Renamed',
-};
+import { FocusedCommitDiffPanelClient } from "./focusedCommitDiffPanelClient";
interface FocusedCommitDiffPanelProps {
repoName: string;
@@ -28,37 +9,13 @@ interface FocusedCommitDiffPanelProps {
path: string;
}
-// Git's well-known empty-tree SHA. Used as the diff base when the commit has
-// no parent (i.e. the initial commit), since `^` doesn't resolve there.
-const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
-
export const FocusedCommitDiffPanel = async ({
repoName,
revisionName,
commitSha,
path,
}: FocusedCommitDiffPanelProps) => {
- const [commitResponse, initialDiffResponse, repoInfoResponse] = await Promise.all([
- getCommit({
- repo: repoName,
- ref: commitSha,
- }),
- getDiff({
- repo: repoName,
- base: `${commitSha}^`,
- head: commitSha,
- path,
- }),
- getRepoInfoByName(repoName),
- ]);
-
- if (isServiceError(commitResponse)) {
- return (
-
- Error loading commit: {commitResponse.message}
-
- );
- }
+ const repoInfoResponse = await getRepoInfoByName(repoName);
if (isServiceError(repoInfoResponse)) {
return (
@@ -68,128 +25,18 @@ export const FocusedCommitDiffPanel = async ({
);
}
- // Initial commit has no parent — `^` fails. Fall back to diffing
- // against git's empty tree.
- let diffResponse = initialDiffResponse;
- if (isServiceError(initialDiffResponse) && commitResponse.parents.length === 0) {
- diffResponse = await getDiff({
- repo: repoName,
- base: EMPTY_TREE_SHA,
- head: commitSha,
- path,
- });
- }
-
- if (isServiceError(diffResponse)) {
- return (
-
- Error loading diff: {diffResponse.message}
-
- );
- }
-
- // Match by either side so deletions (oldPath === path, newPath === null)
- // and renames (oldPath !== newPath) both resolve to the right entry.
- const file = diffResponse.files.find(
- (f) => f.newPath === path || f.oldPath === path,
- );
-
- const authors = getCommitAuthors(commitResponse);
- const commitDate = new Date(commitResponse.date);
- const relativeDate = formatDistanceToNow(commitDate, { addSuffix: true });
- const absoluteDate = format(commitDate, 'PPpp');
-
return (
-
-
- {file ? (
- <>
-
-
-
-
- {FILE_STATUS_LABELS[getFileStatus(file)]}
-
-
by
-
-
a.name).join(", ")}
- >
- {formatAuthorsText(authors)}
-
-
- {relativeDate}
-
-
·
-
- View full commit
-
-
-
-
-
-
-
-
-
-
-
-
- Exit diff view
-
-
-
-
-
-
- >
- ) : (
-
- This file was not modified in this commit.
-
- )}
-
+
);
};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanelClient.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanelClient.tsx
new file mode 100644
index 000000000..a4bfa7ade
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanelClient.tsx
@@ -0,0 +1,160 @@
+'use client';
+
+import { PathHeader } from "@/app/(app)/components/pathHeader";
+import { Button } from "@/components/ui/button";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { format, formatDistanceToNow } from "date-fns";
+import { Loader2, X } from "lucide-react";
+import Link from "next/link";
+import { ComponentProps } from "react";
+import { formatAuthorsText, getCommitAuthors } from "../../../components/commitAuthors";
+import { AuthorsAvatarGroup } from "../../../components/commitParts";
+import { getBrowsePath } from "../../../hooks/utils";
+import { computeChangeCounts, DiffStat } from "./diffStat";
+import { FileStatus, getFileStatus, StatusBadge } from "./fileStatus";
+import { LightweightDiffViewer } from "./lightweightDiffViewer";
+import { useCommitDiff } from "./useCommitDiff";
+
+const FILE_STATUS_LABELS: Record = {
+ added: 'Added',
+ modified: 'Modified',
+ deleted: 'Deleted',
+ renamed: 'Renamed',
+};
+
+interface FocusedCommitDiffPanelClientProps {
+ repoName: string;
+ revisionName?: string;
+ commitSha: string;
+ path: string;
+ repo: ComponentProps['repo'];
+}
+
+export const FocusedCommitDiffPanelClient = ({
+ repoName,
+ revisionName,
+ commitSha,
+ path,
+ repo,
+}: FocusedCommitDiffPanelClientProps) => {
+ const { data, isPending, error } = useCommitDiff({ repoName, commitSha, path });
+
+ if (isPending) {
+ return (
+
+
+ Loading...
+
+ );
+ }
+
+ if (error || !data) {
+ return (
+
+ {error instanceof Error ? error.message : 'Error loading commit'}
+
+ );
+ }
+
+ const { commit: commitResponse, diff: diffResponse } = data;
+
+ // Match by either side so deletions (oldPath === path, newPath === null)
+ // and renames (oldPath !== newPath) both resolve to the right entry.
+ const file = diffResponse.files.find(
+ (f) => f.newPath === path || f.oldPath === path,
+ );
+
+ const authors = getCommitAuthors(commitResponse);
+ const commitDate = new Date(commitResponse.date);
+ const relativeDate = formatDistanceToNow(commitDate, { addSuffix: true });
+ const absoluteDate = format(commitDate, 'PPpp');
+
+ return (
+
+
+ {file ? (
+ <>
+
+
+
+
+ {FILE_STATUS_LABELS[getFileStatus(file)]}
+
+
by
+
+
a.name).join(", ")}
+ >
+ {formatAuthorsText(authors)}
+
+
+ {relativeDate}
+
+
·
+
+ View full commit
+
+
+
+
+
+
+
+
+
+
+
+
+ Exit diff view
+
+
+
+
+
+
+ >
+ ) : (
+
+ This file was not modified in this commit.
+
+ )}
+
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fullCommitDiffPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fullCommitDiffPanel.tsx
index 1379f69dd..d7f3f6cd1 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fullCommitDiffPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fullCommitDiffPanel.tsx
@@ -1,9 +1,9 @@
+'use client';
+
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
-import { getCommit, getDiff } from "@/features/git";
-import { isServiceError } from "@/lib/utils";
import { format } from "date-fns";
-import { FileCode } from "lucide-react";
+import { FileCode, Loader2 } from "lucide-react";
import Link from "next/link";
import { formatAuthorsText, getCommitAuthors } from "../../../components/commitAuthors";
import { AuthorsAvatarGroup } from "../../../components/commitParts";
@@ -12,56 +12,35 @@ import { CommitHashLine } from "./commitHashLine";
import { CommitMessage } from "./commitMessage";
import { computeTotalChangeCounts, DiffStat } from "./diffStat";
import { FileDiffList } from "./fileDiffList";
+import { useCommitDiff } from "./useCommitDiff";
interface FullCommitDiffPanelProps {
repoName: string;
commitSha: string;
}
-// Git's well-known empty-tree SHA. Used as the diff base when the commit has
-// no parent (i.e. the initial commit), since `^` doesn't resolve there.
-const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
-
-export const FullCommitDiffPanel = async ({ repoName, commitSha }: FullCommitDiffPanelProps) => {
- const [commitResponse, initialDiffResponse] = await Promise.all([
- getCommit({
- repo: repoName,
- ref: commitSha,
- }),
- getDiff({
- repo: repoName,
- base: `${commitSha}^`,
- head: commitSha,
- }),
- ]);
+export const FullCommitDiffPanel = ({ repoName, commitSha }: FullCommitDiffPanelProps) => {
+ const { data, isPending, error } = useCommitDiff({ repoName, commitSha });
- if (isServiceError(commitResponse)) {
+ if (isPending) {
return (
-
- Error loading commit: {commitResponse.message}
+
+
+ Loading...
);
}
- // Initial commit has no parent — `
^` fails. Fall back to diffing
- // against git's empty tree so all files show as added.
- let diffResponse = initialDiffResponse;
- if (isServiceError(initialDiffResponse) && commitResponse.parents.length === 0) {
- diffResponse = await getDiff({
- repo: repoName,
- base: EMPTY_TREE_SHA,
- head: commitSha,
- });
- }
-
- if (isServiceError(diffResponse)) {
+ if (error || !data) {
return (
- Error loading diff: {diffResponse.message}
+ {error instanceof Error ? error.message : 'Error loading commit'}
);
}
+ const { commit: commitResponse, diff: diffResponse } = data;
+
const baseSha = commitResponse.parents.length > 0 ? commitResponse.parents[0] : null;
const subject = commitResponse.message.split('\n')[0];
const formattedDate = format(new Date(commitResponse.date), 'MMM d, yyyy');
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/useCommitDiff.ts b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/useCommitDiff.ts
new file mode 100644
index 000000000..36b68facd
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/useCommitDiff.ts
@@ -0,0 +1,61 @@
+'use client';
+
+import { getCommit, getDiff } from "@/app/api/(client)/client";
+import { isServiceError } from "@/lib/utils";
+import { useQuery } from "@tanstack/react-query";
+
+// Git's well-known empty-tree SHA. Used as the diff base when the commit has
+// no parent (i.e. the initial commit), since `^` doesn't resolve there.
+const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
+
+interface UseCommitDiffProps {
+ repoName: string;
+ commitSha: string;
+ // When set, restricts the diff to changes touching this file path.
+ path?: string;
+}
+
+export const useCommitDiff = ({ repoName, commitSha, path }: UseCommitDiffProps) => {
+ return useQuery({
+ queryKey: ['commitDiff', repoName, commitSha, path ?? null],
+ queryFn: async () => {
+ const [commitResponse, initialDiffResponse] = await Promise.all([
+ getCommit({
+ repo: repoName,
+ ref: commitSha,
+ }),
+ getDiff({
+ repo: repoName,
+ base: `${commitSha}^`,
+ head: commitSha,
+ path,
+ }),
+ ]);
+
+ if (isServiceError(commitResponse)) {
+ throw new Error(`Error loading commit: ${commitResponse.message}`);
+ }
+
+ // Initial commit has no parent — `^` fails. Fall back to diffing
+ // against git's empty tree so all files show as added.
+ let diffResponse = initialDiffResponse;
+ if (isServiceError(initialDiffResponse) && commitResponse.parents.length === 0) {
+ diffResponse = await getDiff({
+ repo: repoName,
+ base: EMPTY_TREE_SHA,
+ head: commitSha,
+ path,
+ });
+ }
+
+ if (isServiceError(diffResponse)) {
+ throw new Error(`Error loading diff: ${diffResponse.message}`);
+ }
+
+ return {
+ commit: commitResponse,
+ diff: diffResponse,
+ };
+ },
+ });
+};
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 5762e0f50..400c488f5 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
@@ -1,10 +1,6 @@
-
-import { Separator } from "@/components/ui/separator";
import { getRepoInfoByName } from "@/actions";
-import { PathHeader } from "@/app/(app)/components/pathHeader";
-import { getFolderContents } from "@/features/git/getFolderContentsApi";
import { isServiceError } from "@/lib/utils";
-import { PureTreePreviewPanel } from "./pureTreePreviewPanel";
+import { TreePreviewPanelClient } from "./treePreviewPanelClient";
interface TreePreviewPanelProps {
path: string;
@@ -13,37 +9,23 @@ interface TreePreviewPanelProps {
}
export const TreePreviewPanel = async ({ path, repoName, revisionName }: TreePreviewPanelProps) => {
- const [repoInfoResponse, folderContentsResponse] = await Promise.all([
- getRepoInfoByName(repoName),
- getFolderContents({
- repoName,
- revisionName: revisionName ?? 'HEAD',
- path,
- })
- ]);
+ const repoInfoResponse = await getRepoInfoByName(repoName);
- if (isServiceError(folderContentsResponse) || isServiceError(repoInfoResponse)) {
+ if (isServiceError(repoInfoResponse)) {
return Error loading tree preview
}
return (
- <>
-
-
-
- >
+
)
-}
\ No newline at end of file
+}
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
new file mode 100644
index 000000000..afb3f888b
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanelClient.tsx
@@ -0,0 +1,57 @@
+'use client';
+
+import { getFolderContents } from "@/app/api/(client)/client";
+import { PathHeader } from "@/app/(app)/components/pathHeader";
+import { Separator } from "@/components/ui/separator";
+import { isServiceError } from "@/lib/utils";
+import { useQuery } from "@tanstack/react-query";
+import { Loader2 } from "lucide-react";
+import { ComponentProps } from "react";
+import { PureTreePreviewPanel } from "./pureTreePreviewPanel";
+
+interface TreePreviewPanelClientProps {
+ path: string;
+ repoName: string;
+ revisionName?: string;
+ repo: ComponentProps['repo'];
+}
+
+export const TreePreviewPanelClient = ({ path, repoName, revisionName, repo }: TreePreviewPanelClientProps) => {
+ const { data: folderContentsResponse, isPending } = useQuery({
+ queryKey: ['folderContents', repoName, revisionName ?? null, path],
+ queryFn: () => getFolderContents({
+ repoName,
+ revisionName: revisionName ?? 'HEAD',
+ path,
+ }),
+ });
+
+ if (isPending) {
+ return (
+
+
+ Loading...
+
+ );
+ }
+
+ if (!folderContentsResponse || isServiceError(folderContentsResponse)) {
+ return Error loading tree preview
+ }
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/packages/web/src/app/api/(client)/client.ts b/packages/web/src/app/api/(client)/client.ts
index a75c5396c..a2fe88840 100644
--- a/packages/web/src/app/api/(client)/client.ts
+++ b/packages/web/src/app/api/(client)/client.ts
@@ -14,8 +14,14 @@ import {
} from "@/features/codeNav/types";
import {
Commit,
+ CommitDetail,
+ FileBlameRequest,
+ FileBlameResponse,
+ FileTreeItem,
+ GetDiffResult,
GetFilesRequest,
GetFilesResponse,
+ GetFolderContentsRequest,
GetTreeRequest,
GetTreeResponse,
FileSourceRequest,
@@ -164,6 +170,74 @@ export const listCommits = async (queryParams: ListCommitsQueryParams): Promise<
return { commits: result as Commit[], totalCount };
}
+export const getCommit = async (queryParams: { repo: string; ref: string }): Promise => {
+ const url = new URL("/api/commit", window.location.origin);
+ for (const [key, value] of Object.entries(queryParams)) {
+ url.searchParams.set(key, value);
+ }
+
+ const result = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Sourcebot-Client-Source": "sourcebot-web-client",
+ },
+ }).then(response => response.json());
+
+ return result as CommitDetail | ServiceError;
+}
+
+export const getDiff = async (queryParams: { repo: string; base: string; head: string; path?: string }): Promise => {
+ const url = new URL("/api/diff", window.location.origin);
+ for (const [key, value] of Object.entries(queryParams)) {
+ if (value !== undefined) {
+ url.searchParams.set(key, value);
+ }
+ }
+
+ const result = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Sourcebot-Client-Source": "sourcebot-web-client",
+ },
+ }).then(response => response.json());
+
+ return result as GetDiffResult | ServiceError;
+}
+
+export const getFolderContents = async (queryParams: GetFolderContentsRequest): Promise => {
+ const url = new URL("/api/folder_contents", window.location.origin);
+ for (const [key, value] of Object.entries(queryParams)) {
+ url.searchParams.set(key, value);
+ }
+
+ const result = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Sourcebot-Client-Source": "sourcebot-web-client",
+ },
+ }).then(response => response.json());
+
+ return result as FileTreeItem[] | ServiceError;
+}
+
+export const getFileBlame = async (queryParams: FileBlameRequest): Promise => {
+ const url = new URL("/api/blame", window.location.origin);
+ for (const [key, value] of Object.entries(queryParams)) {
+ if (value !== undefined) {
+ url.searchParams.set(key, value);
+ }
+ }
+
+ const result = await fetch(url, {
+ method: "GET",
+ headers: {
+ "X-Sourcebot-Client-Source": "sourcebot-web-client",
+ },
+ }).then(response => response.json());
+
+ return result as FileBlameResponse | ServiceError;
+}
+
export const getFiles = async (body: GetFilesRequest): Promise => {
const result = await fetch("/api/files", {
method: "POST",
diff --git a/packages/web/src/app/api/(server)/folder_contents/route.ts b/packages/web/src/app/api/(server)/folder_contents/route.ts
new file mode 100644
index 000000000..100031523
--- /dev/null
+++ b/packages/web/src/app/api/(server)/folder_contents/route.ts
@@ -0,0 +1,30 @@
+import { getFolderContents, getFolderContentsRequestSchema } from "@/features/git";
+import { apiHandler } from "@/lib/apiHandler";
+import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { NextRequest } from "next/server";
+
+// eslint-disable-next-line authz/require-auth-wrapper -- delegates to getFolderContents() which calls withOptionalAuth
+export const GET = apiHandler(async (request: NextRequest): Promise => {
+ const rawParams = Object.fromEntries(
+ Object.keys(getFolderContentsRequestSchema.shape).map(key => [
+ key,
+ request.nextUrl.searchParams.get(key) ?? undefined
+ ])
+ );
+ const parsed = getFolderContentsRequestSchema.safeParse(rawParams);
+
+ if (!parsed.success) {
+ return serviceErrorResponse(
+ queryParamsSchemaValidationError(parsed.error)
+ );
+ }
+
+ const result = await getFolderContents(parsed.data);
+
+ if (isServiceError(result)) {
+ return serviceErrorResponse(result);
+ }
+
+ return Response.json(result);
+});
diff --git a/packages/web/src/app/robots.ts b/packages/web/src/app/robots.ts
new file mode 100644
index 000000000..5fc77b634
--- /dev/null
+++ b/packages/web/src/app/robots.ts
@@ -0,0 +1,31 @@
+import type { MetadataRoute } from 'next';
+
+// Sourcebot exposes an unbounded URL space (every file × every revision ×
+// every commit), and crawlers walking it generate heavy server-side rendering
+// load. Disallow all crawling, except for link-preview fetchers so that
+// shared links (e.g. chat pages) still unfurl with their OpenGraph metadata
+// in Slack, X, LinkedIn, etc. Notably NOT allowed: Applebot, GPTBot, and
+// meta-externalagent, which are search/AI-training crawlers rather than
+// preview fetchers.
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: [
+ {
+ userAgent: [
+ 'Slackbot',
+ 'Twitterbot',
+ 'LinkedInBot',
+ 'Discordbot',
+ 'facebookexternalhit',
+ 'TelegramBot',
+ 'WhatsApp',
+ ],
+ allow: '/',
+ },
+ {
+ userAgent: '*',
+ disallow: '/',
+ },
+ ],
+ };
+}