Compare commits

...
Author SHA1 Message Date
Nariman Jelveh b144f1d11c docs(embed): list the top-up tests among the checks to run 2026-11-10 14:39:01 -07:00
Nariman Jelveh 2ceeaacd31 fix(puter-js): offer Add credits after an upgrade when whoami was read again
The user menu keeps the whoami object it loaded and ignores a token set
again unchanged, but the SDK reads whoami into a new object each time a
token is set. A framed upgrade then marked the new object as subscribed
and the menu went on reading the old one, so Add credits stayed hidden
until a reload. The menu now reads the SDK's cached whoami when it is
the same account's.
2026-11-10 14:38:57 -07:00
Nariman Jelveh 0c984ca75b fix(puter-js): keep requestUpgrade from framing the upgrade over an open top-up
The usage-limit dialog already stays away while the top-up shows, but a
page's own requestUpgrade() call, such as one in the handler of a call
refused mid-payment, still mounted the upgrade frame. Frames are single
per path, so it covered the top-up and the payment being typed in it,
and the two dialogs' own guard can't see across documents. The call now
waits for the top-up and resolves { upgraded: false } once it closes.
2026-11-10 14:38:37 -07:00
Nariman Jelveh 16916a903d fix(puter-js): let a top-up that failed to start be asked for again
showTopUp_ cleared its in-flight marker in a finally inside the async
function it stored. A failure before that function's first await, such
as building the dashboard address from an origin with no scheme, ran the
finally before the marker was set, so the marker kept the rejected
promise for good: every later call got the same rejection, and
topUpShowing_ went on suppressing the usage-limit dialog. The marker is
now cleared once the stored promise settles.
2026-11-10 14:05:10 -07:00
Nariman Jelveh eda4015dc7 fix(puter-js): offer Add credits after an upgrade made on the page
The user menu showed Add credits by the whoami it loaded with the page.
A user who subscribed in the framed upgrade therefore found no Add
credits in the menu until a reload. A framed upgrade that reports a plan
now marks the page's cached whoami as subscribed, as a confirmed email
already is, and the menu reads it again each time it opens.
2026-11-10 14:04:35 -07:00
Nariman Jelveh 429b7e2f90 docs(embed): say when the top-up dialog's open() resolves
It resolves after the account has loaded, not as soon as the dialog
shows.
2026-11-10 13:36:11 -07:00
Nariman Jelveh b41ed5dfd6 fix(puter-js): return keyboard focus to the account button after Add credits
Choosing Add credits closed the menu with focus on the item it had just
hidden, and the frame restored focus to the menu's shadow host, which
can't take it, so a keyboard user was left on the page body once the
dialog closed. The menu now moves focus to its button first, and a
framed popup restores focus to the element inside a shadow root.
2026-11-10 13:35:42 -07:00
Nariman Jelveh b3dd98d24e fix(puter-js): open the top-up tab on the page's own deployment
The tab the Add credits item falls back to went to puter.com whatever
GUI origin the page belongs to, so on another deployment it opened a
different one, signed in as whoever is signed in there. It now opens the
dashboard of the GUI origin, like the menu's Usage link beside it.
2026-11-10 13:31:48 -07:00
Nariman Jelveh 1522ed3cdd fix(puter-js): leave Add credits out of the user menu for a team seat
whoami reports a seat as subscribed through its team's plan, so the menu
offered Add credits to an account that can't buy any: the dialog only
answers that the team pays. The item now stays hidden when whoami names a
team paying for the account, as the desktop's own menu already does.
2026-11-10 13:31:23 -07:00
Nariman Jelveh e936038f80 fix(puter-js): show no usage-limit dialog over an open top-up
A call refused for funds while the framed top-up was open put the
usage-limit dialog in the top layer over it, covering the payment the
user was making to clear that refusal. The prompt now stays out of the
way while a top-up shows, as it does for the framed upgrade.
2026-11-10 13:31:04 -07:00
Nariman Jelveh a9f5e818db feat(gui): let a first-party page frame the deployment's top-up dialog
`/embed/top-up` joins the framed popups. Like the upgrade, it covers the
framing page with a transparent GUI page, serves only the page's own
user, and reports `unavailable` unless the deployment defines
`window.UITopUp` with `embeddable === true` and no account check is
waiting. It runs `new UITopUp({ onClose }).open()` and posts
`closed { toppedUp }` once the dialog closes. The frame's lifecycle is the
upgrade's, now shared by both.

