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 ( +
+
+
+
+ +
+ + + + + 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 ( +