mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64b9f74edf | ||
|
|
c5e67a0da3 | ||
|
|
6319ef2ffe | ||
|
|
e4b04ee0e6 | ||
|
|
f36ac83aa8 | ||
|
|
ffe50592f5 | ||
|
|
d604735a09 | ||
|
|
1a807f089c | ||
|
|
aba28d58bf | ||
|
|
175e945f51 | ||
|
|
5d995ee12e | ||
|
|
156dfb6b02 |
No files matched your search
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* ContextMenuModal
|
||||
*
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
// @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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
|
||||
@@ -241,6 +241,9 @@ const TabAccount = {
|
||||
$('.profile-image').css('background-image', `url(${ html_encode(url) })`);
|
||||
$('.profile-image').toggleClass('profile-image-has-picture', !! picture);
|
||||
}
|
||||
// A save started after another one owns the avatar and the hint; the
|
||||
// earlier one's answer must not put its picture (or a failure) back.
|
||||
let avatar_save_seq = 0;
|
||||
function onAvatarPicked (e) {
|
||||
$el_window.off('file_opened', onAvatarPicked);
|
||||
const selectedFile = Array.isArray(e.detail) ? e.detail[0] : e.detail;
|
||||
@@ -251,6 +254,7 @@ const TabAccount = {
|
||||
$container: $el_window,
|
||||
returnFocusTo: $el_window.find('.dashboard-section-account .change-profile-picture').get(0),
|
||||
onSave: async (dataUrl) => {
|
||||
const seq = ++avatar_save_seq;
|
||||
const previous = window.user?.profile?.picture ?? null;
|
||||
const $remove = $el_window.find('.dashboard-section-account .dashboard-profile-remove-picture');
|
||||
const $hint = $el_window.find('.dashboard-section-account .dashboard-profile-hint');
|
||||
@@ -259,6 +263,7 @@ const TabAccount = {
|
||||
$remove.prop('hidden', false).prop('disabled', true);
|
||||
// update_profile resolves with no profile when the request failed.
|
||||
const profile = await update_profile(window.user.username, { picture: dataUrl });
|
||||
if ( seq !== avatar_save_seq ) return;
|
||||
$remove.prop('disabled', false);
|
||||
if ( ! profile ) {
|
||||
showProfilePicture(previous);
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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 UIContextMenu from '../UIContextMenu.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import launch_app from '../../helpers/launchApp.js';
|
||||
@@ -34,25 +53,6 @@ const APP_NAMES_NO_UNINSTALL = new Set([
|
||||
'app-center',
|
||||
]);
|
||||
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// -- Add-an-app tile --
|
||||
/** Puter's app marketplace — the add-an-app tile's route to installing one. */
|
||||
const APP_CENTER_APP_NAME = 'app-center';
|
||||
@@ -371,10 +371,12 @@ function buildPagerHtml (items, layout, instant) {
|
||||
}
|
||||
h += '</div>';
|
||||
|
||||
h += `<button class="myapps-pager-arrow myapps-pager-arrow-prev" aria-label="${i18n('previous_page')}">`;
|
||||
// Mouse-only, like their hover reveal: the dots page by keyboard, and an
|
||||
// arrow that is invisible until hovered must not be a Tab stop.
|
||||
h += `<button class="myapps-pager-arrow myapps-pager-arrow-prev" tabindex="-1" aria-label="${i18n('previous_page')}">`;
|
||||
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
|
||||
h += '</button>';
|
||||
h += `<button class="myapps-pager-arrow myapps-pager-arrow-next" aria-label="${i18n('next_page')}">`;
|
||||
h += `<button class="myapps-pager-arrow myapps-pager-arrow-next" tabindex="-1" aria-label="${i18n('next_page')}">`;
|
||||
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||
h += '</button>';
|
||||
|
||||
@@ -773,6 +775,12 @@ function showAddAppModal ({ $el_window }) {
|
||||
showPane('options');
|
||||
}
|
||||
|
||||
/** An app id minted from a uuid (`app-<uuid>`), as website shortcuts are named. */
|
||||
const GENERATED_APP_ID = /^app-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/** Longest the grid waits on page one's icons before showing anyway. */
|
||||
const ICON_REVEAL_DEADLINE_MS = 1500;
|
||||
|
||||
function revealWhenLoaded ($container) {
|
||||
const $pager = $container.find('.myapps-pager-loading');
|
||||
if ( $pager.length === 0 ) return;
|
||||
@@ -787,11 +795,15 @@ function revealWhenLoaded ($container) {
|
||||
|
||||
let loaded = 0;
|
||||
const total = imgs.length;
|
||||
const reveal = () => $pager.removeClass('myapps-pager-loading');
|
||||
// One icon host that never answers must not keep the whole grid hidden.
|
||||
const deadline = setTimeout(reveal, ICON_REVEAL_DEADLINE_MS);
|
||||
|
||||
function onDone () {
|
||||
loaded++;
|
||||
if ( loaded >= total ) {
|
||||
$pager.removeClass('myapps-pager-loading');
|
||||
clearTimeout(deadline);
|
||||
reveal();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1071,7 +1083,7 @@ const TabApps = {
|
||||
html: i18n('app_group_rename'),
|
||||
// Renaming happens in the folder's own header —
|
||||
// one place to edit the name, seen in context.
|
||||
onClick: () => self._openGroup($el_window, groupId, tile, { editName: true }),
|
||||
onClick: () => self._openGroup($el_window, groupId, tile, { rename: true }),
|
||||
},
|
||||
'-',
|
||||
{
|
||||
@@ -1218,6 +1230,9 @@ const TabApps = {
|
||||
if ( $('.window').not($el_window[0]).filter(':visible').length ) return;
|
||||
// An open context menu owns arrows and Enter (keyboard.js).
|
||||
if ( $('.context-menu').length ) return;
|
||||
// So does a dialog over the grid: the notifications panel, an
|
||||
// account dialog, or the touch action sheet.
|
||||
if ( $('.dashboard-notifications-panel.open, .dashboard-dialog-overlay, .context-menu-modal-backdrop').length ) return;
|
||||
|
||||
const $panelGrid = $el_window.find('.myapps-group-panel-grid');
|
||||
const pageTiles = ($panelGrid.length
|
||||
@@ -1354,14 +1369,15 @@ const TabApps = {
|
||||
if ( query ) {
|
||||
// Match the same values the tiles expose as data-app-name,
|
||||
// data-app-title (the displayed title, e.g. the hostname for
|
||||
// website shortcuts), and data-app-uid.
|
||||
// website shortcuts), and data-app-uid. Generated ids match only
|
||||
// whole: as substrings, "app" or a hex letter would hit them all.
|
||||
const matches = (value) => (GENERATED_APP_ID.test(value) ? value === query : value.includes(query));
|
||||
list = list.filter(app => {
|
||||
const title = resolveTileDisplay(app).title.toLowerCase();
|
||||
const rawTitle = (app.title || '').toLowerCase();
|
||||
const name = (app.name || '').toLowerCase();
|
||||
const uid = String(app.uid || app.uuid || '').toLowerCase();
|
||||
return title.includes(query) || rawTitle.includes(query)
|
||||
|| name.includes(query) || uid.includes(query);
|
||||
return matches(title) || matches(rawTitle) || matches(name) || uid === query;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1636,7 +1652,7 @@ const TabApps = {
|
||||
// this grid, moves with the dashboard window, and closes on Escape, on a
|
||||
// click outside, or on the tile that opened it.
|
||||
|
||||
_openGroup ($el_window, groupId, fromTile, { editName = false } = {}) {
|
||||
_openGroup ($el_window, groupId, fromTile, { editName = false, rename = false } = {}) {
|
||||
const group = findGroupById(this._groups, groupId);
|
||||
if ( ! group ) return;
|
||||
// Clicking the open folder's own tile again closes it, like iOS.
|
||||
@@ -1677,10 +1693,11 @@ const TabApps = {
|
||||
this._bindGroupPanel($el_window, $overlay);
|
||||
this._animateGroupPanelOpen($el_window, fromTile);
|
||||
|
||||
if ( editName && ! isTouchPrimaryDevice() ) {
|
||||
if ( rename || (editName && ! isTouchPrimaryDevice()) ) {
|
||||
// Naming is the first thing a new folder wants; on touch the same
|
||||
// gesture would throw an on-screen keyboard over the folder the
|
||||
// user just made, so there they tap the name when ready.
|
||||
// user just made, so there they tap the name when ready. An
|
||||
// explicit Rename asked for the keyboard, though.
|
||||
const input = $overlay.find('.myapps-group-name')[0];
|
||||
input.focus();
|
||||
input.select();
|
||||
@@ -1874,6 +1891,9 @@ const TabApps = {
|
||||
const $name = $overlay.find('.myapps-group-name');
|
||||
// Never overwrite a name being typed.
|
||||
if ( ! $name.is(':focus') ) $name.val(groupLabel(group));
|
||||
// Named and counted like its tile: current name, apps actually shown.
|
||||
$overlay.find('.myapps-group-panel')
|
||||
.attr('aria-label', i18n('app_group_tile_aria', [groupLabel(group), apps.length], false));
|
||||
const $grid = $overlay.find('.myapps-group-panel-grid');
|
||||
// Only as wide as it needs to be, within the cap the stylesheet sets
|
||||
// for the viewport — a folder of three in a four-wide card would sit
|
||||
@@ -2815,6 +2835,11 @@ const TabApps = {
|
||||
}
|
||||
}
|
||||
|
||||
// The drag moved the folder's tile nodes; a cancelled or no-op drop
|
||||
// leaves the state, and so the cached panel markup, unchanged, which
|
||||
// would skip the rebuild and keep the dragged-to order on screen.
|
||||
if ( d.panelGrid ) d.panelGrid.__myappsPanelHtml = null;
|
||||
|
||||
// Rebuild so pages rebalance to exactly perPage; skip the load fade.
|
||||
this.renderApps(d.$el_window, { preservePage: true, instant: true });
|
||||
|
||||
@@ -3261,8 +3286,11 @@ const TabApps = {
|
||||
|
||||
this._hasCustomOrder = Array.isArray(effectiveOrder) && effectiveOrder.length > 0;
|
||||
|
||||
// A refresh of a grid already on screen keeps the user's page and
|
||||
// skips the first load's fade-in.
|
||||
const refreshing = Array.isArray(this._apps) && $container.find('.myapps-page').length > 0;
|
||||
this._apps = reconcileAppOrder(merged, effectiveOrder);
|
||||
this.renderApps($el_window);
|
||||
this.renderApps($el_window, refreshing ? { preservePage: true, instant: true } : undefined);
|
||||
} catch (e) {
|
||||
console.error('Failed to load installed apps:', e);
|
||||
// Only show the failure placeholder when nothing has loaded yet; a
|
||||
|
||||
@@ -174,9 +174,18 @@ const TabFiles = {
|
||||
const meta = file.metadata ? JSON.parse(file.metadata) : null;
|
||||
if ( meta && meta.original_name ) displayName = meta.original_name;
|
||||
} catch { /* keep raw name */ }
|
||||
// The row's handlers act on the entry renderItem bound them to;
|
||||
// keep it current so a remote rename or move doesn't leave them
|
||||
// opening, copying or sharing the old path.
|
||||
const fsentry = $row.data('fsentry');
|
||||
if ( fsentry ) {
|
||||
for ( const key of ['name', 'path', 'type', 'size', 'modified', 'metadata'] ) {
|
||||
if ( file[key] !== undefined ) fsentry[key] = file[key];
|
||||
}
|
||||
}
|
||||
if ( displayName ) {
|
||||
$row.attr('data-name', displayName);
|
||||
$row.find('.item-name').text(displayName);
|
||||
_this.fitItemNames($row.find('.item-name'));
|
||||
// Never write over an open editor — a just-created row has one
|
||||
// focused already, and mkdir's own item.added lands here right
|
||||
// after, which would wipe out whatever the user has typed.
|
||||
@@ -286,7 +295,12 @@ const TabFiles = {
|
||||
// Preference reads are best-effort (the tab renders with the defaults
|
||||
// rather than not at all) and independent of each other, so they go
|
||||
// out together: awaited one by one they cost four round-trips before
|
||||
// the first listing could even start.
|
||||
// the first listing could even start. A back/forward navigation can
|
||||
// render a folder while they are out, so the defaults go in first.
|
||||
this.currentView = 'list';
|
||||
this.sortColumn = 'name';
|
||||
this.sortDirection = 'asc';
|
||||
this.columnWidths = { name: null, size: 100, modified: 120 };
|
||||
const [savedView, savedSortColumn, savedSortDirection, savedWidths] = await Promise.all([
|
||||
puter.kv.get('view_mode').catch(() => null),
|
||||
puter.kv.get('sort_column').catch(() => null),
|
||||
@@ -302,11 +316,6 @@ const TabFiles = {
|
||||
|
||||
// Column widths state (for resizing). A corrupt saved value falls back
|
||||
// to the defaults instead of taking the whole tab down with it.
|
||||
this.columnWidths = {
|
||||
name: null, // auto/flex
|
||||
size: 100,
|
||||
modified: 120,
|
||||
};
|
||||
try {
|
||||
const parsedWidths = savedWidths ? JSON.parse(savedWidths) : null;
|
||||
if ( parsedWidths && typeof parsedWidths === 'object' ) {
|
||||
@@ -376,14 +385,16 @@ const TabFiles = {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.helper.data('dropped', true);
|
||||
|
||||
// Get target folder path from the element itself. Using the
|
||||
// element's own data-path covers folders (Public, Home) that
|
||||
// aren't keyed in window.user.directories.
|
||||
const targetPath = folderElement.getAttribute('data-path');
|
||||
|
||||
if ( ! targetPath ) return;
|
||||
// The Shared view is a query rather than a folder;
|
||||
// dwelling on it still opens it, to drop into a folder inside.
|
||||
if ( ! targetPath || targetPath === window.shared_path ) return;
|
||||
|
||||
ui.helper.data('dropped', true);
|
||||
|
||||
// Collect all items to move
|
||||
const itemsToMove = [ui.draggable[0]];
|
||||
@@ -490,8 +501,8 @@ const TabFiles = {
|
||||
|
||||
const folderPath = folderElement.getAttribute('data-path');
|
||||
|
||||
// Don't allow drop on trash
|
||||
if ( folderPath === window.trash_path ) {
|
||||
// Don't allow drop on trash or the Shared view
|
||||
if ( folderPath === window.trash_path || folderPath === window.shared_path ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -512,8 +523,8 @@ const TabFiles = {
|
||||
|
||||
const folderPath = folderElement.getAttribute('data-path');
|
||||
|
||||
// Block uploads to trash
|
||||
if ( folderPath === window.trash_path ) {
|
||||
// Block uploads to trash and the Shared view
|
||||
if ( folderPath === window.trash_path || folderPath === window.shared_path ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -669,8 +680,12 @@ const TabFiles = {
|
||||
if ( this.currentPath && window.is_dashboard_mode ) {
|
||||
this.updateDashboardUrl(this.currentPath);
|
||||
}
|
||||
// Crumbs rendered while the tab was hidden had no width to scroll.
|
||||
// Crumbs rendered while the tab was hidden had no width to scroll,
|
||||
// and grid tiles had none to fit their names to.
|
||||
this.revealCurrentCrumb();
|
||||
if ( this.isGridView?.() ) {
|
||||
this.fitItemNames(this.$el_window.find('.files-tab .files .row:not(.header) .item-name'));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -711,7 +726,7 @@ const TabFiles = {
|
||||
// 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').length > 0 ) return;
|
||||
if ( $('.share-modal-overlay, .item-props-overlay, .dashboard-notifications-panel.open, .context-menu-modal-backdrop').length > 0 ) return;
|
||||
|
||||
const focused_el = document.activeElement;
|
||||
|
||||
@@ -899,8 +914,11 @@ const TabFiles = {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Cutting is a move: rows that can't be moved (see the
|
||||
// Delete key above) would only fail on paste.
|
||||
const rows = await _this.restructurableRows($selectedRows.toArray());
|
||||
// Delete key above) would only fail on paste. Trashed
|
||||
// items leave the Trash through Restore, which puts their
|
||||
// original name back; a plain move would not.
|
||||
const rows = (await _this.restructurableRows($selectedRows.toArray()))
|
||||
.filter(row => ! ($(row).attr('data-path') || '').startsWith(`${window.trash_path}/`));
|
||||
if ( rows.length > 0 ) {
|
||||
window.clipboard = [];
|
||||
window.clipboard_op = 'move';
|
||||
@@ -920,8 +938,12 @@ const TabFiles = {
|
||||
if ( window.clipboard.length > 0 && _this.currentPath ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Don't allow paste in Trash unless it's a move operation
|
||||
if ( _this.currentPath.startsWith(window.trash_path) && window.clipboard_op !== 'move' ) {
|
||||
// Same rule as the Paste menu item: a move into the Trash
|
||||
// would skip the metadata Restore needs (Delete trashes),
|
||||
// and the Shared view is a query, not a folder.
|
||||
if ( _this.currentPath === window.trash_path
|
||||
|| _this.currentPath.startsWith(`${window.trash_path}/`)
|
||||
|| _this.currentPath === window.shared_path ) {
|
||||
return false;
|
||||
}
|
||||
// After the paste completes, refresh and highlight the
|
||||
@@ -1122,7 +1144,10 @@ const TabFiles = {
|
||||
// Check if it's an image file
|
||||
const extension = fileName.split('.').pop().toLowerCase();
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
|
||||
if ( ! imageExtensions.includes(extension) ) {
|
||||
if ( ! imageExtensions.includes(extension) || $row.attr('data-is_dir') === '1' ) {
|
||||
// Moving the selection onto a non-image must not leave the
|
||||
// previous image up under the wrong selection.
|
||||
if ( this.previewOpen ) this.closeImagePreview();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1164,8 +1189,13 @@ const TabFiles = {
|
||||
// Close on click outside the popover. Remove any prior handler first so
|
||||
// repeated calls (image arrow-navigation) don't stack duplicate handlers.
|
||||
const _this = this;
|
||||
// A plain click on a row is left to the row's own handler, which
|
||||
// switches the preview to it.
|
||||
$(document).off('click.imagepreview').on('click.imagepreview', (e) => {
|
||||
if ( ! $(e.target).closest('.image-preview-popover').length ) {
|
||||
if ( $(e.target).closest('.image-preview-popover').length ) return;
|
||||
const plainRowClick = ! (e.ctrlKey || e.metaKey || e.shiftKey)
|
||||
&& $(e.target).closest('.files-tab .files .row:not(.header)').length;
|
||||
if ( ! plainRowClick ) {
|
||||
_this.closeImagePreview();
|
||||
}
|
||||
});
|
||||
@@ -1590,6 +1620,8 @@ const TabFiles = {
|
||||
$actions.find('.download-btn').hide();
|
||||
$actions.find('.cut-btn').hide();
|
||||
$actions.find('.copy-btn').hide();
|
||||
// A non-Trash selection may have hidden it pending its lookup.
|
||||
$actions.find('.delete-btn').show();
|
||||
// Change delete label to "Delete Permanently"
|
||||
$actions.find('.delete-btn span').text(i18n('delete_permanently') || 'Delete Permanently');
|
||||
} else {
|
||||
@@ -1684,6 +1716,22 @@ const TabFiles = {
|
||||
|
||||
this.applyColumnWidths();
|
||||
|
||||
// Widths depend on the room the listing has: re-fit when the window or
|
||||
// sidebar changes it (at most once a frame), not only on navigation.
|
||||
const filesEl = this.$el_window.find('.files-tab .files')[0];
|
||||
if ( filesEl && typeof ResizeObserver !== 'undefined' ) {
|
||||
let lastWidth = filesEl.clientWidth;
|
||||
let frame = null;
|
||||
new ResizeObserver(() => {
|
||||
if ( filesEl.clientWidth === lastWidth || ! filesEl.clientWidth ) return;
|
||||
lastWidth = filesEl.clientWidth;
|
||||
frame ??= requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
_this.applyColumnWidths();
|
||||
});
|
||||
}).observe(filesEl);
|
||||
}
|
||||
|
||||
$columns.find('.col-resize-handle').on('mousedown', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1704,7 +1752,11 @@ const TabFiles = {
|
||||
}
|
||||
|
||||
const startWidth = $targetColumn.outerWidth();
|
||||
_this.adoptAppliedColumnWidths();
|
||||
|
||||
// Relayout at most once a frame: each pass restyles every row and
|
||||
// re-truncates every name.
|
||||
let frame = null;
|
||||
$(document).on('mousemove.colresize', function (moveEvent) {
|
||||
const diff = moveEvent.pageX - startX;
|
||||
let newWidth = Math.max(60, startWidth + diff); // Minimum width of 60px
|
||||
@@ -1714,12 +1766,20 @@ const TabFiles = {
|
||||
newWidth = Math.max(100, newWidth);
|
||||
}
|
||||
|
||||
_this.columnWidths[column] = newWidth;
|
||||
_this.applyColumnWidths();
|
||||
_this.columnWidths[column] = _this.clampColumnWidth(column, newWidth);
|
||||
frame ??= requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
_this.applyColumnWidths();
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on('mouseup.colresize', function () {
|
||||
$(document).off('mousemove.colresize mouseup.colresize');
|
||||
if ( frame !== null ) {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = null;
|
||||
_this.applyColumnWidths();
|
||||
}
|
||||
puter.kv.set('column_widths', JSON.stringify(_this.columnWidths))
|
||||
.catch(err => console.warn('Could not save column_widths:', err));
|
||||
});
|
||||
@@ -1733,6 +1793,7 @@ const TabFiles = {
|
||||
const column = $(this).attr('data-resize');
|
||||
const $filesTab = _this.$el_window.find('.files-tab');
|
||||
const padding = 16; // 8px padding on each side
|
||||
_this.adoptAppliedColumnWidths();
|
||||
let maxWidth = 60; // Minimum width
|
||||
|
||||
if ( column === 'name' ) {
|
||||
@@ -1763,7 +1824,7 @@ const TabFiles = {
|
||||
}
|
||||
|
||||
// Apply the new width
|
||||
_this.columnWidths[column] = Math.ceil(maxWidth);
|
||||
_this.columnWidths[column] = _this.clampColumnWidth(column, Math.ceil(maxWidth));
|
||||
_this.applyColumnWidths();
|
||||
puter.kv.set('column_widths', JSON.stringify(_this.columnWidths))
|
||||
.catch(err => console.warn('Could not save column_widths:', err));
|
||||
@@ -1782,28 +1843,21 @@ const TabFiles = {
|
||||
const $container = $filesTab.find('.files');
|
||||
const containerWidth = $container.width();
|
||||
|
||||
// Fixed widths: icon(24) + spacers(4*3) + more(20) = 56px, plus some margin
|
||||
const fixedWidth = 56 + 20;
|
||||
|
||||
let nameWidth = this.columnWidths.name;
|
||||
let sizeWidth = this.columnWidths.size || 100;
|
||||
let modifiedWidth = this.columnWidths.modified || 120;
|
||||
|
||||
// Check if total width exceeds container width
|
||||
// Columns that don't fit lay out at the defaults, but the saved
|
||||
// widths stay so they come back once the listing is wide enough.
|
||||
if ( containerWidth > 0 && nameWidth ) {
|
||||
const totalWidth = fixedWidth + nameWidth + sizeWidth + modifiedWidth;
|
||||
const totalWidth = COLUMN_FIXED_WIDTH + nameWidth + sizeWidth + modifiedWidth;
|
||||
if ( totalWidth > containerWidth ) {
|
||||
// Reset to defaults - columns don't fit
|
||||
this.columnWidths = {
|
||||
name: null,
|
||||
size: 100,
|
||||
modified: 120,
|
||||
};
|
||||
nameWidth = null;
|
||||
sizeWidth = 100;
|
||||
modifiedWidth = 120;
|
||||
}
|
||||
}
|
||||
this.appliedColumnWidths = { name: nameWidth, size: sizeWidth, modified: modifiedWidth };
|
||||
|
||||
const nameCol = nameWidth ? `${nameWidth}px` : 'auto';
|
||||
const gridTemplate = `24px ${nameCol} 4px ${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`;
|
||||
@@ -1814,6 +1868,38 @@ const TabFiles = {
|
||||
this.fitItemNames($filesTab.find('.files .row:not(.header) .item-name'));
|
||||
},
|
||||
|
||||
/**
|
||||
* A resize starts from the widths on screen. Saved widths too wide for
|
||||
* the listing lay out at the defaults (see applyColumnWidths), and
|
||||
* resizing from those would fight the layout the user is looking at.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
adoptAppliedColumnWidths () {
|
||||
if ( this.appliedColumnWidths ) this.columnWidths = { ...this.appliedColumnWidths };
|
||||
},
|
||||
|
||||
/**
|
||||
* `width` held between the column's minimum and the widest it can be
|
||||
* without the columns overflowing the listing, given the other columns.
|
||||
*
|
||||
* @param {'name'|'size'|'modified'} column
|
||||
* @param {number} width
|
||||
* @returns {number}
|
||||
*/
|
||||
clampColumnWidth (column, width) {
|
||||
const min = column === 'name' ? 100 : 60;
|
||||
const containerWidth = this.$el_window.find('.files-tab .files').width();
|
||||
if ( ! containerWidth ) return Math.max(min, width);
|
||||
const widths = {
|
||||
name: this.columnWidths.name || 100,
|
||||
size: this.columnWidths.size || 100,
|
||||
modified: this.columnWidths.modified || 120,
|
||||
};
|
||||
const others = Object.keys(widths).filter(c => c !== column).reduce((sum, c) => sum + widths[c], 0);
|
||||
return Math.max(min, Math.min(width, containerWidth - COLUMN_FIXED_WIDTH - others));
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes each row's name into its `.item-name`, middle-truncated to fit
|
||||
* the name column (list view with a set width) or the tile (grid view).
|
||||
@@ -1822,10 +1908,14 @@ const TabFiles = {
|
||||
* @returns {void}
|
||||
*/
|
||||
fitItemNames ($names) {
|
||||
const nameWidth = this.columnWidths.name;
|
||||
const nameWidth = this.appliedColumnWidths ? this.appliedColumnWidths.name : this.columnWidths.name;
|
||||
const isList = this.currentView === 'list';
|
||||
if ( ! isList && ! this.isGridView() ) return;
|
||||
const names = $names.toArray();
|
||||
if ( ! names.length ) return;
|
||||
// Measure in the font the names render in (the compact grid's is smaller).
|
||||
const style = getComputedStyle(names[0]);
|
||||
const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
||||
// Grid tiles size the room: read every width before writing any
|
||||
// text, or each write forces a layout for the next read.
|
||||
const widths = isList ? null : names.map(el => $(el).width() || 156);
|
||||
@@ -1833,10 +1923,10 @@ const TabFiles = {
|
||||
const fullName = el.closest('.row')?.getAttribute('data-name');
|
||||
if ( ! fullName ) return;
|
||||
if ( ! isList ) {
|
||||
el.textContent = truncateFilenameToWidth(fullName, widths[i]);
|
||||
el.textContent = truncateFilenameToWidth(fullName, widths[i], font);
|
||||
} else if ( nameWidth ) {
|
||||
// 8px padding on each side
|
||||
el.textContent = truncateFilenameToWidth(fullName, nameWidth - 16);
|
||||
el.textContent = truncateFilenameToWidth(fullName, nameWidth - 16, font);
|
||||
} else {
|
||||
// Auto-width column: the full name, clipped by CSS
|
||||
el.textContent = fullName;
|
||||
@@ -2001,6 +2091,7 @@ const TabFiles = {
|
||||
const newModified = file.modified || 0;
|
||||
const sortColumn = this.sortColumn;
|
||||
const sortDirection = this.sortDirection;
|
||||
let inserted = false;
|
||||
|
||||
$existingRows.each(function () {
|
||||
const $existing = $(this);
|
||||
@@ -2009,6 +2100,7 @@ const TabFiles = {
|
||||
// Folders always come before files
|
||||
if ( newIsDir && !existingIsDir ) {
|
||||
$newRow.insertBefore($existing);
|
||||
inserted = true;
|
||||
return false;
|
||||
}
|
||||
if ( !newIsDir && existingIsDir ) {
|
||||
@@ -2035,11 +2127,14 @@ const TabFiles = {
|
||||
|
||||
if ( comparison < 0 ) {
|
||||
$newRow.insertBefore($existing);
|
||||
inserted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// If not inserted, it belongs at the end (already there from append)
|
||||
// It belongs at the end. A just-appended row already is, but a renamed
|
||||
// one is still wherever its old name sorted.
|
||||
if ( ! inserted ) $newRow.insertAfter($existingRows.last());
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2401,7 +2496,7 @@ const TabFiles = {
|
||||
|
||||
$(dirnameElement).on('contextmenu taphold', async (e) => {
|
||||
// Dismiss taphold on non-touch devices
|
||||
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) {
|
||||
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet && !isTouchPrimaryDevice() ) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -2434,8 +2529,10 @@ const TabFiles = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't drop on current directory
|
||||
if ( targetPath === _this.currentPath ) {
|
||||
// Don't drop on current directory, or on the Shared view,
|
||||
// which is a query rather than a folder (dwelling on it
|
||||
// still opens it, to drop into a folder inside).
|
||||
if ( targetPath === _this.currentPath || targetPath === window.shared_path ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2493,7 +2590,19 @@ const TabFiles = {
|
||||
// fetch; swap them for the fresh listing now that it's here. The
|
||||
// clear and the appends below run without an intervening task, so
|
||||
// the browser paints the old and new listing back to back.
|
||||
// An open rename editor is carried across so the typing survives.
|
||||
let openEditor = null;
|
||||
if ( isSameDirRefresh ) {
|
||||
const editor = $files.find('.item-name-editor-active:visible')[0];
|
||||
if ( editor ) {
|
||||
openEditor = {
|
||||
uid: $(editor).closest('.row').attr('data-uid'),
|
||||
value: editor.value,
|
||||
start: editor.selectionStart,
|
||||
end: editor.selectionEnd,
|
||||
direction: editor.selectionDirection,
|
||||
};
|
||||
}
|
||||
clearListing();
|
||||
}
|
||||
|
||||
@@ -2540,6 +2649,24 @@ const TabFiles = {
|
||||
|
||||
this.hideSpinner();
|
||||
this.renderingDirectory = false;
|
||||
if ( openEditor ) this.reopenNameEditor(openEditor);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reopens a rename editor that a same-directory refresh tore down, with
|
||||
* the text and caret the user left in it.
|
||||
*
|
||||
* @param {{ uid: string, value: string, start: number, end: number, direction: string }} state
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async reopenNameEditor ({ uid, value, start, end, direction }) {
|
||||
const row = Array.from(this.$el_window.find('.files-tab .files .row')).find(r => r.getAttribute('data-uid') === uid);
|
||||
if ( ! row ) return;
|
||||
await window.activate_item_name_editor(row);
|
||||
const editor = row.querySelector('.item-name-editor');
|
||||
if ( ! editor || ! $(editor).hasClass('item-name-editor-active') ) return;
|
||||
editor.value = value;
|
||||
editor.setSelectionRange(start, end, direction);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2686,6 +2813,7 @@ const TabFiles = {
|
||||
row.setAttribute("data-modified", file.modified);
|
||||
row.setAttribute("data-associated_app_name", file.associated_app?.name ?? '');
|
||||
row.setAttribute("data-path", file.path);
|
||||
$(row).data('fsentry', file);
|
||||
row.innerHTML = `
|
||||
<div class="item-checkbox"><span class="checkbox-icon"></span></div>
|
||||
<div class="item-icon">
|
||||
@@ -3004,6 +3132,9 @@ const TabFiles = {
|
||||
return;
|
||||
}
|
||||
if ( isPending() ) return;
|
||||
// The preview floats above app windows; opening the item ends it,
|
||||
// as Enter does.
|
||||
if ( _this.previewOpen ) _this.closeImagePreview();
|
||||
if ( isFolder === "1" ) {
|
||||
_this.pushNavHistory(file.path);
|
||||
_this.renderDirectory(file.path);
|
||||
@@ -3107,7 +3238,7 @@ const TabFiles = {
|
||||
e.preventDefault();
|
||||
rename_cancelled = true;
|
||||
$(el_item_name_editor).hide();
|
||||
$(el_item_name_editor).val(file.name);
|
||||
$(el_item_name_editor).val($(el_item).attr('data-name'));
|
||||
$(el_item_name).show();
|
||||
}
|
||||
});
|
||||
@@ -3149,7 +3280,7 @@ const TabFiles = {
|
||||
// selection, not just the row the long-press landed on.
|
||||
const menuTitle = isMultiSelection
|
||||
? i18n('items_count_other', { count: selectedRows.length }, false)
|
||||
: file.name;
|
||||
: $(el_item).attr('data-name');
|
||||
|
||||
// The touch sheet is for touch interactions and touch-first
|
||||
// devices. A mouse right-click gets the desktop menu at the
|
||||
@@ -4131,7 +4262,7 @@ const TabFiles = {
|
||||
modal.show(items, targetRect, {
|
||||
title: isMultiSelection
|
||||
? i18n('items_count_other', { count: selectedRows.length }, false)
|
||||
: file.name,
|
||||
: $(rowElement).attr('data-name'),
|
||||
});
|
||||
} else {
|
||||
// The '⋯' click doesn't select the row, so without this class the
|
||||
@@ -4267,7 +4398,7 @@ const TabFiles = {
|
||||
const mayRestructure = (await _this.restructurableRows(selectedRows)).length === selectedRows.length;
|
||||
|
||||
// Cut
|
||||
if ( mayRestructure ) {
|
||||
if ( mayRestructure && ! anyTrashed ) {
|
||||
items.push({
|
||||
html: `${i18n('cut')}`,
|
||||
onClick: function () {
|
||||
@@ -4605,7 +4736,7 @@ const TabFiles = {
|
||||
selectionContainerClass: 'selection-area-container',
|
||||
selectionAreaClass: 'hidden-selection-area',
|
||||
container: `#${containerId}`,
|
||||
selectables: [`#${containerId} .row`],
|
||||
selectables: [`#${containerId} .row.item`],
|
||||
startareas: [`#${containerId}`],
|
||||
boundaries: [`#${containerId}`],
|
||||
behaviour: {
|
||||
@@ -4665,11 +4796,11 @@ const TabFiles = {
|
||||
return true;
|
||||
});
|
||||
|
||||
selection.on('start', ({ store, event }) => {
|
||||
selection.on('start', ({ event }) => {
|
||||
if ( !event.ctrlKey && !event.metaKey ) {
|
||||
for ( const el of store.stored ) {
|
||||
el.classList.remove('selected');
|
||||
}
|
||||
// store.stored only knows earlier marquee picks; rows picked
|
||||
// by click, Ctrl+A or the arrow keys must be dropped too.
|
||||
filesContainer.querySelectorAll('.row.selected').forEach(el => el.classList.remove('selected'));
|
||||
selection.clearSelection();
|
||||
}
|
||||
|
||||
@@ -5145,6 +5276,10 @@ const TabFiles = {
|
||||
|
||||
// Canvas context for measuring text width (reused for performance)
|
||||
let measureContext = null;
|
||||
let measureFont = null;
|
||||
|
||||
/** List columns other than name/size/modified: icon(24) + spacers(4*3) + more(20), plus margin. */
|
||||
const COLUMN_FIXED_WIDTH = 56 + 20;
|
||||
|
||||
/**
|
||||
* Measures the pixel width of text using a canvas context.
|
||||
@@ -5158,7 +5293,11 @@ function measureTextWidth (text, font = '500 13px system-ui, -apple-system, sans
|
||||
const canvas = document.createElement('canvas');
|
||||
measureContext = canvas.getContext('2d');
|
||||
}
|
||||
measureContext.font = font;
|
||||
// Assigning font re-parses it; truncation measures many times per name.
|
||||
if ( measureFont !== font ) {
|
||||
measureContext.font = font;
|
||||
measureFont = font;
|
||||
}
|
||||
return measureContext.measureText(text).width;
|
||||
}
|
||||
|
||||
|
||||
@@ -367,7 +367,7 @@ const TabHome = {
|
||||
if (targetLink && targetLink !== '') {
|
||||
window.open(targetLink, '_blank', 'noopener,noreferrer');
|
||||
} else if (appName) {
|
||||
window.open(`/app/${appName}`, '_blank', 'noopener,noreferrer');
|
||||
window.open(`/app/${encodeURIComponent(appName)}`, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -415,8 +415,11 @@ const TabHome = {
|
||||
// Always refetch: gating on an empty list froze "Recently used" for the
|
||||
// whole session (apps launched after load never appeared). loadUsageData
|
||||
// refreshes on the same activate/focus triggers, so this stays in step.
|
||||
// Only the newest request may write: an older one answering last
|
||||
// would put a stale list back.
|
||||
const seq = (this._recentAppsSeq = (this._recentAppsSeq ?? 0) + 1);
|
||||
try {
|
||||
window.launch_apps = await $.ajax({
|
||||
const launchApps = await $.ajax({
|
||||
url: `${window.api_origin}/get-launch-apps?icon_size=64`,
|
||||
type: 'GET',
|
||||
async: true,
|
||||
@@ -425,9 +428,12 @@ const TabHome = {
|
||||
Authorization: `Bearer ${window.auth_token}`,
|
||||
},
|
||||
});
|
||||
if (seq !== this._recentAppsSeq) return;
|
||||
window.launch_apps = launchApps;
|
||||
} catch (e) {
|
||||
console.error('Failed to load launch apps:', e);
|
||||
}
|
||||
if (seq !== this._recentAppsSeq) return;
|
||||
$el_window
|
||||
.find('.bento-recent-apps-container')
|
||||
.html(buildRecentAppsHTML());
|
||||
|
||||
@@ -371,12 +371,15 @@ async function update_usage_details ($el_window) {
|
||||
let general_used = res.used;
|
||||
|
||||
let host_usage_percentage = 0;
|
||||
$('#storage-puter-used-w').toggle(!! res.host_used);
|
||||
if ( res.host_used ) {
|
||||
$('#storage-puter-used').html(window.byte_format(res.used));
|
||||
$('#storage-puter-used-w').show();
|
||||
|
||||
general_used = res.host_used;
|
||||
host_usage_percentage = ((res.host_used - res.used) / res.capacity * 100).toFixed(0);
|
||||
// The host segment sits beside Puter's own, so together they
|
||||
// can't run past a full bar.
|
||||
const host_share = res.capacity ? (res.host_used - res.used) / res.capacity * 100 : 0;
|
||||
host_usage_percentage = Math.max(0, Math.min(100 - Number(usage_percentage), host_share)).toFixed(0);
|
||||
}
|
||||
|
||||
$('#storage-used').html(window.byte_format(general_used));
|
||||
|
||||
@@ -313,7 +313,8 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-token-copy">${i18n('copy')}</button>`;
|
||||
h += '</div>';
|
||||
h += `<p class="dashboard-form-hint">${i18n('token_manage_hint')} <button type="button" class="dashboard-token-sessions">${i18n('ui_manage_sessions')}</button>.</p>`;
|
||||
dialog.finish({ html: h });
|
||||
// The token is shown only once; only Done, ✕ or Escape put it away.
|
||||
dialog.finish({ html: h, keepOnBackdrop: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -321,14 +322,25 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
|
||||
dialog.$dialog.on('click', '.dashboard-token-copy', function () {
|
||||
const $btn = $(this);
|
||||
const $value = dialog.$dialog.find('.dashboard-token-value');
|
||||
navigator.clipboard.writeText(String($value.val())).then(() => {
|
||||
const copied = () => {
|
||||
$btn.text(t('token_copied'));
|
||||
clearTimeout(copied_timer);
|
||||
copied_timer = setTimeout(() => $btn.text(t('copy')), TOKEN_COPIED_RESET_MS);
|
||||
}, () => {
|
||||
// No clipboard access: select it so the user can copy it by hand.
|
||||
};
|
||||
// No clipboard access (an insecure origin has no navigator.clipboard
|
||||
// at all): select it, try the legacy copy, and leave it selected so
|
||||
// the user can copy it by hand.
|
||||
const by_hand = () => {
|
||||
$value.get(0)?.select();
|
||||
});
|
||||
try {
|
||||
if ( document.execCommand('copy') ) copied();
|
||||
} catch { /* stays selected */ }
|
||||
};
|
||||
if ( navigator.clipboard?.writeText ) {
|
||||
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
|
||||
} else {
|
||||
by_hand();
|
||||
}
|
||||
});
|
||||
dialog.$dialog.on('focus', '.dashboard-token-value', function () {
|
||||
this.select();
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
// @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';
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ async function UIDashboard (options) {
|
||||
}
|
||||
const isActive = tab.id === initialTabId ? ' active' : '';
|
||||
const tabHref = `#${tab.id}`;
|
||||
h += `<a class="dashboard-sidebar-item allow-native-ctxmenu${isActive}" href="${tabHref}" data-section="${tab.id}" data-tooltip="${html_encode(tab.label)}">`;
|
||||
h += `<a class="dashboard-sidebar-item allow-native-ctxmenu${isActive}" href="${tabHref}" data-section="${tab.id}" data-tooltip="${html_encode(tab.label)}"${isActive ? ' aria-current="page"' : ''}>`;
|
||||
h += tab.icon;
|
||||
h += tab.label;
|
||||
h += '</a>';
|
||||
@@ -151,9 +151,10 @@ async function UIDashboard (options) {
|
||||
|
||||
// User options button at bottom
|
||||
h += '<div class="dashboard-user-options">';
|
||||
h += '<button type="button" class="dashboard-user-btn" aria-haspopup="menu">';
|
||||
// Named by the username even while the collapsed sidebar hides it.
|
||||
h += '<button type="button" class="dashboard-user-btn" aria-haspopup="menu" aria-labelledby="dashboard-user-name">';
|
||||
h += `<div class="dashboard-user-avatar profile-pic" style="background-image: url(${window.user?.profile?.picture || window.icons['profile.svg']})"></div>`;
|
||||
h += `<span class="dashboard-user-name username">${window.html_encode(window.user?.username || i18n('user', [], false))}</span>`;
|
||||
h += `<span class="dashboard-user-name username" id="dashboard-user-name">${window.html_encode(window.user?.username || i18n('user', [], false))}</span>`;
|
||||
h += '<svg class="dashboard-user-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
|
||||
h += '</button>';
|
||||
h += '</div>';
|
||||
@@ -192,9 +193,16 @@ async function UIDashboard (options) {
|
||||
|
||||
const $el_window = $(el_window);
|
||||
|
||||
// Remove `?ref=...` from the URL, keeping the path and tab hash (mirrors UIDesktop)
|
||||
// The active tab, shown by the class and announced by aria-current.
|
||||
const setActiveSidebarItem = ($item) => {
|
||||
$el_window.find('.dashboard-sidebar-item').removeClass('active').removeAttr('aria-current');
|
||||
$item.addClass('active').attr('aria-current', 'page');
|
||||
};
|
||||
|
||||
// Remove `?ref=...` from the URL, keeping the path and tab hash (mirrors
|
||||
// UIDesktop). Replaced, not pushed, so Back leaves the dashboard.
|
||||
if ( window.url_query_params?.has('ref') ) {
|
||||
window.history.pushState(null, document.title, window.location.pathname + window.location.hash);
|
||||
window.history.replaceState(null, document.title, window.location.pathname + window.location.hash);
|
||||
}
|
||||
|
||||
// Restore sidebar collapsed state
|
||||
@@ -369,13 +377,19 @@ async function UIDashboard (options) {
|
||||
const $el = $(`.item[data-uid='${item.uid}']`);
|
||||
if ( $el.length === 0 ) return;
|
||||
|
||||
// Files-tab rows go through the shared updater, which also refits
|
||||
// the name and keeps the entry their handlers use current.
|
||||
const $fileRows = $el.filter('.files-tab .item');
|
||||
if ( $fileRows.length ) window.UIDashboardFileItemUpdate?.($fileRows, item);
|
||||
const $others = $el.not($fileRows);
|
||||
|
||||
// Update data attributes (raw values — .attr() stores literally)
|
||||
$el.attr('data-name', item.name);
|
||||
$el.attr('data-path', item.path);
|
||||
$others.attr('data-name', item.name);
|
||||
$others.attr('data-path', item.path);
|
||||
|
||||
// Update displayed name
|
||||
$el.find('.item-name').text(item.name);
|
||||
$el.find('.item-name-editor').val(item.name);
|
||||
$others.find('.item-name').text(item.name);
|
||||
$others.find('.item-name-editor').val(item.name);
|
||||
});
|
||||
|
||||
window.socket.on('item.updated', async (item) => {
|
||||
@@ -416,8 +430,7 @@ async function UIDashboard (options) {
|
||||
|
||||
// Only switch if the tab exists
|
||||
if ( $targetTab.length > 0 ) {
|
||||
$el_window.find('.dashboard-sidebar-item').removeClass('active');
|
||||
$targetTab.addClass('active');
|
||||
setActiveSidebarItem($targetTab);
|
||||
$el_window.find('.dashboard-section').removeClass('active');
|
||||
$el_window.find(`.dashboard-section[data-section="${tabId}"]`).addClass('active');
|
||||
|
||||
@@ -473,14 +486,12 @@ async function UIDashboard (options) {
|
||||
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
|
||||
if ( tab === 'home' ) {
|
||||
// Home tab
|
||||
$el_window.find('.dashboard-sidebar-item').removeClass('active');
|
||||
$el_window.find('.dashboard-sidebar-item').first().addClass('active');
|
||||
setActiveSidebarItem($el_window.find('.dashboard-sidebar-item').first());
|
||||
$el_window.find('.dashboard-section').removeClass('active');
|
||||
$el_window.find('.dashboard-section').first().addClass('active');
|
||||
document.querySelector('.dashboard-content').setAttribute('class', 'dashboard-content');
|
||||
} else if ( $targetTab.length > 0 ) {
|
||||
$el_window.find('.dashboard-sidebar-item').removeClass('active');
|
||||
$targetTab.addClass('active');
|
||||
setActiveSidebarItem($targetTab);
|
||||
$el_window.find('.dashboard-section').removeClass('active');
|
||||
$el_window.find(`.dashboard-section[data-section="${tab}"]`).addClass('active');
|
||||
document.querySelector('.dashboard-content').setAttribute('class', 'dashboard-content');
|
||||
@@ -514,14 +525,15 @@ async function UIDashboard (options) {
|
||||
// Sidebar item click handler — only tab items ([data-section]); plain links
|
||||
// like the "Open Desktop" button navigate natively
|
||||
$el_window.on('click', '.dashboard-sidebar-item[data-section]', function (e) {
|
||||
// A modified click opens the tab's link in a new browser tab, natively.
|
||||
if ( e.metaKey || e.ctrlKey || e.shiftKey ) return;
|
||||
e.preventDefault();
|
||||
const $this = $(this);
|
||||
const section = $this.attr('data-section');
|
||||
notifications.close({ restoreFocus: false });
|
||||
|
||||
// Update active sidebar item
|
||||
$el_window.find('.dashboard-sidebar-item').removeClass('active');
|
||||
$this.addClass('active');
|
||||
setActiveSidebarItem($this);
|
||||
|
||||
// Update active content section
|
||||
$el_window.find('.dashboard-section').removeClass('active');
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
// @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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
|
||||
@@ -56,9 +56,10 @@ const FORM_ICONS = {
|
||||
* @property {(name: string, show: boolean) => void} showField
|
||||
* @property {(text: string, opts?: { kind?: 'error'|'info', field?: string }) => void} setStatus
|
||||
* Plain text; an empty string clears it. Naming a field marks it invalid and focuses it.
|
||||
* @property {(opts: { message?: string, html?: string }) => void} finish Swaps the
|
||||
* form for a done state (a plain-text `message`, or the caller's encoded
|
||||
* `html`) with a single Done button.
|
||||
* @property {(opts: { message?: string, html?: string, keepOnBackdrop?: boolean }) => void} finish
|
||||
* Swaps the form for a done state (a plain-text `message`, or the caller's
|
||||
* encoded `html`) with a single Done button. `keepOnBackdrop` stops a stray
|
||||
* click outside the card from closing it, for content shown only once.
|
||||
*/
|
||||
|
||||
const attrs_html = (attrs) => Object.entries(attrs ?? {})
|
||||
@@ -138,12 +139,20 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
h += '</button>';
|
||||
h += '</div></form>';
|
||||
|
||||
const { $dialog, close, isClosed } = mountDashboardOverlay({ html: h, $container, returnFocusTo });
|
||||
let busy = false;
|
||||
let keep_on_backdrop = false;
|
||||
// While the handler runs, the dialog is where its outcome will show, so
|
||||
// only the explicit ✕ closes it then; a stray click or Escape does not.
|
||||
const { $dialog, close, isClosed } = mountDashboardOverlay({
|
||||
html: h,
|
||||
$container,
|
||||
returnFocusTo,
|
||||
canDismiss: (via) => via === 'close' || ! (busy || (keep_on_backdrop && via === 'backdrop')),
|
||||
});
|
||||
const $status = $dialog.find('.dashboard-form-status');
|
||||
const $submit = $dialog.find('.dashboard-form-submit');
|
||||
const $controls = () => $dialog.find('.dashboard-form-input');
|
||||
|
||||
let busy = false;
|
||||
/** @type {HTMLElement | null} */
|
||||
let focus_after = null;
|
||||
|
||||
@@ -184,7 +193,8 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
else $field.get(0)?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
finish ({ message, html }) {
|
||||
finish ({ message, html, keepOnBackdrop = false }) {
|
||||
keep_on_backdrop = keepOnBackdrop;
|
||||
const content = html ?? `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message ?? '')}</p></div>`;
|
||||
$dialog.attr('aria-describedby', null);
|
||||
$dialog.find('.dashboard-dialog-body').html(`<div class="dashboard-dialog-content" role="status">${content}</div>`);
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
// @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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
@@ -46,6 +64,35 @@ describe('UIDashboardFormDialog', () => {
|
||||
expect($('.dashboard-form-field[data-field="code"]').prop('hidden')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores Escape and backdrop clicks while the handler runs, but not the close button', async () => {
|
||||
let release;
|
||||
const dialog = open({ onSubmit: () => new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}) });
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
submit();
|
||||
$(document).trigger($.Event('keydown', { key: 'Escape' }));
|
||||
$overlay.trigger($.Event('mousedown', { target: $overlay[0] }));
|
||||
$overlay.trigger($.Event('click', { target: $overlay[0] }));
|
||||
expect($('.dashboard-dialog-overlay')).toHaveLength(1);
|
||||
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
release();
|
||||
await tick();
|
||||
expect(dialog.$dialog.closest('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps content shown once up through a backdrop click, not through Escape', () => {
|
||||
const dialog = open();
|
||||
dialog.finish({ html: '<p>token</p>', keepOnBackdrop: true });
|
||||
const $overlay = $('.dashboard-dialog-overlay');
|
||||
$overlay.trigger($.Event('mousedown', { target: $overlay[0] }));
|
||||
$overlay.trigger($.Event('click', { target: $overlay[0] }));
|
||||
expect($overlay.find('p').text()).toBe('token');
|
||||
$(document).trigger($.Event('keydown', { key: 'Escape' }));
|
||||
expect($overlay.hasClass('dashboard-dialog-show')).toBe(false);
|
||||
});
|
||||
|
||||
it('reads every field back by name', () => {
|
||||
const dialog = open();
|
||||
expect(dialog.values()).toEqual({ name: 'Ada <3', secret: '', about: 'hi', when: '7d', code: '' });
|
||||
|
||||
@@ -317,6 +317,7 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
const hadFocus = $panel[0].contains(document.activeElement);
|
||||
const count = unreadCount(entries);
|
||||
const label = badgeLabel(count);
|
||||
|
||||
@@ -340,6 +341,11 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
const was = isOpen ? focusedRow() : null;
|
||||
$list.html(entries.map(rowHtml).join(''));
|
||||
restoreFocus(was);
|
||||
// A control this render hid (Mark all read, Retry) took focus with
|
||||
// it; keep it in the panel rather than on the page behind.
|
||||
if ( isOpen && hadFocus && ! $panel[0].contains(document.activeElement) ) {
|
||||
$panel[0].focus({ preventScroll: true });
|
||||
}
|
||||
justAdded.clear();
|
||||
if ( isOpen ) for ( const entry of entries ) surfaced.add(entry.uid);
|
||||
};
|
||||
@@ -709,10 +715,11 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
socket.on('notif.ack', ({ uid }) => {
|
||||
if ( ! uid ) return;
|
||||
$(`.notification[data-uid="${html_encode(uid)}"]`).closest('.notification-wrapper').remove();
|
||||
if ( ! entries.some((e) => e.uid === uid && isUnread(e)) ) return;
|
||||
// Read in another tab: a listing already in flight predates it too.
|
||||
// Read in another tab: a listing already in flight predates it too,
|
||||
// even one bringing an entry this tab hasn't seen yet.
|
||||
const now = Date.now();
|
||||
acked.set(uid, now);
|
||||
if ( ! entries.some((e) => e.uid === uid && isUnread(e)) ) return;
|
||||
entries = setReadAt(entries, uid, now);
|
||||
render();
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
|
||||
import item_icon from '../../helpers/itemIcon.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import { isOverlayOnTop } from './dashboardOverlay.js';
|
||||
|
||||
const { html_encode } = window;
|
||||
|
||||
@@ -56,10 +58,11 @@ function addRow ($list, labelHtml, valueHtml) {
|
||||
*/
|
||||
export default function UIItemPropertiesModal ({ name, path: item_path, uid, $container }) {
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const el_previous_focus = document.activeElement;
|
||||
|
||||
const $overlay = $(`
|
||||
<div class="item-props-overlay">
|
||||
<div class="item-props-modal" role="dialog" aria-modal="true" aria-label="${html_encode(name)} ${i18n('properties')}">
|
||||
<div class="item-props-modal" role="dialog" aria-modal="true" tabindex="-1" aria-label="${html_encode(name)} ${i18n('properties')}">
|
||||
<div class="item-props-header">
|
||||
<div class="item-props-title">
|
||||
<span class="item-props-title-icon"></span>
|
||||
@@ -70,15 +73,15 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
</div>
|
||||
<button class="item-props-close" aria-label="${i18n('close')}" title="${i18n('close')}">${closeIcon}</button>
|
||||
</div>
|
||||
<div class="item-props-tabs">
|
||||
<button class="item-props-tab item-props-tab-active" data-tab="general">${i18n('general')}</button>
|
||||
<button class="item-props-tab item-props-tab-versions" data-tab="versions">${i18n('versions')}</button>
|
||||
<div class="item-props-tabs" role="tablist">
|
||||
<button class="item-props-tab item-props-tab-active" role="tab" aria-selected="true" data-tab="general">${i18n('general')}</button>
|
||||
<button class="item-props-tab item-props-tab-versions" role="tab" aria-selected="false" data-tab="versions">${i18n('versions')}</button>
|
||||
</div>
|
||||
<div class="item-props-body enable-user-select">
|
||||
<div class="item-props-panel item-props-panel-active" data-tab="general">
|
||||
<dl class="item-props-list"></dl>
|
||||
<div class="item-props-panel item-props-panel-active" role="tabpanel" data-tab="general">
|
||||
<dl class="item-props-list" aria-busy="true"><div class="item-props-empty">${i18n('loading')}</div></dl>
|
||||
</div>
|
||||
<div class="item-props-panel" data-tab="versions">
|
||||
<div class="item-props-panel" role="tabpanel" data-tab="versions">
|
||||
<div class="item-props-versions"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,8 +91,13 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
|
||||
$root.append($overlay);
|
||||
|
||||
const el_modal = $overlay.find('.item-props-modal').get(0);
|
||||
|
||||
// Reveal after paint so the CSS transition (fade + scale/slide) runs.
|
||||
requestAnimationFrame(() => $overlay.addClass('item-props-show'));
|
||||
requestAnimationFrame(() => {
|
||||
$overlay.addClass('item-props-show');
|
||||
el_modal.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
let closed = false;
|
||||
const close = () => {
|
||||
@@ -98,6 +106,9 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
$overlay.removeClass('item-props-show');
|
||||
$(document).off('keydown.item-props');
|
||||
setTimeout(() => $overlay.remove(), 200);
|
||||
if ( el_previous_focus && document.body.contains(el_previous_focus) ) {
|
||||
el_previous_focus.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
// -- Dismissal wiring --
|
||||
@@ -106,16 +117,39 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
if ( e.target === $overlay[0] ) close();
|
||||
});
|
||||
$(document).on('keydown.item-props', function (e) {
|
||||
if ( e.key === 'Escape' ) close();
|
||||
if ( e.key === 'Escape' && isOverlayOnTop($overlay[0]) ) close();
|
||||
});
|
||||
|
||||
// Keep Tab cycling inside the dialog while it's up.
|
||||
$overlay.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $overlay.find('button, a[href]').filter(':visible:not(:disabled)');
|
||||
if ( ! focusables.length ) return;
|
||||
const first = focusables.get(0);
|
||||
const last = focusables.get(focusables.length - 1);
|
||||
if ( focusables.index(document.activeElement) === -1 ) {
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
} else if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// -- Tab switching --
|
||||
$overlay.on('click', '.item-props-tab', function () {
|
||||
const tab = $(this).attr('data-tab');
|
||||
$overlay.find('.item-props-tab').removeClass('item-props-tab-active');
|
||||
$(this).addClass('item-props-tab-active');
|
||||
const show_tab = (tab) => {
|
||||
$overlay.find('.item-props-tab').each(function () {
|
||||
const active = $(this).attr('data-tab') === tab;
|
||||
$(this).toggleClass('item-props-tab-active', active).attr('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
$overlay.find('.item-props-panel').removeClass('item-props-panel-active');
|
||||
$overlay.find(`.item-props-panel[data-tab="${tab}"]`).addClass('item-props-panel-active');
|
||||
};
|
||||
$overlay.on('click', '.item-props-tab', function () {
|
||||
show_tab($(this).attr('data-tab'));
|
||||
});
|
||||
|
||||
const $list = $overlay.find('.item-props-list');
|
||||
@@ -146,11 +180,14 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
}
|
||||
} catch { /* icon is best-effort */ }
|
||||
|
||||
// Directories have no version history — drop the tab entirely.
|
||||
// Directories have no version history — drop the tab entirely,
|
||||
// leaving General showing even if Versions was picked meanwhile.
|
||||
if ( fsentry.is_dir ) {
|
||||
$overlay.find('.item-props-tab-versions').remove();
|
||||
show_tab('general');
|
||||
}
|
||||
|
||||
$list.empty().attr('aria-busy', 'false');
|
||||
addRow($list, i18n('name'), html_encode(fsentry.name));
|
||||
addRow($list, i18n('path'), html_encode(item_path));
|
||||
|
||||
@@ -192,7 +229,7 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
const websitesHtml = fsentry.subdomains.map(subdomain => `
|
||||
<p class="item-props-website-entry" data-uuid="${html_encode(subdomain.uuid)}">
|
||||
<a target="_blank" rel="noopener" href="${html_encode(subdomain.address)}">${html_encode(subdomain.address)}</a>
|
||||
<span class="item-props-disassociate" data-uuid="${html_encode(subdomain.uuid)}" data-subdomain="${html_encode(window.extractSubdomain(subdomain.address))}">${i18n('disassociate')}</span>
|
||||
<button type="button" class="item-props-disassociate" data-uuid="${html_encode(subdomain.uuid)}" data-subdomain="${html_encode(window.extractSubdomain(subdomain.address))}">${i18n('disassociate')}</button>
|
||||
</p>
|
||||
`).join('');
|
||||
addRow($list, i18n('associated_websites'), websitesHtml);
|
||||
@@ -217,10 +254,22 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
// Disassociate handler
|
||||
$overlay.on('click', '.item-props-disassociate', function () {
|
||||
const $link = $(this);
|
||||
if ( $link.prop('disabled') ) return;
|
||||
$link.prop('disabled', true);
|
||||
puter.hosting.update($link.attr('data-subdomain'), null).then(() => {
|
||||
$overlay.find(`.item-props-website-entry[data-uuid="${$link.attr('data-uuid')}"]`).remove();
|
||||
// Remove the website badge from every row for this item.
|
||||
$(`.item[data-uid="${item_uid}"]`).find('.item-has-website-badge').fadeOut(200);
|
||||
const $entry = $link.closest('.item-props-website-entry');
|
||||
const $row = $entry.closest('.item-props-row');
|
||||
$entry.remove();
|
||||
if ( $row.find('.item-props-website-entry').length > 0 ) return;
|
||||
$row.remove();
|
||||
el_modal.focus({ preventScroll: true });
|
||||
// The item serves no website any more: drop its badge from every row.
|
||||
$('.item').filter(function () {
|
||||
return $(this).attr('data-uid') === item_uid;
|
||||
}).attr('data-has_website', '0').find('.item-has-website-badge').fadeOut(200);
|
||||
}).catch((err) => {
|
||||
$link.prop('disabled', false);
|
||||
UIAlert(err?.message || i18n('error_unknown_cause', [], false));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { share_outcome } from '../../helpers/shareOutcome.js';
|
||||
import { aggregateOwners, aggregateShares, linkShareState, missingPathsFor } from './shareAggregate.js';
|
||||
import shareRecipientPicker from '../../helpers/shareRecipientPicker.js';
|
||||
import { isOverlayOnTop } from './dashboardOverlay.js';
|
||||
import { teams_for_sharing } from '../../helpers/shareTeams.js';
|
||||
import { share_link_for } from '../../helpers/sharePaths.js';
|
||||
import { is_plan_gate_error, open_upgrade_flow } from '../../helpers/planGate.js';
|
||||
@@ -487,7 +488,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
if ( e.target === $overlay[0] && backdrop_pressed ) close();
|
||||
});
|
||||
$(document).on('keydown.share-modal', function (e) {
|
||||
if ( e.key !== 'Escape' ) return;
|
||||
// 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.
|
||||
const $confirm = $list.find('.share-modal-row-confirm');
|
||||
if ( $confirm.length ) {
|
||||
@@ -574,17 +576,22 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
}
|
||||
};
|
||||
|
||||
/** Open the selection to anyone with the link at `mode`, or close it with null. */
|
||||
const set_link_access = async (mode) => {
|
||||
/**
|
||||
* Open the selection to anyone with the link at `mode`, or close it with
|
||||
* null. Focus goes back to `el_control`, the select the user changed.
|
||||
*/
|
||||
const set_link_access = async (mode, el_control) => {
|
||||
$link_access.prop('disabled', true);
|
||||
$link_mode.prop('disabled', true);
|
||||
try {
|
||||
if ( mode ) {
|
||||
const created = await grant_access({ anyone: true }, mode, target_paths);
|
||||
if ( ! is_multi ) single_uid ??= created?.[0]?.entryUid ?? null;
|
||||
link_state = { access: 'anyone', mode };
|
||||
show_success(i18n(`share_link_on_${mode === 'write' ? 'write' : 'read'}${is_multi ? '_items' : ''}`));
|
||||
} else {
|
||||
await revoke_access({ anyone: true }, target_paths);
|
||||
link_state = { access: 'restricted', mode: null };
|
||||
show_success(i18n(is_multi ? 'share_link_off_items' : 'share_link_off'));
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -599,20 +606,25 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
$link_access.prop('disabled', false);
|
||||
$link_mode.prop('disabled', false);
|
||||
}
|
||||
// The listings are the truth either way, and they put the control back.
|
||||
// The listings are the truth either way, and they put the control
|
||||
// back; a selection too large to list keeps what was just applied.
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
focus_dialog();
|
||||
render_link_access();
|
||||
if ( el_control && $(el_control).is(':visible') ) el_control.focus({ preventScroll: true });
|
||||
else focus_dialog();
|
||||
};
|
||||
|
||||
$overlay.on('change', '.share-modal-link-access', function () {
|
||||
const value = $(this).val();
|
||||
if ( value === 'mixed' ) return;
|
||||
set_link_access(value === 'anyone' ? ($link_mode.val() || 'read') : null);
|
||||
// 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);
|
||||
});
|
||||
$overlay.on('change', '.share-modal-link-mode', function () {
|
||||
const mode = $(this).val();
|
||||
if ( mode ) set_link_access(mode);
|
||||
if ( mode ) set_link_access(mode, this);
|
||||
});
|
||||
$overlay.on('click', '.share-modal-copy-link', async function () {
|
||||
if ( ! single_uid ) return;
|
||||
|
||||
@@ -52,10 +52,15 @@ export const formatCredits = (credits) => {
|
||||
return value < 0.01 ? '<0.01' : String(Number(value.toFixed(2)));
|
||||
};
|
||||
|
||||
/** Dollar rendering for deployments that report raw (unscaled) amounts. */
|
||||
/**
|
||||
* Dollar rendering for deployments that report raw (unscaled) amounts. Like
|
||||
* `formatCredits`, a nonzero amount under a cent shows as "<$0.01", not "$0.00".
|
||||
*/
|
||||
export const formatDollarsFromMicrocents = (microcents) => {
|
||||
const mc = Number.isFinite(microcents) ? microcents : 0;
|
||||
return window.number_format(mc / 100_000_000, {
|
||||
const dollars = mc / 100_000_000;
|
||||
if ( dollars !== 0 && Math.abs(dollars) < 0.005 ) return `${dollars < 0 ? '-' : ''}<$0.01`;
|
||||
return window.number_format(dollars, {
|
||||
decimals: 2,
|
||||
prefix: '$',
|
||||
});
|
||||
|
||||
@@ -73,4 +73,11 @@ describe('formatDollarsFromMicrocents', () => {
|
||||
expect(formatDollarsFromMicrocents(37_000_000)).toBe('$0.37');
|
||||
expect(formatDollarsFromMicrocents(undefined)).toBe('$0.00');
|
||||
});
|
||||
|
||||
it('never renders a nonzero sub-cent amount as $0.00', () => {
|
||||
expect(formatDollarsFromMicrocents(40_000)).toBe('<$0.01');
|
||||
expect(formatDollarsFromMicrocents(-40_000)).toBe('-<$0.01');
|
||||
expect(formatDollarsFromMicrocents(0)).toBe('$0.00');
|
||||
expect(formatDollarsFromMicrocents(600_000)).toBe('$0.01');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,21 @@
|
||||
|
||||
let overlay_seq = 0;
|
||||
|
||||
/**
|
||||
* Whether a keypress belongs to this overlay rather than to a window or
|
||||
* modal opened over it. Not all of those take focus, so what is painted
|
||||
* over the overlay's center counts too.
|
||||
*
|
||||
* @param {HTMLElement} el_overlay
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isOverlayOnTop (el_overlay) {
|
||||
const el_active = document.activeElement;
|
||||
if ( el_active && el_active !== document.body && ! el_overlay.contains(el_active) ) return false;
|
||||
const el_top = document.elementFromPoint?.(window.innerWidth / 2, window.innerHeight / 2);
|
||||
return ! (el_top && ! el_overlay.contains(el_top));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts `.dashboard-dialog-overlay` around the given card and wires the
|
||||
* reveal transition, dismissal (Escape, `.dashboard-dialog-close`, a press
|
||||
@@ -35,9 +50,11 @@ let overlay_seq = 0;
|
||||
* back to whatever had it when the overlay opened.
|
||||
* @param {() => void} [opts.onDismiss] Runs when the user dismisses the
|
||||
* overlay, after it has closed. Not called for `close()`.
|
||||
* @param {(via: 'escape'|'close'|'backdrop') => boolean} [opts.canDismiss]
|
||||
* Consulted before each dismissal; returning false keeps the overlay up.
|
||||
* @returns {{ $overlay: jQuery, $dialog: jQuery, close: () => void, isClosed: () => boolean }}
|
||||
*/
|
||||
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss }) {
|
||||
export default function mountDashboardOverlay ({ html, $container, returnFocusTo, onDismiss, canDismiss }) {
|
||||
const $root = $container && $container.length ? $container : $('body');
|
||||
const ns = `.dashboard-overlay-${++overlay_seq}`;
|
||||
const el_previous_focus = document.activeElement;
|
||||
@@ -81,13 +98,13 @@ export default function mountDashboardOverlay ({ html, $container, returnFocusTo
|
||||
el_focus_target?.focus({ preventScroll: true });
|
||||
} catch { /* focus restoration is best-effort */ }
|
||||
};
|
||||
const dismiss = () => {
|
||||
if ( closed ) return;
|
||||
const dismiss = (via) => {
|
||||
if ( closed || canDismiss?.(via) === false ) return;
|
||||
close();
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
$overlay.on('click', '.dashboard-dialog-close', dismiss);
|
||||
$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
|
||||
@@ -100,19 +117,14 @@ export default function mountDashboardOverlay ({ html, $container, returnFocusTo
|
||||
window.mouseover_window = undefined;
|
||||
});
|
||||
$overlay.on('click', function (e) {
|
||||
if ( e.target === el_overlay && backdrop_pressed ) dismiss();
|
||||
if ( e.target === el_overlay && backdrop_pressed ) dismiss('backdrop');
|
||||
});
|
||||
|
||||
$(document).on(`keydown${ns}`, function (e) {
|
||||
if ( e.key !== 'Escape' ) return;
|
||||
// Escape belongs to whatever is on top: a window or modal opened over
|
||||
// this overlay closes itself, not the overlay underneath it. Not all
|
||||
// of them take focus, so check what is painted over the overlay too.
|
||||
const el_active = document.activeElement;
|
||||
if ( el_active && el_active !== document.body && ! el_overlay.contains(el_active) ) return;
|
||||
const el_top = document.elementFromPoint?.(window.innerWidth / 2, window.innerHeight / 2);
|
||||
if ( el_top && ! el_overlay.contains(el_top) ) return;
|
||||
dismiss();
|
||||
// this overlay closes itself, not the overlay underneath it.
|
||||
if ( e.key !== 'Escape' || ! isOverlayOnTop(el_overlay) ) return;
|
||||
dismiss('escape');
|
||||
});
|
||||
|
||||
// Keep Tab cycling inside the card while it's up.
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
// @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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
@@ -45,6 +63,17 @@ describe('mountDashboardOverlay', () => {
|
||||
expect(onDismiss).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stays up when canDismiss says no, and tells it which way was tried', () => {
|
||||
const canDismiss = vi.fn(() => false);
|
||||
const a = mountDashboardOverlay({ html: card, canDismiss });
|
||||
escape();
|
||||
$('.dashboard-dialog-close').trigger('click');
|
||||
a.$overlay.trigger($.Event('mousedown', { target: a.$overlay[0] }));
|
||||
a.$overlay.trigger($.Event('click', { target: a.$overlay[0] }));
|
||||
expect(canDismiss.mock.calls.map(([via]) => via)).toEqual(['escape', 'close', 'backdrop']);
|
||||
expect(a.isClosed()).toBe(false);
|
||||
});
|
||||
|
||||
// A window opened from the overlay (the session manager, say) owns its own Escape.
|
||||
it('leaves Escape alone while focus is somewhere outside it', () => {
|
||||
document.body.innerHTML = '<input class="elsewhere">';
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { aggregateOwners, aggregateShares, linkShareState, missingPathsFor } from './shareAggregate.js';
|
||||
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
|
||||
// Mirrors the real i18n: encodes unless the third argument is false.
|
||||
|
||||
@@ -55,7 +55,8 @@ export const usageBudget = (usage, allowanceInfo) => {
|
||||
return {
|
||||
used,
|
||||
capacity,
|
||||
percent: Math.round(share),
|
||||
// 100% reads as spent: an allowance with anything left shows 99%.
|
||||
percent: share < 100 ? Math.min(99, Math.round(share)) : Math.round(share),
|
||||
barPercent: Math.max(0, Math.min(100, share)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
import { addonCreditsRemaining, usageBudget } from './usageBudget.js';
|
||||
|
||||
@@ -82,6 +101,11 @@ describe('usageBudget', () => {
|
||||
expect(budget.barPercent).toBe(100);
|
||||
});
|
||||
|
||||
it('reads 99% rather than 100% while some allowance is left', () => {
|
||||
expect(usageBudget({ total: 996 }, info(1000)).percent).toBe(99);
|
||||
expect(usageBudget({ total: 1000 }, info(1000)).percent).toBe(100);
|
||||
});
|
||||
|
||||
it('answers zero for an account with no budget at all', () => {
|
||||
const budget = usageBudget({ total: 0 }, info(0));
|
||||
expect(budget).toMatchObject({ capacity: 0, used: 0, percent: 0 });
|
||||
|
||||
+16
-14
@@ -383,17 +383,18 @@ async function UIDesktop (options) {
|
||||
$(`.item[data-uid='${item.uid}']`).find('.item-icon-thumb').attr('src', new_icon);
|
||||
$(`.item[data-uid='${item.uid}']`).find('.item-icon-icon').attr('src', new_icon);
|
||||
|
||||
// Set new data-name
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-name', html_encode(item.name));
|
||||
$(`.window-${item.uid}`).attr('data-name', html_encode(item.name));
|
||||
// Set new data-name. Attributes and form values are stored raw
|
||||
// (.attr()/.val() don't parse HTML), as rename_file writes them.
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-name', item.name);
|
||||
$(`.window-${item.uid}`).attr('data-name', item.name);
|
||||
|
||||
// Set new title attribute
|
||||
$(`.item[data-uid='${item.uid}']`).attr('title', html_encode(item.name));
|
||||
$(`.window-${options.uid}`).attr('title', html_encode(item.name));
|
||||
$(`.item[data-uid='${item.uid}']`).attr('title', item.name);
|
||||
$(`.window-${item.uid}`).attr('title', item.name);
|
||||
|
||||
// Set new value for item-name-editor
|
||||
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(html_encode(item.name));
|
||||
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', html_encode(item.name));
|
||||
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
|
||||
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
|
||||
|
||||
// Set new data-path
|
||||
const new_path = item.path;
|
||||
@@ -616,17 +617,18 @@ async function UIDesktop (options) {
|
||||
const new_icon = (item.is_dir ? window.icons['folder.svg'] : (await item_icon(item)).image);
|
||||
$(`.item[data-uid='${item.uid}']`).find('.item-icon-icon').attr('src', new_icon);
|
||||
|
||||
// Set new data-name
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-name', html_encode(item.name));
|
||||
$(`.window-${item.uid}`).attr('data-name', html_encode(item.name));
|
||||
// Set new data-name. Attributes and form values are stored raw
|
||||
// (.attr()/.val() don't parse HTML), as rename_file writes them.
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-name', item.name);
|
||||
$(`.window-${item.uid}`).attr('data-name', item.name);
|
||||
|
||||
// Set new title attribute
|
||||
$(`.item[data-uid='${item.uid}']`).attr('title', html_encode(item.name));
|
||||
$(`.window-${options.uid}`).attr('title', html_encode(item.name));
|
||||
$(`.item[data-uid='${item.uid}']`).attr('title', item.name);
|
||||
$(`.window-${item.uid}`).attr('title', item.name);
|
||||
|
||||
// Set new value for item-name-editor
|
||||
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(html_encode(item.name));
|
||||
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', html_encode(item.name));
|
||||
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
|
||||
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
|
||||
|
||||
// Set new data-path
|
||||
const new_path = item.path;
|
||||
|
||||
@@ -1880,11 +1880,6 @@ window.activate_item_name_editor = async function (el_item) {
|
||||
$(el_item_name_editor).focus();
|
||||
$(el_item_name_editor).addClass('item-name-editor-active');
|
||||
|
||||
// html-decode the content of the item name editor, this is necessary because the item name is html-encoded when displayed
|
||||
// but the item name editor is not html-encoded. If we remove this line, the item name editor will display the html-encoded
|
||||
// version of the item name after a successful name edit.
|
||||
$(el_item_name_editor).val(html_decode($(el_item_name_editor).val()));
|
||||
|
||||
// select all text before extension
|
||||
const item_name = $(el_item).attr('data-name');
|
||||
const is_dir = parseInt($(el_item).attr('data-is_dir'));
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
/* ======================================
|
||||
Dashboard
|
||||
====================================== */
|
||||
@@ -1082,6 +1101,12 @@ input.myapps-search:disabled {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* An arrow with no page to go to leaves the accessibility tree once faded. */
|
||||
.myapps-pager-arrow.myapps-pager-arrow-hidden {
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, background 0.15s ease, visibility 0s 0.15s;
|
||||
}
|
||||
|
||||
.myapps-pager-arrow-prev {
|
||||
left: 0;
|
||||
}
|
||||
@@ -1167,6 +1192,13 @@ input.myapps-search:disabled {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* The enlargement alone is easy to miss; a ring says which tile has focus. */
|
||||
.myapps-tile:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* Uninstall: the tile shrinks away in place, then the surviving tiles
|
||||
FLIP-slide into the closed-up grid (see showUninstallModal). Duration is
|
||||
mirrored by TILE_REMOVE_ANIM_MS in TabApps.js. */
|
||||
@@ -2236,6 +2268,11 @@ p.myapps-add-error {
|
||||
}
|
||||
.item-props-disassociate {
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--dashboard-danger-text);
|
||||
cursor: pointer;
|
||||
@@ -2243,6 +2280,20 @@ p.myapps-add-error {
|
||||
.item-props-disassociate:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.item-props-disassociate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
}
|
||||
.item-props-modal:focus {
|
||||
outline: none;
|
||||
}
|
||||
.item-props-close:focus-visible,
|
||||
.item-props-tab:focus-visible,
|
||||
.item-props-disassociate:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
/* Versions */
|
||||
.item-props-version-entry {
|
||||
@@ -2302,6 +2353,13 @@ p.myapps-add-error {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.item-props-overlay,
|
||||
.item-props-modal {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Share modal --
|
||||
Responsive, from-scratch replacement for UIWindowShare in the Dashboard:
|
||||
a centered card on desktop, a bottom sheet on mobile. Mirrors the item
|
||||
@@ -4036,6 +4094,22 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode keeps the toolbar's light hover chip (see .path-btn), so what sits
|
||||
on it goes dark; at rest the action icons match the inverted nav arrows. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dashboard-section-files .header .path-action-btn {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.dashboard-section-files .header .path-action-btn:hover {
|
||||
color: #5c5c5c;
|
||||
}
|
||||
.dashboard-section-files .header .path .dirname:hover {
|
||||
color: #3f3f46;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path .dirname.drop-target {
|
||||
color: var(--dashboard-text);
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
@@ -4224,6 +4298,14 @@ body.myapps-reordering .myapps-tile {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Without hover there is no reveal, so touch screens show it at rest as
|
||||
quietly as the row's other metadata. */
|
||||
@media (hover: none) {
|
||||
.dashboard-section-files .row .item-more {
|
||||
color: var(--dashboard-text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-section-files .row:hover .item-more {
|
||||
color: var(--dashboard-text);
|
||||
@@ -4632,7 +4714,7 @@ body.myapps-reordering .myapps-tile {
|
||||
height: 7px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
box-shadow: 0 0 0 1px white;
|
||||
box-shadow: 0 0 0 1px var(--dashboard-background);
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-list-view .row .item-icon img {
|
||||
@@ -5803,6 +5885,12 @@ body.myapps-reordering .myapps-tile {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Inset: the card's rounded overflow clip would cut an outer ring. */
|
||||
.dashboard .bento-card-fancy-header:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.dashboard .bento-recent > .bento-card-fancy-header {
|
||||
background-color: var(--dashboard-bento-apps-header-bg);
|
||||
}
|
||||
@@ -6487,6 +6575,8 @@ body.myapps-reordering .myapps-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* Lets a long username or email wrap inside the card, not run past it. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-profile-info h3 {
|
||||
@@ -6494,12 +6584,14 @@ body.myapps-reordering .myapps-tile {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--dashboard-text-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-profile-info p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--dashboard-text-hint);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.dashboard-profile-hint {
|
||||
@@ -6778,6 +6870,12 @@ body.myapps-reordering .myapps-tile {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* The same focus ring as the settings cards' buttons; the base style.css
|
||||
rule drops the outline. */
|
||||
.dashboard-danger-card .button:focus-visible {
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
/* Responsive styles for Account tab */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-tab-content {
|
||||
@@ -6880,6 +6978,19 @@ body.myapps-reordering .myapps-tile {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Reduced motion: these still fade, but don't scale or slide into place. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.context-menu-modal-dialog {
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
.dashboard-section-files .files-selection-actions {
|
||||
transition: opacity 0.25s ease, visibility 0.25s ease;
|
||||
}
|
||||
.app-feedback-modal {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Title shown at top of context menu (e.g. filename) */
|
||||
.context-menu-modal-dialog .context-menu-title {
|
||||
padding: 10px 14px 6px;
|
||||
|
||||
@@ -262,10 +262,14 @@ export function createNotificationFeed ({
|
||||
undismissed(),
|
||||
]);
|
||||
|
||||
const claimed = [];
|
||||
for ( const item of replayCandidates(pages.flat(), { unacknowledged, delivered }) ) {
|
||||
const claimed = await claimShown(item.uid).catch(() => false);
|
||||
if ( claimed ) hand([item], true);
|
||||
if ( await claimShown(item.uid).catch(() => false) ) claimed.push(item);
|
||||
}
|
||||
// One hand-off, so a pile of missed notifications toasts as a burst
|
||||
// (a few, then a summary) rather than one toast each. Anything that
|
||||
// arrived live while the claims were out has been shown already.
|
||||
hand(claimed.filter((item) => ! delivered.has(item.uid)), true);
|
||||
};
|
||||
|
||||
/** The subscription is gone; the socket listeners render from here on. */
|
||||
|
||||
@@ -249,6 +249,16 @@ describe('createNotificationFeed', () => {
|
||||
expect(claimShown.mock.calls.map(([uid]) => uid)).toEqual(['n1', 'n2']);
|
||||
});
|
||||
|
||||
it('hands a replay over in one batch, so it can toast as a burst', async () => {
|
||||
const sdk = fakeSdk({ pages: { 'notif:account': [event('n1'), event('n2'), event('n3')] } });
|
||||
const { delivered } = await startFeed({
|
||||
sdk,
|
||||
mailbox: { undismissed: async () => new Set(['n1', 'n2', 'n3']), claimShown: async () => true },
|
||||
});
|
||||
expect(delivered).toHaveLength(1);
|
||||
expect(delivered[0][0].map((item) => item.uid)).toEqual(['n1', 'n2', 'n3']);
|
||||
});
|
||||
|
||||
it('does not replay what it already delivered live', async () => {
|
||||
const sdk = fakeSdk({
|
||||
pages: { 'notif:account': [event('n1')] },
|
||||
@@ -293,7 +303,7 @@ describe('createNotificationFeed', () => {
|
||||
sdk,
|
||||
mailbox: { undismissed: async () => new Set(['n1', 'n2']) },
|
||||
});
|
||||
expect(delivered.map(([items]) => items[0].uid)).toEqual(['n1', 'n2']);
|
||||
expect(delivered.flatMap(([items]) => items.map((item) => item.uid))).toEqual(['n1', 'n2']);
|
||||
});
|
||||
|
||||
it('resumes a later read where the last one stopped', async () => {
|
||||
|
||||
@@ -66,6 +66,9 @@ window.i18n = function (key, replacements = [], encode_html = true) {
|
||||
for ( let i = 0; i < replacements.length; i++ ) {
|
||||
// sanitize the replacement
|
||||
replacements[i] = encode_html ? html_encode(replacements[i]) : replacements[i];
|
||||
// Function replacements below: a string one would expand `$&`, `$'`
|
||||
// and the like inside the value (a team named "R&D $& Co").
|
||||
const value = replacements[i];
|
||||
// find first occurrence of %strong%
|
||||
let index = str.indexOf('%strong%');
|
||||
// find first occurrence of %%
|
||||
@@ -74,13 +77,13 @@ window.i18n = function (key, replacements = [], encode_html = true) {
|
||||
if ( index === -1 && index2 === -1 ) {
|
||||
break;
|
||||
} else if ( index === -1 ) {
|
||||
str = str.replace('%%', replacements[i]);
|
||||
str = str.replace('%%', () => value);
|
||||
} else if ( index2 === -1 ) {
|
||||
str = str.replace('%strong%', `<strong>${ replacements[i] }</strong>`);
|
||||
str = str.replace('%strong%', () => `<strong>${ value }</strong>`);
|
||||
} else if ( index < index2 ) {
|
||||
str = str.replace('%strong%', `<strong>${ replacements[i] }</strong>`);
|
||||
str = str.replace('%strong%', () => `<strong>${ value }</strong>`);
|
||||
} else {
|
||||
str = str.replace('%%', replacements[i]);
|
||||
str = str.replace('%%', () => value);
|
||||
}
|
||||
}
|
||||
return str;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 { describe, expect, it } from 'vitest';
|
||||
|
||||
globalThis.window = globalThis.window ?? globalThis;
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
await import('./i18n.js');
|
||||
|
||||
describe('i18n %% replacements', () => {
|
||||
it('inserts a value literally, even one that looks like a replacement pattern', () => {
|
||||
expect(window.i18n('moving_file', ["R&D $& $' Co"], false)).toBe("Moving R&D $& $' Co");
|
||||
expect(window.i18n('moving_file', ['a$&b'])).toBe('Moving a$&b');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user