@@ -2,126 +2,526 @@
< body class = "bg-slate-950 text-slate-100 min-h-screen flex" x-data = "{sidebarOpen:true}" >
< aside x-show = "sidebarOpen" x-transition class = "w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar > < / aside >
< div class = "flex-1 flex flex-col min-w-0" >
< header class = "bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between" >
< header class = "bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between flex-wrap gap-2 " >
< div class = "flex items-center gap-4" >
< button @ click = "sidebarOpen=!sidebarOpen" class = "text-slate-400 hover:text-white transition" > < svg class = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" > < path stroke-linecap = "round" stroke-width = "2" d = "M4 6h16M4 12h16M4 18h16" / > < / svg > < / button >
< h1 class = "text-white font-semibold" > 文件管理< / h1 >
< / div >
< div class = "flex items-center gap-3 " >
< select id = "serverSelect" class = "px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" >
< div class = "flex items-center gap-2 flex-wrap " >
< select id = "serverSelect" class = "px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange = "onServerChange() ">
< option value = "" > -- 选择服务器 --< / option >
< / select >
< input id = "dirPath" type = "text" value = "/" placeholder = "/ 路径 " class = "px-3 py-1.5 bg-slate-8 00 border border -slate-7 00 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48" >
< button onclick = "browseDir()" class = "px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition" > 浏览 < / button >
< button id = "refreshServersBtn " class = "px-2 py-1.5 bg-slate-7 00 hover:bg -slate-6 00 text-white text-sm rounded-lg transition" title = "刷新服务器列表" onclick = "loadServers()" > 🔄 < / button >
< input id = "dirPath" type = "text" value = "/www/wwwroot" placeholder = "/www/wwwroot" class = "px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48" >
< button id = "browseBtn" onclick = "browseDir()" class = "px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition" > 浏览< / button >
< button onclick = "doMkdir()" class = "px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title = "新建目录" > 📁+< / button >
< button onclick = "pickUpload File()" class = "px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title = "上传 文件" > ↑ 上传 < / button >
< button onclick = "doNew File()" class = "px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title = "新建 文件" > 📄+ < / button >
< button id = "uploadBtn" onclick = "pickUploadFile()" class = "px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title = "上传文件" > ↑ 上传< / button >
< button onclick = "openTerminal()" class = "px-3 py-1.5 bg-green-700 hover:bg-green-600 text-white text-sm rounded-lg transition" title = "打开SSH终端" > 🖥 终端< / button >
< input id = "uploadFileInput" type = "file" multiple class = "hidden" onchange = "doUploadFiles(this.files)" >
< / div >
< / header >
<!-- Breadcrumb -->
< div id = "breadcrumb " class = "bg-slate-900/5 0 border-b border-slate-800 px-6 py-2 flex items-center gap-1 text-sm hidden " >
< span class = "text-slate-500" > 路径: < / span >
< div id = "breadcrumbPath" class = "flex items-center gap -1"> < / div >
<!-- Upload Progress Bar -->
< div id = "uploadProgress " class = "hidden bg-slate-900/8 0 border-b border-slate-800 px-6 py-2" >
< div class = "flex items-center gap-3 text-sm" >
< span id = "uploadFileName" class = "text-slate-300 truncate flex -1"> < / span >
< span id = "uploadPercent" class = "text-brand-light tabular-nums w-12 text-right" > < / span >
< / div >
< div class = "mt-1 h-1.5 bg-slate-800 rounded-full overflow-hidden" >
< div id = "uploadBar" class = "h-full bg-brand rounded-full transition-all" style = "width:0%" > < / div >
< / div >
< / div >
<!-- Breadcrumb + Search -->
< div id = "breadcrumb" class = "bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-2 hidden" >
< span class = "text-slate-500 text-sm shrink-0" > 路径:< / span >
< div id = "breadcrumbPath" class = "flex items-center gap-1 flex-1 overflow-x-auto" > < / div >
< input id = "fileSearch" placeholder = "🔍 搜索..." oninput = "filterFiles(this.value)" class = "px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand" >
< select onchange = "setSort(this.value)" class = "px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none" >
< option value = "name" > 名称↑< / option > < option value = "size" > 大小< / option > < option value = "time" > 时间< / option >
< / select >
< select onchange = "filterByExt(this.value)" class = "px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none" >
< option value = "" > 全部< / option > < option value = "conf" > .conf< / option > < option value = "log" > .log< / option > < option value = "sh" > .sh< / option > < option value = "py" > .py< / option > < option value = "json" > .json< / option > < option value = "yml" > .yml< / option > < option value = "txt" > .txt< / option >
< / select >
< / div >
<!-- Batch Action Bar -->
< div id = "batchBar" class = "hidden bg-slate-800 border-b border-slate-700 px-6 py-2 flex items-center gap-3" >
< span id = "batchCount" class = "text-slate-300 text-sm" > < / span >
< button onclick = "batchDelete()" class = "px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition" > 🗑 批量删除< / button >
< button onclick = "clearSelection()" class = "px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white text-xs rounded-lg transition" > 取消选择< / button >
< / div >
< main class = "flex-1 overflow-y-auto p-6" >
< div id = "fileList" class = "bg-slate-900 rounded-xl border border-slate-800 overflow-hidden" >
< div class = "px-4 py-8 text-center text-slate-500" > 选择服务器并输入目录路径开始浏览 < / div >
< div class = "px-4 py-12 text-center text-slate-500" >
< div class = "text-4xl mb-3" > 📂< / div >
< p > 选择服务器并输入目录路径开始浏览< / p >
< / div >
< / div >
< / main >
< / div >
<!-- IDE Editor Panel -->
< div id = "editorPanel" class = "hidden fixed inset-0 z-40 bg-slate-950 flex" >
<!-- File tree sidebar -->
< div id = "editorTree" class = "w-56 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" >
< div class = "px-3 py-2 border-b border-slate-800 flex items-center justify-between" >
< span class = "text-xs text-slate-500 font-medium" > 📁 文件列表< / span >
< button onclick = "toggleEditorTree()" class = "text-slate-600 hover:text-slate-400 text-xs" > ◀< / button >
< / div >
< div id = "editorTreeList" class = "flex-1 overflow-y-auto text-sm" > < / div >
< / div >
<!-- Editor area -->
< div class = "flex-1 flex flex-col min-w-0" >
<!-- Tab bar -->
< div id = "editorTabBar" class = "flex bg-slate-900 border-b border-slate-800 overflow-x-auto shrink-0" > < / div >
<!-- Monaco container (each tab gets its own child div) -->
< div id = "editorMonacoWrap" class = "flex-1 relative" > < / div >
<!-- Status bar -->
< div class = "flex items-center gap-4 px-4 py-1.5 bg-slate-900 border-t border-slate-800 text-xs text-slate-500 shrink-0" >
< span id = "editorPos" > Ln 1, Col 1< / span >
< span id = "editorLang" > plaintext< / span >
< span > UTF-8< / span >
< span id = "editorMod" class = "text-amber-400 hidden" > ● 未保存< / span >
< div class = "flex-1" > < / div >
< span id = "editorFileName" class = "text-slate-400" > < / span >
< button onclick = "minimizeEditor()" class = "px-2 py-0.5 hover:text-white hover:bg-slate-700 rounded transition" title = "最小化" > —< / button >
< button onclick = "closeAllTabs()" class = "px-2 py-0.5 hover:text-red-400 hover:bg-red-900/30 rounded transition" title = "关闭全部" > ✕< / button >
< / div >
< / div >
< / div >
<!-- Minimized editor bar -->
< div id = "editorMinBar" class = "hidden fixed bottom-0 left-0 right-0 z-30 bg-slate-900 border-t border-slate-700 px-4 py-1.5 flex items-center gap-2" >
< div id = "editorMinTabs" class = "flex-1 flex items-center gap-1 overflow-x-auto" > < / div >
< button onclick = "restoreEditor()" class = "px-2 py-0.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" > ▲ 展开< / button >
< button onclick = "closeAllTabs()" class = "px-2 py-0.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-900/30 rounded" > ✕< / button >
< / div >
<!-- Modal (for non - editor operations: rename/delete/mkdir/chmod/compress) -->
< div id = "modal" class = "hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" >
< div id = "modalDialog" class = "bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700" >
< h3 id = "modalTitle" class = "text-white font-medium mb-4 text-lg" > < / h3 >
< p id = "modalText" class = "text-slate-300 text-sm mb-4 hidden whitespace-pre-line" > < / p >
< input id = "modalInput" class = "hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 focus:outline-none focus:ring-2 focus:ring-brand text-sm" autocomplete = "off" >
< div class = "flex gap-2 justify-end" >
< button onclick = "closeModal()" class = "px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" > 取消< / button >
< button id = "modalConfirm" class = "px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition" > 确认< / button >
< / div >
< / div >
< / div >
<!-- F - 1: Right - click context menu -->
< div id = "contextMenu" class = "hidden fixed z-50 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]" >
< button data-ctx = "preview" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > 👁 预览/编辑< / button >
< button data-ctx = "download" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > ⬇ 下载< / button >
< button data-ctx = "rename" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > ✏ 重命名< / button >
< button data-ctx = "chmod" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > 🔐 修改权限< / button >
< button data-ctx = "copyPath" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > 📋 复制路径< / button >
< button data-ctx = "terminal" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > 🖥 在此打开终端< / button >
< div class = "border-t border-slate-700 my-1" > < / div >
< button data-ctx = "compress" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > 📦 压缩为 tar.gz< / button >
< button data-ctx = "decompress" class = "ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition" > 📂 解压到当前目录< / button >
< div class = "border-t border-slate-700 my-1" > < / div >
< button data-ctx = "delete" class = "ctx-item w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-900/30 transition" > 🗑 删除< / button >
< / div >
< script src = "/app/api.js" > < / script >
< script src = "/app/layout.js" > < / script >
< script >
// Monaco Editor preload (inline to avoid api.js caching issues)
if(!window._monacoLoading& & !window._monacoReady){
window._monacoLoading=true;
const _ms=document.createElement('script');
_ms.src='https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
_ms.onload=function(){
require.config({paths:{vs:'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'}});
require(['vs/editor/editor.main'],function(){window.monaco=monaco;window._monacoReady=true});
};
document.head.appendChild(_ms);
}
initLayout('files');
let _currentPath='/';
let _currentServerId=null;
let _allEntries=[];
let _selectedPaths=new Set();
let _fileCache={}; // {path: {content, size, loaded_at}}
// ── P2-5: File size formatting ──
function formatSize(bytes){
if(!bytes||bytes==='--')return'--';
const b=parseInt(bytes);if(isNaN(b))return bytes;
if(b< 1024 ) return b + ' B ' ; if ( b < 1048576 ) return ( b / 1024 ) . toFixed ( 1 ) + ' KB ' ;
if(b< 1073741824 ) return ( b / 1048576 ) . toFixed ( 1 ) + ' MB ' ; return ( b / 1073741824 ) . toFixed ( 1 ) + ' GB ' ;
}
// ── P2-6: File type icons ──
function fileIcon(name,isDir,isLink){
if(isDir)return{icon:'📁',color:'text-yellow-400'};
if(isLink)return{icon:'🔗',color:'text-cyan-400'};
const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';
const m={py:'🐍',js:'📜',ts:'📜',html:'🌐',htm:'🌐',css:'🎨',json:'📋',yml:'📋',yaml:'📋',xml:'📋',conf:'📋',cfg:'📋',ini:'📋',toml:'📋',
sh:'⚙️',bash:'⚙️',zsh:'⚙️',
jpg:'🖼️',jpeg:'🖼️',png:'🖼️',gif:'🖼️',svg:'🖼️',ico:'🖼️',webp:'🖼️',
tar:'📦',gz:'📦',zip:'📦',rar:'📦',tgz:'📦',bz2:'📦',xz:'📦',
log:'📊',txt:'📝',md:'📝',csv:'📊',tsv:'📊',
sql:'🗄️',db:'🗄️',sqlite:'🗄️',
pdf:'📕',doc:'📘',docx:'📘',xls:'📗',xlsx:'📗',
mp3:'🎵',mp4:'🎬',avi:'🎬',mkv:'🎬',
env:'🔒',key:'🔒',pem:'🔒',crt:'🔒',cert:'🔒'};
return{icon:m[ext]||'📄',color:'text-slate-400'};
}
// ── P2-2/P2-3: Event delegation ──
let _clickTimer=null;
const fileListEl=document.getElementById('fileList');
fileListEl.addEventListener('click',e=>{
const btn=e.target.closest('[data-action]');
if(!btn)return;
// Delay single-click to allow dblclick to cancel it
clearTimeout(_clickTimer);
_clickTimer=setTimeout(()=>{
const action=btn.dataset.action,path=btn.dataset.path||'',name=btn.dataset.name||'',isDir=btn.dataset.isDir==='true';
if(action==='browse')browseDir(path);
else if(action==='rename')doRename(path,name);
else if(action==='delete')doDelete(path,isDir);
else if(action==='download')doDownload(path,name);
else if(action==='preview')doPreview(path);
else if(action==='select')toggleSelect(btn,path);
else if(action==='select-all')toggleSelectAll(btn);
},250);
});
// ── Double-click: open editor for files, browse for directories ──
fileListEl.addEventListener('dblclick',e=>{
clearTimeout(_clickTimer); // Cancel single-click action
const row=e.target.closest('.file-row');
if(!row)return;
const pathBtn=row.querySelector('[data-action="rename"]');
if(!pathBtn)return;
const path=pathBtn.dataset.path;
const isDir=!!row.querySelector('[data-action="browse"]');
if(isDir){browseDir(path)}else{doPreview(path)}
});
// ── F-1: Right-click context menu ──
let _ctxPath='',_ctxIsDir=false,_ctxName='';
const ctxMenu=document.getElementById('contextMenu');
fileListEl.addEventListener('contextmenu',e=>{
e.preventDefault();
const row=e.target.closest('.file-row');
if(!row){ctxMenu.classList.add('hidden');return}
const renameBtn=row.querySelector('[data-action="rename"]');
if(!renameBtn)return;
_ctxPath=renameBtn.dataset.path;_ctxName=renameBtn.dataset.name||'';
_ctxIsDir=!!row.querySelector('[data-action="browse"]');
// Show/hide items based on file type
ctxMenu.querySelectorAll('[data-ctx="preview"],[data-ctx="download"]').forEach(b=>{b.classList.toggle('hidden',_ctxIsDir)});
ctxMenu.querySelectorAll('[data-ctx="decompress"]').forEach(b=>{b.classList.toggle('hidden',!_ctxName.match(/\.(tar\.gz|tgz|zip)$/))});
// Position
const x=Math.min(e.clientX,window.innerWidth-200);
const y=Math.min(e.clientY,window.innerHeight-350);
ctxMenu.style.left=x+'px';ctxMenu.style.top=y+'px';
ctxMenu.classList.remove('hidden');
});
document.addEventListener('click',()=>ctxMenu.classList.add('hidden'));
ctxMenu.addEventListener('click',async e=>{
const btn=e.target.closest('[data-ctx]');
if(!btn)return;
const action=btn.dataset.ctx;
ctxMenu.classList.add('hidden');
if(action==='preview')doPreview(_ctxPath);
else if(action==='download')doDownload(_ctxPath,_ctxName);
else if(action==='rename')doRename(_ctxPath,_ctxName);
else if(action==='delete')doDelete(_ctxPath,_ctxIsDir);
else if(action==='copyPath'){navigator.clipboard.writeText(_ctxPath);toast('success','已复制路径')}
else if(action==='chmod')doChmod(_ctxPath,_ctxName);
else if(action==='terminal'){window.open(`/app/terminal.html?server_id=${_currentServerId}& path=${encodeURIComponent(_ctxPath.replace(/\/[^/]+$/,'')||'/')}`,'_blank')}
else if(action==='compress')doCompress([_ctxPath],_ctxName);
else if(action==='decompress')doDecompress(_ctxPath);
});
// ── F-3: chmod ──
async function doChmod(path,name){
// Get current perms from _allEntries
const entry=_allEntries.find(e=>{const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;return fp===path});
const currentPerms=entry?.perms||'';
showModal({title:`🔐 修改权限 — ${name}`,text:`当前权限: ${currentPerms}`,input:true,inputDefault:'',confirmText:'修改',
onConfirm:async(mode)=>{
if(!mode.match(/^[0-7]{3,4}$/)){toast('error','权限格式错误,如 755');return}
const r=await apiFetch(API+'/sync/chmod',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,mode})});
if(r& & r.ok){toast('success',`权限已修改: ${mode}`);browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','修改失败: '+(e.detail||''))}
}
});
}
// ── F-6: Compress ──
async function doCompress(paths,baseName){
const dest=_currentPath.replace(/\/$/,'')+'/'+(baseName||'archive')+'.tar.gz';
showModal({title:'📦 压缩文件',text:`将 ${paths.length} 个文件压缩为:\n${dest}`,input:true,inputDefault:dest,confirmText:'压缩',
onConfirm:async(destPath)=>{
if(!destPath)return;
const r=await apiFetch(API+'/sync/compress',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,paths,dest:destPath,format:'tar.gz'})});
if(r&&r.ok){toast('success','压缩完成'); browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','压缩失败: '+(e.detail||''))}
}
});
}
// ── F-7: Decompress ──
async function doDecompress(path){
const dest=_currentPath.replace(/\/$/,'')+'/';
showModal({title:'📂 解压文件',text:`解压到: ${dest}`,confirmText:'解压',
onConfirm:async()=>{
const r=await apiFetch(API+'/sync/decompress',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,dest})});
if(r&&r.ok){toast('success','解压完成'); browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','解压失败: '+(e.detail||''))}
}
});
}
// ── F-2: New file ──
async function doNewFile(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
showModal({title:'📄 新建文件',input:true,inputDefault:'',confirmText:'创建',
onConfirm:async(name)=>{
if(!name)return;
if(name.includes('/')){toast('error','文件名不能包含 /');return}
const path=_currentPath.replace(/\/$/,'')+'/'+name;
const r=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content:''})});
if(r& & r.ok){toast('success',`已创建: ${name}`);browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','创建失败: '+(e.detail||''))}
}
});
}
// ── F-4: Sort toggle ──
let _sortBy='name';let _sortAsc=true;
function setSort(by){
if(_sortBy===by){_sortAsc=!_sortAsc}else{_sortBy=by;_sortAsc=true}
renderFileList(_allEntries);
}
function sortEntries(entries){
return[...entries].sort((a,b)=>{
if(a.is_dir!==b.is_dir)return b.is_dir-a.is_dir;
let cmp=0;
if(_sortBy==='name')cmp=a.name.localeCompare(b.name);
else if(_sortBy==='size')cmp=parseInt(a.size||0)-parseInt(b.size||0);
else if(_sortBy==='time')cmp=(a.modified||'').localeCompare(b.modified||'');
return _sortAsc?cmp:-cmp;
});
}
// ── F-5: File type filter ──
let _filterExt='';
function filterByExt(ext){_filterExt=ext;renderFileList(_allEntries)}
/** H-29: HTML-escape a string for safe innerHTML insertion */
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Open SSH terminal to current server/path ──
function openTerminal(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
const path=_currentPath||'/www/wwwroot';
window.open(`/app/terminal.html?server_id=${_currentServerId}&path=${encodeURIComponent(path)}`,'_blank');
}
// ── Server change: reset path and clear file list ──
function onServerChange(){
document.getElementById('dirPath').value='/www/wwwroot';
document.getElementById('breadcrumb').classList.add('hidden');
fileListEl.innerHTML='< div class = "px-4 py-12 text-center text-slate-500" > < div class = "text-4xl mb-3" > 📂< / div > < p > 点击「浏览」查看目录< / p > < / div > ';
_fileCache={};
}
// ── Modal system (P2-9) ──
let _modalResolve=null;
function showModal({title,text,input,textarea,inputDefault,textareaValue,confirmText,danger,onConfirm}){
document.getElementById('modalTitle').textContent=title;
const textEl=document.getElementById('modalText');
const inputEl=document.getElementById('modalInput');
const textareaEl=document.getElementById('modalTextarea');
const confirmBtn=document.getElementById('modalConfirm');
textEl.classList.toggle('hidden',!text);
textEl.textContent=text||'';
inputEl.classList.toggle('hidden',!input);
if(input){inputEl.value=inputDefault||'';setTimeout(()=>inputEl.focus(),50)}
textareaEl.classList.toggle('hidden',!textarea);
if(textarea){textareaEl.value=textareaValue||'';setTimeout(()=>textareaEl.focus(),50)}
confirmBtn.textContent=confirmText||'确认';
confirmBtn.className=`px-4 py-2 text-white text-sm rounded-lg transition ${danger?'bg-red-600 hover:bg-red-700':'bg-brand hover:bg-brand-dark'}`;
document.getElementById('modal').classList.remove('hidden');
_modalResolve=onConfirm;
}
function closeModal(){
document.getElementById('modal').classList.add('hidden');_modalResolve=null;
}
document.getElementById('modalConfirm').onclick=()=>{
if(_modalResolve){
const inputEl=document.getElementById('modalInput');
const textareaEl=document.getElementById('modalTextarea');
const val=textareaEl.classList.contains('hidden')?inputEl.value:textareaEl.value;
_modalResolve(val);
}
closeModal();
};
document.getElementById('modalInput').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('modalConfirm').click()});
document.getElementById('modal').addEventListener('click',e=>{if(e.target===document.getElementById('modal'))closeModal()});
// ── Servers (P3-4: refresh button) ──
async function loadServers(){
const r=await apiFetch(API+'/servers/');if(!r)return;
const data=await r.json();const servers=data.items||data;
document.getElementById('serverSelect').innerHTML=' < option value = "" > -- 选择服务器 -- < / option > '+servers.map(s=>` < option value = "${s.id}" > ${esc(s.name)} (${esc(s.domain)}) < / option > `).join('') ;
const sel= document.getElementById('serverSelect');
const prev=sel.value;
sel.innerHTML='< option value = "" > -- 选择服务器 --< / option > '+servers.map(s=>`< option value = "${s.id}" > ${esc(s.name)} (${esc(s.domain)})< / option > `).join('');
if(prev)sel.value=prev;
toast('info','服务器列表已刷新');
}
// ── Browse directory ──
async function browseDir(path){
const sid=document.getElementById('serverSelect').value;
if(path){document.getElementById('dirPath').value=path;_currentPath=path}
else{_currentPath=document.getElementById('dirPath')?.value||'/'}
if(!sid){toast('warning','请先选择服务器');return}
_currentServerId=parseInt(sid);
_currentServerId=parseInt(sid);_selectedPaths.clear();updateBatchBar();
document.getElementById(' fileList') .innerHTML='< div class = "px-4 py-8 text-center text-slate-500" > 加载中...< / div > ';
fileListEl .innerHTML='< div class = "px-4 py-12 text-center text-slate-500" > < div class = "text-2xl mb-2 animate-spin" > ⏳ < / div > 加载中...< / div > ';
try{
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
if(!r){document.getElementById(' fileList') .innerHTML='< div class = "px-4 py-8 text-center text-red-400" > 认证失败< / div > ';return}
if(!r){fileListEl .innerHTML='< div class = "px-4 py-12 text-center text-red-400" > 认证失败< / div > ';return}
const d=await r.json();
if(d.error){document.getElementById(' fileList') .innerHTML=`< div class = "px-4 py-8 text-center text-red-400" > ${esc(d.error)}< / div > `;toast('error','浏览失败: '+d.error.substring(0,50));return}
if(d.error){fileListEl .innerHTML=`< div class = "px-4 py-12 text-center text-red-400" > ${esc(d.error)}< / div > `;toast('error','浏览失败: '+d.error.substring(0,50));return}
// Update breadcrumb
updateBreadcrumb(_currentPath);
// Build f ile l ist
let html='';
// Parent directory link (if not root)
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`< div class = "flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick = "browseDir('${escAttr(parentPath)}')" >
< span class = "text-slate-500" > 📂< / span > < span class = "text-slate-400 text-sm" > ..< / span > < span class = "text-slate-600 text-xs" > 返回上级目录< / span >
< / div > `;
}
if(d.entries.length){
// Sort: directories first, then files
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
html+=sorted.map(e=>{
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
return `< div class = "group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50" >
< span class = "${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm shrink-0" > ${e.is_dir?'📁':'📄'}< / span >
< span class = "text-white text-sm flex-1 ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" $ { e . is_dir ? ` onclick = "browseDir('${escAttr(fullPath)}')" ` : ' ' } id = "name_${escAttr(fullPath)}" > ${esc(e.name)}< / span >
< span class = "text-slate-500 text-xs tabular-nums w-16 text-right shrink-0" > ${e.is_dir?'--':e.size}< / span >
< span class = "text-slate-600 text-xs shrink-0 hidden sm:block" > ${esc(e.perms)}< / span >
< span class = "text-slate-600 text-xs shrink-0 hidden md:block" > ${esc(e.owner||'')}< / span >
<!-- Action buttons (appear on row hover) -->
< div class = "flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" >
< button onclick = "doRename('${escAttr(fullPath)}','${escAttr(e.name)}')" class = "px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded transition" title = "重命名" > ✏< / button >
< button onclick = "doDelete('${escAttr(fullPath)}',${e.is_dir})" class = "px-2 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded transition" title = "${e.is_dir?'删除目录':'删除文件'}" > 🗑< / button >
< / div >
< / div > `;
}).join('');
}else if(_currentPath==='/'){
html+='< div class = "px-4 py-8 text-center text-slate-500" > 空目录< / div > ';
}
document.getElementById('fileList').innerHTML=html;
_allEntries=d.entries||[];
renderF ileL ist(_allEntries);
preloadFiles(_allEntries); // Background preload small text files
if(_editorVisible)renderFileTree(); // Update IDE file tree
}catch(e){
document.getElementById(' fileList') .innerHTML=`< div class = "px-4 py-8 text-center text-red-400" > 连接失败: ${esc(e.message)}< / div > `;
fileListEl .innerHTML=`< div class = "px-4 py-12 text-center text-red-400" > 连接失败: ${esc(e.message)}< / div > `;
toast('error','连接失败');
}
}
function renderFileList(entries){
let html='';
// Parent directory
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`< div class = "flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" data-action = "browse" data-path = "${esc(parentPath)}" >
< span class = "text-slate-500" > 📂< / span > < span class = "text-slate-400 text-sm" > ..< / span > < span class = "text-slate-600 text-xs" > 返回上级目录< / span >
< / div > `;
}
// P2-10: Select all checkbox
if(entries.length){
html+=`< div class = "flex items-center gap-3 px-4 py-1.5 border-b border-slate-800 bg-slate-800/30 text-xs text-slate-500" >
< input type = "checkbox" data-action = "select-all" class = "rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand cursor-pointer" >
< span class = "flex-1" > 全选< / span > < span class = "w-16 text-right" > 大小< / span > < span class = "w-20 hidden sm:block" > 权限< / span > < span class = "w-16 hidden md:block" > 所有者< / span > < span class = "w-20" > < / span >
< / div > `;
}
// File list
const filtered=_filterExt?entries.filter(e=>{if(e.is_dir)return true;const ext=e.name.includes('.')?e.name.split('.').pop().toLowerCase():'';return ext===_filterExt}):entries;
const sorted=sortEntries(filtered);
html+=sorted.map(e=>{
const fi=fileIcon(e.name,e.is_dir,e.is_link);
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
const linkHint=e.is_link?`title="→ ${esc(e.link_target||'')}"`:'';
return `< div class = "file-row group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50" data-name = "${esc(e.name.toLowerCase())}" >
< input type = "checkbox" data-action = "select" data-path = "${esc(fullPath)}" class = "file-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand shrink-0" >
< span class = "${fi.color} text-sm shrink-0" $ { linkHint } > ${fi.icon}< / span >
< span class = "text-white text-sm flex-1 truncate ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" $ { e . is_dir ? ` data-action = "browse" data-path = "${esc(fullPath)}" ` : ' ' } $ { linkHint } > ${esc(e.name)}< / span >
< span class = "text-slate-500 text-xs tabular-nums w-16 text-right shrink-0" > ${e.is_dir?'--':formatSize(e.size)}< / span >
< span class = "text-slate-600 text-xs shrink-0 w-20 hidden sm:block" > ${esc(e.perms)}< / span >
< span class = "text-slate-600 text-xs shrink-0 w-16 hidden md:block truncate" > ${esc(e.owner||'')}< / span >
< div class = "flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 w-20 justify-end" >
${!e.is_dir?`< button data-action = "preview" data-path = "${esc(fullPath)}" class = "px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title = "预览" > 👁< / button > `:''}
${!e.is_dir?`< button data-action = "download" data-path = "${esc(fullPath)}" data-name = "${esc(e.name)}" class = "px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title = "下载" > ⬇< / button > `:''}
< button data-action = "rename" data-path = "${esc(fullPath)}" data-name = "${esc(e.name)}" class = "px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title = "重命名" > ✏< / button >
< button data-action = "delete" data-path = "${esc(fullPath)}" data-is-dir = "${e.is_dir}" class = "px-1.5 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded" title = "${e.is_dir?'删除目录':'删除文件'}" > 🗑< / button >
< / div >
< / div > `;
}).join('');
// P3-3: Empty state
if(!entries.length){
html+=`< div class = "px-4 py-12 text-center" > < div class = "text-4xl mb-3" > 📂< / div > < p class = "text-slate-400 mb-3" > 空目录< / p > < button onclick = "pickUploadFile()" class = "px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition" > 上传文件< / button > < / div > `;
}
fileListEl.innerHTML=html;
document.getElementById('fileSearch').value='';
}
// ── P3-1: File search filter ──
function filterFiles(q){
q=q.toLowerCase();
document.querySelectorAll('.file-row').forEach(row=>{
row.style.display=!q||row.dataset.name.includes(q)?'':'none';
});
}
// ── P2-10: Selection ──
function toggleSelect(cb,path){
if(cb.checked)_selectedPaths.add(path);else _selectedPaths.delete(path);
updateBatchBar();
}
function toggleSelectAll(cb){
const cbs=document.querySelectorAll('.file-cb');
cbs.forEach(c=>{c.checked=cb.checked;const p=c.dataset.path;if(p){cb.checked?_selectedPaths.add(p):_selectedPaths.delete(p)}});
updateBatchBar();
}
function clearSelection(){_selectedPaths.clear();document.querySelectorAll('.file-cb').forEach(c=>c.checked=false);updateBatchBar()}
function updateBatchBar(){
const bar=document.getElementById('batchBar');
const n=_selectedPaths.size;
bar.classList.toggle('hidden',n===0);
document.getElementById('batchCount').textContent=`已选择 ${n} 项`;
}
async function batchDelete(){
const paths=[..._selectedPaths];if(!paths.length)return;
showModal({
title:'⚠️ 批量删除',text:`确定要删除选中的 ${paths.length} 项?此操作不可恢复!`,
confirmText:`删除 ${paths.length} 项`,danger:true,
onConfirm:async()=>{
let ok=0,fail=0;
for(const p of paths){
const isDir=_allEntries.some(e=>{const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;return fp===p&&e.is_dir});
if(await fileOp('delete',p)){ok++}else{fail++}
}
toast('success',`批量删除完成: ${ok} 成功${fail?`, ${fail} 失败`:''}`);
clearSelection();browseDir(_currentPath);
}
});
}
// ── Breadcrumb ──
document.getElementById('breadcrumbPath').addEventListener('click',e=>{
const el=e.target.closest('[data-action]');
if(!el)return;
const action=el.dataset.action;
if(action==='browse')browseDir(el.dataset.path);
});
function updateBreadcrumb(path){
const bc=document.getElementById('breadcrumb');
const bcPath=document.getElementById('breadcrumbPath');
bc.classList.remove('hidden');
// Split path into segments
const segments=path.split('/').filter(Boolean);
let html=`< span class = "cursor-pointer text-brand-light hover:underline" onclick = "browseDir('/') " > /< / span > `;
let html=`< span class = "cursor-pointer text-brand-light hover:underline shrink-0 " data-action = "browse" data-path = "/ "> /< / span > `;
let accumulated='';
segments.forEach((seg,i)=>{
accumulated+='/'+seg;
const p=accumulated ;
accumulated+='/'+seg;const p=accumulated;
const sep=i===0?'':' < span class = "text-slate-600" > / < / span > ' ;
if(i< segments.length-1 ) {
html+=`< span class = "text-slate-600" > / < / span > < span class = "cursor-pointer text-brand-light hover:underline" onclick = "browseDir(' ${escAttr (p)}') " > ${esc(seg)}< / span > `;
html+=`${sep} < span class = "cursor-pointer text-brand-light hover:underline" data-action = "browse" data-path = " ${esc(p)}"> ${esc(seg)}< / span > `;
}else{
html+=`< span class = "text-slate-600" > / < / span > < span class = "text-white font-medium" > ${esc(seg)}< / span > `;
html+=`${sep} < span class = "text-white font-medium" > ${esc(seg)}< / span > `;
}
});
bcPath.innerHTML=html;
}
// ── File Operations ──
async function fileOp(operation, path, newPath){
async function fileOp(operation,path,newPath){
if(!_currentServerId){toast('warning','请先选择服务器');return false}
const body={server_id:_currentServerId, operation, path};
const body={server_id:_currentServerId,operation,path};
if(newPath)body.new_path=newPath;
const r=await apiFetch(API+'/sync/file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
if(!r){toast('error','请求失败');return false}
@@ -129,72 +529,322 @@
return true;
}
async function doDelete(fullPath, isDir){
// P1-1: Delete with double confirmation for directories
async function doDelete(fullPath,isDir){
const name=fullPath.split('/').pop();
const warn=isDir?`确定要删除目录 "${name}" 及其所有内容吗?此操作不可恢复!`:`确定要删除文件 "${name}" 吗?`;
if(!confirm(warn))return;
if(await fileOp('delete', fullPath)){
toast('success', `已删除: ${name}`);
browseDir(_currentPath);
if(isDir){
showModal({
title:'⚠️ 危险操作',
text:`即将递归删除目录「${name}」及其所有内容,此操作不可恢复!\n请输入目录名以确认:`,
input:true,inputDefault:'',
confirmText:'确认删除',danger:true,
onConfirm:async(val)=>{
if(val!==name){toast('error','目录名不匹配,取消删除');return}
if(await fileOp('delete',fullPath)){toast('success',`已删除目录: ${name}`);browseDir(_currentPath)}
}
});
}else{
showModal({
title:'删除文件',text:`确定要删除文件「${name}」?`,
confirmText:'删除',danger:true,
onConfirm:async()=>{if(await fileOp('delete',fullPath)){toast('success',`已删除: ${name}`);browseDir(_currentPath)}}
});
}
}
async function doRename(fullPath, oldName){
const dir=fullPath.slice(0, fullPath.lastIndexOf('/')) || '/';
const newName=prompt(`重命名「${oldName}」为:`, oldName);
async function doRename(fullPath,oldName){
const dir=fullPath.slice(0,fullPath.lastIndexOf('/'))||'/';
showModal({
title:'重命名',input:true,inputDefault:oldName,confirmText:'确认',
onConfirm:async(newName)=>{
if(!newName||newName===oldName)return;
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
const newPath=dir.replace(/\/$/,'')+'/'+newName;
if(await fileOp('rename', fullPath, newPath)){
toast('success', `已重命名: ${oldName} → ${newName}`);
browseDir(_currentPath);
if(await fileOp('rename',fullPath,newPath)){toast('success',`已重命名: ${oldName} → ${newName}`);browseDir(_currentPath)}
}
});
}
async function doMkdir(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
const name=prompt('新建目录名称:', '');
showModal({
title:'新建目录',input:true,inputDefault:'',confirmText:'创建',
onConfirm:async(name)=>{
if(!name)return;
if(name.includes('/')){toast('error','目录名不能包含 /');return}
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
if(await fileOp('mkdir', fullPath)){
toast('success', `已创建目录: ${name}`);
browseDir(_currentPath );
if(await fileOp('mkdir',fullPath)){toast('success',`已创建目录: ${name}`);browseDir(_currentPath)}
}
} );
}
// P2-7: Download
async function doDownload(path,name){
toast('info',`正在下载 ${name}...`);
const r=await apiFetch(API+'/sync/download',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','下载失败: '+(e.detail||''));return}
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download=name;a.click();
URL.revokeObjectURL(url);toast('success',`已下载: ${name}`);
}
// ── File content preload + cache ──
const PRELOAD_MAX_SIZE=1000000;
const PRELOAD_MAX_FILES=10;
const PRELOAD_TEXT_EXTS=new Set(['conf','cfg','ini','toml','sh','bash','py','js','ts','json','yml','yaml','html','htm','css','xml','sql','md','env','txt','log','service','nginx','vhost','properties']);
function isTextFile(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return PRELOAD_TEXT_EXTS.has(ext)||ext.length< =4}
async function preloadFiles(entries){
const toPreload=entries.filter(e=>!e.is_dir&&!e.is_link&&parseInt(e.size)<=PRELOAD_MAX_SIZE&&parseInt(e.size)>0&&isTextFile(e.name)).slice(0,PRELOAD_MAX_FILES);
await Promise.allSettled(toPreload.map(async e=>{
const path=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(_fileCache[path])return;
try{const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r& & r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size}}}catch(e){}
}));
}
// P2-8: Preview/Edit (with cache) → IDE editor
async function doPreview(path){
const name=path.split('/').pop();
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
toast('info','正在读取文件...');
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''));return}
const d=await r.json();
_fileCache[path]={content:d.content,size:d.size};
openFileInEditor(path,name,d.content);
}
// ── IDE Editor ──
const LANG_MAP={conf:'ini',cfg:'ini',ini:'ini',toml:'ini',sh:'shell',bash:'shell',zsh:'shell',
py:'python',js:'javascript',ts:'typescript',json:'json',yml:'yaml',yaml:'yaml',
html:'html',htm:'html',css:'css',xml:'xml',sql:'sql',md:'markdown',
env:'ini',txt:'plaintext',log:'plaintext',service:'ini',nginx:'ini',properties:'ini',
go:'go',rs:'rust',rb:'ruby',java:'java',php:'php',c:'cpp',h:'cpp',cpp:'cpp'};
function getLang(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return LANG_MAP[ext]||'plaintext'}
let _openTabs=[]; // [{path, name, content, originalContent, language, editor, container, modified}]
let _activeTab=-1;
let _editorVisible=false;
let _editorMinimized=false;
function openFileInEditor(path,name,content){
// Check if already open
const existing=_openTabs.findIndex(t=>t.path===path);
if(existing>=0){switchTab(existing);return}
// Add new tab
const lang=getLang(name);
const container=document.createElement('div');
container.className='absolute inset-0';
container.style.display='none';
document.getElementById('editorMonacoWrap').appendChild(container);
const tab={path,name,content,originalContent:content,language:lang,editor:null,container,modified:false};
_openTabs.push(tab);
const idx=_openTabs.length-1;
// Create Monaco instance
if(window._monacoReady){
tab.editor=monaco.editor.create(container,{
value:content,language:lang,theme:'vs-dark',
automaticLayout:true,minimap:{enabled:true,side:'right'},
wordWrap:'on',fontSize:13,lineNumbers:'on',
scrollBeyondLastLine:false,bracketPairColorization:{enabled:true},
scrollbar:{verticalScrollbarSize:10},
});
tab.editor.addCommand(monaco.KeyMod.CtrlCmd|monaco.KeyCode.KeyS,()=>saveCurrentTab());
tab.editor.onDidChangeModelContent(()=>{
tab.modified=tab.editor.getValue()!==tab.originalContent;
updateStatusBar();renderTabBar();
});
tab.editor.onDidChangeCursorPosition(e=>{
document.getElementById('editorPos').textContent=`Ln ${e.position.lineNumber}, Col ${e.position.column}`;
});
}
switchTab(idx);
showEditorPanel();
renderFileTree();
}
function switchTab(idx){
if(idx< 0 | | idx > =_openTabs.length)return;
// Hide current
if(_activeTab>=0& & _openTabs[_activeTab]){
const prev=_openTabs[_activeTab];
if(prev.container)prev.container.style.display='none';
}
_activeTab=idx;
const tab=_openTabs[idx];
if(tab.container)tab.container.style.display='';
if(tab.editor){tab.editor.focus();tab.editor.layout()}
updateStatusBar();renderTabBar();renderMinBar();
}
function closeTab(idx){
if(idx< 0 | | idx > =_openTabs.length)return;
const tab=_openTabs[idx];
if(tab.modified& & !confirm(`文件 "${tab.name}" 有未保存的修改,确定关闭?`))return;
if(tab.editor)tab.editor.dispose();
if(tab.container)tab.container.remove();
_openTabs.splice(idx,1);
if(_openTabs.length===0){hideEditorPanel();return}
if(_activeTab>=_openTabs.length)_activeTab=_openTabs.length-1;
switchTab(_activeTab);
}
function closeAllTabs(){
const unsaved=_openTabs.filter(t=>t.modified).length;
if(unsaved& & !confirm(`有 ${unsaved} 个文件未保存,确定全部关闭?`))return;
_openTabs.forEach(t=>{if(t.editor)t.editor.dispose();if(t.container)t.container.remove()});
_openTabs=[];_activeTab=-1;
hideEditorPanel();
}
function saveCurrentTab(){
if(_activeTab< 0 ) return ;
const tab=_openTabs[_activeTab];
const content=tab.editor?tab.editor.getValue():tab.content;
if(content===tab.originalContent){toast('info','无变更');return}
apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:tab.path,content})}).then(r=>{
if(r&&r.ok){toast('success','已保存'); tab.originalContent=content;tab.modified=false;_fileCache[tab.path]={content,size:content.length};updateStatusBar();renderTabBar()}
else{r?r.json().then(e=>toast('error','保存失败: '+(e.detail||''))).catch(()=>toast('error','保存失败')):toast('error','保存失败')}
});
}
function showEditorPanel(){
_editorVisible=true;_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
// Trigger layout on active Monaco
setTimeout(()=>{if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout()},50);
}
function hideEditorPanel(){
_editorVisible=false;
document.getElementById('editorPanel').classList.add('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
renderMinBar();
}
function minimizeEditor(){
_editorMinimized=true;
document.getElementById('editorPanel').classList.add('hidden');
document.getElementById('editorMinBar').classList.remove('hidden');
renderMinBar();
}
function restoreEditor(){
_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
if(_activeTab>=0& & _openTabs[_activeTab].editor){setTimeout(()=>_openTabs[_activeTab].editor.layout(),50)}
}
function renderTabBar(){
const bar=document.getElementById('editorTabBar');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
const mod=t.modified?'● ':'';
return `< div class = "flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-slate-800 shrink-0 ${active?'bg-slate-800 text-white':'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}" onclick = "switchTab(${i})" >
< span class = "${t.modified?'text-amber-400':''}" > ${mod}${esc(t.name)}< / span >
< button onclick = "event.stopPropagation();closeTab(${i})" class = "ml-1 text-slate-600 hover:text-red-400" > × < / button >
< / div > `;
}).join('');
}
function renderMinBar(){
const bar=document.getElementById('editorMinTabs');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
return `< div class = "flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-slate-400 hover:text-slate-200'}" onclick = "switchTab(${i});restoreEditor()" >
< span class = "${t.modified?'text-amber-400':''}" > ${t.modified?'● ':''}${esc(t.name)}< / span >
< button onclick = "event.stopPropagation();closeTab(${i})" class = "ml-1 text-slate-600 hover:text-red-400" > × < / button >
< / div > `;
}).join('');
}
function updateStatusBar(){
if(_activeTab< 0 ) return ;
const tab=_openTabs[_activeTab];
document.getElementById('editorLang').textContent=tab.language;
document.getElementById('editorMod').classList.toggle('hidden',!tab.modified);
document.getElementById('editorFileName').textContent=tab.path;
}
function renderFileTree(){
const list=document.getElementById('editorTreeList');
let html=`< div class = "px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick = "browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')" > 📂 ..< / div > `;
const sorted=sortEntries(_allEntries);
sorted.forEach(e=>{
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(e.is_dir){
html+=`< div class = "px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick = "browseDir('${esc(fp)}')" > 📁 ${esc(e.name)}< / div > `;
}else{
const fi=fileIcon(e.name,false,e.is_link);
const inTab=_openTabs.findIndex(t=>t.path===fp);
const active=inTab===_activeTab;
html+=`< div class = "px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick = "openFileFromTree('${esc(fp)}','${esc(e.name)}')" >
${fi.icon} ${esc(e.name)}${inTab>=0?'< span class = "text-brand-light ml-1" > ●< / span > ':''}
< / div > `;
}
});
list.innerHTML=html;
}
async function openFileFromTree(path,name){
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r& & r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size};openFileInEditor(path,name,d.content)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''))}
}
function toggleEditorTree(){
const tree=document.getElementById('editorTree');
tree.classList.toggle('hidden');
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function escAttr(s){if(!s)return'';return s.replace(/&/g,'& ').replace(/"/g,'" ').replace(/'/g,'' ').replace(/< /g,'< ').replace(/>/g,'> ')}
// ── File Upload ──
// ── Upload (P2-4: progress bar) ──
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
async function doUploadFiles(files){
if(!_currentServerId){toast('warning','请先选择服务器');return}
if(!files||!files.length)return;
const remotePath=_currentPath||'/';
const progressEl=document.getElementById('uploadProgress');
const barEl=document.getElementById('uploadBar');
const fileEl=document.getElementById('uploadFileName');
const pctEl=document.getElementById('uploadPercent');
progressEl.classList.remove('hidden');
let ok=0,fail=0;
for(const file of files){
const formData=new FormData() ;
formData.append('server_id',_currentServerId) ;
formData.append('remote_path',remotePath) ;
formData.append('file',file);
for(let i=0;i < files.length ; i + + ) {
const file=files[i] ;
fileEl.textContent=`[${i+1}/${files.length}] ${file.name}` ;
barEl.style.width='0%';pctEl.textContent='0%' ;
try{
const r=await apiFetch(API+'/sync/upload',{method:'POST',body:formData});
if(r& & r.ok){ok++}else{
fail++ ;
const e=await r.json().catch(()=>({detail:'上传失败'}) );
toast('error',`${file.name}: ${(e.detail||'未知错误').substring(0,60)}` );
}
await new Promise((resolve,reject)=>{
const fd=new FormData();
fd.append('server_id',_currentServerId) ;
fd.append('remote_path',remotePath );
fd.append('file',file );
const xhr=new XMLHttpRequest();
xhr.upload.onprogress=e=>{if(e.lengthComputable){const p=Math.round(e.loaded/e.total*100);barEl.style.width=p+'%';pctEl.textContent=p+'%'}};
xhr.onload=()=>{
if(xhr.status===200){ok++;resolve()}
else{fail++;try{const d=JSON.parse(xhr.responseText);toast('error',`${file.name}: ${(d.detail||'上传失败').substring(0,60)}`)}catch(e){toast('error',`${file.name}: 上传失败 (${xhr.status})`)}resolve()}
};
xhr.onerror=()=>{fail++;toast('error',`${file.name}: 网络错误`);resolve()};
xhr.open('POST',API+'/sync/upload');
xhr.setRequestHeader('Authorization','Bearer '+access_token);
xhr.send(fd);
});
}catch(e){fail++;toast('error',`${file.name}: ${e.message}`)}
}
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?', '+fail+' 失败':''}` );
progressEl.classList.add('hidden' );
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?`, ${fail} 失败`:''}`);
if(ok>0)browseDir(_currentPath);
document.getElementById('uploadFileInput').value='';
}
// Drag & drop support on file list area
const fileListEl=document.getElementById('fileList');
// ── Drag & drop ──
fileListEl.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.add('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
fileListEl.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.remove('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
fileListEl.addEventListener('drop',e=>{
@@ -203,9 +853,18 @@
if(e.dataTransfer.files.length)doUploadFiles(e.dataTransfer.files);
});
// Enter key in path input triggers browse
// Enter key in path input
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
loadServers();
// Auto-select server from URL param (e.g., from servers.html "文件" button)
loadServers().then(()=>{
const urlServerId=new URLSearchParams(window.location.search).get('server_id');
if(urlServerId){
const sel=document.getElementById('serverSelect');
sel.value=urlServerId;
_currentServerId=parseInt(urlServerId);
browseDir('/www/wwwroot');
}
});
< / script >
< / body > < / html >