395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
|
import { useRouter } from "expo-router";
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
|
|
import { useCheckoutStatusQuery } from "@/hooks/use-checkout-status-query";
|
|
import { useCheckoutPrStatusQuery } from "@/hooks/use-checkout-pr-status-query";
|
|
import { buildGitActions, type GitActions } from "@/components/git-actions-policy";
|
|
import { buildNewAgentRoute, resolveNewAgentWorkingDir } from "@/utils/new-agent-routing";
|
|
import { openExternalUrl } from "@/utils/open-external-url";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
|
|
export type { GitActionId, GitAction, GitActions } from "@/components/git-actions-policy";
|
|
|
|
function openURLInNewTab(url: string): void {
|
|
void openExternalUrl(url);
|
|
}
|
|
|
|
interface UseGitActionsInput {
|
|
serverId: string;
|
|
cwd: string;
|
|
icons: {
|
|
commit: ReactElement;
|
|
pull: ReactElement;
|
|
push: 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 router = useRouter();
|
|
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);
|
|
}
|
|
})
|
|
.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]);
|
|
|
|
// Store selectors
|
|
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 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 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],
|
|
);
|
|
|
|
// Handlers
|
|
const handleCommit = useCallback(() => {
|
|
void runCommit({ serverId, cwd })
|
|
.then(() => {
|
|
toastActionSuccess("Committed");
|
|
})
|
|
.catch((err) => {
|
|
toastActionError(err, "Failed to commit");
|
|
});
|
|
}, [cwd, runCommit, serverId, toastActionError, toastActionSuccess]);
|
|
|
|
const handlePull = useCallback(() => {
|
|
void runPull({ serverId, cwd })
|
|
.then(() => {
|
|
toastActionSuccess("Pulled");
|
|
})
|
|
.catch((err) => {
|
|
toastActionError(err, "Failed to pull");
|
|
});
|
|
}, [cwd, runPull, serverId, toastActionError, toastActionSuccess]);
|
|
|
|
const handlePush = useCallback(() => {
|
|
void runPush({ serverId, cwd })
|
|
.then(() => {
|
|
toastActionSuccess("Pushed");
|
|
})
|
|
.catch((err) => {
|
|
toastActionError(err, "Failed to push");
|
|
});
|
|
}, [cwd, runPush, serverId, toastActionError, toastActionSuccess]);
|
|
|
|
const handleCreatePr = useCallback(() => {
|
|
void persistShipDefault("pr");
|
|
void runCreatePr({ serverId, cwd })
|
|
.then(() => {
|
|
toastActionSuccess("PR created");
|
|
})
|
|
.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");
|
|
})
|
|
.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");
|
|
})
|
|
.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(() => {
|
|
router.replace(buildNewAgentRoute(serverId, targetWorkingDir));
|
|
})
|
|
.catch((err) => {
|
|
toastActionError(err, "Failed to archive worktree");
|
|
});
|
|
}, [cwd, router, runArchiveWorktree, serverId, status, toast, toastActionError]);
|
|
|
|
// Derived state
|
|
const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading;
|
|
const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0;
|
|
const behindBaseCount = gitStatus?.aheadBehind?.behind ?? 0;
|
|
const aheadOfOrigin = gitStatus?.aheadOfOrigin ?? 0;
|
|
const behindOfOrigin = gitStatus?.behindOfOrigin ?? 0;
|
|
const baseRefLabel = useMemo(() => {
|
|
if (!baseRef) return "base";
|
|
const trimmed = baseRef.replace(/^refs\/(heads|remotes)\//, "").trim();
|
|
return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed;
|
|
}, [baseRef]);
|
|
const hasPullRequest = Boolean(prStatus?.url);
|
|
const hasRemote = gitStatus?.hasRemote ?? false;
|
|
const isPaseoOwnedWorktree = gitStatus?.isPaseoOwnedWorktree ?? false;
|
|
const isMergedPullRequest = Boolean(prStatus?.isMerged);
|
|
const currentBranch = gitStatus?.currentBranch;
|
|
const isOnBaseBranch = currentBranch === baseRefLabel;
|
|
const shouldPromoteArchive =
|
|
isPaseoOwnedWorktree &&
|
|
!hasUncommittedChanges &&
|
|
(postShipArchiveSuggested || isMergedPullRequest);
|
|
|
|
const commitDisabled = actionsDisabled || commitStatus === "pending";
|
|
const pullDisabled = actionsDisabled || pullStatus === "pending";
|
|
const prDisabled = actionsDisabled || prCreateStatus === "pending";
|
|
const mergeDisabled = actionsDisabled || mergeStatus === "pending";
|
|
const mergeFromBaseDisabled = actionsDisabled || mergeFromBaseStatus === "pending";
|
|
const pushDisabled = actionsDisabled || pushStatus === "pending";
|
|
const archiveDisabled = actionsDisabled || archiveStatus === "pending";
|
|
|
|
const branchLabel =
|
|
gitStatus?.currentBranch && gitStatus.currentBranch !== "HEAD"
|
|
? gitStatus.currentBranch
|
|
: notGit
|
|
? "Not a git repository"
|
|
: "Unknown";
|
|
|
|
// 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,
|
|
},
|
|
pr: {
|
|
disabled: prDisabled,
|
|
status: hasPullRequest ? "idle" : prCreateStatus,
|
|
icon: hasPullRequest ? icons.viewPr : icons.createPr,
|
|
handler: () => {
|
|
if (prStatus?.url) {
|
|
openURLInNewTab(prStatus.url);
|
|
return;
|
|
}
|
|
handleCreatePr();
|
|
},
|
|
},
|
|
"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,
|
|
prDisabled,
|
|
mergeDisabled,
|
|
mergeFromBaseDisabled,
|
|
archiveDisabled,
|
|
commitStatus,
|
|
pullStatus,
|
|
pushStatus,
|
|
prCreateStatus,
|
|
mergeStatus,
|
|
mergeFromBaseStatus,
|
|
archiveStatus,
|
|
handleCommit,
|
|
handlePull,
|
|
handlePush,
|
|
handleCreatePr,
|
|
handleMergeBranch,
|
|
handleMergeFromBase,
|
|
handleArchiveWorktree,
|
|
icons,
|
|
baseRef,
|
|
]);
|
|
|
|
return { gitActions, branchLabel, isGit };
|
|
}
|