mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
Merge remote-tracking branch 'origin/main' into ci/simplify-test-suite
# Conflicts: # packages/app/e2e/browser/changes-pane.spec.ts
This commit is contained in:
@@ -360,7 +360,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Weight file groups by observed duration; raw test counts overload
|
||||
# early agent/Changes specs. Required check names remain unchanged.
|
||||
PWTEST_SHARD_WEIGHTS: "111:170:137:160"
|
||||
PWTEST_SHARD_WEIGHTS: "111:172:137:160"
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -1111,30 +1137,25 @@ test("the whole reviewable row reveals the gutter affordance and uses a text cur
|
||||
await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text");
|
||||
});
|
||||
|
||||
test("selects, copies, and dismisses diff text without opening a review", async ({
|
||||
context,
|
||||
test("selects, preserves, copies, and dismisses diff text without opening a review", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await openCopyableSelectionDiff(page, "ABCDEFGHIJ");
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
|
||||
const readClipboard = () => page.evaluate(() => navigator.clipboard.readText());
|
||||
await test.step("copy the selected characters with the keyboard", async () => {
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
await page.keyboard.press("ControlOrMeta+C");
|
||||
await expect.poll(readClipboard).toBe("CDEFGH");
|
||||
await copyDiffSelectionWithKeyboard(page);
|
||||
await expectClipboardText(page, "CDEFGH");
|
||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||
});
|
||||
await test.step("copy the selection and the whole line from the context menu", async () => {
|
||||
await page.evaluate(() => navigator.clipboard.writeText(""));
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-selection").click();
|
||||
await expect.poll(readClipboard).toBe("CDEFGH");
|
||||
await rightClickFirstChangedLine(page);
|
||||
await page.getByTestId("diff-source-copy-line").click();
|
||||
await expect.poll(readClipboard).toBe("ABCDEFGHIJ");
|
||||
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");
|
||||
});
|
||||
await test.step("dismiss a new selection without starting a review", async () => {
|
||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
||||
@@ -1596,6 +1617,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,
|
||||
@@ -1749,6 +1826,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -884,6 +884,9 @@ export const ar: TranslationResources = {
|
||||
openChangesTab: "فتح علامة تبويب التغييرات",
|
||||
openDiffTab: "فتح علامة تبويب الفرق",
|
||||
closeChangesTab: "إغلاق علامة تبويب التغييرات",
|
||||
jumpToFile: {
|
||||
title: "الانتقال إلى ملف",
|
||||
},
|
||||
binaryFile: "ملف ثنائي",
|
||||
tooLarge: "الفرق كبير جدًا بحيث لا يمكن عرضه",
|
||||
previewTooLargeTitle: "هذا الفرق كبير جدًا بحيث لا يمكن معاينته",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -896,6 +896,9 @@ export const ja: TranslationResources = {
|
||||
openChangesTab: "変更タブを開く",
|
||||
openDiffTab: "差分タブを開く",
|
||||
closeChangesTab: "変更タブを閉じる",
|
||||
jumpToFile: {
|
||||
title: "ファイルへ移動",
|
||||
},
|
||||
binaryFile: "バイナリファイル",
|
||||
tooLarge: "差分が大きすぎて表示できません",
|
||||
previewTooLargeTitle: "この差分は大きすぎるためプレビューできません",
|
||||
|
||||
@@ -891,6 +891,9 @@ export const ko: TranslationResources = {
|
||||
openChangesTab: "변경사항 탭 열기",
|
||||
openDiffTab: "Diff 탭 열기",
|
||||
closeChangesTab: "변경사항 탭 닫기",
|
||||
jumpToFile: {
|
||||
title: "파일로 이동",
|
||||
},
|
||||
binaryFile: "바이너리 파일",
|
||||
tooLarge: "표시하기에 diff가 너무 큽니다",
|
||||
previewTooLargeTitle: "Diff가 너무 커서 미리 볼 수 없습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -900,6 +900,9 @@ export const ru: TranslationResources = {
|
||||
openChangesTab: "Открыть вкладку «Изменения»",
|
||||
openDiffTab: "Открыть вкладку «Дифф»",
|
||||
closeChangesTab: "Закрыть вкладку «Изменения»",
|
||||
jumpToFile: {
|
||||
title: "Перейти к файлу",
|
||||
},
|
||||
binaryFile: "Бинарный файл",
|
||||
tooLarge: "Дифф слишком велик для отображения",
|
||||
previewTooLargeTitle: "Этот дифф слишком велик для предварительного просмотра",
|
||||
|
||||
@@ -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 +
|
||||
|
||||
Reference in New Issue
Block a user