Skip to content

Commit ffffbeb

Browse files
Merge remote-tracking branch 'origin/Dependency'
2 parents 7aa0fcf + 6ccc030 commit ffffbeb

File tree

9 files changed

+699
-42
lines changed

9 files changed

+699
-42
lines changed
Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
import { useRef, useEffect, useState } from "react";
2+
import { Editor, loader } from "@monaco-editor/react";
3+
import * as monaco from "monaco-editor";
4+
import { MonacoBinding } from "y-monaco";
5+
import {
6+
useEditorCollaboration,
7+
type CollaborationUser,
8+
} from "../../../Contexts/EditorContext";
9+
import { useTheme } from "../../../Contexts/ThemeProvider";
10+
import type { FileNode } from "../ProjectManagementPanel/file.types";
11+
import CollaborativeCursor from "./CollaborativeCursor";
12+
import { VFSBridge } from "../../../lib/vfs/vfs-bridge";
13+
import { VFSMonacoIntegration } from "../../../lib/integration/vfs-monaco-integration";
14+
15+
interface MonacoEditorProps {
16+
selectedFile?: FileNode | null;
17+
initialValue?: string;
18+
onFileContentChange?: (fileId: string, content: string) => void;
19+
}
20+
21+
// Ensure the React wrapper uses the same Monaco instance we import via ESM
22+
loader.config({ monaco });
23+
24+
export default function MonacoEditor({
25+
selectedFile,
26+
initialValue = "// Select a file to start editing",
27+
onFileContentChange,
28+
}: MonacoEditorProps) {
29+
const { theme } = useTheme();
30+
31+
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
32+
const currentBindingRef = useRef<MonacoBinding | null>(null);
33+
const currentFileRef = useRef<string | null>(null);
34+
const contentUnsubscribeRef = useRef<(() => void) | null>(null);
35+
const vfsBridgeRef = useRef<VFSBridge | null>(null);
36+
const integrationRef = useRef<VFSMonacoIntegration | null>(null);
37+
const [editorReady, setEditorReady] = useState(false);
38+
const editorDisposedRef = useRef(false);
39+
const cursorListenerDisposeRef = useRef<monaco.IDisposable | null>(null);
40+
41+
const [language, setLanguage] = useState<string>("plaintext");
42+
const [fileUsers, setFileUsers] = useState<CollaborationUser[]>([]);
43+
44+
const {
45+
isConnected,
46+
files,
47+
getUsersInFile,
48+
getFileText,
49+
initializeFileContent,
50+
onFileContentChange: registerFileContentChange,
51+
getAwareness,
52+
updateCursorPosition,
53+
} = useEditorCollaboration();
54+
55+
useEffect(() => {
56+
if (!vfsBridgeRef.current) {
57+
vfsBridgeRef.current = new VFSBridge();
58+
}
59+
return () => {
60+
// Cleanup on unmount
61+
integrationRef.current?.dispose();
62+
integrationRef.current = null;
63+
setEditorReady(false);
64+
vfsBridgeRef.current = null;
65+
};
66+
}, []);
67+
68+
// Keep VFS in sync with the collaborative file tree so diagnostics stay up to date
69+
useEffect(() => {
70+
if (vfsBridgeRef.current) {
71+
vfsBridgeRef.current.syncToVFS(files);
72+
// Re-run diagnostics after tree changes
73+
integrationRef.current?.updateDiagnostics?.();
74+
}
75+
}, [files]);
76+
77+
// Removed early selectedFile effect to avoid binding on disposed editors
78+
79+
const bindEditorToFile = (file: FileNode) => {
80+
if (!editorRef.current || editorDisposedRef.current) return;
81+
82+
const editor = editorRef.current;
83+
// Ensure Monaco model is created with a stable file:// URI so markers attach correctly
84+
let vfsPath = vfsBridgeRef.current?.getPathById(file.id);
85+
if (!vfsPath) {
86+
// Attempt to sync then retry path resolution if not yet available
87+
vfsBridgeRef.current?.syncToVFS(files);
88+
vfsPath = vfsBridgeRef.current?.getPathById(file.id);
89+
if (!vfsPath) return;
90+
}
91+
// Ensure integration is initialized
92+
if (!integrationRef.current) return;
93+
const model =
94+
integrationRef.current.ensureMonacoModel(vfsPath) || editor.getModel();
95+
if (!model) return;
96+
// Switch editor to the proper model if needed
97+
if (
98+
!editorDisposedRef.current &&
99+
editor.getModel()?.uri.toString() !== model.uri.toString()
100+
) {
101+
editor.setModel(model);
102+
}
103+
104+
// Destroy previous binding and content subscription
105+
if (currentBindingRef.current) {
106+
currentBindingRef.current.destroy();
107+
currentBindingRef.current = null;
108+
}
109+
if (contentUnsubscribeRef.current) {
110+
contentUnsubscribeRef.current();
111+
contentUnsubscribeRef.current = null;
112+
}
113+
114+
if (file.content) {
115+
initializeFileContent(file.id, file.content);
116+
}
117+
118+
const fileYText = getFileText(file.id);
119+
120+
const awareness = getAwareness();
121+
if (awareness && fileYText) {
122+
// Defer binding to the next tick to ensure the editor view is fully attached
123+
setTimeout(() => {
124+
if (editorDisposedRef.current) return;
125+
// Create Yjs binding
126+
currentBindingRef.current = new MonacoBinding(
127+
fileYText,
128+
model,
129+
new Set([editor]),
130+
awareness
131+
);
132+
133+
// Dispose previous cursor listener if any
134+
cursorListenerDisposeRef.current?.dispose?.();
135+
cursorListenerDisposeRef.current = editor.onDidChangeCursorPosition(
136+
() => {
137+
const position = editor.getPosition();
138+
if (position) {
139+
const selection = editor.getSelection();
140+
updateCursorPosition(file.id, {
141+
line: position.lineNumber,
142+
column: position.column,
143+
selection: selection
144+
? {
145+
startLine: selection.startLineNumber,
146+
startColumn: selection.startColumn,
147+
endLine: selection.endLineNumber,
148+
endColumn: selection.endColumn,
149+
}
150+
: undefined,
151+
});
152+
}
153+
}
154+
);
155+
}, 0);
156+
}
157+
158+
contentUnsubscribeRef.current = registerFileContentChange(
159+
file.id,
160+
(content) => {
161+
onFileContentChange?.(file.id, content);
162+
// Reflect content changes into VFS so dependency/diagnostic engine can react
163+
vfsBridgeRef.current?.updateFileContent(file.id, content);
164+
// Nudge diagnostics for the current file
165+
integrationRef.current?.updateDiagnostics?.();
166+
}
167+
);
168+
169+
currentFileRef.current = file.id;
170+
};
171+
172+
const handleEditorDidMount = (
173+
editorInstance: monaco.editor.IStandaloneCodeEditor
174+
): void => {
175+
editorRef.current = editorInstance;
176+
setEditorReady(true);
177+
editorDisposedRef.current = false;
178+
179+
// When editor is disposed (e.g., key changes), clear refs to prevent setModel on disposed instance
180+
try {
181+
(
182+
editorInstance as unknown as { onDidDispose?: (cb: () => void) => void }
183+
).onDidDispose?.(() => {
184+
editorDisposedRef.current = true;
185+
editorRef.current = null;
186+
setEditorReady(false);
187+
});
188+
} catch {
189+
// no-op
190+
}
191+
192+
// Initialize Monaco integration after editor mounts to avoid worker loader issues
193+
if (!integrationRef.current && vfsBridgeRef.current) {
194+
integrationRef.current = new VFSMonacoIntegration(
195+
vfsBridgeRef.current.getVFSStore(),
196+
{
197+
enableDiagnostics: true,
198+
enableAutoCompletion: true,
199+
enableCodeActions: true,
200+
}
201+
);
202+
}
203+
204+
if (selectedFile && selectedFile.type === "file") {
205+
bindEditorToFile(selectedFile);
206+
} else {
207+
// Set placeholder content
208+
const model = editorInstance.getModel();
209+
if (model) {
210+
model.setValue(initialValue);
211+
}
212+
}
213+
};
214+
215+
// Rebind when selection changes, but only after editor is ready
216+
useEffect(() => {
217+
if (!editorReady) return;
218+
219+
setFileUsers([]);
220+
221+
if (selectedFile && selectedFile.type === "file" && editorRef.current) {
222+
setLanguage(getLanguageFromFileName(selectedFile.name));
223+
224+
if (currentFileRef.current !== selectedFile.id) {
225+
bindEditorToFile(selectedFile);
226+
}
227+
228+
const users = getUsersInFile(selectedFile.id);
229+
setFileUsers(users);
230+
} else if (!selectedFile) {
231+
if (currentBindingRef.current) {
232+
currentBindingRef.current.destroy();
233+
currentBindingRef.current = null;
234+
}
235+
if (contentUnsubscribeRef.current) {
236+
contentUnsubscribeRef.current();
237+
contentUnsubscribeRef.current = null;
238+
}
239+
currentFileRef.current = null;
240+
}
241+
}, [editorReady, selectedFile, getUsersInFile]);
242+
243+
const getLanguageFromFileName = (fileName: string): string => {
244+
const extension = fileName.split(".").pop()?.toLowerCase();
245+
const languageMap: Record<string, string> = {
246+
js: "javascript",
247+
jsx: "javascript",
248+
ts: "typescript",
249+
tsx: "typescript",
250+
py: "python",
251+
java: "java",
252+
json: "json",
253+
md: "markdown",
254+
html: "html",
255+
css: "css",
256+
scss: "scss",
257+
sass: "sass",
258+
less: "less",
259+
xml: "xml",
260+
yaml: "yaml",
261+
yml: "yaml",
262+
sql: "sql",
263+
sh: "shell",
264+
bash: "shell",
265+
php: "php",
266+
rb: "ruby",
267+
go: "go",
268+
rs: "rust",
269+
cpp: "cpp",
270+
c: "c",
271+
cs: "csharp",
272+
kt: "kotlin",
273+
swift: "swift",
274+
dart: "dart",
275+
};
276+
return languageMap[extension || ""] || "plaintext";
277+
};
278+
279+
const getDisplayContent = () => {
280+
if (selectedFile?.type === "file") {
281+
// For files, let the binding handle the content
282+
return "";
283+
}
284+
return initialValue;
285+
};
286+
287+
return (
288+
<div className="h-full flex flex-col">
289+
<CollaborativeCursor
290+
editor={editorRef.current}
291+
selectedFile={
292+
selectedFile ? { id: selectedFile.id, type: selectedFile.type } : null
293+
}
294+
/>
295+
296+
{/* File Tab/Header */}
297+
{selectedFile && selectedFile.type === "file" && (
298+
<div
299+
className={`px-4 py-2 ${theme.surfaceSecondary} border-b ${theme.border} flex items-center justify-between`}
300+
>
301+
<div className="flex items-center gap-2">
302+
<span className={`text-sm ${theme.text}`}>{selectedFile.name}</span>
303+
<span className={`text-xs ${theme.textMuted}`}>({language})</span>
304+
</div>
305+
306+
<div className="flex items-center gap-2">
307+
{/* Connection status */}
308+
<div
309+
className={`w-2 h-2 rounded-full ${
310+
isConnected ? "bg-green-500" : "bg-red-500"
311+
}`}
312+
title={isConnected ? "Connected" : "Disconnected"}
313+
/>
314+
315+
{/* Connected users count */}
316+
{fileUsers.length > 0 && (
317+
<div className="flex items-center gap-1">
318+
<div className="flex -space-x-1">
319+
{fileUsers.slice(0, 3).map((user, index) => (
320+
<div
321+
key={index}
322+
className="w-4 h-4 rounded-full border border-gray-600"
323+
style={{ backgroundColor: user.color }}
324+
title={user.name}
325+
/>
326+
))}
327+
{fileUsers.length > 3 && (
328+
<div
329+
className={`w-4 h-4 rounded-full ${theme.surface} border ${theme.border} flex items-center justify-center text-xs`}
330+
>
331+
+{fileUsers.length - 3}
332+
</div>
333+
)}
334+
</div>
335+
<span className={`text-xs ${theme.textMuted}`}>
336+
{fileUsers.length} user
337+
{fileUsers.length !== 1 ? "s" : ""}
338+
</span>
339+
</div>
340+
)}
341+
</div>
342+
</div>
343+
)}
344+
345+
{/* Editor */}
346+
<div className="flex-1">
347+
<Editor
348+
height="100%"
349+
language={language}
350+
theme={theme.monacoTheme}
351+
defaultValue={getDisplayContent()}
352+
onMount={handleEditorDidMount}
353+
options={{
354+
minimap: { enabled: true },
355+
fontSize: 14,
356+
lineNumbers: "on",
357+
roundedSelection: false,
358+
scrollBeyondLastLine: false,
359+
automaticLayout: true,
360+
wordWrap: "on",
361+
tabSize: 2,
362+
insertSpaces: true,
363+
formatOnPaste: true,
364+
formatOnType: true,
365+
suggestOnTriggerCharacters: true,
366+
acceptSuggestionOnEnter: "on",
367+
quickSuggestions: {
368+
other: true,
369+
comments: true,
370+
strings: true,
371+
},
372+
// Show read-only when no file is selected
373+
readOnly: !selectedFile || selectedFile.type !== "file",
374+
}}
375+
/>
376+
</div>
377+
378+
{/* Placeholder when no file is selected */}
379+
{(!selectedFile || selectedFile.type !== "file") && (
380+
<div
381+
className={`absolute inset-0 flex items-center justify-center ${theme.textMuted} pointer-events-none`}
382+
>
383+
<div className="text-center">
384+
<p className="text-lg mb-2">
385+
{!selectedFile ? "No file selected" : "Folder selected"}
386+
</p>
387+
<p className="text-sm">
388+
{!selectedFile
389+
? "Select a file from the explorer to start editing"
390+
: "Select a file (not a folder) to edit its contents"}
391+
</p>
392+
</div>
393+
</div>
394+
)}
395+
</div>
396+
);
397+
}

0 commit comments

Comments
 (0)