From 9e1ccf7ff31d506109e013136f41539906dcd4ef Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Mon, 26 Oct 2026 17:21:35 -0700 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20ninth=20adversarial=20review?= =?UTF-8?q?=20of=20Files=20=E2=80=94=20bugs,=20UX,=20a11y,=20perf=20(#4356?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): keep Files keys away from the rows under a publish dialog With Publish as website or Publish as Worker open and focus on one of its buttons, the arrow keys moved the listing's selection behind the dialog, Ctrl/Cmd+A selected every row, and Delete trashed whatever was selected. The Files shortcuts already stood aside for the share and properties modals; they now do for the dashboard dialog overlay too. * fix(dashboard): let type-to-select in Files find names with spaces Typing "my z" to reach "my zebra.txt" stopped at "my notes.txt": the space went to the image preview instead of the name being typed, and the search went on as "myz". A space typed while a name is being typed now belongs to it; on its own, Space still opens and closes the preview. * fix(gui): say so when a rename can't reach the server A rename that failed on the network (offline, connection dropped) put the old name back without a word, so in Files it looked as if the rename had been ignored; only failures the server answered with a message were reported. Every failure is now reported, with the unknown-cause message when there is no other. The rename's own promise was also left to reject unhandled on every failure, conflicts included; the error callback already handles it. * fix(dashboard): show a grid tile's full name on hover A tile name cut short to fit shows in full on hover, but in the grid views the hover never reached it: the badge layer is the size of the whole tile and sat on top of the name. The name now sits above that layer, so its tooltip shows; clicking and dragging by the name behave as before. * fix(dashboard): make the empty-folder notice readable in dark mode "No files in this directory." and "This folder couldn't be opened." were styled inline with no color of their own and inherited the page default, black, which on the dark theme's background could hardly be seen. Both now share a class with the muted text color the Apps tab's empty state uses, in either theme. * fix(dashboard): let a folder be published again once its website is removed Disassociating a folder's website in Properties took the globe badge off its row, but the row's entry still said it had a website: its menu kept Publish as website greyed out, and going into another folder and back brought the badge back. The entry the menu and the listing read is now updated with the row, and the sites cache is refreshed, as publishing does. * fix(dashboard): keep a just-published worker's badge across navigation Publishing a .js file as a worker badged its row, but the row's entry still listed no worker. Going into a folder and back redrew the row from that entry (the folder's cached listing), so the badge went away and the menu offered Publish as Worker again, which would deploy the file a second time. The entry now records the worker, as publishing a website records its site. * fix(dashboard): badge a folder published from inside it once you go up Publishing the folder on screen (from the listing's background menu) and going Up showed its row without the globe badge, and with Publish as website on offer again: the parent's listing came from puter.js's cache, and adding a website sends no fs event to clear it. Removing a website from inside the folder (Properties > Disassociate) left the badge behind the same way. Both now drop the parent folder's cached listing. * fix(dashboard): don't offer a second website for a published folder A folder's own row greys out Publish as website once the folder has a site, but its menu on the sidebar, a breadcrumb or the listing's background kept offering it, and publishing again gave the folder a second address. Those menus now check the user's sites as well. * fix(dashboard): give Files' row menu button a finger-sized target on tablets On a touch screen with room for the full list columns (an iPad in landscape, say), a row's '⋯' was a 20x23px target right beside a row whose tap opens the item, so a near miss opened the file instead of its menu. On touch it now answers across the row's height and 44px wide; phones' two-row layout keeps its 44px square, and mouse pointers are unchanged. * fix(dashboard): keep Files' Back arrow clear of the phone menu button On phones the breadcrumb row was shorter than the fixed menu button beside it, so the button hung over the arrows below, and its enlarged tap area took the top quarter of Back: a tap there opened the menu. The crumb row is now as tall as the button, which also lines the crumbs up with it, and on touch the crumbs themselves get a taller tap target. * fix(dashboard): fit the Files image preview to the image and the listing The Space preview was a strip the full width of the listing whatever the image's size (a 64px icon sat in a card 900px wide), and at 3px from each edge rather than the 20px it was given, its padding coming on top of its width. In a short listing (a phone in landscape) it ran past the bottom of the screen and cut off the file name. The card now wraps the image, stays 20px inside the listing both ways, and shrinks the image to keep the name in view. * fix(dashboard): let the last grid tile scroll clear of the phone footer On a phone with a home indicator (a web app on the home screen, or Safari with its bars collapsed) the Files footer grows by the bottom safe area. The list makes room for that, but the grid views clear the footer with a fixed 30px margin, so the bottom 17px or so of the last tile stayed behind the footer even scrolled to the end. The grids' margin now grows with the footer; select mode already made room for its bar and is unchanged. * fix(dashboard): don't claim a large shared selection is restricted Sharing more than 50 items skips listing who has access, but General access still said "Restricted: only people with access can open these items" without having looked, though some of them may be open to anyone with the link. Choosing Restricted to close them did nothing either, since it was already the selected option. It now says the link access wasn't checked, and either choice applies to every item. * fix(dashboard): don't let "Add to all" lower access on an item it couldn't list Sharing several items lists who has access to each. When one item's listing failed, the person's row counted that item as one they couldn't reach and offered "Add to all", which granted it at the row's mode: someone who could edit that item was quietly brought down to view. "Add to all" now extends a grant only to items whose access was listed. * fix(dashboard): let a click through as soon as a Files dialog closes The share, properties and publish dialogs fade out for 200ms after they close, and while they did, their invisible backdrop still took the pointer: a click on a row right after Escape or Close was lost. The backdrop now takes clicks only while the dialog is showing. * fix(dashboard): don't make a removal under way look cancelled on Escape In the share dialog, Escape on an open "Remove access" confirmation puts the person's row back. Pressed after Remove was clicked, while the request was still out, it did the same: the row came back as if the removal had been called off, and then vanished when it finished. Escape now leaves a removal that is under way alone. * fix(dashboard): keep Share down while a share is on its way Editing the name in the share dialog while a share was still being made enabled the Share button again, so Enter sent a second share alongside the first; the first one's end then took the spinner off while the second was still running, and the two results raced for the status line. The button now stays down until the share in flight is done. * fix(gui): find the web address of files inside a published folder again Files under a folder served as a website carry a link badge and their address on the site. Working that out read `dir_path` and `address` off each of the user's sites, which the hosting list no longer returns, so no file ever had one. In Files the badges publishing just put up were gone at the next listing. The folder and address are now read from the site's root_dir, where the list carries them. * fix(dashboard): show the link badge in the dark list view In dark mode the list view drew a file's link badge (it is served by a published website) as its black glyph on the dark row, where it couldn't be seen. The grid view already gave it a light disc; the list view now draws the glyph light. * fix(dashboard): let Undo take back a paste of something cut from its menu A row menu's Cut puts the item's bare path on the clipboard, where the Files keys and the selection bar put { path, uid }. Pasting it moved the item but recorded nothing for Undo (and didn't point Back/Forward at a moved folder), so Ctrl/Cmd+Z then undid whatever came before, a rename say. A bare path is now taken as an item like the rest. * fix(gui): undo one action per Ctrl/Cmd+Z The Undo shortcut sits in a handler bound to both keydown and keyup, and ran on each: letting Z go before Ctrl/Cmd, as one usually does, undid a second action straight after the first. In Files that could put back a rename or delete an upload the user never meant to touch. It now runs on the key going down only. * fix(dashboard): re-sort and re-icon a Files row renamed elsewhere A file or folder renamed in another tab, on another device or in a desktop window took its new name in the listing but kept the place its old name sorted to, and the icon of its old type (notes.txt renamed to notes.png still showed a text icon). A renamed folder also left Back/Forward on its old path. Such a row is now handled as a rename made in Files is. * fix(dashboard): stop failed and cancelled Files uploads throwing uncaught An upload from Files reports a failure in its progress window, through the error callback, but the upload's promise also rejected with nobody listening, so every failed or cancelled upload logged an uncaught error. It is now handled there. --- src/gui/src/UI/Dashboard/TabFiles.js | 85 ++++----- src/gui/src/UI/Dashboard/TabFiles.test.js | 119 +++++++++++++ .../src/UI/Dashboard/UIItemPropertiesModal.js | 10 +- .../Dashboard/UIItemPropertiesModal.test.js | 71 ++++++++ src/gui/src/UI/Dashboard/UIPublishDialogs.js | 6 + .../src/UI/Dashboard/UIPublishDialogs.test.js | 8 + src/gui/src/UI/Dashboard/UIShareModal.js | 40 +++-- src/gui/src/UI/Dashboard/UIShareModal.test.js | 161 ++++++++++++++++++ src/gui/src/css/dashboard.css | 78 ++++++++- .../src/helpers.determineWebsiteUrl.test.js | 62 +++++++ src/gui/src/helpers.js | 20 ++- src/gui/src/helpers.renameFile.test.js | 61 +++++++ src/gui/src/helpers/forgetParentListing.js | 34 ++++ src/gui/src/i18n/translations/en.js | 3 + src/gui/src/keyboard.js | 6 +- src/gui/src/keyboard.undo.test.js | 46 +++++ 16 files changed, 748 insertions(+), 62 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/UIItemPropertiesModal.test.js create mode 100644 src/gui/src/UI/Dashboard/UIShareModal.test.js create mode 100644 src/gui/src/helpers.determineWebsiteUrl.test.js create mode 100644 src/gui/src/helpers.renameFile.test.js create mode 100644 src/gui/src/helpers/forgetParentListing.js create mode 100644 src/gui/src/keyboard.undo.test.js 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(``); + 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); + }); +});