Unverified 提交 daec6206 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Adding discard changes button when reviewing uncommitted changes (#3165)

<!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3165" target="_blank"> <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 --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 75867f6e
...@@ -9,6 +9,74 @@ import * as fs from "fs"; ...@@ -9,6 +9,74 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import { execSync } from "child_process"; import { execSync } from "child_process";
const runDiscardChangesTest = async (po: PageObject, nativeGit: boolean) => {
await po.setUp({ disableNativeGit: !nativeGit });
await po.sendPrompt("tc=basic");
const appPath = await po.appManagement.getCurrentAppPath();
if (!appPath) {
throw new Error("No app path found");
}
const banner = po.page.getByTestId("uncommitted-files-banner");
// Verify clean state
await expect(banner).not.toBeVisible();
// Create a new file (untracked)
const newFilePath = path.join(appPath, "discard-test.txt");
fs.writeFileSync(newFilePath, "This file should be discarded");
// Modify an existing file
const indexPath = path.join(appPath, "index.html");
let originalContent: string | null = null;
if (fs.existsSync(indexPath)) {
originalContent = fs.readFileSync(indexPath, "utf-8");
fs.writeFileSync(
indexPath,
originalContent + "\n<!-- Should be discarded -->",
);
}
// Wait for the banner to appear
await expect(banner).toBeVisible({ timeout: Timeout.MEDIUM });
// Click "Review & commit" to open the dialog
await po.page.getByTestId("review-commit-button").click();
await expect(po.page.getByTestId("commit-dialog")).toBeVisible();
// Verify files are listed
const changedFilesList = po.page.getByTestId("changed-files-list");
await expect(changedFilesList).toContainText("discard-test.txt");
// Click "Discard all" button
await po.page.getByTestId("discard-button").click();
// Verify confirmation warning appears
await expect(po.page.getByTestId("confirm-discard-button")).toBeVisible();
// Confirm the discard
await po.page.getByTestId("confirm-discard-button").click();
// Wait for success toast
await po.toastNotifications.waitForToast("success");
// Dialog should close
await expect(po.page.getByTestId("commit-dialog")).not.toBeVisible();
// Banner should disappear
await expect(banner).not.toBeVisible({ timeout: Timeout.MEDIUM });
// Verify the new file was removed
expect(fs.existsSync(newFilePath)).toBe(false);
// Verify the modified file was restored
if (originalContent !== null) {
const restoredContent = fs.readFileSync(indexPath, "utf-8");
expect(restoredContent).toBe(originalContent);
}
};
const runUncommittedFilesBannerTest = async ( const runUncommittedFilesBannerTest = async (
po: PageObject, po: PageObject,
nativeGit: boolean, nativeGit: boolean,
...@@ -112,3 +180,14 @@ testSkipIfWindows( ...@@ -112,3 +180,14 @@ testSkipIfWindows(
await runUncommittedFilesBannerTest(po, true); await runUncommittedFilesBannerTest(po, true);
}, },
); );
test("discard all uncommitted changes", async ({ po }) => {
await runDiscardChangesTest(po, false);
});
testSkipIfWindows(
"discard all uncommitted changes with native git",
async ({ po }) => {
await runDiscardChangesTest(po, true);
},
);
{ {
"name": "dyad", "name": "dyad",
"version": "0.42.0", "version": "0.43.0-beta.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dyad", "name": "dyad",
"version": "0.42.0", "version": "0.43.0-beta.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.46", "@ai-sdk/amazon-bedrock": "^4.0.46",
......
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { import {
FileWarning, FileWarning,
Plus, Plus,
Pencil, Pencil,
Trash2, Trash2,
ArrowRightLeft, ArrowRightLeft,
TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
...@@ -21,6 +22,7 @@ import { ...@@ -21,6 +22,7 @@ import {
type UncommittedFile, type UncommittedFile,
} from "@/hooks/useUncommittedFiles"; } from "@/hooks/useUncommittedFiles";
import { useCommitChanges } from "@/hooks/useCommitChanges"; import { useCommitChanges } from "@/hooks/useCommitChanges";
import { useDiscardChanges } from "@/hooks/useDiscardChanges";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface UncommittedFilesBannerProps { interface UncommittedFilesBannerProps {
...@@ -85,8 +87,21 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) { ...@@ -85,8 +87,21 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) {
const { uncommittedFiles, hasUncommittedFiles, isLoading } = const { uncommittedFiles, hasUncommittedFiles, isLoading } =
useUncommittedFiles(appId); useUncommittedFiles(appId);
const { commitChanges, isCommitting } = useCommitChanges(); const { commitChanges, isCommitting } = useCommitChanges();
const { discardChanges, isDiscarding } = useDiscardChanges();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [commitMessage, setCommitMessage] = useState(""); const [commitMessage, setCommitMessage] = useState("");
const [showDiscardConfirm, setShowDiscardConfirm] = useState(false);
const confirmPanelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (showDiscardConfirm) {
confirmPanelRef.current
?.querySelector<HTMLButtonElement>(
'[data-testid="confirm-discard-button"]',
)
?.focus();
}
}, [showDiscardConfirm]);
if (!appId || isLoading || !hasUncommittedFiles) { if (!appId || isLoading || !hasUncommittedFiles) {
return null; return null;
...@@ -103,10 +118,19 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) { ...@@ -103,10 +118,19 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) {
if (!appId || !commitMessage.trim()) return; if (!appId || !commitMessage.trim()) return;
await commitChanges({ appId, message: commitMessage.trim() }); await commitChanges({ appId, message: commitMessage.trim() });
setShowDiscardConfirm(false);
setIsDialogOpen(false); setIsDialogOpen(false);
setCommitMessage(""); setCommitMessage("");
}; };
const handleDiscard = async () => {
if (!appId) return;
await discardChanges({ appId });
setShowDiscardConfirm(false);
setIsDialogOpen(false);
};
return ( return (
<> <>
<div <div
...@@ -133,8 +157,9 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) { ...@@ -133,8 +157,9 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) {
<Dialog <Dialog
open={isDialogOpen} open={isDialogOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
// Prevent closing while committing // Prevent closing while committing or discarding
if (!open && isCommitting) return; if (!open && (isCommitting || isDiscarding)) return;
if (!open) setShowDiscardConfirm(false);
setIsDialogOpen(open); setIsDialogOpen(open);
}} }}
> >
...@@ -206,17 +231,67 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) { ...@@ -206,17 +231,67 @@ export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) {
</div> </div>
</div> </div>
{showDiscardConfirm && (
<div
ref={confirmPanelRef}
role="alertdialog"
aria-labelledby="discard-confirm-title"
aria-describedby="discard-confirm-desc"
className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3"
>
<TriangleAlert className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<p
id="discard-confirm-title"
className="text-sm text-destructive font-medium"
>
Discard changes to {uncommittedFiles.length}{" "}
{uncommittedFiles.length === 1 ? "file" : "files"}?{" "}
<span id="discard-confirm-desc">This cannot be undone.</span>
</p>
<div className="flex gap-2">
<Button
variant="destructive"
size="sm"
onClick={handleDiscard}
disabled={isDiscarding}
data-testid="confirm-discard-button"
>
{isDiscarding ? "Discarding..." : "Yes, discard all"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDiscardConfirm(false)}
disabled={isDiscarding}
>
Keep changes
</Button>
</div>
</div>
</div>
)}
<DialogFooter> <DialogFooter>
<Button
variant="outline"
className="text-destructive hover:text-destructive hover:bg-destructive/10 mr-auto"
onClick={() => setShowDiscardConfirm(true)}
disabled={isCommitting || isDiscarding || showDiscardConfirm}
data-testid="discard-button"
>
Discard all
</Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsDialogOpen(false)} onClick={() => setIsDialogOpen(false)}
disabled={isCommitting} disabled={isCommitting || isDiscarding}
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleCommit} onClick={handleCommit}
disabled={!commitMessage.trim() || isCommitting} disabled={!commitMessage.trim() || isCommitting || isDiscarding}
data-testid="commit-button" data-testid="commit-button"
> >
{isCommitting ? "Committing..." : "Commit"} {isCommitting ? "Committing..." : "Commit"}
......
import { ipc } from "@/ipc/types";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
import { queryKeys } from "@/lib/queryKeys";
export function useDiscardChanges() {
const queryClient = useQueryClient();
const { mutateAsync: discardChanges, isPending: isDiscarding } = useMutation({
mutationFn: async ({ appId }: { appId: number }) => {
return ipc.git.discardChanges({ appId });
},
onSuccess: (_, { appId }) => {
showSuccess("All changes discarded");
queryClient.invalidateQueries({
queryKey: queryKeys.uncommittedFiles.byApp({ appId }),
});
queryClient.invalidateQueries({
queryKey: queryKeys.versions.list({ appId }),
});
},
onError: (error: Error) => {
showError(`Failed to discard changes: ${error.message}`);
},
});
return {
discardChanges,
isDiscarding,
};
}
...@@ -20,6 +20,7 @@ import { ...@@ -20,6 +20,7 @@ import {
getGitUncommittedFilesWithStatus, getGitUncommittedFilesWithStatus,
gitAddAll, gitAddAll,
gitCommit, gitCommit,
gitDiscardAllChanges,
} from "../utils/git_utils"; } from "../utils/git_utils";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { db } from "../../db"; import { db } from "../../db";
...@@ -370,37 +371,50 @@ async function handleGetUncommittedFiles( ...@@ -370,37 +371,50 @@ async function handleGetUncommittedFiles(
return getGitUncommittedFilesWithStatus({ path: appPath }); return getGitUncommittedFilesWithStatus({ path: appPath });
} }
async function handleCommitChanges( async function withAppGitOp<T>(
event: IpcMainInvokeEvent, appId: number,
{ appId, message }: { appId: number; message: string }, operation: string,
): Promise<string> { fn: (appPath: string) => Promise<T>,
): Promise<T> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) }); const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound); if (!app) throw new DyadError("App not found", DyadErrorKind.NotFound);
const appPath = getDyadAppPath(app.path); const appPath = getDyadAppPath(app.path);
return withLock(appId, async () => { return withLock(appId, async () => {
// Check for merge or rebase in progress
if (isGitMergeInProgress({ path: appPath })) { if (isGitMergeInProgress({ path: appPath })) {
throw GitStateError( throw GitStateError(
"Cannot commit: merge in progress. Please complete or abort the merge first.", `Cannot ${operation}: merge in progress. Please complete or abort the merge first.`,
GIT_ERROR_CODES.MERGE_IN_PROGRESS, GIT_ERROR_CODES.MERGE_IN_PROGRESS,
); );
} }
if (isGitRebaseInProgress({ path: appPath })) { if (isGitRebaseInProgress({ path: appPath })) {
throw GitStateError( throw GitStateError(
"Cannot commit: rebase in progress. Please complete or abort the rebase first.", `Cannot ${operation}: rebase in progress. Please complete or abort the rebase first.`,
GIT_ERROR_CODES.REBASE_IN_PROGRESS, GIT_ERROR_CODES.REBASE_IN_PROGRESS,
); );
} }
// Stage all changes return fn(appPath);
await gitAddAll({ path: appPath }); });
}
// Commit with the provided message async function handleCommitChanges(
const commitHash = await gitCommit({ path: appPath, message }); _event: IpcMainInvokeEvent,
{ appId, message }: { appId: number; message: string },
): Promise<string> {
return withAppGitOp(appId, "commit", async (appPath) => {
await gitAddAll({ path: appPath });
return gitCommit({ path: appPath, message });
});
}
return commitHash; async function handleDiscardChanges(
_event: IpcMainInvokeEvent,
{ appId }: GitBranchAppIdParams,
): Promise<void> {
return withAppGitOp(appId, "discard changes", async (appPath) => {
await gitDiscardAllChanges({ path: appPath });
}); });
} }
...@@ -478,4 +492,5 @@ export function registerGithubBranchHandlers() { ...@@ -478,4 +492,5 @@ export function registerGithubBranchHandlers() {
handleGetUncommittedFiles, handleGetUncommittedFiles,
); );
createTypedHandler(gitContracts.commitChanges, handleCommitChanges); createTypedHandler(gitContracts.commitChanges, handleCommitChanges);
createTypedHandler(gitContracts.discardChanges, handleDiscardChanges);
} }
...@@ -314,6 +314,12 @@ export const gitContracts = { ...@@ -314,6 +314,12 @@ export const gitContracts = {
input: CommitChangesParamsSchema, input: CommitChangesParamsSchema,
output: z.string(), // Returns commit hash output: z.string(), // Returns commit hash
}), }),
discardChanges: defineContract({
channel: "git:discard-changes",
input: GitBranchAppIdParamsSchema,
output: z.void(),
}),
} as const; } as const;
// ============================================================================= // =============================================================================
......
...@@ -537,6 +537,85 @@ export async function gitReset({ path }: GitBaseParams): Promise<void> { ...@@ -537,6 +537,85 @@ export async function gitReset({ path }: GitBaseParams): Promise<void> {
} }
} }
export async function gitDiscardAllChanges({
path,
}: GitBaseParams): Promise<void> {
const settings = readSettings();
if (settings.enableNativeGit) {
// Reset all tracked files (index + working tree) to HEAD state
await execOrThrow(
["reset", "--hard", "HEAD"],
path,
"Failed to reset to HEAD",
);
// Remove untracked files and directories
await execOrThrow(
["clean", "-fd"],
path,
"Failed to remove untracked files",
);
} else {
const matrix = await git.statusMatrix({ fs, dir: path });
const removedFileDirs = new Set<string>();
for (const row of matrix) {
const [filepath, headStatus, workdirStatus, stageStatus] = row;
const fullPath = pathModule.join(path, filepath);
if (headStatus === 1) {
// Tracked file: restore if changed in workdir or stage
if (workdirStatus !== 1 || stageStatus !== 1) {
await git.checkout({
fs,
dir: path,
ref: "HEAD",
filepaths: [filepath],
force: true,
});
}
} else if (stageStatus !== 0) {
// Staged new file: remove from index
await git.remove({ fs, dir: path, filepath });
// Delete from disk if still present
if (fs.existsSync(fullPath)) {
await fsPromises.rm(fullPath, { recursive: true, force: true });
removedFileDirs.add(pathModule.dirname(fullPath));
}
} else if (workdirStatus !== 0) {
// Purely untracked file/directory: just delete from disk
if (fs.existsSync(fullPath)) {
await fsPromises.rm(fullPath, { recursive: true, force: true });
removedFileDirs.add(pathModule.dirname(fullPath));
}
}
}
// Prune empty directories only where files were actually removed.
// Collect each dir and its parent chain up to the repo root, then
// sort deepest-first so children are removed before parents.
const dirsToCheck = new Set<string>();
for (const dir of removedFileDirs) {
let current = dir;
while (current !== path && current.startsWith(path)) {
dirsToCheck.add(current);
current = pathModule.dirname(current);
}
}
const sorted = [...dirsToCheck].sort((a, b) => b.length - a.length);
for (const dir of sorted) {
try {
const remaining = await fsPromises.readdir(dir);
if (remaining.length === 0) {
await fsPromises.rmdir(dir);
}
} catch {
// Ignore errors (broken symlinks, permission issues) so the
// discard operation isn't aborted by a single failing directory.
}
}
}
}
export async function gitInit({ export async function gitInit({
path, path,
ref = "main", ref = "main",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论