Skip to content

Commit 693c6d1

Browse files
authored
feat: diff editor improvements (#196)
- collapsible Lines - the ability to restore your changes made (per line, not per file yet) - fixed a bug with scroll view - basic handlers for file writing
1 parent 2b98a12 commit 693c6d1

File tree

10 files changed

+324
-16
lines changed

10 files changed

+324
-16
lines changed

apps/array/src/main/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
257257
ipcRenderer.invoke("save-question-answers", repoPath, taskId, answers),
258258
readRepoFile: (repoPath: string, filePath: string): Promise<string | null> =>
259259
ipcRenderer.invoke("read-repo-file", repoPath, filePath),
260+
writeRepoFile: (
261+
repoPath: string,
262+
filePath: string,
263+
content: string,
264+
): Promise<void> =>
265+
ipcRenderer.invoke("write-repo-file", repoPath, filePath, content),
260266
getChangedFilesHead: (
261267
repoPath: string,
262268
): Promise<Array<{ path: string; status: string; originalPath?: string }>> =>

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,4 +434,31 @@ export function registerFsIpc(): void {
434434
}
435435
},
436436
);
437+
438+
ipcMain.handle(
439+
"write-repo-file",
440+
async (
441+
_event: IpcMainInvokeEvent,
442+
repoPath: string,
443+
filePath: string,
444+
content: string,
445+
): Promise<void> => {
446+
try {
447+
const fullPath = path.join(repoPath, filePath);
448+
const resolvedPath = path.resolve(fullPath);
449+
const resolvedRepo = path.resolve(repoPath);
450+
if (!resolvedPath.startsWith(resolvedRepo)) {
451+
throw new Error("Access denied: path outside repository");
452+
}
453+
454+
await fsPromises.writeFile(fullPath, content, "utf-8");
455+
log.debug(`Wrote file ${filePath} to ${repoPath}`);
456+
457+
repoFileCache.delete(repoPath);
458+
} catch (error) {
459+
log.error(`Failed to write file ${filePath} to ${repoPath}:`, error);
460+
throw error;
461+
}
462+
},
463+
);
437464
}

apps/array/src/renderer/features/code-editor/components/CodeMirrorDiffEditor.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ interface CodeMirrorDiffEditorProps {
99
originalContent: string;
1010
modifiedContent: string;
1111
filePath?: string;
12+
onContentChange?: (content: string) => void;
1213
}
1314

1415
export function CodeMirrorDiffEditor({
1516
originalContent,
1617
modifiedContent,
1718
filePath,
19+
onContentChange,
1820
}: CodeMirrorDiffEditorProps) {
1921
const [viewMode, setViewMode] = useState<ViewMode>("split");
2022
const extensions = useEditorExtensions(filePath, true);
@@ -25,8 +27,16 @@ export function CodeMirrorDiffEditor({
2527
extensions,
2628
mode: viewMode,
2729
filePath,
30+
onContentChange,
2831
}),
29-
[originalContent, modifiedContent, extensions, viewMode, filePath],
32+
[
33+
originalContent,
34+
modifiedContent,
35+
extensions,
36+
viewMode,
37+
filePath,
38+
onContentChange,
39+
],
3040
);
3141
const containerRef = useCodeMirror(options);
3242

