Skip to content

Commit 2505068

Browse files
committed
Fix unraid#2500: File Manager UI/UX improvements
This PR addresses multiple issues and feature requests from unraid#2500: 1. Fix PR unraid#2402 Markdown Parser regression (literal colon bug) - Changed dialog templates from <div> to <span> for inline processing - Cleaned up unused dfm_info CSS rules 2. Show total size of running transfer - Enhanced rsync parser to calculate total transfer size - Displays 'Transferring X of Y' format 3. Show last N used destination paths in FileTree - New PopularDestinations.php with frequency-based scoring - FUSE conflict prevention for /mnt/user vs /mnt/diskX 4. Manually typing destination path updates FileTree - Keyboard navigation with arrow keys and Enter/Escape - Automatic FileTree open/close on input interaction 5. Add 'Open Terminal here' button - OpenTerminal.php accepts path parameter - File Manager can open terminal at selected folder Note: This PR depends on unraid#2491 and unraid#2490 being merged first.
1 parent 987fa42 commit 2505068

File tree

11 files changed

+898
-121
lines changed

11 files changed

+898
-121
lines changed

emhttp/plugins/dynamix/Browse.page

Lines changed: 383 additions & 8 deletions
Large diffs are not rendered by default.

emhttp/plugins/dynamix/include/Browse.php

