feat: add side-by-side diff layout and whitespace toggle (#208)
This commit is contained in:
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user