2026-03-05 00:04:26 -06:00
import { encodeMsgpack , decodeMsgpack } from "./msgpack-helpers.js" ;
2026-04-13 17:15:06 -05:00
import { checkFeatureAccess } from "./feature-gate.js" ;
2026-03-05 00:04:26 -06:00
const clientId = window . location . pathname . split ( "/" )[ 1 ];
let ws = null ;
let currentPath = "" ;
let pathHistory = [];
let selectedFiles = new Set ();
let fileDownloads = new Map ();
let fileUploads = new Map ();
let fileUploadsById = new Map ();
let activeTransfers = new Map ();
let currentEditingFile = null ;
2026-03-26 09:55:02 -05:00
let currentPreviewBlobUrl = null ;
let currentPreviewPath = null ;
2026-03-05 00:04:26 -06:00
let lastSuccessfulResponse = 0 ;
let pendingToast = null ;
const recentToasts = new Map ();
const pendingCommandResults = new Map ();
2026-03-08 00:53:22 -06:00
const pendingCommandWaiters = new Map ();
2026-03-06 11:28:35 -06:00
const VIRTUALIZATION_THRESHOLD = 400 ;
const VIRTUAL_ROW_HEIGHT = 58 ;
const VIRTUAL_OVERSCAN = 8 ;
let directoryEntries = [];
let filteredDirectoryEntries = [];
let virtualScrollHandler = null ;
let virtualResizeHandler = null ;
let virtualRenderRaf = null ;
let isVirtualizedList = false ;
2026-03-05 00:04:26 -06:00
const statusEl = document . getElementById ( "status-indicator" );
const breadcrumbEl = document . getElementById ( "breadcrumb" );
const fileListEl = document . getElementById ( "file-list" );
const refreshBtn = document . getElementById ( "refresh-btn" );
const uploadBtn = document . getElementById ( "upload-btn" );
const mkdirBtn = document . getElementById ( "mkdir-btn" );
const searchBtn = document . getElementById ( "search-btn" );
const fileInput = document . getElementById ( "file-input" );
const contextMenu = document . getElementById ( "context-menu" );
const clientIdHeader = document . getElementById ( "client-id-header" );
const backBtn = document . getElementById ( "back-btn" );
const homeBtn = document . getElementById ( "home-btn" );
const pathInput = document . getElementById ( "path-input" );
const pathGoBtn = document . getElementById ( "path-go-btn" );
const transferPanel = document . getElementById ( "transfer-panel" );
const transferList = document . getElementById ( "transfer-list" );
2026-03-06 11:28:35 -06:00
const fileListPanel = document . getElementById ( "file-list-panel" );
const sortFieldEl = document . getElementById ( "sort-field" );
const sortOrderBtn = document . getElementById ( "sort-order-btn" );
const filterTypeEl = document . getElementById ( "filter-type" );
const fileCountSummaryEl = document . getElementById ( "file-count-summary" );
2026-03-05 00:04:26 -06:00
const searchBar = document . getElementById ( "search-bar" );
const searchInput = document . getElementById ( "search-input" );
const searchContentCheckbox = document . getElementById (
"search-content-checkbox" ,
);
const searchExecuteBtn = document . getElementById ( "search-execute-btn" );
const searchCloseBtn = document . getElementById ( "search-close-btn" );
const bulkActionsBar = document . getElementById ( "bulk-actions-bar" );
const selectedCountEl = document . getElementById ( "selected-count" );
const bulkDownloadBtn = document . getElementById ( "bulk-download-btn" );
const bulkDeleteBtn = document . getElementById ( "bulk-delete-btn" );
const bulkMoveBtn = document . getElementById ( "bulk-move-btn" );
const bulkCopyBtn = document . getElementById ( "bulk-copy-btn" );
const clearSelectionBtn = document . getElementById ( "clear-selection-btn" );
const fileEditorModal = document . getElementById ( "file-editor-modal" );
const editorTextarea = document . getElementById ( "editor-textarea" );
const editorFileName = document . getElementById ( "editor-file-name" );
const editorStatus = document . getElementById ( "editor-status" );
const editorSaveBtn = document . getElementById ( "editor-save-btn" );
const editorCancelBtn = document . getElementById ( "editor-cancel-btn" );
const editorCloseBtn = document . getElementById ( "editor-close-btn" );
2026-03-26 09:55:02 -05:00
const filePreviewModal = document . getElementById ( "file-preview-modal" );
const previewFileNameEl = document . getElementById ( "preview-file-name" );
const previewContent = document . getElementById ( "preview-content" );
const previewCloseBtn = document . getElementById ( "preview-close-btn" );
const previewDownloadBtn = document . getElementById ( "preview-download-btn" );
const previewStatusEl = document . getElementById ( "preview-status" );
2026-03-05 00:04:26 -06:00
if ( clientIdHeader ) {
clientIdHeader . textContent = ` ${ clientId } - File Browser` ;
}
2026-03-06 11:28:35 -06:00
let sortField = localStorage . getItem ( "filebrowser.sortField" ) || "name" ;
let sortOrder = localStorage . getItem ( "filebrowser.sortOrder" ) || "asc" ;
let filterType = localStorage . getItem ( "filebrowser.filterType" ) || "all" ;
let dragDepth = 0 ;
2026-03-05 00:04:26 -06:00
function connect () {
const protocol = window . location . protocol === "https:" ? "wss:" : "ws:" ;
const wsUrl = ` ${ protocol } // ${ window . location . host } /api/clients/ ${ clientId } /files/ws` ;
const socket = new WebSocket ( wsUrl );
socket . binaryType = "arraybuffer" ;
ws = socket ;
socket . onopen = () => {
console . log ( "File browser connected" );
updateStatus ( "connected" , "Connected" );
enableControls ( true );
listFiles ( currentPath || "." , socket );
};
socket . onmessage = ( event ) => {
const msg = decodeMsgpack ( event . data );
if ( ! msg ) {
console . error ( "Failed to decode message" );
return ;
}
handleMessage ( msg );
};
socket . onerror = ( err ) => {
console . error ( "WebSocket error:" , err );
updateStatus ( "error" , "Connection Error" );
};
socket . onclose = () => {
console . log ( "File browser disconnected" );
updateStatus ( "disconnected" , "Disconnected" );
enableControls ( false );
if ( ws === socket ) {
setTimeout (() => connect (), 3000 );
}
};
}
function updateStatus ( state , text ) {
const icons = {
connecting : '<i class="fa-solid fa-circle-notch fa-spin"></i>' ,
connected : '<i class="fa-solid fa-circle text-green-400"></i>' ,
error : '<i class="fa-solid fa-circle-exclamation text-red-400"></i>' ,
disconnected : '<i class="fa-solid fa-circle text-slate-500"></i>' ,
};
statusEl . innerHTML = ` ${ icons [ state ] || icons . disconnected } ${ text } ` ;
statusEl . className =
state === "connected"
? "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-green-900/40 text-green-100 border border-green-700/60"
: "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300" ;
}
function enableControls ( enabled ) {
refreshBtn . disabled = ! enabled ;
uploadBtn . disabled = ! enabled ;
mkdirBtn . disabled = ! enabled ;
}
function send ( msg , socket = ws ) {
if ( socket && socket . readyState === WebSocket . OPEN ) {
console . log (
"[DEBUG] Sending message:" ,
msg . type ,
msg . commandType || "" ,
"to server" ,
);
socket . send ( encodeMsgpack ( msg ));
} else {
console . error (
"[DEBUG] Cannot send - WebSocket not open. State:" ,
socket ? . readyState ,
);
}
}
function notifyToast ( message , type = "info" , duration = 4000 ) {
if ( typeof window . showToast !== "function" ) return ;
const key = ` ${ type } : ${ message } ` ;
const now = Date . now ();
const last = recentToasts . get ( key ) || 0 ;
if ( now - last < 1000 ) return ;
recentToasts . set ( key , now );
if ( document . visibilityState === "hidden" ) {
if ( ! pendingToast ) {
pendingToast = { message , type , duration , count : 1 };
} else {
pendingToast . message = message ;
pendingToast . type = type ;
pendingToast . duration = duration ;
pendingToast . count += 1 ;
}
return ;
}
window . showToast ( message , type , duration );
}
function trackCommandResult ( commandId , options = {}) {
if ( ! commandId ) return ;
const {
refreshOnSuccess = false ,
successMessage = null ,
errorPrefix = "Operation failed" ,
} = options ;
pendingCommandResults . set ( commandId , {
refreshOnSuccess ,
successMessage ,
errorPrefix ,
});
}
2026-03-08 00:53:22 -06:00
function waitForCommandResult ( commandId , timeoutMs = 10 * 60 * 1000 ) {
return new Promise (( resolve , reject ) => {
if ( ! commandId ) {
reject ( new Error ( "missing command id" ));
return ;
}
const existing = pendingCommandWaiters . get ( commandId );
if ( existing ) {
clearTimeout ( existing . timeoutId );
existing . reject ( new Error ( "superseded command waiter" ));
}
const timeoutId = setTimeout (() => {
pendingCommandWaiters . delete ( commandId );
reject ( new Error ( "command timed out" ));
}, timeoutMs );
pendingCommandWaiters . set ( commandId , { resolve , reject , timeoutId });
});
}
2026-03-05 00:04:26 -06:00
document . addEventListener ( "visibilitychange" , () => {
if ( document . visibilityState !== "visible" ) return ;
if ( ! pendingToast || typeof window . showToast !== "function" ) return ;
const { message , type , duration , count } = pendingToast ;
pendingToast = null ;
const summary = count > 1 ? ` ${ message } (+ ${ count - 1 } more)` : message ;
window . showToast ( summary , type , duration );
});
function handleMessage ( msg ) {
console . log ( "[DEBUG] Received message:" , msg . type , msg );
switch ( msg . type ) {
case "ready" :
console . log ( "Session ready:" , msg . sessionId );
2026-03-13 13:33:39 -05:00
if ( msg . clientUser && msg . clientOs ) {
applyClientInfo ( msg . clientOs , msg . clientUser );
}
2026-03-05 00:04:26 -06:00
break ;
case "status" :
console . log ( "[DEBUG] Status message:" , msg );
if ( msg . status === "offline" ) {
const recentlyActive = Date . now () - lastSuccessfulResponse < 10_000 ;
if ( ! recentlyActive ) {
updateStatus ( "error" , "Client Offline" );
enableControls ( false );
}
}
break ;
case "file_list_result" :
handleFileList ( msg );
break ;
case "file_download" :
handleFileDownload ( msg );
break ;
case "file_upload_result" :
handleFileUploadResult ( msg );
break ;
case "file_read_result" :
console . log ( "[DEBUG] Routing to handleFileReadResult" );
handleFileReadResult ( msg );
break ;
case "file_search_result" :
handleFileSearchResult ( msg );
break ;
case "command_result" :
console . log ( "[DEBUG] Command result:" , msg );
handleCommandResult ( msg );
break ;
case "command_progress" :
console . log ( "[DEBUG] Command progress:" , msg );
handleCommandProgress ( msg );
break ;
default :
console . log ( "[DEBUG] Unknown message type:" , msg . type , msg );
}
}
function listFiles ( path , socket = ws ) {
if ( currentPath && currentPath !== path ) {
pathHistory . push ( currentPath );
}
currentPath = path ;
send ({ type : "file_list" , path }, socket );
updateBreadcrumb ( path );
updatePathInput ( path );
updateBackButton ();
}
function updatePathInput ( path ) {
pathInput . value = path || "." ;
}
function updateBackButton () {
backBtn . disabled = pathHistory . length === 0 ;
backBtn . classList . toggle ( "opacity-50" , pathHistory . length === 0 );
backBtn . classList . toggle ( "cursor-not-allowed" , pathHistory . length === 0 );
}
function goBack () {
if ( pathHistory . length > 0 ) {
const previousPath = pathHistory . pop ();
currentPath = previousPath ;
send ({ type : "file_list" , path : previousPath });
updateBreadcrumb ( previousPath );
updatePathInput ( previousPath );
updateBackButton ();
}
}
function goHome () {
pathHistory = [];
listFiles ( "." );
}
function updateBreadcrumb ( path ) {
const parts = path . split ( /[\/\\]/ ). filter (( p ) => p && p !== "." );
breadcrumbEl . innerHTML = "" ;
const root = document . createElement ( "span" );
root . className = "breadcrumb-item hover:text-blue-400 transition-colors" ;
root . innerHTML =
'<i class="fa-solid fa-hard-drive"></i> <span class="text-xs">Drives</span>' ;
root . onclick = () => listFiles ( "." );
breadcrumbEl . appendChild ( root );
if ( ! path || path === "." ) {
return ;
}
let accumulated = "" ;
parts . forEach (( part , idx ) => {
accumulated += ( accumulated ? "/" : "" ) + part ;
const pathSegment = accumulated ;
const separator = document . createElement ( "span" );
separator . className = "text-slate-600 mx-1" ;
separator . innerHTML = '<i class="fa-solid fa-chevron-right text-xs"></i>' ;
breadcrumbEl . appendChild ( separator );
const crumb = document . createElement ( "span" );
crumb . className = "breadcrumb-item hover:text-blue-400 transition-colors" ;
crumb . textContent = part ;
crumb . onclick = () => listFiles ( pathSegment );
breadcrumbEl . appendChild ( crumb );
});
}
2026-03-06 11:28:35 -06:00
function getFileExt ( name = "" ) {
const idx = name . lastIndexOf ( "." );
if ( idx < 0 || idx === name . length - 1 ) return "" ;
return name . slice ( idx + 1 ). toLowerCase ();
}
2026-03-26 09:55:02 -05:00
const PREVIEW_IMAGE_EXTS = new Set ([ "jpg" , "jpeg" , "png" , "gif" , "webp" , "bmp" ]);
const PREVIEW_PDF_EXTS = new Set ([ "pdf" ]);
const PREVIEW_MAX_BYTES = 50 * 1024 * 1024 ;
// list of binary files from chatgpt (I can't think of anything else either)
const KNOWN_BINARY_EXTS = new Set ([
"mp4" , "avi" , "mkv" , "mov" , "wmv" , "flv" , "webm" , "m4v" ,
"mp3" , "wav" , "flac" , "ogg" , "aac" , "wma" , "m4a" ,
"exe" , "msi" , "com" , "app" , "appimage" ,
"dll" , "so" , "dylib" , "lib" ,
"zip" , "rar" , "7z" , "tar" , "gz" , "bz2" , "xz" , "tgz" ,
"db" , "sqlite" , "sqlite3" , "mdb" ,
"ttf" , "otf" , "woff" , "woff2" , "eot" ,
"iso" , "img" , "vhd" , "vmdk" ,
]);
const IMAGE_MIME_MAP = {
jpg : "image/jpeg" ,
jpeg : "image/jpeg" ,
png : "image/png" ,
gif : "image/gif" ,
webp : "image/webp" ,
bmp : "image/bmp" ,
};
function isPreviewable ( name ) {
const ext = getFileExt ( name );
return PREVIEW_IMAGE_EXTS . has ( ext ) || PREVIEW_PDF_EXTS . has ( ext );
}
function getPreviewMimeType ( name ) {
const ext = getFileExt ( name );
if ( PREVIEW_PDF_EXTS . has ( ext )) return "application/pdf" ;
return IMAGE_MIME_MAP [ ext ] || null ;
}
function closePreview () {
if ( filePreviewModal ) filePreviewModal . classList . remove ( "show" );
if ( currentPreviewBlobUrl ) {
URL . revokeObjectURL ( currentPreviewBlobUrl );
currentPreviewBlobUrl = null ;
}
if ( previewContent ) previewContent . innerHTML = "" ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
currentPreviewPath = null ;
}
async function openFilePreview ( path , knownSize ) {
const fileName = path . split ( /[\/\\]/ ). pop () || "" ;
const mimeType = getPreviewMimeType ( fileName );
if ( ! mimeType ) return ;
if ( typeof knownSize === "number" && knownSize > PREVIEW_MAX_BYTES ) {
notifyToast ( `File too large to preview ( ${ formatBytes ( knownSize ) } )` , "info" , 4000 );
return ;
}
currentPreviewPath = path ;
if ( previewFileNameEl ) previewFileNameEl . textContent = fileName ;
if ( previewContent ) previewContent . innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-slate-400 text-2xl"></i>' ;
if ( previewStatusEl ) previewStatusEl . textContent = "Loading..." ;
if ( filePreviewModal ) filePreviewModal . classList . add ( "show" );
try {
const requestRes = await fetch ( "/api/file/download/request" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
credentials : "include" ,
body : JSON . stringify ({ clientId , path }),
});
if ( ! requestRes . ok ) {
const text = await requestRes . text ();
if ( currentPreviewPath !== path ) return ;
if ( previewContent ) previewContent . innerHTML = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i> ${ escapeHtml ( text || "Failed to load preview" ) } </div>` ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
return ;
}
const requestData = await requestRes . json ();
const downloadUrl = typeof requestData ? . downloadUrl === "string"
? requestData . downloadUrl
: ( requestData ? . downloadId
? `/api/file/download/ ${ encodeURIComponent ( requestData . downloadId ) } `
: "" );
if ( ! downloadUrl ) {
if ( currentPreviewPath !== path ) return ;
if ( previewContent ) previewContent . innerHTML = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i>Failed to load preview</div>` ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
return ;
}
const res = await fetch ( downloadUrl , {
method : "GET" ,
credentials : "include" ,
});
if ( ! res . ok ) {
const text = await res . text ();
if ( currentPreviewPath !== path ) return ;
if ( previewContent ) previewContent . innerHTML = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i> ${ escapeHtml ( text || "Failed to load preview" ) } </div>` ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
return ;
}
// Reject oversized files before buffering to prevent OOM.
const contentLength = Number ( res . headers . get ( "Content-Length" ) || 0 );
if ( contentLength > PREVIEW_MAX_BYTES ) {
if ( currentPreviewPath !== path ) return ;
if ( previewContent ) previewContent . innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview ( ${ escapeHtml ( formatBytes ( contentLength )) } )</div>` ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
return ;
}
const arrayBuffer = await res . arrayBuffer ();
if ( currentPreviewPath !== path ) return ;
if ( arrayBuffer . byteLength > PREVIEW_MAX_BYTES ) {
if ( previewContent ) previewContent . innerHTML = `<div class="text-slate-400 text-sm text-center p-6"><i class="fa-solid fa-file mr-2"></i>File too large to preview ( ${ escapeHtml ( formatBytes ( arrayBuffer . byteLength )) } )</div>` ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
return ;
}
if ( currentPreviewBlobUrl ) {
URL . revokeObjectURL ( currentPreviewBlobUrl );
currentPreviewBlobUrl = null ;
}
const blob = new Blob ([ arrayBuffer ], { type : mimeType });
currentPreviewBlobUrl = URL . createObjectURL ( blob );
if ( previewContent ) {
previewContent . innerHTML = "" ;
if ( PREVIEW_IMAGE_EXTS . has ( getFileExt ( fileName ))) {
const img = document . createElement ( "img" );
img . src = currentPreviewBlobUrl ;
img . alt = "" ;
previewContent . appendChild ( img );
} else {
const obj = document . createElement ( "object" );
obj . data = currentPreviewBlobUrl ;
obj . type = "application/pdf" ;
previewContent . appendChild ( obj );
}
}
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
} catch ( err ) {
if ( currentPreviewPath !== path ) return ;
if ( previewContent ) previewContent . innerHTML = `<div class="text-red-400 text-sm text-center p-6"><i class="fa-solid fa-exclamation-triangle mr-2"></i> ${ escapeHtml ( err . message || "Failed to load preview" ) } </div>` ;
if ( previewStatusEl ) previewStatusEl . textContent = "" ;
}
}
2026-03-13 13:33:39 -05:00
const FILE_ICON_MAP = {
// Images
jpg : "fa-file-image text-purple-400" , jpeg : "fa-file-image text-purple-400" ,
png : "fa-file-image text-purple-400" , gif : "fa-file-image text-purple-400" ,
webp : "fa-file-image text-purple-400" , bmp : "fa-file-image text-purple-400" ,
svg : "fa-file-image text-purple-400" , ico : "fa-file-image text-purple-400" ,
tiff : "fa-file-image text-purple-400" , tif : "fa-file-image text-purple-400" ,
// Video
mp4 : "fa-file-video text-pink-400" , avi : "fa-file-video text-pink-400" ,
mkv : "fa-file-video text-pink-400" , mov : "fa-file-video text-pink-400" ,
wmv : "fa-file-video text-pink-400" , flv : "fa-file-video text-pink-400" ,
webm : "fa-file-video text-pink-400" , m4v : "fa-file-video text-pink-400" ,
// Audio
mp3 : "fa-file-audio text-orange-400" , wav : "fa-file-audio text-orange-400" ,
flac : "fa-file-audio text-orange-400" , ogg : "fa-file-audio text-orange-400" ,
aac : "fa-file-audio text-orange-400" , wma : "fa-file-audio text-orange-400" ,
m4a : "fa-file-audio text-orange-400" ,
// PDF
pdf : "fa-file-pdf text-red-400" ,
// Word
doc : "fa-file-word text-blue-400" , docx : "fa-file-word text-blue-400" ,
odt : "fa-file-word text-blue-400" , rtf : "fa-file-word text-blue-400" ,
// Excel
xls : "fa-file-excel text-green-400" , xlsx : "fa-file-excel text-green-400" ,
ods : "fa-file-excel text-green-400" , csv : "fa-file-excel text-green-400" ,
// PowerPoint
ppt : "fa-file-powerpoint text-orange-500" , pptx : "fa-file-powerpoint text-orange-500" ,
odp : "fa-file-powerpoint text-orange-500" ,
// Archives
zip : "fa-file-zipper text-yellow-500" , rar : "fa-file-zipper text-yellow-500" ,
"7z" : "fa-file-zipper text-yellow-500" , tar : "fa-file-zipper text-yellow-500" ,
gz : "fa-file-zipper text-yellow-500" , bz2 : "fa-file-zipper text-yellow-500" ,
xz : "fa-file-zipper text-yellow-500" , tgz : "fa-file-zipper text-yellow-500" ,
// Code
js : "fa-file-code text-yellow-300" , ts : "fa-file-code text-blue-300" ,
jsx : "fa-file-code text-cyan-300" , tsx : "fa-file-code text-cyan-300" ,
py : "fa-file-code text-yellow-300" , go : "fa-file-code text-cyan-400" ,
rs : "fa-file-code text-orange-300" , c : "fa-file-code text-blue-300" ,
cpp : "fa-file-code text-blue-300" , h : "fa-file-code text-blue-300" ,
java : "fa-file-code text-red-300" , cs : "fa-file-code text-green-300" ,
rb : "fa-file-code text-red-400" , php : "fa-file-code text-indigo-300" ,
swift : "fa-file-code text-orange-400" , kt : "fa-file-code text-purple-300" ,
// Web
html : "fa-code text-orange-300" , htm : "fa-code text-orange-300" ,
css : "fa-code text-blue-300" , scss : "fa-code text-pink-300" ,
sass : "fa-code text-pink-300" , less : "fa-code text-blue-300" ,
// Config / Data
json : "fa-file-code text-emerald-400" , yaml : "fa-file-code text-emerald-400" ,
yml : "fa-file-code text-emerald-400" , xml : "fa-file-code text-emerald-400" ,
toml : "fa-file-code text-emerald-400" , ini : "fa-file-code text-emerald-400" ,
cfg : "fa-file-code text-emerald-400" , conf : "fa-file-code text-emerald-400" ,
env : "fa-file-code text-emerald-400" ,
// Text / Docs
txt : "fa-file-lines text-slate-300" , md : "fa-file-lines text-slate-300" ,
log : "fa-file-lines text-slate-400" , readme : "fa-file-lines text-slate-300" ,
// Executables
exe : "fa-gear text-green-400" , msi : "fa-gear text-green-400" ,
com : "fa-gear text-green-400" , app : "fa-gear text-green-400" ,
appimage : "fa-gear text-green-400" ,
// Scripts
bat : "fa-terminal text-green-300" , cmd : "fa-terminal text-green-300" ,
ps1 : "fa-terminal text-blue-300" , sh : "fa-terminal text-green-300" ,
bash : "fa-terminal text-green-300" , zsh : "fa-terminal text-green-300" ,
// Libraries
dll : "fa-puzzle-piece text-indigo-400" , so : "fa-puzzle-piece text-indigo-400" ,
dylib : "fa-puzzle-piece text-indigo-400" , lib : "fa-puzzle-piece text-indigo-400" ,
a : "fa-puzzle-piece text-indigo-400" ,
// Databases
db : "fa-database text-amber-400" , sqlite : "fa-database text-amber-400" ,
sqlite3 : "fa-database text-amber-400" , mdb : "fa-database text-amber-400" ,
sql : "fa-database text-amber-400" ,
// Fonts
ttf : "fa-font text-teal-400" , otf : "fa-font text-teal-400" ,
woff : "fa-font text-teal-400" , woff2 : "fa-font text-teal-400" ,
eot : "fa-font text-teal-400" ,
// Keys/certs
pem : "fa-key text-yellow-300" , crt : "fa-key text-yellow-300" ,
cer : "fa-key text-yellow-300" , key : "fa-key text-yellow-300" ,
pfx : "fa-key text-yellow-300" , p12 : "fa-key text-yellow-300" ,
// Disk images
iso : "fa-compact-disc text-slate-300" , img : "fa-compact-disc text-slate-300" ,
vhd : "fa-compact-disc text-slate-300" , vmdk : "fa-compact-disc text-slate-300" ,
};
function getFileIcon ( entry ) {
if ( entry . isDir ) {
return '<i class="fa-solid fa-folder text-yellow-400"></i>' ;
}
const ext = getFileExt ( entry . name );
const cls = FILE_ICON_MAP [ ext ];
if ( cls ) {
return `<i class="fa-solid ${ cls } "></i>` ;
}
return '<i class="fa-solid fa-file text-slate-400"></i>' ;
}
2026-03-06 11:28:35 -06:00
function entryMatchesFilter ( entry , mode ) {
if ( mode === "all" ) return true ;
if ( mode === "dirs" ) return !! entry . isDir ;
if ( mode === "files" ) return ! entry . isDir ;
if ( entry . isDir ) return false ;
const ext = getFileExt ( entry . name );
const imageExt = new Set ([ "jpg" , "jpeg" , "png" , "gif" , "webp" , "bmp" , "svg" , "ico" ]);
const docExt = new Set ([ "txt" , "md" , "pdf" , "doc" , "docx" , "xls" , "xlsx" , "ppt" , "pptx" , "csv" , "json" , "xml" , "yaml" , "yml" ]);
const archiveExt = new Set ([ "zip" , "rar" , "7z" , "tar" , "gz" , "bz2" , "xz" ]);
const execExt = new Set ([ "exe" , "msi" , "bat" , "cmd" , "ps1" , "sh" , "appimage" , "bin" , "com" ]);
if ( mode === "images" ) return imageExt . has ( ext );
if ( mode === "docs" ) return docExt . has ( ext );
if ( mode === "archives" ) return archiveExt . has ( ext );
if ( mode === "executables" ) return execExt . has ( ext );
return true ;
}
function sortEntries ( entries , field , order ) {
const dirRank = ( entry ) => ( entry . isDir ? 0 : 1 );
const factor = order === "desc" ? - 1 : 1 ;
const collator = new Intl . Collator ( undefined , { numeric : true , sensitivity : "base" });
return [... entries ]. sort (( a , b ) => {
const dirDiff = dirRank ( a ) - dirRank ( b );
if ( dirDiff !== 0 ) return dirDiff ;
let valueA = a . name || "" ;
let valueB = b . name || "" ;
if ( field === "size" ) {
valueA = Number ( a . size || 0 );
valueB = Number ( b . size || 0 );
} else if ( field === "modified" ) {
valueA = Number ( a . modTime || 0 );
valueB = Number ( b . modTime || 0 );
} else if ( field === "type" ) {
valueA = a . isDir ? "" : getFileExt ( a . name );
valueB = b . isDir ? "" : getFileExt ( b . name );
}
if ( typeof valueA === "number" && typeof valueB === "number" ) {
if ( valueA === valueB ) {
return factor * collator . compare ( a . name || "" , b . name || "" );
}
return factor * ( valueA - valueB );
}
const diff = collator . compare ( String ( valueA ), String ( valueB ));
if ( diff !== 0 ) return factor * diff ;
return factor * collator . compare ( a . name || "" , b . name || "" );
});
}
function applySortAndFilterEntries ( entries ) {
const filtered = entries . filter (( entry ) => entryMatchesFilter ( entry , filterType ));
return sortEntries ( filtered , sortField , sortOrder );
}
function updateSortOrderButton () {
if ( ! sortOrderBtn ) return ;
sortOrderBtn . textContent = sortOrder === "asc" ? "Asc" : "Desc" ;
}
function updateDirectorySummaryAndPaging ( totalCount , shownCount ) {
if ( fileCountSummaryEl ) {
fileCountSummaryEl . textContent = ` ${ totalCount } items` ;
}
}
function clearVirtualizedListMode () {
if ( virtualRenderRaf ) {
cancelAnimationFrame ( virtualRenderRaf );
virtualRenderRaf = null ;
}
if ( virtualScrollHandler ) {
window . removeEventListener ( "scroll" , virtualScrollHandler );
virtualScrollHandler = null ;
}
if ( virtualResizeHandler ) {
window . removeEventListener ( "resize" , virtualResizeHandler );
virtualResizeHandler = null ;
}
isVirtualizedList = false ;
fileListEl . classList . add ( "divide-y" , "divide-slate-800" );
}
function renderDirectoryStandard ( entries , canGoUp , parentPath , disableAnimations ) {
clearVirtualizedListMode ();
if ( ! disableAnimations ) {
fileListEl . style . opacity = "0" ;
fileListEl . style . transform = "translateX(20px)" ;
} else {
fileListEl . style . transition = "none" ;
fileListEl . style . opacity = "1" ;
fileListEl . style . transform = "translateX(0)" ;
2026-03-05 00:04:26 -06:00
}
2026-03-06 11:28:35 -06:00
const renderList = () => {
2026-03-05 00:04:26 -06:00
fileListEl . innerHTML = "" ;
if ( canGoUp ) {
2026-03-06 11:28:35 -06:00
fileListEl . appendChild ( createParentRow ( parentPath ));
2026-03-05 00:04:26 -06:00
}
if ( entries . length === 0 && ! canGoUp ) {
fileListEl . innerHTML =
'<div class="px-4 py-6 text-center text-slate-400"><i class="fa-solid fa-folder-open mr-2"></i>Empty directory</div>' ;
fileListEl . style . opacity = "1" ;
fileListEl . style . transform = "translateX(0)" ;
return ;
}
entries . forEach (( entry , index ) => {
const row = createFileRow ( entry );
2026-03-06 11:28:35 -06:00
if ( ! disableAnimations ) {
row . style . animationDelay = ` ${ index * 0.02 } s` ;
row . classList . add ( "card-animate" );
}
2026-03-05 00:04:26 -06:00
fileListEl . appendChild ( row );
});
2026-03-06 11:28:35 -06:00
if ( ! disableAnimations ) {
fileListEl . style . transition =
"opacity 0.3s ease-out, transform 0.3s ease-out" ;
fileListEl . style . opacity = "1" ;
fileListEl . style . transform = "translateX(0)" ;
}
};
if ( disableAnimations ) {
renderList ();
} else {
setTimeout ( renderList , 150 );
}
}
function renderDirectoryVirtualized ( entries , canGoUp , parentPath ) {
clearVirtualizedListMode ();
isVirtualizedList = true ;
fileListEl . style . transition = "none" ;
fileListEl . style . opacity = "1" ;
fileListEl . style . transform = "translateX(0)" ;
fileListEl . classList . remove ( "divide-y" , "divide-slate-800" );
fileListEl . innerHTML = "" ;
if ( canGoUp ) {
fileListEl . appendChild ( createParentRow ( parentPath ));
}
const host = document . createElement ( "div" );
const topSpacer = document . createElement ( "div" );
const rowsContainer = document . createElement ( "div" );
const bottomSpacer = document . createElement ( "div" );
host . className = "virtualized-list-host" ;
host . appendChild ( topSpacer );
host . appendChild ( rowsContainer );
host . appendChild ( bottomSpacer );
fileListEl . appendChild ( host );
const renderWindow = () => {
const hostTop = host . getBoundingClientRect (). top + window . scrollY ;
const viewportTop = window . scrollY ;
const viewportBottom = viewportTop + window . innerHeight ;
const relativeTop = Math . max ( 0 , viewportTop - hostTop );
const relativeBottom = Math . max ( 0 , viewportBottom - hostTop );
let start = Math . floor ( relativeTop / VIRTUAL_ROW_HEIGHT ) - VIRTUAL_OVERSCAN ;
let end = Math . ceil ( relativeBottom / VIRTUAL_ROW_HEIGHT ) + VIRTUAL_OVERSCAN ;
start = Math . max ( 0 , start );
end = Math . min ( entries . length , Math . max ( start + 1 , end ));
topSpacer . style . height = ` ${ start * VIRTUAL_ROW_HEIGHT } px` ;
bottomSpacer . style . height = ` ${ Math . max ( 0 , ( entries . length - end ) * VIRTUAL_ROW_HEIGHT ) } px` ;
rowsContainer . innerHTML = "" ;
for ( let i = start ; i < end ; i += 1 ) {
rowsContainer . appendChild ( createFileRow ( entries [ i ]));
}
};
const scheduleRender = () => {
if ( virtualRenderRaf ) return ;
virtualRenderRaf = requestAnimationFrame (() => {
virtualRenderRaf = null ;
renderWindow ();
});
};
virtualScrollHandler = () => scheduleRender ();
virtualResizeHandler = () => scheduleRender ();
window . addEventListener ( "scroll" , virtualScrollHandler , { passive : true });
window . addEventListener ( "resize" , virtualResizeHandler );
renderWindow ();
}
function renderCurrentDirectory () {
filteredDirectoryEntries = applySortAndFilterEntries ( directoryEntries );
const visibleEntries = filteredDirectoryEntries ;
updateDirectorySummaryAndPaging ( visibleEntries . length , visibleEntries . length );
const canGoUp = shouldShowParentDirectory ( currentPath );
const parentPath = canGoUp ? getParentPath ( currentPath ) : "." ;
if ( visibleEntries . length > VIRTUALIZATION_THRESHOLD ) {
renderDirectoryVirtualized ( visibleEntries , canGoUp , parentPath );
return ;
}
const disableAnimations = visibleEntries . length > 50 ;
renderDirectoryStandard ( visibleEntries , canGoUp , parentPath , disableAnimations );
}
function handleFileList ( msg ) {
if ( msg . error ) {
clearVirtualizedListMode ();
fileListEl . innerHTML = `<div class="px-4 py-6 text-center text-red-400"><i class="fa-solid fa-exclamation-triangle mr-2"></i> ${ escapeHtml ( msg . error ) } </div>` ;
updateDirectorySummaryAndPaging ( 0 , 0 );
return ;
}
lastSuccessfulResponse = Date . now ();
updateStatus ( "connected" , "Connected" );
enableControls ( true );
currentPath = msg . path ;
directoryEntries = Array . isArray ( msg . entries ) ? msg . entries : [];
2026-03-13 13:33:39 -05:00
// Sidebar: detect OS/home on first successful listing
if ( ! detectedOS ) {
detectOSAndHome ( currentPath );
// If root listing with drive letters, detect as Windows
if ( ! detectedOS && ( ! currentPath || currentPath === "." ) && directoryEntries . some (( e ) => e . isDir && e . name . match ( /^[A-Za-z]:$/ ))) {
detectedOS = "windows" ;
updateSidebar ();
}
} else if ( ! detectedHomePath ) {
// Keep trying to detect home path on subsequent navigations
detectOSAndHome ( currentPath );
}
// Populate drives panel when at root
if ( ! msg . path || msg . path === "." ) {
lastDriveEntries = directoryEntries ;
updateSidebarDrives ( directoryEntries );
} else if ( lastDriveEntries . length === 0 ) {
updateSidebarDrives ([]);
}
highlightSidebarActive ();
2026-03-06 11:28:35 -06:00
selectedFiles . clear ();
updateSelectionUI ();
renderCurrentDirectory ();
2026-03-05 00:04:26 -06:00
}
function shouldShowParentDirectory ( path ) {
if ( ! path || path === "." ) {
return false ;
}
return true ;
}
function getParentPath ( path ) {
const normalized = path . replace ( /\\/g , "/" );
const parts = normalized . split ( "/" ). filter (( p ) => p );
if ( parts . length === 1 && parts [ 0 ]. match ( /^[A-Za-z]:$/ )) {
return "." ;
}
if ( parts . length <= 1 ) {
return "." ;
}
parts . pop ();
let parentPath = parts . join ( "/" );
if ( parentPath . match ( /^[A-Za-z]:?$/ )) {
return parentPath . replace ( /^([A-Za-z]):?$/ , "$1:\\" );
}
return parentPath || "." ;
}
function createParentRow ( parentPath ) {
const row = document . createElement ( "div" );
row . className =
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors hover:bg-slate-800/50" ;
row . dataset . path = parentPath ;
row . dataset . isDir = "true" ;
row . innerHTML = `
<div class="col-span-6 flex items-center gap-2">
<i class="fa-solid fa-folder-arrow-up text-blue-400"></i>
<span class="font-semibold text-blue-300">..</span>
<span class="text-xs text-slate-500">(parent directory)</span>
</div>
<div class="col-span-2 text-sm text-slate-400">-</div>
<div class="col-span-3 text-sm text-slate-400">-</div>
<div class="col-span-1"></div>
` ;
row . ondblclick = () => listFiles ( parentPath );
row . onclick = () => listFiles ( parentPath );
return row ;
}
function createFileRow ( entry ) {
const row = document . createElement ( "div" );
row . className =
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors" ;
row . dataset . path = entry . path ;
row . dataset . isDir = entry . isDir ;
2026-03-13 13:33:39 -05:00
const icon = getFileIcon ( entry );
2026-03-05 00:04:26 -06:00
const size = entry . isDir ? "-" : formatBytes ( entry . size );
const modTime = new Date ( entry . modTime * 1000 ). toLocaleString ();
row . innerHTML = `
<input type="checkbox" class="file-checkbox" data-path=" ${ escapeHtml ( entry . path ) } ">
<div class="col-span-6 flex items-center gap-2 truncate pl-3">
${ icon }
<span class="truncate"> ${ escapeHtml ( entry . name ) } </span>
</div>
<div class="col-span-2 text-sm text-slate-400 file-size-col"> ${ size } </div>
<div class="col-span-3 text-sm text-slate-400 file-modified-col"> ${ modTime } </div>
<div class="col-span-1 flex items-center justify-end gap-1 action-buttons">
${ ! entry . isDir ? '<button class="action-btn px-2 py-1 rounded hover:bg-slate-700" data-action="download" title="Download"><i class="fa-solid fa-download"></i></button>' : "" }
${ entry . isDir ? '<button class="action-btn px-2 py-1 rounded hover:bg-slate-700" data-action="zip" title="Zip & Download"><i class="fa-solid fa-file-zipper"></i></button>' : "" }
<button class="action-btn px-2 py-1 rounded hover:bg-slate-700 text-red-400" data-action="delete" title="Delete"><i class="fa-solid fa-trash"></i></button>
</div>
` ;
const nameDiv = row . querySelector ( ".col-span-6" );
const mobileMetaDiv = document . createElement ( "div" );
mobileMetaDiv . className = "file-meta" ;
2026-03-26 09:55:02 -05:00
mobileMetaDiv . innerHTML = `<span> ${ size } </span><span class="opacity-30 select-none mx-1">·</span><span> ${ modTime } </span>` ;
2026-03-05 00:04:26 -06:00
nameDiv . appendChild ( mobileMetaDiv );
row . onclick = ( e ) => {
if ( e . target . closest ( ".file-checkbox" ) || e . target . closest ( ".action-btn" )) {
return ;
}
if ( entry . isDir ) {
listFiles ( entry . path );
2026-03-26 09:55:02 -05:00
} else if ( isPreviewable ( entry . name )) {
openFilePreview ( entry . path , entry . size );
} else if ( KNOWN_BINARY_EXTS . has ( getFileExt ( entry . name ))) {
notifyToast ( "Binary file — use Download to save it" , "info" , 3000 );
2026-03-05 00:04:26 -06:00
} else {
openFileInEditor ( entry . path );
}
};
const checkbox = row . querySelector ( ".file-checkbox" );
checkbox . onclick = ( e ) => {
e . stopPropagation ();
};
checkbox . onchange = ( e ) => {
if ( e . target . checked ) {
selectedFiles . add ( entry . path );
row . classList . add ( "selected" );
} else {
selectedFiles . delete ( entry . path );
row . classList . remove ( "selected" );
}
updateSelectionUI ();
};
row . querySelectorAll ( ".action-btn" ). forEach (( btn ) => {
btn . onclick = ( e ) => {
e . stopPropagation ();
const action = btn . dataset . action ;
handleFileAction ( action , entry );
};
});
row . oncontextmenu = ( e ) => {
e . preventDefault ();
showContextMenu ( e . clientX , e . clientY , entry );
};
return row ;
}
function toggleSelection ( row , path ) {
const checkbox = row . querySelector ( ".file-checkbox" );
if ( selectedFiles . has ( path )) {
selectedFiles . delete ( path );
row . classList . remove ( "selected" );
if ( checkbox ) checkbox . checked = false ;
} else {
selectedFiles . add ( path );
row . classList . add ( "selected" );
if ( checkbox ) checkbox . checked = true ;
}
updateSelectionUI ();
}
function handleFileAction ( action , entry ) {
switch ( action ) {
case "edit" :
openFileInEditor ( entry . path );
break ;
case "download" :
downloadFile ( entry . path );
break ;
case "zip" :
zipAndDownload ( entry . path );
break ;
case "copy" :
const copyDest = prompt ( "Copy to:" , entry . path + "_copy" );
if ( copyDest ) {
const commandId = `copy- ${ Date . now () } ` ;
send ({
type : "command" ,
commandType : "file_copy" ,
id : commandId ,
payload : { source : entry . path , dest : copyDest },
});
trackCommandResult ( commandId , {
refreshOnSuccess : true ,
successMessage : "Copy completed" ,
errorPrefix : "Copy failed" ,
});
}
break ;
case "move" :
const moveDest = prompt ( "Move to:" , entry . path );
if ( moveDest ) {
const commandId = `move- ${ Date . now () } ` ;
send ({
type : "command" ,
commandType : "file_move" ,
id : commandId ,
payload : { source : entry . path , dest : moveDest },
});
trackCommandResult ( commandId , {
refreshOnSuccess : true ,
successMessage : "Move completed" ,
errorPrefix : "Move failed" ,
});
}
break ;
case "chmod" :
const mode = prompt (
"Enter permissions (octal, e.g., 0755):" ,
entry . mode || "0644" ,
);
if ( mode ) {
const commandId = `chmod- ${ Date . now () } ` ;
send ({
type : "command" ,
commandType : "file_chmod" ,
id : commandId ,
payload : { path : entry . path , mode },
});
trackCommandResult ( commandId , {
refreshOnSuccess : true ,
successMessage : "Permissions updated" ,
errorPrefix : "Permissions update failed" ,
});
}
break ;
case "delete" :
deleteFile ( entry . path );
break ;
}
}
function downloadFile ( path ) {
console . log ( "Requesting download:" , path );
const transferId = `download- ${ Date . now () } - ${ Math . random () } ` ;
const fileName = path . split ( /[\/\\]/ ). pop ();
const abortController = new AbortController ();
const transfer = {
id : transferId ,
type : "download" ,
path ,
fileName ,
progress : 0 ,
total : 0 ,
received : 0 ,
receivedBytes : 0 ,
receivedOffsets : new Map (),
receivedChunks : new Set (),
chunkSize : 0 ,
expectedChunks : 0 ,
buffer : null ,
chunks : [],
cancelled : false ,
abortController ,
source : "http" ,
expectedCommandId : null ,
};
fileDownloads . set ( path , transfer );
activeTransfers . set ( transferId , transfer );
addTransferToUI ( transfer );
updateStatus ( "connected" , `Downloading ${ fileName } ...` );
( async () => {
try {
console . debug ( "[filebrowser] download request" , { path , clientId });
const requestRes = await fetch ( "/api/file/download/request" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
credentials : "include" ,
signal : abortController . signal ,
body : JSON . stringify ({ clientId , path }),
});
console . debug ( "[filebrowser] download request response" , {
ok : requestRes . ok ,
status : requestRes . status ,
});
if ( ! requestRes . ok ) {
const text = await requestRes . text ();
notifyToast ( text || "Download failed" , "error" , 5000 );
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
return ;
}
const requestData = await requestRes . json ();
2026-03-08 00:53:22 -06:00
const downloadUrl = typeof requestData ? . downloadUrl === "string"
? requestData . downloadUrl
: ( requestData ? . downloadId
? `/api/file/download/ ${ encodeURIComponent ( requestData . downloadId ) } `
: "" );
if ( ! downloadUrl ) {
2026-03-05 00:04:26 -06:00
notifyToast ( "Download failed" , "error" , 5000 );
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
return ;
}
console . debug ( "[filebrowser] download request accepted" , {
2026-03-08 00:53:22 -06:00
downloadUrl ,
2026-03-05 00:04:26 -06:00
});
console . debug ( "[filebrowser] download stream start" , {
2026-03-08 00:53:22 -06:00
downloadUrl ,
2026-03-05 00:04:26 -06:00
});
2026-03-08 00:53:22 -06:00
const res = await fetch ( downloadUrl , {
method : "GET" ,
2026-03-05 00:04:26 -06:00
credentials : "include" ,
signal : abortController . signal ,
});
console . debug ( "[filebrowser] download response" , {
ok : res . ok ,
status : res . status ,
contentType : res . headers . get ( "Content-Type" ),
contentLength : res . headers . get ( "Content-Length" ),
});
if ( ! res . ok ) {
const text = await res . text ();
notifyToast ( text || "Download failed" , "error" , 5000 );
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
return ;
}
const total = Number ( res . headers . get ( "Content-Length" ) || 0 );
if ( Number . isFinite ( total ) && total > 0 ) {
transfer . total = total ;
}
const chunks = [];
let received = 0 ;
let lastLoggedBytes = 0 ;
if ( res . body ) {
const reader = res . body . getReader ();
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
if ( value ) {
chunks . push ( value );
received += value . length ;
transfer . received = received ;
if ( received - lastLoggedBytes >= 5 * 1024 * 1024 ) {
lastLoggedBytes = received ;
console . debug ( "[filebrowser] download stream" , {
path ,
received ,
total : transfer . total ,
});
}
if ( transfer . total > 0 ) {
transfer . progress = Math . round (( received / transfer . total ) * 100 );
updateTransferProgress ( transferId , transfer . progress , received , transfer . total );
}
}
if ( transfer . cancelled ) {
break ;
}
}
} else {
console . warn ( "[filebrowser] download response missing body" , { path });
const blob = await res . blob ();
chunks . push ( new Uint8Array ( await blob . arrayBuffer ()));
received = chunks [ 0 ] ? . length || 0 ;
transfer . received = received ;
}
if ( transfer . cancelled ) {
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
return ;
}
if ( transfer . total === 0 && received > 0 ) {
transfer . progress = 100 ;
updateTransferProgress ( transferId , transfer . progress , received , transfer . total );
}
const blob = new Blob ( chunks );
const url = URL . createObjectURL ( blob );
const a = document . createElement ( "a" );
a . href = url ;
a . download = fileName ;
document . body . appendChild ( a );
a . click ();
document . body . removeChild ( a );
URL . revokeObjectURL ( url );
console . log ( "Download complete:" , path , ` ${ received } bytes` );
console . debug ( "[filebrowser] download complete" , {
path ,
received ,
total : transfer . total ,
chunks : chunks . length ,
});
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
} catch ( err ) {
if ( transfer . cancelled ) return ;
console . error ( "Download error:" , err );
notifyToast ( `Download failed: ${ err . message || err } ` , "error" , 5000 );
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
}
})();
}
function handleFileDownload ( msg ) {
const toNumber = ( value ) => {
if ( typeof value === "number" && Number . isFinite ( value )) return value ;
if ( typeof value === "bigint" ) {
const asNumber = Number ( value );
if ( Number . isSafeInteger ( asNumber )) return asNumber ;
}
return null ;
};
console . debug ( "[filebrowser] file_download" , {
path : msg . path ,
hasData : !! msg . data ,
dataLen : msg . data ? . length ?? null ,
totalType : typeof msg . total ,
total : msg . total ,
chunkIndex : msg . chunkIndex ,
chunksTotal : msg . chunksTotal ,
offset : msg . offset ,
error : msg . error || null ,
});
if ( msg . error ) {
alert ( `Download failed: ${ msg . error } ` );
const download = fileDownloads . get ( msg . path );
if ( download ) {
removeTransfer ( download . id );
fileDownloads . delete ( msg . path );
}
return ;
}
let download = fileDownloads . get ( msg . path );
if ( ! download ) {
return ;
}
if ( download . source === "http" ) {
return ;
}
if ( download . expectedCommandId ) {
if ( ! msg . commandId || msg . commandId !== download . expectedCommandId ) {
console . debug ( "[filebrowser] ignoring unsolicited download" , {
path : msg . path ,
commandId : msg . commandId || null ,
});
return ;
}
}
if ( download . cancelled ) {
fileDownloads . delete ( msg . path );
return ;
}
const total = toNumber ( msg . total );
if ( total && total > 0 ) {
if ( ! download . total ) {
download . total = total ;
console . debug ( "[filebrowser] download total set" , {
path : msg . path ,
total : download . total ,
});
}
if ( download . total > 0 && ! download . buffer ) {
download . buffer = new Uint8Array ( download . total );
download . receivedBytes = 0 ;
download . receivedOffsets = new Map ();
download . receivedChunks = new Set ();
download . chunkSize = 0 ;
download . expectedChunks = 0 ;
download . chunks = [];
}
}
const chunkIndex = toNumber ( msg . chunkIndex );
const chunksTotal = toNumber ( msg . chunksTotal );
if ( chunksTotal && ! download . expectedChunks ) {
download . expectedChunks = chunksTotal ;
console . debug ( "[filebrowser] expected chunks set" , {
path : msg . path ,
expectedChunks : download . expectedChunks ,
});
}
if ( msg . data && msg . data . length > 0 ) {
let data = msg . data ;
if ( data instanceof ArrayBuffer ) {
data = new Uint8Array ( data );
} else if ( typeof data === "string" ) {
data = Uint8Array . from ( atob ( data ), ( c ) => c . charCodeAt ( 0 ));
}
if ( data instanceof Uint8Array ) {
const chunkOffset = toNumber ( msg . offset );
if ( download . total > 0 && ! download . chunkSize && data . length > 0 ) {
download . chunkSize = data . length ;
if ( ! download . expectedChunks ) {
download . expectedChunks = Math . ceil ( download . total / download . chunkSize );
console . debug ( "[filebrowser] inferred expected chunks" , {
path : msg . path ,
expectedChunks : download . expectedChunks ,
chunkSize : download . chunkSize ,
});
}
}
if ( download . total > 0 && download . buffer && chunkOffset !== null ) {
const end = chunkOffset + data . length ;
if ( chunkOffset >= 0 && end <= download . total ) {
const seen = chunkIndex !== null
? download . receivedChunks . has ( chunkIndex )
: download . receivedOffsets . has ( chunkOffset );
if ( ! seen ) {
download . buffer . set ( data , chunkOffset );
if ( chunkIndex !== null ) {
download . receivedChunks . add ( chunkIndex );
} else {
download . receivedOffsets . set ( chunkOffset , data . length );
}
download . receivedBytes += data . length ;
}
}
download . received = Math . min ( download . receivedBytes , download . total );
} else {
download . chunks . push ( data );
download . received += data . length ;
}
}
}
if ( download . total > 0 ) {
download . progress = Math . round (( download . received / download . total ) * 100 );
updateTransferProgress (
download . id ,
download . progress ,
download . received ,
download . total ,
);
console . debug ( "[filebrowser] download progress" , {
path : msg . path ,
progress : download . progress ,
received : download . received ,
total : download . total ,
});
} else if ( download . expectedChunks > 0 ) {
const chunkProgress = Math . round (
( download . receivedChunks . size / download . expectedChunks ) * 100 ,
);
download . progress = Math . min ( 100 , Math . max ( 0 , chunkProgress ));
updateTransferProgress (
download . id ,
download . progress ,
download . received ,
download . total || 0 ,
);
console . debug ( "[filebrowser] download chunk progress" , {
path : msg . path ,
progress : download . progress ,
receivedChunks : download . receivedChunks . size ,
expectedChunks : download . expectedChunks ,
received : download . received ,
});
}
const receivedChunkCount =
download . receivedChunks . size + download . receivedOffsets . size ;
const hasAllChunks =
download . expectedChunks > 0
? receivedChunkCount >= download . expectedChunks
: download . received >= download . total ;
if (( download . total > 0 ? download . received >= download . total : hasAllChunks ) && hasAllChunks ) {
console . debug ( "[filebrowser] download complete" , {
path : msg . path ,
received : download . received ,
total : download . total ,
expectedChunks : download . expectedChunks ,
receivedChunks : download . receivedChunks . size ,
});
let fullData = null ;
if ( download . buffer ) {
fullData = download . buffer ;
} else {
fullData = new Uint8Array ( download . received );
let offset = 0 ;
download . chunks . forEach (( chunk ) => {
fullData . set ( chunk , offset );
offset += chunk . length ;
});
}
const blob = new Blob ([ fullData ]);
const url = URL . createObjectURL ( blob );
const a = document . createElement ( "a" );
a . href = url ;
a . download = download . fileName ;
document . body . appendChild ( a );
a . click ();
document . body . removeChild ( a );
URL . revokeObjectURL ( url );
console . log ( "Download complete:" , msg . path , ` ${ download . received } bytes` );
removeTransfer ( download . id );
fileDownloads . delete ( msg . path );
updateStatus ( "connected" , "Connected" );
}
}
function zipAndDownload ( path ) {
console . log ( "Requesting zip:" , path );
const zipPath = path + ".zip" ;
const transferId = `download-zip- ${ Date . now () } - ${ Math . random () } ` ;
const fileName = zipPath . split ( /[\/\\]/ ). pop ();
const transfer = {
id : transferId ,
type : "download" ,
path : zipPath ,
fileName ,
progress : 0 ,
total : 0 ,
received : 0 ,
receivedBytes : 0 ,
receivedOffsets : new Map (),
receivedChunks : new Set (),
chunkSize : 0 ,
expectedChunks : 0 ,
buffer : null ,
chunks : [],
cancelled : false ,
source : "ws" ,
expectedCommandId : null ,
};
fileDownloads . set ( zipPath , transfer );
const commandId = "zip_" + Date . now ();
transfer . expectedCommandId = commandId ;
send ({ type : "file_zip" , path , commandId });
trackCommandResult ( commandId , {
refreshOnSuccess : true ,
successMessage : "Zip completed" ,
errorPrefix : "Zip failed" ,
});
showProgressNotification ( commandId , "Starting zip operation..." , path );
}
let activeProgressNotifications = new Map ();
function showProgressNotification ( commandId , message , path ) {
hideProgressNotification ( commandId );
const notification = document . createElement ( "div" );
notification . id = `progress- ${ commandId } ` ;
notification . className =
"fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-lg shadow-lg p-4 min-w-[320px] z-50" ;
notification . innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex items-center gap-2">
<i class="fa-solid fa-file-zipper text-blue-400"></i>
<span class="font-semibold text-slate-200">Zipping Directory</span>
</div>
<button class="text-slate-400 hover:text-red-400 transition-colors" data-command-id=" ${ escapeHtml ( commandId ) } " onclick="cancelZipOperation(this.dataset.commandId)">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="text-sm text-slate-400 mb-2" id="progress-message- ${ escapeHtml ( commandId ) } "> ${ escapeHtml ( message ) } </div>
<div class="text-xs text-slate-500 truncate" title=" ${ escapeHtml ( path ) } "> ${ escapeHtml ( path ) } </div>
` ;
document . body . appendChild ( notification );
activeProgressNotifications . set ( commandId , notification );
}
function updateProgressNotification ( commandId , message ) {
const messageEl = document . getElementById ( `progress-message- ${ commandId } ` );
if ( messageEl ) {
messageEl . textContent = message ;
}
}
function hideProgressNotification ( commandId ) {
const notification = activeProgressNotifications . get ( commandId );
if ( notification ) {
notification . remove ();
activeProgressNotifications . delete ( commandId );
}
}
function cancelZipOperation ( commandId ) {
if ( confirm ( "Cancel this zip operation?" )) {
send ({ type : "command_abort" , commandId });
hideProgressNotification ( commandId );
updateStatus ( "connected" , "Zip operation cancelled" );
}
}
function handleCommandProgress ( msg ) {
if ( msg . commandId ) {
updateProgressNotification ( msg . commandId , msg . message || "Processing..." );
}
}
function deleteFile ( path ) {
if ( ! confirm ( `Are you sure you want to delete ${ path } ?` )) return ;
console . log ( "Deleting:" , path );
const commandId = `delete- ${ Date . now () } ` ;
send ({ type : "file_delete" , path , commandId });
trackCommandResult ( commandId , {
refreshOnSuccess : true ,
successMessage : "Delete completed" ,
errorPrefix : "Delete failed" ,
});
}
function handleFileUploadResult ( msg ) {
const toNumber = ( value ) => {
if ( typeof value === "number" && Number . isFinite ( value )) return value ;
if ( typeof value === "bigint" ) {
const asNumber = Number ( value );
if ( Number . isSafeInteger ( asNumber )) return asNumber ;
}
return null ;
};
const transfer = msg . transferId
? fileUploadsById . get ( msg . transferId )
: ( msg . path ? fileUploads . get ( msg . path ) : null );
if ( ! msg . ok ) {
notifyToast ( `Upload failed: ${ msg . error } ` , "error" , 5000 );
updateStatus ( "connected" , "Connected" );
if ( transfer ) {
removeTransfer ( transfer . id );
fileUploads . delete ( transfer . path );
fileUploadsById . delete ( transfer . transferId );
}
return ;
}
if ( ! transfer ) {
console . log ( "Upload ack received (no active transfer):" , msg . path || msg . transferId );
return ;
}
const offset = toNumber ( msg . offset );
2026-03-08 00:53:22 -06:00
if ( offset !== null ) {
const firstAckForOffset = ! transfer . ackedOffsets . has ( offset );
if ( firstAckForOffset ) {
transfer . ackedOffsets . add ( offset );
transfer . receivedChunks += 1 ;
}
if ( Number . isFinite ( msg . received )) {
transfer . receivedBytes = Math . min ( Number ( msg . received ), transfer . total );
transfer . sent = transfer . receivedBytes ;
}
if ( transfer . total > 0 ) {
transfer . progress = Math . round (( transfer . sent / transfer . total ) * 100 );
updateTransferProgress ( transfer . id , transfer . progress , transfer . sent , transfer . total );
}
}
2026-03-05 00:04:26 -06:00
const pendingOffset = offset !== null
? offset
: ( transfer . pendingAcks . has ( 0 ) ? 0 : null );
if ( pendingOffset !== null ) {
const pending = transfer . pendingAcks . get ( pendingOffset );
if ( pending ) {
clearTimeout ( pending . timeoutId );
transfer . pendingAcks . delete ( pendingOffset );
pending . resolve ( msg );
}
}
if ( transfer . completed && transfer . receivedChunks >= transfer . expectedChunks ) {
finishUpload ( transfer );
}
}
function handleCommandResult ( msg ) {
if ( msg . commandId && activeProgressNotifications . has ( msg . commandId )) {
setTimeout (() => hideProgressNotification ( msg . commandId ), 2000 );
}
if ( currentEditingFile && editorStatus . textContent === "Saving..." ) {
if ( msg . ok ) {
editorStatus . textContent = "Saved successfully!" ;
notifyToast ( "File saved successfully" , "success" , 5000 );
setTimeout ( closeEditor , 1000 );
} else {
editorStatus . textContent = `Error: ${ msg . message || "Save failed" } ` ;
notifyToast (
`Save failed: ${ msg . message || "Unknown error" } ` ,
"error" ,
5000 ,
);
editorSaveBtn . disabled = false ;
}
if ( msg . commandId ) pendingCommandResults . delete ( msg . commandId );
return ;
}
const tracked = msg . commandId
? pendingCommandResults . get ( msg . commandId )
: null ;
2026-03-08 00:53:22 -06:00
const waiter = msg . commandId
? pendingCommandWaiters . get ( msg . commandId )
: null ;
if ( waiter ) {
clearTimeout ( waiter . timeoutId );
pendingCommandWaiters . delete ( msg . commandId );
if ( msg . ok ) {
waiter . resolve ( msg );
} else {
waiter . reject ( new Error ( msg . message || "operation failed" ));
}
}
2026-03-05 00:04:26 -06:00
if ( ! tracked ) {
return ;
}
if ( msg . commandId ) pendingCommandResults . delete ( msg . commandId );
if ( ! msg . ok ) {
const errorText = msg . message
? ` ${ tracked . errorPrefix } : ${ msg . message } `
: tracked . errorPrefix ;
notifyToast (
errorText ,
"error" ,
5000 ,
);
} else {
notifyToast (
tracked . successMessage || "Operation completed successfully" ,
"success" ,
5000 ,
);
if ( tracked . refreshOnSuccess ) {
listFiles ( currentPath );
}
}
}
function showContextMenu ( x , y , entry ) {
contextMenu . style . left = ` ${ x } px` ;
contextMenu . style . top = ` ${ y } px` ;
contextMenu . classList . add ( "show" );
contextMenu . dataset . path = entry . path ;
contextMenu . dataset . isDir = entry . isDir ;
const editItem = contextMenu . querySelector ( '[data-action="edit"]' );
const zipItem = contextMenu . querySelector ( '[data-action="zip"]' );
const chmodItem = contextMenu . querySelector ( '[data-action="chmod"]' );
if ( editItem ) editItem . style . display = entry . isDir ? "none" : "block" ;
if ( zipItem ) zipItem . style . display = entry . isDir ? "block" : "none" ;
if ( chmodItem ) chmodItem . style . display = entry . mode ? "block" : "none" ;
}
function hideContextMenu () {
contextMenu . classList . remove ( "show" );
}
function formatBytes ( bytes ) {
if ( bytes === 0 || bytes === 0 n ) return "0 B" ;
const sizes = [ "B" , "KB" , "MB" , "GB" , "TB" ];
if ( typeof bytes === "bigint" ) {
const k = 1024 n ;
let i = 0 ;
let value = bytes ;
while ( value >= k && i < sizes . length - 1 ) {
value /= k ;
i += 1 ;
}
return ` ${ value . toString () } ${ sizes [ i ] } ` ;
}
const k = 1024 ;
const i = Math . floor ( Math . log ( bytes ) / Math . log ( k ));
return Math . round (( bytes / Math . pow ( k , i )) * 100 ) / 100 + " " + sizes [ i ];
}
function escapeHtml ( text ) {
const div = document . createElement ( "div" );
div . textContent = text ;
return div . innerHTML ;
}
refreshBtn . onclick = () => listFiles ( currentPath );
uploadBtn . onclick = () => fileInput . click ();
fileInput . onchange = async ( e ) => {
const files = Array . from ( e . target . files );
if ( files . length === 0 ) return ;
for ( const file of files ) {
await uploadFile ( file );
}
fileInput . value = "" ;
listFiles ( currentPath );
};
2026-03-06 11:28:35 -06:00
async function uploadMultipleFiles ( files ) {
for ( const file of files ) {
await uploadFile ( file );
}
listFiles ( currentPath );
}
function hasFileDrag ( event ) {
const types = Array . from ( event . dataTransfer ? . types || []);
return types . includes ( "Files" );
}
function setDropTargetActive ( active ) {
if ( ! fileListPanel ) return ;
fileListPanel . classList . toggle ( "ring-2" , active );
fileListPanel . classList . toggle ( "ring-blue-500" , active );
fileListPanel . classList . toggle ( "ring-offset-2" , active );
fileListPanel . classList . toggle ( "ring-offset-slate-950" , active );
fileListPanel . classList . toggle ( "bg-blue-500/5" , active );
}
function setupDragAndDropUpload () {
if ( ! fileListPanel ) return ;
fileListPanel . addEventListener ( "dragenter" , ( e ) => {
if ( ! hasFileDrag ( e )) return ;
e . preventDefault ();
dragDepth += 1 ;
setDropTargetActive ( true );
});
fileListPanel . addEventListener ( "dragover" , ( e ) => {
if ( ! hasFileDrag ( e )) return ;
e . preventDefault ();
e . dataTransfer . dropEffect = "copy" ;
setDropTargetActive ( true );
});
fileListPanel . addEventListener ( "dragleave" , ( e ) => {
if ( ! hasFileDrag ( e )) return ;
e . preventDefault ();
dragDepth = Math . max ( 0 , dragDepth - 1 );
if ( dragDepth === 0 ) {
setDropTargetActive ( false );
}
});
fileListPanel . addEventListener ( "drop" , async ( e ) => {
if ( ! hasFileDrag ( e )) return ;
e . preventDefault ();
dragDepth = 0 ;
setDropTargetActive ( false );
const files = Array . from ( e . dataTransfer ? . files || []);
if ( files . length === 0 ) return ;
notifyToast ( `Uploading ${ files . length } file(s)...` , "info" , 3000 );
await uploadMultipleFiles ( files );
});
}
if ( sortFieldEl ) {
sortFieldEl . value = sortField ;
sortFieldEl . addEventListener ( "change" , () => {
sortField = sortFieldEl . value ;
localStorage . setItem ( "filebrowser.sortField" , sortField );
renderCurrentDirectory ();
});
}
if ( filterTypeEl ) {
filterTypeEl . value = filterType ;
filterTypeEl . addEventListener ( "change" , () => {
filterType = filterTypeEl . value ;
localStorage . setItem ( "filebrowser.filterType" , filterType );
renderCurrentDirectory ();
});
}
if ( sortOrderBtn ) {
updateSortOrderButton ();
sortOrderBtn . addEventListener ( "click" , () => {
sortOrder = sortOrder === "asc" ? "desc" : "asc" ;
localStorage . setItem ( "filebrowser.sortOrder" , sortOrder );
updateSortOrderButton ();
renderCurrentDirectory ();
});
}
2026-03-05 00:04:26 -06:00
function finishUpload ( transfer ) {
console . log ( "Upload complete:" , transfer . path );
updateStatus ( "connected" , "Connected" );
notifyToast ( "File uploaded successfully" , "success" , 5000 );
removeTransfer ( transfer . id );
fileUploads . delete ( transfer . path );
fileUploadsById . delete ( transfer . transferId );
listFiles ( currentPath );
}
2026-03-08 00:53:22 -06:00
async function uploadFileViaHttpPull ( file , path , transfer ) {
console . debug ( "[filebrowser] upload request start" , {
clientId ,
path ,
fileName : file . name ,
size : file . size ,
});
const requestRes = await fetch ( "/api/file/upload/request" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
credentials : "include" ,
body : JSON . stringify ({
clientId ,
path ,
fileName : file . name ,
}),
});
if ( ! requestRes . ok ) {
const text = await requestRes . text ();
throw new Error ( text || "upload request failed" );
}
const requestData = await requestRes . json ();
const uploadUrl = typeof requestData ? . uploadUrl === "string"
? requestData . uploadUrl
: ( requestData ? . uploadId
? `/api/file/upload/ ${ encodeURIComponent ( requestData . uploadId ) } `
: "" );
if ( ! uploadUrl ) {
throw new Error ( "upload request failed" );
}
console . debug ( "[filebrowser] upload stage url" , { uploadUrl });
2026-03-08 03:19:22 -05:00
const uploadData = await new Promise (( resolve , reject ) => {
const xhr = new XMLHttpRequest ();
transfer . uploadXhr = xhr ;
2026-03-08 03:07:23 -05:00
2026-03-08 03:19:22 -05:00
// Keep request-level timeout high for large WAN uploads.
xhr . timeout = 30 * 60 * 1000 ;
xhr . open ( "POST" , uploadUrl , true );
xhr . withCredentials = true ;
xhr . responseType = "text" ;
xhr . setRequestHeader ( "Content-Type" , "application/octet-stream" );
2026-03-08 00:53:22 -06:00
2026-03-08 03:19:22 -05:00
const onAbort = () => {
try {
xhr . abort ();
} catch {}
};
2026-03-08 00:53:22 -06:00
2026-03-08 03:19:22 -05:00
if ( transfer . abortController ? . signal ) {
transfer . abortController . signal . addEventListener ( "abort" , onAbort , { once : true });
}
xhr . upload . onprogress = ( event ) => {
const total = event . total || file . size || transfer . total || 0 ;
if ( ! total ) return ;
const loaded = Math . min ( event . loaded , total );
// First 50% is browser -> server staging upload.
const stageRatio = loaded / total ;
transfer . receivedBytes = loaded ;
transfer . sent = loaded ;
transfer . progress = Math . max ( 0 , Math . min ( 50 , Math . round ( stageRatio * 50 )));
updateTransferProgress ( transfer . id , transfer . progress , transfer . sent , transfer . total );
};
xhr . onerror = () => {
reject ( new Error ( "upload staging failed" ));
};
xhr . ontimeout = () => {
reject ( new Error ( "upload staging timed out" ));
};
xhr . onabort = () => {
reject ( new Error ( "Upload cancelled" ));
};
xhr . onload = () => {
const text = xhr . responseText || "" ;
if ( xhr . status < 200 || xhr . status >= 300 ) {
console . debug ( "[filebrowser] upload stage failed" , {
status : xhr . status ,
body : text ,
});
reject ( new Error ( text || "upload staging failed" ));
return ;
}
try {
resolve ( text ? JSON . parse ( text ) : {});
} catch {
reject ( new Error ( "upload staging failed" ));
}
};
try {
xhr . send ( file );
} catch ( err ) {
reject ( err instanceof Error ? err : new Error ( "upload staging failed" ));
}
});
2026-03-08 00:53:22 -06:00
if ( ! uploadData ? . pullUrl ) {
throw new Error ( "upload staging failed" );
}
console . debug ( "[filebrowser] upload staged" , {
pullUrl : uploadData . pullUrl ,
size : uploadData . size ,
});
const commandId = `upload-http- ${ Date . now () } - ${ Math . random () } ` ;
const waitResult = waitForCommandResult ( commandId , 12 * 60 * 1000 );
send ({
type : "command" ,
commandType : "file_upload_http" ,
id : commandId ,
payload : {
path ,
url : uploadData . pullUrl ,
total : file . size ,
},
});
await waitResult ;
console . debug ( "[filebrowser] upload command completed" , {
path ,
size : file . size ,
});
transfer . receivedBytes = file . size ;
transfer . sent = file . size ;
transfer . progress = 100 ;
transfer . receivedChunks = transfer . expectedChunks ;
updateTransferProgress ( transfer . id , transfer . progress , transfer . sent , transfer . total );
2026-03-08 03:19:22 -05:00
transfer . uploadXhr = null ;
2026-03-08 00:53:22 -06:00
}
2026-03-05 00:04:26 -06:00
async function uploadFile ( file ) {
const path = currentPath ? ` ${ currentPath } / ${ file . name } ` : file . name ;
const transferId = `upload- ${ Date . now () } - ${ Math . random () } ` ;
console . log ( "Uploading:" , path );
const transfer = {
id : transferId ,
type : "upload" ,
path ,
fileName : file . name ,
progress : 0 ,
total : file . size ,
sent : 0 ,
cancelled : false ,
expectedChunks : 0 ,
receivedChunks : 0 ,
receivedBytes : 0 ,
pendingAcks : new Map (),
ackedOffsets : new Set (),
transferId ,
completed : false ,
2026-03-08 03:07:23 -05:00
abortController : new AbortController (),
2026-03-08 03:19:22 -05:00
uploadXhr : null ,
2026-03-05 00:04:26 -06:00
};
fileUploads . set ( path , transfer );
fileUploadsById . set ( transferId , transfer );
activeTransfers . set ( transferId , transfer );
addTransferToUI ( transfer );
try {
2026-03-08 00:53:22 -06:00
await uploadFileViaHttpPull ( file , path , transfer );
finishUpload ( transfer );
2026-03-05 00:04:26 -06:00
} catch ( err ) {
console . error ( "Upload error:" , err );
removeTransfer ( transferId );
fileUploads . delete ( path );
fileUploadsById . delete ( transferId );
alert ( `Upload failed: ${ err . message } ` );
}
}
mkdirBtn . onclick = () => {
const name = prompt ( "Enter folder name:" );
if ( ! name ) return ;
const path = currentPath ? ` ${ currentPath } / ${ name } ` : name ;
console . log ( "Creating directory:" , path );
const commandId = `mkdir- ${ Date . now () } ` ;
send ({ type : "file_mkdir" , path , commandId });
trackCommandResult ( commandId , {
refreshOnSuccess : true ,
successMessage : "Folder created" ,
errorPrefix : "Create folder failed" ,
});
};
backBtn . onclick = () => goBack ();
homeBtn . onclick = () => goHome ();
pathGoBtn . onclick = () => {
const path = pathInput . value . trim ();
if ( path ) {
pathHistory = [];
listFiles ( path );
}
};
pathInput . onkeydown = ( e ) => {
if ( e . key === "Enter" ) {
const path = pathInput . value . trim ();
if ( path ) {
pathHistory = [];
listFiles ( path );
}
}
};
document . addEventListener ( "click" , ( e ) => {
if ( ! e . target . closest ( "#context-menu" )) {
hideContextMenu ();
}
});
2026-03-06 11:28:35 -06:00
setupDragAndDropUpload ();
2026-03-05 00:04:26 -06:00
updateStatus ( "connecting" , "Connecting..." );
updateBackButton ();
2026-03-13 13:33:39 -05:00
// ── Sidebar Quick Access ──
let detectedHomePath = "" ;
let detectedOS = "" ;
const sidebarContent = document . getElementById ( "sidebar-content" );
const sidebarDrives = document . getElementById ( "sidebar-drives" );
let lastDriveEntries = [];
function detectOSAndHome ( path ) {
if ( ! path || path === "." ) return ;
const winMatch = path . match ( /^([A-Za-z]:\\Users\\[^\\]+)/i );
if ( winMatch ) { detectedOS = "windows" ; detectedHomePath = winMatch [ 1 ]; updateSidebar (); return ; }
if ( path . match ( /^[A-Za-z]:\\/ )) { detectedOS = "windows" ; updateSidebar (); return ; }
const macMatch = path . match ( /^(\/Users\/[^\/]+)/ );
if ( macMatch ) { detectedOS = "mac" ; detectedHomePath = macMatch [ 1 ]; updateSidebar (); return ; }
const linuxMatch = path . match ( /^(\/home\/[^\/]+)/ );
if ( linuxMatch ) { detectedOS = "linux" ; detectedHomePath = linuxMatch [ 1 ]; updateSidebar (); return ; }
if ( path . startsWith ( "/root" )) { detectedOS = "linux" ; detectedHomePath = "/root" ; updateSidebar (); return ; }
if ( path . startsWith ( "/" )) { detectedOS = "linux" ; updateSidebar (); }
}
function applyClientInfo ( osStr , userName ) {
if ( detectedOS && detectedHomePath ) return ;
const os = ( osStr || "" ). toLowerCase ();
const user = ( userName || "" ). trim ();
if ( ! user ) return ;
if ( os . includes ( "windows" )) {
detectedOS = "windows" ;
detectedHomePath = "C:\\Users\\" + user ;
} else if ( os . includes ( "darwin" ) || os . includes ( "mac" )) {
detectedOS = "mac" ;
detectedHomePath = "/Users/" + user ;
} else {
detectedOS = "linux" ;
detectedHomePath = user === "root" ? "/root" : "/home/" + user ;
}
updateSidebar ();
updateSidebarDrives ( lastDriveEntries );
}
function sidebarItem ( icon , label , path , color ) {
const active = currentPath && ( currentPath === path || currentPath . startsWith ( path + "/" ) || currentPath . startsWith ( path + "\\" ));
return `<button class="sidebar-item w-full flex items-center gap-3 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left ${ active ? " active" : "" } " data-path=" ${ escapeHtml ( path ) } ">
<i class="fa-solid ${ icon } ${ color } w-4 text-center text-xs"></i>
<span class="truncate"> ${ label } </span>
</button>` ;
}
function updateSidebar () {
if ( ! sidebarContent ) return ;
let html = "" ;
if ( detectedOS === "windows" && detectedHomePath ) {
const h = detectedHomePath ;
html += sidebarItem ( "fa-desktop" , "Desktop" , h + "\\Desktop" , "text-blue-400" );
html += sidebarItem ( "fa-download" , "Downloads" , h + "\\Downloads" , "text-green-400" );
html += sidebarItem ( "fa-file-lines" , "Documents" , h + "\\Documents" , "text-yellow-400" );
html += sidebarItem ( "fa-images" , "Pictures" , h + "\\Pictures" , "text-purple-400" );
html += sidebarItem ( "fa-music" , "Music" , h + "\\Music" , "text-pink-400" );
html += sidebarItem ( "fa-video" , "Videos" , h + "\\Videos" , "text-red-400" );
html += '<div class="border-t border-slate-700/50 my-1.5"></div>' ;
html += sidebarItem ( "fa-gear" , "AppData" , h + "\\AppData" , "text-slate-400" );
html += sidebarItem ( "fa-temperature-high" , "Temp" , h + "\\AppData\\Local\\Temp" , "text-orange-400" );
html += sidebarItem ( "fa-folder-open" , "Program Files" , "C:\\Program Files" , "text-amber-400" );
html += sidebarItem ( "fa-window-maximize" , "Windows" , "C:\\Windows" , "text-blue-300" );
} else if ( detectedOS === "linux" || detectedOS === "mac" ) {
if ( detectedHomePath ) {
html += sidebarItem ( "fa-home" , "Home" , detectedHomePath , "text-blue-400" );
html += sidebarItem ( "fa-desktop" , "Desktop" , detectedHomePath + "/Desktop" , "text-blue-300" );
html += sidebarItem ( "fa-download" , "Downloads" , detectedHomePath + "/Downloads" , "text-green-400" );
html += sidebarItem ( "fa-file-lines" , "Documents" , detectedHomePath + "/Documents" , "text-yellow-400" );
html += '<div class="border-t border-slate-700/50 my-1.5"></div>' ;
}
html += sidebarItem ( "fa-gears" , "/etc" , "/etc" , "text-slate-400" );
html += sidebarItem ( "fa-database" , "/var" , "/var" , "text-amber-400" );
html += sidebarItem ( "fa-temperature-high" , "/tmp" , "/tmp" , "text-orange-400" );
html += sidebarItem ( "fa-cube" , "/opt" , "/opt" , "text-teal-400" );
html += sidebarItem ( "fa-user" , "/usr" , "/usr" , "text-indigo-400" );
} else {
html = '<div class="text-xs text-slate-500 text-center py-3">Navigate to detect paths</div>' ;
}
sidebarContent . innerHTML = html ;
bindSidebarClicks ( sidebarContent );
}
function updateSidebarDrives ( entries ) {
if ( ! sidebarDrives ) return ;
if ( ! entries || entries . length === 0 ) {
if ( detectedOS === "windows" ) {
sidebarDrives . innerHTML = sidebarItem ( "fa-hard-drive" , "This PC" , "." , "text-slate-300" );
} else {
sidebarDrives . innerHTML = sidebarItem ( "fa-hard-drive" , "Root /" , "/" , "text-slate-300" );
}
bindSidebarClicks ( sidebarDrives );
return ;
}
let html = "" ;
entries . forEach (( e ) => {
if ( e . isDir && e . name . match ( /^[A-Za-z]:$/ )) {
html += sidebarItem ( "fa-hard-drive" , e . name + "\\" , e . name + "\\" , "text-slate-300" );
}
});
if ( ! html ) {
html = sidebarItem ( "fa-hard-drive" , detectedOS === "windows" ? "This PC" : "Root /" , detectedOS === "windows" ? "." : "/" , "text-slate-300" );
}
sidebarDrives . innerHTML = html ;
bindSidebarClicks ( sidebarDrives );
}
function bindSidebarClicks ( container ) {
container . querySelectorAll ( ".sidebar-item" ). forEach (( btn ) => {
btn . onclick = () => listFiles ( btn . dataset . path );
});
}
function highlightSidebarActive () {
document . querySelectorAll ( ".sidebar-item" ). forEach (( btn ) => {
const p = btn . dataset . path ;
const active = currentPath && ( currentPath === p || currentPath . startsWith ( p + "/" ) || currentPath . startsWith ( p + "\\" ));
btn . classList . toggle ( "active" , active );
});
}
2026-04-13 17:15:06 -05:00
checkFeatureAccess ( "file_browser" , clientId ). then ( ok => ok && connect ());
2026-03-05 00:04:26 -06:00
function addTransferToUI ( transfer ) {
const transferItem = document . createElement ( "div" );
transferItem . id = `transfer- ${ transfer . id } ` ;
transferItem . className =
"transfer-item bg-slate-800/50 border border-slate-700 rounded-lg p-3" ;
const icon = transfer . type === "upload" ? "fa-upload" : "fa-download" ;
const color = transfer . type === "upload" ? "text-blue-400" : "text-green-400" ;
transferItem . innerHTML = `
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2 flex-1 min-w-0">
<i class="fa-solid ${ icon } ${ color } "></i>
<span class="text-sm truncate transfer-name"></span>
</div>
<button class="cancel-btn text-red-400 hover:text-red-300 px-2" type="button">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="progress-bar-container w-full bg-slate-700 rounded-full h-2 mb-1">
<div class="progress-bar bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: ${ transfer . progress } %"></div>
</div>
<div class="flex justify-between text-xs text-slate-400">
<span class="progress-text"> ${ transfer . progress } %</span>
<span class="size-text"> ${ formatBytes ( transfer . sent || transfer . received || 0 ) } / ${ formatBytes ( transfer . total ) } </span>
</div>
` ;
const nameEl = transferItem . querySelector ( ".transfer-name" );
if ( nameEl ) {
nameEl . textContent = transfer . fileName ;
}
const cancelBtn = transferItem . querySelector ( ".cancel-btn" );
if ( cancelBtn ) {
cancelBtn . addEventListener ( "click" , () => cancelTransfer ( transfer . id ));
}
transferList . appendChild ( transferItem );
transferPanel . classList . remove ( "hidden" );
}
function updateTransferProgress ( transferId , progress , current , total ) {
const transferItem = document . getElementById ( `transfer- ${ transferId } ` );
if ( ! transferItem ) return ;
const progressBar = transferItem . querySelector ( ".progress-bar" );
const progressText = transferItem . querySelector ( ".progress-text" );
const sizeText = transferItem . querySelector ( ".size-text" );
if ( progressBar ) progressBar . style . width = ` ${ progress } %` ;
if ( progressText ) progressText . textContent = ` ${ progress } %` ;
if ( sizeText )
sizeText . textContent = ` ${ formatBytes ( current ) } / ${ formatBytes ( total ) } ` ;
}
function removeTransfer ( transferId ) {
const transferItem = document . getElementById ( `transfer- ${ transferId } ` );
if ( transferItem ) {
transferItem . remove ();
}
activeTransfers . delete ( transferId );
if ( transferList . children . length === 0 ) {
transferPanel . classList . add ( "hidden" );
}
}
window . cancelTransfer = function ( transferId ) {
const transfer = activeTransfers . get ( transferId );
if ( transfer ) {
transfer . cancelled = true ;
if ( transfer . abortController ) {
transfer . abortController . abort ();
}
if ( transfer . pendingAcks ) {
transfer . pendingAcks . forEach (( pending ) => {
clearTimeout ( pending . timeoutId );
pending . reject ( new Error ( "Upload cancelled" ));
});
transfer . pendingAcks . clear ();
}
removeTransfer ( transferId );
if ( transfer . type === "upload" ) {
fileUploads . delete ( transfer . path );
fileUploadsById . delete ( transfer . transferId );
} else {
fileDownloads . delete ( transfer . path );
}
console . log ( "Transfer cancelled:" , transferId );
}
};
function updateSelectionUI () {
const count = selectedFiles . size ;
selectedCountEl . textContent = count ;
if ( count > 0 ) {
bulkActionsBar . classList . remove ( "hidden" );
} else {
bulkActionsBar . classList . add ( "hidden" );
}
document . querySelectorAll ( ".file-item" ). forEach (( row ) => {
const path = row . dataset . path ;
if ( selectedFiles . has ( path )) {
row . classList . add ( "selected" );
} else {
row . classList . remove ( "selected" );
}
});
}
function clearSelection () {
selectedFiles . clear ();
updateSelectionUI ();
}
searchBtn . addEventListener ( "click" , () => {
searchBar . classList . toggle ( "hidden" );
if ( ! searchBar . classList . contains ( "hidden" )) {
searchInput . focus ();
}
});
searchCloseBtn . addEventListener ( "click" , () => {
searchBar . classList . add ( "hidden" );
searchInput . value = "" ;
listFiles ( currentPath );
});
searchExecuteBtn . addEventListener ( "click" , () => {
const query = searchInput . value . trim ();
if ( ! query ) return ;
const searchContent = searchContentCheckbox . checked ;
performSearch ( query , searchContent );
});
searchInput . addEventListener ( "keypress" , ( e ) => {
if ( e . key === "Enter" ) {
searchExecuteBtn . click ();
}
});
function performSearch ( pattern , searchContent ) {
2026-03-06 11:28:35 -06:00
clearVirtualizedListMode ();
updateDirectorySummaryAndPaging ( 0 , 0 );
2026-03-05 00:04:26 -06:00
const searchId = `search- ${ Date . now () } ` ;
const cmdId = `search-cmd- ${ Date . now () } ` ;
const msg = {
type : "command" ,
commandType : "file_search" ,
id : cmdId ,
payload : {
searchId ,
path : currentPath || "." ,
pattern ,
searchContent ,
maxResults : 500 ,
},
};
send ( msg );
fileListEl . innerHTML =
'<div class="px-4 py-6 text-center text-blue-400"><i class="fa-solid fa-circle-notch fa-spin mr-2"></i>Searching...</div>' ;
}
function handleFileSearchResult ( msg ) {
2026-03-06 11:28:35 -06:00
clearVirtualizedListMode ();
updateDirectorySummaryAndPaging ( 0 , 0 );
2026-03-05 00:04:26 -06:00
if ( msg . error ) {
fileListEl . innerHTML = `<div class="px-4 py-6 text-center text-red-400"><i class="fa-solid fa-exclamation-triangle mr-2"></i> ${ escapeHtml ( msg . error ) } </div>` ;
return ;
}
const results = msg . results || [];
if ( results . length === 0 ) {
fileListEl . innerHTML =
'<div class="px-4 py-6 text-center text-slate-400"><i class="fa-solid fa-search mr-2"></i>No results found</div>' ;
return ;
}
fileListEl . innerHTML = "" ;
results . forEach (( result ) => {
const row = document . createElement ( "div" );
row . className =
"file-item px-4 py-3 border border-slate-700 rounded cursor-pointer hover:bg-slate-800/50 mb-2" ;
const fileName = result . path . split ( /[\/\\]/ ). pop ();
const lineInfo = result . line ? ` (line ${ result . line } )` : "" ;
const matchPreview = result . match
? `<div class="text-xs text-slate-500 mt-1 font-mono"> ${ escapeHtml ( result . match . substring ( 0 , 100 )) } </div>`
: "" ;
2026-03-13 13:33:39 -05:00
const searchIcon = getFileIcon ({ isDir : false , name : fileName });
2026-03-05 00:04:26 -06:00
row . innerHTML = `
<div class="flex items-center gap-2">
2026-03-13 13:33:39 -05:00
${ searchIcon }
2026-03-05 00:04:26 -06:00
<div class="flex-1">
<div class="font-medium"> ${ escapeHtml ( fileName ) } <span class="text-slate-500"> ${ lineInfo } </span></div>
<div class="text-xs text-slate-400"> ${ escapeHtml ( result . path ) } </div>
${ matchPreview }
</div>
<button class="px-2 py-1 rounded hover:bg-slate-700" onclick="event.stopPropagation(); downloadFile(' ${ escapeHtml ( result . path ) } ')">
<i class="fa-solid fa-download"></i>
</button>
</div>
` ;
row . onclick = () => {
2026-03-26 09:55:02 -05:00
const resultName = result . path . split ( /[\/\\]/ ). pop () || "" ;
if ( isPreviewable ( resultName )) {
openFilePreview ( result . path );
} else {
openFileInEditor ( result . path );
}
2026-03-05 00:04:26 -06:00
};
fileListEl . appendChild ( row );
});
}
function openFileInEditor ( path ) {
const cmdId = `file-read- ${ Date . now () } ` ;
const msg = {
type : "command" ,
commandType : "file_read" ,
id : cmdId ,
payload : {
path ,
maxSize : 10 * 1024 * 1024 ,
},
};
console . log ( "[DEBUG] Opening file in editor:" , path );
console . log (
"[DEBUG] Sending file_read command:" ,
JSON . stringify ( msg , null , 2 ),
);
console . log (
"[DEBUG] WebSocket state:" ,
ws ? . readyState ,
"OPEN=" ,
WebSocket . OPEN ,
);
send ( msg );
currentEditingFile = path ;
editorFileName . textContent = path . split ( /[/\\\\]/ ). pop ();
editorStatus . textContent = "Loading..." ;
fileEditorModal . classList . add ( "show" );
}
function handleFileReadResult ( msg ) {
console . log ( "[DEBUG] handleFileReadResult called:" , {
path : msg . path ,
hasError : !! msg . error ,
isBinary : msg . isBinary ,
contentLength : msg . content ? . length ,
});
if ( msg . error ) {
alert ( `Error reading file: ${ escapeHtml ( msg . error ) } ` );
closeEditor ();
return ;
}
if ( msg . isBinary ) {
2026-03-26 09:55:02 -05:00
notifyToast ( "Binary file — use Download to save it" , "info" , 3000 );
2026-03-05 00:04:26 -06:00
closeEditor ();
return ;
}
console . log ( "[DEBUG] Setting editor content, length:" , msg . content ? . length );
editorTextarea . value = msg . content || "" ;
editorStatus . textContent = "Ready" ;
applySyntaxHighlighting ();
editorTextarea . classList . add ( "hidden" );
editorPreview . classList . remove ( "hidden" );
editorPreviewTab . classList . add ( "bg-blue-600" );
editorPreviewTab . classList . remove ( "bg-slate-700" , "hover:bg-slate-600" );
editorEditTab . classList . remove ( "bg-blue-600" );
editorEditTab . classList . add ( "bg-slate-700" , "hover:bg-slate-600" );
}
function saveFileFromEditor () {
if ( ! currentEditingFile ) return ;
const content = editorTextarea . value ;
const cmdId = `file-write- ${ Date . now () } ` ;
const msg = {
type : "command" ,
commandType : "file_write" ,
id : cmdId ,
payload : {
path : currentEditingFile ,
content ,
},
};
send ( msg );
editorStatus . textContent = "Saving..." ;
editorSaveBtn . disabled = true ;
}
function closeEditor () {
fileEditorModal . classList . remove ( "show" );
editorTextarea . value = "" ;
currentEditingFile = null ;
editorStatus . textContent = "Ready" ;
editorSaveBtn . disabled = false ;
}
function applySyntaxHighlighting () {
const code = editorTextarea . value ;
const codeElement = document . getElementById ( "editor-code" );
const fileName = currentEditingFile ? . split ( /[/\\\\]/ ). pop () || "" ;
const ext = fileName . split ( "." ). pop () ? . toLowerCase ();
const languageMap = {
js : "javascript" ,
jsx : "javascript" ,
ts : "typescript" ,
tsx : "typescript" ,
py : "python" ,
rb : "ruby" ,
java : "java" ,
cpp : "cpp" ,
c : "c" ,
cs : "csharp" ,
php : "php" ,
go : "go" ,
rs : "rust" ,
sh : "bash" ,
bash : "bash" ,
bat : "powershell" ,
cmd : "powershell" ,
ps1 : "powershell" ,
json : "json" ,
xml : "xml" ,
html : "html" ,
css : "css" ,
scss : "scss" ,
sql : "sql" ,
yaml : "yaml" ,
yml : "yaml" ,
md : "markdown" ,
txt : "plaintext" ,
};
const language = languageMap [ ext ] || "plaintext" ;
codeElement . className = `language- ${ language } ` ;
codeElement . textContent = code ;
delete codeElement . dataset . highlighted ;
if ( window . hljs ) {
hljs . highlightElement ( codeElement );
}
}
const editorEditTab = document . getElementById ( "editor-edit-tab" );
const editorPreviewTab = document . getElementById ( "editor-preview-tab" );
const editorPreview = document . getElementById ( "editor-preview" );
editorEditTab . addEventListener ( "click" , () => {
editorTextarea . classList . remove ( "hidden" );
editorPreview . classList . add ( "hidden" );
editorEditTab . classList . add ( "bg-blue-600" );
editorEditTab . classList . remove ( "bg-slate-700" , "hover:bg-slate-600" );
editorPreviewTab . classList . remove ( "bg-blue-600" );
editorPreviewTab . classList . add ( "bg-slate-700" , "hover:bg-slate-600" );
});
editorPreviewTab . addEventListener ( "click" , () => {
applySyntaxHighlighting ();
editorTextarea . classList . add ( "hidden" );
editorPreview . classList . remove ( "hidden" );
editorPreviewTab . classList . add ( "bg-blue-600" );
editorPreviewTab . classList . remove ( "bg-slate-700" , "hover:bg-slate-600" );
editorEditTab . classList . remove ( "bg-blue-600" );
editorEditTab . classList . add ( "bg-slate-700" , "hover:bg-slate-600" );
});
editorSaveBtn . addEventListener ( "click" , saveFileFromEditor );
editorCancelBtn . addEventListener ( "click" , closeEditor );
editorCloseBtn . addEventListener ( "click" , closeEditor );
2026-03-26 09:55:02 -05:00
if ( previewCloseBtn ) previewCloseBtn . addEventListener ( "click" , closePreview );
if ( previewDownloadBtn ) previewDownloadBtn . addEventListener ( "click" , () => {
if ( currentPreviewPath ) downloadFile ( currentPreviewPath );
});
if ( filePreviewModal ) {
// Close on backdrop click.
filePreviewModal . addEventListener ( "click" , ( e ) => {
if ( e . target === filePreviewModal ) closePreview ();
});
}
document . addEventListener ( "keydown" , ( e ) => {
if ( e . key === "Escape" && filePreviewModal && filePreviewModal . classList . contains ( "show" )) {
closePreview ();
}
});
2026-03-05 00:04:26 -06:00
const editorRunBtn = document . getElementById ( "editor-run-btn" );
editorRunBtn . addEventListener ( "click" , () => {
if ( ! currentEditingFile ) return ;
const ext = currentEditingFile . split ( "." ). pop () ? . toLowerCase ();
let command = "" ;
const isWindows = currentPath . includes ( ":\\" );
if ( isWindows ) {
switch ( ext ) {
case "bat" :
case "cmd" :
command = currentEditingFile ;
break ;
case "ps1" :
command = `powershell.exe -ExecutionPolicy Bypass -File " ${ currentEditingFile } "` ;
break ;
case "exe" :
case "com" :
command = `" ${ currentEditingFile } "` ;
break ;
case "py" :
command = `python " ${ currentEditingFile } "` ;
break ;
case "js" :
command = `node " ${ currentEditingFile } "` ;
break ;
default :
command = `" ${ currentEditingFile } "` ;
}
} else {
switch ( ext ) {
case "sh" :
case "bash" :
command = `bash " ${ currentEditingFile } "` ;
break ;
case "py" :
command = `python3 " ${ currentEditingFile } "` ;
break ;
case "rb" :
command = `ruby " ${ currentEditingFile } "` ;
break ;
case "js" :
command = `node " ${ currentEditingFile } "` ;
break ;
case "pl" :
command = `perl " ${ currentEditingFile } "` ;
break ;
default :
command = `" ${ currentEditingFile } "` ;
}
}
window . open (
`/ ${ clientId } /console?cmd= ${ encodeURIComponent ( command ) } ` ,
"_blank" ,
);
});
bulkDownloadBtn . addEventListener ( "click" , () => {
selectedFiles . forEach (( path ) => downloadFile ( path ));
});
bulkDeleteBtn . addEventListener ( "click" , () => {
if ( ! confirm ( `Delete ${ selectedFiles . size } selected items?` )) return ;
selectedFiles . forEach (( path ) => {
send ({ type : "file_delete" , path });
});
clearSelection ();
setTimeout (() => listFiles ( currentPath ), 500 );
});
bulkMoveBtn . addEventListener ( "click" , () => {
const dest = prompt ( "Enter destination path:" );
if ( ! dest ) return ;
selectedFiles . forEach (( path ) => {
const fileName = path . split ( /[\/\\]/ ). pop ();
const destPath = ` ${ dest } / ${ fileName } ` ;
send ({ type : "file_move" , source : path , dest : destPath });
});
clearSelection ();
setTimeout (() => listFiles ( currentPath ), 500 );
});
bulkCopyBtn . addEventListener ( "click" , () => {
const dest = prompt ( "Enter destination path:" );
if ( ! dest ) return ;
selectedFiles . forEach (( path ) => {
const fileName = path . split ( /[\/\\]/ ). pop ();
const destPath = ` ${ dest } / ${ fileName } ` ;
send ({ type : "file_copy" , source : path , dest : destPath });
});
clearSelection ();
setTimeout (() => listFiles ( currentPath ), 500 );
});
clearSelectionBtn . addEventListener ( "click" , clearSelection );
contextMenu . querySelectorAll ( ".context-menu-item" ). forEach (( item ) => {
item . addEventListener ( "click" , () => {
const action = item . dataset . action ;
const path = contextMenu . dataset . path ;
const isDir = contextMenu . dataset . isDir === "true" ;
const entry = { path , isDir };
contextMenu . classList . remove ( "show" );
handleFileAction ( action , entry );
});
});