VIBN Frontend for Coolify deployment
This commit is contained in:
447
app/[workspace]/project/[projectId]/code/page.tsx
Normal file
447
app/[workspace]/project/[projectId]/code/page.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import type { JSX } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Code2,
|
||||
FolderOpen,
|
||||
File,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Search,
|
||||
Loader2,
|
||||
Github,
|
||||
RefreshCw,
|
||||
FileCode
|
||||
} from "lucide-react";
|
||||
import { auth } from "@/lib/firebase/config";
|
||||
import { db } from "@/lib/firebase/config";
|
||||
import { doc, getDoc } from "firebase/firestore";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Project {
|
||||
githubRepo?: string;
|
||||
githubRepoUrl?: string;
|
||||
githubDefaultBranch?: string;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
path: string;
|
||||
name: string;
|
||||
type: 'file' | 'folder';
|
||||
children?: FileNode[];
|
||||
size?: number;
|
||||
sha?: string;
|
||||
}
|
||||
|
||||
interface GitHubFile {
|
||||
path: string;
|
||||
sha: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default function CodePage() {
|
||||
const params = useParams();
|
||||
const projectId = params.projectId as string;
|
||||
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingFiles, setLoadingFiles] = useState(false);
|
||||
const [fileTree, setFileTree] = useState<FileNode[]>([]);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['/']));
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetchProject();
|
||||
}, [projectId]);
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const projectRef = doc(db, "projects", projectId);
|
||||
const projectSnap = await getDoc(projectRef);
|
||||
|
||||
if (projectSnap.exists()) {
|
||||
const projectData = projectSnap.data() as Project;
|
||||
setProject(projectData);
|
||||
|
||||
// Auto-load files if GitHub is connected
|
||||
if (projectData.githubRepo) {
|
||||
await fetchFileTree(projectData.githubRepo, projectData.githubDefaultBranch);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching project:", error);
|
||||
toast.error("Failed to load project");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFileTree = async (repoFullName: string, branch = 'main') => {
|
||||
setLoadingFiles(true);
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const [owner, repo] = repoFullName.split('/');
|
||||
|
||||
const response = await fetch(
|
||||
`/api/github/repo-tree?owner=${owner}&repo=${repo}&branch=${branch}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch repository files");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const tree = buildFileTree(data.files);
|
||||
setFileTree(tree);
|
||||
|
||||
toast.success(`Loaded ${data.totalFiles} files from ${repoFullName}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching file tree:", error);
|
||||
toast.error("Failed to load repository files");
|
||||
} finally {
|
||||
setLoadingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildFileTree = (files: GitHubFile[]): FileNode[] => {
|
||||
const root: FileNode = {
|
||||
path: '/',
|
||||
name: '/',
|
||||
type: 'folder',
|
||||
children: [],
|
||||
};
|
||||
|
||||
files.forEach((file) => {
|
||||
const parts = file.path.split('/');
|
||||
let currentNode = root;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isFile = index === parts.length - 1;
|
||||
const fullPath = parts.slice(0, index + 1).join('/');
|
||||
|
||||
if (!currentNode.children) {
|
||||
currentNode.children = [];
|
||||
}
|
||||
|
||||
let childNode = currentNode.children.find(child => child.name === part);
|
||||
|
||||
if (!childNode) {
|
||||
childNode = {
|
||||
path: fullPath,
|
||||
name: part,
|
||||
type: isFile ? 'file' : 'folder',
|
||||
...(isFile && { size: file.size, sha: file.sha }),
|
||||
...(!isFile && { children: [] }),
|
||||
};
|
||||
currentNode.children.push(childNode);
|
||||
}
|
||||
|
||||
if (!isFile) {
|
||||
currentNode = childNode;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Sort children recursively
|
||||
const sortNodes = (nodes: FileNode[]) => {
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
nodes.forEach(node => {
|
||||
if (node.children) {
|
||||
sortNodes(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (root.children) {
|
||||
sortNodes(root.children);
|
||||
}
|
||||
|
||||
return root.children || [];
|
||||
};
|
||||
|
||||
const fetchFileContent = async (filePath: string) => {
|
||||
if (!project?.githubRepo) return;
|
||||
|
||||
setLoadingContent(true);
|
||||
setSelectedFile(filePath);
|
||||
setFileContent(null);
|
||||
|
||||
try {
|
||||
const user = auth.currentUser;
|
||||
if (!user) {
|
||||
toast.error("Please sign in");
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await user.getIdToken();
|
||||
const [owner, repo] = project.githubRepo.split('/');
|
||||
const branch = project.githubDefaultBranch || 'main';
|
||||
|
||||
console.log('[Code Page] Fetching file:', filePath);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/github/file-content?owner=${owner}&repo=${repo}&path=${encodeURIComponent(filePath)}&branch=${branch}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('[Code Page] Failed to fetch file:', errorData);
|
||||
throw new Error(errorData.error || "Failed to fetch file content");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[Code Page] File loaded:', data.name, `(${data.size} bytes)`);
|
||||
setFileContent(data.content);
|
||||
} catch (error) {
|
||||
console.error("Error fetching file content:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to load file content");
|
||||
setFileContent(`// Error loading file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setLoadingContent(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
const newExpanded = new Set(expandedFolders);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedFolders(newExpanded);
|
||||
};
|
||||
|
||||
const renderFileTree = (nodes: FileNode[], level = 0): JSX.Element[] => {
|
||||
return nodes
|
||||
.filter(node => {
|
||||
if (!searchQuery) return true;
|
||||
return node.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
})
|
||||
.map((node) => (
|
||||
<div key={node.path}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (node.type === 'folder') {
|
||||
toggleFolder(node.path);
|
||||
} else {
|
||||
fetchFileContent(node.path);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted rounded transition-colors",
|
||||
selectedFile === node.path && "bg-muted"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12 + 8}px` }}
|
||||
>
|
||||
{node.type === 'folder' ? (
|
||||
<>
|
||||
{expandedFolders.has(node.path) ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-4" />
|
||||
<FileCode className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{node.name}</span>
|
||||
{node.size && (
|
||||
<span className="ml-auto text-xs text-muted-foreground shrink-0">
|
||||
{formatFileSize(node.size)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{node.type === 'folder' && expandedFolders.has(node.path) && node.children && (
|
||||
renderFileTree(node.children, level + 1)
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project?.githubRepo) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center gap-2 px-6">
|
||||
<Code2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Code</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<Card className="max-w-2xl mx-auto p-8 text-center">
|
||||
<div className="mb-4 rounded-full bg-muted p-4 w-fit mx-auto">
|
||||
<Github className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-2">No Repository Connected</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Connect a GitHub repository in the Context section to view your code here
|
||||
</p>
|
||||
<Button onClick={() => window.location.href = `/${params.workspace}/project/${projectId}/context`}>
|
||||
<Github className="h-4 w-4 mr-2" />
|
||||
Connect Repository
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="flex h-14 items-center gap-2 px-6">
|
||||
<Code2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-lg font-semibold">Code</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<a
|
||||
href={project.githubRepoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
{project.githubRepo}
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fetchFileTree(project.githubRepo!, project.githubDefaultBranch)}
|
||||
disabled={loadingFiles}
|
||||
>
|
||||
{loadingFiles ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* File Tree Sidebar */}
|
||||
<div className="w-80 border-r flex flex-col bg-background">
|
||||
<div className="p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search files..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{loadingFiles ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : fileTree.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No files found
|
||||
</div>
|
||||
) : (
|
||||
renderFileTree(fileTree)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Viewer */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden bg-muted/30">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<div className="px-4 py-2 border-b bg-background flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-mono">{selectedFile}</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto bg-background">
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<div className="flex">
|
||||
{/* Line Numbers */}
|
||||
<div className="select-none border-r bg-muted/30 px-4 py-4 text-right text-sm font-mono text-muted-foreground">
|
||||
{fileContent.split('\n').map((_, i) => (
|
||||
<div key={i} className="leading-relaxed">
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Code Content */}
|
||||
<pre className="flex-1 p-4 text-sm font-mono leading-relaxed overflow-x-auto">
|
||||
<code>{fileContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<p className="text-sm">Failed to load file content</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Code2 className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Select a file to view its contents</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user