From 4a23c296f00ec75cd9f28d172a4b94dfc24a91a8 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sun, 13 Sep 2026 11:31:16 -0700 Subject: [PATCH] Fix/dashboard apps files audit (#3857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dashboard Files: keep rows in sorted order when icons resolve late renderDirectory appended each row as soon as its icon resolved. Icons for weblinks and .app files are read from the file itself, so those rows always landed at the end of the listing regardless of the sort column or direction. Resolve every icon first, then append the rows in sorted order; renderItem takes the pre-resolved icon and still looks it up itself for single-row callers (socket adds, instant folder creation). * Rename: store the new item name raw in data attributes and the editor rename_file wrote html_encode(new_name) into data-name, title and the name editor's value. jQuery's attr()/val() store strings literally, so a file renamed to "a&b.txt" carried data-name "a&b.txt": the dashboard Files tab, which re-reads data-name for column truncation, then showed the entity on screen, type-to-select and sorting compared the encoded string, and the editor reopened on the encoded name. The initial render already stores these raw; make the rename path match. * Dashboard Files: Enter with several folders selected enters only the first The Enter handler called pushNavHistory + renderDirectory for every selected folder. Only the first render runs (renderDirectory drops calls while one is in flight), but every folder was pushed onto the navigation history, so after selecting two folders and pressing Enter, Back led to the folder already on screen and Forward to one that was never opened. Enter now navigates into the first selected folder only; selected files still open in their apps. * Dashboard Files: don't offer to move items the user can't move Trashing or cutting sends an item to its owner's trash or a new folder, which a shared root or a read-only share can't do — the server answers Forbidden and the user gets an error alert for an action that should not have been offered. The single-item context menu already leaves Delete out for such rows; the Delete key, Ctrl/Cmd+X, the multi-selection menu and the mobile selection bar did not check. Route all of them through can_restructure, skipping rows that can't move and hiding Cut/Delete when the whole selection can't. * Dashboard Files: hide New Folder and Upload in the Shared view Shared is a query over other people's items, not a directory. Both buttons stayed visible there: New Folder did nothing, and Upload opened the OS file picker and then silently dropped whatever the user picked. Hide them the same way Trash already does. * Dashboard Files: let the same file be picked again after a failed upload The upload input was only cleared in the success callback, so after an upload that failed, was cancelled, or was blocked (Shared view), choosing the same file again fired no change event and nothing happened. Snapshot the picked files and clear the input as soon as the change fires. This also drops the document.querySelector('form').reset() that reset whichever form came first in the document rather than this one. * Dashboard Files: read saved preferences in parallel and re-sort immediately init awaited four independent kv reads one after another before the first listing could start, and handleSort awaited two kv writes before re-rendering, so a sort click cost two round-trips before anything moved. Issue the reads together and let the writes settle in the background. While here, set $el_window before the first await (renderDirectory reached through a route change or socket event during init threw and left renderingDirectory stuck), and tolerate a corrupt saved column_widths value instead of failing init. * Dashboard Files: show the loading spinner on the first directory load showSpinner keyed off a loading flag, but clearing the listing at the start of a navigation removed the overlay without resetting it: init's spinner was wiped by the first render's clear, and the render's own showSpinner call then no-oped, so the initial load ran with no indicator. Guard on the overlay's presence instead. --- src/gui/src/UI/Dashboard/TabFiles.js | 216 +++++++++++++++++++-------- src/gui/src/helpers.js | 21 +-- 2 files changed, 165 insertions(+), 72 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index ca13e8078..288614a9b 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -40,7 +40,7 @@ import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js' import { icons } from '../../helpers/actionIcons.js'; import list_all_shared from '../../helpers/listAllShared.js'; -import { can_share, remember_shared_roots } from '../../helpers/sharedAccess.js'; +import { can_restructure, can_share, remember_shared_roots } from '../../helpers/sharedAccess.js'; import { parent_path_for, shared_crumbs_for, shared_uids_from_paths } from '../../helpers/sharePaths.js'; const { html_encode, SelectionArea } = window; @@ -146,6 +146,10 @@ const TabFiles = { * @returns {Promise} */ async init ($el_window) { + // Before the awaits below: renderDirectory (reached through a route + // change or a socket event) reads it, and would otherwise throw with + // renderingDirectory already set, blocking every later navigation. + this.$el_window = $el_window; this.showSpinner(); const _this = this; window.dashboard_object = _this; @@ -277,21 +281,35 @@ const TabFiles = { this.typeSearchTerm = ''; this.typeSearchTimeout = null; this.selectModeActive = false; - // Preference reads are best-effort: the tab renders with the - // defaults rather than not rendering at all. - this.currentView = await puter.kv.get('view_mode').catch(() => null) || 'list'; + // Preference reads are best-effort (the tab renders with the defaults + // rather than not at all) and independent of each other, so they go + // out together: awaited one by one they cost four round-trips before + // the first listing could even start. + const [savedView, savedSortColumn, savedSortDirection, savedWidths] = await Promise.all([ + puter.kv.get('view_mode').catch(() => null), + puter.kv.get('sort_column').catch(() => null), + puter.kv.get('sort_direction').catch(() => null), + puter.kv.get('column_widths').catch(() => null), + ]); + this.currentView = savedView || 'list'; // Sorting state - this.sortColumn = await puter.kv.get('sort_column').catch(() => null) || 'name'; - this.sortDirection = await puter.kv.get('sort_direction').catch(() => null) || 'asc'; + this.sortColumn = savedSortColumn || 'name'; + this.sortDirection = savedSortDirection || 'asc'; - // Column widths state (for resizing) - const savedWidths = await puter.kv.get('column_widths').catch(() => null); - this.columnWidths = savedWidths ? JSON.parse(savedWidths) : { + // Column widths state (for resizing). A corrupt saved value falls back + // to the defaults instead of taking the whole tab down with it. + this.columnWidths = { name: null, // auto/flex size: 100, modified: 120, }; + try { + const parsedWidths = savedWidths ? JSON.parse(savedWidths) : null; + if ( parsedWidths && typeof parsedWidths === 'object' ) { + this.columnWidths = { ...this.columnWidths, ...parsedWidths }; + } + } catch { /* keep the defaults */ } // Add touch-device class on touch-FIRST devices only (coarse pointer, // no hover — which also catches iPads whose UA claims macOS). @@ -550,9 +568,6 @@ const TabFiles = { } }); - // Store reference to $el_window for later use (must be before createHeaderEventListeners) - this.$el_window = $el_window; - this.createHeaderEventListeners($el_window); this.createSelectionActionListeners($el_window); this.initRubberBandSelection(); @@ -714,10 +729,17 @@ const TabFiles = { if ( $selectedRows.length > 0 ) { e.preventDefault(); e.stopPropagation(); + // One listing, so only the first selected folder can be + // entered. renderDirectory already drops the later calls, + // but every pushNavHistory landed in history, leaving Back + // and Forward pointing at folders that were never shown. + let entered = false; $selectedRows.each(function () { const isDir = $(this).attr('data-is_dir') === '1'; const itemPath = $(this).attr('data-path'); if ( isDir ) { + if ( entered ) return; + entered = true; _this.pushNavHistory(itemPath); _this.renderDirectory(itemPath); } else { @@ -789,8 +811,14 @@ const TabFiles = { await window.refresh_trash_state(); } } else { - // Move to trash - await window.move_items($selectedRows.toArray(), window.trash_path); + // Trashing sends an item to its owner's trash, which a + // shared root or read-only share can't do (the server + // answers Forbidden) — skip those rows, as the context + // menu leaves Delete out for them. + const rows = await _this.restructurableRows($selectedRows.toArray()); + if ( rows.length > 0 ) { + await window.move_items(rows, window.trash_path); + } } } return false; @@ -834,14 +862,19 @@ const TabFiles = { if ( $selectedRows.length > 0 ) { e.preventDefault(); e.stopPropagation(); - window.clipboard = []; - window.clipboard_op = 'move'; - $selectedRows.each(function () { - window.clipboard.push({ - path: $(this).attr('data-path'), - uid: $(this).attr('data-uid'), - }); - }); + // Cutting is a move: rows that can't be moved (see the + // Delete key above) would only fail on paste. + const rows = await _this.restructurableRows($selectedRows.toArray()); + if ( rows.length > 0 ) { + window.clipboard = []; + window.clipboard_op = 'move'; + for ( const row of rows ) { + window.clipboard.push({ + path: $(row).attr('data-path'), + uid: $(row).attr('data-uid'), + }); + } + } } return false; } @@ -1270,8 +1303,13 @@ const TabFiles = { // Upload input element fileInput.onchange = async (e) => { - const files = e.target.files; - if ( !files || files.length === 0 ) return; + // Snapshot the picked files and clear the input at once: the + // FileList is live, and a value left in place means picking the + // same file again fires no change event, so an upload that failed, + // was cancelled, or was blocked could not be retried. + const files = Array.from(e.target.files || []); + fileInput.value = ''; + if ( files.length === 0 ) return; if ( _this.currentPath === window.shared_path ) return; let upload_progress_window; @@ -1333,9 +1371,6 @@ const TabFiles = { window.show_save_account_notice_if_needed(); // remove from active_uploads delete window.active_uploads[opid]; - // Clear the input value to allow uploading the same file again - fileInput.value = ''; - document.querySelector('form').reset(); // refresh, then highlight the uploaded items await _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); _this.selectUploadedRows(files); @@ -1444,11 +1479,12 @@ const TabFiles = { }); // Cut button - $actions.find('.cut-btn').on('click', function () { - const selectedRows = document.querySelectorAll('.files-tab .row.selected'); + $actions.find('.cut-btn').on('click', async function () { + const rows = await _this.restructurableRows(document.querySelectorAll('.files-tab .row.selected')); + if ( rows.length === 0 ) return; window.clipboard_op = 'move'; window.clipboard = []; - selectedRows.forEach(row => { + rows.forEach(row => { window.clipboard.push({ path: $(row).attr('data-path'), uid: $(row).attr('data-uid'), @@ -1491,7 +1527,10 @@ const TabFiles = { await window.refresh_trash_state(); } } else { - window.move_items(Array.from(selectedRows), window.trash_path); + const rows = await _this.restructurableRows(selectedRows); + if ( rows.length > 0 ) { + window.move_items(rows, window.trash_path); + } } $actions.removeClass('visible'); }); @@ -1544,9 +1583,30 @@ const TabFiles = { if ( this._shareCheckToken !== token ) return; $actions.find('.share-btn').toggle(may_share); }); + // Cut and Delete move items, which a shared root or a read-only + // share can't be — same wait-for-the-answer treatment as Share. + $actions.find('.cut-btn, .delete-btn').hide(); + this.restructurableRows(selectedRows).then((rows) => { + if ( this._shareCheckToken !== token ) return; + $actions.find('.cut-btn, .delete-btn').toggle(rows.length === selectedRows.length); + }); } }, + /** + * The rows the user may move or trash: their own items, plus items inside + * a shared folder they hold write on. A shared root or a read-only share + * stays where its owner put it (see can_restructure). + * + * @param {NodeList|Array} rows - The selected row elements + * @returns {Promise} + */ + async restructurableRows (rows) { + const list = Array.from(rows); + const answers = await Promise.all(list.map((row) => can_restructure($(row).attr('data-path')))); + return list.filter((_row, i) => answers[i]); + }, + /** * Whether every selected row may be shared with someone else. A selection * mixing your own items with someone else's read-only ones can't be, and @@ -1779,16 +1839,19 @@ const TabFiles = { * Updates header action buttons based on current folder context. * * Shows/hides new folder, upload, and empty trash buttons as appropriate. + * Neither Trash nor the Shared view (a query, not a directory) can have + * anything created or uploaded into it, so those two buttons stay out + * there rather than opening a picker whose files would be dropped. * * @param {boolean} isTrashFolder - Whether the current folder is the Trash * @returns {void} */ updateActionButtons (isTrashFolder) { const $pathActions = this.$el_window.find('.path-actions'); + const canCreate = ! isTrashFolder && this.currentPath !== window.shared_path; + $pathActions.find('.new-folder-btn, .upload-btn').toggle(canCreate); if ( isTrashFolder ) { - $pathActions.find('.new-folder-btn, .upload-btn').hide(); - if ( $pathActions.find('.empty-trash-btn').length === 0 ) { const emptyTrashBtn = $(``); $pathActions.append(emptyTrashBtn); @@ -1798,7 +1861,6 @@ const TabFiles = { } $pathActions.find('.empty-trash-btn').show(); } else { - $pathActions.find('.new-folder-btn, .upload-btn').show(); $pathActions.find('.empty-trash-btn').hide(); } }, @@ -2128,9 +2190,9 @@ const TabFiles = { * sets ascending order. Persists settings and re-renders the directory. * * @param {string} column - Column name to sort by ('name', 'size', or 'modified') - * @returns {Promise} + * @returns {void} */ - async handleSort (column) { + handleSort (column) { if ( this.sortColumn === column ) { this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; } else { @@ -2138,13 +2200,15 @@ const TabFiles = { this.sortDirection = 'asc'; } - await puter.kv.set('sort_column', this.sortColumn) - .catch(err => console.warn('Could not save sort_column:', err)); - await puter.kv.set('sort_direction', this.sortDirection) - .catch(err => console.warn('Could not save sort_direction:', err)); - this.updateSortIndicators(); this.renderDirectory(this.currentPath); + + // Persisting is best-effort and must not hold up the re-sort: awaited, + // the two writes added two round-trips before anything moved. + puter.kv.set('sort_column', this.sortColumn) + .catch(err => console.warn('Could not save sort_column:', err)); + puter.kv.set('sort_direction', this.sortDirection) + .catch(err => console.warn('Could not save sort_direction:', err)); }, /** @@ -2434,9 +2498,21 @@ const TabFiles = { } const sortedContents = this.sortFiles(directoryContents); - // allSettled so one item that fails to render can't reject the batch, - // which would skip cleanup and leave the tab stuck (renderingDirectory). - await Promise.allSettled(sortedContents.map(file => this.renderItem(file))); + // Icons resolve at different speeds (weblinks and .app files read + // theirs from the file) and a row is appended once its icon lands, so + // resolve every icon first and append in sorted order. allSettled so + // one failure can't reject the batch and leave renderingDirectory stuck. + const iconResults = await Promise.allSettled(sortedContents.map(file => item_icon(file))); + for ( let i = 0; i < sortedContents.length; i++ ) { + const iconResult = iconResults[i].status === 'fulfilled' + ? iconResults[i].value + : { image: window.icons['file.svg'], type: 'icon' }; + try { + await this.renderItem(sortedContents[i], iconResult); + } catch ( err ) { + console.error('Failed to render item:', err); + } + } this.applyColumnWidths(); this.updateFooterStats(); @@ -2545,9 +2621,11 @@ const TabFiles = { * it to the files container, then attaches event listeners. * * @param {Object} file - The file/folder object from the filesystem API + * @param {{ image: string, type: string }} [iconResult] - A pre-resolved + * icon (see renderDirectory); looked up here when omitted * @returns {void} */ - async renderItem (file) { + async renderItem (file, iconResult = null) { // For trashed items, use original_name from metadata if available const item_id = window.global_element_id++; // metadata is a client-writable, untrusted string stored verbatim, so @@ -2568,7 +2646,7 @@ const TabFiles = { const is_shortcut = file.is_shortcut ? 1 : 0; const is_worker = file.workers?.length > 0; const worker_url = is_worker ? file.workers[0]?.address : ''; - const iconResult = await item_icon(file); + if ( ! iconResult ) iconResult = await item_icon(file); const icon = ``; const row = document.createElement("div"); // A dot-file only reaches this point when the preference reveals it; @@ -4126,20 +4204,27 @@ const TabFiles = { items.push('-'); } + // Cut and Delete move items, which a shared root or a read-only share + // can't be (the server answers Forbidden) — offered only when the whole + // selection may move, as the single-item menu does. + const mayRestructure = (await _this.restructurableRows(selectedRows)).length === selectedRows.length; + // Cut - items.push({ - html: `${i18n('cut')}`, - onClick: function () { - window.clipboard_op = 'move'; - window.clipboard = []; - selectedRows.forEach(row => { - window.clipboard.push({ - path: $(row).attr('data-path'), - uid: $(row).attr('data-uid'), + if ( mayRestructure ) { + items.push({ + html: `${i18n('cut')}`, + onClick: function () { + window.clipboard_op = 'move'; + window.clipboard = []; + selectedRows.forEach(row => { + window.clipboard.push({ + path: $(row).attr('data-path'), + uid: $(row).attr('data-uid'), + }); }); - }); - }, - }); + }, + }); + } // Copy if ( ! anyTrashed ) { @@ -4155,10 +4240,9 @@ const TabFiles = { }); } - items.push('-'); - // Delete if ( anyTrashed ) { + items.push('-'); items.push({ html: i18n('delete_permanently'), onClick: async function () { @@ -4178,7 +4262,8 @@ const TabFiles = { }, }); } - else { + else if ( mayRestructure ) { + items.push('-'); items.push({ html: `${i18n('delete')}`, onClick: function () { @@ -4898,7 +4983,12 @@ const TabFiles = { * Shows loading spinner over files section */ showSpinner () { - if ( this.loading ) return; + const files = document.querySelector('.directory-contents .files'); + if ( ! files ) return; + // Guard on the overlay itself rather than on a flag: clearing the + // listing takes the overlay with it, and a flag left set meant the + // first directory load ran with no spinner at all. + if ( files.querySelector('.files-loading-overlay') ) return; this.loading = true; const overlay = document.createElement('div'); @@ -4910,7 +5000,7 @@ const TabFiles = { `; - document.querySelector('.directory-contents .files').appendChild(overlay); + files.appendChild(overlay); setTimeout(() => { overlay.style.opacity = 1; }, 100); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 67eb26c1c..f7a09779a 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -3235,19 +3235,22 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i const new_icon = (options.is_dir ? window.icons['folder.svg'] : (await item_icon(fsentry)).image); $(el_item_icon).find('.item-icon-icon').attr('src', new_icon); - // Set new `data-name` + // Set new `data-name`. Attributes and form values are stored raw: + // .attr()/.val() don't parse HTML, so an encoded name would come + // back as "a&b" wherever it is read (sorting, type-to-select, + // the dashboard's column truncation). options.name = new_name; - $(el_item).attr('data-name', html_encode(new_name)); - $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('data-name', html_encode(new_name)); - $(`.window-${options.uid}`).attr('data-name', html_encode(new_name)); + $(el_item).attr('data-name', new_name); + $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('data-name', new_name); + $(`.window-${options.uid}`).attr('data-name', new_name); // Set new `title` attribute - $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('title', html_encode(new_name)); - $(`.window-${options.uid}`).attr('title', html_encode(new_name)); + $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('title', new_name); + $(`.window-${options.uid}`).attr('title', new_name); // Set new value for `item-name-editor` - $(`.item[data-uid='${$(el_item).attr('data-uid')}'] .item-name-editor`).val(html_encode(new_name)); - $(`.item[data-uid='${$(el_item).attr('data-uid')}'] .item-name`).attr('title', html_encode(new_name)); + $(`.item[data-uid='${$(el_item).attr('data-uid')}'] .item-name-editor`).val(new_name); + $(`.item[data-uid='${$(el_item).attr('data-uid')}'] .item-name`).attr('title', new_name); // Set new `data-path` attribute options.path = path.join(path.dirname(options.path), options.name); @@ -3301,7 +3304,7 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i // hide item name editor $(el_item_name_editor).hide(); - $(el_item_name_editor).val(html_encode($(el_item).attr('data-name'))); + $(el_item_name_editor).val($(el_item).attr('data-name')); //show error if ( err.message ) {