apps/array/src/renderer/features/code-editor/components/DiffEditorPanel.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { getRelativePath } from "@features/code-editor/utils/pathUtils";
55
import { useTaskData } from "@features/task-detail/hooks/useTaskData";
66
import { Box } from "@radix-ui/themes";
77
import type { Task } from "@shared/types";
8-
import { useQuery } from "@tanstack/react-query";
8+
import { useQuery, useQueryClient } from "@tanstack/react-query";
9+
import { useCallback } from "react";
910
import {
1011
selectWorktreePath,
1112
useWorkspaceStore,
@@ -26,6 +27,7 @@ export function DiffEditorPanel({
2627
const worktreePath = useWorkspaceStore(selectWorktreePath(taskId));
2728
const repoPath = worktreePath ?? taskData.repoPath;
2829
const filePath = getRelativePath(absolutePath, repoPath);
30+
const queryClient = useQueryClient();
2931

3032
const { data: changedFiles = [] } = useQuery({
3133
queryKey: ["changed-files-head", repoPath],
@@ -56,6 +58,24 @@ export function DiffEditorPanel({
5658
staleTime: Infinity,
5759
});
5860

61+
const handleContentChange = useCallback(
62+
async (newContent: string) => {
63+
if (!repoPath) return;
64+
65+
try {
66+
await window.electronAPI.writeRepoFile(repoPath, filePath, newContent);
67+
68+
queryClient.invalidateQueries({
69+
queryKey: ["repo-file", repoPath, filePath],
70+
});
71+
queryClient.invalidateQueries({
72+
queryKey: ["changed-files-head", repoPath],
73+
});
74+
} catch (_error) {}
75+
},
76+
[repoPath, filePath, queryClient],
77+
);
78+
5979
if (!repoPath) {
6080
return <PanelMessage>No repository path available</PanelMessage>;
6181
}
@@ -76,6 +96,7 @@ export function DiffEditorPanel({
7696
originalContent={originalContent ?? ""}
7797
modifiedContent={modifiedContent ?? ""}
7898
filePath={absolutePath}
99+
onContentChange={handleContentChange}
79100
/>
80101
) : (
81102
<CodeMirrorEditor

apps/array/src/renderer/features/code-editor/hooks/useCodeMirror.ts

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { MergeView, unifiedMergeView } from "@codemirror/merge";
1+
import {
2+
acceptChunk,
3+
MergeView,
4+
rejectChunk,
5+
unifiedMergeView,
6+
} from "@codemirror/merge";
27
import { EditorState, type Extension } from "@codemirror/state";
38
import { EditorView } from "@codemirror/view";
49
import { handleExternalAppAction } from "@utils/handleExternalAppAction";
@@ -19,8 +24,44 @@ interface DiffOptions extends UseCodeMirrorOptions {
1924
original: string;
2025
modified: string;
2126
mode: "split" | "unified";
27+
onContentChange?: (content: string) => void;
2228
}
2329

30+
const createMergeControls = (onReject?: () => void) => {
31+
return (type: "accept" | "reject", action: (e: MouseEvent) => void) => {
32+
if (type === "accept") {
33+
return document.createElement("span");
34+
}
35+
36+
const button = document.createElement("button");
37+
button.textContent = "Reject";
38+
button.name = "reject";
39+
button.style.background = "var(--red-9)";
40+
button.style.color = "white";
41+
button.style.border = "none";
42+
button.style.padding = "2px 6px";
43+
button.style.borderRadius = "3px";
44+
button.style.cursor = "pointer";
45+
button.style.fontSize = "11px";
46+
47+
button.onmousedown = (e) => {
48+
action(e);
49+
onReject?.();
50+
};
51+
52+
return button;
53+
};
54+
};
55+
56+
const getBaseDiffConfig = (
57+
onReject?: () => void,
58+
): Partial<Parameters<typeof unifiedMergeView>[0]> => ({
59+
collapseUnchanged: { margin: 3, minSize: 4 },
60+
highlightChanges: false,
61+
gutter: true,
62+
mergeControls: createMergeControls(onReject),
63+
});
64+
2465
export function useCodeMirror(options: SingleDocOptions | DiffOptions) {
2566
const containerRef = useRef<HTMLDivElement>(null);
2667
const instanceRef = useRef<EditorInstance | null>(null);
@@ -40,21 +81,63 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) {
4081
parent: containerRef.current,
4182
});
4283
} else if (options.mode === "split") {
84+
const diffConfig = getBaseDiffConfig(
85+
options.onContentChange
86+
? () => {
87+
if (instanceRef.current instanceof MergeView) {
88+
const content = instanceRef.current.b.state.doc.toString();
89+
options.onContentChange?.(content);
90+
}
91+
}
92+
: undefined,
93+
);
94+
95+
const updateListener = options.onContentChange
96+
? EditorView.updateListener.of((update) => {
97+
if (
98+
update.docChanged &&
99+
update.transactions.some((tr) => tr.isUserEvent("revert"))
100+
) {
101+
const content = update.state.doc.toString();
102+
options.onContentChange?.(content);
103+
}
104+
})
105+
: [];
106+
43107
instanceRef.current = new MergeView({
44108
a: { doc: options.original, extensions: options.extensions },
45-
b: { doc: options.modified, extensions: options.extensions },
109+
b: {
110+
doc: options.modified,
111+
extensions: [
112+
...options.extensions,
113+
...(Array.isArray(updateListener)
114+
? updateListener
115+
: [updateListener]),
116+
],
117+
},
118+
...diffConfig,
46119
parent: containerRef.current,
120+
revertControls: "a-to-b",
47121
});
48122
} else {
123+
const diffConfig = getBaseDiffConfig(
124+
options.onContentChange
125+
? () => {
126+
if (instanceRef.current instanceof EditorView) {
127+
const content = instanceRef.current.state.doc.toString();
128+
options.onContentChange?.(content);
129+
}
130+
}
131+
: undefined,
132+
);
133+
49134
instanceRef.current = new EditorView({
50135
doc: options.modified,
51136
extensions: [
52137
...options.extensions,
53138
unifiedMergeView({
54139
original: options.original,
55-
highlightChanges: true,
56-
gutter: true,
57-
mergeControls: false,
140+
...diffConfig,
58141
}),
59142
],
60143
parent: containerRef.current,
@@ -95,3 +178,5 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) {
95178

96179
return containerRef;
97180
}
181+
182+
export { acceptChunk, rejectChunk };

apps/array/src/renderer/features/code-editor/hooks/useEditorExtensions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from "@codemirror/view";
77
import { useThemeStore } from "@stores/themeStore";
88
import { useMemo } from "react";
9-
import { oneDark, oneLight } from "../theme/editorTheme";
9+
import { mergeViewTheme, oneDark, oneLight } from "../theme/editorTheme";
1010
import { getLanguageExtension } from "../utils/languages";
1111

1212
export function useEditorExtensions(filePath?: string, readOnly = false) {
@@ -20,6 +20,7 @@ export function useEditorExtensions(filePath?: string, readOnly = false) {
2020
lineNumbers(),
2121
highlightActiveLineGutter(),
2222
theme,
23+
mergeViewTheme,
2324
EditorView.editable.of(!readOnly),
2425
...(readOnly ? [EditorState.readOnly.of(true)] : []),
2526
...(languageExtension ? [languageExtension] : []),

apps/array/src/renderer/features/code-editor/theme/editorTheme.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,116 @@ export const oneLight: Extension = [
197197
createEditorTheme(light, false),
198198
syntaxHighlighting(createHighlightStyle(light)),
199199
];
200+
201+
export const mergeViewTheme = EditorView.baseTheme({
202+
".cm-mergeView": {
203+
overflowY: "auto",
204+
},
205+
".cm-mergeViewEditors": {
206+
display: "flex",
207+
alignItems: "stretch",
208+
},
209+
".cm-mergeViewEditor": {
210+
flexGrow: "1",
211+
flexBasis: "0",
212+
overflow: "hidden",
213+
},
214+
".cm-merge-revert": {
215+
width: "1.6em",
216+
flexGrow: "0",
217+
flexShrink: "0",
218+
position: "relative",
219+
},
220+
".cm-merge-revert button": {
221+
position: "absolute",
222+
display: "block",
223+
width: "100%",
224+
boxSizing: "border-box",
225+
textAlign: "center",
226+
background: "none",
227+
border: "none",
228+
font: "inherit",
229+
cursor: "pointer",
230+
},
231+
".cm-mergeView & .cm-scroller, .cm-mergeView &": {
232+
height: "auto !important",
233+
overflowY: "visible !important",
234+
},
235+
"&.cm-merge-a .cm-changedLine, .cm-deletedChunk": {
236+
backgroundColor: "rgba(160, 128, 100, .08)",
237+
},
238+
"&.cm-merge-b .cm-changedLine, .cm-inlineChangedLine": {
239+
backgroundColor: "rgba(100, 160, 128, .08)",
240+
},
241+
"&light.cm-merge-a .cm-changedText, &light .cm-deletedChunk .cm-deletedText":
242+
{
243+
background:
244+
"linear-gradient(#ee443366, #ee443366) bottom/100% 2px no-repeat",
245+
},
246+
"&dark.cm-merge-a .cm-changedText, &dark .cm-deletedChunk .cm-deletedText": {
247+
background:
248+
"linear-gradient(#ffaa9966, #ffaa9966) bottom/100% 2px no-repeat",
249+
},
250+
"&light.cm-merge-b .cm-changedText": {
251+
background:
252+
"linear-gradient(#22bb22aa, #22bb22aa) bottom/100% 2px no-repeat",
253+
},
254+
"&dark.cm-merge-b .cm-changedText": {
255+
background:
256+
"linear-gradient(#88ff88aa, #88ff88aa) bottom/100% 2px no-repeat",
257+
},
258+
"&.cm-merge-b .cm-deletedText": {
259+
background: "#ff000033",
260+
},
261+
".cm-insertedLine, .cm-deletedLine, .cm-deletedLine del": {
262+
textDecoration: "none",
263+
},
264+
".cm-deletedChunk": {
265+
paddingLeft: "6px",
266+
"& .cm-chunkButtons": {
267+
position: "absolute",
268+
insetInlineEnd: "5px",
269+
},
270+
"& button": {
271+
border: "none",
272+
cursor: "pointer",
273+
color: "white",
274+
margin: "0 2px",
275+
borderRadius: "3px",
276+
"&[name=accept]": { background: "#2a2" },
277+
"&[name=reject]": { background: "#d43" },
278+
},
279+
},
280+
".cm-collapsedLines": {
281+
padding: "5px 5px 5px 10px",
282+
cursor: "pointer",
283+
"&:before": {
284+
content: '"⦚"',
285+
marginInlineEnd: "7px",
286+
},
287+
"&:after": {
288+
content: '"⦚"',
289+
marginInlineStart: "7px",
290+
},
291+
},
292+
"&light .cm-collapsedLines": {
293+
color: "#444",
294+
background:
295+
"linear-gradient(to bottom, transparent 0, #f3f3f3 30%, #f3f3f3 70%, transparent 100%)",
296+
},
297+
"&dark .cm-collapsedLines": {
298+
color: "#ddd",
299+
background:
300+
"linear-gradient(to bottom, transparent 0, #222 30%, #222 70%, transparent 100%)",
301+
},
302+
".cm-changeGutter": { width: "3px", paddingLeft: "1px" },
303+
"&light.cm-merge-a .cm-changedLineGutter, &light .cm-deletedLineGutter": {
304+
background: "#e43",
305+
},
306+
"&dark.cm-merge-a .cm-changedLineGutter, &dark .cm-deletedLineGutter": {
307+
background: "#fa9",
308+
},
309+
"&light.cm-merge-b .cm-changedLineGutter": { background: "#2b2" },
310+
"&dark.cm-merge-b .cm-changedLineGutter": { background: "#8f8" },
311+
".cm-inlineChangedLineGutter": { background: "#75d" },
312+
});

0 commit comments

Comments
 (0)