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:
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compareCheckoutDiffPaths, orderCheckoutDiffFiles } from "./checkout-diff-order";
|
||||
|
||||
function createFile(path: string, additions = 0) {
|
||||
return {
|
||||
path,
|
||||
isNew: false,
|
||||
isDeleted: false,
|
||||
additions,
|
||||
deletions: 0,
|
||||
hunks: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("checkout diff ordering", () => {
|
||||
it("compares paths deterministically", () => {
|
||||
expect(compareCheckoutDiffPaths("a.ts", "b.ts")).toBeLessThan(0);
|
||||
expect(compareCheckoutDiffPaths("b.ts", "a.ts")).toBeGreaterThan(0);
|
||||
expect(compareCheckoutDiffPaths("same.ts", "same.ts")).toBe(0);
|
||||
});
|
||||
|
||||
it("sorts files by path", () => {
|
||||
const ordered = orderCheckoutDiffFiles([
|
||||
createFile("zeta.ts"),
|
||||
createFile("alpha.ts"),
|
||||
createFile("beta.ts"),
|
||||
]);
|
||||
|
||||
expect(ordered.map((file) => file.path)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"]);
|
||||
});
|
||||
|
||||
it("preserves relative order for equal paths", () => {
|
||||
const ordered = orderCheckoutDiffFiles([
|
||||
createFile("same.ts", 1),
|
||||
createFile("same.ts", 2),
|
||||
createFile("same.ts", 3),
|
||||
]);
|
||||
|
||||
expect(ordered.map((file) => file.additions)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { SubscribeCheckoutDiffResponse } from "@server/shared/messages";
|
||||
|
||||
type ParsedDiffFile = SubscribeCheckoutDiffResponse["payload"]["files"][number];
|
||||
|
||||
export function compareCheckoutDiffPaths(left: string, right: string): number {
|
||||
if (left === right) {
|
||||
return 0;
|
||||
}
|
||||
return left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
export function orderCheckoutDiffFiles(files: ParsedDiffFile[]): ParsedDiffFile[] {
|
||||
if (files.length < 2) {
|
||||
return files;
|
||||
}
|
||||
const ordered = [...files];
|
||||
ordered.sort((a, b) => compareCheckoutDiffPaths(a.path, b.path));
|
||||
return ordered;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
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";
|
||||
|
||||
function checkoutDiffQueryKey(
|
||||
serverId: string,
|
||||
cwd: string,
|
||||
mode: "uncommitted" | "base",
|
||||
baseRef?: string,
|
||||
ignoreWhitespace?: boolean,
|
||||
) {
|
||||
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutDiffQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
type CheckoutDiffQueryPayload = Omit<SubscribeCheckoutDiffResponse["payload"], "subscriptionId">;
|
||||
|
||||
export type ParsedDiffFile = CheckoutDiffQueryPayload["files"][number];
|
||||
export type DiffHunk = ParsedDiffFile["hunks"][number];
|
||||
export type DiffLine = DiffHunk["lines"][number];
|
||||
export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
|
||||
|
||||
function normalizeCheckoutDiffCompare(compare: {
|
||||
mode: "uncommitted" | "base";
|
||||
baseRef?: string;
|
||||
ignoreWhitespace?: boolean;
|
||||
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
|
||||
const ignoreWhitespace = compare.ignoreWhitespace === true;
|
||||
if (compare.mode === "uncommitted") {
|
||||
return { mode: "uncommitted", ignoreWhitespace };
|
||||
}
|
||||
const trimmedBaseRef = compare.baseRef?.trim();
|
||||
return trimmedBaseRef
|
||||
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
|
||||
: { mode: "base", ignoreWhitespace };
|
||||
}
|
||||
|
||||
export function useCheckoutDiffQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
mode,
|
||||
baseRef,
|
||||
ignoreWhitespace,
|
||||
enabled = true,
|
||||
}: UseCheckoutDiffQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const hookInstanceId = useId();
|
||||
const normalizedCompare = useMemo(
|
||||
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
|
||||
[mode, baseRef, ignoreWhitespace],
|
||||
);
|
||||
const compareMode = normalizedCompare.mode;
|
||||
const compareBaseRef = normalizedCompare.baseRef;
|
||||
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
|
||||
const queryKey = useMemo(
|
||||
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
|
||||
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
|
||||
);
|
||||
|
||||
const query = useQuery<CheckoutDiffQueryPayload>({
|
||||
queryKey,
|
||||
queryFn: skipToken,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd || !enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionId = [
|
||||
"checkoutDiff",
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
cwd,
|
||||
compareMode,
|
||||
compareBaseRef ?? "",
|
||||
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
|
||||
].join(":");
|
||||
let cancelled = false;
|
||||
|
||||
const unsubscribeUpdate = client.on("checkout_diff_update", (message) => {
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: `subscription:${subscriptionId}`,
|
||||
});
|
||||
});
|
||||
const unsubscribeSubscribeResponse = client.on(
|
||||
"subscribe_checkout_diff_response",
|
||||
(message) => {
|
||||
if (message.payload.subscriptionId !== subscriptionId) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||
cwd: message.payload.cwd,
|
||||
files: orderCheckoutDiffFiles(message.payload.files),
|
||||
error: message.payload.error,
|
||||
requestId: message.payload.requestId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
void client
|
||||
.subscribeCheckoutDiff(
|
||||
cwd,
|
||||
{
|
||||
mode: compareMode,
|
||||
baseRef: compareBaseRef,
|
||||
ignoreWhitespace: compareIgnoreWhitespace,
|
||||
},
|
||||
{ subscriptionId },
|
||||
)
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<CheckoutDiffQueryPayload>(queryKey, {
|
||||
cwd: payload.cwd,
|
||||
files: orderCheckoutDiffFiles(payload.files),
|
||||
error: payload.error,
|
||||
requestId: payload.requestId,
|
||||
});
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
console.error("[useCheckoutDiffQuery] subscribeCheckoutDiff failed", {
|
||||
serverId,
|
||||
cwd,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSubscribeResponse();
|
||||
try {
|
||||
client.unsubscribeCheckoutDiff(subscriptionId);
|
||||
} catch {
|
||||
// Ignore disconnect race during effect cleanup.
|
||||
}
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
isConnected,
|
||||
cwd,
|
||||
enabled,
|
||||
hookInstanceId,
|
||||
serverId,
|
||||
compareMode,
|
||||
compareBaseRef,
|
||||
compareIgnoreWhitespace,
|
||||
queryKey,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const payload = query.data ?? null;
|
||||
const payloadError = payload?.error ?? null;
|
||||
|
||||
return {
|
||||
files: payload?.files ?? [],
|
||||
payloadError,
|
||||
isLoading: payload === null && enabled && isConnected,
|
||||
isFetching: false,
|
||||
isError: Boolean(payloadError),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||
|
||||
export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutPrStatusQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
|
||||
export interface PrHint {
|
||||
url: string;
|
||||
number: number;
|
||||
state: "open" | "merged" | "closed";
|
||||
checks?: Array<{ name: string; status: string; url: string | null }>;
|
||||
checksStatus?: "none" | "pending" | "success" | "failure";
|
||||
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
|
||||
}
|
||||
|
||||
function parsePullRequestNumber(url: string): number | null {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
|
||||
const status = payload.status;
|
||||
if (!status?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = parsePullRequestNumber(status.url);
|
||||
if (number === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let state: "merged" | "open" | "closed";
|
||||
if (status.isMerged || status.state === "merged") state = "merged";
|
||||
else if (status.state === "open") state = "open";
|
||||
else state = "closed";
|
||||
|
||||
return {
|
||||
url: status.url,
|
||||
number,
|
||||
state,
|
||||
checks: status.checks,
|
||||
checksStatus: status.checksStatus as PrHint["checksStatus"],
|
||||
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
|
||||
};
|
||||
}
|
||||
|
||||
export function useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
const prStatus = message.payload.prStatus;
|
||||
if (!prStatus || prStatus.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
status: query.data?.status ?? null,
|
||||
githubFeaturesEnabled: query.data?.githubFeaturesEnabled ?? true,
|
||||
payloadError: query.data?.error ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspacePrHint({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
const prStatus = message.payload.prStatus;
|
||||
if (!prStatus || prStatus.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
select: selectWorkspacePrHint,
|
||||
});
|
||||
|
||||
return query.data ?? null;
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
focusManager,
|
||||
onlineManager,
|
||||
} 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";
|
||||
|
||||
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
const { mockRuntime, mockClient, checkoutStatusUpdateHandlers } = vi.hoisted(() => {
|
||||
const hoistedHandlers = new Set<(message: unknown) => void>();
|
||||
const hoistedClient = {
|
||||
getCheckoutStatus: vi.fn(),
|
||||
on: vi.fn((type: string, handler: (message: unknown) => void) => {
|
||||
if (type !== "checkout_status_update") {
|
||||
return () => {};
|
||||
}
|
||||
hoistedHandlers.add(handler);
|
||||
return () => {
|
||||
hoistedHandlers.delete(handler);
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
mockClient: hoistedClient,
|
||||
checkoutStatusUpdateHandlers: hoistedHandlers,
|
||||
mockRuntime: {
|
||||
client: hoistedClient,
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
useHostRuntimeClient: () => mockRuntime.client,
|
||||
useHostRuntimeIsConnected: () => mockRuntime.isConnected,
|
||||
}));
|
||||
|
||||
const serverId = "server-1";
|
||||
const cwd = "/repo";
|
||||
|
||||
function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): CheckoutStatusPayload {
|
||||
return {
|
||||
cwd,
|
||||
error: null,
|
||||
requestId: "checkout-status-1",
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: cwd,
|
||||
currentBranch: "main",
|
||||
isDirty: false,
|
||||
baseRef: "origin/main",
|
||||
aheadBehind: { ahead: 0, behind: 0 },
|
||||
aheadOfOrigin: 0,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
remoteUrl: "git@github.com:getpaseo/paseo.git",
|
||||
...overrides,
|
||||
} as CheckoutStatusPayload;
|
||||
}
|
||||
|
||||
function createTestQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCheckoutStatusHook({
|
||||
queryClient = createTestQueryClient(),
|
||||
hookCwd = cwd,
|
||||
}: {
|
||||
queryClient?: QueryClient;
|
||||
hookCwd?: string;
|
||||
} = {}) {
|
||||
let latest: ReturnType<typeof useCheckoutStatusQuery> | null = null;
|
||||
|
||||
function Probe() {
|
||||
latest = useCheckoutStatusQuery({ serverId, cwd: hookCwd });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
|
||||
return {
|
||||
get latest() {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
},
|
||||
async mount() {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(Probe),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
async unmount() {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForExpectation(assertion: () => void): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10);
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function emitCheckoutStatusUpdate(payload: CheckoutStatusPayload): Promise<void> {
|
||||
await act(async () => {
|
||||
for (const handler of checkoutStatusUpdateHandlers) {
|
||||
handler({
|
||||
type: "checkout_status_update",
|
||||
payload,
|
||||
});
|
||||
}
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("useCheckoutStatusQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: dom.window,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
mockRuntime.client = mockClient;
|
||||
mockRuntime.isConnected = true;
|
||||
mockClient.getCheckoutStatus.mockReset();
|
||||
mockClient.on.mockClear();
|
||||
checkoutStatusUpdateHandlers.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
focusManager.setFocused(undefined);
|
||||
onlineManager.setOnline(true);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not schedule polling after the cold read", async () => {
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||
const hook = renderCheckoutStatusHook();
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(60_000);
|
||||
});
|
||||
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns the cached snapshot without calling the daemon", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const cached = checkoutStatus({ requestId: "cached" });
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "uncached" }));
|
||||
const hook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await hook.mount();
|
||||
|
||||
expect(hook.latest.status).toEqual(cached);
|
||||
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches once on a cold read and uses cache on remount", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const fetched = checkoutStatus({ requestId: "cold-read" });
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(fetched);
|
||||
const firstHook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await firstHook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(firstHook.latest.status).toEqual(fetched);
|
||||
});
|
||||
await firstHook.unmount();
|
||||
|
||||
const secondHook = renderCheckoutStatusHook({ queryClient });
|
||||
await secondHook.mount();
|
||||
|
||||
expect(secondHook.latest.status).toEqual(fetched);
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refetch checkout status on focus or reconnect", async () => {
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus());
|
||||
const hook = renderCheckoutStatusHook();
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.currentBranch).toBe("main");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
onlineManager.setOnline(false);
|
||||
onlineManager.setOnline(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockClient.getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("server-pushed checkout status update writes into the React Query cache at checkoutStatusQueryKey(serverId, cwd)", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
||||
const hook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.requestId).toBe("initial");
|
||||
});
|
||||
|
||||
mockClient.getCheckoutStatus.mockClear();
|
||||
const pushed = checkoutStatus({
|
||||
requestId: "server-push",
|
||||
currentBranch: "pushed-branch",
|
||||
isDirty: true,
|
||||
aheadBehind: { ahead: 2, behind: 1 },
|
||||
aheadOfOrigin: 2,
|
||||
behindOfOrigin: 1,
|
||||
});
|
||||
await emitCheckoutStatusUpdate(pushed);
|
||||
|
||||
expect(mockClient.getCheckoutStatus).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
});
|
||||
|
||||
it("server push only updates the matching cwd's cache key, not other workspaces", async () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
const otherCwd = "/other-repo";
|
||||
const otherCached = checkoutStatus({
|
||||
cwd: otherCwd,
|
||||
repoRoot: otherCwd,
|
||||
requestId: "other-cached",
|
||||
currentBranch: "other-main",
|
||||
});
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
|
||||
mockClient.getCheckoutStatus.mockResolvedValue(checkoutStatus({ requestId: "initial" }));
|
||||
const hook = renderCheckoutStatusHook({ queryClient });
|
||||
|
||||
await hook.mount();
|
||||
await waitForExpectation(() => {
|
||||
expect(hook.latest.status?.requestId).toBe("initial");
|
||||
});
|
||||
|
||||
const pushed = checkoutStatus({
|
||||
requestId: "server-push",
|
||||
currentBranch: "pushed-branch",
|
||||
});
|
||||
await emitCheckoutStatusUpdate(pushed);
|
||||
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
|
||||
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
|
||||
otherCached,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { CheckoutStatusResponse } from "@server/shared/messages";
|
||||
|
||||
export const CHECKOUT_STATUS_STALE_TIME = 15_000;
|
||||
|
||||
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
interface UseCheckoutStatusQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||
|
||||
interface CheckoutStatusClient {
|
||||
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
|
||||
}
|
||||
|
||||
function fetchCheckoutStatus(
|
||||
client: CheckoutStatusClient,
|
||||
cwd: string,
|
||||
): Promise<CheckoutStatusPayload> {
|
||||
return client.getCheckoutStatus(cwd);
|
||||
}
|
||||
|
||||
async function peekOrFetchSnapshot({
|
||||
queryClient,
|
||||
client,
|
||||
serverId,
|
||||
cwd,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
client: CheckoutStatusClient;
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}): Promise<CheckoutStatusPayload> {
|
||||
const queryKey = checkoutStatusQueryKey(serverId, cwd);
|
||||
const cached = queryClient.getQueryData<CheckoutStatusPayload>(queryKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const snapshot = await fetchCheckoutStatus(client, cwd);
|
||||
queryClient.setQueryData(queryKey, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("checkout_status_update", (message) => {
|
||||
if (message.payload.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
|
||||
});
|
||||
}, [client, isConnected, cwd, queryClient, serverId]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await peekOrFetchSnapshot({ queryClient, client, serverId, cwd });
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
status: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isFetching: query.isFetching,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to checkout status updates from the React Query cache without
|
||||
* initiating a fetch. Useful for list rows where a parent component prefetches
|
||||
* only the visible agents.
|
||||
*/
|
||||
export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
|
||||
return useQuery({
|
||||
queryKey: checkoutStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await fetchCheckoutStatus(client, cwd);
|
||||
},
|
||||
enabled: false,
|
||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
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 { 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";
|
||||
|
||||
function openURLInNewTab(url: string): void {
|
||||
void openExternalUrl(url);
|
||||
}
|
||||
|
||||
function isActionDisabled(actionsDisabled: boolean, status: CheckoutGitActionStatus): boolean {
|
||||
return actionsDisabled || status === "pending";
|
||||
}
|
||||
|
||||
function resolveBranchLabel(input: {
|
||||
currentBranch: string | null | undefined;
|
||||
notGit: boolean;
|
||||
}): string {
|
||||
if (input.currentBranch && input.currentBranch !== "HEAD") {
|
||||
return input.currentBranch;
|
||||
}
|
||||
if (input.notGit) {
|
||||
return "Not a git repository";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function formatBaseRefLabel(baseRef: string | undefined): string {
|
||||
if (!baseRef) return "base";
|
||||
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
||||
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 {
|
||||
isGit: boolean;
|
||||
status: CheckoutStatusPayload | null;
|
||||
gitStatus: CheckoutStatusPayload | null;
|
||||
prStatus: PrStatusValue;
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isStatusLoading: boolean;
|
||||
baseRefLabel: string;
|
||||
}
|
||||
|
||||
interface DerivedGitActionsState {
|
||||
actionsDisabled: boolean;
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
hasPullRequest: boolean;
|
||||
hasRemote: boolean;
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
isOnBaseBranch: boolean;
|
||||
shouldPromoteArchive: boolean;
|
||||
}
|
||||
|
||||
interface GitCommitCounts {
|
||||
aheadCount: number;
|
||||
behindBaseCount: number;
|
||||
aheadOfOrigin: number;
|
||||
behindOfOrigin: number;
|
||||
}
|
||||
|
||||
function extractGitCommitCounts(gitStatus: CheckoutStatusPayload | null): GitCommitCounts {
|
||||
return {
|
||||
aheadCount: gitStatus?.aheadBehind?.ahead ?? 0,
|
||||
behindBaseCount: gitStatus?.aheadBehind?.behind ?? 0,
|
||||
aheadOfOrigin: gitStatus?.aheadOfOrigin ?? 0,
|
||||
behindOfOrigin: gitStatus?.behindOfOrigin ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function computeShouldPromoteArchive(input: {
|
||||
isPaseoOwnedWorktree: boolean;
|
||||
hasUncommittedChanges: boolean;
|
||||
postShipArchiveSuggested: boolean;
|
||||
isMergedPullRequest: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
input.isPaseoOwnedWorktree &&
|
||||
!input.hasUncommittedChanges &&
|
||||
(input.postShipArchiveSuggested || input.isMergedPullRequest)
|
||||
);
|
||||
}
|
||||
|
||||
function deriveGitActionsState(args: DeriveGitActionsStateArgs): DerivedGitActionsState {
|
||||
const {
|
||||
isGit,
|
||||
status,
|
||||
gitStatus,
|
||||
prStatus,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRefLabel,
|
||||
} = args;
|
||||
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
||||
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
||||
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
||||
return {
|
||||
actionsDisabled,
|
||||
...extractGitCommitCounts(gitStatus),
|
||||
hasPullRequest: Boolean(prStatus?.url),
|
||||
hasRemote: gitStatus?.hasRemote ?? false,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch: gitStatus?.currentBranch === baseRefLabel,
|
||||
shouldPromoteArchive: computeShouldPromoteArchive({
|
||||
isPaseoOwnedWorktree,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isMergedPullRequest,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
icons: {
|
||||
commit: ReactElement;
|
||||
pull: ReactElement;
|
||||
push: ReactElement;
|
||||
pullAndPush: ReactElement;
|
||||
viewPr: ReactElement;
|
||||
createPr: ReactElement;
|
||||
merge: ReactElement;
|
||||
mergeFromBase: ReactElement;
|
||||
archive: ReactElement;
|
||||
};
|
||||
}
|
||||
|
||||
interface UseGitActionsResult {
|
||||
gitActions: GitActions;
|
||||
branchLabel: string;
|
||||
isGit: boolean;
|
||||
}
|
||||
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
const toast = useToast();
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||
|
||||
const { status, isLoading: isStatusLoading } = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const gitStatus = status && status.isGit ? status : null;
|
||||
const isGit = Boolean(gitStatus);
|
||||
const notGit = status !== null && !status.isGit && !status.error;
|
||||
const baseRef = gitStatus?.baseRef ?? undefined;
|
||||
|
||||
const hasUncommittedChanges = Boolean(gitStatus?.isDirty);
|
||||
|
||||
const { status: prStatus, githubFeaturesEnabled } = useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
|
||||
// Ship default persistence
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPostShipArchiveSuggested(false);
|
||||
}, [cwd]);
|
||||
|
||||
const {
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
} = useGitActionStatuses(serverId, cwd);
|
||||
|
||||
const {
|
||||
runCommit,
|
||||
runPull,
|
||||
runPush,
|
||||
runPullAndPush,
|
||||
runCreatePr,
|
||||
runMergeBranch,
|
||||
runMergeFromBase,
|
||||
runArchiveWorktree,
|
||||
} = useGitActionRunners();
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleCommit = useCallback(() => {
|
||||
void runCommit({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Committed");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to commit");
|
||||
});
|
||||
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePull = useCallback(() => {
|
||||
void runPull({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pulled");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to pull");
|
||||
});
|
||||
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePush = useCallback(() => {
|
||||
void runPush({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pushed");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to push");
|
||||
});
|
||||
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handlePullAndPush = useCallback(() => {
|
||||
void runPullAndPush({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("Pulled and pushed");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to pull and push");
|
||||
});
|
||||
}, [cwd, runPullAndPush, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleCreatePr = useCallback(() => {
|
||||
void persistShipDefault("pr");
|
||||
void runCreatePr({ serverId, cwd })
|
||||
.then(() => {
|
||||
toastActionSuccess("PR created");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to create PR");
|
||||
});
|
||||
}, [cwd, persistShipDefault, runCreatePr, serverId, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleMergeBranch = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
toast.error("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void persistShipDefault("merge");
|
||||
void runMergeBranch({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
setPostShipArchiveSuggested(true);
|
||||
toastActionSuccess("Merged");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to merge");
|
||||
});
|
||||
}, [
|
||||
baseRef,
|
||||
cwd,
|
||||
persistShipDefault,
|
||||
runMergeBranch,
|
||||
serverId,
|
||||
toast,
|
||||
toastActionError,
|
||||
toastActionSuccess,
|
||||
]);
|
||||
|
||||
const handleMergeFromBase = useCallback(() => {
|
||||
if (!baseRef) {
|
||||
toast.error("Base ref unavailable");
|
||||
return;
|
||||
}
|
||||
void runMergeFromBase({ serverId, cwd, baseRef })
|
||||
.then(() => {
|
||||
toastActionSuccess("Updated");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to merge from base");
|
||||
});
|
||||
}, [baseRef, cwd, runMergeFromBase, serverId, toast, toastActionError, toastActionSuccess]);
|
||||
|
||||
const handleArchiveWorktree = useCallback(() => {
|
||||
const worktreePath = status?.cwd;
|
||||
if (!worktreePath) {
|
||||
toast.error("Worktree path unavailable");
|
||||
return;
|
||||
}
|
||||
const targetWorkingDir = resolveNewAgentWorkingDir(cwd, status ?? null);
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath })
|
||||
.then(() => {
|
||||
navigateToWorkspace(serverId, targetWorkingDir);
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
toastActionError(err, "Failed to archive worktree");
|
||||
});
|
||||
}, [cwd, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
||||
|
||||
const baseRefLabel = useMemo(() => formatBaseRefLabel(baseRef), [baseRef]);
|
||||
const derived = deriveGitActionsState({
|
||||
isGit,
|
||||
status,
|
||||
gitStatus,
|
||||
prStatus,
|
||||
hasUncommittedChanges,
|
||||
postShipArchiveSuggested,
|
||||
isStatusLoading,
|
||||
baseRefLabel,
|
||||
});
|
||||
const {
|
||||
actionsDisabled,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
hasPullRequest,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
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,
|
||||
});
|
||||
|
||||
const handlePrAction = useCallback(() => {
|
||||
if (prStatus?.url) {
|
||||
openURLInNewTab(prStatus.url);
|
||||
return;
|
||||
}
|
||||
handleCreatePr();
|
||||
}, [prStatus?.url, handleCreatePr]);
|
||||
|
||||
// Build actions
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
return buildGitActions({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
hasPullRequest,
|
||||
pullRequestUrl: prStatus?.url ?? null,
|
||||
hasRemote,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
hasUncommittedChanges,
|
||||
baseRefAvailable: Boolean(baseRef),
|
||||
baseRefLabel,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shouldPromoteArchive,
|
||||
shipDefault,
|
||||
runtime: {
|
||||
commit: {
|
||||
disabled: commitDisabled,
|
||||
status: commitStatus,
|
||||
icon: icons.commit,
|
||||
handler: handleCommit,
|
||||
},
|
||||
pull: {
|
||||
disabled: pullDisabled,
|
||||
status: pullStatus,
|
||||
icon: icons.pull,
|
||||
handler: handlePull,
|
||||
},
|
||||
push: {
|
||||
disabled: pushDisabled,
|
||||
status: pushStatus,
|
||||
icon: icons.push,
|
||||
handler: handlePush,
|
||||
},
|
||||
"pull-and-push": {
|
||||
disabled: pullAndPushDisabled,
|
||||
status: pullAndPushStatus,
|
||||
icon: icons.pullAndPush,
|
||||
handler: handlePullAndPush,
|
||||
},
|
||||
pr: {
|
||||
disabled: prDisabled,
|
||||
status: hasPullRequest ? "idle" : prCreateStatus,
|
||||
icon: hasPullRequest ? icons.viewPr : icons.createPr,
|
||||
handler: handlePrAction,
|
||||
},
|
||||
"merge-branch": {
|
||||
disabled: mergeDisabled,
|
||||
status: mergeStatus,
|
||||
icon: icons.merge,
|
||||
handler: handleMergeBranch,
|
||||
},
|
||||
"merge-from-base": {
|
||||
disabled: mergeFromBaseDisabled,
|
||||
status: mergeFromBaseStatus,
|
||||
icon: icons.mergeFromBase,
|
||||
handler: handleMergeFromBase,
|
||||
},
|
||||
"archive-worktree": {
|
||||
disabled: archiveDisabled,
|
||||
status: archiveStatus,
|
||||
icon: icons.archive,
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
commitDisabled,
|
||||
pullDisabled,
|
||||
pushDisabled,
|
||||
pullAndPushDisabled,
|
||||
prDisabled,
|
||||
mergeDisabled,
|
||||
mergeFromBaseDisabled,
|
||||
archiveDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
]);
|
||||
|
||||
return { gitActions, branchLabel, isGit };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user