feat: add side-by-side diff layout and whitespace toggle (#208)

This commit is contained in:
Illia Panasenko
2026-04-07 10:22:20 +08:00
committed by GitHub
parent 03380cfad0
commit fd9dfb0cc8
13 changed files with 925 additions and 163 deletions
+412 -121
View File
@@ -18,17 +18,21 @@ import {
type LayoutChangeEvent, type LayoutChangeEvent,
type NativeSyntheticEvent, type NativeSyntheticEvent,
type NativeScrollEvent, type NativeScrollEvent,
TextStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { import {
AlignJustify,
Archive, Archive,
ChevronDown, ChevronDown,
Columns2,
GitBranch, GitBranch,
GitCommitHorizontal, GitCommitHorizontal,
GitMerge, GitMerge,
ListChevronsDownUp, ListChevronsDownUp,
ListChevronsUpDown, ListChevronsUpDown,
Pilcrow,
RefreshCcw, RefreshCcw,
Upload, Upload,
WrapText, WrapText,
@@ -42,6 +46,7 @@ import {
} from "@/hooks/use-checkout-diff-query"; } from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "./diff-scroll"; import { DiffScroll } from "./diff-scroll";
import { import {
darkHighlightColors, darkHighlightColors,
@@ -51,6 +56,7 @@ import {
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme"; import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -77,13 +83,27 @@ type HighlightStyle = NonNullable<HighlightToken["style"]>;
interface HighlightedTextProps { interface HighlightedTextProps {
tokens: HighlightToken[]; tokens: HighlightToken[];
baseStyle: HighlightStyle | null; wrapLines?: boolean;
lineType: "add" | "remove" | "context" | "header";
} }
function HighlightedText({ tokens, lineType }: HighlightedTextProps) { type WrappedWebTextStyle = TextStyle & {
whiteSpace?: "pre" | "pre-wrap";
overflowWrap?: "normal" | "anywhere";
};
function getWrappedTextStyle(wrapLines: boolean): WrappedWebTextStyle | undefined {
if (Platform.OS !== "web") {
return undefined;
}
return wrapLines
? { whiteSpace: "pre-wrap", overflowWrap: "anywhere" }
: { whiteSpace: "pre", overflowWrap: "normal" };
}
function HighlightedText({ tokens, wrapLines = false }: HighlightedTextProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isDark = theme.colorScheme === "dark"; const isDark = theme.colorScheme === "dark";
const lineHeight = theme.lineHeight.diff;
const getTokenColor = (style: HighlightStyle | null): string => { const getTokenColor = (style: HighlightStyle | null): string => {
const baseColor = isDark ? "#c9d1d9" : "#24292f"; const baseColor = isDark ? "#c9d1d9" : "#24292f";
@@ -93,9 +113,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) {
}; };
return ( return (
<Text style={styles.diffLineText}> <Text
style={[
styles.diffLineText,
{ lineHeight, ...getWrappedTextStyle(wrapLines) },
]}
>
{tokens.map((token, index) => ( {tokens.map((token, index) => (
<Text key={index} style={{ color: getTokenColor(token.style) }}> <Text key={index} style={{ color: getTokenColor(token.style), lineHeight }}>
{token.text} {token.text}
</Text> </Text>
))} ))}
@@ -115,10 +140,12 @@ function DiffLineView({
line, line,
lineNumber, lineNumber,
gutterWidth, gutterWidth,
wrapLines,
}: { }: {
line: DiffLine; line: DiffLine;
lineNumber: number | null; lineNumber: number | null;
gutterWidth: number; gutterWidth: number;
wrapLines: boolean;
}) { }) {
return ( return (
<View <View
@@ -142,11 +169,12 @@ function DiffLineView({
</Text> </Text>
</View> </View>
{line.tokens && line.type !== "header" ? ( {line.tokens && line.type !== "header" ? (
<HighlightedText tokens={line.tokens} baseStyle={null} lineType={line.type} /> <HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : ( ) : (
<Text <Text
style={[ style={[
styles.diffLineText, styles.diffLineText,
getWrappedTextStyle(wrapLines),
line.type === "add" && styles.addLineText, line.type === "add" && styles.addLineText,
line.type === "remove" && styles.removeLineText, line.type === "remove" && styles.removeLineText,
line.type === "header" && styles.headerLineText, line.type === "header" && styles.headerLineText,
@@ -160,6 +188,59 @@ function DiffLineView({
); );
} }
function SplitDiffCell({
line,
gutterWidth,
wrapLines,
showDivider = false,
}: {
line: SplitDiffDisplayLine | null;
gutterWidth: number;
wrapLines: boolean;
showDivider?: boolean;
}) {
return (
<View
style={[
styles.splitCell,
showDivider && styles.splitCellWithDivider,
!line && styles.emptySplitCell,
line?.type === "add" && styles.addLineContainer,
line?.type === "remove" && styles.removeLineContainer,
line?.type === "context" && styles.contextLineContainer,
]}
>
<View style={[styles.lineNumberGutter, { width: gutterWidth }]}>
<Text
style={[
styles.lineNumberText,
line?.type === "add" && styles.addLineNumberText,
line?.type === "remove" && styles.removeLineNumberText,
]}
>
{line?.lineNumber != null ? String(line.lineNumber) : ""}
</Text>
</View>
{line?.tokens ? (
<HighlightedText tokens={line.tokens} wrapLines={wrapLines} />
) : (
<Text
style={[
styles.diffLineText,
getWrappedTextStyle(wrapLines) ,
line?.type === "add" && styles.addLineText,
line?.type === "remove" && styles.removeLineText,
line?.type === "context" && styles.contextLineText,
!line && styles.emptySplitCellText,
]}
>
{line?.content ?? ""}
</Text>
)}
</View>
);
}
const DiffFileHeader = memo(function DiffFileHeader({ const DiffFileHeader = memo(function DiffFileHeader({
file, file,
isExpanded, isExpanded,
@@ -245,21 +326,25 @@ const DiffFileHeader = memo(function DiffFileHeader({
function DiffFileBody({ function DiffFileBody({
file, file,
layout,
wrapLines, wrapLines,
onBodyHeightChange, onBodyHeightChange,
testID, testID,
}: { }: {
file: ParsedDiffFile; file: ParsedDiffFile;
layout: "unified" | "split";
wrapLines: boolean; wrapLines: boolean;
onBodyHeightChange?: (path: string, height: number) => void; onBodyHeightChange?: (path: string, height: number) => void;
testID?: string; testID?: string;
}) { }) {
const [scrollViewWidth, setScrollViewWidth] = useState(0); const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [bodyWidth, setBodyWidth] = useState(0);
return ( return (
<View <View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]} style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => { onLayout={(event) => {
setBodyWidth(event.nativeEvent.layout.width);
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}} }}
testID={testID} testID={testID}
@@ -275,50 +360,80 @@ function DiffFileBody({
); );
} }
const linesContent = (() => { let maxLineNo = 0;
let maxLineNo = 0; for (const hunk of file.hunks) {
for (const hunk of file.hunks) { maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount);
maxLineNo = Math.max( }
maxLineNo, const gutterWidth = lineNumberGutterWidth(maxLineNo);
hunk.oldStart + hunk.oldCount,
hunk.newStart + hunk.newCount, const linesContent =
); layout === "split"
} ? buildSplitDiffRows(file).map((row, rowIndex) => {
const gutterWidth = lineNumberGutterWidth(maxLineNo); if (row.kind === "header") {
return file.hunks.map((hunk, hunkIndex) => { return (
let oldLineNo = hunk.oldStart; <View key={`header-${rowIndex}`} style={styles.splitHeaderRow}>
let newLineNo = hunk.newStart; <Text style={[styles.diffLineText, styles.headerLineText]}>{row.content}</Text>
return hunk.lines.map((line, lineIndex) => { </View>
let lineNumber: number | null = null; );
if (line.type === "remove") { }
lineNumber = oldLineNo;
oldLineNo++; return (
} else if (line.type === "add") { <View key={`pair-${rowIndex}`} style={styles.splitRow}>
lineNumber = newLineNo; <SplitDiffCell line={row.left} gutterWidth={gutterWidth} wrapLines={wrapLines} />
newLineNo++; <SplitDiffCell
} else if (line.type === "context") { line={row.right}
lineNumber = newLineNo; gutterWidth={gutterWidth}
oldLineNo++; wrapLines={wrapLines}
newLineNo++; showDivider
} />
return ( </View>
<DiffLineView );
key={`${hunkIndex}-${lineIndex}`} })
line={line} : file.hunks.map((hunk, hunkIndex) => {
lineNumber={lineNumber} let oldLineNo = hunk.oldStart;
gutterWidth={gutterWidth} let newLineNo = hunk.newStart;
/> return hunk.lines.map((line, lineIndex) => {
); let lineNumber: number | null = null;
}); if (line.type === "remove") {
}); lineNumber = oldLineNo;
})(); oldLineNo++;
} else if (line.type === "add") {
lineNumber = newLineNo;
newLineNo++;
} else if (line.type === "context") {
lineNumber = newLineNo;
oldLineNo++;
newLineNo++;
}
return (
<DiffLineView
key={`${hunkIndex}-${lineIndex}`}
line={line}
lineNumber={lineNumber}
gutterWidth={gutterWidth}
wrapLines={wrapLines}
/>
);
});
});
const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth;
const contentContainer = (
<View
style={[
layout === "split" ? styles.splitLinesContainer : styles.linesContainer,
availableWidth > 0 &&
(layout === "split"
? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth }
: { minWidth: availableWidth }),
]}
>
{linesContent}
</View>
);
if (wrapLines) { if (wrapLines) {
return ( return <View style={styles.diffContent}>{contentContainer}</View>;
<View style={styles.diffContent}>
<View style={styles.linesContainer}>{linesContent}</View>
</View>
);
} }
return ( return (
@@ -328,11 +443,7 @@ function DiffFileBody({
style={styles.diffContent} style={styles.diffContent}
contentContainerStyle={styles.diffContentInner} contentContainerStyle={styles.diffContentInner}
> >
<View {contentContainer}
style={[styles.linesContainer, scrollViewWidth > 0 && { minWidth: scrollViewWidth }]}
>
{linesContent}
</View>
</DiffScroll> </DiffScroll>
); );
})()} })()}
@@ -355,26 +466,31 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
const canUseSplitLayout = Platform.OS === "web" && !isMobile;
const router = useRouter(); const router = useRouter();
const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const [wrapLines, setWrapLines] = useState(false); const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
useEffect(() => { const wrapLines = changesPreferences.wrapLines;
AsyncStorage.getItem("diff-wrap-lines").then((value) => { const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
if (value === "true") setWrapLines(true);
});
}, []);
const handleToggleWrapLines = useCallback(() => { const handleToggleWrapLines = useCallback(() => {
setWrapLines((prev) => { void updateChangesPreferences({ wrapLines: !wrapLines });
const next = !prev; }, [updateChangesPreferences, wrapLines]);
AsyncStorage.setItem("diff-wrap-lines", String(next));
return next; const handleLayoutChange = useCallback(
}); (nextLayout: "unified" | "split") => {
}, []); void updateChangesPreferences({ layout: nextLayout });
},
[updateChangesPreferences],
);
const handleToggleHideWhitespace = useCallback(() => {
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
const { const {
status, status,
@@ -410,6 +526,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
cwd, cwd,
mode: diffMode, mode: diffMode,
baseRef, baseRef,
ignoreWhitespace: changesPreferences.hideWhitespace,
enabled: isGit, enabled: isGit,
}); });
const { const {
@@ -726,13 +843,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
return ( return (
<DiffFileBody <DiffFileBody
file={item.file} file={item.file}
layout={effectiveLayout}
wrapLines={wrapLines} wrapLines={wrapLines}
onBodyHeightChange={handleBodyHeightChange} onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`} testID={`diff-file-${item.fileIndex}-body`}
/> />
); );
}, },
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines], [
effectiveLayout,
handleBodyHeightChange,
handleHeaderHeightChange,
handleToggleExpanded,
wrapLines,
],
); );
const flatKeyExtractor = useCallback( const flatKeyExtractor = useCallback(
@@ -821,7 +945,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
bodyContent = ( bodyContent = (
<View style={styles.emptyContainer}> <View style={styles.emptyContainer}>
<Text style={styles.emptyText}> <Text style={styles.emptyText}>
{diffMode === "uncommitted" ? "No uncommitted changes" : `No changes vs ${baseRefLabel}`} {changesPreferences.hideWhitespace
? "No visible changes after hiding whitespace"
: diffMode === "uncommitted"
? "No uncommitted changes"
: `No changes vs ${baseRefLabel}`}
</Text> </Text>
</View> </View>
); );
@@ -833,7 +961,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
renderItem={renderFlatItem} renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor} keyExtractor={flatKeyExtractor}
stickyHeaderIndices={stickyHeaderIndices} stickyHeaderIndices={stickyHeaderIndices}
extraData={expandedByPath} extraData={{ expandedByPath, effectiveLayout, wrapLines }}
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
testID="git-diff-scroll" testID="git-diff-scroll"
@@ -1019,56 +1147,149 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{files.length > 0 ? ( <View style={styles.diffStatusButtons}>
<View style={styles.diffStatusButtons}> {canUseSplitLayout ? (
<Tooltip delayDuration={300}> <View style={styles.toggleButtonGroup}>
<TooltipTrigger asChild> <Tooltip delayDuration={300}>
<Pressable <TooltipTrigger asChild>
style={({ hovered, pressed }) => [ <Pressable
styles.expandAllButton, accessibilityRole="button"
(hovered || pressed) && styles.diffStatusRowHovered, accessibilityLabel="Unified diff"
]} testID="changes-layout-unified"
onPress={handleToggleWrapLines} onPress={() => handleLayoutChange("unified")}
> style={({ hovered, pressed }) => [
<WrapText size={isMobile ? 18 : 14} color={theme.colors.foregroundMuted} /> styles.toggleButton,
</Pressable> styles.toggleButtonGroupStart,
</TooltipTrigger> changesPreferences.layout === "unified" && styles.toggleButtonSelected,
<TooltipContent side="bottom"> (hovered || pressed) && styles.diffStatusRowHovered,
<Text style={styles.tooltipText}> ]}
{wrapLines ? "Scroll long lines" : "Wrap long lines"} >
</Text> <AlignJustify
</TooltipContent> size={14}
</Tooltip> color={
<Tooltip delayDuration={300}> changesPreferences.layout === "unified"
<TooltipTrigger asChild> ? theme.colors.foreground
<Pressable : theme.colors.foregroundMuted
style={({ hovered, pressed }) => [ }
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/> />
) : ( </Pressable>
<ListChevronsUpDown </TooltipTrigger>
size={isMobile ? 18 : 14} <TooltipContent side="bottom">
color={theme.colors.foregroundMuted} <Text style={styles.tooltipText}>Unified diff</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Side-by-side diff"
testID="changes-layout-split"
onPress={() => handleLayoutChange("split")}
style={({ hovered, pressed }) => [
styles.toggleButton,
styles.toggleButtonGroupEnd,
changesPreferences.layout === "split" && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
>
<Columns2
size={14}
color={
changesPreferences.layout === "split"
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/> />
)} </Pressable>
</Pressable> </TooltipTrigger>
</TooltipTrigger> <TooltipContent side="bottom">
<TooltipContent side="bottom"> <Text style={styles.tooltipText}>Side-by-side diff</Text>
<Text style={styles.tooltipText}> </TooltipContent>
{allExpanded ? "Collapse all files" : "Expand all files"} </Tooltip>
</Text> </View>
</TooltipContent> ) : null}
</Tooltip> <Tooltip delayDuration={300}>
</View> <TooltipTrigger asChild>
) : null} <Pressable
accessibilityRole="button"
accessibilityLabel="Hide whitespace"
testID="changes-toggle-whitespace"
style={({ hovered, pressed }) => [
styles.expandAllButton,
changesPreferences.hideWhitespace && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleHideWhitespace}
>
<Pilcrow
size={isMobile ? 18 : 14}
color={
changesPreferences.hideWhitespace
? theme.colors.foreground
: theme.colors.foregroundMuted
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Hide whitespace</Text>
</TooltipContent>
</Tooltip>
{files.length > 0 ? (
<View style={styles.diffStatusButtons}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
wrapLines && styles.toggleButtonSelected,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleWrapLines}
>
<WrapText
size={isMobile ? 18 : 14}
color={wrapLines ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{wrapLines ? "Scroll long lines" : "Wrap long lines"}
</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
style={({ hovered, pressed }) => [
styles.expandAllButton,
(hovered || pressed) && styles.diffStatusRowHovered,
]}
onPress={handleToggleExpandAll}
>
{allExpanded ? (
<ListChevronsDownUp
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
) : (
<ListChevronsUpDown
size={isMobile ? 18 : 14}
color={theme.colors.foregroundMuted}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>
{allExpanded ? "Collapse all files" : "Expand all files"}
</Text>
</TooltipContent>
</Tooltip>
</View>
) : null}
</View>
</View> </View>
</View> </View>
) : null} ) : null}
@@ -1165,6 +1386,43 @@ const styles = StyleSheet.create((theme) => ({
sm: theme.spacing[1], sm: theme.spacing[1],
md: 0, md: 0,
}, },
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.base,
overflow: "hidden",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 28,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
backgroundColor: theme.colors.surface1,
},
toggleButtonGroupStart: {
borderRightWidth: 1,
borderRightColor: theme.colors.border,
},
toggleButtonGroupEnd: {},
toggleButtonSelected: {
backgroundColor: theme.colors.surface3,
}, },
expandAllButton: { expandAllButton: {
flexDirection: "row", flexDirection: "row",
@@ -1343,6 +1601,36 @@ const styles = StyleSheet.create((theme) => ({
linesContainer: { linesContainer: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
}, },
splitLinesContainer: {
backgroundColor: theme.colors.surface1,
minWidth: 760,
},
splitRow: {
flexDirection: "row",
alignItems: "stretch",
width: "100%",
minWidth: "100%",
},
splitHeaderRow: {
backgroundColor: theme.colors.surface2,
paddingHorizontal: theme.spacing[3],
},
splitCell: {
flex: 1,
flexBasis: 0,
minWidth: 0,
flexDirection: "row",
alignItems: "stretch",
overflow: "hidden",
backgroundColor: theme.colors.surface2,
},
emptySplitCell: {
backgroundColor: theme.colors.surfaceDiffEmpty,
},
splitCellWithDivider: {
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.border,
},
diffLineContainer: { diffLineContainer: {
flexDirection: "row", flexDirection: "row",
alignItems: "stretch", alignItems: "stretch",
@@ -1352,13 +1640,13 @@ const styles = StyleSheet.create((theme) => ({
borderRightColor: theme.colors.border, borderRightColor: theme.colors.border,
marginRight: theme.spacing[2], marginRight: theme.spacing[2],
alignSelf: "stretch", alignSelf: "stretch",
justifyContent: "center", justifyContent: "flex-start",
}, },
lineNumberText: { lineNumberText: {
textAlign: "right", textAlign: "right",
paddingRight: theme.spacing[2], paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
userSelect: "none", userSelect: "none",
@@ -1372,8 +1660,8 @@ const styles = StyleSheet.create((theme) => ({
diffLineText: { diffLineText: {
flex: 1, flex: 1,
paddingRight: theme.spacing[3], paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[1],
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
lineHeight: theme.lineHeight.diff,
fontFamily: Fonts.mono, fontFamily: Fonts.mono,
color: theme.colors.foreground, color: theme.colors.foreground,
userSelect: "text", userSelect: "text",
@@ -1402,6 +1690,9 @@ const styles = StyleSheet.create((theme) => ({
contextLineText: { contextLineText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
emptySplitCellText: {
color: "transparent",
},
statusMessageContainer: { statusMessageContainer: {
borderTopWidth: theme.borderWidth[1], borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border, borderTopColor: theme.colors.border,
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("use-changes-preferences", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("defaults to unified layout with visible whitespace", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:changes-preferences",
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
);
});
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "diff-wrap-lines") {
return "true";
}
return null;
});
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "unified",
wrapLines: true,
hideWhitespace: false,
});
});
it("loads persisted layout and whitespace preferences", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:changes-preferences") {
return JSON.stringify({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
}
return null;
});
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,89 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
wrapLines: false,
hideWhitespace: false,
};
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
if (legacyValue === "true") {
return true;
}
if (legacyValue === "false") {
return false;
}
return null;
}
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
if (stored) {
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
if (parsed.success) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
}
}
const legacyWrapLines = await loadLegacyWrapLinesPreference();
const next = {
...DEFAULT_CHANGES_PREFERENCES,
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
} satisfies ChangesPreferences;
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
return next;
}
export interface UseChangesPreferencesReturn {
preferences: ChangesPreferences;
isLoading: boolean;
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
}
export function useChangesPreferences(): UseChangesPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
queryFn: loadChangesPreferencesFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferences = useCallback(
async (updates: Partial<ChangesPreferences>) => {
const prev =
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
DEFAULT_CHANGES_PREFERENCES;
const next = { ...prev, ...updates };
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
return {
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
isLoading: isPending,
updatePreferences,
};
}
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
cwd: string, cwd: string,
mode: "uncommitted" | "base", mode: "uncommitted" | "base",
baseRef?: string, baseRef?: string,
ignoreWhitespace?: boolean,
) { ) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const; return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
} }
interface UseCheckoutDiffQueryOptions { interface UseCheckoutDiffQueryOptions {
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
cwd: string; cwd: string;
mode: "uncommitted" | "base"; mode: "uncommitted" | "base";
baseRef?: string; baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean; enabled?: boolean;
} }
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
function normalizeCheckoutDiffCompare(compare: { function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base"; mode: "uncommitted" | "base";
baseRef?: string; baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } { ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") { if (compare.mode === "uncommitted") {
return { mode: "uncommitted" }; return { mode: "uncommitted", ignoreWhitespace };
} }
const trimmedBaseRef = compare.baseRef?.trim(); const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" }; return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
} }
export function useCheckoutDiffQuery({ export function useCheckoutDiffQuery({
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
cwd, cwd,
mode, mode,
baseRef, baseRef,
ignoreWhitespace,
enabled = true, enabled = true,
}: UseCheckoutDiffQueryOptions) { }: UseCheckoutDiffQueryOptions) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const hookInstanceId = useId(); const hookInstanceId = useId();
const normalizedCompare = useMemo( const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef }), () => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef], [mode, baseRef, ignoreWhitespace],
); );
const compareMode = normalizedCompare.mode; const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef; const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo( const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef), () => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef], [serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
); );
const query = useQuery({ const query = useQuery({
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
const payload = await client.getCheckoutDiff(cwd, { const payload = await client.getCheckoutDiff(cwd, {
mode: compareMode, mode: compareMode,
baseRef: compareBaseRef, baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
}); });
return { return {
...payload, ...payload,
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
cwd, cwd,
compareMode, compareMode,
compareBaseRef ?? "", compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":"); ].join(":");
let cancelled = false; let cancelled = false;
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
{ {
mode: compareMode, mode: compareMode,
baseRef: compareBaseRef, baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
}, },
{ subscriptionId }, { subscriptionId },
) )
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
serverId, serverId,
compareMode, compareMode,
compareBaseRef, compareBaseRef,
compareIgnoreWhitespace,
queryKey, queryKey,
queryClient, queryClient,
]); ]);
+6
View File
@@ -111,6 +111,7 @@ const lightSemanticColors = {
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100) surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200) surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300) surface4: "#d4d4d8", // Extra emphasis (was zinc-400, now zinc-300)
surfaceDiffEmpty: "#f6f6f6", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main) surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
surfaceWorkspace: "#ffffff", // Workspace main background surfaceWorkspace: "#ffffff", // Workspace main background
@@ -185,6 +186,7 @@ const darkSemanticColors = {
surface2: "#272A29", // Elevated: badges, inputs, sheets surface2: "#272A29", // Elevated: badges, inputs, sheets
surface3: "#434645", // Highest elevation surface3: "#434645", // Highest elevation
surface4: "#595B5B", // Extra emphasis surface4: "#595B5B", // Extra emphasis
surfaceDiffEmpty: "#252827", // Empty side of split diff rows, between surface1 and surface2 and biased toward surface2
surfaceSidebar: "#141716", // Sidebar background (darker than main) surfaceSidebar: "#141716", // Sidebar background (darker than main)
surfaceWorkspace: "#1E2120", // Workspace main background (surface1) surfaceWorkspace: "#1E2120", // Workspace main background (surface1)
@@ -279,6 +281,10 @@ const commonTheme = {
"4xl": 34, "4xl": 34,
}, },
lineHeight: {
diff: 22,
},
iconSize: { iconSize: {
xs: 12, xs: 12,
sm: 14, sm: 14,
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { buildSplitDiffRows } from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
function makeFile(lines: ParsedDiffFile["hunks"][number]["lines"]): ParsedDiffFile {
return {
path: "example.ts",
isNew: false,
isDeleted: false,
additions: lines.filter((line) => line.type === "add").length,
deletions: lines.filter((line) => line.type === "remove").length,
status: "ok",
hunks: [
{
oldStart: 10,
oldCount: 4,
newStart: 10,
newCount: 5,
lines,
},
],
};
}
describe("buildSplitDiffRows", () => {
it("pairs replacement runs by index", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,2 +10,2 @@" },
{ type: "remove", content: "before one" },
{ type: "remove", content: "before two" },
{ type: "add", content: "after one" },
{ type: "add", content: "after two" },
]),
);
expect(rows).toHaveLength(3);
expect(rows[1]).toMatchObject({
kind: "pair",
left: { type: "remove", content: "before one", lineNumber: 10 },
right: { type: "add", content: "after one", lineNumber: 10 },
});
expect(rows[2]).toMatchObject({
kind: "pair",
left: { type: "remove", content: "before two", lineNumber: 11 },
right: { type: "add", content: "after two", lineNumber: 11 },
});
});
it("keeps unmatched additions on the right side only", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,1 +10,2 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after one" },
{ type: "add", content: "after two" },
]),
);
expect(rows[2]).toMatchObject({
kind: "pair",
left: null,
right: { type: "add", content: "after two", lineNumber: 11 },
});
});
it("duplicates context rows on both sides", () => {
const rows = buildSplitDiffRows(
makeFile([
{ type: "header", content: "@@ -10,1 +10,1 @@" },
{ type: "context", content: "same line" },
]),
);
expect(rows[1]).toMatchObject({
kind: "pair",
left: { type: "context", content: "same line", lineNumber: 10 },
right: { type: "context", content: "same line", lineNumber: 10 },
});
});
});
+147
View File
@@ -0,0 +1,147 @@
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
export interface SplitDiffDisplayLine {
type: DiffLine["type"];
content: string;
tokens?: DiffLine["tokens"];
lineNumber: number | null;
}
export type SplitDiffRow =
| {
kind: "header";
content: string;
}
| {
kind: "pair";
left: SplitDiffDisplayLine | null;
right: SplitDiffDisplayLine | null;
};
function toDisplayLine(input: {
line: DiffLine;
oldLineNumber: number | null;
newLineNumber: number | null;
side: "left" | "right";
}): SplitDiffDisplayLine | null {
const { line, oldLineNumber, newLineNumber, side } = input;
if (line.type === "header") {
return null;
}
if (line.type === "remove") {
if (side !== "left") {
return null;
}
return {
type: "remove",
content: line.content,
tokens: line.tokens,
lineNumber: oldLineNumber,
};
}
if (line.type === "add") {
if (side !== "right") {
return null;
}
return {
type: "add",
content: line.content,
tokens: line.tokens,
lineNumber: newLineNumber,
};
}
return {
type: "context",
content: line.content,
tokens: line.tokens,
lineNumber: side === "left" ? oldLineNumber : newLineNumber,
};
}
export function buildSplitDiffRows(file: ParsedDiffFile): SplitDiffRow[] {
const rows: SplitDiffRow[] = [];
for (const hunk of file.hunks) {
let oldLineNo = hunk.oldStart;
let newLineNo = hunk.newStart;
rows.push({
kind: "header",
content: hunk.lines[0]?.type === "header" ? hunk.lines[0].content : "@@",
});
let pendingRemovals: Array<{ line: DiffLine; oldLineNumber: number }> = [];
let pendingAdditions: Array<{ line: DiffLine; newLineNumber: number }> = [];
const flushPendingRows = () => {
const pairCount = Math.max(pendingRemovals.length, pendingAdditions.length);
for (let index = 0; index < pairCount; index += 1) {
const removal = pendingRemovals[index] ?? null;
const addition = pendingAdditions[index] ?? null;
rows.push({
kind: "pair",
left: removal
? toDisplayLine({
line: removal.line,
oldLineNumber: removal.oldLineNumber,
newLineNumber: null,
side: "left",
})
: null,
right: addition
? toDisplayLine({
line: addition.line,
oldLineNumber: null,
newLineNumber: addition.newLineNumber,
side: "right",
})
: null,
});
}
pendingRemovals = [];
pendingAdditions = [];
};
for (const line of hunk.lines.slice(1)) {
if (line.type === "remove") {
pendingRemovals.push({ line, oldLineNumber: oldLineNo });
oldLineNo += 1;
continue;
}
if (line.type === "add") {
pendingAdditions.push({ line, newLineNumber: newLineNo });
newLineNo += 1;
continue;
}
flushPendingRows();
if (line.type === "context") {
rows.push({
kind: "pair",
left: toDisplayLine({
line,
oldLineNumber: oldLineNo,
newLineNumber: newLineNo,
side: "left",
}),
right: toDisplayLine({
line,
oldLineNumber: oldLineNo,
newLineNumber: newLineNo,
side: "right",
}),
});
oldLineNo += 1;
newLineNo += 1;
}
}
flushPendingRows();
}
return rows;
}
+12 -7
View File
@@ -611,7 +611,10 @@ export class DaemonClient {
private connectionState: ConnectionState = { status: "idle" }; private connectionState: ConnectionState = { status: "idle" };
private checkoutDiffSubscriptions = new Map< private checkoutDiffSubscriptions = new Map<
string, string,
{ cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } {
cwd: string;
compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean };
}
>(); >();
private terminalDirectorySubscriptions = new Set<string>(); private terminalDirectorySubscriptions = new Set<string>();
private terminalSlots = new Map<string, number>(); private terminalSlots = new Map<string, number>();
@@ -2146,20 +2149,22 @@ export class DaemonClient {
private normalizeCheckoutDiffCompare(compare: { private normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base"; mode: "uncommitted" | "base";
baseRef?: string; baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } { ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") { if (compare.mode === "uncommitted") {
return { mode: "uncommitted" }; return { mode: "uncommitted", ignoreWhitespace };
} }
const trimmedBaseRef = compare.baseRef?.trim(); const trimmedBaseRef = compare.baseRef?.trim();
if (!trimmedBaseRef) { if (!trimmedBaseRef) {
return { mode: "base" }; return { mode: "base", ignoreWhitespace };
} }
return { mode: "base", baseRef: trimmedBaseRef }; return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace };
} }
async getCheckoutDiff( async getCheckoutDiff(
cwd: string, cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string }, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
requestId?: string, requestId?: string,
): Promise<CheckoutDiffPayload> { ): Promise<CheckoutDiffPayload> {
const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`;
@@ -2185,7 +2190,7 @@ export class DaemonClient {
async subscribeCheckoutDiff( async subscribeCheckoutDiff(
cwd: string, cwd: string,
compare: { mode: "uncommitted" | "base"; baseRef?: string }, compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean },
options?: { subscriptionId?: string; requestId?: string }, options?: { subscriptionId?: string; requestId?: string },
): Promise<SubscribeCheckoutDiffPayload> { ): Promise<SubscribeCheckoutDiffPayload> {
const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); const subscriptionId = options?.subscriptionId ?? crypto.randomUUID();
@@ -123,11 +123,14 @@ export class CheckoutDiffManager {
} }
private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") { if (compare.mode === "uncommitted") {
return { mode: "uncommitted" }; return { mode: "uncommitted", ignoreWhitespace };
} }
const trimmedBaseRef = compare.baseRef?.trim(); const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" }; return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
} }
private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string { private buildTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
@@ -135,6 +138,7 @@ export class CheckoutDiffManager {
cwd, cwd,
compare.mode, compare.mode,
compare.mode === "base" ? (compare.baseRef ?? "") : "", compare.mode === "base" ? (compare.baseRef ?? "") : "",
compare.ignoreWhitespace === true,
]); ]);
} }
@@ -206,6 +210,7 @@ export class CheckoutDiffManager {
{ {
mode: compare.mode, mode: compare.mode,
baseRef: compare.baseRef, baseRef: compare.baseRef,
ignoreWhitespace: compare.ignoreWhitespace,
includeStructured: true, includeStructured: true,
}, },
{ paseoHome: this.paseoHome }, { paseoHome: this.paseoHome },
+1
View File
@@ -969,6 +969,7 @@ const CheckoutErrorSchema = z.object({
const CheckoutDiffCompareSchema = z.object({ const CheckoutDiffCompareSchema = z.object({
mode: z.enum(["uncommitted", "base"]), mode: z.enum(["uncommitted", "base"]),
baseRef: z.string().optional(), baseRef: z.string().optional(),
ignoreWhitespace: z.boolean().optional(),
}); });
export const CheckoutStatusRequestSchema = z.object({ export const CheckoutStatusRequestSchema = z.object({
@@ -104,6 +104,21 @@ describe("checkout git utilities", () => {
expect(message).toBe("update file"); expect(message).toBe("update file");
}); });
it("hides whitespace-only changes when requested", async () => {
writeFileSync(join(repoDir, "file.txt"), "hello \n");
const visibleDiff = await getCheckoutDiff(repoDir, { mode: "uncommitted" });
expect(visibleDiff.diff).toContain("file.txt");
const hiddenDiff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
ignoreWhitespace: true,
includeStructured: true,
});
expect(hiddenDiff.diff).toBe("");
expect(hiddenDiff.structured).toEqual([]);
});
it("preserves removed-line syntax highlighting with structured diffs", async () => { it("preserves removed-line syntax highlighting with structured diffs", async () => {
const originalContent = `/* const originalContent = `/*
comment line 1 comment line 1
+48 -12
View File
@@ -275,13 +275,25 @@ export async function listBranchSuggestions(
return ordered.slice(0, limit); return ordered.slice(0, limit);
} }
async function listCheckoutFileChanges(cwd: string, ref: string): Promise<CheckoutFileChange[]> { async function listCheckoutFileChanges(
cwd: string,
ref: string,
ignoreWhitespace = false,
): Promise<CheckoutFileChange[]> {
const changes: CheckoutFileChange[] = []; const changes: CheckoutFileChange[] = [];
const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, { const { stdout: nameStatusOut } = await execFileAsync(
cwd, "git",
env: READ_ONLY_GIT_ENV, buildGitDiffArgs({
}); ignoreWhitespace,
extra: ["--name-status", ref],
}),
{
cwd,
env: READ_ONLY_GIT_ENV,
maxBuffer: SMALL_OUTPUT_MAX_BUFFER,
},
);
for (const line of nameStatusOut for (const line of nameStatusOut
.split("\n") .split("\n")
.map((l) => l.trim()) .map((l) => l.trim())
@@ -396,13 +408,24 @@ function normalizeNumstatPath(pathField: string): string {
return pathField; return pathField;
} }
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
}
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
const TRACKED_MAX_CHANGED_LINES = 40_000; const TRACKED_MAX_CHANGED_LINES = 40_000;
async function getTrackedNumstatByPath(cwd: string, ref: string): Promise<Map<string, FileStat>> { async function getTrackedNumstatByPath(
cwd: string,
ref: string,
ignoreWhitespace = false,
): Promise<Map<string, FileStat>> {
const result = await spawnLimitedText({ const result = await spawnLimitedText({
cmd: "git", cmd: "git",
args: ["diff", "--numstat", ref], args: buildGitDiffArgs({
ignoreWhitespace,
extra: ["--numstat", ref],
}),
cwd, cwd,
env: READ_ONLY_GIT_ENV, env: READ_ONLY_GIT_ENV,
maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES,
@@ -578,6 +601,7 @@ export interface CheckoutDiffResult {
export interface CheckoutDiffCompare { export interface CheckoutDiffCompare {
mode: "uncommitted" | "base"; mode: "uncommitted" | "base";
baseRef?: string; baseRef?: string;
ignoreWhitespace?: boolean;
includeStructured?: boolean; includeStructured?: boolean;
} }
@@ -1072,6 +1096,7 @@ function buildPlaceholderParsedDiffFile(
async function getUntrackedDiffText( async function getUntrackedDiffText(
cwd: string, cwd: string,
change: CheckoutFileChange, change: CheckoutFileChange,
ignoreWhitespace = false,
): Promise<{ text: string; truncated: boolean; stat: FileStat }> { ): Promise<{ text: string; truncated: boolean; stat: FileStat }> {
try { try {
const inspected = await inspectUntrackedFile(cwd, change.path); const inspected = await inspectUntrackedFile(cwd, change.path);
@@ -1084,7 +1109,10 @@ async function getUntrackedDiffText(
const result = await spawnLimitedText({ const result = await spawnLimitedText({
cmd: "git", cmd: "git",
args: ["diff", "--no-index", "/dev/null", "--", change.path], args: buildGitDiffArgs({
ignoreWhitespace,
extra: ["--no-index", "/dev/null", "--", change.path],
}),
cwd, cwd,
env: READ_ONLY_GIT_ENV, env: READ_ONLY_GIT_ENV,
maxBytes: PER_FILE_DIFF_MAX_BYTES, maxBytes: PER_FILE_DIFF_MAX_BYTES,
@@ -1299,7 +1327,8 @@ export async function getCheckoutDiff(
refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef; refForDiff = (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef;
} }
const changes = await listCheckoutFileChanges(cwd, refForDiff); const ignoreWhitespace = compare.ignoreWhitespace === true;
const changes = await listCheckoutFileChanges(cwd, refForDiff, ignoreWhitespace);
changes.sort((a, b) => { changes.sort((a, b) => {
if (a.path === b.path) return 0; if (a.path === b.path) return 0;
return a.path < b.path ? -1 : 1; return a.path < b.path ? -1 : 1;
@@ -1330,7 +1359,7 @@ export async function getCheckoutDiff(
const trackedNumstatByPath = const trackedNumstatByPath =
trackedChanges.length > 0 trackedChanges.length > 0
? await getTrackedNumstatByPath(cwd, refForDiff) ? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace)
: new Map<string, FileStat>(); : new Map<string, FileStat>();
const trackedDiffPaths: string[] = []; const trackedDiffPaths: string[] = [];
const trackedPlaceholderByPath = new Map< const trackedPlaceholderByPath = new Map<
@@ -1356,7 +1385,10 @@ export async function getCheckoutDiff(
if (trackedDiffPaths.length > 0) { if (trackedDiffPaths.length > 0) {
const trackedDiffResult = await spawnLimitedText({ const trackedDiffResult = await spawnLimitedText({
cmd: "git", cmd: "git",
args: ["diff", refForDiff, "--", ...trackedDiffPaths], args: buildGitDiffArgs({
ignoreWhitespace,
extra: [refForDiff, "--", ...trackedDiffPaths],
}),
cwd, cwd,
env: READ_ONLY_GIT_ENV, env: READ_ONLY_GIT_ENV,
maxBytes: TOTAL_DIFF_MAX_BYTES, maxBytes: TOTAL_DIFF_MAX_BYTES,
@@ -1445,7 +1477,11 @@ export async function getCheckoutDiff(
if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { if (diffBytes >= TOTAL_DIFF_MAX_BYTES) {
break; break;
} }
const { text, truncated, stat } = await getUntrackedDiffText(cwd, change); const { text, truncated, stat } = await getUntrackedDiffText(
cwd,
change,
ignoreWhitespace,
);
if (!compare.includeStructured) { if (!compare.includeStructured) {
if (stat?.isBinary) { if (stat?.isBinary) {
+13 -13
View File
@@ -23,9 +23,9 @@ import { Route as BlogIndexRouteImport } from './routes/blog/index'
import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees' import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees'
import { Route as DocsVoiceRouteImport } from './routes/docs/voice' import { Route as DocsVoiceRouteImport } from './routes/docs/voice'
import { Route as DocsUpdatesRouteImport } from './routes/docs/updates' import { Route as DocsUpdatesRouteImport } from './routes/docs/updates'
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
import { Route as DocsSecurityRouteImport } from './routes/docs/security' import { Route as DocsSecurityRouteImport } from './routes/docs/security'
import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration' import { Route as DocsConfigurationRouteImport } from './routes/docs/configuration'
import { Route as DocsSkillsRouteImport } from './routes/docs/skills'
import { Route as DocsCliRouteImport } from './routes/docs/cli' import { Route as DocsCliRouteImport } from './routes/docs/cli'
import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices'
import { Route as BlogSplatRouteImport } from './routes/blog/$' import { Route as BlogSplatRouteImport } from './routes/blog/$'
@@ -100,6 +100,11 @@ const DocsUpdatesRoute = DocsUpdatesRouteImport.update({
path: '/updates', path: '/updates',
getParentRoute: () => DocsRoute, getParentRoute: () => DocsRoute,
} as any) } as any)
const DocsSkillsRoute = DocsSkillsRouteImport.update({
id: '/skills',
path: '/skills',
getParentRoute: () => DocsRoute,
} as any)
const DocsSecurityRoute = DocsSecurityRouteImport.update({ const DocsSecurityRoute = DocsSecurityRouteImport.update({
id: '/security', id: '/security',
path: '/security', path: '/security',
@@ -110,11 +115,6 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({
path: '/configuration', path: '/configuration',
getParentRoute: () => DocsRoute, getParentRoute: () => DocsRoute,
} as any) } as any)
const DocsSkillsRoute = DocsSkillsRouteImport.update({
id: '/skills',
path: '/skills',
getParentRoute: () => DocsRoute,
} as any)
const DocsCliRoute = DocsCliRouteImport.update({ const DocsCliRoute = DocsCliRouteImport.update({
id: '/cli', id: '/cli',
path: '/cli', path: '/cli',
@@ -375,6 +375,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsUpdatesRouteImport preLoaderRoute: typeof DocsUpdatesRouteImport
parentRoute: typeof DocsRoute parentRoute: typeof DocsRoute
} }
'/docs/skills': {
id: '/docs/skills'
path: '/skills'
fullPath: '/docs/skills'
preLoaderRoute: typeof DocsSkillsRouteImport
parentRoute: typeof DocsRoute
}
'/docs/security': { '/docs/security': {
id: '/docs/security' id: '/docs/security'
path: '/security' path: '/security'
@@ -389,13 +396,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DocsConfigurationRouteImport preLoaderRoute: typeof DocsConfigurationRouteImport
parentRoute: typeof DocsRoute parentRoute: typeof DocsRoute
} }
'/docs/skills': {
id: '/docs/skills'
path: '/skills'
fullPath: '/docs/skills'
preLoaderRoute: typeof DocsSkillsRouteImport
parentRoute: typeof DocsRoute
}
'/docs/cli': { '/docs/cli': {
id: '/docs/cli' id: '/docs/cli'
path: '/cli' path: '/cli'