448 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|