import React, { useState, useCallback, useRef, useEffect } from "react"; import Editor from "@monaco-editor/react"; import { FiFile, FiFolder, FiEdit3, FiTrash2, FiChevronRight, FiChevronDown, FiSearch, FiGitBranch, FiCheckCircle, FiMinus, FiAlertCircle, } from "react-icons/fi"; import { MdClose, MdAdd } from "react-icons/md"; import { GoFile, GoTrash, GoKebabHorizontal } from "react-icons/go"; /* eslint react-refresh/only-export-components: "off" */ export interface FileNode { name: string; type: "file" | "folder"; content?: string; children?: FileNode[]; path?: string; } export const initialFiles: FileNode = { name: "my-project", type: "folder", children: [ { name: "src", type: "folder", children: [ { name: "main.py", type: "file", content: 'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()', }, { name: "utils.py", type: "file", content: "def helper():\n return 42", }, ], }, { name: "README.md", type: "file", content: "# My Project\n\nWelcome!", }, ], }; const addPaths = (node: FileNode, parentPath: string = ""): FileNode => { const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name; const newNode = { ...node, path: currentPath }; if (newNode.children) { newNode.children = newNode.children.map((child) => addPaths(child, currentPath), ); } return newNode; }; const getAllFolderPaths = (node: FileNode): string[] => { let paths: string[] = []; if (node.type === "folder") { paths.push(node.path || node.name); if (node.children) { node.children.forEach((child) => { paths = [...paths, ...getAllFolderPaths(child)]; }); } } return paths; }; const findNode = (node: FileNode, path: string): FileNode | null => { if (node.path === path) return node; if (node.children) { for (const child of node.children) { const found = findNode(child, path); if (found) return found; } } return null; }; const deleteNode = (node: FileNode, path: string): FileNode | null => { if (node.path === path) return null; if (node.children) { const filtered = node.children.filter((child) => child.path !== path); const mapped = filtered .map((child) => deleteNode(child, path)) .filter((child): child is FileNode => child !== null); return { ...node, children: mapped }; } return node; }; const addNode = ( node: FileNode, parentPath: string, newNode: FileNode, ): FileNode => { if (node.path === parentPath) { const newPath = addPaths(newNode, node.path); return { ...node, children: [...(node.children || []), newPath] }; } if (node.children) { return { ...node, children: node.children.map((child) => addNode(child, parentPath, newNode), ), }; } return node; }; const renameNode = ( node: FileNode, oldPath: string, newName: string, ): FileNode | null => { if (node.path === oldPath) { const pathParts = node.path?.split("/") || []; pathParts[pathParts.length - 1] = newName; const newPath = pathParts.join("/"); const renamedNode = { ...node, name: newName, path: newPath }; if (renamedNode.children) { renamedNode.children = renamedNode.children.map((child) => { const oldChildPath = child.path || ""; const newChildPath = oldChildPath.replace(oldPath, newPath); return ( renameNode( child, oldChildPath, newChildPath.split("/").pop() || "", ) || child ); }); } return renamedNode; } if (node.children) { return { ...node, children: node.children.map( (child) => renameNode(child, oldPath, newName) || child, ), }; } return node; }; const ContextMenu: React.FC<{ x: number; y: number; node: FileNode | null; onClose: () => void; onNewFile: () => void; onNewFolder: () => void; onRename: () => void; onDelete: () => void; }> = ({ x, y, node, onClose, onNewFile, onNewFolder, onRename, onDelete }) => { useEffect(() => { const handleClick = () => onClose(); document.addEventListener("click", handleClick); return () => document.removeEventListener("click", handleClick); }, [onClose]); return (
New File New Folder {node && ( <>
Rename Delete )}
); }; const MenuItem: React.FC<{ onClick: () => void; danger?: boolean; children: React.ReactNode; }> = ({ onClick, danger, children }) => (
{ e.currentTarget.style.backgroundColor = "#2a2d2e"; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }} > {children}
); const InputDialog: React.FC<{ title: string; initialValue?: string; onConfirm: (value: string) => void; onCancel: () => void; }> = ({ title, initialValue = "", onConfirm, onCancel }) => { const [value, setValue] = useState(initialValue); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); return (
e.stopPropagation()} >

{title}

Enter a new name

