diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 6dbfa86a9..2cdc85296 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -1,12 +1,16 @@ -import { View, Text, ActivityIndicator, Platform, RefreshControl } from "react-native"; -import { Gesture, GestureDetector, ScrollView } from "react-native-gesture-handler"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; -import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { useState, useCallback } from "react"; +import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native"; +import { ScrollView } from "react-native-gesture-handler"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { ChevronRight } from "lucide-react-native"; import { useSessionStore } from "@/stores/session-store"; import { useGitDiffQuery } from "@/hooks/use-git-diff-query"; interface ParsedDiffFile { path: string; + isNew: boolean; + additions: number; + deletions: number; lines: Array<{ type: "add" | "remove" | "context" | "header"; content: string; @@ -25,46 +29,139 @@ function parseDiff(diffText: string): ParsedDiffFile[] { const lines = section.split("\n"); const firstLine = lines[0]; + // Check for new file indicator + const isNew = section.includes("new file mode") || section.includes("/dev/null"); + + // Extract path - handle both regular and new file formats + let path = "unknown"; const pathMatch = firstLine.match(/a\/(.*?) b\//); - const path = pathMatch ? pathMatch[1] : "unknown"; + if (pathMatch) { + path = pathMatch[1]; + } else { + // For new files from /dev/null, extract from b/... + const newFileMatch = firstLine.match(/b\/(.+)$/); + if (newFileMatch) { + path = newFileMatch[1]; + } + } const parsedLines: ParsedDiffFile["lines"] = []; + let additions = 0; + let deletions = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; - // Skip metadata lines - they're noise in the UI - // - First line (a/... b/...) - already shown in file header - // - index ... - git hash info, not useful - // - --- a/... and +++ b/... - file markers, redundant + // Skip metadata lines if (i === 0) continue; if (line.startsWith("index ")) continue; if (line.startsWith("--- ")) continue; if (line.startsWith("+++ ")) continue; + if (line.startsWith("new file mode")) continue; if (line.startsWith("@@")) { - // Extract just the line numbers portion from @@ -x,y +x,y @@ context const hunkMatch = line.match(/^(@@ .+? @@)/); const hunkHeader = hunkMatch ? hunkMatch[1] : line; parsedLines.push({ type: "header", content: hunkHeader }); } else if (line.startsWith("+")) { parsedLines.push({ type: "add", content: line.slice(1) }); + additions++; } else if (line.startsWith("-")) { parsedLines.push({ type: "remove", content: line.slice(1) }); + deletions++; } else if (line.startsWith(" ")) { parsedLines.push({ type: "context", content: line.slice(1) }); } else if (line.length > 0) { - // Non-empty lines without prefix (rare, but handle gracefully) parsedLines.push({ type: "context", content: line }); } } - files.push({ path, lines: parsedLines }); + files.push({ path, isNew, additions, deletions, lines: parsedLines }); } return files; } +interface DiffFileSectionProps { + file: ParsedDiffFile; + defaultExpanded?: boolean; +} + +function DiffFileSection({ file, defaultExpanded = true }: DiffFileSectionProps) { + const { theme } = useUnistyles(); + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + const toggleExpanded = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + return ( + + [ + styles.fileHeader, + pressed && styles.fileHeaderPressed, + ]} + onPress={toggleExpanded} + > + + + + + + {file.path} + + {file.isNew && ( + + New + + )} + + + +{file.additions} + -{file.deletions} + + + {isExpanded && ( + + {file.lines.map((line, lineIndex) => ( + + + {line.content || " "} + + + ))} + + )} + + ); +} + interface GitDiffPaneProps { serverId: string; agentId: string; @@ -72,23 +169,11 @@ interface GitDiffPaneProps { export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const { theme } = useUnistyles(); - const { closeGestureRef } = useExplorerSidebarAnimation(); const { diff, isLoading, isFetching, isError, error, refresh } = useGitDiffQuery({ serverId, agentId, }); - const isMobile = - UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - - // Pan gesture gate: only allow horizontal scroll after deliberate horizontal movement - // This prevents diagonal scrolling from being captured by horizontal ScrollView - const horizontalScrollGate = Gesture.Pan() - .enabled(isMobile) - .activeOffsetX([-15, 15]) // Require 15px horizontal movement to activate - .failOffsetY([-10, 10]) // Fail if 10px vertical movement happens first - .blocksExternalGesture(closeGestureRef); // Block sidebar close while scrolling - const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId) ); @@ -133,48 +218,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { ) : ( parsedFiles.map((file, fileIndex) => ( - - - {file.path} - - - - - - {file.lines.map((line, lineIndex) => ( - - - {line.content} - - - ))} - - - - - + )) )} @@ -188,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({ contentContainer: { padding: theme.spacing[4], paddingBottom: theme.spacing[8], + gap: theme.spacing[3], }, loadingContainer: { flex: 1, @@ -223,36 +268,78 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.mutedForeground, }, fileSection: { - marginBottom: theme.spacing[6], borderRadius: theme.borderRadius.lg, overflow: "hidden", - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - width: "100%", + backgroundColor: theme.colors.card, }, fileHeader: { - backgroundColor: theme.colors.muted, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", padding: theme.spacing[3], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, + paddingVertical: theme.spacing[4], + gap: theme.spacing[3], + }, + fileHeaderPressed: { + opacity: 0.7, + }, + fileHeaderLeft: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + minWidth: 0, + }, + fileHeaderRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flexShrink: 0, + }, + chevronContainer: { + transform: [{ rotate: "0deg" }], + }, + chevronExpanded: { + transform: [{ rotate: "90deg" }], }, filePath: { fontSize: theme.fontSize.sm, - fontWeight: theme.fontWeight.semibold, + fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, fontFamily: "monospace", + flex: 1, + }, + newBadge: { + backgroundColor: theme.colors.palette.green[800], + paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + borderRadius: theme.borderRadius.md, + flexShrink: 0, + }, + newBadgeText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.palette.green[200], + }, + additions: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.palette.green[400], + fontFamily: "monospace", + }, + deletions: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.palette.red[500], + fontFamily: "monospace", }, diffContent: { - backgroundColor: theme.colors.card, - }, - diffLinesContainer: { - minWidth: "100%", + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, }, diffLineContainer: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], - flexDirection: "row", - alignItems: "flex-start", }, diffLineText: { fontSize: theme.fontSize.xs,