mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bcad35692 | ||
|
|
690c1bdd23 | ||
|
|
03470597af | ||
|
|
3102137132 | ||
|
|
b5bc832a40 | ||
|
|
3ce16292ff | ||
|
|
5b61e7eb16 | ||
|
|
dfeb92cedf | ||
|
|
ec3fa7c6bf | ||
|
|
0dd1fde9bb | ||
|
|
c84f6f94af | ||
|
|
07b8c1c2b8 | ||
|
|
47709ec855 | ||
|
|
a6fd52c5d1 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user