Revamp Changes pane view + ship menus and stabilize e2e selectors

This commit is contained in:
Mohamed Boudra
2026-01-24 08:52:31 +07:00
parent 2b55d3ae6a
commit b919a3cdd9
2 changed files with 489 additions and 200 deletions
+44 -21
View File
@@ -29,12 +29,33 @@ function getChangesActionButton(page: Page, label: string) {
return getChangesActionLabel(page, label).locator('..');
}
async function selectChangesView(page: Page, view: 'working' | 'base') {
const scope = getChangesScope(page);
await scope.getByTestId('changes-view-selector').click();
if (view === 'working') {
await page.getByTestId('changes-mode-uncommitted').click();
} else {
await page.getByTestId('changes-mode-base').click();
}
}
async function openChangesOverflowMenu(page: Page) {
const menuButton = page.getByTestId('changes-overflow-menu').first();
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
await expect(menuButton).toBeVisible();
await menuButton.click();
}
async function openChangesShipMenu(page: Page) {
const backdrop = page.getByRole('button', { name: 'Ship menu backdrop' }).first();
if (await backdrop.isVisible().catch(() => false)) {
return;
}
const scope = getChangesScope(page);
const caret = scope.getByTestId('changes-ship-caret').first();
await expect(caret).toBeVisible();
await caret.click();
}
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) {
@@ -114,6 +135,8 @@ async function createAgentAndWait(page: Page, message: string) {
async function requestCwd(page: Page) {
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
const message = await waitForAssistantText(page, 'CWD:');
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('.paseo/worktrees/');
const content = (await message.textContent()) ?? '';
const match = content.match(/CWD:\s*(\S+)/);
if (!match) {
@@ -168,9 +191,8 @@ async function enableCreateWorktree(page: Page) {
}
async function refreshUncommittedMode(page: Page) {
const changesScope = getChangesScope(page);
await changesScope.getByTestId('changes-mode-base').click();
await changesScope.getByTestId('changes-mode-uncommitted').click();
await selectChangesView(page, 'base');
await selectChangesView(page, 'working');
}
async function refreshChangesTab(page: Page) {
@@ -253,17 +275,17 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
});
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible();
await expect(getChangesActionLabel(page, 'Commit')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
await getChangesActionButton(page, 'Commit').click();
await getChangesScope(page).getByTestId('changes-mode-uncommitted').click();
await getChangesScope(page).getByTestId('changes-action-commit').click();
await selectChangesView(page, 'working');
await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000,
});
await expect(getChangesActionLabel(page, 'Commit')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
await getChangesScope(page).getByTestId('changes-mode-base').click();
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
@@ -277,19 +299,19 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000,
});
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
await expect(getChangesActionLabel(page, 'Commit')).toBeVisible();
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toBeVisible();
await getChangesScope(page).getByTestId('changes-mode-base').click();
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
await getChangesActionButton(page, 'Commit').click();
await getChangesScope(page).getByTestId('changes-mode-uncommitted').click();
await getChangesScope(page).getByTestId('changes-action-commit').click();
await selectChangesView(page, 'working');
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
await expect(getChangesActionLabel(page, 'Commit')).toHaveCount(0);
await expect(getChangesScope(page).getByTestId('changes-action-commit')).toHaveCount(0);
await getChangesScope(page).getByTestId('changes-mode-base').click();
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000,
});
@@ -297,20 +319,21 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000,
});
await expect(getChangesActionLabel(page, 'Create PR')).toBeVisible();
await getChangesActionButton(page, 'Create PR').click();
await openChangesShipMenu(page);
await page.getByTestId('changes-ship-create-pr').click();
await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, {
timeout: 60000,
});
await expect(getChangesActionLabel(page, 'Create PR')).toHaveCount(0);
await openChangesShipMenu(page);
await expect(page.getByTestId('changes-ship-open-pr')).toBeVisible();
await getChangesActionButton(page, 'Merge to base').click();
await getChangesScope(page).getByTestId('changes-mode-base').click();
await page.getByTestId('changes-ship-merge').click();
await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
timeout: 60000,
});
await refreshChangesTab(page);
await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0, { timeout: 30000 });
await expect(getChangesScope(page).getByTestId('changes-ship-caret')).toHaveCount(0, { timeout: 30000 });
await openChangesOverflowMenu(page);
await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
+445 -179
View File
@@ -15,7 +15,9 @@ import {
} from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronRight, GitBranch, MoreVertical, RotateCcw } from "lucide-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Linking from "expo-linking";
import { ChevronDown, ChevronRight, GitBranch, MoreVertical, RotateCcw } from "lucide-react-native";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -361,6 +363,13 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
const [overflowVisible, setOverflowVisible] = useState(false);
const [overflowAnchor, setOverflowAnchor] = useState({ left: 0, top: 0 });
const [overflowHeight, setOverflowHeight] = useState(0);
const [viewMenuVisible, setViewMenuVisible] = useState(false);
const [viewMenuAnchor, setViewMenuAnchor] = useState({ left: 0, top: 0 });
const [viewMenuHeight, setViewMenuHeight] = useState(0);
const [shipMenuVisible, setShipMenuVisible] = useState(false);
const [shipMenuAnchor, setShipMenuAnchor] = useState({ left: 0, top: 0 });
const [shipMenuHeight, setShipMenuHeight] = useState(0);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, agentId });
const gitStatus = status && status.isGit ? status : null;
@@ -428,6 +437,44 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
void refreshPrStatus();
}, [refreshDiff, refreshStatus, refreshPrStatus]);
const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) {
return null;
}
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
}, [gitStatus?.repoRoot]);
useEffect(() => {
if (!shipDefaultStorageKey) {
return;
}
let isActive = true;
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
}
})
.catch(() => undefined);
return () => {
isActive = false;
};
}, [shipDefaultStorageKey]);
const persistShipDefault = useCallback(
async (next: "merge" | "pr") => {
setShipDefault(next);
if (!shipDefaultStorageKey) return;
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey]
);
const handleOpenOverflowMenu = useCallback((event: any) => {
const { pageX, pageY } = event?.nativeEvent ?? {};
setOverflowAnchor({
@@ -447,6 +494,44 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
setOverflowHeight((current) => (current === height ? current : height));
}, []);
const handleOpenViewMenu = useCallback((event: any) => {
const { pageX, pageY } = event?.nativeEvent ?? {};
setViewMenuAnchor({
left: typeof pageX === "number" ? pageX : 0,
top: typeof pageY === "number" ? pageY : 0,
});
setViewMenuVisible(true);
}, []);
const handleCloseViewMenu = useCallback(() => {
setViewMenuVisible(false);
setViewMenuHeight(0);
}, []);
const handleViewMenuLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setViewMenuHeight((current) => (current === height ? current : height));
}, []);
const handleOpenShipMenu = useCallback((event: any) => {
const { pageX, pageY } = event?.nativeEvent ?? {};
setShipMenuAnchor({
left: typeof pageX === "number" ? pageX : 0,
top: typeof pageY === "number" ? pageY : 0,
});
setShipMenuVisible(true);
}, []);
const handleCloseShipMenu = useCallback(() => {
setShipMenuVisible(false);
setShipMenuHeight(0);
}, []);
const handleShipMenuLayout = useCallback((event: LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
setShipMenuHeight((current) => (current === height ? current : height));
}, []);
const handleToggleExpanded = useCallback((path: string) => {
setExpandedByPath((prev) => ({
...prev,
@@ -624,14 +709,17 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const showCommitAction =
!isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges);
const showCreatePrAction = !isGit || !prStatus;
const showMergeAction =
diffMode === "base" && isGit && (gitStatus?.aheadBehind?.ahead ?? 0) > 0;
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
const canShowCommit = isGit && hasUncommittedChanges;
const canShowShip = isGit && aheadCount > 0;
const baseRefLabel = useMemo(() => {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}, [baseRef]);
const commitDisabled = actionsDisabled || commitMutation.isPending;
const prDisabled = actionsDisabled || prMutation.isPending;
const mergeDisabled = actionsDisabled || mergeMutation.isPending;
const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
const archiveDisabled =
actionsDisabled ||
archiveMutation.isPending ||
@@ -698,62 +786,43 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
);
}
type PrimaryAction = {
key: "commit" | "create_pr" | "merge";
label: string;
testID: string;
disabled: boolean;
isPending: boolean;
onPress: () => void;
};
const hasPullRequest = Boolean(prStatus?.url);
const prActionLabel = hasPullRequest ? "Open PR" : "Create PR";
const primaryAction: PrimaryAction | null = useMemo(() => {
if (!isGit) {
return null;
type ShipActionKey = "merge" | "pr";
const shipActions: { key: ShipActionKey; label: string; disabled: boolean; isPending: boolean }[] =
useMemo(
() => [
{ key: "merge", label: "Merge", disabled: mergeDisabled, isPending: mergeMutation.isPending },
{ key: "pr", label: prActionLabel, disabled: prDisabled, isPending: prMutation.isPending },
],
[mergeDisabled, mergeMutation.isPending, prActionLabel, prDisabled, prMutation.isPending]
);
const resolvedShipPrimary: ShipActionKey | null = useMemo(() => {
if (!canShowShip) return null;
const preferred = shipDefault;
const preferredMeta = shipActions.find((action) => action.key === preferred);
if (preferredMeta && !preferredMeta.disabled) {
return preferred;
}
if (showCommitAction) {
return {
key: "commit",
label: "Commit",
testID: "changes-primary-commit",
disabled: commitDisabled,
isPending: commitMutation.isPending,
onPress: () => commitMutation.mutate(),
};
}
if (showCreatePrAction) {
return {
key: "create_pr",
label: "Create PR",
testID: "changes-primary-create-pr",
disabled: prDisabled,
isPending: prMutation.isPending,
onPress: () => prMutation.mutate(),
};
}
if (showMergeAction) {
return {
key: "merge",
label: "Merge to base",
testID: "changes-primary-merge",
disabled: mergeDisabled,
isPending: mergeMutation.isPending,
onPress: () => mergeMutation.mutate(),
};
}
return null;
}, [
commitDisabled,
commitMutation,
isGit,
mergeDisabled,
mergeMutation,
prDisabled,
prMutation,
showCommitAction,
showCreatePrAction,
showMergeAction,
]);
const fallback = shipActions.find((action) => !action.disabled);
return fallback?.key ?? preferred;
}, [canShowShip, shipActions, shipDefault]);
const shipPrimaryDisabled = useMemo(() => {
if (!resolvedShipPrimary) return true;
if (actionsDisabled) return true;
const meta = shipActions.find((action) => action.key === resolvedShipPrimary);
return meta?.disabled ?? true;
}, [actionsDisabled, resolvedShipPrimary, shipActions]);
const shipPrimaryPending = useMemo(() => {
if (!resolvedShipPrimary) return false;
if (resolvedShipPrimary === "merge") return mergeMutation.isPending;
// "pr" maps to open PR (instant) or create PR (mutation)
return hasPullRequest ? false : prMutation.isPending;
}, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]);
const overflowPosition = useMemo(() => {
if (!overflowVisible) {
@@ -780,6 +849,56 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
windowWidth,
]);
const viewMenuPosition = useMemo(() => {
if (!viewMenuVisible) {
return null;
}
const menuWidth = 180;
const horizontalPadding = theme.spacing[2];
const verticalPadding = theme.spacing[2];
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
const maxTop = Math.max(verticalPadding, windowHeight - viewMenuHeight - verticalPadding);
const left = Math.min(
Math.max(viewMenuAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
maxLeft
);
const top = Math.min(Math.max(viewMenuAnchor.top + verticalPadding, verticalPadding), maxTop);
return { top, left, width: menuWidth };
}, [
theme.spacing,
viewMenuAnchor.left,
viewMenuAnchor.top,
viewMenuHeight,
viewMenuVisible,
windowHeight,
windowWidth,
]);
const shipMenuPosition = useMemo(() => {
if (!shipMenuVisible) {
return null;
}
const menuWidth = 220;
const horizontalPadding = theme.spacing[2];
const verticalPadding = theme.spacing[2];
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
const maxTop = Math.max(verticalPadding, windowHeight - shipMenuHeight - verticalPadding);
const left = Math.min(
Math.max(shipMenuAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
maxLeft
);
const top = Math.min(Math.max(shipMenuAnchor.top + verticalPadding, verticalPadding), maxTop);
return { top, left, width: menuWidth };
}, [
shipMenuAnchor.left,
shipMenuAnchor.top,
shipMenuHeight,
shipMenuVisible,
theme.spacing,
windowHeight,
windowWidth,
]);
return (
<View style={styles.container}>
<View style={styles.header} testID="changes-header">
@@ -788,72 +907,101 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel}
</Text>
{isStatusFetching && (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
)}
{isStatusFetching && (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
)}
</View>
<View style={styles.modeToggle}>
{isGit ? (
<Pressable
testID="changes-mode-uncommitted"
onPress={() => setDiffMode("uncommitted")}
style={[
styles.modeToggleButton,
diffMode === "uncommitted" && styles.modeToggleButtonActive,
]}
testID="changes-view-selector"
onPress={handleOpenViewMenu}
style={styles.viewSelector}
accessibilityRole="button"
accessibilityLabel="Change diff view"
>
<Text
style={[
styles.modeToggleText,
diffMode === "uncommitted" && styles.modeToggleTextActive,
]}
>
Uncommitted
<Text style={styles.viewSelectorText}>
{diffMode === "uncommitted" ? "Working" : "Base"}
</Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable>
<Pressable
testID="changes-mode-base"
onPress={() => setDiffMode("base")}
style={[
styles.modeToggleButton,
diffMode === "base" && styles.modeToggleButtonActive,
]}
>
<Text
style={[
styles.modeToggleText,
diffMode === "base" && styles.modeToggleTextActive,
]}
>
Base
</Text>
</Pressable>
</View>
) : null}
</View>
{isGit ? (
<View style={styles.toolbarRow} testID="changes-toolbar">
<View style={styles.toolbarPrimary}>
{primaryAction ? (
<View style={styles.toolbarLeft}>
{canShowCommit ? (
<Pressable
testID={primaryAction.testID}
testID="changes-action-commit"
style={[
styles.primaryActionButton,
primaryAction.disabled && styles.primaryActionButtonDisabled,
styles.secondaryActionButton,
commitDisabled && styles.secondaryActionButtonDisabled,
]}
onPress={primaryAction.onPress}
disabled={primaryAction.disabled}
onPress={() => commitMutation.mutate()}
disabled={commitDisabled}
>
{primaryAction.isPending ? (
{commitMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<Text style={styles.primaryActionText}>{primaryAction.label}</Text>
<Text style={styles.secondaryActionText}>Commit…</Text>
)}
</Pressable>
) : (
<View style={styles.toolbarPrimarySpacer} />
<View style={styles.toolbarLeftSpacer} />
)}
</View>
<View style={styles.toolbarRight}>
{canShowShip ? (
<View style={styles.shipSplitButton}>
<Pressable
testID="changes-ship-primary"
style={[
styles.shipPrimaryButton,
shipPrimaryDisabled && styles.shipPrimaryButtonDisabled,
]}
onPress={async () => {
if (!resolvedShipPrimary || shipPrimaryDisabled) return;
await persistShipDefault(resolvedShipPrimary);
if (resolvedShipPrimary === "merge") {
if (mergeDisabled) return;
mergeMutation.mutate();
return;
}
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
if (prDisabled) return;
prMutation.mutate();
}}
disabled={shipPrimaryDisabled}
accessibilityRole="button"
accessibilityLabel="Ship changes"
>
{shipPrimaryPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} />
) : (
<>
<Text style={styles.shipPrimaryText}>
{resolvedShipPrimary === "merge" ? "Merge" : prActionLabel}
</Text>
<Text style={styles.shipSecondaryText} numberOfLines={1}>
into {baseRefLabel}
</Text>
</>
)}
</Pressable>
<Pressable
testID="changes-ship-caret"
onPress={handleOpenShipMenu}
style={styles.shipCaretButton}
accessibilityRole="button"
accessibilityLabel="More ship options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
) : null}
<Pressable
testID="changes-overflow-menu"
onPress={handleOpenOverflowMenu}
@@ -871,12 +1019,21 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prStatus ? (
<View style={styles.prStatusRow} testID="changes-pr-status">
<Pressable
style={styles.prStatusRow}
testID="changes-pr-status"
accessibilityRole="button"
accessibilityLabel="Open pull request"
onPress={() => {
if (!prStatus.url) return;
void Linking.openURL(prStatus.url);
}}
>
<Text style={styles.prStatusLabel}>PR</Text>
<Text style={styles.prStatusValue}>
{prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""}
</Text>
</View>
</Pressable>
) : null}
{prErrorMessage ? (
<Text style={styles.actionErrorText}>{prErrorMessage}</Text>
@@ -925,56 +1082,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
<View style={styles.menuDivider} />
{showCommitAction && primaryAction?.key !== "commit" ? (
<Pressable
testID="changes-menu-commit"
style={[styles.menuItem, commitDisabled && styles.menuItemDisabled]}
onPress={() => {
if (commitDisabled) return;
handleCloseOverflowMenu();
commitMutation.mutate();
}}
disabled={commitDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Commit</Text>
</Pressable>
) : null}
{showCreatePrAction && primaryAction?.key !== "create_pr" ? (
<Pressable
testID="changes-menu-create-pr"
style={[styles.menuItem, prDisabled && styles.menuItemDisabled]}
onPress={() => {
if (prDisabled) return;
handleCloseOverflowMenu();
prMutation.mutate();
}}
disabled={prDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Create PR</Text>
</Pressable>
) : null}
{showMergeAction && primaryAction?.key !== "merge" ? (
<Pressable
testID="changes-menu-merge"
style={[styles.menuItem, mergeDisabled && styles.menuItemDisabled]}
onPress={() => {
if (mergeDisabled) return;
handleCloseOverflowMenu();
mergeMutation.mutate();
}}
disabled={mergeDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Merge to base</Text>
</Pressable>
) : null}
<View style={styles.menuDivider} />
<Pressable
testID="changes-menu-archive"
style={[
@@ -998,6 +1105,130 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
) : null}
</View>
</Modal>
<Modal
visible={viewMenuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseViewMenu}
>
<View style={styles.menuOverlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="View menu backdrop"
style={styles.menuBackdrop}
onPress={handleCloseViewMenu}
/>
{viewMenuPosition ? (
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: viewMenuPosition.top,
left: viewMenuPosition.left,
width: viewMenuPosition.width,
},
]}
onLayout={handleViewMenuLayout}
>
<Pressable
testID="changes-mode-uncommitted"
style={[styles.menuItem, diffMode === "uncommitted" && styles.menuItemSelected]}
onPress={() => {
handleCloseViewMenu();
setDiffMode("uncommitted");
}}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Working</Text>
</Pressable>
<Pressable
testID="changes-mode-base"
style={[styles.menuItem, diffMode === "base" && styles.menuItemSelected]}
onPress={() => {
handleCloseViewMenu();
setDiffMode("base");
}}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Base</Text>
</Pressable>
</View>
) : null}
</View>
</Modal>
<Modal
visible={shipMenuVisible}
animationType="fade"
transparent={true}
onRequestClose={handleCloseShipMenu}
>
<View style={styles.menuOverlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Ship menu backdrop"
style={styles.menuBackdrop}
onPress={handleCloseShipMenu}
/>
{shipMenuPosition ? (
<View
style={[
styles.dropdownMenu,
{
position: "absolute",
top: shipMenuPosition.top,
left: shipMenuPosition.left,
width: shipMenuPosition.width,
},
]}
onLayout={handleShipMenuLayout}
>
<Pressable
testID="changes-ship-merge"
style={[styles.menuItem, mergeDisabled && styles.menuItemDisabled]}
onPress={() => {
if (mergeDisabled) return;
handleCloseShipMenu();
void persistShipDefault("merge");
mergeMutation.mutate();
}}
disabled={mergeDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>Merge into {baseRefLabel}</Text>
</Pressable>
{mergeDisabled && hasUncommittedChanges ? (
<Text style={styles.menuHintText}>Requires a clean working tree.</Text>
) : null}
<View style={styles.menuDivider} />
<Pressable
testID={hasPullRequest ? "changes-ship-open-pr" : "changes-ship-create-pr"}
style={[styles.menuItem, prDisabled && styles.menuItemDisabled]}
onPress={() => {
if (prDisabled) return;
handleCloseShipMenu();
void persistShipDefault("pr");
if (hasPullRequest && prStatus?.url) {
void Linking.openURL(prStatus.url);
return;
}
prMutation.mutate();
}}
disabled={prDisabled}
accessibilityRole="button"
>
<Text style={styles.menuItemText}>
{hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`}
</Text>
</Pressable>
</View>
) : null}
</View>
</Modal>
</View>
);
}
@@ -1030,29 +1261,21 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium,
flexShrink: 1,
},
modeToggle: {
viewSelector: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[1],
gap: theme.spacing[1],
},
modeToggleButton: {
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
borderRadius: theme.borderRadius.lg,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
modeToggleButtonActive: {
backgroundColor: theme.colors.surface0,
},
modeToggleText: {
viewSelectorText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.medium,
},
modeToggleTextActive: {
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
toolbarRow: {
flexDirection: "row",
@@ -1062,12 +1285,12 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
toolbarPrimary: {
toolbarLeft: {
flex: 1,
minWidth: 0,
},
toolbarPrimarySpacer: {
height: 32,
toolbarLeftSpacer: {
height: 36,
},
toolbarRight: {
flexDirection: "row",
@@ -1075,7 +1298,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1],
flexShrink: 0,
},
primaryActionButton: {
secondaryActionButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md,
@@ -1084,14 +1307,48 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent,
alignSelf: "flex-start",
},
primaryActionButtonDisabled: {
secondaryActionButtonDisabled: {
opacity: 0.5,
},
primaryActionText: {
secondaryActionText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
shipSplitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface2,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
shipPrimaryButton: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minWidth: 140,
},
shipPrimaryButtonDisabled: {
opacity: 0.6,
},
shipPrimaryText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
shipSecondaryText: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
shipCaretButton: {
width: 36,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
iconButton: {
width: 32,
height: 32,
@@ -1128,6 +1385,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
menuItemSelected: {
backgroundColor: theme.colors.surface2,
},
menuItemDisabled: {
opacity: 0.5,
},
@@ -1136,6 +1396,12 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
},
menuHintText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
menuItemDestructive: {
backgroundColor: "rgba(248, 81, 73, 0.08)",
},