@@ -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 file list
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 || [ ] ;
renderFileList ( _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 ) ;
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 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 }
const name = prompt ( '新建目录名称:' , '' ) ;
if ( ! name ) return ;
if ( name . includes ( '/' ) ) { toast ( 'error' , '目录名不能包含 /' ) ; return }
const fullPath = _currentPath . replace ( /\/$/ , '' ) + '/' + name ;
if ( await fileOp ( 'mkdir' , fullPath ) ) {
toast ( 'success' , ` 已创建目录: ${ name } ` ) ;
browseDir ( _currentPath ) ;
}
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 ) }
}
} ) ;
}
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 , '>' ) }
// 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 Upload ──
// ── 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 ) ;
}
}
// ── 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 >