'use strict'; 'require view'; 'require fs'; 'require ui'; 'require dom'; 'require rpc'; 'require view.system.filemanager.md as md'; 'require view.system.filemanager.md_help as md_help'; 'require view.system.filemanager.HexEditor as HE'; const callFileList = rpc.declare({ object: 'file', method: 'list', params: [ 'path' ] }); const fileTypes = { 'block' : _('Block device'), 'char' : _('Character device'), 'directory' : _('Directory'), 'fifo' : _('FIFO/Pipe'), 'file' : _('File'), 'socket' : _('Socket'), 'symlink' : _('Symlink'), } function pop(a, message, severity) { ui.addNotification(a, message, severity) } function popTimeout(a, message, timeout, severity) { ui.addTimeLimitedNotification(a, message, timeout, severity) } // Initialize global variables let currentPath = '/'; // Current path in the filesystem const selectedItems = new Set(); // Set of selected files/directories let sortField = 'name'; // Field to sort files by let sortAscending = true; // Sort direction (ascending/descending) let configFilePath = '/etc/config/filemanager'; // Path to the configuration file // Initialize drag counter let dragCounter = 0; // Configuration object to store interface settings let config = { // Column widths in the file table columnWidths: { 'name': 150, 'type': 100, 'size': 100, 'mtime': 150, 'permissions': 70, 'actions': 100, }, // Minimum column widths columnMinWidths: { 'name': 100, 'type': 80, 'size': 80, 'mtime': 120, 'permissions': 70, 'actions': 80, }, // Maximum column widths columnMaxWidths: { 'name': 300, 'type': 200, 'size': 200, 'mtime': 300, 'permissions': 70, 'actions': 200, }, // Padding and window sizes padding: 10, paddingMin: 5, paddingMax: 20, currentDirectory: '/', // Current directory windowHeight: 800, windowWidth: 400, texteditorHeight: 550, texteditorWidth: 850, hexeditorHeight: 550, hexeditorWidth: 850, // otherSettings: {} // Additional settings }; // Function to upload a file to the server function uploadFile(filename, filedata, onProgress) { return new Promise(function(resolve, reject) { let formData = new FormData(); formData.append('sessionid', rpc.getSessionID()); // Add session ID formData.append('filename', filename); // File name including path formData.append('filedata', filedata); // File data let xhr = new XMLHttpRequest(); xhr.open('POST', L.env.cgi_base + '/cgi-upload', true); // Configure the request // Monitor upload progress xhr.upload.onprogress = function(event) { if (event.lengthComputable && onProgress) { let percent = (event.loaded / event.total) * 100; onProgress(percent); // Call the progress callback with percentage } }; // Handle request completion xhr.onload = () => { if (xhr.status === 200) { resolve(xhr.responseText); // Upload successful } else { reject(new Error(xhr.statusText)); // Upload error } }; // Handle network errors xhr.onerror = () => { reject(new Error('Network error')); }; xhr.send(formData); // Send the request }); } // Function to load settings from the configuration file function parseKeyValuePairs(input, delimiter, callback) { const pairs = input.split(','); pairs.forEach((pair) => { const [key, value] = pair.split(delimiter); if (key && value) callback(key.trim(), value.trim()); }); } async function loadConfig() { try { const content = await fs.read(configFilePath); const lines = content.trim().split('\n'); lines.forEach((line) => { if (!line.includes('option')) return; const splitLines = line.split('option').filter(Boolean); splitLines.forEach((subline) => { const formattedLine = "option " + subline.trim(); const match = formattedLine.match(/^option\s+(\S+)\s+'([^']+)'$/); if (!match) return; const [, key, value] = match; switch (key) { case 'columnWidths': case 'columnMinWidths': case 'columnMaxWidths': parseKeyValuePairs(value, ':', (k, v) => { config[key] = config[key] || {}; config[key][k] = parseInt(v, 10); }); break; default: config[key] = isNaN(value) ? value : parseInt(value, 10); } }); }); } catch (err) { console.error('Failed to load config: ' + err.message); } } // Function to save settings to the configuration file function saveConfig() { let configLines = ['config filemanager', '\toption columnWidths \'' + Object.keys(config.columnWidths).map((field) => { return field + ':' + config.columnWidths[field]; }).join(',') + '\'', '\toption columnMinWidths \'' + Object.keys(config.columnMinWidths).map((field) => { return field + ':' + config.columnMinWidths[field]; }).join(',') + '\'', '\toption columnMaxWidths \'' + Object.keys(config.columnMaxWidths).map((field) => { return field + ':' + config.columnMaxWidths[field]; }).join(',') + '\'', '\toption padding \'' + config.padding + '\'', '\toption paddingMin \'' + config.paddingMin + '\'', '\toption paddingMax \'' + config.paddingMax + '\'', '\toption currentDirectory \'' + config.currentDirectory + '\'', '\toption windowHeight \'' + config.windowHeight + '\'', '\toption windowWidth \'' + config.windowWidth + '\'', '\toption texteditorWidth \'' + config.texteditorWidth + '\'', '\toption texteditorHeight \'' + config.texteditorHeight + '\'', '\toption hexeditorWidth \'' + config.hexeditorWidth + '\'', '\toption hexeditorHeight \'' + config.hexeditorHeight + '\'', ]; const configContent = configLines.join('\n') + '\n'; // Write settings to file return fs.write(configFilePath, configContent).then(() => { return Promise.resolve(); }).catch((err) => { return Promise.reject(new Error('Failed to save configuration: ' + err.message)); }); } // Function to correctly join paths function joinPath(path, name) { return path.endsWith('/') ? path + name : path + '/' + name; } function modeToRwx(mode) { const perms = mode & 0o777; // extract permission bits const toRwx = n => ((n & 4) ? 'r' : '-') + ((n & 2) ? 'w' : '-') + ((n & 1) ? 'x' : '-'); const owner = toRwx((perms >> 6) & 0b111); const group = toRwx((perms >> 3) & 0b111); const world = toRwx(perms & 0b111); return `${owner}${group}${world}`; } function modeToOctal(mode) { const perms = mode & 0o777; return perms.toString(8); } // Function to get a list of files in a directory function getFileList(path) { return callFileList(path).then((res) => { const files = []; res?.entries?.forEach((file) => { files.push({ ...file, permissions: modeToRwx(file.mode), numericPermissions: modeToOctal(file.mode), }); }); return files; }); } // Function to insert CSS styles into the document function insertCss(cssContent) { const styleElement = document.createElement('style'); styleElement.type = 'text/css'; styleElement.appendChild(document.createTextNode(cssContent)); document.head.appendChild(styleElement); } // CSS styles for the file manager interface const cssContent = ` .cbi-button-apply, .cbi-button-reset, .cbi-button-save:not(.custom-save-button) { display: none !important; } .cbi-page-actions { background: none !important; border: none !important; padding: ${config.padding}px 0 !important; margin: 0 !important; display: flex; justify-content: flex-start; margin-top: 10px; } .cbi-tabmenu { background: none !important; border: none !important; margin: 0 !important; padding: 0 !important; } .cbi-tabmenu li { display: inline-block; margin-right: 10px; } #file-list-container { margin-top: 30px !important; overflow: auto; border: 1px solid #ccc; padding: 0; min-width: 600px; position: relative; resize: both; } #file-list-container.drag-over { border: 2px dashed #00BFFF; background-color: rgba(0, 191, 255, 0.1); } /* Add extra space to the left of the Name and Type columns */ .table th:nth-child(1), .table td:nth-child(1), /* Name column */ .table th:nth-child(2), .table td:nth-child(2) { /* Type column */ padding-left: 5px; /* Adjust this value for the desired spacing */ } /* Add extra space to the right of the Size column */ .table th:nth-child(3), .table td:nth-child(3) { /* Size column */ padding-right: 5px; /* Adjust this value for the desired spacing */ } /* Add extra space to the left of the Size column header */ .table th:nth-child(3) { /* Size column header */ padding-left: 15px; /* Adjust this value for the desired spacing */ } #drag-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 191, 255, 0.2); display: flex; align-items: center; justify-content: center; font-size: 24px; color: #00BFFF; z-index: 10; pointer-events: none; } #content-editor { margin-top: 30px !important; } .editor-container { display: flex; flex-direction: column; resize: both; overflow: hidden; } .editor-content { flex: 1; display: flex; overflow: hidden; } .line-numbers { width: 50px; background-color: #f0f0f0; text-align: right; padding-right: 5px; user-select: none; border-right: 1px solid #ccc; overflow: hidden; flex-shrink: 0; -ms-overflow-style: none; /* Hide scrollbar in IE и Edge */ scrollbar-width: none; /* Hide scrollbar in Firefox */ } .line-numbers::-webkit-scrollbar { display: none; /* Hide scrollbar in Chrome, Safari и Opera */ } .line-numbers div { font-family: monospace; font-size: 14px; line-height: 1.2em; height: 1.2em; } #editor-message { font-size: 18px; font-weight: bold; } #editor-textarea { flex: 1; resize: none; border: none; font-family: monospace; font-size: 14px; line-height: 1.2em; padding: 0; margin: 0; overflow: auto; box-sizing: border-box; } #editor-textarea, .line-numbers { overflow-y: scroll; } th { text-align: left !important; position: sticky; top: 0; border-right: 1px solid #ddd; box-sizing: border-box; padding-right: 30px; white-space: nowrap; min-width: 100px; background-color: #fff; z-index: 2; } td { text-align: left !important; border-right: 1px solid #ddd; box-sizing: border-box; white-space: nowrap; min-width: 100px; overflow: hidden; text-overflow: ellipsis; } tr:hover { background-color: #f0f0f0 !important; } .download-button { color: green; cursor: pointer; margin-left: 5px; } .delete-button { color: red; cursor: pointer; margin-left: 5px; } .edit-button { color: blue; cursor: pointer; margin-left: 5px; } .duplicate-button { color: orange; cursor: pointer; margin-left: 5px; } .symlink { color: green; } .status-link { color: blue; text-decoration: underline; cursor: pointer; } .action-button { margin-right: 10px; cursor: pointer; } .size-cell { font-family: monospace; box-sizing: border-box; white-space: nowrap; align-items: center; } .size-number { display: inline-block; width: 8ch; text-align: right; } .size-unit { display: inline-block; width: 4ch; text-align: right; margin-left: 0.5ch; } .table { table-layout: fixed; border-collapse: collapse; white-space: nowrap; width: 100%; } .table th:nth-child(3), .table td:nth-child(3) { width: 100px; min-width: 100px; max-width: 500px; } .table th:nth-child(3) + th, .table td:nth-child(3) + td { padding-left: 10px; } .resizer { position: absolute; right: 0; top: 0; width: 5px; height: 100%; cursor: col-resize; user-select: none; z-index: 3; } .resizer::after { content: ""; position: absolute; right: 2px; top: 0; width: 1px; height: 100%; background: #aaa; } #file-list-container.resizeable { resize: both; overflow: auto; } .sort-button { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: 1px solid #ccc; /* Add a visible border */ color: #fff; /* White text color for better contrast on dark backgrounds */ cursor: pointer; padding: 2px 5px; /* Add padding for better clickability */ font-size: 12px; /* Set font size */ border-radius: 4px; /* Rounded corners for a better appearance */ background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent black background */ transition: background-color 0.3s, color 0.3s; /* Smooth transition effects for hover */ } .sort-button:hover { background-color: #fff; /* Change background to white on hover */ color: #000; /* Change text color to black on hover */ border-color: #fff; /* White border on hover */ } .sort-button:focus { outline: none; } #status-bar { margin-top: 10px; padding: 10px; background-color: #f9f9f9; border: 1px solid #ccc; min-height: 40px; display: flex; align-items: center; justify-content: space-between; } #status-info { font-weight: bold; display: flex; align-items: center; } #status-progress { width: 50%; } .cbi-progressbar { width: 100%; background-color: #e0e0e0; border-radius: 5px; overflow: hidden; height: 10px; } .cbi-progressbar div { height: 100%; background-color: #76c7c0; width: 0%; transition: width 0.2s; } .file-manager-header { display: flex; align-items: center; } .file-manager-header h2 { margin: 0; } .file-manager-header input { margin-left: 10px; width: 100%; max-width: 700px; font-size: 18px; } .file-manager-header button { margin-left: 10px; font-size: 18px; } .directory-link { /* Choose a color with good contrast or let the theme decide */ color: #00BFFF; /* DeepSkyBlue */ font-weight: bold; } .file-link { color: inherit; /* Use the default text color */ } `; // Main exported view module return view.extend({ editorMode: 'text', hexEditorInstance: null, // Method called when the view is loaded load() { const self = this; return loadConfig().then(() => { currentPath = config.currentDirectory || '/'; return getFileList(currentPath); // Load the file list for the current directory }); }, // Method to render the interface render(data) { const self = this; insertCss(cssContent); // Insert CSS styles const viewContainer = E('div', { 'id': 'file-manager-container' }, [ // File Manager Header E('div', { 'class': 'file-manager-header' }, [ E('h2', {}, _('File Manager: ')), E('input', { 'type': 'text', 'id': 'path-input', 'value': currentPath, 'style': 'margin-left: 10px;', 'keydown'(event) { if (event.key === 'Enter') { self.handleGoButtonClick(); // Trigger directory navigation on Enter } } }), E('button', { 'id': 'go-button', 'click': this.handleGoButtonClick.bind(this), 'style': 'margin-left: 10px;' }, _('Go')) ]), // Tab Panels E('div', { 'class': 'cbi-tabcontainer', 'id': 'tab-group' }, [ E('ul', { 'class': 'cbi-tabmenu' }, [ E('li', { 'class': 'cbi-tab cbi-tab-active', 'id': 'tab-filemanager' }, [ E('a', { 'href': '#', 'click': this.switchToTab.bind(this, 'filemanager') }, _('File Manager')) ]), E('li', { 'class': 'cbi-tab', 'id': 'tab-editor' }, [ E('a', { 'href': '#', 'click': this.switchToTab.bind(this, 'editor') }, _('Editor')) ]), E('li', { 'class': 'cbi-tab', 'id': 'tab-settings' }, [ E('a', { 'href': '#', 'click': this.switchToTab.bind(this, 'settings') }, _('Settings')) ]), // Help Tab E('li', { 'class': 'cbi-tab', 'id': 'tab-help' }, [ E('a', { 'href': '#', 'click': this.switchToTab.bind(this, 'help') }, _('Help')) ]) ]) ]), // Tab Contents E('div', { 'class': 'cbi-tabcontainer-content' }, [ // File Manager Content E('div', { 'id': 'content-filemanager', 'class': 'cbi-tab', 'style': 'display:block;' }, [ // File List Container with Drag-and-Drop (() => { // Create the container for the file list and drag-and-drop functionality const fileListContainer = E('div', { 'id': 'file-list-container', 'class': 'resizeable', 'style': 'width: ' + config.windowWidth + 'px; height: ' + config.windowHeight + 'px;' }, [ E('table', { 'class': 'table', 'id': 'file-table' }, [ E('thead', {}, [ E('tr', {}, [ E('th', { 'data-field': 'name' }, [ _('Name'), E('button', { 'class': 'sort-button', 'data-field': 'name', 'title': _('Sort by Name') }, '↕'), E('div', { 'class': 'resizer' }) ]), E('th', { 'data-field': 'permissions' }, [ _('Permissions'), E('button', { 'class': 'sort-button', 'data-field': 'permissions', 'title': _('Sort by Permissions') }, '↕'), E('div', { 'class': 'resizer' }) ]), E('th', { 'data-field': 'type' }, [ _('Type'), E('button', { 'class': 'sort-button', 'data-field': 'type', 'title': _('Sort by Type') }, '↕'), E('div', { 'class': 'resizer' }) ]), E('th', { 'data-field': 'size' }, [ _('Size'), E('button', { 'class': 'sort-button', 'data-field': 'size', 'title': _('Sort by Size') }, '↕'), E('div', { 'class': 'resizer' }) ]), E('th', { 'data-field': 'mtime' }, [ _('Last Modified'), E('button', { 'class': 'sort-button', 'data-field': 'mtime', 'title': _('Sort by Last Modified') }, '↕'), E('div', { 'class': 'resizer' }) ]), E('th', {'data-field': 'actions'}, [ E('input', { 'type': 'checkbox', 'id': 'select-all-checkbox', 'style': 'margin-right: 5px;', 'change': this.handleSelectAllChange.bind(this), 'click': this.handleSelectAllClick.bind(this) }), _('Actions') ]) ]) ]), E('tbody', { 'id': 'file-list' }) ]), E('div', { 'id': 'drag-overlay', 'style': 'display:none;' }, _('Drop files here to upload')) ]); // Attach drag-and-drop event listeners fileListContainer.addEventListener('dragenter', this.handleDragEnter.bind(this)); fileListContainer.addEventListener('dragover', this.handleDragOver.bind(this)); fileListContainer.addEventListener('dragleave', this.handleDragLeave.bind(this)); fileListContainer.addEventListener('drop', this.handleDrop.bind(this)); return fileListContainer; }).call(this), // Ensure 'this' context is preserved // Status Bar E('div', { 'id': 'status-bar' }, [ E('div', { 'id': 'status-info' }, _('No file selected.')), E('div', { 'id': 'status-progress' }) ]), // Page Actions E('div', { 'class': 'cbi-page-actions' }, [ E('button', { 'class': 'btn action-button', 'click': this.handleUploadClick.bind(this) }, _('Upload File')), E('button', { 'class': 'btn action-button', 'click': this.handleMakeDirectoryClick.bind(this) }, _('Create Folder')), E('button', { 'class': 'btn action-button', 'click': this.handleCreateFileClick.bind(this) }, _('Create File')), E('button', { 'id': 'delete-selected-button', 'class': 'btn action-button', 'style': 'display: none;', 'click': this.handleDeleteSelected.bind(this) }, _('Delete Selected')) ]) ]), // Editor Content E('div', { 'id': 'content-editor', 'class': 'cbi-tab', 'style': 'display:none;' }, [ E('p', { 'id': 'editor-message' }, _('Select a file from the list to edit it here.')), E('div', { 'id': 'editor-container' }) ]), // Help Content E('div', { 'id': 'content-help', 'class': 'cbi-tab', 'style': 'display:none; padding: 10px; overflow:auto; width: 650px; height: 600px; resize: both; border: 1px solid #ccc; box-sizing: border-box;' }, [ // The content will be dynamically inserted by renderHelp() ]), // Settings Content E('div', { 'id': 'content-settings', 'class': 'cbi-tab', 'style': 'display:none;' }, [ E('div', { 'style': 'margin-top: 20px;' }, [ E('h3', {}, _('Interface Settings')), E('div', { 'id': 'settings-container' }, [ E('form', { 'id': 'settings-form' }, [ E('div', {}, [ E('label', {}, _('Window Width:')), E('input', { 'type': 'number', 'id': 'windowWidth-input', 'value': config.windowWidth, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Window Height:')), E('input', { 'type': 'number', 'id': 'windowHeight-input', 'value': config.windowHeight, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Text Editor Width:')), E('input', { 'type': 'number', 'id': 'texteditorWidth-input', 'value': config.texteditorWidth, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Text Editor Height:')), E('input', { 'type': 'number', 'id': 'texteditorHeight-input', 'value': config.texteditorHeight, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Hex Editor Width:')), E('input', { 'type': 'number', 'id': 'hexeditorWidth-input', 'value': config.hexeditorWidth, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Hex Editor Height:')), E('input', { 'type': 'number', 'id': 'hexeditorHeight-input', 'value': config.hexeditorHeight, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Column Widths (format: name:width,type:width,...):')), E('input', { 'type': 'text', 'id': 'columnWidths-input', 'value': Object.values(config.columnWidths).join(''), 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Column Min Widths (format: name:minWidth,type:minWidth,...):')), E('input', { 'type': 'text', 'id': 'columnMinWidths-input', 'value': Object.values(config.columnMinWidths).join(''), 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Column Max Widths (format: name:maxWidth,type:maxWidth,...):')), E('input', { 'type': 'text', 'id': 'columnMaxWidths-input', 'value': Object.values(config.columnMaxWidths).join(''), 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Padding:')), E('input', { 'type': 'number', 'id': 'padding-input', 'value': config.padding, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Padding Min:')), E('input', { 'type': 'number', 'id': 'paddingMin-input', 'value': config.paddingMin, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Padding Max:')), E('input', { 'type': 'number', 'id': 'paddingMax-input', 'value': config.paddingMax, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', {}, [ E('label', {}, _('Current Directory:')), E('input', { 'type': 'text', 'id': 'currentDirectory-input', 'value': config.currentDirectory, 'style': 'width:100%; margin-bottom:10px;' }) ]), E('div', { 'class': 'cbi-page-actions' }, [ E('button', { 'class': 'btn cbi-button-save custom-save-button', 'click': this.handleSaveSettings.bind(this) }, _('Save')) ]) ]) ]) ]) ]) ]) ]); // Add event listeners const sortButtons = viewContainer.querySelectorAll('.sort-button[data-field]'); sortButtons.forEach((button) => { button.addEventListener('click', (event) => { event.preventDefault(); const field = button.getAttribute('data-field'); if (field) { self.sortBy(field); // Sort the file list by the selected field } }); }); // Load the file list and initialize resizeable columns this.loadFileList(currentPath).then(() => { self.initResizeableColumns(); const fileListContainer = document.getElementById('file-list-container'); if (fileListContainer && typeof ResizeObserver !== 'undefined') { // Initialize ResizeObserver only once if (!self.fileListResizeObserver) { self.fileListResizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { const newWidth = entry.contentRect.width; const newHeight = entry.contentRect.height; // Update config only if newWidth and newHeight are greater than 0 if (newWidth > 0 && newHeight > 0) { config.windowWidth = newWidth; config.windowHeight = newHeight; } } }); self.fileListResizeObserver.observe(fileListContainer); } } }); return viewContainer; }, // Handler for the "Select All" checkbox click handleSelectAllClick(ev) { if (ev.altKey) { ev.preventDefault(); // Prevent the default checkbox behavior this.handleInvertSelection(); } else { // Proceed with normal click handling; the 'change' event will be triggered } }, // Function to invert selection handleInvertSelection() { const allCheckboxes = document.querySelectorAll('.select-checkbox'); allCheckboxes.forEach((checkbox) => { checkbox.checked = !checkbox.checked; const filePath = checkbox.getAttribute('data-file-path'); if (checkbox.checked) { selectedItems.add(filePath); } else { selectedItems.delete(filePath); } }); // Update the "Select All" checkbox state this.updateSelectAllCheckbox(); // Update the "Delete Selected" button visibility this.updateDeleteSelectedButton(); }, /** * Switches the active tab in the interface and performs necessary actions based on the selected tab. * * @param {string} tab - The identifier of the tab to switch to ('filemanager', 'editor', 'settings', or 'help'). */ switchToTab(tab) { // Retrieve the content containers for each tab const fileManagerContent = document.getElementById('content-filemanager'); const editorContent = document.getElementById('content-editor'); const settingsContent = document.getElementById('content-settings'); const helpContent = document.getElementById('content-help'); // Retrieve the tab elements const tabFileManager = document.getElementById('tab-filemanager'); const tabEditor = document.getElementById('tab-editor'); const tabSettings = document.getElementById('tab-settings'); const tabHelp = document.getElementById('tab-help'); // Ensure all necessary elements are present if (fileManagerContent && editorContent && settingsContent && helpContent && tabFileManager && tabEditor && tabSettings && tabHelp) { // Display the selected tab's content and hide the others fileManagerContent.style.display = (tab === 'filemanager') ? 'block' : 'none'; editorContent.style.display = (tab === 'editor') ? 'block' : 'none'; settingsContent.style.display = (tab === 'settings') ? 'block' : 'none'; helpContent.style.display = (tab === 'help') ? 'block' : 'none'; // Update the active tab's styling tabFileManager.className = (tab === 'filemanager') ? 'cbi-tab cbi-tab-active' : 'cbi-tab'; tabEditor.className = (tab === 'editor') ? 'cbi-tab cbi-tab-active' : 'cbi-tab'; tabSettings.className = (tab === 'settings') ? 'cbi-tab cbi-tab-active' : 'cbi-tab'; tabHelp.className = (tab === 'help') ? 'cbi-tab cbi-tab-active' : 'cbi-tab'; // Perform actions based on the selected tab if (tab === 'filemanager') { // Reload and display the updated file list when the File Manager tab is activated this.loadFileList(currentPath) .then(() => { // Initialize resizeable columns after successfully loading the file list this.initResizeableColumns(); }) .catch((err) => { // Display an error notification if loading the file list fails pop(null, E('p', _('Failed to update file list: %s').format(err.message)), 'error'); }); } else if (tab === 'settings') { // Load and display settings when the Settings tab is activated this.loadSettings(); } else if (tab === 'help') { // Render the Help content when the Help tab is activated this.renderHelp(); } // No additional actions are required for the Editor tab in this context } }, /** * Renders the Help content by converting Markdown to HTML and inserting it into the Help container. */ renderHelp() { const self = this; // Convert Markdown to HTML const helpContentHTML = md.parseMarkdown(md_help.helpContentMarkdown); // Get the Help content container const helpContent = document.getElementById('content-help'); if (helpContent) { // Insert the converted HTML into the Help container helpContent.innerHTML = helpContentHTML; // Initialize resizeable functionality for the Help window self.initResizeableHelp(); } else { console.error('Help content container not found.'); pop(null, E('p', _('Failed to render Help content: Container not found.')), 'error'); } }, /** * Initializes the resizeable functionality for the Help window. */ initResizeableHelp() { const helpContent = document.getElementById('content-help'); if (helpContent) { // Set initial dimensions helpContent.style.width = '700px'; helpContent.style.height = '600px'; helpContent.style.resize = 'both'; helpContent.style.overflow = 'auto'; helpContent.style.border = '1px solid #ccc'; helpContent.style.padding = '10px'; helpContent.style.boxSizing = 'border-box'; // Optional: Add a drag handle for better user experience /* var dragHandle = E('div', { 'class': 'resize-handle', 'style': 'width: 10px; height: 10px; background: #ccc; position: absolute; bottom: 0; right: 0; cursor: se-resize;' }); helpContent.appendChild(dragHandle); */ } else { console.error('Help content container not found for resizing.'); } }, // Handler for the "Go" button click to navigate to a directory handleGoButtonClick() { // Logic to navigate to the specified directory and update the file list const self = this; const pathInput = document.getElementById('path-input'); if (pathInput) { const newPath = pathInput.value.trim() || '/'; fs.stat(newPath).then((stat) => { if (stat.type === 'directory') { currentPath = newPath; pathInput.value = currentPath; self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); } else { pop(null, E('p', _('The specified path does not appear to be a directory.')), 'error'); } }).catch((err) => { pop(null, E('p', _('Failed to access the specified path: %s').format(err.message)), 'error'); }); } }, // Handler for dragging files over the drop zone handleDragEnter(event) { event.preventDefault(); event.stopPropagation(); dragCounter++; const fileListContainer = document.getElementById('file-list-container'); const dragOverlay = document.getElementById('drag-overlay'); if (fileListContainer && dragOverlay) { fileListContainer.classList.add('drag-over'); dragOverlay.style.display = 'flex'; } }, // Handler for when files are over the drop zone handleDragOver(event) { event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; // Indicate copy action }, // Handler for leaving the drop zone handleDragLeave(event) { event.preventDefault(); event.stopPropagation(); dragCounter--; if (dragCounter === 0) { const fileListContainer = document.getElementById('file-list-container'); const dragOverlay = document.getElementById('drag-overlay'); if (fileListContainer && dragOverlay) { fileListContainer.classList.remove('drag-over'); dragOverlay.style.display = 'none'; } } }, // Handler for dropping files into the drop zone handleDrop(event) { event.preventDefault(); event.stopPropagation(); dragCounter = 0; // Reset counter const self = this; const files = event.dataTransfer.files; const fileListContainer = document.getElementById('file-list-container'); const dragOverlay = document.getElementById('drag-overlay'); if (fileListContainer && dragOverlay) { fileListContainer.classList.remove('drag-over'); dragOverlay.style.display = 'none'; } if (files.length > 0) { self.uploadFiles(files); } }, // Handler for uploading a file handleUploadClick(ev) { const self = this; const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.multiple = true; // Allow selecting multiple files fileInput.style.display = 'none'; document.body.appendChild(fileInput); fileInput.onchange = (event) => { const files = event.target.files; if (!files || files.length === 0) { pop(null, E('p', _('No file selected.')), 'error'); return; } self.uploadFiles(files); // Use the shared upload function }; fileInput.click(); }, uploadFiles(files) { const self = this; const directoryPath = currentPath; const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); const totalFiles = files.length; let uploadedFiles = 0; function uploadNextFile(index) { if (index >= totalFiles) { self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); return; } const file = files[index]; const fullFilePath = joinPath(directoryPath, file.name); if (statusInfo) { statusInfo.textContent = _('Uploading: "%s"...').format(file.name); } if (statusProgress) { statusProgress.innerHTML = ''; const progressBarContainer = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, [E('div', { 'style': 'width:0%' })]); statusProgress.appendChild(progressBarContainer); } uploadFile(fullFilePath, file, (percent) => { if (statusProgress) { const progressBar = statusProgress.querySelector('.cbi-progressbar div'); if (progressBar) { progressBar.style.width = percent.toFixed(2) + '%'; statusProgress.querySelector('.cbi-progressbar').setAttribute('title', percent.toFixed(2) + '%'); } } }).then(() => { if (statusProgress) { statusProgress.innerHTML = ''; } if (statusInfo) { statusInfo.textContent = _('File "%s" uploaded successfully.').format(file.name); } popTimeout(null, E('p', _('File "%s" uploaded successfully.').format(file.name)), 5000, 'info'); uploadedFiles++; uploadNextFile(index + 1); }).catch((err) => { if (statusProgress) { statusProgress.innerHTML = ''; } if (statusInfo) { statusInfo.textContent = _('Upload failed for file "%s": %s').format(file.name, err.message); } pop(null, E('p', _('Upload failed for file "%s": %s').format(file.name, err.message)), 'error'); uploadNextFile(index + 1); }); } uploadNextFile(0); }, // Handler for creating a directory handleMakeDirectoryClick(ev) { // Logic to create a new directory const self = this; const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo && statusProgress) { statusInfo.innerHTML = ''; statusProgress.innerHTML = ''; const dirNameInput = E('input', { 'type': 'text', 'placeholder': _('Directory Name'), 'style': 'margin-right: 10px;' }); const saveButton = E('button', { 'class': 'btn', 'disabled': true, 'click'() { self.createDirectory(dirNameInput.value); } }, _('Save')); dirNameInput.addEventListener('input', () => { if (dirNameInput.value.trim()) { saveButton.disabled = false; } else { saveButton.disabled = true; } }); statusInfo.appendChild(E('span', {}, _('Create Directory: '))); statusInfo.appendChild(dirNameInput); statusProgress.appendChild(saveButton); } }, // Function to create a directory createDirectory(dirName) { // Execute the 'mkdir' command and update the interface const self = this; const trimmedDirName = dirName.trim(); const dirPath = joinPath(currentPath, trimmedDirName); fs.exec('mkdir', [dirPath]).then((res) => { if (res.code !== 0) { return Promise.reject(new Error(res.stderr.trim())); } popTimeout(null, E('p', _('Directory "%s" created successfully.').format(trimmedDirName)), 5000, 'info'); self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo) statusInfo.textContent = _('No directory selected.'); if (statusProgress) statusProgress.innerHTML = ''; }).catch((err) => { pop(null, E('p', _('Failed to create directory "%s": %s').format(trimmedDirName, err.message)), 'error'); }); }, // Handler for creating a file handleCreateFileClick(ev) { // Logic to create a new file const self = this; const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo && statusProgress) { statusInfo.innerHTML = ''; statusProgress.innerHTML = ''; const fileNameInput = E('input', { 'type': 'text', 'placeholder': _('File Name'), 'style': 'margin-right: 10px;' }); const createButton = E('button', { 'class': 'btn', 'disabled': true, 'click'() { self.createFile(fileNameInput.value); } }, _('Create')); fileNameInput.addEventListener('input', () => { if (fileNameInput.value.trim()) { createButton.disabled = false; } else { createButton.disabled = true; } }); statusInfo.appendChild(E('span', {}, _('Create File: '))); statusInfo.appendChild(fileNameInput); statusProgress.appendChild(createButton); } }, // Function to create a file createFile(fileName) { // Execute the 'touch' command and update the interface const self = this; const trimmedFileName = fileName.trim(); const filePath = joinPath(currentPath, trimmedFileName); fs.exec('touch', [filePath]).then((res) => { if (res.code !== 0) { return Promise.reject(new Error(res.stderr.trim())); } popTimeout(null, E('p', _('File "%s" created successfully.').format(trimmedFileName)), 5000, 'info'); self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo) statusInfo.textContent = _('No file selected.'); if (statusProgress) statusProgress.innerHTML = ''; }).catch((err) => { pop(null, E('p', _('Failed to create file "%s": %s').format(trimmedFileName, err.message)), 'error'); }); }, // Handler for checkbox state change on a file handleCheckboxChange(ev) { const cb = ev.target; const filePath = cb.dataset.filePath; cb.checked ? selectedItems.add(filePath) : selectedItems.delete(filePath); this.updateDeleteSelectedButton(); this.updateSelectAllCheckbox(); }, // Update the "Delete Selected" button updateDeleteSelectedButton() { const btn = document.getElementById('delete-selected-button'); if (!btn) return; btn.style.display = selectedItems.size > 0 ? '' : 'none'; }, // Update the "Select All" checkbox state updateSelectAllCheckbox() { const selectAll = document.getElementById('select-all-checkbox'); if (!selectAll) return; const checkboxes = [...document.querySelectorAll('.select-checkbox')]; if (checkboxes.length === 0) { selectAll.checked = false; selectAll.indeterminate = false; return; } const total = checkboxes.length; const checked = checkboxes.filter(cb => cb.checked).length; selectAll.checked = checked === total; selectAll.indeterminate = checked > 0 && checked < total; }, // Handler for the "Select All" checkbox change handleSelectAllChange(ev) { const checked = ev.target.checked; const checkboxes = [...document.querySelectorAll('.select-checkbox')]; selectedItems.clear(); checkboxes.forEach(cb => { cb.checked = checked; if (checked) selectedItems.add(cb.dataset.filePath); }); this.updateDeleteSelectedButton(); this.updateSelectAllCheckbox(); }, // Handler for deleting selected items handleDeleteSelected() { // Delete selected files and directories const self = this; if (selectedItems.size === 0) { return; } if (!confirm(_('Are you sure you want to delete the selected files and directories?'))) { return; } const promises = []; selectedItems.forEach((filePath) => { promises.push(fs.remove(filePath).catch((err) => { pop(null, E('p', _('Failed to delete %s: %s').format(filePath, err.message)), 'error'); })); }); Promise.all(promises).then(() => { popTimeout(null, E('p', _('Selected files and directories deleted successfully.')), 5000, 'info'); selectedItems.clear(); self.updateDeleteSelectedButton(); self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); }).catch((err) => { pop(null, E('p', _('Failed to delete selected files and directories: %s').format(err.message)), 'error'); }); }, // Function to load the file list loadFileList(path) { const self = this; selectedItems.clear(); return getFileList(path).then(files => { // 1. Get column order dynamically from table header const columns = Array.from( document.querySelectorAll('#file-table thead th[data-field]') ).map(th => th.getAttribute('data-field')); const fileList = document.getElementById('file-list'); if (!fileList) { pop(null, E('p', _('Failed to display the file list.')), 'error'); return; } fileList.innerHTML = ''; files.sort(self.compareFiles.bind(self)); // // Add ".." parent row // if (path !== '/') { const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'; const tr = E('tr', { 'data-file-path': parentPath, 'data-file-type': 'directory' }); // Create cells for *every* column for (const col of columns) { if (col === 'name') { tr.appendChild( E('td', { colspan: columns.length }, [ E('a', { href: '#', click: () => self.handleDirectoryClick(parentPath) }, '.. (Parent Directory)') ]) ); break; } else { tr.appendChild(E('td')); // empty cell } } fileList.appendChild(tr); } // // 2. For each file, create row dynamically // for (const file of files) { const fullPath = joinPath(path, file.name); const tr = E('tr', { 'data-file-path': fullPath, 'data-file-type': file.type, 'data-permissions': file.permissions, 'data-numeric-permissions': file.numericPermissions, 'data-owner': file?.user || file.uid, 'data-group': file?.group || file.gid, 'data-size': file.size }); // // Prebuild common reusable items // const nameLink = E('a', { href: '#', title: file.permissions, class: `${file.type}-link`, click(event) { if (file.type === 'directory' || file?.target?.type === 'directory') { self.handleDirectoryClick(fullPath); } else { event.preventDefault(); self.handleFileClick(fullPath, event.altKey ? 'hex' : 'text'); } } }, file?.target ? `${file.name} → ${file.target?.name}` : file.name); const actions = []; const checkbox = E('input', { type: 'checkbox', class: 'select-checkbox', 'data-file-path': fullPath, change: ev => self.handleCheckboxChange(ev) }); actions.push(checkbox); actions.push(E('span', { class: 'edit-button', title: _('Edit properties'), click: () => self.handleEditFile(fullPath, file) }, '✏️')); actions.push(E('span', { class: 'duplicate-button', title: _('Duplicate'), click: () => self.handleDuplicateFile(fullPath, file) }, '📑')); actions.push(E('span', { class: 'delete-button', title: _('Delete'), click: () => self.handleDeleteFile(fullPath, file) }, '🗑️')); if (file.type === 'file') { actions.push(E('span', { class: 'download-button', title: _('Download'), click: () => self.handleDownloadFile(fullPath) }, '⬇️')); } // // 3. Build `` dynamically based on column definitions // for (const col of columns) { let td; switch (col) { case 'name': td = E('td', {}, [nameLink]); break; case 'type': td = E('td', {}, fileTypes[file.type] || file.type); break; case 'size': if (file.type === 'directory' || (file.type === 'symlink' && file.size === -1)) { td = E('td', { class: 'size-cell' }, [ E('span', { class: 'size-number' }, '-'), E('span', { class: 'size-unit' }, ''), ]); } else { const formatted = self.getFormattedSize(file.size); td = E('td', { class: 'size-cell' }, [ E('span', { class: 'size-number' }, formatted.number), E('span', { class: 'size-unit' }, formatted.unit) ]); } break; case 'mtime': td = E('td', {}, new Date(file.mtime * 1000).toLocaleString()); break; case 'actions': td = E('td', {}, actions); break; case 'permissions': td = E('td', {}, file.permissions); break; default: // Support future dynamically-added columns td = E('td', {}, file[col] ?? ''); break; } tr.appendChild(td); } fileList.appendChild(tr); } // // housekeeping // const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo) statusInfo.textContent = _('No file selected.'); if (statusProgress) statusProgress.innerHTML = ''; self.setInitialColumnWidths(); self.updateSelectAllCheckbox(); self.updateDeleteSelectedButton(); return Promise.resolve(); }).catch((err) => { pop(null, E('p', _('Failed to load file list: %s').format(err.message)), 'error'); return Promise.reject(err); }); }, // Function to format file size getFormattedSize(size) { /* 64 bit systems i.e. rpcd have max size of 128 TB */ const units = [' ', 'K', 'M', 'G', 'T']; let index = 0; let value = size; if (size > 0) { // Keep dividing until below 1024 or no more units while (value >= 1024 && index < units.length - 1) { value /= 1024; index++; } } // Format to 2 decimals, always 6 chars wide const num = value.toFixed(2).padStart(6, ' '); return { number: num, unit: ' ' + units[index] + 'B' }; }, // Function to sort files sortBy(field) { // Change the sort field and direction, and reload the file list if (sortField === field) { sortAscending = !sortAscending; } else { sortField = field; sortAscending = true; } this.loadFileList(currentPath); }, // Function to compare files for sorting compareFiles(a, b) { // Compare files based on the selected field and direction const order = sortAscending ? 1 : -1; let aValue = a[sortField]; let bValue = b[sortField]; if (sortField === 'size') { aValue = (a.type === 'directory' || (a.type === 'symlink' && a.size === -1)) ? -1 : a.size; bValue = (b.type === 'directory' || (b.type === 'symlink' && b.size === -1)) ? -1 : b.size; } if (aValue < bValue) return -1 * order; if (aValue > bValue) return 1 * order; return 0; }, // Set initial column widths in the table setInitialColumnWidths() { // Apply column width settings to the file table const table = document.getElementById('file-table'); if (!table) { return; } const headers = table.querySelectorAll('th'); headers.forEach((header, index) => { const field = header.getAttribute('data-field'); if (field && config.columnWidths[field]) { const width = config.columnWidths[field]; const minWidth = config.columnMinWidths[field] || 50; const maxWidth = config.columnMaxWidths[field] || 500; header.style.width = width + 'px'; header.style.minWidth = minWidth + 'px'; header.style.maxWidth = maxWidth + 'px'; const rows = table.querySelectorAll('tr'); rows.forEach((row, rowIndex) => { const cell = row.children[index]; if (cell) { cell.style.width = width + 'px'; cell.style.minWidth = minWidth + 'px'; cell.style.maxWidth = maxWidth + 'px'; } }); } }); }, // Handler for clicking on a directory handleDirectoryClick(newPath) { // Navigate to the selected directory and update the file list const self = this; currentPath = newPath || '/'; const pathInput = document.getElementById('path-input'); if (pathInput) { pathInput.value = currentPath; } this.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); }, /** * Determines whether a given Uint8Array represents UTF-8 text data. * * @param {Uint8Array} uint8Array - The binary data to check. * @returns {boolean} - Returns true if the data is UTF-8 text, false otherwise. */ isText(uint8Array) { const len = uint8Array.length; let i = 0; while (i < len) { const byte = uint8Array[i]; if (byte === 0) return false; // Null byte indicates binary if (byte <= 0x7F) { // ASCII character, no action needed i++; continue; } else if ((byte & 0xE0) === 0xC0) { // 2-byte sequence if (i + 1 >= len || (uint8Array[i + 1] & 0xC0) !== 0x80) return false; i += 2; } else if ((byte & 0xF0) === 0xE0) { // 3-byte sequence if ( i + 2 >= len || (uint8Array[i + 1] & 0xC0) !== 0x80 || (uint8Array[i + 2] & 0xC0) !== 0x80 ) { return false; } i += 3; } else if ((byte & 0xF8) === 0xF0) { // 4-byte sequence if ( i + 3 >= len || (uint8Array[i + 1] & 0xC0) !== 0x80 || (uint8Array[i + 2] & 0xC0) !== 0x80 || (uint8Array[i + 3] & 0xC0) !== 0x80 ) { return false; } i += 4; } else { // Invalid UTF-8 byte return false; } } return true; }, // Function to handle clicking on a file to open it in the editor handleFileClick(filePath, mode) { const self = this; const fileRow = document.querySelector(`tr[data-file-path='${filePath}']`); const editorMessage = document.getElementById('editor-message'); // Set original file permissions self.originalFilePermissions = fileRow ? fileRow.getAttribute('data-numeric-permissions') : '644'; self.editorMode = mode; // Display loading message if (editorMessage) editorMessage.textContent = _('Loading file...'); // Read the file as binary data fs.read_direct(filePath, 'blob') .then(blob => blob.arrayBuffer()) .then(arrayBuffer => { const uint8Array = new Uint8Array(arrayBuffer); self.fileData = uint8Array; self.fileContent = ''; // Can be used for display or left empty self.editorMode = 'hex'; self.textType = self.isText(uint8Array) ? 'text' : 'hex'; if (mode === 'text') { // Determine if the file is text if (self.textType === 'text') { // If text, decode the content self.fileContent = new TextDecoder().decode(uint8Array); self.editorMode = 'text'; } else { // If not text, show a warning and set mode to hex if (editorMessage) { editorMessage.textContent = _('The file does not contain valid text data. Opening in hex mode...'); } pop(null, E('p', _('Opening file in hex mode since it is not a text file.')), 'warning'); } } }) .then(() => { // Render the editor and switch to the editor tab self.renderEditor(filePath); self.switchToTab('editor'); }) .catch(err => { // Handle errors during file reading pop(null, E('p', _('Failed to open file: %s').format(err.message)), 'error'); }); }, // Adjust padding for line numbers in the editor adjustLineNumbersPadding() { // Update padding based on scrollbar size const lineNumbersDiv = document.getElementById('line-numbers'); const editorTextarea = document.getElementById('editor-textarea'); if (!lineNumbersDiv || !editorTextarea) { return; } const scrollbarHeight = editorTextarea.offsetHeight - editorTextarea.clientHeight; lineNumbersDiv.style.paddingBottom = scrollbarHeight + 'px'; }, // Handler for downloading a file handleDownloadFile(filePath) { // Download the file to the user's local machine const self = this; const fileName = filePath.split('/').pop(); // Use the read_direct method to download the file fs.read_direct(filePath, 'blob') .then((blob) => { if (!(blob instanceof Blob)) { throw new Error(_('Response is not a Blob')); } const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); }).catch((err) => { pop(null, E('p', _('Failed to download file "%s": %s').format(fileName, err.message)), 'error'); }); }, // Handler for deleting a file handleDeleteFile(filePath, fileInfo) { // Delete the selected file or directory const self = this; const itemName = filePath.split('/').pop(); const itemTypeLabel = fileTypes[fileInfo?.type]; if (confirm(_('Are you sure you want to delete this %s: "%s"?').format(itemTypeLabel, itemName))) { fs.remove(filePath).then(() => { popTimeout(null, E('p', _('Successfully deleted %s: "%s".').format(itemTypeLabel, itemName)), 5000, 'info'); self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); const statusInfo = document.getElementById('status-info'); if (statusInfo) { statusInfo.textContent = _('Deleted %s: "%s".').format(itemTypeLabel, itemName); } }).catch((err) => { pop(null, E('p', _('Failed to delete %s "%s": %s').format(itemTypeLabel, itemName, err.message)), 'error'); }); } }, // Update line numbers in the text editor updateLineNumbers() { // Update the line numbers display when the text changes const lineNumbersDiv = document.getElementById('line-numbers'); const editorTextarea = document.getElementById('editor-textarea'); if (!lineNumbersDiv || !editorTextarea) return; // Count lines const lineCount = editorTextarea.value.split('\n').length; // Build HTML using join — much faster than concatenation lineNumbersDiv.innerHTML = Array.from({ length: lineCount }, (_, i) => `
${i + 1}
`).join(''); }, // Synchronize scrolling between line numbers and text syncScroll() { // Sync scrolling of line numbers with the text area const lineNumbersDiv = document.getElementById('line-numbers'); const editorTextarea = document.getElementById('editor-textarea'); if (!lineNumbersDiv || !editorTextarea) { return; } lineNumbersDiv.scrollTop = editorTextarea.scrollTop; }, // Toggle line numbers display in the editor toggleLineNumbers() { // Ensure the editor is in Text Mode before toggling line numbers if (this.editorMode !== 'text') { console.warn('Toggle Line Numbers is only available in Text Mode.'); return; } // Get the line numbers div and the textarea const lineNumbersDiv = document.getElementById('line-numbers'); const editorTextarea = document.getElementById('editor-textarea'); if (!lineNumbersDiv || !editorTextarea) { console.error('Line numbers div or editor textarea not found.'); return; } // Toggle the display of line numbers if (lineNumbersDiv.style.display === 'none' || !lineNumbersDiv.style.display) { lineNumbersDiv.style.display = 'block'; this.updateLineNumbers(); this.adjustLineNumbersPadding(); this.syncScroll(); } else { lineNumbersDiv.style.display = 'none'; lineNumbersDiv.innerHTML = ''; } }, // Generate a name for a copy of a file getCopyName(originalName, existingNames) { // Split filename into base name + extension const dotIndex = originalName.lastIndexOf('.'); const hasExt = dotIndex > 0 && dotIndex < originalName.length - 1; const base = hasExt ? originalName.slice(0, dotIndex) : originalName; const ext = hasExt ? originalName.slice(dotIndex) : ''; // First attempt: "name (copy).ext" let candidate = `${base} (copy)${ext}`; // If taken, try: "name (copy 2).ext", "name (copy 3).ext", ... let counter = 2; while (existingNames.includes(candidate)) { candidate = `${base} (copy ${counter++})${ext}`; } return candidate; }, // Handler for duplicating a file handleDuplicateFile(filePath, fileInfo) { // Copy the file or directory with a new name const self = this; getFileList(currentPath).then((files) => { const existingNames = files.map((f) => { return f.name; }); const newName = self.getCopyName(fileInfo.name, existingNames); const newPath = joinPath(currentPath, newName); let command; let args; if (fileInfo.type === 'directory') { command = 'cp'; args = ['-rp', filePath, newPath]; } else if (fileInfo.type === 'symlink') { command = 'cp'; args = ['-Pp', filePath, newPath]; } else { command = 'cp'; args = ['-p', filePath, newPath]; } fs.exec(command, args).then((res) => { if (res.code !== 0) { return Promise.reject(new Error(res.stderr.trim())); } popTimeout(null, E('p', _('Successfully duplicated %s "%s" as "%s".').format(_('item'), fileInfo.name, newName)), 5000, 'info'); self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); }).catch((err) => { pop(null, E('p', _('Failed to duplicate %s "%s": %s').format(_('item'), fileInfo.name, err.message)), 'error'); }); }).catch((err) => { pop(null, E('p', _('Failed to get file list: %s').format(err.message)), 'error'); }); }, // Handler for saving a file after editing handleSaveFile(filePath) { const self = this; let contentBlob; if (self.editorMode === 'text') { const textarea = document.querySelector('#editor-container textarea'); if (!textarea) { pop(null, E('p', _('Editor textarea not found.')), 'error'); return; } const content = textarea.value; self.fileContent = content; // Convert content to Uint8Array in chunks not exceeding 8KB const CHUNK_SIZE = 8 * 1024; // 8KB const totalLength = content.length; let chunks = []; for (let i = 0; i < totalLength; i += CHUNK_SIZE) { const chunkStr = content.slice(i, i + CHUNK_SIZE); const chunkBytes = new TextEncoder().encode(chunkStr); chunks.push(chunkBytes); } // Concatenate chunks into a single Uint8Array const totalBytes = chunks.reduce((prev, curr) => { return prev + curr.length; }, 0); let dataArray = new Uint8Array(totalBytes); let offset = 0; chunks.forEach((chunk) => { dataArray.set(chunk, offset); offset += chunk.length; }); self.fileData = dataArray; // Update binary data contentBlob = new Blob([self.fileData], { type: 'application/octet-stream' }); } else if (self.editorMode === 'hex') { // Get data from hex editor self.fileData = self.hexEditorInstance.getData(); // Assuming getData method is implemented in HexEditor contentBlob = new Blob([self.fileData], { type: 'application/octet-stream' }); } const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); const fileName = filePath.split('/').pop(); if (statusInfo) { statusInfo.textContent = _('Saving file: "%s"...').format(fileName); } if (statusProgress) { statusProgress.innerHTML = ''; const progressBarContainer = E('div', { 'class': 'cbi-progressbar', 'title': '0%' }, [E('div', { 'style': 'width:0%' })]); statusProgress.appendChild(progressBarContainer); } uploadFile(filePath, contentBlob, (percent) => { if (statusProgress) { const progressBar = statusProgress.querySelector('.cbi-progressbar div'); if (progressBar) { progressBar.style.width = percent.toFixed(2) + '%'; statusProgress.querySelector('.cbi-progressbar').setAttribute('title', percent.toFixed(2) + '%'); } } }).then(() => { const permissions = self.originalFilePermissions; if (permissions !== undefined) { return fs.exec('chmod', [permissions, filePath]).then((res) => { if (res.code !== 0) { throw new Error(res.stderr.trim()); } }).then(() => { if (statusInfo) { statusInfo.textContent = _('File "%s" uploaded successfully.').format(fileName); } popTimeout(null, E('p', _('File "%s" uploaded successfully.').format(fileName)), 5000, 'info'); return self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); }).catch((err) => { pop(null, E('p', _('Failed to apply permissions to file "%s": %s').format(fileName, err.message)), 'error'); }); } else { if (statusInfo) { statusInfo.textContent = _('File "%s" uploaded successfully.').format(fileName); } popTimeout(null, E('p', _('File "%s" uploaded successfully.').format(fileName)), 5000, 'info'); return self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); } }).catch((err) => { if (statusProgress) { statusProgress.innerHTML = ''; } if (statusInfo) { statusInfo.textContent = _('Failed to save file "%s": %s').format(fileName, err.message); } pop(null, E('p', _('Failed to save file "%s": %s').format(fileName, err.message)), 'error'); }); }, // Handler for clicking on a symbolic link handleSymlinkClick(linkPath, targetPath, mode) { // Navigate to the target of the symbolic link const self = this; if (!targetPath.startsWith('/')) { targetPath = joinPath(currentPath, targetPath); } fs.stat(targetPath).then((stat) => { if (stat.type === 'directory') { self.handleDirectoryClick(targetPath); } else if (stat.type === 'file') { self.handleFileClick(targetPath, mode); } else { pop(null, E('p', _('The symlink points to an unsupported type.')), 'error'); } }).catch((err) => { pop(null, E('p', _('Failed to access symlink target: %s').format(err.message)), 'error'); }); const statusInfo = document.getElementById('status-info'); if (statusInfo) { statusInfo.textContent = _('Symlink: ') + linkPath + ' -> ' + targetPath; } }, // Initialize resizeable columns in the table initResizeableColumns() { // Add handlers to adjust column widths const self = this; const table = document.getElementById('file-table'); if (!table) { return; } const headers = table.querySelectorAll('th'); headers.forEach((header, index) => { const resizer = header.querySelector('.resizer'); if (resizer) { resizer.removeEventListener('mousedown', header.resizeHandler); header.resizeHandler = (e) => { e.preventDefault(); const startX = e.pageX; const startWidth = header.offsetWidth; const field = header.getAttribute('data-field'); const minWidth = config.columnMinWidths[field] || 50; const maxWidth = config.columnMaxWidths[field] || 500; function doDrag(e) { const currentX = e.pageX; const newWidth = startWidth + (currentX - startX); if (newWidth >= minWidth && newWidth <= maxWidth) { header.style.width = newWidth + 'px'; if (field) { config.columnWidths[field] = newWidth; } const rows = table.querySelectorAll('tr'); rows.forEach((row, rowIndex) => { const cell = row.children[index]; if (cell) { cell.style.width = newWidth + 'px'; } }); } } function stopDrag() { document.removeEventListener('mousemove', doDrag, false); document.removeEventListener('mouseup', stopDrag, false); saveConfig(); } document.addEventListener('mousemove', doDrag, false); document.addEventListener('mouseup', stopDrag, false); }; resizer.addEventListener('mousedown', header.resizeHandler, false); } }); }, // Handler for editing a file's properties (name, permissions, etc.) handleEditFile(filePath, fileInfo) { // Display a form to edit the file's properties const self = this; const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo && statusProgress) { statusInfo.innerHTML = ''; statusProgress.innerHTML = ''; const nameInput = E('input', { 'type': 'text', 'value': fileInfo.name, 'placeholder': fileInfo.name, 'style': 'margin-right: 10px;' }); const permsInput = E('input', { 'type': 'text', 'placeholder': fileInfo.numericPermissions, 'style': 'margin-right: 10px; width: 80px;' }); const ownerInput = E('input', { 'type': 'text', 'placeholder': fileInfo?.user || fileInfo.uid, 'style': 'margin-right: 10px; width: 100px;' }); const groupInput = E('input', { 'type': 'text', 'placeholder': fileInfo?.group || fileInfo.gid, 'style': 'margin-right: 10px; width: 100px;' }); const saveButton = E('button', { 'class': 'btn', 'disabled': true, 'click'() { self.saveFileChanges(filePath, fileInfo, nameInput.value, permsInput.value, ownerInput.value, groupInput.value); } }, _('Save')); [nameInput, permsInput, ownerInput, groupInput].forEach((input) => { input.addEventListener('input', () => { if (nameInput.value !== fileInfo.name || permsInput.value || ownerInput.value || groupInput.value) { saveButton.disabled = false; } else { saveButton.disabled = true; } }); }); statusInfo.appendChild(E('span', {}, _('Editing %s: "%s"').format(_('item'), fileInfo.name))); statusInfo.appendChild(nameInput); statusInfo.appendChild(permsInput); statusInfo.appendChild(ownerInput); statusInfo.appendChild(groupInput); statusProgress.appendChild(saveButton); } }, // Save changes to a file's properties saveFileChanges(filePath, fileInfo, newName, newPerms, newOwner, newGroup) { // Apply changes and update the interface const self = this; const commands = []; const originalPath = filePath; const originalName = fileInfo.name; const newItemName = newName || originalName; if (newName && newName !== fileInfo.name) { const newPath = joinPath(currentPath, newName); commands.push(['mv', [filePath, newPath]]); filePath = newPath; } if (newPerms) { commands.push(['chmod', [newPerms, filePath]]); } if (newOwner || newGroup) { const owner = newOwner ?? (fileInfo?.user || fileInfo.uid); const group = newGroup ?? (fileInfo?.group || fileInfo.gid); commands.push(['chown', [`${owner}:${group}`, filePath]]); } let promise = Promise.resolve(); commands.forEach((cmd) => { promise = promise.then(() => { return fs.exec(cmd[0], cmd[1]).then((res) => { if (res.code !== 0) { return Promise.reject(new Error(res.stderr.trim())); } }); }); }); promise.then(() => { popTimeout(null, E('p', _('Changes to %s "%s" uploaded successfully.').format(_('item'), newItemName)), 5000, 'info'); self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); const statusInfo = document.getElementById('status-info'); const statusProgress = document.getElementById('status-progress'); if (statusInfo) statusInfo.textContent = _('No item selected.'); if (statusProgress) statusProgress.innerHTML = ''; }).catch((err) => { pop(null, E('p', _('Failed to save changes to %s "%s": %s').format(_('item'), newItemName, err.message)), 'error'); }); }, handleSaveSettings(ev) { ev.preventDefault(); var self = this; const parseAndSetConfig = (configPath, value) => { const input = document.getElementById(`${configPath}-input`); if (!input) return; let v = input.value.trim(); if (typeof value == 'object') { parseKeyValuePairs(v, ':', (k, v) => { config[configPath][k] = parseInt(v, 10); }); } else config[configPath] = v; }; Object.entries(config).forEach(([configPath, value]) => { parseAndSetConfig(configPath, value); }); saveConfig().then(() => { popTimeout(null, E('p', _('Settings uploaded successfully.')), 5000, 'info'); self.setInitialColumnWidths(); const styleElement = document.querySelector('style'); if (styleElement) { styleElement.textContent = styleElement.textContent.replace(/padding: \d+px/g, 'padding: ' + config.padding + 'px'); } const fileListContainer = document.getElementById('file-list-container'); if (fileListContainer) { fileListContainer.style.width = config.windowWidth + 'px'; fileListContainer.style.height = config.windowHeight + 'px'; } currentPath = config.currentDirectory || '/'; const pathInput = document.getElementById('path-input'); if (pathInput) { pathInput.value = currentPath; } self.loadFileList(currentPath).then(() => { self.initResizeableColumns(); }); const editorContainer = document.getElementById('editor-container'); if (editorContainer) { const editorMode = self.editorMode; const editorSizes = { width: config[`${editorMode}editorWidth`] || 850, height: config[`${editorMode}editorHeight`] || 550 }; editorContainer.style.width = editorSizes.width + 'px'; editorContainer.style.height = editorSizes.height + 'px'; } }).catch((err) => { pop(null, E('p', _('Failed to save settings: %s').format(err.message)), 'error'); }); }, // Load settings into the settings form loadSettings() { const setInputValue = (inputId, value) => { const input = document.getElementById(`${inputId}-input`); if (!input) return; let v; if (typeof value == 'object') input.value = Object.entries(value).map(([id, value]) => { return `${id}:${value}` }).join(','); else input.value = value; }; Object.entries(config).forEach(([inputId, value]) => { setInputValue(inputId, value); }); }, updateUI() { const styleElement = document.querySelector('style'); if (styleElement) { styleElement.textContent = styleElement.textContent.replace(/padding: \d+px/g, `padding: ${config.padding}px`); } const fileListContainer = document.getElementById('file-list-container'); if (fileListContainer) { fileListContainer.style.width = `${config.windowWidth}px`; fileListContainer.style.height = `${config.windowHeight}px`; } const editorContainer = document.getElementById('editor-container'); if (editorContainer) { const editorMode = this.editorMode; const editorHeight = config[`${editorMode}editorHeight`] || 550; const editorWidth = config[`${editorMode}editorWidth`] || 850; editorContainer.style.width = `${editorWidth}px`; editorContainer.style.height = `${editorHeight}px`; } }, renderEditor(filePath) { const self = this; const editorContainer = document.getElementById('editor-container'); // Clear the editor container editorContainer.innerHTML = ''; // Get the sizes from the config const mode = self.editorMode; // 'text' or 'hex' const editorHeight = config[`${mode}editorHeight`] || 550; const editorWidth = config[`${mode}editorWidth`] || 850; // Create the editor content container const editorContentContainer = E('div', { 'class': 'editor-content', 'style': 'flex: 1; display: flex; overflow: hidden;' }, []); // Action buttons array let actionButtons = []; if (mode === 'text') { // Create line numbers div (initially hidden) const lineNumbersDiv = E('div', { 'id': 'line-numbers', 'class': 'line-numbers', 'style': 'display: none;' // Initially hidden }, []); // Create textarea for text editing const editorTextarea = E('textarea', { 'wrap': 'off', 'id': 'editor-textarea', 'style': 'flex: 1; resize: none; border: none; padding: 0; margin: 0; overflow: auto;' }, [self.fileContent || '']); // Append line numbers and textarea to the editor content container editorContentContainer.appendChild(lineNumbersDiv); editorContentContainer.appendChild(editorTextarea); // Add event listeners for updating line numbers and synchronizing scroll editorTextarea.addEventListener('input', self.updateLineNumbers.bind(self)); editorTextarea.addEventListener('scroll', self.syncScroll.bind(self)); lineNumbersDiv.addEventListener('scroll', () => { editorTextarea.scrollTop = lineNumbersDiv.scrollTop; }); // Define action buttons specific to Text Mode actionButtons = [ E('button', { 'class': 'btn cbi-button-save custom-save-button', 'click'() { self.handleSaveFile(filePath); } }, _('Save')), E('button', { 'class': 'btn', 'id': 'toggle-hex-mode', 'style': 'margin-left: 10px;', 'click'() { self.toggleHexMode(filePath); } }, _('Toggle to Hex Mode')), E('button', { 'class': 'btn', 'id': 'toggle-line-numbers', 'style': 'margin-left: 10px;', 'click'() { self.toggleLineNumbers(); } }, _('Toggle Line Numbers')) ]; } else if (mode === 'hex') { // Create hex editor container const hexeditContainer = E('div', { 'id': 'hexedit-container', 'style': 'flex: 1; overflow: hidden; display: flex; flex-direction: column;' }); // Append hex editor to the editor content container editorContentContainer.appendChild(hexeditContainer); // Initialize the HexEditor instance self.hexEditorInstance = HE.initialize(hexeditContainer); // Load data into the HexEditor self.hexEditorInstance.setData(self.fileData); // self.fileData is a Uint8Array // Define action buttons specific to Hex Mode actionButtons = [ E('button', { 'class': 'btn cbi-button-save custom-save-button', 'click'() { self.handleSaveFile(filePath); } }, _('Save')), ...(self.textType !== 'hex' ? [ E('button', { 'class': 'btn', 'id': 'toggle-text-mode', 'style': 'margin-left: 10px;', 'click'() { self.toggleHexMode(filePath); } }, _('Toggle to ASCII Mode')) ] : []) ]; } // Create the editor container with resizing and scrolling const editor = E('div', { 'class': 'editor-container', 'style': 'display: flex; flex-direction: column; width: ' + editorWidth + 'px; height: ' + editorHeight + 'px; resize: both; overflow: hidden;' }, [ editorContentContainer, E('div', { 'class': 'cbi-page-actions' }, actionButtons) ]); // Append the editor to the editorContainer editorContainer.appendChild(editor); // Update status bar and message const statusInfo = document.getElementById('status-info'); if (statusInfo) { statusInfo.textContent = _('Editing: ') + filePath; } const editorMessage = document.getElementById('editor-message'); if (editorMessage) { editorMessage.textContent = _('Editing: ') + filePath; } // Clear any progress messages const statusProgress = document.getElementById('status-progress'); if (statusProgress) { statusProgress.innerHTML = ''; } // **Add ResizeObserver to editor-container to update config.editorContainerSizes** if (typeof ResizeObserver !== 'undefined') { // Disconnect existing observer if it exists to prevent multiple observers if (self.editorResizeObserver) { self.editorResizeObserver.disconnect(); self.editorResizeObserver = null; } // Initialize a new ResizeObserver instance self.editorResizeObserver = new ResizeObserver((entries) => { for (let entry of entries) { let newWidth = Math.round(entry.contentRect.width); let newHeight = Math.round(entry.contentRect.height); // Update config only if newWidth and newHeight are greater than 0 if (newWidth > 0 && newHeight > 0) { config.editorWidth = newWidth; config.editorHeight = newHeight; } } }); // Observe the editor container self.editorResizeObserver.observe(editor); } }, /** * Toggles the editor mode between text and hex. * * @param {string} filePath - The path of the file to be edited. */ toggleHexMode(filePath) { const self = this; if (self.editorMode === 'text') { // Before switching to hex mode, update self.fileData from the textarea const textarea = document.querySelector('#editor-container textarea'); if (textarea) { const content = textarea.value; self.fileContent = content; // Convert content to Uint8Array const encoder = new TextEncoder(); self.fileData = encoder.encode(content); } self.editorMode = 'hex'; } else { // Before switching to text mode, check if the file is textual if (self.textType !== 'text') { pop(null, E('p', _('This file is not a text file and cannot be edited in text mode.')), 'error'); return; // Abort the toggle } // Before switching to text mode, update self.fileData from HexEditor if (self.hexEditorInstance) { const hexData = self.hexEditorInstance.getData(); if (hexData instanceof Uint8Array) { self.fileData = hexData; } else { pop(null, E('p', _('Failed to retrieve data from Hex Editor.')), 'error'); return; // Abort the toggle if data retrieval fails } } // Convert self.fileData to string const decoder = new TextDecoder(); try { self.fileContent = decoder.decode(self.fileData); } catch (error) { pop(null, E('p', _('Failed to decode file data to text: %s').format(error.message)), 'error'); return; // Abort the toggle if decoding fails } self.editorMode = 'text'; } // Re-render the editor with the updated mode and content self.renderEditor(filePath); } });