Skip to content

Commit f4dab75

Browse files
feat: Add inline code review comments (#231)
1 parent 38af55b commit f4dab75

File tree

15 files changed

+1536
-6
lines changed

15 files changed

+1536
-6
lines changed

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,22 @@ interface CodeMirrorDiffEditorProps {
99
originalContent: string;
1010
modifiedContent: string;
1111
filePath?: string;
12+
fileId?: string; // Unique identifier for comments (e.g., relative path)
1213
onContentChange?: (content: string) => void;
1314
}
1415

1516
export function CodeMirrorDiffEditor({
1617
originalContent,
1718
modifiedContent,
1819
filePath,
20+
fileId,
1921
onContentChange,
2022
}: CodeMirrorDiffEditorProps) {
2123
const [viewMode, setViewMode] = useState<ViewMode>("split");
22-
const extensions = useEditorExtensions(filePath, true);
24+
const extensions = useEditorExtensions(filePath, true, {
25+
enableComments: true,
26+
fileId: fileId || filePath, // Fall back to filePath if no fileId provided
27+
});
2328
const options = useMemo(
2429
() => ({
2530
original: originalContent,

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,22 @@ import { useEditorExtensions } from "../hooks/useEditorExtensions";
55
interface CodeMirrorEditorProps {
66
content: string;
77
filePath?: string;
8+
fileId?: string; // Unique identifier for comments (e.g., relative path)
89
readOnly?: boolean;
10+
enableComments?: boolean;
911
}
1012

1113
export function CodeMirrorEditor({
1214
content,
1315
filePath,
16+
fileId,
1417
readOnly = false,
18+
enableComments = false,
1519
}: CodeMirrorEditorProps) {
16-
const extensions = useEditorExtensions(filePath, readOnly);
20+
const extensions = useEditorExtensions(filePath, readOnly, {
21+
enableComments,
22+
fileId: fileId || filePath,
23+
});
1724
const options = useMemo(
1825
() => ({ doc: content, extensions, filePath }),
1926
[content, extensions, filePath],

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,16 @@ export function DiffEditorPanel({
9696
originalContent={originalContent ?? ""}
9797
modifiedContent={modifiedContent ?? ""}
9898
filePath={absolutePath}
99+
fileId={filePath} // Use relative path as fileId for comments
99100
onContentChange={handleContentChange}
100101
/>
101102
) : (
102103
<CodeMirrorEditor
103104
content={content ?? ""}
104105
filePath={absolutePath}
106+
fileId={filePath}
105107
readOnly
108+
enableComments
106109
/>
107110
)}
108111
</Box>

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

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,61 @@ import {
44
highlightActiveLineGutter,
55
lineNumbers,
66
} from "@codemirror/view";
7+
import { commentComposerExtension } from "@features/comments/extensions/commentComposerExtension";
8+
import { commentGutterExtension } from "@features/comments/extensions/commentGutterExtension";
9+
import { commentWidgetExtension } from "@features/comments/extensions/commentWidgetExtension";
10+
import { useCommentStore } from "@features/comments/store/commentStore";
711
import { useThemeStore } from "@stores/themeStore";
8-
import { useMemo } from "react";
12+
import { useCallback, useMemo } from "react";
913
import { mergeViewTheme, oneDark, oneLight } from "../theme/editorTheme";
1014
import { getLanguageExtension } from "../utils/languages";
1115

12-
export function useEditorExtensions(filePath?: string, readOnly = false) {
16+
export function useEditorExtensions(
17+
filePath?: string,
18+
readOnly = false,
19+
options?: { enableComments?: boolean; fileId?: string },
20+
) {
1321
const isDarkMode = useThemeStore((state) => state.isDarkMode);
22+
const showComments = useCommentStore((state) => state.showComments);
23+
const getCommentsForFile = useCommentStore(
24+
(state) => state.getCommentsForFile,
25+
);
26+
const openComposer = useCommentStore((state) => state.openComposer);
27+
const closeComposer = useCommentStore((state) => state.closeComposer);
28+
const createComment = useCommentStore((state) => state.createComment);
29+
const composerState = useCommentStore((state) => state.composerState);
30+
31+
const { enableComments = false, fileId } = options || {};
32+
33+
// Handler for when user clicks "+" to add a comment
34+
const handleOpenComposer = useCallback(
35+
(clickedFileId: string, line: number) => {
36+
openComposer(clickedFileId, line);
37+
},
38+
[openComposer],
39+
);
40+
41+
// Handler for submitting a new comment
42+
const handleSubmitComment = useCallback(
43+
async (line: number, content: string) => {
44+
if (!fileId) return;
45+
46+
await createComment({
47+
fileId,
48+
line,
49+
side: "right",
50+
content,
51+
});
52+
closeComposer();
53+
},
54+
[fileId, createComment, closeComposer],
55+
);
1456

1557
return useMemo(() => {
1658
const languageExtension = filePath ? getLanguageExtension(filePath) : null;
1759
const theme = isDarkMode ? oneDark : oneLight;
1860

19-
return [
61+
const extensions = [
2062
lineNumbers(),
2163
highlightActiveLineGutter(),
2264
theme,
@@ -25,5 +67,38 @@ export function useEditorExtensions(filePath?: string, readOnly = false) {
2567
...(readOnly ? [EditorState.readOnly.of(true)] : []),
2668
...(languageExtension ? [languageExtension] : []),
2769
];
28-
}, [filePath, isDarkMode, readOnly]);
70+
71+
// Add comment extensions if enabled and we have a fileId
72+
if (enableComments && fileId) {
73+
// Gutter with "+" button for adding comments
74+
extensions.push(commentGutterExtension(fileId, handleOpenComposer));
75+
// Inline comment composer
76+
extensions.push(
77+
commentComposerExtension(
78+
fileId,
79+
() => composerState,
80+
handleSubmitComment,
81+
closeComposer,
82+
),
83+
);
84+
// Inline comment display
85+
extensions.push(
86+
commentWidgetExtension(() => getCommentsForFile(fileId), showComments),
87+
);
88+
}
89+
90+
return extensions;
91+
}, [
92+
filePath,
93+
isDarkMode,
94+
readOnly,
95+
enableComments,
96+
fileId,
97+
showComments,
98+
getCommentsForFile,
99+
handleOpenComposer,
100+
handleSubmitComment,
101+
closeComposer,
102+
composerState,
103+
]);
29104
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* ============================================
3+
* COMMENT API ADAPTER
4+
* ============================================
5+
*
6+
* TODO FOR API INTEGRATION:
7+
*
8+
* 1. Create a new file `githubCommentApi.ts` that implements `CommentApi`
9+
* 2. Replace `mockCommentApi` with your implementation below
10+
*
11+
* Example:
12+
* import { githubCommentApi } from "./githubCommentApi";
13+
* export const commentApi: CommentApi = githubCommentApi;
14+
*
15+
* The store calls these functions automatically - you just need to
16+
* implement them to hit your GitHub API endpoints.
17+
* ============================================
18+
*/
19+
20+
import type { Comment } from "@shared/types";
21+
import { getCurrentUser } from "../utils/currentUser";
22+
23+
// ============================================
24+
// INPUT TYPES
25+
// ============================================
26+
27+
export interface CreateCommentInput {
28+
fileId: string;
29+
line: number;
30+
side: "left" | "right";
31+
content: string;
32+
}
33+
34+
export interface CreateReplyInput extends CreateCommentInput {
35+
parentId: string;
36+
}
37+
38+
// ============================================
39+
// API INTERFACE
40+
// ============================================
41+
42+
export interface CommentApi {
43+
/**
44+
* Fetch all comments for a file
45+
* GET /api/comments?fileId=...
46+
*/
47+
fetchComments(fileId: string): Promise<Comment[]>;
48+
49+
/**
50+
* Create a new comment on a line
51+
* POST /api/comments
52+
* Body: { fileId, line, side, content }
53+
* Returns: Created comment with server-generated ID
54+
*/
55+
createComment(input: CreateCommentInput): Promise<Comment>;
56+
57+
/**
58+
* Create a reply to an existing comment
59+
* POST /api/comments/:parentId/replies
60+
* Body: { content }
61+
* Returns: Created reply with server-generated ID
62+
*/
63+
createReply(input: CreateReplyInput): Promise<Comment>;
64+
65+
/**
66+
* Update a comment's content
67+
* PATCH /api/comments/:commentId
68+
* Body: { content }
69+
* Returns: Updated comment
70+
*/
71+
updateComment(commentId: string, content: string): Promise<Comment>;
72+
73+
/**
74+
* Delete a comment
75+
* DELETE /api/comments/:commentId
76+
*/
77+
deleteComment(commentId: string): Promise<void>;
78+
79+
/**
80+
* Mark a comment as resolved or unresolved
81+
* PATCH /api/comments/:commentId/resolve
82+
* Body: { resolved: boolean }
83+
* Returns: Updated comment
84+
*/
85+
resolveComment(commentId: string, resolved: boolean): Promise<Comment>;
86+
}
87+
88+
// ============================================
89+
// MOCK IMPLEMENTATION (local-only, no persistence)
90+
// ============================================
91+
92+
/**
93+
* Mock API that works with local state only.
94+
* Replace this with `githubCommentApi` when ready.
95+
*/
96+
export const mockCommentApi: CommentApi = {
97+
async fetchComments(_fileId) {
98+
// Mock: Return empty array (store manages local state)
99+
// Real: GET /api/comments?fileId=...
100+
return [];
101+
},
102+
103+
async createComment(input) {
104+
// Mock: Create comment object with generated ID
105+
// Real: POST /api/comments, return server response
106+
const user = getCurrentUser();
107+
return {
108+
id: crypto.randomUUID(),
109+
fileId: input.fileId,
110+
line: input.line,
111+
side: input.side,
112+
content: input.content,
113+
author: user.name,
114+
timestamp: new Date(),
115+
resolved: false,
116+
replies: [],
117+
};
118+
},
119+
120+
async createReply(input) {
121+
// Mock: Create reply object with generated ID
122+
// Real: POST /api/comments/:parentId/replies
123+
const user = getCurrentUser();
124+
return {
125+
id: crypto.randomUUID(),
126+
fileId: input.fileId,
127+
line: input.line,
128+
side: input.side,
129+
content: input.content,
130+
author: user.name,
131+
timestamp: new Date(),
132+
resolved: false,
133+
replies: [],
134+
};
135+
},
136+
137+
async updateComment(commentId, content) {
138+
// Mock: Return updated comment (store handles state update)
139+
// Real: PATCH /api/comments/:commentId
140+
return {
141+
id: commentId,
142+
content,
143+
} as Comment;
144+
},
145+
146+
async deleteComment(_commentId) {
147+
// Mock: No-op (store handles state removal)
148+
// Real: DELETE /api/comments/:commentId
149+
},
150+
151+
async resolveComment(commentId, resolved) {
152+
// Mock: Return updated comment (store handles state update)
153+
// Real: PATCH /api/comments/:commentId/resolve
154+
return {
155+
id: commentId,
156+
resolved,
157+
} as Comment;
158+
},
159+
};
160+
161+
// ============================================
162+
// ACTIVE API IMPLEMENTATION
163+
// ============================================
164+
165+
/**
166+
* Change this to switch between mock and real API.
167+
*
168+
* When ready for GitHub integration:
169+
* import { githubCommentApi } from "./githubCommentApi";
170+
* export const commentApi: CommentApi = githubCommentApi;
171+
*/
172+
export const commentApi: CommentApi = mockCommentApi;

0 commit comments

Comments
 (0)