`<puter-user-menu>` gets an Add credits item under Usage, shown only when
whoami reports `subscribed: true`. It frames the top-up on a page that can
frame the GUI, and otherwise opens the dashboard with `?topup=` in a new
tab, within the click. Hidden items are skipped by the menu's arrow keys.

`puter.ui.showTopUp_()` is internal; no public SDK method is added.
2026-11-10 13:00:13 -07:00
Nariman Jelveh 1550be358d fix(explorer): navigation, keyboard, drag-and-drop, realtime and layout fixes (#4569)
* fix(explorer): record typed address-bar paths in navigation history and canonicalize them

Pressing Enter in the path input now pushes the destination onto the
window's Back/Forward history like every other navigation, and resolves
the entry first (trim, ~ and home-relative expansion, trailing or doubled
slashes) so the window's path matches the form the rest of the GUI compares
against.

* fix(explorer): keep the address-bar input in step with the Shared view and the root

The path input was only updated after a successful stat, so in views that
are never statted it kept the previous folder's path and Enter navigated
back there.

* fix(explorer): drop the previous folder's identity when a folder fails to load

A rejected stat was never handled, so the window kept the previous folder's
uid, title and item count while showing the load error, and layout or sort
changes were saved to the wrong folder. The window now clears them, and the
failed request no longer surfaces as an unhandled rejection.

* fix(explorer): release a window's selection listeners and history when it closes

Each folder window left its rubber-band selection instance, a document
mouseup handler and its navigation history behind after closing, so every
later mouse press ran once per window ever opened. Windows that are not
folders no longer bind the handler at all.

* fix(explorer): anchor a Ctrl/Cmd rubber-band selection where the drag started

The rectangle's origin was only recorded for unmodified drags, so adding to
a selection drew it from the previous drag's start point or the top-left
corner.

* fix(explorer): keep arrow-key navigation on visible items and a valid anchor

Arrow keys could land on a hidden dotfile and then stop moving, threw when
the remembered anchor belonged to another window, and left the footer's
selected count stale.

* fix(explorer): ignore non-character keys in type-to-select

Shift, arrows and Enter were appended to the search term by name, so typing
a capital letter or typing right after navigating matched nothing. Hidden
files are no longer matched, and the footer count follows the container the
selection changed in.

* fix(explorer): make the keyboard permanent-delete confirmation work in every language

The confirmation compared the dialog's answer with the English button label,
so in any other language confirming did nothing.

* fix(explorer): paste once per Ctrl+V press

The shortcut ran on both keydown and keyup, so releasing V before Ctrl
pasted the clipboard twice.

* fix(explorer): keep selection shortcuts working after navigating, and out of text fields

Select All, Copy, Cut and Delete looked up the folder from the element last
pressed, which is a removed item or a sidebar entry after navigating, so the
shortcuts did nothing. Select All also skips hidden files, and Delete keys
typed in a text field are left to the field.

* fix(explorer): count only visible items in the footer and drop hidden files from the selection

The item count included dotfiles that were not shown, and hiding hidden
files left them selected, so a later Delete or Cut acted on files the user
could not see.

* fix(explorer): paste cut items after their folder is no longer displayed

Paste resolved the cut paths to rows on screen, so cutting a file and then
navigating the same window to the destination moved nothing and silently
emptied the clipboard. Items without a row are now looked up and moved the
same way.

* fix(explorer): show a moved item once in its destination

The tab that moves an item renders the row and also receives the move
event, so the destination showed the item twice whenever the event arrived
first. Both paths now replace any row the other already rendered.

* fix(explorer): sort names the way the dashboard does, with a stable tie-break

Folder windows and the desktop compared raw lowercase strings, so file10
came before file2 and accented names landed after z, and equal sizes, dates
or types kept whatever order they arrived in. They now use the dashboard's
comparator, so the same folder reads the same in both.

* fix(explorer): keep the Trash window's states when Trash is emptied elsewhere

Emptying Trash from another tab cleared the whole container, including its
empty, error and loading elements, and left the item count stale.

* fix(explorer): remove rows deleted elsewhere instead of hiding them

A deletion from another tab or an app only faded the row out, so it still
counted in the footer, in Select All and in keyboard actions. The row is now
removed, and windows showing the deleted folder close even when its own row
is not displayed anywhere.

* fix(explorer): refresh size and date when a file is overwritten elsewhere

An overwrite arrives as an update event, which only applied the name, icon
and path, so the size and modified columns and sorting kept old values.

* fix(explorer): download files whose names contain &, #, % or +

The path went into the download URL unencoded, so the server received a
truncated path and the download failed.

* fix(explorer): allow moving into folders whose name starts with Trash

The deleted-folder check matched any sibling of Trash sharing its prefix,
so moving into a folder such as Trash-old was refused.

* fix(explorer): end the extract operation when an archive is corrupt or has no files

A corrupt zip threw before its error could be shown, and an archive with
nothing to write or a failed upload never closed the progress window. A
progress update arriving before the window existed also threw.

* fix(explorer): follow renamed and moved folders in Back/Forward and on undo

A window's navigation history kept the old path after a folder was renamed
or moved, so Back led to a load error. Undoing a folder rename also left
windows and items inside it on the renamed path, because the undo retargeted
from the path it was restoring.

* fix(explorer): keep one progress window per copy or move

The progress timer is re-armed after every conflict dialog, and it opened a
second window when one was already showing, leaving the first on screen for
good.

* fix(explorer): show file names once-escaped in move alerts

The name was escaped before being passed to an alert that escapes its whole
message, so names with &, quotes or angle brackets showed entity text.

* fix(explorer): don't flash the empty-folder message while a folder refreshes

Clearing the rows for a refresh showed the message until the listing came
back, even for a folder full of items.

* fix(explorer): accept drops into a window that covers a desktop folder

A desktop folder hidden under a window still reacted to the drag and
disabled the window as a drop target, so the drop was ignored. A drop that
bailed out early also left every folder view disabled as a target until the
next hover; targets are now restored when the drag ends.

* fix(explorer): release each row's document click handler when the row is removed

Every file row bound a click handler on document that was never unbound, so
browsing folders grew the number of handlers run on every click and kept the
removed rows alive.

* fix(explorer): let Delete work on a file after its rename was cancelled

Cancelling a rename with Escape, or leaving the editor without a change,
left the item marked as being renamed, and the Delete shortcut skips such
items.

* fix(explorer): offer a download when no app can open a file

The prompt called a translation import that is not a function, so opening
a file with no suitable app threw and did nothing.

* fix(explorer): use an item's current path and name in its context menu

Cut, Copy, Download, Create Shortcut and the rename editor read the path and
name captured when the row was created. After a rename made from another
window or tab they acted on the old path, and closing the editor repainted
the old name.

* fix(explorer): offer Cut only where the item can be moved

The item menu on the desktop and in folder windows offered Cut on trashed
items and on shared items the user may not move, unlike the same menu built
for the dashboard.

* fix(explorer): give a folder named *.app a folder icon

The app-shortcut branch ran before the directory branch, so such a folder
was read as a file on every listing, failed, and showed the app icon.

* fix(explorer): make New > JPEG Image create an actual JPEG

The blank image was encoded with the canvas default, so the .jpg file held
transparent PNG data.

* fix(explorer): handle a drop onto a folder that is part of the dragged selection

Dragging a selection onto one of its own selected folders also tried to move
that folder into itself. On the desktop the drop also cleared the saved
position of the target folder instead of the items that left the desktop.

* fix(explorer): don't highlight a window as a drop target for its own items

The check meant to skip the folder an item is already in compared the wrong
path, so dragging inside a window outlined it although dropping there does
nothing.

* fix(explorer): keep desktop rearranging to the desktop itself

Dropping an item from a window showing the Desktop folder onto the desktop
background positioned the window's row absolutely and saved that position.

* fix(explorer): empty Trash from its window in one request

The Trash window's own Empty Trash sent one delete request per item and
reported Trash as empty before any of them finished. It now runs the same
single operation as the taskbar and desktop.

* fix(explorer): label the root and Shared in the Back/Forward menus

The long-press history menus showed an empty row for the root folder and a
lowercase path segment for Shared, and the Forward menu's Home entry was not
translated.

* fix(explorer): keep sidebar labels translated and titled after reordering

A reordered sidebar rendered the labels saved at the time of the drag, so
they stayed in the old language, and every item's tooltip was empty.

* fix(explorer): enable the Open dialog's button only for a selected file

The button stayed enabled after navigating to another folder, and a
rubber-band selection of folders alone enabled it, although clicking it then
did nothing.

* fix(explorer): show the slow-load message only for a load that is slow

Once any folder took over a second to load, the spinner text switched to
the slow-load message three seconds later even after the load finished, and
every later load in that window opened with it. The text is also translated
now.

* fix(explorer): keep the current folder visible in a long breadcrumb

A path too long for the bar was cut at its end, hiding the folder being
viewed and its nearest parents. It is now cut at the root end.

* fix(explorer): grey out the whole selected row in an unfocused details view

Only the name lost its highlight when the window lost focus, leaving a grey
pill inside a blue row.

* fix(explorer): align the details header with its rows and mark it clickable

The header ended 10px before the rows and the selection highlight, and its
sortable column titles kept the default cursor.

* fix(explorer): give the layout button a tooltip and a pointer cursor

It was the only navbar control without either, so it did not read as
clickable.

* fix(explorer): remove the stray tooltip from the loading spinner

The spinner's SVG carried an internal title that showed as a tooltip and
was read out by screen readers.

* fix(explorer): refuse uploads, copies and shortcuts into the Shared view

Dropping files on Shared in the sidebar, or Ctrl/Alt-dropping items onto a
window showing it, sent requests against a path that is not a directory and
ended in an error or a stuck progress window.

* fix(explorer): open an item's context menu where it was requested

The menu is built after permission and app lookups, and it opened wherever
the pointer had moved to by the time they finished.

* fix(explorer): close a folder's window when the folder is deleted, however it was opened

Windows opened by path, such as with Ctrl-click on a breadcrumb or sidebar
entry, never received the marker used to find a deleted folder's windows,
so they stayed open on a folder that no longer existed.
2026-11-10 11:23:30 -07:00
33 changed files with 1286 additions and 303 deletions

No files matched your search

+31 -1
View File
@@ -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;
+2 -1
View File
@@ -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 {
+32 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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')}` : ''}`);
};
+29 -2
View File
@@ -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 {
+105 -72
View File
@@ -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);
},
};
+23 -1
View File
@@ -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']);
});
});
+19
View File
@@ -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));
};
+26 -1
View File
@@ -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');
});
});
+1 -1
View File
@@ -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();
+6 -1
View File
@@ -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
+2 -1
View File
@@ -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';
+15 -1
View File
@@ -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']);
});
});
+19
View File
@@ -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);
});
}
};
+61 -17
View File
@@ -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
+95
View File
@@ -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);
+16 -1
View File
@@ -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();
+1
View File
@@ -1067,6 +1067,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',
+20
View File
@@ -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
View File
@@ -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
+10 -7
View File
@@ -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
+3 -2
View File
@@ -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' });
+71 -7
View File
@@ -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.
+2
View File
@@ -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();