feat(git-diff): redesign diff UI with collapsible files and line counts
- Make each file section collapsible (expanded by default) - Remove bold from file name, use normal weight - Truncate long file paths with ellipsis - Show +X -Y line counts in file header - Add "New" badge indicator for new files - Remove horizontal scroll, let diff content wrap - Add chevron icon that rotates on expand/collapse 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
41b321a621
commit
607decff84
@@ -1,12 +1,16 @@
|
|||||||
import { View, Text, ActivityIndicator, Platform, RefreshControl } from "react-native";
|
import { useState, useCallback } from "react";
|
||||||
import { Gesture, GestureDetector, ScrollView } from "react-native-gesture-handler";
|
import { View, Text, ActivityIndicator, Pressable, RefreshControl } from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
import { ScrollView } from "react-native-gesture-handler";
|
||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { ChevronRight } from "lucide-react-native";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
|
import { useGitDiffQuery } from "@/hooks/use-git-diff-query";
|
||||||
|
|
||||||
interface ParsedDiffFile {
|
interface ParsedDiffFile {
|
||||||
path: string;
|
path: string;
|
||||||
|
isNew: boolean;
|
||||||
|
additions: number;
|
||||||
|
deletions: number;
|
||||||
lines: Array<{
|
lines: Array<{
|
||||||
type: "add" | "remove" | "context" | "header";
|
type: "add" | "remove" | "context" | "header";
|
||||||
content: string;
|
content: string;
|
||||||
@@ -25,46 +29,139 @@ function parseDiff(diffText: string): ParsedDiffFile[] {
|
|||||||
const lines = section.split("\n");
|
const lines = section.split("\n");
|
||||||
const firstLine = lines[0];
|
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 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"] = [];
|
const parsedLines: ParsedDiffFile["lines"] = [];
|
||||||
|
let additions = 0;
|
||||||
|
let deletions = 0;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
// Skip metadata lines - they're noise in the UI
|
// Skip metadata lines
|
||||||
// - First line (a/... b/...) - already shown in file header
|
|
||||||
// - index ... - git hash info, not useful
|
|
||||||
// - --- a/... and +++ b/... - file markers, redundant
|
|
||||||
if (i === 0) continue;
|
if (i === 0) continue;
|
||||||
if (line.startsWith("index ")) continue;
|
if (line.startsWith("index ")) continue;
|
||||||
if (line.startsWith("--- ")) continue;
|
if (line.startsWith("--- ")) continue;
|
||||||
if (line.startsWith("+++ ")) continue;
|
if (line.startsWith("+++ ")) continue;
|
||||||
|
if (line.startsWith("new file mode")) continue;
|
||||||
|
|
||||||
if (line.startsWith("@@")) {
|
if (line.startsWith("@@")) {
|
||||||
// Extract just the line numbers portion from @@ -x,y +x,y @@ context
|
|
||||||
const hunkMatch = line.match(/^(@@ .+? @@)/);
|
const hunkMatch = line.match(/^(@@ .+? @@)/);
|
||||||
const hunkHeader = hunkMatch ? hunkMatch[1] : line;
|
const hunkHeader = hunkMatch ? hunkMatch[1] : line;
|
||||||
parsedLines.push({ type: "header", content: hunkHeader });
|
parsedLines.push({ type: "header", content: hunkHeader });
|
||||||
} else if (line.startsWith("+")) {
|
} else if (line.startsWith("+")) {
|
||||||
parsedLines.push({ type: "add", content: line.slice(1) });
|
parsedLines.push({ type: "add", content: line.slice(1) });
|
||||||
|
additions++;
|
||||||
} else if (line.startsWith("-")) {
|
} else if (line.startsWith("-")) {
|
||||||
parsedLines.push({ type: "remove", content: line.slice(1) });
|
parsedLines.push({ type: "remove", content: line.slice(1) });
|
||||||
|
deletions++;
|
||||||
} else if (line.startsWith(" ")) {
|
} else if (line.startsWith(" ")) {
|
||||||
parsedLines.push({ type: "context", content: line.slice(1) });
|
parsedLines.push({ type: "context", content: line.slice(1) });
|
||||||
} else if (line.length > 0) {
|
} else if (line.length > 0) {
|
||||||
// Non-empty lines without prefix (rare, but handle gracefully)
|
|
||||||
parsedLines.push({ type: "context", content: line });
|
parsedLines.push({ type: "context", content: line });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
files.push({ path, lines: parsedLines });
|
files.push({ path, isNew, additions, deletions, lines: parsedLines });
|
||||||
}
|
}
|
||||||
|
|
||||||
return files;
|
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 (
|
||||||
|
<View style={styles.fileSection}>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.fileHeader,
|
||||||
|
pressed && styles.fileHeaderPressed,
|
||||||
|
]}
|
||||||
|
onPress={toggleExpanded}
|
||||||
|
>
|
||||||
|
<View style={styles.fileHeaderLeft}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.chevronContainer,
|
||||||
|
isExpanded && styles.chevronExpanded,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
size={16}
|
||||||
|
color={theme.colors.mutedForeground}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.filePath} numberOfLines={1} ellipsizeMode="middle">
|
||||||
|
{file.path}
|
||||||
|
</Text>
|
||||||
|
{file.isNew && (
|
||||||
|
<View style={styles.newBadge}>
|
||||||
|
<Text style={styles.newBadgeText}>New</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.fileHeaderRight}>
|
||||||
|
<Text style={styles.additions}>+{file.additions}</Text>
|
||||||
|
<Text style={styles.deletions}>-{file.deletions}</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
{isExpanded && (
|
||||||
|
<View style={styles.diffContent}>
|
||||||
|
{file.lines.map((line, lineIndex) => (
|
||||||
|
<View
|
||||||
|
key={lineIndex}
|
||||||
|
style={[
|
||||||
|
styles.diffLineContainer,
|
||||||
|
line.type === "add" && styles.addLineContainer,
|
||||||
|
line.type === "remove" && styles.removeLineContainer,
|
||||||
|
line.type === "header" && styles.headerLineContainer,
|
||||||
|
line.type === "context" && styles.contextLineContainer,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.diffLineText,
|
||||||
|
line.type === "add" && styles.addLineText,
|
||||||
|
line.type === "remove" && styles.removeLineText,
|
||||||
|
line.type === "header" && styles.headerLineText,
|
||||||
|
line.type === "context" && styles.contextLineText,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{line.content || " "}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface GitDiffPaneProps {
|
interface GitDiffPaneProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -72,23 +169,11 @@ interface GitDiffPaneProps {
|
|||||||
|
|
||||||
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { closeGestureRef } = useExplorerSidebarAnimation();
|
|
||||||
const { diff, isLoading, isFetching, isError, error, refresh } = useGitDiffQuery({
|
const { diff, isLoading, isFetching, isError, error, refresh } = useGitDiffQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
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) =>
|
const agent = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.agents?.get(agentId)
|
state.sessions[serverId]?.agents?.get(agentId)
|
||||||
);
|
);
|
||||||
@@ -133,48 +218,7 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
parsedFiles.map((file, fileIndex) => (
|
parsedFiles.map((file, fileIndex) => (
|
||||||
<View key={fileIndex} style={styles.fileSection}>
|
<DiffFileSection key={fileIndex} file={file} />
|
||||||
<View style={styles.fileHeader}>
|
|
||||||
<Text style={styles.filePath}>{file.path}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.diffContent}>
|
|
||||||
<GestureDetector gesture={horizontalScrollGate}>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
showsHorizontalScrollIndicator
|
|
||||||
nestedScrollEnabled
|
|
||||||
directionalLockEnabled={Platform.OS === "ios"}
|
|
||||||
>
|
|
||||||
<View style={styles.diffLinesContainer}>
|
|
||||||
{file.lines.map((line, lineIndex) => (
|
|
||||||
<View
|
|
||||||
key={lineIndex}
|
|
||||||
style={[
|
|
||||||
styles.diffLineContainer,
|
|
||||||
line.type === "add" && styles.addLineContainer,
|
|
||||||
line.type === "remove" && styles.removeLineContainer,
|
|
||||||
line.type === "header" && styles.headerLineContainer,
|
|
||||||
line.type === "context" && styles.contextLineContainer,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.diffLineText,
|
|
||||||
line.type === "add" && styles.addLineText,
|
|
||||||
line.type === "remove" && styles.removeLineText,
|
|
||||||
line.type === "header" && styles.headerLineText,
|
|
||||||
line.type === "context" && styles.contextLineText,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{line.content}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</GestureDetector>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -188,6 +232,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
contentContainer: {
|
contentContainer: {
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
paddingBottom: theme.spacing[8],
|
paddingBottom: theme.spacing[8],
|
||||||
|
gap: theme.spacing[3],
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
loadingContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -223,36 +268,78 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
},
|
},
|
||||||
fileSection: {
|
fileSection: {
|
||||||
marginBottom: theme.spacing[6],
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
borderWidth: theme.borderWidth[1],
|
backgroundColor: theme.colors.card,
|
||||||
borderColor: theme.colors.border,
|
|
||||||
width: "100%",
|
|
||||||
},
|
},
|
||||||
fileHeader: {
|
fileHeader: {
|
||||||
backgroundColor: theme.colors.muted,
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
paddingVertical: theme.spacing[4],
|
||||||
borderBottomColor: theme.colors.border,
|
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: {
|
filePath: {
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontFamily: "monospace",
|
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: {
|
diffContent: {
|
||||||
backgroundColor: theme.colors.card,
|
borderTopWidth: theme.borderWidth[1],
|
||||||
},
|
borderTopColor: theme.colors.border,
|
||||||
diffLinesContainer: {
|
|
||||||
minWidth: "100%",
|
|
||||||
},
|
},
|
||||||
diffLineContainer: {
|
diffLineContainer: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "flex-start",
|
|
||||||
},
|
},
|
||||||
diffLineText: {
|
diffLineText: {
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
|
|||||||
Reference in New Issue
Block a user