Restore compact Changes diff controls (#1945)

* feat(app): restore compact Changes diff controls

Keep the folder tree behind a view toggle while preserving the flat file list as the default.

Move secondary diff options into the overflow menu so the Changes toolbar stays compact.

* fix(app): mirror whitespace diff option copy

* fix(app): polish diff controls review follow-up
This commit is contained in:
Mohamed Boudra
2026-07-08 21:20:51 +00:00
committed by GitHub
parent c7406a415d
commit 29a3261dd6
14 changed files with 348 additions and 303 deletions
+44 -1
View File
@@ -218,6 +218,38 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click();
await expectFlatFileList(page);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
@@ -266,13 +298,24 @@ async function useUnwrappedDiffLines(page: Page): Promise<void> {
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({ layout: "unified", wrapLines: false, hideWhitespace: false }),
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {
@@ -22,6 +22,7 @@ describe("buildDiffFlatItems", () => {
it("emits a body and a sticky index for each expanded file", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files: [createFile("a.ts"), createFile("b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["a.ts"]),
});
@@ -32,9 +33,23 @@ describe("buildDiffFlatItems", () => {
expect(items[0].type).toBe("header");
});
it("emits the old flat list without folder rows or indentation", () => {
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "flat",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
expect(summarize(items)).toEqual(["a.ts", "body:a.ts", "b.ts"]);
expect(stickyHeaderIndices).toEqual([0]);
expect(items.every((item) => item.type !== "folder")).toBe(true);
});
it("groups files under compressed folder rows, all expanded by default", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
@@ -45,6 +60,7 @@ describe("buildDiffFlatItems", () => {
it("collapsing a folder hides its descendants but keeps the row", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app/nested"]),
expandedPaths: new Set(),
});
@@ -54,6 +70,7 @@ describe("buildDiffFlatItems", () => {
it("collapsing an ancestor hides everything below it", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(["src/app"]),
expandedPaths: new Set(),
});
@@ -65,6 +82,7 @@ describe("buildDiffFlatItems", () => {
// header at index 3, with the body right after.
const { items, stickyHeaderIndices } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(["src/app/a.ts"]),
});
@@ -84,6 +102,7 @@ describe("buildDiffFlatItems", () => {
it("maps tree file rows back to their original files array index", () => {
const { items } = buildDiffFlatItems({
files,
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
});
@@ -96,6 +115,7 @@ describe("buildDiffFlatItems", () => {
describe("sumHeightsBefore", () => {
const items = buildDiffFlatItems({
files: [createFile("src/a.ts"), createFile("src/b.ts")],
viewMode: "tree",
collapsedFolders: new Set(),
expandedPaths: new Set(),
}).items; // [folder, a.ts, b.ts]
+9
View File
@@ -30,6 +30,7 @@ export interface DiffFlatItemsResult {
export interface BuildDiffFlatItemsInput {
files: ParsedDiffFile[];
viewMode: "flat" | "tree";
/** Full uncompressed directory paths that are collapsed (empty = all expanded). */
collapsedFolders: ReadonlySet<string>;
/** File paths whose diff body is expanded. */
@@ -52,6 +53,7 @@ export interface BuildDiffFlatItemsInput {
*/
export function buildDiffFlatItems({
files,
viewMode,
collapsedFolders,
expandedPaths,
tree,
@@ -68,6 +70,13 @@ export function buildDiffFlatItems({
}
};
if (viewMode === "flat") {
for (const [fileIndex, file] of files.entries()) {
pushFile(file, fileIndex, 0);
}
return { items, stickyHeaderIndices };
}
const indexByPath = new Map(files.map((file, index) => [file.path, index]));
const compressedTree = tree ?? compressSingleChildChains(buildDiffTree(files));
const rows = flattenDiffTree(compressedTree, collapsedFolders);
+214 -300
View File
@@ -39,6 +39,7 @@ import {
FolderTree,
GitCommitHorizontal,
GitMerge,
List,
ListChevronsDownUp,
ListChevronsUpDown,
Pilcrow,
@@ -1219,7 +1220,6 @@ type PressableStyleFn = (
state: PressableStateCallbackType & { hovered?: boolean; open?: boolean },
) => StyleProp<ViewStyle>;
const foregroundIconColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
@@ -1230,6 +1230,7 @@ const ThemedWrapText = withUnistyles(WrapText);
const ThemedListChevronsDownUp = withUnistyles(ListChevronsDownUp);
const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
const ThemedFolderTree = withUnistyles(FolderTree);
const ThemedList = withUnistyles(List);
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
@@ -1240,101 +1241,89 @@ const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown);
interface DiffLayoutToggleGroupProps {
const DIFF_OPTIONS_WHITESPACE_ICON = (
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
);
const DIFF_OPTIONS_WRAP_ICON = (
<ThemedWrapText size={14} uniProps={foregroundMutedIconColorMapping} />
);
interface DiffLayoutToggleProps {
layout: "unified" | "split";
unifiedToggleStyle: PressableStyleFn;
splitToggleStyle: PressableStyleFn;
onUnified: () => void;
onSplit: () => void;
}
function DiffLayoutToggleGroup({
layout,
unifiedToggleStyle,
splitToggleStyle,
onUnified,
onSplit,
}: DiffLayoutToggleGroupProps) {
const { t } = useTranslation();
const unifiedLabel = t("workspace.git.diff.unified");
const splitLabel = t("workspace.git.diff.split");
return (
<View style={styles.toggleButtonGroup}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={unifiedLabel}
testID="changes-layout-unified"
onPress={onUnified}
style={unifiedToggleStyle}
>
<ThemedAlignJustify
size={14}
uniProps={
layout === "unified" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{unifiedLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={splitLabel}
testID="changes-layout-split"
onPress={onSplit}
style={splitToggleStyle}
>
<ThemedColumns2
size={14}
uniProps={
layout === "split" ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{splitLabel}</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
interface DiffWhitespaceToggleProps {
hideWhitespace: boolean;
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffWhitespaceToggle({
hideWhitespace,
isMobile,
toggleStyle,
onToggle,
}: DiffWhitespaceToggleProps) {
function DiffLayoutToggle({ layout, isMobile, toggleStyle, onToggle }: DiffLayoutToggleProps) {
const { t } = useTranslation();
const label = t("workspace.git.diff.hideWhitespace");
const label =
layout === "unified"
? t("workspace.git.diff.switchToSplit")
: t("workspace.git.diff.switchToUnified");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-whitespace"
testID="changes-toggle-layout"
onPress={onToggle}
style={toggleStyle}
>
{layout === "unified" ? (
<ThemedColumns2 size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedAlignJustify
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{label}</Text>
</TooltipContent>
</Tooltip>
);
}
interface DiffViewModeToggleProps {
viewMode: "flat" | "tree";
isMobile: boolean;
toggleStyle: PressableStyleFn;
onToggle: () => void;
}
function DiffViewModeToggle({
viewMode,
isMobile,
toggleStyle,
onToggle,
}: DiffViewModeToggleProps) {
const { t } = useTranslation();
const label =
viewMode === "flat"
? t("workspace.git.diff.showTreeView")
: t("workspace.git.diff.showFlatView");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={label}
testID="changes-toggle-view-mode"
style={toggleStyle}
onPress={onToggle}
>
<ThemedPilcrow
size={isMobile ? 18 : 14}
uniProps={hideWhitespace ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
{viewMode === "flat" ? (
<ThemedFolderTree
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedList size={isMobile ? 18 : 14} uniProps={foregroundMutedIconColorMapping} />
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
@@ -1345,82 +1334,24 @@ function DiffWhitespaceToggle({
}
interface DiffFilesToolbarProps {
wrapLines: boolean;
// Controls file-DIFF bodies only (not folders). Named explicitly so the
// folder control below stays unambiguous (Codex item 4).
allFileDiffsExpanded: boolean;
showFolderControl: boolean;
allFoldersCollapsed: boolean;
isMobile: boolean;
wrapLinesToggleStyle: PressableStyleFn;
expandAllToggleStyle: PressableStyleFn;
folderToggleStyle: PressableStyleFn;
onToggleWrapLines: () => void;
onToggleExpandAll: () => void;
onToggleCollapseAllFolders: () => void;
}
function DiffFilesToolbar({
wrapLines,
allFileDiffsExpanded,
showFolderControl,
allFoldersCollapsed,
isMobile,
wrapLinesToggleStyle,
expandAllToggleStyle,
folderToggleStyle,
onToggleWrapLines,
onToggleExpandAll,
onToggleCollapseAllFolders,
}: DiffFilesToolbarProps) {
const { t } = useTranslation();
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const expandAllLabel = allFileDiffsExpanded
? t("workspace.git.diff.collapseAll")
: t("workspace.git.diff.expandAll");
const folderLabel = allFoldersCollapsed
? t("workspace.git.diff.expandAllFolders")
: t("workspace.git.diff.collapseAllFolders");
return (
<View style={styles.diffStatusButtons}>
{showFolderControl ? (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={folderLabel}
testID="changes-toggle-folders"
style={folderToggleStyle}
onPress={onToggleCollapseAllFolders}
>
<ThemedFolderTree
size={isMobile ? 18 : 14}
uniProps={
allFoldersCollapsed ? foregroundIconColorMapping : foregroundMutedIconColorMapping
}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{folderLabel}</Text>
</TooltipContent>
</Tooltip>
) : null}
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable style={wrapLinesToggleStyle} onPress={onToggleWrapLines}>
<ThemedWrapText
size={isMobile ? 18 : 14}
uniProps={wrapLines ? foregroundIconColorMapping : foregroundMutedIconColorMapping}
/>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{wrapLinesLabel}</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
@@ -1450,50 +1381,105 @@ function DiffFilesToolbar({
);
}
interface DiffRefreshButtonProps {
interface DiffOptionsMenuProps {
hideWhitespace: boolean;
isMobile: boolean;
isRefreshing: boolean;
toggleStyle: PressableStyleFn;
onPress: () => void;
overflowToggleStyle: PressableStyleFn;
refreshSupported: boolean;
wrapLines: boolean;
onRefresh: () => void;
onToggleHideWhitespace: () => void;
onToggleWrapLines: () => void;
}
function DiffOptionsMenu({
hideWhitespace,
isMobile,
isRefreshing,
overflowToggleStyle,
refreshSupported,
wrapLines,
onRefresh,
onToggleHideWhitespace,
onToggleWrapLines,
}: DiffOptionsMenuProps) {
const { t } = useTranslation();
const whitespaceLabel = hideWhitespace
? t("workspace.git.diff.showWhitespace")
: t("workspace.git.diff.hideWhitespace");
const wrapLinesLabel = wrapLines
? t("workspace.git.diff.scrollLongLines")
: t("workspace.git.diff.wrapLongLines");
const optionsLabel = t("workspace.git.diff.options");
const refreshIcon = useMemo(
() =>
isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
),
[isRefreshing],
);
return (
<DropdownMenu>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<DropdownMenuTrigger
accessibilityRole="button"
accessibilityLabel={optionsLabel}
testID="changes-options-menu"
style={overflowToggleStyle}
>
<ThemedChevronDown
size={isMobile ? 18 : 14}
uniProps={foregroundMutedIconColorMapping}
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{optionsLabel}</Text>
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" width={240} testID="changes-options-menu-content">
<DropdownMenuItem
leading={DIFF_OPTIONS_WHITESPACE_ICON}
selected={hideWhitespace}
testID="changes-toggle-whitespace"
onSelect={onToggleHideWhitespace}
>
{whitespaceLabel}
</DropdownMenuItem>
<DropdownMenuItem
leading={DIFF_OPTIONS_WRAP_ICON}
selected={wrapLines}
testID="changes-toggle-wrap-lines"
onSelect={onToggleWrapLines}
>
{wrapLinesLabel}
</DropdownMenuItem>
{refreshSupported ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
leading={refreshIcon}
disabled={isRefreshing}
testID="changes-refresh"
onSelect={onRefresh}
>
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) {
const { t } = useTranslation();
const refreshLabel = t("workspace.git.diff.refresh");
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={
isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refreshState")
}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner
size={ICON_SIZE.sm}
uniProps={foregroundMutedIconColorMapping}
/>
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>{refreshLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
type DiffFlatItemLayoutGetter = NonNullable<FlatListProps<DiffFlatItem>["getItemLayout"]>;
function getUnifiedDiffLineCount(file: ParsedDiffFile): number {
@@ -1756,30 +1742,22 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
const viewMode = changesPreferences.viewMode;
const effectiveLayout = canUseSplitLayout ? changesPreferences.layout : "unified";
const handleToggleWrapLines = useCallback(() => {
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 handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
}, [handleLayoutChange]);
const handleLayoutSplit = useCallback(() => {
handleLayoutChange("split");
}, [handleLayoutChange]);
const handleToggleLayout = useCallback(() => {
void updateChangesPreferences({
layout: changesPreferences.layout === "unified" ? "split" : "unified",
});
}, [changesPreferences.layout, updateChangesPreferences]);
const codeFontSize = appSettings.codeFontSize;
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
@@ -1796,39 +1774,19 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}, [appSettings.monoFontFamily, codeFontSize, diffBodyLineHeight]);
const diffModeTriggerStyle = useMemo(() => buildDiffModeTriggerStyle(), []);
const unifiedToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "unified", [
styles.toggleButton,
styles.toggleButtonGroupStart,
]),
[changesPreferences.layout],
const layoutToggleStyle = useMemo(
() => buildToggleButtonStyle(false, styles.expandAllButton),
[],
);
const splitToggleStyle = useMemo(
() =>
buildToggleButtonStyle(changesPreferences.layout === "split", [
styles.toggleButton,
styles.toggleButtonGroupEnd,
]),
[changesPreferences.layout],
);
const hideWhitespaceToggleStyle = useMemo(
() => buildToggleButtonStyle(changesPreferences.hideWhitespace, styles.expandAllButton),
[changesPreferences.hideWhitespace],
);
const wrapLinesToggleStyle = useMemo(
() => buildToggleButtonStyle(wrapLines, styles.expandAllButton),
[wrapLines],
const viewModeToggleStyle = useMemo(
() => buildToggleButtonStyle(viewMode === "tree", styles.expandAllButton),
[viewMode],
);
const expandAllToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const folderToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const refreshToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const overflowToggleStyle = useMemo(() => buildExpandAllButtonStyle(), []);
const toast = useToast();
const refreshSupported = useSessionStore(
@@ -1995,6 +1953,16 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
[collapsedFoldersArray, allFolderPathSet],
);
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const handleToggleViewMode = useCallback(() => {
const nextViewMode = viewMode === "flat" ? "tree" : "flat";
if (nextViewMode === "tree") {
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
if (workspaceStateKey) {
setDiffCollapsedFoldersForWorkspace(workspaceStateKey, []);
}
}
void updateChangesPreferences({ viewMode: nextViewMode });
}, [setDiffCollapsedFoldersForWorkspace, updateChangesPreferences, viewMode, workspaceStateKey]);
const scrollbar = useWebScrollViewScrollbar(diffListRef, {
enabled: showDesktopWebScrollbar,
});
@@ -2012,12 +1980,13 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const { items, stickyHeaderIndices: stickyIndices } = buildDiffFlatItems({
files,
viewMode,
tree: compressedTree,
collapsedFolders,
expandedPaths,
});
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [compressedTree, collapsedFolders, expandedPaths, files]);
}, [compressedTree, collapsedFolders, expandedPaths, files, viewMode]);
const getBodyHeightKey = useCallback(
(file: ParsedDiffFile): string => {
@@ -2248,25 +2217,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
}
}, [allFileDiffsExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Membership, not count: collapsedFolders is already filtered to present folders, so
// "all collapsed" means every present folder path is in it.
const allFoldersCollapsed =
allFolderPaths.length > 0 && allFolderPaths.every((path) => collapsedFolders.has(path));
const handleToggleCollapseAllFolders = useCallback(() => {
if (!workspaceStateKey) {
return;
}
// Anchor to the top before collapsing everything so the viewport isn't stranded.
if (!allFoldersCollapsed) {
diffListRef.current?.scrollToOffset({ offset: 0, animated: false });
}
setDiffCollapsedFoldersForWorkspace(
workspaceStateKey,
allFoldersCollapsed ? [] : allFolderPaths,
);
}, [allFolderPaths, allFoldersCollapsed, setDiffCollapsedFoldersForWorkspace, workspaceStateKey]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "folder") {
@@ -2290,7 +2240,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
file={item.file}
isExpanded={item.isExpanded}
depth={item.depth}
showDir={false}
showDir={viewMode === "flat"}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
@@ -2320,6 +2270,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
handleToggleExpanded,
handleToggleFolder,
reviewActions,
viewMode,
wrapLines,
],
);
@@ -2347,6 +2298,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
}),
@@ -2356,6 +2308,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
effectiveLayout,
diffBodyTypographyKey,
heightVersion,
viewMode,
wrapLines,
reviewActions,
],
@@ -2485,42 +2438,40 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
</DropdownMenu>
<View style={styles.diffStatusButtons}>
{canUseSplitLayout ? (
<DiffLayoutToggleGroup
<DiffLayoutToggle
layout={changesPreferences.layout}
unifiedToggleStyle={unifiedToggleStyle}
splitToggleStyle={splitToggleStyle}
onUnified={handleLayoutUnified}
onSplit={handleLayoutSplit}
isMobile={isMobile}
toggleStyle={layoutToggleStyle}
onToggle={handleToggleLayout}
/>
) : null}
{files.length > 0 ? (
<DiffViewModeToggle
viewMode={viewMode}
isMobile={isMobile}
toggleStyle={viewModeToggleStyle}
onToggle={handleToggleViewMode}
/>
) : null}
<DiffWhitespaceToggle
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
toggleStyle={hideWhitespaceToggleStyle}
onToggle={handleToggleHideWhitespace}
/>
{files.length > 0 ? (
<DiffFilesToolbar
wrapLines={wrapLines}
allFileDiffsExpanded={allFileDiffsExpanded}
showFolderControl={allFolderPaths.length > 0}
allFoldersCollapsed={allFoldersCollapsed}
isMobile={isMobile}
wrapLinesToggleStyle={wrapLinesToggleStyle}
expandAllToggleStyle={expandAllToggleStyle}
folderToggleStyle={folderToggleStyle}
onToggleWrapLines={handleToggleWrapLines}
onToggleExpandAll={handleToggleExpandAll}
onToggleCollapseAllFolders={handleToggleCollapseAllFolders}
/>
) : null}
{refreshSupported ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
<DiffOptionsMenu
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
overflowToggleStyle={overflowToggleStyle}
refreshSupported={refreshSupported}
wrapLines={wrapLines}
onRefresh={handleRefresh}
onToggleHideWhitespace={handleToggleHideWhitespace}
onToggleWrapLines={handleToggleWrapLines}
/>
</View>
</View>
</View>
@@ -2602,46 +2553,9 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexWrap: "wrap",
},
toggleButtonGroup: {
flexDirection: "row",
alignItems: "center",
},
toggleButton: {
alignItems: "center",
justifyContent: "center",
minWidth: {
xs: 32,
sm: 32,
md: 24,
},
height: {
xs: 32,
sm: 32,
md: 24,
},
paddingHorizontal: {
xs: theme.spacing[2],
sm: theme.spacing[2],
md: theme.spacing[1],
},
},
toggleButtonGroupStart: {
borderTopLeftRadius: theme.borderRadius.base,
borderBottomLeftRadius: theme.borderRadius.base,
},
toggleButtonGroupEnd: {
borderTopRightRadius: theme.borderRadius.base,
borderBottomRightRadius: theme.borderRadius.base,
},
toggleButtonSelected: {
backgroundColor: theme.colors.surface2,
},
refreshIcon: {
width: ICON_SIZE.md,
height: ICON_SIZE.md,
alignItems: "center",
justifyContent: "center",
},
expandAllButton: {
flexDirection: "row",
alignItems: "center",
@@ -28,6 +28,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({
layout: "unified",
viewMode: "flat",
wrapLines: true,
hideWhitespace: false,
});
@@ -37,6 +38,7 @@ describe("loadChangesPreferencesFromStorage", () => {
it("loads persisted layout and whitespace preferences without rewriting storage", async () => {
const persisted = JSON.stringify({
layout: "split",
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
});
@@ -48,6 +50,7 @@ describe("loadChangesPreferencesFromStorage", () => {
expect(result).toEqual({
layout: "split",
viewMode: "tree",
hideWhitespace: true,
wrapLines: false,
});
@@ -64,11 +67,16 @@ describe("saveChangesPreferences", () => {
await saveChangesPreferences({
queryClient,
updates: { layout: "split", hideWhitespace: true },
updates: { layout: "split", viewMode: "tree", hideWhitespace: true },
storage,
});
const expected = { ...DEFAULT_CHANGES_PREFERENCES, layout: "split", hideWhitespace: true };
const expected = {
...DEFAULT_CHANGES_PREFERENCES,
layout: "split",
viewMode: "tree",
hideWhitespace: true,
};
expect(queryClient.getQueryData(CHANGES_PREFERENCES_QUERY_KEY)).toEqual(expected);
expect(storage.entries.get(CHANGES_PREFERENCES_STORAGE_KEY)).toBe(JSON.stringify(expected));
});
@@ -7,18 +7,21 @@ export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
viewMode: "flat" | "tree";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
};
+6
View File
@@ -707,7 +707,13 @@ export const ar: TranslationResources = {
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
unified: "الفرق الموحدة",
split: "فرق جنبًا إلى جنب",
switchToUnified: "التبديل إلى الفرق الموحد",
switchToSplit: "التبديل إلى الفرق جنبًا إلى جنب",
showTreeView: "إظهار شجرة المجلدات",
showFlatView: "إظهار قائمة الملفات المسطحة",
options: "خيارات الفرق",
hideWhitespace: "إخفاء المسافة البيضاء",
showWhitespace: "إظهار المسافة البيضاء",
scrollLongLines: "قم بتمرير الخطوط الطويلة",
wrapLongLines: "لف الخطوط الطويلة",
collapseAll: "طي كافة الملفات",
+6
View File
@@ -714,7 +714,13 @@ export const en = {
tooLarge: "Diff too large to display",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "Switch to unified diff",
switchToSplit: "Switch to side-by-side diff",
showTreeView: "Show folder tree",
showFlatView: "Show flat file list",
options: "Diff options",
hideWhitespace: "Hide whitespace",
showWhitespace: "Show whitespace",
scrollLongLines: "Scroll long lines",
wrapLongLines: "Wrap long lines",
collapseAll: "Collapse all files",
+6
View File
@@ -734,7 +734,13 @@ export const es: TranslationResources = {
tooLarge: "La diferencia es demasiado grande para mostrarse",
unified: "Diferencia unificada",
split: "Diferencia de lado a lado",
switchToUnified: "Cambiar a diferencia unificada",
switchToSplit: "Cambiar a diferencia lado a lado",
showTreeView: "Mostrar árbol de carpetas",
showFlatView: "Mostrar lista plana de archivos",
options: "Opciones de diferencia",
hideWhitespace: "Ocultar espacios en blanco",
showWhitespace: "Mostrar espacios en blanco",
scrollLongLines: "Desplazarse por largas filas",
wrapLongLines: "Envolver largas filas",
collapseAll: "Contraer todos los archivos",
+6
View File
@@ -733,7 +733,13 @@ export const fr: TranslationResources = {
tooLarge: "Diff trop grand pour être affiché",
unified: "Différentiel unifié",
split: "Différent côte à côte",
switchToUnified: "Passer au diff unifié",
switchToSplit: "Passer au diff côte à côte",
showTreeView: "Afficher l'arborescence des dossiers",
showFlatView: "Afficher la liste de fichiers à plat",
options: "Options du diff",
hideWhitespace: "Masquer les espaces",
showWhitespace: "Afficher les espaces",
scrollLongLines: "Faire défiler les longues lignes",
wrapLongLines: "Enroulez les longues lignes",
collapseAll: "Réduire tous les fichiers",
+6
View File
@@ -719,7 +719,13 @@ export const ja: TranslationResources = {
tooLarge: "差分が大きすぎて表示できません",
unified: "ユニファイド差分",
split: "左右比較",
switchToUnified: "ユニファイド差分に切り替え",
switchToSplit: "左右比較差分に切り替え",
showTreeView: "フォルダツリーを表示",
showFlatView: "フラットなファイル一覧を表示",
options: "差分オプション",
hideWhitespace: "空白を非表示",
showWhitespace: "空白を表示",
scrollLongLines: "長い行をスクロール",
wrapLongLines: "長い行を折り返す",
collapseAll: "すべて折りたたむ",
+6
View File
@@ -725,7 +725,13 @@ export const ptBR: TranslationResources = {
tooLarge: "Diff grande demais para exibir",
unified: "Diff unificado",
split: "Diff lado a lado",
switchToUnified: "Alternar para diff unificado",
switchToSplit: "Alternar para diff lado a lado",
showTreeView: "Mostrar árvore de pastas",
showFlatView: "Mostrar lista plana de arquivos",
options: "Opções do diff",
hideWhitespace: "Ocultar espaços em branco",
showWhitespace: "Mostrar espaços em branco",
scrollLongLines: "Rolar linhas longas",
wrapLongLines: "Quebrar linhas longas",
collapseAll: "Recolher todos os arquivos",
+6
View File
@@ -726,7 +726,13 @@ export const ru: TranslationResources = {
tooLarge: "Разница слишком велика для отображения",
unified: "Единый дифференциал",
split: "Параллельная разница",
switchToUnified: "Переключить на объединенный diff",
switchToSplit: "Переключить на diff рядом",
showTreeView: "Показать дерево папок",
showFlatView: "Показать плоский список файлов",
options: "Параметры diff",
hideWhitespace: "Скрыть пробелы",
showWhitespace: "Показать пробелы",
scrollLongLines: "Прокручивать длинные строки",
wrapLongLines: "Перенос длинных строк",
collapseAll: "Свернуть все файлы",
+6
View File
@@ -701,7 +701,13 @@ export const zhCN: TranslationResources = {
tooLarge: "Diff 过大,无法显示",
unified: "Unified diff",
split: "Side-by-side diff",
switchToUnified: "切换到统一差异视图",
switchToSplit: "切换到并排差异视图",
showTreeView: "显示文件夹树",
showFlatView: "显示平铺文件列表",
options: "Diff 选项",
hideWhitespace: "隐藏空白差异",
showWhitespace: "显示空白差异",
scrollLongLines: "滚动长行",
wrapLongLines: "自动换行长行",
collapseAll: "折叠所有文件",