mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771506a58d | ||
|
|
f26201649a | ||
|
|
1550be358d |
No files matched your search
@@ -259,6 +259,36 @@ no user to name), puter.js opens the dashboard's upgrade page in a new tab with
|
||||
what was refused, through a consent dialog when the click has expired or the
|
||||
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
|
||||
|
||||
## Top-up
|
||||
|
||||
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
|
||||
buys add-on credit, served and framed like the upgrade: a transparent GUI page
|
||||
covering the host with no box or close button, allowing `payment` and
|
||||
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
|
||||
|
||||
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
|
||||
shows only when whoami reports `subscribed: true` and no `team` paying for the
|
||||
account. On a page that can't frame the GUI, the item opens the dashboard with
|
||||
`?topup=` in a new tab instead.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting, and the deployment defines
|
||||
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UITopUp({ onClose }).open();
|
||||
```
|
||||
|
||||
`open()` resolves once the dialog has loaded the account and shows what it can
|
||||
buy, and `onClose({ toppedUp })` must run exactly once, when the dialog closes
|
||||
or when it declines to open for the account. The GUI then posts `{ msg: 'puterPopupFrame', state: 'closed', toppedUp }`
|
||||
and the page removes the frame. When the frame can't show the top-up, puter.js
|
||||
opens the dashboard's top-up in a new tab, as it does the upgrade.
|
||||
|
||||
While the top-up shows, a call refused for funds still rejects but opens no
|
||||
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
|
||||
resolves `{ upgraded: false }` once the top-up closes.
|
||||
|
||||
## Email confirmation
|
||||
|
||||
`GET /embed/request-email-confirmation` walks a first-party page's user through
|
||||
@@ -299,7 +329,7 @@ Chromium installed), and the backend route tests:
|
||||
|
||||
```sh
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
@@ -36,9 +36,9 @@ import type {
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab, and the email confirmation,
|
||||
* which it otherwise can't show.
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade and
|
||||
* top-up dialogs, which it would otherwise open in a dashboard tab, and the
|
||||
* email confirmation, which it otherwise can't show.
|
||||
*/
|
||||
export const FRAMED_POPUP_ACTIONS = [
|
||||
'sign-in',
|
||||
@@ -46,6 +46,7 @@ export const FRAMED_POPUP_ACTIONS = [
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ describe('PuterServer host header validation', () => {
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
])('lets a deployment subdomain frame the %s popup and names it to the GUI', async (action) => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
@@ -447,7 +448,7 @@ describe('PuterServer host header validation', () => {
|
||||
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Top-Up', '/embed/top-up/', '/embed/Request-Email-Confirmation'])(
|
||||
'lets a first-party page frame only the exact spelling of %s',
|
||||
async (spelling) => {
|
||||
const res = await request(spelling, {
|
||||
|
||||
@@ -69,6 +69,7 @@ beforeAll(async () => {
|
||||
[sessionToken]: { uuid: sessionUser, username: 'session-user' },
|
||||
'app-token-session-user': { uuid: sessionUser, username: 'session-user' },
|
||||
'app-token-other-user': { uuid: otherUser, username: 'other-user' },
|
||||
'app-token-subscriber': { uuid: sessionUser, username: 'session-user', subscribed: true },
|
||||
};
|
||||
// The SDK on a sibling subdomain sends credentialed cross-origin requests.
|
||||
app.use(['/whoami', '/profile', '/rao'], (req, res, next) => {
|
||||
@@ -513,6 +514,81 @@ describe('embedded app browser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('offers Add credits to an account with a plan, and frames the top-up over the page', async () => {
|
||||
const context = await menuContext('app-token-subscriber');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').click();
|
||||
const item = page.locator('puter-user-menu .add-credits');
|
||||
await item.waitFor();
|
||||
expect(await item.textContent()).toBe('Add credits');
|
||||
// Under Usage, in the same row style as the links around it.
|
||||
expect(await page.locator('puter-user-menu .usage + .add-credits').count()).toBe(1);
|
||||
expect(await item.evaluate(el => getComputedStyle(el).height))
|
||||
.toBe(await page.locator('puter-user-menu .feedback').evaluate(el => getComputedStyle(el).height));
|
||||
|
||||
await item.click();
|
||||
const frame = page.locator('dialog.puter-popup-frame iframe');
|
||||
await frame.waitFor({ state: 'attached' });
|
||||
expect(await frame.getAttribute('src')).toBe(`${origin}/embed/top-up#user=${sessionUser}`);
|
||||
expect(await frame.getAttribute('allow')).toBe('payment; otp-credentials');
|
||||
expect(await page.locator('puter-user-menu .menu').isHidden()).toBe(true);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns keyboard focus to the account button when Add credits is chosen', async () => {
|
||||
const context = await menuContext('app-token-subscriber');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('puter-user-menu .add-credits').waitFor();
|
||||
await page.locator('puter-user-menu .add-credits').focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('dialog.puter-popup-frame iframe').waitFor({ state: 'attached' });
|
||||
const focused = () => page.evaluate(() => document.activeElement.shadowRoot?.activeElement?.className ?? document.activeElement.tagName);
|
||||
expect(await focused()).toBe('avatar');
|
||||
|
||||
// The dialog hands the focus back when it closes.
|
||||
const fromFrame = (state) => page.evaluate(([guiOrigin, frameState]) => {
|
||||
const source = document.querySelector('dialog.puter-popup-frame iframe').contentWindow;
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puterPopupFrame', state: frameState }, origin: guiOrigin, source,
|
||||
}));
|
||||
}, [origin, state]);
|
||||
await fromFrame('ready');
|
||||
expect(await page.locator('dialog.puter-popup-frame').evaluate(el => el.open)).toBe(true);
|
||||
await fromFrame('closed');
|
||||
await page.locator('dialog.puter-popup-frame').waitFor({ state: 'detached' });
|
||||
expect(await focused()).toBe('avatar');
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves Add credits out of the menu of an account without a plan', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(6000);
|
||||
await page.goto(`http://spreadsheet.puter.localhost:${port}/menu-host`);
|
||||
await page.locator('puter-user-menu .avatar').click();
|
||||
await page.locator('puter-user-menu .usage').waitFor();
|
||||
expect(await page.locator('puter-user-menu .add-credits').isHidden()).toBe(true);
|
||||
} finally {
|
||||
guestApps = [];
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the account menu when Tab leaves it', async () => {
|
||||
const context = await menuContext('app-token-session-user');
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { formatCount, formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
|
||||
import { isOrgSeat } from './orgSeat.js';
|
||||
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { addonCreditsBudget, addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
|
||||
|
||||
// Whether the server reported credits — decides whether the usage surfaces
|
||||
@@ -77,10 +77,23 @@ const TabUsage = {
|
||||
<div class="usage-progbar-wrapper">
|
||||
<div class="usage-progbar" style="width: 0;"></div>
|
||||
</div>
|
||||
<p class="usage-addon-credits" style="display: none;">
|
||||
<span class="usage-addon-left"></span>
|
||||
<span class="usage-addon-note">· ${i18n('addon_credits_note')}</span>
|
||||
</p>
|
||||
<div class="usage-addon" style="display: none;">
|
||||
<div class="driver-usage-header">
|
||||
<h3 id="usage-addon-title" style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('addon_credits')}</h3>
|
||||
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
|
||||
<span class="usage-addon-percent">--%</span>
|
||||
<span> · </span>
|
||||
${i18n('used_of', { used: '<span class="usage-addon-used">--</span>', available: '<span class="usage-addon-capacity">--</span>' }, false)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-progbar-wrapper" role="progressbar" aria-labelledby="usage-addon-title" aria-valuemin="0" aria-valuemax="100">
|
||||
<div class="usage-addon-bar" style="width: 0;"></div>
|
||||
</div>
|
||||
<p class="usage-addon-credits">
|
||||
<span class="usage-addon-left"></span>
|
||||
<span class="usage-addon-note">· ${i18n('addon_credits_note')}</span>
|
||||
</p>
|
||||
</div>
|
||||
<h3 style="margin:15px 0 10px 0; font-size: 14px; font-weight: 500;">${i18n('usage_details')}</h3>
|
||||
<div class="driver-usage-details-content visible">
|
||||
</div>
|
||||
@@ -330,8 +343,20 @@ async function update_usage_details ($el_window) {
|
||||
'background-color': window.usage_bar_color(budget.barPercent),
|
||||
});
|
||||
|
||||
$('.usage-addon-credits').toggle(addonLeft !== null);
|
||||
if ( addonLeft !== null ) {
|
||||
// Its own meter under the monthly one, for an account that has ever had any.
|
||||
const addon = addonCreditsBudget(res.allowanceInfo);
|
||||
$('.usage-addon').toggle(addon !== null);
|
||||
if ( addon !== null ) {
|
||||
$('.usage-addon-used').html(amount(addon.used));
|
||||
$('.usage-addon-capacity').html(amount(addon.capacity));
|
||||
$('.usage-addon-percent').html(`${addon.percent}%`);
|
||||
$('.usage-addon-bar').css({
|
||||
width: `${addon.barPercent}%`,
|
||||
'background-color': window.usage_bar_color(addon.barPercent),
|
||||
}).parent().attr({
|
||||
'aria-valuenow': addon.percent,
|
||||
'aria-valuetext': `${addon.percent}%`,
|
||||
});
|
||||
$('.usage-addon-left').html(i18n('addon_credits_available', { amount: amount(addonLeft) }));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ export const meterPercent = (share) => (share < 100 ? Math.min(99, Math.round(sh
|
||||
|
||||
/**
|
||||
* @typedef {Object} UsageBudget
|
||||
* @property {number} used - Allowance-charged spend, in the server's units.
|
||||
* @property {number} capacity - The monthly plan allowance.
|
||||
* @property {number} used - Spend charged to it, in the server's units.
|
||||
* @property {number} capacity - What the meter is out of: the monthly plan allowance, or the add-on credit bought.
|
||||
* @property {number} percent - `used` as a whole-number share of `capacity`.
|
||||
* @property {number} barPercent - The share clamped to 0-100, for a bar width.
|
||||
*/
|
||||
@@ -83,6 +83,28 @@ export const addonCreditsRemaining = (allowanceInfo) => {
|
||||
return Math.max(0, purchased - nonNegative(addons.consumedPurchaseCredits));
|
||||
};
|
||||
|
||||
/**
|
||||
* The add-on credit meter: what has been spent of all the add-on credit the
|
||||
* account ever bought. Null when it has never had any, as there is no pool
|
||||
* to meter.
|
||||
*
|
||||
* @param {Object | null | undefined} allowanceInfo
|
||||
* @returns {UsageBudget | null}
|
||||
*/
|
||||
export const addonCreditsBudget = (allowanceInfo) => {
|
||||
const left = addonCreditsRemaining(allowanceInfo);
|
||||
if ( left === null ) return null;
|
||||
const capacity = nonNegative(allowanceInfo.addons.purchasedCredits);
|
||||
const used = capacity - left;
|
||||
const share = (used / capacity) * 100;
|
||||
return {
|
||||
used,
|
||||
capacity,
|
||||
percent: meterPercent(share),
|
||||
barPercent: Math.max(0, Math.min(100, share)),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* `getMonthlyUsage()` resolves with whatever body the server answered, an
|
||||
* error included. Throws unless that body is a usage report, so a failed
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { addonCreditsBudget, addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
|
||||
const info = (allowance, addons = {}) => ({
|
||||
monthUsageAllowance: allowance,
|
||||
@@ -148,6 +148,41 @@ describe('addonCreditsRemaining', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addonCreditsBudget', () => {
|
||||
it('has no meter for an account that never had add-on credit', () => {
|
||||
expect(addonCreditsBudget(info(900_000_000))).toBeNull();
|
||||
expect(addonCreditsBudget(info(900_000_000, { purchasedCredits: 0 }))).toBeNull();
|
||||
expect(addonCreditsBudget(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('meters what was spent of the credit bought', () => {
|
||||
expect(addonCreditsBudget(info(900_000_000, {
|
||||
purchasedCredits: 2_500,
|
||||
consumedPurchaseCredits: 850,
|
||||
}))).toEqual({ used: 850, capacity: 2_500, percent: 34, barPercent: 34 });
|
||||
});
|
||||
|
||||
it('starts empty when nothing has been spent, or the spend is unreadable', () => {
|
||||
expect(addonCreditsBudget(info(0, { purchasedCredits: 1_000 })))
|
||||
.toEqual({ used: 0, capacity: 1_000, percent: 0, barPercent: 0 });
|
||||
expect(addonCreditsBudget(info(0, { purchasedCredits: 1_000, consumedPurchaseCredits: NaN })).used).toBe(0);
|
||||
});
|
||||
|
||||
it('reads full, never past it, once the pool is spent', () => {
|
||||
expect(addonCreditsBudget(info(0, {
|
||||
purchasedCredits: 1_000,
|
||||
consumedPurchaseCredits: 1_200,
|
||||
}))).toEqual({ used: 1_000, capacity: 1_000, percent: 100, barPercent: 100 });
|
||||
});
|
||||
|
||||
it('holds at 99% while any credit is left', () => {
|
||||
expect(addonCreditsBudget(info(0, {
|
||||
purchasedCredits: 100_000,
|
||||
consumedPurchaseCredits: 99_999,
|
||||
})).percent).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
describe('monthlyUsageOrThrow', () => {
|
||||
it('passes a usage report through', () => {
|
||||
const res = { usage: { total: 0 }, allowanceInfo: null };
|
||||
|
||||
+32
-10
@@ -34,7 +34,7 @@ import UIWindowProgress from './UIWindowProgress.js';
|
||||
import UITaskbar from './UITaskbar.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { atPath, retargetPaths, underPath } from '../helpers/pathElements.js';
|
||||
import { atPath, retargetNavHistory, retargetPaths, underPath } from '../helpers/pathElements.js';
|
||||
import changeLanguage from '../i18n/i18nChangeLanguage.js';
|
||||
import UIWindowTaskManager from './UIWindowTaskManager.js';
|
||||
import truncate_filename from '../helpers/truncateFilename.js';
|
||||
@@ -233,7 +233,12 @@ async function UIDesktop (options) {
|
||||
// empty trash windows if needed
|
||||
if ( msg.is_empty )
|
||||
{
|
||||
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.item-container').empty();
|
||||
// only the rows: the container also holds its empty, error and loading states
|
||||
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).each(function () {
|
||||
$(this).find('.item-container .item').removeItems();
|
||||
window.update_explorer_footer_item_count(this);
|
||||
window.update_explorer_footer_selected_items_count(this);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -416,14 +421,17 @@ async function UIDesktop (options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// hide all UIItems with matching uids
|
||||
// remove all UIItems at the removed path
|
||||
atPath('.item', item.path).fadeOut(150, function () {
|
||||
// close all windows with matching uids
|
||||
// $('.window-' + item.uid).close();
|
||||
// close all windows that belong to a descendant of this item
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', item.path).close();
|
||||
const $parent_window = $(this).closest('.window');
|
||||
$(this).removeItems();
|
||||
window.update_explorer_footer_item_count($parent_window);
|
||||
window.update_explorer_footer_selected_items_count($parent_window);
|
||||
});
|
||||
// close the windows showing the removed folder or anything inside it,
|
||||
// whether or not its own row is displayed anywhere
|
||||
// todo this has to be case-insensitive
|
||||
atPath('.window[data-app="explorer"]', item.path).add(underPath('.window', item.path)).close();
|
||||
});
|
||||
|
||||
window.socket.on('item.updated', async (item) => {
|
||||
@@ -455,6 +463,14 @@ async function UIDesktop (options) {
|
||||
$(`.item[data-uid='${item.uid}'] .item-name-editor`).val(item.name);
|
||||
$(`.item[data-uid='${item.uid}'] .item-name`).attr('title', item.name);
|
||||
|
||||
// An overwrite arrives as an update, with a new size and date
|
||||
if ( item.size !== undefined && item.modified !== undefined ) {
|
||||
const $updated_items = $(`.item[data-uid='${item.uid}']`);
|
||||
$updated_items.attr({ 'data-size': item.size ?? '', 'data-modified': item.modified ?? '' });
|
||||
$updated_items.find('.item-attr--size > span').text(item.size ? window.byte_format(item.size) : '-');
|
||||
$updated_items.find('.item-attr--modified > span').text(item.modified === 0 ? '-' : timeago.format(item.modified * 1000));
|
||||
}
|
||||
|
||||
// Set new data-path
|
||||
const new_path = item.path;
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
|
||||
@@ -523,6 +539,7 @@ async function UIDesktop (options) {
|
||||
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
|
||||
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
|
||||
});
|
||||
retargetNavHistory(resp.old_path, fsentry.path);
|
||||
}
|
||||
|
||||
if ( dest_path === window.trash_path ) {
|
||||
@@ -546,13 +563,18 @@ async function UIDesktop (options) {
|
||||
// Trashing renames the entry to its uid; the real name is in metadata.
|
||||
fsentry.name = metadata.original_name ?? fsentry.name;
|
||||
|
||||
const moved_item_icon = await item_icon(fsentry);
|
||||
const $dest_containers = atPath('.item-container', dest_path);
|
||||
// the tab that made this move renders the row itself, in either order
|
||||
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
|
||||
|
||||
// create new item on matching containers
|
||||
UIItem({
|
||||
appendTo: atPath('.item-container', dest_path),
|
||||
appendTo: $dest_containers,
|
||||
immutable: fsentry.immutable || fsentry.writable === false,
|
||||
uid: fsentry.uid,
|
||||
path: fsentry.path,
|
||||
icon: await item_icon(fsentry),
|
||||
icon: moved_item_icon,
|
||||
name: fsentry.name,
|
||||
is_dir: fsentry.is_dir,
|
||||
size: fsentry.size,
|
||||
|
||||
+38
-16
@@ -396,7 +396,7 @@ async function UIItem (options) {
|
||||
// do not highlight if the target is the same as the item being moved
|
||||
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') &&
|
||||
// do not highlight if item is being moved to where it already is
|
||||
$(el_item).attr('data-path') !== $(window.mouseover_item_container).attr('data-path') ) {
|
||||
path.dirname($(el_item).attr('data-path')) !== $(window.mouseover_item_container).attr('data-path') ) {
|
||||
|
||||
// highlight item container
|
||||
$(window.mouseover_item_container).addClass('item-container-active');
|
||||
@@ -416,7 +416,8 @@ async function UIItem (options) {
|
||||
},
|
||||
stop: function (event, ui) {
|
||||
// Allow rearranging only if item is on desktop, not trash container, auto arrange is disabled and item is not dropped into another item
|
||||
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path &&
|
||||
// the desktop itself, not a window showing the Desktop folder
|
||||
if ( $(el_item).closest('.item-container').hasClass('desktop') &&
|
||||
!window.is_auto_arrange_enabled && $(el_item).attr('data-path') !== window.trash_path && !ui.helper.data('dropped') &&
|
||||
// Item must be dropped on the Desktop and not on the taskbar
|
||||
window.mouseover_window === undefined && ui.position.top <= window.desktop_height - window.taskbar_height - 15 ) {
|
||||
@@ -434,7 +435,9 @@ async function UIItem (options) {
|
||||
// re-enable all droppable UIItems that are not a dir
|
||||
$('.item[data-is_dir=\'0\']:not(.item-selected)').droppable('enable');
|
||||
// remove active item-container border highlights
|
||||
$('.item-container').removeClass('item-container-active');
|
||||
$('.item-container').removeClass('item-container-active item-container-transparent-border');
|
||||
// a drop that bailed out early never re-enables the containers an item's hover disabled
|
||||
$('.item-container.ui-droppable').droppable('enable');
|
||||
// reset longer hover timeout and last window dragged over
|
||||
clearTimeout(longer_hover_timeout);
|
||||
last_window_dragged_over = null;
|
||||
@@ -548,11 +551,14 @@ async function UIItem (options) {
|
||||
}
|
||||
// Otherwise, move items
|
||||
else if ( options.is_dir ) {
|
||||
// a selected folder can be the drop target of its own selection; it stays where it is
|
||||
const items_to_drop = items_to_move.filter(item => $(item).attr('data-uid') !== $(el_item).attr('data-uid'));
|
||||
if ( $(el_item).closest('.item-container').attr('data-path') === window.desktop_path ) {
|
||||
delete window.desktop_item_positions[$(el_item).attr('data-uid')];
|
||||
// the items leaving the desktop lose their saved positions, not the folder they land in
|
||||
items_to_drop.forEach(item => delete window.desktop_item_positions[$(item).attr('data-uid')]);
|
||||
window.save_desktop_item_positions();
|
||||
}
|
||||
window.move_items(items_to_move, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
|
||||
window.move_items(items_to_drop, $(el_item).attr('data-shortcut_to_path') !== '' ? $(el_item).attr('data-shortcut_to_path') : $(el_item).attr('data-path'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,8 +569,9 @@ async function UIItem (options) {
|
||||
},
|
||||
over: function (event, ui) {
|
||||
// Check hovering over an item that is VISIBILE
|
||||
// A desktop item has no window, so it only counts while the pointer is over the desktop itself
|
||||
const $event_parent_win = $(event.target).closest('.window');
|
||||
if ( $event_parent_win.length > 0 && $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
|
||||
if ( $event_parent_win.attr('data-id') !== $(window.mouseover_window).attr('data-id') )
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -722,7 +729,7 @@ async function UIItem (options) {
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', function (e) {
|
||||
$(document).on(`click.item-${item_id}`, function (e) {
|
||||
if ( !$(e.target).hasClass('item') && !$(e.target).hasClass('item-name') && !$(e.target).hasClass('item-icon') ) {
|
||||
skip_a_rename_click = true;
|
||||
}
|
||||
@@ -731,6 +738,10 @@ async function UIItem (options) {
|
||||
skip_a_rename_click = true;
|
||||
}
|
||||
});
|
||||
// a document handler outlives the row unless it is released with it
|
||||
$(el_item).on('remove', function () {
|
||||
$(document).off(`click.item-${item_id}`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Rename
|
||||
@@ -738,6 +749,7 @@ async function UIItem (options) {
|
||||
function rename () {
|
||||
if ( rename_cancelled ) {
|
||||
rename_cancelled = false;
|
||||
$(el_item_name_editor).removeClass('item-name-editor-active');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -757,10 +769,12 @@ async function UIItem (options) {
|
||||
UIAlert('The name ".." is not allowed, because it is a reserved name. Please choose another name.');
|
||||
}
|
||||
|
||||
$(el_item_name).html(html_encode(truncate_filename(options.name)));
|
||||
$(el_item_name).html(html_encode(truncate_filename($(el_item).attr('data-name'))));
|
||||
$(el_item_name).show();
|
||||
$(el_item_name_editor).val($(el_item).attr('data-name'));
|
||||
$(el_item_name_editor).hide();
|
||||
// the Delete shortcut skips an item whose editor is marked active
|
||||
$(el_item_name_editor).removeClass('item-name-editor-active');
|
||||
return;
|
||||
}
|
||||
// deactivate item name editable
|
||||
@@ -806,8 +820,9 @@ async function UIItem (options) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
rename_cancelled = true;
|
||||
$(el_item_name_editor).removeClass('item-name-editor-active');
|
||||
$(el_item_name_editor).hide();
|
||||
$(el_item_name_editor).val(options.name);
|
||||
$(el_item_name_editor).val($(el_item).attr('data-name'));
|
||||
$(el_item_name).show();
|
||||
}
|
||||
});
|
||||
@@ -866,6 +881,11 @@ async function UIItem (options) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The menu is built after lookups that take time, so it opens where the pointer was when it was asked for.
|
||||
const menu_position = (isMobile.phone || isMobile.tablet)
|
||||
? { left: window.last_touch_x, top: window.last_touch_y }
|
||||
: { left: window.mouseX, top: window.mouseY };
|
||||
|
||||
// if editing item name, preserve native context menu
|
||||
if ( event.target === el_item_name_editor )
|
||||
{
|
||||
@@ -1137,7 +1157,7 @@ async function UIItem (options) {
|
||||
// try to find suitable apps
|
||||
const suitable_apps = await window.suggest_apps_for_fsentry({
|
||||
uid: options.uid,
|
||||
path: options.path,
|
||||
path: $(el_item).attr('data-path'),
|
||||
});
|
||||
if ( suitable_apps && suitable_apps.length > 0 ) {
|
||||
options.suggested_apps = suitable_apps;
|
||||
@@ -1275,7 +1295,7 @@ async function UIItem (options) {
|
||||
file_path: $(el_item).attr('data-path'),
|
||||
file_uid: $(el_item).attr('data-uid'),
|
||||
params: {
|
||||
source_path: options.path,
|
||||
source_path: $(el_item).attr('data-path'),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -1322,7 +1342,7 @@ async function UIItem (options) {
|
||||
}
|
||||
else
|
||||
{
|
||||
window.trigger_download([options.path]);
|
||||
window.trigger_download([$(el_item).attr('data-path')]);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1437,12 +1457,13 @@ async function UIItem (options) {
|
||||
// -------------------------------------------
|
||||
// Cut
|
||||
// -------------------------------------------
|
||||
if ( $(el_item).attr('data-immutable') === '0' ) {
|
||||
// Trashed items leave the Trash through Restore; moved any other way, they'd keep their uid as their name.
|
||||
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
|
||||
menu_items.push({
|
||||
html: i18n('cut'),
|
||||
onClick: function () {
|
||||
window.clipboard_op = 'move';
|
||||
window.clipboard = [options.path];
|
||||
window.clipboard = [$(el_item).attr('data-path')];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1454,7 +1475,7 @@ async function UIItem (options) {
|
||||
html: i18n('copy'),
|
||||
onClick: function () {
|
||||
window.clipboard_op = 'copy';
|
||||
window.clipboard = [{ path: options.path }];
|
||||
window.clipboard = [{ path: $(el_item).attr('data-path') }];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1502,7 +1523,7 @@ async function UIItem (options) {
|
||||
base_dir,
|
||||
options.appendTo,
|
||||
options.shortcut_to === '' ? options.uid : options.shortcut_to,
|
||||
options.shortcut_to_path === '' ? options.path : options.shortcut_to_path,
|
||||
options.shortcut_to_path === '' ? $(el_item).attr('data-path') : options.shortcut_to_path,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1631,6 +1652,7 @@ async function UIItem (options) {
|
||||
// Create ContextMenu
|
||||
UIContextMenu({
|
||||
parent_element: ($(options.appendTo).hasClass('desktop') ? undefined : options.appendTo),
|
||||
position: menu_position,
|
||||
items: menu_items,
|
||||
});
|
||||
|
||||
|
||||
+139
-122
@@ -26,6 +26,7 @@ import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import new_context_menu_item from '../helpers/newContextMenuItem.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
import { atPath } from '../helpers/pathElements.js';
|
||||
import { resolve_typed_path } from '../helpers/expandHomePath.js';
|
||||
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
|
||||
import launch_app from '../helpers/launchApp.js';
|
||||
import publish_as_website from '../helpers/publishAsWebsite.js';
|
||||
@@ -347,7 +348,18 @@ async function UIWindow (options) {
|
||||
if ( ! items.some(item => item.path === window.home_path) ) {
|
||||
items.unshift({ path: window.home_path, name: i18n('home') });
|
||||
}
|
||||
// A saved order stores the label it was rendered with; system folders follow the current language instead.
|
||||
const system_folder_labels = {
|
||||
[window.home_path]: i18n('home'),
|
||||
[window.docs_path]: i18n('documents'),
|
||||
[window.public_path]: i18n('public'),
|
||||
[window.pictures_path]: i18n('pictures'),
|
||||
[window.desktop_path]: i18n('desktop'),
|
||||
[window.videos_path]: i18n('videos'),
|
||||
[window.shared_path]: i18n('shared'),
|
||||
};
|
||||
for ( let item of items ) {
|
||||
const label = system_folder_labels[item.path] ?? item.name;
|
||||
let icon;
|
||||
if ( item.path === window.home_path )
|
||||
{
|
||||
@@ -381,7 +393,7 @@ async function UIWindow (options) {
|
||||
{
|
||||
icon = window.icons['sidebar-folder.svg'];
|
||||
}
|
||||
h += `<div title="${html_encode(item.label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(item.name)}</div>`;
|
||||
h += `<div title="${html_encode(label)}" class="window-sidebar-item disable-user-select ${options.path === item.path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(item.path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(icon)}">${html_encode(label)}</div>`;
|
||||
}
|
||||
}
|
||||
h += '</div>';
|
||||
@@ -406,7 +418,7 @@ async function UIWindow (options) {
|
||||
// Path editor
|
||||
h += `<input class="window-navbar-path-input" data-path="${html_encode(options.path)}" value="${html_encode(options.path)}" spellcheck="false"/>`;
|
||||
// Layout settings
|
||||
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false">`;
|
||||
h += `<img class="window-navbar-layout-settings" src="${html_encode(options.layout === 'icons' ? window.icons['layout-icons.svg'] : window.icons['layout-list.svg'])}" draggable="false" title="${i18n('window_click_to_change_layout')}">`;
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
@@ -466,7 +478,7 @@ async function UIWindow (options) {
|
||||
|
||||
// Loading spinner
|
||||
h += '<div class="explorer-loading-spinner">';
|
||||
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
|
||||
h += '<svg style="display:block; margin: 0 auto; " xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><g fill="#212121" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#212121" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>';
|
||||
h += `<p class="explorer-loading-spinner-msg">${i18n('loading')}...</p>`;
|
||||
h += '</div>';
|
||||
}
|
||||
@@ -1114,7 +1126,7 @@ async function UIWindow (options) {
|
||||
|
||||
// build item for context menu
|
||||
items.push({
|
||||
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
|
||||
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
|
||||
val: index,
|
||||
onClick: async function (e) {
|
||||
let history_index = e.value;
|
||||
@@ -1187,7 +1199,7 @@ async function UIWindow (options) {
|
||||
|
||||
// build item for context menu
|
||||
items.push({
|
||||
html: `<span>${history_item === window.home_path ? 'Home' : html_encode(path.basename(history_item))}</span>`,
|
||||
html: `<span>${html_encode(nav_history_label(history_item))}</span>`,
|
||||
val: index,
|
||||
onClick: async function (e) {
|
||||
let history_index = e.value;
|
||||
@@ -1333,6 +1345,8 @@ async function UIWindow (options) {
|
||||
$(el_window).on('remove', function (e) {
|
||||
// if on_close callback is set, call it
|
||||
options.on_close?.();
|
||||
delete window.window_nav_history[win_id];
|
||||
delete window.window_nav_history_current_position[win_id];
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -1426,18 +1440,18 @@ async function UIWindow (options) {
|
||||
.on('beforedrag', evt => {
|
||||
})
|
||||
.on('start', ({ store, event }) => {
|
||||
if ( !event.ctrlKey && !event.metaKey ) {
|
||||
// Get the scroll position of the window body
|
||||
const scrollLeft = $(el_window_body).scrollLeft();
|
||||
const scrollTop = $(el_window_body).scrollTop();
|
||||
const scrollLeft = $(el_window_body).scrollLeft();
|
||||
const scrollTop = $(el_window_body).scrollTop();
|
||||
|
||||
// Get the window body's bounding rect relative to the viewport
|
||||
const windowBodyRect = el_window_body.getBoundingClientRect();
|
||||
// Get the window body's bounding rect relative to the viewport
|
||||
const windowBodyRect = el_window_body.getBoundingClientRect();
|
||||
|
||||
// Calculate position relative to the window body (accounting for scroll)
|
||||
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
|
||||
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
|
||||
// Calculate position relative to the window body (accounting for scroll)
|
||||
selection_area_start_x = window.mouseX - windowBodyRect.left + scrollLeft;
|
||||
selection_area_start_y = window.mouseY - windowBodyRect.top + scrollTop;
|
||||
|
||||
if ( !event.ctrlKey && !event.metaKey ) {
|
||||
for ( const el of store.stored ) {
|
||||
el.classList.remove('item-selected');
|
||||
}
|
||||
@@ -1512,7 +1526,7 @@ async function UIWindow (options) {
|
||||
window.update_explorer_footer_selected_items_count(el_window);
|
||||
|
||||
// If this is openFileDialog, enable/disable the Open button accordingly
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
|
||||
{
|
||||
$(el_openfiledialog_open_btn).removeClass('disabled');
|
||||
}
|
||||
@@ -1523,7 +1537,7 @@ async function UIWindow (options) {
|
||||
})
|
||||
.on('stop', ({ store, event }) => {
|
||||
// If this is openFileDialog, enable/disable the Open button accordingly
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected').length )
|
||||
if ( options.is_openFileDialog && $(el_window).find('.item-selected[data-is_dir="0"]').length )
|
||||
{
|
||||
$(el_openfiledialog_open_btn).removeClass('disabled');
|
||||
}
|
||||
@@ -1532,6 +1546,20 @@ async function UIWindow (options) {
|
||||
$(el_openfiledialog_open_btn).addClass('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on(`mouseup.window-selection-${win_id}`, function () {
|
||||
if ( selection_area ) {
|
||||
$(selection_area).hide();
|
||||
$(selection_area).remove();
|
||||
selection_area = null;
|
||||
}
|
||||
});
|
||||
|
||||
// viselect and the handler above listen on document, so they outlive the window unless released
|
||||
$(el_window).on('remove', function () {
|
||||
$(document).off(`mouseup.window-selection-${win_id}`);
|
||||
selection.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
@@ -2679,37 +2707,8 @@ async function UIWindow (options) {
|
||||
{
|
||||
html: i18n('empty_trash'),
|
||||
disabled: false,
|
||||
onClick: async function () {
|
||||
// TODO: Merge this with window.empty_trash()
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('empty_trash_confirmation'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('yes'),
|
||||
value: 'yes',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
label: i18n('no'),
|
||||
value: 'no',
|
||||
},
|
||||
],
|
||||
});
|
||||
if ( alert_resp === 'no' )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
|
||||
$(`.item[data-path^="${html_encode(window.trash_path)}/"]`).each(function () {
|
||||
window.delete_item(this);
|
||||
});
|
||||
// update other clients
|
||||
if ( window.socket ) {
|
||||
window.socket.emit('trash.is_empty', { is_empty: true });
|
||||
}
|
||||
// use the 'empty trash' icon
|
||||
window.update_trash_icons(true);
|
||||
onClick: function () {
|
||||
window.empty_trash();
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2845,14 +2844,6 @@ async function UIWindow (options) {
|
||||
});
|
||||
}
|
||||
|
||||
$(document).on('mouseup', function (e) {
|
||||
if ( selection_area ) {
|
||||
$(selection_area).hide();
|
||||
$(selection_area).remove();
|
||||
selection_area = null;
|
||||
}
|
||||
});
|
||||
|
||||
//set styles
|
||||
$(el_window_body).css(options.body_css);
|
||||
|
||||
@@ -3072,7 +3063,21 @@ $(document).on('blur', '.window-navbar-path-input', function (e) {
|
||||
|
||||
$(document).on('keyup', '.window-navbar-path-input', function (e) {
|
||||
if ( e.key === 'Enter' || e.keyCode === 13 ) {
|
||||
window.update_window_path($(e.target).closest('.window'), $(e.target).val());
|
||||
const $el_window = $(e.target).closest('.window');
|
||||
const win_id = $el_window.attr('data-id');
|
||||
const current_path = $el_window.attr('data-path');
|
||||
// Other code matches containers by exact path, so navigate to the canonical form.
|
||||
const target_path = resolve_typed_path($(e.target).val(), {
|
||||
current_path,
|
||||
home_path: window.home_path,
|
||||
shared_path: window.shared_path,
|
||||
});
|
||||
if ( target_path !== current_path && window.window_nav_history[win_id] ) {
|
||||
window.window_nav_history[win_id] = window.window_nav_history[win_id].slice(0, window.window_nav_history_current_position[win_id] + 1);
|
||||
window.window_nav_history[win_id].push(target_path);
|
||||
window.window_nav_history_current_position[win_id] = window.window_nav_history[win_id].length - 1;
|
||||
}
|
||||
window.update_window_path($el_window, target_path);
|
||||
$(e.target).hide();
|
||||
$(e.target).siblings('.window-navbar-path').show().select();
|
||||
}
|
||||
@@ -3336,6 +3341,14 @@ window.navbar_path = (abs_path) => {
|
||||
return str;
|
||||
};
|
||||
|
||||
// The root and the Shared view have no basename to show in the Back/Forward menus.
|
||||
const nav_history_label = (history_path) => {
|
||||
if ( history_path === window.home_path ) return i18n('home');
|
||||
if ( history_path === '/' ) return window.root_dirname;
|
||||
if ( history_path === window.shared_path ) return i18n('shared');
|
||||
return path.basename(history_path);
|
||||
};
|
||||
|
||||
window.update_window_path = async function (el_window, target_path) {
|
||||
const win_id = $(el_window).attr('data-id');
|
||||
const el_window_navbar_forward_btn = $(el_window).find('.window-navbar-btn-forward');
|
||||
@@ -3393,6 +3406,8 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
|
||||
// empty body to be filled with the results of /readdir
|
||||
$(el_window_body).find('.item').removeItems();
|
||||
// nothing is selected in the folder being opened
|
||||
$(el_window).find('.openfiledialog-open-btn').addClass('disabled');
|
||||
|
||||
// add the 'Detail View' table header
|
||||
if ( $(el_window).find('.explore-table-headers').length === 0 )
|
||||
@@ -3435,6 +3450,8 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window).attr('data-name', path.basename(target_path));
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
|
||||
// /stat
|
||||
if ( target_path === window.shared_path ) {
|
||||
@@ -3445,72 +3462,72 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
$(el_window).find('.window-head-icon').attr('src', window.icons['shared.svg']);
|
||||
}
|
||||
else if ( target_path !== '/' ) {
|
||||
try {
|
||||
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
|
||||
// the window may have moved on while this stat was in flight
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
}
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('home'));
|
||||
}
|
||||
else if ( target_path === window.desktop_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('desktop'));
|
||||
}
|
||||
else if ( target_path === window.docs_path || target_path === window.documents_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('documents'));
|
||||
}
|
||||
else if ( target_path === window.pictures_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('pictures'));
|
||||
}
|
||||
else if ( target_path === window.videos_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('videos'));
|
||||
}
|
||||
else if ( target_path === window.public_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('public'));
|
||||
}
|
||||
else if ( target_path === window.trash_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('trash'));
|
||||
}
|
||||
else
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(fsentry.name);
|
||||
}
|
||||
// data-name
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
});
|
||||
} catch ( err ) {
|
||||
UIAlert(err.responseText);
|
||||
|
||||
// todo optim: this is dumb because updating the window should only happen if this /readdir request is successful,
|
||||
// in that case there is no need for using update_window_path on error!!
|
||||
window.update_window_path(el_window, old_path);
|
||||
}
|
||||
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
|
||||
// the window may have moved on while this stat was in flight
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
}
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).addClass(`window-${ fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
|
||||
$(el_window_item_container).attr('data-uid', fsentry.id);
|
||||
// title - use i18n for system directories
|
||||
if ( target_path === window.home_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('home'));
|
||||
}
|
||||
else if ( target_path === window.desktop_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('desktop'));
|
||||
}
|
||||
else if ( target_path === window.docs_path || target_path === window.documents_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('documents'));
|
||||
}
|
||||
else if ( target_path === window.pictures_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('pictures'));
|
||||
}
|
||||
else if ( target_path === window.videos_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('videos'));
|
||||
}
|
||||
else if ( target_path === window.public_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('public'));
|
||||
}
|
||||
else if ( target_path === window.trash_path )
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(i18n('trash'));
|
||||
}
|
||||
else
|
||||
{
|
||||
$(el_window).find('.window-head-title').text(fsentry.name);
|
||||
}
|
||||
// data-name
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// update layout
|
||||
window.update_window_layout(el_window, fsentry.layout);
|
||||
// update explore header if in details view
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
}
|
||||
// The folder couldn't be read, so the window must stop identifying as the previous one.
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
$(el_window).attr('data-uid', 'null');
|
||||
$(el_window).find('.window-head-title').text(path.basename(target_path));
|
||||
});
|
||||
}
|
||||
// path is '/' (global root)
|
||||
else {
|
||||
@@ -3866,7 +3883,7 @@ window.scale_window = (el_window) => {
|
||||
|
||||
window.update_explorer_footer_item_count = function (el_window) {
|
||||
//update dir count in explorer footer
|
||||
let item_count = $(el_window).find('.item').length;
|
||||
let item_count = $(el_window).find('.item:not(.item-hidden)').length;
|
||||
$(el_window).find('.explorer-footer .explorer-footer-item-count').html(`${item_count } ${i18n('item')}${ item_count == 0 || item_count > 1 ? `${i18n('plural_suffix')}` : ''}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -500,8 +500,9 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, sel
|
||||
|
||||
.explore-table-headers {
|
||||
display: none;
|
||||
width: 100%;
|
||||
min-width: 795px;
|
||||
/* the negative margin below shifts it left by the body's padding */
|
||||
width: calc(100% + 10px);
|
||||
min-width: 805px;
|
||||
height: 25px;
|
||||
border-bottom: 1px solid rgb(226, 226, 226);
|
||||
background-color: #fff;
|
||||
@@ -534,6 +535,7 @@ span.header-sort-icon img {
|
||||
margin-left: 15px;
|
||||
color: #555c61;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.explore-table-headers-th-active {
|
||||
@@ -842,6 +844,14 @@ span.header-sort-icon img {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.device-desktop .item-container-details .item-selected.item-blurred {
|
||||
background-color: #dbdada;
|
||||
}
|
||||
|
||||
.device-desktop .item-container-details .item-selected.item-blurred .item-attr {
|
||||
color: rgb(73, 73, 73);
|
||||
}
|
||||
|
||||
.item-container-details .item-attr--modified {
|
||||
left: 350px;
|
||||
}
|
||||
@@ -1199,6 +1209,22 @@ span.header-sort-icon img {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* A path too long for the bar is cut at the root end, so the current folder stays visible. */
|
||||
.window-navbar-path {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
min-height: 37px;
|
||||
}
|
||||
|
||||
.window-navbar-path > * {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.window-navbar-path > :last-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.device-phone .window-navbar-path {
|
||||
display: none;
|
||||
}
|
||||
@@ -1208,6 +1234,7 @@ span.header-sort-icon img {
|
||||
height: 30px;
|
||||
margin-left: 10px;
|
||||
margin-top: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-phone .window-navbar-layout-settings {
|
||||
@@ -5010,17 +5037,21 @@ fieldset[name=number-code] {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#storage-bar, #storage-bar-host, .usage-progbar {
|
||||
#storage-bar, #storage-bar-host, .usage-progbar, .usage-addon-bar {
|
||||
height: 100%;
|
||||
background-color: var(--dashboard-bento-usage-bar-fill, #ea580c);
|
||||
width: 0;
|
||||
transition: width 0.4s ease, background-color 0.4s ease;
|
||||
}
|
||||
|
||||
#storage-used-percent, .usage-progbar-percent {
|
||||
#storage-used-percent, .usage-progbar-percent, .usage-addon-percent {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usage-addon {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.usage-addon-credits {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
|
||||
+105
-72
@@ -23,7 +23,7 @@ import { archiveEntryPath } from './helpers/archiveEntryPath.js';
|
||||
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
|
||||
import globToRegExp from './helpers/globToRegExp.js';
|
||||
import item_icon from './helpers/itemIcon.js';
|
||||
import { atPath, retargetPaths, underPath } from './helpers/pathElements.js';
|
||||
import { atPath, retargetNavHistory, retargetPaths, underPath } from './helpers/pathElements.js';
|
||||
import { is_owned_by_me, trash_path_for } from './helpers/pathOwner.js';
|
||||
import { invalidate_shared_roots } from './helpers/sharedAccess.js';
|
||||
import truncate_filename from './helpers/truncateFilename.js';
|
||||
@@ -38,6 +38,7 @@ import mime from './lib/mime.js';
|
||||
import path from './lib/path.js';
|
||||
import UIAlert from './UI/UIAlert.js';
|
||||
import UIItem from './UI/UIItem.js';
|
||||
import { compareEntries, compareNames } from './UI/Dashboard/sortEntries.js';
|
||||
import UIAuthLogin from './UI/Auth/UIAuthLogin.js';
|
||||
import UIWindowProgress from './UI/UIWindowProgress.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
@@ -1120,48 +1121,19 @@ window.sort_items = (item_container, sort_by, sort_order) => {
|
||||
sort_order = 'asc';
|
||||
}
|
||||
|
||||
const direction = sort_order === 'asc' ? 1 : -1;
|
||||
$(item_container).find('.item[data-sortable="true"]').detach().sort(function (a, b) {
|
||||
// Name
|
||||
if ( !sort_by || sort_by === 'name' ) {
|
||||
if ( a.dataset.name.toLowerCase() < b.dataset.name.toLowerCase() ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( a.dataset.name.toLowerCase() > b.dataset.name.toLowerCase() ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
const name_a = a.dataset.name;
|
||||
const name_b = b.dataset.name;
|
||||
if ( sort_by === 'type' ) {
|
||||
const by_extension = compareNames(path.extname(name_a).toLowerCase(), path.extname(name_b).toLowerCase());
|
||||
return direction * (by_extension || compareNames(name_a, name_b));
|
||||
}
|
||||
// Size
|
||||
else if ( sort_by === 'size' ) {
|
||||
if ( parseInt(a.dataset.size) < parseInt(b.dataset.size) ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( parseInt(a.dataset.size) > parseInt(b.dataset.size) ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Modified
|
||||
else if ( sort_by === 'modified' ) {
|
||||
if ( parseInt(a.dataset.modified) < parseInt(b.dataset.modified) ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( parseInt(a.dataset.modified) > parseInt(b.dataset.modified) ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Type
|
||||
else if ( sort_by === 'type' ) {
|
||||
if ( path.extname(a.dataset.name.toLowerCase()) < path.extname(b.dataset.name.toLowerCase()) ) {
|
||||
return (sort_order === 'asc' ? -1 : 1);
|
||||
}
|
||||
if ( path.extname(a.dataset.name.toLowerCase()) > path.extname(b.dataset.name.toLowerCase()) ) {
|
||||
return (sort_order === 'asc' ? 1 : -1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return direction * compareEntries(
|
||||
{ name: name_a, size: parseInt(a.dataset.size), modified: parseInt(a.dataset.modified) },
|
||||
{ name: name_b, size: parseInt(b.dataset.size), modified: parseInt(b.dataset.modified) },
|
||||
sort_by,
|
||||
);
|
||||
}).appendTo(item_container);
|
||||
};
|
||||
|
||||
@@ -1169,10 +1141,18 @@ window.show_or_hide_files = (item_containers) => {
|
||||
const show_hidden_files = window.user_preferences.show_hidden_files;
|
||||
const class_to_add = show_hidden_files ? 'item-revealed' : 'item-hidden';
|
||||
const class_to_remove = show_hidden_files ? 'item-hidden' : 'item-revealed';
|
||||
$(item_containers)
|
||||
const $dotfiles = $(item_containers)
|
||||
.find('.item')
|
||||
.filter((_, item) => item.dataset.name.startsWith('.'))
|
||||
.removeClass(class_to_remove).addClass(class_to_add);
|
||||
// an item the user can no longer see must not stay in the selection
|
||||
if ( ! show_hidden_files ) {
|
||||
$dotfiles.removeClass('item-selected');
|
||||
}
|
||||
$(item_containers).closest('.window').each(function () {
|
||||
window.update_explorer_footer_item_count(this);
|
||||
window.update_explorer_footer_selected_items_count(this);
|
||||
});
|
||||
};
|
||||
|
||||
window.create_folder = async (basedir, appendto_element) => {
|
||||
@@ -1275,6 +1255,9 @@ window.available_templates = () => {
|
||||
};
|
||||
|
||||
window.create_shortcut = async (filename, is_dir, basedir, appendto_element, shortcut_to, shortcut_to_path) => {
|
||||
// The Shared view is a query, not a directory — nothing can be created in it.
|
||||
if ( basedir === window.shared_path ) return;
|
||||
|
||||
const extname = path.extname(filename);
|
||||
const basename = `${path.basename(filename, extname) } - Shortcut`;
|
||||
filename = basename + extname;
|
||||
@@ -1309,6 +1292,8 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// re-armed after a dialog while the window from before it is still open
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
operation_id: copy_op_id,
|
||||
on_cancel: () => {
|
||||
@@ -1442,6 +1427,9 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
|
||||
* @param {string} dest_path - Destination path to copy items to
|
||||
*/
|
||||
window.copy_items = function (el_items, dest_path) {
|
||||
// The Shared view is a query, not a directory — nothing can be copied into it.
|
||||
if ( dest_path === window.shared_path ) return;
|
||||
|
||||
let copy_op_id = window.operation_id++;
|
||||
let overwrite_all = false;
|
||||
(async () => {
|
||||
@@ -1451,6 +1439,8 @@ window.copy_items = function (el_items, dest_path) {
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// re-armed after a dialog while the window from before it is still open
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
operation_id: copy_op_id,
|
||||
on_cancel: () => {
|
||||
@@ -1625,20 +1615,37 @@ window.delete_item = async function (el_item, descendants_only = false) {
|
||||
}
|
||||
};
|
||||
|
||||
window.move_clipboard_items = function (el_target_container, target_path) {
|
||||
window.move_clipboard_items = async function (el_target_container, target_path) {
|
||||
let dest_path = target_path === undefined ? $(el_target_container).attr('data-path') : target_path;
|
||||
let el_items = [];
|
||||
if ( window.clipboard.length > 0 ) {
|
||||
for ( let i = 0; i < window.clipboard.length; i++ ) {
|
||||
el_items.push(atPath('.item', window.clipboard[i]));
|
||||
}
|
||||
if ( el_items.length > 0 )
|
||||
{
|
||||
window.move_items(el_items, dest_path);
|
||||
}
|
||||
}
|
||||
|
||||
const cut_paths = window.clipboard;
|
||||
window.clipboard = [];
|
||||
|
||||
// A cut item's row is gone once its folder is no longer displayed, so
|
||||
// move_items gets a stand-in carrying what it reads off a row.
|
||||
const el_items = (await Promise.all(cut_paths.map(async (cut_path) => {
|
||||
const $el_item = atPath('.item', cut_path);
|
||||
if ( $el_item.length > 0 ) {
|
||||
return $el_item;
|
||||
}
|
||||
try {
|
||||
const fsentry = await puter.fs.stat({ path: cut_path, consistency: 'eventual' });
|
||||
return $('<div>').attr({
|
||||
'data-uid': fsentry.uid,
|
||||
'data-path': cut_path,
|
||||
'data-name': fsentry.name,
|
||||
'data-is_dir': fsentry.is_dir ? '1' : '0',
|
||||
'data-immutable': (fsentry.immutable || fsentry.writable === false) ? '1' : '0',
|
||||
'data-metadata': fsentry.metadata ?? '',
|
||||
});
|
||||
} catch ( e ) {
|
||||
// the item no longer exists, so there is nothing to move
|
||||
return null;
|
||||
}
|
||||
}))).filter(Boolean);
|
||||
|
||||
if ( el_items.length > 0 ) {
|
||||
window.move_items(el_items, dest_path);
|
||||
}
|
||||
};
|
||||
|
||||
function downloadFile (url, postData = {}) {
|
||||
@@ -1696,7 +1703,7 @@ window.trigger_download = (paths) => {
|
||||
let urls = [];
|
||||
for ( let index = 0; index < paths.length; index++ ) {
|
||||
urls.push({
|
||||
download: `${window.origin }/down?path=${ paths[index]}`,
|
||||
download: `${window.origin }/down?path=${ encodeURIComponent(paths[index])}`,
|
||||
filename: path.basename(paths[index]),
|
||||
});
|
||||
}
|
||||
@@ -1802,6 +1809,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
let progwin;
|
||||
let latest_status;
|
||||
const arm_progwin = () => setTimeout(async () => {
|
||||
// re-armed after a dialog while the window from before it is still open
|
||||
if ( progwin ) return;
|
||||
progwin = await UIWindowProgress({
|
||||
operation_id: move_op_id,
|
||||
on_cancel: () => {
|
||||
@@ -1849,7 +1858,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
if ( path.dirname($(el_item).attr('data-path')) === item_dest_path ) {
|
||||
// pause the progress-window timer while waiting for the user
|
||||
clearTimeout(progwin_timeout);
|
||||
await UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>Cannot move item to its current location.`);
|
||||
// UIAlert escapes the message itself
|
||||
await UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>Cannot move item to its current location.`);
|
||||
progwin_timeout = arm_progwin();
|
||||
|
||||
continue;
|
||||
@@ -1923,7 +1933,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
}
|
||||
|
||||
// moving an item into a trashed directory? deny.
|
||||
else if ( dest_path.startsWith(window.trash_path) ) {
|
||||
else if ( dest_path === window.trash_path || dest_path.startsWith(`${window.trash_path}/`) ) {
|
||||
// the pending timer would otherwise open an orphan
|
||||
// progress window after the operation already bailed
|
||||
clearTimeout(progwin_timeout);
|
||||
@@ -2008,6 +2018,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
underPath('.window', moved_from_path).add(atPath('.window', moved_from_path)).each(function () {
|
||||
window.update_window_path(this, path.join(item_dest_path, fsentry.name) + $(this).attr('data-path').slice(moved_from_path.length));
|
||||
});
|
||||
retargetNavHistory(moved_from_path, fsentry.path);
|
||||
}
|
||||
|
||||
// The dashboard's Back/Forward follows a moved folder, and
|
||||
@@ -2035,14 +2046,19 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
// if this is trash, get original name from item metadata
|
||||
fsentry.name = metadata?.original_name || fsentry.name;
|
||||
|
||||
const moved_item_icon = await item_icon(fsentry);
|
||||
const $dest_containers = atPath('.item-container', item_dest_path);
|
||||
// the item.moved echo of this move can render the row first
|
||||
$dest_containers.find(`.item[data-uid='${fsentry.uid}']`).removeItems();
|
||||
|
||||
// create new item on matching containers
|
||||
const options = {
|
||||
appendTo: atPath('.item-container', item_dest_path),
|
||||
appendTo: $dest_containers,
|
||||
immutable: fsentry.immutable || (fsentry.writable === false),
|
||||
associated_app_name: fsentry.associated_app?.name,
|
||||
uid: fsentry.uid,
|
||||
path: fsentry.path,
|
||||
icon: await item_icon(fsentry),
|
||||
icon: moved_item_icon,
|
||||
name: is_trashing ? $(el_item).attr('data-name') : fsentry.name,
|
||||
is_dir: fsentry.is_dir,
|
||||
size: fsentry.size,
|
||||
@@ -2137,7 +2153,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
item_with_same_name_already_exists = false;
|
||||
// error message after source item has reappeared
|
||||
$(el_item).show(0, function () {
|
||||
UIAlert(`<p>Moving <strong>${html_encode($(el_item).attr('data-name'))}</strong></p>${err.message ?? ''}`);
|
||||
UIAlert(`<p>Moving <strong>${$(el_item).attr('data-name')}</strong></p>${err.message ?? ''}`);
|
||||
});
|
||||
|
||||
break;
|
||||
@@ -2400,6 +2416,9 @@ window.upload_items = async function (items, dest_path) {
|
||||
let upload_progress_window;
|
||||
let opid;
|
||||
|
||||
// The Shared view is a query, not a directory — nothing can be uploaded into it.
|
||||
if ( dest_path === window.shared_path ) return;
|
||||
|
||||
if ( dest_path == window.trash_path ) {
|
||||
UIAlert('Uploading to trash is not allowed!');
|
||||
return;
|
||||
@@ -2879,13 +2898,15 @@ window.unzipItem = async function (itemPath) {
|
||||
terminateOp = fflate.unzip(file, async (err, unzipped) => {
|
||||
currentProgress += window.zippingProgressConfig.ZIPPING;
|
||||
progwin?.set_progress(currentProgress.toPrecision(2));
|
||||
if ( err ) {
|
||||
UIAlert(e.message);
|
||||
// close progress window
|
||||
const close_progwin = (hide_delay) => {
|
||||
clearTimeout(progwin_timeout);
|
||||
setTimeout(() => {
|
||||
progwin?.close();
|
||||
}, Math.max(0, window.copy_progress_hide_delay - (Date.now() - start_ts)));
|
||||
}, Math.max(0, hide_delay - (Date.now() - start_ts)));
|
||||
};
|
||||
if ( err ) {
|
||||
UIAlert(err.message);
|
||||
close_progwin(window.copy_progress_hide_delay);
|
||||
} else {
|
||||
const rootdir = await puter.fs.mkdir(`${path.dirname(filePath) }/${ path.basename(filePath, '.zip')}`, { dedupeName: true });
|
||||
let perItemProgress = window.zippingProgressConfig.WRITING / Object.keys(unzipped).length;
|
||||
@@ -2903,6 +2924,10 @@ window.unzipItem = async function (itemPath) {
|
||||
UIAlert(e.message);
|
||||
}
|
||||
});
|
||||
// an archive with no files leaves nothing to upload, so nothing else would end the operation
|
||||
if ( queuedFileWrites.length === 0 ) {
|
||||
close_progwin(window.unzip_progress_hide_delay);
|
||||
}
|
||||
queuedFileWrites.length && puter.fs.upload(
|
||||
// what to upload
|
||||
queuedFileWrites,
|
||||
@@ -2914,7 +2939,7 @@ window.unzipItem = async function (itemPath) {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
progress: async function (operation_id, op_progress) {
|
||||
progwin.set_progress(op_progress);
|
||||
progwin?.set_progress(op_progress);
|
||||
// update title if window is not visible
|
||||
if ( document.visibilityState !== 'visible' ) {
|
||||
update_title_based_on_uploads();
|
||||
@@ -2922,11 +2947,10 @@ window.unzipItem = async function (itemPath) {
|
||||
},
|
||||
success: async function (items) {
|
||||
progwin?.set_progress(window.zippingProgressConfig.TOTAL.toPrecision(2));
|
||||
// close progress window
|
||||
clearTimeout(progwin_timeout);
|
||||
setTimeout(() => {
|
||||
progwin?.close();
|
||||
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
|
||||
close_progwin(window.unzip_progress_hide_delay);
|
||||
},
|
||||
error: function () {
|
||||
close_progwin(window.unzip_progress_hide_delay);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -3158,6 +3182,13 @@ window.untarItem = async function (itemPath) {
|
||||
}
|
||||
}
|
||||
|
||||
// an archive with no files leaves nothing to upload, so nothing else would end the operation
|
||||
if ( queuedFileWrites.length === 0 ) {
|
||||
clearTimeout(progwin_timeout);
|
||||
setTimeout(() => {
|
||||
progwin?.close();
|
||||
}, Math.max(0, window.unzip_progress_hide_delay - (Date.now() - start_ts)));
|
||||
}
|
||||
queuedFileWrites.length && puter.fs.upload(
|
||||
queuedFileWrites,
|
||||
`${rootdir.path }/`,
|
||||
@@ -3166,7 +3197,7 @@ window.untarItem = async function (itemPath) {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
progress: async function (operation_id, op_progress) {
|
||||
progwin.set_progress(op_progress);
|
||||
progwin?.set_progress(op_progress);
|
||||
if ( document.visibilityState !== 'visible' ) {
|
||||
update_title_based_on_uploads();
|
||||
}
|
||||
@@ -3385,7 +3416,9 @@ window.undo_last_action = async () => {
|
||||
delete: () => window.undo_delete(data),
|
||||
rename: () => {
|
||||
const { options, new_name, old_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, onDone } = data;
|
||||
window.rename_file(options, old_name, new_name, old_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
|
||||
// the item now sits at its renamed path, which is what this rename moves away from
|
||||
const renamed_path = path.join(path.dirname(old_path), new_name);
|
||||
window.rename_file(options, old_name, new_name, renamed_path, el_item, el_item_name, el_item_icon, el_item_name_editor, website_url, true, onDone);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ globalThis.html_encode = value => jQuery('<div>').text(value).html();
|
||||
await import('./helpers.js');
|
||||
const { default: apply_item_added_to_containers } = await import('./helpers/applyItemAddedToContainers.js');
|
||||
const { default: UIItem } = await import('./UI/UIItem.js');
|
||||
const { atPath, retargetPaths, underPath } = await import('./helpers/pathElements.js');
|
||||
const { atPath, retargetNavHistory, retargetPaths, underPath } = await import('./helpers/pathElements.js');
|
||||
|
||||
// A plain name, then names holding characters a selector or HTML treats specially.
|
||||
const NAMES = ['Reports 2024', 'R&D', "Dan's files", 'the "big" one', 'a<b>c', 'back\\slash'];
|
||||
@@ -146,3 +146,25 @@ describe.each([undefined, null, ''])('a lookup for the path %s', (missing) => {
|
||||
expect(underPath('.window', missing)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retargetNavHistory', () => {
|
||||
it('rewrites entries at or inside the old path and leaves the rest', () => {
|
||||
window.window_nav_history = {
|
||||
1: ['/alice', '/alice/A', '/alice/A/sub', '/alice/AB', 'puter://shared'],
|
||||
2: ['/alice/Documents'],
|
||||
};
|
||||
|
||||
retargetNavHistory('/alice/A', '/alice/B');
|
||||
|
||||
expect(window.window_nav_history[1]).toEqual(['/alice', '/alice/B', '/alice/B/sub', '/alice/AB', 'puter://shared']);
|
||||
expect(window.window_nav_history[2]).toEqual(['/alice/Documents']);
|
||||
});
|
||||
|
||||
it('is applied by retargetPaths', () => {
|
||||
window.window_nav_history = { 1: ['/alice/A/sub'] };
|
||||
|
||||
retargetPaths('/alice/A', '/alice/B', 'B');
|
||||
|
||||
expect(window.window_nav_history[1]).toEqual(['/alice/B/sub']);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from '../lib/path.js';
|
||||
|
||||
/**
|
||||
* Expands a user-facing path into the absolute path the filesystem API takes:
|
||||
* `~` and `~/x` resolve under the user's home, a bare relative path is read as
|
||||
@@ -39,3 +41,20 @@ export const expand_home_path = (fspath, home_path) => {
|
||||
if ( fspath.startsWith('/') ) return fspath;
|
||||
return `${home}/${fspath}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns what a user typed into an address bar into the canonical absolute
|
||||
* path: trimmed, home-expanded, with `.`/`..`, doubled and trailing slashes
|
||||
* resolved. An empty entry keeps the current path.
|
||||
*
|
||||
* @param {string} typed_path - the raw text the user entered
|
||||
* @param {{ current_path: string, home_path: string, shared_path: string }} context
|
||||
* @returns {string} the path to navigate to
|
||||
*/
|
||||
export const resolve_typed_path = (typed_path, { current_path, home_path, shared_path }) => {
|
||||
const trimmed = typeof typed_path === 'string' ? typed_path.trim() : '';
|
||||
if ( trimmed === '' ) return current_path;
|
||||
// The Shared view is a pseudo-path, not a filesystem one.
|
||||
if ( trimmed === shared_path ) return shared_path;
|
||||
return path.resolve('/', expand_home_path(trimmed, home_path));
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { expand_home_path } from './expandHomePath.js';
|
||||
import { expand_home_path, resolve_typed_path } from './expandHomePath.js';
|
||||
|
||||
describe('expand_home_path', () => {
|
||||
it('resolves a tilde path under the home path', () => {
|
||||
@@ -57,3 +57,28 @@ describe('expand_home_path', () => {
|
||||
expect(expand_home_path(null, '/alice')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve_typed_path', () => {
|
||||
const context = { current_path: '/alice/Videos', home_path: '/alice', shared_path: 'puter://shared' };
|
||||
|
||||
it('canonicalizes trailing, doubled and dot segments', () => {
|
||||
expect(resolve_typed_path('/alice/Documents/', context)).toBe('/alice/Documents');
|
||||
expect(resolve_typed_path('/alice//Documents', context)).toBe('/alice/Documents');
|
||||
expect(resolve_typed_path('/alice/Documents/../Pictures', context)).toBe('/alice/Pictures');
|
||||
expect(resolve_typed_path('/', context)).toBe('/');
|
||||
});
|
||||
|
||||
it('trims whitespace and expands home-relative entries', () => {
|
||||
expect(resolve_typed_path(' ~/Documents ', context)).toBe('/alice/Documents');
|
||||
expect(resolve_typed_path('Documents', context)).toBe('/alice/Documents');
|
||||
});
|
||||
|
||||
it('keeps the current path for an empty entry', () => {
|
||||
expect(resolve_typed_path('', context)).toBe('/alice/Videos');
|
||||
expect(resolve_typed_path(' ', context)).toBe('/alice/Videos');
|
||||
});
|
||||
|
||||
it('passes the Shared view through untouched', () => {
|
||||
expect(resolve_typed_path('puter://shared', context)).toBe('puter://shared');
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ const item_icon = async (fsentry) => {
|
||||
// --------------------------------------------------
|
||||
// .app files
|
||||
// --------------------------------------------------
|
||||
else if ( fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
|
||||
else if ( !fsentry.is_dir && fsentry.name && fsentry.name.toLowerCase().endsWith('.app') ) {
|
||||
try {
|
||||
const content = await puter.fs.read({ path: fsentry.path });
|
||||
const text = typeof content === 'string' ? content : await content.text();
|
||||
|
||||
@@ -145,9 +145,14 @@ const new_context_menu_item = function (dirname, append_to_element) {
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
|
||||
// JPEG has no transparency, so the blank image needs a background
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
window.create_file({ dirname: dirname, append_to_element: append_to_element, name: 'New Image.jpg', content: blob });
|
||||
});
|
||||
}, 'image/jpeg');
|
||||
},
|
||||
},
|
||||
// Worker
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
import UIWindow from '../UI/UIWindow.js';
|
||||
import UIAlert from '../UI/UIAlert.js';
|
||||
import i18n from '../i18n/i18n.js';
|
||||
import launch_app from './launchApp.js';
|
||||
// installs the global i18n()
|
||||
import '../i18n/i18n.js';
|
||||
import path from '../lib/path.js';
|
||||
import item_icon from './itemIcon.js';
|
||||
import { resolve_default_app } from './defaultApp.js';
|
||||
|
||||
@@ -23,7 +23,6 @@ import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../UI/UIWindow.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIAlert.js', () => ({ default: vi.fn(async () => undefined) }));
|
||||
vi.mock('../i18n/i18n.js', () => ({ default: key => key }));
|
||||
vi.mock('./launchApp.js', () => ({ default: vi.fn(async () => ({ launchResult: { launched: true } })) }));
|
||||
vi.mock('./itemIcon.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('./defaultApp.js', () => ({ resolve_default_app: vi.fn(async () => null) }));
|
||||
@@ -113,3 +112,18 @@ describe('open_item with a new tab', () => {
|
||||
expect(tab.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('open_item with no app for the file', () => {
|
||||
it('offers to download it', async () => {
|
||||
window.is_dashboard_mode = false;
|
||||
window.trigger_download = vi.fn();
|
||||
UIAlert.mockResolvedValueOnce('download_file');
|
||||
|
||||
await open_item({ item: row({ name: 'data.bin', path: '/user/data.bin' }) });
|
||||
|
||||
expect(UIAlert).toHaveBeenCalledTimes(1);
|
||||
expect(UIAlert.mock.calls[0][1].map(button => button.value ?? button.label)).toEqual(['download_file', 'Cancel']);
|
||||
expect(window.trigger_download).toHaveBeenCalledWith(['/user/data.bin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,4 +70,23 @@ export const retargetPaths = (oldPath, newPath, newName) => {
|
||||
$inside.each(function () {
|
||||
$(this).attr('data-path', newPath + $(this).attr('data-path').slice(oldPath.length));
|
||||
});
|
||||
retargetNavHistory(oldPath, newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Points every window's Back/Forward entries at `oldPath`, or inside it, to
|
||||
* `newPath`, so they still lead somewhere after a rename or move.
|
||||
*
|
||||
* @param {string} oldPath
|
||||
* @param {string} newPath
|
||||
*/
|
||||
export const retargetNavHistory = (oldPath, newPath) => {
|
||||
if ( ! oldPath ) return;
|
||||
const prefix = `${oldPath}/`;
|
||||
for ( const history of Object.values(window.window_nav_history ?? {}) ) {
|
||||
history.forEach((entry, index) => {
|
||||
if ( entry === oldPath ) history[index] = newPath;
|
||||
else if ( entry.startsWith(prefix) ) history[index] = newPath + entry.slice(oldPath.length);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -30,6 +30,7 @@ export const FRAMED_POPUP_ACTIONS = new Set([
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
@@ -39,17 +40,18 @@ const HOST_USER_ACTIONS = new Set([
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
// Actions framed over the whole page with no box, which their own dialog's
|
||||
// scrim covers.
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'top-up', 'request-email-confirmation']);
|
||||
|
||||
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
|
||||
const UPGRADE_METHOD_MAX = 200;
|
||||
|
||||
// Modal UI the upgrade dialog and the GUI's windows show. Once some has shown
|
||||
// Modal UI the upgrade and top-up dialogs and the GUI's windows show. Once some has shown
|
||||
// and all of it has been gone this long, the dialog closed without reporting
|
||||
// it, and the frame mustn't be left covering the page.
|
||||
const UPGRADE_MODAL_UI = '.window, [aria-modal="true"]';
|
||||
@@ -251,6 +253,23 @@ export const canShowUpgradeFrame = (win, session) =>
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Whether a framed top-up can show for the GUI session `session` (its
|
||||
* `/whoami`): the deployment has a top-up dialog that reports when it closes,
|
||||
* and no account check is waiting, which a frame couldn't be closed from.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {Record<string, unknown> | null} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const canShowTopUpFrame = (win, session) =>
|
||||
win.UITopUp?.embeddable === true &&
|
||||
!!session &&
|
||||
!session.requires_email_confirmation &&
|
||||
!session.requires_phone_verification &&
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Whether a framed email confirmation can show for the GUI session `session`
|
||||
* (its `/whoami`): no check other than the email is waiting, since those show
|
||||
@@ -300,19 +319,18 @@ export const framedUpgradeDetails = (hash) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the deployment's upgrade dialog in a framed popup and tells the
|
||||
* Shows one of the deployment's dialogs in a framed popup and tells the
|
||||
* framing page it's ready once it's open, or before it opens when the page
|
||||
* still hides the frame. Resolves whether the user upgraded,
|
||||
* once the dialog closes, or false once every dialog and window it showed has
|
||||
* gone without it reporting a close.
|
||||
* still hides the frame. Resolves what the dialog reports when it closes, or
|
||||
* false once every dialog and window it showed has gone without it reporting
|
||||
* a close.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
|
||||
* an empty one is passed as null, so the dialog credits the prompt it remembers
|
||||
* @param {(closed: (result: boolean) => void) => unknown} open - opens the dialog, which calls `closed` when it closes
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolve, reject) => {
|
||||
const showFramedDialog = (win, open, tell) => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let seen = false;
|
||||
let goneTimer = null;
|
||||
@@ -345,21 +363,47 @@ export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolv
|
||||
toldReady = true;
|
||||
tell('ready');
|
||||
};
|
||||
const open = () => {
|
||||
const dialog = new win.UIUpgradeAccount({
|
||||
attribution: Object.keys(attribution).length ? attribution : null,
|
||||
onClose: (result) => settle(() => resolve(result?.upgraded === true)),
|
||||
});
|
||||
return dialog.open_as_window();
|
||||
};
|
||||
whenFrameSized(win, ready)
|
||||
.then(open)
|
||||
.then(() => open((result) => settle(() => resolve(result))))
|
||||
.then(() => {
|
||||
check();
|
||||
ready();
|
||||
}, (e) => settle(() => reject(e)));
|
||||
});
|
||||
|
||||
/**
|
||||
* Shows the deployment's upgrade dialog in a framed popup. Resolves whether
|
||||
* the user upgraded, once the dialog closes.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
|
||||
* an empty one is passed as null, so the dialog credits the prompt it remembers
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(win, (closed) => {
|
||||
const dialog = new win.UIUpgradeAccount({
|
||||
attribution: Object.keys(attribution).length ? attribution : null,
|
||||
onClose: (result) => closed(result?.upgraded === true),
|
||||
});
|
||||
return dialog.open_as_window();
|
||||
}, tell);
|
||||
|
||||
/**
|
||||
* Shows the deployment's top-up dialog in a framed popup. Resolves whether
|
||||
* the user bought credit, once the dialog closes.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
|
||||
const dialog = new win.UITopUp({
|
||||
onClose: (result) => closed(result?.toppedUp === true),
|
||||
});
|
||||
return dialog.open();
|
||||
}, tell);
|
||||
|
||||
/**
|
||||
* Resolves once the frame has a viewport. The page keeps a loading frame
|
||||
* hidden, which Chromium and WebKit lay out at 0x0, and a GUI window opened
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
POPUP_FRAME_MSG,
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowTopUpFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedTopUp,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './popupFrame.js';
|
||||
@@ -50,6 +52,7 @@ describe('framedPopupAction', () => {
|
||||
['/embed/show-save-file-picker', 'show-save-file-picker'],
|
||||
['/embed/show-directory-picker', 'show-directory-picker'],
|
||||
['/embed/upgrade', 'upgrade'],
|
||||
['/embed/top-up', 'top-up'],
|
||||
['/embed/request-email-confirmation', 'request-email-confirmation'],
|
||||
['/embed/apps', null],
|
||||
['/embed/feedback', null],
|
||||
@@ -85,6 +88,7 @@ describe('framesHostUser', () => {
|
||||
['show-save-file-picker', true],
|
||||
['show-directory-picker', true],
|
||||
['upgrade', true],
|
||||
['top-up', true],
|
||||
['request-email-confirmation', true],
|
||||
['sign-in', false],
|
||||
[null, false],
|
||||
@@ -133,6 +137,34 @@ describe('canShowUpgradeFrame', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowTopUpFrame', () => {
|
||||
const win = { UITopUp: Object.assign(function () {}, { embeddable: true }) };
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
it('shows for a session with nothing to verify, when the dialog reports its close', () => {
|
||||
expect(canShowTopUpFrame(win, session)).toBe(true);
|
||||
});
|
||||
|
||||
it('needs a top-up dialog that reports its close, whatever the upgrade dialog does', () => {
|
||||
expect(canShowTopUpFrame({}, session)).toBe(false);
|
||||
expect(canShowTopUpFrame({ UITopUp: function () {} }, session)).toBe(false);
|
||||
expect(canShowTopUpFrame({ UIUpgradeAccount: Object.assign(function () {}, { embeddable: true }) }, session)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'requires_email_confirmation',
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
])('leaves an account with %s to the tab', (check) => {
|
||||
expect(canShowTopUpFrame(win, { ...session, [check]: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('needs a session', () => {
|
||||
expect(canShowTopUpFrame(win, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowEmailConfirmationFrame', () => {
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
@@ -361,6 +393,64 @@ describe('showFramedUpgrade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFramedTopUp', () => {
|
||||
const topUpWindow = (open) => {
|
||||
const events = new EventTarget();
|
||||
return {
|
||||
document,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 800,
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
MutationObserver: window.MutationObserver,
|
||||
UITopUp: function (options) {
|
||||
this.open = () => open(options);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('tells the page it is ready once open, and resolves whether credit was bought', async () => {
|
||||
const tell = vi.fn();
|
||||
let options;
|
||||
const result = showFramedTopUp(topUpWindow(async (o) => {
|
||||
options = o;
|
||||
}), tell);
|
||||
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
options.onClose({ toppedUp: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('resolves false for a close without a purchase', async () => {
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
|
||||
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false once the dialog is gone without reporting a close', async () => {
|
||||
vi.useFakeTimers();
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
const result = showFramedTopUp(topUpWindow(async () => {
|
||||
document.body.append(root);
|
||||
}), vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
root.remove();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the dialog fails to open', async () => {
|
||||
await expect(showFramedTopUp(topUpWindow(async () => {
|
||||
throw new Error('no dialog');
|
||||
}), vi.fn())).rejects.toThrow('no dialog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPopupFrame', () => {
|
||||
it('does nothing without a framing page to answer', () => {
|
||||
const win = frameWindow();
|
||||
@@ -396,6 +486,11 @@ describe('startPopupFrame', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the framing page show through a top-up frame', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/top-up' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets the framing page show through an upgrade or email confirmation frame only', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/sign-in' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(false);
|
||||
|
||||
@@ -72,9 +72,13 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
}
|
||||
|
||||
// show the loading spinner
|
||||
$(loading_spinner).find('.explorer-loading-spinner-msg').text(`${i18n('loading')}...`);
|
||||
$(loading_spinner).show();
|
||||
setTimeout(function () {
|
||||
$(loading_spinner).find('.explorer-loading-spinner-msg').html('Taking a little longer than usual. Please wait...');
|
||||
// only a load that is still running has taken long
|
||||
if ( is_current() && $(loading_spinner).is(':visible') ) {
|
||||
$(loading_spinner).find('.explorer-loading-spinner-msg').text(i18n('taking_longer_than_usual'));
|
||||
}
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
|
||||
@@ -98,6 +102,8 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
return;
|
||||
}
|
||||
if ( el_window ) {
|
||||
// the class is how a deleted folder's windows are found, and a window opened by path starts without it
|
||||
$(el_window).removeClass(`window-${$(el_window).attr('data-uid')}`).addClass(`window-${fsentry.id}`);
|
||||
$(el_window).attr('data-uid', fsentry.id);
|
||||
$(el_window).attr('data-sort_by', fsentry.sort_by ?? 'name');
|
||||
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
|
||||
@@ -120,6 +126,8 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
if ( fsentry.layout === 'details' ) {
|
||||
window.update_details_layout_sort_visuals(el_window, fsentry.sort_by, fsentry.sort_order);
|
||||
}
|
||||
}).catch(() => {
|
||||
// The listing request below reports the failure to the user.
|
||||
});
|
||||
|
||||
// is_directoryPicker
|
||||
@@ -135,6 +143,8 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
|
||||
// remove all existing items
|
||||
$(el_item_container).find('.item').removeItems();
|
||||
// removing the last row shows the empty message, which is wrong while the folder is loading
|
||||
$(empty_message).hide();
|
||||
|
||||
// get items with subdomains/workers included to avoid per-item stat calls
|
||||
const entries_promise = is_shared_view
|
||||
@@ -352,6 +362,11 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
$(error_message).html(`Failed to load directory${ html_encode((e && e.message ? `: ${ e.message}` : ''))}`);
|
||||
$(error_message).show();
|
||||
|
||||
// the previous folder's items are gone, so its count must not linger
|
||||
if ( el_window ) {
|
||||
window.update_explorer_footer_item_count(el_window);
|
||||
}
|
||||
|
||||
// Call onComplete callback even in error case, since the "loading" is technically complete
|
||||
if ( options.onComplete && typeof options.onComplete === 'function' ) {
|
||||
options.onComplete();
|
||||
|
||||
@@ -28,6 +28,7 @@ const en = {
|
||||
account_password: 'Verify Account Password',
|
||||
access_granted_to: 'Access Granted To',
|
||||
add_another_account: 'Add Another Account',
|
||||
addon_credits: 'Add-on credits',
|
||||
addon_credits_available: '+ {{amount}} add-on credits left',
|
||||
addon_credits_note: 'Used once your monthly credits run out.',
|
||||
add_app: 'Add an app',
|
||||
@@ -1067,6 +1068,7 @@ const en = {
|
||||
'window_click_to_go_back': 'Click to go back.',
|
||||
'window_click_to_go_forward': 'Click to go forward.',
|
||||
'window_click_to_go_up': 'Click to go one directory up.',
|
||||
'window_click_to_change_layout': 'Click to change the layout.',
|
||||
'window_title_public': 'Public',
|
||||
'window_title_videos': 'Videos',
|
||||
'window_title_pictures': 'Pictures',
|
||||
|
||||
@@ -49,6 +49,7 @@ import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import { embedHostUser } from './helpers/embedHostUser.js';
|
||||
import {
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowTopUpFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedTopUp,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './helpers/popupFrame.js';
|
||||
@@ -1048,6 +1050,23 @@ const postAuthActions = async (action) => {
|
||||
window.tell_popup_frame('closed', { upgraded });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
|
||||
// which hears whether the user bought credit once the dialog closes.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'top-up' && window.embedded_in_frame ) {
|
||||
let toppedUp;
|
||||
try {
|
||||
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
|
||||
} catch (e) {
|
||||
// A dialog that failed to open leaves the page to open the top-up in a tab.
|
||||
console.error('top-up action failed', e);
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
}
|
||||
window.tell_popup_frame('closed', { toppedUp });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: request-email-confirmation — puter.ui.requestEmailConfirmation() on the
|
||||
// first-party page framing it, which hears whether the email is confirmed once the
|
||||
@@ -1841,6 +1860,7 @@ window.initgui = async function (options) {
|
||||
!host_user ||
|
||||
session_user !== host_user ||
|
||||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
|
||||
(action === 'top-up' && !canShowTopUpFrame(window, session)) ||
|
||||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
|
||||
) {
|
||||
window.tell_popup_frame('unavailable');
|
||||
|
||||
+50
-31
@@ -23,6 +23,25 @@ import launch_app from './helpers/launchApp.js';
|
||||
import open_item from './helpers/openItem.js';
|
||||
import determine_active_container_parent from './helpers/determineActiveContainerParent.js';
|
||||
|
||||
// The item container the user is working in: around or inside the element they
|
||||
// last pressed, or elsewhere in its window. A pressed item that the window has
|
||||
// since navigated away from leaves only the container it was in.
|
||||
const selection_container = () => {
|
||||
const $active = $(window.active_element);
|
||||
let $container = $active.closest('.item-container');
|
||||
if ( $container.length === 0 ) {
|
||||
$container = $active.find('.item-container');
|
||||
}
|
||||
if ( $container.length === 0 ) {
|
||||
$container = $active.closest('.window').find('.item-container');
|
||||
}
|
||||
if ( $container.length === 0 && window.active_element && !document.contains(window.active_element)
|
||||
&& document.contains(window.active_item_container) ) {
|
||||
$container = $(window.active_item_container);
|
||||
}
|
||||
return $container;
|
||||
};
|
||||
|
||||
$(document).bind('keydown', async function (e) {
|
||||
const focused_el = document.activeElement;
|
||||
//-----------------------------------------------------------------------------
|
||||
@@ -290,13 +309,14 @@ $(document).bind('keydown', async function (e) {
|
||||
if ( selected.length === 1 ) {
|
||||
return selected.get(0);
|
||||
}
|
||||
if ( selected.length > 1 && window.latest_selected_item ) {
|
||||
// only an item that is still selected in this container can anchor the move
|
||||
if ( selected.is(window.latest_selected_item) ) {
|
||||
return window.latest_selected_item;
|
||||
}
|
||||
if ( window.active_element && $(window.active_element).hasClass('item') ) {
|
||||
if ( selected.is(window.active_element) ) {
|
||||
return window.active_element;
|
||||
}
|
||||
return $(window.active_item_container).find('.item').get(0);
|
||||
return selected.get(-1);
|
||||
}
|
||||
|
||||
function findNeighbor (current, direction) {
|
||||
@@ -363,18 +383,19 @@ $(document).bind('keydown', async function (e) {
|
||||
// select first item if none are already selected
|
||||
const selected = $(window.active_item_container).find('.item-selected');
|
||||
if ( selected.length === 0 ) {
|
||||
const first = $(window.active_item_container).find('.item').get(0);
|
||||
const first = $(window.active_item_container).find('.item:not(.item-hidden)').get(0);
|
||||
if ( first ) {
|
||||
$(first).addClass('item-selected');
|
||||
window.active_element = first;
|
||||
window.latest_selected_item = first;
|
||||
first.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// virtual grid layout to determine item layout and next items
|
||||
const items = Array.from($(window.active_item_container).find('.item'));
|
||||
const items = Array.from($(window.active_item_container).find('.item:not(.item-hidden)'));
|
||||
const grid = items.map(item => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
return {
|
||||
@@ -393,6 +414,7 @@ $(document).bind('keydown', async function (e) {
|
||||
|
||||
const currentEl = getActiveItem();
|
||||
const current = grid.find(i => i.el === currentEl);
|
||||
if ( ! current ) return;
|
||||
const next = findNeighbor(current, dir);
|
||||
|
||||
// apply new selection(s)
|
||||
@@ -409,6 +431,7 @@ $(document).bind('keydown', async function (e) {
|
||||
$(next.el).addClass('item-selected');
|
||||
}
|
||||
next.el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
|
||||
}
|
||||
}
|
||||
// ----------------------------------------------
|
||||
@@ -500,14 +523,15 @@ $(document).bind('keydown', async function (e) {
|
||||
// Shift+Delete (win)/ option+command+delete (Mac) key pressed
|
||||
// Permanent delete bypassing trash after alert
|
||||
//-----------------------------------------------------------------------
|
||||
if ( (e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8) ) {
|
||||
let $selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
|
||||
if ( ((e.keyCode === 46 && e.shiftKey) || (e.altKey && e.metaKey && e.keyCode === 8)) && !$(focused_el).is('input, textarea') ) {
|
||||
let $selected_items = selection_container().find('.item-selected');
|
||||
if ( $selected_items.length > 0 ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('delete'),
|
||||
value: 'delete',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
@@ -515,7 +539,7 @@ $(document).bind('keydown', async function (e) {
|
||||
},
|
||||
],
|
||||
});
|
||||
if ( (alert_resp) === 'Delete' ) {
|
||||
if ( (alert_resp) === 'delete' ) {
|
||||
for ( let index = 0; index < $selected_items.length; index++ ) {
|
||||
const element = $selected_items[index];
|
||||
await window.delete_item(element);
|
||||
@@ -528,15 +552,16 @@ $(document).bind('keydown', async function (e) {
|
||||
// Delete (win)/ ctrl+delete (Mac) / cmd+delete (Mac) key pressed
|
||||
// Permanent delete from trash after alert or move to trash
|
||||
//-----------------------------------------------------------------------
|
||||
if ( e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey)) ) {
|
||||
if ( (e.keyCode === 46 || (e.keyCode === 8 && (e.ctrlKey || e.metaKey))) && !$(focused_el).is('input, textarea') ) {
|
||||
// permanent delete?
|
||||
let $selected_items = $(window.active_element).closest('.item-container').find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
|
||||
let $selected_items = selection_container().find(`.item-selected[data-path^="${`${window.trash_path }/`}"]`);
|
||||
if ( $selected_items.length > 0 ) {
|
||||
const alert_resp = await UIAlert({
|
||||
message: i18n('confirm_delete_multiple_items'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('delete'),
|
||||
value: 'delete',
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
@@ -544,7 +569,7 @@ $(document).bind('keydown', async function (e) {
|
||||
},
|
||||
],
|
||||
});
|
||||
if ( (alert_resp) === 'Delete' ) {
|
||||
if ( (alert_resp) === 'delete' ) {
|
||||
for ( let index = 0; index < $selected_items.length; index++ ) {
|
||||
const element = $selected_items[index];
|
||||
await window.delete_item(element);
|
||||
@@ -554,7 +579,7 @@ $(document).bind('keydown', async function (e) {
|
||||
}
|
||||
// regular delete?
|
||||
else {
|
||||
$selected_items = $(window.active_element).closest('.item-container').find('.item-selected');
|
||||
$selected_items = selection_container().find('.item-selected');
|
||||
if ( $selected_items.length > 0 ) {
|
||||
// Only delete the items if we're not renaming one.
|
||||
if ( $selected_items.children('.item-name-editor-active').length === 0 ) {
|
||||
@@ -569,6 +594,11 @@ $(document).bind('keydown', async function (e) {
|
||||
// A letter or number is pressed and there is no context menu open: search items by name
|
||||
//-----------------------------------------------------------------------
|
||||
if ( !e.ctrlKey && !e.metaKey && !$(focused_el).is('input') && !$(focused_el).is('textarea') && $('.context-menu').length === 0 ) {
|
||||
// Shift, arrows, Enter and other named keys are not part of a name
|
||||
if ( e.key?.length !== 1 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( window.keypress_item_seach_term !== '' )
|
||||
{
|
||||
clearTimeout(window.keypress_item_seach_buffer_timeout);
|
||||
@@ -589,7 +619,7 @@ $(document).bind('keydown', async function (e) {
|
||||
}
|
||||
|
||||
// search for matches
|
||||
let haystack = $(window.active_item_container).find('.item').not('.item-disabled');
|
||||
let haystack = $(window.active_item_container).find('.item').not('.item-disabled, .item-hidden');
|
||||
for ( let j = 0; j < haystack.length; j++ ) {
|
||||
if ( $(haystack[j]).attr('data-name').toLowerCase().startsWith(window.keypress_item_seach_term) ) {
|
||||
matches.push(haystack[j]);
|
||||
@@ -613,7 +643,7 @@ $(document).bind('keydown', async function (e) {
|
||||
// select matching item
|
||||
$(matches[0]).not('.item-disabled').addClass('item-selected');
|
||||
matches[0].scrollIntoView(false);
|
||||
window.update_explorer_footer_selected_items_count($(window.active_element).closest('.window'));
|
||||
window.update_explorer_footer_selected_items_count($(window.active_item_container).closest('.window'));
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -687,11 +717,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
// ctrl/command + a, will select all items on desktop and windows
|
||||
//-----------------------------------------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 65 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
let $parent_container = $(window.active_element).closest('.item-container');
|
||||
if ( $parent_container.length === 0 )
|
||||
{
|
||||
$parent_container = $(window.active_element).find('.item-container');
|
||||
}
|
||||
const $parent_container = selection_container();
|
||||
|
||||
if ( $parent_container.attr('data-multiselectable') === 'false' )
|
||||
{
|
||||
@@ -699,7 +725,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
}
|
||||
|
||||
if ( $parent_container ) {
|
||||
$($parent_container).find('.item').not('.item-disabled').addClass('item-selected');
|
||||
$($parent_container).find('.item').not('.item-disabled, .item-hidden').addClass('item-selected');
|
||||
window.update_explorer_footer_selected_items_count($parent_container.closest('.window'));
|
||||
}
|
||||
|
||||
@@ -732,11 +758,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
!$(focused_el).is('textarea') ) {
|
||||
let $selected_items;
|
||||
|
||||
let parent_container = $(window.active_element).closest('.item-container');
|
||||
if ( parent_container.length === 0 )
|
||||
{
|
||||
parent_container = $(window.active_element).find('.item-container');
|
||||
}
|
||||
const parent_container = selection_container();
|
||||
|
||||
if ( parent_container !== null ) {
|
||||
$selected_items = $(parent_container).find('.item-selected');
|
||||
@@ -761,11 +783,7 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
//-----------------------------------------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 88 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
let $selected_items;
|
||||
let parent_container = $(window.active_element).closest('.item-container');
|
||||
if ( parent_container.length === 0 )
|
||||
{
|
||||
parent_container = $(window.active_element).find('.item-container');
|
||||
}
|
||||
const parent_container = selection_container();
|
||||
|
||||
if ( parent_container !== null ) {
|
||||
$selected_items = $(parent_container).find('.item-selected');
|
||||
@@ -863,7 +881,8 @@ $(document).bind('keyup keydown', async function (e) {
|
||||
// Paste
|
||||
// ctrl/command + v, will paste items from the clipboard to the active element
|
||||
//----------------------------------------------
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
// Once per press: releasing V with Ctrl still held would paste a second time.
|
||||
if ( (e.ctrlKey || e.metaKey) && e.which === 86 && e.type === 'keydown' && !$(focused_el).is('input') && !$(focused_el).is('textarea') ) {
|
||||
let target_path, target_el;
|
||||
|
||||
// continue only if there is something in the clipboard
|
||||
|
||||
@@ -297,7 +297,9 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
const removal = new MutationObserver(() => {
|
||||
if ( ! dialog.isConnected ) dismiss();
|
||||
});
|
||||
const restoreFocus = document.activeElement;
|
||||
// Down through shadow roots: a component's host can't take the focus back itself.
|
||||
let restoreFocus = document.activeElement;
|
||||
while ( restoreFocus?.shadowRoot?.activeElement ) restoreFocus = restoreFocus.shadowRoot.activeElement;
|
||||
const wait = (ms) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => finish(null), ms);
|
||||
@@ -454,12 +456,13 @@ const POPUP_FRAME_BOXES = {
|
||||
'show-directory-picker': { width: 700, height: 400 },
|
||||
};
|
||||
|
||||
// The upgrade has no box: it covers the page and its dialog draws its own
|
||||
// scrim. A payment in it may use the Payment Request API, which a frame can
|
||||
// use only when allowed, and Escape is left to the dialog, which doesn't close
|
||||
// mid-entry.
|
||||
// The upgrade and the top-up have no box: each covers the page and its dialog
|
||||
// draws its own scrim. A payment in it may use the Payment Request API, which
|
||||
// a frame can use only when allowed, and Escape is left to the dialog, which
|
||||
// doesn't close mid-entry.
|
||||
const UNBOXED_POPUP_FRAMES = {
|
||||
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
|
||||
'top-up': { allow: 'payment; otp-credentials', escapeDismisses: false },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -476,8 +479,8 @@ const UNBOXED_POPUP_FRAMES = {
|
||||
* @template T
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.guiOrigin
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'request-email-confirmation'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade or the email confirmation
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'top-up' | 'request-email-confirmation'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade, the top-up or the email confirmation
|
||||
* @param {string} opts.title - the frame's accessible name
|
||||
* @param {string} [opts.closeLabel] - puts a close button on the frame
|
||||
* @param {(data?: Record<string, unknown>) => T} opts.closed
|
||||
|
||||
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
|
||||
*
|
||||
* @typedef {{
|
||||
* env?: string,
|
||||
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
|
||||
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean, topUpShowing_?: () => boolean },
|
||||
* authToken?: string,
|
||||
* appID?: string,
|
||||
* getAppIDFromAuthToken?: (token: unknown) => string | null,
|
||||
@@ -129,8 +129,9 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
|
||||
puter.ui.requestUpgrade({ reason, method, message });
|
||||
} else if ( puter?.env === 'web' || reason === 'storage' ) {
|
||||
// The upgrade a first-party page already shows answers this refusal
|
||||
// too, and a dialog over it would block it.
|
||||
// too, and a dialog over it, or over a top-up, would block it.
|
||||
if ( puter.ui?.upgradeFrameShowing_?.() === true ) return;
|
||||
if ( puter.ui?.topUpShowing_?.() === true ) return;
|
||||
// A first-party page shows the upgrade in a frame over itself
|
||||
// instead of a dashboard tab.
|
||||
const framed = puter?.env === 'web' && puter.ui?.canShowUpgradeFrame_?.() === true;
|
||||
|
||||
@@ -217,6 +217,14 @@ describe('promptIfUpgradeRequired', () => {
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows no dialog over a top-up the page already shows', () => {
|
||||
globalThis.puter.ui.topUpShowing_ = () => true;
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
expect(showUsageLimitDialog).not.toHaveBeenCalled();
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the tab when the page cannot frame it', () => {
|
||||
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
|
||||
@@ -5,7 +5,7 @@ import PuterDialog from './PuterDialog.js';
|
||||
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from '../lib/guiEmbed.js';
|
||||
import { hasUserActivation } from '../lib/auth-popup.js';
|
||||
import { appIDOf } from '../lib/upgradePrompt.js';
|
||||
import { upgradeUrl } from './UsageLimitDialog.js';
|
||||
import { topUpUrl, upgradeUrl } from './UsageLimitDialog.js';
|
||||
import { checkPermissions } from './perms/lib/holds.js';
|
||||
|
||||
|
||||
@@ -538,6 +538,9 @@ export class UIModule extends EventListener {
|
||||
// The framed upgrade showing, which a call made while it's open joins.
|
||||
#upgradeFrame = null;
|
||||
|
||||
// The top-up showing, framed or about to open in a tab, which a call made meanwhile joins.
|
||||
#topUpFrame = null;
|
||||
|
||||
// The framed email confirmation showing, which a call made while it's open joins.
|
||||
#emailConfirmationFrame = null;
|
||||
|
||||
@@ -1803,13 +1806,20 @@ export class UIModule extends EventListener {
|
||||
/**
|
||||
* The upgrade in a frame over the page, for the page's signed-in user, or
|
||||
* in a new tab when the frame can't show it. A call made while one is
|
||||
* showing settles with it, so a payment in progress isn't replaced.
|
||||
* showing settles with it, so a payment in progress isn't replaced, and
|
||||
* one made while the top-up shows resolves `{ upgraded: false }` once
|
||||
* that closes.
|
||||
*
|
||||
* @param {{ reason?: string, method?: string }} details
|
||||
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
|
||||
*/
|
||||
#showUpgradeFrame ({ reason, method }) {
|
||||
if ( this.#upgradeFrame ) return this.#upgradeFrame;
|
||||
// A second full-page frame would cover the top-up, and a payment in it.
|
||||
if ( this.#topUpFrame ) {
|
||||
const notUpgraded = () => ({ upgraded: false });
|
||||
return this.#topUpFrame.then(notUpgraded, notUpgraded);
|
||||
}
|
||||
this.#upgradeFrame = (async () => {
|
||||
try {
|
||||
const user = await this.#frameUser();
|
||||
@@ -1825,6 +1835,9 @@ export class UIModule extends EventListener {
|
||||
closed: (data) => ({ upgraded: data?.upgraded === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
// The page's cached whoami still reads it as having no plan,
|
||||
// which is what the user menu offers Add credits by.
|
||||
if ( framed?.upgraded ) user.subscribed = true;
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
|
||||
return { upgraded: false };
|
||||
@@ -1836,14 +1849,65 @@ export class UIModule extends EventListener {
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the upgrade page in a new tab. The click that asked for it may
|
||||
* have expired by now, so without it the user opens it from a consent
|
||||
* dialog. Resolves once the tab opens or the user answers the dialog.
|
||||
* Whether {@link showTopUp_} is showing the top-up in a frame, about to,
|
||||
* or asking to open it in a tab instead.
|
||||
*
|
||||
* @internal
|
||||
* @returns {boolean}
|
||||
*/
|
||||
topUpShowing_ () {
|
||||
return this.#topUpFrame !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the top-up flow, where a user with a plan buys add-on credit. On
|
||||
* a signed-in first-party page one label under the Puter origin, it shows
|
||||
* in a frame over the page, and the promise resolves once it closes. When
|
||||
* the frame can't show it, and on any other website, it opens on the
|
||||
* dashboard in a new tab and the promise resolves `{ toppedUp: false }`.
|
||||
* A call made while one is showing settles with it, so a payment in
|
||||
* progress isn't replaced.
|
||||
*
|
||||
* @internal
|
||||
* @returns {Promise<{ toppedUp: boolean }>}
|
||||
*/
|
||||
showTopUp_ () {
|
||||
if ( this.#topUpFrame ) return this.#topUpFrame;
|
||||
const showing = (async () => {
|
||||
// Not awaited where no frame can show, so the tab opens within the click.
|
||||
const user = this.#canFrameForUser() ? await this.#frameUser() : null;
|
||||
const framed = user && await showPopupFrame({
|
||||
guiOrigin: puter.defaultGUIOrigin,
|
||||
action: 'top-up',
|
||||
fragment: { user: user.uuid },
|
||||
title: 'Add credits',
|
||||
closed: (data) => ({ toppedUp: data?.toppedUp === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
|
||||
return { toppedUp: false };
|
||||
})();
|
||||
this.#topUpFrame = showing;
|
||||
// Cleared from outside: the steps above can fail before their first
|
||||
// await, which is before the field is set.
|
||||
const done = () => {
|
||||
if ( this.#topUpFrame === showing ) this.#topUpFrame = null;
|
||||
};
|
||||
showing.then(done, done);
|
||||
return showing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the upgrade or top-up page in a new tab. The click that asked for
|
||||
* it may have expired by now, so without it the user opens it from a
|
||||
* consent dialog. Resolves once the tab opens or the user answers the dialog.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {string} [message] - what the consent dialog asks
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
#openUpgradeTab (url) {
|
||||
#openUpgradeTab (url, message = 'This website wants to open the Puter upgrade page.') {
|
||||
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let consent = null;
|
||||
@@ -1853,7 +1917,7 @@ export class UIModule extends EventListener {
|
||||
};
|
||||
try {
|
||||
consent = PuterDialog.show({
|
||||
message: 'This website wants to open the Puter upgrade page.',
|
||||
message,
|
||||
onContinue: () => {
|
||||
window.open(url, '_blank');
|
||||
answered();
|
||||
|
||||
@@ -87,6 +87,26 @@ describe('ui.requestUpgrade on a signed-in first-party page', () => {
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the page\'s cached account as having a plan once the user upgrades', async () => {
|
||||
const account = { uuid: USER, subscribed: false };
|
||||
puter.whoamiCache_ = Promise.resolve(account);
|
||||
const ui = makeUI();
|
||||
|
||||
let result = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await result;
|
||||
expect(account.subscribed).toBe(false);
|
||||
|
||||
result = ui.requestUpgrade(DETAILS);
|
||||
await vi.waitFor(() => expect(upgradeFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', upgraded: true });
|
||||
await result;
|
||||
expect(account.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('says the upgrade is showing from the call until it closes', async () => {
|
||||
const ui = makeUI();
|
||||
expect(ui.upgradeFrameShowing_()).toBe(false);
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url": "https://notes.puter.test/doc"}
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { UIModule } = await import('./UI.js');
|
||||
|
||||
// jsdom has no top-layer dialogs.
|
||||
HTMLDialogElement.prototype.showModal = function () {
|
||||
this.open = true;
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function () {
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
const GUI_ORIGIN = 'https://puter.test';
|
||||
const USER = '0b1c9a52-3f0e-4c7a-9a65-2f6d1e8b7c40';
|
||||
// On the deployment the page belongs to, where the frame would have shown it.
|
||||
const TOP_UP_TAB = `${GUI_ORIGIN}/dashboard/?topup=#usage`;
|
||||
|
||||
const topUpDialog = () => document.querySelector('dialog.puter-popup-frame');
|
||||
const topUpFrame = () => topUpDialog()?.querySelector('iframe') ?? null;
|
||||
const fragment = () => Object.fromEntries(new URLSearchParams(new URL(topUpFrame().src).hash.slice(1)));
|
||||
|
||||
const fromFrame = (data) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data, origin: GUI_ORIGIN, source: topUpFrame().contentWindow,
|
||||
}));
|
||||
|
||||
const makeUI = (env = 'web') => new UIModule(
|
||||
{ env, authToken: 'token-1', APIOrigin: 'https://api.puter.test', util: {} },
|
||||
{ appInstanceID: 'instance-1' },
|
||||
);
|
||||
|
||||
const fakePuter = ({ signedIn = true, guiOrigin = GUI_ORIGIN } = {}) => {
|
||||
globalThis.puter = {
|
||||
defaultGUIOrigin: guiOrigin,
|
||||
whoamiCache_: Promise.resolve({ uuid: USER }),
|
||||
auth: {
|
||||
isSignedIn: () => signedIn,
|
||||
getUser: vi.fn(async () => ({ uuid: USER })),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const setActivation = (isActive) => Object.defineProperty(navigator, 'userActivation', {
|
||||
configurable: true,
|
||||
value: { hasBeenActive: true, isActive },
|
||||
});
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
window.open = vi.fn(() => ({ closed: false }));
|
||||
setActivation(true);
|
||||
fakePuter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete navigator.userActivation;
|
||||
delete globalThis.puter;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ that fails before anything shows', () => {
|
||||
it('stops saying a top-up is showing, and tries again on the next call', async () => {
|
||||
// No scheme, so the dashboard's address can't be built.
|
||||
fakePuter({ guiOrigin: 'puter.test' });
|
||||
const ui = makeUI();
|
||||
await expect(ui.showTopUp_()).rejects.toThrow();
|
||||
expect(ui.topUpShowing_()).toBe(false);
|
||||
|
||||
fakePuter();
|
||||
const result = ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ on a signed-in first-party page', () => {
|
||||
it('shows the top-up in a frame over the page and resolves what the dialog reports', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
|
||||
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
|
||||
expect(fragment()).toEqual({ user: USER });
|
||||
// It covers the page, and the dialog inside draws the scrim.
|
||||
expect(topUpDialog().querySelector('.puter-popup-frame-box')).toBeNull();
|
||||
expect(topUpDialog().querySelector('.puter-popup-frame-close')).toBeNull();
|
||||
expect(topUpFrame().getAttribute('allow')).toBe('payment; otp-credentials');
|
||||
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(topUpDialog().open).toBe(true);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
|
||||
|
||||
await expect(result).resolves.toEqual({ toppedUp: true });
|
||||
expect(topUpFrame()).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves toppedUp false when the dialog closes without a purchase', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
|
||||
it('says a top-up is showing until it closes', async () => {
|
||||
const ui = makeUI();
|
||||
expect(ui.topUpShowing_()).toBe(false);
|
||||
const result = ui.showTopUp_();
|
||||
expect(ui.topUpShowing_()).toBe(true);
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(ui.topUpShowing_()).toBe(true);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed' });
|
||||
await result;
|
||||
expect(ui.topUpShowing_()).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Escape on the page to the dialog once it shows', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
|
||||
topUpDialog().dispatchEvent(new Event('cancel', { cancelable: true }));
|
||||
|
||||
expect(topUpFrame()).not.toBeNull();
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: false });
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
|
||||
it('settles a call made while the top-up shows with it, without replacing it', async () => {
|
||||
const ui = makeUI();
|
||||
const first = ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
const frame = topUpFrame();
|
||||
|
||||
const second = ui.showTopUp_();
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
|
||||
await expect(second).resolves.toEqual({ toppedUp: true });
|
||||
|
||||
// A later call shows a new one.
|
||||
ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
expect(topUpFrame()).not.toBe(frame);
|
||||
});
|
||||
|
||||
it('opens the dashboard top-up in a tab when the frame cannot show it', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'loading' });
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
expect(topUpFrame()).toBeNull();
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
|
||||
});
|
||||
|
||||
it('asks for a click before opening the tab once the original click has expired', async () => {
|
||||
const result = makeUI().showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
setActivation(false);
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'unavailable' });
|
||||
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
|
||||
|
||||
const consent = document.querySelector('puter-dialog');
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
expect(consent.shadowRoot.querySelector('.about').textContent)
|
||||
.toBe('This website wants to open the Puter top-up page.');
|
||||
|
||||
consent.shadowRoot.querySelector('#launch-auth-popup').click();
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
expect(window.open).toHaveBeenCalledWith(TOP_UP_TAB, '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.showTopUp_ elsewhere on the web', () => {
|
||||
it.each([
|
||||
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
|
||||
['a signed-out first-party page', { signedIn: false }],
|
||||
])('opens the dashboard top-up in a tab within the click on %s', async (_label, options) => {
|
||||
fakePuter(options);
|
||||
const result = makeUI().showTopUp_();
|
||||
|
||||
// Before anything is awaited, so the browser still counts the click.
|
||||
expect(window.open).toHaveBeenCalledTimes(1);
|
||||
expect(window.open).toHaveBeenCalledWith(`${globalThis.puter.defaultGUIOrigin}/dashboard/?topup=#usage`, '_blank');
|
||||
expect(topUpFrame()).toBeNull();
|
||||
await expect(result).resolves.toEqual({ toppedUp: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ui.requestUpgrade beside the top-up', () => {
|
||||
it('waits for the top-up showing instead of framing the upgrade over it', async () => {
|
||||
const ui = makeUI();
|
||||
const toppingUp = ui.showTopUp_();
|
||||
await vi.waitFor(() => expect(topUpFrame()).not.toBeNull());
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
|
||||
const upgrading = ui.requestUpgrade({ reason: 'funds' });
|
||||
await expect(settled(upgrading)).resolves.toBe(PENDING);
|
||||
expect(document.querySelectorAll('dialog.puter-popup-frame')).toHaveLength(1);
|
||||
expect(new URL(topUpFrame().src).pathname).toBe('/embed/top-up');
|
||||
|
||||
fromFrame({ msg: 'puterPopupFrame', state: 'closed', toppedUp: true });
|
||||
await expect(toppingUp).resolves.toEqual({ toppedUp: true });
|
||||
await expect(upgrading).resolves.toEqual({ upgraded: false });
|
||||
expect(document.querySelector('dialog.puter-popup-frame')).toBeNull();
|
||||
expect(window.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still asks before opening the upgrade tab in its own words', async () => {
|
||||
const result = makeUI().requestUpgrade({ reason: 'funds' });
|
||||
const frame = () => document.querySelector('dialog.puter-popup-frame iframe');
|
||||
await vi.waitFor(() => expect(frame()).not.toBeNull());
|
||||
expect(new URL(frame().src).pathname).toBe('/embed/upgrade');
|
||||
setActivation(false);
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puterPopupFrame', state: 'unavailable' }, origin: GUI_ORIGIN, source: frame().contentWindow,
|
||||
}));
|
||||
await vi.waitFor(() => expect(document.querySelector('puter-dialog')).not.toBeNull());
|
||||
const consent = document.querySelector('puter-dialog');
|
||||
expect(consent.shadowRoot.querySelector('.about').textContent)
|
||||
.toBe('This website wants to open the Puter upgrade page.');
|
||||
consent.shadowRoot.querySelector('#launch-auth-popup-cancel').click();
|
||||
await expect(result).resolves.toEqual({ upgraded: false });
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,19 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
|
||||
return url.href;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dashboard page that opens the top-up flow, for buying add-on credit.
|
||||
*
|
||||
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const topUpUrl = (guiOrigin) => {
|
||||
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
|
||||
url.searchParams.set('topup', '');
|
||||
url.hash = 'usage';
|
||||
return url.href;
|
||||
};
|
||||
|
||||
class UsageLimitDialog extends (globalThis.HTMLElement || Object) {
|
||||
/**
|
||||
* @param {string} message Plain-text line saying what was refused.
|
||||
|
||||
@@ -343,6 +343,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
|
||||
- Signed in, it shows the user's profile picture, or the username's initial when there is none. Clicking it opens a menu with:
|
||||
- The user's picture, username and email. The email line is hidden when whoami leaves it out, such as for an app that can't read it.
|
||||
- **Manage account**, **Usage** and **Open Puter**, links that open the GUI origin's `/dashboard/#account`, `/dashboard/#usage` and `/` in a new tab.
|
||||
- **Add credits**, under Usage, only when whoami reports `subscribed: true` and no `team` that pays for the account. It shows the top-up dialog in a frame over the page (`/embed/top-up`) on a page that can frame the GUI, and otherwise opens it on the dashboard in a new tab.
|
||||
- **Send feedback**, which calls `puter.ui.showFeedbackDialog()`.
|
||||
- **Sign out**, which calls `puter.auth.signOut()`.
|
||||
- The menu opens with focus on its first item. ArrowUp and ArrowDown move between items and wrap around, and Home and End jump to the first and last.
|
||||
@@ -362,6 +363,7 @@ The account button and Puter's app browser for a first-party app on a Puter subd
|
||||
| `account-label` | `Account` | Account button label, read as `Account: <username>` |
|
||||
| `manage-account-label` | `Manage account` | Menu link to the account settings |
|
||||
| `usage-label` | `Usage` | Menu link to the usage page |
|
||||
| `add-credits-label` | `Add credits` | Menu item that opens the top-up dialog |
|
||||
| `open-puter-label` | `Open Puter` | Menu link to Puter |
|
||||
| `feedback-label` | `Send feedback` | Menu item that opens the feedback dialog |
|
||||
| `theme` | follows the OS | `light` or `dark`, for the component and the app browser in it |
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* Signed out (a temporary account counts as signed out), it shows a Sign in
|
||||
* button. Signed in, it shows the user's profile picture (or initial), which
|
||||
* opens a menu with the account, links to manage it, see its usage and open
|
||||
* Puter, Send feedback, and Sign out. On a page one subdomain under
|
||||
* Puter, Add credits for an account with a plan, Send feedback, and Sign out.
|
||||
* On a page one subdomain under
|
||||
* the Puter origin, an Apps button opens Puter's app browser in a panel under
|
||||
* it, for the same user: signed out, or signed in as an account other than
|
||||
* the Puter session's, the browser shows only recommended apps. The browser
|
||||
@@ -13,8 +14,8 @@
|
||||
* Renders nothing inside the Puter desktop.
|
||||
*
|
||||
* Attributes: theme, apps-label, sign-in-label, sign-out-label, close-label,
|
||||
* account-label, manage-account-label, usage-label, open-puter-label,
|
||||
* feedback-label
|
||||
* account-label, manage-account-label, usage-label, add-credits-label,
|
||||
* open-puter-label, feedback-label
|
||||
* Parts: apps-button, avatar, sign-in, menu, apps-panel
|
||||
* Events: signin (detail.user), signout
|
||||
*/
|
||||
@@ -37,6 +38,7 @@ const LABELS = {
|
||||
'account-label': 'Account',
|
||||
'manage-account-label': 'Manage account',
|
||||
'usage-label': 'Usage',
|
||||
'add-credits-label': 'Add credits',
|
||||
'open-puter-label': 'Open Puter',
|
||||
'feedback-label': 'Send feedback',
|
||||
};
|
||||
@@ -48,6 +50,9 @@ const MENU_LINKS = [
|
||||
{ className: 'open-puter', label: 'open-puter-label', path: '/' },
|
||||
];
|
||||
|
||||
// Sits under Usage, and shows only for an account with a plan.
|
||||
const ADD_CREDITS_ITEM = '<button type="button" class="menu-item add-credits" role="menuitem" hidden></button>';
|
||||
|
||||
// Gap between a panel and the viewport edge, and between its caret and corners.
|
||||
const EDGE = 8;
|
||||
const CARET_INSET = 20;
|
||||
@@ -513,7 +518,7 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
<span class="menu-email" hidden></span>
|
||||
</span>
|
||||
</div>
|
||||
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>`).join('')}
|
||||
${MENU_LINKS.map(link => `<a class="menu-item ${link.className}" role="menuitem" target="_blank" rel="noopener"><span class="menu-label"></span>${EXTERNAL_ICON}</a>${link.className === 'usage' ? ADD_CREDITS_ITEM : ''}`).join('')}
|
||||
<button type="button" class="menu-item feedback" role="menuitem"></button>
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
<button type="button" class="menu-item sign-out" role="menuitem"></button>
|
||||
@@ -548,6 +553,7 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
for ( const link of MENU_LINKS ) {
|
||||
this.$(`.${link.className}`).addEventListener('click', () => this._closePanels());
|
||||
}
|
||||
this.$('.add-credits').addEventListener('click', () => this._addCredits());
|
||||
this.$('.feedback').addEventListener('click', () => this._sendFeedback());
|
||||
this.$('.sign-out').addEventListener('click', () => this._signOut());
|
||||
this.$('.apps-close').addEventListener('click', () => this._closePanels({ returnFocus: true }));
|
||||
@@ -581,6 +587,7 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
close.title = this._label('close-label');
|
||||
this.$('.sign-in').textContent = this._label('sign-in-label');
|
||||
this.$('.sign-out').textContent = this._label('sign-out-label');
|
||||
this.$('.add-credits').textContent = this._label('add-credits-label');
|
||||
this.$('.feedback').textContent = this._label('feedback-label');
|
||||
for ( const link of MENU_LINKS ) {
|
||||
this.$(`.${link.className} .menu-label`).textContent = this._label(link.label);
|
||||
@@ -598,6 +605,19 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
this.$('.avatar').setAttribute('aria-label', username ? `${account}: ${username}` : account);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add-on credit is sold to accounts with a plan, which whoami reports where there are
|
||||
* plans. A seat's plan is its team's, and so is paying for its usage.
|
||||
*/
|
||||
_renderAddCredits () {
|
||||
// The SDK reads whoami into a new object when the same token is set again,
|
||||
// and an upgrade made on the page marks that one.
|
||||
const cached = globalThis.puter.whoami;
|
||||
const user = cached?.uuid && cached.uuid === this.#user?.uuid ? cached : this.#user;
|
||||
const isSeat = typeof user?.team?.uid === 'string' && user.team.uid !== '';
|
||||
this.$('.add-credits').hidden = user?.subscribed !== true || isSeat;
|
||||
}
|
||||
|
||||
_renderState () {
|
||||
const signedIn = this._signedIn();
|
||||
const username = this._username();
|
||||
@@ -614,6 +634,7 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
const email = typeof this.#user?.email === 'string' ? this.#user.email : '';
|
||||
this.$('.menu-email').textContent = email;
|
||||
this.$('.menu-email').hidden = !email;
|
||||
this._renderAddCredits();
|
||||
for ( const link of MENU_LINKS ) {
|
||||
this.$(`.${link.className}`).href = new URL(link.path, globalThis.puter.defaultGUIOrigin).href;
|
||||
}
|
||||
@@ -705,13 +726,15 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
*/
|
||||
_openMenu ({ fromKeyboard = false } = {}) {
|
||||
const menu = this.$('.menu');
|
||||
// An upgrade made on the page since the account loaded gives it a plan.
|
||||
this._renderAddCredits();
|
||||
this._showPanel(menu);
|
||||
(fromKeyboard ? this.$('.menu-item') : menu).focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
/** Moves focus between menu items for an arrow, Home or End key; false for any other key. */
|
||||
_moveMenuFocus (key) {
|
||||
const items = [...this.$$('.menu .menu-item')];
|
||||
const items = [...this.$$('.menu .menu-item:not([hidden])')];
|
||||
const current = items.indexOf(this.shadowRoot.activeElement);
|
||||
const last = items.length - 1;
|
||||
const next = {
|
||||
@@ -786,6 +809,13 @@ class PuterUserMenu extends PuterWebComponent {
|
||||
);
|
||||
}
|
||||
|
||||
_addCredits () {
|
||||
// Focus goes back to the account button, where the dialog leaves it when it closes.
|
||||
this._closePanels({ returnFocus: true });
|
||||
// Called inside the click, so a tab it falls back to isn't blocked.
|
||||
void globalThis.puter.ui.showTopUp_();
|
||||
}
|
||||
|
||||
_sendFeedback () {
|
||||
this._closePanels();
|
||||
void globalThis.puter.ui.showFeedbackDialog();
|
||||
|
||||
@@ -26,6 +26,7 @@ const fakePuter = ({ token = null, user = null, picture = null } = {}) => {
|
||||
},
|
||||
ui: {
|
||||
showFeedbackDialog: vi.fn(() => new Promise(() => {})),
|
||||
showTopUp_: vi.fn(() => new Promise(() => {})),
|
||||
},
|
||||
auth: {
|
||||
getProfilePicture: vi.fn(async () => currentPicture),
|
||||
@@ -225,6 +226,80 @@ describe('<puter-user-menu>', () => {
|
||||
expect(shown(el, '.menu')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers Add credits, under Usage, to an account with a plan', async () => {
|
||||
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(true);
|
||||
expect(part(el, '.add-credits').textContent).toBe('Add credits');
|
||||
expect(part(el, '.usage').nextElementSibling).toBe(part(el, '.add-credits'));
|
||||
|
||||
part(el, '.add-credits').click();
|
||||
expect(sdk.ui.showTopUp_).toHaveBeenCalledTimes(1);
|
||||
expect(shown(el, '.menu')).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an account without a plan', { ...USER, subscribed: false }],
|
||||
['a server that reports no plans', USER],
|
||||
['a seat whose team pays for it', { ...USER, subscribed: true, team: { uid: 'team-1', name: 'Acme' } }],
|
||||
])('leaves Add credits out for %s, and out of the arrow keys', async (_label, user) => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 't', user }));
|
||||
const el = await mount();
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(false);
|
||||
|
||||
part(el, '.usage').focus();
|
||||
part(el, '.usage').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
expect(el.shadowRoot.activeElement).toBe(part(el, '.open-puter'));
|
||||
});
|
||||
|
||||
it('offers Add credits the next time the menu opens after an upgrade made on the page', async () => {
|
||||
const account = { ...USER, subscribed: false };
|
||||
vi.stubGlobal('puter', fakePuter({ token: 't', user: account }));
|
||||
const el = await mount();
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(false);
|
||||
part(el, '.avatar').click();
|
||||
|
||||
// What the SDK does to its cached whoami when a framed upgrade reports a plan.
|
||||
account.subscribed = true;
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(true);
|
||||
});
|
||||
|
||||
it('offers Add credits after an upgrade marks a whoami read again for the same token', async () => {
|
||||
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: false } });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
|
||||
// Setting the token again reads whoami into a new object, which the upgrade then marks.
|
||||
const reread = { ...USER, subscribed: false };
|
||||
sdk.signInAs('t', reread);
|
||||
sdk.whoami = reread;
|
||||
await settle();
|
||||
reread.subscribed = true;
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(true);
|
||||
|
||||
// Never another account's plan.
|
||||
part(el, '.avatar').click();
|
||||
sdk.whoami = { ...OTHER, subscribed: true };
|
||||
part(el, '.avatar').click();
|
||||
expect(shown(el, '.add-credits')).toBe(false);
|
||||
});
|
||||
|
||||
it('stops offering Add credits once the sign-in changes to an account without a plan', async () => {
|
||||
const sdk = fakePuter({ token: 't', user: { ...USER, subscribed: true } });
|
||||
vi.stubGlobal('puter', sdk);
|
||||
const el = await mount();
|
||||
expect(part(el, '.add-credits').hidden).toBe(false);
|
||||
sdk.signInAs('other-token', OTHER);
|
||||
await settle();
|
||||
expect(part(el, '.add-credits').hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('moves focus through the menu with the arrow, Home and End keys', async () => {
|
||||
vi.stubGlobal('puter', fakePuter({ token: 'app-token', user: USER }));
|
||||
const el = await mount();
|
||||
|
||||
Reference in new issue
Block a user