file-surf
Version:
A React package that extends Monaco Editor with VS Code-like file explorer capabilities, allowing users to navigate through multiple files and folders with a familiar interface
524 lines (520 loc) • 18.5 kB
JavaScript
// src/components/FileExplorer/index.tsx
import { useState as useState3, useEffect as useEffect2, useRef as useRef2 } from "react";
// src/components/FileExplorer/FileTree.tsx
import { useState } from "react";
import { ChevronRight, ChevronDown, File, Folder, FolderOpen } from "lucide-react";
import { jsx, jsxs } from "react/jsx-runtime";
var FileTree = ({
files,
onFileSelect,
selectedFile,
explorerWidth
}) => {
const [expandedFolders, setExpandedFolders] = useState(/* @__PURE__ */ new Set(["project", "project/src"]));
const toggleFolder = (path, e) => {
e.stopPropagation();
setExpandedFolders((prevExpanded) => {
const newExpanded = new Set(prevExpanded);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
return newExpanded;
});
};
const buildTree = () => {
const rootNodes = [];
const nodesByParent = /* @__PURE__ */ new Map();
for (const fileMap of files.values()) {
if (!nodesByParent.has(fileMap.parentPath)) {
nodesByParent.set(fileMap.parentPath, []);
}
nodesByParent.get(fileMap.parentPath)?.push(fileMap);
}
const rootItems = nodesByParent.get(null) || [];
rootNodes.push(...rootItems);
const renderNode = (fileMap, level = 0) => {
const { node, path } = fileMap;
const isFolder = node.type === "folder";
const isExpanded = isFolder && expandedFolders.has(path);
const isActive = path === selectedFile;
const basePadding = level * 12;
const fileExtraPadding = !isFolder ? 16 : 0;
const paddingLeft = basePadding + (isFolder ? 0 : fileExtraPadding);
return /* @__PURE__ */ jsxs("div", { className: "overflow-hidden", children: [
/* @__PURE__ */ jsx(
"div",
{
className: `file-item truncate ${isActive ? "active" : ""}`,
style: { paddingLeft: `${paddingLeft}px` },
onClick: (e) => {
if (isFolder) {
toggleFolder(path, e);
} else {
onFileSelect(path);
}
},
children: /* @__PURE__ */ jsxs("span", { className: "flex items-center w-full", children: [
isFolder && /* @__PURE__ */ jsx("span", { onClick: (e) => toggleFolder(path, e), className: "mr-1 cursor-pointer", children: isExpanded ? /* @__PURE__ */ jsx(ChevronDown, { className: "h-4 w-4 file-icon" }) : /* @__PURE__ */ jsx(ChevronRight, { className: "h-4 w-4 file-icon" }) }),
/* @__PURE__ */ jsx("span", { className: "file-icon", children: isFolder ? isExpanded ? /* @__PURE__ */ jsx(FolderOpen, { className: "h-4 w-4" }) : /* @__PURE__ */ jsx(Folder, { className: "h-4 w-4" }) : /* @__PURE__ */ jsx(File, { className: "h-4 w-4" }) }),
/* @__PURE__ */ jsx("span", { className: "truncate", style: { maxWidth: `${explorerWidth - paddingLeft - 50}px` }, children: node.name })
] })
}
),
isFolder && isExpanded && /* @__PURE__ */ jsx("div", { className: "transition-all ease-in-out duration-200", children: (nodesByParent.get(path) || []).sort((a, b) => {
if (a.node.type === "folder" && b.node.type !== "folder") return -1;
if (a.node.type !== "folder" && b.node.type === "folder") return 1;
return a.node.name.localeCompare(b.node.name);
}).map((child) => renderNode(child, level + 1)) })
] }, path);
};
rootNodes.sort((a, b) => {
if (a.node.type === "folder" && b.node.type !== "folder") return -1;
if (a.node.type !== "folder" && b.node.type === "folder") return 1;
return a.node.name.localeCompare(b.node.name);
});
return rootNodes.map((node) => renderNode(node));
};
return /* @__PURE__ */ jsx("div", { className: "file-tree scrollbar-none select-none", style: { width: `${explorerWidth}px` }, children: buildTree() });
};
var FileTree_default = FileTree;
// src/components/FileExplorer/MonacoEditor.tsx
import { useEffect, useRef, useState as useState2 } from "react";
import * as monaco from "monaco-editor";
import { X } from "lucide-react";
import * as Monaco from "monaco-editor";
import { loader } from "@monaco-editor/react";
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
loader.config({ monaco });
loader.init().then(
/* monaco is loaded */
);
var MonacoEditor = ({
files,
selectedFile,
openTabs,
activeTab,
onTabChange,
onTabClose,
highlightedFile
}) => {
const editorRef = useRef(null);
const monacoEl = useRef(null);
const [fileModels, setFileModels] = useState2(/* @__PURE__ */ new Map());
const getLanguage = (filename) => {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const langMap = {
"js": "javascript",
"jsx": "javascript",
"ts": "typescript",
"tsx": "typescript",
"html": "html",
"css": "css",
"json": "json",
"md": "markdown",
"py": "python",
"java": "java",
"c": "c",
"cpp": "cpp",
"go": "go",
"rs": "rust",
"php": "php",
"rb": "ruby",
"sh": "shell",
"yml": "yaml",
"yaml": "yaml",
"xml": "xml",
"sql": "sql",
"graphql": "graphql"
};
return langMap[ext] || "plaintext";
};
const getOrCreateModel = (path, content = "") => {
if (fileModels.has(path)) {
return fileModels.get(path);
}
const file = files.get(path);
if (!file) return null;
const language = getLanguage(file.node.name);
const uri = Monaco.Uri.parse(`file:///${path}`);
const existingModel = Monaco.editor.getModel(uri);
if (existingModel) {
if (file.node.content !== existingModel.getValue()) {
existingModel.setValue(file.node.content || "");
}
setFileModels((prev) => {
const newModels = new Map(prev);
newModels.set(path, existingModel);
return newModels;
});
return existingModel;
}
try {
const model = Monaco.editor.createModel(
file.node.content || content,
language,
uri
);
setFileModels((prev) => {
const newModels = new Map(prev);
newModels.set(path, model);
return newModels;
});
return model;
} catch (error) {
console.error("Error creating model:", error);
return null;
}
};
useEffect(() => {
if (monacoEl.current && !editorRef.current) {
editorRef.current = Monaco.editor.create(monacoEl.current, {
automaticLayout: true,
theme: "vs-dark",
minimap: { enabled: true },
scrollBeyondLastLine: false,
fontSize: 14,
fontFamily: "JetBrains Mono, Menlo, Monaco, Consolas, monospace",
lineNumbers: "on",
wordWrap: "on",
renderLineHighlight: "all",
cursorBlinking: "smooth",
cursorSmoothCaretAnimation: "on",
smoothScrolling: true,
tabSize: 2,
readOnly: false
});
}
return () => {
editorRef.current?.dispose();
};
}, []);
useEffect(() => {
if (!editorRef.current || !activeTab) return;
const file = files.get(activeTab);
if (!file) return;
const model = getOrCreateModel(activeTab, file.node.content || "");
if (model) {
editorRef.current.setModel(model);
}
}, [activeTab, files]);
useEffect(() => {
if (!editorRef.current || !highlightedFile || activeTab !== highlightedFile) return;
const model = editorRef.current.getModel();
if (!model) return;
const lineCount = model.getLineCount();
const decorations = editorRef.current.deltaDecorations([], [
{
range: new Monaco.Range(1, 1, lineCount, model.getLineMaxColumn(lineCount)),
options: {
isWholeLine: true,
className: "highlight-animation"
}
}
]);
const timeout = setTimeout(() => {
editorRef.current?.deltaDecorations(decorations, []);
}, 1e3);
return () => clearTimeout(timeout);
}, [highlightedFile, activeTab]);
useEffect(() => {
for (const [path, file] of files.entries()) {
if (file.node.type === "file" && fileModels.has(path)) {
const model = fileModels.get(path);
if (model && file.node.content !== model.getValue()) {
model.setValue(file.node.content || "");
}
}
}
}, [files, fileModels]);
return /* @__PURE__ */ jsxs2("div", { className: "editor-container", children: [
/* @__PURE__ */ jsx2("div", { className: "tabs-container h-[2.6rem] flex items-center ", children: openTabs.map((tab) => {
const file = files.get(tab);
if (!file) return null;
return /* @__PURE__ */ jsxs2(
"div",
{
className: `tab ${activeTab === tab ? "active" : ""}`,
onClick: () => onTabChange(tab),
children: [
/* @__PURE__ */ jsx2("span", { className: "truncate max-w-[150px]", children: file.node.name }),
/* @__PURE__ */ jsx2(
"button",
{
className: "tab-close",
onClick: (e) => {
e.stopPropagation();
onTabClose(tab);
},
children: /* @__PURE__ */ jsx2(X, { size: 16 })
}
)
]
},
tab
);
}) }),
/* @__PURE__ */ jsx2("div", { ref: monacoEl, className: "monaco-editor-container" })
] });
};
var MonacoEditor_default = MonacoEditor;
// src/components/FileExplorer/index.tsx
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
var FileSurf = ({
files,
height = "100vh",
width = "100vw",
theme = "dark"
}) => {
const [selectedFile, setSelectedFile] = useState3(null);
const [openTabs, setOpenTabs] = useState3([]);
const [activeTab, setActiveTab] = useState3(null);
const [highlightedFile, setHighlightedFile] = useState3(null);
const [explorerWidth, setExplorerWidth] = useState3(250);
const [isResizing, setIsResizing] = useState3(false);
const resizerRef = useRef2(null);
const handleFileSelect = (path) => {
const file = files.get(path);
if (file && file.node.type === "file") {
setSelectedFile(path);
if (!openTabs.includes(path)) {
setOpenTabs((prev) => [...prev, path]);
}
setActiveTab(path);
}
};
const handleTabChange = (path) => {
setActiveTab(path);
setSelectedFile(path);
};
const handleTabClose = (path) => {
setOpenTabs((prev) => prev.filter((tab) => tab !== path));
if (activeTab === path) {
const newTabs = openTabs.filter((tab) => tab !== path);
if (newTabs.length > 0) {
setActiveTab(newTabs[newTabs.length - 1]);
setSelectedFile(newTabs[newTabs.length - 1]);
} else {
setActiveTab(null);
setSelectedFile(null);
}
}
};
const handleMouseDown = () => {
setIsResizing(true);
};
useEffect2(() => {
const handleMouseMove = (e) => {
if (!isResizing) return;
let newWidth = e.clientX;
if (newWidth < 100) newWidth = 100;
if (newWidth > 500) newWidth = 500;
setExplorerWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing]);
useEffect2(() => {
const fileEntries = Array.from(files.entries());
const fileContents = /* @__PURE__ */ new Map();
for (const [path, fileMap] of fileEntries) {
if (fileMap.node.type === "file") {
fileContents.set(path, fileMap.node.content || "");
}
}
const interval = setInterval(() => {
for (const [path, fileMap] of fileEntries) {
if (fileMap.node.type === "file") {
const oldContent = fileContents.get(path);
const newContent = fileMap.node.content || "";
if (oldContent !== newContent) {
fileContents.set(path, newContent);
setHighlightedFile(path);
if (!openTabs.includes(path)) {
setOpenTabs((prev) => [...prev, path]);
setActiveTab(path);
} else {
setActiveTab(path);
}
setTimeout(() => {
setHighlightedFile(null);
}, 1500);
break;
}
}
}
}, 500);
return () => clearInterval(interval);
}, [files, openTabs]);
return /* @__PURE__ */ jsxs3(
"div",
{
className: "file-explorer flex ",
style: { height: typeof height === "number" ? `${height}px` : height, width: typeof width === "number" ? `${width}px` : width },
children: [
/* @__PURE__ */ jsxs3("div", { style: { width: `${explorerWidth}px` }, className: "flex-shrink-0", children: [
/* @__PURE__ */ jsx3("div", { className: "px-4 py-auto h-[2.5rem] flex items-center justify-center font-medium tracking-wider border-b border-explorer-border bg-explorer-background uppercase text-sm", children: "Explorer" }),
/* @__PURE__ */ jsx3(
FileTree_default,
{
files,
onFileSelect: handleFileSelect,
selectedFile,
explorerWidth
}
)
] }),
/* @__PURE__ */ jsx3(
"div",
{
ref: resizerRef,
className: "resize-handle cursor-col-resize",
onMouseDown: handleMouseDown
}
),
/* @__PURE__ */ jsx3("div", { className: "flex-grow h-full bg-editor-background", children: activeTab ? /* @__PURE__ */ jsx3(
MonacoEditor_default,
{
files,
selectedFile,
openTabs,
activeTab,
onTabChange: handleTabChange,
onTabClose: handleTabClose,
highlightedFile
}
) : /* @__PURE__ */ jsx3("div", { className: "flex items-center justify-center h-full text-explorer-foreground", children: /* @__PURE__ */ jsxs3("div", { className: "text-center", children: [
/* @__PURE__ */ jsx3("p", { className: "text-xl font-light", children: "Select a file to view" }),
/* @__PURE__ */ jsx3("p", { className: "text-sm opacity-50 mt-2", children: "No file is currently open" })
] }) }) })
]
}
);
};
var FileExplorer_default = FileSurf;
// src/hooks/useFileExplorer.ts
import { useState as useState4, useEffect as useEffect3, useRef as useRef3 } from "react";
var useFileSurf = (initialFiles) => {
const [files, setFiles] = useState4(/* @__PURE__ */ new Map());
const initialized = useRef3(false);
useEffect3(() => {
if (initialized.current) return;
const fileMap = /* @__PURE__ */ new Map();
const processNode = (node, path, parentPath) => {
fileMap.set(path, {
node: { ...node },
path,
parentPath
});
if (node.type === "folder" && node.children) {
node.children.forEach((child) => {
const childPath = `${path}/${child.name}`;
processNode(child, childPath, path);
});
}
};
processNode(initialFiles, initialFiles.name, null);
setFiles(fileMap);
initialized.current = true;
}, [initialFiles]);
const addFile = (parentPath, newFile) => {
setFiles((prevFiles) => {
const newFiles = new Map(prevFiles);
const parent = newFiles.get(parentPath);
if (!parent || parent.node.type !== "folder") {
console.error(`Cannot add to ${parentPath}. Parent doesn't exist or isn't a folder.`);
return prevFiles;
}
if (!parent.node.children) {
parent.node.children = [];
}
parent.node.children.push(newFile);
const newPath = `${parentPath}/${newFile.name}`;
newFiles.set(newPath, {
node: newFile,
path: newPath,
parentPath
});
if (newFile.type === "folder" && newFile.children) {
newFile.children.forEach((child) => {
const childPath = `${newPath}/${child.name}`;
newFiles.set(childPath, {
node: child,
path: childPath,
parentPath: newPath
});
if (child.type === "folder" && child.children) {
const processChildren = (node, path) => {
node.children?.forEach((grandchild) => {
const grandchildPath = `${path}/${grandchild.name}`;
newFiles.set(grandchildPath, {
node: grandchild,
path: grandchildPath,
parentPath: path
});
if (grandchild.type === "folder" && grandchild.children) {
processChildren(grandchild, grandchildPath);
}
});
};
processChildren(child, childPath);
}
});
}
return newFiles;
});
};
const updateFile = (filePath, newContent) => {
setFiles((prevFiles) => {
const newFiles = new Map(prevFiles);
const file = newFiles.get(filePath);
if (!file || file.node.type !== "file") {
console.error(`Cannot update ${filePath}. File doesn't exist or isn't a file.`);
return prevFiles;
}
file.node.content = newContent;
return newFiles;
});
};
const deleteFile = (filePath) => {
setFiles((prevFiles) => {
const newFiles = new Map(prevFiles);
const file = newFiles.get(filePath);
if (!file) {
console.error(`Cannot delete ${filePath}. Path doesn't exist.`);
return prevFiles;
}
newFiles.delete(filePath);
if (file.parentPath) {
const parent = newFiles.get(file.parentPath);
if (parent && parent.node.children) {
parent.node.children = parent.node.children.filter((child) => child.name !== file.node.name);
}
}
if (file.node.type === "folder") {
for (const [path] of newFiles) {
if (path.startsWith(`${filePath}/`)) {
newFiles.delete(path);
}
}
}
return newFiles;
});
};
return { files, addFile, updateFile, deleteFile };
};
export {
FileExplorer_default as FileSurf,
useFileSurf
};
//# sourceMappingURL=index.js.map