diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index a75d2c104..4ed6ffdb2 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -1243,7 +1243,7 @@ const TabFiles = { } }, // success - success: function (items) { + success: async function (items) { // Add action to actions_history for undo ability const files = []; if ( typeof items[Symbol.iterator] === 'function' ) { @@ -1263,11 +1263,12 @@ const TabFiles = { window.show_save_account_notice_if_needed(); // remove from active_uploads delete window.active_uploads[opid]; - // refresh - _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); // 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); }, // error error: async function (err) { @@ -4062,7 +4063,7 @@ const TabFiles = { update_title_based_on_uploads(); } }, - success: function (items) { + success: async function (items) { const files = []; if ( typeof items[Symbol.iterator] === 'function' ) { for ( const item of items ) { @@ -4080,8 +4081,11 @@ const TabFiles = { }, 1000); window.show_save_account_notice_if_needed(); delete window.active_uploads[opid]; - // Refresh directory to show uploaded files - _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + // Refresh directory to show uploaded files, then highlight + // them (a drop on a sidebar folder/breadcrumb uploads to a + // directory that isn't rendered — no rows match, no-op). + await _this.renderDirectory(_this.currentPath, { consistency: 'strong' }); + _this.selectUploadedRows(files); }, error: async function (err) { const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : []; @@ -4105,6 +4109,29 @@ const TabFiles = { }); }, + /** + * Selects the rows matching the given absolute paths, replacing the + * current selection. Used after uploads so the just-uploaded items land + * highlighted. Paths outside the rendered directory match no rows and + * leave the selection untouched. + * + * @param {string[]} paths - Absolute paths of the items to select + * @returns {void} + */ + selectUploadedRows (paths) { + const wanted = new Set(paths.map(p => String(p).toLowerCase())); + const matches = this.$el_window.find('.files-tab .files .row').filter(function () { + const rowPath = String($(this).attr('data-path') ?? '').toLowerCase(); + return wanted.has(rowPath); + }); + if ( matches.length === 0 ) return; + + this.$el_window.find('.files-tab .files .row.selected').removeClass('selected'); + matches.addClass('selected'); + this.updateFooterStats(); + matches[0].scrollIntoView({ block: 'nearest' }); + }, + /** * Renders the breadcrumb path navigation HTML. * diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index 1ef2ae5d0..290fd891a 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -23,6 +23,7 @@ import item_icon from './helpers/item_icon.js'; import truncate_filename from './helpers/truncate_filename.js'; import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js'; import update_username_in_gui from './helpers/update_username_in_gui.js'; +import { select_uploaded_items } from './helpers/upload_selection.js'; import mime from './lib/mime.js'; import path from './lib/path.js'; import UIAlert from './UI/UIAlert.js'; @@ -2300,6 +2301,10 @@ window.upload_items = async function (items, dest_path) { operation: 'upload', data: files, }); + + // highlight the uploaded items in the destination + select_uploaded_items(dest_path, files); + // close progress window after a bit of delay for a better UX setTimeout(() => { setTimeout(() => { diff --git a/src/gui/src/helpers/apply_item_added_to_containers.js b/src/gui/src/helpers/apply_item_added_to_containers.js index 6d0bf5dbc..e3d3bf0b6 100644 --- a/src/gui/src/helpers/apply_item_added_to_containers.js +++ b/src/gui/src/helpers/apply_item_added_to_containers.js @@ -19,6 +19,7 @@ import UIItem from '../UI/UIItem.js'; import item_icon from './item_icon.js'; +import { select_added_item_if_pending } from './upload_selection.js'; /** * Reflect an `item.added` socket event in every open UIWindow item container @@ -75,6 +76,10 @@ const apply_item_added_to_containers = async function (item) { $containers.each(function () { window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order')); }); + + // If this item was just uploaded from this client, its upload `success` + // handler may have run before this element existed — land it selected. + select_added_item_if_pending(item, $containers); }; export default apply_item_added_to_containers; diff --git a/src/gui/src/helpers/upload_selection.js b/src/gui/src/helpers/upload_selection.js new file mode 100644 index 000000000..52c8d8573 --- /dev/null +++ b/src/gui/src/helpers/upload_selection.js @@ -0,0 +1,136 @@ +/** + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Selection of freshly-uploaded items in UIItem containers — the desktop, + * explorer windows, and file dialogs. + * + * An upload finishes through two independent channels: the batch HTTP + * response (the upload's `success` callback) and per-item `item.added` + * socket events, which are what actually create the item elements. Either + * can arrive first, so selection is applied from both sides: + * `select_uploaded_items` selects the elements that already exist and + * remembers the rest; `select_added_item_if_pending` picks those up when + * the socket event creates their element. + */ + +// Uploaded paths (lowercased — path matching is case-insensitive throughout +// the GUI) still waiting for their `item.added` element. Entries expire so +// an event that never arrives can't select an unrelated item created at the +// same path much later. +const pending_selection_paths = new Map(); +const PENDING_SELECTION_TTL = 10_000; + +const prune_pending = () => { + const now = Date.now(); + for ( const [item_path, expiry] of pending_selection_paths ) { + if ( expiry <= now ) { + pending_selection_paths.delete(item_path); + } + } +}; + +// Selection side effects that normally happen in UIItem's click handlers: +// the explorer footer's "N items selected" line and, in open-file dialogs, +// the Open button (enabled only when a file — not a directory — is selected). +const apply_selection_side_effects = (el_container) => { + const $el_window = $(el_container).closest('.window'); + if ( $el_window.length === 0 ) { + return; + } + window.update_explorer_footer_selected_items_count($el_window); + if ( $el_window.attr('data-is_openFileDialog') === 'true' ) { + const file_is_selected = $el_window.find('.item-selected[data-is_dir="0"]').length > 0; + $el_window.find('.openfiledialog-open-btn').toggleClass('disabled', !file_is_selected); + } +}; + +/** + * Replace the selection in every item container showing `dest_path` with + * the just-uploaded items. Items whose `item.added` event hasn't landed yet + * are remembered and selected on arrival. + * + * Paths are compared (not queried by selector) so names containing selector + * metacharacters or HTML-encodable characters still match. + * + * @param {string} dest_path directory the items were uploaded to + * @param {string[]} uploaded_paths full paths of the uploaded items + */ +export const select_uploaded_items = (dest_path, uploaded_paths) => { + const wanted = new Set(uploaded_paths.map(p => String(p).toLowerCase())); + if ( wanted.size === 0 ) { + return; + } + + const dest = String(dest_path).toLowerCase(); + const found = new Set(); + $('.item-container').filter(function () { + return String($(this).attr('data-path') ?? '').toLowerCase() === dest; + }).each(function () { + const el_container = this; + let el_first_selected = null; + $(el_container).children('.item-selected').removeClass('item-selected'); + $(el_container).children('.item').each(function () { + const item_path = String($(this).attr('data-path') ?? '').toLowerCase(); + if ( ! wanted.has(item_path) ) { + return; + } + found.add(item_path); + if ( ! $(this).hasClass('item-disabled') ) { + $(this).addClass('item-selected'); + el_first_selected ??= this; + } + }); + apply_selection_side_effects(el_container); + el_first_selected?.scrollIntoView({ block: 'nearest' }); + }); + + prune_pending(); + const expiry = Date.now() + PENDING_SELECTION_TTL; + for ( const item_path of wanted ) { + if ( ! found.has(item_path) ) { + pending_selection_paths.set(item_path, expiry); + } + } +}; + +/** + * Select a just-created item element if its path was registered by + * `select_uploaded_items` before the element existed. Adds to the current + * selection rather than replacing it — the replacement already happened + * when the upload finished. + * + * @param {object} item fsentry from the `item.added` event + * @param {JQuery} $containers item containers the element was just added to + */ +export const select_added_item_if_pending = (item, $containers) => { + prune_pending(); + if ( ! pending_selection_paths.delete(String(item.path).toLowerCase()) ) { + return; + } + const uid = item.overwritten_uid || item.uid; + $containers.each(function () { + const $el_item = $(this).children(`.item[data-uid='${uid}']`).not('.item-disabled'); + if ( $el_item.length === 0 ) { + return; + } + $el_item.addClass('item-selected'); + apply_selection_side_effects(this); + }); +}; diff --git a/src/gui/src/helpers/upload_selection.test.js b/src/gui/src/helpers/upload_selection.test.js new file mode 100644 index 000000000..52698f9e5 --- /dev/null +++ b/src/gui/src/helpers/upload_selection.test.js @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// @vitest-environment jsdom + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; +import { select_uploaded_items, select_added_item_if_pending } from './upload_selection.js'; + +const make_item = ({ path, uid = `uid-${path}`, disabled = false }) => { + const el = document.createElement('div'); + el.className = `item${disabled ? ' item-disabled' : ''}`; + el.setAttribute('data-path', path); + el.setAttribute('data-uid', uid); + el.setAttribute('data-is_dir', '0'); + return el; +}; + +const make_container = (dir_path) => { + const el = document.createElement('div'); + el.className = 'item-container'; + el.setAttribute('data-path', dir_path); + document.body.appendChild(el); + return el; +}; + +beforeAll(() => { + globalThis.$ = jQuery; + // jsdom doesn't implement scrollIntoView + Element.prototype.scrollIntoView = () => {}; +}); + +beforeEach(() => { + document.body.innerHTML = ''; + window.update_explorer_footer_selected_items_count = vi.fn(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +const selected_paths = (container) => + [...container.querySelectorAll('.item-selected')].map(el => el.getAttribute('data-path')); + +describe('select_uploaded_items', () => { + it('replaces the selection with the uploaded items already in the container', () => { + const container = make_container('/user/Desktop'); + const old = make_item({ path: '/user/Desktop/old.txt' }); + old.classList.add('item-selected'); + container.append( + old, + make_item({ path: '/user/Desktop/a.txt' }), + make_item({ path: '/user/Desktop/b.txt' }), + make_item({ path: '/user/Desktop/unrelated.txt' }), + ); + + select_uploaded_items('/user/Desktop', ['/user/Desktop/a.txt', '/user/Desktop/b.txt']); + + expect(selected_paths(container)).toEqual(['/user/Desktop/a.txt', '/user/Desktop/b.txt']); + }); + + it('matches paths case-insensitively and only in containers showing the destination', () => { + const container = make_container('/User/Desktop'); + const other = make_container('/user/Documents'); + container.append(make_item({ path: '/User/Desktop/A.TXT' })); + other.append(make_item({ path: '/user/Documents/a.txt' })); + + select_uploaded_items('/user/desktop', ['/user/desktop/a.txt']); + + expect(selected_paths(container)).toEqual(['/User/Desktop/A.TXT']); + expect(selected_paths(other)).toEqual([]); + }); + + it('never selects disabled items (e.g. filtered out by a dialog file-type filter)', () => { + const container = make_container('/user/Desktop'); + container.append(make_item({ path: '/user/Desktop/a.txt', disabled: true })); + + select_uploaded_items('/user/Desktop', ['/user/Desktop/a.txt']); + + expect(selected_paths(container)).toEqual([]); + }); + + it('enables the Open button of an open-file dialog when a file lands selected', () => { + const el_window = document.createElement('div'); + el_window.className = 'window'; + el_window.setAttribute('data-is_openFileDialog', 'true'); + el_window.innerHTML = ''; + document.body.appendChild(el_window); + const container = make_container('/user/Desktop'); + el_window.appendChild(container); + container.append(make_item({ path: '/user/Desktop/a.txt' })); + + select_uploaded_items('/user/Desktop', ['/user/Desktop/a.txt']); + + expect(el_window.querySelector('.openfiledialog-open-btn').classList.contains('disabled')).toBe(false); + expect(window.update_explorer_footer_selected_items_count).toHaveBeenCalled(); + }); +}); + +describe('select_added_item_if_pending', () => { + it('selects an item whose element is created after the upload finished, exactly once', () => { + const container = make_container('/user/Desktop'); + select_uploaded_items('/user/Desktop', ['/user/Desktop/late.txt']); + + const late = make_item({ path: '/user/Desktop/late.txt', uid: 'late-uid' }); + container.append(late); + select_added_item_if_pending({ path: '/user/Desktop/late.txt', uid: 'late-uid' }, $(container)); + expect(selected_paths(container)).toEqual(['/user/Desktop/late.txt']); + + // the entry is consumed — a later item.added for the same path stays unselected + late.classList.remove('item-selected'); + select_added_item_if_pending({ path: '/user/Desktop/late.txt', uid: 'late-uid' }, $(container)); + expect(selected_paths(container)).toEqual([]); + }); + + it('adds to the selection made when the upload finished instead of replacing it', () => { + const container = make_container('/user/Desktop'); + container.append(make_item({ path: '/user/Desktop/a.txt' })); + select_uploaded_items('/user/Desktop', ['/user/Desktop/a.txt', '/user/Desktop/late.txt']); + + container.append(make_item({ path: '/user/Desktop/late.txt', uid: 'late-uid' })); + select_added_item_if_pending({ path: '/user/Desktop/late.txt', uid: 'late-uid' }, $(container)); + + expect(selected_paths(container)).toEqual(['/user/Desktop/a.txt', '/user/Desktop/late.txt']); + }); + + it('forgets a pending path once its TTL passes', () => { + vi.useFakeTimers(); + const container = make_container('/user/Desktop'); + select_uploaded_items('/user/Desktop', ['/user/Desktop/late.txt']); + + vi.advanceTimersByTime(11_000); + + container.append(make_item({ path: '/user/Desktop/late.txt', uid: 'late-uid' })); + select_added_item_if_pending({ path: '/user/Desktop/late.txt', uid: 'late-uid' }, $(container)); + expect(selected_paths(container)).toEqual([]); + }); +});