diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 07968af47..3d2348a67 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -18,17 +18,21 @@ import { type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, + TextStyle, } from "react-native"; -import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { + AlignJustify, Archive, ChevronDown, + Columns2, GitBranch, GitCommitHorizontal, GitMerge, ListChevronsDownUp, ListChevronsUpDown, + Pilcrow, RefreshCcw, Upload, WrapText, @@ -42,6 +46,7 @@ import { } from "@/hooks/use-checkout-diff-query"; import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query"; import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query"; +import { useChangesPreferences } from "@/hooks/use-changes-preferences"; import { DiffScroll } from "./diff-scroll"; import { darkHighlightColors, @@ -51,6 +56,7 @@ import { import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import { Fonts } from "@/constants/theme"; import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll"; +import { buildSplitDiffRows, type SplitDiffDisplayLine } from "@/utils/diff-layout"; import { DropdownMenu, DropdownMenuContent, @@ -77,13 +83,27 @@ type HighlightStyle = NonNullable; interface HighlightedTextProps { tokens: HighlightToken[]; - baseStyle: HighlightStyle | null; - lineType: "add" | "remove" | "context" | "header"; + wrapLines?: boolean; } -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 isDark = theme.colorScheme === "dark"; + const lineHeight = theme.lineHeight.diff; const getTokenColor = (style: HighlightStyle | null): string => { const baseColor = isDark ? "#c9d1d9" : "#24292f"; @@ -93,9 +113,14 @@ function HighlightedText({ tokens, lineType }: HighlightedTextProps) { }; return ( - + {tokens.map((token, index) => ( - + {token.text} ))} @@ -115,10 +140,12 @@ function DiffLineView({ line, lineNumber, gutterWidth, + wrapLines, }: { line: DiffLine; lineNumber: number | null; gutterWidth: number; + wrapLines: boolean; }) { return ( {line.tokens && line.type !== "header" ? ( - + ) : ( + + + {line?.lineNumber != null ? String(line.lineNumber) : ""} + + + {line?.tokens ? ( + + ) : ( + + {line?.content ?? ""} + + )} + + ); +} + const DiffFileHeader = memo(function DiffFileHeader({ file, isExpanded, @@ -245,21 +326,25 @@ const DiffFileHeader = memo(function DiffFileHeader({ function DiffFileBody({ file, + layout, wrapLines, onBodyHeightChange, testID, }: { file: ParsedDiffFile; + layout: "unified" | "split"; wrapLines: boolean; onBodyHeightChange?: (path: string, height: number) => void; testID?: string; }) { const [scrollViewWidth, setScrollViewWidth] = useState(0); + const [bodyWidth, setBodyWidth] = useState(0); return ( { + setBodyWidth(event.nativeEvent.layout.width); onBodyHeightChange?.(file.path, event.nativeEvent.layout.height); }} testID={testID} @@ -275,50 +360,80 @@ function DiffFileBody({ ); } - const linesContent = (() => { - let maxLineNo = 0; - for (const hunk of file.hunks) { - maxLineNo = Math.max( - maxLineNo, - hunk.oldStart + hunk.oldCount, - hunk.newStart + hunk.newCount, - ); - } - const gutterWidth = lineNumberGutterWidth(maxLineNo); - return file.hunks.map((hunk, hunkIndex) => { - let oldLineNo = hunk.oldStart; - 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 ( - - ); - }); - }); - })(); + let maxLineNo = 0; + for (const hunk of file.hunks) { + maxLineNo = Math.max(maxLineNo, hunk.oldStart + hunk.oldCount, hunk.newStart + hunk.newCount); + } + const gutterWidth = lineNumberGutterWidth(maxLineNo); + + const linesContent = + layout === "split" + ? buildSplitDiffRows(file).map((row, rowIndex) => { + if (row.kind === "header") { + return ( + + {row.content} + + ); + } + + return ( + + + + + ); + }) + : file.hunks.map((hunk, hunkIndex) => { + let oldLineNo = hunk.oldStart; + 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 ( + + ); + }); + }); + + const availableWidth = bodyWidth > 0 ? bodyWidth : scrollViewWidth; + const contentContainer = ( + 0 && + (layout === "split" + ? { width: availableWidth, minWidth: availableWidth, maxWidth: availableWidth } + : { minWidth: availableWidth }), + ]} + > + {linesContent} + + ); if (wrapLines) { - return ( - - {linesContent} - - ); + return {contentContainer}; } return ( @@ -328,11 +443,7 @@ function DiffFileBody({ style={styles.diffContent} contentContainerStyle={styles.diffContentInner} > - 0 && { minWidth: scrollViewWidth }]} - > - {linesContent} - + {contentContainer} ); })()} @@ -355,26 +466,31 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile; + const canUseSplitLayout = Platform.OS === "web" && !isMobile; const router = useRouter(); const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); - const [wrapLines, setWrapLines] = useState(false); - - useEffect(() => { - AsyncStorage.getItem("diff-wrap-lines").then((value) => { - if (value === "true") setWrapLines(true); - }); - }, []); + const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } = + useChangesPreferences(); + const wrapLines = changesPreferences.wrapLines; + const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified"; const handleToggleWrapLines = useCallback(() => { - setWrapLines((prev) => { - const next = !prev; - AsyncStorage.setItem("diff-wrap-lines", String(next)); - return next; - }); - }, []); + void updateChangesPreferences({ wrapLines: !wrapLines }); + }, [updateChangesPreferences, wrapLines]); + + const handleLayoutChange = useCallback( + (nextLayout: "unified" | "split") => { + void updateChangesPreferences({ layout: nextLayout }); + }, + [updateChangesPreferences], + ); + + const handleToggleHideWhitespace = useCallback(() => { + void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace }); + }, [changesPreferences.hideWhitespace, updateChangesPreferences]); const { status, @@ -410,6 +526,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi cwd, mode: diffMode, baseRef, + ignoreWhitespace: changesPreferences.hideWhitespace, enabled: isGit, }); const { @@ -726,13 +843,20 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi return ( ); }, - [handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded, wrapLines], + [ + effectiveLayout, + handleBodyHeightChange, + handleHeaderHeightChange, + handleToggleExpanded, + wrapLines, + ], ); const flatKeyExtractor = useCallback( @@ -821,7 +945,11 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi bodyContent = ( - {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}`} ); @@ -833,7 +961,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi renderItem={renderFlatItem} keyExtractor={flatKeyExtractor} stickyHeaderIndices={stickyHeaderIndices} - extraData={expandedByPath} + extraData={{ expandedByPath, effectiveLayout, wrapLines }} style={styles.scrollView} contentContainerStyle={styles.contentContainer} testID="git-diff-scroll" @@ -1019,56 +1147,149 @@ export function GitDiffPane({ serverId, workspaceId, cwd, hideHeaderRow }: GitDi - {files.length > 0 ? ( - - - - [ - styles.expandAllButton, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} - onPress={handleToggleWrapLines} - > - - - - - - {wrapLines ? "Scroll long lines" : "Wrap long lines"} - - - - - - [ - styles.expandAllButton, - (hovered || pressed) && styles.diffStatusRowHovered, - ]} - onPress={handleToggleExpandAll} - > - {allExpanded ? ( - + {canUseSplitLayout ? ( + + + + handleLayoutChange("unified")} + style={({ hovered, pressed }) => [ + styles.toggleButton, + styles.toggleButtonGroupStart, + changesPreferences.layout === "unified" && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + > + - ) : ( - + + + Unified diff + + + + + handleLayoutChange("split")} + style={({ hovered, pressed }) => [ + styles.toggleButton, + styles.toggleButtonGroupEnd, + changesPreferences.layout === "split" && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + > + - )} - - - - - {allExpanded ? "Collapse all files" : "Expand all files"} - - - - - ) : null} + + + + Side-by-side diff + + + + ) : null} + + + [ + styles.expandAllButton, + changesPreferences.hideWhitespace && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + onPress={handleToggleHideWhitespace} + > + + + + + Hide whitespace + + + {files.length > 0 ? ( + + + + [ + styles.expandAllButton, + wrapLines && styles.toggleButtonSelected, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + onPress={handleToggleWrapLines} + > + + + + + + {wrapLines ? "Scroll long lines" : "Wrap long lines"} + + + + + + [ + styles.expandAllButton, + (hovered || pressed) && styles.diffStatusRowHovered, + ]} + onPress={handleToggleExpandAll} + > + {allExpanded ? ( + + ) : ( + + )} + + + + + {allExpanded ? "Collapse all files" : "Expand all files"} + + + + + ) : null} + ) : null} @@ -1165,6 +1386,43 @@ const styles = StyleSheet.create((theme) => ({ sm: theme.spacing[1], 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: { flexDirection: "row", @@ -1343,6 +1601,36 @@ const styles = StyleSheet.create((theme) => ({ linesContainer: { 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: { flexDirection: "row", alignItems: "stretch", @@ -1352,13 +1640,13 @@ const styles = StyleSheet.create((theme) => ({ borderRightColor: theme.colors.border, marginRight: theme.spacing[2], alignSelf: "stretch", - justifyContent: "center", + justifyContent: "flex-start", }, lineNumberText: { textAlign: "right", paddingRight: theme.spacing[2], - paddingVertical: theme.spacing[1], fontSize: theme.fontSize.xs, + lineHeight: theme.lineHeight.diff, fontFamily: Fonts.mono, color: theme.colors.foregroundMuted, userSelect: "none", @@ -1372,8 +1660,8 @@ const styles = StyleSheet.create((theme) => ({ diffLineText: { flex: 1, paddingRight: theme.spacing[3], - paddingVertical: theme.spacing[1], fontSize: theme.fontSize.xs, + lineHeight: theme.lineHeight.diff, fontFamily: Fonts.mono, color: theme.colors.foreground, userSelect: "text", @@ -1402,6 +1690,9 @@ const styles = StyleSheet.create((theme) => ({ contextLineText: { color: theme.colors.foregroundMuted, }, + emptySplitCellText: { + color: "transparent", + }, statusMessageContainer: { borderTopWidth: theme.borderWidth[1], borderTopColor: theme.colors.border, diff --git a/packages/app/src/hooks/use-changes-preferences.test.ts b/packages/app/src/hooks/use-changes-preferences.test.ts new file mode 100644 index 000000000..04451a73d --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const asyncStorageMock = vi.hoisted(() => ({ + getItem: vi.fn<(_: string) => Promise>(), + setItem: vi.fn<(_: string, __: string) => Promise>(), +})); + +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(); + }); +}); diff --git a/packages/app/src/hooks/use-changes-preferences.ts b/packages/app/src/hooks/use-changes-preferences.ts new file mode 100644 index 000000000..cfc6ee57b --- /dev/null +++ b/packages/app/src/hooks/use-changes-preferences.ts @@ -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 { + 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 { + 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) => Promise; +} + +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) => { + const prev = + queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY) ?? + DEFAULT_CHANGES_PREFERENCES; + const next = { ...prev, ...updates }; + queryClient.setQueryData(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, + }; +} diff --git a/packages/app/src/hooks/use-checkout-diff-query.ts b/packages/app/src/hooks/use-checkout-diff-query.ts index 874809dca..e563a8220 100644 --- a/packages/app/src/hooks/use-checkout-diff-query.ts +++ b/packages/app/src/hooks/use-checkout-diff-query.ts @@ -13,8 +13,9 @@ function checkoutDiffQueryKey( cwd: string, mode: "uncommitted" | "base", baseRef?: string, + ignoreWhitespace?: boolean, ) { - return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const; + return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const; } interface UseCheckoutDiffQueryOptions { @@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions { cwd: string; mode: "uncommitted" | "base"; baseRef?: string; + ignoreWhitespace?: boolean; enabled?: boolean; } @@ -35,12 +37,16 @@ export type HighlightToken = NonNullable[number]; function normalizeCheckoutDiffCompare(compare: { mode: "uncommitted" | "base"; 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") { - return { mode: "uncommitted" }; + return { mode: "uncommitted", ignoreWhitespace }; } 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({ @@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({ cwd, mode, baseRef, + ignoreWhitespace, enabled = true, }: UseCheckoutDiffQueryOptions) { const queryClient = useQueryClient(); @@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({ const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const hookInstanceId = useId(); const normalizedCompare = useMemo( - () => normalizeCheckoutDiffCompare({ mode, baseRef }), - [mode, baseRef], + () => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }), + [mode, baseRef, ignoreWhitespace], ); const compareMode = normalizedCompare.mode; const compareBaseRef = normalizedCompare.baseRef; + const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace; const queryKey = useMemo( - () => checkoutDiffQueryKey(serverId, cwd, mode, baseRef), - [serverId, cwd, mode, baseRef], + () => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace), + [serverId, cwd, mode, baseRef, compareIgnoreWhitespace], ); const query = useQuery({ @@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({ const payload = await client.getCheckoutDiff(cwd, { mode: compareMode, baseRef: compareBaseRef, + ignoreWhitespace: compareIgnoreWhitespace, }); return { ...payload, @@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({ cwd, compareMode, compareBaseRef ?? "", + compareIgnoreWhitespace ? "ignore-ws" : "keep-ws", ].join(":"); let cancelled = false; @@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({ { mode: compareMode, baseRef: compareBaseRef, + ignoreWhitespace: compareIgnoreWhitespace, }, { subscriptionId }, ) @@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({ serverId, compareMode, compareBaseRef, + compareIgnoreWhitespace, queryKey, queryClient, ]); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 027f0eca9..1c72d8b59 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -111,6 +111,7 @@ const lightSemanticColors = { surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100) surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200) 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) surfaceWorkspace: "#ffffff", // Workspace main background @@ -185,6 +186,7 @@ const darkSemanticColors = { surface2: "#272A29", // Elevated: badges, inputs, sheets surface3: "#434645", // Highest elevation 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) surfaceWorkspace: "#1E2120", // Workspace main background (surface1) @@ -279,6 +281,10 @@ const commonTheme = { "4xl": 34, }, + lineHeight: { + diff: 22, + }, + iconSize: { xs: 12, sm: 14, diff --git a/packages/app/src/utils/diff-layout.test.ts b/packages/app/src/utils/diff-layout.test.ts new file mode 100644 index 000000000..c5e756d81 --- /dev/null +++ b/packages/app/src/utils/diff-layout.test.ts @@ -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 }, + }); + }); +}); diff --git a/packages/app/src/utils/diff-layout.ts b/packages/app/src/utils/diff-layout.ts new file mode 100644 index 000000000..b5746eab3 --- /dev/null +++ b/packages/app/src/utils/diff-layout.ts @@ -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; +} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 942319554..e2f85e46c 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -611,7 +611,10 @@ export class DaemonClient { private connectionState: ConnectionState = { status: "idle" }; private checkoutDiffSubscriptions = new Map< string, - { cwd: string; compare: { mode: "uncommitted" | "base"; baseRef?: string } } + { + cwd: string; + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }; + } >(); private terminalDirectorySubscriptions = new Set(); private terminalSlots = new Map(); @@ -2146,20 +2149,22 @@ export class DaemonClient { private normalizeCheckoutDiffCompare(compare: { mode: "uncommitted" | "base"; 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") { - return { mode: "uncommitted" }; + return { mode: "uncommitted", ignoreWhitespace }; } const trimmedBaseRef = compare.baseRef?.trim(); if (!trimmedBaseRef) { - return { mode: "base" }; + return { mode: "base", ignoreWhitespace }; } - return { mode: "base", baseRef: trimmedBaseRef }; + return { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }; } async getCheckoutDiff( cwd: string, - compare: { mode: "uncommitted" | "base"; baseRef?: string }, + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, requestId?: string, ): Promise { const oneShotSubscriptionId = `oneshot-checkout-diff:${crypto.randomUUID()}`; @@ -2185,7 +2190,7 @@ export class DaemonClient { async subscribeCheckoutDiff( cwd: string, - compare: { mode: "uncommitted" | "base"; baseRef?: string }, + compare: { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean }, options?: { subscriptionId?: string; requestId?: string }, ): Promise { const subscriptionId = options?.subscriptionId ?? crypto.randomUUID(); diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index cd6372133..8a5246cd0 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -123,11 +123,14 @@ export class CheckoutDiffManager { } private normalizeCompare(compare: CheckoutDiffCompareInput): CheckoutDiffCompareInput { + const ignoreWhitespace = compare.ignoreWhitespace === true; if (compare.mode === "uncommitted") { - return { mode: "uncommitted" }; + return { mode: "uncommitted", ignoreWhitespace }; } 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 { @@ -135,6 +138,7 @@ export class CheckoutDiffManager { cwd, compare.mode, compare.mode === "base" ? (compare.baseRef ?? "") : "", + compare.ignoreWhitespace === true, ]); } @@ -206,6 +210,7 @@ export class CheckoutDiffManager { { mode: compare.mode, baseRef: compare.baseRef, + ignoreWhitespace: compare.ignoreWhitespace, includeStructured: true, }, { paseoHome: this.paseoHome }, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 156ce6510..6804bb8ef 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -969,6 +969,7 @@ const CheckoutErrorSchema = z.object({ const CheckoutDiffCompareSchema = z.object({ mode: z.enum(["uncommitted", "base"]), baseRef: z.string().optional(), + ignoreWhitespace: z.boolean().optional(), }); export const CheckoutStatusRequestSchema = z.object({ diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index cdd7f8ff0..40a8ebbe3 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -104,6 +104,21 @@ describe("checkout git utilities", () => { 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 () => { const originalContent = `/* comment line 1 diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 3745731ca..37c50ea2c 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -275,13 +275,25 @@ export async function listBranchSuggestions( return ordered.slice(0, limit); } -async function listCheckoutFileChanges(cwd: string, ref: string): Promise { +async function listCheckoutFileChanges( + cwd: string, + ref: string, + ignoreWhitespace = false, +): Promise { const changes: CheckoutFileChange[] = []; - const { stdout: nameStatusOut } = await execGit(`git diff --name-status ${ref}`, { - cwd, - env: READ_ONLY_GIT_ENV, - }); + const { stdout: nameStatusOut } = await execFileAsync( + "git", + buildGitDiffArgs({ + ignoreWhitespace, + extra: ["--name-status", ref], + }), + { + cwd, + env: READ_ONLY_GIT_ENV, + maxBuffer: SMALL_OUTPUT_MAX_BUFFER, + }, + ); for (const line of nameStatusOut .split("\n") .map((l) => l.trim()) @@ -396,13 +408,24 @@ function normalizeNumstatPath(pathField: string): string { 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_MAX_CHANGED_LINES = 40_000; -async function getTrackedNumstatByPath(cwd: string, ref: string): Promise> { +async function getTrackedNumstatByPath( + cwd: string, + ref: string, + ignoreWhitespace = false, +): Promise> { const result = await spawnLimitedText({ cmd: "git", - args: ["diff", "--numstat", ref], + args: buildGitDiffArgs({ + ignoreWhitespace, + extra: ["--numstat", ref], + }), cwd, env: READ_ONLY_GIT_ENV, maxBytes: TRACKED_DIFF_NUMSTAT_MAX_BYTES, @@ -578,6 +601,7 @@ export interface CheckoutDiffResult { export interface CheckoutDiffCompare { mode: "uncommitted" | "base"; baseRef?: string; + ignoreWhitespace?: boolean; includeStructured?: boolean; } @@ -1072,6 +1096,7 @@ function buildPlaceholderParsedDiffFile( async function getUntrackedDiffText( cwd: string, change: CheckoutFileChange, + ignoreWhitespace = false, ): Promise<{ text: string; truncated: boolean; stat: FileStat }> { try { const inspected = await inspectUntrackedFile(cwd, change.path); @@ -1084,7 +1109,10 @@ async function getUntrackedDiffText( const result = await spawnLimitedText({ cmd: "git", - args: ["diff", "--no-index", "/dev/null", "--", change.path], + args: buildGitDiffArgs({ + ignoreWhitespace, + extra: ["--no-index", "/dev/null", "--", change.path], + }), cwd, env: READ_ONLY_GIT_ENV, maxBytes: PER_FILE_DIFF_MAX_BYTES, @@ -1299,7 +1327,8 @@ export async function getCheckoutDiff( 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) => { if (a.path === b.path) return 0; return a.path < b.path ? -1 : 1; @@ -1330,7 +1359,7 @@ export async function getCheckoutDiff( const trackedNumstatByPath = trackedChanges.length > 0 - ? await getTrackedNumstatByPath(cwd, refForDiff) + ? await getTrackedNumstatByPath(cwd, refForDiff, ignoreWhitespace) : new Map(); const trackedDiffPaths: string[] = []; const trackedPlaceholderByPath = new Map< @@ -1356,7 +1385,10 @@ export async function getCheckoutDiff( if (trackedDiffPaths.length > 0) { const trackedDiffResult = await spawnLimitedText({ cmd: "git", - args: ["diff", refForDiff, "--", ...trackedDiffPaths], + args: buildGitDiffArgs({ + ignoreWhitespace, + extra: [refForDiff, "--", ...trackedDiffPaths], + }), cwd, env: READ_ONLY_GIT_ENV, maxBytes: TOTAL_DIFF_MAX_BYTES, @@ -1445,7 +1477,11 @@ export async function getCheckoutDiff( if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { break; } - const { text, truncated, stat } = await getUntrackedDiffText(cwd, change); + const { text, truncated, stat } = await getUntrackedDiffText( + cwd, + change, + ignoreWhitespace, + ); if (!compare.includeStructured) { if (stat?.isBinary) { diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts index 506dbef54..037ca9c97 100644 --- a/packages/website/src/routeTree.gen.ts +++ b/packages/website/src/routeTree.gen.ts @@ -23,9 +23,9 @@ import { Route as BlogIndexRouteImport } from './routes/blog/index' import { Route as DocsWorktreesRouteImport } from './routes/docs/worktrees' import { Route as DocsVoiceRouteImport } from './routes/docs/voice' 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 DocsConfigurationRouteImport } from './routes/docs/configuration' -import { Route as DocsSkillsRouteImport } from './routes/docs/skills' import { Route as DocsCliRouteImport } from './routes/docs/cli' import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' import { Route as BlogSplatRouteImport } from './routes/blog/$' @@ -100,6 +100,11 @@ const DocsUpdatesRoute = DocsUpdatesRouteImport.update({ path: '/updates', getParentRoute: () => DocsRoute, } as any) +const DocsSkillsRoute = DocsSkillsRouteImport.update({ + id: '/skills', + path: '/skills', + getParentRoute: () => DocsRoute, +} as any) const DocsSecurityRoute = DocsSecurityRouteImport.update({ id: '/security', path: '/security', @@ -110,11 +115,6 @@ const DocsConfigurationRoute = DocsConfigurationRouteImport.update({ path: '/configuration', getParentRoute: () => DocsRoute, } as any) -const DocsSkillsRoute = DocsSkillsRouteImport.update({ - id: '/skills', - path: '/skills', - getParentRoute: () => DocsRoute, -} as any) const DocsCliRoute = DocsCliRouteImport.update({ id: '/cli', path: '/cli', @@ -375,6 +375,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsUpdatesRouteImport parentRoute: typeof DocsRoute } + '/docs/skills': { + id: '/docs/skills' + path: '/skills' + fullPath: '/docs/skills' + preLoaderRoute: typeof DocsSkillsRouteImport + parentRoute: typeof DocsRoute + } '/docs/security': { id: '/docs/security' path: '/security' @@ -389,13 +396,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DocsConfigurationRouteImport parentRoute: typeof DocsRoute } - '/docs/skills': { - id: '/docs/skills' - path: '/skills' - fullPath: '/docs/skills' - preLoaderRoute: typeof DocsSkillsRouteImport - parentRoute: typeof DocsRoute - } '/docs/cli': { id: '/docs/cli' path: '/cli'