diff --git a/apps/api/src/containers/CustomSandbox.ts b/apps/api/src/containers/CustomSandbox.ts index bb3d956..094e073 100644 --- a/apps/api/src/containers/CustomSandbox.ts +++ b/apps/api/src/containers/CustomSandbox.ts @@ -84,8 +84,10 @@ export class CustomSandbox extends Sandbox { } })(); + console.log('runner', runner); await runner.setup(); const result = await runner.execute(); + console.log('result', result); this.ctx.waitUntil(runner.cleanup()); return result; diff --git a/apps/api/src/containers/images/Dockerfile b/apps/api/src/containers/images/Dockerfile index cd19bd6..e63bcfa 100644 --- a/apps/api/src/containers/images/Dockerfile +++ b/apps/api/src/containers/images/Dockerfile @@ -59,12 +59,6 @@ RUN uv pip install --system --break-system-packages \ find /usr -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \ find /usr -type f -name "*.pyc" -delete -# Install Node.js global packages -RUN npm install -g \ - typescript \ - ts-node \ - @types/node && \ - npm cache clean --force # Install Ruby gems (minimal set) RUN gem install bundler --no-document @@ -112,9 +106,14 @@ RUN uv pip install --system --break-system-packages \ find /usr -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true && \ find /usr -type f -name "*.pyc" -delete +# Install Node.js global packages in runtime stage +RUN npm install -g \ + typescript \ + ts-node \ + @types/node && \ + npm cache clean --force + # Copy language installations -COPY --from=builder /usr/local/lib/node_modules /usr/local/lib/node_modules -COPY --from=builder /usr/local/bin /usr/local/bin COPY --from=builder /usr/lib/ruby/gems /usr/lib/ruby/gems COPY --from=builder /usr/local/bin/bundle* /usr/local/bin/ COPY --from=builder /root/go /root/go diff --git a/apps/api/src/routes/room/publicRoom.routes.ts b/apps/api/src/routes/room/publicRoom.routes.ts index 8d20ae1..1b19eee 100644 --- a/apps/api/src/routes/room/publicRoom.routes.ts +++ b/apps/api/src/routes/room/publicRoom.routes.ts @@ -81,6 +81,7 @@ export const publicRoomRouter = new Hono() const codeRunService = new CodeRunService(ctx); + console.log('running code', code, language); const result = await codeRunService.runCode({ roomId, code, language }); return ctx.json(result); diff --git a/apps/api/src/schema/room.zod.ts b/apps/api/src/schema/room.zod.ts index 7daf3b3..ece5efc 100644 --- a/apps/api/src/schema/room.zod.ts +++ b/apps/api/src/schema/room.zod.ts @@ -13,6 +13,11 @@ export const RoomLanguageSchema = z.enum([ 'go', 'php', 'ruby', + + // web frameworks + 'react', + 'vue', + 'svelte', ]); export const RoomSchema = z.object({ diff --git a/apps/api/src/services/CodeRun.service.ts b/apps/api/src/services/CodeRun.service.ts index c67aa13..34f8ccf 100644 --- a/apps/api/src/services/CodeRun.service.ts +++ b/apps/api/src/services/CodeRun.service.ts @@ -25,15 +25,34 @@ export class CodeRunService { // this.ctx.executionCtx.waitUntil(roomDo.handleCodeExecutioMessage({ type: 'start' })); const sandbox = await this.getSandbox(roomId); - const raw = await sandbox.runCode({ language, code }); - const result = formatExecOutput(raw); - // // Broadcast execution complete - // this.ctx.executionCtx.waitUntil( - // roomDo.handleCodeExecutioMessage({ type: 'complete', output: result?.stdout || result?.stderr || 'No output from execution' }), - // ); - - return result; + try { + const raw = await sandbox.runCode({ language, code }); + + const result = formatExecOutput(raw); + return result; + } catch (error) { + console.error('error', error); + return { + success: false, + timestamp: new Date().toISOString(), + stdout: '', + stderr: 'Error running code', + exitCode: 1, + elapsedTime: -1, + compileTime: undefined, + }; + } + + // throw new Error('test'); + // console.log('result', result); + + // // // Broadcast execution complete + // // this.ctx.executionCtx.waitUntil( + // // roomDo.handleCodeExecutioMessage({ type: 'complete', output: result?.stdout || result?.stderr || 'No output from execution' }), + // // ); + + // return result; } private async getSandbox(roomId: Id<'room'>) { diff --git a/apps/web/biome.json b/apps/web/biome.json index 021c6cb..c5ef307 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -1,7 +1,8 @@ { "$schema": "../../node_modules/@biomejs/biome/configuration_schema.json", + "extends": "//", + "root": false, "files": { "includes": ["src/**/*.ts", "src/**/*.tsx", "!src/routeTree.gen.ts"] - }, - "extends": ["../../biome.json"] + } } diff --git a/apps/web/package.json b/apps/web/package.json index 45303bc..a8bbfdc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,24 @@ }, "dependencies": { "@cloudflare/vite-plugin": "^1.7.5", + "@codemirror/commands": "^6.8.1", + "@codemirror/lang-cpp": "^6.0.3", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-go": "^6.0.1", + "@codemirror/lang-html": "^6.4.10", + "@codemirror/lang-java": "^6.0.2", + "@codemirror/lang-javascript": "^6.2.4", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-markdown": "^6.3.4", + "@codemirror/lang-php": "^6.0.2", + "@codemirror/lang-python": "^6.2.1", + "@codemirror/lang-rust": "^6.0.2", + "@codemirror/state": "^6.5.2", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.38.1", "@coderscreen/ui": "workspace:^", + "@codesandbox/sandpack-client": "^2.19.8", + "@codesandbox/sandpack-react": "^2.20.0", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-checkbox": "^1.1.4", @@ -67,6 +84,7 @@ "better-auth": "^1.2.10", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "codemirror": "^6.0.2", "date-fns": "^4.1.0", "dayjs": "^1.11.13", "devicons-react": "^1.4.1", @@ -91,6 +109,7 @@ "tldraw": "^3.14.0", "unist-util-visit": "^5.0.0", "wrangler": "^4.22.0", + "y-codemirror.next": "^0.3.5", "y-monaco": "^0.1.6", "y-partykit": "^0.0.33", "y-prosemirror": "^1.3.5", diff --git a/apps/web/src/App.css b/apps/web/src/App.css index e971b05..e5f38c0 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -102,6 +102,11 @@ margin-top: 5px; } +/* CODEMIRROR */ +.ΝΌ1.cm-focused { + outline: none; +} + /* Cursor selection background */ .yRemoteSelection { opacity: 0.5; diff --git a/apps/web/src/components/common/LanguageIcon.tsx b/apps/web/src/components/common/LanguageIcon.tsx index 37b49ca..e4f68ca 100644 --- a/apps/web/src/components/common/LanguageIcon.tsx +++ b/apps/web/src/components/common/LanguageIcon.tsx @@ -1,25 +1,33 @@ -import { RoomSchema } from '@coderscreen/api/schema/room'; import BashPlain from 'devicons-react/icons/BashPlain'; import CPlain from 'devicons-react/icons/CPlain'; import CplusplusPlain from 'devicons-react/icons/CplusplusPlain'; +import Css3Plain from 'devicons-react/icons/Css3Plain'; import GoPlain from 'devicons-react/icons/GoPlain'; +import Html5Plain from 'devicons-react/icons/Html5Plain'; import JavaPlain from 'devicons-react/icons/JavaPlain'; -import JavascriptPlain from 'devicons-react/icons/JavascriptPlain'; +import JavascriptOriginal from 'devicons-react/icons/JavascriptOriginal'; +import MarkdownOriginal from 'devicons-react/icons/MarkdownOriginal'; +import NextjsPlain from 'devicons-react/icons/NextjsPlain'; import PhpPlain from 'devicons-react/icons/PhpPlain'; import PythonPlain from 'devicons-react/icons/PythonPlain'; +import ReactPlain from 'devicons-react/icons/ReactOriginal'; import RubyPlain from 'devicons-react/icons/RubyPlain'; import RustOriginal from 'devicons-react/icons/RustOriginal'; +import SolidjsPlain from 'devicons-react/icons/SolidjsPlain'; +import SveltePlain from 'devicons-react/icons/SveltePlain'; import TypescriptPlain from 'devicons-react/icons/TypescriptPlain'; +import VueOriginal from 'devicons-react/icons/VuejsOriginal'; import { useMemo } from 'react'; -import { cn } from '@/lib/utils'; +import { cn, cx } from '@/lib/utils'; +import type { FileType } from '@/query/realtime/editor.query'; -const BASE_ICON_STYLE = 'h-4 w-4'; +const BASE_ICON_STYLE = ''; export const LanguageIcon = ({ language, className, }: { - language: RoomSchema['language']; + language: string | FileType; className?: string; }) => { const iconStyle = useMemo(() => cn(BASE_ICON_STYLE, className), [className]); @@ -28,7 +36,7 @@ export const LanguageIcon = ({ const icon = (() => { switch (language) { case 'javascript': - return ; + return ; case 'typescript': return ; case 'python': @@ -50,10 +58,34 @@ export const LanguageIcon = ({ return ; case 'bash': return ; + case 'react': + return ; + case 'vue': + return ; + case 'nextjs': + return ; + case 'svelte': + return ; + case 'solidjs': + return ; + case 'css': + return ; + case 'html': + return ; + case 'json': + return
{`{ }`}
; + case 'markdown': + return ; + case 'jsx': + return ; // JSX uses React icon + case 'tsx': + return ; // TSX uses TypeScript icon + case 'unknown': + return
; default: return
; } })(); - return
{icon}
; + return
{icon}
; }; diff --git a/apps/web/src/components/org/CreateOrgDialog.tsx b/apps/web/src/components/org/CreateOrgDialog.tsx index de48c95..d291622 100644 --- a/apps/web/src/components/org/CreateOrgDialog.tsx +++ b/apps/web/src/components/org/CreateOrgDialog.tsx @@ -32,7 +32,6 @@ export const CreateOrgDialog = (props: CreateOrgDialogProps) => { }); const handleLogoFileChange = async (file: File | null) => { - console.log('file', file); if (file) { const reader = new FileReader(); reader.onload = async (e) => { @@ -40,7 +39,6 @@ export const CreateOrgDialog = (props: CreateOrgDialogProps) => { setLogoPreview(result); const asset = await uploadLogo(result); - console.log('asset', asset); form.setFieldValue('logo', asset.url); }; reader.readAsDataURL(file); diff --git a/apps/web/src/components/room/Dockview.tsx b/apps/web/src/components/room/Dockview.tsx index 83545c5..45b345e 100644 --- a/apps/web/src/components/room/Dockview.tsx +++ b/apps/web/src/components/room/Dockview.tsx @@ -6,11 +6,11 @@ import { RiStickyNoteLine, RiTerminalLine, } from '@remixicon/react'; -import { DockviewTheme, IDockviewPanelHeaderProps, IDockviewPanelProps } from 'dockview'; +import type { DockviewTheme, IDockviewPanelHeaderProps, IDockviewPanelProps } from 'dockview'; import { useMemo } from 'react'; import { AiChatView } from '@/components/room/ai-chat/AiChatView'; -import { CodeOutput } from '@/components/room/CodeOutput'; -import { CodeEditor } from '@/components/room/editor/CodeEditor'; +import { EditorView } from '@/components/room/editor/EditorView'; +import { CodeOutput } from '@/components/room/output/CodeOutput'; import { InstructionEditor } from '@/components/room/tiptap/InstructionEditor'; import { NotesEditor } from '@/components/room/tiptap/NotesEditor'; import { WhiteboardView } from '@/components/room/whiteboard/WhiteboardView'; @@ -19,7 +19,7 @@ import { cx } from '@/lib/utils'; export const DOCKVIEW_PANEL_IDS = { CODE_EDITOR: 'code-editor', INSTRUCTIONS: 'instructions', - PROGRAM_OUTPUT: 'program-output', + CODE_OUTPUT: 'code-output', WHITEBOARD: 'whiteboard', AI_CHAT: 'ai-chat', NOTES: 'notes', @@ -38,7 +38,7 @@ export const getTabIcon = (panelId: string) => { return ; case DOCKVIEW_PANEL_IDS.INSTRUCTIONS: return ; - case DOCKVIEW_PANEL_IDS.PROGRAM_OUTPUT: + case DOCKVIEW_PANEL_IDS.CODE_OUTPUT: return ; case DOCKVIEW_PANEL_IDS.WHITEBOARD: return ; @@ -57,7 +57,9 @@ export const useDockviewComponents = (isGuest: boolean) => () => ({ 'code-editor': (_: IDockviewPanelProps) => (
- + {/* */} + {/* */} +
), instructions: (_: IDockviewPanelProps) => ( @@ -65,7 +67,7 @@ export const useDockviewComponents = (isGuest: boolean) =>
), - 'program-output': (_: IDockviewPanelProps) => ( + 'code-output': (_: IDockviewPanelProps) => (
diff --git a/apps/web/src/components/room/dockview.css b/apps/web/src/components/room/dockview.css index 95caab4..714ec57 100644 --- a/apps/web/src/components/room/dockview.css +++ b/apps/web/src/components/room/dockview.css @@ -45,4 +45,4 @@ /* Floating elements */ --dv-floating-box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); -} +} \ No newline at end of file diff --git a/apps/web/src/components/room/editor/CodeEditor.tsx b/apps/web/src/components/room/editor/CodeEditor.tsx deleted file mode 100644 index 8135932..0000000 --- a/apps/web/src/components/room/editor/CodeEditor.tsx +++ /dev/null @@ -1,232 +0,0 @@ -'use client'; - -import { RoomSchema } from '@coderscreen/api/schema/room'; -import { Editor } from '@monaco-editor/react'; -import { RiPlayFill } from '@remixicon/react'; -import * as monaco from 'monaco-editor'; -import { editor } from 'monaco-editor'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { LanguageIcon } from '@/components/common/LanguageIcon'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { useCodeEditor } from '@/query/realtime/code.query'; -import { useCodeExecutionHistory } from '@/query/realtime/execution.query'; -import { type EditorSettings, EditorSettingsDialog } from './EditorSettingsDialog'; - -const SUPPORTED_LANGUAGES: { - value: RoomSchema['language']; - label: string; -}[] = [ - { value: 'javascript', label: 'JavaScript' }, - { value: 'typescript', label: 'TypeScript' }, - { value: 'python', label: 'Python' }, - { value: 'java', label: 'Java' }, - { value: 'c', label: 'C' }, - { value: 'c++', label: 'C++' }, - { value: 'bash', label: 'Bash' }, - { value: 'rust', label: 'Rust' }, - { value: 'go', label: 'Go' }, - { value: 'php', label: 'PHP' }, - { value: 'ruby', label: 'Ruby' }, -] satisfies { - value: RoomSchema['language']; - label: string; -}[]; - -export function CodeEditor() { - const { - setupCollaboration, - cleanupCollaboration, - language, - setLanguage, - subscribeToLanguageChanges, - isReady, - isReadOnly, - } = useCodeEditor(); - const { executeCode, isLoading } = useCodeExecutionHistory(); - const [editorRef, setEditorRef] = useState(); - - // Editor settings state - const [editorSettings, setEditorSettings] = useState({ - fontSize: 14, - fontFamily: 'JetBrains Mono, Consolas, monospace', - theme: 'vs', - minimap: false, - wordWrap: 'on', - lineNumbers: 'on', - folding: true, - automaticLayout: true, - scrollBeyondLastLine: false, - roundedSelection: false, - lineDecorationsWidth: 10, - lineNumbersMinChars: 3, - glyphMargin: false, - padding: { top: 10, bottom: 10 }, - }); - - const handleOnMount = useCallback((e: editor.IStandaloneCodeEditor) => { - setEditorRef(e); - }, []); - - // Subscribe to shared language changes - useEffect(() => { - if (!isReady) return; - - const unsubscribe = subscribeToLanguageChanges((newLanguage) => { - // Update Monaco editor language if editor is ready - if (editorRef) { - const model = editorRef.getModel(); - if (model) { - monaco.editor.setModelLanguage(model, newLanguage); - } - } - }); - - return unsubscribe; - }, [isReady, subscribeToLanguageChanges, editorRef]); - - // Setup collaboration when editor is ready - useEffect(() => { - if (editorRef && isReady) { - const cleanup = setupCollaboration(editorRef); - return cleanup; - } - }, [editorRef, isReady, setupCollaboration]); - - // Cleanup collaboration when component unmounts - useEffect(() => { - return () => { - cleanupCollaboration(); - }; - }, [cleanupCollaboration]); - - // Update editor options when settings change - useEffect(() => { - if (editorRef) { - editorRef.updateOptions({ - fontSize: editorSettings.fontSize, - fontFamily: editorSettings.fontFamily, - minimap: { enabled: editorSettings.minimap }, - wordWrap: editorSettings.wordWrap, - lineNumbers: editorSettings.lineNumbers, - folding: editorSettings.folding, - automaticLayout: editorSettings.automaticLayout, - scrollBeyondLastLine: editorSettings.scrollBeyondLastLine, - roundedSelection: editorSettings.roundedSelection, - lineDecorationsWidth: editorSettings.lineDecorationsWidth, - lineNumbersMinChars: editorSettings.lineNumbersMinChars, - glyphMargin: editorSettings.glyphMargin, - padding: editorSettings.padding, - }); - } - }, [editorRef, editorSettings]); - - const handleRunCode = useCallback(async () => { - if (!editorRef || !language) { - return; - } - - const code = editorRef.getModel()?.getValue() ?? ''; - await executeCode(code, language); - }, [editorRef, executeCode, language]); - - const handleLanguageChange = useCallback( - (value: RoomSchema['language']) => { - setLanguage(value); - - if (editorRef) { - const model = editorRef.getModel(); - if (model) { - monaco.editor.setModelLanguage(model, value); - } - } - }, - [editorRef, setLanguage] - ); - - const handleSettingsChange = useCallback((newSettings: EditorSettings) => { - setEditorSettings(newSettings); - }, []); - - const monacoLanguage = useMemo(() => { - switch (language) { - case 'c++': - return 'cpp'; - default: - return language; - } - }, [language]); - - return ( -
- {/* Menu Bar */} -
-
- -
- -
- - -
-
-
- -
-
- ); -} diff --git a/apps/web/src/components/room/editor/EditorHeader.tsx b/apps/web/src/components/room/editor/EditorHeader.tsx new file mode 100644 index 0000000..df50e3a --- /dev/null +++ b/apps/web/src/components/room/editor/EditorHeader.tsx @@ -0,0 +1,160 @@ +import { RoomSchema } from '@coderscreen/api/schema/room'; +import { RiArrowRightLine, RiCloseLine, RiPlayLine } from '@remixicon/react'; +import { useCallback, useState } from 'react'; +import { LanguageIcon } from '@/components/common/LanguageIcon'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useRoomContext } from '@/contexts/RoomContext'; +import { useCodeExecutionHistory } from '@/query/realtime/execution.query'; + +const SUPPORTED_LANGUAGES = [ + { value: 'javascript', label: 'JavaScript' }, + { value: 'typescript', label: 'TypeScript' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + { value: 'c', label: 'C' }, + { value: 'c++', label: 'C++' }, + { value: 'bash', label: 'Bash' }, + { value: 'rust', label: 'Rust' }, + { value: 'go', label: 'Go' }, + { value: 'php', label: 'PHP' }, + { value: 'ruby', label: 'Ruby' }, +] satisfies { + value: RoomSchema['language']; + label: string; +}[]; + +const SUPPORTED_WEB_FRAMEWORKS = [ + { value: 'react', label: 'React' }, + { value: 'vue', label: 'Vue' }, + { value: 'svelte', label: 'Svelte' }, +] satisfies { + value: RoomSchema['language']; + label: string; +}[]; + +interface EditorHeaderProps { + handleWorkspaceReset: (language: RoomSchema['language']) => void; +} + +export const EditorHeader = ({ handleWorkspaceReset }: EditorHeaderProps) => { + const { currentLanguage, setLanguage, isReadOnly } = useRoomContext(); + const { executeCode, isLoading } = useCodeExecutionHistory(); + + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingLanguage, setPendingLanguage] = useState(null); + + // Handle language change with confirmation + const handleLanguageChange = useCallback( + (newLanguage: RoomSchema['language']) => { + if (currentLanguage !== newLanguage) { + setPendingLanguage(newLanguage); + setShowConfirmDialog(true); + } else { + setLanguage(newLanguage); + } + }, + [currentLanguage, setLanguage] + ); + + // Confirm language change + const confirmLanguageChange = useCallback(() => { + if (pendingLanguage) { + // need to reset whole workspace to be like the new language + handleWorkspaceReset(pendingLanguage); + + setLanguage(pendingLanguage); + setPendingLanguage(null); + setShowConfirmDialog(false); + } + }, [pendingLanguage, setLanguage, handleWorkspaceReset]); + + // Cancel language change + const cancelLanguageChange = useCallback(() => { + setPendingLanguage(null); + setShowConfirmDialog(false); + }, []); + + return ( + <> +
+
+ +
+ + +
+ + + + + Switch Language? + + Switching your language will reset your code. Are you sure you want to continue? You + will not be able to revert this action. + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/room/editor/EditorView.tsx b/apps/web/src/components/room/editor/EditorView.tsx new file mode 100644 index 0000000..a372659 --- /dev/null +++ b/apps/web/src/components/room/editor/EditorView.tsx @@ -0,0 +1,30 @@ +import { isLanguageMultiFile } from '@/components/room/editor/lib/utils'; +import { useRoomContext } from '@/contexts/RoomContext'; +import { MultiFileCodeEditor } from './MultiFileCodeEditor'; +import { SingleFileCodeEditor } from './SingleFileCodeEditor'; + +export const EditorView = () => { + const { currentLanguage } = useRoomContext(); + + // Show loading state while language is being determined + if (!currentLanguage) { + return ( +
+
+
+
+
+
+

Loading...

+
+
+ ); + } + + // Conditionally render based on language type + if (isLanguageMultiFile(currentLanguage)) { + return ; + } + + return ; +}; diff --git a/apps/web/src/components/room/editor/FileExplorer.tsx b/apps/web/src/components/room/editor/FileExplorer.tsx new file mode 100644 index 0000000..812b06b --- /dev/null +++ b/apps/web/src/components/room/editor/FileExplorer.tsx @@ -0,0 +1,485 @@ +'use client'; + +import { + RiDeleteBinLine, + RiEditLine, + RiFileAddLine, + RiFileLine, + RiFolderAddLine, + RiFolderFill, + RiFolderOpenFill, + RiMore2Line, +} from '@remixicon/react'; +import { useCallback, useState } from 'react'; +import { LanguageIcon } from '@/components/common/LanguageIcon'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown'; +import { cn } from '@/lib/utils'; +import { FsNode } from '@/query/realtime/editor.query'; +import { AddItemInput } from './multi-file/AddItemInput'; +import { DeleteFileDialog } from './multi-file/DeleteFileDialog'; +import { RenameItemInput } from './multi-file/RenameItemInput'; + +interface FileExplorerProps { + files: FsNode[]; + selectedFile?: string; // Now expects file ID instead of path + onFileSelect: (file: FsNode) => void; + onFileCreate: (path: string) => void; + onFolderCreate: (path: string) => void; + onFileDelete: (path: string) => void; + onFileRename: (oldPath: string, newPath: string) => void; + onFolderRename: (oldPath: string, newPath: string) => void; + checkIfPathExists: (path: string, type: 'file' | 'folder') => boolean; + className?: string; +} + +const FileIcon = ({ file }: { file: FsNode }) => { + if (file.type === 'folder') { + return file.isExpanded ? ( + + ) : ( + + ); + } + + // For files, use file type icon if available + if (file.fileType) { + return ; + } + + return ; +}; + +const FileTreeItem = ({ + file, + level = 0, + selectedFile, + focusedFile, + addingItem, + renamingItem, + onFileSelect, + onToggleExpand, + onConfirmFile, + onCancelFile, + onNewFile, + onDeleteFile, + onRenameFile, + onCancelRename, + onConfirmRename, + checkIfPathExists, +}: { + file: FsNode; + level?: number; + selectedFile?: string; + focusedFile?: FsNode | null; + addingItem?: { path: string; isFolder: boolean } | null; + renamingItem?: FsNode | null; + onFileSelect: (file: FsNode) => void; + onToggleExpand: (file: FsNode) => void; + onConfirmFile: (fileName: string, isFolder: boolean) => void; + onCancelFile: () => void; + onNewFile: (file: FsNode, isFolder: boolean) => void; + onDeleteFile: (file: FsNode) => void; + onRenameFile: (file: FsNode) => void; + onCancelRename: () => void; + onConfirmRename: (file: FsNode, newName: string) => void; + checkIfPathExists: (path: string, type: 'file' | 'folder') => boolean; +}) => { + const isSelected = selectedFile === file.id; + const isFocused = focusedFile?.id === file.id; + + const handleClick = useCallback(() => { + if (file.type === 'folder') { + onToggleExpand(file); + } else { + onFileSelect(file); + } + }, [file, onFileSelect, onToggleExpand]); + + const isRenaming = renamingItem?.id === file.id; + + return ( +
+ {isRenaming ? ( + onConfirmRename(file, newName)} + onCancel={onCancelRename} + checkIfPathExists={checkIfPathExists} + parentPath={file.path.split('/').slice(0, -1).join('/')} + /> + ) : ( +
+ { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }} + className='flex items-center gap-2 flex-1' + > +
+ +
+
{file.name}
+
+ + + { + e.stopPropagation(); + }} + className='opacity-0 group-hover:opacity-100 transition-opacity' + > + + + e.preventDefault()}> + {file.type === 'folder' && ( + <> + onNewFile(file, false)} + > + + New File + + onNewFile(file, true)} + > + + New Folder + + + )} + onRenameFile(file)} + > + + Rename + + onDeleteFile(file)} + > + + Delete + + + +
+ )} + + {file.type === 'folder' && file.isExpanded && file.children && ( +
+ {file.children.map((child) => ( + + ))} + + {addingItem?.path === file.path && ( +
+ onConfirmFile(fileName, addingItem.isFolder)} + onCancel={onCancelFile} + placeholder={`Enter ${addingItem.isFolder ? 'folder' : 'file'} name...`} + checkIfPathExists={checkIfPathExists} + parentPath={file.path} + /> +
+ )} +
+ )} +
+ ); +}; + +export const FileExplorer = ({ + files, + selectedFile, + onFileSelect, + onFileCreate, + onFolderCreate, + onFileDelete, + onFileRename, + onFolderRename, + checkIfPathExists, + className, +}: FileExplorerProps) => { + const [focusedFile, setFocusedFile] = useState(null); + const [addingItem, setAddingItem] = useState<{ + path: string; + isFolder: boolean; + } | null>(null); + const [renamingItem, setRenamingItem] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [fileToDelete, setFileToDelete] = useState(null); + + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + + const handleToggleExpand = useCallback( + (file: FsNode) => { + setFocusedFile(file); + if (file.type === 'folder') { + const newExpanded = new Set(expandedFolders); + if (newExpanded.has(file.id)) { + newExpanded.delete(file.id); + } else { + newExpanded.add(file.id); + } + setExpandedFolders(newExpanded); + } + }, + [expandedFolders] + ); + + const handleFileSelect = useCallback( + (file: FsNode) => { + setFocusedFile(file); + onFileSelect?.(file); + }, + [onFileSelect] + ); + + const handleDeselectFile = useCallback(() => { + setFocusedFile(null); + }, []); + + const handleClickNewFile = useCallback( + (file: FsNode | null, isFolder: boolean) => { + if (!file) { + setAddingItem({ + path: '/', + isFolder, + }); + + return; + } + + // Ensure the folder is expanded when adding a new file/folder + if (file.type === 'folder') { + const newExpanded = new Set(expandedFolders); + newExpanded.add(file.path); + setExpandedFolders(newExpanded); + setAddingItem({ + path: file.path, + isFolder, + }); + } else { + const rawDirPath = file.path.split('/').slice(0, -1).join('/'); + const dirPath = rawDirPath.startsWith('/') ? rawDirPath.substring(1) : rawDirPath; + + // Also expand the parent directory + if (dirPath) { + const newExpanded = new Set(expandedFolders); + newExpanded.add(dirPath); + setExpandedFolders(newExpanded); + } + setAddingItem({ + path: dirPath, + isFolder, + }); + } + }, + [expandedFolders] + ); + + const handleConfirmFile = useCallback( + (fileName: string, isFolder: boolean) => { + if (addingItem !== null) { + const fullPath = addingItem ? `${addingItem.path}/${fileName}` : fileName; + + if (isFolder) { + onFolderCreate(fullPath); + } else { + onFileCreate(fullPath); + } + } + setAddingItem(null); + }, + [addingItem, onFileCreate, onFolderCreate] + ); + + const handleCancelFile = useCallback(() => { + setAddingItem(null); + }, []); + + const handleDeleteFile = useCallback((file: FsNode) => { + setFileToDelete(file); + setDeleteDialogOpen(true); + }, []); + + const handleConfirmDelete = useCallback(() => { + if (fileToDelete) { + onFileDelete(fileToDelete.path); + setDeleteDialogOpen(false); + setFileToDelete(null); + } + }, [fileToDelete, onFileDelete]); + + const handleRenameFile = useCallback((file: FsNode) => { + setRenamingItem(file); + }, []); + + const handleCancelRename = useCallback(() => { + setRenamingItem(null); + }, []); + + const handleConfirmRename = useCallback( + (file: FsNode, newName: string) => { + if (newName.trim() && newName.trim() !== file.name) { + const parentPath = file.path.split('/').slice(0, -1).join('/'); + const newPath = parentPath ? `${parentPath}/${newName.trim()}` : newName.trim(); + + if (file.type === 'folder') { + onFolderRename(file.path, newPath); + } else { + onFileRename(file.path, newPath); + } + } + setRenamingItem(null); + }, + [onFileRename, onFolderRename] + ); + + const toggleExpandedFolders = useCallback( + (files: FsNode[]): FsNode[] => { + return files.map((file) => ({ + ...file, + isExpanded: expandedFolders.has(file.id), + children: file.children ? toggleExpandedFolders(file.children) : undefined, + })); + }, + [expandedFolders] + ); + + const processedFiles = toggleExpandedFolders(files); + + return ( +
+ {/* Header */} +
+

Files

+ + + + + + e.preventDefault()}> + handleClickNewFile(focusedFile, false)} + > + + New File + + handleClickNewFile(focusedFile, true)} + > + + New Folder + + + +
+ + {/* File Tree */} +
+ {processedFiles.length === 0 ? ( +
+
No files
+
+ ) : ( + <> + {processedFiles.map((file) => ( + + ))} + {addingItem?.path === '/' && ( + handleConfirmFile(fileName, addingItem.isFolder)} + onCancel={handleCancelFile} + placeholder={`Enter ${addingItem.isFolder ? 'folder' : 'file'} name...`} + checkIfPathExists={checkIfPathExists} + /> + )} + + )} +
+ +
{ + if (e.key === 'Enter' || e.key === ' ') { + handleDeselectFile(); + } + }} + role='button' + tabIndex={0} + aria-label='Deselect active file' + /> + + +
+ ); +}; diff --git a/apps/web/src/components/room/editor/MultiFileCodeEditor.tsx b/apps/web/src/components/room/editor/MultiFileCodeEditor.tsx new file mode 100644 index 0000000..61f274d --- /dev/null +++ b/apps/web/src/components/room/editor/MultiFileCodeEditor.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useRef } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import { useMultiFileCodeEditor } from '@/query/realtime/editor.query'; +import { EditorHeader } from './EditorHeader'; +import { FileExplorer } from './FileExplorer'; + +interface MultiFileCodeEditorProps { + className?: string; +} + +export function MultiFileCodeEditor({ className }: MultiFileCodeEditorProps) { + const elementRef = useRef(null); + + const { + files, + selectedFile, + setSelectedFile, + focusEditor, + editorVisible, + handleWorkspaceReset, + createFile, + createFolder, + deleteFile, + renameFile, + renameFolder, + checkIfPathExists, + } = useMultiFileCodeEditor(elementRef); + + return ( +
+ + + {/* Editor and File Explorer */} +
+ + {/* File Explorer */} + + setSelectedFile(file.id)} + onFileCreate={createFile} + onFolderCreate={createFolder} + onFileDelete={deleteFile} + onFileRename={renameFile} + onFolderRename={renameFolder} + checkIfPathExists={checkIfPathExists} + /> + + + + + {/* Code Editor */} + +
focusEditor()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + focusEditor(); + } + }} + > +
+ {!editorVisible && ( +
+
+

No file selected

+

Please select a file to edit

+
+
+ )} +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/room/editor/SingleFileCodeEditor.tsx b/apps/web/src/components/room/editor/SingleFileCodeEditor.tsx new file mode 100644 index 0000000..2f58a56 --- /dev/null +++ b/apps/web/src/components/room/editor/SingleFileCodeEditor.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { EditorHeader } from '@/components/room/editor/EditorHeader'; +import { getSingleFileTemplateFileName } from '@/components/room/editor/lib/languageTemplate'; +import { useRoomContext } from '@/contexts/RoomContext'; +import { findFileIdByPath } from '@/contexts/SandpackContext'; +import { useMultiFileCodeEditor } from '@/query/realtime/editor.query'; +import { FS_MAP_KEY, FSEntry } from '@/query/realtime/multi-file/docUtils'; + +interface SingleFileCodeEditorProps { + selectedFile?: string; + onFileChange?: (filePath: string) => void; +} + +export function SingleFileCodeEditor(_props: SingleFileCodeEditorProps) { + const { provider, currentLanguage } = useRoomContext(); + const editorElementRef = useRef(null); + const [isElementReady, setIsElementReady] = useState(false); + + const { handleWorkspaceReset, setSelectedFile } = useMultiFileCodeEditor(editorElementRef); + + const fileId = useMemo(() => { + if (!currentLanguage) { + return null; + } + + const filePath = getSingleFileTemplateFileName(currentLanguage); + + const fsMap = provider.doc.getMap(FS_MAP_KEY); + const id = findFileIdByPath(fsMap, filePath); + return id; + }, [currentLanguage, provider.doc]); + + // Effect to track when the DOM element is ready + useEffect(() => { + if (editorElementRef.current) { + // Use a small delay to ensure the element is fully rendered + const timer = setTimeout(() => { + setIsElementReady(true); + }, 100); + return () => clearTimeout(timer); + } + }, []); + + // Effect to initialize the editor when both fileId and element are ready + useEffect(() => { + if (fileId && isElementReady && editorElementRef.current) { + setSelectedFile(fileId); + } + }, [fileId, isElementReady, setSelectedFile]); + + return ( +
+ {/* Menu Bar */} + + +
+
+ ); +} diff --git a/apps/web/src/components/room/editor/lib/languageTemplate.tsx b/apps/web/src/components/room/editor/lib/languageTemplate.tsx new file mode 100644 index 0000000..f2d4c62 --- /dev/null +++ b/apps/web/src/components/room/editor/lib/languageTemplate.tsx @@ -0,0 +1,79 @@ +import { RoomSchema } from '@coderscreen/api/schema/room'; +import { + REACT_WORKSPACE_TEMPLATE, + SVELTE_WORKSPACE_TEMPLATE, + VUEJS_WORKSPACE_TEMPLATE, +} from '@/components/room/editor/lib/multiFileTemplates'; +import { + BASH_SINGLE_FILE_TEMPLATE, + C_SINGLE_FILE_TEMPLATE, + CPP_SINGLE_FILE_TEMPLATE, + GO_SINGLE_FILE_TEMPLATE, + JAVA_SINGLE_FILE_TEMPLATE, + JAVASCRIPT_SINGLE_FILE_TEMPLATE, + PHP_SINGLE_FILE_TEMPLATE, + PYTHON_SINGLE_FILE_TEMPLATE, + RUBY_SINGLE_FILE_TEMPLATE, + RUST_SINGLE_FILE_TEMPLATE, + TYPESCRIPT_SINGLE_FILE_TEMPLATE, +} from '@/components/room/editor/lib/singleFileTemplates'; + +export type WorkspaceTemplate = { + path: string; + code: string; + isFolder?: boolean; +}[]; + +export const getWorkspaceTemplate = (workspaceType: RoomSchema['language']): WorkspaceTemplate => { + switch (workspaceType) { + case 'react': + return REACT_WORKSPACE_TEMPLATE; + case 'vue': + return VUEJS_WORKSPACE_TEMPLATE; + case 'svelte': + return SVELTE_WORKSPACE_TEMPLATE; + default: + return getSingleFileTemplate(workspaceType); + } +}; + +export const getSingleFileTemplate = (language: RoomSchema['language']): WorkspaceTemplate => { + switch (language) { + case 'typescript': + return TYPESCRIPT_SINGLE_FILE_TEMPLATE; + case 'javascript': + return JAVASCRIPT_SINGLE_FILE_TEMPLATE; + case 'python': + return PYTHON_SINGLE_FILE_TEMPLATE; + case 'bash': + return BASH_SINGLE_FILE_TEMPLATE; + case 'rust': + return RUST_SINGLE_FILE_TEMPLATE; + case 'c++': + return CPP_SINGLE_FILE_TEMPLATE; + case 'c': + return C_SINGLE_FILE_TEMPLATE; + case 'java': + return JAVA_SINGLE_FILE_TEMPLATE; + case 'go': + return GO_SINGLE_FILE_TEMPLATE; + case 'php': + return PHP_SINGLE_FILE_TEMPLATE; + case 'ruby': + return RUBY_SINGLE_FILE_TEMPLATE; + default: + return []; + } +}; + +// Helper function to get the main file name from a single file template +export const getSingleFileTemplateFileName = (language: RoomSchema['language']): string => { + const template = getSingleFileTemplate(language); + if (template.length === 0) { + return ''; + } + + // Get the first file from the template and extract just the filename + const filePath = template[0].path; + return filePath.split('/').pop() ?? ''; +}; diff --git a/apps/web/src/components/room/editor/lib/multiFileTemplates.tsx b/apps/web/src/components/room/editor/lib/multiFileTemplates.tsx new file mode 100644 index 0000000..b34799a --- /dev/null +++ b/apps/web/src/components/room/editor/lib/multiFileTemplates.tsx @@ -0,0 +1,401 @@ +import { WorkspaceTemplate } from '@/components/room/editor/lib/languageTemplate'; + +export const REACT_WORKSPACE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/package.json', + code: JSON.stringify( + { + name: 'react-workspace', + main: 'src/index.tsx', + dependencies: { + react: 'latest', + 'react-dom': 'latest', + '@types/react': 'latest', + '@types/react-dom': 'latest', + typescript: 'latest', + }, + }, + null, + 2 + ), + }, + { + path: '/public', + isFolder: true, + code: '', + }, + { + path: '/public/index.html', + code: ` + + + + + React App + + +
+ + +`, + }, + { + path: '/src', + isFolder: true, + code: '', + }, + { + path: '/src/index.tsx', + code: `import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +const rootElement = document.getElementById("root"); +if (!rootElement) throw new Error("Failed to find the root element"); + +const root = createRoot(rootElement); +root.render(); +`, + }, + { + path: '/src/App.tsx', + code: `import React, { useState } from "react"; + +const App: React.FC = () => { + const [count, setCount] = useState(0); + + return ( +
+

Hello React!

+

Count: {count}

+ +
+ ); +}; + +export default App; +`, + }, + { + path: '/tsconfig.json', + code: JSON.stringify( + { + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + }, + include: ['src'], + references: [{ path: './tsconfig.node.json' }], + }, + null, + 2 + ), + }, + { + path: '/tsconfig.node.json', + code: JSON.stringify( + { + compilerOptions: { + composite: true, + skipLibCheck: true, + module: 'ESNext', + moduleResolution: 'bundler', + allowSyntheticDefaultImports: true, + }, + include: ['vite.config.ts'], + }, + null, + 2 + ), + }, +]; + +export const VUEJS_WORKSPACE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/package.json', + code: JSON.stringify({ + name: 'vue-app', + version: '0.1.0', + private: true, + main: 'src/main.ts', + type: 'module', + scripts: { + dev: 'vite', + build: 'vue-tsc && vite build', + preview: 'vite preview', + }, + dependencies: { + vue: '^3.3.0', + }, + devDependencies: { + '@vitejs/plugin-vue': '^4.2.0', + typescript: '^5.0.0', + 'vue-tsc': '^1.2.0', + vite: '^4.3.0', + }, + }), + }, + { + path: '/index.html', + code: ` + + + + + + Vue App + + +
+ + + +`, + }, + { + path: '/vite.config.ts', + code: `import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], +}) +`, + }, + { + path: '/tsconfig.json', + code: JSON.stringify({ + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'preserve', + strict: true, + noUnusedLocals: true, + noUnusedParameters: true, + noFallthroughCasesInSwitch: true, + }, + include: ['src/**/*.ts', 'src/**/*.d.ts', 'src/**/*.tsx', 'src/**/*.vue'], + references: [{ path: './tsconfig.node.json' }], + }), + }, + { + path: '/tsconfig.node.json', + code: JSON.stringify({ + compilerOptions: { + composite: true, + skipLibCheck: true, + module: 'ESNext', + moduleResolution: 'bundler', + allowSyntheticDefaultImports: true, + }, + include: ['vite.config.ts'], + }), + }, + { + path: '/src', + isFolder: true, + code: '', + }, + { + path: '/src/main.ts', + code: `import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') +`, + }, + { + path: '/src/App.vue', + code: ` + + + + +`, + }, + { + path: '/src/style.css', + code: `:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + color: white; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + button { + background-color: #f9f9f9; + color: #213547; + } +} +`, + }, + { + path: '/src/vite-env.d.ts', + code: `/// +`, + }, +]; + +export const SVELTE_WORKSPACE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/App.svelte', + code: ` + + + +
+

Hello {name}

+
`, + }, + { + path: '/index.js', + code: `import App from "./App.svelte"; +import "./styles.css"; + +const app = new App({ + target: document.body +}); + +export default app; +`, + }, + { + path: '/public/index.html', + code: ` + + + + + + Svelte app + + + + + + + +`, + }, + { + path: '/styles.css', + code: `body { + margin: 0; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +main { + text-align: center; + padding: 20px; +} + +h1 { + color: #333; + margin-bottom: 20px; +} +`, + }, + { + path: '/package.json', + code: JSON.stringify({ + dependencies: { + svelte: '^3.0.0', + }, + main: '/index.js', + }), + }, +]; diff --git a/apps/web/src/components/room/editor/lib/singleFileTemplates.tsx b/apps/web/src/components/room/editor/lib/singleFileTemplates.tsx new file mode 100644 index 0000000..3c5e373 --- /dev/null +++ b/apps/web/src/components/room/editor/lib/singleFileTemplates.tsx @@ -0,0 +1,196 @@ +import { RoomSchema } from '@coderscreen/api/schema/room'; +import { WorkspaceTemplate } from '@/components/room/editor/lib/languageTemplate'; + +// TypeScript single file template +export const TYPESCRIPT_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.ts', + code: `// TypeScript Hello World +function greet(name: string): string { + return \`Hello, \${name}!\`; +} + +const message = greet("World"); +console.log(message); +`, + }, +]; + +// JavaScript single file template +export const JAVASCRIPT_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.js', + code: `// JavaScript Hello World +function greet(name) { + return \`Hello, \${name}!\`; +} + +const message = greet("World"); +console.log(message); +`, + }, +]; + +// Python single file template +export const PYTHON_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.py', + code: `# Python Hello World +def greet(name): + return f"Hello, {name}!" + +if __name__ == "__main__": + message = greet("World") + print(message) +`, + }, +]; + +// Bash single file template +export const BASH_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/script.sh', + code: `#!/bin/bash + +# Bash Hello World +echo "Hello, World!" + +# Function example +greet() { + echo "Hello, $1!" +} + +greet "World" +`, + }, +]; + +// Rust single file template +export const RUST_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.rs', + code: `// Rust Hello World +fn greet(name: &str) -> String { + format!("Hello, {}!", name) +} + +fn main() { + let message = greet("World"); + println!("{}", message); +} +`, + }, +]; + +// C++ single file template +export const CPP_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.cpp', + code: `// C++ Hello World +#include +#include + +std::string greet(const std::string& name) { + return "Hello, " + name + "!"; +} + +int main() { + std::string message = greet("World"); + std::cout << message << std::endl; + return 0; +} +`, + }, +]; + +// C single file template +export const C_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.c', + code: `// C Hello World +#include +#include + +void greet(const char* name) { + printf("Hello, %s!\\n", name); +} + +int main() { + greet("World"); + return 0; +} +`, + }, +]; + +// Java single file template +export const JAVA_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/Solution.java', + code: `// Java Hello World +public class Solution { + public static String greet(String name) { + return "Hello, " + name + "!"; + } + + public static void main(String[] args) { + String message = greet("World"); + System.out.println(message); + } +} +`, + }, +]; + +// Go single file template +export const GO_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.go', + code: `// Go Hello World +package main + +import "fmt" + +func greet(name string) string { + return fmt.Sprintf("Hello, %s!", name) +} + +func main() { + message := greet("World") + fmt.Println(message) +} +`, + }, +]; + +// PHP single file template +export const PHP_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.php', + code: ` +`, + }, +]; + +// Ruby single file template +export const RUBY_SINGLE_FILE_TEMPLATE: WorkspaceTemplate = [ + { + path: '/main.rb', + code: `# Ruby Hello World +def greet(name) + "Hello, #{name}!" +end + +message = greet("World") +puts message +`, + }, +]; diff --git a/apps/web/src/components/room/editor/lib/utils.ts b/apps/web/src/components/room/editor/lib/utils.ts new file mode 100644 index 0000000..61df057 --- /dev/null +++ b/apps/web/src/components/room/editor/lib/utils.ts @@ -0,0 +1,41 @@ +import { RoomSchema } from '@coderscreen/api/schema/room'; + +/** + * Check if a language is a multi-file language / web framework + * @param language + * @returns + */ +export const isLanguageMultiFile = (language: RoomSchema['language']) => { + const singleFileLanguages = [ + 'javascript', + 'typescript', + 'python', + 'rust', + 'c++', + 'c', + 'java', + 'go', + 'php', + 'ruby', + 'bash', + ]; + return !singleFileLanguages.includes(language); +}; + +/** + * Check if a language should use SandpackOutput (multi-file/web frameworks) + * @param language + * @returns + */ +export const shouldUseSandpackOutput = (language: RoomSchema['language']) => { + return isLanguageMultiFile(language); +}; + +/** + * Check if a language should use SingleFileOutput (single-file languages) + * @param language + * @returns + */ +export const shouldUseSingleFileOutput = (language: RoomSchema['language']) => { + return !isLanguageMultiFile(language); +}; diff --git a/apps/web/src/components/room/editor/multi-file/AddItemInput.tsx b/apps/web/src/components/room/editor/multi-file/AddItemInput.tsx new file mode 100644 index 0000000..5579bfc --- /dev/null +++ b/apps/web/src/components/room/editor/multi-file/AddItemInput.tsx @@ -0,0 +1,134 @@ +import { RiFileLine, RiFolderLine } from '@remixicon/react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { LanguageIcon } from '@/components/common/LanguageIcon'; +import { cn } from '@/lib/utils'; +import { type FileType, getFileTypeFromPath } from '@/query/realtime/editor.query'; + +interface AddFileInputProps { + isFolder: boolean; + onConfirm: (fileName: string) => void; + onCancel: () => void; + checkIfPathExists: (fileName: string, type: 'file' | 'folder') => boolean; + parentPath?: string; + placeholder?: string; + className?: string; +} + +const FileIcon = ({ fileType }: { fileType: FileType | null }) => { + if (fileType) { + return ; + } + + return ; +}; + +export const AddItemInput = ({ + isFolder, + onConfirm, + onCancel, + checkIfPathExists, + parentPath, + placeholder = 'Enter file name...', + className, +}: AddFileInputProps) => { + const [fileName, setFileName] = useState(''); + const [disabled, setDisabled] = useState(false); + + const inputRef = useRef(null); + + // Check if path exists and update disabled state + useEffect(() => { + if (fileName.trim()) { + // if parent path is '', just add prefix of / + const fullPath = `${parentPath}/${fileName.trim()}`; + const pathExists = checkIfPathExists(fullPath, isFolder ? 'folder' : 'file'); + + setDisabled(pathExists); + } else { + setDisabled(false); + } + }, [fileName, parentPath, isFolder, checkIfPathExists]); + + // Ensure focus when component mounts + useEffect(() => { + if (inputRef.current) { + // add a small delay to ensure the input is mounted + const timer = setTimeout(() => { + inputRef.current?.focus(); + }, 20); + + return () => clearTimeout(timer); + } + }, []); + + const handleConfirm = useCallback(() => { + if (disabled) { + return; + } + + if (fileName.trim() && !disabled) { + onConfirm(fileName.trim()); + } + }, [fileName, onConfirm, disabled]); + + const handleCancel = useCallback(() => { + setFileName(''); + onCancel(); + }, [onCancel]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleConfirm(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }, + [handleConfirm, handleCancel] + ); + + const handleBlur = useCallback(() => { + if (!fileName.trim()) { + // If no text, close without submitting + handleCancel(); + } else { + // If text was entered, try to submit + handleConfirm(); + } + }, [fileName, handleCancel, handleConfirm]); + + const language = useMemo(() => { + return getFileTypeFromPath(fileName); + }, [fileName]); + + return ( +
+ {isFolder ? ( + + ) : ( + + )} + + setFileName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + placeholder={placeholder} + className='flex-1 border-none text-xs outline-none ' + /> +
+ ); +}; diff --git a/apps/web/src/components/room/editor/multi-file/DeleteFileDialog.tsx b/apps/web/src/components/room/editor/multi-file/DeleteFileDialog.tsx new file mode 100644 index 0000000..207eb73 --- /dev/null +++ b/apps/web/src/components/room/editor/multi-file/DeleteFileDialog.tsx @@ -0,0 +1,44 @@ +import { RiCloseLine, RiDeleteBinLine } from '@remixicon/react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { FsNode } from '@/query/realtime/editor.query'; + +interface DeleteFileDialogProps { + file: FsNode | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onDelete: () => void; +} + +export const DeleteFileDialog = ({ file, open, onOpenChange, onDelete }: DeleteFileDialogProps) => { + return ( + + + + Delete {file?.type === 'folder' ? 'Folder' : 'File'} + + + Are you sure you want to delete "{file?.name}"? This action is irreversible. + {file?.type === 'folder' && ' All files and subfolders will be deleted.'} + + + + + + + + + + ); +}; diff --git a/apps/web/src/components/room/editor/multi-file/RenameItemInput.tsx b/apps/web/src/components/room/editor/multi-file/RenameItemInput.tsx new file mode 100644 index 0000000..19a94bc --- /dev/null +++ b/apps/web/src/components/room/editor/multi-file/RenameItemInput.tsx @@ -0,0 +1,136 @@ +import { RiFileLine, RiFolderLine } from '@remixicon/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { LanguageIcon } from '@/components/common/LanguageIcon'; +import { cn } from '@/lib/utils'; +import { type FileType, getFileTypeFromPath } from '@/query/realtime/editor.query'; + +interface RenameItemInputProps { + isFolder: boolean; + currentName: string; + onConfirm: (newName: string) => void; + onCancel: () => void; + checkIfPathExists: (fileName: string, type: 'file' | 'folder') => boolean; + parentPath?: string; + className?: string; +} + +const FileIcon = ({ fileType }: { fileType: FileType | null }) => { + if (fileType) { + return ; + } + + return ; +}; + +export const RenameItemInput = ({ + isFolder, + currentName, + onConfirm, + onCancel, + checkIfPathExists, + parentPath, + className, +}: RenameItemInputProps) => { + const [newName, setNewName] = useState(currentName); + const [disabled, setDisabled] = useState(false); + + const inputRef = useRef(null); + + // Check if path exists and update disabled state + useEffect(() => { + if (newName.trim() && newName.trim() !== currentName) { + const fullPath = `${parentPath}/${newName.trim()}`; + const pathExists = checkIfPathExists(fullPath, isFolder ? 'folder' : 'file'); + + setDisabled(pathExists); + } else { + setDisabled(false); + } + }, [newName, parentPath, isFolder, checkIfPathExists, currentName]); + + // Ensure focus when component mounts and select the name without extension + useEffect(() => { + if (inputRef.current) { + const timer = setTimeout(() => { + inputRef.current?.focus(); + // Select the name without extension for easier editing + const nameWithoutExt = currentName.split('.').slice(0, -1).join('.'); + if (nameWithoutExt && !isFolder) { + inputRef.current?.setSelectionRange(0, nameWithoutExt.length); + } else { + inputRef.current?.select(); + } + }, 20); + + return () => clearTimeout(timer); + } + }, [currentName, isFolder]); + + const handleConfirm = useCallback(() => { + if (disabled || newName.trim() === currentName) { + return; + } + + if (newName.trim() && !disabled) { + onConfirm(newName.trim()); + } + }, [newName, onConfirm, disabled, currentName]); + + const handleCancel = useCallback(() => { + setNewName(currentName); + onCancel(); + }, [onCancel, currentName]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleConfirm(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }, + [handleConfirm, handleCancel] + ); + + const handleBlur = useCallback(() => { + if (newName.trim() === currentName) { + // If no change, close without submitting + handleCancel(); + } else { + // If text was changed, try to submit + handleConfirm(); + } + }, [newName, handleCancel, handleConfirm, currentName]); + + const language = getFileTypeFromPath(newName); + + return ( +
+ {isFolder ? ( + + ) : ( + + )} + + setNewName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className='flex-1 border-none text-xs outline-none' + /> +
+ ); +}; diff --git a/apps/web/src/components/room/guest/GuestRoomView.tsx b/apps/web/src/components/room/guest/GuestRoomView.tsx index a366838..a6e0284 100644 --- a/apps/web/src/components/room/guest/GuestRoomView.tsx +++ b/apps/web/src/components/room/guest/GuestRoomView.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'; import { GuestRoomHeader } from '@/components/room/guest/GuestRoomHeader'; import { RoomFooter } from '@/components/room/RoomFooter'; import { RoomProvider, useRoomContext } from '@/contexts/RoomContext'; -import { Guest, getGuest, setGuest } from '@/lib/guest'; +import { SandpackProvider } from '@/contexts/SandpackContext'; +import { type Guest, getGuest, setGuest } from '@/lib/guest'; import { usePublicRoom } from '@/query/publicRoom.query'; import { getRandomColor } from '@/query/realtime/utils'; import { @@ -30,7 +31,12 @@ export const GuestRoomView = () => { }, [guestInfo]); const handleJoinAsGuest = (name: string, email: string) => { - const newGuest = { id: crypto.randomUUID(), name, color: getRandomColor(name), email }; + const newGuest = { + id: crypto.randomUUID(), + name, + color: getRandomColor(name), + email, + }; setGuest(newGuest); setGuestInfo(newGuest); }; @@ -59,7 +65,9 @@ export const GuestRoomView = () => { // If guest info exists and room is active, show the room content return ( - + + + ); }; @@ -113,11 +121,13 @@ const GuestRoomContent = () => { }); // Add other panels as tabs in a second panel + api.addPanel({ - id: DOCKVIEW_PANEL_IDS.INSTRUCTIONS, - component: 'instructions', - title: 'Instructions', + id: DOCKVIEW_PANEL_IDS.CODE_OUTPUT, + component: 'code-output', + title: 'Code Output', tabComponent: 'tab', + renderer: 'always', position: { direction: 'right', referencePanel: 'code-editor', @@ -125,10 +135,11 @@ const GuestRoomContent = () => { }); api.addPanel({ - id: DOCKVIEW_PANEL_IDS.PROGRAM_OUTPUT, - component: 'program-output', - title: 'Program Output', + id: DOCKVIEW_PANEL_IDS.INSTRUCTIONS, + component: 'instructions', + title: 'Instructions', tabComponent: 'tab', + inactive: true, }); api.addPanel({ @@ -136,6 +147,7 @@ const GuestRoomContent = () => { component: 'whiteboard', title: 'Whiteboard', tabComponent: 'tab', + inactive: true, }); api.addPanel({ @@ -143,6 +155,7 @@ const GuestRoomContent = () => { component: 'ai-chat', title: 'AI Chat', tabComponent: 'tab', + inactive: true, }); }} /> diff --git a/apps/web/src/components/room/host/HostRoomHeader.tsx b/apps/web/src/components/room/host/HostRoomHeader.tsx index 3a6f8b3..c30a214 100644 --- a/apps/web/src/components/room/host/HostRoomHeader.tsx +++ b/apps/web/src/components/room/host/HostRoomHeader.tsx @@ -91,9 +91,14 @@ export const HostRoomHeader = () => {
{/* Room Title Section */}
- + - +
+
+
+ )} + +
+ + + +
+ + + +