纯前端本地文件管理器(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 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>