Lines changed: 79 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ function my_devs(&$devs,$name,$menu) {
8282

8383
function icon_class($ext) {
8484
switch ($ext) {
85+
case 'broken-symlink':
86+
return 'fa fa-chain-broken red-text';
8587
case '3gp': case 'asf': case 'avi': case 'f4v': case 'flv': case 'm4v': case 'mkv': case 'mov': case 'mp4': case 'mpeg': case 'mpg': case 'm2ts': case 'ogm': case 'ogv': case 'vob': case 'webm': case 'wmv':
8688
return 'fa fa-film';
8789
case '7z': case 'bz2': case 'gz': case 'rar': case 'tar': case 'xz': case 'zip':
@@ -149,48 +151,111 @@ function icon_class($ext) {
149151

150152
if ($user ) {
151153
exec("shopt -s dotglob;getfattr --no-dereference --absolute-names -n system.LOCATIONS ".escapeshellarg($dir)."/* 2>/dev/null",$tmp);
152-
for ($i = 0; $i < count($tmp); $i+=3) $set[basename($tmp[$i])] = explode('"',$tmp[$i+1])[1];
154+
// Decode octal escapes from getfattr output to match actual filenames
155+
// Reason: "getfattr" outputs \012 (newline) but the below "find" returns actual newline character
156+
for ($i = 0; $i < count($tmp); $i+=3) {
157+
// Check bounds: if getfattr fails for a file, we might not have all 3 lines
158+
if (!isset($tmp[$i+1])) break;
159+
$filename = preg_replace_callback('/\\\\([0-7]{3})/', function($m) { return chr(octdec($m[1])); }, $tmp[$i]);
160+
$set[basename($filename)] = explode('"',$tmp[$i+1])[1];
161+
}
153162
unset($tmp);
154163
}
155164

156-
$stat = popen("shopt -s dotglob;stat -L -c'%F|%U|%A|%s|%Y|%n' ".escapeshellarg($dir)."/* 2>/dev/null",'r');
165+
// Get directory listing with stat info NULL-separated to support newlines in file/dir names
166+
// Two separate finds: working symlinks with target info, broken symlinks marked as such
167+
// Format: 7 fields per entry separated by \0: type\0owner\0perms\0size\0timestamp\0name\0symlinkTarget\0
168+
$cmd = <<<'BASH'
169+
cd %s && {
170+
find . -maxdepth 1 -mindepth 1 ! -xtype l -printf '%%y\0%%u\0%%M\0%%s\0%%T@\0%%p\0%%l\0' 2>/dev/null
171+
find . -maxdepth 1 -mindepth 1 -xtype l -printf 'broken\0%%u\0%%M\0%%s\0%%T@\0%%p\0%%l\0' 2>/dev/null
172+
}
173+
BASH;
174+
$stat = popen(sprintf($cmd, escapeshellarg($dir)), 'r');
175+
176+
// Read all output and split by \0 into array
177+
$all_output = stream_get_contents($stat);
178+
pclose($stat);
179+
$fields_array = explode("\0", $all_output);
180+
181+
// Process in groups of 7 fields per entry
182+
for ($i = 0; $i + 7 <= count($fields_array); $i += 7) {
183+
$fields = array_slice($fields_array, $i, 7);
184+
[$type,$owner,$perm,$size,$time,$name,$target] = $fields;
185+
$time = (int)$time;
186+
$name = $dir.'/'.substr($name, 2); // Remove './' prefix from find output
187+
188+
// Determine device name for LOCATION column
189+
// For symlinks with absolute targets, use the target path to determine the device
190+
// For everything else, use the source path
191+
if ($target && $target[0] == '/') {
192+
193+
// Absolute symlink: extract device from target path
194+
// Example: /mnt/disk2/foo/bar -> dev[2] = 'disk2'
195+
$dev = explode('/', $target, 5);
196+
$dev_name = $dev[2] ?? '';
197+
198+
} else {
199+
200+
// Regular file/folder or relative symlink: extract from source path
201+
// Example: /mnt/disk1/sharename/foo -> dev[3] = 'sharename', dev[2] = 'disk1'
202+
$dev = explode('/', $name, 5);
203+
$dev_name = $dev[3] ?? $dev[2];
204+
205+
}
206+
207+
// Build device list for LOCATION column
208+
// In user share: get device list from xattr (system.LOCATIONS) or share config
209+
if ($user) {
210+
$devs_value = $set[basename($name)] ?? $shares[$dev_name]['cachePool'] ?? '';
211+
212+
// On direct disk path:
213+
} else {
214+
215+
// For absolute symlinks: use the target's device name
216+
if ($target && $target[0] == '/') {
217+
$devs_value = $dev_name;
218+
219+
// For regular files/folders: use current device name like disk1, boot, etc.
220+
} else {
221+
$devs_value = $lock;
222+
}
223+
224+
}
225+
$devs = explode(',', $devs_value);
157226

158-
while (($row = fgets($stat)) !== false) {
159-
[$type,$owner,$perm,$size,$time,$name] = explode('|',rtrim($row,"\n"),6);
160-
$dev = explode('/', $name, 5);
161-
$devs = explode(',', $user ? $set[basename($name)] ?? $shares[$dev[3]]['cachePool'] ?? '' : $lock);
162227
$objs++;
163228
$text = [];
164-
if ($type[0] == 'd') {
229+
if ($type == 'd') {
165230
$text[] = '<tr><td><i id="check_'.$objs.'" class="fa fa-fw fa-square-o" onclick="selectOne(this.id)"></i></td>';
166231
$text[] = '<td data=""><i class="fa fa-folder-o"></i></td>';
167-
$text[] = '<td><a id="name_'.$objs.'" oncontextmenu="folderContextMenu(this.id,\'right\');return false" href="/'.$path.'?dir='.rawurlencode(htmlspecialchars($name)).'">'.htmlspecialchars(basename($name)).'</a></td>';
232+
// nl2br() is used to preserve newlines in file/dir names
233+
$text[] = '<td><a id="name_'.$objs.'" oncontextmenu="folderContextMenu(this.id,\'right\');return false" href="/'.$path.'?dir='.rawurlencode(htmlspecialchars($name)).'">'.nl2br(htmlspecialchars(basename($name))).'</a></td>';
168234
$text[] = '<td id="owner_'.$objs.'">'.$owner.'</td>';
169235
$text[] = '<td id="perm_'.$objs.'">'.$perm.'</td>';
170236
$text[] = '<td data="0">&lt;'.$folder.'&gt;</td>';
171237
$text[] = '<td data="'.$time.'"><span class="my_time">'.my_time($time,$fmt).'</span><span class="my_age" style="display:none">'.my_age($time).'</span></td>';
172-
$text[] = '<td class="loc">'.my_devs($devs,$dev[3]??$dev[2],'deviceFolderContextMenu').'</td>';
238+
$text[] = '<td class="loc">'.my_devs($devs,$dev_name,'deviceFolderContextMenu').'</td>';
173239
$text[] = '<td><i id="row_'.$objs.'" data="'.escapeQuote($name).'" type="d" class="fa fa-plus-square-o" onclick="folderContextMenu(this.id,\'both\')" oncontextmenu="folderContextMenu(this.id,\'both\');return false">...</i></td></tr>';
174240
$dirs[] = gzdeflate(implode($text));
175241
} else {
176-
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
242+
$is_broken = ($type == 'broken');
243+
$ext = $is_broken ? 'broken-symlink' : strtolower(pathinfo($name, PATHINFO_EXTENSION));
177244
$tag = count($devs) > 1 ? 'warning' : '';
178245
$text[] = '<tr><td><i id="check_'.$objs.'" class="fa fa-fw fa-square-o" onclick="selectOne(this.id)"></i></td>';
179246
$text[] = '<td class="ext" data="'.$ext.'"><i class="'.icon_class($ext).'"></i></td>';
180-
$text[] = '<td id="name_'.$objs.'" class="'.$tag.'" onclick="fileEdit(this.id)" oncontextmenu="fileContextMenu(this.id,\'right\');return false">'.htmlspecialchars(basename($name)).'</td>';
247+
$text[] = '<td id="name_'.$objs.'" class="'.$tag.'"'.($is_broken ? '' : ' onclick="fileEdit(this.id)"').' oncontextmenu="fileContextMenu(this.id,\'right\');return false">'.nl2br(htmlspecialchars(basename($name))).'</td>';
181248
$text[] = '<td id="owner_'.$objs.'" class="'.$tag.'">'.$owner.'</td>';
182249
$text[] = '<td id="perm_'.$objs.'" class="'.$tag.'">'.$perm.'</td>';
183250
$text[] = '<td data="'.$size.'" class="'.$tag.'">'.my_scale($size,$unit).' '.$unit.'</td>';
184251
$text[] = '<td data="'.$time.'" class="'.$tag.'"><span class="my_time">'.my_time($time,$fmt).'</span><span class="my_age" style="display:none">'.my_age($time).'</span></td>';
185-
$text[] = '<td class="loc '.$tag.'">'.my_devs($devs,$dev[3]??$dev[2],'deviceFileContextMenu').'</td>';
252+
$text[] = '<td class="loc '.$tag.'">'.my_devs($devs,$dev_name,'deviceFileContextMenu').'</td>';
186253
$text[] = '<td><i id="row_'.$objs.'" data="'.escapeQuote($name).'" type="f" class="fa fa-plus-square-o" onclick="fileContextMenu(this.id,\'both\')" oncontextmenu="fileContextMenu(this.id,\'both\');return false">...</i></td></tr>';
187254
$files[] = gzdeflate(implode($text));
188255
$total += $size;
189256
}
190257
}
191258

192-
pclose($stat);
193-
194259
if ($link = parent_link()) echo '<tbody class="tablesorter-infoOnly"><tr><td></td><td><i class="fa fa-folder-open-o"></i></td><td>',$link,'</td><td colspan="6"></td></tr></tbody>';
195260
echo write($dirs),write($files),'<tfoot><tr><td></td><td></td><td colspan="7">',add($objs,'object'),': ',add($dirs,'director','y','ies'),', ',add($files,'file'),' (',my_scale($total,$unit),' ',$unit,' ',_('total'),')</td></tr></tfoot>';
196261
?>

emhttp/plugins/dynamix/include/Control.php

Lines changed: 58 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<?
1414
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
1515
require_once "$docroot/webGui/include/Helpers.php";
16+
require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
1617

1718
// add translations
1819
$_SERVER['REQUEST_URI'] = '';
@@ -109,11 +110,13 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
109110
$file = '/var/tmp/file.manager.jobs';
110111
$rows = file_exists($file) ? file($file,FILE_IGNORE_NEW_LINES) : [];
111112
$job = 1;
112-
for ($x = 0; $x < count($rows); $x+=9) {
113-
$data = parse_ini_string(implode("\n",array_slice($rows,$x,9)));
114-
$task = $data['task'];
115-
$source = explode("\r",$data['source']);
116-
$target = $data['target'];
113+
foreach ($rows as $row) {
114+
if (empty($row)) continue;
115+
$data = json_decode($row, true);
116+
if (!$data) continue;
117+
$task = $data['task'] ?? '';
118+
$source = explode("\r",$data['source'] ?? '');
119+
$target = $data['target'] ?? '';
117120
$more = count($source) > 1 ? " (".sprintf("and %s more",count($source)-1).") " : "";
118121
$jobs[] = '<i id="queue_'.$job.'" class="fa fa-fw fa-square-o blue-text job" onclick="selectOne(this.id,false)"></i>'._('Job')." [".sprintf("%'.04d",$job++)."] - $task ".$source[0].$more.($target ? " --> $target" : "");
119122
}
@@ -134,48 +137,77 @@ function quoted($name) {return is_array($name) ? implode(' ',array_map('escape',
134137
$jobs = '/var/tmp/file.manager.jobs';
135138
$start = '0';
136139
if (file_exists($jobs)) {
137-
exec("sed -n '2,9 p' $jobs > $active");
138-
exec("sed -i '1,9 d' $jobs");
139-
$start = filesize($jobs) > 0 ? '2' : '1';
140-
if ($start=='1') delete_file($jobs);
140+
// read first JSON line from jobs file and write to active
141+
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
142+
if (!empty($lines)) {
143+
file_put_contents($active, $lines[0]);
144+
// remove first line from jobs file
145+
array_shift($lines);
146+
if (count($lines) > 0) {
147+
file_put_contents($jobs, implode("\n", $lines)."\n");
148+
$start = '2';
149+
} else {
150+
delete_file($jobs);
151+
$start = '1';
152+
}
153+
}
141154
}
142155
die($start);
143156
case 'undo':
144157
$jobs = '/var/tmp/file.manager.jobs';
145158
$undo = '0';
146159
if (file_exists($jobs)) {
147160
$rows = array_reverse(explode(',',$_POST['row']));
161+
$lines = file($jobs, FILE_IGNORE_NEW_LINES);
148162
foreach ($rows as $row) {
149-
$end = $row + 8;
150-
exec("sed -i '$row,$end d' $jobs");
163+
$line_number = $row - 1; // Convert 1-based job number to 0-based array index
164+
if (isset($lines[$line_number])) {
165+
unset($lines[$line_number]);
166+
}
167+
}
168+
if (count($lines) > 0) {
169+
file_put_contents($jobs, implode("\n", $lines)."\n");
170+
$undo = '2';
171+
} else {
172+
delete_file($jobs);
173+
$undo = '1';
151174
}
152-
$undo = filesize($jobs) > 0 ? '2' : '1';
153-
if ($undo=='1') delete_file($jobs);
154175
}
155176
die($undo);
156177
case 'read':
157178
$active = '/var/tmp/file.manager.active';
158-
$read = file_exists($active) ? json_encode(parse_ini_file($active)) : '';
179+
$read = file_exists($active) ? file_get_contents($active) : '';
159180
die($read);
160181
case 'file':
161182
$active = '/var/tmp/file.manager.active';
162183
$jobs = '/var/tmp/file.manager.jobs';
163-
$data[] = 'action="'.($_POST['action']??'').'"';
164-
$data[] = 'title="'.rawurldecode($_POST['title']??'').'"';
165-
$data[] = 'source="'.htmlspecialchars_decode(rawurldecode($_POST['source']??'')).'"';
166-
$data[] = 'target="'.rawurldecode($_POST['target']??'').'"';
167-
$data[] = 'H="'.(empty($_POST['hdlink']) ? '' : 'H').'"';
168-
$data[] = 'sparse="'.(empty($_POST['sparse']) ? '' : '--sparse').'"';
169-
$data[] = 'exist="'.(empty($_POST['exist']) ? '--ignore-existing' : '').'"';
170-
$data[] = 'zfs="'.rawurldecode($_POST['zfs']??'').'"';
184+
$data = [
185+
'action' => $_POST['action'] ?? '',
186+
'title' => rawurldecode($_POST['title'] ?? ''),
187+
'source' => htmlspecialchars_decode(rawurldecode($_POST['source'] ?? '')),
188+
'target' => htmlspecialchars_decode(rawurldecode($_POST['target'] ?? '')),
189+
'H' => empty($_POST['hdlink']) ? '' : 'H',
190+
'sparse' => empty($_POST['sparse']) ? '' : '--sparse',
191+
'exist' => empty($_POST['exist']) ? '--ignore-existing' : '',
192+
'zfs' => rawurldecode($_POST['zfs'] ?? '')
193+
];
171194
if (isset($_POST['task'])) {
172195
// add task to queue
173-
$task = rawurldecode($_POST['task']);
174-
$data = "task=\"$task\"\n".implode("\n",$data)."\n";
175-
file_put_contents($jobs,$data,FILE_APPEND);
196+
$data['task'] = rawurldecode($_POST['task']);
197+
file_put_contents($jobs, json_encode($data)."\n", FILE_APPEND);
198+
199+
// Update popular destinations for copy/move operations
200+
if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
201+
updatePopularDestinations($data['target']);
202+
}
176203
} else {
177204
// start operation
178-
file_put_contents($active,implode("\n",$data));
205+
file_put_contents($active, json_encode($data));
206+
207+
// Update popular destinations for copy/move operations
208+
if (in_array($data['action'], ['3', '4', '8', '9']) && !empty($data['target'])) {
209+
updatePopularDestinations($data['target']);
210+
}
179211
}
180212
die();
181213
}

emhttp/plugins/dynamix/include/FileTree.php

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function my_dir($name) {
5050

5151
$docroot = '/usr/local/emhttp';
5252
require_once "$docroot/webGui/include/Secure.php";
53+
require_once "$docroot/plugins/dynamix/include/PopularDestinations.php";
5354

5455
$mntdir = '/mnt/';
5556
$userdir = '/mnt/user/';
@@ -65,12 +66,49 @@ function my_dir($name) {
6566
$UDincluded = ['disks','remotes'];
6667

6768
echo "<ul class='jqueryFileTree'>";
68-
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
69-
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"".htmlspecialchars(dirname($rootdir))."\">..</a></li>";
69+
70+
// Show popular destinations at the top (only at root level and not in autocomplete mode)
71+
if (!$autocomplete && $rootdir === $root) {
72+
$popularPaths = getPopularDestinations(5);
73+
74+
// Filter popular paths to prevent FUSE conflicts between /mnt/user and /mnt/diskX
75+
if (!empty($popularPaths)) {
76+
$isUserContext = (strpos($root, '/mnt/user') === 0 || strpos($root, '/mnt/rootshare') === 0);
77+
78+
if ($isUserContext) {
79+
// In /mnt/user context: only show /mnt/user paths OR non-/mnt paths (external mounts)
80+
$popularPaths = array_filter($popularPaths, function($path) {
81+
return (strpos($path, '/mnt/user') === 0 || strpos($path, '/mnt/rootshare') === 0 || strpos($path, '/mnt/') !== 0);
82+
});
83+
} else if (strpos($root, '/mnt/') === 0) {
84+
// In /mnt/diskX or /mnt/cache context: exclude /mnt/user and /mnt/rootshare paths
85+
$popularPaths = array_filter($popularPaths, function($path) {
86+
return (strpos($path, '/mnt/user') !== 0 && strpos($path, '/mnt/rootshare') !== 0);
87+
});
88+
}
89+
// If root is not under /mnt/, no filtering needed
90+
}
91+
92+
if (!empty($popularPaths)) {
93+
echo "<li class='popular-header small-caps-label' style='list-style:none;padding:5px 0 5px 20px;'>Popular</li>";
94+
95+
foreach ($popularPaths as $path) {
96+
$pathName = basename($path);
97+
$htmlPath = htmlspecialchars($path);
98+
$htmlName = htmlspecialchars(mb_strlen($pathName) <= 33 ? $pathName : mb_substr($pathName, 0, 30).'...');
99+
// Use data-path instead of rel to prevent jQueryFileTree from handling these links
100+
// Use 'directory' class so jQueryFileTree CSS handles the icon
101+
echo "<li class='directory popular-destination' style='list-style:none;'>$checkbox<a href='#' data-path='$htmlPath'>$htmlName</a></li>";
102+
}
103+
104+
// Separator line
105+
echo "<li class='popular-separator' style='list-style:none;border-top:1px solid var(--inverse-border-color);margin:5px 0 5px 20px;'></li>";
106+
}
70107
}
71108

109+
// Read directory contents first (needed for both normal and autocomplete mode)
110+
$dirs = $files = [];
72111
if (is_dir($rootdir)) {
73-
$dirs = $files = [];
74112
$names = array_filter(scandir($rootdir, SCANDIR_SORT_NONE), 'no_dots');
75113
// add UD shares under /mnt/user
76114
foreach ($UDincluded as $name) {
@@ -89,25 +127,33 @@ function my_dir($name) {
89127
$files[] = $name;
90128
}
91129
}
92-
foreach ($dirs as $name) {
93-
// Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
94-
if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
95-
$htmlRel = htmlspecialchars(my_dir($name).$name);
96-
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...');
97-
if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
98-
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"$htmlRel/\">$htmlName</a></li>";
99-
}
130+
}
131+
132+
// Normal mode: show directory tree
133+
if ($_POST['show_parent'] == 'true' && is_top($rootdir)) {
134+
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"".htmlspecialchars(dirname($rootdir))."\">..</a></li>";
135+
}
136+
137+
// Display directories and files (arrays already populated above)
138+
foreach ($dirs as $name) {
139+
// Exclude '.Recycle.Bin' from all shares and UD folders from '/mnt'
140+
if ($name === '.Recycle.Bin' || ($rootdir === $mntdir && in_array($name, $UDexcluded))) continue;
141+
$htmlRel = htmlspecialchars(my_dir($name).$name);
142+
$htmlName = htmlspecialchars(mb_strlen($name) <= 33 ? $name : mb_substr($name, 0, 30).'...');
143+
if (empty($match) || preg_match("/$match/", $rootdir.$name.'/')) {
144+
echo "<li class='directory collapsed'>$checkbox<a href='#' rel=\"$htmlRel/\">$htmlName</a></li>";
100145
}
101-
foreach ($files as $name) {
102-
$htmlRel = htmlspecialchars(my_dir($name).$name);
103-
$htmlName = htmlspecialchars($name);
104-
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
105-
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
106-
if (empty($match) || preg_match("/$match/", $name)) {
107-
echo "<li class='file ext_$ext'>$checkbox<a href='#' rel=\"$htmlRel\">$htmlName</a></li>";
108-
}
146+
}
147+
foreach ($files as $name) {
148+
$htmlRel = htmlspecialchars(my_dir($name).$name);
149+
$htmlName = htmlspecialchars($name);
150+
$ext = mb_strtolower(pathinfo($name, PATHINFO_EXTENSION));
151+
foreach ($filters as $filter) if (empty($filter) || $ext === $filter) {
152+
if (empty($match) || preg_match("/$match/", $name)) {
153+
echo "<li class='file ext_$ext'>$checkbox<a href='#' rel=\"$htmlRel\">$htmlName</a></li>";
109154
}
110155
}
111156
}
157+
112158
echo "</ul>";
113159
?>

0 commit comments

Comments
 (0)