feat(app): give compact Changes an overview and a straight rail (#4861)

* feat(app): give compact Changes an overview and a straight rail

Three fixes to the Explorer's Changes tab on phones, where there is no
tree rail and the diff is the only view of what changed.

No placeholder stat while the sidebar slides in. `useCheckoutDiffQuery`
reported `isLoading` only when the query was enabled and connected, so an
inactive retained panel settled on `files: []`. The comparison toolbar
summed that into "+0 -0" and the body rendered "No changes" for a diff
nobody had fetched yet, both replaced a moment later by real numbers. The
result now derives from the payload alone: no payload means loading,
whatever the query is doing. Callers already handle loading correctly, so
nothing downstream needed a special case.

Jump to file. A floating action button over the diff opens a sheet with
the same changed-files tree the desktop rail renders; tapping a file
closes the sheet and scrolls the diff there. `ChangedFilesTree` moved out
of `diff-pane.tsx` into its own module so both surfaces own one tree, and
its list is now the sheet-aware one so it scrolls with the sheet gesture
on native. `DiffDocument` takes a `contentInsetBottom` so the button
never traps the last line.

Rails on the compact header. The tab labels sat at 20dp and the close X
at 16dp while every toolbar row below them used 12dp leading and 8dp
trailing. The close button was also a hand-rolled Pressable rather than a
ToolbarButton. `paneContentToolbarTrailingPadding` now takes whether the
row's last control is a bare glyph or a framed one: a glyph's hitbox
overhangs its ink so the row pads by less than the rail, but the compact
git-actions trigger paints its own frame and was landing flush against
the screen edge. The commits header chevron moved to the depth-0 tree
rail. Desktop geometry is unchanged.

* fix(app): preserve Explorer touch targets and reset file overview

* fix(app): preserve diff selections across repaints

Header typography refreshes and height resizes changed the repaint callback
and inadvertently cleared active source selections. Keep selection updates
stable while retaining explicit source and selection-layout invalidation.

* test(app): express diff copy regression as user actions

* test(app): express file overview flows as user actions
This commit is contained in:
Mohamed Boudra
2026-09-15 00:30:35 +02:00
committed by GitHub
parent 0aca3b605a
commit 174055a63c
32 changed files with 858 additions and 244 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ A semantic element used in three or more places is a primitive. One of a kind is
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`). A circle pinned over a scrolling surface is wrong; the floating action is `<FloatingActionButton>` (`packages/app/src/components/ui/floating-action-button.tsx`), one per surface, and the surface reserves `FLOATING_ACTION_BUTTON_CLEARANCE` below its content so the button never traps the last row.
Before adding a new component, read `components/ui/`. The primitive usually exists.
+1 -1
View File
@@ -45,7 +45,7 @@ can be reordered, but the dock cannot be split. Selecting an Explorer tab does n
focus.
Cmd+E shows or hides Explorer without changing its selected view. Compact layouts use the combined
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens.
full-screen Explorer overlay for Changes, Files, and pull requests, and close it after a file opens. Compact Changes has no tree rail; its overview is the Jump to file action (`packages/app/src/git/jump-to-file/`), a sheet over the same changed-files tree the desktop rail renders.
Wide native layouts without pane splits use the same combined content in a resizable inline dock;
opening a file leaves that dock visible. Both presentations keep their selection in the panel store
and reuse the layout store's per-workspace Explorer width. They do not create a second Explorer
+134 -12
View File
@@ -876,6 +876,32 @@ test("compact Changes keeps its actions compact and menu-only", async ({ page })
).toContainText("Scroll long lines");
});
test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true });
await useUnwrappedDiffLines(page);
const explorer = await openCompactChanges(page, workspace);
await openChangedFilesOverview(page);
await test.step("reopening the overview expands folders again", async () => {
await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts");
});
await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => {
await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts");
});
});
test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => {
const committed = await createWorkspaceWithCommittedDiff();
await openWorkspaceChangesSurface(page, committed, 90_000);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
const explorer = await openCompactChanges(page, committed);
await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible();
await selectEmptyUncommittedComparison(page, explorer);
await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0);
});
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
@@ -1159,20 +1185,25 @@ test("canvas diff copies a dragged character selection without opening a review"
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
});
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await useUnwrappedDiffLines(page);
await openSelectionWorkspaceChanges(page, workspace);
test("canvas diff preserves selection through a repaint and copies selections and source lines", async ({
page,
}) => {
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-selection").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
await rightClickFirstChangedLine(page);
await page.getByTestId("diff-source-copy-line").click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
await test.step("copy the selection before resizing", async () => {
await copyDiffSelectionWithKeyboard(page);
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the preserved selection after resizing", async () => {
await resizeDiffViewportHeight(page, 960);
await copyFromChangedLineMenu(page, "Copy");
await expectClipboardText(page, "CDEFGH");
});
await test.step("copy the complete source line", async () => {
await copyFromChangedLineMenu(page, "Copy line");
await expectClipboardText(page, "ABCDEFGHIJ");
});
});
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
@@ -1667,6 +1698,62 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
await expectExpandedMountedTabDiff(page);
}
/** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */
async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise<Locator> {
await page.setViewportSize({ width: 390, height: 844 });
// Reload at the compact size: panel selection starts at the center on a cold
// mount, independently of any desktop sidebar used earlier in the test.
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await page.reload();
await page.getByTestId("workspace-explorer-toggle").first().click();
const changesTab = page.getByTestId("explorer-tab-changes").filter({ visible: true });
await expect(changesTab).toBeVisible({ timeout: 30_000 });
await changesTab.click();
const explorer = page.getByTestId("explorer-content-area").filter({ visible: true });
await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 });
return explorer;
}
async function openChangedFilesOverview(page: Page): Promise<void> {
const jumpToFile = page.getByRole("button", { name: "Jump to file" });
await expect(jumpToFile).toBeVisible();
await jumpToFile.click();
await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file");
}
async function expectOverviewReopensExpanded(
page: Page,
folderName: string,
childFileName: string,
): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
const tree = changesTree(page);
const folder = tree
.getByRole("button")
.filter({ has: page.getByText(folderName, { exact: true }) });
const child = tree.getByText(childFileName, { exact: true });
await expect(folder).toBeVisible();
await expect(child).toBeVisible();
await folder.click();
await expect(child).toHaveCount(0);
await sheet.getByRole("button", { name: "Close", exact: true }).click();
await openChangedFilesOverview(page);
await expect(child).toBeVisible();
}
async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise<void> {
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await changesTree(page).getByText(path.basename(filePath), { exact: true }).click();
await expect(sheet).toHaveCount(0);
await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport();
}
async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise<void> {
await explorer.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click();
await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible();
}
async function openWorkspaceChangesSurface(
page: Page,
workspace: DirtyWorkspace,
@@ -1820,6 +1907,41 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
}
async function openCopyableSelectionDiff(page: Page, content: string): Promise<void> {
const workspace = await createWorkspaceWithExactSelectionDiff(content);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await configureDiffPresentation(page, { layout: "unified", wrapLines: false });
await openSelectionWorkspaceChanges(page, workspace);
}
async function copyDiffSelectionWithKeyboard(page: Page): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await page.keyboard.press("ControlOrMeta+C");
}
async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise<void> {
await page.evaluate(() => navigator.clipboard.writeText(""));
await rightClickFirstChangedLine(page);
const item = page.getByRole("menuitem", { name: action, exact: true });
await expect(item).toBeEnabled();
await item.click();
}
async function expectClipboardText(page: Page, text: string): Promise<void> {
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text);
}
async function resizeDiffViewportHeight(page: Page, height: number): Promise<void> {
const canvas = page.getByTestId("git-diff-canvas");
const previousViewport = page.viewportSize()!;
const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height);
await page.setViewportSize({ width: previousViewport.width, height });
await expect(canvas).toHaveCSS(
"height",
`${previousHeight + height - previousViewport.height}px`,
);
}
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
const canvas = page.getByTestId("git-diff-canvas");
@@ -43,6 +43,11 @@ export { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/ada
// match this padding.
export const SHEET_HORIZONTAL_PADDING_SCALE = 6;
// The header's close button grows outward from its glyph, so the glyph's
// trailing rail is the content inset plus this padding. Rows whose trailing
// glyph should line up with the X must reach the same rail.
export const SHEET_HEADER_CLOSE_PADDING_SCALE = 2;
export interface SheetHeaderSearch {
onChange: (value: string) => void;
onFocus?: () => void;
@@ -128,7 +133,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
padding: theme.spacing[SHEET_HEADER_CLOSE_PADDING_SCALE],
borderRadius: theme.borderRadius.lg,
},
searchRow: {
@@ -4,7 +4,7 @@ import { Gesture } from "react-native-gesture-handler";
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { scheduleOnRN } from "react-native-worklets";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
@@ -20,6 +20,7 @@ import {
HEADER_INNER_HEIGHT,
HEADER_INNER_HEIGHT_MOBILE,
HEADER_TOP_PADDING_MOBILE,
useIsCompactFormFactor,
} from "@/constants/layout";
import { ChangesSurface } from "@/git/diff-pane";
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
@@ -38,9 +39,17 @@ import {
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
SIDEBAR_RESIZE_FAIL_OFFSET,
} from "@/components/sidebar-resize-handle-layout";
import { resolveExplorerSidebarWidth } from "@/components/explorer-sidebar-layout";
import {
EXPLORER_TAB_RAIL_INSET,
explorerSidebarCloseButtonLayout,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
import { ToolbarButton } from "@/components/ui/pane-content-toolbar";
import { mutedIconColorMapping } from "@/components/ui/icon-button-chrome";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
const ThemedX = withUnistyles(X);
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
interface ExplorerSidebarProps {
@@ -279,8 +288,12 @@ function ExplorerTabButton({
testID,
children,
}: ExplorerTabButtonProps) {
const isCompact = useIsCompactFormFactor();
const handlePress = useCallback(() => onTabPress(tab), [onTabPress, tab]);
const tabStyle = useMemo(() => [styles.tab, active && styles.tabActive], [active]);
const tabStyle = useMemo(
() => [styles.tab(isCompact), active && styles.tabActive],
[active, isCompact],
);
const tabTextStyle = useMemo(() => [styles.tabText, active && styles.tabTextActive], [active]);
return (
<Pressable testID={testID} style={tabStyle} onPress={handlePress}>
@@ -315,6 +328,17 @@ function ExplorerSidebarContent({
}: SidebarContentProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const closeButtonLayout = explorerSidebarCloseButtonLayout(isCompact);
const closeButtonStyle = useMemo(
() => ({ width: closeButtonLayout.size, height: closeButtonLayout.size }),
[closeButtonLayout.size],
);
// The close glyph shares the trailing rail with the toolbar rows below it.
const headerRightSectionStyle = useMemo(
() => [styles.headerRightSection, { paddingRight: closeButtonLayout.trailingPadding }],
[closeButtonLayout.trailingPadding],
);
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
serverId,
cwd: workspaceRoot,
@@ -341,14 +365,9 @@ function ExplorerSidebarContent({
return (
<View style={styles.sidebarContent} pointerEvents="auto">
{/* Header with tabs and close button */}
<WindowChromeSafeArea
placement="inline"
horizontalPadding={theme.spacing[2]}
style={styles.header}
testID="explorer-header"
>
<WindowChromeSafeArea placement="inline" style={styles.header} testID="explorer-header">
<TitlebarDragRegion />
<View style={styles.tabsContainer}>
<View style={styles.tabsContainer(isCompact)}>
{isGit && (
<ExplorerTabButton
tab="changes"
@@ -383,24 +402,18 @@ function ExplorerSidebarContent({
</ExplorerTabButton>
)}
</View>
<View style={styles.headerRightSection}>
<Pressable
<View style={headerRightSectionStyle}>
<ToolbarButton
compact={isCompact}
style={closeButtonStyle}
hitSlop={closeButtonLayout.hitSlop}
label={t("workspace.tabs.explorerSidebar.close")}
onPress={onClose}
style={styles.closeButton}
testID="explorer-close"
nativeID="explorer-close"
accessible
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.explorerSidebar.close")}
hitSlop={8}
>
{({ hovered, pressed }) => (
<X
size={18}
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
<ThemedX size={closeButtonLayout.iconSize} uniProps={mutedIconColorMapping} />
</ToolbarButton>
</View>
</WindowChromeSafeArea>
@@ -520,18 +533,21 @@ const styles = StyleSheet.create((theme) => ({
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
},
tabsContainer: {
tabsContainer: (isCompact: boolean) => ({
flexDirection: "row",
gap: theme.spacing[1],
},
tab: {
// With the tab's own horizontal padding this puts the label on the pane's
// leading rail, the same way the desktop tab rail does.
paddingLeft: isCompact ? EXPLORER_TAB_RAIL_INSET : theme.spacing[2],
}),
tab: (isCompact: boolean) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingHorizontal: isCompact ? theme.spacing[2] : theme.spacing[3],
borderRadius: theme.borderRadius.md,
},
}),
tabActive: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
@@ -551,10 +567,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
closeButton: {
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
contentArea: {
flex: 1,
minHeight: 0,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
explorerSidebarCloseButtonLayout,
resolveExplorerSidebarDockSizes,
resolveExplorerSidebarWidth,
} from "@/components/explorer-sidebar-layout";
@@ -20,3 +21,20 @@ describe("Explorer sidebar layout", () => {
expect(resolveExplorerSidebarWidth({ requestedWidth: 600, containerWidth: 750 })).toBe(350);
});
});
describe("Explorer close action", () => {
it("preserves the wide-native touch target and glyph rail", () => {
const layout = explorerSidebarCloseButtonLayout(false);
expect(layout.size).toBe(34);
expect(layout.iconSize).toBe(18);
expect(layout.size + layout.hitSlop * 2).toBe(50);
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(16);
});
it("keeps the compact close glyph on the pane rail", () => {
const layout = explorerSidebarCloseButtonLayout(true);
expect(layout.size).toBe(32);
expect(layout.iconSize).toBe(18);
expect(layout.trailingPadding + (layout.size - layout.iconSize) / 2).toBe(8);
});
});
@@ -1,3 +1,28 @@
import {
paneContentToolbarIconSize,
paneContentToolbarTrailingPadding,
} from "@/components/ui/pane-content-toolbar";
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
export function explorerSidebarCloseButtonLayout(compact: boolean) {
// The inline native dock retains its padded close action and touch slop.
if (!compact) {
return { size: 34, iconSize: 18, hitSlop: 8, trailingPadding: 8 };
}
return {
size: smallIconButtonChromeFrameSize(compact),
iconSize: paneContentToolbarIconSize(compact),
hitSlop: 0,
trailingPadding: paneContentToolbarTrailingPadding(compact, "glyph"),
};
}
/**
* Outer inset of an Explorer tab rail. With each tab's own horizontal padding it puts
* the tab label on the pane's shared leading rail (`treeRowPaddingLeft(0)`).
*/
export const EXPLORER_TAB_RAIL_INSET = 4;
const DEFAULT_EXPLORER_SIDEBAR_WIDTH = 320;
const MIN_EXPLORER_SIDEBAR_WIDTH = 240;
const MIN_WORKSPACE_BODY_WIDTH = 400;
@@ -1220,7 +1220,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
return (
<View style={[styles.treePane, styles.treePaneFill]}>
<PaneContentToolbar
style={[styles.paneHeader, { paddingRight: paneContentToolbarTrailingPadding(isCompact) }]}
style={[
styles.paneHeader,
{ paddingRight: paneContentToolbarTrailingPadding(isCompact, "glyph") },
]}
testID="files-pane-header"
>
<Pressable
@@ -0,0 +1,65 @@
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { ICON_SIZE, SPACING } from "@/styles/theme";
const FLOATING_ACTION_BUTTON_SIZE = 56;
const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
/** Space a scrolling surface keeps below its content so the button traps nothing. */
export const FLOATING_ACTION_BUTTON_CLEARANCE =
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
export interface FloatingActionButtonProps {
icon: LucideIcon;
/** Names the action; the button carries no label. */
accessibilityLabel: string;
onPress: () => void;
testID?: string;
}
/**
* The one floating action a scrolling surface can offer. It pins itself to the
* bottom-right of its nearest positioned ancestor, so the ancestor decides what
* the button may cover.
*/
export function FloatingActionButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: FloatingActionButtonProps) {
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
style={styles.button}
highlightStyle={styles.pressed}
testID={testID}
>
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
position: "absolute",
right: FLOATING_ACTION_BUTTON_INSET,
bottom: FLOATING_ACTION_BUTTON_INSET,
width: FLOATING_ACTION_BUTTON_SIZE,
height: FLOATING_ACTION_BUTTON_SIZE,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
...theme.shadow.md,
},
pressed: {
backgroundColor: theme.colors.interactionHighlight,
},
glyph: {
color: theme.colors.accentForeground,
},
}));
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
import { smallIconButtonChromeFrameSize } from "@/components/ui/icon-button-chrome";
import {
paneContentToolbarIconSize,
paneContentToolbarTrailingPadding,
} from "@/components/ui/pane-content-toolbar";
/** Where the last control's painted edge lands, measured from the row's right edge. */
function trailingInkRail(isCompact: boolean, trailing: "glyph" | "framed"): number {
const padding = paneContentToolbarTrailingPadding(isCompact, trailing);
if (trailing === "framed") return padding;
const frameOverhang =
(smallIconButtonChromeFrameSize(isCompact) - paneContentToolbarIconSize(isCompact)) / 2;
return padding + frameOverhang;
}
describe("pane content toolbar trailing rail", () => {
it("lands a bare glyph on the shared rail by letting its hitbox overhang the row", () => {
expect(trailingInkRail(false, "glyph")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(trailingInkRail(true, "glyph")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(paneContentToolbarTrailingPadding(true, "glyph")).toBeLessThan(
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
);
});
it("lands a framed control on the shared rail, because its frame is the ink", () => {
expect(trailingInkRail(false, "framed")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(trailingInkRail(true, "framed")).toBe(WORKSPACE_PANE_TRAILING_GLYPH_RAIL);
expect(paneContentToolbarTrailingPadding(true, "framed")).toBe(
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
);
});
it("pads a framed control more than a glyph, since the glyph's frame is bigger on compact", () => {
expect(paneContentToolbarTrailingPadding(true, "framed")).toBeGreaterThan(
paneContentToolbarTrailingPadding(true, "glyph"),
);
expect(paneContentToolbarTrailingPadding(false, "framed")).toBeGreaterThan(
paneContentToolbarTrailingPadding(false, "glyph"),
);
});
});
@@ -155,8 +155,26 @@ export function paneContentToolbarIconSize(isCompact: boolean): number {
return iconButtonChromeGlyphSize("small", isCompact);
}
/** Keeps a toolbar glyph on the same trailing rail as tree-row glyphs. */
export function paneContentToolbarTrailingPadding(isCompact: boolean): number {
/**
* What the last control in a toolbar row ends with. A `glyph` control paints an icon
* inside a larger invisible hitbox; a `framed` control (bordered trigger, split button)
* paints its own frame, so the frame is the ink.
*/
export type ToolbarTrailingControl = "glyph" | "framed";
/**
* Keeps the last control in a toolbar row on the same trailing rail as tree-row glyphs.
* A bare glyph's hitbox overhangs its ink, so the row pads by less than the rail and lets
* the hitbox run off the edge; a framed control's ink reaches its own edge, so it pads by
* the full rail.
*/
export function paneContentToolbarTrailingPadding(
isCompact: boolean,
trailing: ToolbarTrailingControl,
): number {
if (trailing === "framed") {
return WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
}
const buttonSize = smallIconButtonChromeFrameSize(isCompact);
return (
WORKSPACE_PANE_TRAILING_GLYPH_RAIL - (buttonSize - paneContentToolbarIconSize(isCompact)) / 2
+158
View File
@@ -0,0 +1,158 @@
import { useCallback, useMemo, useState } from "react";
import { StyleSheet } from "react-native-unistyles";
// The sheet-aware list: inside a bottom sheet the tree scrolls with the sheet
// gesture, and outside one it is the ordinary React Native FlatList.
import { FlatList } from "@/components/ui/scroll-view";
import type { WorkingDiffMode } from "@/git/diff-document";
import { DiffFolderRow } from "@/git/diff-folder-row";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "@/git/diff-tree";
import { FileHeader } from "@/git/file-header";
import type { ParsedDiffFile } from "@/git/use-diff-query";
export interface ChangedFilesTreeProps {
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}
/** The changed-files tree shared by the desktop Changes rail and the compact Jump to file sheet. */
export function ChangedFilesTree({
files,
mode,
onSelectFile,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: ChangedFilesTreeProps) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
const items = useMemo(
() => flattenDiffTree(compressedTree, collapsedFolders),
[collapsedFolders, compressedTree],
);
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
const handleSelectFile = useCallback(
(path: string) => {
setSelectedPath(path);
onSelectFile(path);
},
[onSelectFile],
);
const handleToggleFolder = useCallback(
(dirPath: string) => {
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
onCollapsedFolderPathsChange(next);
},
[collapsedFolders, onCollapsedFolderPathsChange],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
onCollapsedFolderPathsChange([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
(folderPath) => folderPath === dirPath || folderPath.startsWith(prefix),
),
]),
]);
},
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
if (item.kind === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={collapsedFolders.has(item.dirPath)}
isSelected={selectedPath === item.dirPath}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onCollapse={handleCollapseFolder}
onSelect={handleSelectPath}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
return (
<FileHeader
file={item.file}
workspaceFileDragScope={mode.workspaceFileDragScope}
bodyVisible={false}
showsBodyState={false}
isSelected={selectedPath === item.file.path}
depth={item.depth}
showDir={false}
onActivate={handleSelectFile}
onSelect={handleSelectPath}
onOpenFile={mode.onOpenFile}
onOpenToSide={mode.onOpenToSide}
onAddToChat={mode.onAddToChat}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDownload={mode.onDownload}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-tree-file-${item.fileIndex}`}
/>
);
},
[
handleCollapseFolder,
handleSelectFile,
handleSelectPath,
handleToggleFolder,
collapsedFolders,
mode,
selectedPath,
],
);
const keyExtractor = useCallback(
(item: DiffTreeRow) =>
item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`,
[],
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="changes-file-tree"
/>
);
}
const styles = StyleSheet.create((theme) => ({
scrollView: {
flex: 1,
},
contentContainer: {
paddingBottom: theme.spacing[8],
},
}));
@@ -4,27 +4,6 @@ import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommitsSection } from "./commits-section";
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function"
? factory({
borderRadius: { full: 9999, sm: 4 },
borderWidth: { 1: 1 },
colors: {
border: "#333",
foreground: "#fff",
foregroundMuted: "#aaa",
statusDanger: "#c33",
surface2: "#222",
},
fontSize: { base: 15, sm: 13 },
spacing: [0, 4, 8, 12],
})
: factory,
},
}));
vi.mock("react-native-safe-area-context", () => ({
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 34, left: 0 }),
}));
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { treeRowPaddingLeft } from "@/components/tree-primitives";
import { normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { CommitRow } from "./commit-row";
@@ -175,7 +176,9 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2],
// Same leading rail as a depth-0 tree row, so the disclosure chevron lines up
// with the folder chevrons above it.
paddingLeft: treeRowPaddingLeft(0),
paddingRight: theme.spacing[3],
paddingVertical: theme.spacing[2],
flexShrink: 0,
@@ -256,9 +256,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
consumedFocusRef.current = requestKey;
}
}, [collapsedFilePaths, mode, model.files, onToggleFile, scrollTop]);
const contentInsetBottom = props.contentInsetBottom ?? 0;
const contentStyle = useMemo(
() => ({ minHeight: Math.max(model.height, viewport.height), backgroundColor: "transparent" }),
[model.height, viewport.height],
() => ({
minHeight: Math.max(model.height, viewport.height) + contentInsetBottom,
backgroundColor: "transparent",
}),
[contentInsetBottom, model.height, viewport.height],
);
return (
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
@@ -10,6 +10,7 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useToast } from "@/contexts/toast-context";
import { useStableEvent } from "@/hooks/use-stable-event";
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import { copyToClipboard } from "@/utils/copy-to-clipboard";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
@@ -557,23 +558,26 @@ export function DiffSurface(props: DiffSurfaceProps) {
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
[pointHitAt],
);
const setSelection = useCallback(
(selection: DiffSelection | null) => {
selectionRef.current = selection;
const currentModel = modelRef.current;
setHasSelection(
Boolean(
selection && currentModel && selectedSourceText(currentModel, selection).length > 0,
),
);
schedulePaint();
},
[schedulePaint],
);
const setSelection = useStableEvent((selection: DiffSelection | null) => {
selectionRef.current = selection;
const currentModel = modelRef.current;
setHasSelection(
Boolean(selection && currentModel && selectedSourceText(currentModel, selection).length > 0),
);
schedulePaint();
});
// Source and code-layout changes invalidate selection coordinates. A new
// repaint callback (header typography, palette, viewport height) does not.
useEffect(() => {
dragRef.current = null;
setSelection(null);
}, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]);
}, [
props.collapsedFilePaths,
props.displayPreferences.layout,
props.files,
desiredTypography,
setSelection,
]);
const pointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
@@ -768,9 +772,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
[props.palette.surface],
);
const contentInsetBottom = props.contentInsetBottom ?? 0;
const contentStyle = useMemo<React.CSSProperties>(
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
[model.height, viewport.height],
() => ({
...CONTENT_STYLE,
height: Math.max(model.height, viewport.height) + contentInsetBottom,
}),
[contentInsetBottom, model.height, viewport.height],
);
const affordanceStyle = useMemo<ViewStyle>(
() => ({
@@ -4,6 +4,8 @@ import type { ReviewableDiffTarget } from "@/utils/diff-layout";
interface DiffDocumentBaseProps {
files: ParsedDiffFile[];
/** Scrollable space kept below the last line so a floating control never traps content. */
contentInsetBottom?: number;
displayPreferences: {
layout: "unified" | "split";
wrapLines: boolean;
+51 -148
View File
@@ -8,7 +8,6 @@ import {
View,
Text,
Pressable,
FlatList,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
@@ -33,15 +32,8 @@ import { type ParsedDiffFile } from "@/git/use-diff-query";
import type { ChangesState } from "@/panels/changes/state";
import { defaultChangesState } from "@/panels/changes/state";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { FileHeader } from "@/git/file-header";
import {
buildDiffTree,
collectDirPaths,
compressSingleChildChains,
flattenDiffTree,
type DiffTreeRow,
} from "@/git/diff-tree";
import { DiffFolderRow } from "@/git/diff-folder-row";
import { ChangedFilesTree } from "@/git/changed-files-tree";
import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file";
import {
selectPrHintFromStatus,
type PrHint,
@@ -81,6 +73,7 @@ import {
paneContentToolbarIconSize,
paneContentToolbarIconButtonStyle,
paneContentToolbarTrailingPadding,
type ToolbarTrailingControl,
ToolbarButton,
ToolbarControls,
} from "@/components/ui/pane-content-toolbar";
@@ -568,7 +561,12 @@ function ChangesDiffOnlyToolbar({
sidebarSurface: boolean;
}) {
return (
<ChangesToolbarRow compact={compact} sidebarSurface={sidebarSurface} testID="changes-header">
<ChangesToolbarRow
compact={compact}
sidebarSurface={sidebarSurface}
testID="changes-header"
trailing="glyph"
>
<ChangesToolbarLeading />
<ChangesToolbarTrailing>
<ChangesToolbarActions mode={mode} compact={compact} />
@@ -582,19 +580,22 @@ function ChangesToolbarRow({
compact,
sidebarSurface,
testID,
trailing,
}: {
children: ReactNode;
compact: boolean;
sidebarSurface: boolean;
testID: string;
/** What the row's last control paints, so it can pad to the shared trailing rail. */
trailing: ToolbarTrailingControl;
}) {
const toolbarStyle = useMemo(
() => [
styles.changesToolbar,
{ paddingRight: paneContentToolbarTrailingPadding(compact) },
{ paddingRight: paneContentToolbarTrailingPadding(compact, trailing) },
sidebarSurface ? styles.changesToolbarSidebar : null,
],
[compact, sidebarSurface],
[compact, sidebarSurface, trailing],
);
return (
<PaneContentToolbar style={toolbarStyle} testID={testID}>
@@ -625,6 +626,7 @@ function ChangesRepositoryToolbar({
compact={compact}
sidebarSurface={sidebarSurface}
testID="changes-repository-header"
trailing={model.gitActions ? "framed" : "glyph"}
>
<ChangesToolbarLeading>
<BranchSwitcher
@@ -720,7 +722,12 @@ function ChangesComparisonToolbar({
sidebarSurface: boolean;
}) {
return (
<ChangesToolbarRow compact={compact} sidebarSurface={sidebarSurface} testID="changes-header">
<ChangesToolbarRow
compact={compact}
sidebarSurface={sidebarSurface}
testID="changes-header"
trailing="glyph"
>
<ChangesToolbarLeading>
<DiffModeMenu
diffMode={model.diffMode}
@@ -1239,134 +1246,23 @@ function buildToggleButtonStyle(
return (state) => [baseStyles, paneContentToolbarIconButtonStyle(state, selected, isMobile)];
}
function ChangedFilesTree({
files,
mode,
onSelectFile,
collapsedFolderPaths,
onCollapsedFolderPathsChange,
}: {
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
collapsedFolderPaths: string[];
onCollapsedFolderPathsChange: (paths: string[]) => void;
}) {
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const compressedTree = useMemo(() => compressSingleChildChains(buildDiffTree(files)), [files]);
const allFolderPaths = useMemo(() => collectDirPaths(compressedTree), [compressedTree]);
const collapsedFolders = useMemo(() => new Set(collapsedFolderPaths), [collapsedFolderPaths]);
const items = useMemo(
() => flattenDiffTree(compressedTree, collapsedFolders),
[collapsedFolders, compressedTree],
);
const handleSelectPath = useCallback((path: string) => setSelectedPath(path), []);
const handleSelectFile = useCallback(
(path: string) => {
setSelectedPath(path);
onSelectFile(path);
},
[onSelectFile],
);
const handleToggleFolder = useCallback(
(dirPath: string) => {
const next = collapsedFolders.has(dirPath)
? Array.from(collapsedFolders).filter((path) => path !== dirPath)
: [...collapsedFolders, dirPath];
onCollapsedFolderPathsChange(next);
},
[collapsedFolders, onCollapsedFolderPathsChange],
);
const handleCollapseFolder = useCallback(
(dirPath: string) => {
const prefix = `${dirPath}/`;
onCollapsedFolderPathsChange([
...new Set([
...collapsedFolders,
...allFolderPaths.filter(
(folderPath) => folderPath === dirPath || folderPath.startsWith(prefix),
),
]),
]);
},
[allFolderPaths, collapsedFolders, onCollapsedFolderPathsChange],
);
const renderItem = useCallback(
({ item }: { item: DiffTreeRow }) => {
if (item.kind === "folder") {
return (
<DiffFolderRow
dirPath={item.dirPath}
displayName={item.displayName}
depth={item.depth}
collapsed={collapsedFolders.has(item.dirPath)}
isSelected={selectedPath === item.dirPath}
additions={item.additions}
deletions={item.deletions}
onToggle={handleToggleFolder}
onCollapse={handleCollapseFolder}
onSelect={handleSelectPath}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-folder-${item.dirPath}`}
/>
);
}
return (
<FileHeader
file={item.file}
workspaceFileDragScope={mode.workspaceFileDragScope}
bodyVisible={false}
showsBodyState={false}
isSelected={selectedPath === item.file.path}
depth={item.depth}
showDir={false}
onActivate={handleSelectFile}
onSelect={handleSelectPath}
onOpenFile={mode.onOpenFile}
onOpenToSide={mode.onOpenToSide}
onAddToChat={mode.onAddToChat}
onCopyPath={mode.onCopyPath}
onCopyRelativePath={mode.onCopyRelativePath}
onReveal={mode.onReveal}
revealTargetName={mode.revealTargetName}
onDownload={mode.onDownload}
onDuplicate={mode.onDuplicate}
onRevert={mode.onRevert}
testID={`diff-tree-file-${item.fileIndex}`}
/>
);
},
[
handleCollapseFolder,
handleSelectFile,
handleSelectPath,
handleToggleFolder,
collapsedFolders,
mode,
selectedPath,
],
);
const keyExtractor = useCallback(
(item: DiffTreeRow) =>
item.kind === "folder" ? `folder-${item.dirPath}` : `file-${item.file.path}`,
[],
);
interface JumpToFileVisibility {
isCompact: boolean;
presentation: ChangesPresentation;
hasChanges: boolean;
isDiffLoading: boolean;
}
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
style={styles.scrollView}
contentContainerStyle={styles.contentContainer}
testID="changes-file-tree"
/>
);
/**
* The room the floating Jump to file action needs over the diff, and zero where
* it is not offered. Compact users never see the tree rail, so the diff is their
* only view of what changed; the tree and diff presentations are already halves
* of a desktop split.
*/
function jumpToFileClearance(visibility: JumpToFileVisibility): number {
if (!visibility.isCompact || visibility.presentation !== "combined") return 0;
if (!visibility.hasChanges || visibility.isDiffLoading) return 0;
return JUMP_TO_FILE_CLEARANCE;
}
function ChangesTreeRail({
@@ -1834,6 +1730,13 @@ export function ChangesSurface({
);
const hasChanges = files.length > 0;
const jumpToFileInset = jumpToFileClearance({
isCompact: isMobile,
presentation,
hasChanges,
isDiffLoading,
});
const showJumpToFile = jumpToFileInset > 0;
const selectedDiffStat = useMemo(
() => computeSelectedDiffStat(files, isDiffLoading),
[files, isDiffLoading],
@@ -1890,6 +1793,7 @@ export function ChangesSurface({
>
<DiffDocument
files={files}
contentInsetBottom={jumpToFileInset}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
@@ -2023,7 +1927,12 @@ export function ChangesSurface({
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</View>
<View style={styles.diffContainer}>
{bodyContent}
{showJumpToFile ? (
<JumpToFile files={files} mode={workingMode} onSelectFile={handleSelectTreeFile} />
) : null}
</View>
<ChangesCommits
presentation={presentation}
@@ -2092,17 +2001,11 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 0,
position: "relative",
},
scrollView: {
flex: 1,
},
scrollContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
contentContainer: {
paddingBottom: theme.spacing[8],
},
loadingContainer: {
flex: 1,
alignItems: "center",
+127
View File
@@ -0,0 +1,127 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FolderTree } from "lucide-react-native";
import {
AdaptiveModalSheet,
SHEET_HEADER_CLOSE_PADDING_SCALE,
SHEET_HORIZONTAL_PADDING_SCALE,
} from "@/components/adaptive-modal-sheet";
import {
treeRowPaddingLeft,
WORKSPACE_PANE_TRAILING_GLYPH_RAIL,
} from "@/components/tree-primitives";
import {
FLOATING_ACTION_BUTTON_CLEARANCE,
FloatingActionButton,
} from "@/components/ui/floating-action-button";
import { ChangedFilesTree } from "@/git/changed-files-tree";
import type { WorkingDiffMode } from "@/git/diff-document";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
// The sheet header indents its leading glyph by the sheet's own content inset,
// while a tree row indents itself by `treeRowPaddingLeft`. The body carries the
// difference so a depth-0 file icon lands on the header glyph's rail.
const TREE_LEADING_INSET = SPACING[SHEET_HORIZONTAL_PADDING_SCALE] - treeRowPaddingLeft(0);
// The header's close glyph sits at the content inset plus the button's own
// padding, while a tree row ends its trailing glyph at
// `WORKSPACE_PANE_TRAILING_GLYPH_RAIL`. The body carries the difference so row
// change icons land on the header X's rail.
const TREE_TRAILING_INSET =
SPACING[SHEET_HORIZONTAL_PADDING_SCALE] +
SPACING[SHEET_HEADER_CLOSE_PADDING_SCALE] -
WORKSPACE_PANE_TRAILING_GLYPH_RAIL;
const ThemedFolderTree = withUnistyles(FolderTree);
const foregroundMutedIconColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
/** Scrollable space the diff keeps below its last line while the action is shown. */
export const JUMP_TO_FILE_CLEARANCE = FLOATING_ACTION_BUTTON_CLEARANCE;
export interface JumpToFileProps {
files: ParsedDiffFile[];
mode: WorkingDiffMode;
onSelectFile: (path: string) => void;
}
/** Compact overview of a loaded diff: a floating action that opens the changed-files tree. */
export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
// The sheet is a transient overview, so it always opens fully expanded and
// never writes to the panel's persisted folder state.
const [collapsedFolderPaths, setCollapsedFolderPaths] = useState<string[]>([]);
const title = t("workspace.git.diff.jumpToFile.title");
const header = useMemo(
() => ({
title,
leading: <ThemedFolderTree size={ICON_SIZE.md} uniProps={foregroundMutedIconColorMapping} />,
}),
[title],
);
const open = useCallback(() => {
setCollapsedFolderPaths([]);
setIsOpen(true);
}, []);
const close = useCallback(() => setIsOpen(false), []);
const handleSelectFile = useCallback(
(path: string) => {
setIsOpen(false);
onSelectFile(path);
},
[onSelectFile],
);
return (
<>
<FloatingActionButton
icon={FolderTree}
accessibilityLabel={title}
onPress={open}
testID="changes-jump-to-file"
/>
<AdaptiveModalSheet
header={header}
visible={isOpen}
onClose={close}
scrollable={false}
contentStyle={styles.sheetBody}
testID="changes-jump-to-file-sheet"
>
<View style={styles.tree}>
<ChangedFilesTree
files={files}
mode={mode}
onSelectFile={handleSelectFile}
collapsedFolderPaths={collapsedFolderPaths}
onCollapsedFolderPathsChange={setCollapsedFolderPaths}
/>
</View>
</AdaptiveModalSheet>
</>
);
}
const styles = StyleSheet.create((theme) => ({
// Rows own their rails, so the sheet's horizontal inset is zeroed here.
sheetBody: {
paddingHorizontal: 0,
paddingTop: 0,
paddingBottom: 0,
gap: 0,
},
tree: {
flex: 1,
minHeight: 0,
paddingLeft: TREE_LEADING_INSET,
paddingRight: TREE_TRAILING_INSET,
paddingTop: theme.spacing[2],
},
}));
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@getpaseo/protocol/messages";
import { deriveCheckoutDiffResult } from "./use-diff-query";
function createParsedDiffFile(path: string): ParsedDiffFile {
return {
path,
isNew: false,
isDeleted: false,
additions: 3,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [{ type: "add", content: "x", tokens: [] }],
},
],
} as ParsedDiffFile;
}
function createPayload(files: ParsedDiffFile[]) {
return { requestId: "req-1", cwd: "/repo", files, error: null };
}
describe("deriveCheckoutDiffResult", () => {
// No payload covers every not-yet-fetched case: the query is disabled because the
// retained panel is still inactive, the request is in flight, or the host is
// disconnected. None of them mean "the diff is empty".
it("reports loading while no payload has arrived, so an empty diff is never implied", () => {
const result = deriveCheckoutDiffResult(null);
expect(result.isLoading).toBe(true);
expect(result.files).toEqual([]);
});
it("reports a settled empty diff once a payload with zero files arrives", () => {
const result = deriveCheckoutDiffResult(createPayload([]));
expect(result.isLoading).toBe(false);
expect(result.files).toEqual([]);
});
it("passes the payload files through once the diff arrives", () => {
const files = [createParsedDiffFile("src/app.ts")];
const result = deriveCheckoutDiffResult(createPayload(files));
expect(result.isLoading).toBe(false);
expect(result.files).toEqual(files);
});
it("surfaces a payload error and the too-large flag", () => {
const error = { code: "NOT_GIT_REPO", message: "not a git repository" } as const;
const result = deriveCheckoutDiffResult({ ...createPayload([]), error, diffTooLarge: true });
expect(result).toEqual({
files: [],
payloadError: error,
diffTooLarge: true,
isLoading: false,
isFetching: false,
isError: true,
error: null,
});
});
});
+24 -3
View File
@@ -91,14 +91,35 @@ export function useCheckoutDiffQuery({
}),
});
const payload = query.data ?? null;
const payloadError = payload?.error ?? null;
return deriveCheckoutDiffResult(query.data ?? null);
}
export interface CheckoutDiffResult {
files: ParsedDiffFile[];
payloadError: CheckoutDiffQueryPayload["error"];
diffTooLarge: boolean;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error: null;
}
/**
* The result derives from the payload alone. Until a payload arrives there is no
* diff to describe, so the boundary reports loading rather than an empty diff —
* whether the query is idle (an inactive retained panel), in flight, or the host
* is disconnected. Reporting `files: []` as settled made callers render "+0 -0"
* and "No changes" for a diff nobody has fetched yet.
*/
export function deriveCheckoutDiffResult(
payload: CheckoutDiffQueryPayload | null,
): CheckoutDiffResult {
const payloadError = payload?.error ?? null;
return {
files: payload?.files ?? [],
payloadError,
diffTooLarge: payload?.diffTooLarge === true,
isLoading: payload === null && queryEnabled && isConnected,
isLoading: payload === null,
isFetching: false,
isError: Boolean(payloadError),
error: null,
+3
View File
@@ -884,6 +884,9 @@ export const ar: TranslationResources = {
openChangesTab: "فتح علامة تبويب التغييرات",
openDiffTab: "فتح علامة تبويب الفرق",
closeChangesTab: "إغلاق علامة تبويب التغييرات",
jumpToFile: {
title: "الانتقال إلى ملف",
},
binaryFile: "ملف ثنائي",
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته",
+3
View File
@@ -893,6 +893,9 @@ export const en = {
openChangesTab: "Open Changes tab",
openDiffTab: "Open Diff tab",
closeChangesTab: "Close Changes tab",
jumpToFile: {
title: "Jump to file",
},
binaryFile: "Binary file",
tooLarge: "Diff too large to display",
previewTooLargeTitle: "This diff is too large to preview",
+3
View File
@@ -916,6 +916,9 @@ export const es: TranslationResources = {
openChangesTab: "Abrir la pestaña Cambios",
openDiffTab: "Abrir la pestaña Diferencia",
closeChangesTab: "Cerrar la pestaña Cambios",
jumpToFile: {
title: "Ir al archivo",
},
binaryFile: "archivo binario",
tooLarge: "La diferencia es demasiado grande para mostrarse",
previewTooLargeTitle: "Este diff es demasiado grande para previsualizarlo",
+3
View File
@@ -915,6 +915,9 @@ export const fr: TranslationResources = {
openChangesTab: "Ouvrir l'onglet Modifications",
openDiffTab: "Ouvrir l'onglet Diff",
closeChangesTab: "Fermer l'onglet Modifications",
jumpToFile: {
title: "Aller au fichier",
},
binaryFile: "Fichier binaire",
tooLarge: "Diff trop grand pour être affiché",
previewTooLargeTitle: "Ce diff est trop volumineux pour être prévisualisé",
+3
View File
@@ -896,6 +896,9 @@ export const ja: TranslationResources = {
openChangesTab: "変更タブを開く",
openDiffTab: "差分タブを開く",
closeChangesTab: "変更タブを閉じる",
jumpToFile: {
title: "ファイルへ移動",
},
binaryFile: "バイナリファイル",
tooLarge: "差分が大きすぎて表示できません",
previewTooLargeTitle: "この差分は大きすぎるためプレビューできません",
+3
View File
@@ -891,6 +891,9 @@ export const ko: TranslationResources = {
openChangesTab: "변경사항 탭 열기",
openDiffTab: "Diff 탭 열기",
closeChangesTab: "변경사항 탭 닫기",
jumpToFile: {
title: "파일로 이동",
},
binaryFile: "바이너리 파일",
tooLarge: "표시하기에 diff가 너무 큽니다",
previewTooLargeTitle: "Diff가 너무 커서 미리 볼 수 없습니다",
+3
View File
@@ -906,6 +906,9 @@ export const ptBR: TranslationResources = {
openChangesTab: "Abrir a aba Alterações",
openDiffTab: "Abrir a aba Diff",
closeChangesTab: "Fechar a aba Alterações",
jumpToFile: {
title: "Ir para o arquivo",
},
binaryFile: "Arquivo binário",
tooLarge: "Diff grande demais para exibir",
previewTooLargeTitle: "Este diff é grande demais para visualizar",
+3
View File
@@ -900,6 +900,9 @@ export const ru: TranslationResources = {
openChangesTab: "Открыть вкладку «Изменения»",
openDiffTab: "Открыть вкладку «Дифф»",
closeChangesTab: "Закрыть вкладку «Изменения»",
jumpToFile: {
title: "Перейти к файлу",
},
binaryFile: "Бинарный файл",
tooLarge: "Дифф слишком велик для отображения",
previewTooLargeTitle: "Этот дифф слишком велик для предварительного просмотра",
+3
View File
@@ -876,6 +876,9 @@ export const zhCN: TranslationResources = {
openChangesTab: "打开“更改”标签页",
openDiffTab: "打开 Diff 标签页",
closeChangesTab: "关闭“更改”标签页",
jumpToFile: {
title: "跳转到文件",
},
binaryFile: "二进制文件",
tooLarge: "Diff 过大,无法显示",
previewTooLargeTitle: "此 diff 过大,无法预览",
@@ -5,6 +5,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import Animated from "react-native-reanimated";
import { SortableInlineList } from "@/components/sortable-inline-list";
import { EXPLORER_TAB_RAIL_INSET } from "@/components/explorer-sidebar-layout";
import type {
DraggableListDragHandleProps,
DraggableRenderItemInfo,
@@ -405,7 +406,7 @@ const styles = StyleSheet.create((theme) => ({
scrollContent: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 4,
paddingHorizontal: EXPLORER_TAB_RAIL_INSET,
},
trailingAccessory: {
marginRight: 4,
@@ -88,7 +88,7 @@ const DROPDOWN_WIDTH = 220;
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
const PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING = 2;
const PANE_SPLIT_ACTIONS_OUTER_MARGIN =
paneContentToolbarTrailingPadding(false) - PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING;
paneContentToolbarTrailingPadding(false, "glyph") - PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING;
const PANE_SPLIT_ACTIONS_RESERVED_WIDTH =
smallIconButtonChromeFrameSize(false) +
PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING * 2 +