diff --git a/.tmuxinator/dev.yml b/.tmuxinator/dev.yml index 18341824..482d32ed 100644 --- a/.tmuxinator/dev.yml +++ b/.tmuxinator/dev.yml @@ -26,3 +26,9 @@ windows: - llm: - echo "LLM Server (Ctrl+a 3 to focus, Ctrl+a r to restart)" - pnpm dev + - docker: + root: <%= ENV["PWD"] %> + panes: + - docker: + - echo "Docker Services (Ctrl+a 4 to focus)" + - docker compose up diff --git a/backend/database.db b/backend/database.db deleted file mode 100644 index 030f2056..00000000 Binary files a/backend/database.db and /dev/null differ diff --git a/backend/src/project/project.model.ts b/backend/src/project/project.model.ts index 83b585ff..cff683de 100644 --- a/backend/src/project/project.model.ts +++ b/backend/src/project/project.model.ts @@ -67,5 +67,5 @@ export class Project extends SystemBaseModel { lazy: true, // Load chats only when accessed onDelete: 'CASCADE', // Delete chats when user is deleted }) - chats: Chat[]; + chats: Promise; } diff --git a/backend/src/project/project.resolver.ts b/backend/src/project/project.resolver.ts index ab0a713a..a6b3a318 100644 --- a/backend/src/project/project.resolver.ts +++ b/backend/src/project/project.resolver.ts @@ -68,6 +68,6 @@ export class ProjectsResolver { @ResolveField('chats', () => [Chat]) async getChats(@Parent() project: Project): Promise { const { chats } = await this.projectsService.getProjectById(project.id); - return chats?.filter((chat) => !chat.isDeleted) || []; + return (await chats)?.filter((chat) => !chat.isDeleted) || []; } } diff --git a/backend/src/project/project.service.ts b/backend/src/project/project.service.ts index 2323537d..4fa75097 100644 --- a/backend/src/project/project.service.ts +++ b/backend/src/project/project.service.ts @@ -46,22 +46,26 @@ export class ProjectService { }); if (projects && projects.length > 0) { - projects.forEach((project) => { - // Filter deleted packages - project.projectPackages = project.projectPackages.filter( - (pkg) => !pkg.isDeleted, - ); - // Filter deleted chats - if (project.chats) { - project.chats = project.chats.filter((chat) => !chat.isDeleted); - } - }); + await Promise.all( + projects.map(async (project) => { + // Filter deleted packages + project.projectPackages = project.projectPackages.filter( + (pkg) => !pkg.isDeleted, + ); + // Filter deleted chats + if (project.chats) { + const chats = await project.chats; + this.logger.log('Project chats:', chats); + // Create a new Promise that resolves to filtered chats + project.chats = Promise.resolve( + chats.filter((chat) => !chat.isDeleted), + ); + } + }), + ); } - if (!projects || projects.length === 0) { - throw new NotFoundException(`User with ID ${userId} has no projects.`); - } - return projects; + return projects.length > 0 ? projects : []; } async getProjectById(projectId: string): Promise { @@ -70,18 +74,20 @@ export class ProjectService { relations: ['projectPackages', 'chats', 'user'], }); - if (project) { - project.projectPackages = project.projectPackages.filter( - (pkg) => !pkg.isDeleted, - ); - if (project.chats) { - project.chats = project.chats.filter((chat) => !chat.isDeleted); - } - } - if (!project) { throw new NotFoundException(`Project with ID ${projectId} not found.`); } + + project.projectPackages = project.projectPackages.filter( + (pkg) => !pkg.isDeleted, + ); + + if (project.chats) { + const chats = await project.chats; + this.logger.log('Project chats:', chats); + project.chats = Promise.resolve(chats.filter((chat) => !chat.isDeleted)); + } + return project; } @@ -95,13 +101,12 @@ export class ProjectService { } try { chat.project = project; - if (!project.chats) { - project.chats = []; - } - const chatArray = await project.chats; - chatArray.push(chat); - console.log(chat); - console.log(project); + + // Get current chats and add new chat + const currentChats = await project.chats; + project.chats = Promise.resolve([...currentChats, chat]); + + // Save both entities await this.projectsRepository.save(project); await this.chatRepository.save(chat); diff --git a/frontend/src/app/(main)/Home.tsx b/frontend/src/app/(main)/Home.tsx index 2c08a268..2897bf03 100644 --- a/frontend/src/app/(main)/Home.tsx +++ b/frontend/src/app/(main)/Home.tsx @@ -129,17 +129,20 @@ export default function Home() { setMessages={setMessages} /> - - - - + {chatId && ( + +
+ +
+
+ )} ); } diff --git a/frontend/src/app/api/runProject/route.ts b/frontend/src/app/api/runProject/route.ts index 8be2fb36..e2d62a76 100644 --- a/frontend/src/app/api/runProject/route.ts +++ b/frontend/src/app/api/runProject/route.ts @@ -43,21 +43,43 @@ function findAvailablePort( }); } +async function checkExistingContainer( + projectPath: string +): Promise { + return new Promise((resolve) => { + const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); + exec( + `docker ps --filter "label=traefik.http.routers.${subdomain}.rule" --format "{{.ID}}"`, + (error, stdout) => { + if (error || !stdout.trim()) { + resolve(null); + } else { + resolve(stdout.trim()); + } + } + ); + }); +} + async function buildAndRunDocker( projectPath: string ): Promise<{ domain: string; containerId: string }> { - console.log(runningContainers); - if (runningContainers.has(projectPath)) { - console.log(`Container for project ${projectPath} is already running.`); - return runningContainers.get(projectPath)!; - } const traefikDomain = process.env.TRAEFIK_DOMAIN || 'docker.localhost'; - const directory = path.join(getProjectPath(projectPath), 'frontend'); - const imageName = projectPath.toLowerCase(); - const containerId = crypto.randomUUID(); - const containerName = `container-${containerId}`; + const existingContainerId = await checkExistingContainer(projectPath); + if (existingContainerId) { + const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); + const domain = `${subdomain}.${traefikDomain}`; + runningContainers.set(projectPath, { + domain, + containerId: existingContainerId, + }); + return { domain, containerId: existingContainerId }; + } + const directory = path.join(getProjectPath(projectPath), 'frontend'); const subdomain = projectPath.replace(/[^\w-]/g, '').toLowerCase(); + const imageName = subdomain; + const containerName = `container-${subdomain}`; const domain = `${subdomain}.${traefikDomain}`; const exposedPort = await findAvailablePort(); return new Promise((resolve, reject) => { @@ -80,6 +102,11 @@ async function buildAndRunDocker( exec(runCommand, (runErr, runStdout, runStderr) => { if (runErr) { + // Check if error is due to container already existing + if (runStderr.includes('Conflict. The container name')) { + resolve({ domain, containerId: containerName }); + return; + } console.error(`Error during Docker run: ${runStderr}`); return reject(runErr); } @@ -100,6 +127,8 @@ async function buildAndRunDocker( ); }); } +const processingRequests = new Set(); + export async function GET(req: Request) { const { searchParams } = new URL(req.url); const projectPath = searchParams.get('projectPath'); @@ -111,6 +140,26 @@ export async function GET(req: Request) { ); } + // First check if container is already running + const existingContainer = runningContainers.get(projectPath); + if (existingContainer) { + return NextResponse.json({ + message: 'Docker container already running', + domain: existingContainer.domain, + containerId: existingContainer.containerId, + }); + } + + // If already processing this project, don't start another build + if (processingRequests.has(projectPath)) { + return NextResponse.json({ + message: 'Build in progress', + status: 'pending', + }); + } + + processingRequests.add(projectPath); + try { const { domain, containerId } = await buildAndRunDocker(projectPath); return NextResponse.json({ @@ -123,5 +172,7 @@ export async function GET(req: Request) { { error: error.message || 'Failed to start Docker container' }, { status: 500 } ); + } finally { + processingRequests.delete(projectPath); } } diff --git a/frontend/src/components/code-engine/code-engine.tsx b/frontend/src/components/code-engine/code-engine.tsx index 451fda37..c71df94d 100644 --- a/frontend/src/components/code-engine/code-engine.tsx +++ b/frontend/src/components/code-engine/code-engine.tsx @@ -34,17 +34,20 @@ export function CodeEngine({ chatId }: { chatId: string }) { Record> >({}); const theme = useTheme(); + console.log('codeengine current chatId: ', chatId); const [isProjectFinished, setIsProjectFinished] = useState(false); 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'; }; + useEffect(() => { async function checkChatProject() { if (curProject?.id) { @@ -57,7 +60,7 @@ export function CodeEngine({ chatId }: { chatId: string }) { } } checkChatProject(); - }, [curProject, pollChatProject]); + }, [chatId, curProject, pollChatProject]); // Effect: Fetch file content when filePath or projectId changes useEffect(() => { @@ -85,6 +88,8 @@ export function CodeEngine({ chatId }: { chatId: string }) { // Effect: Fetch file structure when projectId changes useEffect(() => { async function fetchFiles() { + if (!curProject?.projectPath) return; + try { const response = await fetch( `/api/project?path=${curProject.projectPath}` @@ -96,7 +101,7 @@ export function CodeEngine({ chatId }: { chatId: string }) { } } fetchFiles(); - }, [curProject]); + }, [curProject?.projectPath]); // Reset code to previous state and update editor const handleReset = () => { @@ -135,8 +140,9 @@ export function CodeEngine({ chatId }: { chatId: string }) { setCode(value); setSaving(true); }; + // Responsive toolbar component for header tabs and buttons - const ResponsiveToolbar = () => { + const ResponsiveToolbar = ({ isLoading }: { isLoading: boolean }) => { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(700); const [visibleTabs, setVisibleTabs] = useState(3); @@ -176,13 +182,14 @@ export function CodeEngine({ chatId }: { chatId: string }) { return (
@@ -233,16 +245,24 @@ export function CodeEngine({ chatId }: { chatId: string }) {
{!compactIcons && ( <> - - )} {compactIcons && ( - )} @@ -254,92 +274,100 @@ export function CodeEngine({ chatId }: { chatId: string }) { // Render the CodeEngine layout return ( -
- - {!isProjectFinished && ( - - - - )} - +
{/* Header Bar */} - + - {/* Main Content Area */} -
- {activeTab === 'code' ? ( - <> - {/* File Explorer Panel (collapsible) */} + {/* Main Content Area with Loading */} +
+ + {!isProjectFinished && ( - + +

+ Initializing project... +

-
- + +
+ {activeTab === 'code' ? ( + <> + {/* File Explorer Panel (collapsible) */} + + transition={{ duration: 0.3, ease: 'easeInOut' }} + className="overflow-y-auto border-r" + > + + +
+ +
+ + ) : activeTab === 'preview' ? ( +
+
- - ) : activeTab === 'preview' ? ( - - ) : activeTab === 'console' ? ( -
Console Content (Mock)
- ) : null} -
+ ) : activeTab === 'console' ? ( +
Console Content (Mock)
+ ) : null} +
- {/* Save Changes Bar */} - {saving && ( - - )} + {/* Save Changes Bar */} + {saving && ( + + )} - {/* File Explorer Toggle Button */} - {activeTab === 'code' && ( - - )} + {/* File Explorer Toggle Button */} + {activeTab === 'code' && ( + + )} +
); } + // SaveChangesBar component for showing unsaved changes status const SaveChangesBar = ({ saving, onSave, onReset }) => { return ( diff --git a/frontend/src/components/code-engine/project-context.tsx b/frontend/src/components/code-engine/project-context.tsx index 44cc81f5..8cf12359 100644 --- a/frontend/src/components/code-engine/project-context.tsx +++ b/frontend/src/components/code-engine/project-context.tsx @@ -4,6 +4,8 @@ import React, { ReactNode, useCallback, useMemo, + useRef, + useEffect, } from 'react'; import { useLazyQuery, useMutation, useQuery } from '@apollo/client'; import { CREATE_PROJECT, GET_CHAT_DETAILS } from '@/graphql/request'; @@ -31,14 +33,58 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const [projects, setProjects] = useState([]); const [curProject, setCurProject] = useState(undefined); const [filePath, setFilePath] = useState(null); - const [chatProjectCache, setChatProjectCache] = useState< - Map - >(new Map()); + const chatProjectCache = useRef>(new Map()); const MAX_RETRIES = 100; - useQuery(GET_USER_PROJECTS, { - onCompleted: (data) => setProjects(data.getUserProjects), - onError: (error) => console.error('Error fetching projects:', error), + // Effect to clean up cache on unmount + useEffect(() => { + return () => { + chatProjectCache.current.clear(); + }; + }, []); + + // Effect to restore current project state if needed + useEffect(() => { + if (projects.length > 0 && !curProject) { + const lastProjectId = localStorage.getItem('lastProjectId'); + if (lastProjectId) { + const project = projects.find((p) => p.id === lastProjectId); + if (project) { + setCurProject(project); + } + } + } + }, [projects, curProject]); + + // Effect to save current project id + useEffect(() => { + if (curProject?.id) { + localStorage.setItem('lastProjectId', curProject.id); + } + }, [curProject?.id]); + + const { loading, error, refetch } = useQuery(GET_USER_PROJECTS, { + fetchPolicy: 'network-only', + onCompleted: (data) => { + setProjects(data.getUserProjects); + // If we have a current project in the list, update it + if (curProject) { + const updatedProject = data.getUserProjects.find( + (p) => p.id === curProject.id + ); + if ( + updatedProject && + JSON.stringify(updatedProject) !== JSON.stringify(curProject) + ) { + setCurProject(updatedProject); + } + } + }, + onError: (error) => { + console.error('Error fetching projects:', error); + // Retry after 5 seconds on error + setTimeout(refetch, 5000); + }, }); const [createProject] = useMutation(CREATE_PROJECT, { @@ -86,8 +132,8 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const pollChatProject = useCallback( async (chatId: string): Promise => { - if (chatProjectCache.has(chatId)) { - return chatProjectCache.get(chatId) || null; + if (chatProjectCache.current.has(chatId)) { + return chatProjectCache.current.get(chatId) || null; } let retries = 0; @@ -97,9 +143,7 @@ export function ProjectProvider({ children }: { children: ReactNode }) { const { data } = await getChatDetail({ variables: { chatId } }); if (data?.getChatDetails?.project) { - setChatProjectCache((prev) => - new Map(prev).set(chatId, data.getChatDetails.project) - ); + chatProjectCache.current.set(chatId, data.getChatDetails.project); return data.getChatDetails.project; } } catch (error) { @@ -110,10 +154,10 @@ export function ProjectProvider({ children }: { children: ReactNode }) { retries++; } - setChatProjectCache((prev) => new Map(prev).set(chatId, null)); + chatProjectCache.current.set(chatId, null); return null; }, - [getChatDetail, chatProjectCache] + [getChatDetail] ); const contextValue = useMemo( diff --git a/frontend/src/components/code-engine/web-view.tsx b/frontend/src/components/code-engine/web-view.tsx index b27d7eac..91562ed9 100644 --- a/frontend/src/components/code-engine/web-view.tsx +++ b/frontend/src/components/code-engine/web-view.tsx @@ -1,14 +1,42 @@ import { useContext, useEffect, useRef, useState } from 'react'; import { ProjectContext } from './project-context'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + ChevronLeft, + ChevronRight, + Maximize, + ExternalLink, +} from 'lucide-react'; export default function WebPreview() { const { curProject } = useContext(ProjectContext); - const [url, setUrl] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [displayPath, setDisplayPath] = useState('/'); + const [history, setHistory] = useState(['/']); + const [currentIndex, setCurrentIndex] = useState(0); const iframeRef = useRef(null); + const containerRef = useRef<{ projectPath: string; domain: string } | null>( + null + ); + const lastProjectPathRef = useRef(null); useEffect(() => { const getWebUrl = async () => { + if (!curProject) return; const projectPath = curProject.projectPath; + + if (lastProjectPathRef.current === projectPath) { + return; + } + + lastProjectPathRef.current = projectPath; + + if (containerRef.current?.projectPath === projectPath) { + setBaseUrl(`http://${containerRef.current.domain}`); + return; + } + try { const response = await fetch( `/api/runProject?projectPath=${encodeURIComponent(projectPath)}`, @@ -20,9 +48,15 @@ export default function WebPreview() { } ); const json = await response.json(); - console.log(json); - await new Promise((resolve) => setTimeout(resolve, 10000)); - setUrl(`http://${json.domain}/`); + + await new Promise((resolve) => setTimeout(resolve, 200)); + + containerRef.current = { + projectPath, + domain: json.domain, + }; + setBaseUrl(`http://${json.domain}`); + setDisplayPath('/'); } catch (error) { console.error('fetching url error:', error); } @@ -32,52 +66,124 @@ export default function WebPreview() { }, [curProject]); useEffect(() => { - if (iframeRef.current) { - iframeRef.current.src = url; + if (iframeRef.current && baseUrl) { + const fullUrl = `${baseUrl}${displayPath}`; + iframeRef.current.src = fullUrl; } - }, [url]); + }, [baseUrl, displayPath]); - const refreshIframe = () => { + const enterFullScreen = () => { if (iframeRef.current) { - iframeRef.current.src = url; + iframeRef.current.requestFullscreen(); } }; - const enterFullScreen = () => { - if (iframeRef.current) { - iframeRef.current.requestFullscreen(); + const openInNewTab = () => { + if (baseUrl) { + const fullUrl = `${baseUrl}${displayPath}`; + window.open(fullUrl, '_blank'); + } + }; + + const handlePathChange = (newPath: string) => { + if (!newPath.startsWith('/')) { + newPath = '/' + newPath; + } + setDisplayPath(newPath); + // Add new path to history, removing any forward history + const newHistory = history.slice(0, currentIndex + 1); + setHistory([...newHistory, newPath]); + setCurrentIndex(newHistory.length); + }; + + const goBack = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + setDisplayPath(history[currentIndex - 1]); + } + }; + + const goForward = () => { + if (currentIndex < history.length - 1) { + setCurrentIndex(currentIndex + 1); + setDisplayPath(history[currentIndex + 1]); } }; return ( -
-
- setUrl(e.target.value)} - className="flex-1 p-2 border rounded" - /> - - +
+ {/* URL Bar */} +
+ {/* Navigation Controls */} +
+ + +
+ + {/* URL Input */} +
+ handlePathChange(e.target.value)} + className="h-8 bg-secondary" + placeholder="/" + disabled={!baseUrl} + /> +
+ + {/* Actions */} +
+ + +
-
-