Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/speed-insights": "^1.0.12",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.0.12",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.26",
Expand All @@ -34,6 +35,7 @@
"zustand": "^4.5.7"
},
"devDependencies": {
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20.10.4",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.17",
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/app/project/[projectId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function ProjectPage({ params, searchParams }: ProjectPageProps)
useEffect(() => {
if (!projectId) return;

fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/projects/${projectId}`)
fetch(`/api/proxy/projects/${projectId}`)
.then(res => {
if (!res.ok) {
console.error("Project not found");
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/components/agent/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function ChatPanel({ projectId, initialPrompt }: ChatPanelProps) {
}

try {
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/run`, {
const response = await fetch('/api/proxy/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Expand Down
32 changes: 20 additions & 12 deletions apps/client/src/components/agent/FileSystemPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback } from "react";
import { File, Folder, Search, ChevronRight, ChevronDown, X } from "lucide-react";
import { File, Folder, Search, ChevronRight, ChevronDown, X, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { useProjectStore, FileNode } from "@/store/project";

Expand All @@ -17,8 +17,16 @@ export function FileSystemPanel({ projectId }: FileSystemPanelProps) {
useEffect(() => {
if (!projectId) return;

// Initial Load
setLoading(true);
fetchFiles(projectId).finally(() => setLoading(false));

// Live Polling (Readonly view)
const interval = setInterval(() => {
fetchFiles(projectId).catch(console.error);
}, 3000);

return () => clearInterval(interval);
}, [projectId, fetchFiles]);

const toggleFolder = useCallback((path: string) => {
Expand All @@ -33,17 +41,8 @@ export function FileSystemPanel({ projectId }: FileSystemPanelProps) {
});
}, []);

// Placeholder for buildTree function, assuming it will be defined elsewhere or is a utility.
// For now, we'll just return the files as is to avoid breaking the code.
// In a real scenario, `buildTree` would transform the flat `files` array into a hierarchical structure.
const buildTree = (nodes: FileNode[]): FileNode[] => {
// This is a simplified placeholder. A real buildTree function would construct a tree.
// For the current context, we'll assume `files` is already a tree or `buildTree` is a no-op.
return nodes;
};

const fileStructure = useMemo(() => {
return buildTree(files);
return files;
}, [files]);

const FileTreeItem = useMemo(() => {
Expand Down Expand Up @@ -90,7 +89,16 @@ export function FileSystemPanel({ projectId }: FileSystemPanelProps) {
<div className="w-64 border-r border-white/5 flex flex-col bg-[#050505]">
<div className="h-10 flex items-center justify-between px-3 border-b border-white/5 shrink-0 bg-black/20">
<span className="font-medium text-slate-400">Files</span>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
<a
href={`/api/proxy/projects/${projectId}/download`}
target="_blank"
rel="noopener noreferrer"
className="text-slate-600 hover:text-slate-400"
title="Download Source"
>
<Download className="w-3.5 h-3.5" />
</a>
<Search className="w-3.5 h-3.5 text-slate-600 cursor-pointer hover:text-slate-400" />
<span className="text-[10px] text-slate-600 cursor-pointer hover:text-slate-400" onClick={() => {
// Refresh
Expand Down
49 changes: 43 additions & 6 deletions apps/client/src/components/agent/PreviewPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Loader2, RefreshCw, ExternalLink, AlertCircle } from "lucide-react";
import { Loader2, RefreshCw, ExternalLink, AlertCircle, Rocket } from "lucide-react";
import { cn } from "@/lib/utils";
import confetti from "canvas-confetti";

interface PreviewPanelProps {
projectId: string;
Expand All @@ -20,10 +21,10 @@ const LOADING_MESSAGES = [
];

export function PreviewPanel({ projectId }: PreviewPanelProps) {
const [status, setStatus] = useState<PreviewState>('loading');
const [key, setKey] = useState(0);
const [msgIndex, setMsgIndex] = useState(0); // For cycling messages

const [status, setStatus] = useState<PreviewState>('loading'); // Default to loading on mount
const [key, setKey] = useState(0); // For forcing refresh
const [msgIndex, setMsgIndex] = useState(0);
const [githubUrl, setGithubUrl] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const pollInterval = useRef<NodeJS.Timeout>();
const timeoutTimer = useRef<NodeJS.Timeout>();
Expand Down Expand Up @@ -64,7 +65,13 @@ export function PreviewPanel({ projectId }: PreviewPanelProps) {
if (res.ok) {
stopPolling();
setStatus('ready');
setKey(prev => prev + 1);
setKey(prev => prev + 1); // Force iframe reload
// Trigger confetti
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 }
});
}
} catch (e) {
// Ignore errors
Expand All @@ -74,6 +81,13 @@ export function PreviewPanel({ projectId }: PreviewPanelProps) {

useEffect(() => {
startPolling();
// Fetch project for github url
fetch(`/api/proxy/projects/${projectId}`).then(res => res.json()).then(data => {
if (data.project?.githubUrl) {
setGithubUrl(data.project.githubUrl);
}
}).catch(console.error);
Comment thread
M-DEV-1 marked this conversation as resolved.

return () => stopPolling();
}, [projectId, startPolling]);

Expand All @@ -98,6 +112,29 @@ export function PreviewPanel({ projectId }: PreviewPanelProps) {
<ExternalLink className="w-3 h-3" />
</a>
</div>
<div className="h-3 w-px bg-white/10" />

{githubUrl && (
<>
<a
href={`https://vercel.com/new/import?repository-url=${encodeURIComponent(githubUrl)}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-white bg-black hover:bg-neutral-900 px-2 py-0.5 rounded transition-colors"
>
<Rocket className="w-3 h-3" />
Deploy
</a>
<div className="h-3 w-px bg-white/10" />
</>
)}

<button onClick={handleRefresh} className="text-zinc-400 hover:text-white transition-colors">
<RefreshCw className={cn("w-3 h-3", status === 'loading' && "animate-spin")} />
</button>
<a href={previewUrl} target="_blank" rel="noopener noreferrer" className="hover:text-blue-400 transition-colors">
<ExternalLink className="w-3 h-3" />
</a>
</div>

{/* Loading Overlay */}
Expand Down
4 changes: 2 additions & 2 deletions apps/client/src/store/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ interface ProjectState {
clearLogs: () => void;
}

const API_URL = process.env.NEXT_PUBLIC_API_URL;
const API_URL = process.env.NEXT_PUBLIC_API_URL || "";

export const useProjectStore = create<ProjectState>((set, get) => ({
projectId: null,
Expand Down Expand Up @@ -104,7 +104,7 @@ export const useProjectStore = create<ProjectState>((set, get) => ({

fetchFiles: async (projectId: string) => {
try {
const res = await fetch(`${API_URL}/api/projects/${projectId}/files`);
const res = await fetch(`/api/proxy/projects/${projectId}/files`);
const data = await res.json();
if (data.files) {
const tree = buildTree(data.files);
Expand Down
4 changes: 3 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
},
"dependencies": {
"@octokit/rest": "^20.0.2",
"archiver": "^7.0.1",
"axios": "^1.6.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
Expand All @@ -18,6 +19,7 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/express-session": "^1.17.10",
Expand All @@ -29,4 +31,4 @@
"tsx": "^4.21.0",
"typescript": "^5.9.2"
}
}
}
43 changes: 43 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { WorkspaceService } from './services/workspace';
import { ClineService } from './services/cline';
import fs from 'fs';
import mongoose from "mongoose";
import archiver from "archiver";


dotenv.config();

Expand Down Expand Up @@ -157,6 +159,47 @@ app.get("/api/projects/:projectId/files", async (req, res) => {
}
});

// Download Route
app.get("/api/projects/:projectId/download", async (req, res) => {
try {
const { projectId } = req.params;
// Verify project exists
const project = await Project.findById(projectId);
if (!project) {
return res.status(404).json({ error: "Project not found" });
}

const repoPath = WorkspaceService.getRepoPath(projectId);
if (!fs.existsSync(repoPath)) {
return res.status(404).json({ error: "No code generated yet" });
}

res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${project.name.replace(/[^a-z0-9]/gi, '_').toLowerCase() || 'project'}.zip"`);

const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
});

archive.on('error', function (err) {
console.error("Archiver error:", err);
res.status(500).send({ error: err.message });
});

// pipe archive data to the file
archive.pipe(res);

// append files from a sub-directory, putting its contents at the root of archive
archive.directory(repoPath, false);

archive.finalize();

} catch (error) {
console.error("Download failed:", error);
res.status(500).json({ error: "Download failed" });
}
});
Comment on lines +162 to +201
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Streaming error handling is unsafe after archive.pipe(res)
After piping, res.status(500).send(...) can throw or partially corrupt the zip. Prefer abort/destroy the stream and log.

-        archive.on('error', function (err) {
-            console.error("Archiver error:", err);
-            res.status(500).send({ error: err.message });
-        });
+        archive.on('error', (err) => {
+            console.error("Archiver error:", err);
+            // response is a stream at this point; don't try to write JSON/status reliably
+            res.destroy(err);
+        });
+
+        req.on('close', () => {
+            if (!res.writableEnded) archive.abort();
+        });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@M-DEV-1, thanks for the acknowledgment! Let me know if you'd like any clarification on the suggested approach for handling stream errors more safely.


// Preview Route (Static File Serving)
app.get("/preview/:projectId/*", (req, res) => {
const { projectId } = req.params;
Expand Down
29 changes: 19 additions & 10 deletions apps/server/src/services/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,25 @@ ${prompt}
} else {
// Start with simple text check or limit size
// For now, read all (safe for small hackathon projects)
let content = '';
try {
content = await fs.readFile(fullPath, 'utf-8');
} catch (e) { content = '[Binary or Unreadable]'; }

files.push({
path: relPath,
type: 'file',
content: content
});
const stats = await fs.stat(fullPath);
if (stats.size > 100 * 1024) { // 100KB limit
files.push({
path: relPath,
type: 'file',
content: '[Large File - Content Hidden]'
});
} else {
let content = '';
try {
content = await fs.readFile(fullPath, 'utf-8');
} catch (e) { content = '[Binary or Unreadable]'; }

files.push({
path: relPath,
type: 'file',
content: content
});
}
}
}
}
Expand Down
Loading