diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e3ba9edc..c96ee1b42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added commit history viewer to code browser. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
+- Added commit diff viewer to code browser. [#1154](https://github.com/sourcebot-dev/sourcebot/pull/1154)
- Added `/api/commits/authors` to the public API to allow fetching a list of authors for a given path and revision. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
+- Added optional `path` query parameter to the `/api/diff` endpoint and `get_diff` MCP tool to restrict diffs to changes touching a specific file. [#1154](https://github.com/sourcebot-dev/sourcebot/pull/1154)
### Fixed
- Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)
diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json
index 2c95f79ca..df27ed331 100644
--- a/docs/api-reference/sourcebot-public.openapi.json
+++ b/docs/api-reference/sourcebot-public.openapi.json
@@ -1585,6 +1585,16 @@
"description": "The head git ref (branch, tag, or commit SHA) to diff to.",
"name": "head",
"in": "query"
+ },
+ {
+ "schema": {
+ "type": "string",
+ "description": "Restrict the diff to changes touching this file path. Omit to diff all changes between the two refs."
+ },
+ "required": false,
+ "description": "Restrict the diff to changes touching this file path. Omit to diff all changes between the two refs.",
+ "name": "path",
+ "in": "query"
}
],
"responses": {
diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts
index 0b28f5602..d954c8c42 100644
--- a/packages/shared/src/env.server.ts
+++ b/packages/shared/src/env.server.ts
@@ -246,6 +246,7 @@ const options = {
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'),
+ DEBUG_ENABLE_REACT_GRAB: booleanSchema.default('false'),
LANGFUSE_SECRET_KEY: z.string().optional(),
diff --git a/packages/web/package.json b/packages/web/package.json
index 4e8350c23..78b1d68e5 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -50,6 +50,7 @@
"@codemirror/language": "^6.0.0",
"@codemirror/language-data": "^6.5.1",
"@codemirror/legacy-modes": "^6.4.2",
+ "@codemirror/merge": "^6.12.1",
"@codemirror/search": "^6.5.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.33.0",
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx
similarity index 54%
rename from packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx
index 6f5725783..bad7e67c9 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx
@@ -1,8 +1,13 @@
import { getRepoInfoByName } from "@/actions";
import { PathHeader } from "@/app/(app)/components/pathHeader";
+import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
-import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
+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 { PureCodePreviewPanel } from "./pureCodePreviewPanel";
import { getFileSource } from '@/features/git';
@@ -10,14 +15,19 @@ interface CodePreviewPanelProps {
path: string;
repoName: string;
revisionName?: string;
+ // When set, the file's content is fetched at this ref while the
+ // surrounding browse context (path header) stays at `revisionName`.
+ previewRef?: string;
}
-export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => {
+export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef }: CodePreviewPanelProps) => {
+ const contentRef = previewRef ?? revisionName;
+
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
getFileSource({
path,
repo: repoName,
- ref: revisionName,
+ ref: contentRef,
}, { source: 'sourcebot-web-client' }),
getRepoInfoByName(repoName),
]);
@@ -53,7 +63,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
displayName: repoInfoResponse.displayName,
externalWebUrl: repoInfoResponse.externalWebUrl,
}}
- revisionName={revisionName}
+ revisionName={contentRef}
/>
{fileWebUrl && (
@@ -74,12 +84,54 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
)}
+ {previewRef && (
+
+
+ Previewing file at revision{" "}
+
+ {truncateSha(previewRef)}
+
+
+
+
+
+
+
+
+
+
+ Close preview
+
+
+ )}
>
)
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/pureCodePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx
similarity index 98%
rename from packages/web/src/app/(app)/browse/[...path]/components/pureCodePreviewPanel.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx
index 7f49f6e23..af260bd4b 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/pureCodePreviewPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx
@@ -11,8 +11,8 @@ import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
import { search } from "@codemirror/search";
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
import { useEffect, useMemo, useState } from "react";
-import { EditorContextMenu } from "../../../components/editorContextMenu";
-import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "../../hooks/utils";
+import { EditorContextMenu } from "@/app/(app)/components/editorContextMenu";
+import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/(app)/browse/hooks/utils";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
interface PureCodePreviewPanelProps {
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/rangeHighlightingExtension.ts b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/rangeHighlightingExtension.ts
similarity index 96%
rename from packages/web/src/app/(app)/browse/[...path]/components/rangeHighlightingExtension.ts
rename to packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/rangeHighlightingExtension.ts
index 7d3287811..4f9db872d 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/rangeHighlightingExtension.ts
+++ b/packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/rangeHighlightingExtension.ts
@@ -2,7 +2,7 @@
import { StateField, Range } from "@codemirror/state";
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
-import { BrowseHighlightRange } from "../../hooks/utils";
+import { BrowseHighlightRange } from "@/app/(app)/browse/hooks/utils";
const markDecoration = Decoration.mark({
class: "searchMatch-selected",
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/commitHashLine.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/commitHashLine.tsx
new file mode 100644
index 000000000..7d15bac6e
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/commitHashLine.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
+import { useToast } from "@/components/hooks/use-toast";
+import Link from "next/link";
+import { Fragment, useCallback } from "react";
+import { getBrowsePath } from "../../../hooks/utils";
+
+interface CommitHashLineProps {
+ repoName: string;
+ commitHash: string;
+ parents: string[];
+}
+
+export const CommitHashLine = ({ repoName, commitHash, parents }: CommitHashLineProps) => {
+ const { toast } = useToast();
+
+ const onCopyHash = useCallback(() => {
+ navigator.clipboard.writeText(commitHash).then(() => {
+ toast({ description: "✅ Copied commit SHA to clipboard" });
+ });
+ return true;
+ }, [commitHash, toast]);
+
+ return (
+
+ {parents.length > 0 && (
+ <>
+
+ {parents.length} parent{parents.length > 1 ? 's' : ''}
+
+ {parents.map((parent, i) => (
+
+ {i > 0 && + }
+
+ {parent.slice(0, 7)}
+
+
+ ))}
+ >
+ )}
+ commit {commitHash.slice(0, 7)}
+
+
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/commitMessage.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/commitMessage.tsx
new file mode 100644
index 000000000..7d9a27d4a
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/commitMessage.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { CommitBody, CommitBodyToggle } from "@/app/(app)/browse/components/commitParts";
+import { useState } from "react";
+
+interface CommitMessageProps {
+ subject: string;
+ body: string;
+}
+
+export const CommitMessage = ({ subject, body }: CommitMessageProps) => {
+ const [isBodyExpanded, setIsBodyExpanded] = useState(false);
+ const hasBody = body.trim().length > 0;
+
+ return (
+ <>
+
+
{subject}
+ {hasBody && (
+
+ )}
+
+ {hasBody && isBodyExpanded && (
+
+ )}
+ >
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/diffStat.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/diffStat.tsx
new file mode 100644
index 000000000..b30b65675
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/diffStat.tsx
@@ -0,0 +1,97 @@
+import { FileDiff } from "@/features/git";
+
+const TOTAL_SQUARES = 5;
+
+// Count `+`/`-` lines across all hunks in a file.
+export const computeChangeCounts = (file: FileDiff) => {
+ let additions = 0;
+ let deletions = 0;
+ for (const hunk of file.hunks) {
+ for (const raw of hunk.body.split('\n')) {
+ if (raw.startsWith('+')) {
+ additions++;
+ } else if (raw.startsWith('-')) {
+ deletions++;
+ }
+ }
+ }
+ return { additions, deletions };
+};
+
+// Sum line-level change counts across multiple files.
+export const computeTotalChangeCounts = (files: FileDiff[]) => {
+ let additions = 0;
+ let deletions = 0;
+ for (const file of files) {
+ const counts = computeChangeCounts(file);
+ additions += counts.additions;
+ deletions += counts.deletions;
+ }
+ return { additions, deletions };
+};
+
+// Map a total change count to a number of filled squares (0–5) using a
+// log-ish scale so tiny diffs still show one square and huge diffs cap out.
+// Mirrors GitHub's diffstat indicator behavior.
+const filledSquaresForTotal = (total: number): number => {
+ if (total === 0) {
+ return 0;
+ }
+ if (total < 5) {
+ return 1;
+ }
+ if (total < 10) {
+ return 2;
+ }
+ if (total < 30) {
+ return 3;
+ }
+ if (total < 100) {
+ return 4;
+ }
+ return 5;
+};
+
+interface DiffStatProps {
+ additions: number;
+ deletions: number;
+}
+
+export const DiffStat = ({ additions, deletions }: DiffStatProps) => {
+ const total = additions + deletions;
+
+ // Skip rendering when there are no line-level changes (e.g. pure renames).
+ if (total === 0) {
+ return null;
+ }
+
+ const filled = filledSquaresForTotal(total);
+ const greenCount = Math.round((filled * additions) / total);
+ const redCount = filled - greenCount;
+ const emptyCount = TOTAL_SQUARES - filled;
+
+ return (
+
+ {additions > 0 && (
+
+{additions}
+ )}
+ {deletions > 0 && (
+
-{deletions}
+ )}
+
+ {Array.from({ length: greenCount }).map((_, i) => (
+
+ ))}
+ {Array.from({ length: redCount }).map((_, i) => (
+
+ ))}
+ {Array.from({ length: emptyCount }).map((_, i) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffList.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffList.tsx
new file mode 100644
index 000000000..e05431692
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffList.tsx
@@ -0,0 +1,92 @@
+'use client';
+
+import { FileDiff } from "@/features/git";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import { useMemo, useRef } from "react";
+import { FileDiffRow } from "./fileDiffRow";
+
+interface FileDiffListProps {
+ files: FileDiff[];
+ repoName: string;
+ commitSha: string;
+ // Null for the initial commit (no parent).
+ parentSha: string | null;
+}
+
+// Constants used to estimate row height up front so the virtualizer can size
+// the scroll area before any rows have actually been measured. The values are
+// approximate - the virtualizer re-measures each row after mount via
+// `measureElement`, so the estimate only affects initial scrollbar accuracy
+// and overscan placement.
+const ROW_HEADER_PX = 40;
+const LINE_HEIGHT_PX = 18;
+const MIN_ROW_HEIGHT_PX = 200;
+const CONTEXT_LINES_PER_HUNK = 6;
+
+const estimateRowHeight = (file: FileDiff): number => {
+ const visibleLines = file.hunks.reduce((sum, hunk) => {
+ return sum + Math.max(hunk.oldRange.lines, hunk.newRange.lines) + CONTEXT_LINES_PER_HUNK;
+ }, 0);
+
+ const estimated = ROW_HEADER_PX + visibleLines * LINE_HEIGHT_PX;
+ return Math.max(estimated, MIN_ROW_HEIGHT_PX);
+};
+
+export const FileDiffList = ({ files, repoName, commitSha, parentSha }: FileDiffListProps) => {
+ const parentRef = useRef(null);
+
+ const virtualizer = useVirtualizer({
+ count: files.length,
+ getScrollElement: () => parentRef.current,
+ estimateSize: (index) => estimateRowHeight(files[index]),
+ overscan: 6,
+ });
+
+ return (
+
+
+ {virtualizer.getVirtualItems().map((virtualRow) => {
+ const file = files[virtualRow.index];
+ const rowKey = file.newPath ?? file.oldPath ?? `idx-${virtualRow.index}`;
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx
new file mode 100644
index 000000000..d7c5c50ee
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
+import { FileDiff } from "@/features/git";
+import { FileCode } from "lucide-react";
+import { useCallback, useMemo } from "react";
+import { CommitActionLink } from "../../../components/commitParts";
+import { getBrowsePath } from "../../../hooks/utils";
+import { computeChangeCounts, DiffStat } from "./diffStat";
+import { getFileStatus, StatusBadge } from "./fileStatus";
+import { LightweightDiffViewer } from "./lightweightDiffViewer";
+
+const getDisplayPath = (file: FileDiff): string => {
+ if (getFileStatus(file) === 'renamed') {
+ return `${file.oldPath} → ${file.newPath}`;
+ }
+ return file.newPath ?? file.oldPath ?? '';
+};
+
+interface FileDiffRowProps {
+ file: FileDiff;
+ yOffset: number;
+ repoName: string;
+ commitSha: string;
+ // Null for the initial commit (no parent).
+ parentSha: string | null;
+}
+
+export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: FileDiffRowProps) => {
+ const status = getFileStatus(file);
+
+ // Deleted files don't exist at the commit, so the link points to the
+ // file's last existing state — the old path at the parent commit. For
+ // every other status, link to the new path at this commit.
+ const isDeleted = status === 'deleted';
+ const linkPath = isDeleted ? file.oldPath : file.newPath;
+ const linkRevision = isDeleted ? parentSha : commitSha;
+ const viewAtCommitHref = linkPath && linkRevision
+ ? getBrowsePath({
+ repoName,
+ revisionName: linkRevision,
+ path: linkPath,
+ pathType: 'blob',
+ })
+ : null;
+
+ const onCopyPath = useCallback(() => {
+ const pathToCopy = file.newPath ?? file.oldPath;
+ if (!pathToCopy) {
+ return false;
+ }
+ navigator.clipboard.writeText(pathToCopy);
+ return true;
+ }, [file.newPath, file.oldPath]);
+
+ const changeCounts = useMemo(() => computeChangeCounts(file), [file]);
+
+ return (
+
+
+
+
+ {getDisplayPath(file)}
+
+
+
+ {viewAtCommitHref && (
+
}
+ />
+ )}
+
+ {file.hunks.length === 0 ? (
+
+ No textual diff (binary file or empty change).
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileStatus.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileStatus.tsx
new file mode 100644
index 000000000..4e54b19f7
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileStatus.tsx
@@ -0,0 +1,38 @@
+import type { FileDiff } from "@/features/git";
+
+export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed';
+
+export const getFileStatus = (file: FileDiff): FileStatus => {
+ if (!file.oldPath) {
+ return 'added';
+ }
+ if (!file.newPath) {
+ return 'deleted';
+ }
+ if (file.oldPath !== file.newPath) {
+ return 'renamed';
+ }
+ return 'modified';
+};
+
+const STATUS_BADGE_LABELS: Record = {
+ added: 'A',
+ modified: 'M',
+ deleted: 'D',
+ renamed: 'R',
+};
+
+const STATUS_BADGE_COLORS: Record = {
+ added: 'bg-green-500/20 text-green-700 dark:text-green-400',
+ modified: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
+ deleted: 'bg-red-500/20 text-red-700 dark:text-red-400',
+ renamed: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
+};
+
+export const StatusBadge = ({ status }: { status: FileStatus }) => (
+
+ {STATUS_BADGE_LABELS[status]}
+
+);
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
new file mode 100644
index 000000000..308ab9f6c
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/focusedCommitDiffPanel.tsx
@@ -0,0 +1,195 @@
+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',
+};
+
+interface FocusedCommitDiffPanelProps {
+ repoName: string;
+ revisionName?: string;
+ commitSha: string;
+ 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}
+
+ );
+ }
+
+ if (isServiceError(repoInfoResponse)) {
+ return (
+
+ Error loading repo info: {repoInfoResponse.message}
+
+ );
+ }
+
+ // 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/fullCommitDiffPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fullCommitDiffPanel.tsx
new file mode 100644
index 000000000..1379f69dd
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fullCommitDiffPanel.tsx
@@ -0,0 +1,131 @@
+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 Link from "next/link";
+import { formatAuthorsText, getCommitAuthors } from "../../../components/commitAuthors";
+import { AuthorsAvatarGroup } from "../../../components/commitParts";
+import { getBrowsePath } from "../../../hooks/utils";
+import { CommitHashLine } from "./commitHashLine";
+import { CommitMessage } from "./commitMessage";
+import { computeTotalChangeCounts, DiffStat } from "./diffStat";
+import { FileDiffList } from "./fileDiffList";
+
+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,
+ }),
+ ]);
+
+ if (isServiceError(commitResponse)) {
+ return (
+
+ 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,
+ });
+ }
+
+ if (isServiceError(diffResponse)) {
+ return (
+
+ Error loading diff: {diffResponse.message}
+
+ );
+ }
+
+ 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');
+ const totalChangeCounts = computeTotalChangeCounts(diffResponse.files);
+ const authors = getCommitAuthors(commitResponse);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ Browse files
+
+
+
+ View code at this commit
+
+
+
+
+
a.name).join(", ")}
+ >
+ {formatAuthorsText(authors)}
+
+
committed on {formattedDate}
+
+
+
+
+
+ {diffResponse.files.length} file{diffResponse.files.length > 1 ? 's' : ''} changed
+
+
+
+ {diffResponse.files.length === 0 ? (
+
No files changed.
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/hunkParser.ts b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/hunkParser.ts
new file mode 100644
index 000000000..37f04216d
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/hunkParser.ts
@@ -0,0 +1,35 @@
+import { DiffHunk } from "@/features/git";
+
+export type LineKind = 'add' | 'del' | 'context';
+
+export interface DiffLine {
+ kind: LineKind;
+ content: string;
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+export const parseHunkLines = (hunk: DiffHunk): DiffLine[] => {
+ const lines: DiffLine[] = [];
+ let oldLine = hunk.oldRange.start;
+ let newLine = hunk.newRange.start;
+
+ for (const raw of hunk.body.split('\n')) {
+ const prefix = raw[0];
+ const content = raw.slice(1);
+ if (prefix === '+') {
+ lines.push({ kind: 'add', content, newLineNumber: newLine++ });
+ } else if (prefix === '-') {
+ lines.push({ kind: 'del', content, oldLineNumber: oldLine++ });
+ } else {
+ // Treat anything else (space prefix or empty body line) as context.
+ lines.push({
+ kind: 'context',
+ content,
+ oldLineNumber: oldLine++,
+ newLineNumber: newLine++,
+ });
+ }
+ }
+ return lines;
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/lightweightDiffViewer.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/lightweightDiffViewer.tsx
new file mode 100644
index 000000000..7bdbcc516
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/lightweightDiffViewer.tsx
@@ -0,0 +1,294 @@
+'use client';
+
+import { useCodeMirrorHighlighter } from "@/hooks/useCodeMirrorHighlighter";
+import { getCodeParserByFilename } from "@/lib/codeHighlight";
+import { DiffHunk } from "@/features/git";
+import { presentableDiff } from "@codemirror/merge";
+import { Parser } from "@lezer/common";
+import { Highlighter, highlightTree } from "@lezer/highlight";
+import { Fragment, ReactNode, useEffect, useMemo, useState } from "react";
+import { DiffLine, parseHunkLines } from "./hunkParser";
+import { pairForSplit, SplitRow } from "./splitPairing";
+
+interface LightweightDiffViewerProps {
+ hunks: DiffHunk[];
+ oldPath: string | null;
+ newPath: string | null;
+}
+
+const SIDE_BG: Record<'left' | 'right', Record<'add' | 'del' | 'context' | 'blank', string>> = {
+ left: {
+ add: '',
+ del: 'bg-red-500/10',
+ context: '',
+ blank: 'bg-muted',
+ },
+ right: {
+ add: 'bg-green-500/10',
+ del: '',
+ context: '',
+ blank: 'bg-muted',
+ },
+};
+
+const INNER_DIFF_BG: Record<'left' | 'right', string> = {
+ left: 'bg-red-500/30',
+ right: 'bg-green-500/30',
+};
+
+const MARKER: Record<'add' | 'del' | 'context', string> = {
+ add: '+',
+ del: '-',
+ context: ' ',
+};
+
+// Mirrors `lightweightCodeHighlighter`: skip rendering when any line in the
+// diff exceeds this length. Tree-sitter parsing + per-character span emission
+// gets very expensive on minified files (one-line bundles, etc.), and the
+// resulting display is unreadable anyway.
+const MAX_NUMBER_OF_CHARACTER_PER_LINE = 1000;
+
+export const LightweightDiffViewer = ({ hunks, oldPath, newPath }: LightweightDiffViewerProps) => {
+ const filename = (newPath ?? oldPath ?? '').split('/').pop() ?? '';
+ const highlighter = useCodeMirrorHighlighter();
+
+ const [parser, setParser] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ getCodeParserByFilename(filename).then((p) => {
+ if (!cancelled) {
+ setParser(p);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [filename]);
+
+ const isDiffTooLargeToDisplay = useMemo(() => {
+ return hunks.some((hunk) =>
+ hunk.body.split('\n').some((line) => line.length > MAX_NUMBER_OF_CHARACTER_PER_LINE),
+ );
+ }, [hunks]);
+
+ if (isDiffTooLargeToDisplay) {
+ return (
+
+ Diff too large to display in preview.
+
+ );
+ }
+
+ return (
+ , max-content)` for the line-number and marker
+ // columns: when one side of the diff is entirely blank
+ // (fully-added or fully-deleted files), there's nothing on that
+ // side to size the column, and `max-content` collapses it to
+ // zero. The minimums keep these columns at a consistent width
+ // so the right side starts at the same X across files.
+ gridTemplateColumns: 'minmax(2.5rem, max-content) minmax(1ch, max-content) minmax(0, 1fr) minmax(2.5rem, max-content) minmax(1ch, max-content) minmax(0, 1fr)',
+ columnGap: '0px',
+ }}
+ >
+ {hunks.map((hunk, hunkIdx) => {
+ const lines = parseHunkLines(hunk);
+ const rows = pairForSplit(lines);
+ return (
+
+
+ {rows.map((row, rowIdx) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+};
+
+const HunkHeader = ({ hunk, className = '' }: { hunk: DiffHunk; className?: string }) => {
+ const range = `@@ -${hunk.oldRange.start},${hunk.oldRange.lines} +${hunk.newRange.start},${hunk.newRange.lines} @@`;
+ return (
+
+ {range}
+ {hunk.heading && {hunk.heading} }
+
+ );
+};
+
+interface SplitRowViewProps {
+ row: SplitRow;
+ parser: Parser | null;
+ highlighter: Highlighter;
+}
+
+const SplitRowView = ({ row, parser, highlighter }: SplitRowViewProps) => {
+ // For paired modifications (both sides populated, neither is context),
+ // run a character-level diff so we can mark the changed character ranges
+ // within each line.
+ const intra = useMemo(() => {
+ if (!row.left || !row.right) {
+ return null;
+ }
+ if (row.left.kind !== 'del' || row.right.kind !== 'add') {
+ return null;
+ }
+ const changes = presentableDiff(row.left.content, row.right.content);
+ return {
+ leftRanges: changes.map((c) => ({ from: c.fromA, to: c.toA })),
+ rightRanges: changes.map((c) => ({ from: c.fromB, to: c.toB })),
+ };
+ }, [row.left, row.right]);
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+interface SideCellProps {
+ line: DiffLine | null;
+ side: 'left' | 'right';
+ parser: Parser | null;
+ highlighter: Highlighter;
+ innerDiffRanges?: { from: number; to: number }[];
+}
+
+const SideCell = ({ line, side, parser, highlighter, innerDiffRanges }: SideCellProps) => {
+ // Drawn on the left side's right edge to visually separate the two panes.
+ const separator = side === 'left' ? 'border-r border-border' : '';
+
+ if (!line) {
+ return (
+
+ );
+ }
+
+ const lineNumber = side === 'left' ? line.oldLineNumber : line.newLineNumber;
+ const bg = SIDE_BG[side][line.kind];
+ const marker = MARKER[line.kind];
+
+ return (
+
+
+ {lineNumber ?? ''}
+
+ {marker}
+
+ {renderLineContent(line.content, parser, highlighter, innerDiffRanges, INNER_DIFF_BG[side])}
+
+
+ );
+};
+
+// Synchronous Lezer highlight + optional inner-diff overlay. Walks tokens
+// returned by `highlightTree`, fills in unstyled gaps, and splits each token
+// further at inner-diff range boundaries so we can apply the inner-diff class
+// without breaking syntax classes.
+const renderLineContent = (
+ content: string,
+ parser: Parser | null,
+ highlighter: Highlighter,
+ innerDiffRanges: { from: number; to: number }[] = [],
+ innerDiffClass: string = '',
+): ReactNode => {
+ if (!content) {
+ return null;
+ }
+ if (!parser) {
+ return content;
+ }
+
+ const tree = parser.parse(content);
+ const output: ReactNode[] = [];
+
+ const emit = (from: number, to: number, syntaxClass: string | null) => {
+ // Split [from, to) at inner-diff range boundaries so we can layer
+ // the inner-diff background on top of the syntax color.
+ let cursor = from;
+ while (cursor < to) {
+ const inDiff = isInRange(cursor, innerDiffRanges);
+ let next = to;
+ for (const r of innerDiffRanges) {
+ if (r.from > cursor && r.from < next) {
+ next = r.from;
+ }
+ if (r.to > cursor && r.to < next) {
+ next = r.to;
+ }
+ }
+ const text = content.slice(cursor, next);
+ const className = [syntaxClass, inDiff ? innerDiffClass : null].filter(Boolean).join(' ');
+ output.push(
+ className
+ ? {text}
+ : {text}
+ );
+ cursor = next;
+ }
+ };
+
+ let pos = 0;
+ highlightTree(tree, highlighter, (from, to, classes) => {
+ if (from > pos) {
+ emit(pos, from, null);
+ }
+ emit(from, to, classes);
+ pos = to;
+ });
+ if (pos < content.length) {
+ emit(pos, content.length, null);
+ }
+ return output;
+};
+
+const isInRange = (index: number, ranges: { from: number; to: number }[]): boolean => {
+ return ranges.some((r) => index >= r.from && index < r.to);
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/splitPairing.ts b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/splitPairing.ts
new file mode 100644
index 000000000..b623f01ec
--- /dev/null
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/splitPairing.ts
@@ -0,0 +1,38 @@
+import { DiffLine } from "./hunkParser";
+
+export interface SplitRow {
+ left: DiffLine | null;
+ right: DiffLine | null;
+}
+
+// Walk the line list and emit one row per visual position. Context lines
+// appear in both columns. Runs of consecutive del/add lines are paired
+// index-by-index — leftover rows on the longer side get a blank cell on
+// the shorter side.
+export const pairForSplit = (lines: DiffLine[]): SplitRow[] => {
+ const rows: SplitRow[] = [];
+ let i = 0;
+ while (i < lines.length) {
+ const line = lines[i];
+ if (line.kind === 'context') {
+ rows.push({ left: line, right: line });
+ i++;
+ continue;
+ }
+
+ const dels: DiffLine[] = [];
+ const adds: DiffLine[] = [];
+ while (i < lines.length && lines[i].kind === 'del') {
+ dels.push(lines[i++]);
+ }
+ while (i < lines.length && lines[i].kind === 'add') {
+ adds.push(lines[i++]);
+ }
+
+ const max = Math.max(dels.length, adds.length);
+ for (let k = 0; k < max; k++) {
+ rows.push({ left: dels[k] ?? null, right: adds[k] ?? null });
+ }
+ }
+ return rows;
+};
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/authorFilter.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/authorFilter.tsx
similarity index 100%
rename from packages/web/src/app/(app)/browse/[...path]/components/authorFilter.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/authorFilter.tsx
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/commitRow.tsx
similarity index 61%
rename from packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/commitRow.tsx
index 1ceeed0d8..ad44083d8 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/commitRow.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/commitRow.tsx
@@ -1,48 +1,41 @@
'use client';
import { useCallback, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
-import { Code, FileCode } from "lucide-react";
+import { Code } from "lucide-react";
import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
import { useToast } from "@/components/hooks/use-toast";
-import type { Commit, GitObjectPathType } from "@/features/git";
-import { getBrowsePath } from "../../hooks/utils";
-import { formatAuthorsText, getCommitAuthors } from "../../components/commitAuthors";
+import type { Commit } from "@/features/git";
+import { getBrowsePath } from "@/app/(app)/browse/hooks/utils";
+import { formatAuthorsText, getCommitAuthors } from "@/app/(app)/browse/components/commitAuthors";
import {
AuthorsAvatarGroup,
CommitActionLink,
CommitBody,
CommitBodyToggle,
-} from "../../components/commitParts";
+} from "@/app/(app)/browse/components/commitParts";
interface CommitRowProps {
commit: Commit;
repoName: string;
- path: string;
- pathType: GitObjectPathType;
+ revisionName?: string;
}
-export const CommitRow = ({ commit, repoName, path, pathType }: CommitRowProps) => {
+export const CommitRow = ({ commit, repoName, revisionName }: CommitRowProps) => {
const [isBodyExpanded, setIsBodyExpanded] = useState(false);
const { toast } = useToast();
+ const router = useRouter();
const shortSha = commit.hash.slice(0, 7);
const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true });
const hasBody = commit.body.trim().length > 0;
- const isBlobPath = pathType === 'blob';
const authors = useMemo(
() => getCommitAuthors(commit),
[commit],
);
- const viewFileAtCommitHref = getBrowsePath({
- repoName,
- revisionName: commit.hash,
- path,
- pathType: 'blob',
- });
-
const viewRepoAtCommitHref = getBrowsePath({
repoName,
revisionName: commit.hash,
@@ -50,6 +43,14 @@ export const CommitRow = ({ commit, repoName, path, pathType }: CommitRowProps)
pathType: 'tree',
});
+ const commitDiffHref = getBrowsePath({
+ repoName,
+ revisionName,
+ path: '',
+ pathType: 'commit',
+ commitSha: commit.hash,
+ });
+
const onCopySha = useCallback(() => {
navigator.clipboard.writeText(commit.hash).then(() => {
toast({ description: "✅ Copied commit SHA to clipboard" });
@@ -57,9 +58,39 @@ export const CommitRow = ({ commit, repoName, path, pathType }: CommitRowProps)
return true;
}, [commit.hash, toast]);
+ const navigateToCommit = useCallback(() => {
+ router.push(commitDiffHref);
+ }, [router, commitDiffHref]);
+
+ // Navigate to the commit diff when the row is clicked, unless the click
+ // originated from an interactive child (button or link) — those keep their
+ // own behavior (copy SHA, view file/repo at commit, expand body, etc.).
+ const onRowClick = useCallback((event: React.MouseEvent) => {
+ const target = event.target as HTMLElement;
+ if (target.closest('button, a')) {
+ return;
+ }
+ navigateToCommit();
+ }, [navigateToCommit]);
+
+ const onRowKeyDown = useCallback((event: React.KeyboardEvent) => {
+ if ((event.target as HTMLElement).closest('button, a')) {
+ return;
+ }
+ if (event.key === 'Enter') {
+ navigateToCommit();
+ }
+ }, [navigateToCommit]);
+
return (
<>
-
+
@@ -84,13 +115,6 @@ export const CommitRow = ({ commit, repoName, path, pathType }: CommitRowProps)
{shortSha}
- {isBlobPath && (
- }
- />
- )}
))}
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/dateFilter.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/dateFilter.tsx
similarity index 100%
rename from packages/web/src/app/(app)/browse/[...path]/components/dateFilter.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/commitHistoryPanel/dateFilter.tsx
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/pureTreePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx
similarity index 89%
rename from packages/web/src/app/(app)/browse/[...path]/components/pureTreePreviewPanel.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx
index ca6bb7985..3f7c0f473 100644
--- a/packages/web/src/app/(app)/browse/[...path]/components/pureTreePreviewPanel.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/pureTreePreviewPanel.tsx
@@ -2,9 +2,9 @@
import { useRef } from "react";
import { FileTreeItemComponent } from "@/app/(app)/browse/components/fileTreeItemComponent";
-import { getBrowsePath } from "../../hooks/utils";
+import { getBrowsePath } from "@/app/(app)/browse/hooks/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
-import { useBrowseParams } from "../../hooks/useBrowseParams";
+import { useBrowseParams } from "@/app/(app)/browse/hooks/useBrowseParams";
import { FileTreeItem } from "@/features/git";
interface PureTreePreviewPanelProps {
diff --git a/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx
similarity index 100%
rename from packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel.tsx
rename to packages/web/src/app/(app)/browse/[...path]/components/treePreviewPanel/treePreviewPanel.tsx
diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx
index 9274e7735..bfd42d6c1 100644
--- a/packages/web/src/app/(app)/browse/[...path]/page.tsx
+++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx
@@ -1,9 +1,11 @@
import { Suspense } from "react";
import { getBrowseParamsFromPathParam } from "../hooks/utils";
-import { CodePreviewPanel } from "./components/codePreviewPanel";
-import { CommitsPanel } from "./components/commitsPanel";
+import { CodePreviewPanel } from "./components/codePreviewPanel/codePreviewPanel";
+import { FocusedCommitDiffPanel } from "./components/commitDiffPanel/focusedCommitDiffPanel";
+import { FullCommitDiffPanel } from "./components/commitDiffPanel/fullCommitDiffPanel";
+import { CommitsPanel } from "./components/commitHistoryPanel/commitsPanel";
import { Loader2 } from "lucide-react";
-import { TreePreviewPanel } from "./components/treePreviewPanel";
+import { TreePreviewPanel } from "./components/treePreviewPanel/treePreviewPanel";
import { Metadata } from "next";
/**
@@ -17,58 +19,63 @@ import { Metadata } from "next";
* @returns A formatted title string.
*/
const parsePathForTitle = (path: string[]): string => {
- const pathParam = path.join('/');
-
- const { repoName, revisionName, path: filePath, pathType } = getBrowseParamsFromPathParam(pathParam);
-
- // Build the base repository and revision string.
- const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix
- const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`;
-
- switch (pathType) {
- case 'blob': {
- // For blobs, get the filename from the end of the path.
- const fileName = filePath.split('/').pop() || filePath;
- return `${fileName} - ${repoAndRevision}`;
- }
- case 'tree': {
- // If the path is empty, it's the repo root.
- if (filePath === '' || filePath === '/') {
- return repoAndRevision;
- }
- // Otherwise, show the directory path.
- const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`;
- return `${directoryPath} - ${repoAndRevision}`;
+ const pathParam = path.join('/');
+
+ const browseProps = getBrowseParamsFromPathParam(pathParam);
+ const { repoName, revisionName, path: filePath } = browseProps;
+
+ // Build the base repository and revision string.
+ const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix
+ const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`;
+
+ switch (browseProps.pathType) {
+ case 'blob': {
+ // For blobs, get the filename from the end of the path.
+ const fileName = filePath.split('/').pop() || filePath;
+ return `${fileName} - ${repoAndRevision}`;
+ }
+ case 'tree': {
+ // If the path is empty, it's the repo root.
+ if (filePath === '' || filePath === '/') {
+ return repoAndRevision;
+ }
+ // Otherwise, show the directory path.
+ const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`;
+ return `${directoryPath} - ${repoAndRevision}`;
+ }
+ case 'commits': {
+ if (filePath === '' || filePath === '/') {
+ return `History - ${repoAndRevision}`;
+ }
+ return `History: ${filePath} - ${repoAndRevision}`;
+ }
+ case 'commit': {
+ const shortSha = browseProps.commitSha.substring(0, 7);
+ return `Commit ${shortSha} - ${repoAndRevision}`;
+ }
}
- case 'commits': {
- if (filePath === '' || filePath === '/') {
- return `History - ${repoAndRevision}`;
- }
- return `History: ${filePath} - ${repoAndRevision}`;
- }
- }
}
type Props = {
- params: Promise<{
- path: string[];
- }>;
+ params: Promise<{
+ path: string[];
+ }>;
};
export async function generateMetadata({ params: paramsPromise }: Props): Promise
{
- let title = 'Browse'; // Default Fallback
+ let title = 'Browse'; // Default Fallback
- try {
- const params = await paramsPromise;
- title = parsePathForTitle(params.path);
+ try {
+ const params = await paramsPromise;
+ title = parsePathForTitle(params.path);
- } catch (error) {
- console.error("Failed to generate metadata title from path:", error);
- }
+ } catch (error) {
+ console.error("Failed to generate metadata title from path:", error);
+ }
- return {
- title,
- };
+ return {
+ title,
+ };
}
interface BrowsePageProps {
@@ -80,6 +87,8 @@ interface BrowsePageProps {
author?: string;
since?: string;
until?: string;
+ ref?: string;
+ diff?: string;
}>;
}
@@ -91,12 +100,15 @@ export default async function BrowsePage(props: BrowsePageProps) {
} = params;
const rawPath = _rawPath.join('/');
- const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath);
+ const browseProps = getBrowseParamsFromPathParam(rawPath);
+ const { repoName, revisionName, path } = browseProps;
const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1);
const author = searchParams.author || undefined;
const since = searchParams.since || undefined;
const until = searchParams.until || undefined;
+ const previewRef = searchParams.ref || undefined;
+ const isDiffMode = searchParams.diff === 'true';
return (
@@ -106,13 +118,23 @@ export default async function BrowsePage(props: BrowsePageProps) {
Loading...
}>
- {pathType === 'blob' ? (
-
- ) : pathType === 'commits' ? (
+ {browseProps.pathType === 'blob' ? (
+ isDiffMode && previewRef ? (
+
+ ) : (
+
+ )
+ ) : browseProps.pathType === 'commits' ? (
+ ) : browseProps.pathType === 'commit' ? (
+
) : (
{
updateBrowseState({ isBottomPanelCollapsed: true })}
>
View full history
diff --git a/packages/web/src/app/(app)/browse/components/historyPanel.tsx b/packages/web/src/app/(app)/browse/components/historyPanel.tsx
index dc6ef318a..d26f51dac 100644
--- a/packages/web/src/app/(app)/browse/components/historyPanel.tsx
+++ b/packages/web/src/app/(app)/browse/components/historyPanel.tsx
@@ -93,6 +93,7 @@ export const HistoryPanel = () => {
key={commit.hash}
commit={commit}
repoName={repoName}
+ revisionName={revisionName}
path={path}
pathType={pathType}
/>
diff --git a/packages/web/src/app/(app)/browse/components/historyRow.tsx b/packages/web/src/app/(app)/browse/components/historyRow.tsx
index 721640efb..4dea78c92 100644
--- a/packages/web/src/app/(app)/browse/components/historyRow.tsx
+++ b/packages/web/src/app/(app)/browse/components/historyRow.tsx
@@ -1,32 +1,44 @@
'use client';
import { useMemo } from "react";
+import { useSearchParams } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { Code, FileCode } from "lucide-react";
import type { Commit } from "@/features/git";
-import { BrowsePathType, getBrowsePath } from "../hooks/utils";
+import { cn } from "@/lib/utils";
+import { HoverPrefetchLink } from "@/app/(app)/components/hoverPrefetchLink";
+import { useBrowseParams } from "../hooks/useBrowseParams";
+import { BrowsePathType, getBrowsePath, PREVIEW_REF_QUERY_PARAM } from "../hooks/utils";
import { formatAuthorsText, getCommitAuthors } from "./commitAuthors";
import { AuthorsAvatarGroup, CommitActionLink } from "./commitParts";
interface HistoryRowProps {
commit: Commit;
repoName: string;
+ revisionName?: string;
path: string;
pathType: BrowsePathType;
}
-export const HistoryRow = ({ commit, repoName, path, pathType }: HistoryRowProps) => {
+export const HistoryRow = ({ commit, repoName, revisionName, path, pathType }: HistoryRowProps) => {
+ const browseParams = useBrowseParams();
+ const searchParams = useSearchParams();
const shortSha = commit.hash.slice(0, 7);
const relativeDate = formatDistanceToNow(new Date(commit.date), { addSuffix: true });
const isBlobPath = pathType === 'blob';
+ const isSelected =
+ browseParams.pathType === 'blob' &&
+ searchParams.get(PREVIEW_REF_QUERY_PARAM) === commit.hash;
+
const authors = useMemo(() => getCommitAuthors(commit), [commit]);
const viewCodeHref = getBrowsePath({
repoName,
- revisionName: commit.hash,
+ revisionName,
path,
pathType: 'blob',
+ previewRef: commit.hash,
});
const viewRepoHref = getBrowsePath({
@@ -36,17 +48,58 @@ export const HistoryRow = ({ commit, repoName, path, pathType }: HistoryRowProps
pathType: 'tree',
});
+ // The short SHA always links to the full commit diff (`/-/commit/`).
+ const fullCommitHref = getBrowsePath({
+ repoName,
+ revisionName,
+ path: '',
+ pathType: 'commit',
+ commitSha: commit.hash,
+ });
+
+ // The commit message links to the focused diff for the current file —
+ // only meaningful when the user is browsing a file (blob path).
+ const focusedDiffHref = isBlobPath
+ ? getBrowsePath({
+ repoName,
+ revisionName,
+ path,
+ pathType: 'blob',
+ previewRef: commit.hash,
+ diff: true,
+ })
+ : undefined;
+
return (
-
-
+
{shortSha}
-
-
- {commit.message}
-
+
+ {focusedDiffHref ? (
+
+ {commit.message}
+
+ ) : (
+
+ {commit.message}
+
+ )}
{
const router = useRouter();
- const navigateToPath = useCallback(({
- repoName,
- revisionName = 'HEAD',
- path,
- pathType,
- highlightRange,
- setBrowseState,
- }: GetBrowsePathProps) => {
+ const navigateToPath = useCallback((props: BrowseProps) => {
const browsePath = getBrowsePath({
- repoName,
- revisionName,
- path,
- pathType,
- highlightRange,
- setBrowseState,
+ ...props,
+ revisionName: props.revisionName ?? 'HEAD',
});
router.push(browsePath);
diff --git a/packages/web/src/app/(app)/browse/hooks/utils.ts b/packages/web/src/app/(app)/browse/hooks/utils.ts
index afa1feb49..81ed29496 100644
--- a/packages/web/src/app/(app)/browse/hooks/utils.ts
+++ b/packages/web/src/app/(app)/browse/hooks/utils.ts
@@ -1,6 +1,8 @@
import { BrowseState, SET_BROWSE_STATE_QUERY_PARAM } from "../browseStateProvider";
export const HIGHLIGHT_RANGE_QUERY_PARAM = 'highlightRange';
+export const PREVIEW_REF_QUERY_PARAM = 'ref';
+export const DIFF_QUERY_PARAM = 'diff';
export type BrowseHighlightRange = {
start: { lineNumber: number; column: number; };
@@ -10,21 +12,56 @@ export type BrowseHighlightRange = {
end: { lineNumber: number; };
}
-export type BrowsePathType = 'blob' | 'tree' | 'commits';
-
-export interface GetBrowsePathProps {
+type BaseProps = {
repoName: string;
- revisionName?: string;
path: string;
- pathType: BrowsePathType;
- highlightRange?: BrowseHighlightRange;
+ revisionName?: string;
setBrowseState?: Partial;
}
-export const getBrowseParamsFromPathParam = (pathParam: string) => {
- const sentinelIndex = pathParam.search(/\/-\/(tree|blob|commits)/);
+type BlobProps = BaseProps & {
+ pathType: 'blob',
+ highlightRange?: BrowseHighlightRange;
+ // Override the ref the file's content is fetched at, while the surrounding
+ // browse context (file tree, etc.) stays anchored to `revisionName`.
+ previewRef?: string;
+ // When true, render the focused commit diff (for `previewRef`) instead of
+ // the file's source. Only meaningful alongside `previewRef`.
+ diff?: boolean;
+}
+
+type TreeProps = BaseProps & {
+ pathType: 'tree',
+}
+
+type CommitsProps = BaseProps & {
+ pathType: 'commits',
+}
+
+type CommitProps = BaseProps & {
+ pathType: 'commit',
+ commitSha: string,
+}
+
+export type BrowseProps =
+ BlobProps |
+ TreeProps |
+ CommitsProps |
+ CommitProps;
+
+export type BrowsePathType = BrowseProps['pathType'];
+
+// Repo-relative paths shouldn't have leading slashes — `git log -- /foo` (or
+// just `--`) treats them as absolute filesystem paths. Repo root and `/`
+// both map to the empty path.
+const normalizeRepoPath = (path: string): string => path.replace(/^\/+/, '');
+
+export const getBrowseParamsFromPathParam = (pathParam: string): BrowseProps => {
+ // @note: order matters — `commits` must come before `commit` so the regex
+ // engine doesn't greedily match `commit` against `/-/commits/...`.
+ const sentinelIndex = pathParam.search(/\/-\/(tree|blob|commits|commit)/);
if (sentinelIndex === -1) {
- throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob|commits)/" pattern`);
+ throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob|commits|commit)/" pattern`);
}
const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex));
@@ -33,63 +70,85 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => {
const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex);
const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1);
- const { path, pathType } = ((): { path: string, pathType: BrowsePathType } => {
- const path = pathParam.substring(sentinelIndex + '/-/'.length);
- const pathType: BrowsePathType = path.startsWith('tree')
- ? 'tree'
- : path.startsWith('commits')
- ? 'commits'
- : 'blob';
-
- // @note: decodedURIComponent is needed here incase the path contains a space.
- switch (pathType) {
- case 'tree':
- return {
- path: decodeURIComponent(path.startsWith('tree/') ? path.substring('tree/'.length) : path.substring('tree'.length)),
- pathType,
- };
- case 'commits':
- return {
- path: decodeURIComponent(path.startsWith('commits/') ? path.substring('commits/'.length) : path.substring('commits'.length)),
- pathType,
- };
- case 'blob':
- return {
- path: decodeURIComponent(path.startsWith('blob/') ? path.substring('blob/'.length) : path.substring('blob'.length)),
- pathType,
- };
+ const tail = pathParam.substring(sentinelIndex + '/-/'.length);
+ const pathType = ((): BrowsePathType => {
+ if (tail.startsWith('tree')) {
+ return 'tree';
+ }
+ else if (tail.startsWith('commits')) {
+ return 'commits';
+ }
+ else if (tail.startsWith('commit')) {
+ return 'commit';
}
- })();
-
- // Normalize parsed paths the same way URL generation does, so URLs that
- // happen to contain a leading slash (e.g. legacy bookmarks with `%2F`)
- // don't leak `/foo` into git log args.
- const normalizedPath = path.replace(/^\/+/, '');
- if (pathType === 'blob' && normalizedPath === '') {
- throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`);
- }
+ return 'blob';
+ })();
- return {
- repoName,
- revisionName,
- path: normalizedPath,
- pathType,
+ // @note: decodeURIComponent is needed in case the path contains a space.
+ switch (pathType) {
+ case 'tree': {
+ const rest = tail.startsWith('tree/') ? tail.substring('tree/'.length) : tail.substring('tree'.length);
+ return {
+ repoName,
+ revisionName,
+ pathType,
+ path: normalizeRepoPath(decodeURIComponent(rest)),
+ };
+ }
+ case 'commits': {
+ const rest = tail.startsWith('commits/') ? tail.substring('commits/'.length) : tail.substring('commits'.length);
+ return {
+ repoName,
+ revisionName,
+ pathType,
+ path: normalizeRepoPath(decodeURIComponent(rest)),
+ };
+ }
+ case 'commit': {
+ // Path suffix on /-/commit// is no longer used, but we
+ // keep the slash-split here so legacy URLs still resolve to the
+ // commit (we just ignore everything after the SHA).
+ const rest = tail.startsWith('commit/') ? tail.substring('commit/'.length) : tail.substring('commit'.length);
+ const firstSlash = rest.indexOf('/');
+ const commitSha = decodeURIComponent(firstSlash === -1 ? rest : rest.substring(0, firstSlash));
+
+ if (!commitSha) {
+ throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a commit SHA for commit type`);
+ }
+
+ return {
+ repoName,
+ revisionName,
+ pathType,
+ commitSha,
+ path: '',
+ };
+ }
+ case 'blob': {
+ const rest = tail.startsWith('blob/') ? tail.substring('blob/'.length) : tail.substring('blob'.length);
+ const path = normalizeRepoPath(decodeURIComponent(rest));
+
+ if (path === '') {
+ throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`);
+ }
+
+ return {
+ repoName,
+ revisionName,
+ pathType,
+ path,
+ };
+ }
}
};
-// Repo-relative paths shouldn't have leading slashes — `git log -- /foo` (or
-// just `--`) treats them as absolute filesystem paths. Repo root and `/`
-// both map to the empty path.
-const normalizeRepoPath = (path: string): string => path.replace(/^\/+/, '');
-
-export const getBrowsePath = ({
- repoName, revisionName, path, pathType, highlightRange, setBrowseState,
-}: GetBrowsePathProps) => {
+export const getBrowsePath = (props: BrowseProps) => {
+ const { repoName, revisionName, pathType, setBrowseState } = props;
const params = new URLSearchParams();
- if (highlightRange) {
- const { start, end } = highlightRange;
+ if (pathType === 'blob' && props.highlightRange) {
+ const { start, end } = props.highlightRange;
if ('column' in start && 'column' in end) {
params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`);
@@ -98,12 +157,21 @@ export const getBrowsePath = ({
}
}
+ if (pathType === 'blob' && props.previewRef) {
+ params.set(PREVIEW_REF_QUERY_PARAM, props.previewRef);
+ }
+
+ if (pathType === 'blob' && props.diff) {
+ params.set(DIFF_QUERY_PARAM, 'true');
+ }
+
if (setBrowseState) {
params.set(SET_BROWSE_STATE_QUERY_PARAM, JSON.stringify(setBrowseState));
}
- const encodedPath = encodeURIComponent(normalizeRepoPath(path));
- const browsePath = `/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`;
+ const tail = pathType === 'commit'
+ ? encodeURIComponent(props.commitSha)
+ : encodeURIComponent(normalizeRepoPath(props.path));
+ const browsePath = `/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${tail}${params.size > 0 ? `?${params.toString()}` : ''}`;
return browsePath;
};
-
diff --git a/packages/web/src/app/(app)/browse/layoutClient.tsx b/packages/web/src/app/(app)/browse/layoutClient.tsx
index b69dc4ad1..100724c3e 100644
--- a/packages/web/src/app/(app)/browse/layoutClient.tsx
+++ b/packages/web/src/app/(app)/browse/layoutClient.tsx
@@ -23,7 +23,7 @@ export function LayoutClient({
session,
isSearchAssistSupported,
}: LayoutProps) {
- const { repoName, revisionName } = useBrowseParams();
+ const { repoName, revisionName, pathType } = useBrowseParams();
return (
@@ -61,10 +61,14 @@ export function LayoutClient({
>
{children}
-
-
+ {(pathType === 'blob' || pathType === 'tree') && (
+ <>
+
+
+ >
+ )}
diff --git a/packages/web/src/app/(app)/components/hoverPrefetchLink.tsx b/packages/web/src/app/(app)/components/hoverPrefetchLink.tsx
new file mode 100644
index 000000000..8795e3adc
--- /dev/null
+++ b/packages/web/src/app/(app)/components/hoverPrefetchLink.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import Link from "next/link";
+import { ComponentPropsWithoutRef, useState } from "react";
+
+type HoverPrefetchLinkProps = Omit
, 'prefetch'>;
+
+// Drop-in replacement for next/link that defers prefetching until the user
+// hovers, instead of prefetching on viewport entry. Useful in long lists where
+// the default behavior would fire many prefetches up-front.
+export const HoverPrefetchLink = ({
+ onMouseEnter,
+ ...props
+}: HoverPrefetchLinkProps) => {
+ const [active, setActive] = useState(false);
+
+ return (
+ {
+ setActive(true);
+ onMouseEnter?.(event);
+ }}
+ />
+ );
+};
diff --git a/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx b/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx
index f1d922cf9..a88a3911f 100644
--- a/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx
+++ b/packages/web/src/app/(app)/components/lightweightCodeHighlighter.tsx
@@ -1,22 +1,11 @@
-import { Parser } from '@lezer/common'
-import { LanguageDescription, StreamLanguage } from '@codemirror/language'
-import { Highlighter, highlightTree } from '@lezer/highlight'
-import { languages as builtinLanguages } from '@codemirror/language-data'
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useCodeMirrorHighlighter } from '@/hooks/useCodeMirrorHighlighter'
import tailwind from '@/tailwind'
import { measure } from '@/lib/utils'
+import { highlightCode } from '@/lib/codeHighlight'
import { SourceRange } from '@/features/search'
import { CopyIconButton } from './copyIconButton'
-// Define a plain text language
-const plainTextLanguage = StreamLanguage.define({
- token(stream) {
- stream.next();
- return null;
- }
-});
-
interface LightweightCodeHighlighter {
language: string;
children: string;
@@ -189,128 +178,3 @@ export const LightweightCodeHighlighter = memo((prop
})
LightweightCodeHighlighter.displayName = 'LightweightCodeHighlighter';
-
-async function getCodeParser(
- languageName: string,
-): Promise {
- if (languageName) {
- const parser = await (async () => {
- const found = LanguageDescription.matchLanguageName(
- builtinLanguages,
- languageName,
- true
- );
-
- if (!found) {
- return null;
- }
-
- if (!found.support) {
- await found.load();
- }
- return found.support ? found.support.language.parser : null;
- })();
-
- if (parser) {
- return parser;
- }
- }
- return plainTextLanguage.parser;
-}
-
-async function highlightCode(
- languageName: string,
- input: string,
- highlighter: Highlighter,
- highlightRanges: { from: number, to: number }[] = [],
- callback: (
- text: string,
- style: string | null,
- from: number,
- to: number
- ) => Output,
-): Promise {
- const parser = await getCodeParser(languageName);
-
- /**
- * Converts a range to a series of highlighted subranges.
- */
- const convertRangeToHighlightedSubranges = (
- from: number,
- to: number,
- classes: string | null,
- cb: (from: number, to: number, classes: string | null) => void,
- ) => {
- type HighlightRange = {
- from: number,
- to: number,
- isHighlighted: boolean,
- }
-
- const highlightClasses = classes ? `${classes} searchMatch-selected` : 'searchMatch-selected';
-
- let currentRange: HighlightRange | null = null;
- for (let i = from; i < to; i++) {
- const isHighlighted = isIndexHighlighted(i, highlightRanges);
-
- if (currentRange) {
- if (currentRange.isHighlighted === isHighlighted) {
- currentRange.to = i + 1;
- } else {
- cb(
- currentRange.from,
- currentRange.to,
- currentRange.isHighlighted ? highlightClasses : classes,
- )
-
- currentRange = { from: i, to: i + 1, isHighlighted };
- }
- } else {
- currentRange = { from: i, to: i + 1, isHighlighted };
- }
- }
-
- if (currentRange) {
- cb(
- currentRange.from,
- currentRange.to,
- currentRange.isHighlighted ? highlightClasses : classes,
- )
- }
- }
-
- const tree = parser.parse(input)
- const output: Array = [];
-
- let pos = 0;
- highlightTree(tree, highlighter, (from, to, classes) => {
- // `highlightTree` only calls this callback when at least one style/class
- // is applied to the text (i.e., `classes` is not empty). This means that
- // any unstyled regions will be skipped (e.g., whitespace, `=`. `;`. etc).
- // This check ensures that we process these unstyled regions as well.
- // @see: https://discuss.codemirror.net/t/static-highlighting-using-cm-v6/3420/2
- if (from > pos) {
- convertRangeToHighlightedSubranges(pos, from, null, (from, to, classes) => {
- output.push(callback(input.slice(from, to), classes, from, to));
- })
- }
-
- convertRangeToHighlightedSubranges(from, to, classes, (from, to, classes) => {
- output.push(callback(input.slice(from, to), classes, from, to));
- })
-
- pos = to;
- });
-
- // Process any remaining unstyled regions.
- if (pos != tree.length) {
- convertRangeToHighlightedSubranges(pos, tree.length, null, (from, to, classes) => {
- output.push(callback(input.slice(from, to), classes, from, to));
- })
- }
- return output;
-}
-
-const isIndexHighlighted = (index: number, ranges: { from: number, to: number }[]) => {
- return ranges.some(range => index >= range.from && index < range.to);
-}
diff --git a/packages/web/src/app/(app)/components/pathHeader.tsx b/packages/web/src/app/(app)/components/pathHeader.tsx
index 048e1d641..1739fd724 100644
--- a/packages/web/src/app/(app)/components/pathHeader.tsx
+++ b/packages/web/src/app/(app)/components/pathHeader.tsx
@@ -1,6 +1,6 @@
'use client';
-import { cn, getCodeHostInfoForRepo } from "@/lib/utils";
+import { cn, getCodeHostInfoForRepo, truncateSha } from "@/lib/utils";
import Image from "next/image";
import { getBrowsePath } from "../browse/hooks/utils";
import { ChevronRight, MoreHorizontal } from "lucide-react";
@@ -115,7 +115,7 @@ export const PathHeader = ({
if (!containerRef.current || !breadcrumbsRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
- const availableWidth = containerWidth - 175; // Reserve space for copy button and padding
+ const availableWidth = containerWidth - 40; // Reserve space for copy button and padding
// Create a temporary element to measure segment widths
const tempElement = document.createElement('div');
@@ -233,10 +233,22 @@ export const PathHeader = ({
}}
>
@
- {`${branchDisplayName.replace(/^refs\/(heads|tags)\//, '')}`}
+
+ {truncateSha(branchDisplayName.replace(/^refs\/(heads|tags)\//, ''))}
+
)}
- ·
+ {breadcrumbSegments.length > 0 && (
+ ·
+ )}
{hiddenSegments.length > 0 && (
@@ -296,10 +308,12 @@ export const PathHeader = ({
))}
-
+ {breadcrumbSegments.length > 0 && (
+
+ )}
)
diff --git a/packages/web/src/app/api/(server)/diff/route.ts b/packages/web/src/app/api/(server)/diff/route.ts
index 452f30e5c..3e537e00d 100644
--- a/packages/web/src/app/api/(server)/diff/route.ts
+++ b/packages/web/src/app/api/(server)/diff/route.ts
@@ -1,24 +1,18 @@
import { getDiff } from "@/features/git";
+import { getDiffRequestSchema } from "@/features/git/schemas";
import { apiHandler } from "@/lib/apiHandler";
import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError";
import { isServiceError } from "@/lib/utils";
import { NextRequest } from "next/server";
-import { z } from "zod";
-
-const getDiffQueryParamsSchema = z.object({
- repo: z.string(),
- base: z.string(),
- head: z.string(),
-});
export const GET = apiHandler(async (request: NextRequest): Promise => {
const rawParams = Object.fromEntries(
- Object.keys(getDiffQueryParamsSchema.shape).map(key => [
+ Object.keys(getDiffRequestSchema.shape).map(key => [
key,
request.nextUrl.searchParams.get(key) ?? undefined
])
);
- const parsed = getDiffQueryParamsSchema.safeParse(rawParams);
+ const parsed = getDiffRequestSchema.safeParse(rawParams);
if (!parsed.success) {
return serviceErrorResponse(
diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx
index 60d521ad2..71a90e2a8 100644
--- a/packages/web/src/app/layout.tsx
+++ b/packages/web/src/app/layout.tsx
@@ -44,14 +44,14 @@ export default function RootLayout({
/>
)}
- {env.NODE_ENV === "development" && (
+ {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAB === 'true' && (
)}
- {env.NODE_ENV === "development" && (
+ {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAB === 'true' && (