1689 lines
46 KiB
TypeScript
1689 lines
46 KiB
TypeScript
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 (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
top: y,
|
|
left: x,
|
|
backgroundColor: "#252526",
|
|
border: "1px solid #3e3e42",
|
|
borderRadius: "6px",
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
zIndex: 1000,
|
|
minWidth: "180px",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<MenuItem onClick={onNewFile}>
|
|
<FiFile /> New File
|
|
</MenuItem>
|
|
<MenuItem onClick={onNewFolder}>
|
|
<FiFolder /> New Folder
|
|
</MenuItem>
|
|
{node && (
|
|
<>
|
|
<div
|
|
style={{
|
|
height: "1px",
|
|
backgroundColor: "#3e3e42",
|
|
margin: "4px 0",
|
|
}}
|
|
/>
|
|
<MenuItem onClick={onRename}>
|
|
<FiEdit3 /> Rename
|
|
</MenuItem>
|
|
<MenuItem onClick={onDelete} danger>
|
|
<FiTrash2 /> Delete
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const MenuItem: React.FC<{
|
|
onClick: () => void;
|
|
danger?: boolean;
|
|
children: React.ReactNode;
|
|
}> = ({ onClick, danger, children }) => (
|
|
<div
|
|
onClick={onClick}
|
|
style={{
|
|
padding: "8px 16px",
|
|
cursor: "pointer",
|
|
color: danger ? "#f48771" : "#cccccc",
|
|
fontSize: "13px",
|
|
transition: "background-color 0.1s",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "10px",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
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<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
inputRef.current?.focus();
|
|
inputRef.current?.select();
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0,0,0,0.6)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
zIndex: 2000,
|
|
}}
|
|
onClick={onCancel}
|
|
>
|
|
<div
|
|
style={{
|
|
backgroundColor: "#2d2d30",
|
|
borderRadius: "8px",
|
|
padding: "24px",
|
|
minWidth: "320px",
|
|
border: "1px solid #3e3e42",
|
|
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<h3
|
|
style={{
|
|
margin: "0 0 8px 0",
|
|
color: "#fff",
|
|
fontSize: "16px",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{title}
|
|
</h3>
|
|
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
|
Enter a new name
|
|
</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => 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",
|
|
}}
|
|
/>
|
|
<div
|
|
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
|
|
>
|
|
<button
|
|
onClick={onCancel}
|
|
style={{
|
|
padding: "6px 16px",
|
|
backgroundColor: "transparent",
|
|
border: "1px solid #0e639c",
|
|
borderRadius: "4px",
|
|
color: "#0e639c",
|
|
cursor: "pointer",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => value.trim() && onConfirm(value.trim())}
|
|
style={{
|
|
padding: "6px 16px",
|
|
backgroundColor: "#0e639c",
|
|
border: "none",
|
|
borderRadius: "4px",
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
OK
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<string> => {
|
|
const paths = new Set<string>();
|
|
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<string>;
|
|
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)}
|
|
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
|
|
{text.slice(idx, idx + query.length)}
|
|
</span>
|
|
{text.slice(idx + query.length)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div
|
|
onClick={handleClick}
|
|
onContextMenu={(e) => 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",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontSize: "14px",
|
|
width: "16px",
|
|
textAlign: "center",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{isFolder ? (
|
|
isExpanded ? (
|
|
<FiChevronDown />
|
|
) : (
|
|
<FiChevronRight />
|
|
)
|
|
) : (
|
|
<GoFile />
|
|
)}
|
|
</span>
|
|
<span
|
|
style={{
|
|
flex: 1,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
|
|
</span>
|
|
{hovered && !isRoot && (
|
|
<button
|
|
onClick={handleDelete}
|
|
title={`Delete ${node.name}`}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#858585",
|
|
cursor: "pointer",
|
|
padding: "2px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderRadius: "3px",
|
|
flexShrink: 0,
|
|
width: "20px",
|
|
height: "20px",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = "#f48771";
|
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = "#858585";
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
>
|
|
<FiTrash2 size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{isFolder && isExpanded && node.children && (
|
|
<div>
|
|
{node.children.map((child, idx) => (
|
|
<FileTreeItem
|
|
key={idx}
|
|
node={child}
|
|
level={level + 1}
|
|
onFileSelect={onFileSelect}
|
|
selectedFile={selectedFile}
|
|
onContextMenu={onContextMenu}
|
|
expandedFolders={expandedFolders}
|
|
onToggleFolder={onToggleFolder}
|
|
onDelete={onDelete}
|
|
searchQuery={searchQuery}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
backgroundColor: "#1e1e1e",
|
|
borderBottom: "1px solid #3e3e42",
|
|
overflowX: "auto",
|
|
minHeight: "40px",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
padding: "0 12px",
|
|
gap: "8px",
|
|
borderRight: "1px solid #3e3e42",
|
|
height: "100%",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<button
|
|
onClick={onCloseAll}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
color: "#cccccc",
|
|
cursor: "pointer",
|
|
fontSize: "14px",
|
|
padding: "6px 8px",
|
|
borderRadius: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
transition: "all 0.1s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
title="Close All"
|
|
>
|
|
<MdClose size={14} />
|
|
<span style={{ fontSize: "11px" }}>Close All</span>
|
|
</button>
|
|
</div>
|
|
{openFiles.map((file) => (
|
|
<div
|
|
key={file.path}
|
|
onClick={() => 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",
|
|
}}
|
|
>
|
|
<GoFile />
|
|
<span>{file.name}</span>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCloseFile(file);
|
|
}}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#858585",
|
|
cursor: "pointer",
|
|
fontSize: "16px",
|
|
padding: "0 4px",
|
|
borderRadius: "4px",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.color = "#fff";
|
|
e.currentTarget.style.backgroundColor = "#3e3e42";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.color = "#858585";
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
>
|
|
<MdClose size={14} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{showContextMenu && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
top: showContextMenu.y,
|
|
left: showContextMenu.x,
|
|
backgroundColor: "#252526",
|
|
border: "1px solid #3e3e42",
|
|
borderRadius: "6px",
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
zIndex: 1000,
|
|
minWidth: "160px",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div
|
|
onClick={() => {
|
|
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
|
|
</div>
|
|
<div
|
|
onClick={() => {
|
|
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
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const TitleBar: React.FC = () => {
|
|
return (
|
|
<div
|
|
style={{
|
|
height: "32px",
|
|
backgroundColor: "#2d2d30",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0 12px",
|
|
borderBottom: "1px solid #3e3e42",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
<div style={{ display: "flex", gap: "8px" }}>
|
|
<div
|
|
style={{
|
|
width: "12px",
|
|
height: "12px",
|
|
borderRadius: "50%",
|
|
backgroundColor: "#ed6a5e",
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: "12px",
|
|
height: "12px",
|
|
borderRadius: "50%",
|
|
backgroundColor: "#f5bd4f",
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
width: "12px",
|
|
height: "12px",
|
|
borderRadius: "50%",
|
|
backgroundColor: "#61c454",
|
|
}}
|
|
/>
|
|
</div>
|
|
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
|
|
Web VS Code
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
<FiGitBranch size={12} color="#858585" />
|
|
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
|
|
<FiCheckCircle size={12} color="#61c454" />
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FileExplorer: React.FC<{
|
|
files: FileNode;
|
|
onFileSelect: (node: FileNode) => void;
|
|
selectedFile: string | null;
|
|
onRefresh: (newFiles: FileNode | null, newFile?: FileNode) => void;
|
|
expandedFolders: Set<string>;
|
|
onToggleFolder: (path: string) => void;
|
|
onExpandAll: () => void;
|
|
onCollapseAll: () => void;
|
|
onDeleteRoot: () => void;
|
|
searchQuery: string;
|
|
onSearchChange: (query: string) => void;
|
|
onAutoExpand: (paths: Set<string>) => 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 (
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: "#252526",
|
|
}}
|
|
onContextMenu={handleEmptyContextMenu}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: "0 8px",
|
|
height: "35px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
borderBottom: "1px solid #3e3e42",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
color: "#bbbbbb",
|
|
fontWeight: 500,
|
|
fontSize: "11px",
|
|
letterSpacing: "0.8px",
|
|
}}
|
|
>
|
|
EXPLORER
|
|
</span>
|
|
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
|
|
<button
|
|
onClick={() => setShowSearch(!showSearch)}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
color: showSearch ? "#cccccc" : "#858585",
|
|
cursor: "pointer",
|
|
padding: "4px",
|
|
borderRadius: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
transition: "all 0.1s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}}
|
|
title="Search in files"
|
|
>
|
|
<FiSearch size={14} />
|
|
</button>
|
|
<button
|
|
onClick={onCollapseAll}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
color: "#858585",
|
|
cursor: "pointer",
|
|
padding: "4px",
|
|
borderRadius: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
transition: "all 0.1s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
e.currentTarget.style.color = "#cccccc";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#858585";
|
|
}}
|
|
title="Collapse All"
|
|
>
|
|
<FiMinus size={14} />
|
|
</button>
|
|
<button
|
|
onClick={onExpandAll}
|
|
style={{
|
|
background: "transparent",
|
|
border: "none",
|
|
color: "#858585",
|
|
cursor: "pointer",
|
|
padding: "4px",
|
|
borderRadius: "4px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
transition: "all 0.1s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
e.currentTarget.style.color = "#cccccc";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
e.currentTarget.style.color = "#858585";
|
|
}}
|
|
title="Expand All"
|
|
>
|
|
<GoKebabHorizontal size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
padding: "6px 12px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "6px",
|
|
borderBottom: "1px solid #3e3e42",
|
|
}}
|
|
>
|
|
<FiFolder size={14} color="#858585" />
|
|
<span
|
|
style={{
|
|
color: "#cccccc",
|
|
fontWeight: 600,
|
|
fontSize: "11px",
|
|
letterSpacing: "0.3px",
|
|
flex: 1,
|
|
}}
|
|
>
|
|
{files.name}
|
|
</span>
|
|
</div>
|
|
|
|
{showSearch && (
|
|
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
backgroundColor: "#3c3c3c",
|
|
border: searchQuery
|
|
? "1px solid #007acc"
|
|
: "1px solid transparent",
|
|
borderRadius: "4px",
|
|
padding: "0 6px",
|
|
transition: "border-color 0.1s",
|
|
}}
|
|
>
|
|
<FiSearch size={13} color="#858585" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
placeholder="Search..."
|
|
autoFocus
|
|
style={{
|
|
flex: 1,
|
|
padding: "5px 6px",
|
|
backgroundColor: "transparent",
|
|
border: "none",
|
|
color: "#cccccc",
|
|
fontSize: "12px",
|
|
outline: "none",
|
|
}}
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => onSearchChange("")}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#858585",
|
|
cursor: "pointer",
|
|
padding: "2px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
}}
|
|
>
|
|
<MdClose size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ flex: 1, overflowY: "auto" }}>
|
|
{filteredFiles ? (
|
|
<FileTreeItem
|
|
node={filteredFiles}
|
|
level={0}
|
|
onFileSelect={onFileSelect}
|
|
selectedFile={selectedFile}
|
|
onContextMenu={handleNodeContextMenu}
|
|
expandedFolders={expandedFolders}
|
|
onToggleFolder={onToggleFolder}
|
|
onDelete={handleDelete}
|
|
isRoot
|
|
searchQuery={searchQuery}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
padding: "16px",
|
|
color: "#858585",
|
|
fontSize: "13px",
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
No results found
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{contextMenu && (
|
|
<ContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
node={contextMenu.node}
|
|
onClose={() => 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 && (
|
|
<InputDialog
|
|
title={
|
|
dialog.type === "newFile"
|
|
? "New File"
|
|
: dialog.type === "newFolder"
|
|
? "New Folder"
|
|
: "Rename"
|
|
}
|
|
initialValue={
|
|
dialog.type === "rename" && dialog.node ? dialog.node.name : ""
|
|
}
|
|
onConfirm={handleDialogConfirm}
|
|
onCancel={() => setDialog(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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<string, string> = {
|
|
py: "python",
|
|
js: "javascript",
|
|
ts: "typescript",
|
|
jsx: "javascript",
|
|
tsx: "typescript",
|
|
json: "json",
|
|
md: "markdown",
|
|
css: "css",
|
|
html: "html",
|
|
};
|
|
return map[ext || ""] || "plaintext";
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: "#1e1e1e",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1 }}>
|
|
{filePath ? (
|
|
<Editor
|
|
height="100%"
|
|
language={getLanguage(filePath)}
|
|
value={content}
|
|
onChange={(value) => 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,
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
height: "100%",
|
|
color: "#858585",
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<div>
|
|
<div
|
|
style={{
|
|
marginBottom: "24px",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
opacity: 0.5,
|
|
}}
|
|
>
|
|
<FiFolder size={64} />
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "18px",
|
|
marginBottom: "12px",
|
|
color: "#cccccc",
|
|
}}
|
|
>
|
|
Welcome to Web VS Code
|
|
</div>
|
|
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
|
|
Right-click on a folder to create files
|
|
</div>
|
|
<div style={{ fontSize: "12px", color: "#0e639c" }}>
|
|
Or right-click anywhere in the explorer
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const TestPage: React.FC = () => {
|
|
const [files, setFiles] = useState<FileNode | null>(() =>
|
|
addPaths(initialFiles),
|
|
);
|
|
const [openFiles, setOpenFiles] = useState<FileNode[]>([]);
|
|
const [activeFile, setActiveFile] = useState<FileNode | null>(null);
|
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
|
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 (
|
|
<div
|
|
style={{
|
|
height: "100vh",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: "#1e1e1e",
|
|
fontFamily:
|
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
}}
|
|
>
|
|
<TitleBar />
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<div style={{ textAlign: "center" }}>
|
|
<div
|
|
style={{
|
|
marginBottom: "24px",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
opacity: 0.3,
|
|
}}
|
|
>
|
|
<GoTrash size={72} />
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "22px",
|
|
marginBottom: "12px",
|
|
color: "#cccccc",
|
|
fontWeight: 300,
|
|
}}
|
|
>
|
|
No project open
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "13px",
|
|
marginBottom: "32px",
|
|
color: "#858585",
|
|
}}
|
|
>
|
|
Create a new project to get started
|
|
</div>
|
|
<button
|
|
onClick={handleCreateNewProject}
|
|
style={{
|
|
padding: "10px 24px",
|
|
backgroundColor: "#0e639c",
|
|
border: "none",
|
|
borderRadius: "4px",
|
|
color: "#fff",
|
|
cursor: "pointer",
|
|
fontSize: "13px",
|
|
fontWeight: 500,
|
|
transition: "background-color 0.1s",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#1177bb";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.backgroundColor = "#0e639c";
|
|
}}
|
|
>
|
|
<MdAdd size={14} /> New Project
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
height: "22px",
|
|
backgroundColor: "#007acc",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0 12px",
|
|
fontSize: "12px",
|
|
color: "#ffffff",
|
|
userSelect: "none",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
|
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
|
<FiGitBranch size={12} /> main
|
|
</span>
|
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
|
{activeFile && (
|
|
<span>
|
|
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
|
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
|
</span>
|
|
)}
|
|
<span>Web VS Code</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
height: "100vh",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
backgroundColor: "#1e1e1e",
|
|
fontFamily:
|
|
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
height: "30px",
|
|
backgroundColor: "#323233",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderBottom: "1px solid #1e1e1e",
|
|
fontSize: "12px",
|
|
color: "#cccccc",
|
|
userSelect: "none",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span style={{ fontWeight: 400 }}>
|
|
{activeFile ? `${activeFile.name} - ` : ""}
|
|
{files.name}
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
|
<div style={{ width: "260px", flexShrink: 0 }}>
|
|
<FileExplorer
|
|
files={files}
|
|
onFileSelect={handleFileSelect}
|
|
selectedFile={activeFile?.path || null}
|
|
onRefresh={handleRefresh}
|
|
expandedFolders={expandedFolders}
|
|
onToggleFolder={handleToggleFolder}
|
|
onExpandAll={handleExpandAll}
|
|
onCollapseAll={handleCollapseAll}
|
|
onDeleteRoot={handleDeleteRoot}
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
onAutoExpand={(paths) =>
|
|
setExpandedFolders((prev) => new Set([...prev, ...paths]))
|
|
}
|
|
/>
|
|
</div>
|
|
<div
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<TabBar
|
|
openFiles={openFiles}
|
|
activeFile={activeFile}
|
|
onSelectFile={setActiveFile}
|
|
onCloseFile={handleCloseFile}
|
|
onCloseAll={handleCloseAll}
|
|
onCloseOthers={handleCloseOthers}
|
|
/>
|
|
<CodeEditor
|
|
filePath={activeFile?.path || ""}
|
|
content={activeFile?.content || ""}
|
|
onChange={handleContentChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
style={{
|
|
height: "22px",
|
|
backgroundColor: "#007acc",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0 12px",
|
|
fontSize: "12px",
|
|
color: "#ffffff",
|
|
userSelect: "none",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
|
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
|
<FiGitBranch size={12} /> main
|
|
</span>
|
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
|
</span>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
|
{activeFile && (
|
|
<span>
|
|
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
|
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
|
</span>
|
|
)}
|
|
<span>Web VS Code</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|