Files
Nexus/web/app/files.html
T
Your Name c570801119 fix: 编辑器拖拽修复 — 扩大拖拽区域+退出最大化+边界限制
问题:
- 标签栏整体被排除导致可拖拽区域几乎为零
- 最大化/全屏后拖拽不工作
- 没有拖拽视觉提示

修复:
- 添加⠿拖拽把手图标(cursor-grab)
- 只排除button/input/select, 标签栏空白处可拖拽
- 拖拽时自动退出最大化/全屏模式
- 边界限制(不拖出屏幕)
- 拖拽结束触发Monaco重新layout
2026-05-31 02:31:56 +08:00

984 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<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 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-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>
<button id="refreshServersBtn" class="px-2 py-1.5 bg-slate-700 hover:bg-slate-600 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="doNewFile()" 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>
<!-- Upload Progress Bar -->
<div id="uploadProgress" class="hidden bg-slate-900/80 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-12 text-center text-slate-500">
<div class="text-4xl mb-3">📂</div>
<p>选择服务器并输入目录路径开始浏览</p>
</div>
</div>
</main>
</div>
<!-- Floating Editor Panel (draggable + resizable + file tree) -->
<div id="editorPanel" class="hidden fixed z-40 flex flex-col" style="right:24px;bottom:60px;width:75vw;height:65vh;min-width:500px;min-height:300px;border-radius:12px;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);border:1px solid rgb(51 65 85);background:rgb(15 23 42)">
<!-- Drag handle + tabs + buttons -->
<div id="editorHeader" class="flex items-center bg-slate-900 border-b border-slate-800 shrink-0 select-none">
<!-- Drag grip icon -->
<div class="px-1.5 py-2 text-slate-600 cursor-grab active:cursor-grabbing shrink-0" title="拖拽移动"></div>
<!-- File tree toggle -->
<button onclick="toggleEditorTree()" id="treeToggleBtn" class="text-slate-500 hover:text-white px-2.5 py-2 text-sm hover:bg-slate-700 shrink-0" title="文件树">🌳</button>
<!-- Tabs (clickable, not draggable) -->
<div id="editorTabBar" class="flex flex-1 overflow-x-auto min-w-0"></div>
<!-- Right buttons -->
<div class="flex items-center shrink-0">
<button onclick="toggleEditorSize()" id="sizeBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最大化/还原"></button>
<button onclick="toggleEditorFullscreen()" id="fsBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="全屏"></button>
<button onclick="minimizeEditor()" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最小化"></button>
<button onclick="closeAllTabs()" class="text-slate-500 hover:text-red-400 px-2 py-2 text-sm hover:bg-red-900/30" title="关闭全部"></button>
</div>
</div>
<!-- Body: file tree + editor -->
<div class="flex flex-1 min-h-0">
<!-- File tree sidebar -->
<div id="editorTree" class="w-52 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0 overflow-hidden">
<div class="px-3 py-2 border-b border-slate-800 text-xs text-slate-500 font-medium">📁 文件列表</div>
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
</div>
<!-- Monaco container -->
<div id="editorMonacoWrap" class="flex-1 relative min-w-0"></div>
</div>
<!-- Status bar -->
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-800 border-t border-slate-700 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 truncate max-w-xs"></span>
</div>
<!-- Resize handle (bottom-right corner) -->
<div id="editorResizeHandle" class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10" style="background:linear-gradient(135deg,transparent 50%,rgb(100 116 139) 50%)"></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(!/^[a-zA-Z0-9._\-]+$/.test(name)){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)}
/** HTML-escape for safe innerHTML + onclick attribute insertion */
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML.replace(/"/g,'&quot;').replace(/'/g,'&#39;')}
// ── 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,inputDefault,confirmText,danger,onConfirm}){
document.getElementById('modalTitle').textContent=title;
const textEl=document.getElementById('modalText');
const inputEl=document.getElementById('modalInput');
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)}
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;
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);_selectedPaths.clear();updateBatchBar();
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){fileListEl.innerHTML='<div class="px-4 py-12 text-center text-red-400">认证失败</div>';return}
const d=await r.json();
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}
updateBreadcrumb(_currentPath);
_allEntries=d.entries||[];
renderFileList(_allEntries);
preloadFiles(_allEntries);
if(_editorVisible)renderFileTree(); // Background preload small text files
}catch(e){
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');
const segments=path.split('/').filter(Boolean);
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;
const sep=i===0?'':'<span class="text-slate-600">/</span>';
if(i<segments.length-1){
html+=`${sep}<span class="cursor-pointer text-brand-light hover:underline" data-action="browse" data-path="${esc(p)}">${esc(seg)}</span>`;
}else{
html+=`${sep}<span class="text-white font-medium">${esc(seg)}</span>`;
}
});
bcPath.innerHTML=html;
}
// ── File Operations ──
async function fileOp(operation,path,newPath){
if(!_currentServerId){toast('warning','请先选择服务器');return false}
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}
if(!r.ok){const e=await r.json().catch(()=>({detail:'未知错误'}));toast('error',(e.detail||'操作失败').substring(0,80));return false}
return true;
}
// P1-1: Delete with double confirmation for directories
async function doDelete(fullPath,isDir){
const name=fullPath.split('/').pop();
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('/'))||'/';
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)}
}
});
}
async function doMkdir(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
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)}
}
});
}
// 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', // 匹配 Nexus 暗色主题(官方默认 'vs' 亮色)
automaticLayout:true,
// 行号 + 折叠 (官方默认: lineNumbers='on', folding=true)
lineNumbers:'on',folding:true,
// 括号匹配 (官方默认: matchBrackets='always', bracketPairColorization=true)
bracketPairColorization:{enabled:true},matchBrackets:'always',
// 光标 (官方默认: cursorBlinking='blink', cursorStyle='line')
cursorBlinking:'blink',cursorStyle:'line',
// 字体 (官方默认: fontSize=0即14, 无lineHeight/padding)
fontSize:14,
fontFamily:"'Cascadia Code','Fira Code','JetBrains Mono',Menlo,Monaco,'Courier New',monospace",
// 换行 (官方默认: wordWrap='off')
wordWrap:'off',
// 小地图 (官方默认: enabled=true)
minimap:{enabled:true},
// 滚动 (官方默认: scrollBeyondLastLine=true)
scrollBeyondLastLine:true,
// 补全 (官方默认: suggestOnTriggerCharacters=true)
suggestOnTriggerCharacters:true,
// 缩进 (官方默认: tabSize=4)
tabSize:4,
// 其他官方默认行为
renderWhitespace:'none',
links:true,colorDecorators:true,
});
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();
}
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','保存失败')}
});
}
let _editorMaximized=false;
let _editorFullscreen=false;
let _editorTreeVisible=true;
const _defaultStyle='right:24px;bottom:60px;width:75vw;height:65vh;min-width:500px;min-height:300px';
const _maxStyle='left:16px;right:16px;top:16px;bottom:60px;width:auto;height:auto;min-width:0;min-height:0';
const _fsStyle='left:0;right:0;top:0;bottom:0;width:100vw;height:100vh;min-width:0;min-height:0;border-radius:0';
function showEditorPanel(){
_editorVisible=true;_editorMinimized=false;
const p=document.getElementById('editorPanel');
p.classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
if(!_editorFullscreen&&!_editorMaximized)p.style.cssText=_defaultStyle;
renderFileTree();
setTimeout(()=>{if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout()},50);
}
function hideEditorPanel(){
_editorVisible=false;_editorMaximized=false;_editorFullscreen=false;
const p=document.getElementById('editorPanel');
p.classList.add('hidden');p.style.cssText='';
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(!_editorFullscreen&&!_editorMaximized)document.getElementById('editorPanel').style.cssText=_defaultStyle;
renderFileTree();
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function toggleEditorSize(){
const p=document.getElementById('editorPanel');
_editorFullscreen=false;
_editorMaximized=!_editorMaximized;
p.style.cssText=_editorMaximized?_maxStyle:_defaultStyle;
p.style.borderRadius=_editorMaximized?'12px':'12px';
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function toggleEditorFullscreen(){
const p=document.getElementById('editorPanel');
_editorMaximized=false;
_editorFullscreen=!_editorFullscreen;
p.style.cssText=_editorFullscreen?_fsStyle:_defaultStyle;
p.style.borderRadius=_editorFullscreen?'0':'12px';
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function toggleEditorTree(){
_editorTreeVisible=!_editorTreeVisible;
document.getElementById('editorTree').classList.toggle('hidden',!_editorTreeVisible);
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
function renderFileTree(){
const list=document.getElementById('editorTreeList');
if(!list)return;
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||''))}
}
// ── Drag ──
(function initDrag(){
const header=document.getElementById('editorHeader');
const panel=document.getElementById('editorPanel');
let dragging=false,startX,startY,startLeft,startTop;
function startDrag(e){
// Only block drag on interactive elements (buttons, inputs, specific tab clicks)
if(e.target.closest('button')||e.target.closest('input')||e.target.closest('select'))return;
// Allow drag from: grip icon, empty header space, tab bar background
dragging=true;
_editorMaximized=false;_editorFullscreen=false; // Exit maximize/fullscreen on drag
const rect=panel.getBoundingClientRect();
startX=e.clientX;startY=e.clientY;
startLeft=rect.left;startTop=rect.top;
panel.style.right='auto';panel.style.bottom='auto';
panel.style.width=rect.width+'px';panel.style.height=rect.height+'px';
panel.style.left=startLeft+'px';panel.style.top=startTop+'px';
panel.style.borderRadius='12px';
document.body.style.userSelect='none';
e.preventDefault();
}
header.addEventListener('mousedown',startDrag);
document.addEventListener('mousemove',e=>{
if(!dragging)return;
const x=Math.max(0,Math.min(window.innerWidth-100,startLeft+e.clientX-startX));
const y=Math.max(0,Math.min(window.innerHeight-50,startTop+e.clientY-startY));
panel.style.left=x+'px';panel.style.top=y+'px';
});
document.addEventListener('mouseup',()=>{
if(dragging){dragging=false;document.body.style.userSelect='';
if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout();
}
});
})();
// ── Resize ──
(function initResize(){
const handle=document.getElementById('editorResizeHandle');
const panel=document.getElementById('editorPanel');
let resizing=false,startX,startY,startW,startH;
handle.addEventListener('mousedown',e=>{
e.stopPropagation();
resizing=true;
startX=e.clientX;startY=e.clientY;
startW=panel.offsetWidth;startH=panel.offsetHeight;
document.body.style.userSelect='none';
});
document.addEventListener('mousemove',e=>{
if(!resizing)return;
const newW=Math.max(500,startW+e.clientX-startX);
const newH=Math.max(300,startH+e.clientY-startY);
panel.style.width=newW+'px';panel.style.height=newH+'px';
});
document.addEventListener('mouseup',()=>{
if(resizing){
resizing=false;document.body.style.userSelect='';
if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout();
}
});
})();
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;
}
// ── 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(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{
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}`)}
}
progressEl.classList.add('hidden');
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?`, ${fail} 失败`:''}`);
if(ok>0)browseDir(_currentPath);
document.getElementById('uploadFileInput').value='';
}
// ── 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=>{
e.preventDefault();e.stopPropagation();
fileListEl.classList.remove('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950');
if(e.dataTransfer.files.length)doUploadFiles(e.dataTransfer.files);
});
// Enter key in path input
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
// 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>