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-05-29 22:12:02 -05:00
import { createFileHexHashManager } from "./filebrowser-hex-hash.js" ;
import { createFilePreviewModal } from "./filebrowser-preview-modal.js" ;
import { createTransferPanel } from "./filebrowser-transfer-panel.js" ;
2026-08-01 02:11:52 -05:00
import {
animateElement ,
concealElement ,
revealElement ,
staggerChildren ,
} from "./motion.js" ;
2026-05-29 22:12:02 -05:00
import {
KNOWN_BINARY_EXTS ,
PREVIEW_IMAGE_EXTS ,
escapeHtml ,
formatBytes ,
getFileExt ,
2026-07-14 09:50:09 -05:00
getHighlightLanguage ,
2026-05-29 22:12:02 -05:00
getParentPath ,
isPreviewable ,
2026-08-03 22:25:29 -05:00
joinRemotePath ,
2026-05-29 22:12:02 -05:00
shouldShowParentDirectory ,
} from "./filebrowser-utils.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 ;
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 ;
2026-06-10 19:54:18 -05:00
const MAC_PERMISSION_STORAGE_KEY = `filebrowser.macPermissionAllowed. ${ clientId } .v2` ;
2026-06-03 21:04:14 -05:00
const macPermissionAllowedPaths = new Set ( loadMacPermissionAllowedPaths ());
2026-03-06 11:28:35 -06:00
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" );
2026-08-03 22:25:29 -05:00
const uploadExecuteBtn = document . getElementById ( "upload-execute-btn" );
2026-03-05 00:04:26 -06:00
const mkdirBtn = document . getElementById ( "mkdir-btn" );
const searchBtn = document . getElementById ( "search-btn" );
const fileInput = document . getElementById ( "file-input" );
2026-08-03 22:25:29 -05:00
const executeFileInput = document . getElementById ( "execute-file-input" );
2026-03-05 00:04:26 -06:00
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" );
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" );
if ( clientIdHeader ) {
2026-07-14 00:25:36 -05:00
clientIdHeader . innerHTML = `<i class="fa-solid fa-computer mr-1.5 text-sky-400"></i> ${ escapeHtml ( clientId ) } ` ;
2026-03-05 00:04:26 -06:00
}
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>' ,
};
2026-07-14 09:50:09 -05:00
statusEl . innerHTML = icons [ state ] || icons . disconnected ;
statusEl . appendChild ( document . createTextNode ( ` ${ String ( text ?? "" ) } ` ));
2026-07-14 00:25:36 -05:00
const stateClasses = {
connected : "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" ,
error : "bg-red-500/10 text-red-300 border-red-500/30" ,
disconnected : "bg-slate-800 text-slate-300 border-slate-700" ,
connecting : "bg-sky-500/10 text-sky-300 border-sky-500/30" ,
};
statusEl . className = `inline-flex self-start sm:self-auto items-center gap-2 px-3 py-1.5 rounded-full border text-xs font-medium ${ stateClasses [ state ] || stateClasses . disconnected } ` ;
2026-03-05 00:04:26 -06:00
}
function enableControls ( enabled ) {
refreshBtn . disabled = ! enabled ;
uploadBtn . disabled = ! enabled ;
2026-08-03 22:25:29 -05:00
uploadExecuteBtn . disabled = ! enabled ;
2026-03-05 00:04:26 -06:00
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 ;
2026-05-17 00:51:17 -05:00
case "file_icon_result" :
handleFileIconResult ( msg );
break ;
case "file_thumb_result" :
handleFileThumbResult ( msg );
break ;
case "file_dirsize_result" :
handleFileDirsizeResult ( msg );
break ;
2026-05-21 09:51:12 -05:00
case "file_peek_result" :
handleFilePeekResult ( msg );
break ;
case "file_hash_result" :
handleFileHashResult ( msg );
break ;
2026-03-05 00:04:26 -06:00
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 );
}
}
2026-06-03 21:04:14 -05:00
async function listFiles ( path , socket = ws , options = {}) {
const { resetHistory = false , skipHistory = false } = options ;
if ( ! ( await confirmMacPermissionRisk ( path , "open folder" ))) {
return false ;
}
if ( resetHistory ) {
pathHistory = [];
} else if ( ! skipHistory && currentPath && currentPath !== path ) {
2026-03-05 00:04:26 -06:00
pathHistory . push ( currentPath );
}
currentPath = path ;
send ({ type : "file_list" , path }, socket );
updateBreadcrumb ( path );
updatePathInput ( path );
updateBackButton ();
2026-06-03 21:04:14 -05:00
return true ;
2026-03-05 00:04:26 -06:00
}
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 );
}
2026-06-03 21:04:14 -05:00
async function goBack () {
2026-03-05 00:04:26 -06:00
if ( pathHistory . length > 0 ) {
2026-06-03 21:04:14 -05:00
const previousPath = pathHistory [ pathHistory . length - 1 ];
const ok = await listFiles ( previousPath , ws , { skipHistory : true });
if ( ok ) {
pathHistory . pop ();
}
2026-03-05 00:04:26 -06:00
updateBackButton ();
}
}
2026-06-03 21:04:14 -05:00
function loadMacPermissionAllowedPaths () {
try {
const raw = sessionStorage . getItem ( MAC_PERMISSION_STORAGE_KEY );
const parsed = raw ? JSON . parse ( raw ) : [];
if ( ! Array . isArray ( parsed )) return [];
return parsed . filter (( p ) => typeof p === "string" && p . trim ());
} catch {
return [];
}
}
function persistMacPermissionAllowedPaths () {
try {
sessionStorage . setItem (
MAC_PERMISSION_STORAGE_KEY ,
JSON . stringify ( Array . from ( macPermissionAllowedPaths )),
);
} catch {}
}
function normalizeMacPath ( path ) {
const raw = String ( path || "" ). trim ();
if ( ! raw || raw === "." ) return raw ;
let normalized = raw . replace ( /\\/g , "/" );
if ( normalized === "~" && detectedHomePath ) normalized = detectedHomePath ;
if ( normalized . startsWith ( "~/" ) && detectedHomePath ) {
normalized = detectedHomePath + normalized . slice ( 1 );
}
normalized = normalized . replace ( /\/+/g , "/" );
if ( normalized . length > 1 ) normalized = normalized . replace ( /\/+$/ , "" );
return normalized ;
}
function isSameOrChildPath ( path , root ) {
if ( ! path || ! root ) return false ;
return path === root || path . startsWith ( root + "/" );
}
function markMacPermissionAllowed ( path ) {
2026-06-10 19:54:18 -05:00
const risk = macProtectedLocationForPath ( path );
if ( ! risk ) return ;
macPermissionAllowedPaths . add ( risk . cachePath || risk . root || risk . path );
2026-06-03 21:04:14 -05:00
persistMacPermissionAllowedPaths ();
}
function hasMacPermissionAllowedAncestor ( path ) {
const normalized = normalizeMacPath ( path );
for ( const allowed of macPermissionAllowedPaths ) {
if ( isSameOrChildPath ( normalized , allowed )) return true ;
}
return false ;
}
2026-06-10 19:54:18 -05:00
function macProtectedLocationForPath ( path ) {
2026-06-03 21:04:14 -05:00
const normalized = normalizeMacPath ( path );
2026-06-10 19:54:18 -05:00
if ( ! normalized || normalized === "." ) {
2026-06-03 21:04:14 -05:00
return null ;
}
const maybeMac = detectedOS === "mac" || ( ! detectedOS && normalized . startsWith ( "/Users/" ));
if ( ! maybeMac ) return null ;
let home = normalizeMacPath ( detectedHomePath );
if ( ! home ) {
const homeMatch = normalized . match ( /^(\/Users\/[^/]+)/ );
if ( homeMatch ) home = homeMatch [ 1 ];
}
const protectedRoots = [];
if ( home ) {
for ( const name of [ "Desktop" , "Documents" , "Downloads" , "Pictures" , "Movies" , "Music" ]) {
protectedRoots . push ({ root : ` ${ home } / ${ name } ` , label : name });
}
protectedRoots . push (
{ root : ` ${ home } /Library/Mobile Documents` , label : "iCloud Drive" },
{ root : ` ${ home } /Library/Mail` , label : "Mail data" },
{ root : ` ${ home } /Library/Messages` , label : "Messages data" },
{ root : ` ${ home } /Library/Safari` , label : "Safari data" },
{ root : ` ${ home } /Library/Calendars` , label : "Calendar data" },
{ root : ` ${ home } /Library/Application Support/AddressBook` , label : "Contacts data" },
);
}
protectedRoots . push (
{ root : "/Volumes" , label : "removable or network volume" },
{ root : "/Network" , label : "network location" },
);
const match = protectedRoots . find (( item ) => isSameOrChildPath ( normalized , item . root ));
if ( ! match ) return null ;
const cachePath = match . root === "/Volumes" || match . root === "/Network"
? normalized
: match . root ;
return { ... match , path : normalized , cachePath };
}
2026-06-10 19:54:18 -05:00
function macPermissionRiskForPath ( path ) {
const risk = macProtectedLocationForPath ( path );
if ( ! risk ) return null ;
if ( hasMacPermissionAllowedAncestor ( risk . path )) return null ;
return risk ;
}
2026-06-03 21:04:14 -05:00
function showMacPermissionWarning ( risk , operation ) {
return new Promise (( resolve ) => {
const modal = document . createElement ( "div" );
let onKeyDown = null ;
let settled = false ;
modal . className = "fixed inset-0 z-[2400] flex items-center justify-center bg-black/70 px-4" ;
modal . innerHTML = `
<div class="w-full max-w-md rounded-lg border border-amber-500/40 bg-slate-900 shadow-2xl">
<div class="px-4 py-3 border-b border-slate-700 flex items-center gap-2">
<i class="fa-solid fa-triangle-exclamation text-amber-300"></i>
2026-06-10 19:54:18 -05:00
<div class="font-semibold text-slate-100">Confirm macOS Access Request</div>
2026-06-03 21:04:14 -05:00
</div>
<div class="px-4 py-4 text-sm text-slate-300 space-y-3">
<p> ${ escapeHtml ( operation ) } <span class="font-mono text-slate-100 break-all"> ${ escapeHtml ( risk . path ) } </span> may ask the person using this Mac to allow access to ${ escapeHtml ( risk . label ) } for this app.</p>
2026-06-10 19:54:18 -05:00
<p class="text-xs text-slate-400">After you confirm here, the agent will try to open the folder on the Mac so macOS can show its permission prompt there.</p>
2026-06-03 21:04:14 -05:00
</div>
<div class="px-4 py-3 border-t border-slate-700 flex justify-end gap-2">
<button type="button" data-mac-permission-cancel class="px-3 py-2 rounded bg-slate-700 hover:bg-slate-600 text-sm text-slate-100">Cancel</button>
2026-06-10 19:54:18 -05:00
<button type="button" data-mac-permission-continue class="px-3 py-2 rounded bg-amber-500 hover:bg-amber-400 text-sm font-semibold text-slate-950">Try Access</button>
2026-06-03 21:04:14 -05:00
</div>
</div>
` ;
const cleanup = ( answer ) => {
if ( settled ) return ;
settled = true ;
if ( onKeyDown ) document . removeEventListener ( "keydown" , onKeyDown );
modal . remove ();
resolve ( answer );
};
modal . querySelector ( "[data-mac-permission-cancel]" ) ? . addEventListener ( "click" , () => cleanup ( false ));
modal . querySelector ( "[data-mac-permission-continue]" ) ? . addEventListener ( "click" , () => cleanup ( true ));
modal . addEventListener ( "click" , ( e ) => {
if ( e . target === modal ) cleanup ( false );
});
onKeyDown = ( e ) => {
if ( e . key !== "Escape" ) return ;
cleanup ( false );
};
document . addEventListener ( "keydown" , onKeyDown );
document . body . appendChild ( modal );
modal . querySelector ( "[data-mac-permission-continue]" ) ? . focus ();
});
}
async function confirmMacPermissionRisk ( path , operation = "access" ) {
const risk = macPermissionRiskForPath ( path );
if ( ! risk ) return true ;
const ok = await showMacPermissionWarning ( risk , operation );
if ( ! ok ) {
notifyToast ( "Operation cancelled before macOS permission prompt" , "info" , 2500 );
return false ;
}
return true ;
}
2026-06-10 19:54:18 -05:00
function macPermissionLockedDirectory ( entry ) {
if ( ! entry || ! entry . isDir ) return null ;
return macPermissionRiskForPath ( entry . path );
}
function renderMacPermissionBadge ( risk ) {
if ( ! risk ) return "" ;
return `<span class="inline-flex items-center gap-1 ml-1 px-1.5 py-0.5 rounded border border-amber-500/30 bg-amber-500/10 text-[10px] leading-none text-amber-200 flex-shrink-0" title="macOS may ask for permission to access ${ escapeHtml ( risk . label ) } "><i class="fa-solid fa-lock text-[9px]"></i> Needs permission</span>` ;
}
function renderMacPermissionFolderIcon ( risk ) {
if ( ! risk ) return null ;
return '<span class="relative inline-flex w-4 h-4 items-center justify-center flex-shrink-0"><i class="fa-solid fa-folder text-slate-500"></i><i class="fa-solid fa-lock absolute -right-1 -bottom-1 text-[8px] text-amber-300"></i></span>' ;
}
2026-03-05 00:04:26 -06:00
function goHome () {
2026-06-03 21:04:14 -05:00
listFiles ( "." , ws , { resetHistory : true });
2026-03-05 00:04:26 -06:00
}
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-05-29 22:12:02 -05:00
const filePreviewModalManager = createFilePreviewModal ({
clientId ,
notifyToast ,
onDownload : ( path ) => downloadFile ( path ),
2026-06-03 21:04:14 -05:00
beforeFileRead : ( path , operation ) => confirmMacPermissionRisk ( path , operation ),
2026-05-29 22:12:02 -05:00
});
let openFilePreview = filePreviewModalManager . openFilePreview ;
const transferPanelManager = createTransferPanel ({
onCancel : ( transferId ) => cancelTransfer ( transferId ),
});
const {
addTransferToUI ,
updateTransferProgress ,
} = transferPanelManager ;
2026-03-26 09:55:02 -05:00
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" ,
};
2026-05-17 00:51:17 -05:00
// Extensions whose icon depends on the actual file (embedded resources),
// not just the registered shell association.
const SELF_ICONNING_EXTS = new Set ([ "exe" , "ico" , "lnk" , "dll" , "msi" , "cpl" , "scr" ]);
// Extensions we'll request a Windows shell thumbnail for. Skip everything else.
const THUMBNAIL_EXTS = new Set ([
"jpg" , "jpeg" , "png" , "gif" , "bmp" , "webp" , "tif" , "tiff" , "heic" , "heif" ,
"mp4" , "mkv" , "mov" , "avi" , "webm" , "m4v" ,
"pdf" ,
"docx" , "xlsx" , "pptx" ,
]);
// key → { blobUrl?: string, failed?: boolean, pending?: boolean }
const iconCache = new Map ();
const thumbCache = new Map ();
2026-07-14 09:50:09 -05:00
const MAX_ICON_CACHE_ENTRIES = 256 ;
const MAX_THUMB_CACHE_ENTRIES = 128 ;
function trimBlobCache ( cache , maxEntries ) {
while ( cache . size > maxEntries ) {
const oldestKey = cache . keys (). next (). value ;
const entry = cache . get ( oldestKey );
if ( entry ? . blobUrl ) URL . revokeObjectURL ( entry . blobUrl );
cache . delete ( oldestKey );
}
}
function clearBlobCaches () {
for ( const cache of [ iconCache , thumbCache ]) {
for ( const entry of cache . values ()) {
if ( entry ? . blobUrl ) URL . revokeObjectURL ( entry . blobUrl );
}
cache . clear ();
}
}
window . addEventListener ( "pagehide" , clearBlobCaches , { once : true });
2026-05-17 00:51:17 -05:00
// Queues + batching
const iconQueue = []; // {key, path?, ext?}
const thumbQueue = []; // {key, path, size}
const iconCommandsInFlight = new Set ();
const thumbCommandsInFlight = new Set ();
let iconFlushScheduled = false ;
let thumbFlushScheduled = false ;
const ICON_BATCH_SIZE = 32 ;
const ICON_BATCH_DELAY_MS = 60 ;
const THUMB_BATCH_SIZE = 8 ;
const THUMB_BATCH_DELAY_MS = 80 ;
const THUMB_EDGE = 96 ;
const MAX_THUMB_INFLIGHT_COMMANDS = 2 ;
function iconCacheKey ( entry ) {
if ( entry . isDir ) return null ;
const ext = getFileExt ( entry . name );
if ( ! ext ) return `ext:_noext` ;
if ( SELF_ICONNING_EXTS . has ( ext )) {
return `path: ${ entry . path } | ${ entry . size } | ${ entry . modTime } ` ;
}
return `ext: ${ ext } ` ;
}
function thumbCacheKey ( entry ) {
if ( entry . isDir ) return null ;
const ext = getFileExt ( entry . name );
if ( ! THUMBNAIL_EXTS . has ( ext )) return null ;
// Skip absurdly large files — Windows providers may bog down. 256MB cap.
if ( entry . size > 256 * 1024 * 1024 ) return null ;
return `thumb: ${ entry . path } | ${ entry . size } | ${ entry . modTime } | ${ THUMB_EDGE } ` ;
}
function scheduleIconFlush () {
if ( iconFlushScheduled ) return ;
iconFlushScheduled = true ;
setTimeout ( flushIconQueue , ICON_BATCH_DELAY_MS );
}
function flushIconQueue () {
iconFlushScheduled = false ;
if ( iconQueue . length === 0 ) return ;
const batch = iconQueue . splice ( 0 , ICON_BATCH_SIZE );
const commandId = `icon- ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) } ` ;
iconCommandsInFlight . add ( commandId );
send ({
type : "command" ,
commandType : "file_icon" ,
id : commandId ,
payload : { items : batch },
});
if ( iconQueue . length > 0 ) scheduleIconFlush ();
}
function scheduleThumbFlush () {
if ( thumbFlushScheduled ) return ;
if ( thumbCommandsInFlight . size >= MAX_THUMB_INFLIGHT_COMMANDS ) return ;
thumbFlushScheduled = true ;
setTimeout ( flushThumbQueue , THUMB_BATCH_DELAY_MS );
}
function flushThumbQueue () {
thumbFlushScheduled = false ;
if ( thumbQueue . length === 0 ) return ;
if ( thumbCommandsInFlight . size >= MAX_THUMB_INFLIGHT_COMMANDS ) return ;
const batch = thumbQueue . splice ( 0 , THUMB_BATCH_SIZE );
const commandId = `thumb- ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) } ` ;
thumbCommandsInFlight . add ( commandId );
send ({
type : "command" ,
commandType : "file_thumb" ,
id : commandId ,
payload : { items : batch },
});
if ( thumbQueue . length > 0 ) scheduleThumbFlush ();
}
function requestIconFor ( entry ) {
const key = iconCacheKey ( entry );
if ( ! key ) return null ;
2026-07-14 09:50:09 -05:00
if ( iconCache . has ( key )) {
const cached = iconCache . get ( key );
iconCache . delete ( key );
iconCache . set ( key , cached );
return key ;
}
2026-05-17 00:51:17 -05:00
iconCache . set ( key , { pending : true });
2026-07-14 09:50:09 -05:00
trimBlobCache ( iconCache , MAX_ICON_CACHE_ENTRIES );
2026-05-17 00:51:17 -05:00
const item = { key };
if ( key . startsWith ( "path:" )) {
item . path = entry . path ;
} else {
const ext = getFileExt ( entry . name );
if ( ext ) item . ext = ext ;
else item . ext = "" ;
}
iconQueue . push ( item );
scheduleIconFlush ();
return key ;
}
function requestThumbFor ( entry ) {
const key = thumbCacheKey ( entry );
if ( ! key ) return null ;
2026-07-14 09:50:09 -05:00
if ( thumbCache . has ( key )) {
const cached = thumbCache . get ( key );
thumbCache . delete ( key );
thumbCache . set ( key , cached );
return key ;
}
2026-05-17 00:51:17 -05:00
thumbCache . set ( key , { pending : true });
2026-07-14 09:50:09 -05:00
trimBlobCache ( thumbCache , MAX_THUMB_CACHE_ENTRIES );
2026-05-17 00:51:17 -05:00
thumbQueue . push ({ key , path : entry . path , size : THUMB_EDGE });
scheduleThumbFlush ();
return key ;
}
function handleFileIconResult ( msg ) {
if ( msg . commandId ) iconCommandsInFlight . delete ( msg . commandId );
const items = Array . isArray ( msg . icons ) ? msg . icons : [];
for ( const item of items ) {
if ( ! item || ! item . key ) continue ;
const entry = iconCache . get ( item . key ) || {};
entry . pending = false ;
if ( item . png && item . png . length > 0 ) {
2026-07-14 09:50:09 -05:00
if ( entry . blobUrl ) URL . revokeObjectURL ( entry . blobUrl );
2026-05-17 00:51:17 -05:00
const blob = new Blob ([ item . png ], { type : "image/png" });
entry . blobUrl = URL . createObjectURL ( blob );
} else {
entry . failed = true ;
}
2026-07-14 09:50:09 -05:00
iconCache . delete ( item . key );
2026-05-17 00:51:17 -05:00
iconCache . set ( item . key , entry );
2026-07-14 09:50:09 -05:00
trimBlobCache ( iconCache , MAX_ICON_CACHE_ENTRIES );
2026-05-17 00:51:17 -05:00
applyIconToDom ( item . key , entry );
}
}
function handleFileThumbResult ( msg ) {
if ( msg . commandId ) thumbCommandsInFlight . delete ( msg . commandId );
// Pump the next thumbnail batch now that there's capacity.
if ( thumbQueue . length > 0 ) scheduleThumbFlush ();
const items = Array . isArray ( msg . thumbs ) ? msg . thumbs : [];
for ( const item of items ) {
if ( ! item || ! item . key ) continue ;
const entry = thumbCache . get ( item . key ) || {};
entry . pending = false ;
if ( item . jpeg && item . jpeg . length > 0 ) {
2026-07-14 09:50:09 -05:00
if ( entry . blobUrl ) URL . revokeObjectURL ( entry . blobUrl );
2026-05-17 00:51:17 -05:00
const blob = new Blob ([ item . jpeg ], { type : "image/jpeg" });
entry . blobUrl = URL . createObjectURL ( blob );
entry . w = item . w || 0 ;
entry . h = item . h || 0 ;
} else {
entry . failed = true ;
}
2026-07-14 09:50:09 -05:00
thumbCache . delete ( item . key );
2026-05-17 00:51:17 -05:00
thumbCache . set ( item . key , entry );
2026-07-14 09:50:09 -05:00
trimBlobCache ( thumbCache , MAX_THUMB_CACHE_ENTRIES );
2026-05-17 00:51:17 -05:00
applyThumbToDom ( item . key , entry );
}
}
function applyIconToDom ( key , entry ) {
if ( ! entry || ! entry . blobUrl ) return ;
const escaped = ( typeof CSS !== "undefined" && CSS . escape ) ? CSS . escape ( key ) : key . replace ( /"/g , '\\"' );
document . querySelectorAll ( `[data-icon-key=" ${ escaped } "]` ). forEach (( el ) => {
el . innerHTML = `<img src=" ${ entry . blobUrl } " class="w-4 h-4 object-contain pointer-events-none" alt="" draggable="false">` ;
});
}
function applyThumbToDom ( key , entry ) {
if ( ! entry || ! entry . blobUrl ) return ;
const escaped = ( typeof CSS !== "undefined" && CSS . escape ) ? CSS . escape ( key ) : key . replace ( /"/g , '\\"' );
// Thumbs are used by hover-preview popover; just update the popover if it's currently showing this key.
if ( currentPopoverKey === key ) {
renderQuickLookPopover ( currentPopoverEntry , entry . blobUrl , entry . w , entry . h );
}
}
// Windows FILE_ATTRIBUTE_* flags surfaced as compact badges next to the filename.
const ATTR_READONLY = 0x00000001 ;
const ATTR_HIDDEN = 0x00000002 ;
const ATTR_SYSTEM = 0x00000004 ;
const ATTR_REPARSE_POINT = 0x00000400 ;
const ATTR_COMPRESSED = 0x00000800 ;
const ATTR_ENCRYPTED = 0x00004000 ;
function renderAttrBadges ( attrs ) {
if ( ! attrs || typeof attrs !== "number" ) return "" ;
const pills = [];
const pill = ( label , title , cls ) =>
`<span class="text-[10px] leading-none px-1 py-0.5 rounded ${ cls } " title=" ${ title } "> ${ label } </span>` ;
if ( attrs & ATTR_HIDDEN ) pills . push ( pill ( "H" , "Hidden" , "bg-slate-700 text-slate-300" ));
if ( attrs & ATTR_SYSTEM ) pills . push ( pill ( "S" , "System" , "bg-red-900/60 text-red-200" ));
if ( attrs & ATTR_REPARSE_POINT ) pills . push ( pill ( "L" , "Reparse point / symlink" , "bg-indigo-900/60 text-indigo-200" ));
if ( attrs & ATTR_COMPRESSED ) pills . push ( pill ( "C" , "NTFS compressed" , "bg-blue-900/60 text-blue-200" ));
if ( attrs & ATTR_ENCRYPTED ) pills . push ( pill ( "E" , "NTFS encrypted (EFS)" , "bg-emerald-900/60 text-emerald-200" ));
if ( attrs & ATTR_READONLY ) pills . push ( pill ( "R" , "Read-only" , "bg-slate-700 text-slate-300" ));
if ( pills . length === 0 ) return "" ;
return `<span class="inline-flex items-center gap-1 ml-1 flex-shrink-0"> ${ pills . join ( "" ) } </span>` ;
}
// ---- Hover-preview / spacebar quicklook popover --------------------------------
let currentPopoverKey = null ;
let currentPopoverEntry = null ;
let hoverPreviewTimer = null ;
let popoverEl = null ;
let lastHoveredRow = null ;
let lastHoveredEntry = null ;
const HOVER_DELAY_MS = 350 ;
function ensurePopover () {
if ( popoverEl ) return popoverEl ;
popoverEl = document . createElement ( "div" );
popoverEl . id = "file-quicklook-popover" ;
popoverEl . style . cssText = "position:fixed;z-index:1500;display:none;pointer-events:none;background:#0f172a;border:1px solid #334155;border-radius:0.5rem;box-shadow:0 10px 25px rgba(0,0,0,.5);padding:0.5rem;max-width:280px;" ;
document . body . appendChild ( popoverEl );
return popoverEl ;
}
function hideQuickLookPopover () {
if ( hoverPreviewTimer ) {
clearTimeout ( hoverPreviewTimer );
hoverPreviewTimer = null ;
}
currentPopoverKey = null ;
currentPopoverEntry = null ;
if ( popoverEl ) popoverEl . style . display = "none" ;
}
function renderQuickLookPopover ( entry , thumbUrl , w , h ) {
if ( ! entry ) return ;
const el = ensurePopover ();
const ext = getFileExt ( entry . name );
const sizeStr = entry . isDir ? "" : formatBytes ( entry . size );
const modStr = new Date ( entry . modTime * 1000 ). toLocaleString ();
let imgHtml = "" ;
if ( thumbUrl ) {
imgHtml = `<img src=" ${ thumbUrl } " style="max-width:256px;max-height:256px;display:block;margin:0 auto;border-radius:0.25rem;" alt="">` ;
} else {
const iconKey = iconCacheKey ( entry );
const iconEntry = iconKey ? iconCache . get ( iconKey ) : null ;
if ( iconEntry && iconEntry . blobUrl ) {
imgHtml = `<img src=" ${ iconEntry . blobUrl } " style="width:64px;height:64px;display:block;margin:0 auto;image-rendering:auto;" alt="">` ;
} else {
imgHtml = `<div style="font-size:48px;text-align:center;color:#94a3b8;"> ${ getFileIcon ( entry ) } </div>` ;
}
}
el . innerHTML = `
${ imgHtml }
<div style="margin-top:0.5rem;font-size:11px;color:#e2e8f0;text-align:center;word-break:break-word;"> ${ escapeHtml ( entry . name ) } </div>
<div style="margin-top:0.25rem;font-size:10px;color:#94a3b8;text-align:center;">
${ entry . isDir ? "Folder" : ( ext ? ext . toUpperCase () + " · " : "" ) + sizeStr }
</div>
<div style="font-size:10px;color:#64748b;text-align:center;"> ${ modStr } </div>
` ;
el . style . display = "block" ;
positionPopover ( el );
}
function positionPopover ( el ) {
if ( ! lastHoveredRow ) return ;
const rect = lastHoveredRow . getBoundingClientRect ();
const popoverWidth = el . offsetWidth || 280 ;
const popoverHeight = el . offsetHeight || 320 ;
let left = rect . right + 12 ;
let top = rect . top ;
if ( left + popoverWidth > window . innerWidth - 8 ) {
left = rect . left - popoverWidth - 12 ;
}
if ( left < 8 ) left = 8 ;
if ( top + popoverHeight > window . innerHeight - 8 ) {
top = Math . max ( 8 , window . innerHeight - popoverHeight - 8 );
}
el . style . left = ` ${ left } px` ;
el . style . top = ` ${ top } px` ;
}
function showQuickLookFor ( row , entry , immediate ) {
if ( ! entry ) return ;
lastHoveredRow = row ;
lastHoveredEntry = entry ;
const tKey = thumbCacheKey ( entry );
currentPopoverKey = tKey ;
currentPopoverEntry = entry ;
const cached = tKey ? thumbCache . get ( tKey ) : null ;
if ( cached && cached . blobUrl ) {
renderQuickLookPopover ( entry , cached . blobUrl , cached . w , cached . h );
return ;
}
// No thumb yet — still show the popover with icon + metadata.
renderQuickLookPopover ( entry , null , 0 , 0 );
// Ensure thumb is queued (createFileRow already did this; no-op if so).
if ( tKey ) requestThumbFor ( entry );
}
function attachRowQuicklookEvents ( row , entry ) {
if ( entry . isDir ) return ; // folders: skip hover popover (would just be the icon)
row . addEventListener ( "mouseenter" , () => {
lastHoveredRow = row ;
lastHoveredEntry = entry ;
if ( hoverPreviewTimer ) clearTimeout ( hoverPreviewTimer );
hoverPreviewTimer = setTimeout (() => showQuickLookFor ( row , entry , false ), HOVER_DELAY_MS );
});
row . addEventListener ( "mouseleave" , () => {
hideQuickLookPopover ();
});
}
// Spacebar = open quicklook for last-hovered/focused row (Finder style).
window . addEventListener ( "keydown" , ( e ) => {
if ( e . key !== " " ) return ;
const target = e . target ;
// Don't hijack space in inputs/textareas/editor.
if ( target && ( target . tagName === "INPUT" || target . tagName === "TEXTAREA" || target . isContentEditable )) return ;
if ( document . querySelector ( ".file-editor-modal.show" )) return ;
if ( document . querySelector ( ".file-preview-modal.show" )) return ;
if ( ! lastHoveredRow || ! lastHoveredEntry ) return ;
e . preventDefault ();
showQuickLookFor ( lastHoveredRow , lastHoveredEntry , true );
});
window . addEventListener ( "keyup" , ( e ) => {
if ( e . key !== " " ) return ;
hideQuickLookPopover ();
});
window . addEventListener ( "scroll" , () => hideQuickLookPopover (), { passive : true });
// ---- Folder size --------------------------------------------------------------
// commandId -> { path, toastId? }
const folderSizeInFlight = new Map ();
function requestFolderSize ( entry ) {
if ( ! entry . isDir ) return ;
const commandId = `dirsize- ${ Date . now () } - ${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) } ` ;
folderSizeInFlight . set ( commandId , { path : entry . path , name : entry . name });
send ({
type : "command" ,
commandType : "file_dirsize" ,
id : commandId ,
payload : { path : entry . path },
});
notifyToast ( `Calculating size of ${ entry . name } …` , "info" , 2000 );
}
function handleFileDirsizeResult ( msg ) {
const tracked = msg . commandId ? folderSizeInFlight . get ( msg . commandId ) : null ;
if ( ! tracked ) return ;
if ( ! msg . done ) {
// Progress tick — update status bar only (avoid toast spam).
if ( statusEl ) {
const label = ` ${ tracked . name || msg . path } : ${ formatBytes ( msg . bytes || 0 ) } ( ${ msg . files || 0 } files)` ;
statusEl . innerHTML = `<i class="fa-solid fa-circle-notch fa-spin"></i> ${ escapeHtml ( label ) } ` ;
}
return ;
}
folderSizeInFlight . delete ( msg . commandId );
updateStatus ( "connected" , "Connected" );
if ( msg . error ) {
notifyToast ( `Size of ${ tracked . name } : error — ${ msg . error } ` , "error" , 5000 );
return ;
}
notifyToast (
` ${ tracked . name } : ${ formatBytes ( msg . bytes || 0 ) } ( ${ msg . files || 0 } files, ${ msg . dirs || 0 } folders)` ,
"success" ,
8000 ,
);
}
2026-03-13 13:33:39 -05:00
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 ];
2026-05-17 00:51:17 -05:00
const fallback = cls
? `<i class="fa-solid ${ cls } "></i>`
: '<i class="fa-solid fa-file text-slate-400"></i>' ;
const key = iconCacheKey ( entry );
if ( ! key ) return fallback ;
const cached = iconCache . get ( key );
if ( cached && cached . blobUrl ) {
return `<span class="inline-flex w-4 h-4 items-center justify-center" data-icon-key=" ${ escapeHtml ( key ) } "><img src=" ${ cached . blobUrl } " class="w-4 h-4 object-contain pointer-events-none" alt="" draggable="false"></span>` ;
2026-03-13 13:33:39 -05:00
}
2026-05-17 00:51:17 -05:00
return `<span class="inline-flex w-4 h-4 items-center justify-center" data-icon-key=" ${ escapeHtml ( key ) } "> ${ fallback } </span>` ;
2026-03-13 13:33:39 -05:00
}
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 ();
2026-08-01 02:11:52 -05:00
fileListEl . style . transition = "none" ;
fileListEl . style . opacity = "1" ;
fileListEl . style . transform = "translateX(0)" ;
fileListEl . innerHTML = "" ;
2026-03-06 11:28:35 -06:00
2026-08-01 02:11:52 -05:00
if ( canGoUp ) fileListEl . appendChild ( createParentRow ( parentPath ));
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>' ;
2026-03-06 11:28:35 -06:00
} else {
2026-08-01 02:11:52 -05:00
const fragment = document . createDocumentFragment ();
entries . forEach (( entry ) => fragment . appendChild ( createFileRow ( entry )));
fileListEl . appendChild ( fragment );
2026-03-05 00:04:26 -06:00
}
2026-08-01 02:11:52 -05:00
if ( ! disableAnimations ) {
animateElement ( fileListEl , [
{ opacity : 0.35 , transform : "translateX(8px)" },
{ opacity : 1 , transform : "translateX(0)" },
], { duration : 190 });
staggerChildren ( fileListEl , { limit : 14 , interval : 18 , duration : 190 , offset : 5 });
2026-03-06 11:28:35 -06:00
}
}
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 ();
2026-05-29 15:41:08 -05:00
currentPath = msg . path || currentPath ;
renderFileListError ( msg );
2026-03-06 11:28:35 -06:00
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-05-21 09:51:12 -05:00
updatePinnedSidebar ();
// If the previewed item was from another directory, clear it.
if ( currentPreviewEntry && ! directoryEntries . some (( e ) => e . path === currentPreviewEntry . path )) {
clearPreviewPane ();
}
2026-03-13 13:33:39 -05:00
2026-06-03 21:04:14 -05:00
markMacPermissionAllowed ( currentPath );
2026-03-06 11:28:35 -06:00
selectedFiles . clear ();
updateSelectionUI ();
renderCurrentDirectory ();
2026-03-05 00:04:26 -06:00
}
2026-05-29 15:41:08 -05:00
function isMacFolderAccessError ( msg ) {
if ( ! msg || ! msg . error ) return false ;
if ( msg . canRequestAccess || msg . accessDenied ) return true ;
if ( detectedOS !== "mac" ) return false ;
return /permission denied|operation not permitted|not permitted|access denied/i . test ( msg . error );
}
function renderFileListError ( msg ) {
const canRequestAccess = isMacFolderAccessError ( msg );
2026-06-10 19:54:18 -05:00
const helpText = msg . accessHelp || "macOS blocked this folder. Confirm the retry, approve the prompt on the Mac, then refresh if needed." ;
2026-05-29 15:41:08 -05:00
const actionHtml = canRequestAccess
? `<div class="mt-4 flex flex-wrap items-center justify-center gap-2">
<button id="request-folder-access-btn" class="button primary text-sm px-4 py-2">
2026-06-10 19:54:18 -05:00
<i class="fa-solid fa-unlock-keyhole"></i> Try Access Again
2026-05-29 15:41:08 -05:00
</button>
<button id="retry-folder-access-btn" class="button ghost text-sm px-4 py-2">
<i class="fa-solid fa-rotate-right"></i> Retry
</button>
</div>
<div class="mt-3 text-xs text-amber-200/80 max-w-xl mx-auto"> ${ escapeHtml ( helpText ) } </div>`
: "" ;
fileListEl . innerHTML = `
<div class="px-4 py-6 text-center ${ canRequestAccess ? "text-amber-200" : "text-red-400" } ">
<i class="fa-solid fa-exclamation-triangle mr-2"></i> ${ escapeHtml ( msg . error ) }
${ actionHtml }
</div>` ;
const requestBtn = document . getElementById ( "request-folder-access-btn" );
if ( requestBtn ) {
requestBtn . addEventListener ( "click" , () => requestFolderAccess ( msg . path || currentPath || "." ));
}
const retryBtn = document . getElementById ( "retry-folder-access-btn" );
if ( retryBtn ) {
retryBtn . addEventListener ( "click" , () => listFiles ( msg . path || currentPath || "." ));
}
}
2026-06-10 19:54:18 -05:00
async function requestFolderAccess ( path ) {
const targetPath = path || currentPath || "." ;
notifyToast ( "Trying to open the folder on the Mac..." , "info" , 3500 );
await listFiles ( targetPath , ws , { skipHistory : true });
2026-05-29 15:41:08 -05:00
}
2026-03-05 00:04:26 -06:00
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" );
2026-06-10 19:54:18 -05:00
const macPermissionRisk = macPermissionLockedDirectory ( entry );
2026-03-05 00:04:26 -06:00
row . className =
2026-06-10 19:54:18 -05:00
"file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors" +
( macPermissionRisk ? " opacity-60 bg-slate-950/30 hover:bg-amber-900/10" : " hover:bg-slate-800/50" );
2026-03-05 00:04:26 -06:00
row . dataset . path = entry . path ;
row . dataset . isDir = entry . isDir ;
2026-06-10 19:54:18 -05:00
if ( macPermissionRisk ) {
row . dataset . macPermission = "required" ;
row . title = `macOS may ask the user to allow access to ${ macPermissionRisk . label } .` ;
}
2026-03-05 00:04:26 -06:00
2026-05-17 00:51:17 -05:00
// Kick off lazy fetches for the row's real icon and (if applicable) its thumbnail.
// Both are throttled + batched; the DOM gets updated when results arrive.
requestIconFor ( entry );
if ( ! entry . isDir ) requestThumbFor ( entry );
2026-06-10 19:54:18 -05:00
const icon = renderMacPermissionFolderIcon ( macPermissionRisk ) || getFileIcon ( entry );
2026-05-17 00:51:17 -05:00
const badges = renderAttrBadges ( entry . attrs );
2026-06-10 19:54:18 -05:00
const macPermissionBadge = renderMacPermissionBadge ( macPermissionRisk );
2026-03-05 00:04:26 -06:00
const size = entry . isDir ? "-" : formatBytes ( entry . size );
const modTime = new Date ( entry . modTime * 1000 ). toLocaleString ();
2026-05-21 09:51:12 -05:00
const pinnedNow = entry . isDir && isPinned ( entry . path );
const pinTitle = pinnedNow ? "Unpin from sidebar" : "Pin to sidebar" ;
const pinIcon = pinnedNow ? "fa-star" : "fa-star" ;
const pinClass = pinnedNow ? "pin-star-btn pinned" : "pin-star-btn" ;
const pinBtn = entry . isDir
? `<button class=" ${ pinClass } " data-pin-toggle=" ${ escapeHtml ( entry . path ) } " title=" ${ pinTitle } " type="button"><i class="fa-solid ${ pinIcon } "></i></button>`
: "" ;
2026-03-05 00:04:26 -06:00
row . innerHTML = `
<input type="checkbox" class="file-checkbox" data-path=" ${ escapeHtml ( entry . path ) } ">
2026-07-14 00:25:36 -05:00
<div class="col-span-6 flex items-center gap-2 truncate pl-8">
2026-03-05 00:04:26 -06:00
${ icon }
<span class="truncate"> ${ escapeHtml ( entry . name ) } </span>
2026-05-21 09:51:12 -05:00
${ pinBtn }
2026-05-17 00:51:17 -05:00
${ badges }
2026-06-10 19:54:18 -05:00
${ macPermissionBadge }
2026-03-05 00:04:26 -06:00
</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 ) => {
2026-05-21 09:51:12 -05:00
if ( e . target . closest ( ".file-checkbox" ) || e . target . closest ( ".action-btn" ) || e . target . closest ( ".pin-star-btn" )) {
2026-03-05 00:04:26 -06:00
return ;
}
2026-05-21 09:51:12 -05:00
// Single-click always updates the preview pane.
showPreviewForEntry ( entry );
if ( entry . isDir ) {
listFiles ( entry . path );
}
};
2026-06-03 21:04:14 -05:00
row . ondblclick = async ( e ) => {
2026-05-21 09:51:12 -05:00
if ( e . target . closest ( ".file-checkbox" ) || e . target . closest ( ".action-btn" ) || e . target . closest ( ".pin-star-btn" )) {
return ;
}
2026-03-05 00:04:26 -06:00
if ( entry . isDir ) {
listFiles ( entry . path );
2026-03-26 09:55:02 -05:00
} else if ( isPreviewable ( entry . name )) {
2026-06-03 21:04:14 -05:00
await openFilePreview ( entry . path , entry . size );
2026-03-26 09:55:02 -05:00
} else if ( KNOWN_BINARY_EXTS . has ( getFileExt ( entry . name ))) {
2026-06-03 21:04:14 -05:00
await openHexViewer ( entry . path );
2026-03-05 00:04:26 -06:00
} else {
2026-06-03 21:04:14 -05:00
await openFileInEditor ( entry . path );
2026-03-05 00:04:26 -06:00
}
};
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 );
};
});
2026-05-21 09:51:12 -05:00
const pinToggleBtn = row . querySelector ( "[data-pin-toggle]" );
if ( pinToggleBtn ) {
pinToggleBtn . addEventListener ( "click" , ( e ) => {
e . stopPropagation ();
const target = pinToggleBtn . dataset . pinToggle ;
if ( isPinned ( target )) {
unpinPath ( target );
pinToggleBtn . classList . remove ( "pinned" );
pinToggleBtn . title = "Pin to sidebar" ;
} else {
pinPath ( target , entry . name );
pinToggleBtn . classList . add ( "pinned" );
pinToggleBtn . title = "Unpin from sidebar" ;
}
});
}
2026-03-05 00:04:26 -06:00
row . oncontextmenu = ( e ) => {
e . preventDefault ();
showContextMenu ( e . clientX , e . clientY , entry );
};
2026-05-17 00:51:17 -05:00
attachRowQuicklookEvents ( row , entry );
2026-03-05 00:04:26 -06:00
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 ;
2026-05-17 00:51:17 -05:00
case "execute" :
executeFile ( entry . path , false );
break ;
case "silent_execute" :
executeFile ( entry . path , true );
break ;
2026-03-05 00:04:26 -06:00
case "delete" :
deleteFile ( entry . path );
break ;
2026-05-17 00:51:17 -05:00
case "dirsize" :
requestFolderSize ( entry );
break ;
2026-05-21 09:51:12 -05:00
case "pin" :
pinPath ( entry . path , entry . name );
notifyToast ( "Pinned" , "success" , 1800 );
break ;
case "unpin" :
unpinPath ( entry . path );
notifyToast ( "Unpinned" , "info" , 1800 );
break ;
case "hex_peek" :
if ( ! entry . isDir ) openHexViewer ( entry . path );
break ;
case "hash" :
if ( ! entry . isDir ) requestFileHash ( entry . path , "context" );
break ;
2026-03-05 00:04:26 -06:00
}
}
2026-06-03 21:04:14 -05:00
async function downloadFile ( path ) {
if ( ! ( await confirmMacPermissionRisk ( path , "download file" ))) return ;
2026-03-05 00:04:26 -06:00
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 ;
}
let received = 0 ;
let lastLoggedBytes = 0 ;
2026-05-21 09:51:12 -05:00
const trackChunk = ( size ) => {
received += size ;
2026-03-05 00:04:26 -06:00
transfer . received = received ;
2026-05-21 09:51:12 -05:00
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 );
}
};
const canStreamToDisk = typeof window . showSaveFilePicker === "function" && !! res . body ;
let usedStreamToDisk = false ;
if ( canStreamToDisk ) {
let saveHandle = null ;
try {
saveHandle = await window . showSaveFilePicker ({ suggestedName : fileName });
} catch ( pickerErr ) {
if ( pickerErr && pickerErr . name === "AbortError" ) {
transfer . cancelled = true ;
try { abortController . abort (); } catch {}
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
return ;
}
console . warn ( "[filebrowser] save picker failed, falling back to blob" , pickerErr );
saveHandle = null ;
}
if ( saveHandle ) {
const writable = await saveHandle . createWritable ();
const progressTransform = new TransformStream ({
transform ( chunk , controller ) {
trackChunk ( chunk . byteLength );
controller . enqueue ( chunk );
},
});
try {
await res . body . pipeThrough ( progressTransform ). pipeTo ( writable , {
signal : abortController . signal ,
});
} catch ( pipeErr ) {
try { await writable . abort ( pipeErr ); } catch {}
throw pipeErr ;
}
usedStreamToDisk = true ;
}
}
if ( ! usedStreamToDisk ) {
const chunks = [];
if ( res . body ) {
const reader = res . body . getReader ();
while ( true ) {
const { done , value } = await reader . read ();
if ( done ) break ;
if ( value ) {
chunks . push ( value );
trackChunk ( value . length );
}
if ( transfer . cancelled ) {
try { await reader . cancel (); } catch {}
break ;
}
}
} else {
console . warn ( "[filebrowser] download response missing body" , { path });
const blob = await res . blob ();
const buf = new Uint8Array ( await blob . arrayBuffer ());
chunks . push ( buf );
trackChunk ( buf . length );
}
if ( transfer . cancelled ) {
removeTransfer ( transferId );
fileDownloads . delete ( path );
updateStatus ( "connected" , "Connected" );
return ;
}
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 );
2026-03-05 00:04:26 -06:00
}
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 );
}
console . log ( "Download complete:" , path , ` ${ received } bytes` );
console . debug ( "[filebrowser] download complete" , {
path ,
received ,
total : transfer . total ,
2026-05-21 09:51:12 -05:00
streamedToDisk : usedStreamToDisk ,
2026-03-05 00:04:26 -06:00
});
2026-06-03 21:04:14 -05:00
markMacPermissionAllowed ( path );
2026-03-05 00:04:26 -06:00
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` );
2026-06-03 21:04:14 -05:00
markMacPermissionAllowed ( msg . path );
2026-03-05 00:04:26 -06:00
removeTransfer ( download . id );
fileDownloads . delete ( msg . path );
updateStatus ( "connected" , "Connected" );
}
}
2026-06-03 21:04:14 -05:00
async function zipAndDownload ( path ) {
if ( ! ( await confirmMacPermissionRisk ( path , "read folder" ))) return ;
2026-03-05 00:04:26 -06:00
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>
2026-07-14 09:50:09 -05:00
<button class="cancel-zip-operation text-slate-400 hover:text-red-400 transition-colors" data-command-id=" ${ escapeHtml ( commandId ) } ">
2026-03-05 00:04:26 -06:00
<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>
` ;
2026-07-14 09:50:09 -05:00
notification . querySelector ( ".cancel-zip-operation" ) ? . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
cancelZipOperation ( commandId );
});
2026-03-05 00:04:26 -06:00
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" ,
});
}
2026-05-17 00:51:17 -05:00
function executeFile ( path , silent ) {
if ( silent ) {
const commandId = `silent-exec- ${ Date . now () } ` ;
send ({
type : "command" ,
commandType : "silent_exec" ,
id : commandId ,
payload : { command : path , args : [], hideWindow : true },
});
trackCommandResult ( commandId , {
successMessage : "Silent execution started" ,
errorPrefix : "Silent execution failed" ,
});
} else {
const commandId = `exec- ${ Date . now () } ` ;
send ({
type : "command" ,
commandType : "file_execute" ,
id : commandId ,
payload : { path },
});
trackCommandResult ( commandId , {
successMessage : "Execution started" ,
errorPrefix : "Execution failed" ,
});
}
}
2026-03-05 00:04:26 -06:00
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 );
2026-05-21 09:51:12 -05:00
transfer . sent = Math . max ( transfer . sent || 0 , transfer . receivedBytes );
2026-03-08 00:53:22 -06:00
}
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` ;
2026-08-01 02:11:52 -05:00
contextMenu . style . transformOrigin = ` ${ x > window . innerWidth / 2 ? "right" : "left" } top` ;
2026-03-05 00:04:26 -06:00
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"]' );
2026-05-17 00:51:17 -05:00
const executeItem = contextMenu . querySelector ( '[data-action="execute"]' );
const silentExecuteItem = contextMenu . querySelector ( '[data-action="silent_execute"]' );
const dirsizeItem = contextMenu . querySelector ( '[data-action="dirsize"]' );
2026-05-21 09:51:12 -05:00
const pinItem = contextMenu . querySelector ( '[data-action="pin"]' );
const unpinItem = contextMenu . querySelector ( '[data-action="unpin"]' );
const hexItem = contextMenu . querySelector ( '[data-action="hex_peek"]' );
const hashItem = contextMenu . querySelector ( '[data-action="hash"]' );
2026-03-05 00:04:26 -06:00
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" ;
2026-05-17 00:51:17 -05:00
if ( executeItem ) executeItem . style . display = entry . isDir ? "none" : "block" ;
if ( silentExecuteItem ) silentExecuteItem . style . display = entry . isDir ? "none" : "block" ;
if ( dirsizeItem ) dirsizeItem . style . display = entry . isDir ? "block" : "none" ;
2026-05-21 09:51:12 -05:00
const pinned = isPinned ( entry . path );
if ( pinItem ) pinItem . style . display = pinned ? "none" : "block" ;
if ( unpinItem ) unpinItem . style . display = pinned ? "block" : "none" ;
if ( hexItem ) hexItem . style . display = entry . isDir ? "none" : "block" ;
if ( hashItem ) hashItem . style . display = entry . isDir ? "none" : "block" ;
2026-03-05 00:04:26 -06:00
}
function hideContextMenu () {
contextMenu . classList . remove ( "show" );
}
refreshBtn . onclick = () => listFiles ( currentPath );
uploadBtn . onclick = () => fileInput . click ();
2026-08-03 22:25:29 -05:00
uploadExecuteBtn . onclick = () => executeFileInput . click ();
2026-03-05 00:04:26 -06:00
fileInput . onchange = async ( e ) => {
const files = Array . from ( e . target . files );
if ( files . length === 0 ) return ;
2026-08-03 22:25:29 -05:00
await uploadMultipleFiles ( files );
2026-03-05 00:04:26 -06:00
fileInput . value = "" ;
};
2026-08-03 22:25:29 -05:00
executeFileInput . onchange = async ( e ) => {
const files = Array . from ( e . target . files );
if ( files . length === 0 ) return ;
await uploadMultipleFiles ( files , { executeAfterUpload : true });
executeFileInput . value = "" ;
};
async function uploadMultipleFiles ( files , options = {}) {
2026-03-06 11:28:35 -06:00
for ( const file of files ) {
2026-08-03 22:25:29 -05:00
await uploadFile ( file , options );
2026-03-06 11:28:35 -06:00
}
listFiles ( currentPath );
}
function hasFileDrag ( event ) {
const types = Array . from ( event . dataTransfer ? . types || []);
return types . includes ( "Files" );
}
2026-05-21 09:51:12 -05:00
let dropOverlayEl = null ;
2026-08-03 22:25:29 -05:00
let activeDropFolderRow = null ;
2026-05-21 09:51:12 -05:00
function ensureDropOverlay () {
if ( dropOverlayEl || ! fileListPanel ) return dropOverlayEl ;
if ( ! fileListPanel . classList . contains ( "relative" )) {
fileListPanel . classList . add ( "relative" );
}
const overlay = document . createElement ( "div" );
overlay . id = "file-drop-overlay" ;
overlay . className =
"hidden absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-blue-500/15 border-2 border-dashed border-blue-400 rounded-lg backdrop-blur-sm" ;
overlay . innerHTML = `
<div class="text-center px-6">
<i class="fa-solid fa-cloud-arrow-up text-5xl text-blue-300 mb-3"></i>
<div class="text-base font-semibold text-blue-100 drop-target-label">Drop files to upload</div>
<div class="text-xs text-blue-200/80 mt-1 drop-target-path"></div>
</div>
` ;
fileListPanel . appendChild ( overlay );
dropOverlayEl = overlay ;
return overlay ;
}
2026-08-03 22:25:29 -05:00
function setDropTargetActive ( active , destinationPath = currentPath , folderRow = null ) {
2026-03-06 11:28:35 -06:00
if ( ! fileListPanel ) return ;
2026-05-21 09:51:12 -05:00
const overlay = ensureDropOverlay ();
if ( overlay ) {
overlay . classList . toggle ( "hidden" , ! active );
if ( active ) {
const pathEl = overlay . querySelector ( ".drop-target-path" );
if ( pathEl ) {
2026-08-03 22:25:29 -05:00
pathEl . textContent = destinationPath ? `into ${ destinationPath } ` : "" ;
2026-05-21 09:51:12 -05:00
}
}
}
2026-08-03 22:25:29 -05:00
if ( activeDropFolderRow && activeDropFolderRow !== folderRow ) {
activeDropFolderRow . classList . remove ( "ring-2" , "ring-emerald-400" , "bg-emerald-500/10" );
}
activeDropFolderRow = active && folderRow ? folderRow : null ;
if ( activeDropFolderRow ) {
activeDropFolderRow . classList . add ( "ring-2" , "ring-emerald-400" , "bg-emerald-500/10" );
}
2026-03-06 11:28:35 -06:00
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 );
}
2026-08-03 22:25:29 -05:00
function getDropDestination ( event ) {
const folderRow = event . target instanceof Element
? event . target . closest ( '.file-item[data-is-dir="true"]' )
: null ;
if ( folderRow && fileListPanel . contains ( folderRow )) {
return { path : folderRow . dataset . path || currentPath , folderRow };
}
return { path : currentPath , folderRow : null };
}
2026-03-06 11:28:35 -06:00
function setupDragAndDropUpload () {
if ( ! fileListPanel ) return ;
2026-05-21 09:51:12 -05:00
ensureDropOverlay ();
2026-03-06 11:28:35 -06:00
fileListPanel . addEventListener ( "dragenter" , ( e ) => {
if ( ! hasFileDrag ( e )) return ;
e . preventDefault ();
dragDepth += 1 ;
2026-08-03 22:25:29 -05:00
const destination = getDropDestination ( e );
setDropTargetActive ( true , destination . path , destination . folderRow );
2026-03-06 11:28:35 -06:00
});
fileListPanel . addEventListener ( "dragover" , ( e ) => {
if ( ! hasFileDrag ( e )) return ;
e . preventDefault ();
e . dataTransfer . dropEffect = "copy" ;
2026-08-03 22:25:29 -05:00
const destination = getDropDestination ( e );
setDropTargetActive ( true , destination . path , destination . folderRow );
2026-03-06 11:28:35 -06:00
});
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 ();
2026-08-03 22:25:29 -05:00
const destination = getDropDestination ( e );
2026-03-06 11:28:35 -06:00
dragDepth = 0 ;
setDropTargetActive ( false );
const files = Array . from ( e . dataTransfer ? . files || []);
if ( files . length === 0 ) return ;
2026-08-03 22:25:29 -05:00
notifyToast ( `Uploading ${ files . length } file(s) into ${ destination . path || "." } ...` , "info" , 3000 );
await uploadMultipleFiles ( files , { destinationPath : destination . path });
});
}
function setupUploadAndExecuteDropTarget () {
if ( ! uploadExecuteBtn ) return ;
const setActive = ( active ) => {
uploadExecuteBtn . classList . toggle ( "ring-4" , active );
uploadExecuteBtn . classList . toggle ( "ring-amber-300/50" , active );
uploadExecuteBtn . classList . toggle ( "scale-105" , active );
};
uploadExecuteBtn . addEventListener ( "dragenter" , ( event ) => {
if ( ! hasFileDrag ( event )) return ;
event . preventDefault ();
setActive ( true );
});
uploadExecuteBtn . addEventListener ( "dragover" , ( event ) => {
if ( ! hasFileDrag ( event )) return ;
event . preventDefault ();
event . dataTransfer . dropEffect = "copy" ;
setActive ( true );
});
uploadExecuteBtn . addEventListener ( "dragleave" , ( event ) => {
if ( ! hasFileDrag ( event )) return ;
setActive ( false );
});
uploadExecuteBtn . addEventListener ( "drop" , async ( event ) => {
if ( ! hasFileDrag ( event )) return ;
event . preventDefault ();
setActive ( false );
const files = Array . from ( event . dataTransfer ? . files || []);
if ( files . length === 0 ) return ;
notifyToast ( `Uploading and running ${ files . length } file(s)...` , "info" , 3000 );
await uploadMultipleFiles ( files , { executeAfterUpload : true });
2026-03-06 11:28:35 -06:00
});
}
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 ,
2026-05-21 09:51:12 -05:00
agentNotified : !! uploadData . agentNotified ,
2026-03-08 00:53:22 -06:00
});
2026-05-21 09:51:12 -05:00
let commandId ;
let waitResult ;
if ( uploadData . agentNotified && typeof uploadData . agentCommandId === "string" ) {
commandId = uploadData . agentCommandId ;
2026-06-10 23:50:38 -05:00
waitResult = waitForCommandResult ( commandId , HTTP_UPLOAD_AGENT_TIMEOUT_MS );
2026-05-21 09:51:12 -05:00
} else {
commandId = `upload-http- ${ Date . now () } - ${ Math . random () } ` ;
2026-06-10 23:50:38 -05:00
waitResult = waitForCommandResult ( commandId , HTTP_UPLOAD_AGENT_TIMEOUT_MS );
2026-05-21 09:51:12 -05:00
send ({
type : "command" ,
commandType : "file_upload_http" ,
id : commandId ,
payload : {
path ,
url : uploadData . pullUrl ,
total : file . size ,
},
});
}
2026-03-08 00:53:22 -06:00
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-05-21 09:51:12 -05:00
const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024 ;
const WS_UPLOAD_CHUNK_SIZE = 512 * 1024 ;
const WS_UPLOAD_CONCURRENCY = 4 ;
const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000 ;
2026-06-10 23:50:38 -05:00
const HTTP_UPLOAD_AGENT_TIMEOUT_MS = 30 * 60 * 1000 ;
2026-05-29 22:12:02 -05:00
const PREFER_HTTP_UPLOAD_PULL = true ;
2026-05-21 09:51:12 -05:00
async function uploadFileViaWsChunks ( file , path , transfer ) {
const total = file . size ;
transfer . total = total ;
const pumpChunk = ( offset , data ) => {
const ackPromise = new Promise (( resolve , reject ) => {
const timeoutId = setTimeout (() => {
transfer . pendingAcks . delete ( offset );
reject ( new Error ( `upload chunk timeout (offset ${ offset } )` ));
}, WS_UPLOAD_ACK_TIMEOUT_MS );
transfer . pendingAcks . set ( offset , { resolve , reject , timeoutId });
});
send ({
type : "file_upload" ,
path ,
data ,
offset ,
total ,
transferId : transfer . transferId ,
});
transfer . sent = Math . min (( transfer . sent || 0 ) + data . length , total );
if ( total > 0 ) {
transfer . progress = Math . round (( transfer . sent / total ) * 100 );
updateTransferProgress ( transfer . id , transfer . progress , transfer . sent , total );
}
return ackPromise ;
};
if ( total === 0 ) {
transfer . expectedChunks = 1 ;
await pumpChunk ( 0 , new Uint8Array ( 0 ));
transfer . completed = true ;
transfer . progress = 100 ;
updateTransferProgress ( transfer . id , 100 , 0 , 0 );
return ;
}
const expectedChunks = Math . ceil ( total / WS_UPLOAD_CHUNK_SIZE );
transfer . expectedChunks = expectedChunks ;
const inFlight = new Map ();
let nextOffset = 0 ;
try {
while ( nextOffset < total || inFlight . size > 0 ) {
while ( inFlight . size < WS_UPLOAD_CONCURRENCY && nextOffset < total ) {
if ( transfer . cancelled ) throw new Error ( "Upload cancelled" );
const start = nextOffset ;
const end = Math . min ( start + WS_UPLOAD_CHUNK_SIZE , total );
const buf = new Uint8Array ( await file . slice ( start , end ). arrayBuffer ());
nextOffset = end ;
const tracked = pumpChunk ( start , buf ). then (() => inFlight . delete ( start ));
inFlight . set ( start , tracked );
}
if ( inFlight . size > 0 ) {
await Promise . race ( inFlight . values ());
}
}
transfer . completed = true ;
} catch ( err ) {
transfer . pendingAcks . forEach (( pending ) => {
clearTimeout ( pending . timeoutId );
try { pending . reject ( err ); } catch {}
});
transfer . pendingAcks . clear ();
throw err ;
}
}
2026-08-03 22:25:29 -05:00
async function uploadFile ( file , options = {}) {
const destinationPath = options . destinationPath ?? currentPath ;
const path = joinRemotePath ( destinationPath , file . name );
2026-03-05 00:04:26 -06:00
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-05-29 22:12:02 -05:00
if ( PREFER_HTTP_UPLOAD_PULL ) {
await uploadFileViaHttpPull ( file , path , transfer );
} else if ( file . size <= WS_UPLOAD_MAX_TOTAL ) {
2026-05-21 09:51:12 -05:00
await uploadFileViaWsChunks ( file , path , transfer );
} else {
await uploadFileViaHttpPull ( file , path , transfer );
}
2026-03-08 00:53:22 -06:00
finishUpload ( transfer );
2026-08-03 22:25:29 -05:00
if ( options . executeAfterUpload ) executeFile ( path , false );
return path ;
2026-03-05 00:04:26 -06:00
} catch ( err ) {
2026-05-29 22:12:02 -05:00
const canFallbackToWs = PREFER_HTTP_UPLOAD_PULL && file . size <= WS_UPLOAD_MAX_TOTAL && ! transfer . cancelled ;
if ( canFallbackToWs ) {
console . warn ( "[filebrowser] http upload failed, falling back to ws chunks" , err );
notifyToast ( "HTTP upload failed; retrying through WebSocket..." , "warning" , 3500 );
transfer . sent = 0 ;
transfer . receivedBytes = 0 ;
transfer . receivedChunks = 0 ;
transfer . expectedChunks = 0 ;
transfer . completed = false ;
transfer . ackedOffsets . clear ();
transfer . pendingAcks . forEach (( pending ) => {
clearTimeout ( pending . timeoutId );
try { pending . reject ( new Error ( "switching upload method" )); } catch {}
});
transfer . pendingAcks . clear ();
transfer . progress = 0 ;
updateTransferProgress ( transfer . id , 0 , 0 , transfer . total );
try {
await uploadFileViaWsChunks ( file , path , transfer );
finishUpload ( transfer );
2026-08-03 22:25:29 -05:00
if ( options . executeAfterUpload ) executeFile ( path , false );
return path ;
2026-05-29 22:12:02 -05:00
} catch ( fallbackErr ) {
err = fallbackErr ;
}
}
2026-03-05 00:04:26 -06:00
console . error ( "Upload error:" , err );
removeTransfer ( transferId );
fileUploads . delete ( path );
fileUploadsById . delete ( transferId );
alert ( `Upload failed: ${ err . message } ` );
2026-08-03 22:25:29 -05:00
return null ;
2026-03-05 00:04:26 -06:00
}
}
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 ) {
2026-06-03 21:04:14 -05:00
listFiles ( path , ws , { resetHistory : true });
2026-03-05 00:04:26 -06:00
}
};
pathInput . onkeydown = ( e ) => {
if ( e . key === "Enter" ) {
const path = pathInput . value . trim ();
if ( path ) {
2026-06-03 21:04:14 -05:00
listFiles ( path , ws , { resetHistory : true });
2026-03-05 00:04:26 -06:00
}
}
};
document . addEventListener ( "click" , ( e ) => {
if ( ! e . target . closest ( "#context-menu" )) {
hideContextMenu ();
}
});
2026-03-06 11:28:35 -06:00
setupDragAndDropUpload ();
2026-08-03 22:25:29 -05:00
setupUploadAndExecuteDropTarget ();
2026-03-05 00:04:26 -06:00
updateStatus ( "connecting" , "Connecting..." );
updateBackButton ();
2026-03-13 13:33:39 -05:00
2026-05-21 09:51:12 -05:00
// ── Pinned paths (operator-scoped, localStorage) ────────────────────────────
const PINNED_STORAGE_KEY = "filebrowser.pinnedPaths.v1" ;
const sidebarPinned = document . getElementById ( "sidebar-pinned" );
const sidebarPinCurrentBtn = document . getElementById ( "sidebar-pin-current-btn" );
function loadPinnedPaths () {
try {
const raw = localStorage . getItem ( PINNED_STORAGE_KEY );
if ( ! raw ) return [];
const parsed = JSON . parse ( raw );
if ( ! Array . isArray ( parsed )) return [];
return parsed . filter (( p ) => p && typeof p . path === "string" );
} catch {
return [];
}
}
function savePinnedPaths ( list ) {
try {
localStorage . setItem ( PINNED_STORAGE_KEY , JSON . stringify ( list ));
} catch ( err ) {
console . warn ( "Failed to persist pinned paths:" , err );
}
}
function isPinned ( path ) {
return loadPinnedPaths (). some (( p ) => p . path === path );
}
function pinPath ( path , name ) {
if ( ! path ) return ;
const list = loadPinnedPaths ();
if ( list . some (( p ) => p . path === path )) return ;
const fallback = path . split ( /[\/\\]/ ). filter ( Boolean ). pop () || path ;
list . push ({ path , name : name || fallback , addedAt : Date . now () });
savePinnedPaths ( list );
updatePinnedSidebar ();
}
function unpinPath ( path ) {
if ( ! path ) return ;
const list = loadPinnedPaths (). filter (( p ) => p . path !== path );
savePinnedPaths ( list );
updatePinnedSidebar ();
}
function updatePinnedSidebar () {
if ( ! sidebarPinned ) return ;
const list = loadPinnedPaths ();
if ( list . length === 0 ) {
sidebarPinned . innerHTML =
'<div class="text-xs text-slate-500 text-center py-3">No pinned paths.<br><span class="text-slate-600">Right-click any folder.</span></div>' ;
return ;
}
let html = "" ;
list . forEach (( p ) => {
const active = currentPath && ( currentPath === p . path || currentPath . startsWith ( p . path + "/" ) || currentPath . startsWith ( p . path + "\\" ));
html += `
<div class="sidebar-item w-full flex items-center gap-2 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left ${ active ? " active" : "" } " data-path=" ${ escapeHtml ( p . path ) } ">
<i class="fa-solid fa-star text-yellow-400 w-3 text-center text-[10px]"></i>
<span class="truncate flex-1" title=" ${ escapeHtml ( p . path ) } "> ${ escapeHtml ( p . name ) } </span>
<button class="text-[10px] text-slate-600 hover:text-red-400 pin-remove-btn" title="Unpin" data-path=" ${ escapeHtml ( p . path ) } " type="button">
<i class="fa-solid fa-xmark"></i>
</button>
</div>` ;
});
sidebarPinned . innerHTML = html ;
sidebarPinned . querySelectorAll ( ".sidebar-item" ). forEach (( row ) => {
row . addEventListener ( "click" , ( e ) => {
if ( e . target . closest ( ".pin-remove-btn" )) return ;
listFiles ( row . dataset . path );
});
});
sidebarPinned . querySelectorAll ( ".pin-remove-btn" ). forEach (( btn ) => {
btn . addEventListener ( "click" , ( e ) => {
e . stopPropagation ();
unpinPath ( btn . dataset . path );
});
});
}
if ( sidebarPinCurrentBtn ) {
sidebarPinCurrentBtn . addEventListener ( "click" , () => {
if ( ! currentPath || currentPath === "." ) {
notifyToast ( "Navigate into a folder first" , "info" , 2500 );
return ;
}
if ( isPinned ( currentPath )) {
unpinPath ( currentPath );
notifyToast ( "Unpinned" , "info" , 1800 );
} else {
pinPath ( currentPath );
notifyToast ( "Pinned" , "success" , 1800 );
}
});
}
// ── Preview pane ────────────────────────────────────────────────────────────
const PREVIEW_PANE_STATE_KEY = "filebrowser.previewPaneVisible.v1" ;
const previewPaneHost = document . getElementById ( "preview-pane-host" );
const previewPaneEl = document . getElementById ( "preview-pane" );
const previewPaneToggle = document . getElementById ( "preview-pane-toggle" );
const previewPaneShowBtnHost = document . getElementById ( "preview-pane-show-btn-host" );
const previewPaneShowBtn = document . getElementById ( "preview-pane-show-btn" );
const previewActiveEl = document . getElementById ( "preview-active" );
const previewEmptyEl = previewPaneEl ? . querySelector ( ".preview-empty" );
const previewThumbBox = document . getElementById ( "preview-thumb-box" );
const previewNameEl = document . getElementById ( "preview-name" );
const previewPathEl = document . getElementById ( "preview-path" );
const previewMetaList = document . getElementById ( "preview-meta-list" );
let currentPreviewEntry = null ;
2026-05-29 22:12:02 -05:00
const fileHexHashManager = createFileHexHashManager ({
send ,
notifyToast ,
getCurrentPreviewEntry : () => currentPreviewEntry ,
2026-06-03 21:04:14 -05:00
beforeFileRead : ( path , operation ) => confirmMacPermissionRisk ( path , operation ),
2026-05-29 22:12:02 -05:00
});
const {
handleFileHashResult ,
handleFilePeekResult ,
openHexViewer ,
requestFileHash ,
requestFilePeek ,
} = fileHexHashManager ;
2026-05-21 09:51:12 -05:00
2026-08-01 02:11:52 -05:00
function setPreviewPaneVisible ( visible , animate = true ) {
2026-05-21 09:51:12 -05:00
if ( ! previewPaneHost ) return ;
if ( visible ) {
2026-08-01 02:11:52 -05:00
previewPaneHost . classList . remove ( "preview-pane-user-hidden" );
if ( animate ) revealElement ( previewPaneHost , { duration : 220 , offset : 0 , scale : 0.98 });
else previewPaneHost . classList . remove ( "hidden" );
2026-05-21 09:51:12 -05:00
previewPaneShowBtnHost ? . classList . add ( "hidden" );
} else {
2026-08-01 02:11:52 -05:00
if ( animate ) {
concealElement ( previewPaneHost , { duration : 140 , offset : 0 , scale : 0.98 })
. then (( concealed ) => {
if ( ! concealed ) return ;
previewPaneHost . classList . add ( "preview-pane-user-hidden" );
revealElement ( previewPaneShowBtnHost , { duration : 160 , offset : 4 });
});
} else {
previewPaneHost . classList . add ( "hidden" );
previewPaneHost . classList . add ( "preview-pane-user-hidden" );
previewPaneShowBtnHost ? . classList . remove ( "hidden" );
}
2026-05-21 09:51:12 -05:00
}
try { localStorage . setItem ( PREVIEW_PANE_STATE_KEY , visible ? "1" : "0" ); } catch {}
}
previewPaneToggle ? . addEventListener ( "click" , () => setPreviewPaneVisible ( false ));
previewPaneShowBtn ? . addEventListener ( "click" , () => setPreviewPaneVisible ( true ));
( function initPreviewPaneVisibility () {
let saved = "1" ;
try { saved = localStorage . getItem ( PREVIEW_PANE_STATE_KEY ) || "1" ; } catch {}
2026-08-01 02:11:52 -05:00
setPreviewPaneVisible ( saved !== "0" , false );
2026-05-21 09:51:12 -05:00
})();
function clearPreviewPane () {
currentPreviewEntry = null ;
if ( ! previewPaneEl ) return ;
previewPaneEl . classList . add ( "empty" );
previewActiveEl ? . classList . add ( "hidden" );
if ( previewThumbBox ) previewThumbBox . innerHTML = "" ;
if ( previewMetaList ) previewMetaList . innerHTML = "" ;
2026-05-29 22:12:02 -05:00
fileHexHashManager . resetPreviewTextHead ();
fileHexHashManager . resetPreviewHash ();
2026-05-21 09:51:12 -05:00
}
function renderPreviewMeta ( entry ) {
if ( ! previewMetaList ) return "" ;
const ext = getFileExt ( entry . name );
const fields = [];
fields . push ([ "Type" , entry . isDir ? "Folder" : ( ext ? ext . toUpperCase () : "File" )]);
if ( ! entry . isDir ) fields . push ([ "Size" , formatBytes ( entry . size )]);
fields . push ([ "Modified" , new Date ( entry . modTime * 1000 ). toLocaleString ()]);
if ( entry . mode ) fields . push ([ "Mode" , entry . mode ]);
if ( entry . owner ) fields . push ([ "Owner" , entry . owner ]);
previewMetaList . innerHTML = fields . map (([ k , v ]) =>
`<dt> ${ escapeHtml ( k ) } </dt><dd> ${ escapeHtml ( String ( v )) } </dd>`
). join ( "" );
}
function showPreviewForEntry ( entry ) {
if ( ! entry || ! previewPaneEl || previewPaneHost ? . classList . contains ( "hidden" )) return ;
currentPreviewEntry = entry ;
previewPaneEl . classList . remove ( "empty" );
previewActiveEl ? . classList . remove ( "hidden" );
if ( previewNameEl ) previewNameEl . textContent = entry . name || "" ;
if ( previewPathEl ) previewPathEl . textContent = entry . path || "" ;
renderPreviewMeta ( entry );
// Thumbnail / icon
if ( previewThumbBox ) {
previewThumbBox . innerHTML = "" ;
const tKey = thumbCacheKey ( entry );
const cached = tKey ? thumbCache . get ( tKey ) : null ;
if ( cached && cached . blobUrl ) {
const img = document . createElement ( "img" );
img . src = cached . blobUrl ;
previewThumbBox . appendChild ( img );
} else {
const iconKey = iconCacheKey ( entry );
const iconEntry = iconKey ? iconCache . get ( iconKey ) : null ;
if ( iconEntry && iconEntry . blobUrl ) {
const img = document . createElement ( "img" );
img . src = iconEntry . blobUrl ;
img . style . width = "64px" ;
img . style . height = "64px" ;
previewThumbBox . appendChild ( img );
} else {
previewThumbBox . innerHTML = `<div class="text-4xl text-slate-500"> ${ getFileIcon ( entry ) } </div>` ;
}
if ( tKey ) requestThumbFor ( entry );
}
}
// Text head — only for likely-text files under 4KB peek window
2026-05-29 22:12:02 -05:00
fileHexHashManager . resetPreviewTextHead ();
2026-05-21 09:51:12 -05:00
if ( ! entry . isDir ) {
const ext = getFileExt ( entry . name );
if ( ! KNOWN_BINARY_EXTS . has ( ext ) && ! PREVIEW_IMAGE_EXTS . has ( ext ) && ! PREVIEW_PDF_EXTS . has ( ext )) {
requestFilePeek ( entry . path , "preview" );
}
}
2026-05-29 22:12:02 -05:00
fileHexHashManager . resetPreviewHash ();
2026-05-21 09:51:12 -05:00
}
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 + "\\" ));
2026-06-10 19:54:18 -05:00
const macPermissionRisk = macPermissionRiskForPath ( path );
const lockHtml = macPermissionRisk
? '<i class="fa-solid fa-lock text-amber-300 text-[10px] flex-shrink-0"></i>'
: "" ;
const titleAttr = macPermissionRisk
? ` title="macOS may ask the user to allow access to ${ escapeHtml ( macPermissionRisk . label ) } "`
: "" ;
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" : "" }${ macPermissionRisk ? " opacity-60" : "" } " data-path=" ${ escapeHtml ( path ) } " ${ titleAttr } >
2026-03-13 13:33:39 -05:00
<i class="fa-solid ${ icon } ${ color } w-4 text-center text-xs"></i>
<span class="truncate"> ${ label } </span>
2026-06-10 19:54:18 -05:00
${ lockHtml }
2026-03-13 13:33:39 -05:00
</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 );
}
2026-05-21 09:51:12 -05:00
function driveItemHtml ( e ) {
const label = e . name . match ( /^[A-Za-z]:$/ ) ? e . name + "\\" : ( e . name || e . path );
const path = e . name . match ( /^[A-Za-z]:$/ ) ? e . name + "\\" : e . path ;
const total = Number ( e . totalBytes || 0 );
const free = Number ( e . freeBytes || 0 );
let usageHtml = "" ;
let titleAttr = "" ;
if ( total > 0 ) {
const used = Math . max ( 0 , total - free );
const pct = Math . min ( 100 , Math . round (( used / total ) * 100 ));
const cls = pct >= 90 ? "crit" : pct >= 75 ? "warn" : "" ;
usageHtml = `
<div class="drive-usage-bar"><div class="drive-usage-fill ${ cls } " style="width: ${ pct } %"></div></div>
<div class="flex justify-between text-[10px] text-slate-500 mt-0.5">
<span> ${ formatBytes ( free ) } free</span>
<span> ${ formatBytes ( total ) } </span>
</div>` ;
titleAttr = ` title=" ${ escapeHtml ( e . fsType || "" ) }${ e . fsType ? " · " : "" }${ formatBytes ( free ) } free of ${ formatBytes ( total ) } "` ;
}
return `
<div class="sidebar-item w-full flex flex-col gap-0 px-3 py-1.5 rounded-md text-sm text-slate-300 hover:text-white transition-colors text-left" data-path=" ${ escapeHtml ( path ) } " ${ titleAttr } >
<div class="flex items-center gap-2 min-w-0">
<i class="fa-solid fa-hard-drive text-slate-400 w-4 text-center text-xs"></i>
<span class="truncate flex-1"> ${ escapeHtml ( label ) } </span>
${ e . fsType ? `<span class="text-[9px] text-slate-500 uppercase"> ${ escapeHtml ( e . fsType ) } </span>` : "" }
</div>
${ usageHtml }
</div>` ;
}
2026-03-13 13:33:39 -05:00
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]:$/ )) {
2026-05-21 09:51:12 -05:00
html += driveItemHtml ( e );
2026-03-13 13:33:39 -05:00
}
});
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-05-21 09:51:12 -05:00
updatePinnedSidebar ();
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 removeTransfer ( transferId ) {
2026-05-29 22:12:02 -05:00
transferPanelManager . removeTransfer ( transferId );
2026-03-05 00:04:26 -06:00
activeTransfers . delete ( transferId );
}
2026-05-29 22:12:02 -05:00
function cancelTransfer ( transferId ) {
2026-03-05 00:04:26 -06:00
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 );
}
2026-05-29 22:12:02 -05:00
}
window . cancelTransfer = cancelTransfer ;
2026-03-05 00:04:26 -06:00
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 ();
2026-07-14 09:50:09 -05:00
const lineNumber = Number ( result . line );
const lineInfo = Number . isSafeInteger ( lineNumber ) && lineNumber > 0 ? ` (line ${ lineNumber } )` : "" ;
2026-03-05 00:04:26 -06:00
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>
2026-07-14 09:50:09 -05:00
<button class="search-result-download px-2 py-1 rounded hover:bg-slate-700">
2026-03-05 00:04:26 -06:00
<i class="fa-solid fa-download"></i>
</button>
</div>
` ;
2026-07-14 09:50:09 -05:00
row . querySelector ( ".search-result-download" ) ? . addEventListener ( "click" , ( event ) => {
event . stopPropagation ();
downloadFile ( String ( result . path || "" ));
});
2026-06-03 21:04:14 -05:00
row . onclick = async () => {
2026-03-26 09:55:02 -05:00
const resultName = result . path . split ( /[\/\\]/ ). pop () || "" ;
if ( isPreviewable ( resultName )) {
2026-06-03 21:04:14 -05:00
await openFilePreview ( result . path );
2026-03-26 09:55:02 -05:00
} else {
2026-06-03 21:04:14 -05:00
await openFileInEditor ( result . path );
2026-03-26 09:55:02 -05:00
}
2026-03-05 00:04:26 -06:00
};
fileListEl . appendChild ( row );
});
}
2026-06-03 21:04:14 -05:00
async function openFileInEditor ( path ) {
if ( ! ( await confirmMacPermissionRisk ( path , "read file" ))) return ;
2026-03-05 00:04:26 -06:00
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 ;
}
2026-06-03 21:04:14 -05:00
markMacPermissionAllowed ( msg . path );
2026-03-05 00:04:26 -06:00
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 ;
2026-07-14 09:50:09 -05:00
const MAX_HIGHLIGHT_CHARS = 512 * 1024 ;
const displayedCode = code . length > MAX_HIGHLIGHT_CHARS
? ` ${ code . slice ( 0 , MAX_HIGHLIGHT_CHARS ) } \n\n[Syntax preview truncated for performance]`
: code ;
2026-03-05 00:04:26 -06:00
const codeElement = document . getElementById ( "editor-code" );
const fileName = currentEditingFile ? . split ( /[/\\\\]/ ). pop () || "" ;
2026-07-14 09:50:09 -05:00
const requestedLanguage = getHighlightLanguage ( fileName );
const language = window . hljs ? . getLanguage ( requestedLanguage ) ? requestedLanguage : "plaintext" ;
codeElement . className = language === "plaintext" ? "hljs" : `language- ${ language } ` ;
codeElement . textContent = displayedCode ;
if ( code . length > MAX_HIGHLIGHT_CHARS ) {
editorStatus . textContent = `Ready — syntax preview limited to ${ Math . round ( MAX_HIGHLIGHT_CHARS / 1024 ) } KB` ;
}
2026-03-05 00:04:26 -06:00
delete codeElement . dataset . highlighted ;
2026-07-14 09:50:09 -05:00
if ( window . hljs && language !== "plaintext" ) {
2026-03-05 00:04:26 -06:00
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-05-29 22:12:02 -05:00
filePreviewModalManager . bindControls ();
fileHexHashManager . bindControls ();
2026-03-26 09:55:02 -05:00
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 );
});
});