Skip to content

Commit 33ad56e

Browse files
authored
feat: add CodeMirror-based code viewer for repository files (#136)
Added codemirror, so that when you click on a file in the file tree, it opens the editor as a tab. I went with codemirror since it seems a bit more flexible than monaco, but we can swap this out easily if needed. Will add syntax highlighting, diffs, etc. in other PRs. ![image.png](https://app.graphite.com/user-attachments/assets/ce3e0388-a05c-4637-b672-2866bf31bb6a.png)
1 parent a0c9525 commit 33ad56e

File tree

8 files changed

+220
-1
lines changed

8 files changed

+220
-1
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,4 @@ pnpm-workspace.yaml
3535

3636
**.car
3737

38-
.envrc
38+
.envrc

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@
6868
},
6969
"dependencies": {
7070
"@ai-sdk/openai": "^2.0.52",
71+
"@codemirror/state": "^6.5.2",
72+
"@codemirror/view": "^6.38.8",
7173
"@dnd-kit/react": "^0.1.21",
7274
"@phosphor-icons/react": "^2.1.10",
7375
"@posthog/agent": "1.20.0",

pnpm-lock.yaml

Lines changed: 33 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
202202
}>,
203203
): Promise<void> =>
204204
ipcRenderer.invoke("save-question-answers", repoPath, taskId, answers),
205+
readRepoFile: (repoPath: string, filePath: string): Promise<string | null> =>
206+
ipcRenderer.invoke("read-repo-file", repoPath, filePath),
205207
onOpenSettings: (listener: () => void): (() => void) => {
206208
const wrapped = () => listener();
207209
ipcRenderer.on("open-settings", wrapped);

src/main/services/fs.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,4 +375,31 @@ export function registerFsIpc(): void {
375375
}
376376
},
377377
);
378+
379+
ipcMain.handle(
380+
"read-repo-file",
381+
async (
382+
_event: IpcMainInvokeEvent,
383+
repoPath: string,
384+
filePath: string,
385+
): Promise<string | null> => {
386+
try {
387+
const fullPath = path.join(repoPath, filePath);
388+
const resolvedPath = path.resolve(fullPath);
389+
const resolvedRepo = path.resolve(repoPath);
390+
if (!resolvedPath.startsWith(resolvedRepo)) {
391+
throw new Error("Access denied: path outside repository");
392+
}
393+
394+
const content = await fsPromises.readFile(fullPath, "utf-8");
395+
return content;
396+
} catch (error) {
397+
console.error(
398+
`Failed to read file ${filePath} from ${repoPath}:`,
399+
error,
400+
);
401+
return null;
402+
}
403+
},
404+
);
378405
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { CodeMirrorEditor } from "@features/code-editor/components/CodeMirrorEditor";
2+
import { useTaskData } from "@features/task-detail/hooks/useTaskData";
3+
import { Box, Flex, Text } from "@radix-ui/themes";
4+
import type { Task } from "@shared/types";
5+
import { useQuery } from "@tanstack/react-query";
6+
7+
interface CodeEditorPanelProps {
8+
taskId: string;
9+
task: Task;
10+
filePath: string;
11+
}
12+
13+
export function CodeEditorPanel({
14+
taskId,
15+
task,
16+
filePath,
17+
}: CodeEditorPanelProps) {
18+
const taskData = useTaskData({ taskId, initialTask: task });
19+
const repoPath = taskData.repoPath;
20+
21+
const {
22+
data: fileContent,
23+
isLoading,
24+
error,
25+
} = useQuery({
26+
queryKey: ["repo-file", repoPath, filePath],
27+
enabled: !!repoPath && !!filePath,
28+
staleTime: 30000,
29+
queryFn: async () => {
30+
if (!window.electronAPI || !repoPath || !filePath) {
31+
return null;
32+
}
33+
const content = await window.electronAPI.readRepoFile(repoPath, filePath);
34+
return content;
35+
},
36+
});
37+
38+
if (!repoPath) {
39+
return (
40+
<Box height="100%" p="4">
41+
<Flex align="center" justify="center" height="100%">
42+
<Text size="2" color="gray">
43+
No repository path available
44+
</Text>
45+
</Flex>
46+
</Box>
47+
);
48+
}
49+
50+
if (isLoading) {
51+
return (
52+
<Box height="100%" p="4">
53+
<Flex align="center" justify="center" height="100%">
54+
<Text size="2" color="gray">
55+
Loading file...
56+
</Text>
57+
</Flex>
58+
</Box>
59+
);
60+
}
61+
62+
if (error || !fileContent) {
63+
return (
64+
<Box height="100%" p="4">
65+
<Flex align="center" justify="center" height="100%">
66+
<Text size="2" color="gray">
67+
Failed to load file
68+
</Text>
69+
</Flex>
70+
</Box>
71+
);
72+
}
73+
74+
return (
75+
<Box height="100%" style={{ overflow: "hidden" }}>
76+
<CodeMirrorEditor content={fileContent} readOnly />
77+
</Box>
78+
);
79+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { Extension } from "@codemirror/state";
2+
import { EditorState } from "@codemirror/state";
3+
import {
4+
EditorView,
5+
highlightActiveLineGutter,
6+
lineNumbers,
7+
} from "@codemirror/view";
8+
import { useEffect, useRef } from "react";
9+
10+
interface CodeMirrorEditorProps {
11+
content: string;
12+
readOnly?: boolean;
13+
}
14+
15+
export function CodeMirrorEditor({
16+
content,
17+
readOnly = false,
18+
}: CodeMirrorEditorProps) {
19+
const editorRef = useRef<HTMLDivElement>(null);
20+
const viewRef = useRef<EditorView | null>(null);
21+
22+
useEffect(() => {
23+
if (!editorRef.current) {
24+
return;
25+
}
26+
27+
const extensions: Extension[] = [
28+
lineNumbers(),
29+
highlightActiveLineGutter(),
30+
EditorView.editable.of(!readOnly),
31+
EditorView.theme({
32+
"&": {
33+
height: "100%",
34+
fontSize: "14px",
35+
backgroundColor: "var(--color-background)",
36+
},
37+
".cm-scroller": {
38+
overflow: "auto",
39+
fontFamily: "var(--code-font-family)",
40+
},
41+
".cm-content": {
42+
padding: "16px 0",
43+
},
44+
".cm-line": {
45+
padding: "0 16px",
46+
},
47+
".cm-gutters": {
48+
backgroundColor: "var(--color-background)",
49+
color: "var(--gray-9)",
50+
border: "none",
51+
},
52+
".cm-activeLineGutter": {
53+
backgroundColor: "var(--color-background)",
54+
},
55+
}),
56+
];
57+
58+
const state = EditorState.create({
59+
doc: content,
60+
extensions,
61+
});
62+
63+
viewRef.current = new EditorView({
64+
state,
65+
parent: editorRef.current,
66+
});
67+
68+
return () => {
69+
viewRef.current?.destroy();
70+
viewRef.current = null;
71+
};
72+
}, [content, readOnly]);
73+
74+
return <div ref={editorRef} style={{ height: "100%", width: "100%" }} />;
75+
}

src/renderer/types/electron.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export interface IElectronAPI {
124124
customInput?: string;
125125
}>,
126126
) => Promise<void>;
127+
readRepoFile: (repoPath: string, filePath: string) => Promise<string | null>;
127128
onOpenSettings: (listener: () => void) => () => void;
128129
getAppVersion: () => Promise<string>;
129130
onUpdateReady: (listener: () => void) => () => void;

0 commit comments

Comments
 (0)