本项目为一个纯前端实现的本地文件管理器网页(index.html),可在 Chrome/Edge 浏览器中直接打开,具备类似 VSCode 的本地文件夹操作体验。
无需后端,所有功能均在浏览器端实现。
选择本地文件夹
文件树展示
文件/文件夹操作
编辑器体验
界面与交互
index.html
index.html
如需二次开发或自定义功能,可直接修改 index.html
,所有逻辑均在本文件内实现。
如需支持更多语言高亮,可在 中引入更多 CodeMirror 5 的 mode 脚本。
代码如下 :
DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>本地文件管理器 Demotitle>
<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>
<link rel="stylesheet" href="https://unpkg.com/[email protected]/lib/codemirror.css">
<script src="https://unpkg.com/[email protected]/lib/codemirror.js">script>
<script src="https://unpkg.com/[email protected]/mode/javascript/javascript.js">script>
<script src="https://unpkg.com/[email protected]/mode/python/python.js">script>
<script src="https://unpkg.com/[email protected]/mode/htmlmixed/htmlmixed.js">script>
<script src="https://unpkg.com/[email protected]/mode/css/css.js">script>
<script src="https://unpkg.com/[email protected]/mode/xml/xml.js">script>
<script src="https://unpkg.com/[email protected]/mode/markdown/markdown.js">script>
<script src="https://unpkg.com/[email protected]/mode/clike/clike.js">script>
head>
<body>
<header>
<h2>本地文件管理器 Demoh2>
<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 = ``;
} else {
icon = document.createElement('span');
icon.innerHTML = ``;
}
// 展开/收缩箭头
let caret = null;
if (handle.kind === 'directory') {
caret = document.createElement('span');
caret.innerHTML = ``;
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 = ``;
editBtn.onclick = e => { e.stopPropagation(); openFile(handle, li); };
actions.appendChild(editBtn);
}
const renameBtn = document.createElement('button');
renameBtn.title = '重命名';
renameBtn.innerHTML = ``;
renameBtn.onclick = e => { e.stopPropagation(); renameEntry(handle, dirHandle, name); };
actions.appendChild(renameBtn);
const delBtn = document.createElement('button');
delBtn.title = '删除';
delBtn.innerHTML = ``;
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>