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