mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-28 17:07:16 +00:00
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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user