纯前端本地文件管理器(VSCode风格)(浏览器对本地文件增删改查)
纯前端本地文件管理器(VSCode风格)(浏览器对本地文件增删改查)
简介
本项目为一个纯前端实现的本地文件管理器网页(index.html),可在 Chrome/Edge 浏览器中直接打开,具备类似 VSCode 的本地文件夹操作体验。
无需后端,所有功能均在浏览器端实现。
主要功能
-
选择本地文件夹
- 用户点击左上角文件夹按钮,授权后可浏览和操作本地文件夹内容。
-
文件树展示
- 以树形结构展示所选文件夹下的所有文件和子文件夹。
- 文件夹和文件有不同图标区分。
- 文件夹默认收缩,点击可展开/收缩子项。
- 每个目录下,文件夹排在前,文件排在后,均按名称排序。
-
文件/文件夹操作
- 新建文件、新建文件夹(在当前选中目录下)
- 编辑文件(支持多语言代码高亮,编辑区为 CodeMirror 5)
- 保存文件
- 删除文件/文件夹(支持递归删除文件夹)
- 重命名文件/文件夹(文件夹为递归复制+删除实现)
-
编辑器体验
- 编辑区为 CodeMirror 5,支持多种主流编程语言高亮(js、py、html、css、md、c/c++/java等)。
- 编辑区可编辑,支持行号、自动换行。
- 保存按钮为图标形式,点击后将编辑内容写回本地文件。
-
界面与交互
- 所有操作按钮均为小图标,无文字,简洁美观。
- 文件树和编辑区自适应布局,支持多层嵌套目录。
- 状态栏实时提示操作结果(如保存成功、删除失败等)。
技术说明
- 前端技术:原生 HTML + CSS + JavaScript
- 代码高亮:CodeMirror 5(通过 unpkg CDN 引入)
- 本地文件操作:File System Access API(需用户授权,支持 Chrome/Edge)
- 无需后端,所有数据均在本地浏览器内存和本地文件系统中操作
使用方法
- 用 Chrome 或 Edge 浏览器打开
index.html
- 点击左上角“选择文件夹”图标,授权访问本地文件夹
- 在左侧文件树中浏览、增删改查文件和文件夹
- 编辑文件后,点击保存图标即可写回本地
注意事项
- 仅支持支持 File System Access API 的浏览器(如 Chrome、Edge)
- 仅能操作用户授权的文件夹及其子文件
- 由于浏览器安全限制,网页无法自动访问任意本地文件夹,需每次手动授权
- 编辑器高亮支持的语言可根据需要扩展
依赖
- CodeMirror 5
通过 unpkg CDN 引入,无需本地安装
主要文件结构
index.html
主页面,包含所有功能和样式,无需其他依赖文件
如需二次开发或自定义功能,可直接修改 index.html
,所有逻辑均在本文件内实现。
如需支持更多语言高亮,可在 <head>
中引入更多 CodeMirror 5 的 mode 脚本。
代码如下 :
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>本地文件管理器 Demo</title><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>body { font-family: Arial, sans-serif; margin: 0; padding: 0; background: #f5f5f5; }header { background: #222; color: #fff; padding: 0.2em; text-align: center; display: flex; justify-content: center; align-items: center;}#container { display: flex; height: 90vh; }#sidebar { width: 320px; background: #fff; border-right: 1px solid #ddd; overflow-y: auto; padding: 1em; }#main { flex: 1; padding: 1em; display: flex; flex-direction: column; }#fileTree ul { list-style: none; padding-left: 1em; }#fileTree li { margin: 2px 0; cursor: pointer; display: flex; flex-direction: column; align-items: stretch; }#fileTree .row { display: flex; align-items: center; }#fileTree li.selected { background: #e0e7ff; }.actions { margin-left: auto; display: flex; }.actions button { background: none; border: none; padding: 2px; margin-right: 2px; cursor: pointer; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; }.actions button:last-child { margin-right: 0; }.actions button svg { width: 18px; height: 18px; }#editor { flex: 1; width: 100%; margin-top: 1em; font-family: monospace; font-size: 1em; }#saveBtn { margin-top: 0.5em; background: none; border: none; cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; }#saveBtn svg { width: 22px; height: 22px; }.folder { font-weight: bold; }.file { color: #333; }.hidden { display: none; }#status { color: #888; font-size: 0.9em; margin-top: 0.5em; }#toolbar { margin-bottom: 1em; display: flex; }#toolbar button { background: none; border: none; margin-right: 0.5em; cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; }#toolbar button svg { width: 22px; height: 22px; }.tree-icon { width: 18px; height: 18px; margin-right: 4px; flex-shrink: 0; }.caret { width: 14px; height: 14px; margin-right: 2px; transition: transform 0.2s; }.caret.collapsed { transform: rotate(-90deg); }.caret.expanded { transform: rotate(0deg); }.caret.invisible { opacity: 0; }</style><!-- CodeMirror 5 --><link rel="stylesheet" href="https://unpkg.com/codemirror@5.65.16/lib/codemirror.css"><script src="https://unpkg.com/codemirror@5.65.16/lib/codemirror.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/javascript/javascript.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/python/python.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/htmlmixed/htmlmixed.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/css/css.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/xml/xml.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/markdown/markdown.js"></script><script src="https://unpkg.com/codemirror@5.65.16/mode/clike/clike.js"></script>
</head>
<body><header><h2>本地文件管理器 Demo</h2><div>(仅支持 Chrome/Edge,需授权访问本地文件夹)</div></header><div id="container"><div id="sidebar"><div id="toolbar"><button id="pickFolderBtn" title="选择文件夹"><svg viewBox="0 0 20 20" fill="none"><path d="M2 5a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5z" stroke="#333" stroke-width="1.5"/></svg></button><button id="newFileBtn" title="新建文件" disabled><svg viewBox="0 0 20 20" fill="none"><path d="M4 4h8l4 4v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" stroke="#333" stroke-width="1.5"/><path d="M12 4v4h4" stroke="#333" stroke-width="1.5"/><path d="M10 9v6" stroke="#333" stroke-width="1.5"/><path d="M7 12h6" stroke="#333" stroke-width="1.5"/></svg></button><button id="newFolderBtn" title="新建文件夹" disabled><svg viewBox="0 0 20 20" fill="none"><path d="M2 6a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6z" stroke="#333" stroke-width="1.5"/><path d="M7 10h6" stroke="#333" stroke-width="1.5"/><path d="M10 7v6" stroke="#333" stroke-width="1.5"/></svg></button></div><div id="fileTree"></div></div><div id="main"><div id="fileInfo"></div><textarea id="editor" class="hidden" style="height: 100%; min-height: 300px;"></textarea><button id="saveBtn" class="hidden" title="保存"><svg viewBox="0 0 20 20" fill="none"><path d="M4 4h12v12H4V4z" stroke="#333" stroke-width="1.5"/><path d="M7 4v4h6V4" stroke="#333" stroke-width="1.5"/><path d="M7 12h6" stroke="#333" stroke-width="1.5"/></svg></button><div id="status"></div></div></div><script>let rootHandle = null;let currentFileHandle = null;let currentDirHandle = null;let selectedLi = null;const pickFolderBtn = document.getElementById('pickFolderBtn');const newFileBtn = document.getElementById('newFileBtn');const newFolderBtn = document.getElementById('newFolderBtn');const fileTree = document.getElementById('fileTree');const editorTextarea = document.getElementById('editor');const saveBtn = document.getElementById('saveBtn');const fileInfo = document.getElementById('fileInfo');const status = document.getElementById('status');let cm = null;// 语言模式映射function getMode(filename) {const ext = filename.split('.').pop().toLowerCase();if (["js", "jsx", "ts", "tsx", "cjs", "mjs"].includes(ext)) return "javascript";if (["py"].includes(ext)) return "python";if (["html", "htm"].includes(ext)) return "htmlmixed";if (["css", "scss", "less"].includes(ext)) return "css";if (["json"].includes(ext)) return "javascript";if (["md", "markdown"].includes(ext)) return "markdown";if (["c", "cpp", "h", "hpp", "java"].includes(ext)) return "clike";return "javascript"; // 默认js}// 初始化CodeMirror 5function showEditor(text, filename) {editorTextarea.classList.remove('hidden');if (cm) {cm.toTextArea();cm = null;}cm = CodeMirror.fromTextArea(editorTextarea, {value: text,mode: getMode(filename),lineNumbers: true,lineWrapping: true,theme: 'default',indentUnit: 2,tabSize: 2,autofocus: true,});cm.setValue(text);setTimeout(() => cm.refresh(), 0);}// 工具函数function escapeHtml(str) {return str.replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}// 选择文件夹pickFolderBtn.onclick = async () => {try {rootHandle = await window.showDirectoryPicker();fileTree.innerHTML = '';await renderTree(rootHandle, fileTree);status.textContent = '已选择文件夹';newFileBtn.disabled = false;newFolderBtn.disabled = false;hideEditor();fileInfo.textContent = '';} catch (e) {status.textContent = '未选择文件夹';}};// 渲染文件树async function renderTree(dirHandle, container, path = '', collapsed = true) {container.innerHTML = '';const ul = document.createElement('ul');// 收集所有 entriesconst entries = [];for await (const [name, handle] of dirHandle.entries()) {entries.push({ name, handle });}// 文件夹在前,文件在后,按名称排序entries.sort((a, b) => {if (a.handle.kind !== b.handle.kind) {return a.handle.kind === 'directory' ? -1 : 1;}return a.name.localeCompare(b.name, 'zh-Hans-CN');});for (const { name, handle } of entries) {const li = document.createElement('li');const row = document.createElement('div');row.className = 'row';// 图标let icon;if (handle.kind === 'directory') {icon = document.createElement('span');icon.innerHTML = `<svg class="tree-icon" viewBox="0 0 20 20" fill="none"><rect x="3" y="5" width="14" height="10" rx="2" stroke="#fbbf24" stroke-width="1.5" fill="#fde68a"/></svg>`;} else {icon = document.createElement('span');icon.innerHTML = `<svg class="tree-icon" viewBox="0 0 20 20" fill="none"><rect x="4" y="3" width="12" height="14" rx="2" stroke="#60a5fa" stroke-width="1.5" fill="#dbeafe"/></svg>`;}// 展开/收缩箭头let caret = null;if (handle.kind === 'directory') {caret = document.createElement('span');caret.innerHTML = `<svg class="caret collapsed" viewBox="0 0 20 20"><polyline points="7,8 13,10 7,12" fill="none" stroke="#888" stroke-width="2"/></svg>`;caret.classList.add('caret', 'collapsed');} else {caret = document.createElement('span');caret.classList.add('caret', 'invisible');}row.appendChild(caret);row.appendChild(icon);// 名称const nameSpan = document.createElement('span');nameSpan.textContent = name;nameSpan.style.flex = '1';nameSpan.style.userSelect = 'none';nameSpan.className = handle.kind;row.appendChild(nameSpan);li.title = name;li.dataset.path = path + '/' + name;li.classList.add(handle.kind);// 操作按钮const actions = document.createElement('span');actions.className = 'actions';if (handle.kind === 'file') {const editBtn = document.createElement('button');editBtn.title = '编辑';editBtn.innerHTML = `<svg viewBox="0 0 20 20"><path d="M4 13.5V16h2.5l7.1-7.1a1 1 0 0 0 0-1.4l-2.1-2.1a1 1 0 0 0-1.4 0L4 13.5z" stroke="#333" stroke-width="1.2" fill="#fffbe6"/></svg>`;editBtn.onclick = e => { e.stopPropagation(); openFile(handle, li); };actions.appendChild(editBtn);}const renameBtn = document.createElement('button');renameBtn.title = '重命名';renameBtn.innerHTML = `<svg viewBox="0 0 20 20"><path d="M4 13.5V16h2.5l7.1-7.1a1 1 0 0 0 0-1.4l-2.1-2.1a1 1 0 0 0-1.4 0L4 13.5z" stroke="#6366f1" stroke-width="1.2" fill="#eef2ff"/></svg>`;renameBtn.onclick = e => { e.stopPropagation(); renameEntry(handle, dirHandle, name); };actions.appendChild(renameBtn);const delBtn = document.createElement('button');delBtn.title = '删除';delBtn.innerHTML = `<svg viewBox="0 0 20 20"><rect x="5" y="7" width="10" height="8" rx="2" stroke="#ef4444" stroke-width="1.5" fill="#fee2e2"/><path d="M8 9v4M12 9v4" stroke="#ef4444" stroke-width="1.2"/></svg>`;delBtn.onclick = e => { e.stopPropagation(); deleteEntry(handle, dirHandle, name); };actions.appendChild(delBtn);row.appendChild(actions);li.appendChild(row);// 点击选中/展开收缩if (handle.kind === 'directory') {let expanded = false;let subUl = document.createElement('ul');subUl.style.display = 'none';li.appendChild(subUl);nameSpan.onclick = async e => {e.stopPropagation();expanded = !expanded;if (expanded) {caret.classList.remove('collapsed');caret.classList.add('expanded');subUl.style.display = '';await renderTree(handle, subUl, path + '/' + name, false);} else {caret.classList.remove('expanded');caret.classList.add('collapsed');subUl.style.display = 'none';}};// 支持选中li.onclick = e => {e.stopPropagation();if (selectedLi) selectedLi.classList.remove('selected');li.classList.add('selected');selectedLi = li;currentDirHandle = handle;currentFileHandle = null;hideEditor();fileInfo.textContent = '文件夹: ' + name;};} else {// 文件点击选中并编辑nameSpan.onclick = e => {e.stopPropagation();openFile(handle, li);};}ul.appendChild(li);}container.appendChild(ul);}// 打开文件async function openFile(fileHandle, li) {try {const file = await fileHandle.getFile();const text = await file.text();showEditor(text, file.name);saveBtn.classList.remove('hidden');fileInfo.textContent = '文件: ' + file.name;currentFileHandle = fileHandle;currentDirHandle = null;if (selectedLi) selectedLi.classList.remove('selected');if (li) { li.classList.add('selected'); selectedLi = li; }} catch (e) {status.textContent = '无法打开文件: ' + e.message;}}// 保存文件saveBtn.onclick = async () => {if (!currentFileHandle) return;try {const writable = await currentFileHandle.createWritable();const value = cm ? cm.getValue() : '';await writable.write(value);await writable.close();status.textContent = '保存成功';} catch (e) {status.textContent = '保存失败: ' + e.message;}};// 新建文件newFileBtn.onclick = async () => {if (!rootHandle) return;let dir = currentDirHandle || rootHandle;const name = prompt('输入新文件名:');if (!name) return;try {const fileHandle = await dir.getFileHandle(name, { create: true });await renderTree(rootHandle, fileTree);status.textContent = '新建文件成功';// 新建后自动打开openFile(fileHandle, null);} catch (e) {status.textContent = '新建文件失败: ' + e.message;}};// 新建文件夹newFolderBtn.onclick = async () => {if (!rootHandle) return;let dir = currentDirHandle || rootHandle;const name = prompt('输入新文件夹名:');if (!name) return;try {await dir.getDirectoryHandle(name, { create: true });await renderTree(rootHandle, fileTree);status.textContent = '新建文件夹成功';} catch (e) {status.textContent = '新建文件夹失败: ' + e.message;}};// 删除文件/文件夹async function deleteEntry(handle, parentHandle, name) {if (!confirm('确定要删除 ' + name + ' 吗?')) return;try {await parentHandle.removeEntry(name, { recursive: handle.kind === 'directory' });await renderTree(rootHandle, fileTree);status.textContent = '删除成功';hideEditor();fileInfo.textContent = '';} catch (e) {status.textContent = '删除失败: ' + e.message;}}// 重命名文件/文件夹async function renameEntry(handle, parentHandle, oldName) {const newName = prompt('输入新名称:', oldName);if (!newName || newName === oldName) return;try {// 只能通过新建+复制+删除实现if (handle.kind === 'file') {const file = await handle.getFile();const newHandle = await parentHandle.getFileHandle(newName, { create: true });const writable = await newHandle.createWritable();await writable.write(await file.text());await writable.close();} else {// 文件夹递归复制await copyDirectory(handle, parentHandle, newName);}await parentHandle.removeEntry(oldName, { recursive: true });await renderTree(rootHandle, fileTree);status.textContent = '重命名成功';} catch (e) {status.textContent = '重命名失败: ' + e.message;}}// 递归复制文件夹async function copyDirectory(srcHandle, destParent, newName) {const newDir = await destParent.getDirectoryHandle(newName, { create: true });for await (const [name, handle] of srcHandle.entries()) {if (handle.kind === 'file') {const file = await handle.getFile();const newFile = await newDir.getFileHandle(name, { create: true });const writable = await newFile.createWritable();await writable.write(await file.text());await writable.close();} else {await copyDirectory(handle, newDir, name);}}}// 隐藏编辑器function hideEditor() {if (cm) {cm.toTextArea();cm = null;}editorTextarea.classList.add('hidden');}</script>
</body>
</html>