纯前端本地文件管理器(VSCode风格)(浏览器对本地文件增删改查)

发布于:2025-07-01 ⋅ 阅读:(16) ⋅ 点赞:(0)

纯前端本地文件管理器(VSCode风格)(浏览器对本地文件增删改查)

简介

本项目为一个纯前端实现的本地文件管理器网页(index.html),可在 Chrome/Edge 浏览器中直接打开,具备类似 VSCode 的本地文件夹操作体验。
无需后端,所有功能均在浏览器端实现。


主要功能

  1. 选择本地文件夹

    • 用户点击左上角文件夹按钮,授权后可浏览和操作本地文件夹内容。
  2. 文件树展示

    • 以树形结构展示所选文件夹下的所有文件和子文件夹。
    • 文件夹和文件有不同图标区分。
    • 文件夹默认收缩,点击可展开/收缩子项。
    • 每个目录下,文件夹排在前,文件排在后,均按名称排序。
  3. 文件/文件夹操作

    • 新建文件、新建文件夹(在当前选中目录下)
    • 编辑文件(支持多语言代码高亮,编辑区为 CodeMirror 5)
    • 保存文件
    • 删除文件/文件夹(支持递归删除文件夹)
    • 重命名文件/文件夹(文件夹为递归复制+删除实现)
  4. 编辑器体验

    • 编辑区为 CodeMirror 5,支持多种主流编程语言高亮(js、py、html、css、md、c/c++/java等)。
    • 编辑区可编辑,支持行号、自动换行。
    • 保存按钮为图标形式,点击后将编辑内容写回本地文件。
  5. 界面与交互

    • 所有操作按钮均为小图标,无文字,简洁美观。
    • 文件树和编辑区自适应布局,支持多层嵌套目录。
    • 状态栏实时提示操作结果(如保存成功、删除失败等)。

技术说明

  • 前端技术:原生 HTML + CSS + JavaScript
  • 代码高亮:CodeMirror 5(通过 unpkg CDN 引入)
  • 本地文件操作:File System Access API(需用户授权,支持 Chrome/Edge)
  • 无需后端,所有数据均在本地浏览器内存和本地文件系统中操作

使用方法

  1. 用 Chrome 或 Edge 浏览器打开 index.html
  2. 点击左上角“选择文件夹”图标,授权访问本地文件夹
  3. 在左侧文件树中浏览、增删改查文件和文件夹
  4. 编辑文件后,点击保存图标即可写回本地

注意事项

  • 仅支持支持 File System Access API 的浏览器(如 Chrome、Edge)
  • 仅能操作用户授权的文件夹及其子文件
  • 由于浏览器安全限制,网页无法自动访问任意本地文件夹,需每次手动授权
  • 编辑器高亮支持的语言可根据需要扩展

依赖


主要文件结构

  • 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 5
    function 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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');
      // 收集所有 entries
      const 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> 

网站公告

今日签到

点亮在社区的每一天
去签到