mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
Restore Files tab in dashboard (#3397)
* Restore Files tab in dashboard
Reinstates the Files tab (TabFiles) that was removed in 973d046d, bringing
back the dashboard file browser: directory navigation, list/grid views,
sorting, drag-and-drop, context menus, upload, and trash support.
- Restore src/gui/src/UI/Dashboard/TabFiles.js (identical to pre-removal)
- Re-wire UIDashboard.js: tab registration, initial file path, item socket
handlers, and route-based file navigation
- Re-add file-tab CSS in dashboard.css and the selection-area rule in style.css
- Re-add dashboard-mode item creation hooks in helpers.js move_items
- Re-add #files/<path> deep-link parsing in initgui.js parseDashboardRoute,
while preserving Apps as the default dashboard tab
* Only remove stale rows when moving items
* Fix Dashboard Files tab bugs
- Escape filenames in renderItem to prevent stored XSS via HTML in file
names (matches UIItem); raw name is still preserved for display/rename.
- Read clone data-id from the nested .row in drag drop handlers so
multi-select drag moves all selected items, not just the grabbed one.
- Resolve sidebar drop target from the element's data-path so dragging
onto Public/Home works (they aren't keyed in user.directories); fix the
matching UID-vs-path comparison in the drag-out handler.
- Set data-is_worker/data-worker_url from is_worker itself instead of an
always-true "!== undefined" check, so worker context-menu behavior only
applies to actual workers.
- Give delete-confirmation buttons explicit values so permanent delete
works in non-English locales.
- Refresh the view after a context-menu move-paste, mirroring Ctrl+V.
- Guard the item.updated socket handler against the client's own echo,
matching the other handlers and UIDesktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Prevent Files tab lockup when readdir fails
renderDirectory sets renderingDirectory=true then awaits puter.fs.readdir
with no error handling. readdir rejects on any backend error (permission,
deleted directory, network), leaving renderingDirectory stuck true so the
re-entry guard blocks all future navigation and the spinner never clears.
Catch the rejection and reset state.
* Isolate render failures so one bad entry can't lock the Files tab
renderItem parsed file.metadata with an unguarded JSON.parse, but metadata
is client-writable and stored verbatim, so '', undefined, or malformed
values throw (item_icon.js guards the identical call). Combined with
Promise.all, one bad entry rejected the whole render and left the tab
frozen. Guard the parse and use Promise.allSettled for per-item isolation.
* Clear selection when clicking empty area in Files tab
The empty-area click handler is an arrow function, so `this` was the
TabFiles object rather than the container element, making `e.target === this`
always false. Only clicks landing exactly on `.files` deselected. Use
e.currentTarget so clicks on the container background deselect as intended.
* Avoid stacking image-preview document click handlers
showImagePreview bound a click.imagepreview handler without removing a
prior one. It is re-invoked during image arrow-navigation while a preview
is open, so identical document handlers accumulated until close. Remove
any existing handler before binding.
* Sort live-inserted items by display name
insertAtSortedPosition compared the new item's raw file.name against
existing rows' data-name (the display name). For trashed items, name is
the UID while the real name is in metadata.original_name, so newly trashed
items landed in the wrong position in Trash until a full re-render. Compare
on the new row's data-name to match sortFiles and the existing rows.
* Use display name in in-place item update
UIDashboardFileItem's in-place update path set data-name and the shown
name from raw file.name, unlike renderItem which uses
metadata.original_name || file.name. An update to an already-visible
trashed item replaced its name with the UID and corrupted data-name
(which sort and type-to-search read). Compute the display name to match.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
102cef523f
commit
9da32c73cb
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,7 @@ import UIWindowFeedback from '../UIWindowFeedback.js';
|
||||
|
||||
// Import tab modules
|
||||
import TabHome from './TabHome.js';
|
||||
import TabFiles from './TabFiles.js';
|
||||
import TabApps from './TabApps.js';
|
||||
import TabUsage from './TabUsage.js';
|
||||
import TabAccount from './TabAccount.js';
|
||||
@@ -53,6 +54,7 @@ import TabSecurity from './TabSecurity.js';
|
||||
const builtinTabs = [
|
||||
TabHome,
|
||||
TabApps,
|
||||
TabFiles,
|
||||
'-',
|
||||
TabUsage,
|
||||
TabAccount,
|
||||
@@ -165,6 +167,11 @@ async function UIDashboard (options) {
|
||||
$el_window.find('.dashboard-sidebar').addClass('collapsed');
|
||||
}
|
||||
|
||||
// Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it)
|
||||
if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) {
|
||||
window.dashboard_initial_file_path = window.dashboard_initial_route.path;
|
||||
}
|
||||
|
||||
// Initialize all tabs
|
||||
for ( const tab of tabs ) {
|
||||
if ( tab.init ) {
|
||||
@@ -236,6 +243,106 @@ async function UIDashboard (options) {
|
||||
}
|
||||
});
|
||||
|
||||
// Trash status updates
|
||||
window.socket.on('trash.is_empty', async (msg) => {
|
||||
// Update sidebar Trash icon
|
||||
const trashIcon = msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
|
||||
$('.directories [data-folder=\'Trash\'] img').attr('src', trashIcon);
|
||||
|
||||
// If currently viewing trash and it's empty, clear the file list
|
||||
const dashboard = window.dashboard_object;
|
||||
if ( msg.is_empty && dashboard && dashboard.currentPath === window.trash_path ) {
|
||||
$('.files-tab .files').empty();
|
||||
}
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Item event handlers
|
||||
// Incremental DOM updates using UIDashboardFileItem for item creation and
|
||||
// direct jQuery manipulation for removals/updates. Mirrors UIDesktop's
|
||||
// approach but adapted for Dashboard's list-view structure.
|
||||
// =========================================================================
|
||||
|
||||
window.socket.on('item.moved', async (resp) => {
|
||||
if ( resp.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
// Fade out the stale row at the item's OLD location only. A moved item
|
||||
// keeps its uid, so removing by uid would also match the row at the new
|
||||
// location — and when the destination is the directory currently in
|
||||
// view (e.g. after spring-loading a folder open mid-drag), that deletes
|
||||
// the freshly-added row instead of the stale one.
|
||||
const old_path = resp.old_path ?? resp.from_path;
|
||||
if ( old_path ) {
|
||||
$(`.item[data-path='${html_encode(old_path)}']`).fadeOut(150, function () {
|
||||
$(this).remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Create new item at destination if user is viewing that directory
|
||||
if ( window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(resp);
|
||||
}
|
||||
});
|
||||
|
||||
window.socket.on('item.removed', async (item) => {
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
if ( item.descendants_only ) return;
|
||||
|
||||
$(`.item[data-path='${html_encode(item.path)}']`).fadeOut(150, function () {
|
||||
$(this).remove();
|
||||
});
|
||||
});
|
||||
|
||||
window.socket.on('item.renamed', async (item) => {
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
const $el = $(`.item[data-uid='${item.uid}']`);
|
||||
if ( $el.length === 0 ) return;
|
||||
|
||||
// Update data attributes
|
||||
$el.attr('data-name', html_encode(item.name));
|
||||
$el.attr('data-path', html_encode(item.path));
|
||||
|
||||
// Update displayed name
|
||||
$el.find('.item-name').text(item.name);
|
||||
$el.find('.item-name-editor').val(item.name);
|
||||
});
|
||||
|
||||
window.socket.on('item.updated', async (item) => {
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
const $el = $(`.item[data-uid='${item.uid}']`);
|
||||
if ( $el.length === 0 ) return;
|
||||
|
||||
// Update data attributes
|
||||
$el.attr('data-name', html_encode(item.name));
|
||||
$el.attr('data-path', html_encode(item.path));
|
||||
$el.attr('data-size', item.size);
|
||||
$el.attr('data-modified', item.modified);
|
||||
$el.attr('data-type', html_encode(item.type));
|
||||
|
||||
// Update displayed name
|
||||
$el.find('.item-name').text(item.name);
|
||||
$el.find('.item-name-editor').val(item.name);
|
||||
|
||||
if (
|
||||
window.dashboard_object?.currentView === 'grid'
|
||||
&& typeof item.thumbnail === 'string'
|
||||
&& item.thumbnail.length > 0
|
||||
) {
|
||||
$el.find('.item-icon img').attr('src', item.thumbnail);
|
||||
}
|
||||
});
|
||||
|
||||
window.socket.on('item.added', async (item) => {
|
||||
if ( !item || Object.keys(item).length === 0 ) return;
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
|
||||
if ( window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Apply initial route from URL - activate the correct tab
|
||||
if ( window.dashboard_initial_route ) {
|
||||
const route = window.dashboard_initial_route;
|
||||
@@ -269,6 +376,7 @@ async function UIDashboard (options) {
|
||||
const handleRouteChange = () => {
|
||||
const route = window.parseDashboardRoute();
|
||||
const tab = route.tab;
|
||||
const filePath = route.path;
|
||||
|
||||
// Switch to correct tab
|
||||
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
|
||||
@@ -290,6 +398,14 @@ async function UIDashboard (options) {
|
||||
|
||||
// Scroll content area to top
|
||||
$el_window.find('.dashboard-content').scrollTop(0);
|
||||
|
||||
// If files tab with path, navigate without adding to history
|
||||
if ( tab === 'files' && filePath ) {
|
||||
const filesTab = tabs.find(t => t.id === 'files');
|
||||
if ( filesTab?.renderDirectory ) {
|
||||
filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for both hashchange and popstate to handle all navigation scenarios
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1919,6 +1919,19 @@ span.header-sort-icon img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* TabFiles rubber band selection area */
|
||||
.tabfiles-selection-area {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
border: 1px solid var(--select-color);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.container {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
+14
-2
@@ -1817,8 +1817,12 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
// update all shortcut_to_path
|
||||
$(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', fsentry.path);
|
||||
|
||||
// Remove all items with matching uids
|
||||
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).fadeOut(150, function () {
|
||||
// Remove all items with matching uids from their OLD location(s).
|
||||
// Exclude any row already at the item's new path: a concurrent
|
||||
// item.moved socket handler may have just created a row at the
|
||||
// destination (e.g. the dashboard file view showing the target
|
||||
// directory), and removing by uid alone would delete it too.
|
||||
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(`[data-path="${html_encode(fsentry.path)}" i]`).fadeOut(150, function () {
|
||||
// find all parent windows that contain this item
|
||||
let parent_windows = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).closest('.window');
|
||||
// remove this item
|
||||
@@ -1890,6 +1894,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
suggested_apps: fsentry.suggested_apps,
|
||||
};
|
||||
UIItem(options);
|
||||
// In dashboard mode, also create item via dashboard's renderer
|
||||
if ( window.is_dashboard_mode && window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(fsentry);
|
||||
}
|
||||
moved_items.push({ 'options': options, 'original_path': $(el_item).attr('data-path') });
|
||||
|
||||
// this operation may have created some missing directories,
|
||||
@@ -1915,6 +1923,10 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
suggested_apps: dir.suggested_apps,
|
||||
});
|
||||
}
|
||||
// In dashboard mode, also create parent dirs via dashboard's renderer
|
||||
if ( window.is_dashboard_mode && window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(dir);
|
||||
}
|
||||
window.sort_items(item_container);
|
||||
});
|
||||
|
||||
|
||||
+12
-6
@@ -532,15 +532,21 @@ if (jQuery) {
|
||||
/**
|
||||
* Parses the dashboard URL hash into a route object.
|
||||
* Apps is the default tab (root URL / no hash); Home is reached via #home.
|
||||
* Hash format: #home or #usage or #account etc.
|
||||
* @returns {{ tab: string }} Route object with tab name
|
||||
* Hash format: #files/username/Documents or #home or #usage or #account etc.
|
||||
* @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path
|
||||
*/
|
||||
function parseDashboardRoute() {
|
||||
const hash = decodeURIComponent(window.location.hash.slice(1));
|
||||
if (!hash) return { tab: 'apps' };
|
||||
const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding
|
||||
if (!hash) return { tab: 'apps', path: null };
|
||||
|
||||
const tab = hash.split('/').filter(Boolean)[0];
|
||||
return { tab: tab || 'apps' };
|
||||
const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents']
|
||||
const tab = parts[0]; // 'files', 'usage', 'account', 'security'
|
||||
|
||||
if (tab === 'files' && parts.length > 1) {
|
||||
const filePath = `/${parts.slice(1).join('/')}`; // /username/Documents
|
||||
return { tab: 'files', path: filePath };
|
||||
}
|
||||
return { tab: tab || 'apps', path: null };
|
||||
}
|
||||
|
||||
// Make parseDashboardRoute available globally for hashchange handler
|
||||
|
||||
Reference in New Issue
Block a user