From 00f2df6c08137d3566b8c17492f7bc4ca33ae00d Mon Sep 17 00:00:00 2001 From: NarwhalChen <125920907+NarwhalChen@users.noreply.github.com> Date: Tue, 4 Feb 2025 20:59:27 -0600 Subject: [PATCH] feat(frontend): Feat frontend code review (#112) adding code review with monaco editior and file structure tree https://jam.dev/c/3df072a0-b9db-497a-b251-b167636332b8 ## Summary by CodeRabbit - **New Features** - Launched an interactive chat interface and a modern code editor integrated with a file explorer and hierarchical file viewer. - Introduced new endpoints that support seamless file content updates and project file retrieval. - Added new components for managing project-related state and file structure visualization. - Implemented a new tabbed interface for better organization of content. - Introduced a sidebar interface for managing chat functionalities. - **UI/UX Enhancements** - Redesigned the sidebar and tabs for improved responsiveness and easier interaction. - Refined spacing in key forms for a cleaner and more accessible layout. - Enhanced the layout and functionality of the chat sidebar. - **Chores** - Updated configuration settings with new module aliases and added essential dependencies to enhance overall functionality. --------- Co-authored-by: Nahuel Chen Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Sma1lboy <541898146chen@gmail.com> --- codefox-common/src/common-path.ts | 3 +- frontend/components.json | 5 +- frontend/package.json | 4 + frontend/src/app/(main)/Home.tsx | 147 ++++++++ frontend/src/app/(main)/MainLayout.tsx | 90 ++--- frontend/src/app/(main)/layout.tsx | 8 +- frontend/src/app/(main)/page.tsx | 110 +----- frontend/src/app/api/file/route.ts | 90 +++++ frontend/src/app/api/project/route.ts | 113 ++++++ frontend/src/components/chat/chat.tsx | 2 +- .../components/code-engine/code-engine.tsx | 334 ++++++++++++++++++ .../code-engine/file-explorer-button.tsx | 56 +++ .../components/code-engine/file-structure.tsx | 91 +++++ .../components/code-engine/project-context.ts | 15 + frontend/src/components/detail-settings.tsx | 2 +- .../src/components/edit-username-form.tsx | 2 +- frontend/src/components/fileSidebar.tsx | 96 +++++ frontend/src/components/sidebar.tsx | 57 +-- frontend/src/components/ui/tabs.tsx | 55 +++ frontend/src/utils/file-reader.ts | 98 +++++ pnpm-lock.yaml | 77 ++++ 21 files changed, 1263 insertions(+), 192 deletions(-) create mode 100644 frontend/src/app/(main)/Home.tsx create mode 100644 frontend/src/app/api/file/route.ts create mode 100644 frontend/src/app/api/project/route.ts create mode 100644 frontend/src/components/code-engine/code-engine.tsx create mode 100644 frontend/src/components/code-engine/file-explorer-button.tsx create mode 100644 frontend/src/components/code-engine/file-structure.tsx create mode 100644 frontend/src/components/code-engine/project-context.ts create mode 100644 frontend/src/components/fileSidebar.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/utils/file-reader.ts diff --git a/codefox-common/src/common-path.ts b/codefox-common/src/common-path.ts index 23435fd8..b81aeaa6 100644 --- a/codefox-common/src/common-path.ts +++ b/codefox-common/src/common-path.ts @@ -1,13 +1,14 @@ import * as path from 'path'; import fsExtra from 'fs-extra'; +import { cwd } from 'process'; const { existsSync, mkdirSync, promises, writeFileSync } = fsExtra; // Constants for base directories const APP_NAME = 'codefox'; // TODO: hack way to get the root directory of the workspace -const WORKSPACE_ROOT = path.resolve(path.join(__dirname, '..', '..', '..')); +const WORKSPACE_ROOT = path.resolve(cwd(), '..'); const ROOT_DIR = path.join(WORKSPACE_ROOT, `.${APP_NAME}`); diff --git a/frontend/components.json b/frontend/components.json index b6f843f2..7d57192b 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -12,6 +12,9 @@ }, "aliases": { "components": "@/components", - "utils": "@/lib/utils" + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" } } diff --git a/frontend/package.json b/frontend/package.json index d594a50a..dd2a8ec6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@hookform/resolvers": "^3.9.0", "@langchain/community": "^0.3.1", "@langchain/core": "^0.3.3", + "@monaco-editor/react": "^4.6.0", "@nestjs/common": "^10.4.6", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.4", @@ -32,10 +33,12 @@ "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.1", "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.6", "@types/dom-speech-recognition": "^0.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codefox-common": "workspace:*", "emoji-mart": "^5.6.0", "framer-motion": "^11.5.6", "graphql": "^16.9.0", @@ -46,6 +49,7 @@ "react": "^18.3.1", "react-activity-calendar": "^2.7.8", "react-code-blocks": "^0.1.6", + "react-complex-tree": "^2.4.6", "react-dom": "^18.3.1", "react-dropzone": "^14.2.9", "react-hook-form": "^7.53.0", diff --git a/frontend/src/app/(main)/Home.tsx b/frontend/src/app/(main)/Home.tsx new file mode 100644 index 00000000..9b2d079e --- /dev/null +++ b/frontend/src/app/(main)/Home.tsx @@ -0,0 +1,147 @@ +// app/page.tsx or components/Home.tsx +'use client'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from '@/components/ui/resizable'; +import { CodeEngine } from '@/components/code-engine/code-engine'; +import { GET_CHAT_HISTORY } from '@/graphql/request'; +import { useQuery } from '@apollo/client'; +import { toast } from 'sonner'; +import { EventEnum } from '@/components/enum'; +import { useModels } from '../hooks/useModels'; +import { useChatList } from '../hooks/useChatList'; +import { useChatStream } from '../hooks/useChatStream'; +import EditUsernameForm from '@/components/edit-username-form'; +import ChatContent from '@/components/chat/chat'; +import { ProjectContext } from '@/components/code-engine/project-context'; + +export default function Home() { + // Initialize state, refs, and custom hooks + const urlParams = new URLSearchParams(window.location.search); + const [chatId, setChatId] = useState(''); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const formRef = useRef(null); + + const { models } = useModels(); + const [selectedModel, setSelectedModel] = useState(models[0] || 'gpt-4o'); + + const { refetchChats } = useChatList(); + + //TODO: adding project id from .codefox/projects + const [projectId, setProjectId] = useState( + '2025-02-02-dfca4698-6e9b-4aab-9fcb-98e9526e5f21' + ); + const [filePath, setFilePath] = useState('frontend/vite.config.ts'); + + // Apollo query to fetch chat history + useQuery(GET_CHAT_HISTORY, { + variables: { chatId }, + onCompleted: (data) => { + if (data?.getChatHistory) { + setMessages(data.getChatHistory); + } + }, + onError: () => { + toast.error('Failed to load chat history'); + }, + }); + + // Custom hook for handling chat streaming + const { loadingSubmit, handleSubmit, handleInputChange, stop } = + useChatStream({ + chatId, + input, + setInput, + setMessages, + selectedModel, + }); + + // Callback to clear the chat ID + const cleanChatId = () => setChatId(''); + + // Callback to update chat ID based on URL parameters and refresh the chat list + const updateChatId = useCallback(() => { + const params = new URLSearchParams(window.location.search); + setChatId(params.get('id') || ''); + refetchChats(); + }, [refetchChats]); + + // Callback to switch to the settings view + const updateSetting = () => setChatId(EventEnum.SETTING); + + // Effect to initialize chat ID and refresh the chat list based on URL parameters + useEffect(() => { + setChatId(urlParams.get('id') || ''); + refetchChats(); + }, [urlParams, refetchChats]); + + // Effect to add and remove global event listeners + useEffect(() => { + window.addEventListener(EventEnum.CHAT, updateChatId); + window.addEventListener(EventEnum.NEW_CHAT, cleanChatId); + window.addEventListener(EventEnum.SETTING, updateSetting); + return () => { + window.removeEventListener(EventEnum.CHAT, updateChatId); + window.removeEventListener(EventEnum.NEW_CHAT, cleanChatId); + window.removeEventListener(EventEnum.SETTING, updateSetting); + }; + }, [updateChatId]); + + // Render the settings view if chatId indicates settings mode + if (chatId === EventEnum.SETTING) { + return ( +
+ +
+ ); + } + + // Render the main layout + return ( + + + + + + + + {projectId ? ( + + + + + + ) : ( +

