const btnConnections = $('#connections');
const btnNavBack = $('#navBack');
const btnNavForward = $('#navForward');
const inputNavPath = $('#inputNavPath');
const btnGo = $('#pathGo');
const btnPathPopup = $('#pathPopup');
const elBelowNavBar = $('#belowNavBar');
const btnDirMenu = $('#dirMenu');
const btnDeselectAll = $('#deselectAll');
const btnUpload = $('#upload');
const btnDirCreate = $('#dirCreate');
const btnFileCreate = $('#fileCreate');
const btnSelectionCut = $('#fileCut');
const btnSelectionCopy = $('#fileCopy');
const btnSelectionPaste = $('#filePaste');
const btnRename = $('#fileRename');
const btnSelectionMoveTo = $('#fileMoveTo');
const btnSelectionCopyTo = $('#fileCopyTo');
const btnSelectionDelete = $('#fileDelete');
const btnSelectionPerms = $('#filePerms');
const btnDownload = $('#fileDownload');
const btnShare = $('#fileShare');
const btnDirSort = $('#dirSort');
const btnDirView = $('#dirView');
const btnDirSelection = $('#dirSelection');
const elFileColHeadings = $('#fileColHeadings');
const elFiles = $('#files');
const inputSearch = $('#inputNavSearch');
const btnSearchCancel = $('#navSearchCancel');
const btnSearchGo = $('#navSearchGo');
const forceTileViewWidth = 720;
/** An array of paths in the back history */
let backPaths = [];
/** An array of paths in the forward history */
let forwardPaths = [];
/** An array of paths cut or copied to the clipboard */
let selectionClipboard = [];
/** True the clipboard paste mode is cut */
let isClipboardCut = false;
/**
* The current file sort order
* @type {'name'|'size'|'date'}
*/
let sortType = window.localStorage.getItem('sortType') || 'name';
/**
* True of the file sort order is to be reversed
* @type {boolean}
*/
let sortDesc = window.localStorage.getItem('sortDesc');
sortDesc = (sortDesc == null) ? false : (sortDesc === 'true');
/**
* True if hidden files should be visible
* @type {boolean}
*/
let showHidden = window.localStorage.getItem('showHidden');
showHidden = (showHidden == null) ? true : (showHidden === 'true');
/**
* The current file view mode
* @type {'list'|'tile'}
*/
let viewMode = window.localStorage.getItem('viewMode') || 'list';
/** True if an upload is in progress */
let isUploading = false;
/** The index of the most recently selected file, or -1 if no files are selected */
let lastSelectedIndex = -1;
/** True of a directory load is in progress
* and currently visible files shouldn't be accessed */
let fileAccessLock = false;
/**
* True of hidden files should be visible
* @type {boolean}
*/
let showDownloadPopup = window.localStorage.getItem('showDownloadPopup');
showDownloadPopup = (showDownloadPopup == null) ? true : (showDownloadPopup === 'true');
// Variables for file name navigation
let keypressString = '';
let keypressClearTimeout;
// Variables for recursive searching
let searchWebsocket;
let isSearching = false;
/**
* Saves the current state of the `connections` object to LocalStorage.
*/
const saveConnections = () => {
window.localStorage.setItem('connections', JSON.stringify(connections));
}
/**
* Returns the `connections` object as a sorted array, and each value has an added `id` property.
*/
const getSortedConnectionsArray = () => {
const connectionValues = [];
for (const id of Object.keys(connections)) {
const connection = connections[id];
connectionValues.push({
id: id,
...connection
});
}
connectionValues.sort((a, b) => {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
if (aName < bName) return -1;
if (aName > bName) return 1;
return 0;
});
return connectionValues;
}
/**
* Prompts the user to export a connection.
* @param {number} id The connection ID
*/
const exportConnectionDialog = async (id) => {
const connection = connections[id];
const exportBody = document.createElement('div');
exportBody.classList = 'col gap-10';
exportBody.style.maxWidth = '400px';
exportBody.innerHTML = /*html*/`
Without credentials
With private key or password
Only share exports with credentials with people you trust! These credentials grant access to not only your server's files, but oftentimes an interactive terminal (SSH).
`;
new PopupBuilder()
.setTitle(`Export ${connection.name}`)
.addBody(exportBody)
.addAction(action => action
.setLabel('Export')
.setIsPrimary(true)
.setClickHandler(() => {
const includeCredentials = $('input[name="exportCredentials"]:checked', exportBody).value == 'include';
const data = {
name: connection.name,
host: connection.host,
port: connection.port,
username: connection.username,
path: connection.path
};
if (includeCredentials) {
if (connection.key)
data.key = connection.key;
if (connection.password)
data.password = connection.password;
}
const blob = new Blob([
JSON.stringify(data)
], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${connection.name.replace(/[^a-zA-Z-_\. ]/g, '').trim() || 'connection'}.json`;
a.click();
URL.revokeObjectURL(url);
}))
.addAction(action => action.setLabel('Cancel'))
.show();
}
/**
* Opens a dialog popup to manage stored connection information.
*/
const connectionManagerDialog = () => {
const popup = new PopupBuilder();
const el = document.createElement('div');
el.id = 'connectionManager';
el.classList = 'col gap-15';
const connectionValues = getSortedConnectionsArray();
for (const connection of connectionValues) {
const entry = document.createElement('div');
entry.classList = 'entry row gap-10 align-center';
entry.innerHTML = /*html*/`
cloud
${connection.name}
${connection.username}@${connection.host}:${connection.port}
${connection.path}
`;
$('.btn.menu', entry).addEventListener('click', () => {
new ContextMenuBuilder()
.addItem(option => option
.setLabel('Edit...')
.setIcon('edit')
.setClickHandler(async() => {
popup.hide();
await editConnectionDialog(connection.id);
connectionManagerDialog();
}))
.addItem(option => option
.setLabel('Export...')
.setIcon('download')
.setClickHandler(async() => {
exportConnectionDialog(connection.id);
}))
.addSeparator()
.addItem(option => option
.setLabel('Delete')
.setIcon('delete')
.setIsDanger(true)
.setClickHandler(async() => {
delete connections[connection.id];
saveConnections();
entry.remove();
}))
.showAtCursor();
});
$('.btn.connect', entry).addEventListener('click', () => {
popup.hide();
setActiveConnection(connection.id);
});
el.appendChild(entry);
}
const elButtons = document.createElement('div');
elButtons.classList = 'row gap-10 flex-wrap';
const btnAdd = document.createElement('button');
btnAdd.classList = 'btn success small';
btnAdd.innerHTML = /*html*/`
add
New connection...
`;
btnAdd.addEventListener('click', async() => {
popup.hide();
await addNewConnectionDialog();
connectionManagerDialog();
});
elButtons.appendChild(btnAdd);
const btnImport = document.createElement('button');
btnImport.classList = 'btn secondary small';
btnImport.innerHTML = /*html*/`
cloud_upload
Import...
`;
btnImport.addEventListener('click', async() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.addEventListener('change', async() => {
const file = input.files[0];
if (!file) return;
popup.hide();
const reader = new FileReader();
reader.addEventListener('load', async() => {
try {
const data = JSON.parse(reader.result);
const id = Date.now();
connections[id] = data;
saveConnections();
if (!data.key && !data.password) {
return editConnectionDialog(id);
}
} catch (error) {
console.error(error);
}
connectionManagerDialog();
});
reader.readAsText(file);
});
input.click();
});
elButtons.appendChild(btnImport);
el.appendChild(elButtons);
popup
.setTitle('Connections')
.addBody(el)
.addAction(action => action
.setIsPrimary(true)
.setLabel('Done')
.setClickHandler(() => {
saveConnections();
}));
popup.show();
}
/**
* Opens a dialog to edit an existing connection by its ID.
* @param {number} id The connection ID
* @returns {Promise} Resolves with the ID passed in
*/
const editConnectionDialog = async (id) => new Promise(resolve => {
const connection = connections[id];
if (!connection) throw new Error(`Connection with ID ${id} not found!`);
const securityNote = thing => `Your ${thing} is saved in this browser and only persists on the server during and for a few minutes after each request.`;
const el = document.createElement('div');
el.classList = 'col gap-10';
el.innerHTML = /*html*/`
Friendly name
Username
${securityNote('password')}
Your private key is typically located under C:\\Users\\you\\.ssh on Windows, or /home/you/.ssh on Unix. It's not the ".pub" file! The server has to be configured to accept your public key for your private one to work.
${securityNote('private key')}
Starting path
`;
const inputName = $('#inputName', el);
const inputHost = $('#inputHost', el);
const inputPort = $('#inputPort', el);
const inputUsername = $('#inputUsername', el);
const authTypePassword = $('#authTypePassword', el);
const authTypeKey = $('#authTypeKey', el);
const inputPassword = $('#inputPassword', el);
const elPasswordCont = $('#passwordCont', el);
const elKeyCont = $('#keyCont', el);
const inputKey = $('#inputKey', el);
const btnLoadKey = $('#loadKeyFromFile', el);
const inputPath = $('#inputPath', el);
authTypePassword.addEventListener('change', () => {
elPasswordCont.style.display = '';
elKeyCont.style.display = 'none';
});
authTypeKey.addEventListener('change', () => {
elPasswordCont.style.display = 'none';
elKeyCont.style.display = '';
});
if (!connection.password && !connection.key) {
authTypeKey.checked = true;
authTypeKey.dispatchEvent(new Event('change'));
} else if (!connection.password) {
authTypeKey.checked = true;
authTypeKey.dispatchEvent(new Event('change'));
} else {
authTypePassword.checked = true;
authTypePassword.dispatchEvent(new Event('change'));
}
btnLoadKey.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.addEventListener('change', () => {
const file = input.files[0];
if (file.size > 1024) return;
const reader = new FileReader();
reader.addEventListener('load', () => {
inputKey.value = reader.result;
});
reader.readAsText(file);
});
input.click();
});
const popup = new PopupBuilder()
.setTitle('Edit connection')
.addBody(el);
popup.addAction(action => action
.setIsPrimary(true)
.setLabel('Save')
.setClickHandler(() => {
connection.host = inputHost.value;
connection.port = inputPort.value || 22;
connection.username = inputUsername.value;
connection.name = inputName.value || `${connection.username}@${connection.host}`;
if (authTypePassword.checked) {
connection.password = inputPassword.value;
delete connection.key;
} else {
connection.key = inputKey.value;
delete connection.password;
}
connection.path = inputPath.value;
saveConnections();
}));
popup.addAction(action => action.setLabel('Cancel'));
popup.show();
popup.setOnHide(() => resolve(id));
});
/**
* Adds a new connection with basic placeholder data and runs `editConnectionDialog()` on it.
*/
const addNewConnectionDialog = async() => {
const id = Date.now();
connections[id] = {
name: 'New Connection',
host: '',
port: 22,
username: '',
key: '',
password: '',
path: '/'
};
await editConnectionDialog(id);
if (!connections[id].host || !connections[id].username) {
delete connections[id];
}
}
/**
* Sets the active connection to the one with the specified ID.
* @param {number} id The connection ID
* @param {string} path An initial directory path to override the saved one
*/
const setActiveConnection = (id, path) => {
if (!connections[id]) {
throw new Error(`Connection with ID ${id} not found!`);
}
backPaths = [];
forwardPaths = [];
activeConnection = JSON.parse(JSON.stringify(connections[id]));
activeConnectionId = id;
selectionClipboard = [];
changePath(path, false);
}
/**
* Fetches connection details from the server for a given connection ID.
* @param {string} connectionId The connection ID
* @returns {Promise} The connection details or null if failed
*/
const fetchConnectionDetails = async (connectionId) => {
try {
const response = await fetch(`/api/connect/${connectionId}`);
const data = await response.json();
if (!data.success) {
setStatus(`Error: ${data.error}`, true);
return null;
}
return data.connection;
} catch (error) {
setStatus(`Error fetching connection details: ${error.message}`, true);
return null;
}
};
/**
* Changes the path and loads the directory or file.
* @param {string} path The target path
* @param {boolean} pushState If `true`, update the back/forward history
*/
const changePath = async(path, pushState = true) => {
loadStartTime = Date.now();
if (!activeConnection) return;
// Lock file selection to prevent double-clicking during load
fileAccessLock = true;
// Use the current path if none is specified
path = path || activeConnection.path;
// Disable nav buttons during load
btnNavBack.disabled = true;
btnNavForward.disabled = true;
btnGo.disabled = true;
// Stat the path to make sure it exists
setStatus(`Checking path...`);
const dataStats = await api.get('files/stat', { path: path });
// If there was an error
if (dataStats.error) {
setStatus(`Error: ${dataStats.error}`, true);
// Otherwise...
} else {
// Get extension info
const info = getFileExtInfo(dataStats.path.split('/').pop(), dataStats.stats.size);
// If the path is a file
if (dataStats.stats.isFile) {
// If the file is viewable, open the file viewer
if (info.isViewable) {
openFileViewer(dataStats.path);
} else {
await downloadFile(dataStats.path);
}
// Update the path bar
inputNavPath.value = activeConnection.path;
// If the path is a directory
} else if (dataStats.stats.isDirectory) {
// Update the path bar
inputNavPath.value = dataStats.path;
// If the path has changed, push the old path to the back history
if (pushState && activeConnection.path != path)
backPaths.push(activeConnection.path);
// Update the stored current path
activeConnection.path = dataStats.path;
// Update display
document.title = `${activeConnection.name} - ${activeConnection.path}`;
window.history.replaceState(null, null, `?con=${activeConnectionId}&path=${encodeURIComponent(activeConnection.path)}`);
// Load the directory
await loadDirectory(dataStats.path);
// Otherwise, show an error
} else {
setStatus(`Error: Path is not a file or directory`, true);
}
}
// Re-enable nav buttons accordingly
btnNavBack.disabled = (backPaths.length == 0);
btnNavForward.disabled = (forwardPaths.length == 0);
btnGo.disabled = false;
// Unlock file selection
fileAccessLock = false;
}
/**
* Loads a directory and populates the file list.
* @param {string} path The directory path
*/
const loadDirectory = async path => {
// Hide the file view
elFiles.style.transition = 'none';
elFiles.style.pointerEvents = 'none';
requestAnimationFrame(() => {
elFiles.style.opacity = 0;
});
// Remove all existing file elements
elFiles.innerHTML = `
Folders
Files
`;
const elFilesFolders = $('.folders', elFiles);
const elFilesFiles = $('.files', elFiles);
lastSelectedIndex = -1;
// Disable directory controls
updateDirControls();
btnUpload.disabled = true;
btnDirCreate.disabled = true;
btnFileCreate.disabled = true;
btnDownload.disabled = true;
btnShare.disabled = true;
btnDirSort.disabled = true;
inputSearch.value = '';
isSearching = false;
try { searchWebsocket.close(); } catch (error) {}
// Get the directory listing
setStatus(`Loading directory...`);
const data = await api.get('directories/list', { path: path });
// If an error occurred, update the status bar
// then set the file list to an empty array
if (data.error) {
setStatus(`Error: ${data.error}`, true);
data.list = [];
}
// Add the ".." directory
const list = [{
name: '..',
type: 'd',
longname: '-'
}, ...data.list];
// Loop through the list and create file elements
for (const file of list) {
const elFile = getFileEntryElement(file, path);
// Add the file element to the file list
if (file.type == 'd')
elFilesFolders.appendChild(elFile);
else
elFilesFiles.appendChild(elFile);
}
// Sort the file list
sortFiles();
// Show the file view
elFiles.style.transition = '0.15s var(--bezier)';
requestAnimationFrame(() => {
elFiles.style.opacity = 1;
setTimeout(() => {
elFiles.style.pointerEvents = '';
elFiles.style.transition = 'none';
}, 200);
});
// Re-enable directory controls accordingly
if (!data.error) {
btnUpload.disabled = false;
btnDirCreate.disabled = false;
btnFileCreate.disabled = false;
btnDownload.disabled = false;
btnShare.disabled = false;
btnDirSort.disabled = false;
inputSearch.placeholder = `Search within ${path.split('/').pop() || '/'}...`;
updateDirControls();
setStatus(`Loaded directory with ${list.length} items in ${Date.now()-loadStartTime}ms`);
}
}
const searchDirectory = async(path, query) => {
let startTime = Date.now();
isSearching = true;
setStatus(`Starting search...`);
document.title = `${activeConnection.name} - Searching for "${query}" in ${path}`;
// Disable controls
updateDirControls();
btnUpload.disabled = true;
btnDirCreate.disabled = true;
btnFileCreate.disabled = true;
// Remove all existing file elements
elFiles.innerHTML = `
Folders
Files
`;
const elFilesFolders = $('.folders', elFiles);
const elFilesFiles = $('.files', elFiles);
lastSelectedIndex = -1;
// Get socket key
const resSocketKey = await api.get('key');
const key = resSocketKey.key;
// Connect to the search websocket
try { searchWebsocket.close(); } catch (error) {}
searchWebsocket = new WebSocket(`wss://${window.location.host}/api/sftp/directories/search?key=${key}&path=${encodeURIComponent(path)}&query=${encodeURIComponent(query)}`);
let count = 0;
let maxCount = 500;
let finishedSuccessfully = false;
searchWebsocket.addEventListener('message', e => {
const data = JSON.parse(e.data);
if (data.error) {
setStatus(`Error: ${data.error}`, true, -1);
}
if (data.status == 'scanning') {
setStatus(`Searching within ${data.path}...`, false, -1);
}
if (data.status == 'complete') {
finishedSuccessfully = true;
searchWebsocket.close();
}
if (data.status == 'list') {
// Add file elements to the file list
for (const file of data.list) {
const pathSplit = file.path.split('/');
pathSplit.pop();
const folderPath = pathSplit.join('/');
const elFile = getFileEntryElement(file, path);
elFile.classList.add('search');
const elNameCont = $('.nameCont', elFile);
elNameCont.insertAdjacentHTML('afterbegin', `
${folderPath}
`);
if (file.type == 'd')
elFilesFolders.appendChild(elFile);
else
elFilesFiles.appendChild(elFile);
count++;
if (count >= maxCount) {
finishedSuccessfully = true;
searchWebsocket.close();
}
}
// Sort file list
sortFiles();
}
});
searchWebsocket.addEventListener('close', () => {
setStatus(`Found ${count >= maxCount ? `${maxCount}+` : count} file(s) in ${Date.now()-startTime}ms`);
});
}
/**
* Generates a file list entry element with the data for a given file.
* @param {object} file A file object returned from the directory list API
* @param {string} dirPath The path of the directory containing this file
* @returns {HTMLElement}
*/
const getFileEntryElement = (file, dirPath) => {
const elFile = document.createElement('button');
elFile.classList = 'btn fileEntry row';
// If the file is "hidden", give it the class
if (file.name != '..' && file.name.substring(0, 1) === '.') {
elFile.classList.add('hidden');
}
// Get icon
let icon = 'insert_drive_file';
if (file.type == 'd') icon = 'folder';
if (file.type == 'l') icon = 'file_present';
if (file.type == 'b') icon = 'save';
if (file.type == 'p') icon = 'filter_alt';
if (file.type == 'c') icon = 'output';
if (file.type == 's') icon = 'wifi';
if (file.name == '..') icon = 'drive_folder_upload';
// Get formatted file info
const sizeFormatted = (file.size && file.type !== 'd') ? formatSize(file.size) : '-';
const dateRelative = file.modifyTime ? getRelativeDate(file.modifyTime) : '-';
const dateAbsolute = file.modifyTime ? dayjs(file.modifyTime).format('MMM D, YYYY, h:mm A') : null;
const perms = file.longname.split(' ')[0].replace(/\*/g, '');
const permsNum = permsStringToNum(perms);
// Add data attributes to the file element
elFile.dataset.path = file.path || `${dirPath}/${file.name}`;
elFile.dataset.type = file.type;
elFile.dataset.name = file.name;
elFile.dataset.size = file.size;
elFile.dataset.date = file.modifyTime;
elFile.dataset.perms = perms;
// Build the HTML
let lower = [];
if (dateRelative !== '-') lower.push(dateRelative);
if (sizeFormatted !== '-') lower.push(sizeFormatted);
elFile.innerHTML = /*html*/`
${icon}
${file.name}
${lower.length > 0 ? /*html*/`
${lower.join(' • ')}
`:''}
${dateRelative}
${sizeFormatted}
${perms}
`;
// Handle access
const accessFile = () => {
if (fileAccessLock) return;
forwardPaths = [];
changePath(elFile.dataset.path);
};
// Handle clicks
let lastClick = 0;
elFile.addEventListener('click', e => {
e.stopPropagation();
if (getIsMobileDevice()) return;
// If the control key isn't held
if (!e.ctrlKey) {
// If this is a double-click
if ((Date.now()-lastClick) < 300) {
accessFile();
}
}
// Update our last click time
lastClick = Date.now();
// Return if ..
if (file.name == '..') return;
// Handle selection
if (e.shiftKey && lastSelectedIndex >= 0) {
// Select all files between the last selected file and this one
// based on this file's index and lastSelectedIndex
const files = [...$$('#files .fileEntry', elFiles)];
const start = Math.min(lastSelectedIndex, parseInt(elFile.dataset.index));
const end = Math.max(lastSelectedIndex, parseInt(elFile.dataset.index));
for (let j = start; j <= end; j++) {
const el = files[j];
selectFile(el.dataset.path, false, false, false);
}
} else {
// Update selection based on shift and ctrl key state
const state = e.shiftKey || e.ctrlKey;
selectFile(elFile.dataset.path, !state, state);
}
});
// Handle keypresses
elFile.addEventListener('keydown', e => {
// If the enter key is pressed
if (e.code === 'Enter') {
accessFile();
}
// Focus the next file
if (e.code == 'ArrowDown') {
e.preventDefault();
const next = elFile.nextElementSibling;
if (next) next.focus();
}
// Focus the previous file
if (e.code == 'ArrowUp') {
e.preventDefault();
const prev = elFile.previousElementSibling;
if (prev) prev.focus();
}
// If the escape key is pressed, deselect all files
if (e.code === 'Escape') {
deselectAllFiles();
}
// Return if ..
if (file.name == '..') return;
// If the spacebar is pressed
if (e.code === 'Space') {
// Prevent scrolling
e.preventDefault();
// Update selection based on ctrl key state
if (file.name != '..')
selectFile(elFile.dataset.path, !e.ctrlKey, true);
}
});
elFile.addEventListener('keypress', e => {
if (e.ctrlKey || e.shiftKey || e.altKey) return;
clearTimeout(keypressClearTimeout);
keypressString += e.key;
keypressClearTimeout = setTimeout(() => {
keypressString = '';
}, 500);
// Get all file elements
const files = [...$$('#files .fileEntry', elFiles)];
// Put all files before this one at the end of the array
// This causes the search to wrap around to the beginning
const filesWrapped = [
...files.slice(parseInt(elFile.dataset.index)+1),
...files.slice(0, parseInt(elFile.dataset.index)+1)
];
// Search through file elements and select the first one
// whose data-name starts with the keypress string
for (const el of filesWrapped) {
if (el.dataset.name.toLowerCase().startsWith(keypressString.toLowerCase())) {
selectFile(el.dataset.path, true, false, true);
break;
}
}
});
// Handle right-clicks
elFile.addEventListener('contextmenu', e => {
e.stopPropagation();
e.preventDefault();
if (getIsMobileDevice()) return;
// If the file is already selected, don't change selection
if (!elFile.classList.contains('selected')) {
selectFile(elFile.dataset.path, true, true);
}
fileContextMenu();
});
// Handle mobile touch start
let timeTouchStart = 0;
let initialSelectTimeout = null;
let fileListScrollTopOnStart = 0;
elFile.addEventListener('touchstart', e => {
if (!getIsMobileDevice()) return;
timeTouchStart = Date.now();
fileListScrollTopOnStart = elFiles.scrollTop;
if (!checkIsSelecting()) {
initialSelectTimeout = setTimeout(() => {
if ((Date.now()-timeTouchStart) > 1000) return;
selectFile(elFile.dataset.path, true, false);
if (navigator.vibrate) navigator.vibrate(2);
}, 400);
}
});
// Handle mobile touch end
elFile.addEventListener('touchend', e => {
if (!getIsMobileDevice()) return;
clearTimeout(initialSelectTimeout);
if ((Date.now()-timeTouchStart) > 380) return;
if (elFiles.scrollTop != fileListScrollTopOnStart) return;
if (checkIsSelecting()) {
selectFile(elFile.dataset.path, false, true);
} else {
accessFile();
}
});
// Handle mobile touch move
elFile.addEventListener('touchmove', e => {
if (!getIsMobileDevice()) return;
clearTimeout(initialSelectTimeout);
timeTouchStart = 0;
});
return elFile;
}
/**
* Displays a context menu with actions for the selected file(s).
* @param {HTMLElement} elDisplay An HTML element to display the menu relative to
*/
const fileContextMenu = (elDisplay = null) => {
const allVisibleFiles = [...$$('#files .fileEntry:not(.hidden)', elFiles)];
if (showHidden)
allVisibleFiles.push(...[...$$('#files .fileEntry.hidden', elFiles)]);
const selectedFiles = [...getSelectedFiles()];
// We have to delay button clicks to allow time for
// the context menu to lose focus trap
const clickButton = btn => setTimeout(() => btn.click(), 100);
// Selection status shortcuts
const isNoneSelected = selectedFiles.length == 0;
const isSomeSelected = selectedFiles.length > 0;
const isSingleSelected = selectedFiles.length == 1;
const isMultiSelected = selectedFiles.length > 1;
const isAllSelected = selectedFiles.length == allVisibleFiles.length-1;
// Build the menu
const menu = new ContextMenuBuilder();
if (isNoneSelected) menu.addItem(item => {
item.setIcon($('.icon', btnUpload).innerText)
.setLabel('Upload files...')
.setClickHandler(() => clickButton(btnUpload))
btnUpload.disabled ? item.disable() : item.enable();
return item;
});
if (isNoneSelected) menu.addItem(item => {
item.setIcon($('.icon', btnDirCreate).innerText)
.setLabel('New folder...')
.setClickHandler(() => clickButton(btnDirCreate))
btnDirCreate.disabled ? item.disable() : item.enable();
return item;
});
if (isNoneSelected) menu.addItem(item => {
item.setIcon($('.icon', btnFileCreate).innerText)
.setLabel('New file...')
.setClickHandler(() => clickButton(btnFileCreate))
btnFileCreate.disabled ? item.disable() : item.enable();
return item;
});
if (isNoneSelected) menu.addSeparator();
if (!btnSelectionCut.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionCut).innerText)
.setLabel(`Cut`)
.setClickHandler(() => clickButton(btnSelectionCut))
return item;
});
if (!btnSelectionCopy.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionCopy).innerText)
.setLabel(`Copy`)
.setClickHandler(() => clickButton(btnSelectionCopy))
return item;
});
if (isNoneSelected) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionPaste).innerText)
.setLabel(`Paste`)
.setClickHandler(() => clickButton(btnSelectionPaste))
btnSelectionPaste.disabled ? item.disable() : item.enable();
return item;
});
if (isSomeSelected) menu.addSeparator();
if (!btnRename.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnRename).innerText)
.setLabel('Rename...')
.setClickHandler(() => clickButton(btnRename))
return item;
});
if (!btnSelectionMoveTo.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionMoveTo).innerText)
.setLabel(`Move to...`)
.setClickHandler(() => clickButton(btnSelectionMoveTo))
return item;
});
if (!btnSelectionCopyTo.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionCopyTo).innerText)
.setLabel(`Copy to...`)
.setClickHandler(() => clickButton(btnSelectionCopyTo))
return item;
});
if (!btnSelectionDelete.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionDelete).innerText)
.setLabel(`Delete...`)
.setClickHandler(() => clickButton(btnSelectionDelete))
.setIsDanger(true)
return item;
});
if (!btnSelectionPerms.disabled) menu.addItem(item => {
item.setIcon($('.icon', btnSelectionPerms).innerText)
.setLabel(`Edit permissions...`)
.setClickHandler(() => clickButton(btnSelectionPerms))
return item;
});
menu.addSeparator();
menu.addItem(item => {
item.setIcon('download')
.setLabel(`Download`)
.setClickHandler(() => clickButton(btnDownload))
btnDownload.disabled ? item.disable() : item.enable();
return item;
});
if (!isLocalhost) menu.addItem(item => {
item.setIcon('share')
.setLabel(`Copy download link...`)
.setClickHandler(() => clickButton(btnShare))
btnDownload.disabled ? item.disable() : item.enable();
return item;
});
if (!isMultiSelected) menu.addSeparator();
if (!isMultiSelected) menu.addItem(item => {
item.setIcon('conversion_path')
.setLabel('Copy path')
.setClickHandler(() => {
const path = isNoneSelected ? activeConnection.path : selectedFiles[0].dataset.path;
console.log('Clipboard context:', window.location.href, window.top === window ? 'Parent' : 'Iframe');
navigator.clipboard.writeText(path);
setStatus(`Copied path to clipboard`);
})
return item;
});
if (allVisibleFiles.length > 1)
menu.addSeparator();
if (!isAllSelected) menu.addItem(item => item
.setIcon('select_all')
.setLabel('Select all')
.setTooltip('Ctrl + A')
.setClickHandler(selectAllFiles))
if (isSomeSelected) menu.addItem(item => item
.setIcon('select')
.setLabel('Deselect all')
.setTooltip('Ctrl + Shift + A')
.setClickHandler(deselectAllFiles))
if (isSomeSelected && !isAllSelected) menu.addItem(item => item
.setIcon('move_selection_up')
.setLabel('Invert selection')
.setTooltip('Ctrl + Alt + A')
.setClickHandler(invertFileSelection))
if (elDisplay) {
const rect = elDisplay.getBoundingClientRect();
menu.showAtCoords(rect.left, rect.bottom-5);
} else {
menu.showAtCursor();
}
}
/**
* Sorts the current file list using `sortType` and `sortDesc`.
*/
const sortFiles = () => {
deselectAllFiles();
// Define sorting functions
const sortFuncs = {
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }),
size: (a, b) => a.size - b.size,
date: (a, b) => a.date - b.date
};
// Loop through file sections
const sections = [...$$('.section', elFiles)];
let i = 0;
for (const section of sections) {
const files = [...$$('#files .fileEntry', section)];
// Sort files
files.sort((a, b) => {
if (a.dataset.name == '..') return -1;
if (b.dataset.name == '..') return 1;
const aData = {
name: a.dataset.name,
size: parseInt(a.dataset.size),
date: parseInt(a.dataset.date)
};
const bData = {
name: b.dataset.name,
size: parseInt(b.dataset.size),
date: parseInt(b.dataset.date)
};
const sortFunc = sortFuncs[sortType];
return sortFunc(aData, bData) * (sortDesc ? -1 : 1);
});
// Append files to the file list
for (const file of files) {
file.dataset.index = i;
section.appendChild(file);
i++;
}
}
}
/**
* Changes the file sort type and re-sorts the file list.
* @param {sortType} type The new sort type
*/
const changeFileSortType = type => {
sortType = type;
window.localStorage.setItem('sortType', sortType);
sortFiles();
}
/**
* Changes the file sort direction and re-sorts the file list.
* @param {sortDesc} descending
*/
const changeFileSortDirection = descending => {
sortDesc = descending;
window.localStorage.setItem('sortDesc', sortDesc);
sortFiles();
}
/**
* Changes the file view mode and updates the file list.
* @param {viewMode} type The new view mode
*/
const changeFileViewMode = type => {
if (type == 'list' && window.innerWidth < forceTileViewWidth) {
return new PopupBuilder()
.setTitle(`Can't switch to list view`)
.addBodyHTML(`Your screen is too narrow to switch to list view! Rotate your device or move to a larger screen, then try again.
`)
.addAction(action => action.setLabel('Okay').setIsPrimary(true))
.show();
}
viewMode = type;
window.localStorage.setItem('viewMode', viewMode);
elFiles.classList.remove('list', 'tiles');
elFiles.classList.add(viewMode);
elFileColHeadings.classList.toggle('tiles', type == 'tiles');
}
/** Toggles the visibility of hidden files. */
const toggleHiddenFileVisibility = () => {
showHidden = !showHidden;
window.localStorage.setItem('showHidden', showHidden);
elFiles.classList.toggle('showHidden', showHidden);
}
/**
* Opens a file preview/editor tab/window.
* @param {string} path The file path.
*/
const openFileViewer = path => {
const url = `/file.html?con=${activeConnectionId}&path=${encodeURIComponent(path)}`;
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches
|| window.matchMedia('(display-mode: minimal-ui)').matches;
if (!isStandalone) {
// Open in new tab
window.open(url, '_blank');
setStatus(`File opened in new tab`);
} else {
// Set size
const viewerWidth = parseInt(window.localStorage.getItem('viewerWidth')) || window.innerWidth;
const viewerHeight = parseInt(window.localStorage.getItem('viewerHeight')) || window.innerHeight;
const coords = {
// Center the new window on top of this one
x: window.screenX + (window.innerWidth - viewerWidth)/2,
y: window.screenY + (window.innerHeight - viewerHeight)/2,
w: viewerWidth,
h: viewerHeight
};
// Open window
window.open(url, path, `width=${coords.w},height=${coords.h},left=${coords.x},top=${coords.y}`);
setStatus(`File opened in new window`);
}
}
/**
* Resolves with a download URL for a zip file containing all of the files and directories specified, or `false` if an error occurred.
* @param {string[]} paths An array of file and/or directory paths
* @param {string} rootPath The directory path to start at inside the zip file - leave undefined to use `'/'`
* @returns {Promise}
*/
const getZipDownloadUrl = async(paths, rootPath = '/') => {
const pathsJson = JSON.stringify(paths);
if ((pathsJson.length+rootPath.length) > 1900) {
return setStatus(`Error: Too many selected paths for zip download`, true);
}
setStatus(`Getting zip file download URL...`);
const res = await api.get('files/get/multi/url', {
paths: pathsJson,
rootPath: rootPath
});
if (res.error) {
return setStatus(`Error: ${res.error}`, true);
}
if (res.download_url) {
return res.download_url;
}
return false;
}
/**
* Starts a zip file download for all of the paths specified.
* @param {string[]} paths An array of file and/or directory paths
* @param {string} [rootPath='/'] The directory path to start at inside the zip file
*/
const downloadZip = async(paths, rootPath = '/') => {
const url = await getZipDownloadUrl(paths, rootPath);
if (url) {
downloadUrl(url);
setStatus(`Zip file download started`);
}
}
/**
* Updates the disabled/enabled state of all control buttons depending on the currently selected file entries.
*/
const updateDirControls = () => {
const selectedFiles = $$('.selected', elFiles);
btnSelectionCut.disabled = true;
btnSelectionCopy.disabled = true;
btnSelectionPaste.disabled = true;
btnRename.disabled = true;
btnSelectionMoveTo.disabled = true;
btnSelectionCopyTo.disabled = true;
btnSelectionDelete.disabled = true;
btnSelectionPerms.disabled = true;
if (isSearching) {
btnDownload.disabled = true;
btnShare.disabled = true;
}
btnDeselectAll.style.display = 'none';
// When no files are selected
if (selectedFiles.length == 0) {
btnDirMenu.classList.remove('info');
// When files are selected
} else {
btnDirMenu.classList.add('info');
// When a single file is selected
if (selectedFiles.length == 1) {
btnRename.disabled = false;
}
btnSelectionCut.disabled = false;
btnSelectionCopy.disabled = false;
btnSelectionMoveTo.disabled = false;
btnSelectionCopyTo.disabled = false;
btnSelectionDelete.disabled = false;
btnSelectionPerms.disabled = false;
btnDownload.disabled = false;
btnShare.disabled = false;
btnDeselectAll.style.display = '';
}
// When there are files in the clipboard
if (selectionClipboard.length > 0 && !isSearching) {
btnSelectionPaste.disabled = false;
}
}
/**
* An array of all selected file elements.
* @returns {HTMLElement[]}
*/
const getSelectedFiles = () => [...$$('.selected', elFiles)];
/**
* Updates the selected state of the file element with the specified path.
* @param {string} path The path of the file to select in the list
* @param {boolean} [deselectOthers] If `true`, other files will be deselected - defaults to `true`
* @param {boolean} [toggle] If `true`, toggle the selected state of this file - defaults to `false`
* @param {boolean} [focus] If `true`, focus this file element in the list - defaults to `false`
*/
const selectFile = (path, deselectOthers = true, toggle = false, focus = false) => {
const el = $(`.fileEntry[data-path="${path}"]`, elFiles);
if (!el) return;
if (el.dataset.name == '..') return;
const isSelected = el.classList.contains('selected');
if (deselectOthers) deselectAllFiles();
if (toggle)
el.classList.toggle('selected', !isSelected);
else
el.classList.add('selected');
if (focus) el.focus();
deselectHiddenFiles();
lastSelectedIndex = parseInt(el.dataset.index);
updateDirControls();
}
/**
* Selects all files in the file list, excluding hidden ones if they aren't visible.
*/
const selectAllFiles = () => {
const files = [...$$('.fileEntry', elFiles)];
for (const el of files) {
if (el.dataset.name == '..') continue;
el.classList.add('selected');
}
deselectHiddenFiles();
lastSelectedIndex = parseInt($('.fileEntry.selected:last-child', elFiles).dataset.index || -1);
updateDirControls();
}
/**
* Deselects all files in the file list.
*/
const deselectAllFiles = () => {
const selected = getSelectedFiles();
for (const el of selected) {
el.classList.remove('selected');
}
lastSelectedIndex = -1;
updateDirControls();
}
/**
* Deselects all invisible files in the file list. Nothing will happen if hidden files are visible.
*/
const deselectHiddenFiles = () => {
if (showHidden) return;
const hidden = [...$$('.hidden', elFiles)];
for (const el of hidden) {
el.classList.remove('selected');
}
updateDirControls();
}
/**
* Inverts the current file selection, only including visible files.
*/
const invertFileSelection = () => {
const files = [...$$('#files .fileEntry', elFiles)];
files.shift();
for (const el of files) {
el.classList.toggle('selected');
}
lastSelectedIndex = parseInt($('#files .fileEntry:last-child', elFiles).dataset.index) || -1;
deselectHiddenFiles();
updateDirControls();
}
/**
* Returns `true` if there are currently files selected, `false` otherwise.
* @returns {boolean}
*/
const checkIsSelecting = () => {
const selected = getSelectedFiles();
return selected.length > 0;
}
/**
* Opens a dialog prompting the user to create a directory.
*/
const createDirectoryDialog = () => {
const el = document.createElement('div');
el.innerHTML = /*html*/`
`;
const inputDirName = $('#inputDirName', el);
const popup = new PopupBuilder()
.setTitle('New folder')
.addBody(el)
.addAction(action => action
.setIsPrimary(true)
.setLabel('Create')
.setClickHandler(async() => {
const name = inputDirName.value;
if (!name) return;
const path = `${activeConnection.path}/${name}`;
const data = await api.post('directories/create', { path: path });
if (data.error) {
setStatus(`Error: ${data.error}`, true);
} else {
await changePath();
selectFile(data.path, true, false, true);
}
}))
.addAction(action => action.setLabel('Cancel'))
.show();
inputDirName.focus();
inputDirName.addEventListener('keydown', e => {
if (e.key === 'Enter') {
$('.btn:first-of-type', popup.el).click();
}
});
}
/**
* Opens a dialog prompting the user to rename the file with the specified path.
* @param {string} path The file path
*/
const renameFileDialog = async(path, shouldReload = true) => new Promise(resolve => {
const el = document.createElement('div');
const currentName = path.split('/').pop();
el.innerHTML = /*html*/`
`;
const input = $('#inputFileName', el);
const popup = new PopupBuilder()
.setTitle(`Rename file`)
.addBody(el)
.addAction(action => action
.setIsPrimary(true)
.setLabel('Rename')
.setClickHandler(async() => {
popup.setOnHide(() => {});
const name = input.value;
if (!name) return resolve(path);
const pathOld = path;
const dir = pathOld.split('/').slice(0, -1).join('/');
let pathNew = `${dir}/${name}`;
if (pathNew == pathOld) return resolve(path);
// Check if the new path exists
if (await checkFileExists(pathNew)) {
if ((await fileConflictDialog(pathNew, false, true)).type == 'skip') {
setStatus(`Rename cancelled`);
return resolve();
}
pathNew = await getAvailableFileName(dir, name);
}
const data = await api.put('files/move', {
pathOld, pathNew
});
if (data.error) {
setStatus(`Error: ${data.error}`, true);
} else if (shouldReload) {
const pathNewDir = data.pathNew.split('/').slice(0, -1).join('/');
await changePath(pathNewDir);
selectFile(data.pathNew, true, false, true);
}
resolve(data.pathNew || path);
}))
.addAction(action => action.setLabel('Cancel'))
.setOnHide(() => resolve(path))
.show();
input.focus();
input.select();
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
$('.btn:first-of-type', popup.el).click();
}
});
});
/**
* Opens a dialog prompting the user to select a directory with an interactive browser.
* @param {string} [startPath] The directory to start in
* @param {string} [title] The popup title
* @param {string} [actionLabel] The label of the confirm button
* @returns {Promise} A promise resolving to the selected directory path, or `null` if cancelled
*/
const selectDirDialog = async(startPath = activeConnection.path, title = 'Select folder', actionLabel = 'Select') => new Promise(resolve => {
const el = document.createElement('div');
el.innerHTML = /*html*/`
`;
const input = $('#inputDirPath', el);
const btnGo = $('.btn.go', el);
const elFolders = $('.folders', el);
const loadFolders = async dir => {
elFolders.innerHTML = '';
const data = await api.get('directories/list', {
path: dir, dirsOnly: true
});
const subDirs = data.list || [];
subDirs.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
if (dir != '/') subDirs.unshift({ name: '..' });
for (const subDir of subDirs) {
const elDir = document.createElement('button');
elDir.classList = 'btn fileEntry row gap-10';
if (subDir.name != '..' && subDir.name.substring(0, 1) === '.')
elDir.classList.add('hidden');
let subDirPath = path = `${dir}/${subDir.name}`;
elDir.innerHTML = /*html*/`
folder
`;
elDir.addEventListener('click', () => {
loadFolders(subDirPath);
});
elFolders.appendChild(elDir);
}
if (data.path) input.value = data.path;
};
btnGo.addEventListener('click', () => loadFolders(input.value || startPath || '/'));
btnGo.click();
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
btnGo.click();
}
});
new PopupBuilder()
.setTitle(title)
.addBody(el)
.addAction(action => action
.setIsPrimary(true)
.setLabel(actionLabel)
.setClickHandler(() => resolve(input.value)))
.addAction(action => action.setLabel('Cancel'))
.setOnHide(() => resolve(null))
.show();
});
/**
* Moves files from their original directories into a single new directory while keeping their names.
* @param {string} newDirPath The new directory
* @param {string[]} filePaths An array of file paths to move
* @returns {Promise} An array of new paths of the files successfully moved, or `null` if no files were moved
*/
const moveFiles = async(newDirPath, filePaths) => {
// Loop through selected files
const newPaths = [];
let i = 0;
let replaceStatus = { type: 'skip', all: false };
for (const pathOld of filePaths) {
const name = pathOld.split('/').pop();
let pathNew = `${newDirPath}/${name}`;
if (pathOld == pathNew) continue;
setStatus(`Moving file: ${pathOld}`, false, Math.round((i/filePaths.length)*100));
i++;
// Check if the new path exists
// If it does, prompt the user to replace it
if (await checkFileExists(pathNew)) {
if (!replaceStatus.all) {
replaceStatus = await fileConflictDialog(pathNew, true, true);
}
if (replaceStatus.type == 'skip') {
setStatus(`File move skipped`);
continue;
}
if (replaceStatus.type == 'replace') {
const resDelete = await deleteFile(pathNew);
if (resDelete.error) return;
}
if (replaceStatus.type == 'rename') {
pathNew = await getAvailableFileName(newDirPath, name);
}
}
const data = await api.put('files/move', {
pathOld, pathNew
});
if (data.error) {
setStatus(`Error: ${data.error}`, true);
break;
}
const el = $(`#files .fileEntry[data-path="${pathOld}"]`, elFiles);
if (el) el.remove();
newPaths.push(data.pathNew);
}
if (newPaths.length > 0) {
setStatus(`Moved ${newPaths.length} file(s) to ${newDirPath}`);
return newPaths;
}
return null;
}
/**
* Copies files into the provided directory.
* @param {string} newDirPath The target directory
* @param {string[]} filePaths An array of file paths to copy
* @returns {Promise} An array of new paths of the files successfully copied, or `null` if no files were copied
*/
const copyFiles = async(newDirPath, filePaths) => {
// Loop through selected files
const newPaths = [];
let i = 0;
let replaceStatus = { type: 'skip', all: false };
for (const pathSource of filePaths) {
const name = pathSource.split('/').pop();
let pathDest = `${newDirPath}/${name}`;
setStatus(`Copying file: ${pathSource}`, false, Math.round((i/filePaths.length)*100));
i++;
// Check if the new path exists
// If it does, prompt the user to replace it
if (await checkFileExists(pathDest)) {
if (!replaceStatus.all) {
replaceStatus = await fileConflictDialog(name, true, true);
}
if (replaceStatus.type == 'skip') {
setStatus(`File copy skipped`);
continue;
}
if (replaceStatus.type == 'replace') {
const res = await deleteFile(pathDest);
if (res.error) return;
}
if (replaceStatus.type == 'rename') {
pathDest = await getAvailableFileName(newDirPath, name);
}
}
const data = await api.put('files/copy', {
pathSrc: pathSource, pathDest: pathDest
});
if (data.error) {
setStatus(`Error: ${data.error}`, true);
return false;
}
newPaths.push(data.pathDest);
}
if (newPaths.length > 0) {
setStatus(`Copied ${newPaths.length} file(s) to ${newDirPath}`);
return newPaths;
}
return false;
}
/**
* Opens a dialog prompting the user to select a directory to transfer the selected files to.
* @param {boolean} copy If `true`, copy the files instead of moving them
* @returns {Promise} An array of new file paths, or `null` if no files were transferred
*/
const moveFilesDialog = async(copy = false) => {
const selectedPaths = [...getSelectedFiles()].map(el => el.dataset.path);
// Prompt the user to select a directory
const newDirPath = await selectDirDialog(undefined, `${copy ? 'Copy':'Move'} ${selectedPaths.length > 1 ? `${selectedPaths.length} files`:'file'}`, `${copy ? 'Copy':'Move'} here`);
if (!newDirPath) return null;
// Move or copy the files
if (copy)
return copyFiles(newDirPath, selectedPaths);
else
return moveFiles(newDirPath, selectedPaths);
}
/**
* Prompts the user if they want to skip or replace the current file in the current transfer process, with the additional option of doing this for the all remaining conflicts.
* @param {string} fileName The file's name or path to display to the user
* @returns {Promise<'skip'|'skipAll'|'replace'|'replaceAll'>} One of 4 states representing the user's choice: `skip`, `skipAll`, `replace`, `replaceAll`
*/
const fileConflictDialog = (fileName, allowReplace = true, allowDuplicate = false) => new Promise(resolve => {
const el = document.createElement('div');
el.innerHTML = `
${fileName} already exists. What do you want to do?
Do this for all remaining conflicts
`;
const checkbox = $('input', el);
const popup = new PopupBuilder()
.setClickOutside(false)
.setTitle(`File exists`)
.addBody(el);
popup.addAction(action => action
.setLabel('Skip')
.setIsPrimary(true)
.setClickHandler(() => resolve({ type: 'skip', all: checkbox.checked })));
if (allowReplace)
popup.addAction(action => action
.setLabel('Replace')
.setClickHandler(() => resolve({ type: 'replace', all: checkbox.checked })));
if (allowDuplicate)
popup.addAction(action => action
.setLabel('Rename')
.setClickHandler(() => resolve({ type: 'rename', all: checkbox.checked })));
popup.show();
});
/**
* Checks if a file exists on the server, returns `null` if error.
* @param {string} path The file path
*/
const checkFileExists = async path => {
const res = await api.get('files/exists', { path: path });
if (res.error) return null;
return res.exists ? true : false;
}
/**
* Appends a number to the end of a file name until it's unique in the specified directory.
* @param {string} dir The directory to check within
* @param {string} name The initial file name
* @returns {Promise} A promise resolving to the new file path
*/
const getAvailableFileName = async(dir, name) => {
let i = 1;
let path = `${dir}/${name}`;
const nameWithoutExt = name.split('.').slice(0, -1).join('.');
const ext = name.split('.').pop();
while (await checkFileExists(path)) {
path = `${dir}/${nameWithoutExt}-${i}.${ext}`;
i++;
}
return path;
}
/**
* Uploads input files to the active server.
* @param {FileSystemHandle[]} inputFiles The input files
*/
const uploadFiles = async inputFiles => {
if (isUploading) return new PopupBuilder()
.setTitle('Upload in progress')
.addBodyHTML('An upload is already in progress. Wait for it to finish before uploading more files.
')
.addAction(action => action.setIsPrimary(true).setLabel('Okay'))
.show();
isUploading = true;
let isCancelled = false;
let replaceStatus = { type: 'skip', all: false };
let dirPath = activeConnection.path;
// Handle status and progress bar
let lastStatusSet = 0;
const setUploadStatus = (text, progress = 0) => {
if ((Date.now()-lastStatusSet) < 500) return;
setStatus(`Cancel | ${text} `, false, progress);
const anchor = $('a', elStatusBar);
anchor.addEventListener('click', e => {
isCancelled = true;
});
lastStatusSet = Date.now();
};
// Sort input files
inputFiles = [...inputFiles];
inputFiles.sort((a, b) => a.name.localeCompare(b.name));
// Loop through selected files
let startTime = Date.now();
let totalBytesUploaded = 0;
const paths = [];
for (const file of inputFiles) {
if (isCancelled) break;
setUploadStatus(`Uploading file: ${file.name}`);
// If the file exists, prompt the user to replace it
let fileName = file.name;
let path = `${dirPath}/${fileName}`;
if (await checkFileExists(path)) {
if (!replaceStatus.all) {
replaceStatus = await fileConflictDialog(fileName, true, true);
}
if (replaceStatus.type == 'skip') {
setStatus(`Upload skipped`);
continue;
}
if (replaceStatus.type == 'replace') {
const resDelete = await deleteFile(path);
if (resDelete.error) return;
}
if (replaceStatus.type == 'rename') {
path = await getAvailableFileName(dirPath, fileName);
fileName = path.split('/').pop();
}
}
// Make a promise to upload the file
await new Promise(async(resolve, reject) => {
let isUploadComplete = false;
// Get socket key
const resSocketKey = await api.get('key');
const key = resSocketKey.key;
// Connect to the file append websocket
const url = `${wsProtocol}://${apiHost}/api/sftp/files/append?path=${encodeURIComponent(path)}&key=${key}`;
const ws = new WebSocket(url);
// Resolve with error if the websocket closes or errors
// before the upload is complete
ws.addEventListener('close', () => {
if (!isUploadComplete) {
isUploading = false;
setStatus(`Error: Websocket unexpectedly closed`, true)
resolve('unexpectedClose');
}
});
ws.addEventListener('error', (e) => {
if (!isUploadComplete) {
isUploading = false;
setStatus(`Error: Websocket error`, true)
resolve('wsError');
}
});
// Handle messages
const messageHandlers = [];
ws.addEventListener('message', e => {
const data = JSON.parse(e.data);
console.log(`Message from upload websocket:`, data);
if (!data.success) {
isUploading = false;
setStatus(`Error: ${data.error}`, true)
resolve('error');
}
const handler = messageHandlers.shift();
if (handler) handler(data.success || false);
});
// Wait for the websocket to open
await new Promise(resolve2 => {
messageHandlers.push(resolve2);
});
console.log(`Opened websocket: ${url}`);
// Upload the file in chunks
const fileSize = file.size;
const bytesPerChunk = 1024*1024*1;
const chunkCount = Math.ceil(file.size / bytesPerChunk);
for (let i = 0; i < chunkCount; i++) {
if (isCancelled) break;
const startByte = i * bytesPerChunk;
const endByte = Math.min(file.size, (i+1) * bytesPerChunk);
const thisChunkSize = endByte - startByte;
const chunk = file.slice(startByte, endByte);
// Upload the chunk
const res = await new Promise(resolve2 => {
// Resolve when the chunk is uploaded
// and server sends a success message
messageHandlers.push(resolve2);
ws.send(chunk);
});
if (!res) break;
// Update status with progress
totalBytesUploaded += thisChunkSize;
const bytesUploaded = Math.min((i+1)*bytesPerChunk, fileSize);
const bytesPerSecond = totalBytesUploaded / ((Date.now()-startTime)/1000);
const percentUploaded = Math.round((bytesUploaded/fileSize)*100);
setUploadStatus(`Uploading file: ${fileName} | ${formatSize(bytesUploaded)} of ${formatSize(fileSize)} (${formatSize(bytesPerSecond)}/s)`, percentUploaded);
}
isUploadComplete = true;
ws.close();
resolve('done');
});
if (!isUploading) return;
// If the upload was cancelled, delete the file
if (isCancelled) {
await deleteFile(path);
setStatus(`Upload cancelled`);
break;
}
// Add the path to the list of uploaded files
paths.push(path);
// Add the file to the file list
if (dirPath == activeConnection.path) {
const elExisting = $(`.fileEntry[data-path="${path}"]`, elFiles);
if (elExisting) elExisting.remove();
const elFile = getFileEntryElement({
name: fileName,
type: '-',
size: file.size,
modifyTime: Date.now(),
longname: '-'
}, dirPath);
$('.section.files', elFiles).appendChild(elFile);
sortFiles();
}
}
isUploading = false;
if (paths.length == 0) return;
// Select all new files
for (const path of paths) {
selectFile(path, false, false, true);
}
setStatus(`Uploaded ${paths.length} file(s)`);
}
/**
* Opens a system file picker and uploads the selected files to the active server.
*/
const uploadFilesPrompt = async() => {
// Prompt user to select files
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.click();
// When files are selected
input.addEventListener('change', () => {
uploadFiles(input.files);
});
}
/**
* Deletes a file from the active server.
* @param {string} path The file path
* @param {boolean} refresh If `true`, refresh the file list after deleting the file - defaults to `true`
* @returns {Promise} The API response object
*/
const deleteFile = async(path) => {
const data = await api.delete('files/delete', { path: path });
if (data.error) {
setStatus(`Error: ${data.error}`, true);
} else {
const elFile = $(`.fileEntry[data-path="${path}"]`, elFiles);
if (elFile) elFile.remove();
}
return data;
}
/**
* Deletes a directory from the active server.
* @param {string} path The directory path
* @param {boolean} refresh If `true`, refresh the file list after deleting the directory - defaults to `true`
* @returns {Promise} The API response object
*/
const deleteDirectory = async(path) => {
const data = await api.delete('directories/delete', { path: path });
if (data.error) {
setStatus(`Error: ${data.error}`, true);
} else {
const elFile = $(`.fileEntry[data-path="${path}"]`, elFiles);
if (elFile) elFile.remove();
}
return data;
}
/**
* Shows a context menu containing a set of navigable file paths.
* @param {Event} e The `ContextMenu` event
* @param {HTMLElement} btn The button that was clicked
* @param {string[]} paths An array of paths to show in the menu
* @param {ContextMenuBuilder} menu An existing menu object to add items to
*/
const historyContextMenu = (e, btn, paths, menu = new ContextMenuBuilder()) => {
if (btn.disabled) return;
e.preventDefault();
paths = JSON.parse(JSON.stringify(paths)).reverse();
for (let i = 0; i < 10; i++) {
let path = paths[i];
if (!path) break;
let split = path.split('/');
let base = split.pop();
let dir = `/${split.join('/')}/`.replace(/\/\//g, '/');
menu.addItem(item => {
const html = /*html*/`
${escapeHTML(dir)}${escapeHTML(base)}
`;
item.elLabel.innerHTML = html;
const span = $('span', item.elLabel);
span.title = html;
item.setClickHandler(() => {
changePath(path, false);
});
return item;
});
}
menu.el.style.maxWidth = '100%';
menu.setIconVisibility(false);
const rect = btn.getBoundingClientRect();
menu.showAtCoords(rect.left, rect.bottom-5);
}
btnConnections.addEventListener('click', () => {
const menu = new ContextMenuBuilder();
const connectionValues = getSortedConnectionsArray();
for (const connection of connectionValues) {
menu.addItem(option => option
.setLabel(connection.name)
.setIcon('cloud')
.setTooltip(`Click to connect to ${connection.name}${connection.username}@${connection.host}:${connection.port} ${connection.path} `)
.setClickHandler(() => {
setActiveConnection(connection.id);
}));
};
menu.addSeparator();
menu.addItem(option => option
.setLabel('Manage connections...')
.setIcon('smb_share')
.setClickHandler(connectionManagerDialog));
menu.addItem(option => option
.setLabel('New connection...')
.setIcon('library_add')
.setClickHandler(addNewConnectionDialog));
menu.addSeparator().addItem(item => item
.setIcon('code')
.setLabel('SFTP Browser GitHub')
.setClickHandler(() => {
window.open('https://github.com/CyberGen49/sftp-browser');
}));
const rect = btnConnections.getBoundingClientRect();
menu.showAtCoords(rect.left, rect.bottom-5);
});
btnNavBack.addEventListener('click', () => {
if (backPaths.length > 0) {
forwardPaths.push(activeConnection.path);
changePath(backPaths.pop(), false);
}
});
btnNavForward.addEventListener('click', () => {
if (forwardPaths.length > 0) {
backPaths.push(activeConnection.path);
changePath(forwardPaths.pop(), false);
}
});
btnNavBack.addEventListener('contextmenu', (e) => {
historyContextMenu(e, btnNavBack, backPaths);
});
btnNavForward.addEventListener('contextmenu', (e) => {
historyContextMenu(e, btnNavForward, forwardPaths);
});
inputNavPath.addEventListener('keydown', e => {
if (e.key === 'Enter') {
btnGo.click();
}
});
btnGo.addEventListener('click', () => {
changePath(inputNavPath.value || '/');
});
btnPathPopup.addEventListener('click', () => {
const menu = new ContextMenuBuilder()
.addItem(item => item
.setIcon('pin_drop')
.setLabel('Go to path...')
.setClickHandler(() => {
const popup = new PopupBuilder()
.setTitle('Go to path')
.addAction(action => action
.setIsPrimary(true)
.setLabel('Go')
.setClickHandler(() => {
const path = $('#inputGoToPath', popup.el).value || activeConnection.path;
if (path == activeConnection.path) return;
changePath(path);
popup.hide();
}))
.addAction(action => action.setLabel('Cancel'))
const elBody = $('.body', popup.el);
elBody.innerHTML = /*html*/`
`;
const input = $('#inputGoToPath', elBody);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
$('.btn:first-of-type', popup.el).click();
}
});
popup.show();
setTimeout(() => {
input.focus();
input.select();
}, 100);
}));
const pathSplit = activeConnection.path.split('/');
pathSplit.pop();
if (pathSplit.length > 0) {
menu.addSeparator();
let path = '';
for (const node of pathSplit) {
path += `/${node}`;
menu.addItem(item => item
.setIcon('folder_open')
.setLabel(node || '/')
.setClickHandler(() => {
changePath(path);
}));
}
}
const rect = btnPathPopup.getBoundingClientRect();
menu.showAtCoords(rect.right, rect.bottom-5);
});
btnDirMenu.addEventListener('click', () => {
fileContextMenu(btnDirMenu);
});
btnDeselectAll.addEventListener('click', deselectAllFiles);
btnUpload.addEventListener('click', uploadFilesPrompt);
btnDirCreate.addEventListener('click', createDirectoryDialog);
btnRename.addEventListener('click', () => {
renameFileDialog(getSelectedFiles()[0].dataset.path);
});
btnSelectionCut.addEventListener('click', () => {
selectionClipboard = [...getSelectedFiles()].map(el => el.dataset.path);
const prevEls = [...$$('.cut', elFiles), ...$$('.copied', elFiles)];
for (const el of prevEls) el.classList.remove('cut', 'copied');
for (const path of selectionClipboard) {
const el = $(`#files .fileEntry[data-path="${path}"]`, elFiles);
el.classList.add('cut');
}
isClipboardCut = true;
setStatus(`Cut ${selectionClipboard.length} file path(s) to selection clipboard`);
updateDirControls();
});
btnSelectionCopy.addEventListener('click', () => {
selectionClipboard = [...getSelectedFiles()].map(el => el.dataset.path);
const prevEls = [...$$('.cut', elFiles), ...$$('.copied', elFiles)];
for (const el of prevEls) el.classList.remove('cut', 'copied');
for (const path of selectionClipboard) {
const el = $(`#files .fileEntry[data-path="${path}"]`, elFiles);
el.classList.add('copied');
}
isClipboardCut = false;
setStatus(`Copied ${selectionClipboard.length} file path(s) to selection clipboard`);
updateDirControls();
});
btnSelectionPaste.addEventListener('click', async() => {
const newDirPath = activeConnection.path;
if (!newDirPath) return;
// Move files
let newPaths = true;
if (isClipboardCut) {
// Move the files
newPaths = await moveFiles(newDirPath, selectionClipboard);
if (!newPaths) return;
// Clear the clipboard
selectionClipboard = [];
// Copy files
} else {
// Copy the files
newPaths = await copyFiles(newDirPath, selectionClipboard);
if (!newPaths) return;
}
// Reload directory
await changePath();
// Select the new files
for (const path of newPaths) {
selectFile(path, false, false, true);
}
});
btnSelectionMoveTo.addEventListener('click', () => moveFilesDialog(false));
btnSelectionCopyTo.addEventListener('click', () => moveFilesDialog(true));
btnFileCreate.addEventListener('click', async() => {
let dir = activeConnection.path;
let filePath = await getAvailableFileName(dir, 'file.txt');
const data = await api.post('files/create', { path: filePath }, '');
if (data.error) {
return setStatus(`Error: ${data.error}`, true);
}
filePath = await renameFileDialog(filePath, false);
console.log(filePath)
await changePath(dir);
selectFile(filePath, true, false, true);
});
btnSelectionDelete.addEventListener('click', async() => {
const selected = [...getSelectedFiles()];
const containsDirs = selected.some(el => el.dataset.type === 'd');
new PopupBuilder()
.setTitle(`Delete ${selected.length == 1 ? 'file':`${selected.length} files`}`)
.addBodyHTML(`
Are you sure you want to delete ${selected.length == 1 ? `${selected[0].dataset.name} `:`these files`}?
${containsDirs ? `
${selected.length == 1 ? 'This file is a directory':'Your selection contains directories'}! Deleting ${selected.length == 1 ? 'it':'them'} will also delete everything inside of ${selected.length == 1 ? 'it':'them'}.
`:''}
This usually can't be undone!
`)
.addAction(action => action
.setIsDanger(true)
.setLabel('Delete')
.setClickHandler(async() => {
let i = 0;
for (const el of selected) {
setStatus(`Deleting file: ${el.dataset.path}`, false, Math.round((i/selected.length)*100));
let res = null;
if (el.dataset.type === 'd') {
res = await deleteDirectory(el.dataset.path);
} else {
res = await deleteFile(el.dataset.path);
}
i++;
}
setStatus(`Deleted ${selected.length} file(s)`);
updateDirControls();
}))
.addAction(action => action.setLabel('Cancel'))
.show();
});
btnSelectionPerms.addEventListener('click', async() => {
const selected = [...getSelectedFiles()];
// File permissions matrix
// Columns are read, write, execute
// Rows are owner, group, other
let permsMatrix = [
[ 0, 0, 0 ],
[ 0, 0, 0 ],
[ 0, 0, 0 ]
];
for (const el of selected) {
const perms = el.dataset.perms.padEnd(10, '-').split('');
if (perms[1] != '-') permsMatrix[0][0]++;
if (perms[2] != '-') permsMatrix[0][1]++;
if (perms[3] != '-') permsMatrix[0][2]++;
if (perms[4] != '-') permsMatrix[1][0]++;
if (perms[5] != '-') permsMatrix[1][1]++;
if (perms[6] != '-') permsMatrix[1][2]++;
if (perms[7] != '-') permsMatrix[2][0]++;
if (perms[8] != '-') permsMatrix[2][1]++;
if (perms[9] != '-') permsMatrix[2][2]++;
}
const elMatrix = document.createElement('div');
elMatrix.classList = 'col permsMatrix';
elMatrix.innerHTML = /*html*/`
`;
for (let i = 0; i < 3; i++) {
for (let ii = 0; ii < 3; ii++) {
permsMatrix[i][ii] = Math.round(permsMatrix[i][ii] / selected.length);
if (permsMatrix[i][ii] == 1) {
$(`input[data-row="${i+1}"][data-col="${ii+1}"]`, elMatrix).checked = true;
}
}
}
new PopupBuilder()
.setTitle(`Edit file permissions`)
.addBody(elMatrix)
.addAction(action => action
.setIsPrimary(true)
.setLabel('Save')
.setClickHandler(async() => {
// Get permissions number
let str = '-';
for (let i = 0; i < 3; i++) {
for (let ii = 0; ii < 3; ii++) {
const checkbox = $(`input[data-row="${i+1}"][data-col="${ii+1}"]`, elMatrix);
if (checkbox.checked) {
str += 'rwx'[ii];
} else {
str += '-';
}
}
}
let perms = permsStringToNum(str);
// Set permissions
const changedPaths = [];
try {
let i = 0;
for (const file of selected) {
setStatus(`Updating permissions for ${file.dataset.path}...`, false, Math.round((i/selected.length)*100));
const res = await api.put('files/chmod', {
path: file.dataset.path,
mode: perms
});
if (res.error) throw new Error(res.error);
changedPaths.push(file.dataset.path);
i++;
}
// Reload directory and select changed files
if (changedPaths.length > 0) {
await changePath();
for (const file of selected) {
selectFile(file.dataset.path, false, false, true);
}
}
} catch (error) {
setStatus(`Error: ${error}`, true);
}
}))
.addAction(action => action.setLabel('Cancel'))
.show();
});
btnDownload.addEventListener('click', () => {
const selected = [...getSelectedFiles()];
const rootPath = activeConnection.path;
if (selected.length == 1) {
if (selected[0].dataset.type === 'd') {
downloadZip([ selected[0].dataset.path ], rootPath);
} else {
downloadFile(selected[0].dataset.path);
}
} else if (selected.length > 1) {
downloadZip(selected.map(el => el.dataset.path), rootPath);
} else {
downloadZip([ activeConnection.path ], rootPath);
}
});
btnShare.addEventListener('click', async() => {
new PopupBuilder()
.setTitle('Copy download link')
.addBodyHTML(`This link will allow anyone to download your selected files and folders for the next 24 hours without the need for any credentials. Make sure you aren't sharing anything sensitive!
`)
.addAction(action => action
.setLabel('Copy')
.setIsPrimary(true)
.setClickHandler(async() => {
let url;
let selected = [...getSelectedFiles()];
const isNoneSelected = selected.length == 0;
const isSingleSelected = selected.length == 1;
const isMultiSelected = selected.length > 1;
if (isNoneSelected)
url = await getZipDownloadUrl([activeConnection.path], activeConnection.path);
else if (isSingleSelected) {
const el = selected[0];
if (el.dataset.type == 'd')
url = await getZipDownloadUrl([el.dataset.path], activeConnection.path);
else
url = await getFileDownloadUrl(el.dataset.path);
} else if (isMultiSelected)
url = await getZipDownloadUrl(selected.map(el => el.dataset.path), activeConnection.path);
if (url) {
try {
await navigator.clipboard.writeText(url);
setStatus(`Copied download link to clipboard`);
} catch (err) {
console.error('Clipboard error:', err);
// Fallback
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
setStatus(`Copied download link to clipboard (fallback)`);
} catch (fallbackErr) {
console.error('Fallback failed:', fallbackErr);
setStatus(`Failed to copy link. Please copy manually: ${url}`, true);
}
document.body.removeChild(textarea);
}
}
}))
.addAction(action => action.setLabel('Cancel'))
.show();
});
if (isLocalhost) btnShare.style.display = 'none';
btnDirView.addEventListener('click', () => {
const menu = new ContextMenuBuilder();
menu.addItem(item => item
.setIcon(viewMode === 'list' ? 'check' : '')
.setLabel('List')
.setClickHandler(() => changeFileViewMode('list')));
menu.addItem(item => item
.setIcon(viewMode === 'tiles' ? 'check' : '')
.setLabel('Tiles')
.setClickHandler(() => changeFileViewMode('tiles')));
menu.addSeparator();
menu.addItem(item => item
.setIcon(showHidden ? 'check' : '')
.setLabel('Show hidden files')
.setClickHandler(() => toggleHiddenFileVisibility()));
const rect = btnDirView.getBoundingClientRect();
menu.showAtCoords(rect.left, rect.bottom-5);
});
elFiles.classList.toggle('showHidden', showHidden);
elFiles.classList.add(viewMode);
elFileColHeadings.classList.toggle('tiles', elFiles.classList.contains('tiles'));
btnDirSort.addEventListener('click', () => {
const menu = new ContextMenuBuilder();
menu.addItem(item => item
.setIcon(sortType === 'name' ? 'check' : '')
.setLabel('Name')
.setClickHandler(() => changeFileSortType('name')));
menu.addItem(item => item
.setIcon(sortType === 'date' ? 'check' : '')
.setLabel('Modified')
.setClickHandler(() => changeFileSortType('date')));
menu.addItem(item => item
.setIcon(sortType === 'size' ? 'check' : '')
.setLabel('Size')
.setClickHandler(() => changeFileSortType('size')));
menu.addSeparator();
menu.addItem(item => item
.setIcon(!sortDesc ? 'check' : '')
.setLabel('Ascending')
.setClickHandler(() => changeFileSortDirection(false)));
menu.addItem(item => item
.setIcon(sortDesc ? 'check' : '')
.setLabel('Descending')
.setClickHandler(() => changeFileSortDirection(true)));
const rect = btnDirSort.getBoundingClientRect();
menu.showAtCoords(rect.left, rect.bottom-5);
});
btnDirSelection.addEventListener('click', () => {
const menu = new ContextMenuBuilder()
.addItem(item => item
.setIcon('select_all')
.setLabel('Select all')
.setTooltip('Ctrl + A')
.setClickHandler(selectAllFiles))
.addItem(item => item
.setIcon('select')
.setLabel('Deselect all')
.setTooltip('Ctrl + Shift + A')
.setClickHandler(deselectAllFiles))
.addItem(item => item
.setIcon('move_selection_up')
.setLabel('Invert selection')
.setTooltip('Ctrl + Alt + A')
.setClickHandler(invertFileSelection));
const rect = btnDirSelection.getBoundingClientRect();
menu.showAtCoords(rect.left, rect.bottom-5);
});
elFiles.addEventListener('dragover', e => {
e.preventDefault();
e.stopPropagation();
elFiles.classList.add('dragover');
});
elFiles.addEventListener('dragleave', e => {
e.preventDefault();
e.stopPropagation();
elFiles.classList.remove('dragover');
});
elFiles.addEventListener('drop', e => {
e.preventDefault();
e.stopPropagation();
elFiles.classList.remove('dragover');
const files = [];
for (const file of e.dataTransfer.files) {
if (file.type !== '') {
files.push(file);
}
}
uploadFiles(files);
});
elFiles.addEventListener('contextmenu', e => {
e.preventDefault();
deselectAllFiles();
fileContextMenu();
});
inputSearch.addEventListener('keydown', e => {
if (e.key === 'Enter') {
btnSearchGo.click();
}
});
btnSearchGo.addEventListener('click', () => {
const value = inputSearch.value.trim();
if (value)
searchDirectory(activeConnection.path, inputSearch.value);
});
btnSearchCancel.addEventListener('click', () => {
if (isSearching) changePath(activeConnection.path);
});
window.addEventListener('click', e => {
const matchIds = [ 'controls', 'files', 'filesFiles', 'filesFolders', 'fileColHeadings', 'statusBar' ];
if (!matchIds.includes(e.target.id)) return;
if (!e.ctrlKey) {
if (getIsMobileDevice()) return;
deselectAllFiles();
}
});
window.addEventListener('keydown', e => {
const elActive = document.activeElement;
const isCtrlF = (e.ctrlKey && e.code == 'KeyF');
if (elActive.tagName == 'INPUT' && !isCtrlF) return;
let func = (() => {
if (e.ctrlKey) {
if (e.shiftKey) {
// Ctrl + Shift
if (e.code === 'Space')
return () => connectionManagerDialog();
if (e.code === 'KeyA')
return () => deselectAllFiles();
}
if (e.altKey) {
// Ctrl + Alt
if (e.code === 'KeyA')
return () => invertFileSelection();
}
// Ctrl
if (e.code === 'KeyX')
return () => btnSelectionCut.click();
if (e.code === 'KeyC')
return () => btnSelectionCopy.click();
if (e.code === 'KeyV')
return () => btnSelectionPaste.click();
if (e.code === 'KeyA')
return () => selectAllFiles();
if (e.code === 'KeyR')
return () => btnGo.click();
if (e.code === 'KeyF')
return () => {
inputSearch.focus();
inputSearch.select();
}
}
// Shift
if (e.shiftKey) {
if (e.code === 'KeyD')
return () => btnDownload.click();
if (e.code === 'KeyH')
return () => toggleHiddenFileVisibility();
if (e.code === 'KeyN')
return () => btnDirCreate.click();
if (e.code === 'KeyU')
return () => btnUpload.click();
if (e.code === 'KeyM')
return () => btnSelectionMoveTo.click();
if (e.code === 'KeyC')
return () => btnSelectionCopyTo.click();
}
// Alt
if (e.altKey) {
if (e.code === 'ArrowLeft')
return () => btnNavBack.click();
if (e.code === 'ArrowRight')
return () => btnNavForward.click();
}
// No modifiers
if (e.code === 'F2')
return () => btnRename.click();
if (e.code === 'Delete')
return () => btnSelectionDelete.click();
})();
if (func) {
console.log(`Handling press of ${e.code}`);
e.preventDefault();
func();
}
});
window.addEventListener('resize', () => {
if (window.innerWidth < forceTileViewWidth) {
changeFileViewMode('tiles');
}
});
window.addEventListener('load', async () => {
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.register('/worker.js');
console.log('Service Worker registered with scope:', registration.scope);
}
// Check if the URL matches /connect/:connectionId
const pathMatch = window.location.pathname.match(/^\/connect\/([^/]+)$/);
if (pathMatch) {
const connectionId = pathMatch[1];
const connectionDetails = await fetchConnectionDetails(connectionId);
if (connectionDetails) {
// Clear all existing connections to prevent duplicates
for (const id in connections) {
delete connections[id];
}
// Add the new connection using the connectionId as the key
connections[connectionId] = {
name: `${connectionDetails.username}@${connectionDetails.host}`,
host: connectionDetails.host,
port: connectionDetails.port || 22,
username: connectionDetails.username,
password: connectionDetails.password || '',
key: connectionDetails.privateKey || '',
path: '/minecraft'
};
saveConnections();
setActiveConnection(connectionId);
} else {
// Show connection manager if connection fails
connectionManagerDialog();
}
} else {
// Existing logic for query parameters
const params = new URLSearchParams(window.location.search);
const connection = connections[params.get('con') || '0'];
if (connection) {
setActiveConnection(params.get('con'), params.get('path'));
} else {
connectionManagerDialog();
}
}
window.dispatchEvent(new Event('resize'));
});
// Dynamically update file list relative dates
setInterval(() => {
if (document.hidden) return;
const els = $$('.fileEntry[data-date]', elFiles);
if (els.length > 1000) return;
for (const el of els) {
const timestamp = parseInt(el.dataset.date);
if (!timestamp) continue;
const elDateMain = $('.date', el);
requestAnimationFrame(() => {
const newText = getRelativeDate(timestamp);
if (elDateMain.innerText == newText) return;
elDateMain.innerText = newText;
});
}
}, 1000*60);