Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 64b9f74edf fix(gui): desktop rows store remote renames raw; column resize stays in bounds
- The desktop's item.renamed/item.updated handlers wrote HTML-escaped names
  into data-name, the title and the name editor (and titled windows by an
  undefined uid). They store the raw name now, as rename_file does, so the
  editor shows the real name now that it no longer decodes a second time.
- Resizing a Files column while saved widths are too wide for the listing
  starts from the widths on screen, and a width is held at its column's
  minimum, so a drag or auto-fit can't save a negative width.
- Double-clicking a row closes the image preview, which would otherwise
  float above the app window it opens.
2026-10-23 12:24:14 -07:00
Nariman Jelveh c5e67a0da3 fix(dashboard): focus rings, dark-mode contrast and reduced motion
- App tiles show a focus ring, not only the hover enlargement; Home card
  headers get an inset ring the card's rounded clip can't cut off; the
  Delete account button gets the settings cards' ring.
- Pager arrows are mouse-only (the dots page by keyboard), so invisible
  arrows are no longer Tab stops, and an arrow with no page leaves the
  accessibility tree.
- Dark mode: the Files toolbar's action icons match the nav arrows and stay
  readable on the hover chip, hovered breadcrumbs keep their contrast, and
  the shared-item dot's ring follows the row background.
- The row '⋯' button is visible at rest on touch screens.
- A long username or email wraps inside the profile card.
- The touch action sheet, the Files selection bar and the app feedback
  modal stop scaling/sliding under reduced motion.
2026-10-23 12:15:04 -07:00
Nariman Jelveh 6319ef2ffe fix(dashboard): shell navigation, a11y and notification sync
- Missed notifications replayed on load toast as one burst (a few, then a
  summary) instead of one toast each.
- A notification read in another tab while this tab's first listing is in
  flight is no longer shown as unread when that listing lands.
- Focus stays in the notifications panel when Mark all read or Retry hides
  the button that had it.
- The active sidebar tab carries aria-current, and the account button is
  named by the username even while the collapsed sidebar hides it.
- Landing with ?ref= replaces the history entry, so one Back leaves.
- Ctrl/Cmd/Shift-click on a sidebar tab opens it in a new browser tab.
- Recently used keeps the newest list when refreshes overlap, and app names
  are URL-encoded in the links it opens.
2026-10-23 12:05:28 -07:00
Nariman Jelveh e4b04ee0e6 fix(dashboard): Apps folder drag, search, keys and loading
- A drag inside an open folder that is cancelled (Escape, blur) or dropped
  back in place puts the tiles back in their saved order; the cached panel
  markup used to skip the rebuild, and the next drag then saved the
  cancelled move too.
- Search no longer matches substrings of generated app ids, so "app" or a
  single hex letter stops returning every website shortcut.
- Arrow keys and Enter stay with the notifications panel, an account dialog
  or the touch action sheet while one is open over the grid.
- The grid shows after 1.5s even if an icon host never answers.
- Refreshing a grid already on screen keeps the page and skips the fade.
- Rename from a folder's menu focuses the name on touch devices too, and
  the folder dialog's label follows renames and counts the apps shown.
- i18n inserts %% values literally, so a name containing $& or $' no longer
  comes out mangled.
2026-10-23 12:00:34 -07:00
Nariman Jelveh f36ac83aa8 fix(dashboard): account dialogs, avatar saves and usage figures
- A form dialog ignores Escape and backdrop clicks while its request runs,
  so the outcome isn't lost behind a closed dialog; the close button still
  works.
- A new API token, shown only once, survives a stray click outside the card.
- Copy token works on origins without navigator.clipboard (plain-http
  self-hosts) instead of throwing, falling back to a selected field.
- Saving one profile picture and then another before the first finished
  can no longer leave the first one's picture or failure on screen.
- The storage host bar is clamped to the room left beside Puter's share and
  hides its label when there is no host figure.
- Sub-cent dollar costs read "<$0.01" rather than "$0.00", and the usage
  meter reads 99% until the allowance is actually spent.
2026-10-23 11:53:23 -07:00
Nariman Jelveh ffe50592f5 fix(dashboard): share and properties modals — focus, Escape, link state
Share modal:
- Escape pressed in a window opened over the modal (a verification or
  upgrade gate) no longer closes the modal underneath.
- Turning the link on or off for a selection too large to list now shows
  the result instead of leaving the control on its old state.
