diff --git a/src/main/gitService.ts b/src/main/gitService.ts index f91b7e7..35ca640 100644 --- a/src/main/gitService.ts +++ b/src/main/gitService.ts @@ -1013,6 +1013,23 @@ export class GitService { ], undefined, validation.patch); } + async discardHunk(request: GitHunkRequest): Promise { + if (request.side !== "unstaged") { + return this.createOperationFailure(request.repoPath, "Only unstaged hunks can be reverted."); + } + const validation = await this.validateHunkRequest(request, "unstaged"); + if ("error" in validation) { + return this.createOperationFailure(request.repoPath, validation.error); + } + + return this.runGitOperation(request.repoPath, [ + "apply", + "--reverse", + "--whitespace=nowarn", + "-" + ], undefined, validation.patch); + } + async unstageHunk(request: GitHunkRequest): Promise { const validation = await this.validateHunkRequest(request, "staged"); if ("error" in validation) { diff --git a/src/main/main.ts b/src/main/main.ts index b75e9d1..47828ec 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -795,6 +795,18 @@ ipcMain.handle(IPC_CHANNELS.unstageFiles, async (event, request: CoordinatedRequ ); }); +ipcMain.handle(IPC_CHANNELS.discardHunk, async (event, request: CoordinatedRequest) => { + return runExclusiveGitOperation( + async () => { + if ((await vcsRouter.resolveKind(request.repoPath)) !== "git") { + return createOperationFailure(request.repoPath, "Revert Hunk is available only for Git repositories."); + } + return gitService.discardHunk(request); + }, + repositoryOperationOptions(event, request.operationId, request.repoPath) + ); +}); + ipcMain.handle(IPC_CHANNELS.stageHunk, async (event, request: CoordinatedRequest) => { return runExclusiveGitOperation( async () => (await vcsRouter.serviceForRepo(request.repoPath)).stageHunk(request), diff --git a/src/main/preload.ts b/src/main/preload.ts index ae8ac65..9ef6239 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -244,6 +244,8 @@ const api: GitheadApi = { ipcRenderer.invoke(IPC_CHANNELS.stageFiles, request) as ReturnType, unstageFiles: (request: CoordinatedRequest) => ipcRenderer.invoke(IPC_CHANNELS.unstageFiles, request) as ReturnType, + discardHunk: (request: CoordinatedRequest) => + ipcRenderer.invoke(IPC_CHANNELS.discardHunk, request) as ReturnType, stageHunk: (request: CoordinatedRequest) => ipcRenderer.invoke(IPC_CHANNELS.stageHunk, request) as ReturnType, unstageHunk: (request: CoordinatedRequest) => diff --git a/src/renderer/App.history.test.tsx b/src/renderer/App.history.test.tsx index 63c8a5c..105508c 100644 --- a/src/renderer/App.history.test.tsx +++ b/src/renderer/App.history.test.tsx @@ -1935,6 +1935,27 @@ describe("App", { timeout: 10_000 }, () => { expect(screen.queryByRole("button", { name: "Wrap diff lines" })).toBeNull(); }); + it("confirms hunk discard and reloads the remaining changes", async () => { + const user = userEvent.setup(); + const file = createStatusFile("src/App.tsx", { isUnstaged: true, worktreeStatus: "M" }); + const diff = createTextDiff(file.path, "discard-me"); + vi.mocked(githead.getRepoSummary).mockResolvedValue(createSummary({ files: [file] })); + vi.mocked(githead.getFileDiff).mockResolvedValueOnce(diff).mockResolvedValue(createTextDiff(file.path, "remaining-hunk")); + render(); + await user.click(await screen.findByRole("option", { name: /src\/App\.tsx/ })); + await user.click(await screen.findByRole("button", { name: "Revert Hunk" })); + expect(githead.discardHunk).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(githead.discardHunk).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Revert Hunk" })); + await user.click(screen.getByRole("button", { name: "Revert changes" })); + await waitFor(() => expect(githead.discardHunk).toHaveBeenCalledWith({ + repoPath, path: file.path, side: "unstaged", patch: `${diff.text}\n`, operationId: expect.any(String) + })); + expect(await screen.findByText("remaining-hunk")).toBeTruthy(); + expect(githead.stageHunk).not.toHaveBeenCalled(); + }); + it("keeps an unstaged file selected and reloads its remaining diff after staging a hunk", async () => { const user = userEvent.setup(); const initialFile = createStatusFile("src/App.tsx", { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 254a0ab..748e27c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -5098,15 +5098,19 @@ export function App({ initialAppSettings = null }: { initialAppSettings?: AppSet ); }, [runRepoOperation]); - const applySelectedHunk = useCallback(async (patch: string): Promise => { + const applySelectedHunk = useCallback(async (patch: string, discard = false): Promise => { const current = stateRef.current; const selection = current.selection; - if (!selection || current.diffChanged) { + if (!selection || current.diffChanged || (discard && selection.side !== "unstaged")) { return; } const repoPath = current.repoPath; - const result = selection.side === "unstaged" + const result = discard + ? await runRepoOperation("Reverting hunk", selection, (operationId) => + window.githead.discardHunk({ repoPath, path: selection.path, side: selection.side, patch, operationId }) + ) + : selection.side === "unstaged" ? await runRepoOperation("Staging hunk", selection, (operationId) => window.githead.stageHunk({ repoPath, @@ -10489,7 +10493,7 @@ function StatusView({ onUnstageFiles: (paths: string[], selection?: FileSelection) => void; onRefreshDiff: () => void; onDownloadImage: () => void; - onApplyHunk: (patch: string) => void; + onApplyHunk: (patch: string, discard?: boolean) => void; onContextAction: (file: GitStatusFile, side: GitDiffSide, kind: ContextActionKind, paths?: string[]) => void; onUpdateSubmodules: (path?: string) => void; onSyncSubmodules: () => void; @@ -10523,7 +10527,8 @@ function StatusView({ ? { side: selectedSide, disabled: disabled || diffChanged, - onApply: onApplyHunk + onApply: onApplyHunk, + onDiscard: selectedSide === "unstaged" ? (patch) => onApplyHunk(patch, true) : undefined } : undefined ), [canApplyHunks, diffChanged, disabled, onApplyHunk, selectedSide]); @@ -11168,6 +11173,7 @@ function DiffPanel({ } interface DiffHunkAction { + onDiscard?: ((patch: string) => void) | undefined; side: GitDiffSide; disabled: boolean; onApply: (patch: string) => void; @@ -11184,6 +11190,8 @@ const DiffRows = memo(function DiffRows({ truncated: boolean; hunkAction?: DiffHunkAction | undefined; }): ReactNode { + const [discardTarget, setDiscardTarget] = useState<{ patch: string; filePath: string; text: string } | null>(null); + const discardTargetCurrent = discardTarget?.filePath === filePath && discardTarget.text === text; const sessionRef = useRef | null>(null); const { value: processedValue, @@ -11233,6 +11241,21 @@ const DiffRows = memo(function DiffRows({ return (
{!processed ? : null} + { if (!open) setDiscardTarget(null); }}> + + + Revert this hunk? + The changes in this hunk will be reverted. Other hunks and staged changes will be kept. + + + + + + + {groups.map((group, groupIndex) => { const groupKey = `${groupIndex}:${group.kind}:${group.rows[0]?.text ?? ""}`; const rowViews = group.rows.flatMap((row, rowIndex) => { @@ -11259,6 +11282,18 @@ const DiffRows = memo(function DiffRows({