/* ── 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}), '*'); } };