UNPKG

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
// 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