diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js
index 124aac028..269a40ace 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.js
@@ -234,6 +234,8 @@ const TabFiles = {
// drift — e.g. trashed items must show metadata.original_name, not
// the UID that is their raw name (matching renderItem).
window.UIDashboardFileItemUpdate = function ($row, file) {
+ const oldName = $row.attr('data-name');
+ const oldPath = $row.attr('data-path');
// Minimal update payloads may omit fields — only write what the
// event actually carries, so it can't blank a correct name/path
// (or zero a size) that is already on screen. A metadata-only
@@ -289,6 +291,7 @@ const TabFiles = {
) {
$row.find('.item-icon img').attr('src', file.thumbnail);
}
+ if ( displayName && displayName !== oldName && fsentry ) _this.followRename($row, fsentry, oldPath);
// The footer's item-count/total-size line is computed from the
// rows' data-size attributes — keep it in step with the cell.
_this.updateFooterStats();
@@ -856,11 +859,11 @@ const TabFiles = {
// Only handle if Dashboard Files tab is active
if ( ! _this.isDashboardFilesActive() ) return;
- // A Files-tab modal (share, item properties) owns the keyboard
- // while open: Enter/Space must reach its buttons, arrows its
- // selects, and typing must not retarget row selection — nor may
- // Enter/Delete open or trash the rows behind the overlay.
- if ( $('.share-modal-overlay, .item-props-overlay, .dashboard-notifications-panel.open, .context-menu-modal-backdrop').length > 0 ) return;
+ // A Files-tab modal (share, item properties, publish) owns the
+ // keyboard while open: Enter/Space must reach its buttons, arrows
+ // its selects, and typing must not retarget row selection — nor
+ // may Enter/Delete open or trash the rows behind the overlay.
+ if ( $('.share-modal-overlay, .item-props-overlay, .dashboard-dialog-overlay, .dashboard-notifications-panel.open, .context-menu-modal-backdrop').length > 0 ) return;
const focused_el = document.activeElement;
@@ -1227,8 +1230,9 @@ const TabFiles = {
return false;
}
- // Space - Toggle image preview
- if ( e.which === 32 ) {
+ // Space - Toggle image preview. Typed into a name being
+ // type-selected ("my notes"), it is part of the name.
+ if ( e.which === 32 && _this.typeSearchTerm === '' ) {
e.preventDefault();
e.stopPropagation();
@@ -1363,10 +1367,10 @@ const TabFiles = {
$('body').append(previewHtml);
const $popover = $('.image-preview-popover');
- // Position centered over the files container
+ // Centered over the files container, and inside it
$popover.css({
maxWidth: `${containerWidth - 40}px`,
- width: '100%',
+ maxHeight: `${$filesContainer.height() - 40}px`,
left: `${containerOffset.left + (containerWidth / 2)}px`,
top: `${containerOffset.top + ($filesContainer.height() / 2)}px`,
transform: 'translate(-50%, -50%)',
@@ -1778,6 +1782,26 @@ const TabFiles = {
return list.filter((_row, i) => answers[i]);
},
+ /**
+ * Puts a row renamed elsewhere where its new name sorts, with the icon its
+ * new type takes, and Back/Forward on a renamed folder's new path, as a
+ * rename made here does.
+ *
+ * @param {jQuery} $row
+ * @param {Object} fsentry - The row's entry, renamed
+ * @param {string} oldPath - The row's path before the rename
+ * @returns {void}
+ */
+ followRename ($row, fsentry, oldPath) {
+ this.insertAtSortedPosition($row, fsentry);
+ item_icon(fsentry).then(({ image }) => {
+ $row.find('.item-icon > img').first().attr('src', image);
+ });
+ if ( fsentry.is_dir && oldPath && fsentry.path && fsentry.path !== oldPath ) {
+ this.folderMoved(oldPath, fsentry.path);
+ }
+ },
+
/**
* Points Back/Forward history entries in or under `oldPath` at `newPath`,
* after the folder was renamed or the home folder moved.
@@ -2546,19 +2570,7 @@ const TabFiles = {
// in here too, and treating that as "already showing something" would
// leave the pane blank once the spinner is taken away.
if ( $files.find('.files-empty-notice').length > 0 ) return;
- $files.append(`
- ${i18n('folder_empty')}
-
`);
+ $files.append(`
${i18n('folder_empty')}
`);
},
/**
@@ -3109,19 +3121,7 @@ const TabFiles = {
// stale rows during a same-directory refresh) with a message
// rather than leaving it unexplained.
clearListing();
- $files.html(`
${i18n('folder_open_failed')}
`);
+ $files.html(`
${i18n('folder_open_failed')}
`);
// The list was emptied above; without this the footer keeps the
// previous directory's item count over a pane with zero rows.
this.updateFooterStats();
@@ -4409,9 +4409,10 @@ const TabFiles = {
// then overwrite without asking again.
let overwrite_all = false;
- for ( const item of window.clipboard ) {
- // Handle both object format { path, uid } and legacy string format
- const source = item.uid || item.path || item;
+ for ( const entry of window.clipboard ) {
+ // A row menu's Cut puts the bare path here; the rest put { path, uid }.
+ const item = typeof entry === 'string' ? { path: entry } : entry;
+ const source = item.uid || item.path;
let overwrite = overwrite_all;
let keep_both = false;
let retry;
@@ -5375,8 +5376,10 @@ const TabFiles = {
items.push({
html: i18n('publish_as_website'),
// The right sharing takes, as on the folder's own menu: the
- // folder is yours, or shared with you to manage.
- disabled: ! is_owned_by_me(targetPath) && shared_root_for(targetPath)?.mode !== 'manage',
+ // folder is yours, or shared with you to manage. Nor, as
+ // there, a second site for a folder that has one.
+ disabled: (! is_owned_by_me(targetPath) && shared_root_for(targetPath)?.mode !== 'manage')
+ || (window.sites ?? []).some(site => site.root_dir?.path === targetPath),
onClick: function () {
openPublishWebsiteDialog({ name: targetName, path: targetPath, $container: _this.$el_window });
},
@@ -5761,6 +5764,8 @@ const TabFiles = {
abort: async function (_operation_id) {
delete window.active_uploads[opid];
},
+ }).catch(() => {
+ // A failure is reported by the error callback above; a cancel needs no word.
});
},
diff --git a/src/gui/src/UI/Dashboard/TabFiles.test.js b/src/gui/src/UI/Dashboard/TabFiles.test.js
index 17953e3bc..2600247ae 100644
--- a/src/gui/src/UI/Dashboard/TabFiles.test.js
+++ b/src/gui/src/UI/Dashboard/TabFiles.test.js
@@ -435,6 +435,16 @@ describe('TabFiles.moveClipboardItems', () => {
options: expect.objectContaining({ uid: 'a1', path: '/alice/Pictures/a.txt' }),
})]);
});
+
+ it("is what Undo takes back after a row menu's Cut too", async () => {
+ window.clipboard = ['/alice/Documents/a.txt'];
+ await TabFiles.moveClipboardItems('/alice/Pictures');
+
+ expect(window.puter.fs.move).toHaveBeenCalledWith(expect.objectContaining({ source: '/alice/Documents/a.txt' }));
+ const last = window.actions_history.at(-1);
+ expect(last.operation).toBe('move');
+ expect(last.data).toEqual([expect.objectContaining({ original_path: '/alice/Documents/a.txt' })]);
+ });
});
describe('TabFiles refreshing a folder during a refresh of it', () => {
@@ -1099,6 +1109,27 @@ describe('TabFiles uploads', () => {
expect($('.files .row.selected')).toHaveLength(2);
});
+ it('that fail or are cancelled leave no rejection unhandled', async () => {
+ mountListing(['a.txt'], '/alice/Documents');
+ // Not a vi.fn(), which handles the promises it returns.
+ let uploads = 0;
+ window.puter.fs.upload = async (items, dest, callbacks) => {
+ uploads++;
+ await callbacks.init('op1', { abort: vi.fn() });
+ await callbacks.abort('op1');
+ throw { code: 'upload_aborted', message: 'Upload aborted.' };
+ };
+ const unhandled = vi.fn();
+ process.on('unhandledRejection', unhandled);
+
+ TabFiles.uploadFiles([new File(['x'], 'new.txt')], '/alice/Documents');
+ await new Promise(resolve => setTimeout(resolve, 50));
+ process.off('unhandledRejection', unhandled);
+
+ expect(uploads).toBe(1);
+ expect(unhandled).not.toHaveBeenCalled();
+ });
+
it('into the folder on screen list it again', async () => {
mountListing(['a.txt'], '/alice/Documents');
@@ -1255,3 +1286,91 @@ describe('TabFiles.folderMoved', () => {
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/A']);
});
});
+
+describe('TabFiles keyboard', () => {
+ /** Files listing `names`, its shortcuts bound as on the dashboard. */
+ const mountKeyboard = (names) => {
+ mountListing(names, '/alice/Documents');
+ $('.files .row').each((i, row) => row.setAttribute('data-name', names[i]));
+ TabFiles.typeSearchTerm = '';
+ Element.prototype.scrollIntoView ??= () => {};
+ vi.spyOn(TabFiles, 'isDashboardFilesActive').mockReturnValue(true);
+ TabFiles.setupKeyboardShortcuts();
+ };
+ const press = (el, key, init = {}) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init }));
+ const selected = () => $('.files .row.selected').toArray().map(r => r.getAttribute('data-path'));
+
+ afterEach(() => {
+ $(document).off('keydown.tabfiles');
+ clearTimeout(TabFiles.typeSearchTimeout);
+ vi.restoreAllMocks();
+ });
+
+ it('leaves the rows alone while a dashboard dialog is open over them', () => {
+ mountKeyboard(['a.txt', 'b.txt']);
+ $('.files .row').first().addClass('selected');
+ $('.window').append('');
+ const button = $('.dashboard-dialog-overlay .ok')[0];
+ button.focus();
+
+ press(button, 'ArrowDown', { which: 40, keyCode: 40 });
+ press(button, 'a', { which: 65, keyCode: 65, metaKey: true });
+
+ expect(selected()).toEqual(['/alice/Documents/a.txt']);
+ });
+
+ it('takes a space typed into a name as part of it', () => {
+ mountKeyboard(['my notes.txt', 'my zebra.txt']);
+ for ( const key of ['m', 'y', ' ', 'z'] ) press(document.body, key, { which: key.toUpperCase().charCodeAt(0) });
+ expect(selected()).toEqual(['/alice/Documents/my zebra.txt']);
+ });
+});
+
+describe('TabFiles folder menu', () => {
+ const publishItem = (folderPath) => TabFiles.generateFolderContextMenu(folderPath)
+ .find(item => item?.html === i18n('publish_as_website'));
+
+ beforeEach(() => {
+ mountListing([], '/alice/Documents');
+ window.user = { username: 'alice' };
+ window.trash_path = TRASH;
+ window.shared_path = '/alice/Shared';
+ window.file_templates = [];
+ window.sites = [];
+ TabFiles.trashedNames = new Map();
+ });
+
+ it('offers no second website for a folder that has one', () => {
+ expect(publishItem('/alice/Documents/site').disabled).toBe(false);
+ window.sites = [{ subdomain: 'mine', root_dir: { path: '/alice/Documents/site' } }];
+ expect(publishItem('/alice/Documents/site').disabled).toBe(true);
+ expect(publishItem('/alice/Documents/other').disabled).toBe(false);
+ });
+});
+
+describe('TabFiles.followRename', () => {
+ beforeEach(() => {
+ mountListing(['a.txt', 'b.txt', 'c.txt'], '/alice/Documents');
+ $('.files .row').each((_, row) => {
+ row.setAttribute('data-name', row.getAttribute('data-path').split('/').pop());
+ row.setAttribute('data-is_dir', '0');
+ });
+ TabFiles.sortColumn = 'name';
+ TabFiles.sortDirection = 'asc';
+ });
+
+ it('puts a row renamed elsewhere where its new name sorts', () => {
+ const $row = $('.files .row').first();
+ $row.attr({ 'data-name': 'z.txt', 'data-path': '/alice/Documents/z.txt' });
+ TabFiles.followRename($row, { name: 'z.txt', path: '/alice/Documents/z.txt', is_dir: false }, '/alice/Documents/a.txt');
+ expect($('.files .row').toArray().map(row => row.getAttribute('data-name'))).toEqual(['b.txt', 'c.txt', 'z.txt']);
+ });
+
+ it('points Back/Forward at a folder renamed elsewhere', () => {
+ window.dashboard_nav_history = ['/alice/Documents', '/alice/Documents/old/sub'];
+ window.dashboard_nav_history_current_position = 0;
+ const $row = $('.files .row').first();
+ TabFiles.followRename($row, { name: 'new', path: '/alice/Documents/new', is_dir: true }, '/alice/Documents/old');
+ expect(window.dashboard_nav_history).toEqual(['/alice/Documents', '/alice/Documents/new/sub']);
+ });
+});
diff --git a/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js
index a135761cd..f40e6c313 100644
--- a/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js
+++ b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.js
@@ -19,6 +19,7 @@
import item_icon from '../../helpers/itemIcon.js';
import UIAlert from '../UIAlert.js';
+import forget_parent_listing from '../../helpers/forgetParentListing.js';
import { isOverlayOnTop } from './dashboardOverlay.js';
import { formatRelativeTime } from '../../i18n/formatTime.js';
import { owner_of_path } from '../../helpers/pathOwner.js';
@@ -298,10 +299,17 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
el_modal.focus({ preventScroll: true });
if ( $row.find('.item-props-website-entry').length > 0 ) return;
$row.remove();
- // The item serves no website any more: drop its badge from every row.
+ // The item serves no website any more: drop its badge from
+ // every row, and from the entry its menu and the next
+ // listing read, which would offer no Publish and put it back.
$('.item').filter(function () {
return $(this).attr('data-uid') === item_uid;
+ }).each(function () {
+ const row_fsentry = $(this).data('fsentry');
+ if ( row_fsentry ) row_fsentry.has_website = false;
}).attr('data-has_website', '0').find('.item-has-website-badge').fadeOut(200);
+ forget_parent_listing(item_path);
+ window.update_sites_cache?.();
}).catch((err) => {
$link.prop('disabled', false);
UIAlert(err?.message || i18n('error_unknown_cause', [], false));
diff --git a/src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js
new file mode 100644
index 000000000..1f9b2a86b
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js
@@ -0,0 +1,71 @@
+// @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 .
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
+
+vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
+vi.mock('../../helpers/itemIcon.js', () => ({ default: vi.fn(async () => ({ image: 'icon.svg' })) }));
+
+globalThis.$ = jQuery;
+globalThis.jQuery = jQuery;
+globalThis.i18n = (key) => key;
+globalThis.html_encode = (value) => String(value ?? '')
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+window.html_encode = globalThis.html_encode;
+window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
+
+const { default: UIItemPropertiesModal } = await import('./UIItemPropertiesModal.js');
+
+const tick = () => new Promise(resolve => setTimeout(resolve, 0));
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ delete globalThis.puter;
+});
+
+describe('UIItemPropertiesModal', () => {
+ it('leaves a folder whose website is disassociated publishable again', async () => {
+ const fsentry = { uid: 'u1', name: 'site', path: '/alice/site', is_dir: true, has_website: true };
+ const $row = $('
')
+ .data('fsentry', fsentry).appendTo('body');
+ window.extractSubdomain = () => 'mysite';
+ window.update_sites_cache = vi.fn();
+ globalThis.puter = {
+ fs: { stat: vi.fn(({ success }) => {
+ success({ ...fsentry, id: 'u1', modified: 1700000000, created: 1700000000, subdomains: [{ uuid: 's1', address: 'https://mysite.puter.site' }] });
+ return Promise.resolve();
+ }) },
+ hosting: { update: vi.fn(async () => ({})) },
+ fsCacheKey: (kind, path) => `${kind}:${path}`,
+ _cache: { del: vi.fn() },
+ };
+
+ UIItemPropertiesModal({ name: 'site', path: '/alice/site', uid: 'u1' });
+ await tick();
+ $('.item-props-disassociate').trigger('click');
+ await tick();
+
+ expect(puter.hosting.update).toHaveBeenCalledWith('mysite', null);
+ expect($row.attr('data-has_website')).toBe('0');
+ expect(fsentry.has_website).toBe(false);
+ expect(window.update_sites_cache).toHaveBeenCalled();
+ expect(puter._cache.del).toHaveBeenCalledWith('readdir:/alice');
+ });
+});
diff --git a/src/gui/src/UI/Dashboard/UIPublishDialogs.js b/src/gui/src/UI/Dashboard/UIPublishDialogs.js
index 85200a27d..d80aaf95b 100644
--- a/src/gui/src/UI/Dashboard/UIPublishDialogs.js
+++ b/src/gui/src/UI/Dashboard/UIPublishDialogs.js
@@ -21,6 +21,7 @@
// worker: dashboard form dialogs rather than desktop windows.
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
+import forget_parent_listing from '../../helpers/forgetParentListing.js';
import { SVG } from './UIDashboardDialog.js';
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
@@ -71,6 +72,8 @@ const KINDS = {
.find('.item-has-website-url-badge').show();
}
});
+ // The folder's row, when it isn't on screen, is drawn from there next.
+ forget_parent_listing(path);
window.update_sites_cache?.();
},
},
@@ -98,6 +101,9 @@ const KINDS = {
if ( item_path !== path ) return;
$item.attr({ 'data-is_worker': '1', 'data-worker_url': url })
.find('.item-is-worker').show();
+ // The listing draws the badge, and the menu its state, from here.
+ const fsentry = $item.data('fsentry');
+ if ( fsentry ) fsentry.workers = [{ address: url }];
});
},
},
diff --git a/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js b/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js
index ee7831c2a..26db0dca8 100644
--- a/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js
+++ b/src/gui/src/UI/Dashboard/UIPublishDialogs.test.js
@@ -60,6 +60,8 @@ beforeEach(() => {
window.update_sites_cache = vi.fn();
window.require_email_verification_to_publish_website = false;
globalThis.puter = {
+ fsCacheKey: (kind, path) => `${kind}:${path}`,
+ _cache: { del: vi.fn() },
hosting: { create: vi.fn(async () => ({})) },
workers: { create: vi.fn(async (name) => ({ success: true, url: `https://${name}.puter.work` })) },
};
@@ -94,6 +96,8 @@ describe('publish website', () => {
expect($folder.find('.item-has-website-badge').css('display')).not.toBe('none');
expect($child.attr('data-website_url')).toBe('https://my-site.puter.site/index.html');
expect(window.update_sites_cache).toHaveBeenCalled();
+ // Its row in /alice, when not on screen, is listed afresh next time.
+ expect(puter._cache.del).toHaveBeenCalledWith('readdir:/alice');
});
it("links to the server's address for the site when it sends one", async () => {
@@ -168,6 +172,8 @@ describe('publish worker', () => {
it('deploys sandboxed by default and badges the file', async () => {
const $file = addRow('/alice/api.js');
+ const fsentry = { path: '/alice/api.js', workers: [] };
+ $file.data('fsentry', fsentry);
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
expect($('.dashboard-form-suffix').text()).toBe('.puter.work');
@@ -177,6 +183,8 @@ describe('publish worker', () => {
expect($('.dashboard-copy-value').val()).toBe('https://my_api.puter.work');
expect($file.attr('data-is_worker')).toBe('1');
expect($file.attr('data-worker_url')).toBe('https://my_api.puter.work');
+ // What the next listing of the folder draws the row from.
+ expect(fsentry.workers).toEqual([{ address: 'https://my_api.puter.work' }]);
});
it('deploys unsandboxed when the switch is off', async () => {
diff --git a/src/gui/src/UI/Dashboard/UIShareModal.js b/src/gui/src/UI/Dashboard/UIShareModal.js
index c1658c1df..2a111e65b 100644
--- a/src/gui/src/UI/Dashboard/UIShareModal.js
+++ b/src/gui/src/UI/Dashboard/UIShareModal.js
@@ -200,6 +200,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
+
@@ -230,8 +231,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
const $link_note = $overlay.find('.share-modal-general-note');
const $copy_link = $overlay.find('.share-modal-copy-link');
- /** Where the selection stands on "anyone with the link", as last listed. */
- let link_state = { access: 'restricted', mode: null };
+ /**
+ * Where the selection stands on "anyone with the link", as last listed.
+ * A selection too large to list (see refresh) is unchecked until set here.
+ */
+ let link_state = { access: total > MAX_ITEMS_PER_REQUEST ? 'unchecked' : 'restricted', mode: null };
/** The one item's uid, which its link is built on. Single item only. */
let single_uid = is_multi ? null : (targets[0].fsentry?.uid ?? null);
@@ -267,6 +271,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// The last successfully aggregated access list, so canceling an inline
// revoke confirmation can restore the row without another round-trip.
let last_groups = [];
+ // The items whose access that list was read from. An item whose listing
+ // failed may well be shared already: "add to all" granting it the group's
+ // mode could take a stronger grant down.
+ let listed_paths = target_paths;
const group_for = (key) => last_groups.find((group) => group.key === key);
// Re-rendering the list replaces its nodes wholesale, and disabling a
@@ -357,7 +365,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|| (is_self && group.directPaths.length > 0);
// Extending someone needs a mode to extend; a person whose grants
// disagree levels them with the select first.
- const missing = missingPathsFor(target_paths, group).length;
+ const missing = missingPathsFor(listed_paths, group).length;
const can_add_to_all = can_change && missing > 0 && group.mode !== null;
// The clauses read as one muted line; the action keeps its own box on
@@ -447,6 +455,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
last_groups = [];
$list.attr('aria-busy', 'false')
.html(`
${i18n('share_access_list_too_many', { total })}
`);
+ render_link_access();
return;
}
@@ -480,6 +489,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
show_error(failure?.message ? html_encode(failure.message) : i18n('share_load_failed'));
return;
}
+ listed_paths = target_paths.filter((target) => by_path.has(target));
render(aggregateShares(target_paths, by_path));
link_state = linkShareState(target_paths, by_path);
render_link_access();
@@ -497,7 +507,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// `null` rests the mode on the "Mixed" placeholder, as the rows do.
if ( access === 'anyone' ) $link_mode.html(options_for(mode, { allow_manage: false }));
let note;
- if ( access === 'mixed' ) {
+ if ( access === 'unchecked' ) {
+ note = i18n('share_link_unchecked_note', { total });
+ } else if ( access === 'mixed' ) {
note = i18n('share_link_mixed_note');
} else if ( access === 'anyone' ) {
note = i18n(`share_link_anyone_note_${mode === 'write' ? 'write' : 'read'}${is_multi ? '_items' : ''}`);
@@ -523,8 +535,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
$(document).on(`keydown.share-modal-${modal_id}`, function (e) {
// A gate or upgrade window opened over the modal takes its own Escape.
if ( e.key !== 'Escape' || ! isOverlayOnTop($overlay[0]) ) return;
- // An open revoke confirmation swallows the first Escape.
+ // An open revoke confirmation swallows the first Escape. Once Remove
+ // is under way it can't be taken back, and the row must not look it.
const $confirm = $list.find('.share-modal-row-confirm');
+ if ( $confirm.find('button:disabled').length ) return;
if ( $confirm.length ) {
const key = $confirm.attr('data-key');
render(last_groups);
@@ -655,7 +669,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
$overlay.on('change', '.share-modal-link-access', function () {
const value = $(this).val();
- if ( value === 'mixed' ) return;
+ if ( value === 'mixed' || value === 'unchecked' ) return;
// Opening up starts at view. The mode control is hidden unless every
// item is already open, so its value may be left over from before.
set_link_access(value === 'anyone' ? 'read' : null, this);
@@ -689,6 +703,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
/** A team is named by uid; a person by the name the row already shows. */
const recipient_of = (group) => (group.teamUid ? { team: group.teamUid } : group.name);
+ // A share on its way keeps the button down, though the field is edited.
+ let sharing = false;
+
// One field for every kind of recipient: a colleague, a whole team, someone
// shared with before, or an address typed from scratch.
const picker = shareRecipientPicker({
@@ -698,7 +715,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// re-grant what their row already shows.
excluded: () => last_groups.map((group) => group.key),
onChange: () => {
- $submit.prop('disabled', ! picker.recipient());
+ $submit.prop('disabled', sharing || ! picker.recipient());
// Choosing again retires a stale success/error message.
clear_status();
},
@@ -726,9 +743,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// A team is named by uid; anything typed goes as-is, since a bare
// string is what the backend reads as an email or a username.
const chosen = picker.recipient();
- if ( ! chosen ) return;
+ if ( ! chosen || sharing ) return;
const recipient = chosen.label;
+ sharing = true;
$submit.prop('disabled', true).addClass('share-modal-btn-busy');
try {
const created = await grant_access(
@@ -736,6 +754,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
$overlay.find('.share-modal-mode').val(),
target_paths,
);
+ sharing = false;
// Clear only what we sent; a name typed mid-flight shouldn't vanish.
if ( $recipient.val().trim() === recipient ) picker.clear();
$submit.prop('disabled', ! picker.recipient());
@@ -767,8 +786,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
await refresh();
if ( ! focus_moved_on() ) $recipient.get(0)?.focus({ preventScroll: true });
} catch (err) {
+ sharing = false;
show_error(error_html(err));
- $submit.prop('disabled', false);
+ $submit.prop('disabled', ! picker.recipient());
// Disabling the clicked button dropped focus to ; put it
// where the correction happens.
$recipient.get(0)?.focus({ preventScroll: true });
@@ -802,7 +822,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
const key = $(this).attr('data-key');
const group = group_for(key);
if ( ! group || group.mode === null ) return;
- const missing = missingPathsFor(target_paths, group);
+ const missing = missingPathsFor(listed_paths, group);
if ( ! missing.length ) return;
$(this).prop('disabled', true);
try {
diff --git a/src/gui/src/UI/Dashboard/UIShareModal.test.js b/src/gui/src/UI/Dashboard/UIShareModal.test.js
new file mode 100644
index 000000000..a8f4c1c81
--- /dev/null
+++ b/src/gui/src/UI/Dashboard/UIShareModal.test.js
@@ -0,0 +1,161 @@
+// @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 .
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
+
+vi.mock('./ContextMenu/ContextMenu.js', () => ({ isTouchPrimaryDevice: () => false }));
+vi.mock('../../helpers/itemIcon.js', () => ({ default: vi.fn(async () => ({ image: 'icon.svg' })) }));
+vi.mock('../../helpers/shareTeams.js', () => ({ teams_for_sharing: vi.fn(async () => []) }));
+// The field's choice, and the dialog's hook into it, for tests to drive.
+const picked = { recipient: null, onChange: null };
+vi.mock('../../helpers/shareRecipientPicker.js', () => ({
+ default: ({ onChange }) => {
+ picked.onChange = onChange;
+ return { destroy () {}, recipient: () => picked.recipient, setTeams () {}, clear () {}, remember () {} };
+ },
+}));
+
+globalThis.$ = jQuery;
+globalThis.jQuery = jQuery;
+globalThis.i18n = (key, args) => (args && ! Array.isArray(args) ? `${key}:${JSON.stringify(args)}` : key);
+globalThis.html_encode = (value) => String(value ?? '')
+ .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+window.html_encode = globalThis.html_encode;
+window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
+globalThis.def = globalThis.def ?? (value => value);
+globalThis.use = globalThis.use ?? (() => class {});
+
+const { default: UIShareModal } = await import('./UIShareModal.js');
+
+const settle = async () => {
+ for ( let i = 0; i < 5; i++ ) await new Promise(resolve => setTimeout(resolve, 0));
+};
+
+beforeEach(() => {
+ picked.recipient = null;
+ window.user = { username: 'alice' };
+ globalThis.puter = {
+ fs: {
+ getShares: vi.fn(async () => []),
+ share: vi.fn(async ({ paths }) => paths.map(p => ({ path: p }))),
+ unshare: vi.fn(async () => ({})),
+ stat: vi.fn(async () => ({ uid: 'u' })),
+ },
+ };
+});
+
+afterEach(() => {
+ document.body.innerHTML = '';
+});
+
+describe('UIShareModal general access', () => {
+ const items = (count) => Array.from({ length: count }, (_, i) => ({ path: `/alice/f${i}.txt` }));
+
+ it("doesn't call a selection too large to list restricted, and lets either choice apply", async () => {
+ UIShareModal({ items: items(51) });
+ await settle();
+
+ expect($('.share-modal-link-access')[0].value).toBe('unchecked');
+ expect($('.share-modal-general-note').html()).toBe(i18n('share_link_unchecked_note', { total: 51 }));
+ expect(puter.fs.getShares).not.toHaveBeenCalled();
+
+ $('.share-modal-link-access').val('restricted').trigger('change');
+ await settle();
+ expect(puter.fs.unshare).toHaveBeenCalledTimes(2);
+ expect($('.share-modal-link-access').val()).toBe('restricted');
+ });
+
+ it('shows a listed selection as the listings say', async () => {
+ UIShareModal({ items: items(2) });
+ await settle();
+ expect($('.share-modal-link-access').val()).toBe('restricted');
+ expect(puter.fs.getShares).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('UIShareModal extending a grant', () => {
+ const bob = (mode) => ({ holder: 'bob', mode, issuer: 'alice' });
+
+ it('offers it on the items listed, not on one whose listing failed', async () => {
+ puter.fs.getShares = vi.fn(async (target) => {
+ if ( target === '/alice/b.txt' ) throw new Error('Network hiccup');
+ return [bob('read')];
+ });
+ UIShareModal({ items: [{ path: '/alice/a.txt' }, { path: '/alice/b.txt' }] });
+ await settle();
+ expect($('.share-modal-row-mode').length).toBe(1);
+ expect($('.share-modal-row-link').length).toBe(0);
+ });
+
+ it('offers it on the items bob is missing', async () => {
+ puter.fs.getShares = vi.fn(async (target) => (target === '/alice/a.txt' ? [bob('read')] : []));
+ UIShareModal({ items: [{ path: '/alice/a.txt' }, { path: '/alice/b.txt' }] });
+ await settle();
+ $('.share-modal-row-link').trigger('click');
+ await settle();
+ expect(puter.fs.share).toHaveBeenCalledWith({ paths: ['/alice/b.txt'], recipient: 'bob', mode: 'read' });
+ });
+});
+
+describe('UIShareModal removing access', () => {
+ it("doesn't look cancelled by an Escape while the removal is under way", async () => {
+ puter.fs.getShares = vi.fn(async () => [{ holder: 'bob', mode: 'read', issuer: 'alice' }]);
+ let finish;
+ puter.fs.unshare = vi.fn(() => new Promise((resolve) => {
+ finish = resolve;
+ }));
+ UIShareModal({ path: '/alice/a.txt' });
+ await settle();
+
+ $('.share-modal-revoke').trigger('click');
+ $('.share-modal-confirm-remove').trigger('click');
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
+
+ expect($('.share-modal-row-confirm').length).toBe(1);
+ expect($('.share-modal-overlay').length).toBe(1);
+ finish();
+ await settle();
+ });
+});
+
+describe('UIShareModal adding people', () => {
+ it('keeps Share down while a share is on its way, though the name is edited', async () => {
+ let finish;
+ puter.fs.share = vi.fn(() => new Promise((resolve) => {
+ finish = resolve;
+ }));
+ UIShareModal({ path: '/alice/a.txt' });
+ await settle();
+ picked.recipient = { value: 'bob', label: 'bob' };
+ picked.onChange();
+ expect($('.share-modal-submit').prop('disabled')).toBe(false);
+
+ $('.share-modal-add').trigger('submit');
+ picked.recipient = { value: 'bobby', label: 'bobby' };
+ picked.onChange();
+ expect($('.share-modal-submit').prop('disabled')).toBe(true);
+ $('.share-modal-add').trigger('submit');
+ expect(puter.fs.share).toHaveBeenCalledTimes(1);
+
+ finish([{ path: '/alice/a.txt' }]);
+ await settle();
+ expect($('.share-modal-submit').prop('disabled')).toBe(false);
+ });
+});
diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css
index 9840c0cd4..a027c98a9 100644
--- a/src/gui/src/css/dashboard.css
+++ b/src/gui/src/css/dashboard.css
@@ -2229,9 +2229,12 @@ p.myapps-add-error {
opacity: 0;
transition: opacity 200ms ease;
z-index: 1000;
+ /* Fading out after a close, it must not take the next click. */
+ pointer-events: none;
}
.item-props-overlay.item-props-show {
opacity: 1;
+ pointer-events: auto;
}
.item-props-modal {
@@ -2556,9 +2559,12 @@ p.myapps-add-error {
opacity: 0;
transition: opacity 200ms ease;
z-index: 1000;
+ /* Fading out after a close, it must not take the next click. */
+ pointer-events: none;
}
.share-modal-overlay.share-modal-show {
opacity: 1;
+ pointer-events: auto;
}
.share-modal {
@@ -4940,6 +4946,25 @@ body.myapps-reordering .myapps-tile {
color: var(--dashboard-text-tertiary);
}
+/* On a touch screen the '⋯' is a 20x23 target beside a row whose tap opens
+ the item, so a near miss opened the item instead of the menu. It takes the
+ row's height and a finger's width. (Two-row rows get a 44px square below.) */
+@media (pointer: coarse) {
+ .dashboard-section-files .files-tab .files.files-list-view .row .item-more {
+ position: relative;
+ }
+
+ .dashboard-section-files .files-tab .files.files-list-view .row .item-more::before {
+ content: '';
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 44px;
+ height: 32px;
+ transform: translate(-50%, -50%);
+ }
+}
+
.dashboard-section-files .files-tab .files.files-list-view .row.selected .item-size,
.dashboard-section-files .files-tab .files.files-list-view .row.selected .item-modified {
color: inherit;
@@ -5155,6 +5180,13 @@ body.myapps-reordering .myapps-tile {
filter: none;
}
+@media (prefers-color-scheme: dark) {
+ /* A black glyph, lost on the dark row: drawn light instead. */
+ .dashboard-section-files .files-tab .files.files-list-view .row img.item-has-website-url-badge {
+ filter: invert(1);
+ }
+}
+
/* Badges live top-LEFT in grid view; the top-right corner belongs to the
hover '⋯' button (.item-more) and they must not overlap. */
.dashboard-section-files .files-tab .files.files-grid-view .row .item-badges {
@@ -5288,6 +5320,9 @@ body.myapps-reordering .myapps-tile {
.dashboard-section-files .files-tab .files.files-grid-view .row .item-name-wrapper {
width: 100%;
display: block;
+ /* Above the tile-sized badge layer, which would take the hover that
+ shows a shortened name in full. */
+ position: relative;
}
@media (hover: hover) {
@@ -5787,6 +5822,10 @@ body.myapps-reordering .myapps-tile {
position: fixed;
/* On body, above the dashboard window (see the sidebar tooltip). */
z-index: 999999999;
+ /* Sized to the image, within the bounds showImagePreview sets */
+ box-sizing: border-box;
+ width: max-content;
+ min-width: 160px;
background: var(--dashboard-background);
border: 1px solid var(--dashboard-border);
border-radius: 8px;
@@ -5801,11 +5840,14 @@ body.myapps-reordering .myapps-tile {
.image-preview-popover img {
max-width: 100%;
max-height: 70vh;
+ /* Shrinks to leave the name room in a short listing */
+ min-height: 0;
object-fit: contain;
border-radius: 4px;
}
.image-preview-name {
+ flex-shrink: 0;
font-size: 14px;
color: var(--dashboard-text);
text-align: center;
@@ -6031,9 +6073,20 @@ body.myapps-reordering .myapps-tile {
margin-bottom: 12px;
}
- /* Clear the fixed menu button beside the crumbs */
+ /* Clear the fixed menu button beside the crumbs, and match its height:
+ a shorter row put the arrows below under the button's tap area, so
+ the top of Back opened the menu. */
.dashboard-section-files .header .path-breadcrumbs {
margin-left: 50px;
+ box-sizing: content-box;
+ min-height: 40px;
+ }
+
+ /* Finger-sized crumbs, in that row */
+ @media (pointer: coarse) {
+ .dashboard-section-files .header .path .dirname {
+ padding: 8px 6px;
+ }
}
/* Adjust footer position - full width since sidebar is hidden. It sits
@@ -6049,6 +6102,13 @@ body.myapps-reordering .myapps-tile {
padding-bottom: calc(30px + env(safe-area-inset-bottom, 0px));
}
+ /* The grids keep their own padding and clear the footer with a margin,
+ which has to grow with the footer as well. (Select mode makes room
+ for its bar, safe area included, below.) */
+ .dashboard-section-files .files-tab:not(.select-mode-active) .files.files-grid-view {
+ margin-bottom: calc(30px + env(safe-area-inset-bottom, 0px));
+ }
+
/* Mobile: Floating selection actions - icon-only, full width */
.dashboard-section-files .files-selection-actions {
left: 0;
@@ -7629,6 +7689,19 @@ body.myapps-reordering .myapps-tile {
}
}
+/* "No files in this directory." and "couldn't be opened", in place of rows */
+.files-notice {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 0 16px;
+ text-align: center;
+ color: var(--dashboard-text-tertiary);
+ pointer-events: none;
+}
+
/* ======================================
Files Loading Spinner
====================================== */
@@ -8882,10 +8955,13 @@ body.dashboard-mode .notifications-close-all {
opacity: 0;
transition: opacity 200ms ease;
z-index: 1000;
+ /* Fading out after a close, it must not take the next click. */
+ pointer-events: none;
}
.dashboard-dialog-overlay.dashboard-dialog-show {
opacity: 1;
+ pointer-events: auto;
}
.dashboard-dialog {
diff --git a/src/gui/src/helpers.determineWebsiteUrl.test.js b/src/gui/src/helpers.determineWebsiteUrl.test.js
new file mode 100644
index 000000000..53888e407
--- /dev/null
+++ b/src/gui/src/helpers.determineWebsiteUrl.test.js
@@ -0,0 +1,62 @@
+// @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 .
+ */
+
+import { 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/UIWindowLogin.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('
').text(value).html();
+
+await import('./helpers.js');
+
+describe('determine_website_url', () => {
+ // As puter.hosting.list() returns a site.
+ const site = {
+ subdomain: 'mysite',
+ root_dir: {
+ path: '/alice/Site',
+ subdomains: [{ subdomain: 'mysite', address: 'https://mysite.puter.site' }],
+ },
+ };
+
+ it('gives an item under a published folder its address there', () => {
+ window.sites = [site];
+ expect(window.determine_website_url('/alice/Site/index.html')).toBe('https://mysite.puter.site/index.html');
+ expect(window.determine_website_url('/alice/Site/css/a b.css')).toBe('https://mysite.puter.site/css/a b.css');
+ });
+
+ it('gives none to the folder itself or to anything outside it', () => {
+ window.sites = [site];
+ expect(window.determine_website_url('/alice/Site')).toBeNull();
+ expect(window.determine_website_url('/alice/Sites/index.html')).toBeNull();
+ window.sites = [];
+ expect(window.determine_website_url('/alice/Site/index.html')).toBeNull();
+ });
+});
diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js
index dd6126ed7..1e026122d 100644
--- a/src/gui/src/helpers.js
+++ b/src/gui/src/helpers.js
@@ -2166,10 +2166,13 @@ window.refresh_desktop_background = function () {
};
window.determine_website_url = function (fsentry_path) {
- // search window.sites and if any site has `dir_path` set and the fsentry_path starts with that dir_path + '/', return the site's url + path
- for ( let i = 0; i < window.sites.length; i++ ) {
- if ( window.sites[i].dir_path && fsentry_path.startsWith(`${window.sites[i].dir_path }/`) ) {
- return window.sites[i].address + fsentry_path.replace(window.sites[i].dir_path, '');
+ // The address an item has under a site serving a folder above it. The
+ // site's folder and address come with its root_dir.
+ for ( const site of window.sites ) {
+ const dir_path = site.root_dir?.path;
+ const address = site.root_dir?.subdomains?.find((entry) => entry.subdomain === site.subdomain)?.address;
+ if ( dir_path && address && fsentry_path.startsWith(`${dir_path}/`) ) {
+ return address + fsentry_path.slice(dir_path.length);
}
}
@@ -3309,11 +3312,12 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i
onDone?.(null);
- //show error
- if ( err.message ) {
- UIAlert(err.message);
- }
+ // A network failure rejects with no message, and the name going
+ // back on its own would look like the rename was ignored.
+ UIAlert(err?.message || i18n('error_unknown_cause', [], false));
},
+ }).catch(() => {
+ // Handled by the error callback above.
});
};
diff --git a/src/gui/src/helpers.renameFile.test.js b/src/gui/src/helpers.renameFile.test.js
new file mode 100644
index 000000000..81ac70c9f
--- /dev/null
+++ b/src/gui/src/helpers.renameFile.test.js
@@ -0,0 +1,61 @@
+// @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 .
+ */
+
+import { 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/UIWindowLogin.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('
').text(value).html();
+
+await import('./helpers.js');
+const { default: UIAlert } = await import('./UI/UIAlert.js');
+
+describe('rename_file', () => {
+ it('says so when the rename never reaches the server, and leaves no rejection behind', async () => {
+ const failure = new XMLHttpRequest();
+ // Not a vi.fn(), which handles the promises it returns.
+ globalThis.puter = { fs: { rename: ({ error }) => {
+ error(failure);
+ return Promise.reject(failure);
+ } } };
+ const onDone = vi.fn();
+ const unhandled = vi.fn();
+ process.on('unhandledRejection', unhandled);
+
+ await window.rename_file({ uid: 'u1', name: 'a.txt', path: '/alice/a.txt' }, 'b.txt', 'a.txt', '/alice/a.txt',
+ document.createElement('div'), null, null, null, '', false, onDone);
+ await new Promise(resolve => setTimeout(resolve, 0));
+ process.off('unhandledRejection', unhandled);
+
+ expect(onDone).toHaveBeenCalledWith(null);
+ expect(UIAlert).toHaveBeenCalledWith('error_unknown_cause');
+ expect(unhandled).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/gui/src/helpers/forgetParentListing.js b/src/gui/src/helpers/forgetParentListing.js
new file mode 100644
index 000000000..ddb138d68
--- /dev/null
+++ b/src/gui/src/helpers/forgetParentListing.js
@@ -0,0 +1,34 @@
+/*
+ * 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 .
+ */
+
+import path from '../lib/path.js';
+
+/**
+ * Drops puter.js's cached listing of the folder that holds `item_path`, so
+ * the next listing of it is read fresh. For changes to an item the server
+ * sends no fs event about (a website added to or removed from a folder),
+ * which the cached listing would go on showing the old way.
+ *
+ * @param {string} item_path
+ * @returns {void}
+ */
+export default function forget_parent_listing (item_path) {
+ if ( typeof puter.fsCacheKey !== 'function' || ! puter._cache ) return;
+ puter._cache.del(puter.fsCacheKey('readdir', path.dirname(item_path)));
+}
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index 4aad1d774..93c5c61e2 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -610,6 +610,9 @@ const en = {
share_link_anyone_note_write_items:
'Anyone signed in to Puter who has a link to one of these items can view and edit it.',
share_link_mixed_note: 'Some of these items are open to anyone with the link and some are not.',
+ share_link_unchecked: 'Not checked',
+ share_link_unchecked_note:
+ 'Whether this many items are open to anyone with the link isn’t checked. A choice here applies to all {{total}}.',
share_link_on_read: 'Anyone with the link can now view this item',
share_link_on_write: 'Anyone with the link can now edit this item',
share_link_on_read_items: 'Anyone with the link can now view these items',
diff --git a/src/gui/src/keyboard.js b/src/gui/src/keyboard.js
index 9b04ffafb..78e6aff7b 100644
--- a/src/gui/src/keyboard.js
+++ b/src/gui/src/keyboard.js
@@ -911,8 +911,10 @@ $(document).bind('keyup keydown', async function (e) {
// ctrl/command + z, will undo last action
//-----------------------------------------------------------------------------
// Not in a text field, whose own undo is what the user means: the last
- // file action could be an upload, which Undo deletes outright.
- if ( (e.ctrlKey || e.metaKey) && e.which === 90 && ! $(focused_el).is('input, textarea, [contenteditable]') ) {
+ // file action could be an upload, which Undo deletes outright. Once per
+ // press: releasing Z with Ctrl/Cmd still held would undo a second action.
+ if ( (e.ctrlKey || e.metaKey) && e.which === 90 && e.type === 'keydown'
+ && ! $(focused_el).is('input, textarea, [contenteditable]') ) {
window.undo_last_action();
return false;
}
diff --git a/src/gui/src/keyboard.undo.test.js b/src/gui/src/keyboard.undo.test.js
new file mode 100644
index 000000000..657cbdcef
--- /dev/null
+++ b/src/gui/src/keyboard.undo.test.js
@@ -0,0 +1,46 @@
+// @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 .
+ */
+
+import { 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/UIWindowSearch.js', () => ({ default: vi.fn() }));
+vi.mock('./helpers/launchApp.js', () => ({ default: vi.fn() }));
+vi.mock('./helpers/openItem.js', () => ({ default: vi.fn() }));
+
+globalThis.$ = jQuery;
+globalThis.jQuery = jQuery;
+globalThis.i18n = key => key;
+globalThis.def = globalThis.def ?? (value => value);
+globalThis.use = globalThis.use ?? (() => class {});
+
+await import('./keyboard.js');
+
+describe('Ctrl/Cmd+Z', () => {
+ it('undoes one action per press', () => {
+ window.undo_last_action = vi.fn();
+ const z = { key: 'z', keyCode: 90, which: 90, metaKey: true, bubbles: true };
+ document.body.dispatchEvent(new KeyboardEvent('keydown', z));
+ // Z let go before Cmd.
+ document.body.dispatchEvent(new KeyboardEvent('keyup', z));
+ expect(window.undo_last_action).toHaveBeenCalledTimes(1);
+ });
+});