From 2b55d3ae6a4dba44abd5696d79d52bbb1a74426d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 23 Jan 2026 22:59:31 +0700 Subject: [PATCH] Add Changes overflow menu and primary action toolbar --- packages/app/e2e/checkout-ship.spec.ts | 14 +- packages/app/src/components/git-diff-pane.tsx | 399 +++++++++++++++--- 2 files changed, 340 insertions(+), 73 deletions(-) diff --git a/packages/app/e2e/checkout-ship.spec.ts b/packages/app/e2e/checkout-ship.spec.ts index d4965cce7..28bc9bc90 100644 --- a/packages/app/e2e/checkout-ship.spec.ts +++ b/packages/app/e2e/checkout-ship.spec.ts @@ -29,6 +29,12 @@ function getChangesActionButton(page: Page, label: string) { return getChangesActionLabel(page, label).locator('..'); } +async function openChangesOverflowMenu(page: Page) { + const menuButton = page.getByTestId('changes-overflow-menu').first(); + await expect(menuButton).toBeVisible(); + await menuButton.click(); +} + async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) { const changesHeader = getChangesHeader(page); if (!(await changesHeader.isVisible())) { @@ -306,7 +312,9 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await refreshChangesTab(page); await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0, { timeout: 30000 }); - await getChangesActionButton(page, 'Archive').click(); + await openChangesOverflowMenu(page); + await expect(page.getByTestId('changes-menu-archive')).toBeVisible(); + await page.getByTestId('changes-menu-archive').click(); // Archiving a worktree deletes agents and redirects to home await expect(page).toHaveURL(/\/$/, { timeout: 30000 }); await setWorkingDirectory(page, repo.path); @@ -331,9 +339,7 @@ test('checkout-first Changes panel ship loop', async ({ page }) => { await waitForAssistantText(page, 'NON-GIT'); await openChangesPanel(page, { expectGit: false }); await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible(); - await expect(getChangesActionButton(page, 'Commit')).toHaveAttribute('aria-disabled', 'true'); - await expect(getChangesActionButton(page, 'Create PR')).toHaveAttribute('aria-disabled', 'true'); - await expect(getChangesActionLabel(page, 'Merge to base')).toHaveCount(0); + await expect(getChangesScope(page).getByTestId('changes-toolbar')).toHaveCount(0); } finally { await rm(nonGitDir, { recursive: true, force: true }); await repo.cleanup(); diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 33a669536..0714f00ee 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -6,13 +6,16 @@ import { ActivityIndicator, Pressable, FlatList, + Modal, + useWindowDimensions, + type LayoutChangeEvent, type NativeSyntheticEvent, type NativeScrollEvent, type ListRenderItem, } from "react-native"; import { ScrollView, type ScrollView as ScrollViewType } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronRight, GitBranch } from "lucide-react-native"; +import { ChevronRight, GitBranch, MoreVertical, RotateCcw } from "lucide-react-native"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSessionStore } from "@/stores/session-store"; import { @@ -348,12 +351,16 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { const { theme } = useUnistyles(); const router = useRouter(); const queryClient = useQueryClient(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted"); const [actionError, setActionError] = useState(null); const [actionStatus, setActionStatus] = useState(null); + const [overflowVisible, setOverflowVisible] = useState(false); + const [overflowAnchor, setOverflowAnchor] = useState({ left: 0, top: 0 }); + const [overflowHeight, setOverflowHeight] = useState(0); const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } = useCheckoutStatusQuery({ serverId, agentId }); const gitStatus = status && status.isGit ? status : null; @@ -421,6 +428,25 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { void refreshPrStatus(); }, [refreshDiff, refreshStatus, refreshPrStatus]); + const handleOpenOverflowMenu = useCallback((event: any) => { + const { pageX, pageY } = event?.nativeEvent ?? {}; + setOverflowAnchor({ + left: typeof pageX === "number" ? pageX : 0, + top: typeof pageY === "number" ? pageY : 0, + }); + setOverflowVisible(true); + }, []); + + const handleCloseOverflowMenu = useCallback(() => { + setOverflowVisible(false); + setOverflowHeight(0); + }, []); + + const handleOverflowLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout; + setOverflowHeight((current) => (current === height ? current : height)); + }, []); + const handleToggleExpanded = useCallback((path: string) => { setExpandedByPath((prev) => ({ ...prev, @@ -672,6 +698,88 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { ); } + type PrimaryAction = { + key: "commit" | "create_pr" | "merge"; + label: string; + testID: string; + disabled: boolean; + isPending: boolean; + onPress: () => void; + }; + + const primaryAction: PrimaryAction | null = useMemo(() => { + if (!isGit) { + return null; + } + 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 overflowPosition = useMemo(() => { + if (!overflowVisible) { + 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 - overflowHeight - verticalPadding); + const left = Math.min( + Math.max(overflowAnchor.left - menuWidth + horizontalPadding, horizontalPadding), + maxLeft + ); + const top = Math.min(Math.max(overflowAnchor.top + verticalPadding, verticalPadding), maxTop); + return { top, left, width: menuWidth }; + }, [ + overflowAnchor.left, + overflowAnchor.top, + overflowHeight, + overflowVisible, + theme.spacing, + windowHeight, + windowWidth, + ]); + return ( @@ -722,67 +830,43 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { - - {showCommitAction ? ( - commitMutation.mutate()} - disabled={commitDisabled} - > - {commitMutation.isPending ? ( - + {isGit ? ( + + + {primaryAction ? ( + + {primaryAction.isPending ? ( + + ) : ( + {primaryAction.label} + )} + ) : ( - Commit + )} - - ) : null} - {showCreatePrAction ? ( - prMutation.mutate()} - disabled={prDisabled} - > - {prMutation.isPending ? ( - - ) : ( - Create PR - )} - - ) : null} - {showMergeAction ? ( - mergeMutation.mutate()} - disabled={mergeDisabled} - > - {mergeMutation.isPending ? ( - - ) : ( - Merge to base - )} - - ) : null} - archiveMutation.mutate()} - disabled={archiveDisabled} - > - {archiveMutation.isPending ? ( - - ) : ( - Archive - )} - - - - - mode={diffMode} files={files.length} isGit={String(isGit)} ahead= - {gitStatus?.aheadBehind ? String(gitStatus.aheadBehind.ahead) : "null"} - + + + + + + + + ) : null} {actionStatus ? {actionStatus} : null} {actionError ? {actionError} : null} @@ -799,6 +883,121 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) { ) : null} {bodyContent} + + + + + {overflowPosition ? ( + + { + handleCloseOverflowMenu(); + handleRefresh(); + }} + accessibilityRole="button" + > + + Refresh + + + + + {showCommitAction && primaryAction?.key !== "commit" ? ( + { + if (commitDisabled) return; + handleCloseOverflowMenu(); + commitMutation.mutate(); + }} + disabled={commitDisabled} + accessibilityRole="button" + > + Commit + + ) : null} + + {showCreatePrAction && primaryAction?.key !== "create_pr" ? ( + { + if (prDisabled) return; + handleCloseOverflowMenu(); + prMutation.mutate(); + }} + disabled={prDisabled} + accessibilityRole="button" + > + Create PR + + ) : null} + + {showMergeAction && primaryAction?.key !== "merge" ? ( + { + if (mergeDisabled) return; + handleCloseOverflowMenu(); + mergeMutation.mutate(); + }} + disabled={mergeDisabled} + accessibilityRole="button" + > + Merge to base + + ) : null} + + + + { + if (archiveDisabled) return; + handleCloseOverflowMenu(); + archiveMutation.mutate(); + }} + disabled={archiveDisabled} + accessibilityRole="button" + > + + Archive worktree + + + + ) : null} + + ); } @@ -855,35 +1054,97 @@ const styles = StyleSheet.create((theme) => ({ modeToggleTextActive: { color: theme.colors.foreground, }, - actionsRow: { + toolbarRow: { flexDirection: "row", - flexWrap: "wrap", + alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, - actionButton: { + toolbarPrimary: { + flex: 1, + minWidth: 0, + }, + toolbarPrimarySpacer: { + height: 32, + }, + toolbarRight: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + flexShrink: 0, + }, + primaryActionButton: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, + alignSelf: "flex-start", }, - actionButtonDisabled: { + primaryActionButtonDisabled: { opacity: 0.5, }, - actionButtonText: { + primaryActionText: { fontSize: theme.fontSize.xs, color: theme.colors.foreground, fontWeight: theme.fontWeight.medium, }, - debugText: { + iconButton: { + width: 32, + height: 32, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.md, + }, + menuOverlay: { + flex: 1, + }, + menuBackdrop: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + dropdownMenu: { + backgroundColor: theme.colors.surface0, + borderWidth: 1, + borderColor: theme.colors.borderAccent, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 8, + }, + menuItem: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[1], - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, + paddingVertical: theme.spacing[2], + }, + menuItemDisabled: { + opacity: 0.5, + }, + menuItemText: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + fontWeight: theme.fontWeight.medium, + }, + menuItemDestructive: { + backgroundColor: "rgba(248, 81, 73, 0.08)", + }, + menuItemTextDestructive: { + color: theme.colors.destructive, + }, + menuDivider: { + height: 1, + backgroundColor: theme.colors.border, }, actionStatusText: { paddingHorizontal: theme.spacing[3],