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 ( - <> -
- - - {fileWebUrl && ( - - - {codeHostInfo.codeHostName} - Open in {codeHostInfo.codeHostName} - - )} -
- - {!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 ( + <> +
+ + + {fileWebUrl && ( + + + {codeHostInfo.codeHostName} + Open in {codeHostInfo.codeHostName} + + )} +
+ + {!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: '/', + }, + ], + }; +}