Merge remote-tracking branch 'origin/main' into diagnose/workspace-crash-loop-4677

This commit is contained in:
Mohamed Boudra
2026-09-15 00:39:33 +02:00
33 changed files with 870 additions and 250 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");
@@ -269,7 +269,9 @@ async function persistHiddenGeneratedExplorer(page: Page): Promise<void> {
state: WorkspaceLayoutPersistedStateSchema,
version: z.number().int().nonnegative().optional(),
})
.parse(JSON.parse(raw), { error: "Explorer fixture: invalid persisted workspace layout" });
.parse(JSON.parse(raw), {
error: () => "Explorer fixture: invalid persisted workspace layout",
});
const [key] = Object.keys(stored.state.layoutByWorkspace);
if (!key) throw new Error("Explorer fixture: persisted layout contains no workspace");
const tabs: WorkspaceTab[] = [
@@ -284,7 +286,7 @@ async function persistHiddenGeneratedExplorer(page: Page): Promise<void> {
}),
),
];
stored.state.layoutByWorkspace[key] = {
const corruptedLayout = {
root: {
kind: "pane",
pane: {
@@ -297,10 +299,14 @@ async function persistHiddenGeneratedExplorer(page: Page): Promise<void> {
},
focusedPaneId: null,
};
stored.state.explorerPaneIdByWorkspace = {
...stored.state.explorerPaneIdByWorkspace,
[key]: "pane_generated_report_equivalent",
};
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: corruptedLayout },
explorerPaneIdByWorkspace: {
...stored.state.explorerPaneIdByWorkspace,
[key]: "pane_generated_report_equivalent",
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
@@ -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 +