Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 71ee7e1c62 fix(dashboard): leave Files select mode when the window widens past 480px
Select mode's Select and Done buttons only show up to 480px, so turning
it on in a narrow window and widening it left every click toggling rows,
with no way out short of narrowing the window again.
2026-10-23 15:11:27 -07:00
Nariman Jelveh a03c4f06db fix(dashboard): drag a select-mode selection from its own rows
Select mode returned from pointerdown before recording whether the
pressed row was already selected, so a drag started from that stale
value: dragging selected rows didn't move them, and a stale "selected"
could let a drag from an unselected row clear the selection. The value is
recorded first now.
2026-10-23 15:11:27 -07:00
Nariman Jelveh 56b80ff4cf fix(gui): run a context menu item once on a double-click
The menu stays clickable during its 200ms fade-out, so the second click
of a double-click ran the item again: double-clicking Share… or
Properties in the dashboard opened two stacked dialogs. A menu that has
started closing now ignores item clicks.
2026-10-23 15:11:27 -07:00
Nariman Jelveh 9843e0ebc8 fix(gui): stop leaking a document click handler per context menu
Every context menu bound its item-click handler on document, delegated
to its own id, and never unbound it. Each menu opened therefore left a
handler that ran on every later click in the page and kept the menu's
items alive, rows and fs entries included. The handler is now bound on
the menu itself and goes with it. It still returns false, so item clicks
keep not reaching document-level click handlers, as before.
2026-10-23 15:11:27 -07:00
Nariman Jelveh cf5d15ea33 fix(dashboard): translate the New menu's Web Link and templates strings
"Web Link", its URL prompt, validation and failure messages, the "User
templates" submenu, and the account prompt before publishing a worker
were hardcoded English in the Files New and item menus.
2026-10-23 15:11:27 -07:00
Nariman Jelveh ccfe1a4fd6 fix(dashboard): show why Delete Permanently failed and keep the item
delete_item fades the item out before the request; when the request
failed it left the item hidden, as if deleted, and passed the alert the
undefined `responseText` of a puter.js error, which threw. The item now
comes back and the alert shows the error's message.
2026-10-23 15:11:27 -07:00
Nariman Jelveh 036966cb88 fix(dashboard): name a trashed folder by its old name in the breadcrumbs
A trashed item's name on disk is its uid, so opening a trashed folder
showed a crumb like "ffaff8cb-dcaf-44f7-…" while its row in the Trash
read "Old project". The crumb now uses the name the row shows.
2026-10-23 15:11:27 -07:00
Nariman Jelveh 328726f8c1 fix(dashboard): only the Trash gets the Trash label in breadcrumbs
Any folder named "Trash" was shown with the translated Trash label in the
breadcrumbs, so in German ~/Documents/Trash read as "Papierkorb". The
label now goes on the user's Trash folder only.
2026-10-23 15:11:27 -07:00
Nariman Jelveh e35fd23430 fix(dashboard): clear "1 item selected" when a double-click opens a file
Opening a file by double-click deselects its row, but the footer kept
saying "1 item selected" with nothing selected.
2026-10-23 15:11:27 -07:00
Nariman Jelveh 4eefb905c8 fix(dashboard): don't drag-move items the user can't move
Delete, Cut and their buttons already leave out shared roots and items in
a read-only share, but dropping them on a folder, a sidebar entry or a
breadcrumb still sent the moves, and each came back as a "Forbidden"
alert. Drag moves now leave those rows out too; copying them still works.
2026-10-23 15:11:27 -07:00
Nariman Jelveh af57086ac6 fix(dashboard): open a breadcrumb's long-press menu as the touch sheet
A breadcrumb's long-press opened the desktop context menu at the event's
pointer position, which a long-press doesn't have, so on tablets the menu
landed wherever an unset position put it. Breadcrumbs now open the same
touch sheet, anchored to the crumb, as the sidebar folders and rows do.
2026-10-23 15:11:27 -07:00
Nariman Jelveh f2cc01202e fix(dashboard): open the Sort and View menus at their buttons from the keyboard
Enter or Space on either button fires a click with no coordinates, so the
menu opened at the top-left corner of the screen, far from the button.
A keyboard press now opens it below the button; a pointer click still
opens it at the pointer.
2026-10-23 15:11:27 -07:00
Nariman Jelveh e2feba41b9 perf(dashboard): lay out only the new row when an item is added to Files
Every item added to the open folder from elsewhere (another tab, an
upload, a move into it) restyled and re-truncated every row in the
listing, so a burst of adds was quadratic in the folder's size: 100 adds
into a 300-item folder took ~800ms of main-thread work. The added row now
takes the listing's column layout on its own (~130ms for the same burst).
2026-10-23 15:11:27 -07:00
Nariman Jelveh ae70800ca8 fix(dashboard): follow a username change in the Files tab
Changing the username, or saving a temporary account under a chosen
name, moves the home folder. The GUI rewrote data-path attributes and the
path globals, but the Files tab kept its current folder, back/forward
history, clipboard and drawn rows on the old path, so sorting, New
Folder, Upload, Up, Back and the breadcrumbs all failed with "This folder
couldn't be opened" until a reload. update_username_in_gui now announces
the change, and the Files tab moves its paths over and redraws.
2026-10-23 15:11:27 -07:00
Nariman Jelveh c292bc04cc fix(dashboard): make hover and press feedback visible in dark mode
Dark mode's hover color, #2a2a2a, was 1.05:1 against the #262626 cards
and #252525 sidebar, so hovering a notification or sidebar item and
pressing a touch-menu or drawer item showed next to nothing. #333 sits
as far off them as light mode's hover sits off white, and stays below
the selected item's #3d3d3d.
2026-10-23 15:11:26 -07:00
Nariman Jelveh c33edecb1a fix(dashboard): name the plan on self-hosted installs instead of "--"
A self-hosted install has no /marketplace/subscriptions/current route, and
Home treated its 404 like any failed read, leaving "Your Plan" at "--" for
good. A 404 now shows the plan every account starts on. The upgrade badge
and link, which have nothing to reach there, go too, unless the hosted
upgrade dialog is loaded.
2026-10-23 15:11:26 -07:00
Nariman Jelveh b3073990ec fix(dashboard): bring muted and tertiary text up to AA contrast
The muted and tertiary greys carry real information (form hints, file
dates and sizes, share "via" lines, the sidebar's team name,
notification text and times) but fell short of 4.5:1: 2.56:1 and 3.54:1
on white, 3.18:1 and 3.13:1 on dark cards. They now clear it on the
cards and the sidebar in both themes (light #5f6b7d / #707070, dark
#8b95a5 / #94949c). Disabled buttons keep the fainter grey through their
own token, so they still read as disabled.
2026-10-23 14:26:42 -07:00
Nariman Jelveh 3038ee0b84 fix(dashboard): keep collapsed-sidebar labels above the dashboard
The hover label is attached to body at z-index 1000, while the dashboard
window's z-index climbs with every click (focusWindow raises it). Over a
long session the window passed 1000 and the labels rendered behind it,
so hovering the collapsed sidebar showed nothing.
2026-10-23 14:25:58 -07:00
Nariman Jelveh 4644b63125 fix(dashboard): give primary buttons readable contrast
The dialogs' primary buttons, the share modal's Share and the photo
crop's Save were filled with the link color under white text: 4.07:1 in
light mode, 3.08:1 in dark, where hover lightened it further to 2.38:1.
They take their own fill now, #4a67f5 (4.62:1) darkening to #3d5bd9
(5.68:1) on hover in both themes; links keep their color.
2026-10-23 14:24:37 -07:00
Nariman Jelveh baecb7ba8f fix(dashboard): keep keyboard focus on the Usage table's Show more/less
The table redraws without the button that was pressed, so focus fell back
to the page and the next Tab started from the top. Focus now moves to the
button that replaces it, as the sort headers already do.
2026-10-23 14:24:18 -07:00
Nariman Jelveh eef34ce4c8 perf(dashboard): load usage and storage once when the dashboard opens
Home and Usage both load the monthly usage and the storage space when the
dashboard opens, whichever tab it opens on, so every open sent
/metering/usage and /df twice. Overlapping loads now share one request.
Nothing is cached: a load after the shared one settles fetches again.
2026-10-23 14:23:20 -07:00
Nariman Jelveh dc9ce4fffd fix(dashboard): refresh the Usage tab when the subscription changes
An upgrade from the Usage tab's own button, or a return from the billing
portal, refreshed Home behind it while the Usage meter and plan button
kept the old allowance until the user left the tab and came back. Usage
now handles puter:subscription:changed while it is showing.
2026-10-23 14:21:47 -07:00
Nariman Jelveh bb74a2e8de fix(dashboard): don't leave the sidebar collapse button shaded after a tap
Its hover style was the sidebar's only one not limited to devices that
hover, so on a wide touch screen (an iPad in landscape) the button kept
its hover shade after being tapped.
2026-10-23 14:21:13 -07:00
Nariman Jelveh c407eb0112 fix(dashboard): lighten the General access dropdown arrows in dark mode
The dark-mode chevron override covered the share modal's recipient
selects but not the two General access ones, which kept the darker
light-theme stroke on the dark card.
2026-10-23 14:20:28 -07:00
Nariman Jelveh 9a1269be7a fix(dashboard): give the API token field 16px text on touch screens
The coarse-pointer rule that sets the token field to 16px, as the other
dialog inputs are, came before the field's own 13px rule of the same
specificity, so the 13px won and iOS zoomed the page when the field was
focused. The touch rule now follows it.
2026-10-23 14:19:52 -07:00
Nariman Jelveh e18860f1e8 fix(dashboard): leave the temporary-account state once the account is saved
Saving a temporary account from the user menu promoted the user in place
without a reload, but Home kept its Save session warning (reopening the
save dialog for a saved account) and Account and Security stayed without
their password and email cards until the page was reloaded. The dashboard
now reacts to account-saved, which fires after the user is updated.
2026-10-23 14:19:37 -07:00
Nariman Jelveh 22cc279075 fix(dashboard): make the toast close button readable in dark mode
Dark mode gave the close button the card color and then inverted the
whole element, which turned the disc light grey and the cross white:
about 1.4:1. Without the background the invert gives the light-mode
button's mirror, a dark disc with a white cross.
2026-10-23 14:18:54 -07:00
Nariman Jelveh 485cd6468b fix(dashboard): stop the mobile drawer animating under reduced motion
The reduced-motion override for the drawer, its scrim and the menu button
came before the drawer rules it was meant to cancel, and the open
drawer's rule outranked it too, so the drawer still slid and the scrim
still faded. The override now follows those rules and covers the open
state.
2026-10-23 14:17:14 -07:00
Nariman Jelveh ba06512ac4 fix(dashboard): draw Files select-mode accents in the selection color
The checked checkbox, the active Select button and the Done button were
filled with --primary-color, the desktop's text color rather than an
accent: charcoal by default, and white (on white text and ticks) for
anyone whose desktop theme uses light text. They use --select-color, the
accent the rest of the selection already uses.
2026-10-23 14:16:11 -07:00
Nariman Jelveh 4c29f7268f fix(dashboard): don't show a failed usage request as zero usage
getMonthlyUsage() resolves with the error body when the request fails, and
Usage and Home read it as a month with nothing used: $0.00 of $0.00, 0%,
and on Usage an empty table replacing the one already on screen. A body
without a usage report is treated as the failure it is, so the last good
numbers stay and a first load shows the "unavailable" note.
2026-10-23 14:15:35 -07:00
Nariman Jelveh d364509b91 fix(dashboard): keep the Files spinner under the mobile drawer
The loading overlay sat at the maximum z-index, so on phones it drew over
the open drawer and its scrim and took their taps while a folder loaded.
It only needs to sit above the rows.
2026-10-23 14:14:54 -07:00
Nariman Jelveh 222d7eb744 fix(dashboard): show items added elsewhere while New › Web Link prompts
The New menu's create wrapper suppressed item.added for as long as it ran,
which for Web Link includes its URL prompt, so files added to the folder
meanwhile never appeared. It now counts only from the actual create. A
create that fails now says so instead of failing silently.
2026-10-23 14:13:06 -07:00
Nariman Jelveh e4dfb51ea3 fix(dashboard): select mode toggles rows with any pointer
In select mode, a stylus or mouse tap on a phone or tablet toggled the row
in pointerdown and again in click, leaving it as it was; in a narrow
desktop window a click replaced the selection and the selection bar never
showed. Select mode now toggles in click for every pointer and shows its
bar whenever it is on.
2026-10-23 14:13:06 -07:00
Nariman Jelveh 5851671cd6 fix(dashboard): translate the shortcut badge's tooltip 2026-10-23 14:13:06 -07:00
Nariman Jelveh 7561bb0011 fix(dashboard): list failed uploads one per line
The failed-upload list was joined with newlines, which the alert collapses
onto a single line.
2026-10-23 14:13:05 -07:00
Nariman Jelveh 62b1223d5f fix(dashboard): show names as typed in conflict and move-failure alerts
These alerts html-encoded the file name before UIAlert encoded the
message again, so "R&D.txt" read "R&D.txt". "already exists." is
translatable now too.
2026-10-23 14:13:05 -07:00
Nariman Jelveh eb9fd60ade fix(dashboard): offer Restore only for items Restore can handle
Selecting files inside a trashed folder offered Restore in the
multi-select menu and the selection bar, which then failed once per item:
only direct children of Trash carry restore metadata. The trashed
multi-select menu also drew two separators in a row.
2026-10-23 14:13:05 -07:00
Nariman Jelveh cfa1dd58c8 fix(dashboard): listing drops keep working after a drop on a folder row
Dropping files from the computer onto a folder row stopped the native
event before the listing's dragster saw it, leaving its enter state stuck:
later drags didn't highlight the listing, and drops on its background
uploaded nothing.
2026-10-23 14:13:04 -07:00
Nariman Jelveh 3bd73a4b3f fix(dashboard): clear a drop target's highlight after the drop
jQuery UI fires no `out` on drop, so the folder row a selection was
dropped on stayed selected (and went to the Trash with the next Delete),
a sidebar folder stayed highlighted as the current one, and a breadcrumb
kept its drop outline.
2026-10-23 14:13:04 -07:00
Nariman Jelveh ac0cffe5d9 fix(dashboard): refresh Files on return after a cancelled rename
Escape, or keeping a name unchanged, hid the rename editor but left its
-active class, and the refresh on returning to the browser tab takes that
class for an open editor: the listing stopped refreshing until the next
navigation.
2026-10-23 14:13:03 -07:00
Nariman Jelveh 5718f0f3dc fix(dashboard): Delete Permanently works in every language
The confirmation compared the clicked button's label with 'Delete', so in
any other locale confirming did nothing. The buttons carry values now.
2026-10-23 14:13:03 -07:00
Nariman Jelveh 4be5a8d2c6 fix(dashboard): don't cut or copy trashed items out of the Trash
The single-item menu still offered Cut on trashed items, and Ctrl+C
copied them; pasted elsewhere, they kept their raw name in the Trash, a
uid. Trashed items leave through Restore, which puts the original name
back.
2026-10-23 14:13:03 -07:00
Nariman Jelveh b052916bf9 fix(dashboard): make Paste Into Folder work after a cut
The row menu's Paste Into Folder handed a cut to the desktop's
move_clipboard_items, which finds items by their on-screen elements and
plain-string paths. The Files tab's cut stores {path, uid} objects, so
nothing moved and the clipboard was emptied. The menu now takes a paste
handler, and Paste, Ctrl+V and Paste Into Folder share one.
2026-10-23 14:13:02 -07:00
Nariman Jelveh f8cdcdb8fc fix(dashboard): no New, Upload or Paste where nothing can be created
New Folder, Upload, the New/Paste/Upload menu items and file drops were
only refused in the Trash itself and the Shared view. Inside a trashed
folder they created items without the metadata Restore needs, and at /
every create failed (an upload there threw before its progress window
existed and showed nothing). One check now covers the Trash, folders in
it, the Shared view and /.
2026-10-23 14:13:02 -07:00
Nariman Jelveh c2f2ed8156 fix(dashboard): upload from a folder's menu into that folder
Upload in a sidebar or breadcrumb folder's menu opened the shared file
picker, whose handler always uploaded into the folder on screen: files
picked for Pictures while viewing Documents landed in Documents. The menu
now hands its folder to the picker.
2026-10-23 14:12:47 -07:00
Nariman Jelveh 639d400b31 fix(dashboard): keep focus inside the Apps modals and return it on close
The uninstall and add-app modals are aria-modal but had no Tab trap, so
Tab walked into the search box and tiles behind the scrim, where typing
filtered the grid under the modal. Closing the add-app modal, and
confirming an uninstall, dropped focus to the page body.

Tab now cycles inside both cards; closing the add-app modal returns focus
to the add tile, and an uninstall leaves focus on the tile that takes the
removed app's slot.
2026-10-23 14:05:27 -07:00
Nariman Jelveh 0c76865197 fix(dashboard): say so when an app tile fails to launch
A tile click whose launch rejected (for example the app lookup failing)
only logged to the console: the icon flourish played, settled, and nothing
opened. It now shows the same alert the add-app modal shows for that
failure.
2026-10-23 14:05:27 -07:00
Nariman Jelveh 3e2ba20384 fix(dashboard): don't double-encode the add-app launch failure alert
UIAlert encodes its message, so passing it the already-encoded i18n string
showed entities on screen in languages with an apostrophe (French read
"s'est mal passé").
2026-10-23 14:05:27 -07:00
Nariman Jelveh 7db4d99fab fix(dashboard): put a re-installed app back in its saved slot
Uninstall keeps the app's name in the saved order so a re-install returns
it to its place, but a deep-link landing appended the tile at the end of
the grid. The install animation played on the last page and the tile then
jumped back to its saved slot on the next refresh. The landing now places
the tile through reconcileAppOrder like every other load.
2026-10-23 14:05:27 -07:00
Nariman Jelveh fde558d14a fix(dashboard): drop the Apps tile a failed deep-link launch drew
Landing on /app/<name> (or choosing an option in the add-app modal) adds
the app's tile before the launch runs, because the launch is what installs
it. When the launch failed — no access, no app token, a thrown error — the
tile stayed for the rest of the session, survived refreshes, and every
click on it failed again.

The launch outcome is now passed to settleDeepLinkLaunch, which takes a
tile it drew back out (and restores a previous uninstall record) when
nothing was launched.
2026-10-23 14:05:26 -07:00
Nariman Jelveh 33b7f60bd6 fix(dashboard): keep Apps folders and order when a settings read fails
The Apps tab read the saved order, folders and uninstalled-apps list with
`.catch(() => null)`, so a failed read looked like a user who had never
saved any. One flaky request emptied the folders and reset the grid to
the default order, and the next drag or folder edit wrote that loss back
to kv for good.

A failed read now keeps what the session already has (the last
uninstalled-apps list included). Until a read of the order or folders has
succeeded, saves stay in memory instead of overwriting the stored value.
2026-10-23 14:05:16 -07:00
Nariman Jelveh d7e88e2121 fix(dashboard): make the touch context menu reachable by assistive tech and keyboard
The touch action sheet had no roles and never took focus: it was appended
at the end of the page, so a screen reader announced nothing on open and
focus stayed on the page behind it. It's now a menu of menuitems (with
separators and submenu popups marked), takes focus on open, gives it back
on close, moves between enabled items with the arrow keys and Home/End,
and shows an inset focus ring for keyboard use. Taps draw no ring.
2026-10-23 14:04:55 -07:00
Nariman Jelveh 1163baee4a fix(dashboard): don't double-encode the "anyone" and withheld-invite share rows
The share dialog encodes row names when it renders them, but these two
labels came from i18n() already HTML-encoded, so a translation containing
an apostrophe or ampersand would show its entity text. They're now taken
unencoded like every other row name.
2026-10-23 14:04:45 -07:00
Nariman Jelveh b73fd456cb fix(dashboard): handle the Properties stat failure instead of leaving it unhandled
puter.fs.stat both calls the error callback and rejects. The properties
dialog rendered the error from the callback but dropped the promise, so
opening it on a deleted item or offline logged an unhandled rejection.
2026-10-23 14:04:42 -07:00
Nariman Jelveh 589a411f53 fix(dashboard): keep focus in Properties after disassociating one of several websites
With more than one website on an item, disassociating one removed the
focused button and returned early before refocusing the dialog, so focus
fell to the page and the next Tab landed in the dashboard behind the modal.
The dialog takes focus back as soon as the entry is removed.
2026-10-23 14:04:39 -07:00
Nariman Jelveh 93359218bb fix(dashboard): don't close Properties when a text selection ends on the backdrop
Drag-selecting the path or UID in the properties dialog and releasing over
the dimmed backdrop closed the dialog: the click lands on the overlay when
press and release are on different elements. The backdrop now closes it
only when the press started there, as the share dialog already does.
2026-10-23 14:04:33 -07:00
Nariman Jelveh 0a291e936b fix(dashboard): keep Escape working when two share or properties dialogs are open
Each share and properties dialog bound its document keydown under one fixed
namespace, so closing either instance unbound every instance's Escape.
Double-clicking Share… or Properties in the context menu opens two; after
the top one closed, Escape no longer closed the one left behind. Each dialog
now binds under its own namespace.
2026-10-23 14:04:26 -07:00
25 changed files with 809 additions and 230 deletions

