diff --git a/CHANGELOG.md b/CHANGELOG.md index c96ee1b42..d3887f16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) +- Added collapsible file diffs in the commit diff panel. [#1157](https://github.com/sourcebot-dev/sourcebot/pull/1157) ### Fixed - Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155) 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 index e05431692..dc243b63b 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffList.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffList.tsx @@ -2,7 +2,7 @@ import { FileDiff } from "@/features/git"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useMemo, useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import { FileDiffRow } from "./fileDiffRow"; interface FileDiffListProps { @@ -23,7 +23,15 @@ const LINE_HEIGHT_PX = 18; const MIN_ROW_HEIGHT_PX = 200; const CONTEXT_LINES_PER_HUNK = 6; -const estimateRowHeight = (file: FileDiff): number => { +const getRowKey = (file: FileDiff, index: number): string => { + return file.newPath ?? file.oldPath ?? `idx-${index}`; +}; + +const estimateRowHeight = (file: FileDiff, isCollapsed: boolean): number => { + if (isCollapsed) { + return ROW_HEADER_PX; + } + const visibleLines = file.hunks.reduce((sum, hunk) => { return sum + Math.max(hunk.oldRange.lines, hunk.newRange.lines) + CONTEXT_LINES_PER_HUNK; }, 0); @@ -34,14 +42,39 @@ const estimateRowHeight = (file: FileDiff): number => { export const FileDiffList = ({ files, repoName, commitSha, parentSha }: FileDiffListProps) => { const parentRef = useRef(null); + const [collapsedKeys, setCollapsedKeys] = useState>(() => new Set()); + const collapsedKeysRef = useRef(collapsedKeys); + collapsedKeysRef.current = collapsedKeys; const virtualizer = useVirtualizer({ count: files.length, getScrollElement: () => parentRef.current, - estimateSize: (index) => estimateRowHeight(files[index]), + estimateSize: (index) => { + const file = files[index]; + return estimateRowHeight(file, collapsedKeys.has(getRowKey(file, index))); + }, overscan: 6, }); + const toggleCollapsed = useCallback((key: string, index: number, rowStart: number) => { + const willCollapse = !collapsedKeysRef.current.has(key); + setCollapsedKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + if (willCollapse) { + const scrollTop = parentRef.current?.scrollTop ?? 0; + if (scrollTop > rowStart) { + virtualizer.scrollToIndex(index, { align: 'start' }); + } + } + }, [virtualizer]); + return (
{virtualizer.getVirtualItems().map((virtualRow) => { const file = files[virtualRow.index]; - const rowKey = file.newPath ?? file.oldPath ?? `idx-${virtualRow.index}`; + const rowKey = getRowKey(file, virtualRow.index); return (
toggleCollapsed(rowKey, virtualRow.index, virtualRow.start)} />
); 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 index d7c5c50ee..b6a8c8c69 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx @@ -24,9 +24,11 @@ interface FileDiffRowProps { commitSha: string; // Null for the initial commit (no parent). parentSha: string | null; + isCollapsed: boolean; + onToggleCollapsed: () => void; } -export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: FileDiffRowProps) => { +export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha, isCollapsed, onToggleCollapsed }: FileDiffRowProps) => { const status = getFileStatus(file); // Deleted files don't exist at the commit, so the link points to the @@ -61,7 +63,7 @@ export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: F className="flex flex-row items-center gap-2 py-2 px-3 border-b bg-muted sticky z-10" style={{ top: `-${yOffset}px` }} > - +
{getDisplayPath(file)} @@ -75,16 +77,18 @@ export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: F /> )}
- {file.hunks.length === 0 ? ( -
- No textual diff (binary file or empty change). -
- ) : ( - + {!isCollapsed && ( + 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 index 4e54b19f7..dcd717b57 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileStatus.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileStatus.tsx @@ -1,4 +1,5 @@ import type { FileDiff } from "@/features/git"; +import { ChevronDown, ChevronRight } from "lucide-react"; export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed'; @@ -29,10 +30,35 @@ const STATUS_BADGE_COLORS: Record = { renamed: 'bg-blue-500/20 text-blue-700 dark:text-blue-400', }; -export const StatusBadge = ({ status }: { status: FileStatus }) => ( - - {STATUS_BADGE_LABELS[status]} - -); +interface StatusBadgeProps { + status: FileStatus; + onToggle?: () => void; + isCollapsed?: boolean; +} + +export const StatusBadge = ({ status, onToggle, isCollapsed }: StatusBadgeProps) => { + const baseClassName = `inline-flex items-center justify-center w-5 h-5 rounded text-xs font-mono font-bold ${STATUS_BADGE_COLORS[status]}`; + + if (!onToggle) { + return ( + + {STATUS_BADGE_LABELS[status]} + + ); + } + + const Chevron = isCollapsed ? ChevronRight : ChevronDown; + + return ( + + ); +};