- Opening the link up starts at view access; it no longer reuses a hidden
  mode left over from earlier, which could hand out edit access.
- Focus returns to the select the user changed.

Properties modal:
- Takes focus when it opens, keeps Tab inside, gives focus back on close,
  and leaves Escape to a window opened over it.
- Shows a loading state, and opening Versions on a folder before its
  details load no longer leaves an empty body.
- Disassociate is a real button, ignores repeat clicks while pending,
  reports a failure, and keeps the website badge while other sites remain.
- Tabs carry tab/tabpanel roles; transitions respect reduced motion.
2026-10-23 11:50:18 -07:00
Nariman Jelveh d604735a09 fix(dashboard): Files keyboard and touch edge cases
- The marquee selects file rows only, not the Shared/Trash place rows Home
  shows on narrow screens.
- Row shortcuts stand aside while the touch action sheet is open.
- Breadcrumb long-press opens its menu on iPadOS too.
- A folder rendered before the saved view preferences load uses the
  defaults instead of throwing and blocking navigation.
2026-10-23 11:43:19 -07:00
Nariman Jelveh 1a807f089c fix(dashboard): Files columns follow the room the listing has
- Resizing a column past the listing's width clamps it instead of resetting
  every column to the defaults; a window too narrow for the saved widths
  lays out at the defaults but keeps the saved widths for when it widens.
- Columns and grid names re-fit when the window or sidebar resizes the
  listing, not only on the next navigation, and grid names hidden behind
  another tab are fitted when the Files tab is shown.
- Names are measured in the font they render in (the compact grid's is
  smaller), and a column drag relayouts at most once a frame.
2026-10-23 11:42:19 -07:00
Nariman Jelveh aba28d58bf fix(dashboard): keep an open rename across refreshes; re-sort renamed rows
A same-folder refresh (an upload or paste finishing, a sort change) rebuilt
every row and silently dropped a rename in progress; the editor now comes
back on the same item with the text and caret the user left.

A renamed row whose new name sorts last stayed where its old name was.
2026-10-23 11:38:16 -07:00
Nariman Jelveh 175e945f51 fix(dashboard): Files selection, preview and Trash/Shared edge cases
- A marquee drag without modifiers now replaces a selection made by click,
  Ctrl+A or the arrow keys instead of adding to it.
- With the image preview open, clicking another image switches to it, and
  moving onto a non-image closes it instead of keeping the old image up.
- Cut (Ctrl+X, multi-select menu) skips trashed items, which leave the Trash
  through Restore; Ctrl+V refuses the Trash and the Shared view like the
  Paste menu item does.
- Drops on the Shared sidebar entry or breadcrumb are refused (the drag
  snaps back) instead of sending writes to a path that isn't a folder.
- The Trash selection bar shows Delete permanently again after a selection
  elsewhere had hidden it.
2026-10-23 11:35:24 -07:00
Nariman Jelveh 5d995ee12e fix(dashboard): rename editor keeps entity-like names; rows follow remote renames
The name editor decoded its already-decoded value a second time, so opening
it on a file such as "R&amp;D.txt" and clicking away renamed it to "R&D.txt".

A rename or update from another tab only patched a row's attributes, while
its open/copy/share handlers kept using the entry they were rendered with;
the row now keeps that entry current and refits its name.
2026-10-23 11:30:44 -07:00
Nariman Jelveh 156dfb6b02 chore(dashboard): add the standard file header where it was missing 2026-10-23 11:21:01 -07:00
32 changed files with 879 additions and 185 deletions

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;
+5
View File
@@ -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);
+58 -30
View File
@@ -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
+192 -53
View File
@@ -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;
}
+8 -2
View File
@@ -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());
+5 -2
View File
@@ -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));
+17 -5
View File
@@ -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';
+29 -17
View File
@@ -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));
});
});
},
+19 -7
View File
@@ -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;
+7 -2
View File
@@ -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: '$',
});
+7
View File
@@ -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');
});
});
+25 -13
View File
@@ -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">';
+19
View File
@@ -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.
+2 -1
View File
@@ -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
View File
@@ -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;
-5
View File
@@ -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'));
+112 -1
View File
@@ -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;
+6 -2
View File
@@ -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. */
+11 -1
View File
@@ -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 () => {
+7 -4
View File
@@ -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;
+31
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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$&amp;b');
});
});