diff --git a/packages/app/src/data/query.ts b/packages/app/src/data/query.ts index b656bd836..003323719 100644 --- a/packages/app/src/data/query.ts +++ b/packages/app/src/data/query.ts @@ -1,6 +1,7 @@ import { keepPreviousData, skipToken, + useQueries, useQuery, type QueryKey, type UseQueryOptions, @@ -50,6 +51,12 @@ export function useFetchQuery< return useQuery(fetchQueryOptions(input)); } +export function useFetchQueries( + inputs: FetchQueryInput[], +): UseQueryResult[] { + return useQueries({ queries: inputs.map((input) => fetchQueryOptions(input)) }); +} + function replicaQueryOptions< TQueryFnData, TError = Error, diff --git a/packages/app/src/git/commits-section/commit-row.tsx b/packages/app/src/git/commits-section/commit-row.tsx new file mode 100644 index 000000000..4ea57941b --- /dev/null +++ b/packages/app/src/git/commits-section/commit-row.tsx @@ -0,0 +1,68 @@ +import { memo, useCallback } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import type { CheckoutCommit } from "@getpaseo/protocol/messages"; +import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; +import { dotStyles } from "./shared"; + +interface CommitRowProps { + commit: CheckoutCommit; + onCommitPress: (sha: string) => void; +} + +export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) { + const handlePress = useCallback(() => { + onCommitPress(commit.sha); + }, [commit.sha, onCommitPress]); + + return ( + + + {commit.shortSha} + + {commit.subject} + + + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[2], + paddingVertical: theme.spacing[1], + }, + shortSha: { + fontSize: theme.fontSize.xs, + fontFamily: theme.fontFamily.mono, + color: theme.colors.foregroundMuted, + flexShrink: 0, + }, + subject: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + caret: { + width: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, +})); diff --git a/packages/app/src/git/commits-section/commits-section.tsx b/packages/app/src/git/commits-section/commits-section.tsx new file mode 100644 index 000000000..0c2760130 --- /dev/null +++ b/packages/app/src/git/commits-section/commits-section.tsx @@ -0,0 +1,226 @@ +import { useCallback, useMemo } from "react"; +import { Pressable, Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { useTranslation } from "react-i18next"; +import { useChangesPreferences } from "@/hooks/use-changes-preferences"; +import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query"; +import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron"; +import { CommitRow } from "./commit-row"; +import { dotStyles } from "./shared"; + +interface CommitsSectionProps { + serverId: string; + cwd: string; + onCommitPress: (sha: string) => void; +} + +const SKELETON_ROW_KEYS = ["commit-skeleton-1", "commit-skeleton-2", "commit-skeleton-3"]; + +function CommitsSectionSkeleton() { + const { t } = useTranslation(); + return ( + + {SKELETON_ROW_KEYS.map((key) => ( + + + + + + ))} + + ); +} + +function CommitsSectionContent({ + query, + onCommitPress, +}: { + query: Exclude; + onCommitPress: (sha: string) => void; +}) { + const { t } = useTranslation(); + if (query.status === "error") { + return ( + + {t("workspace.git.diff.commits.loadError")} + + ); + } + if (query.status !== "loaded") { + return ; + } + if (query.data.commits.length === 0) { + return ( + + {t("workspace.git.diff.commits.empty")} + + ); + } + return ( + + {query.data.commits.map((commit) => ( + + ))} + + ); +} + +export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) { + const { t } = useTranslation(); + const { preferences, updatePreferences } = useChangesPreferences(); + const collapsed = preferences.commitsCollapsed; + const query = useCheckoutCommitsQuery({ + serverId, + cwd, + enabled: !collapsed, + }); + + const handleToggleSection = useCallback(() => { + void updatePreferences({ commitsCollapsed: !collapsed }); + }, [collapsed, updatePreferences]); + + const headerChevronStyle = useMemo( + () => [styles.headerChevron, !collapsed && styles.headerChevronExpanded], + [collapsed], + ); + + if (query.status === "unsupported") { + return null; + } + const commitCount = query.status === "loaded" ? query.data.commits.length : null; + + return ( + + + + + + {t("workspace.git.diff.commits.title")} + {commitCount === null ? ( + + ) : ( + + {commitCount} + + )} + + + {t("workspace.git.diff.commits.legendLocal")} + + {t("workspace.git.diff.commits.legendRemote")} + + + {collapsed ? null : } + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[3], + paddingVertical: theme.spacing[2], + flexShrink: 0, + }, + headerChevron: { + width: 16, + height: 16, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + headerChevronExpanded: { + transform: [{ rotate: "90deg" }], + }, + title: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + }, + count: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + flex: 1, + }, + countSpacer: { + flex: 1, + }, + legend: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 0, + }, + legendText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + list: { + paddingBottom: theme.spacing[1], + }, + emptyRow: { + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + errorRow: { + fontSize: theme.fontSize.xs, + color: theme.colors.statusDanger, + paddingLeft: theme.spacing[2], + paddingRight: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + skeleton: { + paddingHorizontal: theme.spacing[2], + paddingBottom: theme.spacing[2], + gap: theme.spacing[2], + }, + skeletonRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minHeight: 20, + }, + skeletonDot: { + width: 8, + height: 8, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + }, + skeletonSha: { + width: 48, + height: 10, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + }, + skeletonSubject: { + width: "55%", + height: 12, + borderRadius: theme.borderRadius.sm, + backgroundColor: theme.colors.surface2, + }, +})); diff --git a/packages/app/src/git/commits-section/shared.ts b/packages/app/src/git/commits-section/shared.ts new file mode 100644 index 000000000..11e7b3811 --- /dev/null +++ b/packages/app/src/git/commits-section/shared.ts @@ -0,0 +1,51 @@ +import { StyleSheet } from "react-native-unistyles"; +import type { CheckoutCommit } from "@getpaseo/protocol/messages"; + +export type CheckoutCommitFile = CheckoutCommit["files"][number]; + +export type FilePressHandler = (commit: CheckoutCommit, file: CheckoutCommitFile) => void; + +export const DOT_SIZE = 8; + +// The "on remote" dot is intentionally understated: the local-only ring is the +// state worth noticing (you still have work to push), so the remote fill is +// dimmed toward the background rather than rendered at full-strength green. +const REMOTE_DOT_OPACITY = 0.55; + +/** + * Shared local/remote commit dot styles used by both the commit rows and the + * section legend so the two never drift. + * + * Semantics: a local-only commit is a hollow ring; a commit that has reached + * the remote is a subtle (dimmed) filled green dot. + */ +export const dotStyles = StyleSheet.create((theme) => ({ + dotLocal: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: "transparent", + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.foregroundMuted, + flexShrink: 0, + }, + dotRemote: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.statusSuccess, + opacity: REMOTE_DOT_OPACITY, + flexShrink: 0, + }, + // Legend variant of the remote dot: same fill, with leading spacing so it + // separates from the preceding "local" label in the section header legend. + legendDotRemote: { + width: DOT_SIZE, + height: DOT_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.statusSuccess, + opacity: REMOTE_DOT_OPACITY, + flexShrink: 0, + marginLeft: theme.spacing[1], + }, +})); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 8fc3b701c..393fffc0f 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -7,7 +7,6 @@ import { memo, type ReactElement, type ReactNode, - type RefObject, } from "react"; import { useTranslation } from "react-i18next"; import { DiffStat } from "@/components/diff-stat"; @@ -62,6 +61,7 @@ import { SvgXml } from "react-native-svg"; import { getFileIconSvg } from "@/components/material-file-icons"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query"; +import { CommitsSection } from "@/git/commits-section/commits-section"; import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { useAppSettings } from "@/hooks/use-settings"; import { DiffScroll } from "@/components/diff-scroll"; @@ -94,6 +94,8 @@ import { useSessionStore } from "@/stores/session-store"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; import { formatDiffContentText, @@ -202,7 +204,8 @@ interface DiffFileSectionProps { depth?: number; /** Show the muted directory suffix (flat list); false inside the folder tree. */ showDir?: boolean; - onToggle: (path: string) => void; + interactive?: boolean; + onToggle?: (path: string) => void; onHeaderHeightChange?: (path: string, height: number) => void; testID?: string; } @@ -902,6 +905,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ isExpanded, depth = 0, showDir = true, + interactive = true, onToggle, onHeaderHeightChange, testID, @@ -912,9 +916,12 @@ const DiffFileHeader = memo(function DiffFileHeader({ const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null); const toggleExpanded = useCallback(() => { + if (!interactive) { + return; + } pressHandledRef.current = true; - onToggle(file.path); - }, [file.path, onToggle]); + onToggle?.(file.path); + }, [file.path, interactive, onToggle]); const handleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -935,7 +942,13 @@ const DiffFileHeader = memo(function DiffFileHeader({ const handlePressOut = useCallback( (event: { nativeEvent: { pageX: number; pageY: number } }) => { - if (isNative && !pressHandledRef.current && layoutYRef.current === 0 && pressInRef.current) { + if ( + interactive && + isNative && + !pressHandledRef.current && + layoutYRef.current === 0 && + pressInRef.current + ) { const durationMs = Date.now() - pressInRef.current.ts; const dx = event.nativeEvent.pageX - pressInRef.current.pageX; const dy = event.nativeEvent.pageY - pressInRef.current.pageY; @@ -945,7 +958,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ } } }, - [toggleExpanded], + [interactive, toggleExpanded], ); const containerStyle = useMemo( @@ -965,56 +978,65 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); const fileName = file.path.split("/").pop() ?? file.path; + const headerContent = ( + <> + + {showDir ? null : ( + + + + )} + + {fileName} + + {showDir ? ( + + {file.path.includes("/") ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` : ""} + + ) : ( + // Flex spacer in tree mode (no dir suffix) so the New/Deleted badge + // stays right-aligned next to the diff stats, as in the flat list. + + )} + {file.isNew && ( + + {t("workspace.git.diff.newFile")} + + )} + {file.isDeleted && ( + + {t("workspace.git.diff.deletedFile")} + + )} + + + + + + ); return ( - - - {showDir ? null : ( - - - - )} - - {fileName} - - {showDir ? ( - - {file.path.includes("/") - ? ` ${file.path.slice(0, file.path.lastIndexOf("/"))}` - : ""} - - ) : ( - // Flex spacer in tree mode (no dir suffix) so the New/Deleted badge - // stays right-aligned next to the diff stats, as in the flat list. - - )} - {file.isNew && ( - - {t("workspace.git.diff.newFile")} - - )} - {file.isDeleted && ( - - {t("workspace.git.diff.deletedFile")} - - )} + {interactive ? ( + + {headerContent} + + ) : ( + + {headerContent} - - - - + )} {file.path} @@ -1024,7 +1046,7 @@ const DiffFileHeader = memo(function DiffFileHeader({ ); }); -function DiffFileBody({ +export function DiffFileBody({ file, layout, wrapLines, @@ -1480,6 +1502,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); type DiffFlatItemLayoutGetter = NonNullable["getItemLayout"]>; +const EMPTY_PATH_LIST: string[] = []; function getUnifiedDiffLineCount(file: ParsedDiffFile): number { let lineCount = 0; @@ -1526,15 +1549,7 @@ interface DiffBodyContentProps { diffErrorMessage: string | null; hasChanges: boolean; emptyMessage: string; - flatItems: DiffFlatItem[]; - stickyHeaderIndices: number[]; - renderFlatItem: ({ item }: { item: DiffFlatItem }) => ReactElement; - flatKeyExtractor: (item: DiffFlatItem) => string; - getFlatItemLayout: DiffFlatItemLayoutGetter; - flatExtraData: unknown; - diffListRef: RefObject | null>; - handleDiffListLayout: (event: LayoutChangeEvent) => void; - handleDiffListScroll: (event: NativeSyntheticEvent) => void; + children: ReactElement; checkingRepositoryLabel: string; notRepositoryLabel: string; } @@ -1547,15 +1562,7 @@ function DiffBodyContent({ diffErrorMessage, hasChanges, emptyMessage, - flatItems, - stickyHeaderIndices, - renderFlatItem, - flatKeyExtractor, - getFlatItemLayout, - flatExtraData, - diffListRef, - handleDiffListLayout, - handleDiffListScroll, + children, checkingRepositoryLabel, notRepositoryLabel, }: DiffBodyContentProps) { @@ -1602,6 +1609,380 @@ function DiffBodyContent({ ); } + return children; +} + +interface SharedDiffViewProps { + files: ParsedDiffFile[]; + displayPreferences: { + layout: "unified" | "split"; + wrapLines: boolean; + codeFontSize: number; + monoFontFamily: string; + }; + mode: + | { + kind: "working_tree"; + viewMode: "flat" | "tree"; + expandedPaths: string[]; + collapsedFolders: string[]; + reviewActions?: InlineReviewActions; + onExpandedPathsChange: (paths: string[]) => void; + onCollapsedFoldersChange: (paths: string[]) => void; + } + | { + kind: "commit"; + }; +} + +export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { + const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; + const diffBodyLineHeight = Math.round(codeFontSize * 1.5); + const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":"); + const textMetricsStyle = useMemo(() => { + const trimmedMonoFontFamily = monoFontFamily.trim(); + return { + fontSize: codeFontSize, + lineHeight: diffBodyLineHeight, + ...(trimmedMonoFontFamily ? { fontFamily: trimmedMonoFontFamily } : null), + }; + }, [codeFontSize, diffBodyLineHeight, monoFontFamily]); + const viewMode = mode.kind === "working_tree" ? mode.viewMode : "flat"; + const expandedPathsArray = useMemo( + () => (mode.kind === "working_tree" ? mode.expandedPaths : files.map((file) => file.path)), + [files, mode], + ); + const expandedPaths = useMemo(() => new Set(expandedPathsArray), [expandedPathsArray]); + const collapsedFoldersArray = + mode.kind === "working_tree" ? mode.collapsedFolders : EMPTY_PATH_LIST; + const collapsedFolders = useMemo(() => new Set(collapsedFoldersArray), [collapsedFoldersArray]); + const stickyHeaders = mode.kind === "working_tree"; + const interactive = mode.kind === "working_tree"; + const reviewActions = mode.kind === "working_tree" ? mode.reviewActions : undefined; + const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); + const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); + const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); + const effectiveCollapsedFolders = useMemo( + () => new Set(Array.from(collapsedFolders).filter((path) => allFolderPathSet.has(path))), + [allFolderPathSet, collapsedFolders], + ); + const diffListRef = useRef>(null); + const diffListScrollOffsetRef = useRef(0); + const diffListViewportHeightRef = useRef(0); + const headerHeightByPathRef = useRef>({}); + const bodyHeightByKeyRef = useRef>({}); + const folderRowHeightRef = useRef(0); + const defaultHeaderHeightRef = useRef(44); + const [heightVersion, setHeightVersion] = useState(0); + const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; + const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; + + const { flatItems, stickyHeaderIndices } = useMemo(() => { + const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ + files, + viewMode, + tree: compressedTree, + collapsedFolders: effectiveCollapsedFolders, + expandedPaths, + }); + return { + flatItems: items, + stickyHeaderIndices: stickyHeaders ? stickyIndices : [], + }; + }, [compressedTree, effectiveCollapsedFolders, expandedPaths, files, stickyHeaders, viewMode]); + + const getBodyHeightKey = useCallback( + (file: ParsedDiffFile): string => { + if (file.status === "too_large" || file.status === "binary") { + return `${layout}:${wrapLines ? "wrap" : "scroll"}:${typographyKey}:${file.path}:${file.status}`; + } + + return [ + layout, + wrapLines ? "wrap" : "scroll", + typographyKey, + file.path, + file.status ?? "ok", + file.additions, + file.deletions, + file.hunks.length, + getUnifiedDiffLineCount(file), + getDiffContentLength(file), + ].join(":"); + }, + [layout, typographyKey, wrapLines], + ); + + const estimateBodyHeight = useCallback( + (file: ParsedDiffFile): number => { + if (file.status === "too_large" || file.status === "binary") { + return statusBodyHeightEstimate; + } + + const lineCount = + layout === "split" ? buildSplitDiffRows(file).length : getUnifiedDiffLineCount(file); + return diffBodyChromeHeight + lineCount * diffBodyLineHeight; + }, + [diffBodyChromeHeight, diffBodyLineHeight, layout, statusBodyHeightEstimate], + ); + + const getFlatItemHeight = useCallback( + (item: DiffFlatItem): number => { + if (item.type === "folder") { + return folderRowHeightRef.current || defaultHeaderHeightRef.current; + } + if (item.type === "header") { + return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; + } + const bodyHeightKey = getBodyHeightKey(item.file); + return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); + }, + [estimateBodyHeight, getBodyHeightKey], + ); + + const handleFolderRowHeightChange = useCallback((height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + const previousHeight = folderRowHeightRef.current; + if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { + return; + } + folderRowHeightRef.current = height; + setHeightVersion((version) => version + 1); + }, []); + + const handleHeaderHeightChange = useCallback((path: string, height: number) => { + if (!Number.isFinite(height) || height <= 0) { + return; + } + const previousHeight = headerHeightByPathRef.current[path]; + if ( + previousHeight !== undefined && + Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON + ) { + return; + } + headerHeightByPathRef.current[path] = height; + defaultHeaderHeightRef.current = height; + setHeightVersion((version) => version + 1); + }, []); + + const handleBodyHeightChange = useCallback( + (file: ParsedDiffFile, height: number) => { + if (!Number.isFinite(height) || height < 0) { + return; + } + const heightKey = getBodyHeightKey(file); + const previousHeight = bodyHeightByKeyRef.current[heightKey]; + if ( + previousHeight !== undefined && + Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON + ) { + return; + } + bodyHeightByKeyRef.current[heightKey] = height; + setHeightVersion((version) => version + 1); + }, + [getBodyHeightKey], + ); + + const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + }, []); + + const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + if (!Number.isFinite(height) || height <= 0) { + return; + } + diffListViewportHeightRef.current = height; + }, []); + + const computeItemOffset = useCallback( + (predicate: (item: DiffFlatItem) => boolean): number | null => { + const index = flatItems.findIndex(predicate); + if (index < 0) { + return null; + } + return sumHeightsBefore(flatItems, index, getFlatItemHeight); + }, + [flatItems, getFlatItemHeight], + ); + + const computeHeaderOffset = useCallback( + (path: string): number => + computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0, + [computeItemOffset], + ); + + const handleToggleExpanded = useCallback( + (path: string) => { + if (mode.kind !== "working_tree") { + return; + } + const isCurrentlyExpanded = expandedPaths.has(path); + const nextExpanded = !isCurrentlyExpanded; + const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; + const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; + const shouldAnchor = + isCurrentlyExpanded && + targetOffset !== null && + shouldAnchorHeaderBeforeCollapse({ + headerOffset: targetOffset, + headerHeight, + viewportOffset: diffListScrollOffsetRef.current, + viewportHeight: diffListViewportHeightRef.current, + }); + + if (shouldAnchor && targetOffset !== null) { + diffListRef.current?.scrollToOffset({ + offset: targetOffset, + animated: false, + }); + } + + mode.onExpandedPathsChange( + nextExpanded + ? [...expandedPaths, path] + : Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path), + ); + }, + [computeHeaderOffset, expandedPaths, mode], + ); + + const handleToggleFolder = useCallback( + (dirPath: string) => { + if (mode.kind !== "working_tree") { + return; + } + const isCurrentlyCollapsed = effectiveCollapsedFolders.has(dirPath); + if (!isCurrentlyCollapsed) { + const targetOffset = computeItemOffset( + (item) => item.type === "folder" && item.dirPath === dirPath, + ); + const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; + if ( + targetOffset !== null && + shouldAnchorHeaderBeforeCollapse({ + headerOffset: targetOffset, + headerHeight: folderHeight, + viewportOffset: diffListScrollOffsetRef.current, + viewportHeight: diffListViewportHeightRef.current, + }) + ) { + diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); + } + } + + mode.onCollapsedFoldersChange( + isCurrentlyCollapsed + ? Array.from(effectiveCollapsedFolders).filter((path) => path !== dirPath) + : [...effectiveCollapsedFolders, dirPath], + ); + }, + [computeItemOffset, effectiveCollapsedFolders, mode], + ); + + const renderFlatItem = useCallback( + ({ item }: { item: DiffFlatItem }) => { + if (item.type === "folder") { + return ( + + ); + } + if (item.type === "header") { + return ( + + ); + } + return ( + + ); + }, + [ + codeFontSize, + handleBodyHeightChange, + handleFolderRowHeightChange, + handleHeaderHeightChange, + handleToggleExpanded, + handleToggleFolder, + layout, + reviewActions, + textMetricsStyle, + viewMode, + wrapLines, + interactive, + ], + ); + + const flatKeyExtractor = useCallback( + (item: DiffFlatItem) => + item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`, + [], + ); + + const getFlatItemLayout = useCallback( + (_data, index) => { + const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight); + const item = flatItems[index]; + const length = item ? getFlatItemHeight(item) : 0; + return { length, offset, index }; + }, + [flatItems, getFlatItemHeight], + ); + + const flatExtraData = useMemo( + () => ({ + expandedPathsArray, + collapsedFoldersArray, + layout, + typographyKey, + heightVersion, + viewMode, + wrapLines, + reviewActions, + }), + [ + expandedPathsArray, + collapsedFoldersArray, + heightVersion, + layout, + reviewActions, + typographyKey, + viewMode, + wrapLines, + ], + ); + return ( (() => { - const monoFontFamily = appSettings.monoFontFamily.trim(); - return { - fontSize: codeFontSize, - lineHeight: diffBodyLineHeight, - ...(monoFontFamily ? { fontFamily: monoFontFamily } : null), - }; - }, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]); const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []); const layoutToggleStyle = useMemo( @@ -1782,6 +2149,23 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []); const toast = useToast(); + const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); + const commitDiffPersistenceKey = useMemo( + () => buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? cwd }), + [cwd, serverId, workspaceId], + ); + const handleCommitPress = useCallback( + (sha: string) => { + if (!commitDiffPersistenceKey) { + return; + } + openWorkspaceTabFocused(commitDiffPersistenceKey, { + kind: "commit_diff", + sha, + }); + }, + [commitDiffPersistenceKey, openWorkspaceTabFocused], + ); const refreshSupported = useSessionStore( (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); @@ -1930,256 +2314,24 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane const setDiffCollapsedFoldersForWorkspace = usePanelStore( (state) => state.setDiffCollapsedFoldersForWorkspace, ); - // Build the directory tree once per files-change; collapse/expand toggles only - // re-flatten it (they don't change tree shape). - const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]); - // Every directory path currently in the tree — used by "collapse all folders" and to - // filter stale collapse state. - const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]); - const allFolderPathSet = useMemo(() => new Set(allFolderPaths), [allFolderPaths]); - // Effective collapsed set: intersect the persisted paths with the folders actually - // present, purely at render (no store-syncing effect). A folder that left the diff and - // reappears defaults to expanded; toggles write back this pruned set, so the stored - // array stays bounded. (empty = all folders expanded, the default) - const collapsedFolders = useMemo( - () => new Set((collapsedFoldersArray ?? []).filter((path) => allFolderPathSet.has(path))), - [collapsedFoldersArray, allFolderPathSet], + const stableExpandedPathsArray = expandedPathsArray ?? EMPTY_PATH_LIST; + const stableCollapsedFoldersArray = collapsedFoldersArray ?? EMPTY_PATH_LIST; + const sharedDisplayPreferences = useMemo( + () => ({ + layout: effectiveLayout, + wrapLines, + codeFontSize, + monoFontFamily: appSettings.monoFontFamily, + }), + [appSettings.monoFontFamily, codeFontSize, effectiveLayout, wrapLines], ); - const diffListRef = useRef>(null); const handleToggleViewMode = useCallback(() => { const nextViewMode = viewMode === "flat" ? "tree" : "flat"; - if (nextViewMode === "tree") { - diffListRef.current?.scrollToOffset({ offset: 0, animated: false }); - if (workspaceStateKey) { - setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []); - } + if (nextViewMode === "tree" && workspaceStateKey) { + setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []); } void updateChangesPreferences({ viewMode: nextViewMode }); }, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]); - const diffListScrollOffsetRef = useRef(0); - const diffListViewportHeightRef = useRef(0); - const headerHeightByPathRef = useRef>({}); - const bodyHeightByKeyRef = useRef>({}); - // Folder rows are a distinct kind; keep their height out of headerHeightByPathRef - // (Codex item 6) so file/folder heights can't collide by path. - const folderRowHeightRef = useRef(0); - const defaultHeaderHeightRef = useRef(44); - const [heightVersion, setHeightVersion] = useState(0); - const diffBodyChromeHeight = BORDER_WIDTH[1] * 2; - const statusBodyHeightEstimate = diffBodyChromeHeight + SPACING[4] * 2 + diffBodyLineHeight; - const { flatItems, stickyHeaderIndices } = useMemo(() => { - const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({ - files, - viewMode, - tree: compressedTree, - collapsedFolders, - expandedPaths, - }); - return { flatItems: items, stickyHeaderIndices: stickyIndices }; - }, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]); - - const getBodyHeightKey = useCallback( - (file: ParsedDiffFile): string => { - if (file.status === "too_large" || file.status === "binary") { - return `${effectiveLayout}:${wrapLines ? "wrap" : "scroll"}:${diffBodyTypographyKey}:${file.path}:${file.status}`; - } - - return [ - effectiveLayout, - wrapLines ? "wrap" : "scroll", - diffBodyTypographyKey, - file.path, - file.status ?? "ok", - file.additions, - file.deletions, - file.hunks.length, - getUnifiedDiffLineCount(file), - getDiffContentLength(file), - ].join(":"); - }, - [diffBodyTypographyKey, effectiveLayout, wrapLines], - ); - - const estimateBodyHeight = useCallback( - (file: ParsedDiffFile): number => { - if (file.status === "too_large" || file.status === "binary") { - return statusBodyHeightEstimate; - } - - const lineCount = - effectiveLayout === "split" - ? buildSplitDiffRows(file).length - : getUnifiedDiffLineCount(file); - return diffBodyChromeHeight + lineCount * diffBodyLineHeight; - }, - [diffBodyChromeHeight, diffBodyLineHeight, effectiveLayout, statusBodyHeightEstimate], - ); - - // Single height source of truth for both getItemLayout and the collapse - // scroll-anchor math. Folder rows use their own measured height (Codex item 6), - // falling back to the default header height before first measurement. - const getFlatItemHeight = useCallback( - (item: DiffFlatItem): number => { - if (item.type === "folder") { - return folderRowHeightRef.current || defaultHeaderHeightRef.current; - } - if (item.type === "header") { - return headerHeightByPathRef.current[item.file.path] ?? defaultHeaderHeightRef.current; - } - const bodyHeightKey = getBodyHeightKey(item.file); - return bodyHeightByKeyRef.current[bodyHeightKey] ?? estimateBodyHeight(item.file); - }, - [estimateBodyHeight, getBodyHeightKey], - ); - - const handleFolderRowHeightChange = useCallback((height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = folderRowHeightRef.current; - if (previousHeight > 0 && Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON) { - return; - } - folderRowHeightRef.current = height; - setHeightVersion((version) => version + 1); - }, []); - - const handleHeaderHeightChange = useCallback((path: string, height: number) => { - if (!Number.isFinite(height) || height <= 0) { - return; - } - const previousHeight = headerHeightByPathRef.current[path]; - if ( - previousHeight !== undefined && - Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON - ) { - return; - } - headerHeightByPathRef.current[path] = height; - defaultHeaderHeightRef.current = height; - setHeightVersion((version) => version + 1); - }, []); - - const handleBodyHeightChange = useCallback( - (file: ParsedDiffFile, height: number) => { - if (!Number.isFinite(height) || height < 0) { - return; - } - const heightKey = getBodyHeightKey(file); - const previousHeight = bodyHeightByKeyRef.current[heightKey]; - if ( - previousHeight !== undefined && - Math.abs(previousHeight - height) <= DIFF_HEIGHT_CHANGE_EPSILON - ) { - return; - } - bodyHeightByKeyRef.current[heightKey] = height; - setHeightVersion((version) => version + 1); - }, - [getBodyHeightKey], - ); - - const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, []); - - const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - }, []); - - // Offset of the first item matching `predicate`, walking the SAME flatItems - // list getFlatItemLayout uses so folder rows are counted (single source of - // truth — Codex item 5 / finding 2). - const computeItemOffset = useCallback( - (predicate: (item: DiffFlatItem) => boolean): number | null => { - const index = flatItems.findIndex(predicate); - if (index < 0) { - return null; - } - return sumHeightsBefore(flatItems, index, getFlatItemHeight); - }, - [flatItems, getFlatItemHeight], - ); - - const computeHeaderOffset = useCallback( - (path: string): number => - computeItemOffset((item) => item.type === "header" && item.file.path === path) ?? 0, - [computeItemOffset], - ); - - const handleToggleExpanded = useCallback( - (path: string) => { - if (!workspaceStateKey) { - return; - } - const isCurrentlyExpanded = expandedPaths.has(path); - const nextExpanded = !isCurrentlyExpanded; - const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null; - const headerHeight = headerHeightByPathRef.current[path] ?? defaultHeaderHeightRef.current; - const shouldAnchor = - isCurrentlyExpanded && - targetOffset !== null && - shouldAnchorHeaderBeforeCollapse({ - headerOffset: targetOffset, - headerHeight, - viewportOffset: diffListScrollOffsetRef.current, - viewportHeight: diffListViewportHeightRef.current, - }); - - // Anchor to the clicked header before collapsing so visual context is preserved. - if (shouldAnchor && targetOffset !== null) { - diffListRef.current?.scrollToOffset({ - offset: targetOffset, - animated: false, - }); - } - - const nextPaths = nextExpanded - ? [...expandedPaths, path] - : Array.from(expandedPaths).filter((expandedPath) => expandedPath !== path); - setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths); - }, - [computeHeaderOffset, expandedPaths, setDiffExpandedPathsForWorkspace, workspaceStateKey], - ); - - const handleToggleFolder = useCallback( - (dirPath: string) => { - if (!workspaceStateKey) { - return; - } - const isCurrentlyCollapsed = collapsedFolders.has(dirPath); - // Collapsing hides the subtree below this row; anchor to the folder row - // first so the viewport doesn't jump to a dead offset (Codex item 5). - if (!isCurrentlyCollapsed) { - const targetOffset = computeItemOffset( - (item) => item.type === "folder" && item.dirPath === dirPath, - ); - const folderHeight = folderRowHeightRef.current || defaultHeaderHeightRef.current; - if ( - targetOffset !== null && - shouldAnchorHeaderBeforeCollapse({ - headerOffset: targetOffset, - headerHeight: folderHeight, - viewportOffset: diffListScrollOffsetRef.current, - viewportHeight: diffListViewportHeightRef.current, - }) - ) { - diffListRef.current?.scrollToOffset({ offset: targetOffset, animated: false }); - } - } - - const nextCollapsed = isCurrentlyCollapsed - ? Array.from(collapsedFolders).filter((path) => path !== dirPath) - : [...collapsedFolders, dirPath]; - setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextCollapsed); - }, - [collapsedFolders, computeItemOffset, setDiffCollapsedFoldersForWorkspace, workspaceStateKey], - ); - const allFileDiffsExpanded = useMemo(() => { if (files.length === 0) return false; return files.every((file) => expandedPaths.has(file.path)); @@ -2198,101 +2350,41 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane ); } }, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]); - - const renderFlatItem = useCallback( - ({ item }: { item: DiffFlatItem }) => { - if (item.type === "folder") { - return ( - - ); + const handleExpandedPathsChange = useCallback( + (nextPaths: string[]) => { + if (!workspaceStateKey) { + return; } - if (item.type === "header") { - return ( - - ); + setDiffExpandedPathsForWorkspace(workspaceStateKey, nextPaths); + }, + [setDiffExpandedPathsForWorkspace, workspaceStateKey], + ); + const handleCollapsedFoldersChange = useCallback( + (nextPaths: string[]) => { + if (!workspaceStateKey) { + return; } - return ( - - ); + setDiffCollapsedFoldersForWorkspace(workspaceStateKey, nextPaths); }, - [ - codeFontSize, - diffTextMetricsStyle, - effectiveLayout, - handleBodyHeightChange, - handleFolderRowHeightChange, - handleHeaderHeightChange, - handleToggleExpanded, - handleToggleFolder, - reviewActions, - viewMode, - wrapLines, - ], + [setDiffCollapsedFoldersForWorkspace, workspaceStateKey], ); - - const flatKeyExtractor = useCallback( - (item: DiffFlatItem) => - item.type === "folder" ? `folder-${item.dirPath}` : `${item.type}-${item.file.path}`, - [], - ); - - const getFlatItemLayout = useCallback( - (_data, index) => { - const offset = sumHeightsBefore(flatItems, index, getFlatItemHeight); - const item = flatItems[index]; - const length = item ? getFlatItemHeight(item) : 0; - return { length, offset, index }; - }, - [flatItems, getFlatItemHeight], - ); - - const flatExtraData = useMemo( + const workingTreeMode = useMemo( () => ({ - expandedPathsArray, - collapsedFoldersArray, - effectiveLayout, - diffBodyTypographyKey, - heightVersion, + kind: "working_tree" as const, viewMode, - wrapLines, + expandedPaths: stableExpandedPathsArray, + collapsedFolders: stableCollapsedFoldersArray, reviewActions, + onExpandedPathsChange: handleExpandedPathsChange, + onCollapsedFoldersChange: handleCollapsedFoldersChange, }), [ - expandedPathsArray, - collapsedFoldersArray, - effectiveLayout, - diffBodyTypographyKey, - heightVersion, viewMode, - wrapLines, + stableExpandedPathsArray, + stableCollapsedFoldersArray, reviewActions, + handleExpandedPathsChange, + handleCollapsedFoldersChange, ], ); @@ -2352,18 +2444,15 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane diffErrorMessage={diffErrorMessage} hasChanges={hasChanges} emptyMessage={emptyMessage} - flatItems={flatItems} - stickyHeaderIndices={stickyHeaderIndices} - renderFlatItem={renderFlatItem} - flatKeyExtractor={flatKeyExtractor} - getFlatItemLayout={getFlatItemLayout} - flatExtraData={flatExtraData} - diffListRef={diffListRef} - handleDiffListLayout={handleDiffListLayout} - handleDiffListScroll={handleDiffListScroll} checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")} notRepositoryLabel={t("workspace.git.diff.notRepository")} - /> + > + + ); return ( @@ -2460,6 +2549,8 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane {prErrorMessage ? {prErrorMessage} : null} {bodyContent} + + ); } diff --git a/packages/app/src/git/query-keys.ts b/packages/app/src/git/query-keys.ts index e3441eed6..75390aa8a 100644 --- a/packages/app/src/git/query-keys.ts +++ b/packages/app/src/git/query-keys.ts @@ -13,6 +13,10 @@ interface CheckoutQueryScope { type CheckoutQueryKey = readonly unknown[]; +// A commit's file diff is immutable for a given sha+path, so every consumer +// can share the same long-lived cache policy. +export const COMMIT_FILE_DIFF_STALE_TIME = 5 * 60_000; + export function checkoutStatusQueryKey(serverId: string, cwd: string) { return ["checkoutStatus", serverId, cwd] as const; } @@ -31,6 +35,19 @@ export function checkoutPrStatusQueryKey(serverId: string, cwd: string) { return ["checkoutPrStatus", serverId, cwd] as const; } +export function checkoutCommitsQueryKey(serverId: string, cwd: string) { + return ["checkoutCommits", serverId, cwd] as const; +} + +export function checkoutCommitFileDiffQueryKey( + serverId: string, + cwd: string, + sha: string, + path: string, +) { + return ["checkoutCommitFileDiff", serverId, cwd, sha, path] as const; +} + export async function invalidateCheckoutGitQueriesForClient( queryClient: QueryClient, identity: CheckoutQueryIdentity, diff --git a/packages/app/src/git/themed-chevron.ts b/packages/app/src/git/themed-chevron.ts new file mode 100644 index 000000000..c958f489c --- /dev/null +++ b/packages/app/src/git/themed-chevron.ts @@ -0,0 +1,12 @@ +import { ChevronRight } from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; + +// Shared chevron used by collapsible git rows (changed-files tree, commits +// section). Kept in one place so the muted-foreground tint and the +// withUnistyles wrapping stay consistent across every disclosure control. +export const ThemedChevron = withUnistyles(ChevronRight); + +export const chevronColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); diff --git a/packages/app/src/git/use-commits-query.test.ts b/packages/app/src/git/use-commits-query.test.ts new file mode 100644 index 000000000..c5f7aa7d4 --- /dev/null +++ b/packages/app/src/git/use-commits-query.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { resolveCheckoutCommitsQueryResult, type CheckoutCommitsData } from "./use-commits-query"; + +const EMPTY_COMMITS: CheckoutCommitsData = { baseRef: "main", commits: [] }; + +describe("resolveCheckoutCommitsQueryResult", () => { + it("stays idle while the collapsed section has never loaded", () => { + expect( + resolveCheckoutCommitsQueryResult({ + enabled: false, + capabilityPresent: true, + canFetch: true, + data: undefined, + isPlaceholderData: false, + error: null, + }), + ).toEqual({ status: "idle" }); + }); + + it("reports loading instead of an empty result while the first request is pending", () => { + expect( + resolveCheckoutCommitsQueryResult({ + enabled: true, + capabilityPresent: true, + canFetch: true, + data: undefined, + isPlaceholderData: false, + error: null, + }), + ).toEqual({ status: "loading" }); + }); + + it("types an empty commit list as loaded data", () => { + expect( + resolveCheckoutCommitsQueryResult({ + enabled: true, + capabilityPresent: true, + canFetch: true, + data: EMPTY_COMMITS, + isPlaceholderData: false, + error: null, + }), + ).toEqual({ status: "loaded", data: EMPTY_COMMITS }); + }); + + it("surfaces a cold-load error", () => { + const error = new Error("git log failed"); + expect( + resolveCheckoutCommitsQueryResult({ + enabled: true, + capabilityPresent: true, + canFetch: true, + data: undefined, + isPlaceholderData: false, + error, + }), + ).toEqual({ status: "error", error }); + }); + + it("keeps cached data available while collapsed", () => { + expect( + resolveCheckoutCommitsQueryResult({ + enabled: false, + capabilityPresent: true, + canFetch: true, + data: EMPTY_COMMITS, + isPlaceholderData: false, + error: null, + }), + ).toEqual({ status: "loaded", data: EMPTY_COMMITS }); + }); + + it("keeps previous-checkout placeholder data in loading state", () => { + expect( + resolveCheckoutCommitsQueryResult({ + enabled: true, + capabilityPresent: true, + canFetch: true, + data: EMPTY_COMMITS, + isPlaceholderData: true, + error: null, + }), + ).toEqual({ status: "loading" }); + }); +}); diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts new file mode 100644 index 000000000..19b24ba80 --- /dev/null +++ b/packages/app/src/git/use-commits-query.ts @@ -0,0 +1,102 @@ +import type { CheckoutCommit } from "@getpaseo/protocol/messages"; +import { useFetchQuery } from "@/data/query"; +import { checkoutCommitsQueryKey } from "@/git/query-keys"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; + +// Commits ahead of base change rarely while the section is open; this keeps a +// collapse/re-expand cycle warm without leaving the fetch result stale for long. +const CHECKOUT_COMMITS_STALE_TIME = 30_000; + +interface UseCheckoutCommitsQueryOptions { + serverId: string; + cwd: string; + enabled?: boolean; +} + +export interface CheckoutCommitsData { + baseRef: string | null; + commits: CheckoutCommit[]; +} + +export type CheckoutCommitsQueryResult = + | { status: "unsupported" } + | { status: "idle" } + | { status: "connecting" } + | { status: "loading" } + | { status: "error"; error: Error } + | { status: "loaded"; data: CheckoutCommitsData }; + +interface ResolveCheckoutCommitsQueryResultInput { + enabled: boolean; + capabilityPresent: boolean; + canFetch: boolean; + data: CheckoutCommitsData | undefined; + isPlaceholderData: boolean; + error: Error | null; +} + +export function resolveCheckoutCommitsQueryResult({ + enabled, + capabilityPresent, + canFetch, + data, + isPlaceholderData, + error, +}: ResolveCheckoutCommitsQueryResultInput): CheckoutCommitsQueryResult { + if (!capabilityPresent) { + return { status: "unsupported" }; + } + if (data && !isPlaceholderData) { + return { status: "loaded", data }; + } + if (!enabled) { + return { status: "idle" }; + } + if (!canFetch) { + return { status: "connecting" }; + } + if (error) { + return { status: "error", error }; + } + return { status: "loading" }; +} + +export function useCheckoutCommitsQuery({ + serverId, + cwd, + enabled = true, +}: UseCheckoutCommitsQueryOptions): CheckoutCommitsQueryResult { + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. + // Single capability-detection site; downstream reads a clean load-state union. + const capabilityPresent = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.commitsList === true, + ); + + const canFetch = Boolean(cwd) && Boolean(client) && isConnected; + const queryEnabled = enabled && capabilityPresent && canFetch; + + const query = useFetchQuery({ + queryKey: checkoutCommitsQueryKey(serverId, cwd), + queryFn: async () => { + if (!client) { + throw new Error("Host disconnected"); + } + return client.listCheckoutCommits(cwd); + }, + enabled: queryEnabled, + staleTimeMs: CHECKOUT_COMMITS_STALE_TIME, + dataShape: "list", + }); + + return resolveCheckoutCommitsQueryResult({ + enabled, + capabilityPresent, + canFetch, + data: query.data, + isPlaceholderData: query.isPlaceholderData, + error: query.error, + }); +} diff --git a/packages/app/src/git/use-diff-files.test.ts b/packages/app/src/git/use-diff-files.test.ts new file mode 100644 index 000000000..dd270431c --- /dev/null +++ b/packages/app/src/git/use-diff-files.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { resolveCommitDiffFiles } from "./use-diff-files"; + +function createCommitFile( + overrides: Partial & { path: string }, +): CheckoutCommitFile { + return { + path: overrides.path, + additions: overrides.additions ?? 0, + deletions: overrides.deletions ?? 0, + ...(overrides.status ? { status: overrides.status } : {}), + }; +} + +function createParsedDiffFile( + overrides: Partial & { path: string }, +): ParsedDiffFile { + return { + path: overrides.path, + isNew: overrides.isNew ?? false, + isDeleted: overrides.isDeleted ?? false, + additions: overrides.additions ?? 1, + deletions: overrides.deletions ?? 0, + hunks: overrides.hunks ?? [ + { + oldStart: 1, + oldCount: 1, + newStart: 1, + newCount: 1, + lines: [{ type: "add", content: "x", tokens: [] }], + }, + ], + status: overrides.status, + } as ParsedDiffFile; +} + +describe("resolveCommitDiffFiles", () => { + it("keeps pending commit files out of the shared view until their per-file diff resolves", () => { + const files = [ + createCommitFile({ path: "blob.bin", status: "added" }), + createCommitFile({ path: "src/app.ts", additions: 3, deletions: 1, status: "modified" }), + ]; + + const resolvedByPath = new Map([ + ["blob.bin", undefined], + [ + "src/app.ts", + createParsedDiffFile({ + path: "src/app.ts", + additions: 3, + deletions: 1, + }), + ], + ]); + + expect(resolveCommitDiffFiles(files, resolvedByPath)).toEqual([ + expect.objectContaining({ + path: "src/app.ts", + additions: 3, + deletions: 1, + }), + ]); + }); + + it("preserves binary-only commit files from commit metadata when the per-file diff is null", () => { + const files = [ + createCommitFile({ path: "blob.bin", status: "added" }), + createCommitFile({ path: "src/app.ts", additions: 3, deletions: 1, status: "modified" }), + ]; + + const resolvedByPath = new Map([ + ["blob.bin", null], + [ + "src/app.ts", + createParsedDiffFile({ + path: "src/app.ts", + additions: 3, + deletions: 1, + }), + ], + ]); + + expect(resolveCommitDiffFiles(files, resolvedByPath)).toEqual([ + { + path: "blob.bin", + isNew: true, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + status: "binary", + }, + expect.objectContaining({ + path: "src/app.ts", + additions: 3, + deletions: 1, + }), + ]); + }); +}); diff --git a/packages/app/src/git/use-diff-files.ts b/packages/app/src/git/use-diff-files.ts new file mode 100644 index 000000000..a154933a8 --- /dev/null +++ b/packages/app/src/git/use-diff-files.ts @@ -0,0 +1,117 @@ +import { useMemo } from "react"; +import type { CheckoutCommitFile, ParsedDiffFile } from "@getpaseo/protocol/messages"; +import { useFetchQueries } from "@/data/query"; +import { checkoutCommitFileDiffQueryKey, COMMIT_FILE_DIFF_STALE_TIME } from "@/git/query-keys"; +import { useCheckoutCommitsQuery } from "@/git/use-commits-query"; +import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; + +/** + * Context needed to resolve a commit diff against a host: which daemon + * (`serverId`), which checkout (`cwd`), and which commit (`sha`). `enabled` + * lets the consumer pause all fetching (e.g. an inactive tab). + */ +export interface CommitDiffFilesContext { + serverId: string; + cwd: string; + sha: string; + enabled?: boolean; +} + +export interface CommitDiffFilesResult { + files: ParsedDiffFile[]; + isLoading: boolean; + error: Error | null; + capabilityMissing: boolean; +} + +export function resolveCommitDiffFile( + file: CheckoutCommitFile, + resolved: ParsedDiffFile | null | undefined, +): ParsedDiffFile | null { + if (resolved !== undefined && resolved !== null) { + return resolved; + } + if (resolved === undefined) { + return null; + } + return { + path: file.path, + isNew: file.status === "added", + isDeleted: file.status === "deleted", + additions: file.additions, + deletions: file.deletions, + hunks: [], + status: "binary", + }; +} + +export function resolveCommitDiffFiles( + files: CheckoutCommitFile[], + resolvedByPath: ReadonlyMap, +): ParsedDiffFile[] { + return files.flatMap((file) => { + const resolved = resolveCommitDiffFile(file, resolvedByPath.get(file.path)); + return resolved ? [resolved] : []; + }); +} + +export function useCommitDiffFiles(ctx: CommitDiffFilesContext): CommitDiffFilesResult { + const { serverId, cwd, sha, enabled = true } = ctx; + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + const commitsQuery = useCheckoutCommitsQuery({ serverId, cwd, enabled }); + const commitsData = commitsQuery.status === "loaded" ? commitsQuery.data : null; + const commitFiles = useMemo(() => { + if (!sha || !commitsData) { + return []; + } + return commitsData.commits.find((commit) => commit.sha === sha)?.files ?? []; + }, [commitsData, sha]); + + const fileDiffsEnabled = + enabled && + commitsQuery.status === "loaded" && + Boolean(cwd) && + Boolean(sha) && + Boolean(client) && + isConnected; + const fileDiffResults = useFetchQueries( + commitFiles.map((file) => ({ + queryKey: checkoutCommitFileDiffQueryKey(serverId, cwd, sha, file.path), + queryFn: async (): Promise<{ file: ParsedDiffFile | null }> => { + if (!client) { + throw new Error("Host disconnected"); + } + return client.getCommitFileDiff(cwd, sha, file.path); + }, + enabled: fileDiffsEnabled, + staleTimeMs: COMMIT_FILE_DIFF_STALE_TIME, + dataShape: "value" as const, + })), + ); + const commitsLoading = commitsQuery.status === "connecting" || commitsQuery.status === "loading"; + const commitsError = commitsQuery.status === "error" ? commitsQuery.error : null; + const capabilityMissing = commitsQuery.status === "unsupported"; + + return useMemo(() => { + const resolvedByPath = new Map(); + commitFiles.forEach((file, index) => { + const fileResult = fileDiffResults[index]; + resolvedByPath.set(file.path, fileResult?.data ? fileResult.data.file : undefined); + }); + const files = resolveCommitDiffFiles(commitFiles, resolvedByPath); + let firstFileError: Error | null = null; + for (const fileResult of fileDiffResults) { + if (fileResult.error) { + firstFileError = fileResult.error; + break; + } + } + return { + files, + isLoading: commitsLoading || fileDiffResults.some((r) => r.isLoading), + error: commitsError ?? firstFileError, + capabilityMissing, + }; + }, [capabilityMissing, commitFiles, commitsError, commitsLoading, fileDiffResults]); +} diff --git a/packages/app/src/git/use-diff-query.ts b/packages/app/src/git/use-diff-query.ts index 4ab14c695..a3b222246 100644 --- a/packages/app/src/git/use-diff-query.ts +++ b/packages/app/src/git/use-diff-query.ts @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { useReplicaQuery } from "@/data/query"; import { checkoutDiffPushRoute } from "@/data/push-router"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import type { SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; +import type { ParsedDiffFile, SubscribeCheckoutDiffResponse } from "@getpaseo/protocol/messages"; import { checkoutDiffQueryKey } from "@/git/query-keys"; interface UseCheckoutDiffQueryOptions { @@ -16,7 +16,8 @@ interface UseCheckoutDiffQueryOptions { type CheckoutDiffQueryPayload = Omit; -export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number]; +// Re-export the canonical protocol type so all consumers share one definition. +export type { ParsedDiffFile }; export type DiffHunk = ParsedDiffFile["hunks"][number]; export type DiffLine = DiffHunk["lines"][number]; export type HighlightToken = NonNullable[number]; diff --git a/packages/app/src/hooks/use-changes-preferences/storage.test.ts b/packages/app/src/hooks/use-changes-preferences/storage.test.ts index 8a00b5746..e4e08aa94 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.test.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.test.ts @@ -31,6 +31,7 @@ describe("loadChangesPreferencesFromStorage", () => { viewMode: "flat", wrapLines: true, hideWhitespace: false, + commitsCollapsed: true, }); expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(result)); }); @@ -53,12 +54,39 @@ describe("loadChangesPreferencesFromStorage", () => { viewMode: "tree", hideWhitespace: true, wrapLines: false, + commitsCollapsed: true, }); expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(persisted); expect(storage.entries.size).toBe(1); }); }); +describe("changes preferences commitsCollapsed", () => { + it("collapses commits by default", () => { + expect(DEFAULT_CHANGES_PREFERENCES.commitsCollapsed).toBe(true); + }); + + it("round-trips commitsCollapsed: false", async () => { + const storage = createInMemoryKeyValueStorage({ + [CHANGES_PREFERENCES_STORAGE_KEY]: JSON.stringify({ commitsCollapsed: false }), + }); + + const prefs = await loadChangesPreferencesFromStorage(storage); + + expect(prefs.commitsCollapsed).toBe(false); + }); + + it("falls back to collapsed for invalid commitsCollapsed", async () => { + const storage = createInMemoryKeyValueStorage({ + [CHANGES_PREFERENCES_STORAGE_KEY]: JSON.stringify({ commitsCollapsed: "nope" }), + }); + + const prefs = await loadChangesPreferencesFromStorage(storage); + + expect(prefs.commitsCollapsed).toBe(true); + }); +}); + describe("saveChangesPreferences", () => { it("merges updates onto cached preferences and persists the result", async () => { const storage = createInMemoryKeyValueStorage(); diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts index bfcef3f7f..3ceaadead 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -10,6 +10,7 @@ const changesPreferencesSchema = z.object({ viewMode: z.enum(["flat", "tree"]).optional(), wrapLines: z.boolean().optional(), hideWhitespace: z.boolean().optional(), + commitsCollapsed: z.boolean().optional(), }); export interface ChangesPreferences { @@ -17,6 +18,7 @@ export interface ChangesPreferences { viewMode: "flat" | "tree"; wrapLines: boolean; hideWhitespace: boolean; + commitsCollapsed: boolean; } export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { @@ -24,6 +26,7 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { viewMode: "flat", wrapLines: false, hideWhitespace: false, + commitsCollapsed: true, }; export interface KeyValueStorage { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index b19073d80..8482f9d38 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -742,6 +742,17 @@ export const ar: TranslationResources = { base: "قاعدة", newFile: "جديد", deletedFile: "تم الحذف", + commits: { + title: "الإيداعات", + legendLocal: "محلي", + legendRemote: "على المستودع البعيد", + countLabel: "{{count}} إيداعات قبل الأساس", + fileDiffEmpty: "لا توجد تغييرات لعرضها", + fileDiffError: "تعذّر تحميل فروق الملف", + loading: "جارٍ تحميل الإيداعات…", + loadError: "تعذّر تحميل الإيداعات", + empty: "لا توجد إيداعات قبل الأساس", + }, }, openInEditor: { open: "يفتح", @@ -1396,6 +1407,15 @@ export const ar: TranslationResources = { failedToLoad: "فشل تحميل الملف", failedToLoadPreview: "فشل تحميل معاينة الملف", }, + diff: { + changesLabel: "التغييرات", + changesSubtitle: "فروقات شجرة العمل", + commitSubtitle: "فروقات الالتزام", + directoryMissing: "لم يتم العثور على دليل Workspace.", + empty: "لا توجد تغييرات", + loadError: "فشل تحميل الفروقات", + capabilityMissing: "حدّث المضيف لعرض فروقات الالتزامات.", + }, }, toolCallDetails: { error: "خطأ", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 410cc1fbb..fac197913 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -748,6 +748,17 @@ export const en = { base: "base", newFile: "New", deletedFile: "Deleted", + commits: { + title: "Commits", + legendLocal: "local", + legendRemote: "on remote", + countLabel: "{{count}} commits ahead of base", + fileDiffEmpty: "No changes to display", + fileDiffError: "Failed to load file diff", + loading: "Loading commits…", + loadError: "Failed to load commits", + empty: "No commits ahead of base", + }, }, openInEditor: { open: "Open", @@ -1403,6 +1414,15 @@ export const en = { failedToLoad: "Failed to load file", failedToLoadPreview: "Failed to load file preview", }, + diff: { + changesLabel: "Changes", + changesSubtitle: "Working tree diff", + commitSubtitle: "Commit diff", + directoryMissing: "Workspace directory not found.", + empty: "No changes", + loadError: "Failed to load diff", + capabilityMissing: "Update the host to view commit diffs.", + }, }, toolCallDetails: { error: "Error", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 183e8e7e2..a02bac4f3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -769,6 +769,17 @@ export const es: TranslationResources = { base: "base", newFile: "Nuevo", deletedFile: "Eliminado", + commits: { + title: "Commits", + legendLocal: "local", + legendRemote: "en remoto", + countLabel: "{{count}} commits por delante de la base", + fileDiffEmpty: "No hay cambios para mostrar", + fileDiffError: "Error al cargar el diff del archivo", + loading: "Cargando commits…", + loadError: "Error al cargar los commits", + empty: "No hay commits por delante de la base", + }, }, openInEditor: { open: "Abierto", @@ -1435,6 +1446,15 @@ export const es: TranslationResources = { failedToLoad: "No se pudo cargar el archivo", failedToLoadPreview: "No se pudo cargar la vista previa del archivo", }, + diff: { + changesLabel: "Cambios", + changesSubtitle: "Diferencias del árbol de trabajo", + commitSubtitle: "Diferencias del commit", + directoryMissing: "No se encontró el directorio de Workspace.", + empty: "Sin cambios", + loadError: "No se pudieron cargar las diferencias", + capabilityMissing: "Actualiza el host para ver las diferencias de los commits.", + }, }, toolCallDetails: { error: "Error", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 8a0031459..3e2aaf282 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -767,6 +767,17 @@ export const fr: TranslationResources = { base: "base", newFile: "Nouveau", deletedFile: "Supprimé", + commits: { + title: "Commits", + legendLocal: "local", + legendRemote: "sur le distant", + countLabel: "{{count}} commits en avance sur la base", + fileDiffEmpty: "Aucune modification à afficher", + fileDiffError: "Échec du chargement du diff du fichier", + loading: "Chargement des commits…", + loadError: "Échec du chargement des commits", + empty: "Aucun commit en avance sur la base", + }, }, openInEditor: { open: "Ouvrir", @@ -1437,6 +1448,15 @@ export const fr: TranslationResources = { failedToLoad: "Échec du chargement du fichier", failedToLoadPreview: "Échec du chargement de l'aperçu du fichier", }, + diff: { + changesLabel: "Modifications", + changesSubtitle: "Différences de l'arbre de travail", + commitSubtitle: "Différences du commit", + directoryMissing: "Répertoire Workspace introuvable.", + empty: "Aucune modification", + loadError: "Échec du chargement des différences", + capabilityMissing: "Mettez à jour l'hôte pour voir les différences des commits.", + }, }, toolCallDetails: { error: "Erreur", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index dd6e65668..03aa9e8d3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -754,6 +754,17 @@ export const ja: TranslationResources = { base: "ベース", newFile: "新規", deletedFile: "削除済み", + commits: { + title: "コミット", + legendLocal: "ローカル", + legendRemote: "リモート", + countLabel: "ベースより先のコミット数: {{count}}", + fileDiffEmpty: "表示する変更はありません", + fileDiffError: "ファイル差分の読み込みに失敗しました", + loading: "コミットを読み込み中…", + loadError: "コミットの読み込みに失敗しました", + empty: "ベースより先のコミットはありません", + }, }, openInEditor: { open: "開く", @@ -1413,6 +1424,15 @@ export const ja: TranslationResources = { failedToLoad: "ファイルの読み込みに失敗しました", failedToLoadPreview: "ファイルプレビューの読み込みに失敗しました", }, + diff: { + changesLabel: "変更", + changesSubtitle: "作業ツリーの差分", + commitSubtitle: "コミット差分", + directoryMissing: "ワークスペースディレクトリが見つかりません。", + empty: "変更はありません", + loadError: "差分の読み込みに失敗しました", + capabilityMissing: "コミット差分を表示するにはホストを更新してください。", + }, }, toolCallDetails: { error: "エラー", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1dc58cb78..92ee29b42 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -760,6 +760,17 @@ export const ptBR: TranslationResources = { base: "base", newFile: "Novo", deletedFile: "Excluído", + commits: { + title: "Commits", + legendLocal: "local", + legendRemote: "no remoto", + countLabel: "{{count}} commits à frente da base", + fileDiffEmpty: "Nenhuma alteração para exibir", + fileDiffError: "Falha ao carregar diff do arquivo", + loading: "Carregando commits…", + loadError: "Falha ao carregar commits", + empty: "Nenhum commit à frente da base", + }, }, openInEditor: { open: "Abrir", @@ -1421,6 +1432,15 @@ export const ptBR: TranslationResources = { failedToLoad: "Falha ao carregar arquivo", failedToLoadPreview: "Falha ao carregar prévia do arquivo", }, + diff: { + changesLabel: "Alterações", + changesSubtitle: "Diff da árvore de trabalho", + commitSubtitle: "Diff do commit", + directoryMissing: "Diretório do workspace não encontrado.", + empty: "Nenhuma alteração", + loadError: "Falha ao carregar diff", + capabilityMissing: "Atualize o host para ver diffs de commits.", + }, }, toolCallDetails: { error: "Erro", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3d6385dde..6a0fa28cc 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -761,6 +761,17 @@ export const ru: TranslationResources = { base: "база", newFile: "Новый", deletedFile: "Удалено", + commits: { + title: "Коммиты", + legendLocal: "локально", + legendRemote: "на удалённом", + countLabel: "{{count}} коммитов впереди базы", + fileDiffEmpty: "Нет изменений для отображения", + fileDiffError: "Не удалось загрузить различия файла", + loading: "Загрузка коммитов…", + loadError: "Не удалось загрузить коммиты", + empty: "Нет коммитов впереди базы", + }, }, openInEditor: { open: "Открыть", @@ -1427,6 +1438,15 @@ export const ru: TranslationResources = { failedToLoad: "Не удалось загрузить файл", failedToLoadPreview: "Не удалось загрузить предварительный просмотр файла.", }, + diff: { + changesLabel: "Изменения", + changesSubtitle: "Различия рабочего дерева", + commitSubtitle: "Различия коммита", + directoryMissing: "Каталог Workspace не найден.", + empty: "Нет изменений", + loadError: "Не удалось загрузить различия", + capabilityMissing: "Обновите хост, чтобы просматривать различия коммитов.", + }, }, toolCallDetails: { error: "Ошибка", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index dcce8c541..5f0846129 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -737,6 +737,17 @@ export const zhCN: TranslationResources = { base: "base", newFile: "新增", deletedFile: "已删除", + commits: { + title: "提交", + legendLocal: "本地", + legendRemote: "已推送", + countLabel: "领先基线 {{count}} 个提交", + fileDiffEmpty: "没有可显示的更改", + fileDiffError: "加载文件差异失败", + loading: "正在加载提交…", + loadError: "加载提交失败", + empty: "没有领先基线的提交", + }, }, openInEditor: { open: "打开", @@ -1380,6 +1391,15 @@ export const zhCN: TranslationResources = { failedToLoad: "加载文件失败", failedToLoadPreview: "加载文件预览失败", }, + diff: { + changesLabel: "更改", + changesSubtitle: "工作区差异", + commitSubtitle: "提交差异", + directoryMissing: "未找到 workspace 目录。", + empty: "没有更改", + loadError: "加载差异失败", + capabilityMissing: "请更新主机以查看提交差异。", + }, }, toolCallDetails: { error: "错误", diff --git a/packages/app/src/panels/commit-diff-panel.tsx b/packages/app/src/panels/commit-diff-panel.tsx new file mode 100644 index 000000000..9410a249f --- /dev/null +++ b/packages/app/src/panels/commit-diff-panel.tsx @@ -0,0 +1,124 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { GitCommitHorizontal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isWeb } from "@/constants/platform"; +import { useChangesPreferences } from "@/hooks/use-changes-preferences"; +import { useAppSettings } from "@/hooks/use-settings"; +import { SharedDiffView } from "@/git/diff-pane"; +import { useCommitDiffFiles } from "@/git/use-diff-files"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; +import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; + +const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal); + +function CommitDiffPanel() { + const { t } = useTranslation(); + const { serverId, workspaceId, target } = usePaneContext(); + const cwd = useWorkspaceDirectory(serverId, workspaceId); + const { settings } = useAppSettings(); + const { preferences } = useChangesPreferences(); + const isCompact = useIsCompactFormFactor(); + invariant(target.kind === "commit_diff", "CommitDiffPanel requires commit_diff target"); + + const { files, isLoading, error, capabilityMissing } = useCommitDiffFiles({ + serverId, + cwd: cwd ?? "", + sha: target.sha, + enabled: Boolean(cwd), + }); + const effectiveLayout = isWeb && !isCompact ? preferences.layout : "unified"; + const displayPreferences = useMemo( + () => ({ + layout: effectiveLayout, + wrapLines: preferences.wrapLines, + codeFontSize: settings.codeFontSize, + monoFontFamily: settings.monoFontFamily, + }), + [effectiveLayout, preferences.wrapLines, settings.codeFontSize, settings.monoFontFamily], + ); + const commitMode = useMemo(() => ({ kind: "commit" as const }), []); + + if (!cwd) { + return ( + + {t("panels.diff.directoryMissing")} + + ); + } + + if (capabilityMissing) { + return ( + + {t("panels.diff.capabilityMissing")} + + ); + } + if (error) { + return ( + + {t("panels.diff.loadError")} + + ); + } + if (isLoading && files.length === 0) { + return ( + + {t("workspace.tabs.loading")} + + ); + } + if (files.length === 0) { + return ( + + {t("panels.diff.empty")} + + ); + } + + return ; +} + +function useCommitDiffPanelDescriptor( + target: Extract, +): PanelDescriptor { + const { t } = useTranslation(); + return { + label: target.sha.slice(0, 7), + subtitle: t("panels.diff.commitSubtitle"), + titleState: "ready", + icon: ThemedGitCommitHorizontal, + statusBucket: null, + }; +} + +export const commitDiffPanelRegistration: PanelRegistration<"commit_diff"> = { + kind: "commit_diff", + component: CommitDiffPanel, + useDescriptor: useCommitDiffPanelDescriptor, +}; + +const styles = StyleSheet.create((theme) => ({ + centerState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[16], + }, + mutedText: { + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + textAlign: "center", + }, + errorText: { + fontSize: theme.fontSize.base, + color: theme.colors.destructive, + textAlign: "center", + }, +})); diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index b6d160d79..c86c656e3 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -1,5 +1,6 @@ import { agentPanelRegistration } from "@/panels/agent-panel"; import { browserPanelRegistration } from "@/panels/browser-panel"; +import { commitDiffPanelRegistration } from "@/panels/commit-diff-panel"; import { draftPanelRegistration } from "@/panels/draft-panel"; import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; @@ -20,5 +21,6 @@ export function ensurePanelsRegistered(): void { registerPanel(terminalPanelRegistration); registerPanel(browserPanelRegistration); registerPanel(filePanelRegistration); + registerPanel(commitDiffPanelRegistration); panelsRegistered = true; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 96cc874ec..1712f2259 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -351,6 +351,9 @@ function getFallbackTabOptionLabel( if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } return labels.agent; } @@ -382,6 +385,9 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "provider_subagent") { return labels.agent; } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } return tab.target.path; } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 211444517..db15ea577 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -141,6 +141,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "provider_subagent") { return `workspace-provider-subagent-close-${tab.target.subagentId}`; } + if (tab.target.kind === "commit_diff") { + return `workspace-commit-diff-close-${encodeFilePathForPathSegment(tab.target.sha)}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 86cba617c..3b06aa9c8 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -971,6 +971,52 @@ export function collectAllPanes(root: SplitNode): SplitPane[] { return internalRoot.group.children.flatMap((child) => collectAllPanes(child)); } +function isEphemeralTab(tab: WorkspaceTab): boolean { + // Commit diff tabs are ephemeral: their SHA may be rebased away before the next + // load, so a restored tab could point at a dead commit. + return tab.target.kind === "commit_diff"; +} + +function stripEphemeralTabsFromNode(node: SplitNodeInternal): SplitNodeInternal { + if (node.kind === "pane") { + const nextTabs = node.pane.tabs.filter((tab) => !isEphemeralTab(tab)); + if (nextTabs.length === node.pane.tabs.length) { + return node; + } + // createPaneNode repoints focusedTabId to a surviving tab (or null) when the + // previously focused tab was an ephemeral one that we just removed. + return createPaneNode({ + id: node.pane.id, + tabs: nextTabs, + focusedTabId: node.pane.focusedTabId, + }); + } + return createGroupNode({ + id: node.group.id, + direction: node.group.direction, + children: node.group.children.map((child) => stripEphemeralTabsFromNode(child)), + sizes: node.group.sizes, + }); +} + +/** + * Returns a copy of `layout` with every ephemeral tab (commit diff tabs) removed + * from each pane. Applied in the layout store's `partialize` so commit diff tabs + * are never written to storage — they're dropped on the next reload rather than + * restored pointing at a possibly-rebased SHA. Working diff tabs and all other + * tab kinds are left intact. Panes (and their ids/structure) are preserved even + * when emptied; the parent-tab map is renormalized against the surviving tabs. + */ +export function stripEphemeralTabsFromLayout(layout: WorkspaceLayout): WorkspaceLayout { + const internalLayout = asInternalLayout(layout); + const nextRoot = stripEphemeralTabsFromNode(internalLayout.root); + return withNormalizedParentTabMap({ + root: nextRoot, + focusedPaneId: internalLayout.focusedPaneId, + parentTabIdByTabId: layout.parentTabIdByTabId, + }); +} + export function getFocusedBrowserId(layout: WorkspaceLayout | null | undefined): string | null { if (!layout) { return null; diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 673fc210a..896be66a6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -37,6 +37,7 @@ import { retargetTabInLayout, splitPaneEmptyInLayout, splitPaneInLayout, + stripEphemeralTabsFromLayout, type SplitGroup, type SplitNode, type SplitPane, @@ -59,6 +60,7 @@ export { normalizeLayout, removePaneFromTree, removeTabFromTree, + stripEphemeralTabsFromLayout, }; export type { SplitGroup, @@ -902,7 +904,11 @@ export function createWorkspaceLayoutStore( partialize: (state) => { const layoutByWorkspace: Record = {}; for (const key in state.layoutByWorkspace) { - layoutByWorkspace[key] = normalizeLayout(state.layoutByWorkspace[key]); + // Strip ephemeral (commit diff) tabs before persisting so they are + // dropped on reload rather than restored pointing at a rebased SHA. + layoutByWorkspace[key] = stripEphemeralTabsFromLayout( + normalizeLayout(state.layoutByWorkspace[key]), + ); } return { layoutByWorkspace, diff --git a/packages/app/src/stores/workspace-tabs-store/state.test.ts b/packages/app/src/stores/workspace-tabs-store/state.test.ts index 68b2ef6f5..a8bde4245 100644 --- a/packages/app/src/stores/workspace-tabs-store/state.test.ts +++ b/packages/app/src/stores/workspace-tabs-store/state.test.ts @@ -8,6 +8,7 @@ import { applyRetargetTab, buildWorkspaceTabPersistenceKey, initialWorkspaceTabsCoreState, + migrateWorkspaceTabsState, type WorkspaceTabsCoreState, } from "./state"; @@ -334,6 +335,20 @@ describe("workspace-tabs-store reducers", () => { expect(result.state.focusedTabIdByWorkspace[WORKSPACE_KEY]).toBe(result.tabId); }); + it("opens a commit diff tab with a commit-specific id", () => { + let state = emptyState(); + const commit = applyOpenOrFocusTab(state, { + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "commit_diff", sha: "abc123" }, + now: NOW, + }); + state = commit.state; + + expect(commit.tabId).toBe("commit_diff_abc123"); + expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); + }); + it("closeTab focuses the most-recent remaining tab when the focused tab is removed", () => { let state = emptyState(); const first = applyOpenOrFocusTab(state, { @@ -362,3 +377,56 @@ describe("workspace-tabs-store reducers", () => { expect(state.uiTabsByWorkspace[WORKSPACE_KEY]).toHaveLength(1); }); }); + +describe("migrateWorkspaceTabsState commit diff coercion", () => { + // This legacy store no longer enforces the "commit diff tabs are ephemeral" + // guarantee — that now lives in the workspace-layout store's partialize (see + // stripEphemeralTabsFromLayout). This migration only needs to carry old commit + // diff targets forward to the dedicated `commit_diff` tab shape. + it("migrates a legacy commit diff tab to the dedicated target shape", () => { + const persisted = { + state: { + uiTabsByWorkspace: { + [WORKSPACE_KEY]: [ + { + tabId: "commit_diff_abc123", + target: { kind: "diff", diffTarget: { kind: "commit", sha: "abc123" } }, + createdAt: NOW, + }, + ], + }, + }, + }; + + const migrated = migrateWorkspaceTabsState(persisted, { now: NOW }); + const tabs = migrated.uiTabsByWorkspace[WORKSPACE_KEY] ?? []; + + expect(tabs).toHaveLength(1); + expect(tabs[0]?.target).toEqual({ kind: "commit_diff", sha: "abc123" }); + expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toEqual(["commit_diff_abc123"]); + }); + + it("drops a legacy working diff tab during migration", () => { + const persisted = { + state: { + uiTabsByWorkspace: { + [WORKSPACE_KEY]: [ + { + tabId: "diff_working:base:main", + target: { + kind: "diff", + diffTarget: { kind: "working", mode: "base", baseRef: "main" }, + }, + createdAt: NOW, + }, + ], + }, + }, + }; + + const migrated = migrateWorkspaceTabsState(persisted, { now: NOW }); + + expect(migrated.uiTabsByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + expect(migrated.tabOrderByWorkspace[WORKSPACE_KEY]).toBeUndefined(); + }); +}); diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts index 6d4ebcb77..9a670b271 100644 --- a/packages/app/src/stores/workspace-tabs-store/state.ts +++ b/packages/app/src/stores/workspace-tabs-store/state.ts @@ -23,7 +23,8 @@ export type WorkspaceTabTarget = | { kind: "terminal"; terminalId: string } | { kind: "browser"; browserId: string } | WorkspaceFileTabTarget - | { kind: "setup"; workspaceId: string }; + | { kind: "setup"; workspaceId: string } + | { kind: "commit_diff"; sha: string }; export interface WorkspaceTab { tabId: string; @@ -537,7 +538,35 @@ function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTar if (kind === "setup" && typeof raw.workspaceId === "string") { return normalizeWorkspaceTabTarget({ kind: "setup", workspaceId: raw.workspaceId }); } - return null; + return coercePersistedDiffTabTargetByKind(kind, raw); +} + +function coercePersistedDiffTabTargetByKind( + kind: string | null, + raw: Record, +): WorkspaceTabTarget | null { + if (kind === "commit_diff" && typeof raw.sha === "string") { + return normalizeWorkspaceTabTarget({ kind: "commit_diff", sha: raw.sha }); + } + return kind === "diff" ? coercePersistedDiffTabTarget(raw) : null; +} + +// NOTE: This legacy store no longer persists diff tabs — live tab persistence is +// owned by the workspace-layout store, which is where the "commit diff tabs are +// ephemeral on reload" guarantee is enforced (see stripEphemeralTabsFromLayout). +// This coercion exists only so this store's migration stays type-complete for any +// old diff-shaped entries left in `workspace-tabs-state` blobs. Working-tree diff +// tabs are dropped because they no longer exist as workspace targets; commit diff +// tabs migrate to the dedicated `commit_diff` target shape. +function coercePersistedDiffTabTarget(raw: Record): WorkspaceTabTarget | null { + const diffTarget = toObjectRecord(raw.diffTarget); + if (!diffTarget || diffTarget.kind !== "commit" || typeof diffTarget.sha !== "string") { + return null; + } + return normalizeWorkspaceTabTarget({ + kind: "commit_diff", + sha: diffTarget.sha, + }); } function migrateSingleTab(rawTab: unknown, now: number): WorkspaceTab | null { diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts index e6fec45da..ccf901e1d 100644 --- a/packages/app/src/utils/diff-layout.ts +++ b/packages/app/src/utils/diff-layout.ts @@ -1,4 +1,5 @@ -import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query"; +import type { ParsedDiffFile } from "@getpaseo/protocol/messages"; +import type { DiffLine } from "@/git/use-diff-query"; type ReviewSide = "old" | "new"; type ReviewableLineType = "add" | "remove" | "context"; diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts index b75270958..60e85293b 100644 --- a/packages/app/src/workspace-tabs/identity.test.ts +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, it, test } from "vitest"; import { buildDeterministicWorkspaceTabId, normalizeWorkspaceTabTarget, @@ -43,3 +43,56 @@ describe("provider subagent tab identity", () => { expect(first).not.toBe(second); }); }); + +describe("commit diff tab identity", () => { + it("keys a commit diff tab by its sha", () => { + expect(buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" })).toBe( + "commit_diff_abc123", + ); + }); + + it("does not collide a commit diff tab id with a file tab id", () => { + const diffId = buildDeterministicWorkspaceTabId({ kind: "commit_diff", sha: "abc123" }); + const fileId = buildDeterministicWorkspaceTabId({ + kind: "file", + path: "abc123", + }); + expect(diffId).not.toBe(fileId); + }); + + it("treats two commit diff targets with the same sha as equal", () => { + expect( + workspaceTabTargetsEqual( + { kind: "commit_diff", sha: "abc123" }, + { kind: "commit_diff", sha: "abc123" }, + ), + ).toBe(true); + }); + + it("treats commit diff targets with different shas as unequal", () => { + expect( + workspaceTabTargetsEqual( + { kind: "commit_diff", sha: "abc123" }, + { kind: "commit_diff", sha: "def456" }, + ), + ).toBe(false); + }); + + it("normalizes a commit diff target", () => { + expect( + normalizeWorkspaceTabTarget({ + kind: "commit_diff", + sha: "abc123", + }), + ).toEqual({ kind: "commit_diff", sha: "abc123" }); + }); + + it("rejects a commit diff target with a blank sha", () => { + expect( + normalizeWorkspaceTabTarget({ + kind: "commit_diff", + sha: " ", + }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 3e5bf004e..73bfec022 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -28,22 +28,37 @@ export function normalizeWorkspaceTabTarget( ? { kind: "provider_subagent", parentAgentId, subagentId } : null; } - if (value.kind === "terminal") { - const terminalId = trimNonEmpty(value.terminalId); - return terminalId ? { kind: "terminal", terminalId } : null; - } - if (value.kind === "browser") { - const browserId = trimNonEmpty(value.browserId); - return browserId ? { kind: "browser", browserId } : null; - } if (value.kind === "file") { return normalizeFileTabTarget(value); } - if (value.kind === "setup") { - const workspaceId = trimNonEmpty(value.workspaceId); - return workspaceId ? { kind: "setup", workspaceId } : null; + return normalizeSimpleWorkspaceTabTarget(value); +} + +function normalizeSimpleWorkspaceTabTarget(value: WorkspaceTabTarget): WorkspaceTabTarget | null { + switch (value.kind) { + case "agent": { + const agentId = trimNonEmpty(value.agentId); + return agentId ? { kind: "agent", agentId } : null; + } + case "terminal": { + const terminalId = trimNonEmpty(value.terminalId); + return terminalId ? { kind: "terminal", terminalId } : null; + } + case "browser": { + const browserId = trimNonEmpty(value.browserId); + return browserId ? { kind: "browser", browserId } : null; + } + case "setup": { + const workspaceId = trimNonEmpty(value.workspaceId); + return workspaceId ? { kind: "setup", workspaceId } : null; + } + case "commit_diff": { + const sha = trimNonEmpty(value.sha); + return sha ? { kind: "commit_diff", sha } : null; + } + default: + return null; } - return null; } export function normalizeWorkspaceDraftTabSetup( @@ -98,6 +113,9 @@ export function workspaceTabTargetsEqual( if (left.kind === "setup" && right.kind === "setup") { return left.workspaceId === right.workspaceId; } + if (left.kind === "commit_diff" && right.kind === "commit_diff") { + return left.sha === right.sha; + } return false; } @@ -153,6 +171,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "setup") { return `setup_${target.workspaceId}`; } + if (target.kind === "commit_diff") { + return `commit_diff_${target.sha}`; + } return `file_${target.path}`; } diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 5b3c7355d..1c5d897c7 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -29,6 +29,8 @@ import type { AgentForkContextResponseMessage, GitSetupOptions, CheckoutStatusResponse, + CheckoutCommit, + ParsedDiffFile, CheckoutCommitResponse, CheckoutMergeResponse, CheckoutMergeFromBaseResponse, @@ -3499,6 +3501,48 @@ export class DaemonClient { }); } + async listCheckoutCommits( + cwd: string, + requestId?: string, + ): Promise<{ baseRef: string | null; commits: CheckoutCommit[] }> { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.list.response">({ + requestId, + message: { + type: "checkout.commits.list.request", + cwd, + }, + timeout: 60000, + }); + if (payload.error) { + throw new Error(payload.error.message); + } + return { baseRef: payload.baseRef, commits: payload.commits }; + } + + async getCommitFileDiff( + cwd: string, + sha: string, + path: string, + requestId?: string, + ): Promise<{ file: ParsedDiffFile | null }> { + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"checkout.commits.file_diff.response">({ + requestId, + message: { + type: "checkout.commits.file_diff.request", + cwd, + sha, + path, + }, + timeout: 60000, + }); + if (payload.error) { + throw new Error(payload.error.message); + } + return { file: payload.file }; + } + async checkoutPrCreate( cwd: string, input: { title?: string; body?: string; baseRef?: string }, diff --git a/packages/protocol/src/messages.checkout-commit-file-diff.test.ts b/packages/protocol/src/messages.checkout-commit-file-diff.test.ts new file mode 100644 index 000000000..3ed95bfc2 --- /dev/null +++ b/packages/protocol/src/messages.checkout-commit-file-diff.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "vitest"; + +import { + CheckoutCommitFileDiffRequestSchema, + CheckoutCommitFileDiffResponseSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; + +describe("checkout.commits.file_diff schemas", () => { + test("parses a valid request", () => { + expect( + CheckoutCommitFileDiffRequestSchema.parse({ + type: "checkout.commits.file_diff.request", + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/a.ts", + requestId: "request-file-diff", + }), + ).toEqual({ + type: "checkout.commits.file_diff.request", + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/a.ts", + requestId: "request-file-diff", + }); + }); + + test("parses a valid response with a populated ParsedDiffFile", () => { + const payload = { + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/a.ts", + file: { + path: "src/a.ts", + isNew: false, + isDeleted: false, + additions: 2, + deletions: 1, + hunks: [ + { + oldStart: 1, + oldCount: 2, + newStart: 1, + newCount: 3, + lines: [ + { type: "header" as const, content: "@@ -1,2 +1,3 @@" }, + { type: "context" as const, content: "first" }, + { type: "remove" as const, content: "old" }, + { type: "add" as const, content: "new1" }, + { type: "add" as const, content: "new2" }, + ], + }, + ], + status: "ok" as const, + }, + error: null, + requestId: "request-file-diff", + }; + + const parsed = CheckoutCommitFileDiffResponseSchema.parse({ + type: "checkout.commits.file_diff.response", + payload, + }); + + expect(parsed.payload).toEqual(payload); + expect(parsed.payload.file?.hunks[0]?.lines).toHaveLength(5); + }); + + test("parses a valid response with a null file", () => { + const payload = { + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/missing.ts", + file: null, + error: null, + requestId: "request-file-diff", + }; + + expect( + CheckoutCommitFileDiffResponseSchema.parse({ + type: "checkout.commits.file_diff.response", + payload, + }).payload, + ).toEqual(payload); + }); + + test("accepts a null file with an error payload", () => { + const payload = { + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/a.ts", + file: null, + error: { code: "UNKNOWN" as const, message: "boom" }, + requestId: "request-file-diff", + }; + + expect( + CheckoutCommitFileDiffResponseSchema.parse({ + type: "checkout.commits.file_diff.response", + payload, + }).payload, + ).toEqual(payload); + }); + + test("parses the request through the inbound message union", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "checkout.commits.file_diff.request", + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/a.ts", + requestId: "request-file-diff", + }), + ).toMatchObject({ type: "checkout.commits.file_diff.request" }); + }); + + test("parses the response through the outbound message union", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "checkout.commits.file_diff.response", + payload: { + cwd: "/tmp/repo", + sha: "1111111111111111111111111111111111111111", + path: "src/a.ts", + file: null, + error: null, + requestId: "request-file-diff", + }, + }), + ).toMatchObject({ type: "checkout.commits.file_diff.response" }); + }); +}); diff --git a/packages/protocol/src/messages.checkout-commits.test.ts b/packages/protocol/src/messages.checkout-commits.test.ts new file mode 100644 index 000000000..290f39450 --- /dev/null +++ b/packages/protocol/src/messages.checkout-commits.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "vitest"; + +import { + CheckoutCommitsListRequestSchema, + CheckoutCommitsListResponseSchema, + ServerInfoStatusPayloadSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; + +describe("checkout.commits.list schemas", () => { + test("parses a valid request", () => { + expect( + CheckoutCommitsListRequestSchema.parse({ + type: "checkout.commits.list.request", + cwd: "/tmp/repo", + requestId: "request-commits", + }), + ).toEqual({ + type: "checkout.commits.list.request", + cwd: "/tmp/repo", + requestId: "request-commits", + }); + }); + + test("parses a valid response with local-only and remote commits", () => { + const payload = { + cwd: "/tmp/repo", + baseRef: "main", + commits: [ + { + sha: "1111111111111111111111111111111111111111", + shortSha: "1111111", + subject: "Add feature", + authorName: "Ada", + authorDate: "2026-06-13T10:00:00.000Z", + isOnRemote: true, + files: [ + { path: "src/a.ts", additions: 10, deletions: 2, status: "modified" }, + { path: "src/b.ts", additions: 5, deletions: 0, status: "added" }, + ], + }, + { + sha: "2222222222222222222222222222222222222222", + shortSha: "2222222", + subject: "Local only work", + authorName: "Ada", + authorDate: "2026-06-13T11:00:00.000Z", + isOnRemote: false, + files: [{ path: "src/c.ts", additions: 1, deletions: 1 }], + }, + ], + error: null, + requestId: "request-commits", + }; + + const parsed = CheckoutCommitsListResponseSchema.parse({ + type: "checkout.commits.list.response", + payload, + }); + + expect(parsed.payload).toEqual(payload); + expect(parsed.payload.commits[0]?.isOnRemote).toBe(true); + expect(parsed.payload.commits[1]?.isOnRemote).toBe(false); + expect(parsed.payload.commits[1]?.files[0]?.status).toBeUndefined(); + }); + + test("accepts a null baseRef and an error payload", () => { + const payload = { + cwd: "/tmp/repo", + baseRef: null, + commits: [], + error: { code: "NOT_GIT_REPO" as const, message: "not a repo" }, + requestId: "request-commits", + }; + + expect( + CheckoutCommitsListResponseSchema.parse({ + type: "checkout.commits.list.response", + payload, + }).payload, + ).toEqual(payload); + }); + + test("parses the request through the inbound message union", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "checkout.commits.list.request", + cwd: "/tmp/repo", + requestId: "request-commits", + }), + ).toMatchObject({ type: "checkout.commits.list.request" }); + }); + + test("parses the response through the outbound message union", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "checkout.commits.list.response", + payload: { + cwd: "/tmp/repo", + baseRef: "main", + commits: [], + error: null, + requestId: "request-commits", + }, + }), + ).toMatchObject({ type: "checkout.commits.list.response" }); + }); + + test("accepts the commitsList server_info feature flag", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "srv_test", + features: { + commitsList: true, + }, + }).features, + ).toEqual({ commitsList: true }); + }); + + test("still parses server_info without the commitsList feature flag", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "srv_test", + features: { + providersSnapshot: true, + }, + }).features, + ).toEqual({ providersSnapshot: true }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 93a54a61c..f3858f269 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1644,6 +1644,37 @@ export const CheckoutGithubSetAutoMergeRequestSchema = z.object({ requestId: z.string(), }); +const CheckoutCommitFileSchema = z.object({ + path: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "modified", "deleted", "renamed"]).optional(), +}); + +const CheckoutCommitSchema = z.object({ + sha: z.string(), + shortSha: z.string(), + subject: z.string(), + authorName: z.string(), + authorDate: z.string(), // ISO 8601 + isOnRemote: z.boolean(), // false = local-only (unpushed) + files: z.array(CheckoutCommitFileSchema), +}); + +export const CheckoutCommitsListRequestSchema = z.object({ + type: z.literal("checkout.commits.list.request"), + cwd: z.string(), + requestId: z.string(), +}); + +export const CheckoutCommitFileDiffRequestSchema = z.object({ + type: z.literal("checkout.commits.file_diff.request"), + cwd: z.string(), + sha: z.string(), + path: z.string(), + requestId: z.string(), +}); + const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/); export const CheckoutGithubGetCheckDetailsRequestSchema = z.object({ @@ -2246,6 +2277,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrCreateRequestSchema, CheckoutPrMergeRequestSchema, CheckoutGithubSetAutoMergeRequestSchema, + CheckoutCommitsListRequestSchema, + CheckoutCommitFileDiffRequestSchema, CheckoutGithubGetCheckDetailsRequestSchema, CheckoutPrStatusRequestSchema, PullRequestTimelineRequestSchema, @@ -2523,6 +2556,8 @@ export const ServerInfoStatusPayloadSchema = z workspaceGithubRepositorySearch: z.boolean().optional(), // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. projectCreateDirectory: z.boolean().optional(), + // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. + commitsList: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), }) @@ -3794,6 +3829,31 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({ }), }); +export const CheckoutCommitsListResponseSchema = z.object({ + type: z.literal("checkout.commits.list.response"), + payload: z.object({ + cwd: z.string(), + baseRef: z.string().nullable(), + commits: z.array(CheckoutCommitSchema), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + +export const CheckoutCommitFileDiffResponseSchema = z.object({ + type: z.literal("checkout.commits.file_diff.response"), + payload: z.object({ + cwd: z.string(), + sha: z.string(), + path: z.string(), + // null when the file is absent from the commit or carries no textual diff + // (e.g. binary-only changes). + file: ParsedDiffFileSchema.nullable(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + const CheckoutGithubCheckAnnotationSchema = z.object({ path: z.string().optional(), startLine: z.number().optional(), @@ -4571,6 +4631,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrCreateResponseSchema, CheckoutPrMergeResponseSchema, CheckoutGithubSetAutoMergeResponseSchema, + CheckoutCommitsListResponseSchema, + CheckoutCommitFileDiffResponseSchema, CheckoutGithubGetCheckDetailsResponseSchema, CheckoutPrStatusResponseSchema, PullRequestTimelineResponseSchema, @@ -4883,6 +4945,13 @@ export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; export type CheckoutRefreshRequest = z.infer; export type CheckoutRefreshResponse = z.infer; +export type CheckoutCommitFile = z.infer; +export type CheckoutCommit = z.infer; +export type CheckoutCommitsListRequest = z.infer; +export type CheckoutCommitsListResponse = z.infer; +export type CheckoutCommitFileDiffRequest = z.infer; +export type CheckoutCommitFileDiffResponse = z.infer; +export type ParsedDiffFile = z.infer; export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrCreateResponse = z.infer; export type CheckoutPrMergeRequest = z.infer; diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 40f7bc8c4..0065d9e5e 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1006,6 +1006,7 @@ test("receives server_info on websocket connect", async () => { expect(serverInfo).not.toBeNull(); expect(serverInfo?.serverId.length).toBeGreaterThan(0); expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); + expect(serverInfo?.features?.commitsList).toBe(true); expect(serverInfo?.desktopManaged).toBe(false); expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); expect(serverInfo?.features?.worktreeRestore).toBe(true); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0402733b5..b848a3300 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1620,6 +1620,10 @@ export class Session { switch (msg.type) { case "checkout_status_request": return this.checkoutSession.handleStatusRequest(msg); + case "checkout.commits.list.request": + return this.checkoutSession.handleCommitsListRequest(msg); + case "checkout.commits.file_diff.request": + return this.checkoutSession.handleCommitFileDiffRequest(msg); case "validate_branch_request": return this.checkoutSession.handleValidateBranchRequest(msg); case "branch_suggestions_request": diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index 6c39014b2..5102daec6 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -1,8 +1,11 @@ import type pino from "pino"; +import { isAbsolute } from "node:path"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import type { BranchSuggestionsRequest, + CheckoutCommitsListRequest, + CheckoutCommitFileDiffRequest, CheckoutRefreshRequest, CheckoutRenameBranchRequest, CheckoutStatusRequest, @@ -41,6 +44,8 @@ import { mergeToBase, pullCurrentBranch, pushCurrentBranch, + listCheckoutCommits, + getCommitFileDiff, } from "../../../utils/checkout-git.js"; import { execCommand } from "../../../utils/spawn.js"; import { expandTilde } from "../../../utils/path.js"; @@ -172,6 +177,44 @@ export class CheckoutSession { } } + async handleCommitsListRequest(msg: CheckoutCommitsListRequest): Promise { + const { cwd, requestId } = msg; + + try { + const { baseRef, commits } = await listCheckoutCommits({ cwd: expandTilde(cwd) }); + this.host.emit({ + type: "checkout.commits.list.response", + payload: { cwd, baseRef, commits, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.commits.list.response", + payload: { cwd, baseRef: null, commits: [], error: toCheckoutError(error), requestId }, + }); + } + } + + async handleCommitFileDiffRequest(msg: CheckoutCommitFileDiffRequest): Promise { + const { cwd, sha, path, requestId } = msg; + + try { + assertSafeGitRef(sha, "commit"); + if (path.length === 0 || isAbsolute(path) || path.split(/[\\/]/).includes("..")) { + throw new Error(`Invalid path: ${path}`); + } + const file = await getCommitFileDiff({ cwd: expandTilde(cwd), sha, path }); + this.host.emit({ + type: "checkout.commits.file_diff.response", + payload: { cwd, sha, path, file, error: null, requestId }, + }); + } catch (error) { + this.host.emit({ + type: "checkout.commits.file_diff.response", + payload: { cwd, sha, path, file: null, error: toCheckoutError(error), requestId }, + }); + } + } + async handleValidateBranchRequest(msg: ValidateBranchRequest): Promise { const { cwd, branchName, requestId } = msg; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ea6a9a112..239de93b2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1262,6 +1262,8 @@ export class VoiceAssistantWebSocketServer { workspaceGithubRepositorySearch: true, // COMPAT(projectCreateDirectory): added in v0.1.108, remove gate after 2027-01-15. projectCreateDirectory: true, + // COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16. + commitsList: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, }, diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts new file mode 100644 index 000000000..bcf5b095e --- /dev/null +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -0,0 +1,91 @@ +import { execFileSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getCommitFileDiff } from "./checkout-git.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "commit-file-diff-test-"))); + tempDirs.push(dir); + return dir; +} + +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { cwd }).toString(); +} + +function commitFile(repoDir: string, name: string, content: string, message: string): void { + writeFileSync(join(repoDir, name), content); + git(["add", "."], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir); +} + +function initRepo(): string { + const tempDir = makeTempDir(); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + git(["init", "-b", "main"], repoDir); + git(["config", "user.email", "test@test.com"], repoDir); + git(["config", "user.name", "Test User"], repoDir); + return repoDir; +} + +function headSha(repoDir: string): string { + return git(["rev-parse", "HEAD"], repoDir).trim(); +} + +describe("getCommitFileDiff", () => { + it("returns the parsed diff for a modified file in a commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial"); + commitFile(repoDir, "foo.txt", "a\nB\nc\nd\n", "edit foo"); + const sha = headSha(repoDir); + + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "foo.txt" }); + + expect(file).not.toBeNull(); + expect(file?.path).toBe("foo.txt"); + expect(file?.additions).toBe(2); + expect(file?.deletions).toBe(1); + expect(file?.hunks.length).toBeGreaterThan(0); + + const lines = file?.hunks[0]?.lines ?? []; + expect(lines.some((line) => line.type === "add" && line.content === "B")).toBe(true); + expect(lines.some((line) => line.type === "add" && line.content === "d")).toBe(true); + expect(lines.some((line) => line.type === "remove" && line.content === "b")).toBe(true); + }); + + it("returns a diff flagged as new for an added file", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "README.md", "base\n", "initial"); + commitFile(repoDir, "added.txt", "x\ny\n", "add file"); + const sha = headSha(repoDir); + + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }); + + expect(file?.path).toBe("added.txt"); + expect(file?.isNew).toBe(true); + expect(file?.additions).toBe(2); + expect(file?.deletions).toBe(0); + }); + + it("returns null for a path not changed in the commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "a\n", "initial"); + commitFile(repoDir, "foo.txt", "a\nb\n", "edit foo"); + const sha = headSha(repoDir); + + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "does-not-exist.txt" }); + + expect(file).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/checkout-git.commits.test.ts b/packages/server/src/utils/checkout-git.commits.test.ts new file mode 100644 index 000000000..b1a5a242b --- /dev/null +++ b/packages/server/src/utils/checkout-git.commits.test.ts @@ -0,0 +1,138 @@ +import { execFileSync } from "child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { listCheckoutCommits } from "./checkout-git.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-commits-test-"))); + tempDirs.push(dir); + return dir; +} + +function git(args: string[], cwd?: string): void { + execFileSync("git", args, cwd ? { cwd } : {}); +} + +function commit(repoDir: string, message: string): void { + git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir); +} + +function commitFile(repoDir: string, name: string, content: string, message: string): void { + writeFileSync(join(repoDir, name), content); + git(["add", "."], repoDir); + commit(repoDir, message); +} + +function initRepoOnMain(): { repoDir: string; tempDir: string } { + const tempDir = makeTempDir(); + const repoDir = join(tempDir, "repo"); + mkdirSync(repoDir, { recursive: true }); + git(["init", "-b", "main"], repoDir); + git(["config", "user.email", "test@test.com"], repoDir); + git(["config", "user.name", "Test User"], repoDir); + commitFile(repoDir, "README.md", "base\n", "initial"); + return { repoDir, tempDir }; +} + +function addBareRemote(repoDir: string, tempDir: string): string { + const remoteDir = join(tempDir, "remote.git"); + git(["init", "--bare", "-b", "main", remoteDir]); + git(["remote", "add", "origin", remoteDir], repoDir); + return remoteDir; +} + +describe("listCheckoutCommits", () => { + it("lists commits ahead of base newest-first with on-remote flags and file stats", async () => { + const { repoDir, tempDir } = initRepoOnMain(); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "foo.txt", "a\nb\nc\n", "Add foo"); + + // Push feature (containing only commit A) to the remote, then add B locally. + addBareRemote(repoDir, tempDir); + git(["push", "-u", "origin", "feature"], repoDir); + commitFile(repoDir, "bar.txt", "x\n", "Add bar"); + + const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(baseRef).toBe("main"); + expect(commits).toHaveLength(2); + expect(commits[0]?.subject).toBe("Add bar"); + expect(commits[1]?.subject).toBe("Add foo"); + + expect(commits[0]?.isOnRemote).toBe(false); + expect(commits[1]?.isOnRemote).toBe(true); + + expect(commits[0]?.files).toEqual([ + { path: "bar.txt", additions: 1, deletions: 0, status: "added" }, + ]); + expect(commits[1]?.files).toEqual([ + { path: "foo.txt", additions: 3, deletions: 0, status: "added" }, + ]); + + expect(commits[0]?.authorName).toBe("Test User"); + expect(commits[0]?.sha).toHaveLength(40); + expect((commits[0]?.shortSha.length ?? 0) > 0).toBe(true); + expect(Number.isNaN(new Date(commits[0]?.authorDate ?? "").getTime())).toBe(false); + }); + + it("returns [] when there are no commits ahead of base", async () => { + const { repoDir } = initRepoOnMain(); + const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); + expect(baseRef).toBeNull(); + expect(commits).toEqual([]); + }); + + it("marks all commits local-only when there is no remote", async () => { + const { repoDir } = initRepoOnMain(); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "foo.txt", "a\n", "Add foo"); + commitFile(repoDir, "bar.txt", "b\n", "Add bar"); + + const { baseRef, commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(baseRef).toBe("main"); + expect(commits).toHaveLength(2); + expect(commits.every((c) => c.isOnRemote === false)).toBe(true); + }); + + it("classifies renamed files with status renamed and correct destination path", async () => { + const { repoDir } = initRepoOnMain(); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "original.txt", "content\n", "Add original"); + git(["mv", "original.txt", "renamed.txt"], repoDir); + commit(repoDir, "Rename file"); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits[0]?.files).toEqual([ + { path: "renamed.txt", additions: 0, deletions: 0, status: "renamed" }, + ]); + }); + + it("derives status for modified and deleted files", async () => { + const { repoDir } = initRepoOnMain(); + git(["checkout", "-b", "feature"], repoDir); + commitFile(repoDir, "README.md", "base\nmore\n", "Edit readme"); + git(["rm", "README.md"], repoDir); + commit(repoDir, "Delete readme"); + + const { commits } = await listCheckoutCommits({ cwd: repoDir }); + + expect(commits[0]?.files).toEqual([ + { path: "README.md", additions: 0, deletions: 2, status: "deleted" }, + ]); + expect(commits[1]?.files).toEqual([ + { path: "README.md", additions: 1, deletions: 0, status: "modified" }, + ]); + }); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 208aef3a7..06b3cc88f 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2,6 +2,7 @@ import { resolve, dirname, basename } from "path"; import { existsSync, realpathSync } from "fs"; import { open as openFile, readFile, stat as statFile } from "fs/promises"; import { TTLCache } from "@isaacs/ttlcache"; +import type { CheckoutCommit, CheckoutCommitFile } from "@getpaseo/protocol/messages"; import type { Logger } from "pino"; import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js"; @@ -1923,6 +1924,323 @@ export async function getCheckoutStatus( }; } +// Cap on how many ahead-of-base commits we enumerate. Branches with more than +// this many unmerged commits are truncated to the newest MAX_CHECKOUT_COMMITS. +const MAX_CHECKOUT_COMMITS = 200; +// Bytes git emits between fields/records. We split parsed output on these. +const COMMIT_FIELD_SEPARATOR = "\x00"; +const COMMIT_RECORD_SEPARATOR = "\x1e"; +// Record-separated, NUL-field-separated so arbitrary subject text stays parseable. +// `%x1e`/`%x00` are git placeholders (literal text in the arg, real bytes in the +// output) — passing actual NUL bytes as a process arg is rejected by Node. +const COMMIT_LOG_FORMAT = "%x1e%H%x00%h%x00%an%x00%aI%x00%s"; + +type CheckoutCommitFileStatus = NonNullable; + +interface ParsedCheckoutCommit { + sha: string; + shortSha: string; + authorName: string; + authorDate: string; + subject: string; + files: CheckoutCommitFile[]; +} + +function mapNameStatusLetter(letter: string): CheckoutCommitFileStatus | undefined { + switch (letter) { + case "A": + return "added"; + case "C": + return "added"; + case "M": + return "modified"; + case "T": + return "modified"; + case "D": + return "deleted"; + case "R": + return "renamed"; + default: + return undefined; + } +} + +// A `--raw` line: `: \t` +// (rename/copy add a second path: `R100\t\t`). The status token is the +// last space-separated field before the first tab. Keyed on the destination path. +function parseRawStatusLine(line: string, statuses: Map): void { + const tabParts = line.split("\t"); + const meta = tabParts[0] ?? ""; + const statusToken = meta.slice(meta.lastIndexOf(" ") + 1); + const letter = statusToken.charAt(0); + const status = mapNameStatusLetter(letter); + if (!status) { + return; + } + const path = + letter === "R" || letter === "C" ? (tabParts[tabParts.length - 1] ?? "") : (tabParts[1] ?? ""); + if (!path) { + return; + } + statuses.set(path, status); +} + +// A `--numstat` line: `\t\t` (renames use `old => new`, binary +// files report `-` for both counts). Keyed on the (normalized) destination path. +function parseNumstatLine( + line: string, + stats: Map, +): void { + const parts = line.split("\t"); + if (parts.length < 3) { + return; + } + const additionsField = parts[0] ?? ""; + const deletionsField = parts[1] ?? ""; + const path = normalizeNumstatPath(parts.slice(2).join("\t")); + if (!path) { + return; + } + if (additionsField === "-" || deletionsField === "-") { + stats.set(path, { additions: 0, deletions: 0 }); + return; + } + const additions = Number.parseInt(additionsField, 10); + const deletions = Number.parseInt(deletionsField, 10); + if (Number.isNaN(additions) || Number.isNaN(deletions)) { + return; + } + stats.set(path, { additions, deletions }); +} + +// Parses the single combined `git log ... --raw --numstat -M` stream. Each record +// (split on the record separator) starts with the NUL-field-separated header line, +// then a blank line, then the interleaved `--raw` (status) and `--numstat` (counts) +// blocks. We merge both by destination path so each file carries counts + status. +function parseCheckoutCommitRecords(stdout: string): ParsedCheckoutCommit[] { + const records = stdout.split(COMMIT_RECORD_SEPARATOR).filter((record) => record.length > 0); + const commits: ParsedCheckoutCommit[] = []; + for (const record of records) { + const lines = record.split("\n"); + const fields = (lines[0] ?? "").split(COMMIT_FIELD_SEPARATOR); + if (fields.length < 5) { + continue; + } + const sha = (fields[0] ?? "").trim(); + if (!sha) { + continue; + } + + const stats = new Map(); + const statuses = new Map(); + for (let index = 1; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (!line) { + continue; + } + if (line.startsWith(":")) { + parseRawStatusLine(line, statuses); + } else { + parseNumstatLine(line, stats); + } + } + + const files: CheckoutCommitFile[] = []; + for (const [path, stat] of stats) { + const status = statuses.get(path); + files.push({ + path, + additions: stat.additions, + deletions: stat.deletions, + ...(status ? { status } : {}), + }); + } + + commits.push({ + sha, + shortSha: (fields[1] ?? "").trim(), + authorName: fields[2] ?? "", + authorDate: (fields[3] ?? "").trim(), + subject: fields[4] ?? "", + files, + }); + } + return commits; +} + +async function resolveCheckoutCommitUpstreamRef( + cwd: string, + currentBranch: string, + context?: CheckoutContext, +): Promise { + // Prefer the branch's configured `@{u}`. If it's configured but the + // remote-tracking ref isn't present locally (e.g. configured upstream that was + // never fetched), fall back to `origin/` when that ref does exist, so a + // standard `origin` push is still recognized. Both missing => no remote. + const configured = await getConfiguredUpstreamRef(cwd, currentBranch, context); + if (configured && (await doesGitRefExist(cwd, `refs/remotes/${configured}`, context))) { + return configured; + } + if (await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`, context)) { + return `origin/${currentBranch}`; + } + return null; +} + +// Returns the set of SHAs on the current branch that are NOT on the upstream +// (local-only/unpushed), or `null` when no upstream exists (no remote at all). +async function getLocalOnlyCommitShas( + cwd: string, + currentBranch: string, + context?: CheckoutContext, +): Promise | null> { + const upstreamRef = await resolveCheckoutCommitUpstreamRef(cwd, currentBranch, context); + if (!upstreamRef) { + return null; + } + const { stdout } = await runGitCommand(["rev-list", `${upstreamRef}..HEAD`], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + logger: context?.logger, + }); + return new Set( + stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + ); +} + +/** + * Lists the current branch's commits that are ahead of its base branch, newest + * first, each flagged local-only vs on-remote with per-commit file +/- stats. + * + * Base ref is resolved exactly like {@link getCheckoutStatus}: the stored/default + * base branch, mapped to the best comparison ref (origin/ when present, + * else local ). Returns `[]` when the base cannot be resolved, the current + * ref is the base itself, or there are no commits ahead. + */ +export interface CheckoutCommitsResult { + baseRef: string | null; + commits: CheckoutCommit[]; +} + +export async function listCheckoutCommits({ + cwd, +}: { + cwd: string; +}): Promise { + const currentBranch = await getCurrentBranch(cwd); + if (!currentBranch) { + return { baseRef: null, commits: [] }; + } + + const { resolvedBaseRef } = await resolveBaseRefForCwd(cwd); + if (!resolvedBaseRef) { + return { baseRef: null, commits: [] }; + } + + const normalizedBaseRef = normalizeLocalBranchRefName(resolvedBaseRef); + if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + return { baseRef: null, commits: [] }; + } + + let comparisonBaseRef: string; + try { + comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef); + } catch { + // Base branch is not present locally or on origin — nothing to compare against. + return { baseRef: null, commits: [] }; + } + + // Single pass: `--raw` carries the status letter, `--numstat` the +/- counts. + // (`--name-status` cannot be combined with `--numstat` — git emits only one.) + const logResult = await runGitCommand( + [ + "log", + `${comparisonBaseRef}..HEAD`, + "--no-merges", + `--max-count=${MAX_CHECKOUT_COMMITS}`, + `--format=${COMMIT_LOG_FORMAT}`, + "--raw", + "--numstat", + "-M", + ], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + + const records = parseCheckoutCommitRecords(logResult.stdout); + if (records.length === 0) { + return { baseRef: comparisonBaseRef, commits: [] }; + } + + const localOnlyShas = await getLocalOnlyCommitShas(cwd, currentBranch); + + const commits = records.map((record) => ({ + sha: record.sha, + shortSha: record.shortSha, + subject: record.subject, + authorName: record.authorName, + authorDate: record.authorDate, + isOnRemote: localOnlyShas === null ? false : !localOnlyShas.has(record.sha), + files: record.files, + })); + + return { baseRef: comparisonBaseRef, commits }; +} + +/** + * Fetches the unified diff of a single file as introduced by one commit and + * parses it into the same {@link ParsedDiffFile} shape the diff subscription + * emits (so the client can reuse its existing renderer). + * + * Runs `git show --format= -- ` with the sha and path passed as + * separate process args (never interpolated into a shell string), and `--format=` + * suppresses the commit header so the output is a pure unified diff. The text is + * parsed and highlighted by {@link parseAndHighlightDiff} — the exact parser the + * diff subscription uses. Returns `null` when the file is absent from the commit + * or the change is binary-only (no textual hunks). Throws on git failure (e.g. an + * unknown sha), which the caller maps to a typed checkout error. + */ +export async function getCommitFileDiff({ + cwd, + sha, + path, +}: { + cwd: string; + sha: string; + path: string; +}): Promise { + const { stdout } = await runGitCommand(["show", sha, "--format=", "--", path], { + cwd, + envOverlay: READ_ONLY_GIT_ENV, + }); + + if (stdout.trim().length === 0) { + return null; + } + + const parsedFiles = await parseAndHighlightDiff(stdout, cwd, { + getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path), + getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path), + }); + + // `--` scopes the diff to a single pathspec, so there is at most one real + // entry. Pick by path to drop any stray header-only section the parser emits. + const file = parsedFiles.find((candidate) => candidate.path === path) ?? null; + if (!file) { + return null; + } + + // Binary changes carry a "Binary files ... differ" marker and no hunks; there + // is nothing textual to render, so report them as absent. + if (file.hunks.length === 0 && /^Binary files .* differ$/m.test(stdout)) { + return null; + } + + return file; +} + export interface CheckoutShortstat { additions: number; deletions: number;