Files
vibn-frontend/app/[workspace]/project/[projectId]/code/page.tsx

448 lines
14 KiB
TypeScript

"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>
);
}