No files matched your search

@@ -49,11 +49,13 @@ export default class ContextMenuModal {
this.modal = null;
this.menuItems = null;
this.ignoreInteractions = false;
this.previousFocus = null;
// Event handler references for cleanup
this.backdropClickHandler = null;
this.escapeKeyHandler = null;
this.itemClickHandler = null;
this.arrowKeyHandler = null;
}
/**
@@ -79,6 +81,11 @@ export default class ContextMenuModal {
// Create modal dialog
this.modal = document.createElement('div');
this.modal.className = 'context-menu-modal-dialog';
this.modal.setAttribute('role', 'menu');
this.modal.tabIndex = -1;
if ( options.title ) {
this.modal.setAttribute('aria-label', options.title);
}
// Build modal content
let titleHtml = '';
@@ -107,6 +114,11 @@ export default class ContextMenuModal {
// Setup event listeners
this.setupEventListeners();
// Move focus into the menu, so assistive tech announces it and the
// keyboard reaches its items rather than the page behind it.
this.previousFocus = document.activeElement;
this.modal.focus({ preventScroll: true });
// Ignore interactions briefly to prevent accidental selection on touch devices
this.ignoreInteractions = true;
setTimeout(() => {
@@ -183,6 +195,8 @@ export default class ContextMenuModal {
if ( container ) {
container.innerHTML = this.renderMenuItems(this.getVisibleItems());
}
// The row that had focus was just replaced.
this.modal.focus({ preventScroll: true });
this.positionModal(this._targetRect);
}
@@ -197,7 +211,7 @@ export default class ContextMenuModal {
return menuItems.map((item, index) => {
// Handle separators
if ( item === '-' || item.is_divider ) {
return '<div class="context-menu-separator"></div>';
return '<div class="context-menu-separator" role="separator"></div>';
}
// Get label - support both formats
@@ -228,7 +242,7 @@ export default class ContextMenuModal {
: '';
return `
<button class="context-menu-item ${deleteClass} ${disabledClass}" data-index="${index}"${item.disabled ? ' disabled' : ''}>
<button class="context-menu-item ${deleteClass} ${disabledClass}" role="menuitem" data-index="${index}"${hasSubmenu ? ' aria-haspopup="menu"' : ''}${item.disabled ? ' disabled' : ''}>
<div class="context-menu-item-icon">
${iconHtml}
</div>
@@ -308,6 +322,22 @@ export default class ContextMenuModal {
}
};
document.addEventListener('keydown', this.escapeKeyHandler);
// Arrow keys move between the enabled items, as in any menu.
this.arrowKeyHandler = (e) => {
const items = [...this.modal.querySelectorAll('.context-menu-item:not(:disabled)')];
if ( items.length === 0 ) return;
const current = items.indexOf(document.activeElement);
let next;
if ( e.key === 'ArrowDown' ) next = (current + 1) % items.length;
else if ( e.key === 'ArrowUp' ) next = current <= 0 ? items.length - 1 : current - 1;
else if ( e.key === 'Home' ) next = 0;
else if ( e.key === 'End' ) next = items.length - 1;
else return;
e.preventDefault();
items[next].focus();
};
this.modal.addEventListener('keydown', this.arrowKeyHandler);
}
/**
@@ -326,6 +356,17 @@ export default class ContextMenuModal {
if ( this.escapeKeyHandler ) {
document.removeEventListener('keydown', this.escapeKeyHandler);
}
if ( this.arrowKeyHandler && this.modal ) {
this.modal.removeEventListener('keydown', this.arrowKeyHandler);
}
// Hand focus back to where it was, unless something else took it.
const el_active = document.activeElement;
if ( this.previousFocus && document.contains(this.previousFocus)
&& (this.modal?.contains(el_active) || el_active === document.body) ) {
this.previousFocus.focus({ preventScroll: true });
}
this.previousFocus = null;
// Trigger closing animation
this.backdrop.classList.remove('show');
@@ -58,3 +58,55 @@ describe('ContextMenuModal width', () => {
expect(openMenu().style.width).toBe('auto');
});
});
describe('ContextMenuModal accessibility', () => {
const items = [
{ label: 'Open', action: () => {} },
'-',
{ label: 'Locked', disabled: true, action: () => {} },
{ label: 'New', items: [{ label: 'Folder', action: () => {} }] },
];
const press = (el, key) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
it('is a menu of menuitems and separators', () => {
setTouchViewport(390);
const menu = new ContextMenuModal();
menu.show(items, { left: 0, top: 0, width: 10, height: 10 }, { title: 'a.txt' });
const dialog = document.querySelector('.context-menu-modal-dialog');
expect(dialog.getAttribute('role')).toBe('menu');
expect(dialog.getAttribute('aria-label')).toBe('a.txt');
expect([...dialog.querySelectorAll('.context-menu-item')].map(b => b.getAttribute('role')))
.toEqual(['menuitem', 'menuitem', 'menuitem']);
expect(dialog.querySelector('.context-menu-separator').getAttribute('role')).toBe('separator');
expect(dialog.querySelectorAll('[aria-haspopup="menu"]')).toHaveLength(1);
});
it('takes focus on open and gives it back on close', () => {
setTouchViewport(390);
const opener = document.createElement('button');
document.body.appendChild(opener);
opener.focus();
const menu = new ContextMenuModal();
menu.show(items, { left: 0, top: 0, width: 10, height: 10 });
const dialog = document.querySelector('.context-menu-modal-dialog');
expect(document.activeElement).toBe(dialog);
menu.close();
expect(document.activeElement).toBe(opener);
});
it('moves between enabled items with the arrow keys, skipping disabled ones', () => {
setTouchViewport(390);
const menu = new ContextMenuModal();
menu.show(items, { left: 0, top: 0, width: 10, height: 10 });
const dialog = document.querySelector('.context-menu-modal-dialog');
const label = () => document.activeElement.querySelector('.context-menu-item-label')?.textContent;
press(dialog, 'ArrowDown');
expect(label()).toBe('Open');
press(document.activeElement, 'ArrowDown');
expect(label()).toBe('New');
press(document.activeElement, 'ArrowDown');
expect(label()).toBe('Open');
press(document.activeElement, 'ArrowUp');
expect(label()).toBe('New');
});
});
+109 -22
View File
@@ -47,6 +47,13 @@ import {
serializeAppGroups,
} from './appGroups.js';
/**
* What a failed kv read resolves to. It must not pass for null ("never
* saved"): the grid would drop the user's folders and order, and the next
* save would write that loss back.
*/
const KV_READ_FAILED = Symbol('kv read failed');
/** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */
const APP_NAMES_NO_UNINSTALL = new Set([
'dev-center',
@@ -390,6 +397,27 @@ function buildPagerHtml (items, layout, instant) {
return h;
}
// The tab's modals are aria-modal: Tab cycles inside the card rather than
// walking off into the search box and tiles behind the scrim.
function trapModalTab ($overlay) {
$overlay.on('keydown', function (e) {
if ( e.key !== 'Tab' ) return;
const focusables = $overlay.find('button, textarea').toArray()
.filter(el => ! el.disabled && el.offsetParent !== null);
if ( focusables.length === 0 ) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
const idx = focusables.indexOf(document.activeElement);
if ( e.shiftKey && idx <= 0 ) {
e.preventDefault();
last.focus();
} else if ( ! e.shiftKey && (idx === -1 || idx === focusables.length - 1) ) {
e.preventDefault();
first.focus();
}
});
}
function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
const displayName = (appTitle || appName || '').trim();
const $overlay = $(`
@@ -409,6 +437,7 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
// tile when the user backs out (on confirm the tile is leaving).
$el_window.append($overlay);
$overlay.find('.myapps-modal-cancel')[0].focus({ preventScroll: true });
trapModalTab($overlay);
const close = ({ restoreFocus = true } = {}) => {
$overlay.remove();
@@ -475,8 +504,16 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) {
firstRects.set(tileIdentity(el), el.getBoundingClientRect());
}
}
const slot = $el_window.find('.myapps-page .myapps-tile').toArray()
.findIndex(el => el.dataset.appName === appName);
self._apps.splice(idx, 1);
self.renderApps($el_window, { preservePage: true, instant: true });
// The confirm button left with the modal; keep a keyboard user on
// the grid, at the slot the app vacated.
if ( slot !== -1 && (! document.activeElement || document.activeElement === document.body) ) {
const tiles = $el_window.find('.myapps-page .myapps-tile').toArray();
tiles[Math.min(slot, tiles.length - 1)]?.focus({ preventScroll: true });
}
// FLIP the survivors from their old boxes into the new layout.
const moved = [];
for ( const el of $el_window.find('.myapps-tile').toArray() ) {
@@ -645,9 +682,12 @@ function showAddAppModal ({ $el_window }) {
const $overlay = $(h);
$el_window.append($overlay);
trapModalTab($overlay);
const close = () => {
$overlay.remove();
$(document).off('keydown.add-app-modal');
$el_window.find('.myapps-add-tile')[0]?.focus({ preventScroll: true });
};
const currentPane = () => $overlay.find('.myapps-add-pane:not([hidden])').attr('data-pane');
@@ -705,16 +745,19 @@ function showAddAppModal ({ $el_window }) {
// No intro — still launch.
}
const app_obj = await app_info_promise;
let launched = false;
launch_app({
name: appName,
maximized: true,
...(app_obj ? { app_obj } : {}),
window_options: { morph_from_dashboard_tile: true },
}).then(outcome => {
launched = outcome?.launchResult?.launched !== false;
}).catch(err => {
console.error(`Failed to launch ${appName}:`, err);
UIAlert(i18n('something_went_wrong'));
UIAlert(i18n('something_went_wrong', [], false));
}).finally(() => {
TabApps.settleDeepLinkLaunch(appName, tile);
TabApps.settleDeepLinkLaunch(appName, tile, launched);
});
})();
};
@@ -842,6 +885,13 @@ const TabApps = {
_savedOrderNames: null,
_orderSavedAtSeq: 0,
_groupsSavedAtSeq: 0,
// Whether a read of the stored order / folders has ever succeeded. Until
// one has, saves stay in memory: writing would replace the stored value
// with one built without it.
_orderLoaded: false,
_groupsLoaded: false,
// The last uninstalled-apps list a read returned, for loads whose read fails.
_lastRemovedApps: null,
_launchingApps: new Set(),
// A deep-link install mid-arrival ({ name, $el_window }) — its tile is
// parked invisible until the intro's arrival beat (see
@@ -850,6 +900,7 @@ const TabApps = {
// their tiles (the mirror of _removedLocal).
_arriving: null,
_pendingInstalls: null,
_splicedLaunches: null,
html () {
let h = '<div class="dashboard-tab-content myapps-tab">';
@@ -1031,6 +1082,7 @@ const TabApps = {
})
.catch((err) => {
console.error(`Failed to launch ${appName}:`, err);
UIAlert(i18n('something_went_wrong', [], false));
})
.finally(() => {
self._launchingApps.delete(appName);
@@ -2156,6 +2208,7 @@ const TabApps = {
saveGroups () {
const groups = serializeAppGroups(this._groups);
this._groups = groups;
if ( ! this._groupsLoaded ) return;
// Loads already in flight fetched kv before this save; mark the
// boundary so their stale snapshot can't replay over it (see
// _resolveGroups).
@@ -2977,14 +3030,27 @@ const TabApps = {
if ( pending.loadSeq < (this._appliedSeq || 0) ) return;
this._appliedSeq = pending.loadSeq;
const orderedNames = this._resolveOrderNames(pending.loadSeq, pending.orderedNames);
this._savedOrderNames = orderedNames;
this._groups = this._resolveGroups(pending.loadSeq, pending.groups);
this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0;
const orderedNames = this._applyLoadedLayout(pending.loadSeq, pending.orderedNames, pending.groups);
this._apps = reconcileAppOrder(pending.merged, orderedNames);
this.renderApps(pending.$el_window, { preservePage: true, instant: true });
},
// Adopt a load's saved order and folders, returning the order to
// reconcile against. A half whose kv read failed keeps what this session
// already has.
_applyLoadedLayout (loadSeq, fetchedOrderNames, fetchedGroups) {
if ( fetchedOrderNames !== KV_READ_FAILED ) {
this._savedOrderNames = this._resolveOrderNames(loadSeq, fetchedOrderNames);
this._orderLoaded = true;
}
if ( fetchedGroups !== KV_READ_FAILED ) {
this._groups = this._resolveGroups(loadSeq, fetchedGroups);
this._groupsLoaded = true;
}
this._hasCustomOrder = Array.isArray(this._savedOrderNames) && this._savedOrderNames.length > 0;
return this._savedOrderNames;
},
// The folders half of _resolveOrderNames, for the same reason: a fetch
// issued before the user's latest local folder edit carries a pre-edit kv
// snapshot, and replaying it would visibly undo the folder they just made.
@@ -3056,6 +3122,7 @@ const TabApps = {
// reconcileAppOrder ignores them.
const names = mergeSavedOrder(serializeAppOrder(this._apps), this._savedOrderNames);
this._savedOrderNames = names;
if ( ! this._orderLoaded ) return;
// Loads already in flight fetched kv before this save; mark the
// boundary so their stale snapshot can't replay over it (see
// _resolveOrderNames).
@@ -3157,9 +3224,9 @@ const TabApps = {
console.error('Failed to fetch launch apps:', err);
return null;
}),
puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null),
puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => null),
puter.kv.get(APP_GROUPS_KV_KEY).catch(() => null),
puter.kv.get(APPS_ORDER_KV_KEY).catch(() => KV_READ_FAILED),
puter.kv.get(REMOVED_APPS_KV_KEY).catch(() => KV_READ_FAILED),
puter.kv.get(APP_GROUPS_KV_KEY).catch(() => KV_READ_FAILED),
]);
const installedApps = installedResult.apps;
@@ -3175,7 +3242,8 @@ const TabApps = {
// overlaid with this session's not-yet-persisted mutations: an
// uninstall races its own kv write, and a failed uninstall's
// rollback races the removal of that write.
const removedNames = parseRemovedApps(removedAppsRaw);
if ( removedAppsRaw !== KV_READ_FAILED ) this._lastRemovedApps = parseRemovedApps(removedAppsRaw);
const removedNames = new Set(this._lastRemovedApps ?? []);
if ( this._removedLocal ) {
for ( const [name, isRemoved] of this._removedLocal ) {
if ( isRemoved ) removedNames.add(name);
@@ -3254,7 +3322,9 @@ const TabApps = {
// in their default order; stale names are ignored.
let orderedNames = null;
try {
if ( savedOrderRaw ) {
if ( savedOrderRaw === KV_READ_FAILED ) {
orderedNames = KV_READ_FAILED;
} else if ( savedOrderRaw ) {
orderedNames = typeof savedOrderRaw === 'string'
? JSON.parse(savedOrderRaw)
: savedOrderRaw;
@@ -3262,7 +3332,7 @@ const TabApps = {
} catch ( _e ) {
orderedNames = null;
}
const groups = parseAppGroups(groupsRaw);
const groups = groupsRaw === KV_READ_FAILED ? KV_READ_FAILED : parseAppGroups(groupsRaw);
// Skip only if a strictly newer load already applied its result.
if ( loadSeq < (this._appliedSeq || 0) ) return;
@@ -3280,11 +3350,7 @@ const TabApps = {
// A drag may have started AND committed while this load was in
// flight; _resolveOrderNames keeps its saved reorder from being
// replayed over by this load's pre-save kv snapshot.
const effectiveOrder = this._resolveOrderNames(loadSeq, orderedNames);
this._savedOrderNames = effectiveOrder;
this._groups = this._resolveGroups(loadSeq, groups);
this._hasCustomOrder = Array.isArray(effectiveOrder) && effectiveOrder.length > 0;
const effectiveOrder = this._applyLoadedLayout(loadSeq, orderedNames, groups);
// A refresh of a grid already on screen keeps the user's page and
// skips the first load's fade-in.
@@ -3333,9 +3399,10 @@ const TabApps = {
// what installs it (the launch grants the permission installedApps
// reports), so the grid says so NOW rather than on some later refresh —
// without this the user's first Back found no tile to minimize into and
// a grid without the app they were just using. The app joins _apps at
// the tail (where reconcileAppOrder appends new apps) with a tile drawn
// from the landing's own app-info prefetch, so only a confirmed-real
// a grid without the app they were just using. The app joins _apps
// where reconcileAppOrder puts it — its saved slot when uninstall left
// one, else the tail — with a tile drawn from the landing's own
// app-info prefetch, so only a confirmed-real
// app ever materializes, with its real icon. `mark` parks the tile
// invisible (.myapps-tile-installing) for the intro's arrival beat to
// reveal; unmarked (no-intro paths) it simply appears. The
@@ -3367,10 +3434,17 @@ const TabApps = {
};
if ( ! this._pendingInstalls ) this._pendingInstalls = new Map();
this._pendingInstalls.set(appName, app);
// What settleDeepLinkLaunch needs to take the tile back out if the
// launch fails: nothing was installed then.
if ( ! this._splicedLaunches ) this._splicedLaunches = new Map();
this._splicedLaunches.set(appName, {
$el_window,
wasRemoved: this._removedLocal?.get(appName) ?? this._lastRemovedApps?.has(appName) ?? false,
});
// A previously uninstalled app is being re-installed by this
// landing; the removed record must not go on filtering it.
this._setAppRemoved(appName, false);
this._apps.push(app);
this._apps = reconcileAppOrder([...this._apps, app], this._savedOrderNames);
if ( mark ) this._arriving = { name: appName, $el_window };
// instant only when the pager is already on screen: this re-render
// must not blink a revealed grid back through the load fade, nor
@@ -3672,7 +3746,7 @@ const TabApps = {
// mirroring the tile click handler's finally (including its
// settle_dashboard_tile_launch, which un-marks the tile if the window's
// morph never claimed it).
settleDeepLinkLaunch (appName, tile) {
settleDeepLinkLaunch (appName, tile, launched = true) {
this._launchingApps.delete(appName);
settle_dashboard_tile_launch(tile);
// Whatever path the intro took out (interruption, hidden tab, the
@@ -3686,6 +3760,19 @@ const TabApps = {
this._closeGroup(this._deepLinkFolder);
this._deepLinkFolder = null;
}
// Only a launch installs the app (it grants the permission), so a
// failed one leaves nothing for the tile _spliceDeepLinkApp drew to
// stand for.
const spliced = this._splicedLaunches?.get(appName);
this._splicedLaunches?.delete(appName);
if ( spliced && ! launched && this._pendingInstalls?.has(appName) ) {
this._pendingInstalls.delete(appName);
if ( spliced.wasRemoved ) this._setAppRemoved(appName, true);
if ( Array.isArray(this._apps) ) {
this._apps = this._apps.filter(a => a.name !== appName);
this.renderApps(spliced.$el_window, { preservePage: true, instant: true });
}
}
},
};
+248 -119
View File
@@ -189,8 +189,8 @@ const TabFiles = {
// Never write over an open editor — a just-created row has one
// focused already, and mkdir's own item.added lands here right
// after, which would wipe out whatever the user has typed.
// Visibility, not the -active class: the class outlives a
// cancelled edit, `.hide()` does not.
// Visibility, not the -active class, which comes off as soon
// as a rename is sent.
const $editor = $row.find('.item-name-editor');
if ( ! $editor.is(':visible') ) {
$editor.val(displayName);
@@ -265,8 +265,8 @@ const TabFiles = {
// Insert at correct sorted position
_this.insertAtSortedPosition($newRow, file);
// Apply column widths to match existing rows
_this.applyColumnWidths();
// Lay it out like the rows already there
_this.applyColumnWidthsToRow($newRow);
// Highlight animation to indicate newly added item
$newRow.addClass('item-newly-added');
@@ -282,7 +282,10 @@ const TabFiles = {
this._creatingItem = 0;
this.activeMenuFileUid = null;
this.currentPath = null;
this.currentPath = null;
// Set by a folder menu's Upload; null uploads into the folder on screen.
this.uploadTargetPath = null;
// Trash item path → the name it had before it was trashed (renderItem).
this.trashedNames = new Map();
this.folderDwellTimer = null;
this.folderDwellTarget = null;
this.springLoadedActive = false;
@@ -378,6 +381,11 @@ const TabFiles = {
clearTimeout(_this.folderDwellTimer);
_this.folderDwellTimer = null;
_this.folderDwellTarget = null;
// A drop fires no `out`, so undo `over`'s highlight here.
$(folderElement).removeClass('dwell-opening');
if ( folderElement.getAttribute('data-path') !== _this.currentPath ) {
$(folderElement).removeClass('active');
}
// Block if ctrl and trashed
const draggedPath = $(ui.draggable).attr('data-path');
@@ -425,7 +433,7 @@ const TabFiles = {
}
else {
// Move
await window.move_items(itemsToMove, targetPath);
await _this.moveDraggedRows(itemsToMove, targetPath);
}
},
@@ -623,6 +631,33 @@ const TabFiles = {
this.updateOpenFileDots();
});
// Select mode's Select and Done buttons only show up to 480px (see
// dashboard.css); widened past that, it could no longer be left.
window.matchMedia('(max-width: 480px)').addEventListener('change', (e) => {
if ( ! e.matches ) this.exitSelectMode();
});
// A new username (also a temporary account saved under one) moves the
// home folder. Paths held here still name the old one, and the rows,
// crumbs and their handlers were drawn with them.
window.addEventListener('username-changed', ({ detail }) => {
const oldHome = `/${detail.oldUsername}`;
const newHome = `/${detail.newUsername}`;
const rebase = (p) => (typeof p === 'string' && (p === oldHome || p.startsWith(`${oldHome}/`)))
? newHome + p.slice(oldHome.length)
: p;
const history = window.dashboard_nav_history;
for ( let i = 0; i < history.length; i++ ) history[i] = rebase(history[i]);
window.clipboard = (window.clipboard ?? []).map(item => (typeof item === 'string'
? rebase(item)
: { ...item, path: rebase(item.path) }));
this.uploadTargetPath = rebase(this.uploadTargetPath);
if ( this.currentPath ) {
this.currentPath = rebase(this.currentPath);
this.renderDirectory(this.currentPath);
}
});
// Refresh current directory when the user returns to this browser tab
document.addEventListener('visibilitychange', async () => {
if ( document.visibilityState !== 'visible' || !this.currentPath ) return;
@@ -631,7 +666,7 @@ const TabFiles = {
// The refresh rebuilds the list, which would destroy an open
// rename editor along with whatever the user has typed.
if ( $filesTab.find('.item-name-editor-active').length > 0 ) return;
if ( $filesTab.find('.item-name-editor-active:visible').length > 0 ) return;
// So would an open menu: its actions are bound to the old rows.
if ( $('.context-menu, .context-menu-modal-backdrop').length > 0 ) return;
@@ -893,17 +928,23 @@ const TabFiles = {
if ( $selectedRows.length > 0 ) {
e.preventDefault();
e.stopPropagation();
window.clipboard = [];
window.clipboard_op = 'copy';
$selectedRows.each(function () {
if ( $(this).attr('data-path') !== window.trash_path ) {
// Neither the Trash nor what's in it: a copy of a trashed
// item would be named after its uid, its raw name.
const rows = $selectedRows.toArray().filter((row) => {
const rowPath = $(row).attr('data-path') || '';
return rowPath !== window.trash_path && ! rowPath.startsWith(`${window.trash_path}/`);
});
if ( rows.length > 0 ) {
window.clipboard = [];
window.clipboard_op = 'copy';
for ( const row of rows ) {
window.clipboard.push({
path: $(this).attr('data-path'),
uid: $(this).attr('data-uid'),
metadata: $(this).attr('data-metadata'),
path: $(row).attr('data-path'),
uid: $(row).attr('data-uid'),
metadata: $(row).attr('data-metadata'),
});
}
});
}
}
return false;
}
@@ -938,27 +979,10 @@ const TabFiles = {
if ( window.clipboard.length > 0 && _this.currentPath ) {
e.preventDefault();
e.stopPropagation();
// Same rule as the Paste menu item: a move into the Trash
// would skip the metadata Restore needs (Delete trashes),
// and the Shared view is a query, not a folder.
if ( _this.currentPath === window.trash_path
|| _this.currentPath.startsWith(`${window.trash_path}/`)
|| _this.currentPath === window.shared_path ) {
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
return false;
}
// After the paste completes, refresh and highlight the
// newly created rows — same treatment as uploads.
if ( window.clipboard_op === 'copy' ) {
window.copy_clipboard_items(_this.currentPath, null).then(async pastedPaths => {
await _this.renderDirectory(_this.currentPath, { consistency: 'strong' });
_this.selectUploadedRows(pastedPaths ?? []);
});
} else {
_this.moveClipboardItems(_this.currentPath).then(async pastedPaths => {
await _this.renderDirectory(_this.currentPath, { consistency: 'strong' });
_this.selectUploadedRows(pastedPaths ?? []);
});
}
_this.pasteClipboardInto(_this.currentPath);
}
return false;
}
@@ -1365,8 +1389,7 @@ const TabFiles = {
// New folder button
document.querySelector('.new-folder-btn').onclick = () => {
// The Shared view is a query, not a directory.
if ( _this.currentPath === window.shared_path ) return;
if ( ! _this.acceptsNewItems(_this.currentPath) ) return;
_this.createFolderInstant(_this.currentPath);
};
@@ -1378,13 +1401,16 @@ const TabFiles = {
// was cancelled, or was blocked could not be retried.
const files = Array.from(e.target.files || []);
fileInput.value = '';
// A folder's menu picks for that folder, which needn't be the one on screen.
const destination = _this.uploadTargetPath ?? _this.currentPath;
_this.uploadTargetPath = null;
if ( files.length === 0 ) return;
if ( _this.currentPath === window.shared_path ) return;
if ( ! _this.acceptsNewItems(destination) ) return;
let upload_progress_window;
let opid;
puter.fs.upload(files, _this.currentPath, {
puter.fs.upload(files, destination, {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
init: async (operation_id, xhr) => {
@@ -1455,8 +1481,9 @@ const TabFiles = {
? ` (${item.message})`
: '';
return `- ${failedPath}${failedMessage}`;
}).join('\n');
UIAlert(`${i18n('upload_some_failed', [], false)}\n${failedSummary}`);
}).join('<br>');
// UIAlert encodes the message itself, apart from <p>, <strong> and <br>.
UIAlert(`${i18n('upload_some_failed', [], false)}<br>${failedSummary}`);
}
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
// remove from active_uploads
@@ -1475,6 +1502,7 @@ const TabFiles = {
// Upload button
document.querySelector('.upload-btn').onclick = async () => {
if ( ! this.currentPath ) return;
this.uploadTargetPath = null;
fileInput.click();
};
@@ -1616,7 +1644,7 @@ const TabFiles = {
if ( anyTrashed ) {
// Show restore, hide download and copy for trashed items
$actions.find('.restore-btn').show();
$actions.find('.restore-btn').toggle(this.allRestorable(selectedRows));
$actions.find('.download-btn').hide();
$actions.find('.cut-btn').hide();
$actions.find('.copy-btn').hide();
@@ -1667,6 +1695,20 @@ const TabFiles = {
return list.filter((_row, i) => answers[i]);
},
/**
* Moves dragged rows into a folder, leaving out the ones the user can't
* move (see restructurableRows), which the server would refuse with an
* alert apiece.
*
* @param {HTMLElement[]} rows
* @param {string} targetPath
* @returns {Promise<void>}
*/
async moveDraggedRows (rows, targetPath) {
const movable = await this.restructurableRows(rows);
if ( movable.length > 0 ) await window.move_items(movable, targetPath);
},
/**
* Whether every selected row may be shared with someone else. A selection
* mixing your own items with someone else's read-only ones can't be, and
@@ -1861,6 +1903,7 @@ const TabFiles = {
const nameCol = nameWidth ? `${nameWidth}px` : 'auto';
const gridTemplate = `24px ${nameCol} 4px ${sizeWidth}px 4px ${modifiedWidth}px 4px 20px`;
this.columnGridTemplate = gridTemplate;
$filesTab.find('.header .columns').css('grid-template-columns', gridTemplate);
$filesTab.find('.files.files-list-view .row').css('grid-template-columns', gridTemplate);
@@ -1868,6 +1911,25 @@ const TabFiles = {
this.fitItemNames($filesTab.find('.files .row:not(.header) .item-name'));
},
/**
* Lays out one added row like the rest of the listing. Restyling every
* row instead (applyColumnWidths) makes a burst of adds, such as a move
* into the folder, quadratic in the folder's size.
*
* @param {jQuery} $row
* @returns {void}
*/
applyColumnWidthsToRow ($row) {
if ( ! this.columnGridTemplate ) {
this.applyColumnWidths();
return;
}
if ( $row.closest('.files').hasClass('files-list-view') ) {
$row.css('grid-template-columns', this.columnGridTemplate);
}
this.fitItemNames($row.find('.item-name'));
},
/**
* A resize starts from the widths on screen. Saved widths too wide for
* the listing lay out at the defaults (see applyColumnWidths), and
@@ -1953,20 +2015,35 @@ const TabFiles = {
});
},
/**
* Whether items can be created, uploaded or pasted into a folder. Not the
* Trash or a folder inside it (Restore needs metadata only Delete writes),
* not the Shared view (a query, not a directory), and not `/`.
*
* @param {string} folderPath
* @returns {boolean}
*/
acceptsNewItems (folderPath) {
return !! folderPath
&& folderPath !== '/'
&& folderPath !== window.trash_path
&& ! folderPath.startsWith(`${window.trash_path}/`)
&& folderPath !== window.shared_path;
},
/**
* Updates header action buttons based on current folder context.
*
* Shows/hides new folder, upload, and empty trash buttons as appropriate.
* Neither Trash nor the Shared view (a query, not a directory) can have
* anything created or uploaded into it, so those two buttons stay out
* there rather than opening a picker whose files would be dropped.
* They stay out where nothing can be created, rather than opening a
* picker whose files would be dropped.
*
* @param {boolean} isTrashFolder - Whether the current folder is the Trash
* @returns {void}
*/
updateActionButtons (isTrashFolder) {
const $pathActions = this.$el_window.find('.path-actions');
const canCreate = ! isTrashFolder && this.currentPath !== window.shared_path;
const canCreate = this.acceptsNewItems(this.currentPath);
$pathActions.find('.new-folder-btn, .upload-btn').toggle(canCreate);
if ( isTrashFolder ) {
@@ -1983,6 +2060,19 @@ const TabFiles = {
}
},
/**
* Where a header button's menu opens: at the pointer, or below the button
* when it was pressed from the keyboard, whose click has no coordinates.
*
* @param {MouseEvent} e - The button's click
* @returns {{ left?: number, right?: number, top: number }}
*/
toolbarMenuPosition (e) {
if ( e.detail > 0 ) return { left: e.pageX, top: e.pageY };
const rect = e.currentTarget.getBoundingClientRect();
return { right: rect.right, top: rect.bottom + 3 };
},
/**
* Displays the sort options context menu.
*
@@ -2013,7 +2103,7 @@ const TabFiles = {
UIContextMenu({
items: items,
position: { left: e.pageX, top: e.pageY },
position: this.toolbarMenuPosition(e),
});
},
@@ -2503,10 +2593,19 @@ const TabFiles = {
e.stopPropagation();
$(dirnameElement).addClass('context-menu-active');
const items = _this.generateFolderContextMenu(clickedPath);
const menu = UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } });
menu.onClose = () => {
$(dirnameElement).removeClass('context-menu-active');
};
// Touch gets the sheet the sidebar folders use: a long-press
// has no pointer position to open a menu at.
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() ) {
const modal = new ContextMenuModal({
onClose: () => $(dirnameElement).removeClass('context-menu-active'),
});
modal.show(items, dirnameElement.getBoundingClientRect());
} else {
const menu = UIContextMenu({ items: items, position: { left: e.pageX, top: e.pageY } });
menu.onClose = () => {
$(dirnameElement).removeClass('context-menu-active');
};
}
});
// Make breadcrumb items droppable for file/folder moves
@@ -2519,7 +2618,8 @@ const TabFiles = {
clearTimeout(_this.folderDwellTimer);
_this.folderDwellTimer = null;
_this.folderDwellTarget = null;
$(dirnameElement).removeClass('dwell-opening');
// A drop fires no `out`, so undo `over`'s highlight here.
$(dirnameElement).removeClass('dwell-opening drop-target');
const targetPath = $(this).attr('data-path');
const draggedPath = $(ui.draggable).attr('data-path');
@@ -2559,7 +2659,7 @@ const TabFiles = {
await window.create_shortcut(itemName, isDir, targetPath, null, shortcutTo, shortcutToPath);
}
} else {
await window.move_items(itemsToMove, targetPath);
await _this.moveDraggedRows(itemsToMove, targetPath);
}
},
@@ -2770,6 +2870,11 @@ const TabFiles = {
const item_id = window.global_element_id++;
const metadata = parse_item_metadata(file.metadata);
const displayName = metadata.original_name || file.name;
// A trashed item's raw name is its uid; the breadcrumbs of a trashed
// folder opened from here show the name it had instead.
if ( metadata.original_name && path.dirname(file.path) === window.trash_path ) {
this.trashedNames.set(file.path, metadata.original_name);
}
let website_url = window.determine_website_url(file.path);
// Normalize is_shortcut to 0/1. Directory listings return it as a number,
// but the puter.fs.upload() result used to render newly-created items omits
@@ -2838,7 +2943,7 @@ const TabFiles = {
style="background-color: #ffffff; padding: 2px; ${is_shortcut !== 0 ? 'display:block;' : ''}"
src="${html_encode(window.icons['shortcut.svg'])}"
data-item-id="${item_id}"
title="Shortcut"
title="${i18n('item_shortcut')}"
>
<img class="item-badge item-is-worker long-hover"
style="background-color: #ffffff; padding: 2px; ${is_worker ? 'display:block;' : ''}"
@@ -2937,6 +3042,9 @@ const TabFiles = {
// Track whether item was already selected before this mousedown
itemWasSelectedOnMousedown = el_item.classList.contains('selected');
// Select mode toggles rows in onclick, whatever the pointer.
if ( _this.selectModeActive ) return;
if ( e.which === 3 && el_item.classList.contains('selected') &&
el_item.parentElement.querySelectorAll('.row.selected').length > 1 ) {
return;
@@ -2999,14 +3107,11 @@ const TabFiles = {
// it must not collapse an existing multi-selection.
}
// In select mode on mobile, treat taps like Ctrl+click (toggle selection)
const isMobileSelectMode = (window.isMobile.phone || window.isMobile.tablet) && _this.selectModeActive;
// If clicking on .item-name, .item-icon, or .item-badges, select immediately so item drag works.
// On touch devices, these elements have pointer-events:none via CSS so this path
// won't be reached — touches land on .row instead, deferring selection to onclick.
const isDragHandle = e.target.closest('.item-name, .item-icon, .item-badges');
if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode && isDragHandle ) {
if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && isDragHandle ) {
applySelection([el_item]);
itemWasSelectedOnMousedown = true;
return;
@@ -3014,7 +3119,7 @@ const TabFiles = {
// If item is NOT selected and no modifier keys: defer selection to click handler.
// This allows rubberband selection to start when dragging from unselected items.
if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode ) {
if ( e.button === 0 && !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') ) {
window.active_element = el_item;
window.active_item_container = el_item.closest('.files');
return;
@@ -3030,14 +3135,14 @@ const TabFiles = {
return;
}
if ( !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') && !isMobileSelectMode ) {
if ( !e.ctrlKey && !e.metaKey && !e.shiftKey && !el_item.classList.contains('selected') ) {
el_item.parentElement.querySelectorAll('.row.selected').forEach(r => {
r.classList.remove('selected');
});
}
if ( ! e.shiftKey ) {
if ( ((e.ctrlKey || e.metaKey) || isMobileSelectMode) && el_item.classList.contains('selected') ) {
if ( (e.ctrlKey || e.metaKey) && el_item.classList.contains('selected') ) {
el_item.classList.remove('selected');
} else {
el_item.classList.add('selected');
@@ -3081,17 +3186,18 @@ const TabFiles = {
return;
}
// In select mode, a tap or click toggles selection instead of opening
if ( _this.selectModeActive ) {
el_item.classList.toggle('selected');
window.latest_selected_item = el_item;
_this.updateFooterStats();
return;
}
// On touch/mobile: tap opens the item directly, no selection step needed.
// Selection (and context menu) is handled via taphold.
// Use lastPointerType as primary signal (works even if isMobile misdetects).
if ( lastPointerType === 'touch' || window.isMobile.phone || window.isMobile.tablet ) {
// In select mode, tap toggles selection instead of opening
if ( _this.selectModeActive ) {
el_item.classList.toggle('selected');
window.latest_selected_item = el_item;
_this.updateFooterStats();
return;
}
if ( isPending() ) return;
if ( isFolder === "1" ) {
_this.pushNavHistory(file.path);
@@ -3142,6 +3248,7 @@ const TabFiles = {
open_item({ item: el_item });
}
el_item.classList.remove('selected');
_this.updateFooterStats();
};
// --------------------------------------------------------
@@ -3180,7 +3287,7 @@ const TabFiles = {
$(el_item_name).show();
_this.fitItemNames($(el_item_name));
$(el_item_name_editor).val($(el_item).attr('data-name'));
$(el_item_name_editor).hide();
$(el_item_name_editor).hide().removeClass('item-name-editor-active');
return;
}
// deactivate item name editable
@@ -3237,7 +3344,7 @@ const TabFiles = {
e.stopPropagation();
e.preventDefault();
rename_cancelled = true;
$(el_item_name_editor).hide();
$(el_item_name_editor).hide().removeClass('item-name-editor-active');
$(el_item_name_editor).val($(el_item).attr('data-name'));
$(el_item_name).show();
}
@@ -3443,7 +3550,7 @@ const TabFiles = {
}
}
else {
window.move_items(itemsToMove, _this.currentPath);
_this.moveDraggedRows(itemsToMove, _this.currentPath);
}
} else {
// Dropped outside file list → cancel, navigate back
@@ -3476,6 +3583,8 @@ const TabFiles = {
clearTimeout(_this.folderDwellTimer);
_this.folderDwellTimer = null;
_this.folderDwellTarget = null;
// A drop fires no `out`, so undo `over`'s highlight here.
$(el_item).removeClass('selected dwell-opening');
// A row drawn ahead of its mkdir has a predicted path, not
// a real one. Moving into it would either fail or — because
@@ -3517,7 +3626,7 @@ const TabFiles = {
}
}
else {
await window.move_items(itemsToMove, targetPath);
await _this.moveDraggedRows(itemsToMove, targetPath);
}
},
@@ -3608,9 +3717,13 @@ const TabFiles = {
$(el_item).removeClass('native-drop-target');
},
drop: async function (_dragsterEvent, event) {
drop: async function (dragsterEvent, event) {
const e = event.originalEvent;
$(el_item).removeClass('native-drop-target');
// Stop this notification, not the native drop: the window's
// handler would cancel that, and the listing's dragster has
// to see it to reset its enter state (it skips folder rows).
dragsterEvent.stopPropagation();
if ( ! e.dataTransfer?.types?.includes('Files') ) {
return;
@@ -3629,9 +3742,7 @@ const TabFiles = {
TabFiles.uploadFiles(e.dataTransfer.items, targetPath);
}
e.stopPropagation();
e.preventDefault();
return false;
},
});
}
@@ -3687,6 +3798,17 @@ const TabFiles = {
}
},
/**
* Whether every row can be restored: only a direct child of Trash
* carries the metadata Restore needs, not what's inside a trashed folder.
*
* @param {Iterable<HTMLElement>} rows
* @returns {boolean}
*/
allRestorable (rows) {
return Array.from(rows).every(row => path.dirname($(row).attr('data-path') || '') === window.trash_path);
},
/**
* Restores Trash rows to where they were deleted from, then drops the
* restored ones from the listing. Skipped or failed rows stay put.
@@ -3721,7 +3843,7 @@ const TabFiles = {
*/
askNameConflict (name, multiple) {
return UIAlert({
message: `<strong>${window.html_encode(name)}</strong> already exists.`,
message: i18n('item_already_exists', [name], false),
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... multiple ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
@@ -3731,6 +3853,27 @@ const TabFiles = {
});
},
/**
* Pastes the clipboard (copy or cut) into a folder.
*
* @param {string} destPath - The destination folder path
* @returns {Promise<void>}
*/
async pasteClipboardInto (destPath) {
let pastedPaths;
if ( window.clipboard_op === 'copy' ) {
pastedPaths = await window.copy_clipboard_items(destPath, null);
} else if ( window.clipboard_op === 'move' ) {
pastedPaths = await this.moveClipboardItems(destPath);
} else {
return;
}
// Moved-away source rows disappear and pasted rows show up, then get
// highlighted like uploads (a no-op when destPath isn't on screen).
await this.renderDirectory(this.currentPath, { consistency: 'strong' });
this.selectUploadedRows(pastedPaths ?? []);
},
/**
* Moves clipboard items to the specified destination path.
*
@@ -3748,7 +3891,6 @@ const TabFiles = {
return [];
}
const { html_encode } = window;
const multiple_items = window.clipboard.length > 1;
const moved_item_paths = [];
// Set once the user picks "Replace all" on a conflict; later items
@@ -3797,7 +3939,8 @@ const TabFiles = {
} else {
console.error('Failed to move item:', err);
const item_name = String(item.path || source).split('/').pop();
UIAlert(`<p>${i18n('moving_file', [`<strong>${html_encode(item_name)}</strong>`], false)}</p>${html_encode(err.message ?? '')}`);
// UIAlert encodes the message itself, apart from <p>, <strong> and <br>.
UIAlert(`<p>${i18n('moving_file', [`<strong>${item_name}</strong>`], false)}</p>${err.message ?? ''}`);
}
}
} while ( retry );
@@ -3878,11 +4021,9 @@ const TabFiles = {
$footer.find('.files-footer-separator').css('display', 'none');
}
// The floating action bar only appears in mobile select mode;
// desktop multi-selection relies on the context menu instead.
const isMobileSelectMode = (window.isMobile.phone || window.isMobile.tablet) && this.selectModeActive;
if ( isMobileSelectMode && selectedCount >= 1 ) {
// The floating action bar only appears in select mode (narrow
// screens); elsewhere multi-selection relies on the context menu.
if ( this.selectModeActive && selectedCount >= 1 ) {
$selectionActions.addClass('visible');
this.updateSelectionActionsState(selectedRows);
} else {
@@ -3940,7 +4081,7 @@ const TabFiles = {
UIContextMenu({
items: items,
position: { left: e.pageX, top: e.pageY },
position: this.toolbarMenuPosition(e),
});
},
@@ -4310,6 +4451,7 @@ const TabFiles = {
onRestore: async (el) => {
await _this.restoreRows([el]);
},
onPasteInto: (destPath) => _this.pasteClipboardInto(destPath),
onOpen: (el, fsentry) => {
// Custom open handler for Dashboard (avoids window_nav_history issues)
if ( fsentry.is_dir ) {
@@ -4361,7 +4503,7 @@ const TabFiles = {
return path?.startsWith(`${window.trash_path}/`);
});
if ( anyTrashed ) {
if ( anyTrashed && _this.allRestorable(selectedRows) ) {
items.push({
html: i18n('restore'),
onClick: async function () {
@@ -4430,7 +4572,7 @@ const TabFiles = {
// Delete
if ( anyTrashed ) {
items.push('-');
if ( items.length > 0 && items[items.length - 1] !== '-' ) items.push('-');
items.push({
html: i18n('delete_permanently'),
onClick: async function () {
@@ -4482,11 +4624,12 @@ const TabFiles = {
// The Shared view is a query, not a directory — nothing can be
// created, pasted or uploaded "into" it.
const isSharedView = targetPath === window.shared_path;
const canCreate = this.acceptsNewItems(targetPath);
const items = [];
// New submenu (folder, text document, etc.) - not available in Trash
// We create a custom "New" submenu to handle folder creation with refresh and rename activation
if ( ! isTrashFolder && ! isSharedView ) {
if ( canCreate ) {
const newMenuItems = new_context_menu_item(targetPath, null);
// Override the "New Folder" onClick to refresh and activate rename
@@ -4502,15 +4645,20 @@ const TabFiles = {
const wrapWithDashboardRename = (originalOnClick) => {
return async () => {
$('.context-menu').remove();
_this._creatingItem++;
// Temporarily intercept create_file to capture the upload promise
let uploadPromise = null;
const origCreateFile = window.create_file;
window.create_file = (options) => {
// Counted from here, not from the click: Web Link
// waits on a prompt first, and other items added
// to the folder meanwhile must still show up.
_this._creatingItem++;
const content = options.content ? [options.content] : [];
uploadPromise = puter.fs.upload(new File(content, options.name), options.dirname);
return uploadPromise;
// A failure is reported once, below, not again by
// a caller that awaits this.
return uploadPromise.catch(() => undefined);
};
try {
@@ -4547,10 +4695,10 @@ const TabFiles = {
}
}
} catch ( err ) {
// File creation failed silently
UIAlert(err?.message ?? String(err));
} finally {
window.create_file = origCreateFile;
_this._creatingItem--;
if ( uploadPromise ) _this._creatingItem--;
}
};
};
@@ -4577,27 +4725,10 @@ const TabFiles = {
}
// Paste - only if clipboard has items and not in Trash
if ( !isTrashFolder && !isSharedView && window.clipboard && window.clipboard.length > 0 ) {
if ( canCreate && window.clipboard && window.clipboard.length > 0 ) {
items.push({
html: i18n('paste'),
onClick: async function () {
// After the paste completes, refresh and highlight the
// newly created rows — same treatment as uploads. A paste
// into a folder that isn't rendered matches no rows and
// the highlight is a no-op.
let pastedPaths = [];
if ( window.clipboard_op === 'copy' ) {
pastedPaths = await window.copy_clipboard_items(targetPath, null) ?? [];
} else if ( window.clipboard_op === 'move' ) {
pastedPaths = await _this.moveClipboardItems(targetPath) ?? [];
} else {
return;
}
// Refresh so moved-away source rows disappear and any
// items pasted into the current folder show up.
await _this.renderDirectory(_this.currentPath, { consistency: 'strong' });
_this.selectUploadedRows(pastedPaths);
},
onClick: () => _this.pasteClipboardInto(targetPath),
});
}
@@ -4611,18 +4742,19 @@ const TabFiles = {
});
}
// Add separator if we added paste or undo
if ( items.length > 2 || (isTrashFolder && items.length > 0) ) {
// Add separator if we added paste or undo (after New and its separator)
if ( items.length > (canCreate ? 2 : 0) ) {
items.push('-');
}
// Upload Here - not available in Trash
if ( ! isTrashFolder && ! isSharedView ) {
if ( canCreate ) {
items.push({
html: i18n('upload'),
onClick: function () {
const fileInput = document.querySelector('#upload-file-dialog');
if ( fileInput ) {
_this.uploadTargetPath = targetPath;
fileInput.click();
}
},
@@ -4916,8 +5048,8 @@ const TabFiles = {
return;
}
// Don't show drop zone if we're in trash
if ( _this.currentPath === window.trash_path ) {
// No drop zone where nothing can be uploaded
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
return;
}
@@ -4949,10 +5081,7 @@ const TabFiles = {
return;
}
// Block uploads to trash, and to the Shared view — a query,
// not a directory.
if ( _this.currentPath === window.trash_path ||
_this.currentPath === window.shared_path ) {
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
return;
}
@@ -5228,7 +5357,7 @@ const TabFiles = {
? `${path_seperator_html}<span class="dirname" tabindex="0" role="link" data-path="${html_encode('/')}">${html_encode(window.root_dirname)}</span>`
: '';
for ( let k = 1; k < dirs.length; k++ ) {
str += `${path_seperator_html}<span class="dirname" tabindex="0" role="link" data-path="${html_encode(dirpaths[k])}">${dirs[k] === 'Trash' ? i18n('trash') : html_encode(dirs[k])}</span>`;
str += `${path_seperator_html}<span class="dirname" tabindex="0" role="link" data-path="${html_encode(dirpaths[k])}">${dirpaths[k] === window.trash_path ? i18n('trash') : html_encode(this.trashedNames.get(dirpaths[k]) ?? dirs[k])}</span>`;
}
return str;
},
+16 -5
View File
@@ -19,7 +19,8 @@
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
import { addonCreditsRemaining, usageBudget } from './usageBudget.js';
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
import { appIconAttrs } from '../../helpers/appIcon.js';
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
@@ -486,14 +487,19 @@ const TabHome = {
*/
async _renderPlanCard($el_window) {
let subscription = null;
// A self-hosted install has no billing routes; its users have the
// plan every account starts on, with nothing to upgrade to (no
// upgrade dialog either, which only hosted Puter loads).
let noBilling = false;
try {
const resp = await fetch(
`${window.api_origin}/marketplace/subscriptions/current`,
{ headers: { Authorization: `Bearer ${puter.authToken}` } },
);
noBilling = resp.status === 404;
// A refusal is not a free plan: a seat owing a password change 403s.
if (!resp.ok) return;
const data = await resp.json();
if (!resp.ok && !noBilling) return;
const data = noBilling ? null : await resp.json();
subscription = data?.subscription ?? null;
} catch (e) {
console.error('Failed to load plan data:', e);
@@ -594,6 +600,11 @@ const TabHome = {
$el_window.find('.bento-plan-upgrade').hide();
}
if (noBilling && typeof window.UIUpgradeAccount !== 'function') {
$badge.text('').removeClass('free');
$el_window.find('.bento-plan-upgrade').hide();
}
$el_window
.find('.bento-plan-upgrade')
.off('click.billing')
@@ -613,7 +624,7 @@ const TabHome = {
// Load storage data
try {
const res = await puter.fs.space();
const res = await getStorageSpace();
// Guard capacity 0 — 0/0 would render literally as "NaN%".
let usage_percentage = res.capacity
? ((res.used / res.capacity) * 100).toFixed(0)
@@ -644,7 +655,7 @@ const TabHome = {
// Load monthly usage data
try {
const res = await puter.auth.getMonthlyUsage();
const res = monthlyUsageOrThrow(await getMonthlyUsage());
const budget = usageBudget(res.usage, res.allowanceInfo);
const addonLeft = addonCreditsRemaining(res.allowanceInfo);
// The server reports credits (already scaled) or raw amounts
+18 -4
View File
@@ -19,7 +19,8 @@
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
import { isOrgSeat } from './orgSeat.js';
import { addonCreditsRemaining, usageBudget } from './usageBudget.js';
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
// Whether the server reported credits — decides whether the usage surfaces
// render credits or fall back to dollars.
@@ -97,6 +98,15 @@ const TabUsage = {
refreshUsageDetails($el_window);
// An upgrade, a plan change, or a return from the billing portal (see
// TabHome) changes the allowance and the button shown here. Hidden,
// the tab refreshes on its next activation instead.
window.addEventListener('puter:subscription:changed', () => {
if ( ! $($el_window).find('.dashboard-section-usage').hasClass('active') ) return;
setupPlanButton($el_window);
refreshUsageDetails($el_window);
});
// Click handler for sortable table headers
$($el_window).on('click', '.driver-usage-details-content-table th[data-sort]', function () {
const column = $(this).data('sort');
@@ -114,16 +124,20 @@ const TabUsage = {
$($el_window).find(`.driver-usage-details-content-table th[data-sort="${column}"] .usage-sort-btn`).trigger('focus');
});
// Click handler for "Show more" to expand the table
// Click handler for "Show more" to expand the table. The redrawn
// table has no clicked button; focus moves to the one replacing it
// (here and below), not to the top of the page.
$($el_window).on('click', '.usage-table-show-more', function () {
usageTableExpanded = true;
renderUsageTable();
$($el_window).find('.usage-table-show-less').trigger('focus');
});
// Click handler for "Show less" to collapse the table
$($el_window).on('click', '.usage-table-show-less', function () {
usageTableExpanded = false;
renderUsageTable();
$($el_window).find('.usage-table-show-more').trigger('focus');
});
},
// Refresh whenever the tab is (re)activated — otherwise the usage numbers
@@ -293,7 +307,7 @@ function renderUsageTable () {
}
async function update_usage_details ($el_window) {
const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(res => {
const monthlyUsagePromise = getMonthlyUsage().then(monthlyUsageOrThrow).then(res => {
const budget = usageBudget(res.usage, res.allowanceInfo);
const addonLeft = addonCreditsRemaining(res.allowanceInfo);
// The server reports credits (already scaled) or raw amounts (no
@@ -363,7 +377,7 @@ async function update_usage_details ($el_window) {
}
});
const spacePromise = puter.fs.space().then(res => {
const spacePromise = getStorageSpace().then(res => {
// Guard capacity 0 — otherwise 0/0 renders literally as "NaN%".
let usage_percentage = res.capacity ? (res.used / res.capacity * 100).toFixed(0) : '0';
usage_percentage = usage_percentage > 100 ? 100 : usage_percentage;
+12
View File
@@ -228,6 +228,18 @@ async function UIDashboard (options) {
}
}
// Saving a temporary account promotes this session's user in place: the
// save prompt goes, and the tabs that leave out cards for a temporary
// user (password, email) render again.
window.addEventListener('account-saved', () => {
$el_window.find('.bento-save-account-warning').remove();
for ( const tab of tabs ) {
if ( tab.id !== 'account' && tab.id !== 'security' ) continue;
$el_window.find(`.dashboard-section-${tab.id}`).html(tab.html());
tab.init?.($el_window);
}
});
// Dispatch 'dashboard-ready' event for extensions
window.dispatchEvent(new CustomEvent('dashboard-ready', { detail: { window: $el_window } }));
@@ -23,6 +23,10 @@ import { isOverlayOnTop } from './dashboardOverlay.js';
const { html_encode } = window;
// Gives each open dialog its own document keydown namespace, so closing one
// doesn't unbind another's Escape.
let modal_seq = 0;
const closeIcon = `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
/**
@@ -59,6 +63,7 @@ function addRow ($list, labelHtml, valueHtml) {
export default function UIItemPropertiesModal ({ name, path: item_path, uid, $container }) {
const $root = $container && $container.length ? $container : $('body');
const el_previous_focus = document.activeElement;
const keydown_ns = `keydown.item-props-${++modal_seq}`;
const $overlay = $(`
<div class="item-props-overlay">
@@ -104,7 +109,7 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
if ( closed ) return;
closed = true;
$overlay.removeClass('item-props-show');
$(document).off('keydown.item-props');
$(document).off(keydown_ns);
setTimeout(() => $overlay.remove(), 200);
if ( el_previous_focus && document.body.contains(el_previous_focus) ) {
el_previous_focus.focus({ preventScroll: true });
@@ -113,10 +118,16 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
// -- Dismissal wiring --
$overlay.on('click', '.item-props-close', close);
$overlay.on('click', function (e) {
if ( e.target === $overlay[0] ) close();
// Backdrop close goes by where the press started: a text selection dragged
// out of the card registers its click on the overlay.
let backdrop_pressed = false;
$overlay.on('mousedown', function (e) {
backdrop_pressed = e.target === $overlay[0];
});
$(document).on('keydown.item-props', function (e) {
$overlay.on('click', function (e) {
if ( e.target === $overlay[0] && backdrop_pressed ) close();
});
$(document).on(keydown_ns, function (e) {
if ( e.key === 'Escape' && isOverlayOnTop($overlay[0]) ) close();
});
@@ -260,9 +271,10 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
const $entry = $link.closest('.item-props-website-entry');
const $row = $entry.closest('.item-props-row');
$entry.remove();
// The focused button just left the DOM; keep focus in the dialog.
el_modal.focus({ preventScroll: true });
if ( $row.find('.item-props-website-entry').length > 0 ) return;
$row.remove();
el_modal.focus({ preventScroll: true });
// The item serves no website any more: drop its badge from every row.
$('.item').filter(function () {
return $(this).attr('data-uid') === item_uid;
@@ -277,7 +289,9 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
if ( closed ) return;
$list.html(`<div class="item-props-empty">${i18n('error_unknown_cause')}</div>`);
},
});
})
// The error callback renders the failure; the promise rejects with it too.
.catch(() => {});
return { close };
}
+6 -5
View File
@@ -78,8 +78,8 @@ const avatar_html = (name) => {
return `<span class="share-modal-avatar" style="--share-avatar-hue: ${avatarHue(name)}" aria-hidden="true">${html_encode(avatarInitial(name))}</span>`;
};
// Distinguishes one open dialog's item list from another's, since the
// disclosure button points at it by id.
// Distinguishes one open dialog from another: the disclosure button points at
// its item list by id, and each dialog's document keydown has its own namespace.
let modal_seq = 0;
/** "1 item" / "4 items", HTML-safe. */
@@ -135,7 +135,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
const is_multi = total > 1;
// Nothing to share: an empty selection is a caller's mistake, not a dialog.
if ( total === 0 ) return { close: () => {} };
const items_list_id = `share-modal-items-${++modal_seq}`;
const modal_id = ++modal_seq;
const items_list_id = `share-modal-items-${modal_id}`;
// The header names the one item, or the size of the pile with the names
// folded into an expandable list below it.
@@ -242,7 +243,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
if ( closed ) return;
closed = true;
$overlay.removeClass('share-modal-show');
$(document).off('keydown.share-modal');
$(document).off(`keydown.share-modal-${modal_id}`);
// The recipient field listens on the document to know when a click
// landed outside its suggestions; that has to come off with the dialog.
picker.destroy();
@@ -487,7 +488,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
$overlay.on('click', function (e) {
if ( e.target === $overlay[0] && backdrop_pressed ) close();
});
$(document).on('keydown.share-modal', function (e) {
$(document).on(`keydown.share-modal-${modal_id}`, function (e) {
// A gate or upgrade window opened over the modal takes its own Escape.
if ( e.key !== 'Escape' || ! isOverlayOnTop($overlay[0]) ) return;
// An open revoke confirmation swallows the first Escape.
+2 -2
View File
@@ -88,7 +88,7 @@ const identify = (share, bucket) => {
// it can only be changed there.
if ( share.anyone ) {
if ( bucket !== 'inherited' ) return null;
return { key: 'anyone', name: i18n('share_row_anyone'), teamUid: null };
return { key: 'anyone', name: i18n('share_row_anyone', [], false), teamUid: null };
}
const team = share.holderTeam;
if ( team?.uid ) {
@@ -101,7 +101,7 @@ const identify = (share, bucket) => {
if ( name === '' && bucket === 'pending' ) {
return {
key: `invite:${share.uid}`,
name: i18n('share_invited_someone'),
name: i18n('share_invited_someone', [], false),
teamUid: null,
anonymous: true,
};
@@ -20,8 +20,9 @@
import { describe, expect, it } from 'vitest';
import { aggregateOwners, aggregateShares, linkShareState, missingPathsFor } from './shareAggregate.js';
// The helper labels an inherited link share through i18n.
globalThis.i18n = (key) => key;
// The helper labels some rows through i18n. Row names are plain text that the
// dialog encodes, so an HTML-encoded label would show its entities.
globalThis.i18n = (key, _args, encode = true) => (encode ? `encoded:${key}` : key);
const grant = (holder, mode, extra = {}) => ({ holder, mode, ...extra });
+15
View File
@@ -74,3 +74,18 @@ export const addonCreditsRemaining = (allowanceInfo) => {
if ( ! (purchased > 0) ) return null;
return Math.max(0, purchased - nonNegative(addons.consumedPurchaseCredits));
};
/**
* `getMonthlyUsage()` resolves with whatever body the server answered, an
* error included. Throws unless that body is a usage report, so a failed
* request isn't shown as a month with nothing used.
*
* @param {Object | null | undefined} res - What `getMonthlyUsage()` resolved with.
* @returns {Object} `res`, once checked.
*/
export const monthlyUsageOrThrow = (res) => {
if ( res === null || typeof res?.usage !== 'object' || res.usage === null ) {
throw new Error(res?.message || 'Usage report unavailable');
}
return res;
};
+15 -1
View File
@@ -18,7 +18,7 @@
*/
import { describe, expect, it } from 'vitest';
import { addonCreditsRemaining, usageBudget } from './usageBudget.js';
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
const info = (allowance, addons = {}) => ({
monthUsageAllowance: allowance,
@@ -147,3 +147,17 @@ describe('addonCreditsRemaining', () => {
}))).toBe(1_000);
});
});
describe('monthlyUsageOrThrow', () => {
it('passes a usage report through', () => {
const res = { usage: { total: 0 }, allowanceInfo: null };
expect(monthlyUsageOrThrow(res)).toBe(res);
});
it('throws on an error body instead of reading it as zero usage', () => {
expect(() => monthlyUsageOrThrow({ message: 'boom' })).toThrow('boom');
expect(() => monthlyUsageOrThrow({ usage: null })).toThrow();
expect(() => monthlyUsageOrThrow(null)).toThrow();
expect(() => monthlyUsageOrThrow(undefined)).toThrow();
});
});
+25
View File
@@ -0,0 +1,25 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import shareInFlight from '../../helpers/shareInFlight.js';
// Home and Usage both show these and both load them when the dashboard
// opens; a load overlapping the other's shares its request.
export const getMonthlyUsage = shareInFlight(() => puter.auth.getMonthlyUsage());
export const getStorageSpace = shareInFlight(() => puter.fs.space());
+9 -2
View File
@@ -605,6 +605,7 @@ function UIContextMenu (options) {
const fade_remove = (item) => {
const parent_data_id = $(item).closest('.context-menu').attr('data-parent-id');
const $stack = $(`#context-menu-${menu_id}, .context-menu[data-element-id="${parent_data_id}"]`);
$stack.addClass('context-menu-closing');
// Animate visible menus out. fadeOut's callback does not fire on
// already-hidden elements (e.g. a parent sheet hidden when its submenu
// opened), so force-remove the whole stack after the animation.
@@ -635,8 +636,14 @@ function UIContextMenu (options) {
});
}
// An item is clicked
$(document).on('click', `#context-menu-${menu_id} > li:not(.context-menu-item-disabled)`, function (e) {
// An item is clicked. Bound on the menu, not delegated from document, so
// the handler (and the items its closure holds) goes when the menu does.
$(contextMenu).on('click', 'li:not(.context-menu-item-disabled)', function (e) {
// A double-click's second click lands while the menu fades out; the
// item has run already (opening a dialog twice, say).
if ( $(contextMenu).hasClass('context-menu-closing') ) {
return false;
}
// onClick
if ( options.items[$(this).attr('data-action')].onClick && typeof options.items[$(this).attr('data-action')].onClick === 'function' ) {
+2 -2
View File
@@ -156,11 +156,11 @@ async function UIWindowSaveAccount (options) {
'Authorization': `Bearer ${window.auth_token}`,
},
success: async function (data) {
window.dispatchEvent(new CustomEvent('account-saved', { detail: { data: data } }));
// /save_account promotes the current session's user in place
// and does not rotate the token, so keep the one we have.
await window.update_auth_data(data.token ?? window.auth_token, data.user);
// After the update, so listeners read the saved user.
window.dispatchEvent(new CustomEvent('account-saved', { detail: { data: data } }));
//close this window
if ( data.user.requires_email_confirmation && !data.user.email_confirmed ) {
+81 -42
View File
@@ -46,12 +46,15 @@
--dashboard-text-primary: #1e293b;
--dashboard-text-secondary: #666;
--dashboard-text-tertiary: #888;
--dashboard-text-tertiary: #707070;
--dashboard-text-heading: #333;
--dashboard-text-card-title: #1a1a1a;
--dashboard-text-username: #414b62;
--dashboard-text-hint: #64748b;
--dashboard-text-muted: #94a3b8;
/* Muted and tertiary text still clear 4.5:1 on the cards and sidebar;
disabled controls keep the fainter grey. */
--dashboard-text-muted: #5f6b7d;
--dashboard-text-disabled: #94a3b8;
--dashboard-card-background: #ffffff;
--dashboard-content-background: #fcfcfd;
@@ -62,6 +65,9 @@
--dashboard-link: #5271ff;
--dashboard-link-hover: #3d5bd9;
/* Primary button fills, under white text: 4.5:1 or better in both themes. */
--dashboard-button-primary: #4a67f5;
--dashboard-button-primary-hover: #3d5bd9;
--dashboard-warning-icon: #ffbb00;
--dashboard-warning-background: #fef3c7;
@@ -161,18 +167,21 @@ body {
--dashboard-text: #d4d4d4;
--dashboard-border: #3d3d3d;
--dashboard-background: #1e1e1e;
--dashboard-hover: #2a2a2a;
/* As far off the dark cards and sidebar as light mode's hover is
off white; #2a2a2a barely differed from them. */
--dashboard-hover: #333333;
--dashboard-icon: #888;
--dashboard-sidebar-background: #252525;
--dashboard-text-primary: #e2e8f0;
--dashboard-text-secondary: #a1a1aa;
--dashboard-text-tertiary: #71717a;
--dashboard-text-tertiary: #94949c;
--dashboard-text-heading: #f4f4f5;
--dashboard-text-card-title: #fafafa;
--dashboard-text-username: #c4cad6;
--dashboard-text-hint: #94a3b8;
--dashboard-text-muted: #64748b;
--dashboard-text-muted: #8b95a5;
--dashboard-text-disabled: #64748b;
--dashboard-card-background: #262626;
--dashboard-content-background: #1e1e1e;
@@ -183,6 +192,9 @@ body {
--dashboard-link: #6b8cff;
--dashboard-link-hover: #8aa4ff;
/* Hover darkens here too: the lighter link-hover fails under white text. */
--dashboard-button-primary: #4a67f5;
--dashboard-button-primary-hover: #3d5bd9;
--dashboard-warning-icon: #fbbf24;
--dashboard-warning-background: #422006;
@@ -362,9 +374,11 @@ body {
display: none;
}
.dashboard-sidebar-collapse-toggle:hover {
background: var(--dashboard-hover);
color: var(--dashboard-text);
@media (hover: hover) {
.dashboard-sidebar-collapse-toggle:hover {
background: var(--dashboard-hover);
color: var(--dashboard-text);
}
}
/* Collapsed sidebar state. Desktop only: below the drawer breakpoint the
@@ -438,7 +452,9 @@ body {
color: var(--dashboard-text);
white-space: nowrap;
pointer-events: none;
z-index: 1000;
/* On body, so above the dashboard window, whose z-index climbs with
every click (focusWindow); same band as the notification toasts. */
z-index: 999999999;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
opacity: 0;
transition: opacity 0.15s ease;
@@ -2749,9 +2765,9 @@ select.share-modal-link-access {
margin-top: 10px;
padding: 0 16px;
height: 38px;
border: 1px solid var(--dashboard-link);
border: 1px solid var(--dashboard-button-primary);
border-radius: 10px;
background: var(--dashboard-link);
background: var(--dashboard-button-primary);
font-size: 13.5px;
font-weight: 600;
font-family: inherit;
@@ -2761,8 +2777,8 @@ select.share-modal-link-access {
}
@media (hover: hover) {
.share-modal-submit:hover:not(:disabled) {
background: var(--dashboard-link-hover);
border-color: var(--dashboard-link-hover);
background: var(--dashboard-button-primary-hover);
border-color: var(--dashboard-button-primary-hover);
}
}
.share-modal-submit:focus-visible {
@@ -2772,13 +2788,13 @@ select.share-modal-link-access {
.share-modal-submit:disabled {
background: var(--dashboard-input-background);
border-color: var(--dashboard-border);
color: var(--dashboard-text-muted);
color: var(--dashboard-text-disabled);
cursor: default;
}
/* In flight it is disabled too, but should read as working, not empty */
.share-modal-submit.share-modal-btn-busy:disabled {
background: var(--dashboard-link);
border-color: var(--dashboard-link);
background: var(--dashboard-button-primary);
border-color: var(--dashboard-button-primary);
color: #fff;
opacity: 0.75;
}
@@ -3119,6 +3135,8 @@ select.share-modal-row-mode:focus {
}
/* Chevron: slate-400, lighter than the light theme's stroke */
select.share-modal-mode,
select.share-modal-link-access,
select.share-modal-link-mode,
select.share-modal-row-mode {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
}
@@ -3450,8 +3468,8 @@ select.share-modal-row-mode:focus {
color: var(--dashboard-text-primary);
}
.profile-crop-save {
border: 1px solid var(--dashboard-link);
background: var(--dashboard-link);
border: 1px solid var(--dashboard-button-primary);
background: var(--dashboard-button-primary);
color: #fff;
}
@media (hover: hover) {
@@ -3459,8 +3477,8 @@ select.share-modal-row-mode:focus {
background: var(--dashboard-sidebar-background);
}
.profile-crop-save:hover:not(:disabled) {
background: var(--dashboard-link-hover);
border-color: var(--dashboard-link-hover);
background: var(--dashboard-button-primary-hover);
border-color: var(--dashboard-button-primary-hover);
}
}
.profile-crop-btn-quiet:focus-visible,
@@ -3471,7 +3489,7 @@ select.share-modal-row-mode:focus {
.profile-crop-save:disabled {
background: var(--dashboard-input-background);
border-color: var(--dashboard-border);
color: var(--dashboard-text-muted);
color: var(--dashboard-text-disabled);
cursor: default;
}
@@ -3819,12 +3837,6 @@ body.myapps-reordering .myapps-tile {
body.myapps-reordering .myapps-tile.myapps-tile-merge-armed .myapps-tile-icon {
transition: none;
}
.dashboard-sidebar,
.dashboard-sidebar-scrim,
.dashboard-sidebar-toggle {
transition: none;
}
}
/* Dashboard files */
@@ -4677,8 +4689,8 @@ body.myapps-reordering .myapps-tile {
}
.dashboard-section-files .files-tab .files .row.selected .item-checkbox .checkbox-icon {
background: var(--primary-color, #3b82f6);
border-color: var(--primary-color, #3b82f6);
background: var(--select-color);
border-color: var(--select-color);
}
.dashboard-section-files .files-tab .files .row.selected .item-checkbox .checkbox-icon::after {
@@ -5342,6 +5354,16 @@ body.myapps-reordering .myapps-tile {
}
}
/* After the drawer rules above, which it has to override. */
@media (prefers-reduced-motion: reduce) {
.dashboard-sidebar,
.dashboard-sidebar.open,
.dashboard-sidebar-scrim,
.dashboard-sidebar-toggle {
transition: none;
}
}
/* Three columns rather than four once four (4x72 + 3x12 + the card's padding)
no longer fits a phone's width. */
@media (max-width: 400px) {
@@ -5622,7 +5644,7 @@ body.myapps-reordering .myapps-tile {
}
.dashboard-section-files .header .path-action-btn.select-mode-btn.active {
background: var(--primary-color, #3b82f6);
background: var(--select-color);
color: white;
border-radius: 6px;
}
@@ -5654,7 +5676,7 @@ body.myapps-reordering .myapps-tile {
/* Mobile: Show Done button in floating action bar during select mode */
.dashboard-section-files .files-tab.select-mode-active .files-selection-actions .done-btn {
display: flex;
background: var(--primary-color, #3b82f6);
background: var(--select-color);
color: white;
padding: 5px;
margin-left: 8px;
@@ -6735,7 +6757,7 @@ body.myapps-reordering .myapps-tile {
.dashboard-settings-card .button:disabled {
background: var(--dashboard-card-background) !important;
border-color: var(--dashboard-border) !important;
color: var(--dashboard-text-muted) !important;
color: var(--dashboard-text-disabled) !important;
text-shadow: none !important;
}
@@ -6978,6 +7000,17 @@ body.myapps-reordering .myapps-tile {
opacity: 1;
}
/* The dialog takes focus only to hand it to its items. */
.context-menu-modal-dialog:focus {
outline: none;
}
/* Inset, so the dialog's rounded clip doesn't cut the ring off. */
.context-menu-modal-dialog .context-menu-item:focus-visible {
outline: 2px solid var(--select-color);
outline-offset: -2px;
}
/* Reduced motion: these still fade, but don't scale or slide into place. */
@media (prefers-reduced-motion: reduce) {
.context-menu-modal-dialog {
@@ -7139,7 +7172,9 @@ body.myapps-reordering .myapps-tile {
width: 100%;
height: 100%;
/* background-color: var(--dashboard-background); */
z-index: 2147483647;
/* Above the rows only: below the footer (10) and selection bar (15), and
never over the mobile drawer, its scrim or the dashboard's modals. */
z-index: 9;
display: flex;
justify-content: center;
align-items: center;
@@ -8316,8 +8351,9 @@ body.dashboard-mode .notifications-close-all {
}
@media (prefers-color-scheme: dark) {
/* The invert turns the white disc and dark cross into a dark disc
with a white cross. */
body.dashboard-mode .notification-close {
background: var(--dashboard-card-background);
filter: invert(1) drop-shadow(0px 0px 0.5px rgb(51, 51, 51));
}
}
@@ -8577,8 +8613,8 @@ body.dashboard-mode .notifications-close-all {
}
.dashboard-dialog-btn-primary {
border-color: var(--dashboard-link);
background: var(--dashboard-link);
border-color: var(--dashboard-button-primary);
background: var(--dashboard-button-primary);
color: #fff;
}
@@ -8594,8 +8630,8 @@ body.dashboard-mode .notifications-close-all {
}
.dashboard-dialog-btn-primary:hover {
background: var(--dashboard-link-hover);
border-color: var(--dashboard-link-hover);
background: var(--dashboard-button-primary-hover);
border-color: var(--dashboard-button-primary-hover);
}
.dashboard-dialog-btn-danger:hover {
@@ -8824,10 +8860,6 @@ body.dashboard-mode .notifications-close-all {
}
@media (pointer: coarse) {
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
font-size: 16px;
}
.dashboard-form-reveal {
width: 40px;
height: 40px;
@@ -8939,6 +8971,13 @@ body.dashboard-mode .notifications-close-all {
font-size: 13px;
}
/* 16px, like the other dialog inputs on touch, so iOS doesn't zoom on focus. */
@media (pointer: coarse) {
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
font-size: 16px;
}
}
.dashboard-token-copy {
flex-shrink: 0;
}
+7 -5
View File
@@ -1288,7 +1288,7 @@ window.copy_clipboard_items = async function (dest_path, dest_container_element)
// pops up on top of the dialog while it waits.
clearTimeout(progwin_timeout);
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
message: i18n('item_already_exists', [err.entry_name], false),
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... (window.clipboard.length > 1) ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
@@ -1427,7 +1427,7 @@ window.copy_items = function (el_items, dest_path) {
// pops up on top of the dialog while it waits.
clearTimeout(progwin_timeout);
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
message: i18n('item_already_exists', [err.entry_name], false),
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... (el_items.length > 1) ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
@@ -1533,7 +1533,9 @@ window.delete_item = async function (el_item, descendants_only = false) {
$(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', '');
});
} catch ( err ) {
UIAlert(err.responseText);
// Nothing was deleted: bring back what was faded out above.
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).stop(true).fadeIn(150);
UIAlert(err?.message ?? err?.responseText ?? String(err));
}
};
@@ -2025,7 +2027,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// pops up on top of the dialog while it waits.
clearTimeout(progwin_timeout);
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
message: i18n('item_already_exists', [err.entry_name], false),
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... (el_items.length > 1) ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
@@ -3657,7 +3659,7 @@ window.handle_same_name_exists = async ({
return false;
}
const alert_resp = await UIAlert({
message: `<strong>${html_encode(err.entry_name)}</strong> already exists.`,
message: i18n('item_already_exists', [err.entry_name], false),
buttons: [
{
label: i18n('replace'),
+12 -5
View File
@@ -47,6 +47,7 @@ import { is_default_app } from './defaultApp.js';
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
* @returns {Promise<Array>} Array of context menu items
*/
const generate_file_context_menu = async function (options) {
@@ -161,7 +162,7 @@ const generate_file_context_menu = async function (options) {
if ( window.user.is_temp &&
!await UIWindowSaveAccount({
send_confirmation_code: true,
message: 'Please create an account to proceed.',
message: i18n('save_account_to_publish'),
window_options: {
backdrop: true,
close_on_backdrop_click: false,
@@ -279,7 +280,8 @@ const generate_file_context_menu = async function (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 ) {
menu_items.push({
html: i18n('cut'),
onClick: function () {
@@ -310,7 +312,10 @@ const generate_file_context_menu = async function (options) {
html: i18n('paste_into_folder'),
disabled: window.clipboard.length > 0 ? false : true,
onClick: function () {
if ( window.clipboard_op === 'copy' ) {
if ( options.onPasteInto ) {
options.onPasteInto($(el_item).attr('data-path'));
}
else if ( window.clipboard_op === 'copy' ) {
window.copy_clipboard_items($(el_item).attr('data-path'), null);
}
else if ( window.clipboard_op === 'move' ) {
@@ -323,7 +328,7 @@ const generate_file_context_menu = async function (options) {
// -------------------------------------------
// Separator
// -------------------------------------------
if ( $(el_item).attr('data-immutable') === '0' && !is_trash ) {
if ( $(el_item).attr('data-immutable') === '0' && !is_trash && !is_trashed ) {
menu_items.push('-');
}
@@ -409,15 +414,17 @@ const generate_file_context_menu = async function (options) {
buttons: [
{
label: i18n('delete'),
value: 'delete',
type: 'primary',
},
{
label: i18n('cancel'),
value: 'cancel',
},
],
});
if ( (alert_resp) === 'Delete' ) {
if ( alert_resp === 'delete' ) {
await window.delete_item(el_item);
await window.refresh_trash_state();
}
+5 -5
View File
@@ -60,18 +60,18 @@ const new_context_menu_item = function (dirname, append_to_element) {
},
// Web Link
{
html: 'Web Link',
html: i18n('web_link'),
icon: `<img src="${html_encode(window.icons['link.svg'])}" class="ctx-item-icon ctx-item-icon-monochrome">`,
onClick: async function () {
// Prompt user for URL
const url = await UIPrompt({
message: 'Enter the URL for the web link:',
message: i18n('web_link_url_prompt', [], false),
placeholder: 'https://example.com',
defaultValue: 'https://',
validator: (value) => {
// Simple URL validation
return value.startsWith('http://') || value.startsWith('https://') ?
true : 'Please enter a valid URL starting with http:// or https://';
true : i18n('web_link_invalid_url', [], false);
},
});
@@ -130,7 +130,7 @@ const new_context_menu_item = function (dirname, append_to_element) {
});
} catch ( error ) {
console.error('Error creating web link:', error);
UIAlert(`Error creating web link: ${ error.message}`);
UIAlert(i18n('web_link_create_failed', [error.message], false));
}
}
},
@@ -187,7 +187,7 @@ router.get('/*page', ({request, params}) => {
// User templates
baseItems.push({
html: 'User templates',
html: i18n('user_templates'),
icon: `<img src="${html_encode(window.icons['file-template.svg'])}" class="ctx-item-icon">`,
items: window.file_templates.map(template => ({
html: template.html,
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Wraps an async call so overlapping callers share one call: until the
* first settles, every caller gets its promise. The next call after it
* settles starts afresh, so nothing is cached.
*
* @template T
* @param {() => Promise<T>} fn
* @returns {() => Promise<T>}
*/
export default function shareInFlight (fn) {
let pending = null;
return () => {
pending ??= Promise.resolve().then(fn).finally(() => {
pending = null;
});
return pending;
};
}
+53
View File
@@ -0,0 +1,53 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi } from 'vitest';
import shareInFlight from './shareInFlight.js';
describe('shareInFlight', () => {
it('gives overlapping callers the same call', async () => {
const fn = vi.fn(async () => 'answer');
const shared = shareInFlight(fn);
const [a, b] = await Promise.all([shared(), shared()]);
expect(fn).toHaveBeenCalledTimes(1);
expect(a).toBe('answer');
expect(b).toBe('answer');
});
it('calls again once the previous call has settled', async () => {
const fn = vi.fn(async () => 'answer');
const shared = shareInFlight(fn);
await shared();
await shared();
expect(fn).toHaveBeenCalledTimes(2);
});
it('shares a rejection, then lets the next call retry', async () => {
const fn = vi.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce('answer');
const shared = shareInFlight(fn);
const first = shared();
const second = shared();
await expect(first).rejects.toThrow('boom');
await expect(second).rejects.toThrow('boom');
await expect(shared()).resolves.toBe('answer');
expect(fn).toHaveBeenCalledTimes(2);
});
});
@@ -18,6 +18,8 @@
*/
const update_username_in_gui = function (new_username) {
// Unset while signing in: then there is no earlier username.
const old_username = window.user?.username;
// ------------------------------------------------------------
// Flush cache
// ------------------------------------------------------------
@@ -107,6 +109,13 @@ const update_username_in_gui = function (new_username) {
window.desktop_path = `/${ new_username }/Desktop`;
window.public_path = `/${ new_username }/Public`;
window.home_path = `/${ new_username}`;
// For paths held outside the DOM, which the rewrite above can't reach.
if ( old_username ) {
window.dispatchEvent(new CustomEvent('username-changed', {
detail: { oldUsername: old_username, newUsername: new_username },
}));
}
};
export default update_username_in_gui;
+6
View File
@@ -912,6 +912,12 @@ const en = {
'item_shared_with_you': 'A user has shared this item with you.',
'item_shared_by_you': 'You have shared this item with at least one other user.',
'item_shortcut': 'Shortcut',
'item_already_exists': '%strong% already exists.',
'web_link': 'Web Link',
'web_link_url_prompt': 'Enter the URL for the web link:',
'web_link_invalid_url': 'Please enter a valid URL starting with http:// or https://',
'web_link_create_failed': 'Error creating web link: %%',
'user_templates': 'User templates',
'item_associated_websites': 'Associated website',
'item_associated_websites_plural': 'Associated websites',
'no_suitable_apps_found': 'No suitable apps found',
+4 -1
View File
@@ -327,6 +327,7 @@ const postAuthActions = async (action) => {
// No dashboard window — no intro; still launch.
}
const app_obj = await app_info_promise;
let launched = false;
launch_app({
name: app_name,
maximized: true,
@@ -340,10 +341,12 @@ const postAuthActions = async (action) => {
},
} : {}),
window_options: { morph_from_dashboard_tile: true },
}).then((outcome) => {
launched = outcome?.launchResult?.launched !== false;
}).catch((err) => {
console.error(`Failed to launch ${app_name} from URL:`, err);
}).finally(() => {
TabApps.settleDeepLinkLaunch(app_name, tile);
TabApps.settleDeepLinkLaunch(app_name, tile, launched);
});
})();
}