Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 2b44aed144 Keep the raw folder name in an explorer window's data-name
update_window_path() and refresh_item_container() wrote the HTML-encoded
name there, so after navigating into "R&D" the tab title and the publish
dialog showed "R&D". A newly opened window already held the raw name.
2026-11-09 14:22:05 -07:00
Nariman Jelveh 647f28db37 Use atPath() to find the items a share badge belongs to
mark_item_shared() had its own copy of the same lookup.
2026-11-09 14:18:11 -07:00
Nariman Jelveh 30006e0108 Match no element when a path lookup has no path
atPath() with a missing path matched every element whose attribute is
empty, which is every item that is not a shortcut and every app window.
An item.moved event without old_path would point all of those items at
the moved file. underPath() with an empty path matched every path.
2026-11-09 14:18:06 -07:00
Nariman Jelveh 71675d6eb7 Read data-path straight from the element in path lookups
atPath() and underPath() built two jQuery objects for every element they
looked at. On a desktop with 5,000 items a lookup took about 1.4 ms,
against 0.08 ms for the selector it replaced. Reading the attribute from
the element brings it to about 0.3 ms.
2026-11-09 14:17:01 -07:00
Nariman Jelveh 6ec568d0b1 Match desktop items and windows by their path value, not by a selector
Path lookups on the desktop compare data-path values through one helper, so
a name holding &, ' or " is found like any other. Explorer windows keep the
raw path in data-path after navigating, the same as when they open.
2026-11-09 14:08:43 -07:00
12 changed files with 294 additions and 105 deletions

No files matched your search

