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 ) {