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('..'); 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) { 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 expect(menuButton).toBeVisible();
await menuButton.click(); 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 }) { async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
const changesHeader = getChangesHeader(page); const changesHeader = getChangesHeader(page);
if (!(await changesHeader.isVisible())) { if (!(await changesHeader.isVisible())) {
@@ -114,6 +135,8 @@ async function createAgentAndWait(page: Page, message: string) {
async function requestCwd(page: Page) { async function requestCwd(page: Page) {
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>'); await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
const message = await waitForAssistantText(page, 'CWD:'); 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 content = (await message.textContent()) ?? '';
const match = content.match(/CWD:\s*(\S+)/); const match = content.match(/CWD:\s*(\S+)/);
if (!match) { if (!match) {
@@ -168,9 +191,8 @@ async function enableCreateWorktree(page: Page) {
} }
async function refreshUncommittedMode(page: Page) { async function refreshUncommittedMode(page: Page) {
const changesScope = getChangesScope(page); await selectChangesView(page, 'base');
await changesScope.getByTestId('changes-mode-base').click(); await selectChangesView(page, 'working');
await changesScope.getByTestId('changes-mode-uncommitted').click();
} }
async function refreshChangesTab(page: Page) { 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 getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
await expect(page.getByText('First change')).toBeVisible(); 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-action-commit').click();
await getChangesScope(page).getByTestId('changes-mode-uncommitted').click(); await selectChangesView(page, 'working');
await refreshChangesTab(page); await refreshChangesTab(page);
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
timeout: 30000, 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({ await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
@@ -277,19 +299,19 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000, timeout: 30000,
}); });
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0); 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({ await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
await getChangesActionButton(page, 'Commit').click(); await getChangesScope(page).getByTestId('changes-action-commit').click();
await getChangesScope(page).getByTestId('changes-mode-uncommitted').click(); await selectChangesView(page, 'working');
await expect(page.getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 }); 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({ await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
@@ -297,20 +319,21 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
timeout: 30000, timeout: 30000,
}); });
await expect(getChangesActionLabel(page, 'Create PR')).toBeVisible(); await openChangesShipMenu(page);
await getChangesActionButton(page, 'Create PR').click(); await page.getByTestId('changes-ship-create-pr').click();
await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, { await expect(getChangesScope(page).getByTestId('changes-pr-status')).toContainText(/open/i, {
timeout: 60000, 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 page.getByTestId('changes-ship-merge').click();
await getChangesScope(page).getByTestId('changes-mode-base').click(); await selectChangesView(page, 'base');
await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({ await expect(getChangesScope(page).getByText('No base changes')).toBeVisible({
timeout: 60000, timeout: 60000,
}); });
await refreshChangesTab(page); 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 openChangesOverflowMenu(page);
await expect(page.getByTestId('changes-menu-archive')).toBeVisible(); await expect(page.getByTestId('changes-menu-archive')).toBeVisible();
+445 -179
View File
@@ -15,7 +15,9 @@ import {
} from "react-native"; } from "react-native";
import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import {
@@ -361,6 +363,13 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
const [overflowVisible, setOverflowVisible] = useState(false); const [overflowVisible, setOverflowVisible] = useState(false);
const [overflowAnchor, setOverflowAnchor] = useState({ left: 0, top: 0 }); const [overflowAnchor, setOverflowAnchor] = useState({ left: 0, top: 0 });
const [overflowHeight, setOverflowHeight] = useState(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 } = const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
useCheckoutStatusQuery({ serverId, agentId }); useCheckoutStatusQuery({ serverId, agentId });
const gitStatus = status && status.isGit ? status : null; const gitStatus = status && status.isGit ? status : null;
@@ -428,6 +437,44 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
void refreshPrStatus(); void refreshPrStatus();
}, [refreshDiff, refreshStatus, 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 handleOpenOverflowMenu = useCallback((event: any) => {
const { pageX, pageY } = event?.nativeEvent ?? {}; const { pageX, pageY } = event?.nativeEvent ?? {};
setOverflowAnchor({ setOverflowAnchor({
@@ -447,6 +494,44 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
setOverflowHeight((current) => (current === height ? current : height)); 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) => { const handleToggleExpanded = useCallback((path: string) => {
setExpandedByPath((prev) => ({ setExpandedByPath((prev) => ({
...prev, ...prev,
@@ -624,14 +709,17 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown");
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
const showCommitAction = const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
!isGit || hasUncommittedChanges || (diffMode === "uncommitted" && hasChanges); const canShowCommit = isGit && hasUncommittedChanges;
const showCreatePrAction = !isGit || !prStatus; const canShowShip = isGit && aheadCount > 0;
const showMergeAction = const baseRefLabel = useMemo(() => {
diffMode === "base" && isGit && (gitStatus?.aheadBehind?.ahead ?? 0) > 0; 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 commitDisabled = actionsDisabled || commitMutation.isPending;
const prDisabled = actionsDisabled || prMutation.isPending; const prDisabled = actionsDisabled || prMutation.isPending;
const mergeDisabled = actionsDisabled || mergeMutation.isPending; const mergeDisabled = actionsDisabled || mergeMutation.isPending || hasUncommittedChanges;
const archiveDisabled = const archiveDisabled =
actionsDisabled || actionsDisabled ||
archiveMutation.isPending || archiveMutation.isPending ||
@@ -698,62 +786,43 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
); );
} }
type PrimaryAction = { const hasPullRequest = Boolean(prStatus?.url);
key: "commit" | "create_pr" | "merge"; const prActionLabel = hasPullRequest ? "Open PR" : "Create PR";
label: string;
testID: string;
disabled: boolean;
isPending: boolean;
onPress: () => void;
};
const primaryAction: PrimaryAction | null = useMemo(() => { type ShipActionKey = "merge" | "pr";
if (!isGit) { const shipActions: { key: ShipActionKey; label: string; disabled: boolean; isPending: boolean }[] =
return null; 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) { const fallback = shipActions.find((action) => !action.disabled);
return { return fallback?.key ?? preferred;
key: "commit", }, [canShowShip, shipActions, shipDefault]);
label: "Commit",
testID: "changes-primary-commit", const shipPrimaryDisabled = useMemo(() => {
disabled: commitDisabled, if (!resolvedShipPrimary) return true;
isPending: commitMutation.isPending, if (actionsDisabled) return true;
onPress: () => commitMutation.mutate(), const meta = shipActions.find((action) => action.key === resolvedShipPrimary);
}; return meta?.disabled ?? true;
} }, [actionsDisabled, resolvedShipPrimary, shipActions]);
if (showCreatePrAction) {
return { const shipPrimaryPending = useMemo(() => {
key: "create_pr", if (!resolvedShipPrimary) return false;
label: "Create PR", if (resolvedShipPrimary === "merge") return mergeMutation.isPending;
testID: "changes-primary-create-pr", // "pr" maps to open PR (instant) or create PR (mutation)
disabled: prDisabled, return hasPullRequest ? false : prMutation.isPending;
isPending: prMutation.isPending, }, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]);
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 overflowPosition = useMemo(() => { const overflowPosition = useMemo(() => {
if (!overflowVisible) { if (!overflowVisible) {
@@ -780,6 +849,56 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
windowWidth, 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 ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.header} testID="changes-header"> <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}> <Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel} {branchLabel}
</Text> </Text>
{isStatusFetching && ( {isStatusFetching && (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} /> <ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
)} )}
</View> </View>
<View style={styles.modeToggle}> {isGit ? (
<Pressable <Pressable
testID="changes-mode-uncommitted" testID="changes-view-selector"
onPress={() => setDiffMode("uncommitted")} onPress={handleOpenViewMenu}
style={[ style={styles.viewSelector}
styles.modeToggleButton, accessibilityRole="button"
diffMode === "uncommitted" && styles.modeToggleButtonActive, accessibilityLabel="Change diff view"
]}
> >
<Text <Text style={styles.viewSelectorText}>
style={[ {diffMode === "uncommitted" ? "Working" : "Base"}
styles.modeToggleText,
diffMode === "uncommitted" && styles.modeToggleTextActive,
]}
>
Uncommitted
</Text> </Text>
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
<Pressable ) : null}
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>
</View> </View>
{isGit ? ( {isGit ? (
<View style={styles.toolbarRow} testID="changes-toolbar"> <View style={styles.toolbarRow} testID="changes-toolbar">
<View style={styles.toolbarPrimary}> <View style={styles.toolbarLeft}>
{primaryAction ? ( {canShowCommit ? (
<Pressable <Pressable
testID={primaryAction.testID} testID="changes-action-commit"
style={[ style={[
styles.primaryActionButton, styles.secondaryActionButton,
primaryAction.disabled && styles.primaryActionButtonDisabled, commitDisabled && styles.secondaryActionButtonDisabled,
]} ]}
onPress={primaryAction.onPress} onPress={() => commitMutation.mutate()}
disabled={primaryAction.disabled} disabled={commitDisabled}
> >
{primaryAction.isPending ? ( {commitMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foreground} /> <ActivityIndicator size="small" color={theme.colors.foreground} />
) : ( ) : (
<Text style={styles.primaryActionText}>{primaryAction.label}</Text> <Text style={styles.secondaryActionText}>Commit…</Text>
)} )}
</Pressable> </Pressable>
) : ( ) : (
<View style={styles.toolbarPrimarySpacer} /> <View style={styles.toolbarLeftSpacer} />
)} )}
</View> </View>
<View style={styles.toolbarRight}> <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 <Pressable
testID="changes-overflow-menu" testID="changes-overflow-menu"
onPress={handleOpenOverflowMenu} onPress={handleOpenOverflowMenu}
@@ -871,12 +1019,21 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
{actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null} {actionStatus ? <Text style={styles.actionStatusText}>{actionStatus}</Text> : null}
{actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null} {actionError ? <Text style={styles.actionErrorText}>{actionError}</Text> : null}
{prStatus ? ( {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.prStatusLabel}>PR</Text>
<Text style={styles.prStatusValue}> <Text style={styles.prStatusValue}>
{prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""} {prStatus.state} {prStatus.url ? `· ${prStatus.url}` : ""}
</Text> </Text>
</View> </Pressable>
) : null} ) : null}
{prErrorMessage ? ( {prErrorMessage ? (
<Text style={styles.actionErrorText}>{prErrorMessage}</Text> <Text style={styles.actionErrorText}>{prErrorMessage}</Text>
@@ -925,56 +1082,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
<View style={styles.menuDivider} /> <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 <Pressable
testID="changes-menu-archive" testID="changes-menu-archive"
style={[ style={[
@@ -998,6 +1105,130 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
) : null} ) : null}
</View> </View>
</Modal> </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> </View>
); );
} }
@@ -1030,29 +1261,21 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
flexShrink: 1, flexShrink: 1,
}, },
modeToggle: { viewSelector: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[1],
gap: theme.spacing[1], gap: theme.spacing[1],
},
modeToggleButton: {
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], 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: { viewSelectorText: {
backgroundColor: theme.colors.surface0,
},
modeToggleText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
fontWeight: theme.fontWeight.medium,
},
modeToggleTextActive: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium,
}, },
toolbarRow: { toolbarRow: {
flexDirection: "row", flexDirection: "row",
@@ -1062,12 +1285,12 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1], paddingBottom: theme.spacing[1],
}, },
toolbarPrimary: { toolbarLeft: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
toolbarPrimarySpacer: { toolbarLeftSpacer: {
height: 32, height: 36,
}, },
toolbarRight: { toolbarRight: {
flexDirection: "row", flexDirection: "row",
@@ -1075,7 +1298,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[1], gap: theme.spacing[1],
flexShrink: 0, flexShrink: 0,
}, },
primaryActionButton: { secondaryActionButton: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
@@ -1084,14 +1307,48 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.borderAccent, borderColor: theme.colors.borderAccent,
alignSelf: "flex-start", alignSelf: "flex-start",
}, },
primaryActionButtonDisabled: { secondaryActionButtonDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
primaryActionText: { secondaryActionText: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
color: theme.colors.foreground, color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium, 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: { iconButton: {
width: 32, width: 32,
height: 32, height: 32,
@@ -1128,6 +1385,9 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
}, },
menuItemSelected: {
backgroundColor: theme.colors.surface2,
},
menuItemDisabled: { menuItemDisabled: {
opacity: 0.5, opacity: 0.5,
}, },
@@ -1136,6 +1396,12 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground, color: theme.colors.foreground,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
}, },
menuHintText: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
menuItemDestructive: { menuItemDestructive: {
backgroundColor: "rgba(248, 81, 73, 0.08)", backgroundColor: "rgba(248, 81, 73, 0.08)",
}, },