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:
@@ -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
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user