mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
86
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b276a4823 | ||
|
|
906ea0f7ee | ||
|
|
54c0d48d69 | ||
|
|
a3bf0bd150 | ||
|
|
2f3b9ec581 | ||
|
|
55c0225c67 | ||
|
|
01b0014c87 | ||
|
|
2590bfcd32 | ||
|
|
345f25d337 | ||
|
|
329e02347e | ||
|
|
09d0c5d640 | ||
|
|
b0c06f23c6 | ||
|
|
a67eea4ab2 | ||
|
|
bf8d9ea349 | ||
|
|
35aeb5189b | ||
|
|
e550ac1207 | ||
|
|
027280eec5 | ||
|
|
0a5cd46373 | ||
|
|
344e7bf406 | ||
|
|
95d846d13b | ||
|
|
3c07591878 | ||
|
|
f8eaf3264c | ||
|
|
af05f0deb9 | ||
|
|
563b6b0c80 | ||
|
|
0eea4cfb17 | ||
|
|
d748100ea9 | ||
|
|
b63175b6d5 | ||
|
|
c260e25f30 | ||
|
|
6c35c2b895 | ||
|
|
5b8a93365c | ||
|
|
ffaed41ade | ||
|
|
94f4ed9eba | ||
|
|
fd150cb475 | ||
|
|
4ca1fa5b63 | ||
|
|
251ceca2c9 | ||
|
|
49d2b010e2 | ||
|
|
22febda3fa | ||
|
|
6d8359de5c | ||
|
|
5d83d73a91 | ||
|
|
dada49ffbb | ||
|
|
75ed97e1d8 | ||
|
|
3f4ac45975 | ||
|
|
1877e040a0 | ||
|
|
1d8a7cb206 | ||
|
|
c89c191ab6 | ||
|
|
562e3ab9ac | ||
|
|
9af451a5d0 | ||
|
|
853beb0b1b | ||
|
|
acb306a82d | ||
|
|
1537846e08 | ||
|
|
b99b5c1bad | ||
|
|
92a5eae92d | ||
|
|
a90cbd29b3 | ||
|
|
93d1a60f33 | ||
|
|
a24980c44e | ||
|
|
a6f0d7c091 | ||
|
|
0bb656b582 | ||
|
|
05b455fa09 | ||
|
|
d95cfd102c | ||
|
|
10372d5824 | ||
|
|
1f0a199b34 | ||
|
|
de6b753a0b | ||
|
|
96d10bfe3f | ||
|
|
12cf5b3a31 | ||
|
|
60b9fc0366 | ||
|
|
4c47fb048f | ||
|
|
1913483f1c | ||
|
|
b100f2f329 | ||
|
|
64e1a93a1a | ||
|
|
ef2547e520 | ||
|
|
cddcc6d39f | ||
|
|
6ec1c0aaf1 | ||
|
|
97d25bb054 | ||
|
|
86304b1fbc | ||
|
|
6df270a133 | ||
|
|
08c5ed405e | ||
|
|
e2545d1b9c | ||
|
|
6acf64f31e | ||
|
|
f7884986f4 | ||
|
|
62743d0a2e | ||
|
|
132a60d53e | ||
|
|
e8c3e38027 | ||
|
|
79e1b3377e | ||
|
|
ea51080f06 | ||
|
|
5a0c2db90f | ||
|
|
83fd00d16d |
No files matched your search
@@ -38,6 +38,12 @@ function isTouchPrimaryDevice () {
|
||||
/** Widest the touch menu gets, so phones (90% of up to ~440px) are unaffected. */
|
||||
const MAX_TOUCH_MENU_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* The menu on screen. A long-press fires both contextmenu and taphold, and
|
||||
* the second must not stack another sheet over the first.
|
||||
*/
|
||||
let openMenu = null;
|
||||
|
||||
/** Labels of the destructive file actions, in the current language. */
|
||||
const dangerLabels = () => new Set([i18n('delete'), i18n('delete_permanently')]);
|
||||
|
||||
@@ -67,6 +73,8 @@ export default class ContextMenuModal {
|
||||
*/
|
||||
show (menuItems, targetRect, options = {}) {
|
||||
if ( this.backdrop ) return; // Already showing
|
||||
if ( openMenu && document.contains(openMenu.backdrop) ) return;
|
||||
openMenu = this;
|
||||
|
||||
this.menuItems = menuItems;
|
||||
// Stack of parent menus for submenu drill-in, and the anchor rect so we
|
||||
@@ -348,6 +356,7 @@ export default class ContextMenuModal {
|
||||
*/
|
||||
close () {
|
||||
if ( ! this.backdrop ) return;
|
||||
if ( openMenu === this ) openMenu = null;
|
||||
|
||||
// Remove event listeners
|
||||
if ( this.backdropClickHandler ) {
|
||||
|
||||
@@ -125,3 +125,24 @@ describe('ContextMenuModal accessibility', () => {
|
||||
expect(label()).toBe('New');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextMenuModal stacking', () => {
|
||||
it("doesn't open a second sheet over one already up", () => {
|
||||
setTouchViewport(390);
|
||||
openMenu();
|
||||
openMenu();
|
||||
expect(document.querySelectorAll('.context-menu-modal-backdrop')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('opens again once the first is closed', () => {
|
||||
vi.useFakeTimers();
|
||||
setTouchViewport(390);
|
||||
const first = new ContextMenuModal();
|
||||
first.show([{ label: 'Open', action: () => {} }], { left: 0, top: 0, width: 10, height: 10 });
|
||||
first.close();
|
||||
openMenu();
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
expect(document.querySelectorAll('.context-menu-modal-backdrop')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -89,15 +89,20 @@ const TabAccount = {
|
||||
h += `<span class="username">${html_encode(window.user.username)}</span>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
// A seat's name is the team's; the console and audit log key on it.
|
||||
if ( isOrgSeat(window.user) ) {
|
||||
// A seat's name and address are the team's; the console and audit
|
||||
// log key on the name, and admin-issued credentials go to the address.
|
||||
const setByTeamNote = () => {
|
||||
const team = orgSeatTeamName(window.user);
|
||||
h += `<span class="dashboard-settings-card-note">${
|
||||
return `<span class="dashboard-settings-card-note">${
|
||||
team
|
||||
? i18n('username_set_by_team', { team })
|
||||
: i18n('username_set_by_team_generic')
|
||||
}</span>`;
|
||||
} else {
|
||||
};
|
||||
if ( isOrgSeat(window.user) ) {
|
||||
h += setByTeamNote();
|
||||
} else if ( ! window.user.is_temp ) {
|
||||
// A temporary account can't change it until it is saved.
|
||||
h += `<button class="button change-username">${i18n('change_username')}</button>`;
|
||||
}
|
||||
h += '</div>';
|
||||
@@ -130,7 +135,9 @@ const TabAccount = {
|
||||
h += `<span class="user-email">${html_encode(window.user.email)}</span>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
h += `<button class="button change-email">${i18n('change_email')}</button>`;
|
||||
h += isOrgSeat(window.user)
|
||||
? setByTeamNote()
|
||||
: `<button class="button change-email">${i18n('change_email')}</button>`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
@@ -140,21 +147,25 @@ const TabAccount = {
|
||||
h += '<div class="dashboard-settings-card-icon">';
|
||||
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>';
|
||||
h += '</div>';
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${i18n('api_token')}</strong>`;
|
||||
h += `<span>${i18n('api_token_description')}</span>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
if ( window.user?.email_confirmed ) {
|
||||
h += `<button class="button copy-auth-token">${i18n('create_token')}</button>`;
|
||||
} else {
|
||||
// "Verify your email" is a dead end for an account with none.
|
||||
const tokenHint = window.user?.email
|
||||
// "Verify your email" is a dead end for an account with none.
|
||||
const tokenHint = window.user?.email_confirmed
|
||||
? null
|
||||
: window.user?.email
|
||||
? i18n('verify_email_to_create_token')
|
||||
: i18n('email_needed_to_create_token');
|
||||
h += '<div class="dashboard-settings-card-info">';
|
||||
h += `<strong>${i18n('api_token')}</strong>`;
|
||||
// Why the button is off, where keyboard, touch and screen reader
|
||||
// users can read it too, not only in a hover tooltip.
|
||||
h += `<span>${tokenHint ?? i18n('api_token_description')}</span>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
if ( ! tokenHint ) {
|
||||
h += `<button class="button copy-auth-token">${i18n('create_token')}</button>`;
|
||||
} else {
|
||||
// Disabled buttons have `pointer-events: none`, so the tooltip
|
||||
// lives on a wrapping span that still receives hover.
|
||||
h += `<span title="${html_encode(tokenHint)}" style="cursor: not-allowed;">`;
|
||||
h += `<span title="${tokenHint}" style="cursor: not-allowed;">`;
|
||||
h += `<button class="button copy-auth-token" disabled>${i18n('create_token')}</button>`;
|
||||
h += '</span>';
|
||||
}
|
||||
@@ -281,8 +292,11 @@ const TabAccount = {
|
||||
const $btn = $(this);
|
||||
const $hint = $el_window.find('.dashboard-section-account .dashboard-profile-hint');
|
||||
$btn.prop('disabled', true);
|
||||
// A picture saved meanwhile is newer than this removal.
|
||||
const seq = ++avatar_save_seq;
|
||||
// update_profile resolves with no profile when the request failed.
|
||||
const profile = await update_profile(window.user.username, { picture: null });
|
||||
if ( seq !== avatar_save_seq ) return;
|
||||
$btn.prop('disabled', false);
|
||||
if ( ! profile ) {
|
||||
$hint.text(i18n('profile_picture_remove_failed', [], false));
|
||||
|
||||
@@ -759,6 +759,12 @@ function showAddAppModal ({ $el_window }) {
|
||||
})();
|
||||
};
|
||||
|
||||
// Enter held on the "+" tile opens this with the first option focused;
|
||||
// its repeats must not pick that option.
|
||||
$overlay.on('keydown', '.myapps-add-option', function (e) {
|
||||
if ( e.key === 'Enter' && e.originalEvent?.repeat ) e.preventDefault();
|
||||
});
|
||||
|
||||
$overlay.on('click', '.myapps-add-option', function (e) {
|
||||
// The second click of a double-click on the "+" tile lands on
|
||||
// whichever option opened under the pointer; that isn't a choice.
|
||||
@@ -938,11 +944,15 @@ const TabApps = {
|
||||
h += '<div class="myapps-search-wrap">';
|
||||
h += '<div class="myapps-search-inner">';
|
||||
h += '<svg class="myapps-search-icon myapps-icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
|
||||
h += '<svg class="myapps-search-icon myapps-icon-clear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
// type="search" (plus autocomplete/autofill opt-outs) keeps Chrome from
|
||||
// offering email/contact autofill suggestions on focus.
|
||||
h += `<input type="search" name="myapps-search" class="myapps-search" placeholder="${i18n('search_apps')}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-form-type="other" data-lpignore="true" data-1p-ignore>`;
|
||||
// After the field, so Tab reaches it from there.
|
||||
h += `<button type="button" class="myapps-search-icon myapps-icon-clear" aria-label="${i18n('clear_search')}" title="${i18n('clear_search')}" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
||||
h += '</div>';
|
||||
// Says when a search finds nothing; the grid itself is re-rendered
|
||||
// silently.
|
||||
h += '<div class="myapps-search-status" role="status" aria-live="polite"></div>';
|
||||
// Touch devices' way into drag-to-reorder (CSS shows it only there);
|
||||
// toggles the reorder mode — see _setReorderMode.
|
||||
h += `<button type="button" class="myapps-reorder-btn" aria-pressed="false" aria-label="${i18n('edit_apps')}">${REORDER_BTN_ICON}</button>`;
|
||||
@@ -1018,6 +1028,15 @@ const TabApps = {
|
||||
// Tiles double as the app switcher for headless in-page apps: a
|
||||
// dot marks tiles whose app has an open (or minimized) window.
|
||||
// UIWindow fires this event on every window open/close.
|
||||
// Another browser tab may have rearranged the grid or its folders
|
||||
// meanwhile; this tab's copy would otherwise be what its next change
|
||||
// writes back over them.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if ( document.visibilityState !== 'visible' ) return;
|
||||
if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return;
|
||||
self.loadApps($el_window);
|
||||
});
|
||||
|
||||
document.addEventListener('dashboard-app-windows-changed', () => {
|
||||
self.updateRunningDots($el_window);
|
||||
});
|
||||
@@ -1315,6 +1334,9 @@ const TabApps = {
|
||||
// take over from there. While a folder is open the same arrows walk
|
||||
// ITS tiles: the grid behind is inert, so focus must not be there.
|
||||
$(document).off('keydown.myapps-keyboard').on('keydown.myapps-keyboard', function (e) {
|
||||
// Keys typed into an IME (choosing a CJK candidate) are its own.
|
||||
// jQuery's event doesn't carry isComposing; the browser's does.
|
||||
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
|
||||
// The Menu key or Shift+F10 on a focused tile opens its menu
|
||||
// (Open, Uninstall, a folder's Ungroup) at the tile; browsers
|
||||
// don't all send a contextmenu event for them.
|
||||
@@ -1337,8 +1359,8 @@ const TabApps = {
|
||||
// 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;
|
||||
// account dialog, the touch action sheet, or the phone drawer.
|
||||
if ( $('.dashboard-notifications-panel.open, .dashboard-dialog-overlay, .context-menu-modal-backdrop, .dashboard-sidebar.open').length ) return;
|
||||
|
||||
const $panelGrid = $el_window.find('.myapps-group-panel-grid');
|
||||
const pageTiles = ($panelGrid.length
|
||||
@@ -1358,7 +1380,8 @@ const TabApps = {
|
||||
if ( ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT' || ae.isContentEditable) ) {
|
||||
const emptySearch = $(ae).hasClass('myapps-search') && ae.value === '';
|
||||
// Enter in a search opens the first match, as in any launcher.
|
||||
if ( e.key === 'Enter' && $(ae).hasClass('myapps-search') && ! emptySearch && ! e.isComposing ) {
|
||||
if ( e.key === 'Enter' && $(ae).hasClass('myapps-search') && ! emptySearch ) {
|
||||
if ( e.originalEvent?.repeat ) return;
|
||||
const first = pageTiles.find(el => ! el.classList.contains('myapps-add-tile'));
|
||||
if ( first ) {
|
||||
e.preventDefault();
|
||||
@@ -1377,7 +1400,9 @@ const TabApps = {
|
||||
|
||||
if ( e.key === 'Enter' || e.key === ' ' ) {
|
||||
e.preventDefault();
|
||||
$(ae).trigger('click');
|
||||
// A held key acts once: opening a folder moves focus to its
|
||||
// first app, which a repeat would then launch.
|
||||
if ( ! e.originalEvent?.repeat ) $(ae).trigger('click');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1422,7 +1447,11 @@ const TabApps = {
|
||||
self.goToPage($el_window, self._page, false);
|
||||
return;
|
||||
}
|
||||
self.renderApps($el_window, { preservePage: true });
|
||||
// A grid already on screen re-flows at once rather than
|
||||
// blinking back through the load fade (see _spliceDeepLinkApp).
|
||||
const revealed = $el_window.find('.myapps-pager').length > 0
|
||||
&& $el_window.find('.myapps-pager-loading').length === 0;
|
||||
self.renderApps($el_window, { preservePage: true, instant: revealed });
|
||||
}, 100);
|
||||
});
|
||||
self._resizeObserver.observe($el_window.find('.myapps-container')[0]);
|
||||
@@ -1509,6 +1538,9 @@ const TabApps = {
|
||||
const emptyNotice = items.length === 0
|
||||
? (query ? buildNoMatchesHtml() : buildNoAppsHtml())
|
||||
: '';
|
||||
const status = query && items.length === 0 ? i18n('no_apps_match', [], false) : '';
|
||||
const $status = $el_window.find('.myapps-search-status');
|
||||
if ( $status.text() !== status ) $status.text(status);
|
||||
|
||||
// The add-an-app tile rides at the tail of the grid: after every app,
|
||||
// on the last page, and nowhere else — a drag can't place an app past
|
||||
@@ -1535,7 +1567,17 @@ const TabApps = {
|
||||
this._pageCount = Math.ceil(items.length / layout.perPage);
|
||||
this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1);
|
||||
|
||||
// Every tile is replaced; a keyboard user on one stays on it.
|
||||
const focused = document.activeElement;
|
||||
const focusedTile = focused?.classList.contains('myapps-tile') && $container[0].contains(focused)
|
||||
? tileIdentity(focused)
|
||||
: null;
|
||||
$container.html(emptyNotice + buildPagerHtml(items, layout, instant));
|
||||
if ( focusedTile ) {
|
||||
$container.find('.myapps-tile').toArray()
|
||||
.find(el => tileIdentity(el) === focusedTile)
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
// A tile mid-arrival (a deep-link landing installing its app — see
|
||||
// _spliceDeepLinkApp) stays parked invisible across re-renders; the
|
||||
// intro's arrival beat, not the render, is what reveals it.
|
||||
@@ -1936,6 +1978,9 @@ const TabApps = {
|
||||
// Enter commits (and gets out of the way); blur commits too, so a
|
||||
// click straight onto an app in the folder keeps the new name.
|
||||
$name.on('keydown', function (e) {
|
||||
// Enter and Escape that confirm or cancel an IME conversion
|
||||
// aren't the name's.
|
||||
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
|
||||
if ( e.key === 'Enter' ) {
|
||||
e.preventDefault();
|
||||
// The name box owns this Enter: the grid's document-level key
|
||||
@@ -2451,6 +2496,9 @@ const TabApps = {
|
||||
// the drag saves only the final position.
|
||||
_ejectToGrid (d) {
|
||||
const appName = d.tileEl.dataset.appName;
|
||||
// The eject is saved now, mid-drag; a drag cancelled after it puts
|
||||
// this back (see _endDrag).
|
||||
const preEject = { groups: JSON.parse(JSON.stringify(this._groups)), apps: this._apps.slice() };
|
||||
if ( ! this._ejectApp(d.$el_window, d.groupId, appName) ) return;
|
||||
this._closeGroup(d.$el_window);
|
||||
this.renderApps(d.$el_window, { preservePage: true, instant: true, duringDrag: true });
|
||||
@@ -2463,6 +2511,7 @@ const TabApps = {
|
||||
this._endDrag(false);
|
||||
return;
|
||||
}
|
||||
d.preEject = preEject;
|
||||
|
||||
// A folder-page flip armed just before the icon left the card must
|
||||
// not fire into the grid drag this has become.
|
||||
@@ -2912,6 +2961,16 @@ const TabApps = {
|
||||
// drop spot) — it is the tail of the drag, not an exit-the-mode tap.
|
||||
this._suppressEmptyTapBriefly();
|
||||
|
||||
// Cancelled (Escape, the window losing focus) after the app had been
|
||||
// carried out of its folder: the folder, a two-app one included, and
|
||||
// the order go back to what they were.
|
||||
if ( ! commit && d.preEject ) {
|
||||
this._groups = d.preEject.groups;
|
||||
this._apps = reconcileAppOrder(this._apps, d.preEject.apps.map(a => a.name));
|
||||
this.saveGroups();
|
||||
this.saveOrder();
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if ( mergeEl ) {
|
||||
changed = this._commitMerge(d, mergeEl);
|
||||
@@ -3709,6 +3768,9 @@ const TabApps = {
|
||||
const tiles = $el_window.find('.dashboard-section-apps.active .myapps-tile').toArray();
|
||||
const candidate = tiles.find(el => el.dataset.appName === appName)
|
||||
|| tiles.find(el => el.dataset.groupId && parseTileGroupApps(el).includes(appName));
|
||||
// A search that leaves the app out keeps its tile off the
|
||||
// grid however long this waits.
|
||||
if ( ! candidate && String($el_window.find('.myapps-search').val() || '').trim() !== '' ) return null;
|
||||
if ( candidate ) {
|
||||
const revealed = ! $el_window.find('.myapps-pager').hasClass('myapps-pager-loading');
|
||||
const img = candidate.querySelector('.myapps-tile-icon img');
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @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';
|
||||
|
||||
// The tab's window and dialog plumbing isn't what these tests exercise.
|
||||
vi.mock('../UIWindow.js', () => ({ begin_dashboard_tile_launch: vi.fn(), settle_dashboard_tile_launch: vi.fn() }));
|
||||
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/launchApp.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.i18n = globalThis.i18n ?? (key => key);
|
||||
window.icons = {};
|
||||
|
||||
const { default: TabApps } = await import('./TabApps.js');
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('TabApps.beginDeepLinkLaunch', () => {
|
||||
it("doesn't wait for a tile the search has left off the grid", async () => {
|
||||
vi.useFakeTimers();
|
||||
document.body.innerHTML = `
|
||||
<div class="window">
|
||||
<input class="myapps-search" value="zzz">
|
||||
<div class="dashboard-section-apps active"><div class="myapps-container"></div></div>
|
||||
</div>`;
|
||||
window.animate_window_opening = true;
|
||||
window.puter = { kv: { get: vi.fn(async () => null) } };
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
TabApps._apps = [{ name: 'app-center', title: 'App Center' }];
|
||||
TabApps._loadPromise = null;
|
||||
|
||||
let settled = false;
|
||||
const intro = TabApps.beginDeepLinkLaunch('app-center', $('.window')).then((tile) => {
|
||||
settled = true;
|
||||
return tile;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(await intro).toBe(null);
|
||||
TabApps._launchingApps.delete('app-center');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabApps cancelling a drag out of a folder', () => {
|
||||
it('puts the folder and the order back as they were', () => {
|
||||
document.body.innerHTML = '<div class="window"><div class="dashboard-section-apps active"><div class="myapps-container"><div class="myapps-page"><div class="myapps-tile" data-app-name="a"></div></div></div></div></div>';
|
||||
window.puter = { kv: { set: vi.fn(async () => {}), get: vi.fn(async () => null) } };
|
||||
TabApps._groupsLoaded = true;
|
||||
TabApps._orderLoaded = true;
|
||||
TabApps._groups = [{ id: 'g1', name: 'Work', apps: ['a', 'b'] }];
|
||||
TabApps._apps = [{ name: 'a' }, { name: 'b' }, { name: 'c' }];
|
||||
const tile = document.createElement('div');
|
||||
tile.dataset.appName = 'a';
|
||||
const $el_window = $('.window');
|
||||
vi.spyOn(TabApps, '_gridOrderNames').mockReturnValue(['g1', 'c']);
|
||||
vi.spyOn(TabApps, '_closeGroup').mockImplementation(() => {});
|
||||
vi.spyOn(TabApps, 'renderApps').mockImplementation(() => {});
|
||||
vi.spyOn(TabApps, '_updatePlaceholder').mockImplementation(() => {});
|
||||
|
||||
const d = { started: true, tileEl: tile, $el_window, groupId: 'g1', ejecting: true };
|
||||
TabApps._drag = d;
|
||||
TabApps._ejectToGrid(d);
|
||||
expect(TabApps._groups).toEqual([]);
|
||||
|
||||
TabApps._endDrag(false);
|
||||
|
||||
expect(TabApps._groups).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]);
|
||||
expect(JSON.parse(window.puter.kv.set.mock.calls.at(-2)[1])).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ import UIAlert from '../UIAlert.js';
|
||||
import generate_file_context_menu from '../../helpers/generateFileContextMenu.js';
|
||||
import update_title_based_on_uploads from '../../helpers/updateTitleBasedOnUploads.js';
|
||||
import item_icon from '../../helpers/itemIcon.js';
|
||||
import { user_facing_windows } from '../../helpers/windowVisibility.js';
|
||||
import { formatRelativeTime } from '../../i18n/formatTime.js';
|
||||
import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js';
|
||||
import new_context_menu_item from '../../helpers/newContextMenuItem.js';
|
||||
import publish_as_website from '../../helpers/publishAsWebsite.js';
|
||||
import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
|
||||
@@ -40,9 +41,10 @@ import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js'
|
||||
|
||||
import { icons } from '../../helpers/actionIcons.js';
|
||||
import list_all_shared from '../../helpers/listAllShared.js';
|
||||
import { can_restructure, can_share, remember_shared_root, remember_shared_roots } from '../../helpers/sharedAccess.js';
|
||||
import { can_restructure, can_share, remember_shared_root, remember_shared_roots, shared_root_for } from '../../helpers/sharedAccess.js';
|
||||
import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from_paths } from '../../helpers/sharePaths.js';
|
||||
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
|
||||
import { is_owned_by_me } from '../../helpers/pathOwner.js';
|
||||
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
|
||||
|
||||
const { html_encode, SelectionArea } = window;
|
||||
@@ -82,6 +84,29 @@ export function failedUploadsMessage (err) {
|
||||
return [i18n('upload_some_failed', [], false), ...lines].join('<br>');
|
||||
}
|
||||
|
||||
/** On a Mac, Ctrl+click is a right-click; Cmd is what adds to a selection. */
|
||||
const CTRL_CLICK_IS_SECONDARY = /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform ?? '');
|
||||
|
||||
/**
|
||||
* The row type-to-select lands on, or -1 for none. A repeated letter ("r",
|
||||
* "rr") steps through the names starting with it, past the selected one;
|
||||
* a longer prefix stays on the selected row while it still matches.
|
||||
*
|
||||
* @param {string[]} names - The rows' names, lower-cased, in listing order
|
||||
* @param {number} selected - Index of the selected row, or -1
|
||||
* @param {string} term - What has been typed, lower-cased
|
||||
* @returns {number}
|
||||
*/
|
||||
export function typeSelectTarget (names, selected, term) {
|
||||
const cycling = [...term].every(ch => ch === term[0]);
|
||||
const prefix = cycling ? term[0] : term;
|
||||
const matches = names.flatMap((name, i) => (name.startsWith(prefix) ? [i] : []));
|
||||
if ( matches.length === 0 ) return -1;
|
||||
const at = matches.indexOf(selected);
|
||||
if ( at === -1 ) return matches[0];
|
||||
return cycling ? matches[(at + 1) % matches.length] : selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabFiles - File browser tab component for the Puter Dashboard.
|
||||
*
|
||||
@@ -130,9 +155,9 @@ const TabFiles = {
|
||||
<div class="header">
|
||||
<div class="path">
|
||||
<div class="path-nav-buttons">
|
||||
<img draggable="false" class="path-btn path-btn-back path-btn-disabled" tabindex="0" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_back')}" src="${html_encode(window.icons['arrow-left-thin.svg'])}" title="${i18n('window_click_to_go_back')}">
|
||||
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="0" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
|
||||
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="0" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
|
||||
<img draggable="false" class="path-btn path-btn-back path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_back')}" src="${html_encode(window.icons['arrow-left-thin.svg'])}" title="${i18n('window_click_to_go_back')}">
|
||||
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
|
||||
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
|
||||
</div>
|
||||
<div class="path-breadcrumbs"></div>
|
||||
<div class="path-actions">
|
||||
@@ -234,6 +259,7 @@ const TabFiles = {
|
||||
}
|
||||
}
|
||||
if ( file.path ) $row.attr('data-path', file.path);
|
||||
if ( file.name && ! _this.applyHiddenRule($row, file.name) ) return;
|
||||
if ( typeof file.type !== 'undefined' ) $row.attr('data-type', file.type || '');
|
||||
// Refresh the visible Size/Modified cells too, not just the
|
||||
// hidden data attributes, so a remote overwrite is reflected.
|
||||
@@ -247,7 +273,7 @@ const TabFiles = {
|
||||
}
|
||||
if ( file.modified ) {
|
||||
$row.attr('data-modified', file.modified);
|
||||
$row.find('.item-modified').text(window.timeago.format(file.modified * 1000));
|
||||
$row.find('.item-modified').text(formatRelativeTime(file.modified * 1000, Date.now(), window.locale));
|
||||
}
|
||||
if (
|
||||
_this.isGridView() &&
|
||||
@@ -288,12 +314,18 @@ const TabFiles = {
|
||||
return;
|
||||
}
|
||||
|
||||
// A weblink's or app's icon is read from the file. Another folder
|
||||
// may have opened meanwhile, or the row arrived another way.
|
||||
const icon = await item_icon(file);
|
||||
if ( _this.renderingDirectory || _this.currentPath !== parentDir ) return;
|
||||
if ( _this.$el_window.find(`.files-tab .files .item[data-uid='${file.uid}']`).length > 0 ) return;
|
||||
|
||||
// If the directory was empty, drop the "No files in this directory."
|
||||
// placeholder before inserting the first real row — otherwise it
|
||||
// stays and overlaps the new item.
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
|
||||
|
||||
await _this.renderItem(file);
|
||||
await _this.renderItem(file, icon);
|
||||
|
||||
// Get the newly appended row (it's always last after renderItem)
|
||||
const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${file.uid}']`);
|
||||
@@ -647,6 +679,12 @@ const TabFiles = {
|
||||
// minimized — app window. UIWindow fires this on window open/close.
|
||||
document.addEventListener('dashboard-app-windows-changed', () => {
|
||||
this.updateOpenFileDots();
|
||||
// An app opened from Files (Enter, Open, Open With) would come up
|
||||
// under the preview, which sits above every window.
|
||||
const appWindows = $('.window[data-app]').not(this.$el_window).filter((_, w) => !! w.getAttribute('data-app'));
|
||||
if ( this.previewOpen && user_facing_windows(appWindows).some(is_window_on_screen) ) {
|
||||
this.closeImagePreview();
|
||||
}
|
||||
});
|
||||
|
||||
// Select mode's Select and Done buttons only show up to 480px (see
|
||||
@@ -809,7 +847,7 @@ const TabFiles = {
|
||||
|
||||
// When a context menu is open, yield control to keyboard.js
|
||||
if ( $('.context-menu').length > 0 ) {
|
||||
if ( (e.which >= 37 && e.which <= 40) || e.which === 13 || e.which === 27 ) {
|
||||
if ( (e.which >= 33 && e.which <= 40) || e.which === 13 || e.which === 27 ) {
|
||||
return;
|
||||
}
|
||||
if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) {
|
||||
@@ -838,6 +876,9 @@ const TabFiles = {
|
||||
// row's ⋯ button: the keyboard's only way to Share, Download or
|
||||
// open Properties.
|
||||
if ( e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') ) {
|
||||
// A focused folder, crumb or button gets the browser's
|
||||
// contextmenu event, and with it its own menu.
|
||||
if ( $(focused_el).is('a[href], button, select, [tabindex]') ) return;
|
||||
const row = $selectedRows.toArray().find(r => r === window.latest_selected_item) ?? $selectedRows[0];
|
||||
if ( row ) {
|
||||
e.preventDefault();
|
||||
@@ -934,10 +975,10 @@ const TabFiles = {
|
||||
if ( trashedItems.length > 0 ) {
|
||||
// Permanent delete with confirmation
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
message: i18n('confirm_delete_multiple_items', [], false),
|
||||
buttons: [
|
||||
{ label: i18n('delete'), value: 'delete', type: 'primary' },
|
||||
{ label: i18n('cancel'), value: 'cancel' },
|
||||
{ label: i18n('delete', [], false), value: 'delete', type: 'primary' },
|
||||
{ label: i18n('cancel', [], false), value: 'cancel' },
|
||||
],
|
||||
});
|
||||
if ( alert_resp === 'delete' ) {
|
||||
@@ -1037,16 +1078,16 @@ const TabFiles = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Arrow keys - Navigate items
|
||||
if ( e.which >= 37 && e.which <= 40 ) {
|
||||
// Arrow keys, Home/End and PageUp/PageDown - Navigate items
|
||||
if ( e.which >= 33 && e.which <= 40 ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if ( $allRows.length === 0 ) return false;
|
||||
|
||||
// If nothing selected, select first item
|
||||
// If nothing selected, select first item (End: the last)
|
||||
if ( $selectedRows.length === 0 ) {
|
||||
const $first = $allRows.first();
|
||||
const $first = e.which === 35 ? $allRows.last() : $allRows.first();
|
||||
$first.addClass('selected');
|
||||
window.active_element = $first.get(0);
|
||||
window.latest_selected_item = $first.get(0);
|
||||
@@ -1088,6 +1129,22 @@ const TabFiles = {
|
||||
case 40: // Down
|
||||
nextIndex = Math.min($allRows.length - 1, currentIndex + cols);
|
||||
break;
|
||||
case 36: // Home
|
||||
nextIndex = 0;
|
||||
break;
|
||||
case 35: // End
|
||||
nextIndex = $allRows.length - 1;
|
||||
break;
|
||||
case 33: // PageUp
|
||||
case 34: { // PageDown
|
||||
// A screenful of rows, whole rows of the grid.
|
||||
const rowHeight = $allRows.eq(0).outerHeight(true) || 1;
|
||||
const page = cols * Math.max(1, Math.floor($container.get(0).clientHeight / rowHeight));
|
||||
nextIndex = e.which === 33
|
||||
? Math.max(0, currentIndex - page)
|
||||
: Math.min($allRows.length - 1, currentIndex + page);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( nextIndex !== currentIndex ) {
|
||||
@@ -1176,46 +1233,15 @@ const TabFiles = {
|
||||
|
||||
_this.typeSearchTerm += e.key.toLocaleLowerCase();
|
||||
|
||||
let matches = [];
|
||||
const $currentSelected = $selectedRows.first();
|
||||
|
||||
// If selected item already matches, keep it
|
||||
if ( $currentSelected.length === 1 ) {
|
||||
const selectedName = ($currentSelected.attr('data-name') || '').toLowerCase();
|
||||
if ( selectedName.startsWith(_this.typeSearchTerm) ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search all rows for matches
|
||||
for ( let j = 0; j < $allRows.length; j++ ) {
|
||||
const name = ($allRows.eq(j).attr('data-name') || '').toLowerCase();
|
||||
if ( name.startsWith(_this.typeSearchTerm) ) {
|
||||
matches.push($allRows.get(j));
|
||||
}
|
||||
}
|
||||
|
||||
if ( matches.length > 0 ) {
|
||||
// If multiple matches and one is selected, cycle past it
|
||||
if ( $currentSelected.length > 0 && matches.length > 1 ) {
|
||||
let match_index;
|
||||
for ( let i = 0; i < matches.length - 1; i++ ) {
|
||||
if ( $(matches[i]).is($currentSelected) ) {
|
||||
match_index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( match_index !== undefined ) {
|
||||
matches.splice(0, match_index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Deselect all, select the match
|
||||
const names = $allRows.map((_, row) => (row.getAttribute('data-name') || '').toLowerCase()).get();
|
||||
const target = typeSelectTarget(names, $allRows.index($selectedRows.first()), _this.typeSearchTerm);
|
||||
if ( target !== -1 ) {
|
||||
const row = $allRows.get(target);
|
||||
$allRows.removeClass('selected');
|
||||
$(matches[0]).addClass('selected');
|
||||
window.active_element = matches[0];
|
||||
window.latest_selected_item = matches[0];
|
||||
matches[0].scrollIntoView({ block: 'nearest' });
|
||||
$(row).addClass('selected');
|
||||
window.active_element = row;
|
||||
window.latest_selected_item = row;
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
_this.updateFooterStats();
|
||||
}
|
||||
|
||||
@@ -1617,7 +1643,10 @@ const TabFiles = {
|
||||
// Download button
|
||||
$actions.find('.download-btn').on('click', function () {
|
||||
const selectedRows = document.querySelectorAll('.files-tab .row.selected');
|
||||
if ( selectedRows.length > 0 ) {
|
||||
// One file downloads as itself, as from its menu, not as a zip.
|
||||
if ( selectedRows.length === 1 && $(selectedRows[0]).attr('data-is_dir') !== '1' ) {
|
||||
window.trigger_download([$(selectedRows[0]).attr('data-path')]);
|
||||
} else if ( selectedRows.length > 0 ) {
|
||||
window.zipItems(Array.from(selectedRows), _this.currentPath, true);
|
||||
}
|
||||
});
|
||||
@@ -1663,13 +1692,14 @@ const TabFiles = {
|
||||
|
||||
if ( anyTrashed ) {
|
||||
const confirmed = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
message: i18n('confirm_delete_multiple_items', [], false),
|
||||
buttons: [
|
||||
{ label: i18n('delete'), value: 'delete', type: 'primary' },
|
||||
{ label: i18n('cancel'), value: 'cancel' },
|
||||
{ label: i18n('delete', [], false), value: 'delete', type: 'primary' },
|
||||
{ label: i18n('cancel', [], false), value: 'cancel' },
|
||||
],
|
||||
});
|
||||
if ( confirmed === 'delete' ) {
|
||||
$actions.removeClass('visible');
|
||||
for ( const row of selectedRows ) {
|
||||
await window.delete_item(row);
|
||||
}
|
||||
@@ -1678,10 +1708,10 @@ const TabFiles = {
|
||||
} else {
|
||||
const rows = await _this.restructurableRows(selectedRows);
|
||||
if ( rows.length > 0 ) {
|
||||
$actions.removeClass('visible');
|
||||
window.move_items(rows, window.trash_path);
|
||||
}
|
||||
}
|
||||
$actions.removeClass('visible');
|
||||
});
|
||||
|
||||
// Done button (exits select mode on mobile)
|
||||
@@ -1802,16 +1832,20 @@ const TabFiles = {
|
||||
|
||||
/**
|
||||
* Moves dragged rows into a folder, leaving out the ones already in it (a
|
||||
* drop back where they came from does nothing, as on the breadcrumbs)
|
||||
* and the ones the user can't move (see restructurableRows). move_items
|
||||
* would stop on an alert apiece for either.
|
||||
* drop back where they came from does nothing, as on the breadcrumbs),
|
||||
* the folder itself and its ancestors, and the ones the user can't move
|
||||
* (see restructurableRows). move_items would stop on an alert apiece.
|
||||
*
|
||||
* @param {HTMLElement[]} rows
|
||||
* @param {string} targetPath
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async moveDraggedRows (rows, targetPath) {
|
||||
const elsewhere = Array.from(rows).filter((row) => path.dirname($(row).attr('data-path')) !== targetPath);
|
||||
const elsewhere = Array.from(rows).filter((row) => {
|
||||
const rowPath = $(row).attr('data-path');
|
||||
return path.dirname(rowPath) !== targetPath
|
||||
&& targetPath !== rowPath && ! targetPath.startsWith(`${rowPath}/`);
|
||||
});
|
||||
const movable = await this.restructurableRows(elsewhere);
|
||||
if ( movable.length > 0 ) await window.move_items(movable, targetPath);
|
||||
},
|
||||
@@ -2131,7 +2165,8 @@ const TabFiles = {
|
||||
* Whether items can be created, uploaded or pasted into a folder. Not the
|
||||
* Trash or a folder inside it (Restore needs metadata only Delete writes),
|
||||
* not the Shared view (a query, not a directory), not `/`, and not a
|
||||
* folder Files just failed to list.
|
||||
* folder Files just failed to list, and not a folder shared with the
|
||||
* user to view only (where the server refuses each of them).
|
||||
*
|
||||
* @param {string} folderPath
|
||||
* @returns {boolean}
|
||||
@@ -2142,7 +2177,42 @@ const TabFiles = {
|
||||
&& folderPath !== '/'
|
||||
&& folderPath !== window.trash_path
|
||||
&& ! folderPath.startsWith(`${window.trash_path}/`)
|
||||
&& folderPath !== window.shared_path;
|
||||
&& folderPath !== window.shared_path
|
||||
&& ! this.isReadOnlyShare(folderPath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Applies the hidden-files rule to a row whose name changed, as listing
|
||||
* the folder again would: gone while dot-files are hidden, dimmed while
|
||||
* they show.
|
||||
*
|
||||
* @param {jQuery} $row
|
||||
* @param {string} name - The entry's own name (a trashed one's is its uid)
|
||||
* @returns {boolean} Whether the row stays
|
||||
*/
|
||||
applyHiddenRule ($row, name) {
|
||||
if ( ! isEntryVisible(name, showHiddenFiles()) ) {
|
||||
$row.remove();
|
||||
this.renderEmptyPlaceholderIfNeeded();
|
||||
this.updateFooterStats();
|
||||
return false;
|
||||
}
|
||||
$row.toggleClass('item-revealed', isHiddenName(name));
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a folder is someone else's, shared with the user without write
|
||||
* access, by the shares already loaded (the Shared listing records them).
|
||||
* Unknown counts as writable: the server still has the last word.
|
||||
*
|
||||
* @param {string} folderPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isReadOnlyShare (folderPath) {
|
||||
if ( is_owned_by_me(folderPath) ) return false;
|
||||
const mode = shared_root_for(folderPath)?.mode;
|
||||
return !! mode && ! ['write', 'manage'].includes(mode);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -2430,7 +2500,13 @@ const TabFiles = {
|
||||
// on — a row for `targetPath/New Folder` sitting in another folder's
|
||||
// listing, with the rename editor open on it. Drop the click instead,
|
||||
// exactly as renderDirectory drops navigation clicks while it renders.
|
||||
if ( this.renderingDirectory ) return;
|
||||
// A refresh of this folder keeps its rows up, so the click would look
|
||||
// ignored: take it over, and go on once its listing is in.
|
||||
if ( this.renderingDirectory ) {
|
||||
if ( ! this.refreshInFlight || targetPath !== this.currentPath ) return;
|
||||
await this.renderDirectory(targetPath);
|
||||
if ( this.renderingDirectory || this.currentPath !== targetPath ) return;
|
||||
}
|
||||
|
||||
// Invoked from a sidebar or breadcrumb menu for a folder that isn't
|
||||
// the one on screen — go there first, so the row (and the rename that
|
||||
@@ -2499,6 +2575,9 @@ const TabFiles = {
|
||||
// hand it to the incremental adder once that guard lifts.
|
||||
if ( ! document.body.contains($row[0]) ) {
|
||||
detached = result;
|
||||
// Undo finds the folder by these, whatever the row's fate.
|
||||
$row.attr({ 'data-uid': result.uid, 'data-path': result.path });
|
||||
window.actions_history.push({ operation: 'create_folder', data: $row });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2510,6 +2589,7 @@ const TabFiles = {
|
||||
$row.attr('data-path', result.path);
|
||||
$row.attr('data-modified', result.modified);
|
||||
$row.removeAttr('data-pending');
|
||||
window.actions_history.push({ operation: 'create_folder', data: $row });
|
||||
|
||||
// Our prediction lost a race with another client. Correct the
|
||||
// row, but never over text the user has started typing.
|
||||
@@ -2529,7 +2609,7 @@ const TabFiles = {
|
||||
_this.renderEmptyPlaceholderIfNeeded();
|
||||
_this.updateFooterStats();
|
||||
if ( err?.code === 'directory_depth_limit_exceeded' ) {
|
||||
UIAlert({ message: i18n('directory_depth_limit_exceeded') });
|
||||
UIAlert({ message: i18n('directory_depth_limit_exceeded', [], false) });
|
||||
} else if ( err?.message ) {
|
||||
UIAlert(err.message);
|
||||
}
|
||||
@@ -2597,14 +2677,17 @@ const TabFiles = {
|
||||
* @param {Object} [options] - Optional settings
|
||||
* @param {boolean} [options.skipUrlUpdate] - If true, don't update browser URL
|
||||
* @param {boolean} [options.skipNavHistory] - If true, don't add to navigation history
|
||||
* @param {boolean} [options.force] - Supersede a navigation that is still loading
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async renderDirectory (target, options = {}) {
|
||||
if ( this.renderBlocked(target) ) return;
|
||||
if ( ! options.force && this.renderBlocked() ) return;
|
||||
this.renderingDirectory = true;
|
||||
// A newer render supersedes this one; past each await, a superseded
|
||||
// render stops without touching the listing or the flags.
|
||||
const seq = this.renderSeq = (this.renderSeq ?? 0) + 1;
|
||||
// currentPath only moves once the listing arrives.
|
||||
this.renderTarget = target;
|
||||
const _this = this;
|
||||
// The preview shows a row of the listing being replaced.
|
||||
if ( this.previewOpen && target !== this.currentPath ) this.closeImagePreview();
|
||||
@@ -3124,7 +3207,7 @@ const TabFiles = {
|
||||
<div class="item-metadata">
|
||||
${file.is_dir ? '<div class="item-size"></div>' : `<div class="item-size">${this.formatFileSize(file.size)}</div>`}
|
||||
<div class="col-spacer"></div>
|
||||
<div class="item-modified">${window.timeago.format(file.modified * 1000)}</div>
|
||||
<div class="item-modified">${html_encode(formatRelativeTime(file.modified * 1000, Date.now(), window.locale))}</div>
|
||||
</div>
|
||||
<div class="col-spacer"></div>
|
||||
<div class="item-more">${icons.more}</div>
|
||||
@@ -3294,7 +3377,7 @@ const TabFiles = {
|
||||
// gets the grey context-menu state instead (added by the
|
||||
// contextmenu handler), and an existing selection is preserved
|
||||
// so the multi-select menu still applies to it.
|
||||
if ( e.button === 2 ) {
|
||||
if ( e.button === 2 || (CTRL_CLICK_IS_SECONDARY && e.ctrlKey && e.button === 0) ) {
|
||||
window.active_element = el_item;
|
||||
window.active_item_container = el_item.closest('.files');
|
||||
return;
|
||||
@@ -3470,6 +3553,7 @@ const TabFiles = {
|
||||
// inside it) under its new name.
|
||||
if ( fsentry?.is_dir ) _this.rebaseNavHistory(old_path, path.join(path.dirname(old_path), fsentry.name ?? new_name));
|
||||
if ( ! fsentry || ! document.body.contains(el_item) ) return;
|
||||
if ( ! _this.applyHiddenRule($(el_item), fsentry.name ?? new_name) ) return;
|
||||
$(el_item).attr('data-type', fsentry.type ?? '');
|
||||
_this.insertAtSortedPosition($(el_item), fsentry);
|
||||
item_icon(fsentry).then(({ image }) => {
|
||||
@@ -3706,13 +3790,18 @@ const TabFiles = {
|
||||
event.clientY >= rect.top && event.clientY <= rect.bottom;
|
||||
|
||||
if ( inFiles ) {
|
||||
// Dropped in file list but not on a folder → move to current dir
|
||||
// Dropped in file list but not on a folder → move to
|
||||
// the folder shown, or still opening (whose listing
|
||||
// is what the drop landed on)
|
||||
const itemsToMove = [el_item];
|
||||
$('.item-selected-clone').find('.row').each(function () {
|
||||
itemsToMove.push(this);
|
||||
});
|
||||
const destPath = _this.renderingDirectory && String(_this.renderTarget).startsWith('/')
|
||||
? _this.renderTarget
|
||||
: _this.currentPath;
|
||||
|
||||
void _this.dropRowsInto(itemsToMove, _this.currentPath, event);
|
||||
void _this.dropRowsInto(itemsToMove, destPath, event);
|
||||
} else {
|
||||
// Dropped outside file list → cancel, navigate back
|
||||
_this.navigateBackFromSpringLoad();
|
||||
@@ -3734,7 +3823,9 @@ const TabFiles = {
|
||||
|
||||
if ( file.is_dir ) {
|
||||
$(el_item).droppable({
|
||||
accept: '.row',
|
||||
// Not a target for a drag it is part of: that would move it
|
||||
// into itself, and spring-load it open mid-drag.
|
||||
accept: (draggable) => draggable.hasClass('row') && ! el_item.classList.contains('dragging-source'),
|
||||
tolerance: 'pointer',
|
||||
|
||||
drop: async function (event, ui) {
|
||||
@@ -3847,9 +3938,10 @@ const TabFiles = {
|
||||
|
||||
const targetPath = $(el_item).attr('data-path');
|
||||
|
||||
// Don't allow drop on trash folder
|
||||
// Don't allow drop on trash folder, or one shared view-only
|
||||
if ( targetPath === window.trash_path ||
|
||||
targetPath?.startsWith(`${window.trash_path}/`) ) {
|
||||
targetPath?.startsWith(`${window.trash_path}/`) ||
|
||||
TabFiles.isReadOnlyShare(targetPath) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3875,9 +3967,10 @@ const TabFiles = {
|
||||
|
||||
const targetPath = $(el_item).attr('data-path');
|
||||
|
||||
// Block uploads to trash
|
||||
// Block uploads to trash, and to a folder shared view-only
|
||||
if ( targetPath === window.trash_path ||
|
||||
targetPath?.startsWith(`${window.trash_path}/`) ) {
|
||||
targetPath?.startsWith(`${window.trash_path}/`) ||
|
||||
TabFiles.isReadOnlyShare(targetPath) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3988,10 +4081,10 @@ const TabFiles = {
|
||||
return UIAlert({
|
||||
message: i18n('item_already_exists', [name], false),
|
||||
buttons: [
|
||||
{ label: i18n('replace'), type: 'primary', value: 'replace' },
|
||||
... multiple ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
|
||||
{ label: i18n('keep_both'), value: 'keep_both' },
|
||||
... multiple ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
|
||||
{ label: i18n('replace', [], false), type: 'primary', value: 'replace' },
|
||||
... multiple ? [{ label: i18n('replace_all', [], false), value: 'replace_all' }] : [],
|
||||
{ label: i18n('keep_both', [], false), value: 'keep_both' },
|
||||
... multiple ? [{ label: i18n('skip', [], false), value: 'skip' }] : [{ label: i18n('cancel', [], false), value: 'cancel' }],
|
||||
],
|
||||
});
|
||||
},
|
||||
@@ -4036,6 +4129,8 @@ const TabFiles = {
|
||||
|
||||
const multiple_items = window.clipboard.length > 1;
|
||||
const moved_item_paths = [];
|
||||
// In the shape the desktop's move_items records, for undo_move.
|
||||
const moved_items = [];
|
||||
// Set once the user picks "Replace all" on a conflict; later items
|
||||
// then overwrite without asking again.
|
||||
let overwrite_all = false;
|
||||
@@ -4061,6 +4156,13 @@ const TabFiles = {
|
||||
// with "Keep Both" it differs from the source's name.
|
||||
if ( resp?.moved?.path ) {
|
||||
moved_item_paths.push(resp.moved.path);
|
||||
if ( item.path ) {
|
||||
const { uid, path: movedPath, name, is_dir } = resp.moved;
|
||||
moved_items.push({
|
||||
options: { uid, path: movedPath, name, is_dir, icon: await item_icon(resp.moved) },
|
||||
original_path: item.path,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch ( err ) {
|
||||
// Same conflict resolution as the desktop's move_items:
|
||||
@@ -4090,6 +4192,9 @@ const TabFiles = {
|
||||
}
|
||||
|
||||
window.clipboard = [];
|
||||
if ( moved_items.length > 0 ) {
|
||||
window.actions_history.push({ operation: 'move', data: moved_items });
|
||||
}
|
||||
return moved_item_paths;
|
||||
},
|
||||
|
||||
@@ -4151,12 +4256,12 @@ const TabFiles = {
|
||||
|
||||
const countText = count => i18n(count === 1 ? 'items_count_one' : 'items_count_other', { count }, false);
|
||||
$footer.find('.files-footer-item-count').text(
|
||||
`${countText(totalCount)} · ${window.byte_format(totalSize)}`,
|
||||
`${countText(totalCount)} · ${this.formatFileSize(totalSize)}`,
|
||||
);
|
||||
|
||||
if ( selectedCount > 0 ) {
|
||||
$footer.find('.files-footer-selected-items')
|
||||
.text(`${i18n('items_selected', { items: countText(selectedCount) }, false)} · ${window.byte_format(selectedSize)}`)
|
||||
.text(`${i18n('items_selected', { items: countText(selectedCount) }, false)} · ${this.formatFileSize(selectedSize)}`)
|
||||
.css('display', 'inline');
|
||||
$footer.find('.files-footer-separator').css('display', 'inline');
|
||||
} else {
|
||||
@@ -4164,6 +4269,12 @@ const TabFiles = {
|
||||
$footer.find('.files-footer-separator').css('display', 'none');
|
||||
}
|
||||
|
||||
// Rows come and go through here (deleted, trashed, moved away, here or
|
||||
// elsewhere); the preview goes with the row it shows.
|
||||
if ( this.previewOpen && ! allRows.some(row => row.getAttribute('data-uid') === this.previewCurrentUid) ) {
|
||||
this.closeImagePreview();
|
||||
}
|
||||
|
||||
// The floating action bar only appears in select mode (narrow
|
||||
// screens); elsewhere multi-selection relies on the context menu.
|
||||
if ( this.selectModeActive && selectedCount >= 1 ) {
|
||||
@@ -4295,8 +4406,9 @@ const TabFiles = {
|
||||
$selectBtn.removeClass('active').attr('aria-pressed', 'false');
|
||||
// Clear all selections when exiting select mode
|
||||
this.$el_window.find('.files .row.selected').removeClass('selected');
|
||||
this.updateFooterStats();
|
||||
}
|
||||
// Entering with rows already selected (an upload's) shows the bar.
|
||||
this.updateFooterStats();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4325,11 +4437,14 @@ const TabFiles = {
|
||||
*/
|
||||
navigateBackFromSpringLoad () {
|
||||
if ( ! this.springLoadedOriginalPath ) return;
|
||||
// The folder the drag opened may still be loading; going back
|
||||
// overrides it rather than being dropped.
|
||||
const force = true;
|
||||
|
||||
// Walk back through nav history to find the original path
|
||||
for ( let i = window.dashboard_nav_history_current_position - 1; i >= 0; i-- ) {
|
||||
if ( window.dashboard_nav_history[i] === this.springLoadedOriginalPath ) {
|
||||
this.goToHistoryPosition(i);
|
||||
this.goToHistoryPosition(i, { force });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -4338,12 +4453,12 @@ const TabFiles = {
|
||||
// forward too.
|
||||
for ( let i = window.dashboard_nav_history_current_position + 1; i < window.dashboard_nav_history.length; i++ ) {
|
||||
if ( window.dashboard_nav_history[i] === this.springLoadedOriginalPath ) {
|
||||
this.goToHistoryPosition(i);
|
||||
this.goToHistoryPosition(i, { force });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback: render the original path directly
|
||||
this.renderDirectory(this.springLoadedOriginalPath);
|
||||
this.renderDirectory(this.springLoadedOriginalPath, { force });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4433,22 +4548,22 @@ const TabFiles = {
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Whether a render of `target` would be dropped: one is already loading,
|
||||
* and it isn't a refresh of the folder on screen, which gives way to
|
||||
* opening another folder.
|
||||
* Whether a render of `target` would be dropped: a navigation is already
|
||||
* loading. A refresh of the folder on screen gives way to anything,
|
||||
* another refresh included, since its listing may predate what the newer
|
||||
* one was asked for (an upload that just finished).
|
||||
*
|
||||
* @param {string} target
|
||||
* @returns {boolean}
|
||||
*/
|
||||
renderBlocked (target) {
|
||||
return this.renderingDirectory && ! (this.refreshInFlight && target !== this.currentPath);
|
||||
renderBlocked () {
|
||||
return this.renderingDirectory && ! this.refreshInFlight;
|
||||
},
|
||||
|
||||
pushNavHistory (newPath) {
|
||||
// renderDirectory drops a navigation while another is loading, so
|
||||
// history must not move either, or Back/Forward would point at
|
||||
// folders that were never shown.
|
||||
if ( this.renderBlocked(newPath) ) return;
|
||||
if ( this.renderBlocked() ) return;
|
||||
// If history is empty, initialize with this path
|
||||
if ( window.dashboard_nav_history.length === 0 ) {
|
||||
window.dashboard_nav_history = [newPath];
|
||||
@@ -4470,11 +4585,11 @@ const TabFiles = {
|
||||
* @param {number} position - Index into dashboard_nav_history
|
||||
* @returns {void}
|
||||
*/
|
||||
goToHistoryPosition (position) {
|
||||
if ( this.renderBlocked(window.dashboard_nav_history[position]) ) return;
|
||||
goToHistoryPosition (position, { force = false } = {}) {
|
||||
if ( ! force && this.renderBlocked() ) return;
|
||||
window.dashboard_nav_history_current_position = position;
|
||||
this.updateNavButtonStates();
|
||||
this.renderDirectory(window.dashboard_nav_history[position]);
|
||||
this.renderDirectory(window.dashboard_nav_history[position], { force });
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4492,9 +4607,12 @@ const TabFiles = {
|
||||
const forwardBtn = this.$el_window.find('.path-btn-forward');
|
||||
const upBtn = this.$el_window.find('.path-btn-up');
|
||||
|
||||
// Out of the Tab order while disabled, as a disabled <button> is: at
|
||||
// its 10% opacity a focus ring on it can't be seen.
|
||||
const setDisabled = ($btn, disabled) => $btn
|
||||
.toggleClass('path-btn-disabled', disabled)
|
||||
.attr('aria-disabled', disabled ? 'true' : 'false');
|
||||
.attr('aria-disabled', disabled ? 'true' : 'false')
|
||||
.attr('tabindex', disabled ? '-1' : '0');
|
||||
|
||||
setDisabled(backBtn, window.dashboard_nav_history_current_position === 0);
|
||||
setDisabled(forwardBtn, window.dashboard_nav_history_current_position >= window.dashboard_nav_history.length - 1);
|
||||
@@ -4732,10 +4850,10 @@ const TabFiles = {
|
||||
html: i18n('delete_permanently'),
|
||||
onClick: async function () {
|
||||
const confirmed = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
message: i18n('confirm_delete_multiple_items', [], false),
|
||||
buttons: [
|
||||
{ label: i18n('delete'), value: 'delete', type: 'primary' },
|
||||
{ label: i18n('cancel'), value: 'cancel' },
|
||||
{ label: i18n('delete', [], false), value: 'delete', type: 'primary' },
|
||||
{ label: i18n('cancel', [], false), value: 'cancel' },
|
||||
],
|
||||
});
|
||||
if ( confirmed === 'delete' ) {
|
||||
@@ -4826,6 +4944,12 @@ const TabFiles = {
|
||||
|
||||
if ( uploadPromise ) {
|
||||
const result = await uploadPromise;
|
||||
// Undo deletes by path, so a stand-in row serves
|
||||
// when the new one isn't on screen.
|
||||
window.actions_history.push({
|
||||
operation: 'create_file',
|
||||
data: $('<div>').attr({ 'data-uid': result.uid, 'data-path': result.path }),
|
||||
});
|
||||
if ( targetPath === _this.currentPath ) {
|
||||
// Remove empty-directory placeholder if present
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
|
||||
@@ -4953,7 +5077,8 @@ const TabFiles = {
|
||||
// the Shared view aren't real fsentries, and Trash itself only offers
|
||||
// Empty Trash — same as the desktop's folder menus.
|
||||
if ( targetPath !== '/' && ! isTrashFolder && ! isSharedView ) {
|
||||
const targetName = path.basename(targetPath);
|
||||
// A trashed folder's path ends in its uid, not its name.
|
||||
const targetName = this.trashedNames.get(targetPath) ?? path.basename(targetPath);
|
||||
|
||||
items.push('-');
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.i18n = globalThis.i18n ?? (key => key);
|
||||
window.icons = {};
|
||||
|
||||
const { default: TabFiles, failedUploadsMessage, rebasePath } = await import('./TabFiles.js');
|
||||
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
|
||||
|
||||
const TRASH = '/alice/Trash';
|
||||
|
||||
@@ -142,6 +142,16 @@ describe('TabFiles.moveDraggedRows', () => {
|
||||
expect(window.move_items).toHaveBeenCalledWith([moves], '/alice/Documents');
|
||||
});
|
||||
|
||||
it('leaves out the target folder and any folder holding it', async () => {
|
||||
const target = rowAt('/alice/Documents/Work');
|
||||
const ancestor = rowAt('/alice/Documents');
|
||||
const moves = rowAt('/alice/Pictures/b.png');
|
||||
|
||||
await TabFiles.moveDraggedRows([target, ancestor, moves], '/alice/Documents/Work');
|
||||
|
||||
expect(window.move_items).toHaveBeenCalledWith([moves], '/alice/Documents/Work');
|
||||
});
|
||||
|
||||
it('moves nothing when every row is already there', async () => {
|
||||
await TabFiles.moveDraggedRows([rowAt('/alice/Documents/a.txt'), rowAt('/alice/Documents/b.txt')], '/alice/Documents');
|
||||
|
||||
@@ -265,3 +275,263 @@ describe('TabFiles opening a folder during a refresh', () => {
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles New Folder', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
window.global_element_id = 1;
|
||||
window.activate_item_name_editor = vi.fn();
|
||||
window.actions_history = [{ operation: 'upload', data: ['/alice/Documents/notes.txt'] }];
|
||||
window.puter = { fs: { mkdir: vi.fn(async () => ({
|
||||
uid: 'f1', name: 'New Folder', path: '/alice/Documents/New Folder', is_dir: true,
|
||||
})) } };
|
||||
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
|
||||
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}"></div>`);
|
||||
});
|
||||
vi.spyOn(TabFiles, 'insertAtSortedPosition').mockImplementation(() => {});
|
||||
vi.spyOn(TabFiles, 'applyColumnWidths').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('is what Undo takes back, not the upload before it', async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles._creatingItem = 0;
|
||||
|
||||
await TabFiles.createFolderInstant('/alice/Documents');
|
||||
|
||||
const last = window.actions_history.at(-1);
|
||||
expect(last.operation).toBe('create_folder');
|
||||
expect($(last.data).attr('data-path')).toBe('/alice/Documents/New Folder');
|
||||
});
|
||||
|
||||
it('is made, not dropped, while the folder is refreshing', async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
TabFiles._creatingItem = 0;
|
||||
window.home_path = '/alice';
|
||||
window.puter.fs.readdir = vi.fn(async () => []);
|
||||
TabFiles.renderingDirectory = true;
|
||||
TabFiles.refreshInFlight = true;
|
||||
|
||||
await TabFiles.createFolderInstant('/alice/Documents');
|
||||
|
||||
expect(window.puter.fs.mkdir).toHaveBeenCalled();
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.moveClipboardItems', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice' };
|
||||
window.actions_history = [{ operation: 'upload', data: ['/alice/Documents/notes.txt'] }];
|
||||
window.clipboard = [{ path: '/alice/Documents/a.txt', uid: 'a1' }];
|
||||
window.puter = { fs: { move: vi.fn(async () => ({
|
||||
moved: { uid: 'a1', name: 'a.txt', path: '/alice/Pictures/a.txt', is_dir: false },
|
||||
})) } };
|
||||
});
|
||||
|
||||
it('is what Undo takes back, not the upload before it', async () => {
|
||||
await TabFiles.moveClipboardItems('/alice/Pictures');
|
||||
|
||||
const last = window.actions_history.at(-1);
|
||||
expect(last.operation).toBe('move');
|
||||
expect(last.data).toEqual([expect.objectContaining({
|
||||
original_path: '/alice/Documents/a.txt',
|
||||
options: expect.objectContaining({ uid: 'a1', path: '/alice/Pictures/a.txt' }),
|
||||
})]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles refreshing a folder during a refresh of it', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
|
||||
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}" data-name="${file.name}"></div>`);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the later listing, not the earlier one's", async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
const entry = name => ({ uid: name, name, path: `/alice/Documents/${name}`, is_dir: false, size: 1, modified: 0 });
|
||||
let finishFirst;
|
||||
window.puter = { fs: { readdir: vi.fn()
|
||||
.mockImplementationOnce(() => new Promise(resolve => {
|
||||
finishFirst = resolve;
|
||||
}))
|
||||
.mockImplementationOnce(async () => [entry('a.txt'), entry('b.txt')]) } };
|
||||
|
||||
const first = TabFiles.renderDirectory('/alice/Documents', { consistency: 'strong' });
|
||||
await TabFiles.renderDirectory('/alice/Documents', { consistency: 'strong' });
|
||||
finishFirst([entry('a.txt')]);
|
||||
await first;
|
||||
|
||||
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get().sort()).toEqual(['a.txt', 'b.txt']);
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.toggleSelectMode', () => {
|
||||
beforeEach(() => {
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
});
|
||||
|
||||
it('shows the action bar for rows selected before it was entered', () => {
|
||||
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
|
||||
$('.files .row').first().addClass('selected');
|
||||
TabFiles.selectModeActive = false;
|
||||
|
||||
TabFiles.toggleSelectMode();
|
||||
|
||||
expect($('.files-selection-actions').hasClass('visible')).toBe(true);
|
||||
TabFiles.toggleSelectMode();
|
||||
expect($('.files-selection-actions').hasClass('visible')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles footer', () => {
|
||||
it('gives sizes as the rows do', () => {
|
||||
window.trash_path = TRASH;
|
||||
mountListing(['a.txt'], '/alice/Documents');
|
||||
$('.files .row').attr('data-size', '1536').addClass('selected');
|
||||
TabFiles.updateFooterStats();
|
||||
expect($('.files-footer-item-count').text()).toContain('1.5 KB');
|
||||
expect($('.files-footer-selected-items').text()).toContain('1.5 KB');
|
||||
|
||||
$('.files .row').attr('data-size', '0');
|
||||
TabFiles.updateFooterStats();
|
||||
expect($('.files-footer-item-count').text()).toContain('0 B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('typeSelectTarget', () => {
|
||||
const names = ['notes', 'readme', 'report-a', 'report-b', 'zip'];
|
||||
|
||||
it('steps through the names a repeated letter starts', () => {
|
||||
expect(typeSelectTarget(names, -1, 'r')).toBe(1);
|
||||
expect(typeSelectTarget(names, 1, 'r')).toBe(2);
|
||||
expect(typeSelectTarget(names, 2, 'rr')).toBe(3);
|
||||
expect(typeSelectTarget(names, 3, 'rrr')).toBe(1);
|
||||
expect(typeSelectTarget(names, 0, 'r')).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a selected row a longer prefix still matches', () => {
|
||||
expect(typeSelectTarget(names, 3, 're')).toBe(3);
|
||||
expect(typeSelectTarget(names, 1, 'rep')).toBe(2);
|
||||
expect(typeSelectTarget(names, 0, 'rep')).toBe(2);
|
||||
});
|
||||
|
||||
it('finds nothing to select for a prefix nothing has', () => {
|
||||
expect(typeSelectTarget(names, 0, 'q')).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.updateNavButtonStates', () => {
|
||||
it('takes a disabled arrow out of the Tab order and puts it back', () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="window">
|
||||
<img class="path-btn path-btn-back" tabindex="0">
|
||||
<img class="path-btn path-btn-forward" tabindex="0">
|
||||
<img class="path-btn path-btn-up" tabindex="0">
|
||||
</div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
TabFiles.currentPath = '/alice/Documents';
|
||||
window.dashboard_nav_history = ['/alice', '/alice/Documents'];
|
||||
window.dashboard_nav_history_current_position = 1;
|
||||
|
||||
TabFiles.updateNavButtonStates();
|
||||
|
||||
expect($('.path-btn-back').attr('tabindex')).toBe('0');
|
||||
expect($('.path-btn-forward').attr('tabindex')).toBe('-1');
|
||||
expect($('.path-btn-forward').attr('aria-disabled')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles cancelling a drag that opened a folder', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
});
|
||||
|
||||
it('goes back even while that folder is still loading', async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
window.puter = { fs: { readdir: vi.fn(({ path }) => (path === '/alice/Pictures'
|
||||
? new Promise(() => {})
|
||||
: Promise.resolve([]))) } };
|
||||
window.dashboard_nav_history = ['/alice/Documents'];
|
||||
window.dashboard_nav_history_current_position = 0;
|
||||
|
||||
TabFiles.springLoadedOriginalPath = '/alice/Documents';
|
||||
TabFiles.pushNavHistory('/alice/Pictures');
|
||||
void TabFiles.renderDirectory('/alice/Pictures');
|
||||
TabFiles.navigateBackFromSpringLoad();
|
||||
await vi.waitFor(() => expect(TabFiles.renderingDirectory).toBe(false));
|
||||
|
||||
expect(TabFiles.currentPath).toBe('/alice/Documents');
|
||||
expect(window.dashboard_nav_history_current_position).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles image preview', () => {
|
||||
it('closes once the row it shows is gone', () => {
|
||||
window.trash_path = TRASH;
|
||||
mountListing(['a.png'], '/alice/Pictures');
|
||||
$('.files .row').attr('data-uid', 'u1');
|
||||
$('body').append('<div class="image-preview-popover"></div>');
|
||||
TabFiles.previewOpen = true;
|
||||
TabFiles.previewCurrentUid = 'u1';
|
||||
|
||||
TabFiles.updateFooterStats();
|
||||
expect(TabFiles.previewOpen).toBe(true);
|
||||
|
||||
$('.files .row').remove();
|
||||
TabFiles.updateFooterStats();
|
||||
expect(TabFiles.previewOpen).toBe(false);
|
||||
expect($('.image-preview-popover')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.acceptsNewItems', () => {
|
||||
it('refuses a folder shared to view only, and takes one shared to edit', async () => {
|
||||
const { remember_shared_roots } = await import('../../helpers/sharedAccess.js');
|
||||
window.user = { username: 'alice' };
|
||||
window.trash_path = TRASH;
|
||||
remember_shared_roots([
|
||||
{ path: '/bob/Photos', mode: 'read' },
|
||||
{ path: '/bob/Drafts', mode: 'write' },
|
||||
]);
|
||||
|
||||
expect(TabFiles.acceptsNewItems('/bob/Photos')).toBe(false);
|
||||
expect(TabFiles.acceptsNewItems('/bob/Photos/2024')).toBe(false);
|
||||
expect(TabFiles.acceptsNewItems('/bob/Drafts')).toBe(true);
|
||||
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.applyHiddenRule', () => {
|
||||
it('hides a row renamed to a dot-name, or dims it when hidden files show', () => {
|
||||
window.trash_path = TRASH;
|
||||
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
|
||||
window.user_preferences = { show_hidden_files: false };
|
||||
|
||||
expect(TabFiles.applyHiddenRule($('.files .row').first(), '.a.txt')).toBe(false);
|
||||
expect($('.files .row')).toHaveLength(1);
|
||||
|
||||
window.user_preferences = { show_hidden_files: true };
|
||||
expect(TabFiles.applyHiddenRule($('.files .row').first(), '.b.txt')).toBe(true);
|
||||
expect($('.files .row').first().hasClass('item-revealed')).toBe(true);
|
||||
TabFiles.applyHiddenRule($('.files .row').first(), 'b.txt');
|
||||
expect($('.files .row').first().hasClass('item-revealed')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -24,12 +24,20 @@ import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
|
||||
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
// How long a completed usage load stays fresh enough to skip a repeat. Long
|
||||
// enough to absorb the init/onActivate/routing burst on a single dashboard
|
||||
// open, short enough that a user watching the panel still sees movement.
|
||||
const USAGE_REFRESH_DEBOUNCE_MS = 5_000;
|
||||
|
||||
// How long the plan card waits for the subscription read before giving up.
|
||||
const PLAN_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Whether "Recently used" has its list: until the first answer it says
|
||||
// nothing, rather than "No recent apps yet", and a failed first load says so.
|
||||
let recentAppsState = 'loading';
|
||||
|
||||
function buildRecentAppsHTML() {
|
||||
let h = '';
|
||||
|
||||
@@ -62,6 +70,8 @@ function buildRecentAppsHTML() {
|
||||
h += '</button>';
|
||||
}
|
||||
h += '</div>';
|
||||
} else if (recentAppsState === 'loading') {
|
||||
h += '<div class="bento-recent-apps-empty" aria-busy="true"></div>';
|
||||
} else {
|
||||
h += '<div class="bento-recent-apps-empty">';
|
||||
h +=
|
||||
@@ -71,8 +81,12 @@ function buildRecentAppsHTML() {
|
||||
h +=
|
||||
'<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>';
|
||||
h += '</svg>';
|
||||
h += `<p>${i18n('no_recent_apps')}</p>`;
|
||||
h += `<span>${i18n('no_recent_apps_hint')}</span>`;
|
||||
if (recentAppsState === 'failed') {
|
||||
h += `<p>${i18n('apps_load_failed')}</p>`;
|
||||
} else {
|
||||
h += `<p>${i18n('no_recent_apps')}</p>`;
|
||||
h += `<span>${i18n('no_recent_apps_hint')}</span>`;
|
||||
}
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
@@ -87,7 +101,7 @@ function buildUsageHTML() {
|
||||
h +=
|
||||
'<div class="bento-usage-section bento-usage-card bento-plan-section">';
|
||||
h +=
|
||||
'<a href="#" class="bento-usage-card-header bento-plan-header" data-target-tab="usage">';
|
||||
'<a href="#usage" class="bento-usage-card-header bento-plan-header" data-target-tab="usage">';
|
||||
h += `<h3>${i18n('your_plan')}</h3>`;
|
||||
h += '<span class="bento-usage-card-arrow">›</span>';
|
||||
h += '</a>';
|
||||
@@ -104,7 +118,7 @@ function buildUsageHTML() {
|
||||
|
||||
// Storage section
|
||||
h += '<div class="bento-usage-section bento-usage-card">';
|
||||
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
|
||||
h += '<a href="#usage" class="bento-usage-card-header" data-target-tab="usage">';
|
||||
h += `<h3>${i18n('your_storage')}</h3>`;
|
||||
h += '<span class="bento-usage-card-arrow">›</span>';
|
||||
h += '</a>';
|
||||
@@ -124,7 +138,7 @@ function buildUsageHTML() {
|
||||
|
||||
// Resources section
|
||||
h += '<div class="bento-usage-section bento-usage-card">';
|
||||
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
|
||||
h += '<a href="#usage" class="bento-usage-card-header" data-target-tab="usage">';
|
||||
h += `<h3>${i18n('your_resources')}</h3>`;
|
||||
h += '<span class="bento-usage-card-arrow">›</span>';
|
||||
h += '</a>';
|
||||
@@ -163,12 +177,14 @@ function formatTrialEnd(trialEndsAt) {
|
||||
0,
|
||||
Math.ceil((date.getTime() - Date.now()) / 86400000),
|
||||
);
|
||||
// In the dashboard's language, which the sentences around it are in.
|
||||
const locale = intlLocale(window.locale);
|
||||
return {
|
||||
short: date.toLocaleDateString(undefined, {
|
||||
short: date.toLocaleDateString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
long: date.toLocaleDateString(undefined, {
|
||||
long: date.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
@@ -234,7 +250,7 @@ const TabHome = {
|
||||
// Recent apps card (rectangle)
|
||||
h += '<div class="bento-card bento-recent">';
|
||||
h +=
|
||||
'<a href="#" class="bento-card-fancy-header" data-target-tab="apps">';
|
||||
'<a href="#apps" class="bento-card-fancy-header" data-target-tab="apps">';
|
||||
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-apps">';
|
||||
h +=
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
|
||||
@@ -256,7 +272,7 @@ const TabHome = {
|
||||
// Usage card (spans full width on second row)
|
||||
h += '<div class="bento-card bento-usage">';
|
||||
h +=
|
||||
'<a href="#" class="bento-card-fancy-header" data-target-tab="usage">';
|
||||
'<a href="#usage" class="bento-card-fancy-header" data-target-tab="usage">';
|
||||
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-usage">';
|
||||
h +=
|
||||
'<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/><path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/></svg>';
|
||||
@@ -310,7 +326,11 @@ const TabHome = {
|
||||
// Deliberate refreshes (subscription changed, returning from the
|
||||
// billing portal) must bypass the freshness window — they exist to
|
||||
// replace state we already know is stale.
|
||||
const refresh = () => this.loadUsageData($el_window, { force: true });
|
||||
// Recently used too: an app opened in another browser tab moves up it.
|
||||
const refresh = () => {
|
||||
this.loadUsageData($el_window, { force: true });
|
||||
this.loadRecentApps($el_window);
|
||||
};
|
||||
const refreshAndBroadcast = async () => {
|
||||
// Pull fresh whoami, then broadcast. The dispatched event is handled
|
||||
// by the `refresh` listener below, so we don't call refresh() here —
|
||||
@@ -365,6 +385,9 @@ const TabHome = {
|
||||
$el_window.on('click', '.bento-recent-app', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// The first click of a double-click already opened the app; the
|
||||
// second would open another tab of it, as an Apps tile won't.
|
||||
if ((e.detail ?? 0) > 1) return;
|
||||
const appName = $(this).attr('data-app-name');
|
||||
const targetLink = $(this).attr('data-target-link');
|
||||
if (targetLink && targetLink !== '') {
|
||||
@@ -379,6 +402,9 @@ const TabHome = {
|
||||
'click',
|
||||
'.bento-view-more, .bento-usage-card-header, .bento-card-fancy-header[data-target-tab]',
|
||||
function (e) {
|
||||
// A modified click opens the tab in a new browser tab, natively,
|
||||
// as on the sidebar.
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
const targetTab = $(this).attr('data-target-tab');
|
||||
if (targetTab) {
|
||||
@@ -396,7 +422,10 @@ const TabHome = {
|
||||
$el_window.on('click', '.bento-save-account-warning', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// As from the user menu: the temporary username comes prefilled.
|
||||
UIWindowSaveAccount({
|
||||
send_confirmation_code: false,
|
||||
default_username: window.user.username,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
@@ -416,8 +445,8 @@ const TabHome = {
|
||||
|
||||
async loadRecentApps($el_window) {
|
||||
// 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.
|
||||
// whole session (apps launched after load never appeared). It refreshes
|
||||
// on the same activate/focus triggers as loadUsageData.
|
||||
// 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);
|
||||
@@ -433,8 +462,11 @@ const TabHome = {
|
||||
});
|
||||
if (seq !== this._recentAppsSeq) return;
|
||||
window.launch_apps = launchApps;
|
||||
recentAppsState = 'loaded';
|
||||
} catch (e) {
|
||||
console.error('Failed to load launch apps:', e);
|
||||
// A list already shown stays; it is only out of date.
|
||||
if (recentAppsState === 'loading') recentAppsState = 'failed';
|
||||
}
|
||||
if (seq !== this._recentAppsSeq) return;
|
||||
$el_window
|
||||
@@ -494,9 +526,14 @@ const TabHome = {
|
||||
// upgrade dialog either, which only hosted Puter loads).
|
||||
let noBilling = false;
|
||||
try {
|
||||
// fetch has no timeout of its own; one that never answers would
|
||||
// hold every later refresh of the cards behind it.
|
||||
const resp = await fetch(
|
||||
`${window.api_origin}/marketplace/subscriptions/current`,
|
||||
{ headers: { Authorization: `Bearer ${puter.authToken}` } },
|
||||
{
|
||||
headers: { Authorization: `Bearer ${puter.authToken}` },
|
||||
signal: AbortSignal.timeout?.(PLAN_REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
noBilling = resp.status === 404;
|
||||
// A refusal is not a free plan: a seat owing a password change 403s.
|
||||
@@ -622,21 +659,26 @@ const TabHome = {
|
||||
},
|
||||
|
||||
async _loadUsageDataUncached($el_window) {
|
||||
await this._renderPlanCard($el_window);
|
||||
// Side by side: neither meter needs the plan, and a slow plan read
|
||||
// must not hold them at "--".
|
||||
await Promise.all([
|
||||
this._renderPlanCard($el_window),
|
||||
this._renderStorageCard($el_window),
|
||||
this._renderResourcesCard($el_window),
|
||||
]);
|
||||
},
|
||||
|
||||
// Load storage data
|
||||
async _renderStorageCard($el_window) {
|
||||
try {
|
||||
const res = await getStorageSpace();
|
||||
// On a self-hosted install the disk's use, Puter's files with it;
|
||||
// the meter measures the same amount the card names.
|
||||
const general_used = res.host_used || res.used;
|
||||
// Guard capacity 0 — 0/0 would render literally as "NaN%".
|
||||
const usage_percentage = res.capacity
|
||||
? Math.min(100, meterPercent((res.used / res.capacity) * 100))
|
||||
? Math.min(100, meterPercent((general_used / res.capacity) * 100))
|
||||
: 0;
|
||||
|
||||
let general_used = res.used;
|
||||
if (res.host_used) {
|
||||
general_used = res.host_used;
|
||||
}
|
||||
|
||||
$el_window
|
||||
.find('.bento-storage-used')
|
||||
.text(i18n('amount_used', { amount: window.byte_format(general_used) }, false));
|
||||
@@ -653,8 +695,9 @@ const TabHome = {
|
||||
} catch (e) {
|
||||
console.error('Failed to load storage data:', e);
|
||||
}
|
||||
},
|
||||
|
||||
// Load monthly usage data
|
||||
async _renderResourcesCard($el_window) {
|
||||
try {
|
||||
const res = monthlyUsageOrThrow(await getMonthlyUsage());
|
||||
const budget = usageBudget(res.usage, res.allowanceInfo);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @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 { describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
globalThis.i18n = key => key;
|
||||
window.icons = {};
|
||||
window.launch_apps = { recent: [] };
|
||||
|
||||
const { default: TabHome } = await import('./TabHome.js');
|
||||
|
||||
describe('TabHome recently used', () => {
|
||||
const mount = () => {
|
||||
document.body.innerHTML = '<div class="window"><div class="bento-recent-apps-container"></div></div>';
|
||||
return $('.window');
|
||||
};
|
||||
|
||||
it("doesn't call the list empty before it has loaded, and says when it couldn't", async () => {
|
||||
const $el_window = mount();
|
||||
$el_window.find('.bento-recent-apps-container').html(TabHome.html());
|
||||
expect($el_window.text()).not.toContain('no_recent_apps');
|
||||
|
||||
$.ajax = vi.fn(() => Promise.reject(new Error('offline')));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await TabHome.loadRecentApps($el_window);
|
||||
expect($el_window.text()).toContain('apps_load_failed');
|
||||
|
||||
$.ajax = vi.fn(async () => ({ recent: [] }));
|
||||
await TabHome.loadRecentApps($el_window);
|
||||
expect($el_window.text()).toContain('no_recent_apps');
|
||||
});
|
||||
});
|
||||
@@ -183,8 +183,20 @@ const TabSecurity = {
|
||||
// After the dialog has gone, which can trail its answer.
|
||||
setTimeout(() => returnFocus(this), 250);
|
||||
|
||||
const showState = (on) => {
|
||||
$toggle.prop('checked', on);
|
||||
const $card = $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa');
|
||||
$el_window.find('.dashboard-section-security .user-otp-state').text(i18n(on ? 'two_factor_enabled' : 'two_factor_disabled'));
|
||||
$card.toggleClass('dashboard-settings-card-success', on).toggleClass('dashboard-settings-card-warning', ! on);
|
||||
};
|
||||
|
||||
if ( ! succeeded ) {
|
||||
$toggle.prop('checked', ! enabling);
|
||||
// 2FA may have changed regardless: turned on in another tab
|
||||
// (setup then refuses, "already enabled"), or by a code the
|
||||
// server accepted after the dialog was closed.
|
||||
await window.refresh_user_data?.(window.auth_token);
|
||||
if ( typeof window.user?.otp === 'boolean' ) showState(window.user.otp);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -192,15 +204,7 @@ const TabSecurity = {
|
||||
// real 2FA state. The disable dialog already clears window.user.otp;
|
||||
// the enable path never set it, so the two directions disagreed.
|
||||
if ( window.user ) window.user.otp = enabling;
|
||||
|
||||
const $card = $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa');
|
||||
if ( enabling ) {
|
||||
$el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_enabled'));
|
||||
$card.removeClass('dashboard-settings-card-warning').addClass('dashboard-settings-card-success');
|
||||
} else {
|
||||
$el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_disabled'));
|
||||
$card.removeClass('dashboard-settings-card-success').addClass('dashboard-settings-card-warning');
|
||||
}
|
||||
showState(enabling);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,9 +55,9 @@ const TabUsage = {
|
||||
<div class="driver-usage-header">
|
||||
<h3 style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('Storage')}</h3>
|
||||
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
|
||||
<span id="storage-used-percent"></span>
|
||||
<span id="storage-used-percent">--%</span>
|
||||
<span> · </span>
|
||||
${i18n('used_of', { used: '<span id="storage-used"></span>', available: '<span id="storage-capacity"></span>' }, false)}
|
||||
${i18n('used_of', { used: '<span id="storage-used">--</span>', available: '<span id="storage-capacity">--</span>' }, false)}
|
||||
<span id="storage-puter-used-w" style="display:none;"> (<span id="storage-puter-used"></span> ${i18n('storage_puter_used')})</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,9 +69,9 @@ const TabUsage = {
|
||||
<div class="driver-usage-header">
|
||||
<h3 style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('Resources')}</h3>
|
||||
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
|
||||
<span class="usage-progbar-percent"></span>
|
||||
<span class="usage-progbar-percent">--%</span>
|
||||
<span> · </span>
|
||||
${i18n('used_of', { used: '<span id="total-usage"></span>', available: '<span id="total-capacity"></span>' }, false)}
|
||||
${i18n('used_of', { used: '<span id="total-usage">--</span>', available: '<span id="total-capacity">--</span>' }, false)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-progbar-wrapper">
|
||||
|
||||
@@ -54,6 +54,9 @@ const passwordField = (label) => ({
|
||||
attrs: { autocomplete: 'current-password' },
|
||||
});
|
||||
|
||||
/** Puts a refused password's status on the field, to be typed again. */
|
||||
const wrongPassword = (result, field) => (result.data?.code === 'password_mismatch' ? { field } : undefined);
|
||||
|
||||
/** Text that tells a provider-only account what will happen instead of a password. */
|
||||
const oidcNotice = () => (isOidcOnly() ? `<p>${i18n('revalidate_flow_notice')}</p>` : '');
|
||||
|
||||
@@ -143,6 +146,11 @@ export function openChangeUsernameDialog (opts = {}) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'username' });
|
||||
return;
|
||||
}
|
||||
// The server would call it taken, and count the password try.
|
||||
if ( new_username.toLowerCase() === String(window.user?.username).toLowerCase() ) {
|
||||
dialog.setStatus(t('username_unchanged'), { field: 'username' });
|
||||
return;
|
||||
}
|
||||
if ( ! isOidcOnly() && ! password ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'password' });
|
||||
return;
|
||||
@@ -152,7 +160,7 @@ export function openChangeUsernameDialog (opts = {}) {
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok ) {
|
||||
dialog.setStatus(result.message);
|
||||
dialog.setStatus(result.message, wrongPassword(result, 'password'));
|
||||
return;
|
||||
}
|
||||
update_username_in_gui(new_username);
|
||||
@@ -206,6 +214,11 @@ export function openChangePasswordDialog (opts = {}) {
|
||||
dialog.setStatus(t('passwords_do_not_match'), { field: 'confirm' });
|
||||
return;
|
||||
}
|
||||
// It would go through, and sign every other session out for nothing.
|
||||
if ( current && next === current ) {
|
||||
dialog.setStatus(t('password_unchanged'), { field: 'next' });
|
||||
return;
|
||||
}
|
||||
if ( ! check_password_strength(next).overallPass ) {
|
||||
dialog.setStatus(t('password_strength_error'), { field: 'next' });
|
||||
return;
|
||||
@@ -215,9 +228,12 @@ export function openChangePasswordDialog (opts = {}) {
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok ) {
|
||||
dialog.setStatus(result.message);
|
||||
dialog.setStatus(result.message, wrongPassword(result, 'current'));
|
||||
return;
|
||||
}
|
||||
// A provider-only account now has a password, and the other
|
||||
// dialogs ask for it instead of the provider's sign-in.
|
||||
if ( window.user ) window.user.oidc_only = false;
|
||||
dialog.finish({ message: t('password_changed') });
|
||||
},
|
||||
});
|
||||
@@ -257,6 +273,12 @@ export function openChangeEmailDialog (opts = {}) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'email' });
|
||||
return;
|
||||
}
|
||||
// The server would accept it: mail the same inbox, and unlink any
|
||||
// sign-in provider once confirmed.
|
||||
if ( new_email.toLowerCase() === String(window.user?.email).toLowerCase() ) {
|
||||
dialog.setStatus(t('email_unchanged'), { field: 'email' });
|
||||
return;
|
||||
}
|
||||
if ( ! isOidcOnly() && ! password ) {
|
||||
dialog.setStatus(t('all_fields_required'), { field: 'password' });
|
||||
return;
|
||||
@@ -270,7 +292,7 @@ export function openChangeEmailDialog (opts = {}) {
|
||||
// Accounts with 2FA turned on since the page loaded learn about it here.
|
||||
const needs_code = result.data.factor === 'otp';
|
||||
if ( needs_code ) dialog.showField('code', true);
|
||||
dialog.setStatus(result.message, needs_code ? { field: 'code' } : undefined);
|
||||
dialog.setStatus(result.message, needs_code ? { field: 'code' } : wrongPassword(result, 'password'));
|
||||
return;
|
||||
}
|
||||
// The address is the account's once the emailed link is followed, not yet.
|
||||
@@ -327,7 +349,7 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
|
||||
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>`;
|
||||
// The token is shown only once; only Done, ✕ or Escape put it away.
|
||||
dialog.finish({ html: h, keepOnBackdrop: true });
|
||||
dialog.finish({ html: h, describe: '.dashboard-token-warning', keepOnBackdrop: true });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -418,7 +440,7 @@ export function openDeleteAccountDialog (opts = {}) {
|
||||
onRevalidate: revalidating(dialog),
|
||||
});
|
||||
if ( ! result.ok || ! result.data.success ) {
|
||||
dialog.setStatus(result.message ?? t('error_unknown_cause'));
|
||||
dialog.setStatus(result.message ?? t('error_unknown_cause'), wrongPassword(result, 'password'));
|
||||
return;
|
||||
}
|
||||
window.user.deleted = true;
|
||||
|
||||
@@ -125,6 +125,13 @@ describe('change username', () => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("doesn't post the name the account already has", async () => {
|
||||
dialogs.openChangeUsernameDialog();
|
||||
await submitWith({ username: 'Alice', password: 'pw' });
|
||||
expect(status()).toBe('username_unchanged');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renames the user everywhere on success', async () => {
|
||||
document.body.innerHTML = '<span class="username">alice</span>';
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
@@ -167,6 +174,13 @@ describe('change password', () => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("doesn't post a new password that is the current one", async () => {
|
||||
open();
|
||||
await submitWith({ current: 'Strong1!pass', next: 'Strong1!pass', confirm: 'Strong1!pass' });
|
||||
expect(status()).toBe('password_unchanged');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts the current and new password and confirms the change', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
open();
|
||||
@@ -177,6 +191,16 @@ describe('change password', () => {
|
||||
expect($('.dashboard-form-done p').text()).toBe('password_changed');
|
||||
});
|
||||
|
||||
it('lets a provider-only account use its new password from then on', async () => {
|
||||
window.user.oidc_only = true;
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
open();
|
||||
|
||||
await submitWith({ next: 'Strong1!pass', confirm: 'Strong1!pass' });
|
||||
|
||||
expect(window.user.oidc_only).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the server message and keeps the form when the change is refused', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(400, { message: 'Wrong password' }));
|
||||
open();
|
||||
@@ -203,6 +227,13 @@ describe('change email', () => {
|
||||
expect(window.user.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it("doesn't post the address the account already has", async () => {
|
||||
dialogs.openChangeEmailDialog();
|
||||
await submitWith({ email: ' [email protected] ', password: 'pw' });
|
||||
expect(status()).toBe('email_unchanged');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the code out for an account without 2FA', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(200));
|
||||
dialogs.openChangeEmailDialog();
|
||||
|
||||
@@ -213,6 +213,10 @@ async function UIDashboard (options) {
|
||||
// Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it)
|
||||
if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) {
|
||||
window.dashboard_initial_file_path = window.dashboard_initial_route.path;
|
||||
// Consumed here like a share link: Files doesn't keep its folder in
|
||||
// the URL, so a path left there would be stale after the first move,
|
||||
// and Back or a reload would jump to it.
|
||||
window.history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}#files`);
|
||||
} else if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.shared ) {
|
||||
// A share link: open Shared, and pick out the items it names once
|
||||
// the listing is up. Consumed here, so a reload shows Files plainly.
|
||||
@@ -231,7 +235,10 @@ async function UIDashboard (options) {
|
||||
// Account and Security decide some cards from the account's state when
|
||||
// they render (password and email for a temporary user, the token
|
||||
// button and 2FA for an unconfirmed email).
|
||||
const account_state = () => JSON.stringify([window.user?.email, window.user?.email_confirmed, window.user?.is_temp]);
|
||||
let rendered_account_state = account_state();
|
||||
const rerender_account_tabs = () => {
|
||||
rendered_account_state = account_state();
|
||||
for ( const tab of tabs ) {
|
||||
if ( tab.id !== 'account' && tab.id !== 'security' ) continue;
|
||||
$el_window.find(`.dashboard-section-${tab.id}`).html(tab.html());
|
||||
@@ -245,6 +252,16 @@ async function UIDashboard (options) {
|
||||
rerender_account_tabs();
|
||||
});
|
||||
|
||||
// Returning to the browser tab re-reads the account and then sends this
|
||||
// (see TabHome). An email change confirmed from its link in another tab
|
||||
// sends no socket event of its own, so it shows here; so does a temporary
|
||||
// account saved in another tab.
|
||||
window.addEventListener('puter:subscription:changed', () => {
|
||||
// Saved from another tab: Home's "Save session" has nothing left to save.
|
||||
if ( ! window.user?.is_temp ) $el_window.find('.bento-save-account-warning').remove();
|
||||
if ( account_state() !== rendered_account_state ) rerender_account_tabs();
|
||||
});
|
||||
|
||||
// Dispatch 'dashboard-ready' event for extensions
|
||||
window.dispatchEvent(new CustomEvent('dashboard-ready', { detail: { window: $el_window } }));
|
||||
|
||||
@@ -514,6 +531,12 @@ async function UIDashboard (options) {
|
||||
const tab = route.tab;
|
||||
const filePath = route.path;
|
||||
notifications.close({ restoreFocus: false });
|
||||
// What was open over the tab being left goes with it, as on a
|
||||
// sidebar click: the phone drawer, a menu, and Files' Share and
|
||||
// Properties, which act on its rows (their changes are already saved).
|
||||
setMobileSidebar(false);
|
||||
$('.context-menu').remove();
|
||||
$el_window.find('.share-modal-close, .item-props-close').trigger('click');
|
||||
|
||||
// Switch to correct tab
|
||||
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
|
||||
@@ -542,12 +565,16 @@ async function UIDashboard (options) {
|
||||
// Scroll content area to top
|
||||
$el_window.find('.dashboard-content').scrollTop(0);
|
||||
|
||||
// If files tab with path, navigate without adding to history
|
||||
// A files link with a path opens that folder, as a step in Files' own
|
||||
// Back/Forward, and leaves the URL (see the initial route above).
|
||||
if ( tab === 'files' && filePath ) {
|
||||
const filesTab = tabs.find(t => t.id === 'files');
|
||||
if ( filesTab?.renderDirectory ) {
|
||||
filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true });
|
||||
filesTab.pushNavHistory?.(filePath);
|
||||
filesTab.renderDirectory(filePath, { skipUrlUpdate: true });
|
||||
}
|
||||
window.history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}#files`);
|
||||
lastHandledHref = window.location.href;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -607,10 +634,10 @@ async function UIDashboard (options) {
|
||||
const $scrim = $el_window.find('.dashboard-sidebar-scrim');
|
||||
const $sidebar_toggle = $el_window.find('.dashboard-sidebar-toggle');
|
||||
const drawerFocusables = () => $sidebar.find('a[href], button').filter(':visible:not(:disabled)');
|
||||
const setMobileSidebar = (open) => {
|
||||
const setMobileSidebar = (open, { keepFocus = false } = {}) => {
|
||||
// Focus left inside a closing drawer would sit on hidden controls
|
||||
// (and fall to <body>); the toggle that reopens it takes it instead.
|
||||
const focusInside = ! open && $sidebar.hasClass('open') && $sidebar[0].contains(document.activeElement);
|
||||
const focusInside = ! open && ! keepFocus && $sidebar.hasClass('open') && $sidebar[0].contains(document.activeElement);
|
||||
const opening = open && ! $sidebar.hasClass('open');
|
||||
$sidebar.toggleClass('open', open);
|
||||
$scrim.toggleClass('open', open);
|
||||
@@ -623,6 +650,13 @@ async function UIDashboard (options) {
|
||||
}
|
||||
};
|
||||
|
||||
// Widened past the breakpoint (a tablet turned to landscape), the drawer
|
||||
// is the desktop sidebar again, shown and no longer modal; focus in it
|
||||
// can stay where it is.
|
||||
window.matchMedia('(max-width: 768px)').addEventListener('change', (e) => {
|
||||
if ( ! e.matches && $sidebar.hasClass('open') ) setMobileSidebar(false, { keepFocus: true });
|
||||
});
|
||||
|
||||
// Tab cycles inside the open drawer instead of reaching the content
|
||||
// behind the scrim.
|
||||
$sidebar.on('keydown', function (e) {
|
||||
@@ -727,9 +761,10 @@ async function UIDashboard (options) {
|
||||
.attr('aria-expanded', collapsed ? 'false' : 'true');
|
||||
});
|
||||
|
||||
// Sidebar collapsed tooltips
|
||||
// Sidebar collapsed tooltips, for the keyboard as well as the pointer:
|
||||
// collapsed, an item shows its icon only.
|
||||
const $tooltip = $('<div class="dashboard-sidebar-tooltip"></div>').appendTo('body');
|
||||
$el_window.on('mouseenter', '.dashboard-sidebar-item[data-tooltip]', function () {
|
||||
$el_window.on('mouseenter focusin', '.dashboard-sidebar-item[data-tooltip]', function () {
|
||||
if ( ! $el_window.find('.dashboard-sidebar').hasClass('collapsed') ) return;
|
||||
// The drawer ignores the collapse and shows its labels.
|
||||
if ( window.matchMedia('(max-width: 768px)').matches ) return;
|
||||
@@ -741,7 +776,7 @@ async function UIDashboard (options) {
|
||||
});
|
||||
$tooltip.addClass('visible');
|
||||
});
|
||||
$el_window.on('mouseleave', '.dashboard-sidebar-item[data-tooltip]', function () {
|
||||
$el_window.on('mouseleave focusout', '.dashboard-sidebar-item[data-tooltip]', function () {
|
||||
$tooltip.removeClass('visible');
|
||||
});
|
||||
|
||||
@@ -878,15 +913,15 @@ async function UIDashboard (options) {
|
||||
// Check for open windows
|
||||
if ( $('.window-app').length > 0 ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: `<p>${i18n('confirm_open_apps_log_out')}</p>`,
|
||||
message: `<p>${i18n('confirm_open_apps_log_out', [], false)}</p>`,
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('close_all_windows_and_log_out'),
|
||||
label: i18n('close_all_windows_and_log_out', [], false),
|
||||
value: 'close_and_log_out',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
label: i18n('cancel'),
|
||||
label: i18n('cancel', [], false),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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, keepOnBackdrop?: boolean }) => void} finish
|
||||
* @property {(opts: { message?: string, html?: string, describe?: 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
|
||||
* encoded `html`) with a single Done button. `describe` selects the part of
|
||||
* `html` the focused Done button reads out. `keepOnBackdrop` stops a stray
|
||||
* click outside the card from closing it, for content shown only once.
|
||||
*/
|
||||
|
||||
@@ -193,15 +194,16 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
else $field.get(0)?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
finish ({ message, html, keepOnBackdrop = false }) {
|
||||
finish ({ message, html, describe, 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>`);
|
||||
const described = ! html || (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
|
||||
// A status region that arrives with its text isn't announced;
|
||||
// the focused Done button reads the message out instead.
|
||||
$dialog.find('.dashboard-dialog-footer').html(
|
||||
`<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-form-done-btn"${html ? '' : ` aria-describedby="${id}-done"`}>${i18n('done')}</button>`);
|
||||
`<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-form-done-btn"${described ? ` aria-describedby="${id}-done"` : ''}>${i18n('done')}</button>`);
|
||||
$dialog.find('.dashboard-form-done-btn').get(0)?.focus({ preventScroll: true });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -184,6 +184,15 @@ describe('UIDashboardFormDialog', () => {
|
||||
expect($('.dashboard-form-status').text()).toBe('Server says no');
|
||||
});
|
||||
|
||||
it("has Done read out the part of the caller's html it names", async () => {
|
||||
open({ onSubmit: (dialog) => dialog.finish({ html: '<p class="warn">Shown once</p><input value="tok">', describe: '.warn' }) });
|
||||
submit();
|
||||
await tick();
|
||||
|
||||
const described_by = $('.dashboard-form-done-btn').attr('aria-describedby');
|
||||
expect($(`#${described_by}`).text()).toBe('Shown once');
|
||||
});
|
||||
|
||||
it('swaps the form for a done state whose button closes it', async () => {
|
||||
open({ onSubmit: (dialog) => dialog.finish({ message: 'All <good>' }) });
|
||||
submit();
|
||||
|
||||
@@ -21,12 +21,12 @@ import UINotification from '../UINotification.js';
|
||||
import { reveal_dashboard } from '../UIWindow.js';
|
||||
import { listNotifications, markNotificationAcknowledged } from '../../helpers/notificationApi.js';
|
||||
import { applyToastMark, createNotificationFeed } from '../../helpers/notificationFeed.js';
|
||||
import { formatAbsoluteTime, formatRelativeTime } from '../../i18n/formatTime.js';
|
||||
import {
|
||||
badgeLabel,
|
||||
formatAbsoluteTime,
|
||||
formatRelativeTime,
|
||||
glyphKey,
|
||||
isUnread,
|
||||
listedEntries,
|
||||
mergeEntries,
|
||||
notificationTarget,
|
||||
planBurstToasts,
|
||||
@@ -54,6 +54,12 @@ const ACK_CONCURRENCY = 6;
|
||||
/** How many of the most recent notifications the panel lists, read or not. */
|
||||
const HISTORY_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Rows a listing asks for: past HISTORY_LIMIT for the ones the panel hides
|
||||
* and for older unread ones, which it lists too. The driver's maximum.
|
||||
*/
|
||||
const LISTING_LIMIT = 200;
|
||||
|
||||
/** Matches the panel's CSS transition; `hidden` is set once it has run. */
|
||||
const CLOSE_ANIMATION_MS = 180;
|
||||
|
||||
@@ -366,10 +372,10 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
|
||||
loadFailed = false;
|
||||
if ( ! loaded ) render();
|
||||
listingRequestedAt = Date.now();
|
||||
loading = listNotifications({ predicate: 'all', limit: HISTORY_LIMIT })
|
||||
loading = listNotifications({ predicate: 'all', limit: LISTING_LIMIT })
|
||||
.then((rows) => {
|
||||
const now = Date.now();
|
||||
const server = rows.map((row) => toEntry(row, now)).filter(Boolean);
|
||||
const server = listedEntries(rows, now, HISTORY_LIMIT);
|
||||
// Acked since this listing was requested: unread in its snapshot.
|
||||
// Anything acked before it is settled and can be forgotten.
|
||||
const readSince = new Set();
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import item_icon from '../../helpers/itemIcon.js';
|
||||
import UIAlert from '../UIAlert.js';
|
||||
import { isOverlayOnTop } from './dashboardOverlay.js';
|
||||
import { formatRelativeTime } from '../../i18n/formatTime.js';
|
||||
|
||||
const { html_encode } = window;
|
||||
|
||||
@@ -224,9 +225,9 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
addRow($list, i18n('size'),
|
||||
(fsentry.size === null || fsentry.size === undefined) ? '-' : html_encode(window.byte_format(fsentry.size)));
|
||||
addRow($list, i18n('modified'),
|
||||
fsentry.modified === 0 ? '-' : html_encode(timeago.format(fsentry.modified * 1000)));
|
||||
fsentry.modified === 0 ? '-' : html_encode(formatRelativeTime(fsentry.modified * 1000, Date.now(), window.locale)));
|
||||
addRow($list, i18n('created'),
|
||||
fsentry.created === 0 ? '-' : html_encode(timeago.format(fsentry.created * 1000)));
|
||||
fsentry.created === 0 ? '-' : html_encode(formatRelativeTime(fsentry.created * 1000, Date.now(), window.locale)));
|
||||
|
||||
// Worker (published .js files)
|
||||
if ( fsentry.path?.endsWith('.js') && fsentry.workers?.length > 0 ) {
|
||||
@@ -252,7 +253,7 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
fsentry.versions.slice().reverse().forEach(version => {
|
||||
$versions.append(`
|
||||
<div class="item-props-version-entry">
|
||||
<span class="item-props-version-meta">${html_encode(version.user ? version.user.username : '')} • ${html_encode(timeago.format(version.timestamp * 1000))}</span>
|
||||
<span class="item-props-version-meta">${html_encode(version.user ? version.user.username : '')} • ${html_encode(formatRelativeTime(version.timestamp * 1000, Date.now(), window.locale))}</span>
|
||||
<span class="item-props-version-id">${html_encode(version.id)}</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
@@ -274,6 +274,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
const focus_dialog = () => {
|
||||
$overlay.find('.share-modal').get(0)?.focus({ preventScroll: true });
|
||||
};
|
||||
// Focus a round-trip lost (a disabled control drops it on <body>, a
|
||||
// re-render removes it) goes back; focus the user took elsewhere while
|
||||
// waiting, into the add-people field say, stays where they put it.
|
||||
const focus_moved_on = () => {
|
||||
const el = document.activeElement;
|
||||
return !! el && el !== document.body && el !== $overlay.find('.share-modal').get(0)
|
||||
&& ! $list.get(0).contains(el);
|
||||
};
|
||||
|
||||
// Both take HTML-safe text; `error_html` encodes the one raw source.
|
||||
const show_error = (html) => {
|
||||
@@ -458,7 +466,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
|
||||
if ( by_path.size === 0 ) {
|
||||
$list.attr('aria-busy', 'false').empty();
|
||||
show_error(error_html(failure));
|
||||
// Nothing was being shared: share_failed would say otherwise.
|
||||
show_error(failure?.message ? html_encode(failure.message) : i18n('share_load_failed'));
|
||||
return;
|
||||
}
|
||||
render(aggregateShares(target_paths, by_path));
|
||||
@@ -746,7 +755,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
picker.remember(chosen, created);
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
$recipient.get(0)?.focus({ preventScroll: true });
|
||||
if ( ! focus_moved_on() ) $recipient.get(0)?.focus({ preventScroll: true });
|
||||
} catch (err) {
|
||||
show_error(error_html(err));
|
||||
$submit.prop('disabled', false);
|
||||
@@ -775,7 +784,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
}
|
||||
invalidate_shared_roots();
|
||||
await refresh();
|
||||
focus_list_control('.share-modal-row-mode', key) || focus_dialog();
|
||||
if ( ! focus_moved_on() ) focus_list_control('.share-modal-row-mode', key) || focus_dialog();
|
||||
});
|
||||
|
||||
// -- Extend a partial grant to the rest of the selection --
|
||||
@@ -799,7 +808,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
await refresh();
|
||||
// The link is gone once they hold everything; the mode select is the
|
||||
// nearest surviving control for the same person.
|
||||
focus_list_control('.share-modal-row-mode', key) || focus_dialog();
|
||||
if ( ! focus_moved_on() ) focus_list_control('.share-modal-row-mode', key) || focus_dialog();
|
||||
});
|
||||
|
||||
// -- Revoke, confirmed inline in the row --
|
||||
@@ -862,6 +871,15 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
$(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true);
|
||||
try {
|
||||
await revoke_access(recipient_of(group), revoke_paths);
|
||||
// Leaving takes away the access this list is read with, and the
|
||||
// items may leave the listing behind it (the Shared view) too.
|
||||
if ( ! group.teamUid && ! group.pending && group.name === window.user.username ) {
|
||||
invalidate_shared_roots();
|
||||
close();
|
||||
const files = window.dashboard_object;
|
||||
files?.renderDirectory?.(files.currentPath, { consistency: 'strong' });
|
||||
return;
|
||||
}
|
||||
if ( group.pending ) {
|
||||
show_success(i18n('share_invite_cancelled', { recipient: group.name }));
|
||||
} else {
|
||||
@@ -873,7 +891,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
await refresh();
|
||||
// The focused row is gone; the dialog itself takes focus (the
|
||||
// input would pop the on-screen keyboard on touch devices).
|
||||
focus_dialog();
|
||||
if ( ! focus_moved_on() ) focus_dialog();
|
||||
} catch (err) {
|
||||
show_error(error_html(err));
|
||||
render(last_groups);
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
/**
|
||||
* Usage renders in credits, a display unit the SERVER scales amounts into
|
||||
* before they leave the API (a deployment-config multiplier the client never
|
||||
@@ -25,6 +27,15 @@
|
||||
* raw amounts from a deployment with no multiplier, rendered as dollars.
|
||||
*/
|
||||
|
||||
// In the dashboard's language: "5,000" reads as five in German.
|
||||
const numberFormat = (options) => {
|
||||
try {
|
||||
return new Intl.NumberFormat(intlLocale(globalThis.window?.locale ?? 'en'), options);
|
||||
} catch {
|
||||
return new Intl.NumberFormat('en', options);
|
||||
}
|
||||
};
|
||||
|
||||
/** Whether a `getMonthlyUsage()` response reports credits. */
|
||||
export const usageIsCredits = (allowanceInfo) =>
|
||||
allowanceInfo?.unit === 'credits';
|
||||
@@ -42,14 +53,9 @@ export const formatCredits = (credits) => {
|
||||
const value = Number.isFinite(credits) ? credits : 0;
|
||||
if (value < 0) return `-${formatCredits(-value)}`;
|
||||
if (value <= 0) return '0';
|
||||
if (value >= 100) {
|
||||
return Math.round(value).toLocaleString('en-US');
|
||||
}
|
||||
if (value >= 1) {
|
||||
// Trim trailing zeros so "2.50" reads as "2.5" and "9.00" as "9".
|
||||
return String(Number(value.toFixed(2)));
|
||||
}
|
||||
return value < 0.01 ? '<0.01' : String(Number(value.toFixed(2)));
|
||||
if (value < 0.01) return `<${numberFormat({}).format(0.01)}`;
|
||||
// Trailing zeros go, so 2.50 reads as "2.5" and 9.00 as "9".
|
||||
return numberFormat({ maximumFractionDigits: value >= 100 ? 0 : 2 }).format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,9 +65,7 @@ export const formatCredits = (credits) => {
|
||||
export const formatDollarsFromMicrocents = (microcents) => {
|
||||
const mc = Number.isFinite(microcents) ? microcents : 0;
|
||||
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: '$',
|
||||
});
|
||||
const format = numberFormat({ style: 'currency', currency: 'USD' });
|
||||
if ( dollars !== 0 && Math.abs(dollars) < 0.005 ) return `${dollars < 0 ? '-' : ''}<${format.format(0.01)}`;
|
||||
return format.format(dollars);
|
||||
};
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
formatCredits,
|
||||
formatDollarsFromMicrocents,
|
||||
@@ -58,17 +58,6 @@ describe('formatCredits', () => {
|
||||
});
|
||||
|
||||
describe('formatDollarsFromMicrocents', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.window = globalThis.window ?? {};
|
||||
globalThis.window.number_format = vi.fn(
|
||||
(n, { decimals, prefix }) => `${prefix}${n.toFixed(decimals)}`,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.window.number_format;
|
||||
});
|
||||
|
||||
it('renders the raw-amount dollar string for unscaled deployments', () => {
|
||||
expect(formatDollarsFromMicrocents(37_000_000)).toBe('$0.37');
|
||||
expect(formatDollarsFromMicrocents(undefined)).toBe('$0.00');
|
||||
@@ -81,3 +70,26 @@ describe('formatDollarsFromMicrocents', () => {
|
||||
expect(formatDollarsFromMicrocents(600_000)).toBe('$0.01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('amounts in the dashboard\'s language', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.window = globalThis.window ?? {};
|
||||
globalThis.window.locale = 'de';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete globalThis.window.locale;
|
||||
});
|
||||
|
||||
it('separates thousands and decimals as the language does', () => {
|
||||
expect(formatCredits(9000)).toBe('9.000');
|
||||
expect(formatCredits(2.5)).toBe('2,5');
|
||||
expect(formatCredits(0.001)).toBe('<0,01');
|
||||
expect(formatDollarsFromMicrocents(123_450_000_000).replace(/\s/g, ' ')).toBe('1.234,50 $');
|
||||
});
|
||||
|
||||
it("reads Puter's codes that aren't BCP 47 tags", () => {
|
||||
globalThis.window.locale = 'br';
|
||||
expect(formatCredits(9000.5)).toBe('9.001');
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,22 @@ export const toEntry = (raw, now) => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* The entries a listing contributes: its newest `limit` the center shows,
|
||||
* and every unread one past them, so the badge and "Mark all as read" cover
|
||||
* them all. Hidden rows are dropped before counting, so a run of them (a
|
||||
* developer's deploy results) can't take the places of the rest.
|
||||
*
|
||||
* @param {Object[]} rows - Driver rows, newest first
|
||||
* @param {number} now - Epoch ms
|
||||
* @param {number} limit
|
||||
* @returns {NotificationEntry[]}
|
||||
*/
|
||||
export const listedEntries = (rows, now, limit) => rows
|
||||
.map((row) => toEntry(row, now))
|
||||
.filter(Boolean)
|
||||
.filter((entry, i) => i < limit || isUnread(entry));
|
||||
|
||||
/**
|
||||
* Newest first; entries without a time sort as newest (they are the ones
|
||||
* just pushed), ties keep their order.
|
||||
@@ -287,80 +303,6 @@ export const titleWithBadge = (title, count) => {
|
||||
return label ? `(${label}) ${base}` : base;
|
||||
};
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* How long ago `at` was, in the reader's language: "now", "5 minutes ago",
|
||||
* "yesterday", then a short date once it's more than a week old. Uses the
|
||||
* platform's formatters so nothing here needs translating; falls back to a
|
||||
* plain date where they're missing.
|
||||
*
|
||||
* @param {number} at - Epoch ms
|
||||
* @param {number} now - Epoch ms
|
||||
* @param {string} [locale]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatRelativeTime = (at, now, locale = 'en') => {
|
||||
const diff = Math.max(0, now - at);
|
||||
const rtf = relativeFormatter(locale);
|
||||
if ( diff < 45_000 ) return rtf ? rtf.format(0, 'second') : 'now';
|
||||
if ( diff < 45 * MINUTE ) {
|
||||
const minutes = Math.max(1, Math.round(diff / MINUTE));
|
||||
return rtf ? rtf.format(-minutes, 'minute') : `${minutes}m`;
|
||||
}
|
||||
if ( diff < 22 * HOUR ) {
|
||||
const hours = Math.max(1, Math.round(diff / HOUR));
|
||||
return rtf ? rtf.format(-hours, 'hour') : `${hours}h`;
|
||||
}
|
||||
if ( diff < 7 * DAY ) {
|
||||
const days = Math.max(1, Math.round(diff / DAY));
|
||||
return rtf ? rtf.format(-days, 'day') : `${days}d`;
|
||||
}
|
||||
const then = new Date(at);
|
||||
const sameYear = then.getFullYear() === new Date(now).getFullYear();
|
||||
const dtf = dateFormatter(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
return dtf ? dtf.format(then) : then.toLocaleDateString();
|
||||
};
|
||||
|
||||
/**
|
||||
* The full moment, for a tooltip behind the relative form.
|
||||
*
|
||||
* @param {number} at - Epoch ms
|
||||
* @param {string} [locale]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatAbsoluteTime = (at, locale = 'en') => {
|
||||
const dtf = dateFormatter(locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return dtf ? dtf.format(new Date(at)) : new Date(at).toLocaleString();
|
||||
};
|
||||
|
||||
const tryMake = (make) => {
|
||||
try {
|
||||
return make();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// An unknown locale tag throws; the English formatter beats none at all.
|
||||
const relativeFormatter = (locale) => {
|
||||
if ( typeof Intl === 'undefined' || ! Intl.RelativeTimeFormat ) return null;
|
||||
return tryMake(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }))
|
||||
?? tryMake(() => new Intl.RelativeTimeFormat('en', { numeric: 'auto' }));
|
||||
};
|
||||
|
||||
const dateFormatter = (locale, options) => {
|
||||
if ( typeof Intl === 'undefined' || ! Intl.DateTimeFormat ) return null;
|
||||
return tryMake(() => new Intl.DateTimeFormat(locale, options))
|
||||
?? tryMake(() => new Intl.DateTimeFormat('en', options));
|
||||
};
|
||||
|
||||
/**
|
||||
* How many toasts a burst may raise before the rest are folded into one
|
||||
* "N new notifications" summary — a reconnect after a busy day must not
|
||||
|
||||
@@ -20,11 +20,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
badgeLabel,
|
||||
formatAbsoluteTime,
|
||||
formatRelativeTime,
|
||||
glyphKey,
|
||||
isShareNotification,
|
||||
isUnread,
|
||||
listedEntries,
|
||||
mergeEntries,
|
||||
notificationTarget,
|
||||
parseCreatedAt,
|
||||
@@ -140,6 +139,24 @@ describe('toEntry', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('listedEntries', () => {
|
||||
const row = (uid, type) => ({ uid, value: { type, title: uid }, created_at: null });
|
||||
|
||||
it("doesn't let hidden rows take the places of shown ones", () => {
|
||||
const rows = [
|
||||
...Array.from({ length: 40 }, (_, i) => row(`deploy${i}`, 'app.worker.deployed')),
|
||||
row('share', 'fs.share.granted'),
|
||||
row('older', 'fs.share.granted'),
|
||||
];
|
||||
expect(listedEntries(rows, NOW, 30).map((e) => e.uid)).toEqual(['share', 'older']);
|
||||
});
|
||||
|
||||
it('keeps the newest `limit`, and every unread one past them', () => {
|
||||
const rows = Array.from({ length: 6 }, (_, i) => ({ ...row(`n${i}`), acknowledged: i === 4 ? null : NOW / 1000 }));
|
||||
expect(listedEntries(rows, NOW, 3).map((e) => e.uid)).toEqual(['n0', 'n1', 'n2', 'n4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortEntries', () => {
|
||||
it('orders newest first and keeps undated entries on top', () => {
|
||||
const out = sortEntries([entry('old', NOW - DAY), entry('unknown', null), entry('new', NOW)]);
|
||||
@@ -396,42 +413,6 @@ describe('titleWithBadge', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('describes the recent past in words', () => {
|
||||
expect(formatRelativeTime(NOW, NOW)).toBe('now');
|
||||
expect(formatRelativeTime(NOW - 30_000, NOW)).toBe('now');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW)).toBe('5 minutes ago');
|
||||
expect(formatRelativeTime(NOW - 1 * MIN, NOW)).toBe('1 minute ago');
|
||||
expect(formatRelativeTime(NOW - 3 * HOUR, NOW)).toBe('3 hours ago');
|
||||
expect(formatRelativeTime(NOW - DAY, NOW)).toBe('yesterday');
|
||||
expect(formatRelativeTime(NOW - 3 * DAY, NOW)).toBe('3 days ago');
|
||||
});
|
||||
|
||||
it('falls back to a short date past a week', () => {
|
||||
expect(formatRelativeTime(NOW - 10 * DAY, NOW)).toBe('Aug 17');
|
||||
expect(formatRelativeTime(NOW - 400 * DAY, NOW)).toBe('Jul 23, 2025');
|
||||
});
|
||||
|
||||
it('never reports the future', () => {
|
||||
expect(formatRelativeTime(NOW + HOUR, NOW)).toBe('now');
|
||||
});
|
||||
|
||||
it('localises', () => {
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'fr')).toBe('il y a 5 minutes');
|
||||
});
|
||||
|
||||
it('survives an unknown locale', () => {
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'not-a-locale-!!')).toBe('5 minutes ago');
|
||||
expect(formatRelativeTime(NOW - 10 * DAY, NOW, 'not-a-locale-!!')).toBe('Aug 17');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAbsoluteTime', () => {
|
||||
it('spells out the moment', () => {
|
||||
expect(formatAbsoluteTime(NOW, 'en')).toMatch(/Aug 27, 2026/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planBurstToasts', () => {
|
||||
const many = (n) => Array.from({ length: n }, (_, i) => entry(`n${i}`, NOW - i));
|
||||
|
||||
|
||||
@@ -122,15 +122,26 @@ async function UIWindowDisable2FA (options) {
|
||||
hint.text(REVALIDATE_POPUP_TEXT).show();
|
||||
try {
|
||||
await openRevalidatePopup(revalidateUrl);
|
||||
return true;
|
||||
} catch (e) {
|
||||
onError(e.message || 'Authentication failed');
|
||||
return;
|
||||
return false;
|
||||
} finally {
|
||||
hint.hide();
|
||||
}
|
||||
};
|
||||
|
||||
$(el_window).find('.disable-2fa-btn').on('click', async function (e) {
|
||||
$(el_window).find('.disable-2fa-btn').on('click', async function () {
|
||||
try {
|
||||
await submit();
|
||||
} catch {
|
||||
// A request that never got an answer (offline): say so, and give
|
||||
// the button and the field back for a retry.
|
||||
onError(i18n('something_went_wrong', [], false));
|
||||
}
|
||||
});
|
||||
|
||||
async function submit () {
|
||||
$(el_window).find('.form-success-msg, .form-error-msg').hide();
|
||||
|
||||
const password = password_entry.get('value');
|
||||
@@ -143,7 +154,9 @@ async function UIWindowDisable2FA (options) {
|
||||
}
|
||||
|
||||
if ( oidc_only && !revalidated && !password ) {
|
||||
await myOpenRevalidatePopup();
|
||||
// A popup closed or blocked says so; the request would only be
|
||||
// refused, and its message would replace that.
|
||||
if ( ! await myOpenRevalidatePopup() ) return;
|
||||
|
||||
const res = await doSubmit({ password: undefined });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -168,7 +181,7 @@ async function UIWindowDisable2FA (options) {
|
||||
return;
|
||||
}
|
||||
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
|
||||
await myOpenRevalidatePopup(data.revalidate_url);
|
||||
if ( ! await myOpenRevalidatePopup(data.revalidate_url) ) return;
|
||||
const r = await doSubmit({ password: undefined });
|
||||
if ( r.ok ) {
|
||||
onSuccess();
|
||||
@@ -180,7 +193,7 @@ async function UIWindowDisable2FA (options) {
|
||||
return;
|
||||
}
|
||||
onError(data.message || 'Request failed', data);
|
||||
});
|
||||
}
|
||||
|
||||
function doSubmit ({ password } = {}) {
|
||||
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
|
||||
|
||||
@@ -615,15 +615,22 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
const oidc_only = !!(window.user && window.user.oidc_only);
|
||||
|
||||
// ── API helpers ──────────────────────────────────────────────────────
|
||||
// A network failure is a failed request like any other: rejecting here
|
||||
// would leave the code inputs disabled and the spinner up for good.
|
||||
const api = async (endpoint, body = {}) => {
|
||||
const res = await fetch(`${window.api_origin}/auth/configure-2fa/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${puter.authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${window.api_origin}/auth/configure-2fa/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${puter.authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, data: {} };
|
||||
}
|
||||
return { ok: res.ok, data: await read_json(res) };
|
||||
};
|
||||
|
||||
@@ -709,8 +716,8 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
h += '<div class="tfa-codes-card">';
|
||||
h += ' <div class="tfa-codes-grid"></div>';
|
||||
h += ' <div class="tfa-codes-actions">';
|
||||
h += ` <button class="tfa-copy-codes">${SVG_COPY} <span>${html_encode(i18n('copy'))}</span></button>`;
|
||||
h += ` <button class="tfa-print-codes">${SVG_PRINT} <span>${html_encode(i18n('print'))}</span></button>`;
|
||||
h += ` <button class="tfa-copy-codes">${SVG_COPY} <span>${i18n('copy')}</span></button>`;
|
||||
h += ` <button class="tfa-print-codes">${SVG_PRINT} <span>${i18n('print')}</span></button>`;
|
||||
h += ' </div>';
|
||||
h += '</div>';
|
||||
|
||||
@@ -719,11 +726,11 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
h += '<div class="tfa-confirmations">';
|
||||
h += ' <div class="tfa-confirm-item">';
|
||||
h += ' <input type="checkbox" id="tfa-confirm-saved" />';
|
||||
h += ` <label for="tfa-confirm-saved">${html_encode(i18n('setup2fa_5_confirmation_1'))}</label>`;
|
||||
h += ` <label for="tfa-confirm-saved">${i18n('setup2fa_5_confirmation_1')}</label>`;
|
||||
h += ' </div>';
|
||||
h += ' <div class="tfa-confirm-item">';
|
||||
h += ' <input type="checkbox" id="tfa-confirm-ready" />';
|
||||
h += ` <label for="tfa-confirm-ready">${html_encode(i18n('setup2fa_5_confirmation_2'))}</label>`;
|
||||
h += ` <label for="tfa-confirm-ready">${i18n('setup2fa_5_confirmation_2')}</label>`;
|
||||
h += ' </div>';
|
||||
h += '</div>';
|
||||
|
||||
@@ -732,7 +739,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
|
||||
// ── Screen 3: Verify Code ─────────────────────────────────────────────
|
||||
h += '<div class="tfa-screen" data-screen="3">';
|
||||
h += `<p class="tfa-instruction">${html_encode(i18n('setup2fa_3_step_heading'))}</p>`;
|
||||
h += `<p class="tfa-instruction">${i18n('setup2fa_3_step_heading')}</p>`;
|
||||
h += '<div class="tfa-code-section">';
|
||||
h += ' <div class="tfa-code-inputs">';
|
||||
for ( let i = 0; i < 6; i++ ) {
|
||||
@@ -747,7 +754,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
|
||||
// ── Screen 4: Success ────────────────────────────────────────────────
|
||||
h += '<div class="tfa-screen" data-screen="4">';
|
||||
h += `<div class="tfa-success-icon">${SVG_SHIELD_CHECK}</div>`;
|
||||
h += `<div class="tfa-success-title">${html_encode(i18n('two_factor_enabled'))}</div>`;
|
||||
h += `<div class="tfa-success-title">${i18n('two_factor_enabled')}</div>`;
|
||||
h += '<p class="tfa-success-text">Your account is now protected with two-factor authentication. You\'ll be asked for a verification code each time you sign in.</p>';
|
||||
h += `<button class="tfa-btn tfa-btn-success tfa-done-btn">Done</button>`;
|
||||
h += '</div>'; // end screen 4
|
||||
|
||||
@@ -94,6 +94,17 @@ const kindBadgeLabel = (kind) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/** A refused request's message: the body is JSON `{ message, code }`. */
|
||||
const responseMessage = async (resp) => {
|
||||
const text = await resp.text();
|
||||
try {
|
||||
return JSON.parse(text)?.message || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
options = options ?? {};
|
||||
|
||||
@@ -493,19 +504,23 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
}),
|
||||
},
|
||||
);
|
||||
if ( !resp.ok ) throw new Error(await resp.text());
|
||||
if ( !resp.ok ) throw new Error(await responseMessage(resp));
|
||||
} catch ( e ) {
|
||||
// Roll back optimistic update
|
||||
session.label = original || null;
|
||||
el_title.textContent = sessionTitle(session);
|
||||
alertDialog({ message: e?.toString?.() ?? String(e) });
|
||||
alertDialog({ message: e?.message ?? String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = () => finish(true);
|
||||
el_input.addEventListener('keydown', (ev) => {
|
||||
if ( ev.key === 'Enter' ) finish(true);
|
||||
else if ( ev.key === 'Escape' ) finish(false);
|
||||
else if ( ev.key === 'Escape' ) {
|
||||
// Cancels the rename only, not the whole modal.
|
||||
ev.stopPropagation();
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
el_input.addEventListener('blur', onBlur);
|
||||
};
|
||||
@@ -609,7 +624,9 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
reload_sessions();
|
||||
return;
|
||||
}
|
||||
alertDialog({ message: await resp.text() });
|
||||
// Revoked meanwhile (another tab): the list is what's stale.
|
||||
if ( resp.status === 404 ) reload_sessions();
|
||||
alertDialog({ message: await responseMessage(resp) });
|
||||
} catch ( e ) {
|
||||
alertDialog({ message: e.toString() });
|
||||
}
|
||||
@@ -731,6 +748,14 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
el_search.type = 'search';
|
||||
el_search.placeholder = i18n('ui_search') || 'Search sessions…';
|
||||
el_search.classList.add('session-manager-search');
|
||||
// Escape empties a search first; the next one closes the modal.
|
||||
el_search.addEventListener('keydown', (ev) => {
|
||||
if ( ev.key !== 'Escape' || el_search.value === '' ) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
el_search.value = '';
|
||||
el_search.dispatchEvent(new Event('input'));
|
||||
});
|
||||
el_search.addEventListener('input', () => {
|
||||
searchQuery = el_search.value.trim();
|
||||
// Pure client-side filter — re-render from the cached list
|
||||
@@ -774,7 +799,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
reload_sessions();
|
||||
return;
|
||||
}
|
||||
alertDialog({ message: await resp.text() });
|
||||
alertDialog({ message: await responseMessage(resp) });
|
||||
} catch ( e ) {
|
||||
alertDialog({ message: e.toString() });
|
||||
}
|
||||
|
||||
+118
-23
@@ -36,6 +36,10 @@
|
||||
--select-tint: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.1);
|
||||
--select-tint-strong: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.2);
|
||||
--select-ring: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.4);
|
||||
/* --select-color is 3.7:1 on white, short of the 4.5:1 text needs: the
|
||||
same hue, darker, for text in it (5.3:1) and fills under white text. */
|
||||
--select-text: hsl(var(--select-hue), var(--select-saturation), 45%);
|
||||
--select-fill: hsl(var(--select-hue), var(--select-saturation), 45%);
|
||||
|
||||
--dashboard-text: #444;
|
||||
--dashboard-border: #e0e0e0;
|
||||
@@ -164,6 +168,8 @@ body {
|
||||
--select-tint: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.16);
|
||||
--select-tint-strong: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.28);
|
||||
--select-ring: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.5);
|
||||
/* Lighter on dark surfaces (5.5:1 on the cards); fills keep theirs. */
|
||||
--select-text: hsl(var(--select-hue), var(--select-saturation), 65%);
|
||||
|
||||
--dashboard-text: #d4d4d4;
|
||||
--dashboard-border: #3d3d3d;
|
||||
@@ -575,15 +581,17 @@ body {
|
||||
}
|
||||
|
||||
/* Keyboard focus on the sidebar's controls: swap the UA's default blue
|
||||
ring for the dashboard's own selection ring, hugging the control's
|
||||
ring for the dashboard's own selection color, hugging the control's
|
||||
rounded corners from the inside. :focus-visible only, so a mouse click
|
||||
(which also focuses these) never paints it. */
|
||||
(which also focuses these) never paints it. A 2px ring is the only sign
|
||||
of focus here, so it is the solid color: --select-ring's translucent
|
||||
one is 1.6:1 against the surfaces, under the 3:1 a focus ring needs. */
|
||||
.dashboard-sidebar-item:focus-visible,
|
||||
.dashboard-user-btn:focus-visible,
|
||||
.dashboard-sidebar-toggle:focus-visible,
|
||||
.dashboard-sidebar-collapse-toggle:focus-visible,
|
||||
.dashboard-sidebar-close:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -695,6 +703,17 @@ body {
|
||||
background: linear-gradient(to bottom, rgba(30, 30, 30, 0) 0%, rgba(30, 30, 30, 0.85) 40%, rgba(30, 30, 30, 1) 100%);
|
||||
}
|
||||
|
||||
.usage-table-show-less {
|
||||
color: #9ca3af;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
.usage-table-show-less:hover {
|
||||
background: #374151;
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.dashboard-section-billing .billing-card {
|
||||
background: var(--dashboard-card-background);
|
||||
}
|
||||
@@ -860,6 +879,34 @@ body {
|
||||
.myapps-icon-clear {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.myapps-icon-clear svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.myapps-icon-clear:focus-visible {
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
/* Keeps its 16px look and hit-tests at 44x44. */
|
||||
.myapps-icon-clear::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -1096,6 +1143,17 @@ input.myapps-search:disabled {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Touch: the same 7px dot, in a 24px target; at 17px apiece, side by side,
|
||||
a tap easily lands on the neighbor. */
|
||||
@media (pointer: coarse) {
|
||||
.myapps-pager-dot,
|
||||
.myapps-group-dot {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 8.5px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.myapps-pager-dot:not(.active):hover,
|
||||
.myapps-group-dot:not(.active):hover {
|
||||
@@ -1229,7 +1287,7 @@ input.myapps-search:disabled {
|
||||
|
||||
/* 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: 2px solid var(--select-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
@@ -2110,8 +2168,8 @@ p.myapps-add-error {
|
||||
color: var(--dashboard-error-text);
|
||||
}
|
||||
.myapps-add-send {
|
||||
background: var(--select-color);
|
||||
border-color: var(--select-color);
|
||||
background: var(--select-fill);
|
||||
border-color: var(--select-fill);
|
||||
color: #fff;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
@@ -2167,7 +2225,9 @@ p.myapps-add-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(460px, 100%);
|
||||
max-height: min(600px, 85vh);
|
||||
/* 100%: within the overlay, which is the visible height; vh is the
|
||||
largest one, and with the browser's bars showing overflows it. */
|
||||
max-height: min(600px, 85vh, 100%);
|
||||
background: var(--dashboard-card-background);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 14px;
|
||||
@@ -2411,7 +2471,7 @@ p.myapps-add-error {
|
||||
.item-props-modal {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 88vh;
|
||||
max-height: min(88vh, 100%);
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
transform: translateY(24px);
|
||||
@@ -2465,7 +2525,8 @@ p.myapps-add-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(440px, 100%);
|
||||
max-height: min(620px, 85vh);
|
||||
/* 100%: within the overlay (see .item-props-modal). */
|
||||
max-height: min(620px, 85vh, 100%);
|
||||
background: var(--dashboard-card-background);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 14px;
|
||||
@@ -2986,7 +3047,7 @@ select.share-modal-link-access {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--select-color);
|
||||
color: var(--select-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
@@ -3216,7 +3277,7 @@ select.share-modal-row-mode:focus {
|
||||
.share-modal {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 88vh;
|
||||
max-height: min(88vh, 100%);
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
transform: translateY(24px);
|
||||
@@ -3992,7 +4053,7 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
|
||||
.dashboard-section-files .directories li.active {
|
||||
color: var(--select-color);
|
||||
color: var(--select-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -4091,6 +4152,10 @@ body.myapps-reordering .myapps-tile {
|
||||
.path-btn {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
/* A black chevron; uninverted it disappears into the dark header. */
|
||||
.dashboard-section-files .path-seperator {
|
||||
filter: invert(1);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
/* The hover chip is light (#EEE) even in dark mode, so the icon must
|
||||
drop its dark-mode inversion to stay readable on it. */
|
||||
@@ -4154,7 +4219,7 @@ body.myapps-reordering .myapps-tile {
|
||||
.dashboard-section-files .files-tab .files .row.place-row:focus-visible,
|
||||
.dashboard-section-files .header .path .dirname:focus-visible,
|
||||
.dashboard-section-files .header .path-btn:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -4211,9 +4276,9 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path .dirname.context-menu-active {
|
||||
color: var(--dashboard-background);
|
||||
background-color: var(--select-color);
|
||||
border: 1px solid var(--select-color);
|
||||
color: #fff;
|
||||
background-color: var(--select-fill);
|
||||
border: 1px solid var(--select-fill);
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .columns {
|
||||
@@ -4514,7 +4579,7 @@ body.myapps-reordering .myapps-tile {
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
background: var(--select-color);
|
||||
background: var(--select-fill);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -4887,6 +4952,14 @@ body.myapps-reordering .myapps-tile {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* A black glyph, lost on a dark disc; it gets the white one the shortcut
|
||||
and worker badges carry. */
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row img.item-has-website-url-badge {
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -6014,7 +6087,7 @@ body.myapps-reordering .myapps-tile {
|
||||
|
||||
/* 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: 2px solid var(--select-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -6154,7 +6227,7 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
|
||||
.dashboard .bento-recent-app:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -6924,6 +6997,12 @@ body.myapps-reordering .myapps-tile {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Off, the track is barely darker than the card it sits on (1.2:1 on the
|
||||
2FA warning card); its outline is what shows the control is there. */
|
||||
.dashboard-switch input:not(:checked) + .dashboard-switch-slider {
|
||||
box-shadow: inset 0 0 0 1px var(--dashboard-text-hint);
|
||||
}
|
||||
|
||||
.dashboard-switch input:checked + .dashboard-switch-slider {
|
||||
background-color: var(--dashboard-success-border);
|
||||
}
|
||||
@@ -7003,6 +7082,20 @@ body.myapps-reordering .myapps-tile {
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
/* style.css's red puts the white label at 4.4:1; this is the dashboard
|
||||
dialogs' danger red, at 5.3:1. */
|
||||
.dashboard-danger-card .button-danger {
|
||||
border-color: #d90707;
|
||||
background: #d90707;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.dashboard-danger-card .button-danger:hover {
|
||||
border-color: #b80606;
|
||||
background: #b80606;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive styles for Account tab */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-tab-content {
|
||||
@@ -7765,7 +7858,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--select-color);
|
||||
background: var(--select-fill);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
@@ -8216,7 +8309,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
.dashboard-notifications-mark-all:focus-visible,
|
||||
.dashboard-notifications-close:focus-visible,
|
||||
.dashboard-notifications-retry:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -8329,9 +8422,11 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* Screen-reader-only: the live region for arrivals, and a row's unread state */
|
||||
/* Screen-reader-only: the live region for arrivals, a row's unread state,
|
||||
and what the Apps search found */
|
||||
.dashboard-notifications-live,
|
||||
.dashboard-notification-sr {
|
||||
.dashboard-notification-sr,
|
||||
.myapps-search-status {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
@@ -5036,16 +5036,6 @@ html.dark-mode .usage-table-fade-overlay {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
html.dark-mode .usage-table-show-less {
|
||||
color: #9ca3af;
|
||||
border-color: #4b5563;
|
||||
}
|
||||
|
||||
html.dark-mode .usage-table-show-less:hover {
|
||||
background: #374151;
|
||||
border-color: #6b7280;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
|
||||
.driver-usage-details-content-table {
|
||||
@@ -5104,7 +5094,7 @@ html.dark-mode .usage-table-show-less:hover {
|
||||
}
|
||||
|
||||
.driver-usage-details-content-table thead th .usage-sort-btn:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
outline: 2px solid var(--select-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -1695,7 +1695,7 @@ window.refresh_trash_state = async function () {
|
||||
// 'strong' consistency: an 'eventual' stat can be served from the SDK
|
||||
// cache, which is stale right after a trash mutation and — when seeded
|
||||
// by readdir — lacks the `is_empty` field entirely.
|
||||
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
|
||||
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'strong' });
|
||||
if ( window.socket ) {
|
||||
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ import { fetchWithSessionCookieRetry, isSessionAuthError } from '../util/session
|
||||
* sign-in flow has been started and the user has to try again after it.
|
||||
*/
|
||||
|
||||
// Refusals whose server message is English and, for the password, reads
|
||||
// like the new and confirmed passwords disagreeing ("Password mismatch").
|
||||
const CODE_MESSAGES = {
|
||||
password_mismatch: 'incorrect_password',
|
||||
code_mismatch: 'confirm_code_generic_incorrect',
|
||||
too_many_requests: 'confirm_code_generic_too_many_requests',
|
||||
};
|
||||
|
||||
/**
|
||||
* POSTs an account change to `/user-protected/<route>`, the way every
|
||||
* account dialog has to. Those routes authenticate with the session cookie
|
||||
@@ -62,7 +70,8 @@ export default async function postUserProtected (route, body, { password, onReva
|
||||
return { ok: false, data, reauth: true, message: i18n('reauth_required_message', [], false) };
|
||||
}
|
||||
if ( res.ok ) return { ok: true, data };
|
||||
return { ok: false, data, message: data.message || res.statusText || i18n('error_unknown_cause', [], false) };
|
||||
const known = CODE_MESSAGES[data.code];
|
||||
return { ok: false, data, message: known ? i18n(known, [], false) : data.message || res.statusText || i18n('error_unknown_cause', [], false) };
|
||||
};
|
||||
|
||||
const revalidate = async (url) => {
|
||||
|
||||
@@ -113,6 +113,14 @@ describe('postUserProtected', () => {
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says the current password is wrong in the reader\'s language', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(400, { message: 'Password mismatch', code: 'password_mismatch' }));
|
||||
|
||||
const result = await postUserProtected('change-password', { new_pass: 'n' }, { password: 'wrong' });
|
||||
|
||||
expect(result).toMatchObject({ ok: false, message: 'incorrect_password' });
|
||||
});
|
||||
|
||||
it('passes the server message through on any other failure', async () => {
|
||||
fetch.mockResolvedValueOnce(reply(400, { message: 'Username taken', code: 'bad_request' }));
|
||||
|
||||
|
||||
@@ -369,6 +369,9 @@ export default function shareRecipientPicker ({
|
||||
});
|
||||
|
||||
$input.on('keydown', (e) => {
|
||||
// Keys that drive an IME (picking or confirming a candidate) are its
|
||||
// own; jQuery's event doesn't carry isComposing, the browser's does.
|
||||
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
|
||||
if ( e.key === 'ArrowDown' ) {
|
||||
e.preventDefault();
|
||||
if ( picked ) return;
|
||||
|
||||
@@ -258,6 +258,17 @@ describe('choosing someone', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves Enter and the arrows to an IME that is composing', async () => {
|
||||
const { picker, $input } = await open();
|
||||
const key = (k, init = {}) => $input[0].dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
|
||||
key('ArrowDown', { isComposing: true });
|
||||
key('Enter', { isComposing: true });
|
||||
expect(picker.recipient()).toBe(null);
|
||||
key('ArrowDown');
|
||||
key('Enter');
|
||||
expect(picker.recipient()).not.toBe(null);
|
||||
});
|
||||
|
||||
it('hands a typed address over untouched', async () => {
|
||||
const { picker, $input } = mount();
|
||||
$input.val(' [email protected] ').trigger('input');
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 { intlLocale } from './intlLocale.js';
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* How long ago `at` was, in the reader's language: "now", "5 minutes ago",
|
||||
* "yesterday", then a short date once it's more than a week old. Uses the
|
||||
* platform's formatters so nothing here needs translating; falls back to a
|
||||
* plain date where they're missing.
|
||||
*
|
||||
* @param {number} at - Epoch ms
|
||||
* @param {number} now - Epoch ms
|
||||
* @param {string} [locale]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatRelativeTime = (at, now, locale = 'en') => {
|
||||
const diff = Math.max(0, now - at);
|
||||
const rtf = relativeFormatter(locale);
|
||||
if ( diff < 45_000 ) return rtf ? rtf.format(0, 'second') : 'now';
|
||||
if ( diff < 45 * MINUTE ) {
|
||||
const minutes = Math.max(1, Math.round(diff / MINUTE));
|
||||
return rtf ? rtf.format(-minutes, 'minute') : `${minutes}m`;
|
||||
}
|
||||
const then = new Date(at);
|
||||
// "Yesterday" names a calendar day, so days are counted midnight to midnight.
|
||||
const days = Math.round((startOfDay(new Date(now)) - startOfDay(then)) / DAY);
|
||||
if ( days === 0 || diff < 22 * HOUR ) {
|
||||
const hours = Math.max(1, Math.round(diff / HOUR));
|
||||
return rtf ? rtf.format(-hours, 'hour') : `${hours}h`;
|
||||
}
|
||||
if ( days < 7 ) {
|
||||
return rtf ? rtf.format(-days, 'day') : `${days}d`;
|
||||
}
|
||||
const sameYear = then.getFullYear() === new Date(now).getFullYear();
|
||||
const dtf = dateFormatter(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
return dtf ? dtf.format(then) : then.toLocaleDateString();
|
||||
};
|
||||
|
||||
/**
|
||||
* The full moment, for a tooltip behind the relative form.
|
||||
*
|
||||
* @param {number} at - Epoch ms
|
||||
* @param {string} [locale]
|
||||
* @returns {string}
|
||||
*/
|
||||
export const formatAbsoluteTime = (at, locale = 'en') => {
|
||||
const dtf = dateFormatter(locale, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return dtf ? dtf.format(new Date(at)) : new Date(at).toLocaleString();
|
||||
};
|
||||
|
||||
const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
|
||||
const tryMake = (make) => {
|
||||
try {
|
||||
return make();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// A listing formats a date per row; making a formatter costs far more than
|
||||
// using one. Keyed by language and options, of which there are a handful.
|
||||
const formatters = new Map();
|
||||
const cached = (key, make) => {
|
||||
if ( ! formatters.has(key) ) formatters.set(key, make());
|
||||
return formatters.get(key);
|
||||
};
|
||||
|
||||
// An unknown locale tag throws; the English formatter beats none at all.
|
||||
const relativeFormatter = (locale) => {
|
||||
if ( typeof Intl === 'undefined' || ! Intl.RelativeTimeFormat ) return null;
|
||||
return cached(`relative:${locale}`, () => tryMake(() => new Intl.RelativeTimeFormat(intlLocale(locale), { numeric: 'auto' }))
|
||||
?? tryMake(() => new Intl.RelativeTimeFormat('en', { numeric: 'auto' })));
|
||||
};
|
||||
|
||||
const dateFormatter = (locale, options) => {
|
||||
if ( typeof Intl === 'undefined' || ! Intl.DateTimeFormat ) return null;
|
||||
return cached(`date:${locale}:${JSON.stringify(options)}`, () => tryMake(() => new Intl.DateTimeFormat(intlLocale(locale), options))
|
||||
?? tryMake(() => new Intl.DateTimeFormat('en', options)));
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 { formatAbsoluteTime, formatRelativeTime } from './formatTime.js';
|
||||
|
||||
const NOW = Date.UTC(2026, 7, 27, 12, 0, 0);
|
||||
const MIN = 60_000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
it('describes the recent past in words', () => {
|
||||
expect(formatRelativeTime(NOW, NOW)).toBe('now');
|
||||
expect(formatRelativeTime(NOW - 30_000, NOW)).toBe('now');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW)).toBe('5 minutes ago');
|
||||
expect(formatRelativeTime(NOW - 1 * MIN, NOW)).toBe('1 minute ago');
|
||||
expect(formatRelativeTime(NOW - 3 * HOUR, NOW)).toBe('3 hours ago');
|
||||
expect(formatRelativeTime(NOW - DAY, NOW)).toBe('yesterday');
|
||||
expect(formatRelativeTime(NOW - 3 * DAY, NOW)).toBe('3 days ago');
|
||||
});
|
||||
|
||||
it('counts days by the calendar, not by 24-hour spans', () => {
|
||||
const at = (d, h, m = 0) => new Date(2026, 7, d, h, m).getTime();
|
||||
expect(formatRelativeTime(at(27, 0, 45), at(27, 23, 30))).toBe('23 hours ago');
|
||||
expect(formatRelativeTime(at(24, 23), at(26, 9))).toBe('2 days ago');
|
||||
expect(formatRelativeTime(at(26, 20), at(27, 19))).toBe('yesterday');
|
||||
});
|
||||
|
||||
it('falls back to a short date past a week', () => {
|
||||
expect(formatRelativeTime(NOW - 10 * DAY, NOW)).toBe('Aug 17');
|
||||
expect(formatRelativeTime(NOW - 400 * DAY, NOW)).toBe('Jul 23, 2025');
|
||||
});
|
||||
|
||||
it('never reports the future', () => {
|
||||
expect(formatRelativeTime(NOW + HOUR, NOW)).toBe('now');
|
||||
});
|
||||
|
||||
it('localises', () => {
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'fr')).toBe('il y a 5 minutes');
|
||||
});
|
||||
|
||||
it("reads Puter's language codes that aren't BCP 47 tags", () => {
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'br')).toBe('há 5 minutos');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'ua')).toBe('5 хвилин тому');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'zhtw')).toBe('5 分鐘前');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'emoji')).toBe('5 minutes ago');
|
||||
expect(formatAbsoluteTime(NOW, 'zhtw')).toMatch(/^2026年8月\d+日/);
|
||||
});
|
||||
|
||||
it('survives an unknown locale', () => {
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'not-a-locale-!!')).toBe('5 minutes ago');
|
||||
expect(formatRelativeTime(NOW - 10 * DAY, NOW, 'not-a-locale-!!')).toBe('Aug 17');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAbsoluteTime', () => {
|
||||
it('spells out the moment', () => {
|
||||
expect(formatAbsoluteTime(NOW, 'en')).toMatch(/Aug 27, 2026/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatter reuse', () => {
|
||||
it('keeps each language apart', () => {
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'de')).toBe('vor 5 Minuten');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'fr')).toBe('il y a 5 minutes');
|
||||
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'de')).toBe('vor 5 Minuten');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// Puter's language codes that aren't the BCP 47 tag for that language: Intl
|
||||
// reads `br` as Breton, and `ua` and `emoji` as nothing at all.
|
||||
const INTL_LOCALES = { br: 'pt-BR', ua: 'uk', zh: 'zh-CN', zhtw: 'zh-TW', emoji: 'en' };
|
||||
|
||||
/**
|
||||
* The tag to hand Intl (dates, numbers) for one of Puter's language codes.
|
||||
*
|
||||
* @param {string} locale - A Puter language code, e.g. `window.locale`
|
||||
* @returns {string}
|
||||
*/
|
||||
export const intlLocale = (locale) => INTL_LOCALES[locale] ?? locale;
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 translations from './translations/translations.js';
|
||||
import { intlLocale } from './intlLocale.js';
|
||||
|
||||
describe('intlLocale', () => {
|
||||
it('maps the codes Intl would misread', () => {
|
||||
expect(intlLocale('br')).toBe('pt-BR');
|
||||
expect(intlLocale('ua')).toBe('uk');
|
||||
expect(intlLocale('zhtw')).toBe('zh-TW');
|
||||
expect(intlLocale('emoji')).toBe('en');
|
||||
expect(intlLocale('de')).toBe('de');
|
||||
});
|
||||
|
||||
it('gives Intl a tag it accepts for every language Puter ships', () => {
|
||||
for ( const code of Object.keys(translations) ) {
|
||||
expect(() => new Intl.DateTimeFormat(intlLocale(code)), code).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -324,6 +324,7 @@ const en = {
|
||||
previous_page: 'Previous page',
|
||||
quit: 'Quit',
|
||||
search_apps: 'Search apps...',
|
||||
clear_search: 'Clear search',
|
||||
uninstall: 'Uninstall',
|
||||
uninstall_app: 'Uninstall {{name}}?',
|
||||
uninstall_app_body: 'This will revoke all permissions for this app.',
|
||||
@@ -347,6 +348,9 @@ const en = {
|
||||
password_change_required_title: 'Choose your own password',
|
||||
password_change_required_hint: 'This account was created for you, and its password is still the one you were given. Pick your own to continue.',
|
||||
password_change_required_same: 'Your new password must be different from the one you were given.',
|
||||
password_unchanged: 'Your new password must be different from your current one.',
|
||||
username_unchanged: 'That is already your username.',
|
||||
email_unchanged: 'That is already your email address.',
|
||||
password_strength_error: 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.',
|
||||
passwords_do_not_match: '`New Password` and `Confirm New Password` do not match.',
|
||||
paste: 'Paste',
|
||||
@@ -544,6 +548,7 @@ const en = {
|
||||
share_inherited_via_count: 'via {{count}} folders',
|
||||
share_no_one_items: 'None of these items are shared yet.',
|
||||
share_failed_items: 'Could not share these items.',
|
||||
share_load_failed: 'Could not load who has access.',
|
||||
share_load_partial_failed:
|
||||
'Could not load who has access to every item.',
|
||||
share_access_list_too_many:
|
||||
|
||||
@@ -478,12 +478,17 @@ $(document).bind('keydown', async function (e) {
|
||||
//-----------------------------------------------------------------------
|
||||
if ( e.which === 27 ) {
|
||||
// close open context menus
|
||||
const closedMenu = $('.context-menu').length > 0;
|
||||
$('.context-menu').remove();
|
||||
|
||||
// close the Launch Popover if it's open
|
||||
$('.launch-popover').closest('.popover').fadeOut(200, function () {
|
||||
$('.launch-popover').closest('.popover').remove();
|
||||
});
|
||||
|
||||
// That Escape was the menu's; later handlers must not also act on it
|
||||
// (clear a Files selection, close its preview).
|
||||
if ( closedMenu ) e.stopImmediatePropagation();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -905,7 +910,9 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
// Undo
|
||||
// ctrl/command + z, will undo last action
|
||||
//-----------------------------------------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 90 ) {
|
||||
// Not in a text field, whose own undo is what the user means: the last
|
||||
// file action could be an upload, which Undo deletes outright.
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 90 && ! $(focused_el).is('input, textarea, [contenteditable]') ) {
|
||||
window.undo_last_action();
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user