Forgot to input project id

+ )} +
+ ); +} diff --git a/frontend/src/app/(main)/MainLayout.tsx b/frontend/src/app/(main)/MainLayout.tsx index 4bdac043..36ded8e1 100644 --- a/frontend/src/app/(main)/MainLayout.tsx +++ b/frontend/src/app/(main)/MainLayout.tsx @@ -1,12 +1,15 @@ +// components/MainLayout.tsx 'use client'; - import React, { useEffect, useState } from 'react'; import { cn } from '@/lib/utils'; -import { usePathname } from 'next/navigation'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/components/ui/resizable'; +import { SidebarProvider } from '@/components/ui/sidebar'; +import { ChatSideBar } from '@/components/sidebar'; import { useChatList } from '../hooks/useChatList'; -import { ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'; -import CustomSidebar from '@/components/sidebar'; -import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar'; export default function MainLayout({ children, @@ -15,8 +18,8 @@ export default function MainLayout({ }) { const [isCollapsed, setIsCollapsed] = useState(false); const [isMobile, setIsMobile] = useState(false); - const defaultLayout = [30, 160]; - const navCollapsedSize = 10; + const defaultLayout = [25, 75]; // [sidebar, main] + const navCollapsedSize = 5; const { chats, loading, @@ -36,63 +39,42 @@ export default function MainLayout({ }; checkScreenWidth(); window.addEventListener('resize', checkScreenWidth); - return () => { - window.removeEventListener('resize', checkScreenWidth); - }; + return () => window.removeEventListener('resize', checkScreenWidth); }, []); - console.log(`${isCollapsed}, ${isMobile}`); - return (
{ - document.cookie = `react-resizable-panels:layout=${JSON.stringify( - sizes - )}; path=/; max-age=604800`; + const sidebarSize = sizes[0]; + const isNowCollapsed = sidebarSize < 10; + setIsCollapsed(isNowCollapsed); + + if (isNowCollapsed && sizes.length > 1) { + const newSizes = [navCollapsedSize, 100 - navCollapsedSize]; + document.cookie = `react-resizable-panels:layout=${JSON.stringify(newSizes)}; path=/; max-age=604800`; + return newSizes; + } + + document.cookie = `react-resizable-panels:layout=${JSON.stringify(sizes)}; path=/; max-age=604800`; + return sizes; }} - className="h-screen items-stretch" + className="h-screen items-stretch w-full" > - { - console.log(`setting collapse to T`); - // setIsCollapsed(true); - }} - onExpand={() => { - console.log(`setting collapse to F`); - // setIsCollapsed(false); - }} - className={cn( - 'transition-all duration-300 ease-in-out', - isCollapsed ? 'min-w-[50px] md:min-w-[70px]' : 'md:min-w-[200px]' - )} - > - {loading ? ( -
Loading...
- ) : error ? ( -
- Error: {error.message} -
- ) : ( - - )} -
+ ) { +}) { return {children}; } diff --git a/frontend/src/app/(main)/page.tsx b/frontend/src/app/(main)/page.tsx index 09708d9d..1ca9146d 100644 --- a/frontend/src/app/(main)/page.tsx +++ b/frontend/src/app/(main)/page.tsx @@ -1,109 +1,5 @@ -'use client'; +import Home from './Home'; -import { - SetStateAction, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import { Message } from '@/components/types'; -import { useModels } from '../hooks/useModels'; -import ChatContent from '@/components/chat/chat'; -import { useChatStream } from '../hooks/useChatStream'; -import { GET_CHAT_HISTORY } from '@/graphql/request'; -import { useQuery } from '@apollo/client'; -import { toast } from 'sonner'; -import { useChatList } from '../hooks/useChatList'; -import { EventEnum } from '@/components/enum'; -import DetailSettings from '@/components/detail-settings'; -import EditUsernameForm from '@/components/edit-username-form'; - -export default function Home() { - let urlParams = new URLSearchParams(window.location.search); - const [chatId, setChatId] = useState(''); - // Core message states - const [messages, setMessages] = useState([]); - const [input, setInput] = useState(''); - const formRef = useRef(null); - - const { models } = useModels(); - const [selectedModel, setSelectedModel] = useState( - models[0] || 'gpt-4o' - ); - - const { refetchChats } = useChatList(); - - useEffect(() => { - setChatId(urlParams.get('id') || ''); - refetchChats(); - console.log(`update ${urlParams.get('id')}`); - }, [urlParams]); - - useQuery(GET_CHAT_HISTORY, { - variables: { chatId }, - onCompleted: (data) => { - if (data?.getChatHistory) { - setMessages(data.getChatHistory); - } - }, - onError: (error) => { - toast.error('Failed to load chat history'); - }, - }); - - const cleanChatId = () => { - setChatId(''); - }; - const updateChatId = useCallback(() => { - urlParams = new URLSearchParams(window.location.search); - setChatId(urlParams.get('id') || ''); - refetchChats(); - }, []); - const updateSetting = () => { - setChatId(EventEnum.SETTING); - }; - - useEffect(() => { - window.addEventListener(EventEnum.CHAT, updateChatId); - window.addEventListener(EventEnum.NEW_CHAT, cleanChatId); - window.addEventListener(EventEnum.SETTING, updateSetting); - - return () => { - window.removeEventListener(EventEnum.CHAT, updateChatId); - window.removeEventListener(EventEnum.NEW_CHAT, cleanChatId); - window.removeEventListener(EventEnum.SETTING, updateSetting); - }; - }, [updateChatId]); - - const { loadingSubmit, handleSubmit, handleInputChange, stop } = - useChatStream({ - chatId, - input, - setInput, - setMessages, - selectedModel, - }); - - return ( - <> - {chatId === EventEnum.SETTING.toString() ? ( - - ) : ( - - )} - - ); +export default function Page() { + return ; } diff --git a/frontend/src/app/api/file/route.ts b/frontend/src/app/api/file/route.ts new file mode 100644 index 00000000..7205aaa2 --- /dev/null +++ b/frontend/src/app/api/file/route.ts @@ -0,0 +1,90 @@ +// app/api/file/route.ts +import { NextResponse } from 'next/server'; +import { FileReader } from '@/utils/file-reader'; +import { promises as fs } from 'fs'; +import path from 'path'; + +export async function POST(req: Request) { + console.log('🚀 [API] Received POST request to update file'); + + try { + const { filePath, newContent } = await req.json(); + + if (!filePath || !newContent) { + console.error('[API] Missing required parameters'); + return NextResponse.json( + { error: "Missing 'filePath' or 'newContent'" }, + { status: 400 } + ); + } + const reader = FileReader.getInstance(); + reader.updateFile(filePath, newContent); + + console.log('[API] File updated successfully'); + return NextResponse.json({ + message: 'File updated successfully', + filePath, + }); + } catch (error) { + console.error('[API] Error updating file:', error); + return NextResponse.json( + { error: 'Failed to update file' }, + { status: 500 } + ); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const filePath = searchParams.get('path'); + + if (!filePath) { + return NextResponse.json( + { error: "Missing 'path' parameter" }, + { status: 400 } + ); + } + const reader = FileReader.getInstance(); + const content = await reader.readFileContent(filePath); + const fileType = getFileType(filePath); + const res = NextResponse.json({ filePath, content, type: fileType }); + return res; + } catch (error) { + return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }); + } +} + +function getFileType(filePath: string): string { + const extension = filePath.split('.').pop()?.toLowerCase() || ''; + + const typeMap: { [key: string]: string } = { + txt: 'text', + md: 'markdown', + json: 'json', + js: 'javascript', + ts: 'typescript', + html: 'html', + css: 'css', + scss: 'scss', + xml: 'xml', + csv: 'csv', + yml: 'yaml', + yaml: 'yaml', + jpg: 'image', + jpeg: 'image', + png: 'image', + gif: 'image', + svg: 'vector', + webp: 'image', + mp4: 'video', + mp3: 'audio', + wav: 'audio', + pdf: 'pdf', + zip: 'archive', + tar: 'archive', + gz: 'archive', + }; + + return typeMap[extension] || 'unknown'; +} diff --git a/frontend/src/app/api/project/route.ts b/frontend/src/app/api/project/route.ts new file mode 100644 index 00000000..07a4ad27 --- /dev/null +++ b/frontend/src/app/api/project/route.ts @@ -0,0 +1,113 @@ +// app/api/project/route.ts +import { NextResponse } from 'next/server'; +import { FileReader } from '@/utils/file-reader'; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const projectId = searchParams.get('id'); + + if (!projectId) { + return NextResponse.json({ error: 'Missing projectId' }, { status: 400 }); + } + + try { + const res = await fetchFileStructure(projectId); + return NextResponse.json({ res }); + } catch (error) { + return NextResponse.json( + { error: 'Failed to read project files' }, + { status: 500 } + ); + } +} + +async function fetchFileStructure(projectId) { + const reader = FileReader.getInstance(); + const res = await reader.getAllPaths(projectId); + + if (!res || res.length === 0) { + return { + root: { + index: 'root', + isFolder: true, + children: [], + data: 'Root', + canMove: true, + canRename: true, + }, + }; + } + + const projectPrefix = res[0].split('/')[0] + '/'; + const cleanedPaths = res.map((path) => path.replace(projectPrefix, '')); + + const fileRegex = /\.[a-z0-9]+$/i; + + function buildTree(paths) { + const tree = {}; + + paths.forEach((path) => { + const parts = path.split('/'); + let node = tree; + + parts.forEach((part, index) => { + const isFile = fileRegex.test(part); + + if (!node[part]) { + node[part] = { + __isFolder: !isFile, + children: !isFile ? {} : undefined, + }; + } + + if (!isFile) { + node = node[part].children; + } + }); + }); + + return tree; + } + + function convertTreeToComplexTree(tree, parentId = 'root') { + const items = {}; + + Object.keys(tree).forEach((name, index) => { + const id = `${parentId}/${name}`; + const isFolder = tree[name].__isFolder; + + items[id] = { + index: id, + canMove: true, + isFolder, + children: isFolder + ? Object.keys(tree[name].children).map((child) => `${id}/${child}`) + : [], + data: name, + canRename: true, + }; + + if (isFolder) { + Object.assign(items, convertTreeToComplexTree(tree[name].children, id)); + } + }); + + return items; + } + + const tree = buildTree(cleanedPaths); + + const items = { + root: { + index: 'root', + isFolder: true, + canMove: true, + canRename: true, + children: Object.keys(tree).map((name) => `root/${name}`), + data: 'Root', + }, + ...convertTreeToComplexTree(tree, 'root'), + }; + + return items; +} diff --git a/frontend/src/components/chat/chat.tsx b/frontend/src/components/chat/chat.tsx index f7486870..ec703de7 100644 --- a/frontend/src/components/chat/chat.tsx +++ b/frontend/src/components/chat/chat.tsx @@ -52,7 +52,7 @@ export default function ChatContent({ // setEditContent(''); // }; return ( -
+
diff --git a/frontend/src/components/code-engine/code-engine.tsx b/frontend/src/components/code-engine/code-engine.tsx new file mode 100644 index 00000000..3cc62e3a --- /dev/null +++ b/frontend/src/components/code-engine/code-engine.tsx @@ -0,0 +1,334 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import Editor from '@monaco-editor/react'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { motion } from 'framer-motion'; +import { + Code as CodeIcon, + Copy, + Eye, + GitFork, + Share2, + Terminal, +} from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useContext, useEffect, useRef, useState } from 'react'; +import { TreeItem, TreeItemIndex } from 'react-complex-tree'; +import FileExplorerButton from './file-explorer-button'; +import FileStructure from './file-structure'; +import { ProjectContext } from './project-context'; + +export function CodeEngine() { + // Initialize state, refs, and context + const editorRef = useRef(null); + const { projectId, filePath } = useContext(ProjectContext); + const [preCode, setPrecode] = useState('// some comment'); + const [newCode, setCode] = useState('// some comment'); + const [saving, setSaving] = useState(false); + const [type, setType] = useState('javascript'); + const [isLoading, setIsLoading] = useState(false); + const [isExplorerCollapsed, setIsExplorerCollapsed] = useState(false); + const [fileStructureData, setFileStructureData] = useState< + Record> + >({}); + const theme = useTheme(); + const [activeTab, setActiveTab] = useState<'preview' | 'code' | 'console'>( + 'code' + ); + + // Callback: Handle editor mount + const handleEditorMount = (editorInstance) => { + editorRef.current = editorInstance; + // Set the editor DOM node's position for layout control + editorInstance.getDomNode().style.position = 'absolute'; + }; + + // Effect: Fetch file content when filePath or projectId changes + useEffect(() => { + async function getCode() { + try { + setIsLoading(true); + const res = await fetch( + `/api/file?path=${encodeURIComponent(`${projectId}/${filePath}`)}` + ).then((res) => res.json()); + setCode(res.content); + setPrecode(res.content); + setType(res.type); + setIsLoading(false); + } catch (error: any) { + console.error(error.message); + } + } + getCode(); + }, [filePath, projectId]); + + // Effect: Fetch file structure when projectId changes + useEffect(() => { + async function fetchFiles() { + try { + const response = await fetch(`/api/project?id=${projectId}`); + const data = await response.json(); + setFileStructureData(data.res || {}); + } catch (error) { + console.error('Error fetching file structure:', error); + } + } + fetchFiles(); + }, [projectId]); + + // Reset code to previous state and update editor + const handleReset = () => { + setCode(preCode); + editorRef.current?.setValue(preCode); + setSaving(false); + }; + + // Update file content on the server + const updateCode = async (value) => { + try { + const response = await fetch('/api/file', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filePath: `${projectId}/${filePath}`, + newContent: JSON.stringify(value), + }), + }); + await response.json(); + } catch (error) { + console.error(error); + } + }; + + // Save the new code and update the previous state + const handleSave = () => { + setSaving(false); + setPrecode(newCode); + updateCode(newCode); + }; + + // Update code in state and mark as saving + const updateSavingStatus = (value) => { + setCode(value); + setSaving(true); + }; + + // Responsive toolbar component for header tabs and buttons + const ResponsiveToolbar = () => { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(700); + const [visibleTabs, setVisibleTabs] = useState(3); + const [compactIcons, setCompactIcons] = useState(false); + + // Observe container width changes + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + return () => observer.disconnect(); + }, []); + + // Adjust visible tabs and icon style based on container width + useEffect(() => { + if (containerWidth > 650) { + setVisibleTabs(3); + setCompactIcons(false); + } else if (containerWidth > 550) { + setVisibleTabs(2); + setCompactIcons(false); + } else if (containerWidth > 450) { + setVisibleTabs(1); + setCompactIcons(true); + } else { + setVisibleTabs(0); + setCompactIcons(true); + } + }, [containerWidth]); + + return ( +
+
+ + {visibleTabs >= 2 && ( + + )} + {visibleTabs >= 3 && ( + + )} +
+ +
+
+ + + +
+
+ {!compactIcons && ( + <> + + + + )} + {compactIcons && ( + + )} +
+
+
+ ); + }; + + // Render the CodeEngine layout + return ( +
+ {/* Header Bar */} + + + {/* Main Content Area */} +
+ {activeTab === 'code' ? ( + <> + {/* File Explorer Panel (collapsible) */} + + + +
+ +
+ + ) : activeTab === 'preview' ? ( +
Preview Content (Mock)
+ ) : activeTab === 'console' ? ( +
Console Content (Mock)
+ ) : null} +
+ + {/* Save Changes Bar */} + {saving && ( + + )} + + {/* File Explorer Toggle Button */} + {activeTab === 'code' && ( + + )} +
+ ); +} + +// SaveChangesBar component for showing unsaved changes status +const SaveChangesBar = ({ saving, onSave, onReset }) => { + return ( + saving && ( +
+ + Unsaved Changes + + +
+ ) + ); +}; diff --git a/frontend/src/components/code-engine/file-explorer-button.tsx b/frontend/src/components/code-engine/file-explorer-button.tsx new file mode 100644 index 00000000..7c6c6a4a --- /dev/null +++ b/frontend/src/components/code-engine/file-explorer-button.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; + +const FileExplorerButton = ({ + isExplorerCollapsed, + setIsExplorerCollapsed, +}: { + isExplorerCollapsed: boolean; + setIsExplorerCollapsed: (value: boolean) => void; +}) => { + return ( +
+ + + + + + +

{isExplorerCollapsed ? 'Open File Tree' : 'Close File Tree'}

+
+
+
+
+ ); +}; + +export default FileExplorerButton; diff --git a/frontend/src/components/code-engine/file-structure.tsx b/frontend/src/components/code-engine/file-structure.tsx new file mode 100644 index 00000000..776e52dc --- /dev/null +++ b/frontend/src/components/code-engine/file-structure.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useContext } from 'react'; +import { + StaticTreeDataProvider, + Tree, + TreeItem, + TreeItemIndex, + UncontrolledTreeEnvironment, +} from 'react-complex-tree'; +import 'react-complex-tree/lib/style-modern.css'; +import { ProjectContext } from './project-context'; + +export interface FileNodeType { + name: string; + type: 'file' | 'folder'; + children?: FileNodeType[]; +} +export default function FileStructure({ + filePath, + data, +}: { + filePath: string; + data: Record>; +}) { + const { setFilePath } = useContext(ProjectContext); + + const dataProvider = new StaticTreeDataProvider(data, (item, newName) => ({ + ...item, + data: newName, + })); + return ( +
+

File Explorer

+ {filePath &&
{filePath}
} + + item.data} + viewState={{}} + onSelectItems={(items) => { + setFilePath(items[0].toString().replace(/^root\//, '')); + }} + renderItem={({ item, depth, children, title, context, arrow }) => { + const InteractiveComponent = context.isRenaming ? 'div' : 'button'; + const type = context.isRenaming ? undefined : 'button'; + return ( +
  • +
    + {arrow} + + {title} + +
    + {children} +
  • + ); + }} + > + +
    +
    + ); +} diff --git a/frontend/src/components/code-engine/project-context.ts b/frontend/src/components/code-engine/project-context.ts new file mode 100644 index 00000000..5b8891d7 --- /dev/null +++ b/frontend/src/components/code-engine/project-context.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react'; + +export interface ProjectContextType { + projectId: string; + setProjectId: React.Dispatch>; + filePath: string | null; + setFilePath: React.Dispatch>; +} + +export const ProjectContext = createContext({ + projectId: '', + setProjectId: () => {}, + filePath: null, + setFilePath: () => {}, +}); diff --git a/frontend/src/components/detail-settings.tsx b/frontend/src/components/detail-settings.tsx index 3814aeda..6bd461d4 100644 --- a/frontend/src/components/detail-settings.tsx +++ b/frontend/src/components/detail-settings.tsx @@ -33,7 +33,7 @@ export default function DetailSettings() { Settings - + diff --git a/frontend/src/components/edit-username-form.tsx b/frontend/src/components/edit-username-form.tsx index 03b5cfc0..128a06dd 100644 --- a/frontend/src/components/edit-username-form.tsx +++ b/frontend/src/components/edit-username-form.tsx @@ -85,7 +85,7 @@ export default function EditUsernameForm() { setName(e.currentTarget.value); }; return ( -
    +

    User Settings

    diff --git a/frontend/src/components/fileSidebar.tsx b/frontend/src/components/fileSidebar.tsx new file mode 100644 index 00000000..fe3a1ada --- /dev/null +++ b/frontend/src/components/fileSidebar.tsx @@ -0,0 +1,96 @@ +'use client'; +import { Button } from '@/components/ui/button'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { SquarePen } from 'lucide-react'; +import SidebarSkeleton from './sidebar-skeleton'; +import UserSettings from './user-settings'; +import { SideBarItem } from './sidebar-item'; +import { Chat } from '@/graphql/type'; +import { EventEnum } from './enum'; +import { + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarTrigger, + Sidebar, + SidebarRail, + SidebarFooter, +} from './ui/sidebar'; +import { cn } from '@/lib/utils'; + +export default function FileSidebar({ isCollapsed, isMobile, loading }) { + const [isSimple, setIsSimple] = useState(false); + const [currentChatid, setCurrentChatid] = useState(''); + const handleNewChat = useCallback(() => { + window.history.replaceState({}, '', '/'); + setCurrentChatid(''); + const event = new Event(EventEnum.NEW_CHAT); + window.dispatchEvent(event); + }, []); + + if (loading) return ; + // if (error) { + // console.error('Error loading chats:', error); + // return null; + // } + console.log(`${isCollapsed}, ${isMobile}, ${isSimple}`); + + return ( +
    + + setIsSimple(!isSimple)} + > + + + + + + + + + + + + + +
    + ); +} diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx index 5a2c2fb0..76d23f22 100644 --- a/frontend/src/components/sidebar.tsx +++ b/frontend/src/components/sidebar.tsx @@ -1,8 +1,7 @@ 'use client'; import { Button } from '@/components/ui/button'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useState } from 'react'; import { SquarePen } from 'lucide-react'; import SidebarSkeleton from './sidebar-skeleton'; import UserSettings from './user-settings'; @@ -12,7 +11,6 @@ import { EventEnum } from './enum'; import { SidebarContent, SidebarGroup, - SidebarGroupLabel, SidebarGroupContent, SidebarTrigger, Sidebar, @@ -23,6 +21,7 @@ import { cn } from '@/lib/utils'; interface SidebarProps { isCollapsed: boolean; + setIsCollapsed: (value: boolean) => void; // Parent setter to update collapse state isMobile: boolean; currentChatId?: string; chatListUpdated: boolean; @@ -33,8 +32,9 @@ interface SidebarProps { onRefetch: () => void; } -function CustomSidebar({ +export function ChatSideBar({ isCollapsed, + setIsCollapsed, isMobile, chatListUpdated, setChatListUpdated, @@ -43,8 +43,10 @@ function CustomSidebar({ error, onRefetch, }: SidebarProps) { - const [isSimple, setIsSimple] = useState(false); + // Use a local state only for the currently selected chat. const [currentChatid, setCurrentChatid] = useState(''); + + // Handler for starting a new chat. const handleNewChat = useCallback(() => { window.history.replaceState({}, '', '/'); setCurrentChatid(''); @@ -57,7 +59,13 @@ function CustomSidebar({ console.error('Error loading chats:', error); return null; } - console.log(`${isCollapsed}, ${isMobile}, ${isSimple}`); + + console.log( + 'ChatSideBar state: isCollapsed:', + isCollapsed, + 'currentChatid:', + currentChatid + ); return (
    + {/* Toggle button: Clicking this will toggle the collapse state */} setIsSimple(!isSimple)} - > + className="lg:flex items-center justify-center cursor-pointer p-2 ml-3.5 mt-2" + onClick={() => setIsCollapsed(!isCollapsed)} + /> + {loading ? 'Loading...' - : !isSimple && + : !isCollapsed && chats.map((chat) => ( + - + - + setIsCollapsed(!isCollapsed)} + isSimple={false} + />
    ); } -export default memo(CustomSidebar, (prevProps, nextProps) => { +export default memo(ChatSideBar, (prevProps, nextProps) => { return ( prevProps.isCollapsed === nextProps.isCollapsed && prevProps.isMobile === nextProps.isMobile && diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx new file mode 100644 index 00000000..5ef355c3 --- /dev/null +++ b/frontend/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +'use client'; + +import * as React from 'react'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; + +import { cn } from '@/lib/utils'; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/frontend/src/utils/file-reader.ts b/frontend/src/utils/file-reader.ts new file mode 100644 index 00000000..bd9a0fc4 --- /dev/null +++ b/frontend/src/utils/file-reader.ts @@ -0,0 +1,98 @@ +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { getProjectsDir } from 'codefox-common'; + +export class FileReader { + private static instance: FileReader; + private basePath: string; + + private constructor() { + const baseDir = getProjectsDir(); + this.basePath = path.resolve(baseDir); + } + + public static getInstance(): FileReader { + if (!FileReader.instance) { + FileReader.instance = new FileReader(); + } + return FileReader.instance; + } + + public async getAllPaths(projectId: string): Promise { + const projectPath = path.resolve(this.basePath, projectId); + return this.readDirectory(projectPath); + } + + public async getAllShallowPaths(): Promise { + return this.readShallowDirectory(this.basePath); + } + + public async readFileContent(filePath: string): Promise { + const fullPath = path.join(this.basePath, filePath); + + try { + return await fs.readFile(fullPath, 'utf-8'); + } catch (err) { + console.error(`Error reading file: ${fullPath}`, err); + throw new Error(`Failed to read file: ${fullPath}`); + } + } + + private async readDirectory(dir: string): Promise { + let filePaths: string[] = []; + + try { + const items = await fs.readdir(dir, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(dir, item.name); + const relativePath = path.relative(this.basePath, fullPath); + + filePaths.push(relativePath); + + if (item.isDirectory()) { + filePaths = filePaths.concat(await this.readDirectory(fullPath)); + } + } + } catch (err) { + console.error(`Error reading directory: ${dir}`, err); + } + return filePaths; + } + + private async readShallowDirectory(dir: string): Promise { + const filePaths: string[] = []; + + try { + const items = await fs.readdir(dir, { withFileTypes: true }); + + for (const item of items) { + const fullPath = path.join(dir, item.name); + filePaths.push(path.relative(this.basePath, fullPath)); + } + } catch (err) { + console.error(`Error reading directory: ${dir}`, err); + } + return filePaths; + } + + public async updateFile(filePath: string, newContent: string): Promise { + if (filePath.includes('..')) { + console.error('[FileReader] Invalid file path detected:', filePath); + throw new Error('Invalid file path'); + } + + const fullPath = path.join(this.basePath, filePath); + console.log(`📝 [FileReader] Updating file: ${fullPath}`); + + try { + const content = JSON.parse(newContent); + await fs.writeFile(fullPath, content, 'utf-8'); + + console.log('[FileReader] File updated successfully'); + } catch (err) { + console.error(`[FileReader] Error updating file: ${fullPath}`, err); + throw new Error(`Failed to update file: ${fullPath}`); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49c65487..e1c2de92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -331,6 +331,9 @@ importers: '@langchain/core': specifier: ^0.3.3 version: 0.3.33(openai@4.80.0) + '@monaco-editor/react': + specifier: ^4.6.0 + version: 4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1)(react@18.3.1) '@nestjs/common': specifier: ^10.4.6 version: 10.4.15(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) @@ -364,6 +367,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.1 version: 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-tooltip': specifier: ^1.1.6 version: 1.1.7(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) @@ -376,6 +382,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + codefox-common: + specifier: workspace:* + version: link:../codefox-common emoji-mart: specifier: ^5.6.0 version: 5.6.0 @@ -406,6 +415,9 @@ importers: react-code-blocks: specifier: ^0.1.6 version: 0.1.6(react-dom@18.3.1)(react@18.3.1) + react-complex-tree: + specifier: ^2.4.6 + version: 2.4.6(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -6359,6 +6371,28 @@ packages: react: 18.3.1 dev: false + /@monaco-editor/loader@1.4.0(monaco-editor@0.52.2): + resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==} + peerDependencies: + monaco-editor: '>= 0.21.0 < 1' + dependencies: + monaco-editor: 0.52.2 + state-local: 1.0.7 + dev: false + + /@monaco-editor/react@4.6.0(monaco-editor@0.52.2)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@monaco-editor/loader': 1.4.0(monaco-editor@0.52.2) + monaco-editor: 0.52.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@nestjs/apollo@12.2.2(@apollo/server@4.11.3)(@nestjs/common@10.4.15)(@nestjs/core@10.4.15)(@nestjs/graphql@12.2.2)(graphql@16.10.0): resolution: {integrity: sha512-gsDqSfsmTSvF0k3XaRESRgM3uE/YFO+59txCsq7T1EadDOVOuoF3zVQiFmi6D50Rlnqohqs63qjjf46mgiiXgQ==} peerDependencies: @@ -7925,6 +7959,33 @@ packages: react: 18.3.1 dev: false + /@radix-ui/react-tabs@1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-context': 1.1.1(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.1(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.1(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.18)(react@18.3.1) + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-tooltip@1.1.7(@types/react-dom@18.3.5)(@types/react@18.3.18)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-ss0s80BC0+g0+Zc53MvilcnTYSOi4mSuFWBPYPuTOFGjx+pUU+ZrmamMNwS56t8MTFlniA5ocjd4jYm/CdhbOg==} peerDependencies: @@ -17160,6 +17221,10 @@ packages: hasBin: true dev: false + /monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + dev: false + /motion-dom@11.18.1: resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==} dependencies: @@ -19347,6 +19412,14 @@ packages: - react-dom dev: false + /react-complex-tree@2.4.6(react@18.3.1): + resolution: {integrity: sha512-Akt55R8sI2r66ngxwU7lSgh0YxoBsQHjaVnLzRVCL3Xe+7vbrYfkRpdCK9i/Awga2vYkkdAh9z8etnO3PF/lAg==} + peerDependencies: + react: '>=16.0.0' + dependencies: + react: 18.3.1 + dev: false + /react-dev-utils@12.0.1(eslint@8.57.1)(typescript@5.6.3)(webpack@5.97.1): resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} engines: {node: '>=14'} @@ -20807,6 +20880,10 @@ packages: escape-string-regexp: 2.0.0 dev: true + /state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + dev: false + /statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'}