Introduce a PR tab in the explorer sidebar that shows status checks, reviews, and the merged timeline for the current branch's pull request. Extend checkout PR status with number, draft flag, repo identity, and per-check workflow/duration; add a new pull_request_timeline request/response pair backed by a GraphQL query; enrich the workspace git snapshot to carry the fields through to the client; and extend the checkout-git-actions store so timeline queries are invalidated alongside status when mutations land.
376 lines
10 KiB
TypeScript
376 lines
10 KiB
TypeScript
import type { QueryClient } from "@tanstack/react-query";
|
|
import { create } from "zustand";
|
|
import { queryClient as appQueryClient } from "@/query/query-client";
|
|
import {
|
|
buildWorkspaceTabPersistenceKey,
|
|
useWorkspaceLayoutStore,
|
|
} from "@/stores/workspace-layout-store";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { useWorkspaceTabsStore } from "@/stores/workspace-tabs-store";
|
|
|
|
const SUCCESS_DISPLAY_MS = 1000;
|
|
|
|
export type CheckoutGitActionStatus = "idle" | "pending" | "success";
|
|
|
|
export type CheckoutGitAsyncActionId =
|
|
| "commit"
|
|
| "pull"
|
|
| "push"
|
|
| "create-pr"
|
|
| "merge-branch"
|
|
| "merge-from-base"
|
|
| "archive-worktree";
|
|
|
|
type CheckoutKey = string;
|
|
type StatusMap = Partial<Record<CheckoutGitAsyncActionId, CheckoutGitActionStatus>>;
|
|
|
|
function checkoutKey(serverId: string, cwd: string): CheckoutKey {
|
|
return `${serverId}::${cwd}`;
|
|
}
|
|
|
|
function resolveClient(serverId: string) {
|
|
const session = useSessionStore.getState().sessions[serverId];
|
|
const client = session?.client ?? null;
|
|
if (!client) {
|
|
throw new Error("Daemon client unavailable");
|
|
}
|
|
return client;
|
|
}
|
|
|
|
function setStatus(
|
|
key: CheckoutKey,
|
|
actionId: CheckoutGitAsyncActionId,
|
|
status: CheckoutGitActionStatus,
|
|
) {
|
|
useCheckoutGitActionsStore.setState((state) => {
|
|
const current = state.statusByCheckout[key]?.[actionId] ?? "idle";
|
|
if (current === status) {
|
|
return state;
|
|
}
|
|
return {
|
|
...state,
|
|
statusByCheckout: {
|
|
...state.statusByCheckout,
|
|
[key]: {
|
|
...(state.statusByCheckout[key] ?? {}),
|
|
[actionId]: status,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function invalidateCheckoutGitQueriesForClient(
|
|
queryClient: QueryClient,
|
|
{ serverId, cwd }: { serverId: string; cwd: string },
|
|
) {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["checkoutStatus", serverId, cwd],
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
const key = query.queryKey;
|
|
return (
|
|
Array.isArray(key) && key[0] === "checkoutDiff" && key[1] === serverId && key[2] === cwd
|
|
);
|
|
},
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
const key = query.queryKey;
|
|
return (
|
|
Array.isArray(key) &&
|
|
key[0] === "checkoutPrStatus" &&
|
|
key[1] === serverId &&
|
|
key[2] === cwd
|
|
);
|
|
},
|
|
}),
|
|
queryClient.invalidateQueries({
|
|
predicate: (query) => {
|
|
const key = query.queryKey;
|
|
return (
|
|
Array.isArray(key) && key[0] === "prPaneTimeline" && key[1] === serverId && key[2] === cwd
|
|
);
|
|
},
|
|
}),
|
|
]);
|
|
}
|
|
|
|
function invalidateCheckoutGitQueries(serverId: string, cwd: string) {
|
|
return invalidateCheckoutGitQueriesForClient(appQueryClient, { serverId, cwd });
|
|
}
|
|
|
|
function invalidateWorktreeList() {
|
|
void appQueryClient.invalidateQueries({
|
|
predicate: (query) =>
|
|
Array.isArray(query.queryKey) && query.queryKey[0] === "paseoWorktreeList",
|
|
});
|
|
void appQueryClient.invalidateQueries({
|
|
predicate: (query) =>
|
|
Array.isArray(query.queryKey) && query.queryKey[0] === "sidebarPaseoWorktreeList",
|
|
});
|
|
}
|
|
|
|
function removeWorktreeFromCachedLists(input: { serverId: string; worktreePath: string }): void {
|
|
const serverId = input.serverId.trim();
|
|
const worktreePath = input.worktreePath.trim();
|
|
if (!serverId || !worktreePath) {
|
|
return;
|
|
}
|
|
|
|
const removeFromList = (current: unknown) => {
|
|
if (!Array.isArray(current)) {
|
|
return current;
|
|
}
|
|
const filtered = current.filter((entry) => entry?.worktreePath !== worktreePath);
|
|
return filtered.length === current.length ? current : filtered;
|
|
};
|
|
|
|
appQueryClient.setQueriesData(
|
|
{
|
|
predicate: (query) =>
|
|
Array.isArray(query.queryKey) &&
|
|
query.queryKey[0] === "paseoWorktreeList" &&
|
|
query.queryKey[1] === serverId,
|
|
},
|
|
removeFromList,
|
|
);
|
|
|
|
appQueryClient.setQueriesData(
|
|
{
|
|
predicate: (query) =>
|
|
Array.isArray(query.queryKey) &&
|
|
query.queryKey[0] === "sidebarPaseoWorktreeList" &&
|
|
query.queryKey[1] === serverId,
|
|
},
|
|
removeFromList,
|
|
);
|
|
}
|
|
|
|
function purgeArchivedWorkspaceState(input: { serverId: string; worktreePath: string }): void {
|
|
const serverId = input.serverId.trim();
|
|
const workspaceId = input.worktreePath.trim();
|
|
if (!serverId || !workspaceId) {
|
|
return;
|
|
}
|
|
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
if (workspaceKey) {
|
|
useWorkspaceLayoutStore.getState().purgeWorkspace(workspaceKey);
|
|
}
|
|
useWorkspaceTabsStore.getState().purgeWorkspace({ serverId, workspaceId });
|
|
}
|
|
|
|
const successTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
const inFlight = new Map<string, Promise<unknown>>();
|
|
|
|
function inFlightKey(key: CheckoutKey, actionId: CheckoutGitAsyncActionId): string {
|
|
return `${key}::${actionId}`;
|
|
}
|
|
|
|
interface CheckoutGitActionsStoreState {
|
|
statusByCheckout: Record<CheckoutKey, StatusMap>;
|
|
|
|
getStatus: (params: {
|
|
serverId: string;
|
|
cwd: string;
|
|
actionId: CheckoutGitAsyncActionId;
|
|
}) => CheckoutGitActionStatus;
|
|
|
|
commit: (params: { serverId: string; cwd: string }) => Promise<void>;
|
|
pull: (params: { serverId: string; cwd: string }) => Promise<void>;
|
|
push: (params: { serverId: string; cwd: string }) => Promise<void>;
|
|
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
|
|
mergeBranch: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
|
mergeFromBase: (params: { serverId: string; cwd: string; baseRef: string }) => Promise<void>;
|
|
archiveWorktree: (params: {
|
|
serverId: string;
|
|
cwd: string;
|
|
worktreePath: string;
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
async function runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId,
|
|
run,
|
|
}: {
|
|
serverId: string;
|
|
cwd: string;
|
|
actionId: CheckoutGitAsyncActionId;
|
|
run: () => Promise<void>;
|
|
}): Promise<void> {
|
|
const key = checkoutKey(serverId, cwd);
|
|
const inflightId = inFlightKey(key, actionId);
|
|
|
|
const existing = inFlight.get(inflightId);
|
|
if (existing) {
|
|
await existing;
|
|
return;
|
|
}
|
|
|
|
const prevTimer = successTimers.get(inflightId);
|
|
if (prevTimer) {
|
|
clearTimeout(prevTimer);
|
|
successTimers.delete(inflightId);
|
|
}
|
|
|
|
setStatus(key, actionId, "pending");
|
|
|
|
const promise = (async () => {
|
|
try {
|
|
await run();
|
|
await invalidateCheckoutGitQueries(serverId, cwd);
|
|
setStatus(key, actionId, "success");
|
|
const timer = setTimeout(() => {
|
|
setStatus(key, actionId, "idle");
|
|
successTimers.delete(inflightId);
|
|
}, SUCCESS_DISPLAY_MS);
|
|
successTimers.set(inflightId, timer);
|
|
} catch (error) {
|
|
setStatus(key, actionId, "idle");
|
|
throw error;
|
|
} finally {
|
|
inFlight.delete(inflightId);
|
|
}
|
|
})();
|
|
|
|
inFlight.set(inflightId, promise);
|
|
await promise;
|
|
}
|
|
|
|
export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()((set, get) => ({
|
|
statusByCheckout: {},
|
|
|
|
getStatus: ({ serverId, cwd, actionId }) => {
|
|
const key = checkoutKey(serverId, cwd);
|
|
return get().statusByCheckout[key]?.[actionId] ?? "idle";
|
|
},
|
|
|
|
commit: async ({ serverId, cwd }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "commit",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.checkoutCommit(cwd, { addAll: true });
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
pull: async ({ serverId, cwd }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "pull",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.checkoutPull(cwd);
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
push: async ({ serverId, cwd }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "push",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.checkoutPush(cwd);
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
createPr: async ({ serverId, cwd }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "create-pr",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.checkoutPrCreate(cwd, {});
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
mergeBranch: async ({ serverId, cwd, baseRef }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "merge-branch",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.checkoutMerge(cwd, {
|
|
baseRef,
|
|
strategy: "merge",
|
|
requireCleanTarget: true,
|
|
});
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
mergeFromBase: async ({ serverId, cwd, baseRef }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "merge-from-base",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.checkoutMergeFromBase(cwd, {
|
|
baseRef,
|
|
requireCleanTarget: true,
|
|
});
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
},
|
|
});
|
|
},
|
|
|
|
archiveWorktree: async ({ serverId, cwd, worktreePath }) => {
|
|
await runCheckoutAction({
|
|
serverId,
|
|
cwd,
|
|
actionId: "archive-worktree",
|
|
run: async () => {
|
|
const client = resolveClient(serverId);
|
|
const payload = await client.archivePaseoWorktree({ worktreePath });
|
|
if (payload.error) {
|
|
throw new Error(payload.error.message);
|
|
}
|
|
removeWorktreeFromCachedLists({ serverId, worktreePath });
|
|
invalidateWorktreeList();
|
|
purgeArchivedWorkspaceState({ serverId, worktreePath });
|
|
},
|
|
});
|
|
},
|
|
}));
|
|
|
|
export function __resetCheckoutGitActionsStoreForTests() {
|
|
for (const timer of successTimers.values()) {
|
|
clearTimeout(timer);
|
|
}
|
|
successTimers.clear();
|
|
inFlight.clear();
|
|
useCheckoutGitActionsStore.setState({ statusByCheckout: {} });
|
|
}
|