/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import React, { useState, useEffect, FormEvent } from "react";
import { ChevronRight, ChevronDown, File as FileIcon, FolderOpen, Folder, Save, Search, X, Plus, Trash2, FilePlus, FolderPlus } from "lucide-react";
import Editor from "@monaco-editor/react";
interface TreeNode {
name: string;
path: string;
type: "directory" | "file";
children?: TreeNode[];
}
interface SearchResult {
file: string;
line_number: number;
snippet: string;
}
interface WebIDEProps {
repoUrl: string;
}
const FileTreeNode = ({
node,
activePath,
onSelect,
onCreate,
onDelete,
level = 0,
}: {
node: TreeNode;
activePath: string | null;
onSelect: (path: string) => void;
onCreate: (parentPath: string, isDir: boolean) => void;
onDelete: (path: string) => void;
level?: number;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const isDir = node.type === "directory";
const isActive = activePath === node.path;
const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation();
if (isDir) {
setIsOpen(!isOpen);
} else {
onSelect(node.path);
}
};
return (
setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={`flex items-center justify-between w-full text-left py-1 px-2 cursor-pointer select-none text-sm font-sans transition-colors group ${
isActive ? "bg-[#37373d] text-white" : "text-[#cccccc] hover:bg-[#2a2d2e] hover:text-white"
}`}
style={{ paddingLeft: `${level * 12 + 8}px` }}
>
{isDir ? (
isOpen ? :
) : null}
{isDir ? (
isOpen ? :
) : (
)}
{node.name}
{/* Action Icons (Visible on Hover) */}
{isHovered && (
{isDir && (
<>
>
)}
)}
{isDir && isOpen && node.children && (
{node.children.map((child, idx) => (
))}
)}
);
};
function getBackendUrl(): string {
if (process.env.NEXT_PUBLIC_BACKEND_URL) {
return process.env.NEXT_PUBLIC_BACKEND_URL.replace(/\/$/, "");
}
if (typeof window !== "undefined") {
const port = window.location.port;
const hostname = window.location.hostname;
if (port === "3000") {
return `${window.location.protocol}//${hostname}:8000`;
}
return `${window.location.protocol}//${window.location.host}`;
}
return "http://localhost:8000";
}
export default function WebIDE({ repoUrl }: WebIDEProps) {
// Tree State
const [tree, setTree] = useState(null);
const [loadingTree, setLoadingTree] = useState(true);
const [error, setError] = useState(null);
// Tab State
const [openTabs, setOpenTabs] = useState([]);
const [activeTab, setActiveTab] = useState(null);
const [fileContents, setFileContents] = useState>({});
const [editedContents, setEditedContents] = useState>({});
const [loadingFiles, setLoadingFiles] = useState>({});
// Sidebar State
const [activeSidebarMode, setActiveSidebarMode] = useState<"explorer" | "search">("explorer");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const fetchTree = async () => {
try {
const res = await fetch(`${getBackendUrl()}/repo/${encodeURIComponent(repoUrl)}/tree`);
if (!res.ok) throw new Error("Repository not found or API error");
const data = await res.json();
setTree(data);
} catch (err: any) {
setError(err.message || "Failed to fetch repository tree");
} finally {
setLoadingTree(false);
}
};
useEffect(() => {
setLoadingTree(true);
fetchTree();
}, [repoUrl]);
const openFile = async (path: string) => {
if (!openTabs.includes(path)) {
setOpenTabs([...openTabs, path]);
}
setActiveTab(path);
if (fileContents[path] === undefined) {
setLoadingFiles(prev => ({...prev, [path]: true}));
try {
const res = await fetch(`${getBackendUrl()}/repo/${encodeURIComponent(repoUrl)}/file?file_path=${encodeURIComponent(path)}`);
if (!res.ok) throw new Error("File not found");
const data = await res.json();
setFileContents(prev => ({...prev, [path]: data.content}));
setEditedContents(prev => ({...prev, [path]: data.content}));
} catch (err: any) {
setFileContents(prev => ({...prev, [path]: `// Error: ${err.message}`}));
setEditedContents(prev => ({...prev, [path]: `// Error: ${err.message}`}));
} finally {
setLoadingFiles(prev => ({...prev, [path]: false}));
}
}
};
const closeTab = (e: React.MouseEvent, path: string) => {
e.stopPropagation();
const newTabs = openTabs.filter(t => t !== path);
setOpenTabs(newTabs);
if (activeTab === path) {
setActiveTab(newTabs.length > 0 ? newTabs[newTabs.length - 1] : null);
}
// Clean up memory
setFileContents(prev => { const n = {...prev}; delete n[path]; return n; });
setEditedContents(prev => { const n = {...prev}; delete n[path]; return n; });
};
const handleSave = async () => {
if (!activeTab || editedContents[activeTab] === undefined) return;
const content = editedContents[activeTab];
if (content === fileContents[activeTab]) return;
setIsSaving(true);
try {
const res = await fetch(`${getBackendUrl()}/repo/${encodeURIComponent(repoUrl)}/file`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_path: activeTab, content })
});
if (!res.ok) throw new Error("Failed to save");
setFileContents(prev => ({...prev, [activeTab]: content}));
} catch (err: any) {
alert("Failed to save file: " + err.message);
} finally {
setIsSaving(false);
}
};
const handleCreate = async (parentPath: string, isDir: boolean) => {
const name = prompt(`Enter name for new ${isDir ? 'folder' : 'file'} in ${parentPath}:`);
if (!name) return;
const newPath = parentPath === "." || parentPath === "" ? name : `${parentPath}/${name}`;
try {
const res = await fetch(`${getBackendUrl()}/repo/${encodeURIComponent(repoUrl)}/file/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ file_path: newPath, is_dir: isDir, content: "" })
});
if (!res.ok) throw new Error("Failed to create");
await fetchTree();
if (!isDir) openFile(newPath);
} catch (err: any) {
alert("Failed to create: " + err.message);
}
};
const handleDelete = async (path: string) => {
if (!confirm(`Are you sure you want to delete ${path}? This will also push a commit to GitHub.`)) return;
try {
const res = await fetch(`${getBackendUrl()}/repo/${encodeURIComponent(repoUrl)}/file?file_path=${encodeURIComponent(path)}`, {
method: "DELETE"
});
if (!res.ok) throw new Error("Failed to delete");
if (openTabs.includes(path)) closeTab({ stopPropagation: () => {} } as any, path);
await fetchTree();
} catch (err: any) {
alert("Failed to delete: " + err.message);
}
};
const handleSearch = async (e: FormEvent) => {
e.preventDefault();
if (!searchQuery.trim()) return;
setIsSearching(true);
try {
const res = await fetch(`${getBackendUrl()}/repo/${encodeURIComponent(repoUrl)}/search?q=${encodeURIComponent(searchQuery)}`);
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
setSearchResults(data.results);
} catch (err: any) {
alert(err.message);
} finally {
setIsSearching(false);
}
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTab, editedContents]);
const getLanguage = (filename: string) => {
const ext = filename.split(".").pop()?.toLowerCase();
const map: Record = {
js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript",
py: "python", json: "json", md: "markdown", html: "html", css: "css", sh: "bash",
};
return map[ext || ""] || "text";
};
const hasActiveTabUnsavedChanges = activeTab ? (editedContents[activeTab] !== undefined && editedContents[activeTab] !== fileContents[activeTab]) : false;
return (
{/* Activity Bar */}
{/* Sidebar */}
{activeSidebarMode === "explorer" ? "Explorer" : "Search"}
{activeSidebarMode === "explorer" && (
)}
{activeSidebarMode === "explorer" ? (
loadingTree ? (
Loading tree...
) : error ? (
{error}
) : tree && tree.children ? (
tree.children.map((child, idx) => (
))
) : (
No files found.
)
) : (
{isSearching ? (
Searching...
) : searchResults.length > 0 ? (
searchResults.map((res, i) => (
openFile(res.file)}
>
{res.file}
{res.line_number}:
{res.snippet}
))
) : searchQuery ? (
No results found.
) : (
Enter a query to search.
)}
)}
{/* Editor Area */}
{openTabs.length > 0 ? (
<>
{/* Tab Bar */}
{openTabs.map(tab => {
const isTabActive = tab === activeTab;
const isUnsaved = editedContents[tab] !== undefined && editedContents[tab] !== fileContents[tab];
return (
setActiveTab(tab)}
className={`flex items-center gap-2 h-full px-3 text-sm cursor-pointer border-r border-[#1e1e1e] group ${
isTabActive ? "bg-[#1e1e1e] text-[#cccccc] border-t border-t-[#007acc]" : "bg-[#2d2d2d] text-[#888888] hover:bg-[#2b2b2b]"
}`}
style={{ minWidth: "120px", maxWidth: "200px" }}
>
{tab.split("/").pop()}
);
})}
{/* Editor Breadcrumbs & Save */}
{repoUrl}
>
{activeTab?.split("/").join(" > ")}
{/* Code Content */}
{activeTab && loadingFiles[activeTab] ? (
Loading file content...
) : activeTab && editedContents[activeTab] !== undefined ? (
setEditedContents(prev => ({...prev, [activeTab]: value ?? ""}))}
options={{
minimap: { enabled: true },
fontSize: 14,
wordWrap: "on",
padding: { top: 16 }
}}
/>
) : null}
>
) : (
AutoMaintainer Editor
Select a file from the explorer or search to begin.
Search
Create File
)}
);
}