feat(checkout): merge PR action with real-GitHub e2e test (#814)

* feat(checkout): merge PR action with real-GitHub e2e test

Adds a `checkoutPrMerge` server RPC backed by `gh pr merge` (squash, merge,
or rebase) and surfaces three new actions in the git-actions menu —
"Squash and merge", "Create a merge commit", "Rebase and merge" — with
squash taking the primary slot when a PR is open and mergeable. Also
renames the existing local merge action to "Merge locally" to disambiguate
it from the new GitHub-side merge.

Adds an end-to-end test that creates a real temporary GitHub repo, opens a
PR via the daemon, polls until mergeable, calls `checkoutPrMerge`, and
verifies the merge landed on `main` — no mocks. The test pre-flights the
`delete_repo` scope so it skips cleanly on tokens that can't tear down,
and fails loud if cleanup ever errors so we don't leak repos.

PR status gains an optional `mergeable` field (`MERGEABLE | CONFLICTING |
UNKNOWN`) populated from `gh pr view --json mergeable`. The schema is
backward compatible — old payloads without the field still parse and
default to `UNKNOWN` via `z.catch`.

* Update PR status snapshot expectations

* refactor: collect git/checkout/PR feature into packages/app/src/git/

Move 21 git/checkout/PR files out of components/, hooks/, stores/, screens/,
utils/ flat-peer slop into a single packages/app/src/git/ module with
shrunk filenames (git-diff-pane.tsx -> diff-pane.tsx, use-git-actions.ts ->
use-actions.ts, etc).

Dedupe: drop the duplicate useGitActionHandlers from diff-pane.tsx — the
shared useGitActions hook is the single source. Drop the
buildGitActionsForPane wrapper. Collapse the merge-PR triple fan-out
(mergePrSquash/Merge/RebaseStatus) into one record keyed by
CheckoutPrMergeMethod. Streamline server-side single-use helpers
(dispatchStash/PullRequestMessage adapters, GitHubPullRequestMergeError
wrapper, legacy worktree test helpers).

Schema-additive only: messages.ts exports CheckoutPrMergeMethod and
PullRequestMergeable types; daemon-client.ts uses the typed alias.
Backward-compatible.

Net delta: 43 files, +219/-1212.

* fix(app): use GitHub icon for all merge-PR menu actions

* test: namespace temp GitHub repos under `paseotmp-` and centralize naming

Old prefixes (`paseo-checkout-ship-`, `paseo-e2e-`, `paseo-checkout-pr-merge-`)
collided with the `paseo-*` namespace of real repos, making bulk cleanup
unsafe. Switch every test that creates real GitHub repos to a single
unmistakable `paseotmp-` prefix, owned by one shared helper per test
boundary so no caller can name a repo outside the namespace.

- New `packages/server/src/server/test-utils/temp-github-repo.ts` exports
  `TEMP_GITHUB_REPO_PREFIX` + `createTempGithubRepoName(category)`; both
  server e2e tests (checkout-ship, checkout-pr-merge) consume it.
- `packages/app/e2e/helpers/github-fixtures.ts` locks the prefix internally
  and now requires callers to pass a `category` instead of an arbitrary
  `prefix`. Updated pr-pane and composer-attachments specs accordingly.
This commit is contained in:
Mohamed Boudra
2026-05-09 15:39:26 +08:00
committed by GitHub
parent c097678cdf
commit ed07452c80
63 changed files with 1204 additions and 907 deletions
@@ -13,7 +13,7 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X } from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { PrPane } from "./pr-pane";
import { PrPane } from "@/git/pr-pane";
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
import {
usePanelStore,
@@ -24,7 +24,7 @@ import {
} from "@/stores/panel-store";
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane";
import { GitDiffPane } from "@/git/diff-pane";
import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useWindowControlsPadding } from "@/utils/desktop-window";
@@ -1,216 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
isGit: true,
githubFeaturesEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
hasUncommittedChanges: false,
baseRefAvailable: true,
baseRefLabel: "main",
aheadCount: 0,
behindBaseCount: 0,
aheadOfOrigin: 0,
behindOfOrigin: 0,
shouldPromoteArchive: false,
shipDefault: "merge",
runtime: {
commit: {
disabled: false,
status: "idle",
handler: () => undefined,
},
pull: {
disabled: false,
status: "idle",
handler: () => undefined,
},
push: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"pull-and-push": {
disabled: false,
status: "idle",
handler: () => undefined,
},
pr: {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-from-base": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"archive-worktree": {
disabled: false,
status: "idle",
handler: () => undefined,
},
},
...overrides,
};
}
describe("git-actions-policy", () => {
it("shows only remote sync actions on the base branch", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
expect(actions.secondary.map((action) => action.id)).toEqual(["pull", "push", "pull-and-push"]);
});
it("prioritizes pull when the branch is behind origin", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.primary).toMatchObject({ id: "pull", label: "Pull" });
});
it("keeps push clickable with a clearer message when the branch diverged", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 1,
behindOfOrigin: 1,
}),
);
const pushAction = actions.secondary.find((action) => action.id === "push");
expect(pushAction).toMatchObject({
disabled: false,
unavailableMessage:
"Push isn't available yet because there are newer changes to bring in first",
});
});
it("shows update-from-base only on feature branches that are behind the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
behindBaseCount: 3,
}),
);
const updateAction = actions.secondary.find((action) => action.id === "merge-from-base");
expect(updateAction).toMatchObject({
label: "Update from main",
disabled: false,
unavailableMessage: undefined,
});
});
it("uses a clear sentence when pull is unavailable", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const pullAction = actions.secondary.find((action) => action.id === "pull");
expect(pullAction).toMatchObject({
disabled: false,
unavailableMessage: "Pull isn't available because this branch is already up to date",
});
});
it("keeps update-from-base off the base branch entirely", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
behindOfOrigin: 2,
}),
);
expect(actions.secondary.some((action) => action.id === "merge-from-base")).toBe(false);
});
it("keeps feature branch actions available off the base branch", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
behindBaseCount: 1,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
}),
);
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
).toBe(true);
});
it("enables pull-and-push when the branch has both incoming and outgoing commits", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
aheadOfOrigin: 2,
behindOfOrigin: 3,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
label: "Pull and push",
disabled: false,
unavailableMessage: undefined,
});
});
it("explains why pull-and-push is unavailable when the branch is in sync", () => {
const actions = buildGitActions(createInput({ hasRemote: true }));
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action).toMatchObject({
disabled: false,
unavailableMessage: "Pull and push isn't available because this branch is already in sync",
});
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
hasUncommittedChanges: true,
aheadOfOrigin: 1,
}),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available while you have local changes so commit or stash them first",
);
});
it("only shows archive worktree for paseo worktrees", () => {
const hidden = buildGitActions(createInput());
const shown = buildGitActions(createInput({ isPaseoOwnedWorktree: true }));
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
});
@@ -1,330 +0,0 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
export type GitActionId =
| "commit"
| "pull"
| "push"
| "pull-and-push"
| "pr"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
export interface GitAction {
id: GitActionId;
label: string;
pendingLabel: string;
successLabel: string;
disabled: boolean;
status: ActionStatus;
unavailableMessage?: string;
icon?: ReactElement;
handler: () => void;
}
export interface GitActions {
primary: GitAction | null;
secondary: GitAction[];
menu: GitAction[];
}
interface GitActionRuntimeState {
disabled: boolean;
status: ActionStatus;
icon?: ReactElement;
handler: () => void;
}
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
hasUncommittedChanges: boolean;
baseRefAvailable: boolean;
baseRefLabel: string;
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
shouldPromoteArchive: boolean;
shipDefault: "merge" | "pr";
runtime: Record<GitActionId, GitActionRuntimeState>;
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
return { primary: null, secondary: [], menu: [] };
}
const allActions = new Map<GitActionId, GitAction>();
allActions.set("commit", {
id: "commit",
label: "Commit",
pendingLabel: "Committing...",
successLabel: "Committed",
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
handler: input.runtime.commit.handler,
});
allActions.set("pull", {
id: "pull",
label: "Pull",
pendingLabel: "Pulling...",
successLabel: "Pulled",
disabled: input.runtime.pull.disabled,
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
handler: input.runtime.pull.handler,
});
allActions.set("push", {
id: "push",
label: "Push",
pendingLabel: "Pushing...",
successLabel: "Pushed",
disabled: input.runtime.push.disabled,
status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon,
handler: input.runtime.push.handler,
});
allActions.set("pull-and-push", {
id: "pull-and-push",
label: "Pull and push",
pendingLabel: "Pulling and pushing...",
successLabel: "Pulled and pushed",
disabled: input.runtime["pull-and-push"].disabled,
status: input.runtime["pull-and-push"].status,
unavailableMessage: input.runtime["pull-and-push"].disabled
? undefined
: getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon,
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`,
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled,
status: input.runtime["merge-branch"].status,
unavailableMessage: input.runtime["merge-branch"].disabled
? undefined
: getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
handler: input.runtime["merge-branch"].handler,
});
allActions.set("merge-from-base", {
id: "merge-from-base",
label: `Update from ${input.baseRefLabel}`,
pendingLabel: "Updating...",
successLabel: "Updated",
disabled: input.runtime["merge-from-base"].disabled,
status: input.runtime["merge-from-base"].status,
unavailableMessage: input.runtime["merge-from-base"].disabled
? undefined
: getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
handler: input.runtime["merge-from-base"].handler,
});
allActions.set("archive-worktree", {
id: "archive-worktree",
label: "Archive worktree",
pendingLabel: "Archiving...",
successLabel: "Archived",
disabled: input.runtime["archive-worktree"].disabled,
status: input.runtime["archive-worktree"].status,
unavailableMessage:
input.runtime["archive-worktree"].disabled || input.isPaseoOwnedWorktree
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon,
handler: input.runtime["archive-worktree"].handler,
});
const primaryActionId = getPrimaryActionId(input);
const primary = primaryActionId ? (allActions.get(primaryActionId) ?? null) : null;
const secondaryIds = [...REMOTE_ACTION_IDS];
if (!input.isOnBaseBranch) {
secondaryIds.push(...FEATURE_ACTION_IDS);
}
if (input.isPaseoOwnedWorktree) {
secondaryIds.push("archive-worktree");
}
return {
primary,
secondary: secondaryIds.map((id) => allActions.get(id)!),
menu: [],
};
}
function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shouldPromoteArchive && input.isPaseoOwnedWorktree) {
return "archive-worktree";
}
if (input.hasUncommittedChanges) {
return "commit";
}
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
}
return null;
}
function buildPrAction(input: BuildGitActionsInput): GitAction {
if (input.hasPullRequest && input.pullRequestUrl) {
return {
id: "pr",
label: "View PR",
pendingLabel: "View PR",
successLabel: "View PR",
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
return {
id: "pr",
label: "Create PR",
pendingLabel: "Creating PR...",
successLabel: "PR Created",
disabled: input.runtime.pr.disabled,
status: input.runtime.pr.status,
unavailableMessage: input.runtime.pr.disabled
? undefined
: getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
handler: input.runtime.pr.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
function canPush(input: BuildGitActionsInput): boolean {
return input.hasRemote && input.aheadOfOrigin > 0 && input.behindOfOrigin === 0;
}
function canMergeFromBase(input: BuildGitActionsInput): boolean {
return (
!input.isOnBaseBranch &&
input.baseRefAvailable &&
!input.hasUncommittedChanges &&
input.behindBaseCount > 0
);
}
function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0) {
return "Pull isn't available because this branch is already up to date";
}
return undefined;
}
function getPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Push isn't available here because this branch is not connected to a remote yet";
}
if (input.behindOfOrigin > 0) {
return "Push isn't available yet because there are newer changes to bring in first";
}
if (input.aheadOfOrigin === 0) {
return "Push isn't available because there is nothing new to send";
}
return undefined;
}
function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.hasRemote) {
return "Pull and push isn't available here because this branch is not connected to a remote yet";
}
if (input.hasUncommittedChanges) {
return "Pull and push isn't available while you have local changes so commit or stash them first";
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return "Pull and push isn't available because this branch is already in sync";
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Create PR isn't available right now because GitHub isn't connected";
}
if (input.aheadCount === 0) {
return "Create PR isn't available because this branch doesn't have any new commits yet";
}
return undefined;
}
function getMergeBranchUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Merge isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Merge isn't available while you have local changes so commit or stash them first";
}
if (input.aheadCount === 0) {
return "Merge isn't available because this branch doesn't have anything new to merge yet";
}
return undefined;
}
function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.baseRefAvailable) {
return "Update isn't available because we couldn't determine the base branch";
}
if (input.hasUncommittedChanges) {
return "Update isn't available while you have local changes so commit or stash them first";
}
if (input.behindBaseCount === 0) {
return `Update isn't available because this branch is already up to date with ${input.baseRefLabel}`;
}
return undefined;
}
@@ -1,269 +0,0 @@
import { useCallback, useMemo } from "react";
import {
View,
Text,
ActivityIndicator,
Pressable,
type PressableStateCallbackType,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Shortcut } from "@/components/ui/shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useToast } from "@/contexts/toast-context";
import type { GitAction, GitActions } from "@/components/git-actions-policy";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
hideLabels?: boolean;
}
interface GitActionMenuItemProps {
action: GitAction;
onSelect: (action: GitAction) => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
needsSeparator?: boolean;
showSeparator?: boolean;
closeOnSelect?: boolean;
}
function GitActionMenuItem({
action,
onSelect,
archiveShortcutKeys,
needsSeparator,
showSeparator,
closeOnSelect,
}: GitActionMenuItemProps) {
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
const trailing = useMemo(
() =>
action.id === "archive-worktree" && archiveShortcutKeys ? (
<Shortcut chord={archiveShortcutKeys} />
) : undefined,
[action.id, archiveShortcutKeys],
);
return (
<View>
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
testID={`changes-menu-${action.id}`}
leading={action.icon}
trailing={trailing}
disabled={action.disabled}
muted={Boolean(action.unavailableMessage)}
status={action.status}
pendingLabel={action.pendingLabel}
successLabel={action.successLabel}
closeOnSelect={closeOnSelect}
onSelect={handleSelect}
>
{action.label}
</DropdownMenuItem>
</View>
);
}
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
const { theme } = useUnistyles();
const toast = useToast();
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const getActionDisplayLabel = useCallback((action: GitAction): string => {
if (action.status === "pending") return action.pendingLabel;
if (action.status === "success") return action.successLabel;
return action.label;
}, []);
const handleActionSelect = useCallback(
(action: GitAction) => {
if (action.unavailableMessage) {
toast.show(action.unavailableMessage, {
durationMs: 3200,
icon: <Info size={16} color={theme.colors.foreground} />,
});
return;
}
action.handler();
},
[theme.colors.foreground, toast],
);
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
const primaryDisabled = gitActions.primary?.disabled;
const primaryPressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
primaryDisabled && styles.splitButtonPrimaryDisabled,
],
[primaryDisabled],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
],
[],
);
return (
<View style={styles.row}>
{gitActions.primary ? (
<View style={styles.splitButton}>
<Pressable
testID="changes-primary-cta"
style={primaryPressableStyle}
onPress={gitActions.primary.handler}
disabled={gitActions.primary.disabled}
accessibilityRole="button"
accessibilityLabel={gitActions.primary.label}
>
{gitActions.primary.status === "pending" ? (
<ActivityIndicator
size="small"
color={theme.colors.foreground}
style={styles.splitButtonSpinnerOnly}
/>
) : (
<View style={styles.splitButtonContent}>
{gitActions.primary.icon}
{!hideLabels && (
<Text style={styles.splitButtonText}>
{getActionDisplayLabel(gitActions.primary)}
</Text>
)}
</View>
)}
</Pressable>
{gitActions.secondary.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-primary-cta-caret"
style={caretTriggerStyle}
accessibilityRole="button"
accessibilityLabel="More options"
>
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
{gitActions.secondary.map((action, index) => (
<GitActionMenuItem
key={action.id}
action={action}
onSelect={handleActionSelect}
archiveShortcutKeys={archiveShortcutKeys}
needsSeparator={
action.id === "merge-from-base" || action.id === "archive-worktree"
}
showSeparator={index > 0}
closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR"
}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
) : null}
{gitActions.menu.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger
testID="changes-overflow-menu"
hitSlop={8}
style={overflowMenuButtonStyle}
accessibilityRole="button"
accessibilityLabel="More actions"
>
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
{gitActions.menu.map((action) => (
<GitActionMenuItem
key={action.id}
action={action}
onSelect={handleActionSelect}
closeOnSelect={false}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flexShrink: 0,
},
splitButton: {
flexDirection: "row",
alignItems: "stretch",
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
overflow: "hidden",
},
splitButtonPrimary: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryHovered: {
backgroundColor: theme.colors.surface2,
},
splitButtonPrimaryDisabled: {
opacity: 0.6,
},
splitButtonText: {
fontSize: theme.fontSize.sm,
lineHeight: theme.fontSize.sm * 1.5,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
splitButtonContent: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[2],
},
splitButtonSpinnerOnly: {
transform: [{ scale: 0.8 }],
},
splitButtonCaret: {
width: 28,
alignItems: "center",
justifyContent: "center",
borderLeftWidth: theme.borderWidth[1],
borderLeftColor: theme.colors.borderAccent,
},
splitButtonCaretHovered: {
backgroundColor: theme.colors.surface2,
},
iconButton: {
width: 32,
height: 32,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
overflowMenuButton: {
marginRight: -theme.spacing[2],
},
}));
File diff suppressed because it is too large Load Diff
-438
View File
@@ -1,438 +0,0 @@
import { useCallback, useMemo, useState } from "react";
import { Pressable, ScrollView, Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
ExternalLink,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
GitPullRequestDraft,
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
import type {
CheckStatus,
PrPaneActivity,
PrPaneCheck,
PrPaneData,
PrState,
} from "@/utils/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable];
}
function activityPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.activityRow, Boolean(hovered) && styles.hoverable];
}
export function PrPane({ data }: { data: PrPaneData }) {
const { theme } = useUnistyles();
const [checksOpen, setChecksOpen] = useState(true);
const [reviewsOpen, setReviewsOpen] = useState(true);
const handleOpenPrUrl = useCallback(() => {
void openExternalUrl(data.url);
}, [data.url]);
const handleToggleChecks = useCallback(() => {
setChecksOpen((o) => !o);
}, []);
const handleToggleReviews = useCallback(() => {
setReviewsOpen((o) => !o);
}, []);
const passed = data.checks.filter((c) => c.status === "success").length;
const failed = data.checks.filter((c) => c.status === "failure").length;
const pending = data.checks.filter((c) => c.status === "pending").length;
const approvals = data.activity.filter(
(a) => a.kind === "review" && a.reviewState === "approved",
).length;
const changesRequested = data.activity.filter(
(a) => a.kind === "review" && a.reviewState === "changes_requested",
).length;
const commentCount = data.activity.filter(
(a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"),
).length;
const stateColor = getStateColor(data.state, theme);
const StateIcon = getStateIcon(data.state);
const stateLabel = getStateLabel(data.state);
const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]);
const keyedActivity = useMemo(
() => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })),
[data.activity],
);
const checkSuccessIcon = useMemo(
() => <CircleCheck size={12} color={theme.colors.statusSuccess} />,
[theme.colors.statusSuccess],
);
const checkDangerIcon = useMemo(
() => <CircleX size={12} color={theme.colors.statusDanger} />,
[theme.colors.statusDanger],
);
const checkWarningIcon = useMemo(
() => <CircleDot size={12} color={theme.colors.statusWarning} />,
[theme.colors.statusWarning],
);
const commentIcon = useMemo(
() => <MessageSquare size={11} color={theme.colors.foregroundMuted} />,
[theme.colors.foregroundMuted],
);
return (
<View style={styles.root} testID="pr-pane">
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
{({ hovered }) => (
<>
<View style={styles.stateLine}>
<StateIcon size={14} color={stateColor} />
<Text style={stateLabelStyle} testID="pr-pane-state">
{stateLabel}
</Text>
</View>
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
{data.title}
{hovered ? (
<Text>
{" "}
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
</Text>
) : null}
</Text>
</>
)}
</Pressable>
<View style={styles.divider} />
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
color={theme.colors.statusSuccess}
icon={checkSuccessIcon}
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
color={theme.colors.statusDanger}
icon={checkDangerIcon}
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
color={theme.colors.statusWarning}
icon={checkWarningIcon}
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.map((check) => (
<CheckRow key={check.name} check={check} />
))}
</Section>
<View style={styles.divider} />
<Section
title="Reviews"
open={reviewsOpen}
onToggle={handleToggleReviews}
summary={
<>
<SummaryPill
count={approvals}
color={theme.colors.statusSuccess}
icon={checkSuccessIcon}
/>
<SummaryPill
count={changesRequested}
color={theme.colors.statusDanger}
icon={checkDangerIcon}
/>
<SummaryPill
count={commentCount}
color={theme.colors.foregroundMuted}
icon={commentIcon}
/>
</>
}
>
{keyedActivity.map(({ key, item }) => (
<ActivityRow key={key} item={item} />
))}
</Section>
</View>
);
}
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: React.ReactNode;
children: React.ReactNode;
}
function Section({ title, open, onToggle, summary, children }: SectionProps) {
const { theme } = useUnistyles();
return (
<View style={open ? styles.sectionOpen : undefined}>
<Pressable style={styles.sectionHeader} onPress={onToggle}>
{open ? (
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
) : (
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
)}
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.summaryWrap}>{summary}</View>
</Pressable>
{open && (
<ScrollView
style={styles.sectionBody}
contentContainerStyle={styles.sectionBodyContent}
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
)}
</View>
);
}
function SummaryPill({
count,
color,
icon,
testID,
}: {
count: number;
color: string;
icon: React.ReactNode;
testID?: string;
}) {
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
{icon}
<Text style={textStyle}>{count}</Text>
</View>
);
}
function CheckRow({ check }: { check: PrPaneCheck }) {
const handlePress = useCallback(() => {
void openExternalUrl(check.url);
}, [check.url]);
return (
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.rowTitle} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={styles.rowMetaMid} numberOfLines={1}>
{check.workflow}
</Text>
)}
{check.duration && <Text style={styles.rowMeta}>{check.duration}</Text>}
</Pressable>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
const { theme } = useUnistyles();
if (status === "success") return <CircleCheck size={14} color={theme.colors.statusSuccess} />;
if (status === "failure") return <CircleX size={14} color={theme.colors.statusDanger} />;
if (status === "pending") return <CircleDot size={14} color={theme.colors.statusWarning} />;
return <CircleSlash size={14} color={theme.colors.foregroundMuted} />;
}
function ActivityRow({ item }: { item: PrPaneActivity }) {
const verb = getActivityVerb(item);
const handlePress = useCallback(() => {
void openExternalUrl(item.url);
}, [item.url]);
const avatarStyle = useMemo(
() => [styles.avatar, { backgroundColor: item.avatarColor }],
[item.avatarColor],
);
return (
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
<View style={avatarStyle}>
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
</View>
<View style={styles.activityMain}>
<View style={styles.activityHeader}>
<Text style={styles.rowTitle} numberOfLines={1}>
{item.author}
</Text>
<Text style={styles.rowMetaMid}>{verb}</Text>
<Text style={styles.rowMeta}>{item.age}</Text>
</View>
<Text style={styles.rowBody} numberOfLines={2}>
{item.body}
</Text>
</View>
</Pressable>
);
}
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
if (state === "open") return theme.colors.statusSuccess;
if (state === "draft") return theme.colors.foregroundMuted;
if (state === "merged") return theme.colors.statusMerged;
return theme.colors.statusDanger;
}
function getStateIcon(state: PrState) {
if (state === "draft") return GitPullRequestDraft;
if (state === "merged") return GitMerge;
if (state === "closed") return GitPullRequestClosed;
return GitPullRequest;
}
const styles = StyleSheet.create((theme) => ({
root: {
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surfaceSidebar,
},
hoverable: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
header: {
flexDirection: "column",
gap: theme.spacing[1],
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[4],
},
stateLine: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
stateLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
title: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
lineHeight: 19,
},
divider: {
height: 1,
backgroundColor: theme.colors.border,
},
sectionOpen: {
flexShrink: 1,
minHeight: 0,
},
sectionBody: {
flexShrink: 1,
minHeight: 0,
},
sectionBodyContent: {
paddingBottom: theme.spacing[3],
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
activityRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
activityMain: { flex: 1, minWidth: 0, gap: 2 },
activityHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
rowTitle: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
rowMeta: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
marginLeft: "auto",
},
rowMetaMid: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
rowBody: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
lineHeight: 16,
},
avatar: {
width: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
marginTop: 1,
},
avatarText: {
fontSize: 10,
fontWeight: theme.fontWeight.normal,
color: "#fff",
},
}));
@@ -81,7 +81,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -95,7 +95,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
import { type PrHint, useWorkspacePrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -27,7 +27,7 @@ import { Pressable } from "react-native";
import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import type { PrHint } from "@/git/use-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";