Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 5bcad35692 fix(dashboard): double-clicking Save in the photo crop uploads it once
Save closes the crop dialog, but the dialog stays in the page for its
200ms fade-out with its handlers live, so the second click of a
double-click ran Save again and sent the profile picture to the server
twice. Save does nothing once the dialog has closed.
2026-10-23 17:40:03 -07:00
Nariman Jelveh 690c1bdd23 fix(dashboard): a folder that can't be opened becomes the current folder
When a folder failed to list (deleted elsewhere, share revoked, offline),
Files said "This folder couldn't be opened" but stayed on the previous
folder underneath: history had moved on while the breadcrumbs, sidebar
highlight and New Folder/Upload/Paste still pointed at the old folder,
so a new folder or upload landed somewhere other than where the user
seemed to be.

The tab now moves to the folder it was asked for before showing the
message, and creates nothing in it until it lists again.
2026-10-23 17:38:15 -07:00
Nariman Jelveh 03470597af fix(dashboard): Back and Forward follow a folder the user renamed
Open a folder, go back, rename it, then press Forward: history still
held the old path, so Files showed "This folder couldn't be opened", and
the Back/Forward long-press menus listed the old name. A folder renamed
in Files now rebases the history entries in and under it, using the
same path rebase the username-change handler does.
2026-10-23 17:35:24 -07:00
Nariman Jelveh 3102137132 fix(dashboard): the first row click after a marquee released outside Files counts
A marquee sets a flag so the click that ends it doesn't clear the new
selection; the listing's click handlers consume it. Released over the
sidebar or outside the window, that click lands elsewhere, the flag
stayed set, and it swallowed the user's next click on a row. The flag now
lapses once the release's own click has had its turn.
2026-10-23 17:33:49 -07:00
Nariman Jelveh b5bc832a40 fix(dashboard): a Ctrl- or Alt-drop on the Trash trashes the items
Ctrl-dragging a file onto the sidebar Trash (or a Trash breadcrumb)
copied it into the Trash: the original stayed put, and the copy carried
no original path, so it was offered Restore and Restore failed. Alt-drops
made shortcuts there the same way.

The four drop handlers' copy/shortcut/move choice moves into
dropRowsInto, which only ever trashes what lands on the Trash.
2026-10-23 17:30:41 -07:00
Nariman Jelveh 3ce16292ff fix(dashboard): dropping items back into their own folder does nothing
Dropping a selection on the sidebar entry for the folder it came from, or
spring-loading into a folder and coming back to release in the listing,
asked move_items to move every item where it already was, and each one
stopped on a "Cannot move item to its current location" alert in turn.
The breadcrumbs already ignore such a drop; moveDraggedRows now leaves
those rows out too, and a drop with nothing left to move does nothing.
2026-10-23 17:28:26 -07:00
Nariman Jelveh 5b61e7eb16 fix(dashboard): list failed drag-and-drop uploads one per line
The upload-button path was fixed to put each failed item on its own line,
but files dropped onto the listing still joined them with "\n", which
UIAlert's HTML collapses into spaces: every failure ran together on one
line. Both paths now build the message in failedUploadsMessage.
2026-10-23 17:25:49 -07:00
Nariman Jelveh dfeb92cedf fix(dashboard): say a folder is empty when its last item goes elsewhere
When another tab, device or app deleted or moved away the last item in
the folder Files was showing, its row faded out and left a blank pane:
the item.removed and item.moved handlers updated the footer but never put
the "folder is empty" notice up. They now do, for rows that were in the
Files listing.
2026-10-23 17:25:12 -07:00
Nariman Jelveh ec3fa7c6bf fix(dashboard): show an emptied Trash as empty
Emptying the Trash from the Files tab took its rows away but left the
footer on "N items · X MB" and the pane blank, without the empty-folder
notice; the selection bar kept offering actions on rows that were gone.
Emptied from another tab, the listing was cleared with .empty(), which
also dropped the loading overlay and likewise never updated the footer.

Both paths now finish with the empty notice and a fresh footer.
2026-10-23 17:23:27 -07:00
Nariman Jelveh 0dd1fde9bb fix(dashboard): clicking a toast with nothing to open keeps apps on screen
A toast click brings the dashboard forward before acting on the
notification, hiding every app window. Only share notifications lead
anywhere, so clicking an "events subscription ended" toast (or any other
type) to dismiss it minimized the app the user was working in and showed
a dashboard with nothing to look at. Those toasts now just mark the
notification read.
2026-10-23 17:19:13 -07:00
Nariman Jelveh c84f6f94af fix(gui): show app launch refusals in a Puter alert, translated
launchApp reported "You don't have access to …" and "Couldn't open …"
through window.UIAlert, which nothing defines, so both fell through to
the browser's native alert(): a blocking, unstyled, English-only dialog
over the dashboard or desktop. They now use UIAlert directly, with the
text in en.js, and pass the app title unencoded since UIAlert encodes
its message itself.
2026-10-23 17:15:49 -07:00
Nariman Jelveh 07b8c1c2b8 fix(dashboard): act on an app tile once per double-click
A tile acted on every click, so a double-click opened an external app's
site in two browser tabs. Double-clicking an option in the add-app modal
also launched whichever tile sat under the pointer once the modal had
closed (App Center and Recorder together, say). A tile now ignores the
second click of a double-click; the first already launched, focused or
opened what was asked for.
2026-10-23 17:11:22 -07:00
Nariman Jelveh 47709ec855 fix(dashboard): double-clicking "Add an app" no longer opens App Center
The add-app modal opens under the pointer, so the second click of a
double-click on the "+" tile landed on its first option and launched App
Center (or the AI builder, depending on where the tile sat) without the
user choosing anything. An option now ignores the second click of a
double-click.
2026-10-23 17:11:22 -07:00
Nariman Jelveh a6fd52c5d1 fix(dashboard): close the Apps modals and folders only on a press that starts on the backdrop
The uninstall and add-app modals and the open folder closed on any click
whose target was their backdrop. Drag-selecting text in the app request
box and releasing outside the card lands that click on the backdrop, so
the modal and the typed request were thrown away.

