feat: highlight uploaded items after upload

Uploads now land selected wherever they finish: the desktop, explorer
windows, file dialogs, and the dashboard Files tab. Selection is applied
from both completion channels — the batch response selects elements
already in the DOM, and items whose item.added socket event arrives
later are remembered (with a TTL) and selected on creation. The explorer
footer count and the open-file dialog's Open button are updated to
match, and disabled items (e.g. filtered out by a dialog's file-type
filter) are never selected.
This commit is contained in:
Nariman Jelveh
2026-07-26 21:14:26 -07:00
parent 7f3691aea3
commit 6668d778d2
5 changed files with 333 additions and 6 deletions
+33 -6
View File
@@ -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.
*
+5
View File
@@ -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(() => {
@@ -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;
+136
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
/**
* 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);
});
};
@@ -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 <https://www.gnu.org/licenses/>.
*/
// @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 = '<button class="openfiledialog-open-btn disabled"></button>';
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([]);
});
});