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