/* ── File Manager Module ── */
window.FilesModule = {
currentPath: '',
contextTarget: null,
toDelete: null,
pickerMode: false,
pickerCallback: null,
init: function() {
var self = this;
var params = new URLSearchParams(window.location.search);
if (params.get('picker') === '1') {
self.pickerMode = true;
self.pickerMode = params.get('mode') || 'dir';
self.pickerCallback = params.get('cb') || null;
}
window.addEventListener('message', function(e) {
try {
var d = JSON.parse(e.data);
if (d.action === 'fm-picker-open') {
self.pickerMode = true;
self.pickerMode = d.mode || 'dir';
self.pickerCallback = d.callback_id || null;
self.refresh();
}
} catch(ex) {}
});
// Path input: Enter to jump
var input = FilesModule._$('fm-path-input');
input.onkeydown = function(e) {
if (e.key === 'Enter') {
var p = input.value.trim();
if (p) self._jumpTo(p);
}
};
self.refresh();
},
/* ── API helpers ── */
_apiBase: '',
_scope: null,
_$: function(id){ return this._scope ? this._scope.querySelector('#'+id) : document.getElementById(id); },
_apiUrl: function(path) {
// Use explicit base if set (standalone/iframe mode), otherwise relative
if (this._apiBase) return this._apiBase + '/api/files' + path;
return './api/files' + path;
},
_api: function(url, opts) {
opts = opts || {};
opts.credentials = 'include';
return fetch(url, opts).then(function(r) {
if (!r.ok) throw new Error(r.status + ' ' + r.statusText);
return r.json();
});
},
_get: function(url) { return this._api(this._apiUrl(url)); },
_post: function(url, body) {
return this._api(this._apiUrl(url), {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
},
/* ── Jump to path (with existence check) ── */
_jumpTo: function(path) {
var self = this;
// Probe: try to list the target path
this._get('/info?path=' + encodeURIComponent(path))
.then(function(info) {
if (info.error) { self._pathError(info.error); return; }
if (info.is_dir) {
self.currentPath = info.path;
self.refresh();
} else {
self._pathError('路径是文件,不是目录');
}
}).catch(function(e) {
self._pathError('目录不存在或无权限访问: ' + path);
});
},
_pathError: function(msg) {
var list = FilesModule._$('fm-list');
list.innerHTML = '
' +
'⚠ ' + this._esc(msg) + '
';
// Still update the input and breadcrumb to show what was attempted
FilesModule._$('fm-breadcrumb').innerHTML =
'' + this._esc(msg) + ' ';
},
navigate: function(path) {
this.currentPath = path || '';
this.refresh();
},
refresh: function() {
var self = this;
var showHidden = FilesModule._$('fm-hidden')?.checked ? '1' : '0';
self._get('/list?path=' + encodeURIComponent(self.currentPath) + '&show_hidden=' + showHidden)
.then(function(d) {
self._render(d);
}).catch(function(e) {
FilesModule._$('fm-list').innerHTML =
'加载失败: ' + self._esc(e.message) + '
';
});
},
/* ── Render ── */
_render: function(d) {
var self = this;
self.currentPath = d.current || '';
// Update editable path input
var input = FilesModule._$('fm-path-input');
input.value = self.currentPath;
// Breadcrumbs (logical path)
var bc = FilesModule._$('fm-breadcrumb');
var html = self._buildBreadcrumbHTML(d.breadcrumbs);
// Resolved breadcrumbs — show as second row when symlink redirect
if (d.resolved_breadcrumbs) {
html += '' +
'↳ ' +
self._buildBreadcrumbHTML(d.resolved_breadcrumbs) + '
';
}
bc.innerHTML = html;
bc.querySelectorAll('.fm-crumb').forEach(function(el) {
el.onclick = function() { self.navigate(this.dataset.path); };
});
// File list
var list = FilesModule._$('fm-list');
var rows = '';
// ".." row for parent (unless at filesystem root)
if (d.parent && d.parent !== d.current) {
rows += '' +
'
' +
'
.. ' +
'
' +
'
' +
'
';
}
if (d.items && d.items.length) {
d.items.forEach(function(item) {
if (item.broken) {
// Broken symlink or unreadable entry — show as disabled
rows += '' +
'
' +
'
' + self._esc(item.name) + ' ' +
'
— ' +
'
— ';
return;
}
var icon = item.is_dir ? FilesModule._dirIcon() : FilesModule._fileIcon(item.ext);
var sizeStr = item.is_dir ? '—' : FilesModule._fmtSize(item.size);
rows += '' +
'' + icon + ' ' +
'' + self._esc(item.name) + ' ' +
'' + sizeStr + ' ' +
'' + (item.mtime_str || '') + ' ' +
'
';
});
}
if (!rows) {
list.innerHTML = '空目录
';
} else {
list.innerHTML = rows;
}
// Row click handlers
list.querySelectorAll('.fm-row').forEach(function(row) {
row.onclick = function(e) {
var p = this.dataset.path;
var isDir = this.dataset.isDir === '1';
if (isDir) {
self.navigate(p);
} else if (self.pickerMode) {
self._pickResult(p);
}
};
row.oncontextmenu = function(e) {
e.preventDefault();
self._showContext(e, {
path: this.dataset.path,
isDir: this.dataset.isDir === '1',
name: this.querySelector('.fm-name').textContent
});
};
});
// Close context on outside click
document.onclick = function() {
FilesModule._$('fm-context').style.display = 'none';
};
// Picker banner — at bottom
if (self.pickerMode) {
var banner = FilesModule._$('fm-picker-banner');
if (!banner) {
banner = document.createElement('div');
banner.id = 'fm-picker-banner';
banner.innerHTML = '' +
'
选择' + (self.pickerMode === 'file' ? '文件' : '目录') + '模式' +
'
选择当前目录 ' +
'
取消 ' +
'
';
list.parentNode.appendChild(banner);
list.style.flex = '';
banner.style.marginTop = 'auto';
banner.style.paddingBottom = '0';
} else {
banner.style.display = 'block';
}
} else {
var oldBanner = FilesModule._$('fm-picker-banner');
if (oldBanner) oldBanner.style.display = 'none';
}
},
/* ── Picker ── */
_pickResult: function(path) {
// In iframe (picker mode): post to parent, parent closes overlay
if (window.parent !== window) {
// Ensure path is never empty — use current path as fallback
var p = path || this.currentPath || '/';
window.parent.postMessage(JSON.stringify({action:'fm-picked', path: p, callback_id: this.pickerCallback}), '*');
return;
}
// Standalone clipboard fallback
navigator.clipboard?.writeText(path);
alert('已选择: ' + path);
this.pickerMode = false;
this.refresh();
},
_cancelPicker: function() {
if (window.parent !== window) {
window.parent.postMessage(JSON.stringify({action:'fm-picked', path: null, callback_id: this.pickerCallback}), '*');
return;
}
this.pickerMode = false;
this.refresh();
},
/* ── Create ── */
createFile: function() {
var self = this;
var name = prompt('新建文件名:');
if (!name) return;
this._post('/touch', {path: this.currentPath, name: name})
.then(function() { self.refresh(); })
.catch(function(e) { alert('创建失败: ' + e.message); });
},
createDir: function() {
var self = this;
var name = prompt('新建文件夹名:');
if (!name) return;
this._post('/mkdir', {path: this.currentPath, name: name})
.then(function() { self.refresh(); })
.catch(function(e) { alert('创建失败: ' + e.message); });
},
upload: function(files) {
var self = this;
if (!files || !files.length) return;
var form = new FormData();
for (var i = 0; i < files.length; i++) form.append('file', files[i]);
fetch(self._apiUrl('/upload?path=') + encodeURIComponent(self.currentPath), {method:'POST', credentials:'include', body:form})
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.ok) self.refresh();
else alert('上传失败: ' + (d.error || 'unknown'));
}).catch(function(e) { alert('上传失败: ' + e.message); });
},
/* ── Editor ── */
openEditor: function(path, name) {
var self = this;
this._get('/read?path=' + encodeURIComponent(path))
.then(function(d) {
FilesModule._$('fm-editor-title').innerHTML = ' ' + (name || d.name);
FilesModule._$('fm-editor-textarea').value = d.content;
FilesModule._$('fm-editor-textarea').dataset.path = path;
FilesModule._$('fm-editor').style.display = 'flex';
}).catch(function(e) { alert('无法读取: ' + e.message); });
},
closeEditor: function() {
FilesModule._$('fm-editor').style.display = 'none';
},
saveFile: function() {
var self = this;
var ta = FilesModule._$('fm-editor-textarea');
this._post('/write', {path: ta.dataset.path, content: ta.value})
.then(function() { self.closeEditor(); self.refresh(); })
.catch(function(e) { alert('保存失败: ' + e.message); });
},
/* ── Context menu ── */
_showContext: function(e, item) {
this.contextTarget = item;
var ctx = FilesModule._$('fm-context');
ctx.style.display = 'block';
ctx.style.left = e.pageX + 'px';
ctx.style.top = e.pageY + 'px';
var items = ctx.querySelectorAll('.fm-context-item');
items[2].style.display = item.isDir ? 'none' : 'block';
},
ctxDownload: function() {
var t = this.contextTarget;
if (t) window.open(FilesModule._apiUrl('/download?path=') + encodeURIComponent(t.path), '_blank');
FilesModule._$('fm-context').style.display = 'none';
},
ctxRename: function() {
var self = this;
var t = this.contextTarget;
if (!t) return;
var nn = prompt('新名称:', t.name);
if (!nn || nn === t.name) return;
this._post('/rename', {path: t.path, new_name: nn})
.then(function() { self.refresh(); })
.catch(function(e) { alert('重命名失败: ' + e.message); });
FilesModule._$('fm-context').style.display = 'none';
},
ctxEdit: function() {
var t = this.contextTarget;
if (t && !t.isDir) this.openEditor(t.path, t.name);
FilesModule._$('fm-context').style.display = 'none';
},
ctxDelete: function() {
var t = this.contextTarget;
if (!t) return;
this.toDelete = t;
FilesModule._$('fm-dialog-msg').textContent = '确认删除 "' + t.name + '"?此操作不可撤销。';
FilesModule._$('fm-dialog').style.display = 'flex';
FilesModule._$('fm-context').style.display = 'none';
},
confirmDelete: function() {
var self = this;
if (!this.toDelete) return;
this._post('/delete', {path: this.toDelete.path})
.then(function() { self.toDelete = null; self.closeDialog(); self.refresh(); })
.catch(function(e) { alert('删除失败: ' + e.message); });
},
closeDialog: function() {
FilesModule._$('fm-dialog').style.display = 'none';
this.toDelete = null;
},
/* ── Helpers ── */
_buildBreadcrumbHTML: function(crumbs) {
if (!crumbs || !crumbs.length) return '';
var h = '';
for (var i = 0; i < crumbs.length; i++) {
var c = crumbs[i];
h += '' + this._esc(c.label) + ' ';
if (i < crumbs.length - 1) h += '▸ ';
}
return h;
},
_esc: function(s) {
return String(s).replace(/&/g,'&').replace(//g,'>');
},
_escAttr: function(s) {
return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>');
},
_fmtSize: function(bytes) {
if (bytes === null || bytes === undefined) return '—';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
},
_dirIcon: function() {
return ' ';
},
_fileIcon: function(ext) {
// Group by category → single SVG per category
var code = ' ';
var img = ' ';
var arch = ' ';
var media = ' ';
var codeExts = ['.py','.js','.ts','.jsx','.tsx','.html','.css','.scss','.less','.json','.yaml','.yml','.xml','.toml','.ini','.cfg','.conf','.sql','.sh','.bat','.c','.h','.cpp','.hpp','.java','.kt','.rs','.go','.rb','.php','.swift','.r','.lua','.pl','.scala','.dart','.Makefile','.gitignore','.dockerfile','.env'];
var imgExts = ['.png','.jpg','.jpeg','.gif','.svg','.ico','.bmp','.webp','.tiff'];
var mediaExts = ['.mp3','.wav','.ogg','.flac','.aac','.mp4','.avi','.mkv','.mov','.webm'];
var archExts = ['.zip','.tar','.gz','.7z','.rar','.bz2','.xz'];
if (codeExts.indexOf(ext) >= 0) return code;
if (imgExts.indexOf(ext) >= 0) return img;
if (mediaExts.indexOf(ext) >= 0) return media;
if (archExts.indexOf(ext) >= 0) return arch;
return code; // default: document icon
},
destroy: function() {
FilesModule._$('fm-context').style.display = 'none';
FilesModule._$('fm-dialog').style.display = 'none';
FilesModule._$('fm-editor').style.display = 'none';
}
};
/* ── Global picker functions (used by onclick in banner HTML) ── */
window._fmPick = function() {
var p = window.FilesModule.currentPath || '/';
// Visual feedback to confirm execution
var banner = FilesModule._$('fm-picker-banner');
if(banner) banner.style.background = '#4caf50';
if (window.parent !== window) {
window.parent.postMessage(JSON.stringify({action:'fm-picked', path:p, callback_id:window.FilesModule.pickerCallback}), '*');
}
};
window._fmCancel = function() {
var banner = FilesModule._$('fm-picker-banner');
if(banner) banner.style.background = '#f44336';
if (window.parent !== window) {
window.parent.postMessage(JSON.stringify({action:'fm-picked', path:null, callback_id:window.FilesModule.pickerCallback}), '*');
}
};