setValue(e.target.value)} onKeyDown={(e) => e.key === "Enter" && value.trim() && onConfirm(value.trim()) } style={{ width: "100%", padding: "10px", backgroundColor: "#3c3c3c", border: "1px solid #3e3e42", borderRadius: "6px", color: "#ccc", fontSize: "14px", marginBottom: "20px", outline: "none", }} />
); }; const filterTree = (node: FileNode, query: string): FileNode | null => { if (!query) return node; const lowerQuery = query.toLowerCase(); if (node.type === "file") { if (node.name.toLowerCase().includes(lowerQuery)) return node; return null; } if (node.children) { const filteredChildren = node.children .map((child) => filterTree(child, query)) .filter((child): child is FileNode => child !== null); if (filteredChildren.length > 0) { return { ...node, children: filteredChildren }; } } if (node.name.toLowerCase().includes(lowerQuery)) return node; return null; }; const collectPathsToExpand = (node: FileNode, query: string): Set => { const paths = new Set(); if (!query) return paths; const lowerQuery = query.toLowerCase(); const search = (n: FileNode, currentPath: string) => { if (n.name.toLowerCase().includes(lowerQuery)) { const pathParts = currentPath.split("/"); for (let i = 1; i < pathParts.length; i++) { paths.add(pathParts.slice(0, i).join("/")); } } if (n.children) { n.children.forEach((child) => { const childPath = child.path || `${currentPath}/${child.name}`; search(child, childPath); }); } }; search(node, node.path || node.name); return paths; }; const FileTreeItem: React.FC<{ node: FileNode; level: number; onFileSelect: (node: FileNode) => void; selectedFile: string | null; onContextMenu: (e: React.MouseEvent, node: FileNode) => void; expandedFolders: Set; onToggleFolder: (path: string) => void; onDelete: (node: FileNode) => void; isRoot?: boolean; searchQuery?: string; }> = ({ node, level, onFileSelect, selectedFile, onContextMenu, expandedFolders, onToggleFolder, onDelete, isRoot, searchQuery, }) => { const isFolder = node.type === "folder"; const isSelected = selectedFile === node.path && !isFolder; const isExpanded = expandedFolders.has(node.path || node.name); const [hovered, setHovered] = useState(false); const handleClick = () => { if (isFolder) { onToggleFolder(node.path || node.name); } else { onFileSelect(node); } }; const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); onDelete(node); }; const highlightText = (text: string, query: string) => { if (!query) return text; const idx = text.toLowerCase().indexOf(query.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + query.length)} {text.slice(idx + query.length)} ); }; return (
onContextMenu(e, node)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`, paddingTop: "4px", paddingBottom: "4px", cursor: "pointer", display: "flex", alignItems: "center", gap: "6px", backgroundColor: isSelected ? "#094771" : "transparent", color: isSelected ? "#fff" : "#cccccc", fontSize: "13px", transition: "background-color 0.1s", userSelect: "none", minHeight: "28px", }} > {isFolder ? ( isExpanded ? ( ) : ( ) ) : ( )} {searchQuery ? highlightText(node.name, searchQuery) : node.name} {hovered && !isRoot && ( )}
{isFolder && isExpanded && node.children && (
{node.children.map((child, idx) => ( ))}
)}
); }; const TabBar: React.FC<{ openFiles: FileNode[]; activeFile: FileNode | null; onSelectFile: (file: FileNode) => void; onCloseFile: (file: FileNode) => void; onCloseAll: () => void; onCloseOthers: (file: FileNode) => void; }> = ({ openFiles, activeFile, onSelectFile, onCloseFile, onCloseAll, onCloseOthers, }) => { const [showContextMenu, setShowContextMenu] = useState<{ x: number; y: number; file: FileNode; } | null>(null); const handleContextMenu = (e: React.MouseEvent, file: FileNode) => { e.preventDefault(); setShowContextMenu({ x: e.clientX, y: e.clientY, file }); }; return ( <>
{openFiles.map((file) => (
onSelectFile(file)} onContextMenu={(e) => handleContextMenu(e, file)} style={{ display: "flex", alignItems: "center", padding: "8px 16px", backgroundColor: activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30", color: activeFile?.path === file.path ? "#fff" : "#cccccc", borderRight: "1px solid #3e3e42", cursor: "pointer", fontSize: "13px", gap: "10px", whiteSpace: "nowrap", transition: "all 0.1s", borderTop: activeFile?.path === file.path ? "2px solid #0e639c" : "2px solid transparent", }} > {file.name}
))}
{showContextMenu && (
{ onCloseOthers(showContextMenu.file); setShowContextMenu(null); }} style={{ padding: "8px 16px", cursor: "pointer", color: "#cccccc", fontSize: "13px", }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "#2a2d2e"; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }} > Close Others
{ onCloseAll(); setShowContextMenu(null); }} style={{ padding: "8px 16px", cursor: "pointer", color: "#cccccc", fontSize: "13px", }} onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "#2a2d2e"; }} onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "transparent"; }} > Close All
)} ); }; const TitleBar: React.FC = () => { return (
Web VS Code
main
); }; const FileExplorer: React.FC<{ files: FileNode; onFileSelect: (node: FileNode) => void; selectedFile: string | null; onRefresh: (newFiles: FileNode | null, newFile?: FileNode) => void; expandedFolders: Set; onToggleFolder: (path: string) => void; onExpandAll: () => void; onCollapseAll: () => void; onDeleteRoot: () => void; searchQuery: string; onSearchChange: (query: string) => void; onAutoExpand: (paths: Set) => void; }> = ({ files, onFileSelect, selectedFile, onRefresh, expandedFolders, onToggleFolder, onExpandAll, onCollapseAll, onDeleteRoot, searchQuery, onSearchChange, onAutoExpand, }) => { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: FileNode | null; } | null>(null); const [dialog, setDialog] = useState<{ type: "newFile" | "newFolder" | "rename"; node: FileNode | null; } | null>(null); const [showSearch, setShowSearch] = useState(false); // Для контекстного меню на пустом месте (фон эксплорера) const handleEmptyContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY, node: null }); }; // Для контекстного меню на элементе (передаётся из FileTreeItem) const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => { e.preventDefault(); e.stopPropagation(); // ВАЖНО: предотвращаем всплытие setContextMenu({ x: e.clientX, y: e.clientY, node }); }; const handleDelete = (node: FileNode) => { const isRootNode = node.path === files.path; if (isRootNode) { onDeleteRoot(); } else if (window.confirm(`Delete "${node.name}"?`)) { const newFiles = deleteNode(files, node.path || node.name); if (newFiles) onRefresh(newFiles); } }; const filteredFiles = searchQuery ? filterTree(files, searchQuery) : files; useEffect(() => { if (searchQuery && files) { const pathsToExpand = collectPathsToExpand(files, searchQuery); if (pathsToExpand.size > 0) { onAutoExpand(pathsToExpand); } } }, [searchQuery, files, onAutoExpand]); const handleDialogConfirm = (value: string) => { if (!dialog) return; if (dialog.type === "rename" && dialog.node) { const parentPath = dialog.node.path?.split("/").slice(0, -1).join("/") || ""; const parentNode = parentPath ? findNode(files, parentPath) : files; if ( parentNode?.children?.some( (c) => c.name.toLowerCase() === value.toLowerCase() && c.path !== dialog.node?.path, ) ) { alert(`"${value}" already exists.`); return; } const newFiles = renameNode( files, dialog.node.path || dialog.node.name, value, ); if (newFiles) { onRefresh(newFiles); } setDialog(null); return; } let parentPath: string; if (!dialog.node) { parentPath = files.path || files.name; } else if (dialog.node.type === "folder") { parentPath = dialog.node.path || dialog.node.name; } else { const pathParts = (dialog.node.path || dialog.node.name).split("/"); pathParts.pop(); parentPath = pathParts.join("/") || files.path || files.name; } const parentNode = findNode(files, parentPath); if ( parentNode?.children?.some( (c) => c.name.toLowerCase() === value.toLowerCase(), ) ) { alert(`"${value}" already exists in this folder.`); setDialog(null); return; } let newFiles: FileNode | null = null; let createdNode: FileNode | null = null; if (dialog.type === "newFile") { createdNode = { name: value, type: "file", content: "" }; newFiles = addNode(files, parentPath, createdNode); } else if (dialog.type === "newFolder") { createdNode = { name: value, type: "folder", children: [] }; newFiles = addNode(files, parentPath, createdNode); } if (newFiles) { const allParentPaths: string[] = []; let current = parentPath; while (current) { allParentPaths.push(current); const parts = current.split("/"); parts.pop(); current = parts.join("/"); } allParentPaths.forEach((p) => { if (!expandedFolders.has(p)) { onToggleFolder(p); } }); onAutoExpand(new Set(allParentPaths)); if (createdNode && createdNode.type === "file") { const findAndOpen = (node: FileNode, name: string): FileNode | null => { if (node.name === name && node.type === "file") return node; if (node.children) { for (const child of node.children) { const found = findAndOpen(child, name); if (found) return found; } } return null; }; const openedFile = findAndOpen(newFiles, value); onRefresh(newFiles, openedFile || undefined); } else { onRefresh(newFiles); } } setDialog(null); }; return (
EXPLORER
{files.name}
{showSearch && (
onSearchChange(e.target.value)} placeholder="Search..." autoFocus style={{ flex: 1, padding: "5px 6px", backgroundColor: "transparent", border: "none", color: "#cccccc", fontSize: "12px", outline: "none", }} /> {searchQuery && ( )}
)}
{filteredFiles ? ( ) : (
No results found
)}
{contextMenu && ( setContextMenu(null)} onNewFile={() => { setDialog({ type: "newFile", node: contextMenu.node }); setContextMenu(null); }} onNewFolder={() => { setDialog({ type: "newFolder", node: contextMenu.node }); setContextMenu(null); }} onRename={() => { setDialog({ type: "rename", node: contextMenu.node }); setContextMenu(null); }} onDelete={() => { if (contextMenu.node) { handleDelete(contextMenu.node); } setContextMenu(null); }} /> )} {dialog && ( setDialog(null)} /> )}
); }; const CodeEditor: React.FC<{ filePath: string; content: string; onChange: (content: string) => void; }> = ({ filePath, content, onChange }) => { const getLanguage = (path: string) => { const ext = path.split(".").pop(); const map: Record = { py: "python", js: "javascript", ts: "typescript", jsx: "javascript", tsx: "typescript", json: "json", md: "markdown", css: "css", html: "html", }; return map[ext || ""] || "plaintext"; }; return (
{filePath ? ( onChange(value || "")} theme="vs-dark" options={{ minimap: { enabled: false }, fontSize: 14, fontFamily: "'Cascadia Code', 'Fira Code', monospace", tabSize: 4, wordWrap: "on", lineNumbers: "on", automaticLayout: true, renderWhitespace: "selection", smoothScrolling: true, }} /> ) : (
Welcome to Web VS Code
Right-click on a folder to create files
Or right-click anywhere in the explorer
)}
); }; export const TestPage: React.FC = () => { const [files, setFiles] = useState(() => addPaths(initialFiles), ); const [openFiles, setOpenFiles] = useState([]); const [activeFile, setActiveFile] = useState(null); const [expandedFolders, setExpandedFolders] = useState>( new Set(["my-project"]), ); const [searchQuery, setSearchQuery] = useState(""); const handleFileSelect = useCallback( (node: FileNode) => { if (node.type === "file") { if (!openFiles.find((f) => f.path === node.path)) { setOpenFiles((prev) => [...prev, node]); } setActiveFile(node); } }, [openFiles], ); const handleContentChange = useCallback( (newContent: string) => { if (activeFile && files) { const updatedActiveFile = { ...activeFile, content: newContent }; setActiveFile(updatedActiveFile); setOpenFiles((prev) => prev.map((f) => (f.path === activeFile.path ? updatedActiveFile : f)), ); } }, [activeFile, files], ); const handleCloseFile = useCallback( (file: FileNode) => { const newOpenFiles = openFiles.filter((f) => f.path !== file.path); setOpenFiles(newOpenFiles); if (activeFile?.path === file.path) { setActiveFile(newOpenFiles[newOpenFiles.length - 1] || null); } }, [openFiles, activeFile], ); const handleCloseAll = useCallback(() => { setOpenFiles([]); setActiveFile(null); }, []); const handleCloseOthers = useCallback((file: FileNode) => { setOpenFiles([file]); setActiveFile(file); }, []); const handleRefresh = useCallback( (newFiles: FileNode | null, newFile?: FileNode) => { setFiles(newFiles); if (!newFiles) { setOpenFiles([]); setActiveFile(null); return; } const updatedOpenFiles = openFiles .map((f) => { const found = findNode(newFiles, f.path || ""); return found && found.type === "file" ? found : null; }) .filter((f): f is FileNode => f !== null); setOpenFiles(updatedOpenFiles); if (newFile) { handleFileSelect(newFile); } else if (activeFile) { const stillExists = findNode(newFiles, activeFile.path || ""); if (!stillExists) { setActiveFile(updatedOpenFiles[updatedOpenFiles.length - 1] || null); } else if (stillExists.type === "file") { setActiveFile(stillExists); } } }, [openFiles, activeFile, handleFileSelect], ); const handleToggleFolder = useCallback((path: string) => { setExpandedFolders((prev) => { const newSet = new Set(prev); if (newSet.has(path)) { newSet.delete(path); } else { newSet.add(path); } return newSet; }); }, []); const handleExpandAll = useCallback(() => { if (files) { const allPaths = getAllFolderPaths(files); setExpandedFolders(new Set(allPaths)); } }, [files]); const handleCollapseAll = useCallback(() => { setExpandedFolders(new Set()); }, []); const handleDeleteRoot = useCallback(() => { setFiles(null); setOpenFiles([]); setActiveFile(null); setExpandedFolders(new Set()); }, []); const handleCreateNewProject = useCallback(() => { const newProject = addPaths(initialFiles); setFiles(newProject); setExpandedFolders(new Set([newProject.path || newProject.name])); setSearchQuery(""); }, []); if (!files) { return (
No project open
Create a new project to get started
main 0 0
{activeFile && ( Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "} {activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"} )} Web VS Code
); } return (
{activeFile ? `${activeFile.name} - ` : ""} {files.name}
setExpandedFolders((prev) => new Set([...prev, ...paths])) } />
main 0 0
{activeFile && ( Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "} {activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"} )} Web VS Code
); };