feat: files.html 全面迭代 — 15项改进
安全+后端:
- P2-1 符号链接解析(l权限检测+name/target分离)
- P2-7 新增 /api/sync/download 端点(SFTP流式下载)
- P2-8 新增 /api/sync/read-file + /write-file 端点(base64编码避免shell注入)
前端全面重写:
- P1-1 目录删除二次确认(自定义模态框+输入目录名)
- P2-2/2-3 事件委托替代内联onclick(data-action+data-path)
- P2-4 上传进度条(XHR.upload.onprogress)
- P2-5 文件大小可读化(B/KB/MB/GB)
- P2-6 文件类型图标(30+扩展名映射)
- P2-7 下载按钮+逻辑
- P2-8 文件预览/编辑弹窗(读取+修改+保存)
- P2-9 自定义模态框替代prompt/confirm
- P2-10 批量选择+批量删除(checkbox+全选+底部操作栏)
- P3-1 文件名搜索框(前端实时过滤)
- P3-2 符号链接🔗图标+target tooltip
- P3-3 空目录状态(图标+上传引导按钮)
- P3-4 服务器刷新按钮
- P3-5 操作loading状态(进度条) 2>&1
This commit is contained in:
@@ -192,6 +192,26 @@ class FileOperation(BaseModel):
|
||||
new_path: Optional[str] = None # required for rename
|
||||
|
||||
|
||||
class FileDownload(BaseModel):
|
||||
"""Download a file from a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class FileRead(BaseModel):
|
||||
"""Read text file content from a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1)
|
||||
max_size: int = Field(1_000_000, ge=1, le=5_000_000) # 1MB default, 5MB max
|
||||
|
||||
|
||||
class FileWrite(BaseModel):
|
||||
"""Write text content to a file on a remote server."""
|
||||
server_id: int = Field(..., ge=1)
|
||||
path: str = Field(..., min_length=1)
|
||||
content: str = Field(..., max_length=5_000_000)
|
||||
|
||||
|
||||
# ── Script ──
|
||||
|
||||
class ScriptCreate(BaseModel):
|
||||
|
||||
+151
-4
@@ -9,7 +9,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff
|
||||
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite
|
||||
from server.application.services.sync_engine_v2 import SyncEngineV2
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
|
||||
@@ -177,12 +177,21 @@ async def browse_directory(
|
||||
continue
|
||||
parts = line.split(None, 8)
|
||||
if len(parts) >= 9:
|
||||
is_dir = parts[0].startswith("d")
|
||||
perms = parts[0]
|
||||
is_dir = perms.startswith("d")
|
||||
is_link = perms.startswith("l")
|
||||
name = parts[8]
|
||||
link_target = None
|
||||
# P2-1: Parse symlink: "name -> target"
|
||||
if is_link and " -> " in name:
|
||||
name, link_target = name.split(" -> ", 1)
|
||||
entries.append({
|
||||
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
|
||||
"name": name,
|
||||
"is_dir": is_dir,
|
||||
"is_link": is_link,
|
||||
"link_target": link_target,
|
||||
"size": parts[4],
|
||||
"perms": parts[0],
|
||||
"perms": perms,
|
||||
"owner": parts[2],
|
||||
"modified": " ".join(parts[5:8]),
|
||||
})
|
||||
@@ -1043,3 +1052,141 @@ async def verify_sync(
|
||||
)
|
||||
|
||||
return {"results": results, "total_local_files": len(local_files)}
|
||||
|
||||
|
||||
# ── File Download / Read / Write ──
|
||||
|
||||
MAX_READ_FILE_SIZE = 1_000_000 # 1MB
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def download_file(
|
||||
payload: FileDownload,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""P2-7: Download a file from a remote server via SFTP."""
|
||||
import os
|
||||
from fastapi.responses import StreamingResponse
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
conn = await ssh_pool.acquire(server)
|
||||
try:
|
||||
async with conn.create_sftp_client() as sftp:
|
||||
# Check file exists and size
|
||||
try:
|
||||
stat = await sftp.stat(payload.path)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="文件不存在") from None
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
|
||||
|
||||
if stat.type not in ("regular file", "unknown"):
|
||||
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
|
||||
|
||||
filename = os.path.basename(payload.path)
|
||||
|
||||
async def file_generator():
|
||||
async with sftp.open(payload.path, "rb") as f:
|
||||
while chunk := await f.read(65536):
|
||||
yield chunk
|
||||
|
||||
await _audit_sync(
|
||||
"file_download", "server", payload.server_id,
|
||||
f"下载文件: {payload.path}",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
file_generator(),
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
finally:
|
||||
await ssh_pool.release(server.id)
|
||||
|
||||
|
||||
@router.post("/read-file")
|
||||
async def read_file(
|
||||
payload: FileRead,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""P2-8: Read text file content from a remote server."""
|
||||
import shlex
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
# Check file size first
|
||||
size_result = await exec_ssh_command(server, f"stat -c%s {shlex.quote(payload.path)}", timeout=5)
|
||||
if size_result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=404, detail="文件不存在或无法访问")
|
||||
|
||||
try:
|
||||
file_size = int(size_result["stdout"].strip())
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=502, detail="无法获取文件大小") from None
|
||||
|
||||
if file_size > payload.max_size:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节",
|
||||
)
|
||||
|
||||
# Read file content
|
||||
result = await exec_ssh_command(server, f"cat {shlex.quote(payload.path)}", timeout=15)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"读取失败: {result['stderr'][:200]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_read", "server", payload.server_id,
|
||||
f"读取文件: {payload.path} ({file_size} bytes)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"content": result["stdout"], "size": file_size, "path": payload.path}
|
||||
|
||||
|
||||
@router.post("/write-file")
|
||||
async def write_file(
|
||||
payload: FileWrite,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""P2-8: Write text content to a file on a remote server."""
|
||||
import shlex
|
||||
import base64
|
||||
from server.infrastructure.database.server_repo import ServerRepositoryImpl
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
session = request.state.db
|
||||
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="服务器不存在")
|
||||
|
||||
# Use base64 to avoid shell escaping issues with file content
|
||||
encoded = base64.b64encode(payload.content.encode("utf-8")).decode("ascii")
|
||||
cmd = f"echo '{encoded}' | base64 -d | tee {shlex.quote(payload.path)} > /dev/null"
|
||||
|
||||
result = await exec_ssh_command(server, cmd, timeout=30)
|
||||
if result["exit_code"] != 0:
|
||||
raise HTTPException(status_code=502, detail=f"写入失败: {result['stderr'][:200]}")
|
||||
|
||||
await _audit_sync(
|
||||
"file_write", "server", payload.server_id,
|
||||
f"写入文件: {payload.path} ({len(payload.content)} chars)",
|
||||
admin.username, request,
|
||||
)
|
||||
|
||||
return {"success": True, "path": payload.path, "size": len(payload.content)}
|
||||
|
||||
+328
-100
@@ -2,114 +2,288 @@
|
||||
<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">
|
||||
<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="/" placeholder="/ 路径" 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 onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
|
||||
<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="pickUploadFile()" 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>
|
||||
<input id="uploadFileInput" type="file" multiple class="hidden" onchange="doUploadFiles(this.files)">
|
||||
</div>
|
||||
</header>
|
||||
<!-- Breadcrumb -->
|
||||
<div id="breadcrumb" class="bg-slate-900/50 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/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">
|
||||
</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>
|
||||
|
||||
<!-- P2-9: Custom Modal -->
|
||||
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div 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">
|
||||
<textarea id="modalTextarea" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-brand resize-y" rows="20"></textarea>
|
||||
<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>
|
||||
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
initLayout('files');
|
||||
let _currentPath='/';
|
||||
let _currentServerId=null;
|
||||
let _allEntries=[];
|
||||
let _selectedPaths=new Set();
|
||||
|
||||
// ── 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 ──
|
||||
const fileListEl=document.getElementById('fileList');
|
||||
fileListEl.addEventListener('click',e=>{
|
||||
const btn=e.target.closest('[data-action]');
|
||||
if(!btn)return;
|
||||
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);
|
||||
});
|
||||
|
||||
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
// ── 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);
|
||||
}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 sorted=[...entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
|
||||
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 ──
|
||||
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;
|
||||
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+=`<span class="text-slate-600">/</span><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>`;
|
||||
}
|
||||
@@ -118,10 +292,9 @@
|
||||
}
|
||||
|
||||
// ── 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 +302,127 @@
|
||||
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 ──
|
||||
// P2-8: Preview/Edit
|
||||
async function doPreview(path){
|
||||
const name=path.split('/').pop();
|
||||
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();
|
||||
showModal({
|
||||
title:`📄 ${name}`,textarea:true,textareaValue:d.content,
|
||||
confirmText:'保存',cancelText:'关闭',
|
||||
onConfirm:async(content)=>{
|
||||
if(content===d.content){return}
|
||||
const wr=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content})});
|
||||
if(wr&&wr.ok){toast('success','文件已保存')}else{toast('error','保存失败')}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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,7 +431,7 @@
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user