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";
@@ -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";
@@ -19,7 +19,7 @@ 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";
import type { GitAction, GitActions } from "@/git/policy";
interface GitActionsSplitButtonProps {
gitActions: GitActions;
@@ -163,9 +163,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
action={action}
onSelect={handleActionSelect}
archiveShortcutKeys={archiveShortcutKeys}
needsSeparator={
action.id === "merge-from-base" || action.id === "archive-worktree"
}
needsSeparator={action.startsGroup}
showSeparator={index > 0}
closeOnSelect={
action.status === "idle" && action.id === "pr" && action.label === "View PR"
@@ -9,7 +9,7 @@ import {
invalidateCheckoutGitQueriesForClient,
isLocalWorktreeArchivePending,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
} from "@/git/actions-store";
import {
clearWorkspaceArchivePending,
isWorkspaceArchivePending,
@@ -1,4 +1,5 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/query/query-client";
import {
@@ -27,6 +28,9 @@ export type CheckoutGitAsyncActionId =
| "push"
| "pull-and-push"
| "create-pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
@@ -259,6 +263,11 @@ interface CheckoutGitActionsStoreState {
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: {
serverId: string;
cwd: string;
method: CheckoutPrMergeMethod;
}) => Promise<void>;
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
archiveWorktree: (params: {
@@ -405,6 +414,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
mergePr: async ({ serverId, cwd, method }) => {
await runCheckoutAction({
serverId,
cwd,
actionId: `merge-pr-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutPrMerge(cwd, { method });
if (payload.error) {
throw new Error(payload.error.message);
}
},
});
},
mergeBranch: async ({ serverId, cwd, baseRef }) => {
await runCheckoutAction({
serverId,
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order";
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./diff-order";
function createFile(path: string, additions = 0) {
return {
@@ -25,7 +25,6 @@ import {
type ViewStyle,
type TextStyle,
} from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import {
@@ -45,20 +44,16 @@ import {
Upload,
WrapText,
} from "lucide-react-native";
import {
useCheckoutGitActionsStore,
type CheckoutGitActionStatus,
} from "@/stores/checkout-git-actions-store";
import {
useCheckoutDiffQuery,
type ParsedDiffFile,
type DiffLine,
type HighlightToken,
} from "@/hooks/use-checkout-diff-query";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
} from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { DiffScroll } from "./diff-scroll";
import { DiffScroll } from "@/components/diff-scroll";
import {
darkHighlightColors,
lightHighlightColors,
@@ -66,7 +61,7 @@ import {
} from "@getpaseo/highlight";
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { Fonts } from "@/constants/theme";
import { shouldAnchorHeaderBeforeCollapse } from "@/utils/git-diff-scroll";
import { shouldAnchorHeaderBeforeCollapse } from "@/git/diff-scroll";
import {
buildSplitDiffRows,
buildUnifiedDiffLines,
@@ -83,16 +78,12 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import { usePanelStore } from "@/stores/panel-store";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
import { useToast } from "@/contexts/toast-context";
import {
formatDiffContentText,
formatDiffGutterText,
@@ -120,11 +111,7 @@ import {
type InlineReviewActions,
} from "@/review";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
}
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
@@ -1274,16 +1261,6 @@ function getDiffContentLength(file: ParsedDiffFile): number {
return contentLength;
}
function computeBranchLabel(currentBranch: string | null | undefined, notGit: boolean): string {
if (currentBranch && currentBranch !== "HEAD") {
return currentBranch;
}
if (notGit) {
return "Not a git repository";
}
return "Unknown";
}
function computeEmptyMessage(
hideWhitespace: boolean,
diffMode: "uncommitted" | "base",
@@ -1411,194 +1388,6 @@ function DiffBodyContent({
);
}
interface GitActionRunners {
runCommit: (args: { serverId: string; cwd: string }) => Promise<void>;
runPull: (args: { serverId: string; cwd: string }) => Promise<void>;
runPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runPullAndPush: (args: { serverId: string; cwd: string }) => Promise<void>;
runCreatePr: (args: { serverId: string; cwd: string }) => Promise<void>;
runMergeBranch: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runMergeFromBase: (args: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
runArchiveWorktree: (args: {
serverId: string;
cwd: string;
worktreePath: string;
}) => Promise<void>;
}
interface GitActionHandlersDeps {
serverId: string;
cwd: string;
baseRef: string | undefined;
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
runners: GitActionRunners;
persistShipDefault: (next: "merge" | "pr") => Promise<void>;
toastError: (message: string) => void;
toastActionError: (error: unknown, fallback: string) => void;
toastActionSuccess: (message: string) => void;
onMergeBranchSuccess: () => void;
onArchiveSuccess: (targetWorkingDir: string) => void;
}
interface GitActionHandlers {
handleCommit: () => void;
handlePull: () => void;
handlePush: () => void;
handlePullAndPush: () => void;
handleCreatePr: () => void;
handleMergeBranch: () => void;
handleMergeFromBase: () => void;
handleArchiveWorktree: () => void;
}
function useGitActionHandlers({
serverId,
cwd,
baseRef,
status,
runners,
persistShipDefault,
toastError,
toastActionError,
toastActionSuccess,
onMergeBranchSuccess,
onArchiveSuccess,
}: GitActionHandlersDeps): GitActionHandlers {
const handleCommit = useCallback(() => {
void runners
.runCommit({ serverId, cwd })
.then(() => {
toastActionSuccess("Committed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to commit");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePull = useCallback(() => {
void runners
.runPull({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled");
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePush = useCallback(() => {
void runners
.runPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pushed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to push");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handlePullAndPush = useCallback(() => {
void runners
.runPullAndPush({ serverId, cwd })
.then(() => {
toastActionSuccess("Pulled and pushed");
return;
})
.catch((err) => {
toastActionError(err, "Failed to pull and push");
});
}, [cwd, runners, serverId, toastActionError, toastActionSuccess]);
const handleCreatePr = useCallback(() => {
void persistShipDefault("pr");
void runners
.runCreatePr({ serverId, cwd })
.then(() => {
toastActionSuccess("PR created");
return;
})
.catch((err) => {
toastActionError(err, "Failed to create PR");
});
}, [cwd, persistShipDefault, runners, serverId, toastActionError, toastActionSuccess]);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toastError("Base ref unavailable");
return;
}
void persistShipDefault("merge");
void runners
.runMergeBranch({ serverId, cwd, baseRef })
.then(() => {
onMergeBranchSuccess();
toastActionSuccess("Merged");
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge");
});
}, [
baseRef,
cwd,
onMergeBranchSuccess,
persistShipDefault,
runners,
serverId,
toastActionError,
toastActionSuccess,
toastError,
]);
const handleMergeFromBase = useCallback(() => {
if (!baseRef) {
toastError("Base ref unavailable");
return;
}
void runners
.runMergeFromBase({ serverId, cwd, baseRef })
.then(() => {
toastActionSuccess("Updated");
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge from base");
});
}, [baseRef, cwd, runners, serverId, toastActionError, toastActionSuccess, toastError]);
const handleArchiveWorktree = useCallback(() => {
const worktreePath = status?.cwd;
if (!worktreePath) {
toastError("Worktree path unavailable");
return;
}
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
void runners
.runArchiveWorktree({ serverId, cwd, worktreePath })
.then(() => {
onArchiveSuccess(targetWorkingDir);
return;
})
.catch((err) => {
toastActionError(err, "Failed to archive worktree");
});
}, [cwd, onArchiveSuccess, runners, serverId, status, toastActionError, toastError]);
return {
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
};
}
interface DeriveStatusStateInputs {
status: ReturnType<typeof useCheckoutStatusQuery>["status"];
isStatusLoading: boolean;
@@ -1642,35 +1431,6 @@ function deriveStatusState({
};
}
interface DerivedBranchState {
aheadCount: number;
behindBaseCount: number;
aheadOfOrigin: number;
behindOfOrigin: number;
hasPullRequest: boolean;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isMergedPullRequest: boolean;
currentBranch: string | null | undefined;
}
function deriveBranchState(
gitStatus: DerivedStatusState["gitStatus"],
prStatus: { url?: string | null; isMerged?: boolean | null } | null | undefined,
): DerivedBranchState {
return {
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0,
behindOfOrigin: gitStatus?.behindOfOrigin ?? 0,
hasPullRequest: Boolean(prStatus?.url),
hasRemote: gitStatus?.hasRemote ?? false,
isPaseoOwnedWorktree: gitStatus?.isPaseoOwnedWorktree ?? false,
isMergedPullRequest: Boolean(prStatus?.isMerged),
currentBranch: gitStatus?.currentBranch,
};
}
function computeBaseRefLabel(baseRef: string | undefined): string {
if (!baseRef) return "base";
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
@@ -1687,36 +1447,6 @@ function computeCommittedDiffDescription(
return branchLabel === baseRefLabel ? undefined : `${branchLabel} -> ${baseRefLabel}`;
}
function computeShouldPromoteArchive(
isPaseoOwnedWorktree: boolean,
hasUncommittedChanges: boolean,
postShipArchiveSuggested: boolean,
isMergedPullRequest: boolean,
): boolean {
return (
isPaseoOwnedWorktree &&
!hasUncommittedChanges &&
(postShipArchiveSuggested || isMergedPullRequest)
);
}
function computeDisabledStates(
actionsDisabled: boolean,
statuses: GitActionsStatusInputs,
): GitActionsDisabledInputs {
const pending = "pending";
return {
commitDisabled: actionsDisabled || statuses.commitStatus === pending,
pullDisabled: actionsDisabled || statuses.pullStatus === pending,
pushDisabled: actionsDisabled || statuses.pushStatus === pending,
pullAndPushDisabled: actionsDisabled || statuses.pullAndPushStatus === pending,
prDisabled: actionsDisabled || statuses.prCreateStatus === pending,
mergeDisabled: actionsDisabled || statuses.mergeStatus === pending,
mergeFromBaseDisabled: actionsDisabled || statuses.mergeFromBaseStatus === pending,
archiveDisabled: actionsDisabled || statuses.archiveStatus === pending,
};
}
function computePrErrorMessage(
githubFeaturesEnabled: boolean,
prPayloadError: { message?: string } | null | undefined,
@@ -1736,128 +1466,6 @@ function buildToggleButtonStyle(
];
}
function createPrHandler(
prStatus: { url?: string | null } | null | undefined,
handleCreatePr: () => void,
): () => void {
return () => {
if (prStatus?.url) {
openURLInNewTab(prStatus.url);
return;
}
handleCreatePr();
};
}
interface GitActionsStatusInputs {
commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus;
archiveStatus: CheckoutGitActionStatus;
}
interface GitActionsDisabledInputs {
commitDisabled: boolean;
pullDisabled: boolean;
pushDisabled: boolean;
pullAndPushDisabled: boolean;
prDisabled: boolean;
mergeDisabled: boolean;
mergeFromBaseDisabled: boolean;
archiveDisabled: boolean;
}
interface BuildGitActionsParams {
policy: {
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";
};
statuses: GitActionsStatusInputs;
disabled: GitActionsDisabledInputs;
handlers: GitActionHandlers & { handlePr: () => void };
iconColor: string;
}
function buildGitActionsForPane({
policy,
statuses,
disabled,
handlers,
iconColor,
}: BuildGitActionsParams): GitActions {
return buildGitActions({
...policy,
runtime: {
commit: {
disabled: disabled.commitDisabled,
status: statuses.commitStatus,
icon: <GitCommitHorizontal size={16} color={iconColor} />,
handler: handlers.handleCommit,
},
pull: {
disabled: disabled.pullDisabled,
status: statuses.pullStatus,
icon: <Download size={16} color={iconColor} />,
handler: handlers.handlePull,
},
push: {
disabled: disabled.pushDisabled,
status: statuses.pushStatus,
icon: <Upload size={16} color={iconColor} />,
handler: handlers.handlePush,
},
"pull-and-push": {
disabled: disabled.pullAndPushDisabled,
status: statuses.pullAndPushStatus,
icon: <ArrowDownUp size={16} color={iconColor} />,
handler: handlers.handlePullAndPush,
},
pr: {
disabled: disabled.prDisabled,
status: policy.hasPullRequest ? "idle" : statuses.prCreateStatus,
icon: <GitHubIcon size={16} color={iconColor} />,
handler: handlers.handlePr,
},
"merge-branch": {
disabled: disabled.mergeDisabled,
status: statuses.mergeStatus,
icon: <GitMerge size={16} color={iconColor} />,
handler: handlers.handleMergeBranch,
},
"merge-from-base": {
disabled: disabled.mergeFromBaseDisabled,
status: statuses.mergeFromBaseStatus,
icon: <RefreshCcw size={16} color={iconColor} />,
handler: handlers.handleMergeFromBase,
},
"archive-worktree": {
disabled: disabled.archiveDisabled,
status: statuses.archiveStatus,
icon: <Archive size={16} color={iconColor} />,
handler: handlers.handleArchiveWorktree,
},
},
});
}
function shouldEnableCheckoutDiff(input: { paneEnabled: boolean; isGit: boolean }): boolean {
return input.paneEnabled && input.isGit;
}
@@ -1870,13 +1478,10 @@ export function GitDiffPane({
enabled,
}: GitDiffPaneProps) {
const { theme } = useUnistyles();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
@@ -1943,15 +1548,7 @@ export function GitDiffPane({
error: statusError,
} = useCheckoutStatusQuery({ serverId, cwd });
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
const {
gitStatus,
isGit,
notGit,
statusErrorMessage,
baseRef,
hasUncommittedChanges,
actionsDisabled,
} = statusState;
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
@@ -2041,11 +1638,7 @@ export function GitDiffPane({
setWorkspaceAttachments,
workspaceAttachmentScopeKey,
]);
const {
status: prStatus,
githubFeaturesEnabled,
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
@@ -2079,45 +1672,6 @@ export function GitDiffPane({
const diffBodyLineHeight = theme.lineHeight.diff;
const diffBodyChromeHeight = theme.borderWidth[1] * 2;
const statusBodyHeightEstimate = diffBodyChromeHeight + theme.spacing[4] * 2 + diffBodyLineHeight;
const shipDefaultStorageKey = useMemo(() => {
if (!gitStatus?.repoRoot) {
return null;
}
return `@paseo:changes-ship-default:${gitStatus.repoRoot}`;
}, [gitStatus?.repoRoot]);
useEffect(() => {
if (!shipDefaultStorageKey) {
return;
}
let isActive = true;
AsyncStorage.getItem(shipDefaultStorageKey)
.then((value) => {
if (!isActive) return;
if (value === "pr" || value === "merge") {
setShipDefault(value);
}
return;
})
.catch(() => undefined);
return () => {
isActive = false;
};
}, [shipDefaultStorageKey]);
const persistShipDefault = useCallback(
async (next: "merge" | "pr") => {
setShipDefault(next);
if (!shipDefaultStorageKey) return;
try {
await AsyncStorage.setItem(shipDefaultStorageKey, next);
} catch {
// Ignore persistence failures; default will reset to "merge".
}
},
[shipDefaultStorageKey],
);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
@@ -2304,119 +1858,6 @@ export function GitDiffPane({
setDiffModeOverride(null);
}, [autoDiffMode]);
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
const message = error instanceof Error ? error.message : fallback;
toast.error(message);
},
[toast],
);
const toastActionSuccess = useCallback(
(message: string) => {
toast.show(message, { variant: "success" });
},
[toast],
);
const handleMergeBranchSuccess = useCallback(() => {
setPostShipArchiveSuggested(true);
}, []);
const handleArchiveSuccess = useCallback(
(targetWorkingDir: string) => {
navigateToWorkspace(serverId, targetWorkingDir);
},
[serverId],
);
const toastError = useCallback(
(message: string) => {
toast.error(message);
},
[toast],
);
const runners = useMemo<GitActionRunners>(
() => ({
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runArchiveWorktree,
}),
[
runArchiveWorktree,
runCommit,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runPull,
runPullAndPush,
runPush,
],
);
const {
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
} = useGitActionHandlers({
serverId,
cwd,
baseRef,
status,
runners,
persistShipDefault,
toastError,
toastActionError,
toastActionSuccess,
onMergeBranchSuccess: handleMergeBranchSuccess,
onArchiveSuccess: handleArchiveSuccess,
});
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "header") {
@@ -2499,59 +1940,30 @@ export function GitDiffPane({
const hasChanges = files.length > 0;
const diffErrorMessage = diffPayloadError?.message ?? null;
const prErrorMessage = computePrErrorMessage(githubFeaturesEnabled, prPayloadError);
const branchLabel = computeBranchLabel(gitStatus?.currentBranch, notGit);
const branchState = useMemo(() => deriveBranchState(gitStatus, prStatus), [gitStatus, prStatus]);
const {
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
hasPullRequest,
hasRemote,
isPaseoOwnedWorktree,
isMergedPullRequest,
currentBranch,
} = branchState;
const baseRefLabel = useMemo(() => computeBaseRefLabel(baseRef), [baseRef]);
const iconColor = theme.colors.foregroundMuted;
const gitActionsIcons = useMemo(
() => ({
commit: <GitCommitHorizontal size={16} color={iconColor} />,
pull: <Download size={16} color={iconColor} />,
push: <Upload size={16} color={iconColor} />,
pullAndPush: <ArrowDownUp size={16} color={iconColor} />,
viewPr: <GitHubIcon size={16} color={iconColor} />,
createPr: <GitHubIcon size={16} color={iconColor} />,
mergePrSquash: <GitHubIcon size={16} color={iconColor} />,
mergePrMerge: <GitHubIcon size={16} color={iconColor} />,
mergePrRebase: <GitHubIcon size={16} color={iconColor} />,
merge: <GitMerge size={16} color={iconColor} />,
mergeFromBase: <RefreshCcw size={16} color={iconColor} />,
archive: <Archive size={16} color={iconColor} />,
}),
[iconColor],
);
const { gitActions, branchLabel } = useGitActions({ serverId, cwd, icons: gitActionsIcons });
const committedDiffDescription = useMemo(
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
[baseRefLabel, branchLabel],
);
const isOnBaseBranch = currentBranch === baseRefLabel;
const shouldPromoteArchive = computeShouldPromoteArchive(
isPaseoOwnedWorktree,
hasUncommittedChanges,
postShipArchiveSuggested,
isMergedPullRequest,
);
const gitActionsStatuses = useMemo<GitActionsStatusInputs>(
() => ({
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
}),
[
archiveStatus,
commitStatus,
mergeFromBaseStatus,
mergeStatus,
prCreateStatus,
pullAndPushStatus,
pullStatus,
pushStatus,
],
);
const gitActionsDisabled = useMemo<GitActionsDisabledInputs>(
() => computeDisabledStates(actionsDisabled, gitActionsStatuses),
[actionsDisabled, gitActionsStatuses],
);
const emptyMessage = computeEmptyMessage(
changesPreferences.hideWhitespace,
@@ -2583,101 +1995,6 @@ export function GitDiffPane({
/>
);
useEffect(() => {
setPostShipArchiveSuggested(false);
}, [cwd]);
// ==========================================================================
// Git Actions (Data-Oriented)
// ==========================================================================
// All possible actions are computed as data, then partitioned into:
// - primary: The main CTA button
// - secondary: Dropdown next to primary button
// - menu: Kebab overflow menu
// ==========================================================================
const handlePr = useMemo(
() => createPrHandler(prStatus, handleCreatePr),
[handleCreatePr, prStatus],
);
const gitActionsHandlers = useMemo(
() => ({
handleCommit,
handlePull,
handlePush,
handlePullAndPush,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
handlePr,
}),
[
handleArchiveWorktree,
handleCommit,
handleCreatePr,
handleMergeBranch,
handleMergeFromBase,
handlePr,
handlePull,
handlePullAndPush,
handlePush,
],
);
const gitActions: GitActions = useMemo(
() =>
buildGitActionsForPane({
policy: {
isGit,
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
hasUncommittedChanges,
baseRefAvailable: Boolean(baseRef),
baseRefLabel,
aheadCount,
behindBaseCount,
aheadOfOrigin,
behindOfOrigin,
shouldPromoteArchive,
shipDefault,
},
statuses: gitActionsStatuses,
disabled: gitActionsDisabled,
handlers: gitActionsHandlers,
iconColor: theme.colors.foregroundMuted,
}),
[
aheadCount,
aheadOfOrigin,
baseRef,
baseRefLabel,
behindBaseCount,
behindOfOrigin,
gitActionsDisabled,
gitActionsHandlers,
gitActionsStatuses,
githubFeaturesEnabled,
hasPullRequest,
hasRemote,
hasUncommittedChanges,
isGit,
isOnBaseBranch,
isPaseoOwnedWorktree,
prStatus?.url,
shipDefault,
shouldPromoteArchive,
theme.colors.foregroundMuted,
],
);
// Helper to get display label based on status
return (
<View style={styles.container}>
{!hideHeaderRow ? (
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { shouldAnchorHeaderBeforeCollapse } from "./git-diff-scroll";
import { shouldAnchorHeaderBeforeCollapse } from "./diff-scroll";
describe("shouldAnchorHeaderBeforeCollapse", () => {
it("skips anchor when header is fully visible in viewport", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-repo-url";
import { buildGitHubBranchTreeUrl, parseGitHubRepoFromRemote } from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildGitActions, type BuildGitActionsInput } from "./git-actions-policy";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
return {
@@ -8,6 +8,10 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
githubFeaturesEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
pullRequestState: null,
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -46,6 +50,21 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
status: "idle",
handler: () => undefined,
},
"merge-pr-squash": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-merge": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-pr-rebase": {
disabled: false,
status: "idle",
handler: () => undefined,
},
"merge-branch": {
disabled: false,
status: "idle",
@@ -158,6 +177,9 @@ describe("git-actions-policy", () => {
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
expect(
actions.secondary.some((action) => action.id === "pr" && action.label === "View PR"),
@@ -213,4 +235,183 @@ describe("git-actions-policy", () => {
expect(hidden.secondary.some((action) => action.id === "archive-worktree")).toBe(false);
expect(shown.secondary.some((action) => action.id === "archive-worktree")).toBe(true);
});
it("promotes squash-and-merge when an open PR is mergeable and the branch is in sync", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
});
it("respects the ship preference when choosing between PR merge and local merge", () => {
const prPreferredActions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
shipDefault: "pr",
}),
);
const localMergePreferredActions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
shipDefault: "merge",
}),
);
expect(prPreferredActions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
});
expect(localMergePreferredActions.primary).toMatchObject({
id: "merge-branch",
label: "Merge locally",
});
expect(
localMergePreferredActions.secondary.some((action) => action.id === "merge-pr-squash"),
).toBe(true);
});
it("keeps the merge-pr actions in the feature branch menu", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
}),
);
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
"pull-and-push",
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
]);
});
it("uses Merge locally for the local merge action", () => {
const actions = buildGitActions(
createInput({
isOnBaseBranch: false,
aheadCount: 2,
}),
);
const action = actions.secondary.find((entry) => entry.id === "merge-branch");
expect(action).toMatchObject({ label: "Merge locally" });
});
it.each([
[
"draft",
{ pullRequestIsDraft: true },
"Merge PR isn't available because the pull request is still a draft",
],
[
"merged",
{ pullRequestIsMerged: true },
"Merge PR isn't available because the pull request is already merged",
],
[
"closed",
{ pullRequestState: "closed" as const },
"Merge PR isn't available because the pull request is closed",
],
[
"conflicting",
{ pullRequestMergeable: "CONFLICTING" as const },
"Merge PR isn't available because the pull request has conflicts",
],
])("marks merge-pr actions unavailable when the PR is %s", (_name, overrides, message) => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
...overrides,
}),
);
const mergePrActions = actions.secondary.filter((action) =>
["merge-pr-squash", "merge-pr-merge", "merge-pr-rebase"].includes(action.id),
);
expect(mergePrActions).toHaveLength(3);
expect(mergePrActions.map((action) => action.unavailableMessage)).toEqual([
message,
message,
message,
]);
expect(mergePrActions.map((action) => action.disabled)).toEqual([true, true, true]);
});
it("groups merge-pr actions behind their own menu separator via startsGroup", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
isPaseoOwnedWorktree: true,
}),
);
const allActions = [...actions.secondary];
const groupStarters = allActions
.filter((action) => action.startsGroup)
.map((action) => action.id);
const nonGroupStarters = allActions
.filter((action) => !action.startsGroup)
.map((action) => action.id);
expect(groupStarters).toEqual(["merge-from-base", "merge-pr-squash", "archive-worktree"]);
expect(nonGroupStarters).toEqual([
"pull",
"push",
"pull-and-push",
"merge-branch",
"pr",
"merge-pr-merge",
"merge-pr-rebase",
]);
});
});
@@ -1,6 +1,7 @@
import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@server/shared/messages";
export type GitActionId =
| "commit"
@@ -8,6 +9,9 @@ export type GitActionId =
| "push"
| "pull-and-push"
| "pr"
| "merge-pr-squash"
| "merge-pr-merge"
| "merge-pr-rebase"
| "merge-branch"
| "merge-from-base"
| "archive-worktree";
@@ -21,6 +25,8 @@ export interface GitAction {
status: ActionStatus;
unavailableMessage?: string;
icon?: ReactElement;
/** When true, a menu separator should be rendered before this item. */
startsGroup: boolean;
handler: () => void;
}
@@ -42,6 +48,10 @@ export interface BuildGitActionsInput {
githubFeaturesEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
pullRequestState: "open" | "closed" | null;
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
@@ -58,7 +68,29 @@ export interface BuildGitActionsInput {
}
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const FEATURE_ACTION_IDS: GitActionId[] = ["merge-from-base", "merge-branch", "pr"];
const FEATURE_ACTION_IDS: GitActionId[] = [
"merge-from-base",
"merge-branch",
"pr",
"merge-pr-squash",
"merge-pr-merge",
"merge-pr-rebase",
];
const MERGE_PR_METHODS: Record<
"merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
{ label: string; method: CheckoutPrMergeMethod }
> = {
"merge-pr-squash": { label: "Squash and merge", method: "squash" },
"merge-pr-merge": { label: "Create a merge commit", method: "merge" },
"merge-pr-rebase": { label: "Rebase and merge", method: "rebase" },
};
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
if (state === "closed") return "closed";
return null;
}
export function buildGitActions(input: BuildGitActionsInput): GitActions {
if (!input.isGit) {
@@ -75,6 +107,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
disabled: input.runtime.commit.disabled,
status: input.runtime.commit.status,
icon: input.runtime.commit.icon,
startsGroup: false,
handler: input.runtime.commit.handler,
});
@@ -87,6 +120,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
status: input.runtime.pull.status,
unavailableMessage: input.runtime.pull.disabled ? undefined : getPullUnavailableMessage(input),
icon: input.runtime.pull.icon,
startsGroup: false,
handler: input.runtime.pull.handler,
});
@@ -99,6 +133,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
status: input.runtime.push.status,
unavailableMessage: input.runtime.push.disabled ? undefined : getPushUnavailableMessage(input),
icon: input.runtime.push.icon,
startsGroup: false,
handler: input.runtime.push.handler,
});
@@ -113,14 +148,19 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: getPullAndPushUnavailableMessage(input),
icon: input.runtime["pull-and-push"].icon,
startsGroup: false,
handler: input.runtime["pull-and-push"].handler,
});
allActions.set("pr", buildPrAction(input));
allActions.set("merge-pr-squash", buildMergePrAction(input, "merge-pr-squash"));
allActions.set("merge-pr-merge", buildMergePrAction(input, "merge-pr-merge"));
allActions.set("merge-pr-rebase", buildMergePrAction(input, "merge-pr-rebase"));
allActions.set("merge-branch", {
id: "merge-branch",
label: `Merge into ${input.baseRefLabel}`,
label: "Merge locally",
pendingLabel: "Merging...",
successLabel: "Merged",
disabled: input.runtime["merge-branch"].disabled,
@@ -129,6 +169,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: getMergeBranchUnavailableMessage(input),
icon: input.runtime["merge-branch"].icon,
startsGroup: false,
handler: input.runtime["merge-branch"].handler,
});
@@ -143,6 +184,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: getMergeFromBaseUnavailableMessage(input),
icon: input.runtime["merge-from-base"].icon,
startsGroup: true,
handler: input.runtime["merge-from-base"].handler,
});
@@ -158,6 +200,7 @@ export function buildGitActions(input: BuildGitActionsInput): GitActions {
? undefined
: "Archive isn't available here because this workspace was not created as a Paseo worktree",
icon: input.runtime["archive-worktree"].icon,
startsGroup: true,
handler: input.runtime["archive-worktree"].handler,
});
@@ -195,11 +238,17 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (!input.isOnBaseBranch && canMergeFromBase(input)) {
return "merge-from-base";
}
if (canMergePr(input) && input.shipDefault === "pr") {
return "merge-pr-squash";
}
if (!input.isOnBaseBranch && input.aheadCount > 0 && input.shipDefault === "merge") {
return "merge-branch";
}
if (input.githubFeaturesEnabled && input.hasPullRequest && input.pullRequestUrl) {
return "pr";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return input.shipDefault === "merge" ? "merge-branch" : "pr";
return "pr";
}
return null;
}
@@ -218,6 +267,7 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
? undefined
: "View PR isn't available right now because GitHub isn't connected",
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
};
}
@@ -233,10 +283,32 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
? undefined
: getCreatePrUnavailableMessage(input),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
};
}
function buildMergePrAction(
input: BuildGitActionsInput,
id: "merge-pr-squash" | "merge-pr-merge" | "merge-pr-rebase",
): GitAction {
const runtime = input.runtime[id];
const config = MERGE_PR_METHODS[id];
const unavailableMessage = getMergePrUnavailableMessage(input);
return {
id,
label: config.label,
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: runtime.disabled || shouldDisableMergePrAction(input),
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
startsGroup: id === "merge-pr-squash",
handler: runtime.handler,
};
}
function canPull(input: BuildGitActionsInput): boolean {
return input.hasRemote && !input.hasUncommittedChanges && input.behindOfOrigin > 0;
}
@@ -254,6 +326,22 @@ function canMergeFromBase(input: BuildGitActionsInput): boolean {
);
}
function canMergePr(input: BuildGitActionsInput): boolean {
return (
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestState === "open" &&
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable === "MERGEABLE" &&
input.aheadCount > 0 &&
!input.hasUncommittedChanges &&
input.behindOfOrigin === 0 &&
input.aheadOfOrigin === 0 &&
!canMergeFromBase(input)
);
}
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";
@@ -328,3 +416,34 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string
}
return undefined;
}
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return "Merge PR isn't available right now because GitHub isn't connected";
}
if (!input.hasPullRequest) {
return "Merge PR isn't available because there isn't a pull request yet";
}
if (input.pullRequestIsDraft) {
return "Merge PR isn't available because the pull request is still a draft";
}
if (input.pullRequestIsMerged) {
return "Merge PR isn't available because the pull request is already merged";
}
if (input.pullRequestState === "closed") {
return "Merge PR isn't available because the pull request is closed";
}
if (input.pullRequestMergeable === "CONFLICTING") {
return "Merge PR isn't available because the pull request has conflicts";
}
return undefined;
}
function shouldDisableMergePrAction(input: BuildGitActionsInput): boolean {
return (
input.pullRequestIsDraft ||
input.pullRequestIsMerged ||
input.pullRequestState === "closed" ||
input.pullRequestMergeable === "CONFLICTING"
);
}
@@ -23,6 +23,7 @@ const baseStatus: CheckoutPrStatus = {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
};
@@ -16,14 +16,14 @@ import {
MessageSquare,
} from "lucide-react-native";
import { openExternalUrl } from "@/utils/open-external-url";
import { getActivityVerb, getStateLabel } from "@/utils/pr-pane-data";
import { getActivityVerb, getStateLabel } from "@/git/pr-pane-data";
import type {
CheckStatus,
PrPaneActivity,
PrPaneCheck,
PrPaneData,
PrState,
} from "@/utils/pr-pane-data";
} from "@/git/pr-pane-data";
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.row, Boolean(hovered) && styles.hoverable];
@@ -1,24 +1,16 @@
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import {
type CheckoutGitActionStatus,
useCheckoutGitActionsStore,
} from "@/stores/checkout-git-actions-store";
import {
type CheckoutStatusPayload,
useCheckoutStatusQuery,
} from "@/hooks/use-checkout-status-query";
import {
type CheckoutPrStatusPayload,
useCheckoutPrStatusQuery,
} from "@/hooks/use-checkout-pr-status-query";
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
import { type CheckoutStatusPayload, useCheckoutStatusQuery } from "@/git/use-status-query";
import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { buildGitActions, narrowPullRequestState, type GitActions } from "@/git/policy";
import type { CheckoutPrMergeMethod } from "@server/shared/messages";
import { resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
function openURLInNewTab(url: string): void {
void openExternalUrl(url);
@@ -47,55 +39,6 @@ function formatBaseRefLabel(baseRef: string | undefined): string {
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
}
function useGitActionStatuses(
serverId: string,
cwd: string,
): {
commitStatus: CheckoutGitActionStatus;
pullStatus: CheckoutGitActionStatus;
pushStatus: CheckoutGitActionStatus;
pullAndPushStatus: CheckoutGitActionStatus;
prCreateStatus: CheckoutGitActionStatus;
mergeStatus: CheckoutGitActionStatus;
mergeFromBaseStatus: CheckoutGitActionStatus;
archiveStatus: CheckoutGitActionStatus;
} {
const commitStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergeStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
return {
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
};
}
type PrStatusValue = NonNullable<CheckoutPrStatusPayload["status"]> | null;
interface DeriveGitActionsStateArgs {
@@ -181,27 +124,6 @@ function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActio
};
}
function useGitActionRunners() {
const runCommit = useCheckoutGitActionsStore((state) => state.commit);
const runPull = useCheckoutGitActionsStore((state) => state.pull);
const runPush = useCheckoutGitActionsStore((state) => state.push);
const runPullAndPush = useCheckoutGitActionsStore((state) => state.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((state) => state.createPr);
const runMergeBranch = useCheckoutGitActionsStore((state) => state.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((state) => state.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
return {
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runArchiveWorktree,
};
}
interface UseGitActionsInput {
serverId: string;
cwd: string;
@@ -212,6 +134,9 @@ interface UseGitActionsInput {
pullAndPush: ReactElement;
viewPr: ReactElement;
createPr: ReactElement;
mergePrSquash: ReactElement;
mergePrMerge: ReactElement;
mergePrRebase: ReactElement;
merge: ReactElement;
mergeFromBase: ReactElement;
archive: ReactElement;
@@ -287,27 +212,51 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
setPostShipArchiveSuggested(false);
}, [cwd]);
const {
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
} = useGitActionStatuses(serverId, cwd);
const commitStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "commit" }),
);
const pullStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull" }),
);
const pushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "push" }),
);
const pullAndPushStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "pull-and-push" }),
);
const prCreateStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "create-pr" }),
);
const mergePrStatuses: Record<CheckoutPrMergeMethod, CheckoutGitActionStatus> = {
squash: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-squash" }),
),
merge: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-merge" }),
),
rebase: useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-pr-rebase" }),
),
};
const mergeStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-branch" }),
);
const mergeFromBaseStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "merge-from-base" }),
);
const archiveStatus = useCheckoutGitActionsStore((s) =>
s.getStatus({ serverId, cwd, actionId: "archive-worktree" }),
);
const {
runCommit,
runPull,
runPush,
runPullAndPush,
runCreatePr,
runMergeBranch,
runMergeFromBase,
runArchiveWorktree,
} = useGitActionRunners();
const runCommit = useCheckoutGitActionsStore((s) => s.commit);
const runPull = useCheckoutGitActionsStore((s) => s.pull);
const runPush = useCheckoutGitActionsStore((s) => s.push);
const runPullAndPush = useCheckoutGitActionsStore((s) => s.pullAndPush);
const runCreatePr = useCheckoutGitActionsStore((s) => s.createPr);
const runMergePr = useCheckoutGitActionsStore((s) => s.mergePr);
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const runArchiveWorktree = useCheckoutGitActionsStore((s) => s.archiveWorktree);
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
@@ -381,6 +330,22 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
});
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
const handleMergePr = useCallback(
(method: CheckoutPrMergeMethod) => {
void persistShipDefault("pr");
void runMergePr({ serverId, cwd, method })
.then(() => {
setPostShipArchiveSuggested(true);
toastActionSuccess("PR merged");
return;
})
.catch((err) => {
toastActionError(err, "Failed to merge PR");
});
},
[cwd, persistShipDefault, runMergePr, serverId, toastActionError, toastActionSuccess],
);
const handleMergeBranch = useCallback(() => {
if (!baseRef) {
toast.error("Base ref unavailable");
@@ -463,15 +428,6 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shouldPromoteArchive,
} = derived;
const commitDisabled = isActionDisabled(actionsDisabled, commitStatus);
const pullDisabled = isActionDisabled(actionsDisabled, pullStatus);
const prDisabled = isActionDisabled(actionsDisabled, prCreateStatus);
const mergeDisabled = isActionDisabled(actionsDisabled, mergeStatus);
const mergeFromBaseDisabled = isActionDisabled(actionsDisabled, mergeFromBaseStatus);
const pushDisabled = isActionDisabled(actionsDisabled, pushStatus);
const pullAndPushDisabled = isActionDisabled(actionsDisabled, pullAndPushStatus);
const archiveDisabled = isActionDisabled(actionsDisabled, archiveStatus);
const branchLabel = resolveBranchLabel({
currentBranch: gitStatus?.currentBranch,
notGit,
@@ -492,6 +448,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
githubFeaturesEnabled,
hasPullRequest,
pullRequestUrl: prStatus?.url ?? null,
pullRequestState: narrowPullRequestState(prStatus?.state),
pullRequestIsDraft: prStatus?.isDraft ?? false,
pullRequestIsMerged: prStatus?.isMerged ?? false,
pullRequestMergeable: prStatus?.mergeable ?? "UNKNOWN",
hasRemote,
isPaseoOwnedWorktree,
isOnBaseBranch,
@@ -506,49 +466,67 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shipDefault,
runtime: {
commit: {
disabled: commitDisabled,
disabled: isActionDisabled(actionsDisabled, commitStatus),
status: commitStatus,
icon: icons.commit,
handler: handleCommit,
},
pull: {
disabled: pullDisabled,
disabled: isActionDisabled(actionsDisabled, pullStatus),
status: pullStatus,
icon: icons.pull,
handler: handlePull,
},
push: {
disabled: pushDisabled,
disabled: isActionDisabled(actionsDisabled, pushStatus),
status: pushStatus,
icon: icons.push,
handler: handlePush,
},
"pull-and-push": {
disabled: pullAndPushDisabled,
disabled: isActionDisabled(actionsDisabled, pullAndPushStatus),
status: pullAndPushStatus,
icon: icons.pullAndPush,
handler: handlePullAndPush,
},
pr: {
disabled: prDisabled,
disabled: isActionDisabled(actionsDisabled, prCreateStatus),
status: hasPullRequest ? "idle" : prCreateStatus,
icon: hasPullRequest ? icons.viewPr : icons.createPr,
handler: handlePrAction,
},
"merge-pr-squash": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.squash),
status: mergePrStatuses.squash,
icon: icons.mergePrSquash,
handler: () => handleMergePr("squash"),
},
"merge-pr-merge": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.merge),
status: mergePrStatuses.merge,
icon: icons.mergePrMerge,
handler: () => handleMergePr("merge"),
},
"merge-pr-rebase": {
disabled: isActionDisabled(actionsDisabled, mergePrStatuses.rebase),
status: mergePrStatuses.rebase,
icon: icons.mergePrRebase,
handler: () => handleMergePr("rebase"),
},
"merge-branch": {
disabled: mergeDisabled,
disabled: isActionDisabled(actionsDisabled, mergeStatus),
status: mergeStatus,
icon: icons.merge,
handler: handleMergeBranch,
},
"merge-from-base": {
disabled: mergeFromBaseDisabled,
disabled: isActionDisabled(actionsDisabled, mergeFromBaseStatus),
status: mergeFromBaseStatus,
icon: icons.mergeFromBase,
handler: handleMergeFromBase,
},
"archive-worktree": {
disabled: archiveDisabled,
disabled: isActionDisabled(actionsDisabled, archiveStatus),
status: archiveStatus,
icon: icons.archive,
handler: handleArchiveWorktree,
@@ -560,6 +538,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
hasRemote,
hasPullRequest,
prStatus?.url,
prStatus?.state,
prStatus?.isDraft,
prStatus?.isMerged,
prStatus?.mergeable,
aheadCount,
behindBaseCount,
isPaseoOwnedWorktree,
@@ -571,19 +553,15 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
shipDefault,
baseRefLabel,
shouldPromoteArchive,
commitDisabled,
pullDisabled,
pushDisabled,
pullAndPushDisabled,
prDisabled,
mergeDisabled,
mergeFromBaseDisabled,
archiveDisabled,
actionsDisabled,
commitStatus,
pullStatus,
pushStatus,
pullAndPushStatus,
prCreateStatus,
mergePrStatuses.squash,
mergePrStatuses.merge,
mergePrStatuses.rebase,
mergeStatus,
mergeFromBaseStatus,
archiveStatus,
@@ -592,6 +570,7 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
handlePush,
handlePullAndPush,
handlePrAction,
handleMergePr,
handleMergeBranch,
handleMergeFromBase,
handleArchiveWorktree,
@@ -2,7 +2,7 @@ import { skipToken, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useMemo } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
import { orderCheckoutDiffFiles } from "./checkout-diff-order";
import { orderCheckoutDiffFiles } from "@/git/diff-order";
function checkoutDiffQueryKey(
serverId: string,
@@ -9,7 +9,7 @@ import {
} from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusResponse } from "@server/shared/messages";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-checkout-status-query";
import { checkoutStatusQueryKey, useCheckoutStatusQuery } from "./use-status-query";
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
@@ -9,8 +9,8 @@ import {
Upload,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { GitActionsSplitButton } from "@/components/git-actions-split-button";
import { useGitActions } from "@/hooks/use-git-actions";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { useGitActions } from "@/git/use-actions";
import type { Theme } from "@/styles/theme";
interface WorkspaceGitActionsProps {
@@ -39,6 +39,9 @@ const ICONS = {
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={mutedColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
@@ -3,7 +3,7 @@ import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@server/client/daemon-client";
import type { ComboboxOption } from "@/components/ui/combobox";
import type { ToastApi } from "@/components/toast-host";
import { invalidateCheckoutGitQueriesForClient } from "@/stores/checkout-git-actions-store";
import { invalidateCheckoutGitQueriesForClient } from "@/git/actions-store";
import { confirmDialog } from "@/utils/confirm-dialog";
interface UseBranchSwitcherInput {
@@ -17,7 +17,7 @@ import {
usePrPaneData,
type UsePrPaneDataResult,
} from "./use-pr-pane-data";
import { useWorkspacePrHint } from "./use-checkout-pr-status-query";
import { useWorkspacePrHint } from "@/git/use-pr-status-query";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
@@ -67,6 +67,7 @@ function status(overrides: Partial<CheckoutPrStatus> = {}): CheckoutPrStatus {
headRefName: "feature/pr-pane",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
repoOwner: "getpaseo",
+2 -2
View File
@@ -4,8 +4,8 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@server/shared/messages";
import { mapPrPaneData, type PrPaneData } from "../utils/pr-pane-data";
import { useCheckoutPrStatusQuery } from "./use-checkout-pr-status-query";
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/test/window-local-storage";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { describe, expect, it } from "vitest";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import {
buildReviewAttachmentSnapshot,
buildReviewDraftKey,
+1 -1
View File
@@ -3,7 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { ComposerAttachment } from "@/attachments/types";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { generateMessageId } from "@/types/stream";
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
import type { AgentAttachment } from "@server/shared/messages";
@@ -19,11 +19,11 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
import { buildGitHubBranchTreeUrl } from "@/utils/github-repo-url";
import { buildGitHubBranchTreeUrl } from "@/git/github-url";
import { openExternalUrl } from "@/utils/open-external-url";
import { isAbsolutePath } from "@/utils/path";
import { isWeb } from "@/constants/platform";
@@ -54,7 +54,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/screens/workspace/workspace-git-actions";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -90,10 +90,7 @@ import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import {
checkoutStatusQueryKey,
type CheckoutStatusPayload,
} from "@/hooks/use-checkout-status-query";
import { checkoutStatusQueryKey, type CheckoutStatusPayload } from "@/git/use-status-query";
import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog";
+1 -1
View File
@@ -4,7 +4,7 @@ import {
buildSplitDiffRows,
buildUnifiedDiffLines,
} from "./diff-layout";
import type { ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import type { ReviewableDiffTarget } from "./diff-layout";
function makeFile(
+1 -1
View File
@@ -1,4 +1,4 @@
import type { DiffLine, ParsedDiffFile } from "@/hooks/use-checkout-diff-query";
import type { DiffLine, ParsedDiffFile } from "@/git/use-diff-query";
type ReviewSide = "old" | "new";
type ReviewableLineType = "add" | "remove" | "context";
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import {
parseAgentKey,
resolveNewAgentWorkingDir,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import {
parseHostWorkspaceOpenIntentFromPathname,
parseHostAgentRouteFromPathname,