The backdrop logic the dashboard dialogs already use moves into
bindBackdropDismiss, and the three Apps overlays use it too.
2026-10-23 17:11:22 -07:00
10 changed files with 514 additions and 181 deletions

No files matched your search

+16 -12
View File
@@ -23,6 +23,7 @@ import launch_app from '../../helpers/launchApp.js';
import revokeAppSessions from '../../helpers/revokeAppSessions.js';
import { begin_dashboard_tile_launch, settle_dashboard_tile_launch } from '../UIWindow.js';
import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import { bindBackdropDismiss } from './dashboardOverlay.js';
import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js';
import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js';
import { appTileLink } from './appLink.js';
@@ -449,9 +450,7 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
};
$overlay.on('click', '.myapps-modal-cancel', () => close());
$overlay.on('click', function (e) {
if ( e.target === $overlay[0] ) close();
});
bindBackdropDismiss($overlay, (el) => el === $overlay[0], () => close());
$(document).on('keydown.uninstall-modal', function (e) {
if ( e.key === 'Escape' ) close();
});
@@ -705,9 +704,7 @@ function showAddAppModal ({ $el_window }) {
};
$overlay.on('click', '.myapps-modal-cancel', close);
$overlay.on('click', function (e) {
if ( e.target === $overlay[0] ) close();
});
bindBackdropDismiss($overlay, (el) => el === $overlay[0], close);
$overlay.on('click', '.myapps-add-back', () => showPane('options'));
$(document).on('keydown.add-app-modal', function (e) {
if ( e.key !== 'Escape' ) return;
@@ -762,7 +759,10 @@ function showAddAppModal ({ $el_window }) {
})();
};
$overlay.on('click', '.myapps-add-option', function () {
$overlay.on('click', '.myapps-add-option', function (e) {
// The second click of a double-click on the "+" tile lands on
// whichever option opened under the pointer; that isn't a choice.
if ( (e.detail ?? 0) > 1 ) return;
const key = this.dataset.addOption;
if ( key === 'request' ) {
showPane('request');
@@ -1015,6 +1015,10 @@ const TabApps = {
self._justDragged = false;
return;
}
// The first click of a double-click already acted. The second
// would open an external app's site a second time, or land on
// the tile under a modal option the first click just chose.
if ( (e.detail ?? 0) > 1 ) return;
// The tail tile isn't an app — it asks how you'd like to get one.
// Answered in every mode: it is never part of the arrangement
// reorder mode exists to edit.
@@ -1769,11 +1773,11 @@ const TabApps = {
const self = this;
// Anywhere outside the card — including the strip of scrim beside it.
$overlay.on('click', function (e) {
if ( e.target === this || $(e.target).hasClass('myapps-group-backdrop') ) {
self._closeGroup($el_window);
}
});
bindBackdropDismiss(
$overlay,
(el) => el === $overlay[0] || $(el).hasClass('myapps-group-backdrop'),
() => self._closeGroup($el_window),
);
// A press inside the overlay must not let initgui's global mousedown
// hand focus (and the z-index) back to the window underneath it.
+166 -132
View File
@@ -47,6 +47,41 @@ import parse_item_metadata from '../../helpers/parseItemMetadata.js';
const { html_encode, SelectionArea } = window;
/**
* `p` with the folder `from` (itself, or anything under it) moved to `to`;
* any other path, or a non-string, comes back as it was.
*
* @param {string} p
* @param {string} from
* @param {string} to
* @returns {string}
*/
export function rebasePath (p, from, to) {
if ( typeof p !== 'string' ) return p;
return p === from || p.startsWith(`${from}/`) ? to + p.slice(from.length) : p;
}
/**
* The alert for an upload some of whose items failed, one item per line,
* or null when the error names none. Unencoded: UIAlert encodes the message
* itself, apart from <p>, <strong> and <br>.
*
* @param {{ failedItems?: Array<{ path?: string, name?: string, requestIndex?: number, message?: string }> }} err
* @returns {string|null}
*/
export function failedUploadsMessage (err) {
const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : [];
if ( failedItems.length === 0 ) return null;
const lines = failedItems.map((item) => {
const failedPath = item?.path || item?.name || `item ${item?.requestIndex ?? '?'}`;
const failedMessage = typeof item?.message === 'string' && item.message.length > 0
? ` (${item.message})`
: '';
return `- ${failedPath}${failedMessage}`;
});
return [i18n('upload_some_failed', [], false), ...lines].join('<br>');
}
/**
* TabFiles - File browser tab component for the Puter Dashboard.
*
@@ -414,27 +449,7 @@ const TabFiles = {
if ( sourceItem ) itemsToMove.push(sourceItem);
});
// Perform operation based on modifier keys
if ( event.ctrlKey ) {
// Copy
await window.copy_items(itemsToMove, targetPath);
}
else if ( event.altKey && window.feature_flags?.create_shortcut ) {
// Create shortcuts
for ( const item of itemsToMove ) {
const itemPath = $(item).attr('data-path');
const itemName = itemPath.split('/').pop();
const isDir = $(item).attr('data-is_dir') === '1';
const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid');
const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath;
await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath);
}
}
else {
// Move
await _this.moveDraggedRows(itemsToMove, targetPath);
}
await _this.dropRowsInto(itemsToMove, targetPath, event);
},
over: function (_event, ui) {
@@ -643,11 +658,8 @@ const TabFiles = {
window.addEventListener('username-changed', ({ detail }) => {
const oldHome = `/${detail.oldUsername}`;
const newHome = `/${detail.newUsername}`;
const rebase = (p) => (typeof p === 'string' && (p === oldHome || p.startsWith(`${oldHome}/`)))
? newHome + p.slice(oldHome.length)
: p;
const history = window.dashboard_nav_history;
for ( let i = 0; i < history.length; i++ ) history[i] = rebase(history[i]);
const rebase = (p) => rebasePath(p, oldHome, newHome);
this.rebaseNavHistory(oldHome, newHome);
window.clipboard = (window.clipboard ?? []).map(item => (typeof item === 'string'
? rebase(item)
: { ...item, path: rebase(item.path) }));
@@ -1473,18 +1485,8 @@ const TabFiles = {
},
// error
error: async function (err) {
const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : [];
if ( failedItems.length > 0 ) {
const failedSummary = failedItems.map((item) => {
const failedPath = item?.path || item?.name || `item ${item?.requestIndex ?? '?'}`;
const failedMessage = typeof item?.message === 'string' && item.message.length > 0
? ` (${item.message})`
: '';
return `- ${failedPath}${failedMessage}`;
}).join('<br>');
// UIAlert encodes the message itself, apart from <p>, <strong> and <br>.
UIAlert(`${i18n('upload_some_failed', [], false)}<br>${failedSummary}`);
}
const failedMessage = failedUploadsMessage(err);
if ( failedMessage ) UIAlert(failedMessage);
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
// remove from active_uploads
delete window.active_uploads[opid];
@@ -1696,16 +1698,60 @@ const TabFiles = {
},
/**
* Moves dragged rows into a folder, leaving out the ones the user can't
* move (see restructurableRows), which the server would refuse with an
* alert apiece.
* Points Back/Forward history entries in or under `oldPath` at `newPath`,
* after the folder was renamed or the home folder moved.
*
* @param {string} oldPath
* @param {string} newPath
* @returns {void}
*/
rebaseNavHistory (oldPath, newPath) {
const history = window.dashboard_nav_history ?? [];
for ( let i = 0; i < history.length; i++ ) history[i] = rebasePath(history[i], oldPath, newPath);
},
/**
* Carries out a drop of rows on a folder: Ctrl copies, Alt makes
* shortcuts (where enabled), anything else moves. The Trash only takes
* items by trashing them; a copy or shortcut put there has nowhere to be
* restored to.
*
* @param {HTMLElement[]} rows
* @param {string} targetPath
* @param {{ ctrlKey?: boolean, altKey?: boolean }} modifiers - The drop event's
* @returns {Promise<void>}
*/
async dropRowsInto (rows, targetPath, { ctrlKey = false, altKey = false } = {}) {
const intoTrash = targetPath === window.trash_path;
if ( ctrlKey && ! intoTrash ) {
await window.copy_items(rows, targetPath);
} else if ( altKey && window.feature_flags?.create_shortcut && ! intoTrash ) {
for ( const row of rows ) {
const itemPath = $(row).attr('data-path');
const itemName = itemPath.split('/').pop();
const isDir = $(row).attr('data-is_dir') === '1';
const shortcutTo = $(row).attr('data-shortcut_to') || $(row).attr('data-uid');
const shortcutToPath = $(row).attr('data-shortcut_to_path') || itemPath;
await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath);
}
} else {
await this.moveDraggedRows(rows, targetPath);
}
},
/**
* Moves dragged rows into a folder, leaving out the ones already in it (a
* drop back where they came from does nothing, as on the breadcrumbs)
* and the ones the user can't move (see restructurableRows). move_items
* would stop on an alert apiece for either.
*
* @param {HTMLElement[]} rows
* @param {string} targetPath
* @returns {Promise<void>}
*/
async moveDraggedRows (rows, targetPath) {
const movable = await this.restructurableRows(rows);
const elsewhere = Array.from(rows).filter((row) => path.dirname($(row).attr('data-path')) !== targetPath);
const movable = await this.restructurableRows(elsewhere);
if ( movable.length > 0 ) await window.move_items(movable, targetPath);
},
@@ -2018,13 +2064,15 @@ const TabFiles = {
/**
* Whether items can be created, uploaded or pasted into a folder. Not the
* Trash or a folder inside it (Restore needs metadata only Delete writes),
* not the Shared view (a query, not a directory), and not `/`.
* not the Shared view (a query, not a directory), not `/`, and not a
* folder Files just failed to list.
*
* @param {string} folderPath
* @returns {boolean}
*/
acceptsNewItems (folderPath) {
return !! folderPath
&& folderPath !== this.unreadablePath
&& folderPath !== '/'
&& folderPath !== window.trash_path
&& ! folderPath.startsWith(`${window.trash_path}/`)
@@ -2051,7 +2099,7 @@ const TabFiles = {
const emptyTrashBtn = $(`<button class="path-action-btn empty-trash-btn" title="${i18n('empty_trash')}">${icons.trash}</button>`);
$pathActions.append(emptyTrashBtn);
emptyTrashBtn.on('click', () => {
window.empty_trash();
this.emptyTrash();
});
}
$pathActions.find('.empty-trash-btn').show();
@@ -2227,6 +2275,29 @@ const TabFiles = {
if ( ! inserted ) $newRow.insertAfter($existingRows.last());
},
/**
* Empties the Trash (after the user confirms). The rows go as the delete
* lands; the notice and footer have to follow while Trash is in view.
*
* @returns {Promise<void>}
*/
async emptyTrash () {
await window.empty_trash();
this.syncEmptiedTrash();
},
/**
* Brings an open Trash listing in line with a Trash that is now empty,
* here or in another tab.
*
* @returns {void}
*/
syncEmptiedTrash () {
if ( this.currentPath !== window.trash_path ) return;
this.renderEmptyPlaceholderIfNeeded();
this.updateFooterStats();
},
/**
* Puts the "No files in this directory." notice back when the listing has
* no rows left. The incremental add paths drop it before inserting their
@@ -2498,38 +2569,19 @@ const TabFiles = {
// and live under their owners' paths.
const isSharedView = target === window.shared_path;
let directoryContents;
// readdir rejects on any backend error (permission, deleted dir,
// network). The listing then says so, but the tab still moves to the
// folder it was asked for: left on the previous one, New Folder,
// Upload and Paste would act on a folder that is no longer on screen.
let readFailed = false;
try {
directoryContents = isSharedView
? await this.listSharedView()
: await window.puter.fs.readdir(readdirArg);
} catch ( err ) {
// readdir rejects on any backend error (permission, deleted dir,
// network). Without this, renderingDirectory would stay true and
// the guard above would block all further navigation.
console.error('Failed to read directory:', err);
// Replace whatever the pane shows (blank after navigation, the
// stale rows during a same-directory refresh) with a message
// rather than leaving it unexplained.
clearListing();
this.$el_window.find('.files-tab .files').html(`<div style="
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
text-align: center;
padding: 0 16px;
">${i18n('folder_open_failed')}</div>`);
// The list was emptied above; without this the footer keeps the
// previous directory's item count over a pane with zero rows.
this.updateFooterStats();
this.hideSpinner();
this.renderingDirectory = false;
return;
readFailed = true;
directoryContents = [];
}
if ( ! directoryContents ) {
this.hideSpinner();
@@ -2560,6 +2612,8 @@ const TabFiles = {
}
this.updateSidebarSelection();
// Nothing can be created in a folder that couldn't be read.
this.unreadablePath = readFailed ? this.currentPath : null;
// Filter out hidden files/folders and AppData in home directory.
// Trash is reachable from the sidebar; don't list it as a row too.
@@ -2646,21 +2700,7 @@ const TabFiles = {
if ( sourceItem ) itemsToMove.push(sourceItem);
});
// Perform operation based on modifier keys
if ( event.ctrlKey ) {
await window.copy_items(itemsToMove, targetPath);
} else if ( event.altKey && window.feature_flags?.create_shortcut ) {
for ( const item of itemsToMove ) {
const itemPath = $(item).attr('data-path');
const itemName = itemPath.split('/').pop();
const isDir = $(item).attr('data-is_dir') === '1';
const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid');
const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath;
await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath);
}
} else {
await _this.moveDraggedRows(itemsToMove, targetPath);
}
await _this.dropRowsInto(itemsToMove, targetPath, event);
},
over: function (_event, ui) {
@@ -2686,6 +2726,33 @@ const TabFiles = {
});
});
if ( readFailed ) {
// Replace whatever the pane shows (blank after navigation, the
// stale rows during a same-directory refresh) with a message
// rather than leaving it unexplained.
clearListing();
$files.html(`<div style="
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
text-align: center;
padding: 0 16px;
">${i18n('folder_open_failed')}</div>`);
// The list was emptied above; without this the footer keeps the
// previous directory's item count over a pane with zero rows.
this.updateFooterStats();
this.updateNavButtonStates();
this.hideSpinner();
this.renderingDirectory = false;
return;
}
// Same-directory refresh: the stale rows stayed on screen during the
// fetch; swap them for the fresh listing now that it's here. The
// clear and the appends below run without an intervening task, so
@@ -3299,6 +3366,9 @@ const TabFiles = {
// fit the name to this listing instead. A renamed entry may
// also have a new type (extension) and sort position.
_this.fitItemNames($(el_item_name));
// Back and Forward should reach a renamed folder (and what's
// inside it) under its new name.
if ( fsentry?.is_dir ) _this.rebaseNavHistory(old_path, path.join(path.dirname(old_path), fsentry.name ?? new_name));
if ( ! fsentry || ! document.body.contains(el_item) ) return;
$(el_item).attr('data-type', fsentry.type ?? '');
_this.insertAtSortedPosition($(el_item), fsentry);
@@ -3536,22 +3606,7 @@ const TabFiles = {
itemsToMove.push(this);
});
if ( event.ctrlKey ) {
window.copy_items(itemsToMove, _this.currentPath);
}
else if ( event.altKey && window.feature_flags?.create_shortcut ) {
for ( const item of itemsToMove ) {
const itemPath = $(item).attr('data-path');
const itemName = itemPath.split('/').pop();
const isDir = $(item).attr('data-is_dir') === '1';
const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid');
const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath;
window.create_shortcut(itemName, isDir, _this.currentPath, null, shortcutTo, shortcutToPath);
}
}
else {
_this.moveDraggedRows(itemsToMove, _this.currentPath);
}
void _this.dropRowsInto(itemsToMove, _this.currentPath, event);
} else {
// Dropped outside file list → cancel, navigate back
_this.navigateBackFromSpringLoad();
@@ -3609,25 +3664,7 @@ const TabFiles = {
const targetPath = $(el_item).attr('data-path');
if ( event.ctrlKey ) {
// Copy
await window.copy_items(itemsToMove, targetPath);
}
else if ( event.altKey && window.feature_flags?.create_shortcut ) {
// Create shortcuts
for ( const item of itemsToMove ) {
const itemPath = $(item).attr('data-path');
const itemName = itemPath.split('/').pop();
const isDir = $(item).attr('data-is_dir') === '1';
const shortcutTo = $(item).attr('data-shortcut_to') || $(item).attr('data-uid');
const shortcutToPath = $(item).attr('data-shortcut_to_path') || itemPath;
await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath);
}
}
else {
await _this.moveDraggedRows(itemsToMove, targetPath);
}
await _this.dropRowsInto(itemsToMove, targetPath, event);
},
over: function (_event, ui) {
@@ -4788,7 +4825,7 @@ const TabFiles = {
items.push({
html: i18n('empty_trash'),
onClick: function () {
window.empty_trash();
_this.emptyTrash();
},
});
}
@@ -5007,6 +5044,12 @@ const TabFiles = {
selection_area = null;
// Flag to prevent the click handler from clearing selection
_this.rubberBandSelectionJustEnded = true;
// That click comes straight after the release, and only when
// the release is inside the listing; past this task the flag
// would swallow the user's next, real click on a row.
setTimeout(() => {
_this.rubberBandSelectionJustEnded = false;
}, 0);
}
// Re-enable pointer events on selection actions bar
_this.$el_window.find('.files-selection-actions').removeClass('rubberband-active');
@@ -5173,17 +5216,8 @@ const TabFiles = {
_this.selectUploadedRows(files);
},
error: async function (err) {
const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : [];
if ( failedItems.length > 0 ) {
const failedSummary = failedItems.map((item) => {
const failedPath = item?.path || item?.name || `item ${item?.requestIndex ?? '?'}`;
const failedMessage = typeof item?.message === 'string' && item.message.length > 0
? ` (${item.message})`
: '';
return `- ${failedPath}${failedMessage}`;
}).join('\n');
UIAlert(`${i18n('upload_some_failed', [], false)}\n${failedSummary}`);
}
const failedMessage = failedUploadsMessage(err);
if ( failedMessage ) UIAlert(failedMessage);
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
delete window.active_uploads[opid];
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
+237
View File
@@ -0,0 +1,237 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
// The tab's window and dialog plumbing isn't what these tests exercise.
vi.mock('../UIWindow.js', () => ({ default: vi.fn() }));
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.def = globalThis.def ?? (value => value);
globalThis.use = globalThis.use ?? (() => class {});
globalThis.html_encode = value => jQuery('<div>').text(value).html();
globalThis.i18n = globalThis.i18n ?? (key => key);
window.icons = {};
const { default: TabFiles, failedUploadsMessage, rebasePath } = await import('./TabFiles.js');
const TRASH = '/alice/Trash';
/** A Files tab listing `rows` in `currentPath`, with its footer. */
const mountListing = (rows, currentPath = TRASH) => {
document.body.innerHTML = `
<div class="window">
<div class="files-tab">
<div class="files">${rows.map(name => `<div class="row item" data-path="${currentPath}/${name}" data-size="10"></div>`).join('')}</div>
<div class="files-footer">
<span class="files-footer-item-count"></span>
<span class="files-footer-separator"></span>
<span class="files-footer-selected-items"></span>
</div>
<div class="files-selection-actions"></div>
</div>
</div>`;
TabFiles.$el_window = $('.window');
TabFiles.currentPath = currentPath;
TabFiles.updateFooterStats();
};
const footer = () => $('.files-footer-item-count').text();
describe('TabFiles emptying the Trash', () => {
beforeEach(() => {
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
});
it('shows the empty notice and a zero count once the Trash is emptied here', async () => {
mountListing(['a.txt', 'b.txt']);
expect(footer()).toContain('2 items');
// What a confirmed empty_trash does to the listing.
window.empty_trash = vi.fn(async () => {
$('.item').remove();
});
await TabFiles.emptyTrash();
expect(window.empty_trash).toHaveBeenCalledTimes(1);
expect($('.files-empty-notice').length).toBe(1);
expect(footer()).toContain('0 items');
});
it('leaves a Trash the user chose not to empty as it was', async () => {
mountListing(['a.txt']);
window.empty_trash = vi.fn(async () => {});
await TabFiles.emptyTrash();
expect($('.item').length).toBe(1);
expect($('.files-empty-notice').length).toBe(0);
expect(footer()).toContain('1 item');
});
it('touches nothing when the listing is not the Trash', () => {
mountListing([], '/alice/Documents');
$('.files-footer-item-count').text('stale');
TabFiles.syncEmptiedTrash();
expect($('.files-empty-notice').length).toBe(0);
expect(footer()).toBe('stale');
});
});
describe('failedUploadsMessage', () => {
it('puts each failed item on its own line', () => {
const message = failedUploadsMessage({ failedItems: [
{ path: '/alice/a.txt', message: 'Too large' },
{ name: 'b.txt' },
{ requestIndex: 3 },
] });
// UIAlert keeps <br> and encodes the rest; a "\n" would collapse into a space.
expect(message.split('<br>').slice(1)).toEqual([
'- /alice/a.txt (Too large)',
'- b.txt',
'- item 3',
]);
expect(message).not.toContain('\n');
});
it('has nothing to say when no item is named', () => {
expect(failedUploadsMessage({ message: 'offline' })).toBeNull();
expect(failedUploadsMessage(undefined)).toBeNull();
});
});
describe('TabFiles.moveDraggedRows', () => {
const rowAt = (rowPath) => $(`<div class="row item" data-path="${rowPath}"></div>`)[0];
beforeEach(() => {
window.user = { username: 'alice' };
window.move_items = vi.fn(async () => {});
});
it('leaves out rows dropped back into the folder they are in', async () => {
const stays = rowAt('/alice/Documents/a.txt');
const moves = rowAt('/alice/Pictures/b.png');
await TabFiles.moveDraggedRows([stays, moves], '/alice/Documents');
expect(window.move_items).toHaveBeenCalledWith([moves], '/alice/Documents');
});
it('moves nothing when every row is already there', async () => {
await TabFiles.moveDraggedRows([rowAt('/alice/Documents/a.txt'), rowAt('/alice/Documents/b.txt')], '/alice/Documents');
expect(window.move_items).not.toHaveBeenCalled();
});
});
describe('TabFiles.dropRowsInto', () => {
const rowAt = (rowPath) => $(`<div class="row item" data-path="${rowPath}" data-uid="u-${rowPath}"></div>`)[0];
beforeEach(() => {
window.user = { username: 'alice' };
window.trash_path = TRASH;
window.feature_flags = { create_shortcut: true };
window.move_items = vi.fn(async () => {});
window.copy_items = vi.fn(async () => {});
window.create_shortcut = vi.fn(async () => {});
});
it('copies on Ctrl, makes shortcuts on Alt, moves otherwise', async () => {
const row = rowAt('/alice/Documents/a.txt');
await TabFiles.dropRowsInto([row], '/alice/Pictures', { ctrlKey: true });
expect(window.copy_items).toHaveBeenCalledWith([row], '/alice/Pictures');
await TabFiles.dropRowsInto([row], '/alice/Pictures', { altKey: true });
expect(window.create_shortcut).toHaveBeenCalledWith('a.txt', false, '/alice/Pictures', null, 'u-/alice/Documents/a.txt', '/alice/Documents/a.txt');
await TabFiles.dropRowsInto([row], '/alice/Pictures', {});
expect(window.move_items).toHaveBeenCalledWith([row], '/alice/Pictures');
});
it('trashes what is dropped on the Trash whatever the modifier', async () => {
const row = rowAt('/alice/Documents/a.txt');
await TabFiles.dropRowsInto([row], TRASH, { ctrlKey: true });
await TabFiles.dropRowsInto([row], TRASH, { altKey: true });
expect(window.copy_items).not.toHaveBeenCalled();
expect(window.create_shortcut).not.toHaveBeenCalled();
expect(window.move_items).toHaveBeenCalledTimes(2);
expect(window.move_items).toHaveBeenCalledWith([row], TRASH);
});
});
describe('rebasePath', () => {
it('moves the folder itself and anything under it', () => {
expect(rebasePath('/alice/Proj', '/alice/Proj', '/alice/Proj2')).toBe('/alice/Proj2');
expect(rebasePath('/alice/Proj/src/a', '/alice/Proj', '/alice/Proj2')).toBe('/alice/Proj2/src/a');
});
it('leaves siblings that only share a prefix, and non-strings, alone', () => {
expect(rebasePath('/alice/Projects', '/alice/Proj', '/alice/Proj2')).toBe('/alice/Projects');
expect(rebasePath(null, '/alice/Proj', '/alice/Proj2')).toBe(null);
});
});
describe('TabFiles.rebaseNavHistory', () => {
it('points Back/Forward entries at a renamed folder', () => {
window.dashboard_nav_history = ['/alice', '/alice/Proj', '/alice/Proj/src', '/alice/Projects'];
TabFiles.rebaseNavHistory('/alice/Proj', '/alice/Proj2');
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/Proj2', '/alice/Proj2/src', '/alice/Projects']);
});
});
describe('TabFiles opening a folder that fails to list', () => {
beforeEach(() => {
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
window.puter = { fs: { readdir: vi.fn(async () => {
throw new Error('not found');
}) } };
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('moves to that folder and creates nothing in it', async () => {
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
await TabFiles.renderDirectory('/alice/Gone');
expect(TabFiles.currentPath).toBe('/alice/Gone');
expect($('.files').text()).toContain("couldn't be opened");
expect(TabFiles.acceptsNewItems('/alice/Gone')).toBe(false);
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
expect(TabFiles.renderingDirectory).toBe(false);
});
});
+16 -7
View File
@@ -319,10 +319,12 @@ async function UIDashboard (options) {
// Update sidebar Trash icon
window.update_trash_icons(msg.is_empty);
// If currently viewing trash and it's empty, clear the file list
// Emptied elsewhere while this tab shows Trash: take its rows away.
// Only the rows: the loading overlay lives in the listing too.
const dashboard = window.dashboard_object;
if ( msg.is_empty && dashboard && dashboard.currentPath === window.trash_path ) {
$('.files-tab .files').empty();
$('.files-tab .files .item').remove();
dashboard.syncEmptiedTrash?.();
}
});
@@ -333,6 +335,16 @@ async function UIDashboard (options) {
// approach but adapted for Dashboard's list-view structure.
// =========================================================================
// A row taken away from elsewhere: keep the footer's count and size in
// step, and say the folder is empty when the Files listing just lost its
// last row (rows can also sit in a file picker window).
const remove_row = (el_row) => {
const from_listing = $(el_row).closest('.files-tab .files').length > 0;
$(el_row).remove();
if ( from_listing ) window.dashboard_object?.renderEmptyPlaceholderIfNeeded?.();
window.dashboard_object?.updateFooterStats?.();
};
window.socket.on('item.moved', async (resp) => {
if ( resp.original_client_socket_id === window.socket.id ) return;
@@ -353,8 +365,7 @@ async function UIDashboard (options) {
$candidates.filter(function () {
return $(this).attr('data-path') === old_path;
}).fadeOut(150, function () {
$(this).remove();
window.dashboard_object?.updateFooterStats?.();
remove_row(this);
});
}
@@ -377,9 +388,7 @@ async function UIDashboard (options) {
return $(this).attr('data-path') === item.path;
});
$rows.fadeOut(150, function () {
$(this).remove();
// Keep the footer item count / total size in sync with the removal.
window.dashboard_object?.updateFooterStats?.();
remove_row(this);
});
});
@@ -473,7 +473,12 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
icon: toastIconFor(notification),
value: notification,
timeout: TOAST_TIMEOUT_MS,
click: () => void reveal_dashboard().then(() => actOn(entry)),
// With nothing to open on the dashboard, a click only dismisses;
// it mustn't send the apps the user is working in away.
click: () => {
if ( notificationTarget(notification) === null ) void markRead(entry.uid);
else void reveal_dashboard().then(() => actOn(entry));
},
// The ✕ is a dismissal; timing out is not, so `close` alone acks.
close: () => void markRead(entry.uid),
});
@@ -343,7 +343,9 @@ export default function UIProfilePictureCropModal ({ picture, onSave, $container
// -- Save --
$save.on('click', function () {
if ( ! state ) return;
// The overlay fades out for a moment after closing; a second click on
// Save in that time (a double-click) must not save the picture again.
if ( ! state || closed ) return;
const canvas = document.createElement('canvas');
canvas.width = OUTPUT_SIZE;
canvas.height = OUTPUT_SIZE;
+22 -9
View File
@@ -37,6 +37,26 @@ export function isOverlayOnTop (el_overlay) {
return ! (el_top && ! el_overlay.contains(el_top));
}
/**
* Dismiss an overlay on a press and release on its backdrop. The press must
* start there: a drag that starts in the card (a text selection) and ends
* outside registers its click on the overlay. The second press of a
* double-click on the control that opened it doesn't count either.
*
* @param {jQuery} $overlay
* @param {(el: Element) => boolean} isBackdrop Whether a press on `el` is a press on the backdrop.
* @param {() => void} dismiss
*/
export function bindBackdropDismiss ($overlay, isBackdrop, dismiss) {
let backdrop_pressed = false;
$overlay.on('mousedown', function (e) {
backdrop_pressed = isBackdrop(e.target) && (e.detail ?? 0) < 2;
});
$overlay.on('click', function (e) {
if ( backdrop_pressed && isBackdrop(e.target) ) dismiss();
});
}
/**
* Mounts `.dashboard-dialog-overlay` around the given card and wires the
* reveal transition, dismissal (Escape, `.dashboard-dialog-close`, a press
@@ -106,19 +126,12 @@ export default function mountDashboardOverlay ({ html, $container, returnFocusTo
$overlay.on('click', '.dashboard-dialog-close', () => dismiss('close'));
// Backdrop close goes by where the press started, so a drag that
// starts in the card and ends outside does not dismiss it. The second
// press of a double-click on the button that opened it doesn't count.
let backdrop_pressed = false;
$overlay.on('mousedown', function (e) {
backdrop_pressed = e.target === el_overlay && (e.detail ?? 0) < 2;
bindBackdropDismiss($overlay, (el) => el === el_overlay, () => dismiss('backdrop'));
$overlay.on('mousedown', function () {
// A press inside the overlay must not let initgui's global mousedown
// hand focus (and the z-index) back to the window underneath it.
window.mouseover_window = undefined;
});
$overlay.on('click', function (e) {
if ( e.target === el_overlay && backdrop_pressed ) dismiss('backdrop');
});
$(document).on(`keydown${ns}`, function (e) {
// Escape belongs to whatever is on top: a window or modal opened over
@@ -24,7 +24,7 @@ globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
const { default: mountDashboardOverlay } = await import('./dashboardOverlay.js');
const { default: mountDashboardOverlay, bindBackdropDismiss } = await import('./dashboardOverlay.js');
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
const card = '<div class="dashboard-dialog" tabindex="-1"><button class="dashboard-dialog-close">x</button><input class="inside"></div>';
@@ -151,3 +151,36 @@ describe('mountDashboardOverlay', () => {
expect(window.visualViewport.removeEventListener).toHaveBeenCalledWith('resize', listeners.resize);
});
});
describe('bindBackdropDismiss', () => {
const mount = () => {
document.body.innerHTML = '<div class="scrim"><div class="card"><textarea></textarea></div></div>';
const $overlay = $('.scrim');
const dismiss = vi.fn();
bindBackdropDismiss($overlay, (el) => el === $overlay[0], dismiss);
return { el_scrim: $overlay[0], el_card: $('.card')[0], dismiss };
};
const fire = (el, type, detail = 1) => el.dispatchEvent(new MouseEvent(type, { bubbles: true, detail }));
it('dismisses on a press and release on the backdrop', () => {
const { el_scrim, dismiss } = mount();
fire(el_scrim, 'mousedown');
fire(el_scrim, 'click');
expect(dismiss).toHaveBeenCalledTimes(1);
});
it('keeps the overlay when a drag from the card ends on the backdrop', () => {
const { el_scrim, el_card, dismiss } = mount();
fire(el_card, 'mousedown');
// The browser fires the click on the nearest common ancestor.
fire(el_scrim, 'click');
expect(dismiss).not.toHaveBeenCalled();
});
it('ignores the second press of the double-click that opened it', () => {
const { el_scrim, dismiss } = mount();
fire(el_scrim, 'mousedown', 2);
fire(el_scrim, 'click', 2);
expect(dismiss).not.toHaveBeenCalled();
});
});
+10 -18
View File
@@ -20,6 +20,7 @@
import path from '../lib/path.js';
import { PROCESS_IPC_ATTACHED, PROCESS_RUNNING, PortalProcess, PseudoProcess } from '../definitions.js';
import UIWindow from '../UI/UIWindow.js';
import UIAlert from '../UI/UIAlert.js';
import { starts_hidden } from './startsHidden.js';
import { append_signed_item_params } from './appendSignedItemParams.js';
import { expand_home_path } from './expandHomePath.js';
@@ -238,15 +239,11 @@ const launch_app = async (options) => {
return fallbackLaunchOutcome ?? { launchResult: redirectedLaunchResult };
}
const deniedAppTitle = app_info.title ?? app_info.name ?? options.name ?? 'this app';
const safeDeniedAppTitle = window.html_encode
? window.html_encode(deniedAppTitle)
: deniedAppTitle;
if ( typeof window.UIAlert === 'function' ) {
await window.UIAlert(`You don't have access to ${safeDeniedAppTitle}.`);
} else {
window.alert(`You don't have access to ${deniedAppTitle}.`);
}
// Unencoded: UIAlert encodes its message itself.
const deniedAppTitle = app_info.title ?? app_info.name ?? options.name;
await UIAlert(deniedAppTitle
? i18n('app_launch_no_access', { app: deniedAppTitle }, false)
: i18n('app_launch_no_access_unnamed', [], false));
const deniedLaunchResult = {
launched: false,
@@ -548,15 +545,10 @@ const launch_app = async (options) => {
tokenResult,
});
const tokenErrorAppTitle = app_info?.title ?? app_info?.name ?? options?.name ?? 'this app';
const safeTokenErrorAppTitle = window.html_encode
? window.html_encode(tokenErrorAppTitle)
: tokenErrorAppTitle;
if ( typeof window.UIAlert === 'function' ) {
await window.UIAlert(`Couldn't open ${safeTokenErrorAppTitle}. Please try again.`);
} else {
window.alert(`Couldn't open ${tokenErrorAppTitle}. Please try again.`);
}
const tokenErrorAppTitle = app_info?.title ?? app_info?.name ?? options?.name;
await UIAlert(tokenErrorAppTitle
? i18n('app_launch_failed_retry', { app: tokenErrorAppTitle }, false)
: i18n('app_launch_failed_retry_unnamed', [], false));
const tokenFailureLaunchResult = {
launched: false,
+4
View File
@@ -60,6 +60,10 @@ const en = {
app_group_rename: 'Rename',
app_group_tile_aria: '%%, folder of %% apps',
app_group_ungroup: 'Ungroup',
app_launch_failed_retry: "Couldn't open {{app}}. Please try again.",
app_launch_failed_retry_unnamed: "Couldn't open this app. Please try again.",
app_launch_no_access: "You don't have access to {{app}}.",
app_launch_no_access_unnamed: "You don't have access to this app.",
apply: 'Apply',
ascending: 'Ascending',
associated_websites: 'Associated Websites',