Unverified 提交 e73bea94 authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add uncommitted files banner with review & commit dialog (#2257)

- Add IPC handlers for getting uncommitted files with status and committing changes - Create useUncommittedFiles and useCommitChanges hooks - Add UncommittedFilesBanner component that shows when there are uncommitted changes - Display file status (added, modified, deleted, renamed) in the review dialog - Auto-generate sensible commit messages based on changes - Add E2E tests for the uncommitted files banner feature <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds an uncommitted files banner with a review & commit dialog in ChatHeader to make it easy to see changes and commit from the app. Improves visibility on main and streamlines committing with sensible default messages. - **New Features** - Show banner on main when there are uncommitted changes; includes count and “Review & commit”. - Dialog lists changed files with status (Added/Modified/Deleted/Renamed) and generates an editable default commit message. - Hooks and IPC: useUncommittedFiles (polls every 5s) and useCommitChanges; git:get-uncommitted-files and git:commit-changes. - Commit stages all changes, blocks during merge/rebase, and invalidates queries so the banner disappears and versions refresh. - E2E tests cover banner visibility, review/commit flow, success toast, and multiple file statuses. <sup>Written for commit d28ab8364e2344cfd4d9c9b548eeedaff3187f6a. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces an inline workflow to spot and commit local changes from the chat header when on `main`. > > - **UI**: Adds `UncommittedFilesBanner` in `ChatHeader` with “Review & commit” dialog showing changed files (status: Added/Modified/Deleted/Renamed) and a generated default commit message > - **Hooks**: New `useUncommittedFiles` (polls every 5s) and `useCommitChanges` (toast + query invalidation for `uncommittedFiles` and `versions`) > - **IPC & Types**: Adds `git:get-uncommitted-files` and `git:commit-changes`; updates `ipc_client.ts`, `ipc_types.ts`, and `preload.ts` > - **Git utils**: Implements `getGitUncommittedFilesWithStatus`, `gitAddAll`, and `gitCommit` with merge/rebase state checks and native/isomorphic support > - **Tests**: E2E (`uncommitted_files_banner.spec.ts`) validates banner visibility, dialog content, committing (including native Git path), and resulting commit > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d28ab8364e2344cfd4d9c9b548eeedaff3187f6a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: 's avatarClaude <noreply@anthropic.com> Co-authored-by: 's avatarcubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
上级 52e5a3f1
import { expect } from "@playwright/test";
import {
PageObject,
test,
testSkipIfWindows,
Timeout,
} from "./helpers/test_helper";
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
const runUncommittedFilesBannerTest = async (
po: PageObject,
nativeGit: boolean,
) => {
await po.setUp({ disableNativeGit: !nativeGit });
await po.sendPrompt("tc=basic");
const appPath = await po.getCurrentAppPath();
if (!appPath) {
throw new Error("No app path found");
}
// Ensure clean state - commit any existing changes first
const banner = po.page.getByTestId("uncommitted-files-banner");
// Verify banner is NOT visible when there are no uncommitted changes
await expect(banner).not.toBeVisible();
// Create a new file (tests "added" status)
const newFilePath = path.join(appPath, "new-file.txt");
fs.writeFileSync(newFilePath, "New file content for E2E test");
// Modify an existing file (tests "modified" status)
const indexPath = path.join(appPath, "index.html");
if (fs.existsSync(indexPath)) {
const content = fs.readFileSync(indexPath, "utf-8");
fs.writeFileSync(indexPath, content + "\n<!-- Modified for E2E test -->");
}
// Wait for the banner to appear
await expect(banner).toBeVisible({ timeout: Timeout.MEDIUM });
// Verify the banner text mentions uncommitted changes
await expect(banner).toContainText("uncommitted");
// Click the "Review & commit" button
await po.page.getByTestId("review-commit-button").click();
// Verify the dialog appears
await expect(po.page.getByTestId("commit-dialog")).toBeVisible();
// Verify the commit message input has a default value
const commitInput = po.page.getByTestId("commit-message-input");
await expect(commitInput).toBeVisible();
const defaultMessage = await commitInput.inputValue();
expect(defaultMessage.length).toBeGreaterThan(0);
// Verify the changed files list shows our files
const changedFilesList = po.page.getByTestId("changed-files-list");
await expect(changedFilesList).toContainText("new-file.txt");
await expect(changedFilesList).toContainText("Added");
// Check for modified file if index.html exists
if (fs.existsSync(indexPath)) {
await expect(changedFilesList).toContainText("index.html");
await expect(changedFilesList).toContainText("Modified");
}
// Edit the commit message with a unique identifier we can verify in git
const testCommitMessage = "E2E test commit - uncommitted files banner";
await commitInput.clear();
await commitInput.fill(testCommitMessage);
// Click the commit button
await po.page.getByTestId("commit-button").click();
// Wait for success toast
await po.waitForToast("success");
// The dialog should close
await expect(po.page.getByTestId("commit-dialog")).not.toBeVisible();
// The banner should disappear after commit
await expect(banner).not.toBeVisible({ timeout: Timeout.MEDIUM });
// Verify the git commit was actually made with the correct message
const gitLog = execSync("git log -1 --format=%s", {
cwd: appPath,
encoding: "utf-8",
}).trim();
expect(gitLog).toBe(testCommitMessage);
// Verify the files were committed
const lastCommitFiles = execSync(
"git diff-tree --no-commit-id --name-only -r HEAD",
{
cwd: appPath,
encoding: "utf-8",
},
).trim();
expect(lastCommitFiles).toContain("new-file.txt");
};
test("uncommitted files banner", async ({ po }) => {
await runUncommittedFilesBannerTest(po, false);
});
testSkipIfWindows(
"uncommitted files banner with native git",
async ({ po }) => {
await runUncommittedFilesBannerTest(po, true);
},
);
...@@ -28,6 +28,7 @@ import { useCheckoutVersion } from "@/hooks/useCheckoutVersion"; ...@@ -28,6 +28,7 @@ import { useCheckoutVersion } from "@/hooks/useCheckoutVersion";
import { useRenameBranch } from "@/hooks/useRenameBranch"; import { useRenameBranch } from "@/hooks/useRenameBranch";
import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms"; import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms";
import { LoadingBar } from "../ui/LoadingBar"; import { LoadingBar } from "../ui/LoadingBar";
import { UncommittedFilesBanner } from "./UncommittedFilesBanner";
interface ChatHeaderProps { interface ChatHeaderProps {
isVersionPaneOpen: boolean; isVersionPaneOpen: boolean;
...@@ -178,6 +179,11 @@ export function ChatHeader({ ...@@ -178,6 +179,11 @@ export function ChatHeader({
</div> </div>
)} )}
{/* Show uncommitted files banner when on a branch and there are uncommitted changes */}
{!isVersionPaneOpen && branchInfo?.branch && (
<UncommittedFilesBanner appId={appId} />
)}
{/* Why is this pt-0.5? Because the loading bar is h-1 (it always takes space) and we want the vertical spacing to be consistent.*/} {/* Why is this pt-0.5? Because the loading bar is h-1 (it always takes space) and we want the vertical spacing to be consistent.*/}
<div className="@container flex items-center justify-between pb-1.5 pt-0.5"> <div className="@container flex items-center justify-between pb-1.5 pt-0.5">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
......
import { useState } from "react";
import {
FileWarning,
Plus,
Pencil,
Trash2,
ArrowRightLeft,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
useUncommittedFiles,
type UncommittedFile,
} from "@/hooks/useUncommittedFiles";
import { useCommitChanges } from "@/hooks/useCommitChanges";
import { cn } from "@/lib/utils";
interface UncommittedFilesBannerProps {
appId: number | null;
}
function getStatusIcon(status: UncommittedFile["status"]) {
switch (status) {
case "added":
return <Plus className="h-4 w-4 text-green-500" />;
case "modified":
return <Pencil className="h-4 w-4 text-yellow-500" />;
case "deleted":
return <Trash2 className="h-4 w-4 text-red-500" />;
case "renamed":
return <ArrowRightLeft className="h-4 w-4 text-blue-500" />;
default:
return null;
}
}
function getStatusLabel(status: UncommittedFile["status"]) {
switch (status) {
case "added":
return "Added";
case "modified":
return "Modified";
case "deleted":
return "Deleted";
case "renamed":
return "Renamed";
default:
return status;
}
}
function generateDefaultCommitMessage(files: UncommittedFile[]): string {
if (files.length === 0) return "";
const added = files.filter((f) => f.status === "added").length;
const modified = files.filter((f) => f.status === "modified").length;
const deleted = files.filter((f) => f.status === "deleted").length;
const renamed = files.filter((f) => f.status === "renamed").length;
const parts: string[] = [];
if (added > 0) parts.push(`add ${added} file${added > 1 ? "s" : ""}`);
if (modified > 0)
parts.push(`update ${modified} file${modified > 1 ? "s" : ""}`);
if (deleted > 0)
parts.push(`remove ${deleted} file${deleted > 1 ? "s" : ""}`);
if (renamed > 0)
parts.push(`rename ${renamed} file${renamed > 1 ? "s" : ""}`);
if (parts.length === 0) return "Update files";
// Capitalize first letter
const message = parts.join(", ");
return message.charAt(0).toUpperCase() + message.slice(1);
}
export function UncommittedFilesBanner({ appId }: UncommittedFilesBannerProps) {
const { uncommittedFiles, hasUncommittedFiles, isLoading } =
useUncommittedFiles(appId);
const { commitChanges, isCommitting } = useCommitChanges();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
if (!appId || isLoading || !hasUncommittedFiles) {
return null;
}
const handleOpenDialog = () => {
// Set default commit message only when opening the dialog
// This prevents overwriting user's custom message during polling
setCommitMessage(generateDefaultCommitMessage(uncommittedFiles));
setIsDialogOpen(true);
};
const handleCommit = async () => {
if (!appId || !commitMessage.trim()) return;
await commitChanges({ appId, message: commitMessage.trim() });
setIsDialogOpen(false);
setCommitMessage("");
};
return (
<>
<div
className="flex flex-col @sm:flex-row items-center justify-between px-4 py-2 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
data-testid="uncommitted-files-banner"
>
<div className="flex items-center gap-2 text-sm">
<FileWarning size={16} />
<span>
You have <strong>{uncommittedFiles.length}</strong> uncommitted{" "}
{uncommittedFiles.length === 1 ? "change" : "changes"}.
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleOpenDialog}
data-testid="review-commit-button"
>
Review & commit
</Button>
</div>
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
// Prevent closing while committing
if (!open && isCommitting) return;
setIsDialogOpen(open);
}}
>
<DialogContent className="sm:max-w-lg" data-testid="commit-dialog">
<DialogHeader>
<DialogTitle>Review & Commit Changes</DialogTitle>
<DialogDescription>
Review your changes and enter a commit message.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label
htmlFor="commit-message"
className="text-sm font-medium mb-2 block"
>
Commit message
</label>
<Input
id="commit-message"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Enter commit message..."
data-testid="commit-message-input"
/>
</div>
<div>
<p className="text-sm font-medium mb-2">
Changed files ({uncommittedFiles.length})
</p>
<div
className="max-h-60 overflow-y-auto rounded-md border p-2 space-y-1"
data-testid="changed-files-list"
>
{uncommittedFiles.map((file) => (
<div
key={file.path}
className="flex items-center gap-2 text-sm py-1 px-2 rounded hover:bg-muted"
>
{getStatusIcon(file.status)}
<span
className={cn(
"flex-1 truncate font-mono text-xs",
file.status === "deleted" && "line-through opacity-60",
)}
>
{file.path}
</span>
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded",
file.status === "added" &&
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
file.status === "modified" &&
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
file.status === "deleted" &&
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
file.status === "renamed" &&
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
)}
>
{getStatusLabel(file.status)}
</span>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
disabled={isCommitting}
>
Cancel
</Button>
<Button
onClick={handleCommit}
disabled={!commitMessage.trim() || isCommitting}
data-testid="commit-button"
>
{isCommitting ? "Committing..." : "Commit"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
import { IpcClient } from "@/ipc/ipc_client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
export function useCommitChanges() {
const queryClient = useQueryClient();
const { mutateAsync: commitChanges, isPending: isCommitting } = useMutation({
mutationFn: async ({
appId,
message,
}: {
appId: number;
message: string;
}) => {
const ipcClient = IpcClient.getInstance();
return ipcClient.commitChanges({ appId, message });
},
onSuccess: (_, { appId }) => {
showSuccess("Changes committed successfully");
// Invalidate uncommitted files query
queryClient.invalidateQueries({ queryKey: ["uncommittedFiles", appId] });
// Also invalidate versions query to update version count
queryClient.invalidateQueries({ queryKey: ["versions", appId] });
},
onError: (error: Error) => {
showError(`Failed to commit: ${error.message}`);
},
});
return {
commitChanges,
isCommitting,
};
}
import { IpcClient } from "@/ipc/ipc_client";
import { useQuery } from "@tanstack/react-query";
import type { UncommittedFile } from "@/ipc/ipc_types";
export type { UncommittedFile };
export function useUncommittedFiles(appId: number | null) {
const {
data: uncommittedFiles,
isLoading,
refetch: refetchUncommittedFiles,
} = useQuery<UncommittedFile[], Error>({
queryKey: ["uncommittedFiles", appId],
queryFn: async (): Promise<UncommittedFile[]> => {
if (appId === null) {
throw new Error("appId is null, cannot fetch uncommitted files.");
}
const ipcClient = IpcClient.getInstance();
return ipcClient.getUncommittedFiles(appId);
},
enabled: appId !== null,
// Refetch every 5 seconds to keep the status updated
refetchInterval: 5000,
});
return {
uncommittedFiles: uncommittedFiles ?? [],
hasUncommittedFiles: (uncommittedFiles?.length ?? 0) > 0,
isLoading,
refetchUncommittedFiles,
};
}
...@@ -15,7 +15,11 @@ import { ...@@ -15,7 +15,11 @@ import {
GIT_ERROR_CODES, GIT_ERROR_CODES,
isGitMergeInProgress, isGitMergeInProgress,
isGitRebaseInProgress, isGitRebaseInProgress,
getGitUncommittedFilesWithStatus,
gitAddAll,
gitCommit,
} from "../utils/git_utils"; } from "../utils/git_utils";
import type { UncommittedFile } from "../ipc_types";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { db } from "../../db"; import { db } from "../../db";
import { apps } from "../../db/schema"; import { apps } from "../../db/schema";
...@@ -23,12 +27,18 @@ import { eq } from "drizzle-orm"; ...@@ -23,12 +27,18 @@ import { eq } from "drizzle-orm";
import log from "electron-log"; import log from "electron-log";
import { withLock } from "../utils/lock_utils"; import { withLock } from "../utils/lock_utils";
import { updateAppGithubRepo, ensureCleanWorkspace } from "./github_handlers"; import { updateAppGithubRepo, ensureCleanWorkspace } from "./github_handlers";
import type {
GitBranchAppIdParams,
CreateGitBranchParams,
GitBranchParams,
RenameGitBranchParams,
} from "../ipc_types";
const logger = log.scope("git_branch_handlers"); const logger = log.scope("git_branch_handlers");
async function handleAbortMerge( async function handleAbortMerge(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId }: { appId: number }, { appId }: GitBranchAppIdParams,
): Promise<void> { ): Promise<void> {
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 Error("App not found"); if (!app) throw new Error("App not found");
...@@ -40,7 +50,7 @@ async function handleAbortMerge( ...@@ -40,7 +50,7 @@ async function handleAbortMerge(
// --- GitHub Fetch Handler --- // --- GitHub Fetch Handler ---
async function handleFetchFromGithub( async function handleFetchFromGithub(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId }: { appId: number }, { appId }: GitBranchAppIdParams,
): Promise<void> { ): Promise<void> {
const settings = readSettings(); const settings = readSettings();
const accessToken = settings.githubAccessToken?.value; const accessToken = settings.githubAccessToken?.value;
...@@ -63,7 +73,7 @@ async function handleFetchFromGithub( ...@@ -63,7 +73,7 @@ async function handleFetchFromGithub(
// --- GitHub Branch Handlers --- // --- GitHub Branch Handlers ---
async function handleCreateBranch( async function handleCreateBranch(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId, branch, from }: { appId: number; branch: string; from?: string }, { appId, branch, from }: CreateGitBranchParams,
): Promise<void> { ): Promise<void> {
// Validate branch name // Validate branch name
if (!branch || branch.length === 0 || branch.length > 255) { if (!branch || branch.length === 0 || branch.length > 255) {
...@@ -96,7 +106,7 @@ async function handleCreateBranch( ...@@ -96,7 +106,7 @@ async function handleCreateBranch(
async function handleDeleteBranch( async function handleDeleteBranch(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId, branch }: { appId: number; branch: string }, { appId, branch }: GitBranchParams,
): Promise<void> { ): Promise<void> {
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 Error("App not found"); if (!app) throw new Error("App not found");
...@@ -110,7 +120,7 @@ async function handleDeleteBranch( ...@@ -110,7 +120,7 @@ async function handleDeleteBranch(
async function handleSwitchBranch( async function handleSwitchBranch(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId, branch }: { appId: number; branch: string }, { appId, branch }: GitBranchParams,
): Promise<void> { ): Promise<void> {
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 Error("App not found"); if (!app) throw new Error("App not found");
...@@ -169,11 +179,7 @@ async function handleSwitchBranch( ...@@ -169,11 +179,7 @@ async function handleSwitchBranch(
async function handleRenameBranch( async function handleRenameBranch(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ { appId, oldBranch, newBranch }: RenameGitBranchParams,
appId,
oldBranch,
newBranch,
}: { appId: number; oldBranch: string; newBranch: string },
): Promise<void> { ): Promise<void> {
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 Error("App not found"); if (!app) throw new Error("App not found");
...@@ -211,7 +217,7 @@ class MergeConflictError extends Error { ...@@ -211,7 +217,7 @@ class MergeConflictError extends Error {
async function handleMergeBranch( async function handleMergeBranch(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId, branch }: { appId: number; branch: string }, { appId, branch }: GitBranchParams,
): Promise<void> { ): Promise<void> {
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 Error("App not found"); if (!app) throw new Error("App not found");
...@@ -272,7 +278,7 @@ async function handleMergeBranch( ...@@ -272,7 +278,7 @@ async function handleMergeBranch(
async function handleListLocalBranches( async function handleListLocalBranches(
event: IpcMainInvokeEvent, event: IpcMainInvokeEvent,
{ appId }: { appId: number }, { appId }: GitBranchAppIdParams,
): Promise<{ branches: string[]; current: string | null }> { ): Promise<{ branches: string[]; current: string | null }> {
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 Error("App not found"); if (!app) throw new Error("App not found");
...@@ -295,6 +301,51 @@ async function handleListRemoteBranches( ...@@ -295,6 +301,51 @@ async function handleListRemoteBranches(
return branches; return branches;
} }
async function handleGetUncommittedFiles(
event: IpcMainInvokeEvent,
{ appId }: GitBranchAppIdParams,
): Promise<UncommittedFile[]> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
return getGitUncommittedFilesWithStatus({ path: appPath });
}
async function handleCommitChanges(
event: IpcMainInvokeEvent,
{ appId, message }: { appId: number; message: string },
): Promise<string> {
const app = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
if (!app) throw new Error("App not found");
const appPath = getDyadAppPath(app.path);
return withLock(appId, async () => {
// Check for merge or rebase in progress
if (isGitMergeInProgress({ path: appPath })) {
throw GitStateError(
"Cannot commit: merge in progress. Please complete or abort the merge first.",
GIT_ERROR_CODES.MERGE_IN_PROGRESS,
);
}
if (isGitRebaseInProgress({ path: appPath })) {
throw GitStateError(
"Cannot commit: rebase in progress. Please complete or abort the rebase first.",
GIT_ERROR_CODES.REBASE_IN_PROGRESS,
);
}
// Stage all changes
await gitAddAll({ path: appPath });
// Commit with the provided message
const commitHash = await gitCommit({ path: appPath, message });
return commitHash;
});
}
// --- Registration --- // --- Registration ---
export function registerGithubBranchHandlers() { export function registerGithubBranchHandlers() {
ipcMain.handle("github:merge-abort", handleAbortMerge); ipcMain.handle("github:merge-abort", handleAbortMerge);
...@@ -306,4 +357,6 @@ export function registerGithubBranchHandlers() { ...@@ -306,4 +357,6 @@ export function registerGithubBranchHandlers() {
ipcMain.handle("github:merge-branch", handleMergeBranch); ipcMain.handle("github:merge-branch", handleMergeBranch);
ipcMain.handle("github:list-local-branches", handleListLocalBranches); ipcMain.handle("github:list-local-branches", handleListLocalBranches);
ipcMain.handle("github:list-remote-branches", handleListRemoteBranches); ipcMain.handle("github:list-remote-branches", handleListRemoteBranches);
ipcMain.handle("git:get-uncommitted-files", handleGetUncommittedFiles);
ipcMain.handle("git:commit-changes", handleCommitChanges);
} }
...@@ -33,6 +33,12 @@ import type { ...@@ -33,6 +33,12 @@ import type {
ImportAppResult, ImportAppResult,
ImportAppParams, ImportAppParams,
RenameBranchParams, RenameBranchParams,
GitBranchAppIdParams,
CreateGitBranchParams,
GitBranchParams,
RenameGitBranchParams,
ListRemoteGitBranchesParams,
CommitChangesParams,
UserBudgetInfo, UserBudgetInfo,
CopyAppParams, CopyAppParams,
App, App,
...@@ -89,6 +95,7 @@ import type { ...@@ -89,6 +95,7 @@ import type {
ConsoleEntry, ConsoleEntry,
SetAppThemeParams, SetAppThemeParams,
GetAppThemeParams, GetAppThemeParams,
UncommittedFile,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
import type { Theme } from "../shared/themes"; import type { Theme } from "../shared/themes";
...@@ -896,7 +903,7 @@ export class IpcClient { ...@@ -896,7 +903,7 @@ export class IpcClient {
public async abortGithubMerge(appId: number): Promise<void> { public async abortGithubMerge(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:merge-abort", { await this.ipcRenderer.invoke("github:merge-abort", {
appId, appId,
}); } satisfies GitBranchAppIdParams);
} }
public async continueGithubRebase(appId: number): Promise<void> { public async continueGithubRebase(appId: number): Promise<void> {
...@@ -916,7 +923,9 @@ export class IpcClient { ...@@ -916,7 +923,9 @@ export class IpcClient {
} }
public async fetchGithubRepo(appId: number): Promise<void> { public async fetchGithubRepo(appId: number): Promise<void> {
await this.ipcRenderer.invoke("github:fetch", { appId }); await this.ipcRenderer.invoke("github:fetch", {
appId,
} satisfies GitBranchAppIdParams);
} }
public async createGithubBranch( public async createGithubBranch(
...@@ -928,21 +937,27 @@ export class IpcClient { ...@@ -928,21 +937,27 @@ export class IpcClient {
appId, appId,
branch, branch,
from, from,
}); } satisfies CreateGitBranchParams);
} }
public async deleteGithubBranch( public async deleteGithubBranch(
appId: number, appId: number,
branch: string, branch: string,
): Promise<void> { ): Promise<void> {
await this.ipcRenderer.invoke("github:delete-branch", { appId, branch }); await this.ipcRenderer.invoke("github:delete-branch", {
appId,
branch,
} satisfies GitBranchParams);
} }
public async switchGithubBranch( public async switchGithubBranch(
appId: number, appId: number,
branch: string, branch: string,
): Promise<void> { ): Promise<void> {
await this.ipcRenderer.invoke("github:switch-branch", { appId, branch }); await this.ipcRenderer.invoke("github:switch-branch", {
appId,
branch,
} satisfies GitBranchParams);
} }
public async renameGithubBranch( public async renameGithubBranch(
...@@ -954,11 +969,14 @@ export class IpcClient { ...@@ -954,11 +969,14 @@ export class IpcClient {
appId, appId,
oldBranch, oldBranch,
newBranch, newBranch,
}); } satisfies RenameGitBranchParams);
} }
public async mergeGithubBranch(appId: number, branch: string): Promise<void> { public async mergeGithubBranch(appId: number, branch: string): Promise<void> {
await this.ipcRenderer.invoke("github:merge-branch", { appId, branch }); await this.ipcRenderer.invoke("github:merge-branch", {
appId,
branch,
} satisfies GitBranchParams);
} }
public async getGithubMergeConflicts(appId: number): Promise<string[]> { public async getGithubMergeConflicts(appId: number): Promise<string[]> {
...@@ -968,7 +986,9 @@ export class IpcClient { ...@@ -968,7 +986,9 @@ export class IpcClient {
public async listLocalGithubBranches( public async listLocalGithubBranches(
appId: number, appId: number,
): Promise<{ branches: string[]; current: string | null }> { ): Promise<{ branches: string[]; current: string | null }> {
return this.ipcRenderer.invoke("github:list-local-branches", { appId }); return this.ipcRenderer.invoke("github:list-local-branches", {
appId,
} satisfies GitBranchAppIdParams);
} }
public async listRemoteGithubBranches( public async listRemoteGithubBranches(
...@@ -978,7 +998,7 @@ export class IpcClient { ...@@ -978,7 +998,7 @@ export class IpcClient {
return this.ipcRenderer.invoke("github:list-remote-branches", { return this.ipcRenderer.invoke("github:list-remote-branches", {
appId, appId,
remote, remote,
}); } satisfies ListRemoteGitBranchesParams);
} }
public async getGithubState(appId: number): Promise<{ public async getGithubState(appId: number): Promise<{
...@@ -988,6 +1008,16 @@ export class IpcClient { ...@@ -988,6 +1008,16 @@ export class IpcClient {
return this.ipcRenderer.invoke("github:get-git-state", { appId }); return this.ipcRenderer.invoke("github:get-git-state", { appId });
} }
public async getUncommittedFiles(appId: number): Promise<UncommittedFile[]> {
return this.ipcRenderer.invoke("git:get-uncommitted-files", {
appId,
} satisfies GitBranchAppIdParams);
}
public async commitChanges(params: CommitChangesParams): Promise<string> {
return this.ipcRenderer.invoke("git:commit-changes", params);
}
public async listCollaborators( public async listCollaborators(
appId: number, appId: number,
): Promise<{ login: string; avatar_url: string; permissions: any }[]> { ): Promise<{ login: string; avatar_url: string; permissions: any }[]> {
......
...@@ -299,6 +299,38 @@ export interface RenameBranchParams { ...@@ -299,6 +299,38 @@ export interface RenameBranchParams {
newBranchName: string; newBranchName: string;
} }
// --- Git Branch Handler Types ---
export interface GitBranchAppIdParams {
appId: number;
}
export interface CreateGitBranchParams {
appId: number;
branch: string;
from?: string;
}
export interface GitBranchParams {
appId: number;
branch: string;
}
export interface RenameGitBranchParams {
appId: number;
oldBranch: string;
newBranch: string;
}
export interface ListRemoteGitBranchesParams {
appId: number;
remote?: string;
}
export interface CommitChangesParams {
appId: number;
message: string;
}
export interface ChangeAppLocationParams { export interface ChangeAppLocationParams {
appId: number; appId: number;
parentDirectory: string; parentDirectory: string;
...@@ -746,3 +778,15 @@ export interface SetAppThemeParams { ...@@ -746,3 +778,15 @@ export interface SetAppThemeParams {
export interface GetAppThemeParams { export interface GetAppThemeParams {
appId: number; appId: number;
} }
// --- Uncommitted Files Types ---
export type UncommittedFileStatus =
| "added"
| "modified"
| "deleted"
| "renamed";
export interface UncommittedFile {
path: string;
status: UncommittedFileStatus;
}
...@@ -8,6 +8,7 @@ import pathModule from "node:path"; ...@@ -8,6 +8,7 @@ import pathModule from "node:path";
import { readSettings } from "../../main/settings"; import { readSettings } from "../../main/settings";
import log from "electron-log"; import log from "electron-log";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import type { UncommittedFile, UncommittedFileStatus } from "../ipc_types";
const logger = log.scope("git_utils"); const logger = log.scope("git_utils");
import type { import type {
GitBaseParams, GitBaseParams,
...@@ -416,6 +417,90 @@ export async function getGitUncommittedFiles({ ...@@ -416,6 +417,90 @@ export async function getGitUncommittedFiles({
} }
} }
// Re-export from ipc_types for backwards compatibility
export type { UncommittedFile, UncommittedFileStatus } from "../ipc_types";
/**
* Get uncommitted files with their status (added, modified, deleted, renamed).
* This parses git status --porcelain output to determine the file status.
*/
export async function getGitUncommittedFilesWithStatus({
path,
}: GitBaseParams): Promise<UncommittedFile[]> {
const settings = readSettings();
if (settings.enableNativeGit) {
const result = await exec(["status", "--porcelain"], path);
if (result.exitCode !== 0) {
throw new Error(
`Failed to get uncommitted files: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
return result.stdout
.toString()
.split("\n")
.filter((line) => line.trim() !== "")
.map((line) => {
// Git status --porcelain format: XY filename
// X = staged status, Y = unstaged status
// Common codes: M=modified, A=added, D=deleted, R=renamed, ??=untracked
const statusCode = line.substring(0, 2);
let filePath = line.slice(3).trim();
// Handle renamed files: R old -> new
if (statusCode.startsWith("R")) {
const arrowIndex = filePath.indexOf(" -> ");
if (arrowIndex !== -1) {
filePath = filePath.substring(arrowIndex + 4);
}
return { path: filePath, status: "renamed" as UncommittedFileStatus };
}
// Determine status based on status codes
// Check deleted first: for status code "AD" (added to index, then deleted
// from working directory), the file no longer exists so report as deleted
let status: UncommittedFileStatus;
if (statusCode.includes("D")) {
status = "deleted";
} else if (statusCode === "??" || statusCode.includes("A")) {
status = "added";
} else {
status = "modified";
}
return { path: filePath, status };
});
} else {
// For isomorphic-git, we use the status matrix
// [filepath, HEAD, WORKDIR, STAGE]
// HEAD: 0=absent, 1=present
// WORKDIR: 0=absent, 1=identical to HEAD, 2=modified
// STAGE: 0=absent, 1=identical to HEAD, 2=added, 3=modified
const statusMatrix = await git.statusMatrix({ fs, dir: path });
return statusMatrix
.filter((row) => row[1] !== 1 || row[2] !== 1 || row[3] !== 1)
.map((row) => {
const filePath = row[0];
const head = row[1];
const workdir = row[2];
// Check workdir === 0 first: for a file added to index then deleted from
// working directory, the file no longer exists so report as deleted
let status: UncommittedFileStatus;
if (workdir === 0) {
// File deleted from workdir
status = "deleted";
} else if (head === 0) {
// File not in HEAD = new file
status = "added";
} else {
status = "modified";
}
return { path: filePath, status };
});
}
}
export async function getFileAtCommit({ export async function getFileAtCommit({
path, path,
filePath, filePath,
......
...@@ -80,6 +80,8 @@ const validInvokeChannels = [ ...@@ -80,6 +80,8 @@ const validInvokeChannels = [
"github:delete-branch", "github:delete-branch",
"github:get-git-state", "github:get-git-state",
"github:disconnect", "github:disconnect",
"git:get-uncommitted-files",
"git:commit-changes",
"neon:create-project", "neon:create-project",
"neon:get-project", "neon:get-project",
"neon:delete-branch", "neon:delete-branch",
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论