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([]);
+ });
+});