diff --git a/emhttp/plugins/dynamix/Browse.page b/emhttp/plugins/dynamix/Browse.page
index cb1f94be8d..09b2da5b23 100644
--- a/emhttp/plugins/dynamix/Browse.page
+++ b/emhttp/plugins/dynamix/Browse.page
@@ -57,6 +57,14 @@ function autoscale(value) {
return ((Math.round(scale*data)/scale)+' '+unit[base]).replace('.','=$display['number'][0]?>')+'/s';
}
+function preventFileTreeClose() {
+ // Prevent fileTree dropdown from closing when clicking inside the dialog
+ // by stopping mousedown events from bubbling to the document handler
+ $('.ui-dfm').off('mousedown.dfmFileTree').on('mousedown.dfmFileTree', function(e) {
+ e.stopPropagation();
+ });
+}
+
function folderContextMenu(id, button) {
var opts = [];
context.settings({button:button});
@@ -248,7 +256,7 @@ function fileEdit(id) {
// file editor dialog
dfm.window.html($("#dfm_templateEditFile").html().replace('{$0}',source).dfm_build());
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: fileName(source),
width: 'auto',
@@ -291,7 +299,7 @@ function doJobs(title) {
dfm.window = $("#dfm_dialogWindow");
dfm.window.html($('#dfm_templateJobs').html().dfm_build());
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: title,
height: 'auto',
@@ -312,7 +320,7 @@ function doJobs(title) {
},
"_(Delete)_": function(){
let row = [];
- dfm.window.find('i[id^="queue_"]').each(function(){if ($(this).hasClass('fa-check-square-o')) row.push((($(this).prop('id').split('_')[1]-1)*9)+1);});
+ dfm.window.find('i[id^="queue_"]').each(function(){if ($(this).hasClass('fa-check-square-o')) row.push($(this).prop('id').split('_')[1]);});
$.post('/webGui/include/Control.php',{mode:'undo',row:row.join(',')},function(queue){
$.post('/webGui/include/Control.php',{mode:'jobs'},function(jobs){
$('#dfm_jobs').html(jobs);
@@ -343,6 +351,272 @@ filemonitor.on('message', function(state) {
});
setTimeout(function(){filemonitor.start();},3000);
+// File tree navigation constants
+var FOLDER_EXPAND_DELAY = 300; // Delay per folder expansion in openFolderRecursive (ms)
+var NAVIGATION_BUFFER = 500; // Additional buffer time for navigation completion (ms)
+
+function setupTargetNavigation() {
+ var $target = dfm.window.find('#dfm_target');
+ if (!$target.length) return;
+
+ var navigationTimeout;
+ var inputElement = $target[0];
+ var isNavigatingFromPopular = false;
+ var isProgrammaticNavigation = false; // Flag for programmatic tree navigation
+ var savedInputValue = ''; // Save input value during programmatic navigation
+ var lastNavigatedPath = ''; // Track last navigated path for backward navigation
+
+ // Event handlers for capture phase to prevent fileTree from closing
+ var preventClose = function(e) {
+ // Re-open tree if it was closed (must do this BEFORE stopImmediatePropagation)
+ if (e.type === 'click') {
+ var $tree = $target.closest('dd').find('.fileTree');
+ if ($tree.length && !$tree.is(':visible')) {
+ // Only show if tree has content
+ var $content = $tree.find('.jqueryFileTree');
+ if ($content.length && $content.children().length > 0) {
+ try {
+ $tree.show();
+ } catch(err) {
+ console.error('Error showing tree:', err);
+ }
+ }
+ }
+ }
+
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+ };
+
+ // Attach to input element in capture phase (runs before jquery.filetree.js handler)
+ if (inputElement) {
+ inputElement.addEventListener('mousedown', preventClose, true);
+ inputElement.addEventListener('focus', preventClose, true);
+ inputElement.addEventListener('click', preventClose, true);
+ }
+
+ // Handle clicks on ANY tree item to remove popular destinations
+ var treeClickHandler = function(e) {
+ var $link = $(e.target).closest('.jqueryFileTree a');
+ if ($link.length === 0) return;
+
+ // Remove popular section when clicking on any tree item (except popular items themselves)
+ if (!$link.closest('.popular-destination').length) {
+ var $tree = $('.fileTree .jqueryFileTree');
+ $tree.find('.popular-header, .popular-destination, .popular-separator').remove();
+ }
+ };
+
+ // Attach tree click handler in capture phase
+ document.addEventListener('click', treeClickHandler, true);
+
+ // Handle clicks on popular destinations - use capture phase to run before jQueryFileTree handler
+ var popularClickHandler = function(e) {
+ var $link = $(e.target).closest('.popular-destination a');
+ if ($link.length === 0) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+
+ // Get path from data-path attribute
+ var path = $link.attr('data-path');
+ if (!path) return;
+
+ // Remove trailing slash for input field
+ var cleanPath = path.replace(/\/+$/, '');
+
+ // Remove popular section immediately (before any navigation)
+ var $tree = $('.fileTree .jqueryFileTree');
+ $tree.find('.popular-header, .popular-destination, .popular-separator').remove();
+
+ // Set flag to prevent input handler from triggering
+ isNavigatingFromPopular = true;
+
+ // Set input field value (this triggers 'input' event)
+ $target.val(cleanPath + '/');
+
+ // Trigger navigation to expand the path in normal tree
+ navigateFileTree(cleanPath + '/');
+ lastNavigatedPath = cleanPath + '/';
+
+ // Reset flag after a short delay
+ setTimeout(function() {
+ isNavigatingFromPopular = false;
+ }, 100);
+
+ return false;
+ };
+
+ // Attach in capture phase to run before jQueryFileTree handlers
+ document.addEventListener('click', popularClickHandler, true);
+
+ // Hide/show popular destinations based on input field content
+ $target.on('input', function() {
+ // Ignore input events triggered by popular click or programmatic navigation
+ if (isNavigatingFromPopular || isProgrammaticNavigation) {
+ return;
+ }
+
+ var inputValue = this.value.trim();
+ var $tree = $('.fileTree .jqueryFileTree');
+
+ if (inputValue) {
+ // Input is not empty - remove popular destinations completely
+ $tree.find('.popular-header, .popular-destination, .popular-separator').remove();
+ } else {
+ // Input is empty - popular destinations will be shown on next tree reload
+ }
+
+ clearTimeout(navigationTimeout);
+
+ if (!inputValue) {
+ // Reset tree to initial state when input is cleared
+ resetFileTree($target);
+ lastNavigatedPath = '';
+ return;
+ }
+ if (inputValue[inputValue.length - 1] !== '/') return;
+
+ // Check if we're navigating backward (path got shorter)
+ if (lastNavigatedPath && inputValue.length < lastNavigatedPath.length && lastNavigatedPath.startsWith(inputValue)) {
+ // Close all folders between the new path and the old path
+ var pathToClose = lastNavigatedPath;
+ while (pathToClose.length > inputValue.length) {
+ closeFolderPath(pathToClose);
+ // Remove the last segment to go up one level
+ pathToClose = pathToClose.replace(/\/+$/, ''); // Remove trailing slashes
+ var lastSlash = pathToClose.lastIndexOf('/');
+ if (lastSlash === -1) break;
+ pathToClose = pathToClose.substring(0, lastSlash + 1);
+ }
+ lastNavigatedPath = inputValue;
+ return;
+ }
+
+ navigationTimeout = setTimeout(function() {
+ navigateFileTree(inputValue);
+ lastNavigatedPath = inputValue;
+ }, 200);
+ });
+
+ // Restore input value if changed during programmatic navigation
+ $target.on('change', function() {
+ var isProgrammaticNav = $target.data('isProgrammaticNavigation');
+ var savedValue = $target.data('savedInputValue');
+ if (isProgrammaticNav && savedValue) {
+ this.value = savedValue;
+ }
+ });
+
+ // Cleanup on dialog close
+ dfm.window.on('dialogclose', function() {
+ document.removeEventListener('click', treeClickHandler, true);
+ document.removeEventListener('click', popularClickHandler, true);
+ if (inputElement) {
+ inputElement.removeEventListener('mousedown', preventClose, true);
+ inputElement.removeEventListener('focus', preventClose, true);
+ inputElement.removeEventListener('click', preventClose, true);
+ }
+ });
+}
+
+function closeFolderPath(path) {
+ var $tree = $('.jqueryFileTree').first();
+ if ($tree.length === 0) return;
+
+ // Remove trailing slash for searching
+ var cleanPath = path.replace(/\/+$/, '');
+
+ // Find the folder link by rel attribute
+ var $folderLink = $tree.find('a[rel="' + cleanPath + '/"]');
+ if ($folderLink.length === 0) return;
+
+ var $folderLi = $folderLink.closest('li');
+
+ // Close the folder: change class from expanded to collapsed
+ $folderLi.removeClass('expanded').addClass('collapsed');
+
+ // Remove all child elements (the nested
)
+ $folderLi.find('ul').remove();
+}
+
+function resetFileTree($target) {
+ // Find the .fileTree container (not the jqueryFileTree inside it)
+ var $treeContainer = $target.siblings('.fileTree');
+ if ($treeContainer.length === 0) {
+ return;
+ }
+
+ // Hide the tree first
+ $treeContainer.hide();
+
+ // Empty the container - this will cause fileTreeAttach to reload on next click
+ $treeContainer.empty();
+
+ // Show the tree again by simulating a click
+ // fileTreeAttach checks if html() is empty and will reload
+ setTimeout(function() {
+ $target.click();
+ }, 100);
+}
+
+function navigateFileTree(path) {
+ var $tree = $('.jqueryFileTree').first();
+ if ($tree.length === 0) {
+ return;
+ }
+
+ var $target = dfm.window.find('#dfm_target');
+ var pickroot = $target.attr('data-pickroot') || '/mnt';
+
+ path = path.replace(/\/+$/, '');
+
+ if (path.indexOf(pickroot) !== 0) {
+ return;
+ }
+
+ var relativePath = path.substring(pickroot.length).replace(/^\/+/, '');
+ var parts = relativePath.split('/').filter(function(p) { return p.length > 0; });
+
+ // Use jQuery.data() to store values accessible from anywhere
+ $target.data('savedInputValue', path + '/');
+ $target.data('isProgrammaticNavigation', true);
+
+ openFolderRecursive($tree, pickroot, parts, 0);
+
+ // Reset flag after navigation completes
+ setTimeout(function() {
+ $target.data('isProgrammaticNavigation', false);
+ $target.data('savedInputValue', '');
+ }, parts.length * FOLDER_EXPAND_DELAY + NAVIGATION_BUFFER);
+}
+
+function openFolderRecursive($tree, pickroot, parts, index) {
+ if (index >= parts.length) return;
+
+ var pathSoFar = pickroot + '/' + parts.slice(0, index + 1).join('/');
+ var $folderLink = $tree.find('a[rel="' + pathSoFar + '/"]');
+
+ if ($folderLink.length === 0) return;
+
+ var $folderLi = $folderLink.parent();
+
+ if ($folderLi.hasClass('expanded')) {
+ setTimeout(function() {
+ openFolderRecursive($tree, pickroot, parts, index + 1);
+ }, 100);
+ return;
+ }
+
+ if ($folderLi.hasClass('collapsed') || $folderLi.hasClass('directory')) {
+ $folderLink.trigger('click');
+ setTimeout(function() {
+ openFolderRecursive($tree, pickroot, parts, index + 1);
+ }, FOLDER_EXPAND_DELAY);
+ }
+}
+
function doAction(action, title, id) {
var link = id.substr(0,1) == '/';
var source = link ? id : data($('#'+id));
@@ -374,72 +648,66 @@ function doAction(action, title, id) {
case 0: // create folder
dfm.window.html($('#dfm_templateCreateFolder').html());
source = dir;
- dfm.height = 330;
break;
case 1: // delete folder
dfm.window.html($('#dfm_templateDeleteFolder').html());
dfm_createSource(source.dfm_strip());
- dfm.height = 330;
break;
case 2: // rename folder
dfm.window.html($('#dfm_templateRenameFolder').html());
dfm_createSource(name);
dfm.window.find('#dfm_target').val(name);
- dfm.height = 330;
break;
case 3: // copy folder
dfm.window.html($('#dfm_templateCopyFolder').html());
dfm_createSource(source.dfm_strip());
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-show-popular','true').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bits.length>3 ? '' : share;
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.height = 630;
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 4: // move folder
dfm.window.html($('#dfm_templateMoveFolder').html());
dfm_createSource(source.dfm_strip());
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bits.length>3 ? '' : share;
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.height = 630;
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 6: // delete file
dfm.window.html($('#dfm_templateDeleteFile').html());
dfm_createSource(source);
- dfm.height = 330;
break;
case 7: // rename file
dfm.window.html($('#dfm_templateRenameFile').html());
dfm_createSource(name);
dfm.window.find('#dfm_target').val(name);
- dfm.height = 330;
break;
case 8: // copy file
dfm.window.html($('#dfm_templateCopyFile').html());
dfm_createSource(source);
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
});
- dfm.height = 630;
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 9: // move file
dfm.window.html($('#dfm_templateMoveFile').html());
dfm_createSource(source);
- dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).fileTreeAttach(null,null,function(path){
+ dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match).attr('data-pickfilter','SHOW_POPULAR').fileTreeAttach(null,null,function(path){
dfm.window.find('#dfm_target').val(path).change();
});
- dfm.height = 630;
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 11: // change owner
dfm.window.html($('#dfm_templateChangeOwner').html());
var owner = $('#'+id.dfm_bring('owner')).text();
dfm_createSource(source.dfm_strip());
dfm.window.find('#dfm_target').val(owner);
- dfm.height = 330;
break;
case 12: // change permission
dfm.window.html($('#dfm_templateChangePermission').html());
@@ -448,7 +716,6 @@ function doAction(action, title, id) {
dfm.window.find('#dfm_owner').val('u-'+perm.substr(1,2).dfm_patch());
dfm.window.find('#dfm_group').val('g-'+perm.substr(4,2).dfm_patch());
dfm.window.find('#dfm_other').val('o-'+perm.substr(7,2).dfm_patch());
- dfm.height = 330;
break;
case 13: // download file
downloadFile(source,name);
@@ -470,18 +737,40 @@ function doAction(action, title, id) {
dfm_createSource(source.dfm_strip());
dfm.window.find('.dfm_loc').html(' ').css({'line-height':'normal'});
dfm.window.find('.dfm_text').html('').css({'line-height':'normal'});
- dfm.height = 630;
break;
}
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: title,
- height: dfm.height,
width: 'auto',
resizable: false,
draggable: false,
modal: true,
+ close: function() {
+ $('.ui-dfm').off('mousedown.dfmFileTree');
+ },
+ open: function() {
+ // Add warning to buttonpane for all relevant actions
+ var warningTexts = {
+ 0: "=_("This creates a folder at the current level")?>",
+ 1: "=_("This deletes the folder and all its content")?>",
+ 2: "=_("This renames the folder to the new name")?>",
+ 3: "=_("This copies the folder and all its content to another folder")?>",
+ 4: "=_("This moves the folder and all its content to another folder")?>",
+ 6: "=_("This deletes the selected file")?>",
+ 7: "=_("This renames the selected file")?>",
+ 8: "=_("This copies the selected file")?>",
+ 9: "=_("This moves the selected file")?>",
+ 11: "=_("This changes the owner of the source recursively")?>",
+ 12: "=_("This changes the permission of the source recursively")?>"
+ };
+ var warningText = warningTexts[action];
+ if (warningText) {
+ var $warning = $('').html('
' + warningText);
+ $('.ui-dfm .ui-dialog-buttonset').prepend($warning);
+ }
+ },
buttons: {
"_(Start)_": function(){
if (dfm.running) return;
@@ -592,6 +881,13 @@ function doAction(action, title, id) {
}
});
dfm_close_button();
+ preventFileTreeClose();
+
+ // Setup file tree navigation for target input in copy/move dialogs (action 3=copy folder, 4=move folder, 8=copy file, 9=move file)
+ if ([3,4,8,9].includes(action) && dfm.window.find('#dfm_target').length) {
+ setupTargetNavigation();
+ }
+
if (action == 15) $('.ui-dfm .ui-dialog-buttonset button:eq(1)').prop('disabled',true);
setTimeout(function(){if (dfm.window.find('#dfm_target').length) dfm.window.find('#dfm_target').focus().click(); else $('.ui-dfm .ui-dialog-buttonset button:eq(0)').focus();});
}
@@ -662,48 +958,46 @@ function doActions(action, title) {
case 0: // create folder
dfm.window.html($('#dfm_templateCreateFolder').html());
source[0] = dir;
- dfm.height = 330;
break;
case 1: // delete object
dfm.window.html($('#dfm_templateDeleteObject').html());
dfm_createSource(source);
- dfm.height = 330;
break;
case 2: // rename object
dfm.window.html($('#dfm_templateRenameObject').html());
dfm_createSource(name);
dfm.window.find('#dfm_target').val(name);
- dfm.height = 330;
break;
case 3: // copy object
dfm.window.html($('#dfm_templateCopyObject').html());
dfm_createSource(source);
dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match);
- if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','HIDE_FILES_FILTER');
+ if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR,HIDE_FILES_FILTER');
+ else dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR');
dfm.window.find('#dfm_target').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bulk || bits.length>3 ? '' : (type[0] == 'd' ? share : '');
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.height = 630;
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 4: // move object
dfm.window.html($('#dfm_templateMoveObject').html());
dfm_createSource(source);
dfm.window.find('#dfm_target').attr('data-pickroot',root).attr('data-picktop',root).attr('data-pickmatch',match);
- if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','HIDE_FILES_FILTER');
+ if (bulk || type[0] == 'd') dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR,HIDE_FILES_FILTER');
+ else dfm.window.find('#dfm_target').attr('data-pickfilter','SHOW_POPULAR');
dfm.window.find('#dfm_target').fileTreeAttach(null,null,function(path){
var bits = path.substr(1).split('/');
var auto = bulk || bits.length > 3 ? '' : (type[0] == 'd' ? share : '');
dfm.window.find('#dfm_target').val(path+auto).change();
});
- dfm.height = 630;
+ dfm.window.find('#dfm_target').css('margin-bottom', '320px');
break;
case 11: // change owner
dfm.window.html($('#dfm_templateChangeOwner').html());
dfm_createSource(source);
if (!bulk) dfm.window.find('#dfm_target').val(owner[0]);
- dfm.height = 330;
break;
case 12: // change permission
dfm.window.html($('#dfm_templateChangePermission').html());
@@ -713,7 +1007,6 @@ function doActions(action, title) {
dfm.window.find('#dfm_group').val('g-'+perm[0].substr(4,2).dfm_patch());
dfm.window.find('#dfm_other').val('o-'+perm[0].substr(7,2).dfm_patch());
}
- dfm.height = 330;
break;
case 14: // calculate occupied space
timers.calc = setTimeout(function(){$('div.spinner.fixed').show('slow');},500);
@@ -736,19 +1029,37 @@ function doActions(action, title) {
dfm_createSource(source);
dfm.window.find('.dfm_loc').html(' ').css({'line-height':'normal'});
dfm.window.find('.dfm_text').html('').css({'line-height':'normal'});
- dfm.height = 630;
break;
}
dfm.window.find('#dfm_source').attr('size',Math.min(dfm.tsize[action],source.length));
dfm.window.dialog({
- classes: {'ui-dialog': 'ui-dfm'},
+ classes: {'ui-dialog': 'ui-dfm ui-corner-all'},
autoOpen: true,
title: title,
- height: dfm.height,
width: 'auto',
resizable: false,
draggable: false,
modal: true,
+ close: function() {
+ $('.ui-dfm').off('mousedown.dfmFileTree');
+ },
+ open: function() {
+ // Add warning to buttonpane for all relevant actions (bulk operations)
+ var warningTexts = {
+ 0: "=_("This creates a folder at the current level")?>",
+ 1: "=_("This deletes all selected sources")?>",
+ 2: "=_("This renames the selected source")?>",
+ 3: "=_("This copies all the selected sources")?>",
+ 4: "=_("This moves all the selected sources")?>",
+ 11: "=_("This changes the owner of the source recursively")?>",
+ 12: "=_("This changes the permission of the source recursively")?>"
+ };
+ var warningText = warningTexts[action];
+ if (warningText) {
+ var $warning = $('
').html('
' + warningText);
+ $('.ui-dfm .ui-dialog-buttonset').prepend($warning);
+ }
+ },
buttons: {
"_(Start)_": function(){
if (dfm.running) return;
@@ -862,6 +1173,13 @@ function doActions(action, title) {
}
});
dfm_close_button();
+ preventFileTreeClose();
+
+ // Setup file tree navigation for target input in copy/move dialogs (action 3=copy, 4=move)
+ if ([3,4].includes(action) && dfm.window.find('#dfm_target').length) {
+ setupTargetNavigation();
+ }
+
if (action == 15) $('.ui-dfm .ui-dialog-buttonset button:eq(1)').prop('disabled',true);
setTimeout(function(){if (dfm.window.find('#dfm_target').length) dfm.window.find('#dfm_target').focus().click(); else $('.ui-dfm .ui-dialog-buttonset button:eq(0)').focus();});
}
@@ -960,7 +1278,62 @@ function loadList() {
}
function xlink(link) {
- swal({title:'',text:decodeURIComponent(link),html:true,confirmButtonText:"_(Ok)_"});
+ var path = decodeURIComponent(link).trim();
+
+ // Always show dialog with selectable textarea (better mobile support)
+ var inputId = 'dfm_path_input_' + Date.now();
+ var inputHtml = '
';
+
+ swal({
+ title: '',
+ text: inputHtml,
+ html: true,
+ showCancelButton: true,
+ confirmButtonText: "_(Ok)_",
+ cancelButtonText: "_(Terminal)_"
+ }, function(isConfirm) {
+ if (isConfirm === false) {
+ // Terminal button was clicked (cancel button)
+ var d = new Date();
+ openTerminal('ttyd', 'File_Manager_' + d.getTime(), path);
+ }
+ // Ok button (isConfirm === true) or dialog dismissed - just close
+ });
+
+ // Enable input visibility and hide SweetAlert's own fieldset
+ $('.sweet-alert').addClass('show-input');
+ $('.sweet-alert fieldset').hide();
+
+ // Auto-select text and handle clipboard on click
+ setTimeout(function() {
+ var input = document.getElementById(inputId);
+ if (input) {
+ input.select();
+ input.focus();
+
+ // Copy to clipboard when user clicks/taps
+ $(input).on('click', function() {
+ this.select();
+
+ // Try to copy to clipboard
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(path).then(function() {
+ // Show success tooltip
+ var tooltip = $('
_(Copied to clipboard)_');
+ $('.sweet-alert').append(tooltip);
+
+ setTimeout(function() {
+ tooltip.fadeOut(300, function() {
+ $(this).remove();
+ });
+ }, 1000);
+ }).catch(function() {
+ // Silent fail - user can still manually copy
+ });
+ }
+ });
+ }
+ }, 100);
}
$(function(){
diff --git a/emhttp/plugins/dynamix/include/Control.php b/emhttp/plugins/dynamix/include/Control.php
index 045da68869..b7ea8c3546 100644
--- a/emhttp/plugins/dynamix/include/Control.php
+++ b/emhttp/plugins/dynamix/include/Control.php
@@ -13,6 +13,7 @@
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
require_once "$docroot/webGui/include/Helpers.php";
+require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
// add translations
$_SERVER['REQUEST_URI'] = '';
@@ -57,13 +58,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
chown($file,'nobody');
chmod($file,0666);
}
- $file = file_get_contents($local);
+ $targetFile = file_get_contents($local);
if ($_POST['cancel']==1) {
- delete_file($file);
+ delete_file($targetFile);
die('stop');
}
- if (file_put_contents($file,base64_decode($_POST['data']),FILE_APPEND)===false) {
- delete_file($file);
+ if (file_put_contents($targetFile,base64_decode($_POST['data']),FILE_APPEND)===false) {
+ delete_file($targetFile);
die('error');
}
die();
@@ -109,11 +110,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$file = '/var/tmp/file.manager.jobs';
$rows = file_exists($file) ? file($file,FILE_IGNORE_NEW_LINES) : [];
$job = 1;
- for ($x = 0; $x < count($rows); $x+=9) {
- $data = parse_ini_string(implode("\n",array_slice($rows,$x,9)));
- $task = $data['task'];
- $source = explode("\r",$data['source']);
- $target = $data['target'];
+ foreach ($rows as $row) {
+ if (empty($row)) continue;
+ $data = json_decode($row, true);
+ if (!$data) continue;
+ $task = $data['task'] ?? '';
+ $source = explode("\r",$data['source'] ?? '');
+ $target = $data['target'] ?? '';
$more = count($source) > 1 ? " (".sprintf("and %s more",count($source)-1).") " : "";
$jobs[] = '
'._('Job')." [".sprintf("%'.04d",$job++)."] - $task ".$source[0].$more.($target ? " --> $target" : "");
}
@@ -134,49 +137,97 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
$jobs = '/var/tmp/file.manager.jobs';
$start = '0';
if (file_exists($jobs)) {
- exec("sed -n '2,9 p' $jobs > $active");
- exec("sed -i '1,9 d' $jobs");
- $start = filesize($jobs) > 0 ? '2' : '1';
- if ($start=='1') delete_file($jobs);
+ // read first JSON line from jobs file and write to active
+ $lines = file($jobs, FILE_IGNORE_NEW_LINES);
+ if (!empty($lines)) {
+ // Skip invalid JSON entries (loop until we find valid JSON or run out of entries)
+ while (!empty($lines)) {
+ $data = json_decode($lines[0], true);
+ if ($data) {
+ // Valid JSON found, use it
+ break;
+ }
+ // Invalid JSON, remove this entry and try next
+ array_shift($lines);
+ }
+
+ if (empty($lines)) {
+ // No valid JSON entries found
+ delete_file($jobs);
+ die('0');
+ }
+
+ file_put_contents($active, $lines[0]);
+ // remove first line from jobs file
+ array_shift($lines);
+ if (count($lines) > 0) {
+ file_put_contents($jobs, implode("\n", $lines)."\n");
+ $start = '2';
+ } else {
+ delete_file($jobs);
+ $start = '1';
+ }
+ }
}
die($start);
case 'undo':
$jobs = '/var/tmp/file.manager.jobs';
$undo = '0';
if (file_exists($jobs)) {
- $rows = array_reverse(explode(',',$_POST['row']));
+ $rows = explode(',',$_POST['row']);
+ $lines = file($jobs, FILE_IGNORE_NEW_LINES);
foreach ($rows as $row) {
- $end = $row + 8;
- exec("sed -i '$row,$end d' $jobs");
+ // Validate and convert to integer to prevent non-numeric input
+ if (!is_numeric($row)) {
+ continue; // Skip invalid entries
+ }
+ $row = (int)$row;
+ $line_number = $row - 1; // Convert 1-based job number to 0-based array index
+ if (isset($lines[$line_number])) {
+ unset($lines[$line_number]);
+ }
+ }
+ if (count($lines) > 0) {
+ file_put_contents($jobs, implode("\n", $lines)."\n");
+ $undo = '2';
+ } else {
+ delete_file($jobs);
+ $undo = '1';
}
- $undo = filesize($jobs) > 0 ? '2' : '1';
- if ($undo=='1') delete_file($jobs);
}
die($undo);
case 'read':
$active = '/var/tmp/file.manager.active';
- $read = file_exists($active) ? json_encode(parse_ini_file($active)) : '';
+ $read = file_exists($active) ? file_get_contents($active) : '';
die($read);
case 'file':
$active = '/var/tmp/file.manager.active';
$jobs = '/var/tmp/file.manager.jobs';
- $data[] = 'action="'.($_POST['action']??'').'"';
- $data[] = 'title="'.rawurldecode($_POST['title']??'').'"';
- $data[] = 'source="'.htmlspecialchars_decode(rawurldecode($_POST['source']??'')).'"';
- $data[] = 'target="'.rawurldecode($_POST['target']??'').'"';
- $data[] = 'H="'.(empty($_POST['hdlink']) ? '' : 'H').'"';
- $data[] = 'sparse="'.(empty($_POST['sparse']) ? '' : '--sparse').'"';
- $data[] = 'exist="'.(empty($_POST['exist']) ? '--ignore-existing' : '').'"';
- $data[] = 'zfs="'.rawurldecode($_POST['zfs']??'').'"';
+ $data = [
+ 'action' => (int)($_POST['action'] ?? 0),
+ 'title' => rawurldecode($_POST['title'] ?? ''),
+ 'source' => htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')),
+ 'target' => htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')),
+ 'H' => empty($_POST['hdlink']) ? '' : 'H',
+ 'sparse' => empty($_POST['sparse']) ? '' : '--sparse',
+ 'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
+ 'zfs' => rawurldecode($_POST['zfs'] ?? '')
+ ];
if (isset($_POST['task'])) {
// add task to queue
- $task = rawurldecode($_POST['task']);
- $data = "task=\"$task\"\n".implode("\n",$data)."\n";
- file_put_contents($jobs,$data,FILE_APPEND);
+ $data['task'] = rawurldecode($_POST['task']);
+ file_put_contents($jobs, json_encode($data)."\n", FILE_APPEND);
} else {
// start operation
- file_put_contents($active,implode("\n",$data));
+ file_put_contents($active, json_encode($data));
+ }
+
+ // Update popular destinations for copy/move operations
+ // Action types: 3=copy folder, 4=move folder, 8=copy file, 9=move file
+ if (in_array((int)$data['action'], [3, 4, 8, 9]) && !empty($data['target'])) {
+ updatePopularDestinations($data['target']);
}
+
die();
}
?>
diff --git a/emhttp/plugins/dynamix/include/FileTree.php b/emhttp/plugins/dynamix/include/FileTree.php
index 010aa57125..613214d1c7 100644
--- a/emhttp/plugins/dynamix/include/FileTree.php
+++ b/emhttp/plugins/dynamix/include/FileTree.php
@@ -32,8 +32,8 @@ function path($dir) {
}
function is_top($dir) {
- global $root;
- return mb_strlen($dir) > mb_strlen($root);
+ global $fileTreeRoot;
+ return mb_strlen($dir) > mb_strlen($fileTreeRoot);
}
function no_dots($name) {
@@ -45,11 +45,14 @@ function my_dir($name) {
return ($rootdir === $userdir && in_array($name, $UDincluded)) ? $topdir : $rootdir;
}
-$root = path(realpath($_POST['root']));
-if (!$root) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php");
+$fileTreeRoot = path(realpath($_POST['root']));
+if (!$fileTreeRoot) exit("ERROR: Root filesystem directory not set in jqueryFileTree.php");
$docroot = '/usr/local/emhttp';
require_once "$docroot/webGui/include/Secure.php";
+$_SERVER['REQUEST_URI'] = '';
+require_once "$docroot/webGui/include/Translations.php";
+require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
$mntdir = '/mnt/';
$userdir = '/mnt/user/';
@@ -64,13 +67,51 @@ function my_dir($name) {
// Included UD shares to show under '/mnt/user'
$UDincluded = ['disks','remotes'];
+$showPopular = in_array('SHOW_POPULAR', $filters);
+
echo "
";
-if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
- echo "$checkbox.. ";
+
+// Show popular destinations at the top (only at root level when SHOW_POPULAR filter is set)
+if ($rootdir === $fileTreeRoot && $showPopular) {
+ $popularPaths = getPopularDestinations(5);
+
+ // Filter popular paths to prevent FUSE conflicts between /mnt/user and /mnt/diskX
+ if (!empty($popularPaths)) {
+ $isUserContext = (strpos($fileTreeRoot, '/mnt/user') === 0 || strpos($fileTreeRoot, '/mnt/rootshare') === 0);
+
+ if ($isUserContext) {
+ // In /mnt/user context: only show /mnt/user paths OR non-/mnt paths (external mounts)
+ $popularPaths = array_values(array_filter($popularPaths, function($path) {
+ return (strpos($path, '/mnt/user') === 0 || strpos($path, '/mnt/rootshare') === 0 || strpos($path, '/mnt/') !== 0);
+ }));
+ } else if (strpos($fileTreeRoot, '/mnt/') === 0) {
+ // In /mnt/diskX or /mnt/cache context: exclude /mnt/user and /mnt/rootshare paths
+ $popularPaths = array_values(array_filter($popularPaths, function($path) {
+ return (strpos($path, '/mnt/user') !== 0 && strpos($path, '/mnt/rootshare') !== 0);
+ }));
+ }
+ // If root is not under /mnt/, no filtering needed
+ }
+
+ if (!empty($popularPaths)) {
+ echo "";
+
+ foreach ($popularPaths as $path) {
+ $htmlPath = htmlspecialchars($path, ENT_QUOTES);
+ $displayPath = htmlspecialchars($path, ENT_QUOTES); // Show full path instead of basename
+ // Use data-path instead of rel to prevent jQueryFileTree from handling these links
+ // Use 'directory' class so jQueryFileTree CSS handles the icon
+ echo "$checkbox$displayPath ";
+ }
+
+ // Separator line
+ echo " ";
+ }
}
+// Read directory contents
+$dirs = $files = [];
if (is_dir($rootdir)) {
- $dirs = $files = [];
$names = array_filter(scandir($rootdir, SCANDIR_SORT_NONE), 'no_dots');
// add UD shares under /mnt/user
foreach ($UDincluded as $name) {
@@ -89,25 +130,33 @@ function my_dir($name) {
$files[] = $name;
}
}
- foreach ($dirs as $name) {
- // Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
- if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
- $htmlRel = htmlspecialchars(my_dir($name).$name);
- $htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...');
- if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
- echo "$checkbox$htmlName ";
- }
+}
+
+// Normal mode: show directory tree
+if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
+ echo "$checkbox.. ";
+}
+
+// Display directories and files (arrays already populated above)
+foreach ($dirs as $name) {
+ // Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
+ if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
+ $htmlRel = htmlspecialchars(my_dir($name).$name, ENT_QUOTES);
+ $htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...', ENT_QUOTES);
+ if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
+ echo "$checkbox$htmlName ";
}
- foreach ($files as $name) {
- $htmlRel = htmlspecialchars(my_dir($name).$name);
- $htmlName = htmlspecialchars($name);
- $ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
- foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
- if (empty($match) || preg_match("/$match/", $name)) {
- echo "$checkbox$htmlName ";
- }
+}
+foreach ($files as $name) {
+ $htmlRel = htmlspecialchars(my_dir($name).$name, ENT_QUOTES);
+ $htmlName = htmlspecialchars($name, ENT_QUOTES);
+ $ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
+ foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
+ if (empty($match) || preg_match("/$match/", $name)) {
+ echo "$checkbox$htmlName ";
}
}
}
+
echo " ";
?>
diff --git a/emhttp/plugins/dynamix/include/OpenTerminal.php b/emhttp/plugins/dynamix/include/OpenTerminal.php
index ac6a6833af..a24115106a 100644
--- a/emhttp/plugins/dynamix/include/OpenTerminal.php
+++ b/emhttp/plugins/dynamix/include/OpenTerminal.php
@@ -51,7 +51,45 @@ function command($path,$file) {
// no child processes, restart ttyd to pick up possible font size change
if ($retval != 0) exec("kill ".$ttyd_pid[0]);
}
- if ($retval != 0) exec("ttyd-exec -i '$sock' '" . posix_getpwuid(0)['shell'] . "' --login");
+
+ $more = $_GET['more'] ?? '';
+ if (!empty($more) && substr($more, 0, 1) === '/') {
+ // Terminal at specific path - use 'more' parameter to pass path
+ // Note: openTerminal(tag, name, more) in JS only has 3 params, so we reuse 'more'
+ // Note: Used by File Manager to open terminal at specific folder
+
+ // Validate path
+ $real_path = realpath($more);
+ if ($real_path === false) {
+ // Path doesn't exist - fall back to home directory
+ $real_path = '/root';
+ }
+
+ $name = unbundle($_GET['name']);
+ $exec = "/var/tmp/$name.run.sh";
+ $escaped_path = str_replace("'", "'\\''", $real_path);
+ // Escape sed metacharacters: & (matched string), \\ (escape char), / (delimiter)
+ $sed_escaped = str_replace(['\\', '&', '/'], ['\\\\', '\\&', '\\/'], $escaped_path);
+
+ // Create startup script similar to ~/.bashrc
+ // Note: We can not use ~/.bashrc as it loads /etc/profile which does 'cd $HOME'
+ $script_content = <<
/tmp/$name.profile
+source /tmp/$name.profile
+source /root/.bash_profile 2>/dev/null
+rm /tmp/$name.profile
+exec bash --norc -i
+BASH;
+
+ file_put_contents($exec, $script_content);
+ chmod($exec, 0755);
+ exec("ttyd-exec -i '$sock' $exec");
+ } else {
+ // Standard login shell
+ if ($retval != 0) exec("ttyd-exec -i '$sock' '" . posix_getpwuid(0)['shell'] . "' --login");
+ }
break;
case 'syslog':
// read syslog file
diff --git a/emhttp/plugins/dynamix/include/PopularDestinations.php b/emhttp/plugins/dynamix/include/PopularDestinations.php
new file mode 100644
index 0000000000..2fc1faf36b
--- /dev/null
+++ b/emhttp/plugins/dynamix/include/PopularDestinations.php
@@ -0,0 +1,183 @@
+
+
+// Popular Destinations Management for File Manager
+// Uses frequency-based scoring with decay
+
+define('POPULAR_DESTINATIONS_FILE', '/boot/config/filemanager.json');
+define('SCORE_INCREMENT', 10);
+define('SCORE_DECAY', 1);
+define('MAX_ENTRIES', 50);
+
+/**
+ * Load popular destinations from JSON file
+ *
+ * Note: This function reads without locking, which means it may observe partially
+ * written data if updatePopularDestinations() is writing concurrently. This is
+ * acceptable for display purposes where momentary inconsistency is tolerable.
+ * For updates, always use the atomic read-modify-write operation in
+ * updatePopularDestinations() which uses flock(LOCK_EX).
+ */
+function loadPopularDestinations() {
+ if (!file_exists(POPULAR_DESTINATIONS_FILE)) {
+ return ['destinations' => []];
+ }
+
+ $json = file_get_contents(POPULAR_DESTINATIONS_FILE);
+ $data = json_decode($json, true);
+
+ if (!is_array($data) || !isset($data['destinations'])) {
+ return ['destinations' => []];
+ }
+
+ return $data;
+}
+
+/**
+ * Update popular destinations when a job is started
+ * @param string $targetPath The destination path used in copy/move operation
+ */
+function updatePopularDestinations($targetPath) {
+ // Skip empty paths or paths that are just /mnt or /boot
+ if (empty($targetPath) || $targetPath == '/mnt' || $targetPath == '/boot') {
+ return;
+ }
+
+ // Block path traversal attempts
+ if (strpos($targetPath, '..') !== false) {
+ exec('logger -t webGUI "Security: Blocked path traversal attempt in popular destinations: ' . escapeshellarg($targetPath) . '"');
+ return;
+ }
+
+ // Normalize path (remove trailing slash)
+ $targetPath = rtrim($targetPath, '/');
+
+ // Open file for read+write, create if doesn't exist
+ $fp = fopen(POPULAR_DESTINATIONS_FILE, 'c+');
+ if ($fp === false) {
+ exec('logger -t webGUI "Error: Cannot open popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ return;
+ }
+
+ // Acquire exclusive lock for entire read-modify-write cycle
+ if (!flock($fp, LOCK_EX)) {
+ exec('logger -t webGUI "Error: Cannot lock popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ fclose($fp);
+ return;
+ }
+
+ // Read current data
+ $json = stream_get_contents($fp);
+ if ($json === false || $json === '') {
+ $data = ['destinations' => []];
+ } else {
+ $data = json_decode($json, true);
+ if (!is_array($data) || !isset($data['destinations'])) {
+ $data = ['destinations' => []];
+ }
+ }
+
+ $destinations = $data['destinations'];
+
+ // Find target path first (before decay)
+ $found = false;
+ $targetIndex = -1;
+ foreach ($destinations as $index => $dest) {
+ if ($dest['path'] === $targetPath) {
+ $found = true;
+ $targetIndex = $index;
+ break;
+ }
+ }
+
+ // Decay all scores by 1 (except the target path which we'll increment)
+ foreach ($destinations as $index => &$dest) {
+ if ($index !== $targetIndex) {
+ $dest['score'] -= SCORE_DECAY;
+ } else {
+ // Target path: increment instead of decaying
+ $dest['score'] += SCORE_INCREMENT;
+ }
+ }
+ unset($dest);
+
+ // If path not found, add it
+ if (!$found) {
+ $destinations[] = [
+ 'path' => $targetPath,
+ 'score' => SCORE_INCREMENT
+ ];
+ }
+
+ // Remove entries with score <= 0
+ $destinations = array_filter($destinations, function($dest) {
+ return $dest['score'] > 0;
+ });
+
+ // Sort by score descending
+ usort($destinations, function($a, $b) {
+ return $b['score'] - $a['score'];
+ });
+
+ // Keep only MAX_ENTRIES
+ if (count($destinations) > MAX_ENTRIES) {
+ $destinations = array_slice($destinations, 0, MAX_ENTRIES);
+ }
+
+ // Re-index array
+ $destinations = array_values($destinations);
+
+ // Write back atomically (still holding the lock)
+ $data['destinations'] = $destinations;
+ $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+
+ // Truncate and write from beginning
+ if (ftruncate($fp, 0) === false || fseek($fp, 0) === -1) {
+ exec('logger -t webGUI "Error: Cannot truncate popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ return;
+ }
+
+ $result = fwrite($fp, $json);
+ if ($result === false) {
+ exec('logger -t webGUI "Error: Failed to write popular destinations file: ' . POPULAR_DESTINATIONS_FILE . '"');
+ }
+
+ // Release lock and close file
+ flock($fp, LOCK_UN);
+ fclose($fp);
+}
+
+/**
+ * Get top N popular destinations
+ * @param int $limit Maximum number of destinations to return (default 5)
+ * @return array Array of destination paths
+ */
+function getPopularDestinations($limit = 5) {
+ $data = loadPopularDestinations();
+ $destinations = $data['destinations'];
+
+ // Sort by score descending (should already be sorted, but just in case)
+ usort($destinations, function($a, $b) {
+ return $b['score'] - $a['score'];
+ });
+
+ // Return top N paths
+ $result = array_slice($destinations, 0, $limit);
+
+ return array_map(function($dest) {
+ return $dest['path'];
+ }, $result);
+}
+?>
diff --git a/emhttp/plugins/dynamix/include/Templates.php b/emhttp/plugins/dynamix/include/Templates.php
index b06c5d8032..9a9f06db16 100644
--- a/emhttp/plugins/dynamix/include/Templates.php
+++ b/emhttp/plugins/dynamix/include/Templates.php
@@ -8,6 +8,19 @@
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
+ *
+ * NOTE: The templates below use Markdown syntax for definition lists.
+ *
+ * The (word break opportunity) elements serve two purposes:
+ * 1. They trigger Markdown to generate proper HTML structure
+ * (the Markdown parser requires a "term" before the colon to create definition lists)
+ * Syntax reference: https://www.markdownguide.org/extended-syntax/#definition-lists
+ * 2. They allow CSS to hide empty spacer rows in mobile layouts using dt:has(wbr)
+ * (see @media (max-width: 768px) in default-dynamix.css)
+ *
+ * Without , the colon-prefixed lines would be rendered as paragraphs instead
+ * of definition list items, breaking the intended layout structure. And using
+ * would not allow CSS to target those rows for hiding in mobile views.
*/
?>
@@ -18,46 +31,37 @@
_(New folder name)_:
:
-
+
:
-
-
-:
_(This creates a folder at the current level)_
_(Folder name)_:
:
-
+
:
-
-
-:
=_("This deletes the folder and all its content")?>
_(Current folder name)_:
:
-
+
: _(rename to)_ ...
_(New folder name)_:
:
-
+
:
-
-
-:
_(This renames the folder to the new name)_
_(Source folder)_:
:
-
+
:
@@ -70,14 +74,9 @@
-
+
: _(copy to)_ ...
-
-
- =_("This copies the folder and all its content to another folder")?>
-
-
_(Target folder)_:
:
@@ -86,7 +85,7 @@
_(Source folder)_:
:
-
+
:
@@ -99,14 +98,9 @@
-
+
: _(move to)_ ...
-
-
- =_("This moves the folder and all its content to another folder")?>
-
-
_(Target folder)_:
:
@@ -115,35 +109,29 @@
_(File name)_:
:
-
+
:
-
-
-:
_(This deletes the selected file)_
_(Current file name)_:
:
-
+
: _(rename to)_ ...
_(New file name)_:
:
-
+
:
-
-
-:
_(This renames the selected file)_
_(Source file)_:
:
-
+
:
@@ -156,14 +144,9 @@
-
+
: _(copy to)_ ...
-
-
- _(This copies the selected file)_
-
-
_(Target file)_:
:
@@ -172,7 +155,7 @@
_(Source file)_:
:
-
+
:
@@ -185,14 +168,9 @@
-
+
: _(move to)_ ...
-
-
- _(This moves the selected file)_
-
-
_(Target file)_:
:
@@ -201,32 +179,29 @@
_(Source)_:
:
-
+
:
-
-
-: _(This deletes all selected sources)_
_(Source)_:
:
-
+
: _(rename to)_ ...
_(Target)_:
:
-
-:
_(This renames the selected source)_
+
+:
_(Source)_:
:
-
+
:
@@ -239,14 +214,9 @@
-
+
: _(copy to)_ ...
-
-
- _(This copies all the selected sources)_
-
-
_(Target)_:
:
@@ -255,7 +225,7 @@
_(Source)_:
:
-
+
:
@@ -268,14 +238,9 @@
-
+
: _(move to)_ ...
-
-
- _(This moves all the selected sources)_
-
-
_(Target)_:
:
@@ -284,7 +249,7 @@
_(Source)_:
:
-
+
: _(change owner)_ ...
_(New owner)_:
@@ -293,24 +258,22 @@
echo mk_option(0,'nobody','nobody');
?>
-
+
:
-
- _(This changes the owner of the source recursively)_
_(Source)_:
:
-
+
: _(change permission)_ ...
_(New permission)_:
:
_(Owner)_:
-
+
=mk_option(0,'u-rwx',_('No Access'))?>
=mk_option(0,'u-wx+r',_('Read-only'))?>
=mk_option(0,'u-x+rw',_('Read/Write'))?>
@@ -318,7 +281,7 @@
_(Group)_:
-
+
=mk_option(0,'g-rwx',_('No Access'))?>
=mk_option(0,'g-wx+r',_('Read-only'))?>
=mk_option(0,'g-x+rw',_('Read/Write'))?>
@@ -326,16 +289,15 @@
_(Other)_:
-
+
=mk_option(0,'o-rwx',_('No Access'))?>
=mk_option(0,'o-wx+r',_('Read-only'))?>
=mk_option(0,'o-x+rw',_('Read/Write'))?>
-
-:
-
_(This changes the permission of the source recursively)_
+
+:
diff --git a/emhttp/plugins/dynamix/nchan/file_manager b/emhttp/plugins/dynamix/nchan/file_manager
index 810df8c785..4062063943 100755
--- a/emhttp/plugins/dynamix/nchan/file_manager
+++ b/emhttp/plugins/dynamix/nchan/file_manager
@@ -21,6 +21,11 @@ $empty_dir = '/var/tmp/file.manager.empty_dir/'; // trailing slash is required f
$null = '/dev/null';
$timer = time();
+// Rsync progress calculation constants
+define('RSYNC_MIN_PROGRESS_PERCENT', 3); // Minimum progress percentage before calculating total size
+define('RSYNC_TOTAL_SIZE_SAMPLES', 5); // Number of measurements to average for total size calculation
+
+require_once "$docroot/webGui/include/Helpers.php";
require_once "$docroot/webGui/include/Wrappers.php";
require_once "$docroot/webGui/include/publish.php";
extract(parse_plugin_cfg('dynamix', true));
@@ -37,15 +42,11 @@ function pool_only(&$disks) {
return array_filter($disks, function($disk){return $disk['type'] == 'Cache' && !empty($disk['uuid']);});
}
-function pools_filter(&$disks) {
+function fm_pools_filter(&$disks) {
return array_keys(pool_only($disks));
}
-function delete_file(...$file) {
- array_map('unlink', array_filter($file, 'file_exists'));
-}
-
-function pgrep($pid) {
+function pid_exists($pid) {
$pid = is_array($pid) ? $pid[0] : $pid;
return $pid && file_exists("/proc/$pid") ? $pid : false;
}
@@ -77,26 +78,50 @@ function seconds_to_time($seconds) {
return sprintf("%d:%02d:%02d", $hours, $minutes, $secs);
}
-function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds = null) {
- // Convert transferred size to bytes
+/**
+ * Convert human-readable size string to bytes.
+ *
+ * @param string $size Size string (e.g., "1.5G", "250M", "1024K")
+ * @return float Size in bytes
+ */
+function size_to_bytes($size) {
$multipliers = ['K' => 1024, 'M' => 1024*1024, 'G' => 1024*1024*1024, 'T' => 1024*1024*1024*1024];
- $transferred_bytes = floatval($transferred);
+ $bytes = floatval($size);
foreach ($multipliers as $unit => $mult) {
- if (stripos($transferred, $unit) !== false) {
- $transferred_bytes *= $mult;
+ if (stripos($size, $unit) !== false) {
+ $bytes *= $mult;
break;
}
}
+ return $bytes;
+}
- // Convert speed to bytes/sec
- $speed_bytes = floatval($speed);
- if (stripos($speed, 'kB/s') !== false) {
- $speed_bytes *= 1024;
- } elseif (stripos($speed, 'MB/s') !== false) {
- $speed_bytes *= 1024 * 1024;
- } elseif (stripos($speed, 'GB/s') !== false) {
- $speed_bytes *= 1024 * 1024 * 1024;
+/**
+ * Format bytes to human-readable size string.
+ *
+ * @param float $bytes Size in bytes
+ * @return string Formatted size (e.g., "1.50GB", "250.00MB")
+ */
+function bytes_to_size($bytes) {
+ $display = $bytes;
+ $unit = 'B';
+ $units = ['KB' => 1024, 'MB' => 1024*1024, 'GB' => 1024*1024*1024, 'TB' => 1024*1024*1024*1024];
+ foreach (array_reverse($units, true) as $u => $divisor) {
+ if ($bytes >= $divisor) {
+ $display = $bytes / $divisor;
+ $unit = $u;
+ break;
+ }
}
+ return my_number($display, 2) . $unit;
+}
+
+function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds = null) {
+ // Convert transferred size to bytes
+ $transferred_bytes = size_to_bytes($transferred);
+
+ // Convert speed to bytes/sec (speed format: "50.00MB/s")
+ $speed_bytes = size_to_bytes(str_replace('/s', '', $speed));
// Calculate total size from percent
$percent_val = intval(str_replace('%', '', $percent));
@@ -129,8 +154,31 @@ function calculate_eta($transferred, $percent, $speed, $last_rsync_eta_seconds =
return "N/A";
}
-function parse_rsync_progress($status, $action_label) {
+/**
+ * Parse rsync progress output and track transfer statistics.
+ *
+ * Uses static variables to maintain state across multiple calls during a single operation.
+ * Call with $reset=true before starting a new copy/move operation to clear previous state.
+ *
+ * @param string $status Raw rsync output line to parse
+ * @param string $action_label Label to display for the current action (e.g. "Copying", "Moving")
+ * @param bool $reset If true, resets static state variables (call before new operation)
+ * @return array Associative array with progress information: 'text', 'percent', 'eta', 'speed', etc.
+ */
+function parse_rsync_progress($status, $action_label, $reset = false) {
static $last_rsync_eta_seconds = null;
+ static $total_size = null;
+ static $total_calculations = []; // Store multiple calculations for averaging
+ static $last_calc_percent = null; // Track last percent we calculated from
+
+ // Reset static variables when starting a new transfer
+ if ($reset) {
+ $last_rsync_eta_seconds = null;
+ $total_size = null;
+ $total_calculations = [];
+ $last_calc_percent = null;
+ return [];
+ }
// initialize text array with action label
$text[0] = $action_label . "... ";
@@ -152,6 +200,33 @@ function parse_rsync_progress($status, $action_label) {
$speed = $parts[2];
$time = $parts[3];
+ // Calculate total size by averaging multiple measurements
+ // rsync truncates percent (not rounds), so 47.9% shows as 47%
+ // This causes ~2% error per percent point. We average multiple measurements at different percents.
+ if ($total_size === null || count($total_calculations) < RSYNC_TOTAL_SIZE_SAMPLES) {
+ $percent_val = intval(str_replace('%', '', $percent));
+
+ // Track if this is a "running transfer" line (no xfr# info)
+ $is_running_line = !isset($parts[4]);
+
+ // Calculate total when we have minimum progress on a running transfer line
+ // and the percent changed since last calculation
+ if ($is_running_line && $percent_val >= RSYNC_MIN_PROGRESS_PERCENT && $last_calc_percent !== $percent_val) {
+ // Convert transferred size to bytes
+ $transferred_bytes = size_to_bytes($transferred);
+
+ // Calculate total from transferred and percent (rsync truncates, so add 0.5% for better accuracy)
+ $calculated_total = $transferred_bytes * 100 / ($percent_val + 0.5);
+ $total_calculations[] = $calculated_total;
+ $last_calc_percent = $percent_val;
+
+ // Once we have enough measurements, average them and lock the value
+ if (count($total_calculations) >= RSYNC_TOTAL_SIZE_SAMPLES) {
+ $total_size = array_sum($total_calculations) / count($total_calculations);
+ }
+ }
+ }
+
// Check if this is an ETA line or elapsed time line
// ETA lines have only 4 parts, elapsed time lines have additional (xfr#...) info
if (isset($parts[4])) {
@@ -162,7 +237,20 @@ function parse_rsync_progress($status, $action_label) {
$last_rsync_eta_seconds = time_to_seconds($time);
}
- $text[1] = _('Completed') . ": " . $percent . ", " . _('Speed') . ": " . $speed . ", " . _('ETA') . ": " . $time;
+ // Build progress text with total size
+ $progress_parts = [];
+ $progress_parts[] = _('Completed') . ": " . $percent;
+ $progress_parts[] = _('Speed') . ": " . $speed;
+ $progress_parts[] = _('ETA') . ": " . $time;
+
+ // Always show Total (either calculated or N/A)
+ if ($total_size !== null) {
+ $progress_parts[] = _('Total') . ": ~" . bytes_to_size($total_size);
+ } else {
+ $progress_parts[] = _('Total') . ": N/A";
+ }
+
+ $text[1] = implode(", ", $progress_parts);
}
}
@@ -220,7 +308,7 @@ function cat($file) {
$set[] = exec("getfattr --no-dereference --absolute-names --only-values -n system.LOCATIONS ".quoted($name)." 2>$null");
}
$disks = parse_ini_file('state/disks.ini', true);
- $tag = implode('|',array_merge(['disk'],pools_filter($disks)));
+ $tag = implode('|',array_merge(['disk'],fm_pools_filter($disks)));
$set = explode(';',str_replace(',;',',',preg_replace("/($tag)/", ';$1', implode($set))));
}
foreach (array_diff($rows, $user) as $row) {
@@ -265,14 +353,24 @@ if (!file_exists($empty_dir)) {
// initialize $delete_empty_dirs state: null = not a move operation (yet), true = rsync copy-delete phase, false = cleanup phase (done)
$delete_empty_dirs = null;
+$pid = false;
// infinite loop to monitor and execute file operations
// Note: exec() uses /bin/sh which is symlinked to bash in unraid and a requirement for process substitution syntax >(...)
while (true) {
unset($action, $source, $target, $H, $sparse, $exist, $zfs);
- // read job parameters from ini file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
- if (file_exists($active)) extract(parse_ini_file($active));
+ // read job parameters from JSON file: $action, $title, $source, $target, $H, $sparse, $exist, $zfs (set by emhttp/plugins/dynamix/include/Control.php)
+ if (file_exists($active)) {
+ $json = file_get_contents($active);
+ $data = json_decode($json, true);
+ if (is_array($data)) {
+ extract($data);
+ } elseif ($json !== false && trim($json) !== '') {
+ // Log JSON parse failure for debugging (non-empty file that failed to parse)
+ exec('logger -t file_manager "Warning: Failed to parse active job JSON: ' . escapeshellarg(substr($json, 0, 100)) . '"');
+ }
+ }
// read PID from file (file_manager may have been restarted)
if (!$pid && file_exists($pid_file)) {
@@ -334,6 +432,7 @@ while (true) {
// start action
} else {
+ parse_rsync_progress(null, null, true); // Reset static variables
$target = validname($target, false);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';
@@ -343,7 +442,7 @@ while (true) {
// breaks access for all unraid users through /mnt/user/sharename. We avoid this behavior by using "--no-inc-recursive".
exec($cmd, $pid);
} else {
- $reply['error'] = 'Invalid target name';
+ $reply['error'] = _('Invalid target name');
}
}
break;
@@ -377,6 +476,7 @@ while (true) {
// start action
} else {
+ parse_rsync_progress(null, null, true); // Reset static variables
$target = validname($target, false);
if ($target) {
$mkpath = isdir($target) ? '--mkpath' : '';
@@ -443,7 +543,7 @@ while (true) {
// target must not be a subdirectory of any source (backup-dir should be outside source tree)
$source_dirname = is_dir($valid_source_path) ? $valid_source_path : dirname($valid_source_path);
if (strpos(rtrim($target,'/') . '/', rtrim($source_dirname,'/') . '/') === 0) {
- $reply['error'] = 'Cannot move directory into its own subdirectory';
+ $reply['error'] = _('Cannot move directory into its own subdirectory');
$use_rsync_rename = false;
break 2; // break out of both: foreach and case
}
@@ -473,7 +573,7 @@ while (true) {
}
} else {
- $reply['error'] = 'Invalid target name';
+ $reply['error'] = _('Invalid target name');
}
}
break;
@@ -507,7 +607,7 @@ while (true) {
default:
continue 2;
}
- $pid = pgrep($pid??0);
+ $pid = pid_exists($pid??0);
// Store PID to survive file_manager restarts
if ($pid !== false) {
@@ -518,7 +618,7 @@ while (true) {
if (!empty($delete_empty_dirs)) {
exec("find ".quoted($source)." -type d -empty -print -delete 1>$status 2>$null & echo \$!", $pid);
$delete_empty_dirs = false;
- $pid = pgrep($pid);
+ $pid = pid_exists($pid);
} else {
if ($action != 15) {
$reply['status'] = _('Done');
diff --git a/emhttp/plugins/dynamix/sheets/BrowseButton.css b/emhttp/plugins/dynamix/sheets/BrowseButton.css
index 1a99c5dddc..f9ce63c1e0 100644
--- a/emhttp/plugins/dynamix/sheets/BrowseButton.css
+++ b/emhttp/plugins/dynamix/sheets/BrowseButton.css
@@ -23,12 +23,6 @@
#user-notice {
float: left;
}
-/* div.dfm_info {
- position: absolute;
- bottom: 4px;
- width: 74%;
- margin-left: 23%;
-} */
div.dfm_template {
display: none;
}
diff --git a/emhttp/plugins/dynamix/styles/default-base.css b/emhttp/plugins/dynamix/styles/default-base.css
index 3e6e5ec3dd..0ac1f9323c 100755
--- a/emhttp/plugins/dynamix/styles/default-base.css
+++ b/emhttp/plugins/dynamix/styles/default-base.css
@@ -91,6 +91,31 @@ a.info:hover span {
display: block;
z-index: 1;
}
+/* Base styling for small-caps labels */
+.small-caps-label {
+ white-space: nowrap;
+ font-variant: small-caps;
+ line-height: 2rem;
+ color: var(--text-color);
+}
+/* Tooltip notification for successful clipboard operations */
+.clipboard-tooltip {
+ display: block;
+ white-space: nowrap;
+ font-variant: small-caps;
+ line-height: 2rem;
+ color: var(--text-color);
+ padding: 5px 8px;
+ border: 1px solid var(--inverse-border-color);
+ border-radius: 3px;
+ background-color: var(--background-color);
+ box-shadow: var(--small-shadow);
+ position: fixed;
+ top: 60%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 1;
+}
a.nohand {
cursor: default;
}
diff --git a/emhttp/plugins/dynamix/styles/default-dynamix.css b/emhttp/plugins/dynamix/styles/default-dynamix.css
index a2aae6037c..a5cb3030fa 100644
--- a/emhttp/plugins/dynamix/styles/default-dynamix.css
+++ b/emhttp/plugins/dynamix/styles/default-dynamix.css
@@ -1484,7 +1484,7 @@ div.icon-zip {
flex-direction: column;
align-items: center;
height: auto !important;
- min-height: 35vh !important;
+ min-height: 20vh !important;
max-height: 70vh !important;
overflow-y: auto;
padding-top: 2rem;
@@ -1495,13 +1495,16 @@ div.icon-zip {
}
}
- .dfm_info {
- margin-top: auto;
- }
-
}
.ui-dialog-buttonpane {
+ .dfm-warning {
+ margin-right: 10px;
+ display: inline-block;
+ vertical-align: middle;
+ color: var(--alt-text-color);
+ }
+
.ui-dialog-buttonset {
button {
font-family: clear-sans;
@@ -1747,3 +1750,46 @@ div.icon-zip {
flex: 3;
}
}
+
+/* Mobile dialog improvements (<767px)
+ * Note: These rules apply to ALL dialogs (Docker, VM, Share settings, File Manager, etc.)
+ * to improve mobile UX across the entire application.
+ */
+@media (max-width: 767px) {
+ /* Remove padding-bottom from dialog content for better mobile layout */
+ .ui-dialog .ui-dialog-content {
+ padding-bottom: 0 !important;
+ }
+
+ /* Remove padding-bottom from dl elements in dialogs */
+ .ui-dialog dl {
+ padding-bottom: 0 !important;
+ }
+
+ /* Hide dt elements containing wbr (spacers) - these create unwanted gaps in mobile layout
+ * Note: is used in File Manager templates as markdown definition list trigger */
+ .ui-dialog dt:has(wbr) {
+ display: none;
+ }
+
+ /* Center dd elements that follow hidden dt spacers (e.g., "copy to ...", "move to ...") */
+ .ui-dialog dt:has(wbr) + dd {
+ text-align: center;
+ }
+
+ /* File Manager specific: warning text and button layout */
+ .ui-dialog-buttonpane {
+ .dfm-warning {
+ display: block;
+ width: 100%;
+ margin-right: 0;
+ margin-top: 10px;
+ text-align: center;
+ }
+
+ .ui-dialog-buttonset {
+ width: 100%;
+ text-align: center;
+ }
+ }
+}