(() => {
const API_BASE = '/api/gui/files';
const EDITOR_PAGE = '/file-manager/editor';
const state = {
currentPath: '',
items: [],
selected: new Set(),
lastSelectedIndex: null,
clipboard: null, // {mode: 'copy'|'cut', items: []}
treeCache: new Map(),
treeExpanded: new Set(['']),
isDraggingSelection: false,
dragStart: null,
selectionRect: null,
selectionJustFinished: false,
selectionDisabled: false,
};
const icons = {
directory: '📁',
default: '📄',
editable: '📝',
code: '💻',
markdown: '🧾',
image: '🖼️',
archive: '🗃️',
};
const fileGrid = document.getElementById('fileGrid');
const directoryTree = document.getElementById('directoryTree');
const breadcrumbEl = document.getElementById('breadcrumb');
const selectionInfo = document.getElementById('selectionInfo');
const statusBar = document.getElementById('statusBar');
const contextMenu = document.getElementById('contextMenu');
const dialogBackdrop = document.getElementById('dialogBackdrop');
const dialogTitle = document.getElementById('dialogTitle');
const dialogContent = document.getElementById('dialogContent');
const dialogCancel = document.getElementById('dialogCancel');
const dialogConfirm = document.getElementById('dialogConfirm');
const hiddenUploader = document.getElementById('hiddenUploader');
const pasteBtn = document.getElementById('btnPaste');
const newFolderBtn = document.getElementById('btnNewFolder');
const newFileBtn = document.getElementById('btnNewFile');
const refreshBtn = document.getElementById('btnRefresh');
const uploadBtn = document.getElementById('btnUpload');
const backBtn = document.getElementById('btnBack');
const returnChatBtn = document.getElementById('btnReturnChat');
const downloadBtn = document.getElementById('btnDownload');
const renameBtn = document.getElementById('btnRename');
const copyBtn = document.getElementById('btnCopy');
const cutBtn = document.getElementById('btnCut');
const deleteBtn = document.getElementById('btnDelete');
const toggleSelectionBtn = document.getElementById('btnToggleSelection');
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
const urlParams = new URLSearchParams(window.location.search);
const initialPathParam = (urlParams.get('path') || '').replace(/^\//, '').replace(/\/$/, '');
dialogBackdrop.hidden = true;
let dialogHandlers = { confirm: null, cancel: null };
function clearDialogHandlers() {
dialogHandlers.confirm = null;
dialogHandlers.cancel = null;
}
function registerDialogHandlers(confirmHandler, cancelHandler) {
dialogHandlers.confirm = confirmHandler || null;
dialogHandlers.cancel = cancelHandler || null;
}
function closeDialog() {
dialogBackdrop.hidden = true;
clearDialogHandlers();
}
dialogCancel.addEventListener('click', () => {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
});
dialogConfirm.addEventListener('click', () => {
if (dialogHandlers.confirm) {
const handler = dialogHandlers.confirm;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
});
dialogBackdrop.addEventListener('click', (event) => {
if (event.target === dialogBackdrop) {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
}
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !dialogBackdrop.hidden) {
if (dialogHandlers.cancel) {
const handler = dialogHandlers.cancel;
clearDialogHandlers();
handler();
} else {
closeDialog();
}
}
});
closeDialog();
function showStatus(message) {
statusBar.textContent = message;
}
function formatSize(size) {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
function formatTime(ts) {
const d = new Date(ts * 1000);
return d.toLocaleString();
}
function joinPath(base, name) {
if (!base) return name;
return `${base.replace(/\/$/, '')}/${name}`;
}
function getIcon(entry) {
if (entry.type === 'directory') return icons.directory;
if (entry.is_editable) return icons.editable;
const ext = entry.extension || '';
if (['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp'].includes(ext)) {
return icons.image;
}
if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) {
return icons.archive;
}
if (['.js', '.ts', '.py', '.rb', '.php', '.java', '.kt', '.go', '.rs', '.c', '.cpp', '.h', '.hpp'].includes(ext)) {
return icons.code;
}
if (['.md', '.markdown'].includes(ext)) {
return icons.markdown;
}
return icons.default;
}
async function request(url, options = {}) {
const response = await fetch(url, options);
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
const message = data.error || data.message || `请求失败 (${response.status})`;
throw new Error(message);
}
return data;
}
function updateUrl(path) {
const url = new URL(window.location.href);
if (path) {
url.searchParams.set('path', path);
} else {
url.searchParams.delete('path');
}
window.history.replaceState({}, '', url.pathname + url.search);
}
async function ensureAncestors(path) {
await ensureTreeNode('', false);
state.treeExpanded.add('');
if (!path) {
renderTree();
return;
}
const segments = path.split('/').filter(Boolean);
let current = '';
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index];
current = current ? `${current}/${segment}` : segment;
if (index < segments.length - 1) {
state.treeExpanded.add(current);
}
await ensureTreeNode(current, false);
}
renderTree();
}
async function loadDirectory(path = '', { updateHistory = true } = {}) {
hideContextMenu();
showStatus('加载中...');
try {
const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`);
const resolvedPath = result.data.path || '';
state.currentPath = resolvedPath;
state.items = result.data.items || [];
const directoryEntries = state.items.filter((item) => item.type === 'directory');
state.treeCache.set(resolvedPath, directoryEntries);
if (updateHistory) {
updateUrl(resolvedPath);
}
await ensureAncestors(resolvedPath);
renderBreadcrumb(result.data.breadcrumb || []);
renderGrid();
updateSelection([]);
state.lastSelectedIndex = null;
showStatus(`已加载 ${state.items.length} 项`);
} catch (err) {
showStatus(err.message);
}
}
function renderBreadcrumb(crumbs) {
breadcrumbEl.innerHTML = '';
crumbs.forEach((crumb, index) => {
const span = document.createElement('span');
span.textContent = crumb.name;
span.dataset.path = crumb.path;
span.addEventListener('click', () => {
loadDirectory(crumb.path);
});
breadcrumbEl.appendChild(span);
if (index < crumbs.length - 1) {
const sep = document.createElement('span');
sep.textContent = '›';
sep.classList.add('fm-breadcrumb-sep');
breadcrumbEl.appendChild(sep);
}
});
}
function renderGrid() {
fileGrid.innerHTML = '';
state.items.forEach((entry, index) => {
const card = document.createElement('div');
card.className = 'fm-card';
card.tabIndex = 0;
card.dataset.path = entry.path;
card.dataset.index = index;
if (state.selected.has(entry.path)) {
card.classList.add('selected');
}
const icon = document.createElement('div');
icon.className = 'fm-card-icon';
icon.textContent = getIcon(entry);
const name = document.createElement('div');
name.className = 'fm-card-name';
name.textContent = entry.name;
const meta = document.createElement('div');
meta.className = 'fm-card-meta';
const lines = [];
if (entry.type === 'file') {
lines.push(formatSize(entry.size));
} else {
lines.push('目录');
}
lines.push(formatTime(entry.modified_at));
meta.innerHTML = lines.join('
');
card.appendChild(icon);
card.appendChild(name);
card.appendChild(meta);
card.addEventListener('click', (event) => handleItemClick(event, entry, index));
card.addEventListener('dblclick', () => handleItemDoubleClick(entry));
card.addEventListener('contextmenu', (event) => handleItemContextMenu(event, entry));
fileGrid.appendChild(card);
});
const overlay = document.createElement('div');
overlay.className = 'fm-drop-overlay';
overlay.textContent = '释放即可上传到此目录';
fileGrid.appendChild(overlay);
}
function updateSelection(paths, options = { append: false, range: false }) {
if (!options.append && !options.range) {
state.selected.clear();
if (paths.length === 0) {
state.lastSelectedIndex = null;
}
}
paths.forEach((path) => {
if (state.selected.has(path) && options.append) {
state.selected.delete(path);
} else {
state.selected.add(path);
}
});
syncSelectionUI();
}
function syncSelectionUI() {
const cards = fileGrid.querySelectorAll('.fm-card');
cards.forEach((card) => {
if (state.selected.has(card.dataset.path)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
selectionInfo.textContent = `已选中 ${state.selected.size} 项`;
pasteBtn.disabled = !state.clipboard || !state.clipboard.items.length;
}
function handleItemClick(event, entry, index) {
const isMetaKey = event.metaKey || event.ctrlKey;
const isShiftKey = event.shiftKey;
if (isShiftKey && state.lastSelectedIndex !== null) {
const start = Math.min(state.lastSelectedIndex, index);
const end = Math.max(state.lastSelectedIndex, index);
const paths = state.items.slice(start, end + 1).map((item) => item.path);
updateSelection(paths, { range: true });
} else if (isMetaKey) {
updateSelection([entry.path], { append: true });
state.lastSelectedIndex = index;
} else {
updateSelection([entry.path], { append: false });
state.lastSelectedIndex = index;
}
}
function handleItemDoubleClick(entry) {
if (entry.type === 'directory') {
loadDirectory(entry.path);
return;
}
if (entry.is_editable) {
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
return;
}
window.open(`${API_BASE}/download?path=${encodeURIComponent(entry.path)}`, '_blank');
}
function handleItemContextMenu(event, entry) {
event.preventDefault();
if (!state.selected.has(entry.path)) {
updateSelection([entry.path], { append: false });
}
showContextMenu(event.clientX, event.clientY);
}
function showContextMenu(x, y) {
const single = state.selected.size === 1;
const singleEntry = single ? getSingleSelected() : null;
contextMenu.innerHTML = '';
const entries = [];
if (singleEntry) {
if (singleEntry.type === 'directory') {
entries.push({ label: '打开', action: openSelected, disabled: false });
} else if (singleEntry.is_editable) {
entries.push({ label: '在编辑器中打开', action: openEditor, disabled: false });
if (singleEntry.extension === '.html' || singleEntry.extension === '.htm') {
entries.push({ label: '预览', action: previewSelected, disabled: false });
}
entries.push({ label: '下载', action: downloadSelected, disabled: false });
} else {
const isHtml = singleEntry.extension === '.html' || singleEntry.extension === '.htm';
if (isHtml) {
entries.push({ label: '预览', action: previewSelected, disabled: false });
}
entries.push({ label: '下载', action: downloadSelected, disabled: false });
}
} else if (state.selected.size > 0) {
entries.push({ label: '下载', action: downloadSelected, disabled: false });
}
entries.push(
{ label: '重命名', action: renameSelected, disabled: !single },
{ label: '复制', action: copySelected, disabled: state.selected.size === 0 },
{ label: '剪切', action: cutSelected, disabled: state.selected.size === 0 },
{ label: '粘贴', action: pasteClipboard, disabled: !state.clipboard || !state.clipboard.items.length },
{ label: '删除', action: deleteSelected, disabled: state.selected.size === 0 },
);
entries.forEach((item) => {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.disabled = item.disabled;
btn.addEventListener('click', () => {
hideContextMenu();
item.action();
});
contextMenu.appendChild(btn);
});
contextMenu.style.display = 'block';
const { innerWidth, innerHeight } = window;
const menuRect = contextMenu.getBoundingClientRect();
const left = clamp(x, 0, innerWidth - menuRect.width);
const top = clamp(y, 0, innerHeight - menuRect.height);
contextMenu.style.left = `${left}px`;
contextMenu.style.top = `${top}px`;
}
function hideContextMenu() {
contextMenu.style.display = 'none';
}
function getSingleSelected() {
if (state.selected.size !== 1) return null;
const path = Array.from(state.selected)[0];
return state.items.find((item) => item.path === path) || null;
}
function openSelected() {
const entry = getSingleSelected();
if (!entry) return;
handleItemDoubleClick(entry);
}
function previewSelected() {
const entry = getSingleSelected();
if (!entry) return;
if (entry.extension !== '.html' && entry.extension !== '.htm') {
showStatus('仅支持预览 HTML 文件');
return;
}
window.open(`/file-preview/${encodeURIComponent(entry.path)}`, '_blank');
}
function openEditor() {
const entry = getSingleSelected();
if (!entry || !entry.is_editable) return;
window.location.href = `${EDITOR_PAGE}?path=${encodeURIComponent(entry.path)}`;
}
function triggerBlobDownload(filename, blob) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
async function downloadSelected() {
if (!state.selected.size) return;
if (state.selected.size === 1) {
const path = Array.from(state.selected)[0];
window.open(`${API_BASE}/download?path=${encodeURIComponent(path)}`, '_blank');
return;
}
try {
const resp = await fetch(`${API_BASE}/download/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: Array.from(state.selected) })
});
if (!resp.ok) {
let message = '批量下载失败';
try {
const data = await resp.json();
message = data.error || data.message || message;
} catch (_) {
// ignore
}
throw new Error(message);
}
const blob = await resp.blob();
triggerBlobDownload(`selected_${Date.now()}.zip`, blob);
showStatus(`已开始下载 ${state.selected.size} 个项目`);
} catch (err) {
showStatus(err.message);
}
}
async function renameSelected() {
const entry = getSingleSelected();
if (!entry) return;
const newName = await promptDialog('重命名', entry.name);
if (!newName) return;
try {
await request(`${API_BASE}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: entry.path, new_name: newName }),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
function copySelected() {
if (!state.selected.size) return;
state.clipboard = { mode: 'copy', items: Array.from(state.selected) };
showStatus(`已复制 ${state.clipboard.items.length} 项`);
syncSelectionUI();
}
function cutSelected() {
if (!state.selected.size) return;
state.clipboard = { mode: 'cut', items: Array.from(state.selected) };
showStatus(`已剪切 ${state.clipboard.items.length} 项`);
syncSelectionUI();
}
async function pasteClipboard() {
if (!state.clipboard || !state.clipboard.items.length) return;
const endpoint = state.clipboard.mode === 'copy' ? 'copy' : 'move';
try {
await request(`${API_BASE}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paths: state.clipboard.items,
target_dir: state.currentPath,
}),
});
if (state.clipboard.mode === 'cut') {
state.clipboard = null;
}
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
async function deleteSelected() {
if (!state.selected.size) return;
const confirm = await confirmDialog(`确认删除选中的 ${state.selected.size} 项吗?该操作不可撤销。`);
if (!confirm) return;
try {
await request(`${API_BASE}/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: Array.from(state.selected) }),
});
await loadDirectory(state.currentPath);
} catch (err) {
showStatus(err.message);
}
}
function promptDialog(title, defaultValue = '') {
return new Promise((resolve) => {
dialogTitle.textContent = title;
dialogContent.innerHTML = '';
const input = document.createElement('input');
input.value = defaultValue;
input.autofocus = true;
dialogContent.appendChild(input);
const finish = (value) => {
closeDialog();
resolve(value);
};
registerDialogHandlers(() => finish(input.value.trim()), () => finish(null));
dialogBackdrop.hidden = false;
input.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
evt.preventDefault();
finish(input.value.trim());
} else if (evt.key === 'Escape') {
evt.preventDefault();
finish(null);
}
});
setTimeout(() => input.select(), 50);
});
}
function confirmDialog(message) {
return new Promise((resolve) => {
dialogTitle.textContent = '确认操作';
dialogContent.innerHTML = `
${message}
`; const finish = (value) => { closeDialog(); resolve(value); }; registerDialogHandlers(() => finish(true), () => finish(false)); dialogBackdrop.hidden = false; }); } function handleGlobalClick(event) { if (!contextMenu.contains(event.target)) { hideContextMenu(); } } function handleGridBackgroundClick(event) { if (event.target === fileGrid) { if (state.selectionJustFinished) { return; } updateSelection([]); } } function handleDragEnter(event) { event.preventDefault(); fileGrid.classList.add('drop-target'); } function handleDragOver(event) { event.preventDefault(); } function handleDragLeave(event) { if (event.target === fileGrid) { fileGrid.classList.remove('drop-target'); } } async function handleDrop(event) { event.preventDefault(); fileGrid.classList.remove('drop-target'); if (!event.dataTransfer || !event.dataTransfer.files.length) return; const files = event.dataTransfer.files; await uploadFiles(files, state.currentPath); } async function uploadFiles(fileList, targetPath) { for (const file of fileList) { const form = new FormData(); form.append('file', file, file.name); form.append('filename', file.name); form.append('path', targetPath); try { await request(`${API_BASE}/upload`, { method: 'POST', body: form, }); showStatus(`已上传 ${file.name}`); } catch (err) { showStatus(`上传失败:${err.message}`); } } await loadDirectory(state.currentPath); } function initSelectionRectangle() { fileGrid.addEventListener('pointerdown', (event) => { if (state.selectionDisabled) return; if (event.target !== fileGrid) return; state.isDraggingSelection = true; state.dragStart = { x: event.clientX, y: event.clientY }; state.selectionRect = document.createElement('div'); state.selectionRect.className = 'fm-selection-rect'; fileGrid.appendChild(state.selectionRect); updateSelection([]); state.selectionJustFinished = false; fileGrid.setPointerCapture(event.pointerId); }); fileGrid.addEventListener('pointermove', (event) => { if (!state.isDraggingSelection || !state.selectionRect) return; const rect = fileGrid.getBoundingClientRect(); const current = { x: event.clientX, y: event.clientY }; const x = Math.min(state.dragStart.x, current.x) - rect.left + fileGrid.scrollLeft; const y = Math.min(state.dragStart.y, current.y) - rect.top + fileGrid.scrollTop; const width = Math.abs(state.dragStart.x - current.x); const height = Math.abs(state.dragStart.y - current.y); Object.assign(state.selectionRect.style, { left: `${x}px`, top: `${y}px`, width: `${width}px`, height: `${height}px`, }); const selectionBox = { left: Math.min(state.dragStart.x, current.x), right: Math.max(state.dragStart.x, current.x), top: Math.min(state.dragStart.y, current.y), bottom: Math.max(state.dragStart.y, current.y), }; const selected = []; const cards = fileGrid.querySelectorAll('.fm-card'); cards.forEach((card) => { const bounds = card.getBoundingClientRect(); const intersects = !(selectionBox.right < bounds.left || selectionBox.left > bounds.right || selectionBox.bottom < bounds.top || selectionBox.top > bounds.bottom); if (intersects) { selected.push(card.dataset.path); } }); updateSelection(selected); }); fileGrid.addEventListener('pointerup', (event) => { if (!state.isDraggingSelection) return; state.isDraggingSelection = false; if (state.selectionRect) { state.selectionRect.remove(); state.selectionRect = null; } state.selectionJustFinished = true; requestAnimationFrame(() => { state.selectionJustFinished = false; }); fileGrid.releasePointerCapture(event.pointerId); }); } async function ensureTreeNode(path, shouldRender = true) { let changed = false; if (!state.treeCache.has(path)) { try { const result = await request(`${API_BASE}/entries?path=${encodeURIComponent(path)}`); const directories = result.data.items.filter((item) => item.type === 'directory'); state.treeCache.set(path, directories); changed = true; } catch (err) { showStatus(err.message); } } if (shouldRender && changed) { renderTree(); } return changed; } function renderTree() { directoryTree.innerHTML = ''; const rootNode = createTreeNode('', '根目录'); directoryTree.appendChild(rootNode); } function createTreeNode(path, name) { const li = document.createElement('li'); const header = document.createElement('div'); header.className = 'fm-tree-item'; if (path === state.currentPath) { header.classList.add('active'); } const toggle = document.createElement('span'); toggle.className = 'fm-tree-toggle'; toggle.textContent = state.treeExpanded.has(path) ? '▾' : '▸'; toggle.addEventListener('click', async (event) => { event.stopPropagation(); if (state.treeExpanded.has(path)) { state.treeExpanded.delete(path); } else { state.treeExpanded.add(path); await ensureTreeNode(path, false); } renderTree(); }); const label = document.createElement('span'); label.textContent = name; label.addEventListener('click', () => loadDirectory(path)); header.addEventListener('click', () => loadDirectory(path)); header.appendChild(toggle); header.appendChild(label); li.appendChild(header); if (state.treeExpanded.has(path)) { const children = document.createElement('ul'); children.className = 'fm-tree-children'; const dirs = state.treeCache.get(path) || []; dirs.forEach((dir) => { const child = createTreeNode(dir.path, dir.name); children.appendChild(child); }); li.appendChild(children); } return li; } function bindToolbar() { newFolderBtn.addEventListener('click', async () => { const name = await promptDialog('新建文件夹', '新建文件夹'); if (!name) return; try { await request(`${API_BASE}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.currentPath, name, type: 'directory', }), }); await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } }); newFileBtn.addEventListener('click', async () => { const name = await promptDialog('新建文件', '新建文件.txt'); if (!name) return; try { await request(`${API_BASE}/create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: state.currentPath, name, type: 'file', }), }); await loadDirectory(state.currentPath); } catch (err) { showStatus(err.message); } }); refreshBtn.addEventListener('click', () => loadDirectory(state.currentPath)); uploadBtn.addEventListener('click', () => hiddenUploader.click()); backBtn.addEventListener('click', () => { if (!state.currentPath) { loadDirectory(''); return; } const segments = state.currentPath.split('/').filter(Boolean); if (segments.length === 0) { loadDirectory(''); return; } segments.pop(); const parentPath = segments.join('/'); loadDirectory(parentPath); }); returnChatBtn.addEventListener('click', () => { window.location.href = '/new'; }); downloadBtn.addEventListener('click', downloadSelected); renameBtn.addEventListener('click', renameSelected); copyBtn.addEventListener('click', copySelected); cutBtn.addEventListener('click', cutSelected); pasteBtn.addEventListener('click', pasteClipboard); deleteBtn.addEventListener('click', deleteSelected); toggleSelectionBtn.addEventListener('click', () => { state.selectionDisabled = !state.selectionDisabled; toggleSelectionBtn.textContent = state.selectionDisabled ? '启用框选' : '禁用框选'; const msg = state.selectionDisabled ? '已禁用框选' : '已启用框选'; showStatus(msg); }); hiddenUploader.addEventListener('change', async (event) => { const files = event.target.files; if (files && files.length) { await uploadFiles(files, state.currentPath); } hiddenUploader.value = ''; }); } function bindGlobalEvents() { document.addEventListener('click', handleGlobalClick); fileGrid.addEventListener('click', handleGridBackgroundClick); fileGrid.addEventListener('contextmenu', (event) => { if (event.target === fileGrid) { event.preventDefault(); if (state.selected.size) { showContextMenu(event.clientX, event.clientY); } } }); fileGrid.addEventListener('dragenter', handleDragEnter); fileGrid.addEventListener('dragover', handleDragOver); fileGrid.addEventListener('dragleave', handleDragLeave); fileGrid.addEventListener('drop', handleDrop); initSelectionRectangle(); } async function bootstrap() { bindToolbar(); bindGlobalEvents(); await loadDirectory(initialPathParam, { updateHistory: false }); } bootstrap().catch((err) => { console.error(err); showStatus(err.message); }); })();