mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b39843d70 | ||
|
|
44a2741ce0 | ||
|
|
d5fdcaa4e4 | ||
|
|
4d357c065e | ||
|
|
286ee5237c | ||
|
|
d178436ecd | ||
|
|
7beffe39f9 | ||
|
|
c463bf954e | ||
|
|
9f6c2d612b | ||
|
|
aa9ef5b6df | ||
|
|
3556b6342f | ||
|
|
1c5ef1f2c1 | ||
|
|
fa25c02ba9 | ||
|
|
0451aece0c | ||
|
|
1ccdf392ad | ||
|
|
8e5222b36a | ||
|
|
5a90c91e4e | ||
|
|
de7bfc976c | ||
|
|
9f3818e5a7 | ||
|
|
9f3369406a | ||
|
|
40447f6c0e | ||
|
|
c87066e416 | ||
|
|
2637e2a310 | ||
|
|
d7ca3e81aa | ||
|
|
8b89b9ce54 | ||
|
|
fcc2a9706e | ||
|
|
e95ef045d9 | ||
|
|
570012fa68 | ||
|
|
de62470062 | ||
|
|
4f1788be31 | ||
|
|
df55a4300e | ||
|
|
b3b6e1bcbc | ||
|
|
38d7fc45d7 | ||
|
|
e72d98a8b9 | ||
|
|
c8b65d10d1 | ||
|
|
3277d59b7b | ||
|
|
7db01715d4 | ||
|
|
8a064e2298 | ||
|
|
d240d362c3 | ||
|
|
e47735505b | ||
|
|
79acc853d2 | ||
|
|
27070e883d | ||
|
|
cd2afa5bdf | ||
|
|
b298e948e2 | ||
|
|
01d2a146b2 | ||
|
|
9137ae93f6 | ||
|
|
0b7648fc1c | ||
|
|
611b7b0acc | ||
|
|
f0b2defe4c | ||
|
|
c667053afe | ||
|
|
36a614d74a | ||
|
|
9631b45d77 | ||
|
|
ce5e2d77a3 | ||
|
|
08ce88d5ec | ||
|
|
2e6f9a5542 |
No files matched your search
@@ -323,14 +323,17 @@ export default class ContextMenuModal {
|
||||
};
|
||||
document.addEventListener('keydown', this.escapeKeyHandler);
|
||||
|
||||
// Arrow keys move between the enabled items, as in any menu.
|
||||
// Arrow keys move between the enabled items, as in any menu. So does
|
||||
// Tab: the sheet is modal, and focus mustn't reach the page behind.
|
||||
this.arrowKeyHandler = (e) => {
|
||||
const items = [...this.modal.querySelectorAll('.context-menu-item:not(:disabled)')];
|
||||
if ( items.length === 0 ) return;
|
||||
const current = items.indexOf(document.activeElement);
|
||||
const forward = e.key === 'ArrowDown' || (e.key === 'Tab' && ! e.shiftKey);
|
||||
const back = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey);
|
||||
let next;
|
||||
if ( e.key === 'ArrowDown' ) next = (current + 1) % items.length;
|
||||
else if ( e.key === 'ArrowUp' ) next = current <= 0 ? items.length - 1 : current - 1;
|
||||
if ( forward ) next = (current + 1) % items.length;
|
||||
else if ( back ) 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;
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('ContextMenuModal accessibility', () => {
|
||||
{ label: 'Locked', disabled: true, action: () => {} },
|
||||
{ label: 'New', items: [{ label: 'Folder', action: () => {} }] },
|
||||
];
|
||||
const press = (el, key) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
const press = (el, key, init = {}) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
|
||||
|
||||
it('is a menu of menuitems and separators', () => {
|
||||
setTouchViewport(390);
|
||||
@@ -109,4 +109,19 @@ describe('ContextMenuModal accessibility', () => {
|
||||
press(document.activeElement, 'ArrowUp');
|
||||
expect(label()).toBe('New');
|
||||
});
|
||||
|
||||
it('keeps Tab and Shift+Tab inside the menu', () => {
|
||||
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');
|
||||
press(document.activeElement, 'Tab');
|
||||
expect(label()).toBe('New');
|
||||
expect(press(document.activeElement, 'Tab')).toBe(false);
|
||||
expect(label()).toBe('Open');
|
||||
press(document.activeElement, 'Tab', { shiftKey: true });
|
||||
expect(label()).toBe('New');
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,9 @@ import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
|
||||
|
||||
const TabAccount = {
|
||||
id: 'account',
|
||||
label: i18n('account'),
|
||||
get label () {
|
||||
return i18n('account');
|
||||
},
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
|
||||
|
||||
html () {
|
||||
|
||||
@@ -821,6 +821,35 @@ function showAddAppModal ({ $el_window }) {
|
||||
/** An app id minted from a uuid (`app-<uuid>`), as website shortcuts are named. */
|
||||
const GENERATED_APP_ID = /^app-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
/**
|
||||
* Scrolls a pager to a page. A smooth scroll passes over the pages between,
|
||||
* which the pager's scroll handler must not read back as the current page
|
||||
* (see settledPage) until it arrives, or within a second if the user takes
|
||||
* over.
|
||||
*/
|
||||
function scrollPagerTo (scroller, page, smooth) {
|
||||
const left = page * scroller.clientWidth;
|
||||
clearTimeout(scroller._targetPageTimer);
|
||||
scroller._targetPage = smooth && Math.abs(scroller.scrollLeft - left) > 1 ? page : null;
|
||||
if ( scroller._targetPage !== null ) {
|
||||
scroller._targetPageTimer = setTimeout(() => {
|
||||
scroller._targetPage = null;
|
||||
}, 1000);
|
||||
}
|
||||
scroller.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
|
||||
}
|
||||
|
||||
/** The page a pager's scroll position shows, or null while scrollPagerTo is still on its way. */
|
||||
function settledPage (scroller, pageCount) {
|
||||
const idx = Math.round(Math.abs(scroller.scrollLeft) / (scroller.clientWidth || 1));
|
||||
const page = Math.max(0, Math.min(pageCount - 1, idx));
|
||||
if ( scroller._targetPage !== null && scroller._targetPage !== undefined ) {
|
||||
if ( page !== scroller._targetPage ) return null;
|
||||
scroller._targetPage = null;
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/** Longest the grid waits on page one's icons before showing anyway. */
|
||||
const ICON_REVEAL_DEADLINE_MS = 1500;
|
||||
|
||||
@@ -862,7 +891,9 @@ function revealWhenLoaded ($container) {
|
||||
|
||||
const TabApps = {
|
||||
id: 'apps',
|
||||
label: i18n('apps'),
|
||||
get label () {
|
||||
return i18n('apps');
|
||||
},
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg>',
|
||||
|
||||
_apps: null,
|
||||
@@ -991,16 +1022,18 @@ const TabApps = {
|
||||
self.updateRunningDots($el_window);
|
||||
});
|
||||
|
||||
// Results show at once: the first load's fade waits on page one's
|
||||
// icons, and a slow one would blank the grid on every keystroke.
|
||||
$el_window.on('input', '.myapps-search', function () {
|
||||
self.updateSearchIcons($el_window);
|
||||
self.renderApps($el_window);
|
||||
self.renderApps($el_window, { instant: true });
|
||||
});
|
||||
|
||||
// Clear search on cross click
|
||||
$el_window.on('click', '.myapps-icon-clear', function () {
|
||||
$el_window.find('.myapps-search').val('').focus();
|
||||
self.updateSearchIcons($el_window);
|
||||
self.renderApps($el_window);
|
||||
self.renderApps($el_window, { instant: true });
|
||||
});
|
||||
|
||||
// Handle app tile clicks. External apps carry a target link (their
|
||||
@@ -1253,11 +1286,14 @@ const TabApps = {
|
||||
// Mouse wheel / two-finger vertical swipe flips one page per gesture.
|
||||
// Horizontal trackpad panning is left to the native scroller, whose
|
||||
// snap points already handle it.
|
||||
// jQuery wheel listeners are passive here (see initgui), and the
|
||||
// scroller can't scroll vertically anyway, so nothing is prevented.
|
||||
// A trackpad pinch arrives as ctrlKey wheel events: that's a zoom.
|
||||
$el_window.on('wheel', '.myapps-pager-scroller', function (e) {
|
||||
const oe = e.originalEvent;
|
||||
if ( oe.ctrlKey ) return;
|
||||
if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return;
|
||||
if ( Math.abs(oe.deltaY) < 4 ) return;
|
||||
e.preventDefault();
|
||||
// Inertial scrolling keeps emitting events after the flip; treat
|
||||
// everything within 150ms of the last event as the same gesture.
|
||||
clearTimeout(self._wheelTimer);
|
||||
@@ -1279,6 +1315,20 @@ const TabApps = {
|
||||
// take over from there. While a folder is open the same arrows walk
|
||||
// ITS tiles: the grid behind is inert, so focus must not be there.
|
||||
$(document).off('keydown.myapps-keyboard').on('keydown.myapps-keyboard', function (e) {
|
||||
// The Menu key or Shift+F10 on a focused tile opens its menu
|
||||
// (Open, Uninstall, a folder's Ungroup) at the tile; browsers
|
||||
// don't all send a contextmenu event for them.
|
||||
if ( e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') ) {
|
||||
const tile = document.activeElement;
|
||||
if ( ! tile?.classList?.contains('myapps-tile') || ! $el_window[0].contains(tile) ) return;
|
||||
e.preventDefault();
|
||||
const r = tile.getBoundingClientRect();
|
||||
$(tile).trigger($.Event('contextmenu', {
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if ( ! ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' '].includes(e.key) ) return;
|
||||
if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return;
|
||||
if ( ! $el_window.is(':visible') ) return;
|
||||
@@ -1307,6 +1357,15 @@ const TabApps = {
|
||||
// button behavior keep working.
|
||||
if ( ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT' || ae.isContentEditable) ) {
|
||||
const emptySearch = $(ae).hasClass('myapps-search') && ae.value === '';
|
||||
// Enter in a search opens the first match, as in any launcher.
|
||||
if ( e.key === 'Enter' && $(ae).hasClass('myapps-search') && ! emptySearch && ! e.isComposing ) {
|
||||
const first = pageTiles.find(el => ! el.classList.contains('myapps-add-tile'));
|
||||
if ( first ) {
|
||||
e.preventDefault();
|
||||
$(first).trigger('click');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ( e.key !== 'ArrowDown' && ! (emptySearch && e.key.startsWith('Arrow')) ) return;
|
||||
} else if ( ! e.key.startsWith('Arrow') ) {
|
||||
return;
|
||||
@@ -1506,10 +1565,8 @@ const TabApps = {
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
ticking = false;
|
||||
const pageW = scroller.clientWidth || 1;
|
||||
const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW);
|
||||
const page = Math.max(0, Math.min(self._pageCount - 1, idx));
|
||||
if ( page !== self._page ) {
|
||||
const page = settledPage(scroller, self._pageCount);
|
||||
if ( page !== null && page !== self._page ) {
|
||||
self._page = page;
|
||||
self.updatePagerUI($el_window);
|
||||
}
|
||||
@@ -1563,10 +1620,7 @@ const TabApps = {
|
||||
const scroller = $el_window.find('.myapps-pager-scroller')[0];
|
||||
if ( ! scroller || this._pageCount === 0 ) return;
|
||||
const page = Math.max(0, Math.min(this._pageCount - 1, index));
|
||||
scroller.scrollTo({
|
||||
left: page * scroller.clientWidth,
|
||||
behavior: (smooth && !this._reduceMotion()) ? 'smooth' : 'auto',
|
||||
});
|
||||
scrollPagerTo(scroller, page, smooth && ! this._reduceMotion());
|
||||
// Programmatic scrolls don't reliably emit 'scroll' events (so the
|
||||
// scroll-driven sync above can miss them); track the page eagerly.
|
||||
this._page = page;
|
||||
@@ -1618,7 +1672,7 @@ const TabApps = {
|
||||
if ( on && String($search.val() || '') !== '' ) {
|
||||
$search.val('');
|
||||
this.updateSearchIcons($el_window);
|
||||
this.renderApps($el_window);
|
||||
this.renderApps($el_window, { instant: true });
|
||||
}
|
||||
$search.prop('disabled', on);
|
||||
|
||||
@@ -1793,11 +1847,12 @@ const TabApps = {
|
||||
|
||||
// Mouse wheel / two-finger vertical swipe flips one folder page per
|
||||
// gesture, mirroring the grid's scroller (which see for the debounce).
|
||||
// As the grid's: passive, and a pinch is a zoom.
|
||||
$overlay.on('wheel', '.myapps-group-panel-grid', function (e) {
|
||||
const oe = e.originalEvent;
|
||||
if ( oe.ctrlKey ) return;
|
||||
if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return;
|
||||
if ( Math.abs(oe.deltaY) < 4 ) return;
|
||||
e.preventDefault();
|
||||
clearTimeout(self._groupWheelTimer);
|
||||
self._groupWheelTimer = setTimeout(() => {
|
||||
self._groupWheelActive = false;
|
||||
@@ -1817,10 +1872,8 @@ const TabApps = {
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
ticking = false;
|
||||
const pageW = scroller.clientWidth || 1;
|
||||
const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW);
|
||||
const page = Math.max(0, Math.min((self._groupPageCount || 1) - 1, idx));
|
||||
if ( page !== self._groupPage ) {
|
||||
const page = settledPage(scroller, self._groupPageCount || 1);
|
||||
if ( page !== null && page !== self._groupPage ) {
|
||||
self._groupPage = page;
|
||||
self._updateGroupPagerUI($el_window);
|
||||
}
|
||||
@@ -1860,7 +1913,7 @@ const TabApps = {
|
||||
// The folder is modal: Tab cycles inside it rather than walking
|
||||
// off into the inert grid behind.
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = $overlay.find('.myapps-group-name, .myapps-tile').toArray()
|
||||
const focusables = $overlay.find('.myapps-group-name, .myapps-tile, .myapps-group-dot').toArray()
|
||||
.filter(el => el.offsetParent !== null);
|
||||
if ( focusables.length === 0 ) return;
|
||||
const first = focusables[0];
|
||||
@@ -2037,10 +2090,7 @@ const TabApps = {
|
||||
const scroller = $el_window.find('.myapps-group-panel-grid')[0];
|
||||
if ( ! scroller || ! this._groupPageCount ) return;
|
||||
const page = Math.max(0, Math.min(this._groupPageCount - 1, index));
|
||||
scroller.scrollTo({
|
||||
left: page * scroller.clientWidth,
|
||||
behavior: (smooth && ! this._reduceMotion()) ? 'smooth' : 'auto',
|
||||
});
|
||||
scrollPagerTo(scroller, page, smooth && ! this._reduceMotion());
|
||||
// Programmatic scrolls don't reliably emit 'scroll' events; track the
|
||||
// page eagerly (same as goToPage).
|
||||
this._groupPage = page;
|
||||
@@ -2164,11 +2214,19 @@ const TabApps = {
|
||||
// Dissolve a folder: its apps stay exactly where the folder was, side by
|
||||
// side, so nothing is lost or moved somewhere the user has to find.
|
||||
_ungroup ($el_window, groupId) {
|
||||
if ( ! findGroupById(this._groups, groupId) ) return;
|
||||
const group = findGroupById(this._groups, groupId);
|
||||
if ( ! group ) return;
|
||||
if ( this._openGroupId === groupId ) this._closeGroup($el_window);
|
||||
this._groups = removeGroup(this._groups, groupId);
|
||||
this.saveGroups();
|
||||
this.renderApps($el_window, { preservePage: true, instant: true });
|
||||
// The folder's tile, focused or not, is gone; a keyboard user lands
|
||||
// on its first app instead of back at the top of the page.
|
||||
if ( ! document.activeElement || document.activeElement === document.body ) {
|
||||
$el_window.find('.myapps-page .myapps-tile').toArray()
|
||||
.find(el => el.dataset.appName === group.apps[0])
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
// Take one app out of a folder. It lands on the grid immediately after
|
||||
@@ -2508,6 +2566,9 @@ const TabApps = {
|
||||
if ( this._drag !== d ) return;
|
||||
d.flipping = false;
|
||||
this._updatePlaceholder(d.lastClientX, d.lastClientY);
|
||||
// Still held at the edge: keep paging, as only a move would
|
||||
// otherwise start the next dwell.
|
||||
this._maybeEdgeFlip(d.lastClientX);
|
||||
}, this._reduceMotion() ? 60 : DRAG_FLIP_SETTLE_MS);
|
||||
}, DRAG_EDGE_DWELL_MS);
|
||||
},
|
||||
@@ -2552,6 +2613,7 @@ const TabApps = {
|
||||
if ( this._drag !== d ) return;
|
||||
d.flipping = false;
|
||||
this._updatePlaceholder(d.lastClientX, d.lastClientY);
|
||||
this._maybeGroupEdgeFlip(d.lastClientX);
|
||||
}, this._reduceMotion() ? 60 : DRAG_FLIP_SETTLE_MS);
|
||||
}, DRAG_EDGE_DWELL_MS);
|
||||
},
|
||||
@@ -3536,6 +3598,16 @@ const TabApps = {
|
||||
// the tile mid-intro is swallowed instead of spawning a second instance
|
||||
// (same guard as the click handler). The caller MUST call
|
||||
// settleDeepLinkLaunch once its launch attempt settles.
|
||||
/**
|
||||
* The grid's apps: the load in flight, or the one already done. A launch
|
||||
* from the grid (the add-app modal's options) must not wait on a fresh
|
||||
* fetch of every app list first.
|
||||
*/
|
||||
_appsLoaded ($el_window) {
|
||||
if ( Array.isArray(this._apps) && ! this._loadPromise ) return Promise.resolve();
|
||||
return this.loadApps($el_window);
|
||||
},
|
||||
|
||||
async beginDeepLinkLaunch (appName, $el_window, appInfoPromise = null) {
|
||||
this._launchingApps.add(appName);
|
||||
// No animations, no intro: the morph and the flourish would both
|
||||
@@ -3545,7 +3617,7 @@ const TabApps = {
|
||||
// hold the launch, and a failure just means the tile waits for the
|
||||
// next refresh, as it always did).
|
||||
if ( ! window.animate_window_opening || this._reduceMotion() ) {
|
||||
Promise.resolve(this.loadApps($el_window))
|
||||
Promise.resolve(this._appsLoaded($el_window))
|
||||
.then(() => this._spliceDeepLinkApp($el_window, appName, appInfoPromise))
|
||||
.catch(() => {});
|
||||
return null;
|
||||
@@ -3588,7 +3660,7 @@ const TabApps = {
|
||||
// lets input cut this wait short.)
|
||||
try {
|
||||
await Promise.race([
|
||||
this.loadApps($el_window),
|
||||
this._appsLoaded($el_window),
|
||||
sleep(Math.max(0, deadline - Date.now())),
|
||||
]);
|
||||
} catch ( _e ) { /* a failed load leaves _apps unset; handled below */ }
|
||||
|
||||
@@ -98,7 +98,9 @@ export function failedUploadsMessage (err) {
|
||||
*/
|
||||
const TabFiles = {
|
||||
id: 'files',
|
||||
label: i18n('files'),
|
||||
get label () {
|
||||
return i18n('files');
|
||||
},
|
||||
icon: icons.files,
|
||||
|
||||
/**
|
||||
@@ -134,9 +136,9 @@ const TabFiles = {
|
||||
</div>
|
||||
<div class="path-breadcrumbs"></div>
|
||||
<div class="path-actions">
|
||||
<button class="path-action-btn select-mode-btn" title="${i18n('select')}">${icons.select}</button>
|
||||
<button class="path-action-btn sort-btn" title="${i18n('sort_by')}">${icons.sort}</button>
|
||||
<button class="path-action-btn view-toggle-btn" title="${i18n('toggle_view')}">${icons.grid}</button>
|
||||
<button class="path-action-btn select-mode-btn" title="${i18n('select')}" aria-pressed="false">${icons.select}</button>
|
||||
<button class="path-action-btn sort-btn" title="${i18n('sort_by')}" aria-haspopup="menu">${icons.sort}</button>
|
||||
<button class="path-action-btn view-toggle-btn" title="${i18n('toggle_view')}" aria-haspopup="menu">${icons.grid}</button>
|
||||
<button class="path-action-btn new-folder-btn" title="${i18n('new_folder')}">${icons.newFolder}</button>
|
||||
<button class="path-action-btn upload-btn" title="${i18n('upload')}">${icons.upload}</button>
|
||||
</div>
|
||||
@@ -289,7 +291,7 @@ const TabFiles = {
|
||||
// If the directory was empty, drop the "No files in this directory."
|
||||
// placeholder before inserting the first real row — otherwise it
|
||||
// stays and overlaps the new item.
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row)').remove();
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
|
||||
|
||||
await _this.renderItem(file);
|
||||
|
||||
@@ -311,6 +313,7 @@ const TabFiles = {
|
||||
};
|
||||
|
||||
this.renderingDirectory = false;
|
||||
this.refreshInFlight = false;
|
||||
// Count, not flag: creates are started from a button that answers
|
||||
// instantly now, so two can easily overlap and the first one finishing
|
||||
// must not uncover the second.
|
||||
@@ -682,39 +685,53 @@ const TabFiles = {
|
||||
// So would an open menu: its actions are bound to the old rows.
|
||||
if ( $('.context-menu, .context-menu-modal-backdrop').length > 0 ) return;
|
||||
|
||||
// The rebuild also wipes selection, the keyboard/shift anchor,
|
||||
// and scroll position — state the user expects to survive a quick
|
||||
// trip to another browser tab. Carry it across by uid.
|
||||
const $files = $filesTab.find('.files');
|
||||
const scrollTop = $files.scrollTop();
|
||||
const selectedUids = new Set($filesTab.find('.row.selected').map(function () {
|
||||
return $(this).attr('data-uid');
|
||||
}).get());
|
||||
const anchorUid = window.latest_selected_item
|
||||
? $(window.latest_selected_item).attr('data-uid')
|
||||
: null;
|
||||
|
||||
await this.renderDirectory(this.currentPath, { skipNavHistory: true, skipUrlUpdate: true });
|
||||
|
||||
if ( selectedUids.size > 0 ) {
|
||||
$filesTab.find('.row').each(function () {
|
||||
if ( selectedUids.has($(this).attr('data-uid')) ) {
|
||||
this.classList.add('selected');
|
||||
}
|
||||
});
|
||||
this.updateFooterStats();
|
||||
}
|
||||
if ( anchorUid ) {
|
||||
const anchorRow = $filesTab.find(`.row[data-uid="${anchorUid}"]`)[0];
|
||||
if ( anchorRow ) {
|
||||
window.latest_selected_item = anchorRow;
|
||||
window.active_element = anchorRow;
|
||||
}
|
||||
}
|
||||
$files.scrollTop(scrollTop);
|
||||
await this.refreshKeepingSelection({ skipNavHistory: true, skipUrlUpdate: true });
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Lists the folder on screen again. The rebuild wipes the selection, the
|
||||
* keyboard/shift anchor and the scroll position, which a re-sort or a
|
||||
* quick trip to another browser tab must not lose, so they're carried
|
||||
* across by uid.
|
||||
*
|
||||
* @param {Object} [options] - Passed on to renderDirectory
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async refreshKeepingSelection (options = {}) {
|
||||
const $filesTab = this.$el_window.find('.files-tab');
|
||||
const $files = $filesTab.find('.files');
|
||||
const scrollTop = $files.scrollTop();
|
||||
const selectedUids = new Set($filesTab.find('.row.selected').map(function () {
|
||||
return $(this).attr('data-uid');
|
||||
}).get());
|
||||
const anchorUid = window.latest_selected_item
|
||||
? $(window.latest_selected_item).attr('data-uid')
|
||||
: null;
|
||||
|
||||
const folder = this.currentPath;
|
||||
await this.renderDirectory(folder, options);
|
||||
// The user opened another folder meanwhile.
|
||||
if ( this.currentPath !== folder ) return;
|
||||
|
||||
if ( selectedUids.size > 0 ) {
|
||||
$filesTab.find('.row').each(function () {
|
||||
if ( selectedUids.has($(this).attr('data-uid')) ) {
|
||||
this.classList.add('selected');
|
||||
}
|
||||
});
|
||||
this.updateFooterStats();
|
||||
}
|
||||
if ( anchorUid ) {
|
||||
const anchorRow = $filesTab.find(`.row[data-uid="${anchorUid}"]`)[0];
|
||||
if ( anchorRow ) {
|
||||
window.latest_selected_item = anchorRow;
|
||||
window.active_element = anchorRow;
|
||||
}
|
||||
}
|
||||
$files.scrollTop(scrollTop);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when the Files tab becomes active.
|
||||
* Updates the URL hash to reflect the current file path.
|
||||
@@ -817,6 +834,20 @@ const TabFiles = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Menu key / Shift+F10 - the selection's menu, anchored to the
|
||||
// row's ⋯ button: the keyboard's only way to Share, Download or
|
||||
// open Properties.
|
||||
if ( e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') ) {
|
||||
const row = $selectedRows.toArray().find(r => r === window.latest_selected_item) ?? $selectedRows[0];
|
||||
if ( row ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
row.querySelector('.item-more')?.click();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter - Open selected items
|
||||
if ( e.which === 13 && !$(focused_el).hasClass('item-name-editor') ) {
|
||||
if ( $selectedRows.length > 0 ) {
|
||||
@@ -859,6 +890,13 @@ const TabFiles = {
|
||||
// Force jQuery UI to end the drag
|
||||
$(document).trigger('mouseup');
|
||||
|
||||
// Ended this way, the drag never leaves the target it was
|
||||
// over, so nothing undoes that target's highlight.
|
||||
$('.files-tab .row.ui-droppable-hover:not(.dragging-source)').removeClass('selected');
|
||||
$('.files-tab .ui-droppable-hover').removeClass('ui-droppable-hover');
|
||||
$('.path-breadcrumbs .drop-target').removeClass('drop-target');
|
||||
_this.updateSidebarSelection();
|
||||
|
||||
// Cleanup
|
||||
$('.drag-cancel-zone').remove();
|
||||
$('.item-selected-clone').remove();
|
||||
@@ -991,7 +1029,7 @@ const TabFiles = {
|
||||
if ( window.clipboard.length > 0 && _this.currentPath ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
|
||||
if ( ! _this.acceptsNewItemsHere() ) {
|
||||
return false;
|
||||
}
|
||||
_this.pasteClipboardInto(_this.currentPath);
|
||||
@@ -1058,9 +1096,32 @@ const TabFiles = {
|
||||
if ( ! e.shiftKey ) {
|
||||
// Normal navigation - clear selection
|
||||
$allRows.removeClass('selected');
|
||||
$next.addClass('selected');
|
||||
} else {
|
||||
// A run of Shift+Arrows selects from where it started
|
||||
// to the cursor, on top of what was selected before
|
||||
// it, so stepping back shrinks the range again.
|
||||
// Any other change to the selection starts a new run.
|
||||
const run = _this.shiftArrowRun;
|
||||
const continuing = run?.cursor === $current.get(0)
|
||||
&& $allRows.index(run.anchor) !== -1
|
||||
&& run.after.length === $selectedRows.length
|
||||
&& run.after.every(row => row.classList.contains('selected'));
|
||||
const { anchor, before } = continuing
|
||||
? run
|
||||
: { anchor: $current.get(0), before: $selectedRows.toArray() };
|
||||
const anchorIndex = $allRows.index(anchor);
|
||||
$allRows.removeClass('selected');
|
||||
$(before).addClass('selected');
|
||||
$allRows.slice(Math.min(anchorIndex, nextIndex), Math.max(anchorIndex, nextIndex) + 1)
|
||||
.addClass('selected');
|
||||
_this.shiftArrowRun = {
|
||||
anchor,
|
||||
before,
|
||||
cursor: $next.get(0),
|
||||
after: $allRows.filter('.selected').toArray(),
|
||||
};
|
||||
}
|
||||
|
||||
$next.addClass('selected');
|
||||
window.active_element = $next.get(0);
|
||||
window.latest_selected_item = $next.get(0);
|
||||
$next.get(0).scrollIntoView({ block: 'nearest' });
|
||||
@@ -1295,7 +1356,7 @@ const TabFiles = {
|
||||
const history_item = window.dashboard_nav_history[index];
|
||||
|
||||
items.push({
|
||||
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
|
||||
html: `<span>${html_encode(_this.historyLabel(history_item))}</span>`,
|
||||
val: index,
|
||||
onClick: function (e) {
|
||||
_this.goToHistoryPosition(e.value);
|
||||
@@ -1332,7 +1393,7 @@ const TabFiles = {
|
||||
const history_item = window.dashboard_nav_history[index];
|
||||
|
||||
items.push({
|
||||
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
|
||||
html: `<span>${html_encode(_this.historyLabel(history_item))}</span>`,
|
||||
val: index,
|
||||
onClick: function (e) {
|
||||
_this.goToHistoryPosition(e.value);
|
||||
@@ -1487,7 +1548,7 @@ const TabFiles = {
|
||||
error: async function (err) {
|
||||
const failedMessage = failedUploadsMessage(err);
|
||||
if ( failedMessage ) UIAlert(failedMessage);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
|
||||
// remove from active_uploads
|
||||
delete window.active_uploads[opid];
|
||||
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
|
||||
@@ -2030,15 +2091,20 @@ const TabFiles = {
|
||||
names.forEach((el, i) => {
|
||||
const fullName = el.closest('.row')?.getAttribute('data-name');
|
||||
if ( ! fullName ) return;
|
||||
let text;
|
||||
if ( ! isList ) {
|
||||
el.textContent = truncateFilenameToWidth(fullName, widths[i], font);
|
||||
text = truncateFilenameToWidth(fullName, widths[i], font);
|
||||
} else if ( nameWidth ) {
|
||||
// 8px padding on each side
|
||||
el.textContent = truncateFilenameToWidth(fullName, nameWidth - 16, font);
|
||||
text = truncateFilenameToWidth(fullName, nameWidth - 16, font);
|
||||
} else {
|
||||
// Auto-width column: the full name, clipped by CSS
|
||||
el.textContent = fullName;
|
||||
text = fullName;
|
||||
}
|
||||
if ( el.textContent !== text ) el.textContent = text;
|
||||
// A shortened name shows in full on hover.
|
||||
const title = text === fullName ? '' : fullName;
|
||||
if ( el.title !== title ) el.title = title;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2079,6 +2145,16 @@ const TabFiles = {
|
||||
&& folderPath !== window.shared_path;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether the folder on screen takes new items. Not while another folder
|
||||
* loads: until its listing arrives, currentPath is the one being left.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
acceptsNewItemsHere () {
|
||||
return ! this.loadingFolder && this.acceptsNewItems(this.currentPath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Updates header action buttons based on current folder context.
|
||||
*
|
||||
@@ -2387,7 +2463,7 @@ const TabFiles = {
|
||||
|
||||
// Drop the "No files in this directory." placeholder before inserting
|
||||
// the first row, otherwise it stays and overlaps the new item.
|
||||
this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row)').remove();
|
||||
this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
|
||||
|
||||
await this.renderItem(placeholder);
|
||||
const $row = this.$el_window.find(`.files-tab .files .item[data-uid='${placeholder.uid}']`);
|
||||
@@ -2485,7 +2561,7 @@ const TabFiles = {
|
||||
}
|
||||
|
||||
this.updateSortIndicators();
|
||||
this.renderDirectory(this.currentPath);
|
||||
this.refreshKeepingSelection();
|
||||
|
||||
// Persisting is best-effort and must not hold up the re-sort: awaited,
|
||||
// the two writes added two round-trips before anything moved.
|
||||
@@ -2524,8 +2600,11 @@ const TabFiles = {
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async renderDirectory (target, options = {}) {
|
||||
if ( this.renderingDirectory ) return;
|
||||
if ( this.renderBlocked(target) ) return;
|
||||
this.renderingDirectory = true;
|
||||
// A newer render supersedes this one; past each await, a superseded
|
||||
// render stops without touching the listing or the flags.
|
||||
const seq = this.renderSeq = (this.renderSeq ?? 0) + 1;
|
||||
const _this = this;
|
||||
// The preview shows a row of the listing being replaced.
|
||||
if ( this.previewOpen && target !== this.currentPath ) this.closeImagePreview();
|
||||
@@ -2537,6 +2616,7 @@ const TabFiles = {
|
||||
// read as flicker. Navigating to a different directory still clears
|
||||
// immediately so the user gets instant feedback.
|
||||
const isSameDirRefresh = typeof target === 'string' && target === this.currentPath;
|
||||
this.refreshInFlight = isSameDirRefresh;
|
||||
const $files = this.$el_window.find('.files-tab .files');
|
||||
const prevScrollTop = isSameDirRefresh ? $files.scrollTop() : 0;
|
||||
|
||||
@@ -2558,6 +2638,7 @@ const TabFiles = {
|
||||
if ( ! isSameDirRefresh ) {
|
||||
clearListing();
|
||||
this.showSpinner();
|
||||
this.loadingFolder = true;
|
||||
}
|
||||
|
||||
// Determine whether target is a path or uid
|
||||
@@ -2583,8 +2664,10 @@ const TabFiles = {
|
||||
readFailed = true;
|
||||
directoryContents = [];
|
||||
}
|
||||
if ( seq !== this.renderSeq ) return;
|
||||
if ( ! directoryContents ) {
|
||||
this.hideSpinner();
|
||||
this.loadingFolder = false;
|
||||
this.renderingDirectory = false;
|
||||
return;
|
||||
}
|
||||
@@ -2605,6 +2688,7 @@ const TabFiles = {
|
||||
});
|
||||
this.currentPath = path || target;
|
||||
}
|
||||
this.loadingFolder = false;
|
||||
|
||||
// Update browser URL to reflect current file path (only when Files tab is active)
|
||||
if ( !options.skipUrlUpdate && window.is_dashboard_mode && this.isDashboardFilesActive() ) {
|
||||
@@ -2627,7 +2711,17 @@ const TabFiles = {
|
||||
const isTrashFolder = this.currentPath === window.trash_path;
|
||||
this.updateActionButtons(isTrashFolder);
|
||||
|
||||
// Rebuilding the crumbs drops a focused one; keyboard focus stays on
|
||||
// the same folder's crumb, else on the folder now open.
|
||||
const focusedCrumb = document.activeElement?.matches('.path-breadcrumbs .dirname:focus-visible')
|
||||
? document.activeElement.getAttribute('data-path')
|
||||
: undefined;
|
||||
$('.path-breadcrumbs').html(this.renderPath(this.currentPath, window.user.username));
|
||||
if ( focusedCrumb !== undefined ) {
|
||||
const $crumbs = $('.path-breadcrumbs .dirname');
|
||||
const $same = $crumbs.filter((_, el) => el.getAttribute('data-path') === focusedCrumb);
|
||||
($same[0] ?? $crumbs.last()[0])?.focus({ preventScroll: true });
|
||||
}
|
||||
this.revealCurrentCrumb();
|
||||
$('.path-breadcrumbs .dirname').each(function () {
|
||||
const dirnameElement = this;
|
||||
@@ -2792,6 +2886,7 @@ const TabFiles = {
|
||||
// resolve every icon first and append in sorted order. allSettled so
|
||||
// one failure can't reject the batch and leave renderingDirectory stuck.
|
||||
const iconResults = await Promise.allSettled(sortedContents.map(file => item_icon(file)));
|
||||
if ( seq !== this.renderSeq ) return;
|
||||
for ( let i = 0; i < sortedContents.length; i++ ) {
|
||||
const iconResult = iconResults[i].status === 'fulfilled'
|
||||
? iconResults[i].value
|
||||
@@ -2870,6 +2965,9 @@ const TabFiles = {
|
||||
row.setAttribute('data-path', place.path);
|
||||
row.setAttribute('data-name', place.label);
|
||||
row.setAttribute('data-is_dir', '1');
|
||||
// The sidebar's Shared and Trash are keyboard-reachable; so are these.
|
||||
row.setAttribute('tabindex', '0');
|
||||
row.setAttribute('role', 'link');
|
||||
row.innerHTML = `
|
||||
<div class="item-checkbox"><span class="checkbox-icon"></span></div>
|
||||
<div class="item-icon"><img src="${html_encode(place.icon)}"/></div>
|
||||
@@ -3235,6 +3333,8 @@ const TabFiles = {
|
||||
};
|
||||
|
||||
el_item.onclick = (e) => {
|
||||
// A tap in the name field places the caret; it doesn't open the item.
|
||||
if ( e.target.classList.contains('item-name-editor') ) return;
|
||||
if ( e.target.classList.contains('item-more') ) {
|
||||
if ( isPending() ) return;
|
||||
this.handleMoreClick(el_item, file, e.target, lastPointerType === 'touch');
|
||||
@@ -3428,6 +3528,12 @@ const TabFiles = {
|
||||
|
||||
// Right-click context menu handler (desktop) and taphold (touch devices)
|
||||
$(el_item).on('contextmenu taphold', async (e) => {
|
||||
// The name field keeps the browser's own menu and long-press
|
||||
// text selection; the listing's menu mustn't take it either.
|
||||
if ( e.target.classList.contains('item-name-editor') ) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// A taphold only counts when it came from an actual touch — the
|
||||
// plugin (helpers.js) also fires it for a 1s mouse-button hold,
|
||||
// which must stay inert, touchscreen or not.
|
||||
@@ -3512,7 +3618,7 @@ const TabFiles = {
|
||||
cursorAt: { left: -14, top: -12 },
|
||||
cursor: 'grabbing',
|
||||
revert: 'invalid',
|
||||
zIndex: 10000,
|
||||
zIndex: 999999999,
|
||||
scroll: false,
|
||||
distance: 5,
|
||||
revertDuration: 100,
|
||||
@@ -4138,7 +4244,7 @@ const TabFiles = {
|
||||
|
||||
// Refresh content to update icons for the new view mode
|
||||
if ( this.currentPath ) {
|
||||
this.renderDirectory(this.currentPath);
|
||||
this.refreshKeepingSelection();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -4183,10 +4289,10 @@ const TabFiles = {
|
||||
|
||||
if ( this.selectModeActive ) {
|
||||
$filesTab.addClass('select-mode-active');
|
||||
$selectBtn.addClass('active');
|
||||
$selectBtn.addClass('active').attr('aria-pressed', 'true');
|
||||
} else {
|
||||
$filesTab.removeClass('select-mode-active');
|
||||
$selectBtn.removeClass('active');
|
||||
$selectBtn.removeClass('active').attr('aria-pressed', 'false');
|
||||
// Clear all selections when exiting select mode
|
||||
this.$el_window.find('.files .row.selected').removeClass('selected');
|
||||
this.updateFooterStats();
|
||||
@@ -4204,7 +4310,7 @@ const TabFiles = {
|
||||
const $filesTab = this.$el_window.find('.files-tab');
|
||||
const $selectBtn = this.$el_window.find('.select-mode-btn');
|
||||
$filesTab.removeClass('select-mode-active');
|
||||
$selectBtn.removeClass('active');
|
||||
$selectBtn.removeClass('active').attr('aria-pressed', 'false');
|
||||
// Clear all selections
|
||||
this.$el_window.find('.files .row.selected').removeClass('selected');
|
||||
this.updateFooterStats();
|
||||
@@ -4326,11 +4432,23 @@ const TabFiles = {
|
||||
* @param {string} newPath - The path to add to history
|
||||
* @returns {void}
|
||||
*/
|
||||
/**
|
||||
* Whether a render of `target` would be dropped: one is already loading,
|
||||
* and it isn't a refresh of the folder on screen, which gives way to
|
||||
* opening another folder.
|
||||
*
|
||||
* @param {string} target
|
||||
* @returns {boolean}
|
||||
*/
|
||||
renderBlocked (target) {
|
||||
return this.renderingDirectory && ! (this.refreshInFlight && target !== this.currentPath);
|
||||
},
|
||||
|
||||
pushNavHistory (newPath) {
|
||||
// renderDirectory drops a navigation while another is loading, so
|
||||
// history must not move either, or Back/Forward would point at
|
||||
// folders that were never shown.
|
||||
if ( this.renderingDirectory ) return;
|
||||
if ( this.renderBlocked(newPath) ) return;
|
||||
// If history is empty, initialize with this path
|
||||
if ( window.dashboard_nav_history.length === 0 ) {
|
||||
window.dashboard_nav_history = [newPath];
|
||||
@@ -4353,7 +4471,7 @@ const TabFiles = {
|
||||
* @returns {void}
|
||||
*/
|
||||
goToHistoryPosition (position) {
|
||||
if ( this.renderingDirectory ) return;
|
||||
if ( this.renderBlocked(window.dashboard_nav_history[position]) ) return;
|
||||
window.dashboard_nav_history_current_position = position;
|
||||
this.updateNavButtonStates();
|
||||
this.renderDirectory(window.dashboard_nav_history[position]);
|
||||
@@ -4661,7 +4779,7 @@ 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 canCreate = folderPath ? this.acceptsNewItems(targetPath) : this.acceptsNewItemsHere();
|
||||
const items = [];
|
||||
|
||||
// New submenu (folder, text document, etc.) - not available in Trash
|
||||
@@ -4710,7 +4828,7 @@ const TabFiles = {
|
||||
const result = await uploadPromise;
|
||||
if ( targetPath === _this.currentPath ) {
|
||||
// Remove empty-directory placeholder if present
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row)').remove();
|
||||
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
|
||||
// Add the new file incrementally
|
||||
await _this.renderItem(result);
|
||||
const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`);
|
||||
@@ -4815,7 +4933,7 @@ const TabFiles = {
|
||||
window.mutate_user_preferences({
|
||||
show_hidden_files: ! showHiddenFiles(),
|
||||
});
|
||||
_this.renderDirectory(_this.currentPath);
|
||||
_this.refreshKeepingSelection();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5092,7 +5210,7 @@ const TabFiles = {
|
||||
}
|
||||
|
||||
// No drop zone where nothing can be uploaded
|
||||
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
|
||||
if ( ! _this.acceptsNewItemsHere() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5124,7 +5242,7 @@ const TabFiles = {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
|
||||
if ( ! _this.acceptsNewItemsHere() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5218,7 +5336,7 @@ const TabFiles = {
|
||||
error: async function (err) {
|
||||
const failedMessage = failedUploadsMessage(err);
|
||||
if ( failedMessage ) UIAlert(failedMessage);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
|
||||
delete window.active_uploads[opid];
|
||||
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
|
||||
},
|
||||
@@ -5342,6 +5460,21 @@ const TabFiles = {
|
||||
rows[0].scrollIntoView({ block: 'nearest' });
|
||||
},
|
||||
|
||||
/**
|
||||
* A folder's name in the Back/Forward history menus, as the breadcrumbs
|
||||
* show it.
|
||||
*
|
||||
* @param {string} folderPath
|
||||
* @returns {string} Plain text
|
||||
*/
|
||||
historyLabel (folderPath) {
|
||||
if ( folderPath === window.home_path ) return i18n('home', [], false);
|
||||
if ( folderPath === '/' ) return window.root_dirname;
|
||||
if ( folderPath === window.shared_path ) return i18n('shared', [], false);
|
||||
if ( folderPath === window.trash_path ) return i18n('trash', [], false);
|
||||
return this.trashedNames.get(folderPath) ?? path.basename(folderPath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Renders the breadcrumb path navigation HTML.
|
||||
*
|
||||
|
||||
@@ -235,3 +235,33 @@ describe('TabFiles opening a folder that fails to list', () => {
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles opening a folder during a refresh', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
});
|
||||
|
||||
it('opens it, and the refresh gives way', async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
let finishRefresh;
|
||||
window.puter = { fs: { readdir: vi.fn(({ path }) => (path === '/alice/Documents'
|
||||
? new Promise(resolve => {
|
||||
finishRefresh = resolve;
|
||||
})
|
||||
: Promise.resolve([]))) } };
|
||||
|
||||
const refresh = TabFiles.renderDirectory('/alice/Documents');
|
||||
await TabFiles.renderDirectory('/alice/Pictures');
|
||||
expect(TabFiles.currentPath).toBe('/alice/Pictures');
|
||||
|
||||
finishRefresh([]);
|
||||
await refresh;
|
||||
expect(TabFiles.currentPath).toBe('/alice/Pictures');
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
|
||||
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
|
||||
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
|
||||
import { appIconAttrs } from '../../helpers/appIcon.js';
|
||||
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
|
||||
@@ -197,7 +197,9 @@ function trialUsageNote(fraction, planName) {
|
||||
|
||||
const TabHome = {
|
||||
id: 'home',
|
||||
label: i18n('home'),
|
||||
get label () {
|
||||
return i18n('home');
|
||||
},
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
|
||||
|
||||
html() {
|
||||
@@ -626,10 +628,9 @@ const TabHome = {
|
||||
try {
|
||||
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)
|
||||
: '0';
|
||||
usage_percentage = usage_percentage > 100 ? 100 : usage_percentage;
|
||||
const usage_percentage = res.capacity
|
||||
? Math.min(100, meterPercent((res.used / res.capacity) * 100))
|
||||
: 0;
|
||||
|
||||
let general_used = res.used;
|
||||
if (res.host_used) {
|
||||
@@ -672,7 +673,7 @@ const TabHome = {
|
||||
.find('.bento-resources-capacity')
|
||||
.text(
|
||||
inCredits
|
||||
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'credits' : 'monthly_credits')}`
|
||||
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'usage_credits' : 'monthly_credits')}`
|
||||
: amount(budget.capacity),
|
||||
);
|
||||
$el_window
|
||||
|
||||
@@ -26,7 +26,9 @@ import { openChangePasswordDialog } from './UIAccountDialogs.js';
|
||||
|
||||
const TabSecurity = {
|
||||
id: 'security',
|
||||
label: i18n('security'),
|
||||
get label () {
|
||||
return i18n('security');
|
||||
},
|
||||
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
|
||||
|
||||
html () {
|
||||
@@ -115,6 +117,14 @@ const TabSecurity = {
|
||||
},
|
||||
|
||||
init ($el_window) {
|
||||
// These dialogs don't hand focus back when they close; a keyboard
|
||||
// user would be left on <body>, at the top of the page.
|
||||
const returnFocus = (el) => {
|
||||
if ( ! document.activeElement || document.activeElement === document.body ) {
|
||||
el?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
$el_window.find('.dashboard-section-security .change-password').on('click', function () {
|
||||
openChangePasswordDialog({ $container: $el_window, returnFocusTo: this });
|
||||
});
|
||||
@@ -134,8 +144,10 @@ const TabSecurity = {
|
||||
});
|
||||
|
||||
$el_window.find('.dashboard-section-security .manage-blocked-senders').on('click', function (e) {
|
||||
const opener = this;
|
||||
UIWindowBlockedSenders({
|
||||
window_options: {
|
||||
on_close: () => setTimeout(() => returnFocus(opener)),
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: true,
|
||||
@@ -168,6 +180,8 @@ const TabSecurity = {
|
||||
|
||||
$toggle.prop('disabled', false);
|
||||
$label.removeClass('dashboard-switch-pending');
|
||||
// After the dialog has gone, which can trail its answer.
|
||||
setTimeout(() => returnFocus(this), 250);
|
||||
|
||||
if ( ! succeeded ) {
|
||||
$toggle.prop('checked', ! enabling);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
|
||||
import { isOrgSeat } from './orgSeat.js';
|
||||
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
|
||||
|
||||
// Whether the server reported credits — decides whether the usage surfaces
|
||||
@@ -38,7 +38,9 @@ const USAGE_TABLE_INITIAL_ROWS = 10;
|
||||
|
||||
const TabUsage = {
|
||||
id: 'usage',
|
||||
label: i18n('usage'),
|
||||
get label () {
|
||||
return i18n('usage');
|
||||
},
|
||||
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-speedometer2" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/> <path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/> </svg>',
|
||||
html: () => {
|
||||
return `
|
||||
@@ -271,7 +273,7 @@ function renderUsageTable () {
|
||||
<tr>
|
||||
${sortableTh('resource', i18n('resource'))}
|
||||
<th>${i18n('units')}</th>
|
||||
${sortableTh('cost', i18n(usageShowsCredits ? 'credits' : 'cost'))}
|
||||
${sortableTh('cost', i18n(usageShowsCredits ? 'usage_credits' : 'cost'))}
|
||||
</tr>
|
||||
</thead>`;
|
||||
|
||||
@@ -320,7 +322,7 @@ async function update_usage_details ($el_window) {
|
||||
|
||||
$('#total-usage').html(amount(budget.used));
|
||||
$('#total-capacity').html(inCredits
|
||||
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'credits' : 'monthly_credits')}`
|
||||
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'usage_credits' : 'monthly_credits')}`
|
||||
: amount(budget.capacity));
|
||||
$('.usage-progbar-percent').html(`${budget.percent }%`);
|
||||
$('.usage-progbar').css({
|
||||
@@ -379,8 +381,7 @@ async function update_usage_details ($el_window) {
|
||||
|
||||
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;
|
||||
const usage_percentage = res.capacity ? Math.min(100, meterPercent(res.used / res.capacity * 100)) : 0;
|
||||
|
||||
let general_used = res.used;
|
||||
|
||||
|
||||
@@ -66,6 +66,10 @@ const revalidating = (dialog) => () => dialog.setStatus(t('revalidate_sign_in_po
|
||||
* @param {AccountDialogOptions & { onSave?: (profile: { displayName?: string|null, bio?: string|null }) => void }} [opts]
|
||||
*/
|
||||
export function openEditProfileDialog ({ onSave, ...opts } = {}) {
|
||||
const initial = {
|
||||
displayName: window.user?.profile?.displayName ?? '',
|
||||
bio: window.user?.profile?.bio ?? '',
|
||||
};
|
||||
return UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t('edit_profile'),
|
||||
@@ -74,24 +78,33 @@ export function openEditProfileDialog ({ onSave, ...opts } = {}) {
|
||||
{
|
||||
name: 'displayName',
|
||||
label: i18n('display_name'),
|
||||
value: window.user?.profile?.displayName ?? '',
|
||||
value: initial.displayName,
|
||||
attrs: { maxlength: DISPLAY_NAME_MAX_LENGTH, autocomplete: 'off' },
|
||||
},
|
||||
{
|
||||
name: 'bio',
|
||||
type: 'textarea',
|
||||
label: i18n('bio'),
|
||||
value: window.user?.profile?.bio ?? '',
|
||||
value: initial.bio,
|
||||
attrs: { maxlength: BIO_MAX_LENGTH, rows: 4 },
|
||||
},
|
||||
],
|
||||
async onSubmit (dialog) {
|
||||
const values = dialog.values();
|
||||
// An empty field clears it.
|
||||
const patch = {
|
||||
displayName: values.displayName.trim() || null,
|
||||
bio: values.bio.trim() || null,
|
||||
};
|
||||
// Only what was edited: the prefill comes from this session's copy
|
||||
// of the profile, which can be stale, and sending an untouched
|
||||
// field would write that copy over a newer one. An empty field
|
||||
// clears it.
|
||||
const patch = {};
|
||||
for ( const key of ['displayName', 'bio'] ) {
|
||||
if ( values[key].trim() !== initial[key].trim() ) {
|
||||
patch[key] = values[key].trim() || null;
|
||||
}
|
||||
}
|
||||
if ( ! Object.keys(patch).length ) {
|
||||
dialog.close();
|
||||
return;
|
||||
}
|
||||
// update_profile resolves with no profile when the request failed.
|
||||
const profile = await window.update_profile(window.user.username, patch);
|
||||
if ( ! profile ) {
|
||||
|
||||
@@ -82,13 +82,33 @@ describe('edit profile', () => {
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('sends only the fields that were edited', async () => {
|
||||
window.update_profile = vi.fn(async (_username, patch) => patch);
|
||||
dialogs.openEditProfileDialog();
|
||||
|
||||
await submitWith({ bio: 'New bio' });
|
||||
|
||||
expect(window.update_profile).toHaveBeenCalledWith('alice', { bio: 'New bio' });
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('closes without saving when nothing changed', async () => {
|
||||
window.update_profile = vi.fn();
|
||||
dialogs.openEditProfileDialog();
|
||||
|
||||
await submitWith({ displayName: ' Alice ' });
|
||||
|
||||
expect(window.update_profile).not.toHaveBeenCalled();
|
||||
expect(isOpen()).toBe(false);
|
||||
});
|
||||
|
||||
it('stays open with an error when the save fails', async () => {
|
||||
window.update_profile = vi.fn(async () => undefined);
|
||||
const onSave = vi.fn();
|
||||
dialogs.openEditProfileDialog({ onSave });
|
||||
await settle();
|
||||
|
||||
await submitWith();
|
||||
await submitWith({ bio: 'Bye' });
|
||||
|
||||
expect(status()).toBe('profile_save_failed');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
@@ -228,16 +228,21 @@ 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();
|
||||
// Account and Security decide some cards from the account's state when
|
||||
// they render (password and email for a temporary user, the token
|
||||
// button and 2FA for an unconfirmed email).
|
||||
const rerender_account_tabs = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// Saving a temporary account promotes this session's user in place.
|
||||
window.addEventListener('account-saved', () => {
|
||||
$el_window.find('.bento-save-account-warning').remove();
|
||||
rerender_account_tabs();
|
||||
});
|
||||
|
||||
// Dispatch 'dashboard-ready' event for extensions
|
||||
@@ -285,6 +290,13 @@ async function UIDashboard (options) {
|
||||
console.log('Dashboard Socket: Reconnection Failed');
|
||||
});
|
||||
|
||||
// Confirming the email, here or in another tab, enables the token button
|
||||
// and the 2FA card.
|
||||
window.socket.on('user.email_confirmed', async () => {
|
||||
await window.refresh_user_data(window.auth_token);
|
||||
rerender_account_tabs();
|
||||
});
|
||||
|
||||
// Upload/download progress tracking
|
||||
window.socket.on('upload.progress', (msg) => {
|
||||
if ( window.progress_tracker[msg.operation_id] ) {
|
||||
@@ -594,16 +606,40 @@ async function UIDashboard (options) {
|
||||
const $sidebar = $el_window.find('.dashboard-sidebar');
|
||||
const $scrim = $el_window.find('.dashboard-sidebar-scrim');
|
||||
const $sidebar_toggle = $el_window.find('.dashboard-sidebar-toggle');
|
||||
const drawerFocusables = () => $sidebar.find('a[href], button').filter(':visible:not(:disabled)');
|
||||
const setMobileSidebar = (open) => {
|
||||
// Focus left inside a closing drawer would sit on hidden controls
|
||||
// (and fall to <body>); the toggle that reopens it takes it instead.
|
||||
const focusInside = ! open && $sidebar.hasClass('open') && $sidebar[0].contains(document.activeElement);
|
||||
const opening = open && ! $sidebar.hasClass('open');
|
||||
$sidebar.toggleClass('open', open);
|
||||
$scrim.toggleClass('open', open);
|
||||
$sidebar_toggle.toggleClass('open', open).attr('aria-expanded', open ? 'true' : 'false');
|
||||
if ( focusInside ) $sidebar_toggle[0].focus({ preventScroll: true });
|
||||
// The drawer is modal: focus starts on the current page's entry.
|
||||
if ( opening ) {
|
||||
const current = $sidebar.find('.dashboard-sidebar-item[aria-current="page"]')[0];
|
||||
(current ?? drawerFocusables()[0])?.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Tab cycles inside the open drawer instead of reaching the content
|
||||
// behind the scrim.
|
||||
$sidebar.on('keydown', function (e) {
|
||||
if ( e.key !== 'Tab' || ! $sidebar.hasClass('open') ) return;
|
||||
const $focusables = drawerFocusables();
|
||||
if ( ! $focusables.length ) return;
|
||||
const first = $focusables[0];
|
||||
const last = $focusables[$focusables.length - 1];
|
||||
if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
$el_window.on('click', '.dashboard-sidebar-toggle', function () {
|
||||
setMobileSidebar(!$sidebar.hasClass('open'));
|
||||
});
|
||||
|
||||
@@ -287,7 +287,10 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
|
||||
},
|
||||
error: () => {
|
||||
if ( closed ) return;
|
||||
$list.html(`<div class="item-props-empty">${i18n('error_unknown_cause')}</div>`);
|
||||
// Still marked busy, the list's error would go unannounced.
|
||||
const failed = `<div class="item-props-empty">${i18n('error_unknown_cause')}</div>`;
|
||||
$list.attr('aria-busy', 'false').html(failed);
|
||||
$versions.html(failed);
|
||||
},
|
||||
})
|
||||
// The error callback renders the failure; the promise rejects with it too.
|
||||
|
||||
@@ -315,7 +315,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
if ( group.inheritedFrom ) {
|
||||
notes.push(i18n('share_inherited_via', { folder: path.basename(group.inheritedFrom) }));
|
||||
} else if ( group.inheritedPaths.length ) {
|
||||
notes.push(i18n('share_inherited_via_count', { count: group.inheritedPaths.length }));
|
||||
notes.push(i18n('share_inherited_via_count', { count: group.inheritedFolderCount }));
|
||||
}
|
||||
return notes;
|
||||
};
|
||||
@@ -329,9 +329,15 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
// Only direct grants live on the items themselves; an inherited one
|
||||
// belongs to the ancestor folder and has to be changed there.
|
||||
// Every action addresses a person by name; a withheld invite has none.
|
||||
const can_change = group.directPaths.length > 0 && ! group.anonymous;
|
||||
const can_revoke = ! group.anonymous
|
||||
&& (can_change || group.pendingPaths.length > 0);
|
||||
// Someone the owner let share can only change or withdraw the grants
|
||||
// they issued, and leave their own; on anyone else's the request
|
||||
// would do nothing.
|
||||
const may_act = ! group.anonymous
|
||||
&& (allow_manage || group.issuers.every((issuer) => issuer === window.user.username));
|
||||
const is_self = ! group.teamUid && ! group.pending && group.name === window.user.username;
|
||||
const can_change = group.directPaths.length > 0 && may_act;
|
||||
const can_revoke = (may_act && (can_change || group.pendingPaths.length > 0))
|
||||
|| (is_self && group.directPaths.length > 0);
|
||||
// Extending someone needs a mode to extend; a person whose grants
|
||||
// disagree levels them with the select first.
|
||||
const missing = missingPathsFor(target_paths, group).length;
|
||||
@@ -346,7 +352,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
if ( notes.length ) via += `<span>${notes.join(' · ')}</span>`;
|
||||
if ( can_add_to_all ) {
|
||||
via += `<button type="button" class="share-modal-row-link" data-key="${key}"
|
||||
aria-label="${i18n('share_add_to_all_for', { recipient: group.name, count: missing })}"
|
||||
aria-label="${missing === 1
|
||||
? i18n('share_add_to_all_for_one', { recipient: group.name })
|
||||
: i18n('share_add_to_all_for', { recipient: group.name, count: missing })}"
|
||||
>${i18n('share_add_to_all')}</button>`;
|
||||
}
|
||||
via += '</span>';
|
||||
@@ -365,7 +373,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
// reader user unable to tell whose grant a control changes.
|
||||
row += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">${options_for(group.mode, { allow_manage })}</select>`;
|
||||
} else {
|
||||
const fixed_mode = group.pending ? group.pendingMode : group.inheritedMode;
|
||||
let fixed_mode = group.inheritedMode;
|
||||
if ( group.pending ) fixed_mode = group.pendingMode;
|
||||
else if ( group.directPaths.length ) fixed_mode = group.mode;
|
||||
row += `<span class="share-modal-row-tag">${fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed')}</span>`;
|
||||
}
|
||||
|
||||
@@ -426,7 +436,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
const settled = await Promise.allSettled(
|
||||
target_paths.map((target) => puter.fs.getShares(target)),
|
||||
);
|
||||
if ( closed || seq !== refresh_seq ) return;
|
||||
if ( seq !== refresh_seq ) return;
|
||||
|
||||
/** @type {Map<string, Object[]>} */
|
||||
const by_path = new Map();
|
||||
@@ -439,15 +449,18 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
}
|
||||
});
|
||||
|
||||
// Each listing is authoritative for its own item. The Files rows
|
||||
// still need it when the dialog was closed while a change was saving.
|
||||
for ( const [target, shares] of by_path ) {
|
||||
mark_item_shared(target, has_direct_share(shares));
|
||||
}
|
||||
if ( closed ) return;
|
||||
|
||||
if ( by_path.size === 0 ) {
|
||||
$list.attr('aria-busy', 'false').empty();
|
||||
show_error(error_html(failure));
|
||||
return;
|
||||
}
|
||||
// Each listing is authoritative for its own item.
|
||||
for ( const [target, shares] of by_path ) {
|
||||
mark_item_shared(target, has_direct_share(shares));
|
||||
}
|
||||
render(aggregateShares(target_paths, by_path));
|
||||
link_state = linkShareState(target_paths, by_path);
|
||||
render_link_access();
|
||||
@@ -568,6 +581,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
() => puter.fs.share({ paths: run, recipient, mode }),
|
||||
) ?? []));
|
||||
}
|
||||
// A selection too large to list again (see refresh) learns which rows
|
||||
// are now shared from the grants themselves.
|
||||
if ( total > MAX_ITEMS_PER_REQUEST ) {
|
||||
for ( const share of created ) mark_item_shared(share.path, true);
|
||||
}
|
||||
return created;
|
||||
};
|
||||
|
||||
@@ -801,9 +819,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
|
||||
confirm_text = i18n('share_confirm_remove_items', { recipient: group.name, count: affected });
|
||||
} else {
|
||||
// "this item" only reads right when the dialog is about one item.
|
||||
// A team's removal reaches all its members; the desktop says so too.
|
||||
confirm_text = is_multi
|
||||
? i18n('share_confirm_remove_plain', { recipient: group.name })
|
||||
: i18n('share_confirm_remove', { recipient: group.name });
|
||||
: i18n(group.teamUid ? 'share_confirm_remove_team' : 'share_confirm_remove', { recipient: group.name });
|
||||
}
|
||||
|
||||
// One confirmation at a time: opening a second one restores the first
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
*/
|
||||
|
||||
const ELLIPSIS = '…';
|
||||
// Longer than any extension in common use (".markdown", ".numbers").
|
||||
const MAX_EXTENSION_LENGTH = 10;
|
||||
|
||||
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
@@ -53,8 +55,11 @@ const largestFitting = (max, fits) => {
|
||||
export function middleTruncate (filename, maxWidth, measure) {
|
||||
if ( measure(filename) <= maxWidth ) return filename;
|
||||
|
||||
// Only a short, unbroken tail is an extension: in "Report v1.2 final
|
||||
// draft" the text after the dot is part of the name.
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
const hasExtension = lastDot > 0 && lastDot < filename.length - 1;
|
||||
const tail = filename.slice(lastDot + 1);
|
||||
const hasExtension = lastDot > 0 && tail.length > 0 && tail.length <= MAX_EXTENSION_LENGTH && ! /\s/.test(tail);
|
||||
const extension = hasExtension ? filename.slice(lastDot) : '';
|
||||
const baseName = hasExtension ? filename.slice(0, lastDot) : filename;
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ describe('middleTruncate', () => {
|
||||
expect(middleTruncate('abcdefghij.markdown', 10, measure)).toBe('….markdown');
|
||||
});
|
||||
|
||||
it('treats a long or spaced tail after the last dot as part of the name', () => {
|
||||
expect(middleTruncate('Report v1.2 final draft for board', 15, measure)).toBe('Report …r board');
|
||||
expect(middleTruncate('backup.20240101-full-archive', 15, measure)).toBe('backup.…archive');
|
||||
});
|
||||
|
||||
it('never cuts an emoji in half', () => {
|
||||
const name = `x${'😀'.repeat(30)}.txt`;
|
||||
for ( let width = 6; width <= 60; width++ ) {
|
||||
|
||||
@@ -46,6 +46,10 @@ import { team_label } from '../../helpers/shareTeams.js';
|
||||
* @property {string|null} inheritedMode - Mode of the inherited grants, null when they disagree
|
||||
* @property {string|null} inheritedFrom - The one ancestor every inherited grant
|
||||
* comes through, null when there is more than one
|
||||
* @property {number} inheritedFolderCount - How many ancestors the inherited
|
||||
* grants come through
|
||||
* @property {string[]} issuers - Who issued the direct grants and invitations.
|
||||
* Only the item's owner or a grant's issuer can change or withdraw it.
|
||||
* @property {number} accessCount - Items the person can reach, by any of the three
|
||||
*/
|
||||
|
||||
@@ -61,6 +65,12 @@ const uniform = (values) => {
|
||||
return values.every((value) => (value ?? null) === first) ? first : null;
|
||||
};
|
||||
|
||||
// Weakest to strongest.
|
||||
const MODE_ORDER = ['see', 'list', 'read', 'write', 'manage'];
|
||||
|
||||
/** The stronger of two modes. */
|
||||
const stronger = (a, b) => (MODE_ORDER.indexOf(b) > MODE_ORDER.indexOf(a) ? b : a);
|
||||
|
||||
/**
|
||||
* Which of the three buckets a share row belongs to. Pending wins over
|
||||
* inherited: an invitation carries no username to hang an inherited row on.
|
||||
@@ -131,7 +141,8 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
|
||||
for ( const item_path of paths ) {
|
||||
// The same person can hold more than one grant on an item (different
|
||||
// issuers), which must not count as reaching it twice.
|
||||
// issuers), which must not count as reaching it twice. They hold the
|
||||
// stronger of the two.
|
||||
const counted = new Set();
|
||||
|
||||
for ( const share of sharesByPath.get(item_path) ?? [] ) {
|
||||
@@ -140,7 +151,12 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
if ( ! identity ) continue;
|
||||
const { key, name, teamUid, anonymous } = identity;
|
||||
|
||||
if ( counted.has(`${key}|${bucket}`) ) continue;
|
||||
if ( counted.has(`${key}|${bucket}`) ) {
|
||||
if ( bucket !== 'inherited' ) groups.get(key)._issuers.add(share.issuer);
|
||||
const modes = groups.get(key)[`_${bucket}Modes`];
|
||||
modes[modes.length - 1] = stronger(modes[modes.length - 1], share.mode);
|
||||
continue;
|
||||
}
|
||||
counted.add(`${key}|${bucket}`);
|
||||
|
||||
if ( ! groups.has(key) ) {
|
||||
@@ -157,9 +173,11 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
_pendingModes: [],
|
||||
_inheritedModes: [],
|
||||
_inheritedFroms: [],
|
||||
_issuers: new Set(),
|
||||
});
|
||||
}
|
||||
const group = groups.get(key);
|
||||
if ( bucket !== 'inherited' ) group._issuers.add(share.issuer);
|
||||
|
||||
if ( bucket === 'pending' ) {
|
||||
group.pendingPaths.push(item_path);
|
||||
@@ -194,6 +212,8 @@ export const aggregateShares = (paths, sharesByPath) => {
|
||||
pendingMode: uniform(group._pendingModes),
|
||||
inheritedMode: uniform(group._inheritedModes),
|
||||
inheritedFrom: uniform(group._inheritedFroms),
|
||||
inheritedFolderCount: new Set(group._inheritedFroms).size,
|
||||
issuers: [...group._issuers],
|
||||
accessCount: reached.size,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -88,6 +88,17 @@ describe('aggregateShares', () => {
|
||||
expect(groups[0].inheritedMode).toBe('read');
|
||||
});
|
||||
|
||||
it('counts the folders inherited grants come through, not the items', () => {
|
||||
const groups = aggregateShares(['/me/D/a', '/me/D/b', '/me/D/c', '/me/P/d'], new Map([
|
||||
['/me/D/a', [grant('ann', 'read', { inheritedFrom: '/me/D' })]],
|
||||
['/me/D/b', [grant('ann', 'read', { inheritedFrom: '/me/D' })]],
|
||||
['/me/D/c', [grant('ann', 'read', { inheritedFrom: '/me/D' })]],
|
||||
['/me/P/d', [grant('ann', 'read', { inheritedFrom: '/me/P' })]],
|
||||
]));
|
||||
|
||||
expect(groups[0].inheritedFolderCount).toBe(2);
|
||||
});
|
||||
|
||||
it('keys an invitation by its email, apart from any username', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant(null, 'read', { pending: true, recipientEmail: '[email protected]' })]],
|
||||
@@ -137,6 +148,33 @@ describe('aggregateShares', () => {
|
||||
expect(groups[0].accessCount).toBe(1);
|
||||
});
|
||||
|
||||
it('shows the stronger grant when two issuers grant the same person', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [
|
||||
grant('ann', 'read', { issuer: 'me' }),
|
||||
grant('ann', 'write', { issuer: 'bob' }),
|
||||
]],
|
||||
['/me/b', [
|
||||
grant('ann', 'write', { issuer: 'bob' }),
|
||||
grant('ann', 'read', { issuer: 'me' }),
|
||||
]],
|
||||
]));
|
||||
|
||||
expect(groups[0].mode).toBe('write');
|
||||
});
|
||||
|
||||
it('lists who issued the grants and invitations, but not inherited ones', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [
|
||||
grant('ann', 'read', { issuer: 'me' }),
|
||||
grant('ann', 'write', { issuer: 'bob' }),
|
||||
]],
|
||||
['/me/b', [grant('ann', 'read', { issuer: 'carl', inheritedFrom: '/me' })]],
|
||||
]));
|
||||
|
||||
expect(groups[0].issuers).toEqual(['me', 'bob']);
|
||||
});
|
||||
|
||||
it('lists people in the order the items first mention them', () => {
|
||||
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
|
||||
['/me/a', [grant('zed', 'read')]],
|
||||
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
const nonNegative = (value) => Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
|
||||
/**
|
||||
* A meter's share as a whole percent. 100% reads as spent, so a meter with
|
||||
* anything left shows 99% at most.
|
||||
*
|
||||
* @param {number} share - The share, 0-100 (or past 100 when overspent).
|
||||
* @returns {number}
|
||||
*/
|
||||
export const meterPercent = (share) => (share < 100 ? Math.min(99, Math.round(share)) : Math.round(share));
|
||||
|
||||
/**
|
||||
* @typedef {Object} UsageBudget
|
||||
* @property {number} used - Allowance-charged spend, in the server's units.
|
||||
@@ -55,8 +64,7 @@ export const usageBudget = (usage, allowanceInfo) => {
|
||||
return {
|
||||
used,
|
||||
capacity,
|
||||
// 100% reads as spent: an allowance with anything left shows 99%.
|
||||
percent: share < 100 ? Math.min(99, Math.round(share)) : Math.round(share),
|
||||
percent: meterPercent(share),
|
||||
barPercent: Math.max(0, Math.min(100, share)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
|
||||
|
||||
const info = (allowance, addons = {}) => ({
|
||||
monthUsageAllowance: allowance,
|
||||
@@ -161,3 +161,21 @@ describe('monthlyUsageOrThrow', () => {
|
||||
expect(() => monthlyUsageOrThrow(undefined)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('meterPercent', () => {
|
||||
it('holds at 99% until the meter is full', () => {
|
||||
expect(meterPercent(99.5)).toBe(99);
|
||||
expect(meterPercent(99.99)).toBe(99);
|
||||
expect(meterPercent(100)).toBe(100);
|
||||
});
|
||||
|
||||
it('rounds anything below that', () => {
|
||||
expect(meterPercent(0)).toBe(0);
|
||||
expect(meterPercent(42.5)).toBe(43);
|
||||
expect(meterPercent(98.4)).toBe(98);
|
||||
});
|
||||
|
||||
it('reports an overspent meter as it is', () => {
|
||||
expect(meterPercent(130.2)).toBe(130);
|
||||
});
|
||||
});
|
||||
@@ -355,7 +355,10 @@
|
||||
.mouseleave(mouseleaveRow)
|
||||
.click(clickRow);
|
||||
|
||||
$(document).mousemove(mousemoveDocument);
|
||||
// Comes off with the menu: left bound, it would keep the removed menu
|
||||
// and its items alive and run on every mouse move for the session.
|
||||
$(document).on('mousemove', mousemoveDocument);
|
||||
$menu.on('remove', () => $(document).off('mousemove', mousemoveDocument));
|
||||
|
||||
};
|
||||
})(jQuery);
|
||||
@@ -638,7 +641,7 @@ function UIContextMenu (options) {
|
||||
|
||||
// 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) {
|
||||
$(contextMenu).on('click', 'li:not(.context-menu-item-disabled):not(.context-menu-divider)', 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') ) {
|
||||
|
||||
@@ -738,37 +738,7 @@ async function UIDesktop (options) {
|
||||
window.history.pushState(null, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
//show_hidden_files
|
||||
let show_hidden_files = false;
|
||||
try {
|
||||
show_hidden_files = JSON.parse(await puter.kv.get('user_preferences.show_hidden_files'));
|
||||
} catch (e) {
|
||||
console.error('Error loading show_hidden_files', e);
|
||||
}
|
||||
|
||||
// language
|
||||
let language = 'en';
|
||||
try {
|
||||
language = await puter.kv.get('user_preferences.language');
|
||||
} catch (e) {
|
||||
console.error('Error loading language', e);
|
||||
}
|
||||
|
||||
// update local user preferences
|
||||
const user_preferences = {
|
||||
show_hidden_files: show_hidden_files,
|
||||
language: language,
|
||||
};
|
||||
|
||||
// update default apps
|
||||
{
|
||||
const entries = await puter.kv.list('user_preferences.default_apps.*', true).catch(() => []);
|
||||
for ( const entry of entries ) {
|
||||
user_preferences[entry.key.substring(17)] = entry.value;
|
||||
}
|
||||
|
||||
window.update_user_preferences(user_preferences);
|
||||
}
|
||||
await window.load_user_preferences();
|
||||
|
||||
// Append to <body>
|
||||
$('body').append(h);
|
||||
|
||||
@@ -1859,7 +1859,7 @@ window.activate_item_name_editor = async function (el_item) {
|
||||
}
|
||||
// files in trash cannot be renamed, user should be notified with an Alert.
|
||||
else if ( path.dirname($(el_item).attr('data-path')) === window.trash_path ) {
|
||||
UIAlert(i18n('items_in_trash_cannot_be_renamed'));
|
||||
UIAlert(i18n('items_in_trash_cannot_be_renamed', [], false));
|
||||
return;
|
||||
}
|
||||
// Someone else's item is renameable only with write on it (files, not
|
||||
|
||||
@@ -131,9 +131,12 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
w_body.className = 'session-manager-list';
|
||||
modal.appendChild(w_body);
|
||||
|
||||
// The modal takes keyboard focus while it's up, and gives it back after.
|
||||
const el_previous_focus = document.activeElement;
|
||||
document.body.appendChild(backdrop);
|
||||
// Next frame so the open transition runs.
|
||||
requestAnimationFrame(() => backdrop.classList.add('open'));
|
||||
el_close.focus({ preventScroll: true });
|
||||
|
||||
// Refresh handles — assigned once the list wiring below is in place,
|
||||
// referenced by close(). Declared here so close() can see them.
|
||||
@@ -150,6 +153,9 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
backdrop.classList.remove('open');
|
||||
// Remove after the fade-out; guard against a missing transitionend.
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
if ( modal.contains(document.activeElement) || document.activeElement === document.body ) {
|
||||
el_previous_focus?.focus?.({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Escape: cancel an open confirm sheet if there is one, otherwise close
|
||||
@@ -165,6 +171,23 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
// Tab cycles inside the modal rather than reaching the page behind it.
|
||||
modal.addEventListener('keydown', (e) => {
|
||||
if ( e.key !== 'Tab' ) return;
|
||||
const focusables = [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
|
||||
.filter(el => ! el.disabled && el.offsetParent !== null);
|
||||
if ( ! focusables.length ) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if ( e.shiftKey && document.activeElement === first ) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if ( ! e.shiftKey && document.activeElement === last ) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
el_close.addEventListener('click', close);
|
||||
backdrop.addEventListener('mousedown', (e) => {
|
||||
if ( e.target !== backdrop ) return;
|
||||
|
||||
+162
-35
@@ -63,8 +63,9 @@
|
||||
--dashboard-avatar-background: #ddd;
|
||||
--dashboard-input-background: rgb(245, 247, 249);
|
||||
|
||||
--dashboard-link: #5271ff;
|
||||
--dashboard-link-hover: #3d5bd9;
|
||||
/* Link text: 4.5:1 or better on the cards and the content background. */
|
||||
--dashboard-link: #3d5bd9;
|
||||
--dashboard-link-hover: #2f4bc0;
|
||||
/* Primary button fills, under white text: 4.5:1 or better in both themes. */
|
||||
--dashboard-button-primary: #4a67f5;
|
||||
--dashboard-button-primary-hover: #3d5bd9;
|
||||
@@ -85,9 +86,9 @@
|
||||
|
||||
--dashboard-success-background: #e6ffed;
|
||||
--dashboard-success-border: #08bf4e;
|
||||
--dashboard-success-text: #03933a;
|
||||
--dashboard-success-text: #15803d;
|
||||
|
||||
--dashboard-danger-text: #dc2626;
|
||||
--dashboard-danger-text: #b91c1c;
|
||||
--dashboard-danger-background: #fef2f2;
|
||||
--dashboard-danger-border: #fecaca;
|
||||
--dashboard-error-text: #dc2626;
|
||||
@@ -277,11 +278,12 @@ body {
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
width: 250px;
|
||||
min-width: 200px;
|
||||
/* The safe-area insets clear a landscape phone's notch; elsewhere they're 0. */
|
||||
width: calc(250px + env(safe-area-inset-left, 0px));
|
||||
min-width: calc(200px + env(safe-area-inset-left, 0px));
|
||||
background: var(--dashboard-sidebar-background);
|
||||
border-right: 1px solid var(--dashboard-border);
|
||||
padding: 16px 12px;
|
||||
padding: 16px 12px 16px calc(12px + env(safe-area-inset-left, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
@@ -386,9 +388,9 @@ body {
|
||||
drawer as an unlabeled icon strip. */
|
||||
@media (min-width: 769px) {
|
||||
.dashboard-sidebar.collapsed {
|
||||
width: 50px;
|
||||
min-width: 50px;
|
||||
padding: 16px 6px;
|
||||
width: calc(50px + env(safe-area-inset-left, 0px));
|
||||
min-width: calc(50px + env(safe-area-inset-left, 0px));
|
||||
padding: 16px 6px 16px calc(6px + env(safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
.dashboard-sidebar.collapsed .dashboard-sidebar-header {
|
||||
@@ -503,6 +505,23 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
/* Keeps its 32px look and hit-tests at 44x44. */
|
||||
.dashboard-sidebar-close {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-sidebar-close::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-sidebar-nav {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -637,7 +656,7 @@ body {
|
||||
|
||||
.dashboard-content {
|
||||
flex: 1;
|
||||
padding: 24px 32px;
|
||||
padding: 24px calc(32px + env(safe-area-inset-right, 0px)) 24px 32px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--dashboard-content-background);
|
||||
@@ -696,7 +715,7 @@ body {
|
||||
/* Dashboard Apps */
|
||||
|
||||
.dashboard-content.apps {
|
||||
padding: 0;
|
||||
padding: 0 env(safe-area-inset-right, 0px) 0 0;
|
||||
}
|
||||
|
||||
.dashboard-section-apps {
|
||||
@@ -1594,6 +1613,11 @@ input.myapps-group-name:focus {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.myapps-modal-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
.myapps-modal-cancel {
|
||||
background: var(--dashboard-card-background);
|
||||
color: var(--dashboard-text-primary);
|
||||
@@ -1604,9 +1628,9 @@ input.myapps-group-name:focus {
|
||||
}
|
||||
}
|
||||
.myapps-modal-confirm {
|
||||
background: #e53935;
|
||||
background: #d32f2f;
|
||||
color: #fff;
|
||||
border-color: #e53935;
|
||||
border-color: #d32f2f;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.myapps-modal-confirm:hover {
|
||||
@@ -1669,7 +1693,9 @@ input.myapps-group-name:focus {
|
||||
|
||||
.app-feedback-modal {
|
||||
width: min(440px, 100%);
|
||||
max-height: calc(100vh - 40px);
|
||||
/* dvh: on phones 100vh is the viewport with the browser bars hidden,
|
||||
taller than what's visible while they show. */
|
||||
max-height: calc(100dvh - 40px);
|
||||
overflow-y: auto;
|
||||
background: var(--afb-bg);
|
||||
color: var(--afb-fg);
|
||||
@@ -1696,6 +1722,7 @@ input.myapps-group-name:focus {
|
||||
font-weight: 600;
|
||||
}
|
||||
.app-feedback-x {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -1710,6 +1737,18 @@ input.myapps-group-name:focus {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
/* Keeps its 30px look and hit-tests at 44x44. */
|
||||
.app-feedback-x::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
.app-feedback-x svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -1828,22 +1867,21 @@ input.myapps-group-name:focus {
|
||||
}
|
||||
}
|
||||
.app-feedback-btn-primary {
|
||||
border-color: #088ef0;
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
border-color: var(--dashboard-button-primary);
|
||||
background: var(--dashboard-button-primary);
|
||||
color: #fff;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
/* Must out-rank .app-feedback-btn:hover:not(:disabled) above, which
|
||||
would otherwise repaint the primary button with the light hover bg. */
|
||||
.app-feedback-btn.app-feedback-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
border-color: var(--dashboard-button-primary-hover);
|
||||
background: var(--dashboard-button-primary-hover);
|
||||
}
|
||||
}
|
||||
.app-feedback-btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
filter: none;
|
||||
}
|
||||
/* Cancel/X are disabled while a submit is in flight. */
|
||||
.app-feedback-btn:disabled,
|
||||
@@ -1885,7 +1923,7 @@ input.myapps-group-name:focus {
|
||||
}
|
||||
.app-feedback-modal {
|
||||
width: 100%;
|
||||
max-height: 92vh;
|
||||
max-height: 92dvh;
|
||||
border-radius: 14px 14px 0 0;
|
||||
transform: translateY(16px);
|
||||
/* Flush with the bottom edge: keep the actions above the home
|
||||
@@ -2000,6 +2038,7 @@ input.myapps-group-name:focus {
|
||||
margin: 0;
|
||||
}
|
||||
.myapps-add-back {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2016,6 +2055,18 @@ input.myapps-group-name:focus {
|
||||
color: var(--dashboard-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
/* Keeps its 26px look and hit-tests at 44x44. */
|
||||
.myapps-add-back::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
.myapps-add-back svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
@@ -2178,6 +2229,7 @@ p.myapps-add-error {
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
.item-props-close {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2192,6 +2244,18 @@ p.myapps-add-error {
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
/* Keeps its 32px look and hit-tests at 44x44. */
|
||||
.item-props-close::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.item-props-close:hover {
|
||||
background: var(--dashboard-hover);
|
||||
@@ -3109,7 +3173,8 @@ select.share-modal-row-mode:focus {
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
.share-modal-submit {
|
||||
.share-modal-submit,
|
||||
.share-modal-copy-link {
|
||||
height: 44px;
|
||||
}
|
||||
.share-modal-btn-quiet,
|
||||
@@ -3542,7 +3607,7 @@ select.share-modal-row-mode:focus {
|
||||
.profile-crop-modal {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
max-height: 92vh;
|
||||
max-height: 92dvh;
|
||||
border-radius: 16px 16px 0 0;
|
||||
border-bottom: none;
|
||||
transform: translateY(24px);
|
||||
@@ -3848,7 +3913,7 @@ body.myapps-reordering .myapps-tile {
|
||||
so a vh-sized list overhung the clipped content box and its last rows could
|
||||
never be scrolled into view. */
|
||||
.dashboard-content.files {
|
||||
padding: 0 0 0 10px;
|
||||
padding: 0 env(safe-area-inset-right, 0px) 0 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3998,6 +4063,21 @@ body.myapps-reordering .myapps-tile {
|
||||
padding: 8px;
|
||||
margin: -4px;
|
||||
}
|
||||
|
||||
/* Keeps its 32px look and hit-tests at 44x44. */
|
||||
.dashboard-section-files .header .path-action-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -4068,8 +4148,10 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
}
|
||||
|
||||
/* Keyboard focus on the folder sidebar, crumbs, and nav arrows */
|
||||
/* Keyboard focus on the folder sidebar, crumbs, nav arrows, and Home's
|
||||
Shared/Trash rows */
|
||||
.dashboard-section-files .directories li:focus-visible,
|
||||
.dashboard-section-files .files-tab .files .row.place-row:focus-visible,
|
||||
.dashboard-section-files .header .path .dirname:focus-visible,
|
||||
.dashboard-section-files .header .path-btn:focus-visible {
|
||||
outline: 2px solid var(--select-ring);
|
||||
@@ -4350,7 +4432,8 @@ body.myapps-reordering .myapps-tile {
|
||||
|
||||
.files-drag-ghost {
|
||||
pointer-events: none;
|
||||
z-index: 10000;
|
||||
/* On body, above the dashboard window (see the sidebar tooltip). */
|
||||
z-index: 999999999;
|
||||
opacity: 0.95;
|
||||
animation: files-drag-ghost-in 120ms ease-out;
|
||||
}
|
||||
@@ -4481,7 +4564,7 @@ body.myapps-reordering .myapps-tile {
|
||||
position: absolute;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
background: #ef4444;
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
padding: 16px 32px;
|
||||
border-radius: 6px;
|
||||
@@ -4495,7 +4578,7 @@ body.myapps-reordering .myapps-tile {
|
||||
}
|
||||
|
||||
.drag-cancel-zone.drag-cancel-hover {
|
||||
background: #dc2626;
|
||||
background: #b91c1c;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
@@ -4837,6 +4920,22 @@ body.myapps-reordering .myapps-tile {
|
||||
border-color: var(--select-ring);
|
||||
}
|
||||
|
||||
/* Drop target and spring-load dwell, as in the list: the grid rules above
|
||||
out-rank the shared ones. A pixel of padding pays for the wider border,
|
||||
so the tile doesn't shift. */
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row.folder.ui-droppable-hover,
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row.folder.dwell-opening {
|
||||
padding: 11px;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border: 2px dashed var(--select-color);
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row.folder.dwell-opening {
|
||||
background-image: linear-gradient(90deg, rgba(59, 130, 246, 0.15) 100%, transparent 100%);
|
||||
background-size: 0% 100%;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
@@ -4940,6 +5039,11 @@ body.myapps-reordering .myapps-tile {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row.folder.ui-droppable-hover,
|
||||
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row.folder.dwell-opening {
|
||||
padding: 7px;
|
||||
}
|
||||
|
||||
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row .item-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
@@ -5380,7 +5484,8 @@ body.myapps-reordering .myapps-tile {
|
||||
/* Image preview popover */
|
||||
.image-preview-popover {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
/* On body, above the dashboard window (see the sidebar tooltip). */
|
||||
z-index: 999999999;
|
||||
background: var(--dashboard-background);
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 8px;
|
||||
@@ -6990,7 +7095,9 @@ body.myapps-reordering .myapps-tile {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
transition: transform 200ms ease-in-out, opacity 200ms ease-in-out;
|
||||
max-height: calc(100vh - 40px);
|
||||
/* dvh: ContextMenu.js places the menu within the visible height, which
|
||||
on phones is less than 100vh while the browser bars show. */
|
||||
max-height: calc(100dvh - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -7058,6 +7165,12 @@ body.myapps-reordering .myapps-tile {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.context-menu-modal-dialog .context-menu-item {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-modal-dialog .context-menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
@@ -7837,6 +7950,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
}
|
||||
|
||||
.dashboard-notifications-close {
|
||||
position: relative;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
@@ -7844,6 +7958,19 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
color: var(--dashboard-text-muted);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
/* Keeps its 30px look and hit-tests at 44x44. */
|
||||
.dashboard-notifications-close::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-notifications-close svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -8306,7 +8433,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
|
||||
while one is open must still be seen. Under context menus. */
|
||||
body.dashboard-mode .notification-container {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
right: calc(12px + env(safe-area-inset-right, 0px));
|
||||
z-index: 999999999;
|
||||
}
|
||||
|
||||
@@ -8619,8 +8746,8 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn-danger {
|
||||
border-color: #f00808;
|
||||
background: #f00808;
|
||||
border-color: #d90707;
|
||||
background: #d90707;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -8635,8 +8762,8 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
|
||||
.dashboard-dialog-btn-danger:hover {
|
||||
background: #d90707;
|
||||
border-color: #d90707;
|
||||
background: #b80606;
|
||||
border-color: #b80606;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -108,7 +108,7 @@ try {
|
||||
if ( window.user_preferences === null ) {
|
||||
window.user_preferences = {
|
||||
show_hidden_files: false,
|
||||
language: navigator.language.split('-')[0] || navigator.userLanguage || 'en',
|
||||
language: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+36
-5
@@ -711,13 +711,18 @@ window.mutate_user_preferences = function (user_preferences_delta) {
|
||||
window.update_user_preferences({ ...window.user_preferences, ...user_preferences_delta });
|
||||
};
|
||||
|
||||
/**
|
||||
* The language to render in for a saved `language` preference. The stored
|
||||
* value can be anything, and i18n/Intl throw on some bad locales.
|
||||
*/
|
||||
window.resolve_language = (language) => (window.listSupportedLanguages().some(lang => lang.code === language)
|
||||
? language
|
||||
: 'en');
|
||||
|
||||
window.update_user_preferences = function (user_preferences) {
|
||||
window.user_preferences = user_preferences;
|
||||
localStorage.setItem('user_preferences', JSON.stringify(user_preferences));
|
||||
// The stored value can be anything, and i18n/Intl throw on some bad locales.
|
||||
const language = window.listSupportedLanguages().some(lang => lang.code === user_preferences.language)
|
||||
? user_preferences.language
|
||||
: 'en';
|
||||
const language = window.resolve_language(user_preferences.language);
|
||||
window.locale = language;
|
||||
|
||||
// Broadcast locale change to apps
|
||||
@@ -727,6 +732,32 @@ window.update_user_preferences = function (user_preferences) {
|
||||
}, { sendToNewAppInstances: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the account's saved preferences and apply them. A read that fails
|
||||
* keeps what this browser last loaded.
|
||||
*/
|
||||
window.load_user_preferences = async function () {
|
||||
const user_preferences = {
|
||||
show_hidden_files: window.user_preferences.show_hidden_files,
|
||||
language: window.user_preferences.language,
|
||||
};
|
||||
try {
|
||||
user_preferences.show_hidden_files = JSON.parse(await puter.kv.get('user_preferences.show_hidden_files'));
|
||||
} catch (e) {
|
||||
console.error('Error loading show_hidden_files', e);
|
||||
}
|
||||
try {
|
||||
user_preferences.language = await puter.kv.get('user_preferences.language');
|
||||
} catch (e) {
|
||||
console.error('Error loading language', e);
|
||||
}
|
||||
const entries = await puter.kv.list('user_preferences.default_apps.*', true).catch(() => []);
|
||||
for ( const entry of entries ) {
|
||||
user_preferences[entry.key.substring(17)] = entry.value;
|
||||
}
|
||||
window.update_user_preferences(user_preferences);
|
||||
};
|
||||
|
||||
window.sendWindowWillCloseMsg = function (iframe_element) {
|
||||
return new Promise(function (resolve) {
|
||||
const msg_id = window.uuidv4();
|
||||
@@ -2397,7 +2428,7 @@ window.upload_items = async function (items, dest_path) {
|
||||
},
|
||||
// error
|
||||
error: async function (err) {
|
||||
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
|
||||
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
|
||||
// remove from active_uploads
|
||||
delete window.active_uploads[opid];
|
||||
},
|
||||
|
||||
@@ -281,7 +281,7 @@ const generate_file_context_menu = async function (options) {
|
||||
// Cut
|
||||
// -------------------------------------------
|
||||
// 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 ) {
|
||||
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
|
||||
menu_items.push({
|
||||
html: i18n('cut'),
|
||||
onClick: function () {
|
||||
|
||||
@@ -540,6 +540,7 @@ const en = {
|
||||
share_add_to_all: 'Add to all',
|
||||
share_add_to_all_for:
|
||||
'Give {{recipient}} access to the other {{count}} items',
|
||||
share_add_to_all_for_one: 'Give {{recipient}} access to the other item',
|
||||
share_inherited_via_count: 'via {{count}} folders',
|
||||
share_no_one_items: 'None of these items are shared yet.',
|
||||
share_failed_items: 'Could not share these items.',
|
||||
@@ -716,6 +717,7 @@ const en = {
|
||||
uptime: 'Uptime',
|
||||
used_of: '{{used}} used of {{available}}',
|
||||
usage_subtitle: 'Puter resources and AI usage',
|
||||
usage_credits: 'Credits',
|
||||
usage_details: 'Usage Details',
|
||||
usage_details_unavailable: 'Usage details are unavailable right now.',
|
||||
usage_show_more: 'Show {{count}} more',
|
||||
|
||||
@@ -245,6 +245,20 @@ const postAuthActions = async (action) => {
|
||||
// Dashboard mode
|
||||
// -------------------------------------------------------------------------------------
|
||||
else if ( window.is_dashboard_mode ) {
|
||||
// Paint in the language this browser last loaded, so the dashboard
|
||||
// doesn't wait on the saved preferences; a different saved language
|
||||
// reloads into it.
|
||||
window.locale = window.resolve_language(window.user_preferences.language);
|
||||
const painted_language = window.locale;
|
||||
const painted_hidden = !! window.user_preferences.show_hidden_files;
|
||||
window.load_user_preferences().then(() => {
|
||||
if ( window.locale !== painted_language ) {
|
||||
location.reload();
|
||||
} else if ( !! window.user_preferences.show_hidden_files !== painted_hidden
|
||||
&& window.dashboard_object?.currentPath ) {
|
||||
window.dashboard_object.renderDirectory(window.dashboard_object.currentPath);
|
||||
}
|
||||
});
|
||||
const el_dashboard_promise = UIDashboard();
|
||||
// Direct landing on /app/<name>: open the app in the dashboard the
|
||||
// same way a tile launch does. The dashboard's route is slotted
|
||||
|
||||
Reference in new issue
Block a user