Unverified 提交 186f0d60 authored 作者: Adekunle James Adeniji's avatar Adekunle James Adeniji 提交者: GitHub

Add UI for Git Pull Support (#2342)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2342"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a pull workflow and consolidates branch operations into a single actions menu. > > - UI: Replace standalone buttons with `Branch actions` dropdown (`GithubBranchManager.tsx`) containing `Create new branch`, `Refresh branches`, and new `Git pull`; disables controls while pulling > - IPC: New `github:pull` contract and handler that pulls from `origin` on current branch, uses auth token, tolerates missing remote branch, and reloads branches (`src/ipc/types/github.ts`, `src/ipc/handlers/git_branch_handlers.ts`) > - E2E: Update flows to use `branch-actions-menu-trigger`; add pull test and snapshots; factor `configureGitUser()` helper (`e2e-tests/...`) > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 40bb9e7cd72308acf34563e9758884d2b0c2cd4e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds Git pull support and consolidates branch actions into a single “Branch actions” menu, making it easy to pull remote changes from the UI. Includes IPC wiring and an end-to-end test. - **New Features** - Added “Branch actions” dropdown with Create branch, Refresh branches, and Git pull. - Git pull action with loading state and success/error toasts. - New IPC contract and handler (github:pull) that pulls from origin and tolerates missing remote branch. - **Refactors** - Replaced separate buttons with a dropdown in GithubBranchManager and updated test IDs. - Added configureGitUser helper and new e2e test for pulling from remote. - Updated snapshots to reflect the new menu. <sup>Written for commit 40bb9e7cd72308acf34563e9758884d2b0c2cd4e. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 eb9f9162
......@@ -34,9 +34,12 @@ test.describe("Git Collaboration", () => {
await po.getTitleBarAppNameButton().click(); // Open Publish Panel
// Wait for BranchManager to appear
await expect(po.page.getByTestId("create-branch-trigger")).toBeVisible({
await expect(
po.page.getByTestId("branch-actions-menu-trigger"),
).toBeVisible({
timeout: 10000,
});
await po.page.getByTestId("branch-actions-menu-trigger").click();
await po.page.getByTestId("create-branch-trigger").click();
await po.page.getByTestId("new-branch-name-input").fill(featureBranch);
await po.page.getByTestId("create-branch-submit-button").click();
......@@ -58,6 +61,7 @@ test.describe("Git Collaboration", () => {
);
const featureBranch2 = "feature-2";
await po.page.getByTestId("branch-actions-menu-trigger").click();
await po.page.getByTestId("create-branch-trigger").click();
await po.page.getByTestId("new-branch-name-input").fill(featureBranch2);
// Select source branch 'main' explicitly (though it defaults to HEAD which is main)
......@@ -124,9 +128,7 @@ test.describe("Git Collaboration", () => {
const featureContent = "Content from feature-1 branch";
fs.writeFileSync(mergeTestFilePath, featureContent);
// Configure git user for commit
execSync("git config user.email 'test@example.com'", { cwd: appPath });
execSync("git config user.name 'Test User'", { cwd: appPath });
execSync("git config commit.gpgsign false", { cwd: appPath });
await po.configureGitUser();
execSync(
`git add ${mergeTestFile} && git commit -m "Add merge test file"`,
{
......@@ -188,6 +190,68 @@ test.describe("Git Collaboration", () => {
await po.page.keyboard.press("Escape");
});
test("should pull changes from remote", async ({ po }) => {
await po.setUp({ disableNativeGit: false });
await po.sendPrompt("tc=basic");
await po.getTitleBarAppNameButton().click();
await po.githubConnector.connect();
// Create a new repo to start fresh
const repoName = "test-git-pull-" + Date.now();
await po.githubConnector.fillCreateRepoName(repoName);
await po.githubConnector.clickCreateRepoButton();
// Wait for repo to be connected
await expect(po.page.getByTestId("github-connected-repo")).toBeVisible({
timeout: Timeout.MEDIUM,
});
const appPath = await po.getCurrentAppPath();
if (!appPath) throw new Error("App path not found");
// Configure git user
await po.configureGitUser();
// Create a file locally
const testFile = "pull-test.txt";
const testFilePath = path.join(appPath, testFile);
const fileContent = "Initial content";
fs.writeFileSync(testFilePath, fileContent);
execSync(`git add ${testFile} && git commit -m "Add pull test file"`, {
cwd: appPath,
});
// Go to publish panel
await po.goToChatTab();
await po.getTitleBarAppNameButton().click();
// Open the branch actions dropdown
await expect(
po.page.getByTestId("branch-actions-menu-trigger"),
).toBeVisible({
timeout: 10000,
});
// Test git pull - should succeed with no remote changes
await po.page.getByTestId("branch-actions-menu-trigger").click();
await po.page.getByTestId("git-pull-button").click();
// Wait for success toast
await po.waitForToast("success", 10000);
// Verify the file still exists (pull succeeded)
expect(fs.existsSync(testFilePath)).toBe(true);
expect(fs.readFileSync(testFilePath, "utf-8")).toBe(fileContent);
// Verify git status is clean
const gitStatus = execSync("git status --porcelain", {
cwd: appPath,
encoding: "utf8",
}).trim();
expect(gitStatus).toBe("");
});
test("should invite and remove collaborators", async ({ po }) => {
await po.setUp();
await po.sendPrompt("tc=basic");
......
......@@ -1129,6 +1129,27 @@ export class PageObject {
return this.getAppPath({ appName: currentAppName });
}
async configureGitUser({
email = "test@example.com",
name = "Test User",
disableGpgSign = true,
}: {
email?: string;
name?: string;
disableGpgSign?: boolean;
} = {}) {
const appPath = await this.getCurrentAppPath();
if (!appPath) {
throw new Error("App path not found");
}
execSync(`git config user.email '${email}'`, { cwd: appPath });
execSync(`git config user.name '${name}'`, { cwd: appPath });
if (disableGpgSign) {
execSync("git config commit.gpgsign false", { cwd: appPath });
}
}
getAppPath({ appName }: { appName: string }) {
return path.join(this.userDataDir, "dyad-apps", appName);
}
......
......@@ -3,9 +3,7 @@
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- button "Branch actions":
- img
- img
- heading "Branches" [level=3]
......
......@@ -3,9 +3,7 @@
- combobox:
- img
- text: "Branch: new-branch"
- button "Refresh branches":
- img
- button "Create new branch":
- button "Branch actions":
- img
- img
- heading "Branches" [level=3]
......
......@@ -3,9 +3,7 @@
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- button "Branch actions":
- img
- img
- heading "Branches" [level=3]
......
......@@ -3,9 +3,7 @@
- combobox:
- img
- text: "Branch: new-branch"
- button "Refresh branches":
- img
- button "Create new branch":
- button "Branch actions":
- img
- img
- heading "Branches" [level=3]
......
......@@ -3,9 +3,7 @@
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- button "Branch actions":
- img
- img
- heading "Branches" [level=3]
......
......@@ -3,9 +3,7 @@
- combobox:
- img
- text: "Branch: main"
- button "Refresh branches":
- img
- button "Create new branch":
- button "Branch actions":
- img
- img
- heading "Branches" [level=3]
......
......@@ -21,6 +21,8 @@ import {
Edit2,
MoreHorizontal,
AlertCircle,
GitPullRequestArrow,
EllipsisVertical,
} from "lucide-react";
import { useNavigate } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings";
......@@ -38,7 +40,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
......@@ -98,6 +99,7 @@ export function GithubBranchManager({
const [branchToMerge, setBranchToMerge] = useState<string | null>(null);
const [isMerging, setIsMerging] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isPulling, setIsPulling] = useState(false);
// State for abort confirmation dialog
const [abortConfirmation, setAbortConfirmation] = useState<{
show: boolean;
......@@ -368,6 +370,19 @@ export function GithubBranchManager({
}
};
const handleGitPull = async () => {
setIsPulling(true);
try {
await ipc.github.pull({ appId });
showSuccess("Pulled latest changes from remote");
await loadBranches();
} catch (error: any) {
showError(error.message || "Failed to pull changes");
} finally {
setIsPulling(false);
}
};
return (
<div className="space-y-2">
<div className="flex gap-2">
......@@ -380,7 +395,8 @@ export function GithubBranchManager({
isRenaming ||
isMerging ||
isCreating ||
isLoading
isLoading ||
isPulling
}
>
<SelectTrigger className="w-full" data-testid="branch-select-trigger">
......@@ -402,45 +418,56 @@ export function GithubBranchManager({
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={loadBranches}
disabled={isLoading}
title="Refresh branches"
data-testid="refresh-branches-button"
>
<RefreshCw
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>Refresh branches</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DropdownMenu>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
title="Create new branch"
data-testid="create-branch-trigger"
title="Branch actions"
data-testid="branch-actions-menu-trigger"
>
<Plus className="h-4 w-4" />
<EllipsisVertical className="h-4 w-4" />
</Button>
</DialogTrigger>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Create new branch</TooltipContent>
<TooltipContent>Branch actions</TooltipContent>
</Tooltip>
</TooltipProvider>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setShowCreateDialog(true)}
data-testid="create-branch-trigger"
>
<Plus className="mr-2 h-4 w-4" />
Create new branch
</DropdownMenuItem>
<DropdownMenuItem
onClick={loadBranches}
disabled={isLoading}
data-testid="refresh-branches-button"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh branches
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleGitPull}
disabled={isPulling}
data-testid="git-pull-button"
>
<GitPullRequestArrow
className={`mr-2 h-4 w-4 ${isPulling ? "animate-spin" : ""}`}
/>
Git pull
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Branch</DialogTitle>
......
......@@ -3,6 +3,7 @@ import { readSettings } from "../../main/settings";
import {
gitMergeAbort,
gitFetch,
gitPull,
gitCreateBranch,
gitDeleteBranch,
gitCheckout,
......@@ -348,10 +349,59 @@ async function handleCommitChanges(
});
}
// --- GitHub Pull Handler ---
async function handlePullFromGithub(
event: IpcMainInvokeEvent,
{ appId }: GitBranchAppIdParams,
): Promise<void> {
const settings = readSettings();
const accessToken = settings.githubAccessToken?.value;
if (!accessToken) {
throw new Error("Not authenticated with GitHub.");
}
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app || !app.githubOrg || !app.githubRepo) {
throw new Error("App is not linked to a GitHub repo.");
}
const appPath = getDyadAppPath(app.path);
const currentBranch = await gitCurrentBranch({ path: appPath });
try {
await gitPull({
path: appPath,
remote: "origin",
branch: currentBranch || "main",
accessToken,
});
} catch (pullError: any) {
// Check if it's a missing remote branch error
const errorMessage = pullError?.message || "";
const isMissingRemoteBranch =
pullError?.code === "MissingRefError" ||
(pullError?.code === "NotFoundError" &&
(errorMessage.includes("remote ref") ||
errorMessage.includes("remote branch"))) ||
errorMessage.includes("couldn't find remote ref") ||
errorMessage.includes("Cannot read properties of null");
// If the remote branch doesn't exist yet, we can ignore this
// (e.g., user hasn't pushed the branch yet)
if (!isMissingRemoteBranch) {
throw pullError;
} else {
logger.debug(
"[GitHub Handler] Remote branch missing during pull, continuing",
errorMessage,
);
}
}
}
// --- Registration ---
export function registerGithubBranchHandlers() {
createTypedHandler(githubContracts.mergeAbort, handleAbortMerge);
createTypedHandler(githubContracts.fetch, handleFetchFromGithub);
createTypedHandler(githubContracts.pull, handlePullFromGithub);
createTypedHandler(githubContracts.createBranch, handleCreateBranch);
createTypedHandler(githubContracts.deleteBranch, handleDeleteBranch);
createTypedHandler(githubContracts.switchBranch, handleSwitchBranch);
......
......@@ -186,6 +186,12 @@ export const githubContracts = {
output: z.void(),
}),
pull: defineContract({
channel: "github:pull",
input: GitBranchAppIdParamsSchema,
output: z.void(),
}),
rebase: defineContract({
channel: "github:rebase",
input: z.object({ appId: z.number() }),
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论