+3 -2
View File
@@ -20,6 +20,7 @@
import download from './helpers/download.js';
import ensureConfirmedEmail from './helpers/ensureConfirmedEmail.js';
import item_icon from './helpers/itemIcon.js';
import { atPath } from './helpers/pathElements.js';
import socialLink from './helpers/socialLink.js';
import update_mouse_position from './helpers/updateMousePosition.js';
import path from './lib/path.js';
@@ -1702,7 +1703,7 @@ const ipc_listener = async (event, handled) => {
$(`.item[data-uid="${res.uid}"]`).removeItems();
// now add new items
UIItem({
appendTo: $(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`),
appendTo: atPath('.item-container', path.dirname(target_path)),
immutable: res.immutable || res.writable === false,
associated_app_name: res.associated_app?.name,
path: target_path,
@@ -1716,7 +1717,7 @@ const ipc_listener = async (event, handled) => {
suggested_apps: res.suggested_apps,
});
// sort each window
$(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`).each(function () {
atPath('.item-container', path.dirname(target_path)).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
$(el_filedialog_window).close();
+17 -42
View File
@@ -34,6 +34,7 @@ import UIWindowProgress from './UIWindowProgress.js';
import UITaskbar from './UITaskbar.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath, retargetPaths, underPath } from '../helpers/pathElements.js';
import changeLanguage from '../i18n/i18nChangeLanguage.js';
import UIWindowTaskManager from './UIWindowTaskManager.js';
import truncate_filename from '../helpers/truncateFilename.js';
@@ -416,12 +417,12 @@ async function UIDesktop (options) {
}
// hide all UIItems with matching uids
$(`.item[data-path='${item.path}']`).fadeOut(150, function () {
atPath('.item', item.path).fadeOut(150, function () {
// close all windows with matching uids
// $('.window-' + item.uid).close();
// close all windows that belong to a descendant of this item
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${item.path}/"]`).close();
// todo this has to be case-insensitive
underPath('.window', item.path).close();
});
});
@@ -459,20 +460,7 @@ async function UIDesktop (options) {
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
$(`.window-${item.uid}`).attr('data-path', new_path);
// Update all elements that have matching paths
$(`[data-path="${html_encode(item.old_path)}" i]`).each(function () {
$(this).attr('data-path', new_path);
if ( $(this).hasClass('window-navbar-path-dirname') )
{
$(this).text(item.name);
}
});
// Update all elements whose paths start with old_path
$(`[data-path^="${`${html_encode(item.old_path) }/`}"]`).each(function () {
const new_el_path = $(this).attr('data-path').replace(`${item.old_path }/`, `${new_path }/`);
$(this).attr('data-path', new_el_path);
});
retargetPaths(item.old_path, new_path, item.name);
// Update all exact-matching windows
$(`.window-${item.uid}`).each(function () {
@@ -507,7 +495,7 @@ async function UIDesktop (options) {
const metadata = parse_item_metadata(fsentry.metadata);
// update all shortcut_to_path
$(`.item[data-shortcut_to_path="${html_encode(resp.old_path)}" i]`).attr('data-shortcut_to_path', html_encode(fsentry.path));
atPath('.item', resp.old_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
// remove all items with matching uids
$(`.item[data-uid='${fsentry.uid}']`).fadeOut(150, function () {
@@ -524,16 +512,16 @@ async function UIDesktop (options) {
// if trashing, close windows of trashed items and its descendants
if ( dest_path === window.trash_path ) {
$(`.window[data-path="${html_encode(resp.old_path)}" i]`).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode(resp.old_path)}/"]`).close();
atPath('.window', resp.old_path).close();
// todo this has to be case-insensitive
underPath('.window', resp.old_path).close();
}
// update all paths of its and its descendants' open windows
else {
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode(resp.old_path)}/"], .window[data-path="${html_encode(resp.old_path)}" i]`).each(function () {
window.update_window_path(this, $(this).attr('data-path').replace(resp.old_path, fsentry.path));
// todo this has to be case-insensitive
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
});
}
@@ -560,7 +548,7 @@ async function UIDesktop (options) {
// create new item on matching containers
UIItem({
appendTo: $(`.item-container[data-path='${html_encode(dest_path)}' i]`),
appendTo: atPath('.item-container', dest_path),
immutable: fsentry.immutable || fsentry.writable === false,
uid: fsentry.uid,
path: fsentry.path,
@@ -585,8 +573,8 @@ async function UIDesktop (options) {
// if these new path have any open parents that need to be updated
fsentry.parent_dirs_created.forEach(async dir => {
let item_container = $(`.item-container[data-path='${html_encode(path.dirname(dir.path))}' i]`);
if ( item_container.length > 0 && $(`.item[data-path="${html_encode(dir.path)}" i]`).length === 0 ) {
let item_container = atPath('.item-container', path.dirname(dir.path));
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
UIItem({
appendTo: item_container,
immutable: false,
@@ -606,7 +594,7 @@ async function UIDesktop (options) {
});
}
//sort each container
$(`.item-container[data-path='${html_encode(dest_path)}' i]`).each(function () {
atPath('.item-container', dest_path).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
});
@@ -693,20 +681,7 @@ async function UIDesktop (options) {
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
$(`.window-${item.uid}`).attr('data-path', new_path);
// Update all elements that have matching paths
$(`[data-path="${html_encode(item.old_path)}" i]`).each(function () {
$(this).attr('data-path', new_path);
if ( $(this).hasClass('window-navbar-path-dirname') )
{
$(this).text(item.name);
}
});
// Update all elements whose paths start with old_path
$(`[data-path^="${`${html_encode(item.old_path) }/`}"]`).each(function () {
const new_el_path = $(this).attr('data-path').replace(`${item.old_path }/`, `${new_path }/`);
$(this).attr('data-path', new_el_path);
});
retargetPaths(item.old_path, new_path, item.name);
// Update all exact-matching windows
$(`.window-${item.uid}`).each(function () {
+7 -6
View File
@@ -25,6 +25,7 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath } from '../helpers/pathElements.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
import launch_app from '../helpers/launchApp.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
@@ -3348,7 +3349,7 @@ window.update_window_path = async function (el_window, target_path) {
// update sidebar items' active status
$(el_window).find('.window-sidebar-item').removeClass('window-sidebar-item-active');
$(el_window).find(`.window-sidebar-item[data-path="${html_encode(target_path)}"]`).addClass('window-sidebar-item-active');
atPath($(el_window).find('.window-sidebar-item'), target_path).addClass('window-sidebar-item-active');
// clean
$(el_window).find('.explore-table-headers-th > .header-sort-icon').html('');
@@ -3432,8 +3433,8 @@ window.update_window_path = async function (el_window, target_path) {
}
}
$(el_window).attr('data-path', html_encode(target_path));
$(el_window).attr('data-name', html_encode(path.basename(target_path)));
$(el_window).attr('data-path', target_path);
$(el_window).attr('data-name', path.basename(target_path));
// /stat
if ( target_path === window.shared_path ) {
@@ -3447,7 +3448,7 @@ window.update_window_path = async function (el_window, target_path) {
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== html_encode(target_path) ) {
if ( $(el_window).attr('data-path') !== target_path ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
@@ -3491,9 +3492,9 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).find('.window-head-title').text(fsentry.name);
}
// data-name
$(el_window).attr('data-name', html_encode(fsentry.name));
$(el_window).attr('data-name', fsentry.name);
// data-path
$(el_window).attr('data-path', html_encode(target_path));
$(el_window).attr('data-path', target_path);
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
+2 -1
View File
@@ -19,6 +19,7 @@
import UIWindow from './UIWindow.js';
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
import { underPath } from '../helpers/pathElements.js';
async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_dir_path) {
let h = '';
@@ -110,7 +111,7 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d
});
// find all items whose path starts with target_dir_path
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
underPath('.item', target_dir_path).each(function () {
// show the link badge
$(this).find('.item-has-website-url-badge').show();
// update item's website_url attribute
+2 -1
View File
@@ -19,6 +19,7 @@
import UIWindow from './UIWindow.js';
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
import { underPath } from '../helpers/pathElements.js';
async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_dir_path) {
let h = '';
@@ -117,7 +118,7 @@ async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_di
});
// find all items whose path starts with target_dir_path
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
underPath('.item', target_dir_path).each(function () {
// show the link badge
// $(this).find('.item-has-website-url-badge').show();
// update item's website_url attribute
+26 -46
View File
@@ -23,6 +23,7 @@ import { archiveEntryPath } from './helpers/archiveEntryPath.js';
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
import globToRegExp from './helpers/globToRegExp.js';
import item_icon from './helpers/itemIcon.js';
import { atPath, retargetPaths, underPath } from './helpers/pathElements.js';
import { is_owned_by_me, trash_path_for } from './helpers/pathOwner.js';
import { invalidate_shared_roots } from './helpers/sharedAccess.js';
import truncate_filename from './helpers/truncateFilename.js';
@@ -1178,7 +1179,7 @@ window.create_folder = async (basedir, appendto_element) => {
rename: true,
overwrite: false,
success: function (data) {
const el_created_dir = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
const el_created_dir = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
if ( el_created_dir.length > 0 ) {
window.activate_item_name_editor(el_created_dir);
@@ -1211,7 +1212,7 @@ window.create_file = async (options) => {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
success: async function (data) {
const created_file = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
const created_file = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
if ( created_file.length > 0 ) {
window.activate_item_name_editor(created_file);
@@ -1583,8 +1584,8 @@ window.delete_item = async function (el_item, descendants_only = false) {
// close all windows with matching uids
$(`.window-${ $(el_item).attr('data-uid')}`).close();
// close all windows that belong to a descendant of this item
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${$(el_item).attr('data-path')}/"]`).close();
// todo this has to be case-insensitive
underPath('.window', $(el_item).attr('data-path')).close();
});
try {
@@ -1605,7 +1606,7 @@ window.delete_item = async function (el_item, descendants_only = false) {
window.update_explorer_footer_selected_items_count(this);
});
// update all shortcuts to this item
$(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', '');
atPath('.item', $(el_item).attr('data-path'), 'data-shortcut_to_path').attr('data-shortcut_to_path', '');
});
} catch ( err ) {
// Nothing was deleted: bring back what was faded out above.
@@ -1619,7 +1620,7 @@ window.move_clipboard_items = function (el_target_container, target_path) {
let el_items = [];
if ( window.clipboard.length > 0 ) {
for ( let i = 0; i < window.clipboard.length; i++ ) {
el_items.push($(`.item[data-path="${html_encode(window.clipboard[i])}" i]`));
el_items.push(atPath('.item', window.clipboard[i]));
}
if ( el_items.length > 0 )
{
@@ -1961,24 +1962,18 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// skip next loop iteration because this iteration was successful
item_with_same_name_already_exists = false;
// update all shortcut_to_path — compare raw attribute values
// (item rows store paths unencoded, so an html_encode()d
// selector misses names containing & < > " ')
const moved_from_path_lc = String($(el_item).attr('data-path') || '').toLowerCase();
$('.item[data-shortcut_to_path]').filter(function () {
return String($(this).attr('data-shortcut_to_path')).toLowerCase() === moved_from_path_lc;
}).attr('data-shortcut_to_path', fsentry.path);
const moved_from_path = $(el_item).attr('data-path');
// update all shortcut_to_path
atPath('.item', moved_from_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
// Remove all items with matching uids from their OLD location(s).
// Exclude any row already at the item's new path: a concurrent
// item.moved socket handler may have just created a row at the
// destination (e.g. the dashboard file view showing the target
// directory), and removing by uid alone would delete it too.
// Raw case-insensitive compare, for the same reason as above.
const dest_item_path_lc = fsentry.path.toLowerCase();
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(function () {
return String($(this).attr('data-path') || '').toLowerCase() === dest_item_path_lc;
}).fadeOut(150, function () {
const $moved_items = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`);
$moved_items.not(atPath($moved_items, fsentry.path)).fadeOut(150, function () {
// find all parent windows that contain this item
let parent_windows = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).closest('.window');
// remove this item
@@ -1992,23 +1987,23 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// if trashing, close windows of trashed items and its descendants
if ( is_trashing ) {
$(`.window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"]`).close();
atPath('.window', moved_from_path).close();
// todo this has to be case-insensitive
underPath('.window', moved_from_path).close();
}
// update all paths of its and its descendants' open windows
else {
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"], .window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).each(function () {
window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(item_dest_path, fsentry.name)));
// todo this has to be case-insensitive
underPath('.window', moved_from_path).add(atPath('.window', moved_from_path)).each(function () {
window.update_window_path(this, path.join(item_dest_path, fsentry.name) + $(this).attr('data-path').slice(moved_from_path.length));
});
}
// The dashboard's Back/Forward follows a moved folder, and
// forgets a trashed one.
if ( $(el_item).attr('data-is_dir') === '1' ) {
window.dashboard_object?.folderMoved?.($(el_item).attr('data-path'), is_trashing ? null : fsentry.path);
window.dashboard_object?.folderMoved?.(moved_from_path, is_trashing ? null : fsentry.path);
}
if ( is_trashing ) {
@@ -2032,7 +2027,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// create new item on matching containers
const options = {
appendTo: $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`),
appendTo: atPath('.item-container', item_dest_path),
immutable: fsentry.immutable || (fsentry.writable === false),
associated_app_name: fsentry.associated_app?.name,
uid: fsentry.uid,
@@ -2063,8 +2058,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// see if any of the directories in the path of this file is new AND
// if these new path have any open parents that need to be updated
resp.parent_dirs_created?.forEach(async dir => {
let item_container = $(`.item-container[data-path="${html_encode(path.dirname(dir.path))}" i]`);
if ( item_container.length > 0 && $(`.item[data-path="${html_encode(dir.path)}" i]`).length === 0 ) {
let item_container = atPath('.item-container', path.dirname(dir.path));
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
UIItem({
appendTo: item_container,
immutable: false,
@@ -2090,7 +2085,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
});
//sort each container
$(`.item-container[data-path="${html_encode(item_dest_path)}" i]`).each(function () {
atPath('.item-container', item_dest_path).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
} catch ( err ) {
@@ -2285,9 +2280,7 @@ window.updateSubdomainsForItems = async function (fsentries, container) {
// If not found by uid, try path-based search
if ( $item.length === 0 && fsentry.path ) {
// Escape special characters in path for jQuery selector
const escapedPath = fsentry.path.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
$item = $(document).find(`.item[data-path="${escapedPath}"]`);
$item = atPath('.item', fsentry.path);
}
if ( $item.length > 0 ) {
@@ -3301,20 +3294,7 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('data-path', new_path);
$(`.window-${options.uid}`).attr('data-path', new_path);
// Update all elements that have matching paths
$(`[data-path="${html_encode(old_path)}" i]`).each(function () {
$(this).attr('data-path', new_path);
if ( $(this).hasClass('window-navbar-path-dirname') )
{
$(this).text(new_name);
}
});
// Update the paths of all elements whose paths start with `old_path`
$(`[data-path^="${`${html_encode(old_path) }/`}"]`).each(function () {
const new_el_path = $(this).attr('data-path').replace(`${old_path }/`, `${new_path}/`);
$(this).attr('data-path', new_el_path);
});
retargetPaths(old_path, new_path, new_name);
// Update the 'Sites Cache'
if ( $(el_item).attr('data-has_website') === '1' )
+148
View File
@@ -0,0 +1,148 @@
// @vitest-environment jsdom
/*
* 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/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = key => key;
globalThis.def = globalThis.def ?? (value => value);
globalThis.use = globalThis.use ?? (() => class {});
globalThis.html_encode = value => jQuery('<div>').text(value).html();
await import('./helpers.js');
const { default: apply_item_added_to_containers } = await import('./helpers/applyItemAddedToContainers.js');
const { default: UIItem } = await import('./UI/UIItem.js');
const { atPath, retargetPaths, underPath } = await import('./helpers/pathElements.js');
// A plain name, then names holding characters a selector or HTML treats specially.
const NAMES = ['Reports 2024', 'R&D', "Dan's files", 'the "big" one', 'a<b>c', 'back\\slash'];
const el = (className, attrs) => jQuery('<div>').addClass(className).attr(attrs).appendTo(document.body);
beforeEach(() => {
document.body.innerHTML = '';
jQuery.fx.off = true;
jQuery.fn.close = vi.fn();
jQuery.fn.removeItems = vi.fn();
window.icons = { 'folder.svg': 'folder.svg' };
window.actions_history = [];
window.user = { username: 'alice' };
window.update_window_path = vi.fn();
window.sort_items = vi.fn();
window.update_explorer_footer_item_count = vi.fn();
window.update_explorer_footer_selected_items_count = vi.fn();
window.determine_website_url = () => '';
UIItem.mockClear();
});
describe.each(NAMES)('a folder named %s', (name) => {
const folderPath = `/alice/${name}`;
it('keeps its open subfolders and their items on the new path when renamed', async () => {
const $item = el('item', { 'data-uid': 'u1', 'data-path': folderPath, 'data-name': name });
const $crumb = el('window-navbar-path-dirname', { 'data-path': folderPath });
const $subWindow = el('window', { 'data-path': `${folderPath}/sub` });
const $subItem = el('item', { 'data-uid': 'u2', 'data-path': `${folderPath}/sub/a.txt` });
globalThis.puter = { fs: { rename: ({ success }) => success({ uid: 'u1', name: 'Plain', is_dir: true }) } };
await new Promise(resolve => {
window.rename_file({ uid: 'u1', name, path: folderPath, is_dir: true }, 'Plain', name, folderPath,
$item[0], null, null, null, '', false, resolve);
});
expect($item.attr('data-path')).toBe('/alice/Plain');
expect($crumb.attr('data-path')).toBe('/alice/Plain');
expect($crumb.text()).toBe('Plain');
expect($subWindow.attr('data-path')).toBe('/alice/Plain/sub');
expect($subItem.attr('data-path')).toBe('/alice/Plain/sub/a.txt');
});
it('closes the windows of its subfolders when deleted', async () => {
const $item = el('item', { 'data-uid': 'u1', 'data-path': folderPath });
el('window', { 'data-path': `${folderPath}/sub` });
el('window', { 'data-path': '/alice/Other/sub' });
el('item', { 'data-uid': 'u3', 'data-shortcut_to_path': folderPath });
globalThis.puter = { fs: { delete: vi.fn().mockResolvedValue() } };
await window.delete_item($item[0]);
const closed = jQuery.fn.close.mock.contexts.flatMap($set => $set.toArray().map(w => jQuery(w).attr('data-path')));
expect(closed).toEqual([`${folderPath}/sub`]);
expect(puter.fs.delete).toHaveBeenCalled();
expect(jQuery('[data-uid="u3"]').attr('data-shortcut_to_path')).toBe('');
});
it('shows an item another tab added to it', async () => {
const $container = el('item-container', { 'data-path': folderPath });
await apply_item_added_to_containers({ uid: 'u9', dirpath: folderPath, path: `${folderPath}/new.txt`, name: 'new.txt', is_dir: true });
expect(UIItem).toHaveBeenCalledTimes(1);
expect(UIItem.mock.calls[0][0].appendTo.toArray()).toEqual([$container[0]]);
});
});
describe('retargetPaths', () => {
it('ignores the case of the renamed path itself, and leaves its siblings alone', () => {
const $window = el('window', { 'data-path': '/alice/docs' });
const $sibling = el('item', { 'data-path': '/alice/Docs 2/a.txt' });
retargetPaths('/alice/Docs', '/alice/Papers', 'Papers');
expect($window.attr('data-path')).toBe('/alice/Papers');
expect($sibling.attr('data-path')).toBe('/alice/Docs 2/a.txt');
});
it('writes a new name holding a replacement pattern as it is', () => {
const $item = el('item', { 'data-path': '/alice/Docs/a.txt' });
retargetPaths('/alice/Docs', '/alice/$& and $1', '$& and $1');
expect($item.attr('data-path')).toBe('/alice/$& and $1/a.txt');
});
it('changes nothing when the old path is unknown', () => {
const $item = el('item', { 'data-path': '/alice/Docs/a.txt' });
retargetPaths(undefined, '/alice/Papers', 'Papers');
expect($item.attr('data-path')).toBe('/alice/Docs/a.txt');
});
});
describe.each([undefined, null, ''])('a lookup for the path %s', (missing) => {
it('matches nothing, including elements that have no path', () => {
el('item', { 'data-path': '/alice/a.txt', 'data-shortcut_to_path': '' });
el('window', { 'data-path': '' });
el('window', { 'data-path': '/alice/Docs' });
expect(atPath('.item', missing, 'data-shortcut_to_path')).toHaveLength(0);
expect(atPath('.window', missing)).toHaveLength(0);
expect(underPath('.window', missing)).toHaveLength(0);
});
});
@@ -19,6 +19,7 @@
import UIItem from '../UI/UIItem.js';
import item_icon from './itemIcon.js';
import { atPath } from './pathElements.js';
import { select_added_item_if_pending } from './uploadSelection.js';
/**
@@ -37,7 +38,7 @@ import { select_added_item_if_pending } from './uploadSelection.js';
* @param {object} item fsentry from the `item.added` event
*/
const apply_item_added_to_containers = async function (item) {
const $containers = $(`.item-container[data-path='${html_encode(item.dirpath)}' i]`);
const $containers = atPath('.item-container', item.dirpath);
if ( $containers.length === 0 ) {
return;
}
+73
View File
@@ -0,0 +1,73 @@
/*
* 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/>.
*/
/**
* Narrows `elements` to those whose `attr` is `path`, ignoring case. Reads the
* attribute, because a path can hold any character a selector can't.
*
* @param {string | Element | JQuery} elements - a selector or a set of elements
* @param {string} path
* @param {string} [attr]
* @returns {JQuery}
*/
export const atPath = (elements, path, attr = 'data-path') => {
// Without this, a missing path matches every element whose attribute is empty.
if ( ! path ) return $();
const wanted = String(path).toLowerCase();
return $(elements).filter(function () {
const value = this.getAttribute(attr);
return value !== null && value.toLowerCase() === wanted;
});
};
/**
* Narrows `elements` to those whose `data-path` is inside the folder at `path`.
*
* @param {string | Element | JQuery} elements - a selector or a set of elements
* @param {string} path
* @returns {JQuery}
*/
export const underPath = (elements, path) => {
if ( ! path ) return $();
const prefix = `${path}/`;
return $(elements).filter(function () {
return this.getAttribute('data-path')?.startsWith(prefix) === true;
});
};
/**
* Points every element at `oldPath`, or inside it, to `newPath`.
*
* @param {string} oldPath
* @param {string} newPath
* @param {string} newName - shown by the breadcrumbs that pointed at `oldPath`
*/
export const retargetPaths = (oldPath, newPath, newName) => {
if ( ! oldPath ) return;
const $inside = underPath('[data-path]', oldPath);
atPath('[data-path]', oldPath).each(function () {
$(this).attr('data-path', newPath);
if ( $(this).hasClass('window-navbar-path-dirname') ) {
$(this).text(newName);
}
});
$inside.each(function () {
$(this).attr('data-path', newPath + $(this).attr('data-path').slice(oldPath.length));
});
};
+2 -2
View File
@@ -103,9 +103,9 @@ const refresh_item_container = function (el_item_container, options) {
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
// data-name
$(el_window).attr('data-name', html_encode(fsentry.name));
$(el_window).attr('data-name', fsentry.name);
// data-path
$(el_window).attr('data-path', html_encode(container_path));
$(el_window).attr('data-path', container_path);
$(el_window).find('.window-navbar-path-input').val(container_path);
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
}
@@ -167,6 +167,15 @@ describe('refresh_item_container after the window moves to another folder', () =
expect($('.window').attr('data-uid')).toBe('pictures');
});
it('stores a folder name holding HTML characters on the window as it is', async () => {
navigate('/alice/R&D');
pending.stat['/alice/R&D'].resolve({ id: 'rd', name: 'R&D' });
await vi.waitFor(() => expect($('.window').attr('data-uid')).toBe('rd'));
expect($('.window').attr('data-name')).toBe('R&D');
});
it('drops a listing that arrives just before the window moves on', async () => {
vi.useFakeTimers();
navigate('/alice/Documents');
+3 -4
View File
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { atPath } from './pathElements.js';
/**
* Whether a `getShares()` list means this item is shared.
*
@@ -33,10 +35,7 @@ export const has_direct_share = (shares) =>
* can be on the desktop and in any number of open windows.
*/
export const mark_item_shared = (path, is_shared) => {
if ( ! path ) return;
const $items = $('.item').filter(function() {
return ($(this).attr('data-path') || '').toLowerCase() === path.toLowerCase();
});
const $items = atPath('.item', path);
$items.attr('data-is_shared', is_shared ? 1 : 0);
$items
.find('.item-shared-marker')