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 (
{node && (
<>
>
)}
);
};
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 (
);
};
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
);
};