Skip to content

Commit c30b8be

Browse files
authored
feat: Implement the ability to discard changes (#199)
1 parent c77b91d commit c30b8be

File tree

9 files changed

+237
-16
lines changed

9 files changed

+237
-16
lines changed

apps/array/src/main/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
278278
}> => ipcRenderer.invoke("get-diff-stats", repoPath),
279279
getCurrentBranch: (repoPath: string): Promise<string | undefined> =>
280280
ipcRenderer.invoke("get-current-branch", repoPath),
281+
discardFileChanges: (
282+
repoPath: string,
283+
filePath: string,
284+
fileStatus: string,
285+
): Promise<void> =>
286+
ipcRenderer.invoke("discard-file-changes", repoPath, filePath, fileStatus),
281287
listDirectory: (
282288
dirPath: string,
283289
): Promise<

apps/array/src/main/services/git.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { type ChildProcess, exec } from "node:child_process";
1+
import { type ChildProcess, exec, execFile } from "node:child_process";
22
import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
@@ -10,6 +10,7 @@ import { logger } from "../lib/logger";
1010
const log = logger.scope("git");
1111

1212
const execAsync = promisify(exec);
13+
const execFileAsync = promisify(execFile);
1314
const fsPromises = fs.promises;
1415

1516
const getAllFilesInDirectory = async (
@@ -289,6 +290,39 @@ export interface DiffStats {
289290
linesRemoved: number;
290291
}
291292

293+
const discardFileChanges = async (
294+
directoryPath: string,
295+
filePath: string,
296+
fileStatus: GitFileStatus,
297+
): Promise<void> => {
298+
switch (fileStatus) {
299+
case "modified":
300+
case "deleted":
301+
await execFileAsync("git", ["checkout", "HEAD", "--", filePath], {
302+
cwd: directoryPath,
303+
});
304+
break;
305+
case "added":
306+
await execFileAsync("git", ["rm", "-f", filePath], {
307+
cwd: directoryPath,
308+
});
309+
break;
310+
case "untracked":
311+
await execFileAsync("git", ["clean", "-f", "--", filePath], {
312+
cwd: directoryPath,
313+
});
314+
break;
315+
case "renamed":
316+
// TODO: Restore the original file?
317+
await execFileAsync("git", ["checkout", "HEAD", "--", filePath], {
318+
cwd: directoryPath,
319+
});
320+
break;
321+
default:
322+
throw new Error(`Unknown file status: ${fileStatus}`);
323+
}
324+
};
325+
292326
const getDiffStats = async (directoryPath: string): Promise<DiffStats> => {
293327
try {
294328
// git diff --numstat HEAD shows: added\tremoved\tfilename
@@ -687,4 +721,16 @@ export function registerGitIpc(
687721
return getCurrentBranch(directoryPath);
688722
},
689723
);
724+
725+
ipcMain.handle(
726+
"discard-file-changes",
727+
async (
728+
_event: IpcMainInvokeEvent,
729+
directoryPath: string,
730+
filePath: string,
731+
fileStatus: GitFileStatus,
732+
): Promise<void> => {
733+
return discardFileChanges(directoryPath, filePath, fileStatus);
734+
},
735+
);
690736
}

apps/array/src/renderer/features/panels/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
export { useDragDropHandlers } from "./hooks/useDragDropHandlers";
88
export { usePanelLayoutStore } from "./store/panelLayoutStore";
99
export { usePanelStore } from "./store/panelStore";
10+
export { isDiffTabActiveInTree } from "./store/panelStoreHelpers";
1011

1112
export type {
1213
GroupId,

apps/array/src/renderer/features/panels/store/panelLayoutStore.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
createDiffTabId,
1313
createFileTabId,
1414
generatePanelId,
15+
getDiffTabIdsForFile,
1516
getLeafPanel,
1617
getSplitConfig,
1718
selectNextTabAfterClose,
@@ -55,6 +56,7 @@ export interface PanelLayoutStore {
5556
closeOtherTabs: (taskId: string, panelId: string, tabId: string) => void;
5657
closeTabsToRight: (taskId: string, panelId: string, tabId: string) => void;
5758
closeTabsForFile: (taskId: string, filePath: string) => void;
59+
closeDiffTabsForFile: (taskId: string, filePath: string) => void;
5860
setActiveTab: (taskId: string, panelId: string, tabId: string) => void;
5961
setDraggingTab: (
6062
taskId: string,
@@ -428,12 +430,7 @@ export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()(
428430

429431
const tabIds = [
430432
createFileTabId(filePath),
431-
createDiffTabId(filePath),
432-
createDiffTabId(filePath, "modified"),
433-
createDiffTabId(filePath, "deleted"),
434-
createDiffTabId(filePath, "added"),
435-
createDiffTabId(filePath, "untracked"),
436-
createDiffTabId(filePath, "renamed"),
433+
...getDiffTabIdsForFile(filePath),
437434
];
438435

439436
for (const tabId of tabIds) {
@@ -444,6 +441,20 @@ export const usePanelLayoutStore = createWithEqualityFn<PanelLayoutStore>()(
444441
}
445442
},
446443

444+
closeDiffTabsForFile: (taskId, filePath) => {
445+
const layout = get().taskLayouts[taskId];
446+
if (!layout) return;
447+
448+
const tabIds = getDiffTabIdsForFile(filePath);
449+
450+
for (const tabId of tabIds) {
451+
const tabLocation = findTabInTree(layout.panelTree, tabId);
452+
if (tabLocation) {
453+
get().closeTab(taskId, tabLocation.panelId, tabId);
454+
}
455+
}
456+
},
457+
447458
setActiveTab: (taskId, panelId, tabId) => {
448459
set((state) =>
449460
updateTaskLayout(state, taskId, (layout) => {

apps/array/src/renderer/features/panels/store/panelStoreHelpers.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ export function createDiffTabId(filePath: string, status?: string): string {
2929
return `diff-${filePath}`;
3030
}
3131

32+
export function getDiffTabIdsForFile(filePath: string): string[] {
33+
return [
34+
createDiffTabId(filePath),
35+
createDiffTabId(filePath, "modified"),
36+
createDiffTabId(filePath, "deleted"),
37+
createDiffTabId(filePath, "added"),
38+
createDiffTabId(filePath, "untracked"),
39+
createDiffTabId(filePath, "renamed"),
40+
];
41+
}
42+
3243
export function parseTabId(tabId: string): ParsedTabId & { status?: string } {
3344
if (tabId.startsWith("file-")) {
3445
return { type: "file", value: tabId.slice(5) };
@@ -289,3 +300,20 @@ export function applyCleanupWithFallback(
289300
): PanelNode {
290301
return cleanedTree || originalTree;
291302
}
303+
304+
// Tab active state utilities
305+
function isTabActiveInTree(tree: PanelNode, tabId: string): boolean {
306+
if (tree.type === "leaf") {
307+
return tree.content.activeTabId === tabId;
308+
}
309+
return tree.children.some((child) => isTabActiveInTree(child, tabId));
310+
}
311+
312+
export function isDiffTabActiveInTree(
313+
tree: PanelNode,
314+
filePath: string,
315+
status?: string,
316+
): boolean {
317+
const tabId = createDiffTabId(filePath, status);
318+
return isTabActiveInTree(tree, tabId);
319+
}

apps/array/src/renderer/features/task-detail/components/ChangesPanel.tsx

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { PanelMessage } from "@components/ui/PanelMessage";
2-
import { usePanelLayoutStore } from "@features/panels";
2+
import { isDiffTabActiveInTree, usePanelLayoutStore } from "@features/panels";
33
import { useTaskData } from "@features/task-detail/hooks/useTaskData";
4-
import { FileIcon } from "@phosphor-icons/react";
5-
import { Badge, Box, Flex, Text } from "@radix-ui/themes";
4+
import { ArrowCounterClockwiseIcon, FileIcon } from "@phosphor-icons/react";
5+
import { Badge, Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
66
import type { ChangedFile, GitFileStatus, Task } from "@shared/types";
7-
import { useQuery } from "@tanstack/react-query";
7+
import { useQuery, useQueryClient } from "@tanstack/react-query";
8+
import { showMessageBox } from "@utils/dialog";
89
import { handleExternalAppAction } from "@utils/handleExternalAppAction";
910
import {
1011
selectWorktreePath,
@@ -20,6 +21,7 @@ interface ChangedFileItemProps {
2021
file: ChangedFile;
2122
taskId: string;
2223
repoPath: string;
24+
isActive: boolean;
2325
}
2426

2527
function getStatusIndicator(status: GitFileStatus): {
@@ -41,8 +43,55 @@ function getStatusIndicator(status: GitFileStatus): {
4143
}
4244
}
4345

44-
function ChangedFileItem({ file, taskId, repoPath }: ChangedFileItemProps) {
46+
function getDiscardInfo(
47+
file: ChangedFile,
48+
fileName: string,
49+
): { message: string; action: string } {
50+
switch (file.status) {
51+
case "modified":
52+
return {
53+
message: `Are you sure you want to discard changes in '${fileName}'?`,
54+
action: "Discard File",
55+
};
56+
case "deleted":
57+
return {
58+
message: `Are you sure you want to restore '${fileName}'?`,
59+
action: "Restore File",
60+
};
61+
case "added":
62+
return {
63+
message: `Are you sure you want to remove '${fileName}'?`,
64+
action: "Remove File",
65+
};
66+
case "untracked":
67+
return {
68+
message: `Are you sure you want to delete '${fileName}'?`,
69+
action: "Delete File",
70+
};
71+
case "renamed":
72+
return {
73+
message: `Are you sure you want to undo the rename of '${fileName}'?`,
74+
action: "Undo Rename File",
75+
};
76+
default:
77+
return {
78+
message: `Are you sure you want to discard changes in '${fileName}'?`,
79+
action: "Discard File",
80+
};
81+
}
82+
}
83+
84+
function ChangedFileItem({
85+
file,
86+
taskId,
87+
repoPath,
88+
isActive,
89+
}: ChangedFileItemProps) {
4590
const openDiff = usePanelLayoutStore((state) => state.openDiff);
91+
const closeDiffTabsForFile = usePanelLayoutStore(
92+
(state) => state.closeDiffTabsForFile,
93+
);
94+
const queryClient = useQueryClient();
4695
const fileName = file.path.split("/").pop() || file.path;
4796
const indicator = getStatusIndicator(file.status);
4897

@@ -60,14 +109,45 @@ function ChangedFileItem({ file, taskId, repoPath }: ChangedFileItemProps) {
60109
await handleExternalAppAction(result.action, fullPath, fileName);
61110
};
62111

112+
const handleDiscard = async (e: React.MouseEvent) => {
113+
e.preventDefault();
114+
115+
const { message, action } = getDiscardInfo(file, fileName);
116+
117+
const result = await showMessageBox({
118+
type: "warning",
119+
title: "Discard changes",
120+
message,
121+
buttons: ["Cancel", action],
122+
defaultId: 0,
123+
cancelId: 0,
124+
});
125+
126+
if (result.response !== 1) return;
127+
128+
await window.electronAPI.discardFileChanges(
129+
repoPath,
130+
file.path,
131+
file.status,
132+
);
133+
134+
closeDiffTabsForFile(taskId, file.path);
135+
136+
queryClient.invalidateQueries({
137+
queryKey: ["changed-files-head", repoPath],
138+
});
139+
};
140+
63141
return (
64142
<Flex
65143
align="center"
66144
gap="2"
67145
py="1"
146+
pl="1"
147+
pr="2"
68148
onClick={handleClick}
69149
onContextMenu={handleContextMenu}
70-
className="hover:bg-gray-2"
150+
className={`group ${isActive ? "bg-gray-3" : "hover:bg-gray-2"}`}
71151
style={{ cursor: "pointer", whiteSpace: "nowrap", overflow: "hidden" }}
72152
>
73153
<Badge size="1" color={indicator.color} style={{ flexShrink: 0 }}>
@@ -84,10 +164,23 @@ function ChangedFileItem({ file, taskId, repoPath }: ChangedFileItemProps) {
84164
userSelect: "none",
85165
overflow: "hidden",
86166
textOverflow: "ellipsis",
167+
flex: 1,
87168
}}
88169
>
89170
{file.originalPath ? `${file.originalPath}${file.path}` : file.path}
90171
</Text>
172+
<Tooltip content="Discard changes">
173+
<IconButton
174+
size="1"
175+
variant="ghost"
176+
color="gray"
177+
onClick={handleDiscard}
178+
className={isActive ? "" : "opacity-0 group-hover:opacity-100"}
179+
style={{ flexShrink: 0 }}
180+
>
181+
<ArrowCounterClockwiseIcon size={12} />
182+
</IconButton>
183+
</Tooltip>
91184
</Flex>
92185
);
93186
}
@@ -96,6 +189,7 @@ export function ChangesPanel({ taskId, task }: ChangesPanelProps) {
96189
const taskData = useTaskData({ taskId, initialTask: task });
97190
const worktreePath = useWorkspaceStore(selectWorktreePath(taskId));
98191
const repoPath = worktreePath ?? taskData.repoPath;
192+
const layout = usePanelLayoutStore((state) => state.getLayout(taskId));
99193

100194
const { data: changedFiles = [], isLoading } = useQuery({
101195
queryKey: ["changed-files-head", repoPath],
@@ -104,6 +198,11 @@ export function ChangesPanel({ taskId, task }: ChangesPanelProps) {
104198
refetchOnMount: "always",
105199
});
106200

201+
const isFileActive = (file: ChangedFile): boolean => {
202+
if (!layout) return false;
203+
return isDiffTabActiveInTree(layout.panelTree, file.path, file.status);
204+
};
205+
107206
if (!repoPath) {
108207
return <PanelMessage>No repository path available</PanelMessage>;
109208
}
@@ -125,6 +224,7 @@ export function ChangesPanel({ taskId, task }: ChangesPanelProps) {
125224
file={file}
126225
taskId={taskId}
127226
repoPath={repoPath}
227+
isActive={isFileActive(file)}
128228
/>
129229
))}
130230
</Flex>

apps/array/src/renderer/stores/repositoryWorkspaceStore.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useAuthStore } from "@features/auth/stores/authStore";
22
import { logger } from "@renderer/lib/logger";
33
import { randomSuffix } from "@shared/utils/id";
44
import { cloneStore } from "@stores/cloneStore";
5+
import { showMessageBox } from "@utils/dialog";
56
import { expandTildePath } from "@utils/path";
67
import { create } from "zustand";
78
import { persist } from "zustand/middleware";
@@ -54,7 +55,7 @@ const validateRepository = async (
5455
};
5556

5657
const showError = async (title: string, message: string, detail?: string) => {
57-
await window.electronAPI.showMessageBox({
58+
await showMessageBox({
5859
type: "error",
5960
title,
6061
message,
@@ -137,7 +138,7 @@ export const repositoryWorkspaceStore = create<RepositoryWorkspaceState>()(
137138
detected: string,
138139
): Promise<boolean> => {
139140
const [, repoName] = repository.split("/");
140-
const result = await window.electronAPI.showMessageBox({
141+
const result = await showMessageBox({
141142
type: "error",
142143
title: "Repository mismatch",
143144
message: `Folder '${repoName}' exists but contains a different repository`,
@@ -198,7 +199,7 @@ export const repositoryWorkspaceStore = create<RepositoryWorkspaceState>()(
198199

199200
// Skip check if cloneId provided (clone state already created by caller)
200201
if (!existingCloneId && isCloning(repository)) {
201-
await window.electronAPI.showMessageBox({
202+
await showMessageBox({
202203
type: "warning",
203204
title: "Repository cloning",
204205
message: `${repository} is currently being cloned`,

0 commit comments

Comments
 (0)