diff --git a/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js b/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js index 4e60d3996..0d2d958a0 100644 --- a/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js +++ b/src/gui/src/UI/Dashboard/ContextMenu/ContextMenu.js @@ -42,6 +42,10 @@ export default class ContextMenuModal { if ( this.backdrop ) return; // Already showing this.menuItems = menuItems; + // Stack of parent menus for submenu drill-in, and the anchor rect so we + // can reposition when the menu height changes on navigation. + this._menuStack = []; + this._targetRect = targetRect; // Create backdrop this.backdrop = document.createElement('div'); @@ -62,7 +66,7 @@ export default class ContextMenuModal { this.modal.innerHTML = ` ${titleHtml}
- ${this.renderMenuItems(menuItems)} + ${this.renderMenuItems(this.getVisibleItems())}
`; @@ -102,41 +106,53 @@ export default class ContextMenuModal { const viewportWidth = window.innerWidth; const margin = 20; // Minimum margin from viewport edges - // Default: align with item left and top - let top = targetRect.top; - let left = targetRect.left; + const width = isMobile ? viewportWidth * 0.9 : modalWidth; - // Use target width as minimum, but allow modal to be wider if needed - const width = Math.max(targetRect.width, modalWidth); - - // Horizontal positioning - center over item if possible + // Center over the target horizontally, clamped to the viewport. (The + // old code hardcoded left:300px on non-touch devices — which route here + // whenever maxTouchPoints > 0, e.g. touchscreen laptops — so the menu + // opened nowhere near the item on a wide screen.) const itemCenter = targetRect.left + (targetRect.width / 2); - const modalHalfWidth = width / 2; + let left = itemCenter - width / 2; + left = Math.max(margin, Math.min(left, viewportWidth - width - margin)); - if ( itemCenter - modalHalfWidth >= margin && - itemCenter + modalHalfWidth <= viewportWidth - margin ) { - left = itemCenter - modalHalfWidth; - } else { - // Align with item left, but ensure within viewport - left = 20; //Math.max(margin, Math.min(left, viewportWidth - width - margin)); - } - - // Vertical positioning - ensure modal stays within viewport + let top = targetRect.top; if ( top + modalHeight > viewportHeight - margin ) { - // Would go off bottom, shift up top = Math.max(margin, viewportHeight - modalHeight - margin); } - if ( top < margin ) { top = margin; } - // Apply positioning this.modal.style.top = `${top}px`; - this.modal.style.left = isMobile ? `${left}px` : '300px'; + this.modal.style.left = `${left}px`; this.modal.style.width = isMobile ? '90%' : 'auto'; } + /** + * The item list currently on screen: the active menu, prefixed with a Back + * row when we've drilled into a submenu. + * @returns {Array} + */ + getVisibleItems () { + if ( this._menuStack && this._menuStack.length > 0 ) { + return [{ label: '‹ Back', _isBack: true }, '-', ...this.menuItems]; + } + return this.menuItems; + } + + /** + * Rebuild the item rows in place (after drilling in/out of a submenu) and + * reposition, since the height changed. + */ + rerenderItems () { + const container = this.modal.querySelector('.context-menu-items'); + if ( container ) { + container.innerHTML = this.renderMenuItems(this.getVisibleItems()); + } + this.positionModal(this._targetRect); + } + /** * Render menu items as HTML * Supports both Puter format (html/onClick) and voice-recorder format (label/action) @@ -156,6 +172,8 @@ export default class ContextMenuModal { // Check for delete/danger styling const isDelete = label.toLowerCase().includes('delete'); const deleteClass = isDelete ? 'context-menu-item--delete' : ''; + const disabledClass = item.disabled ? 'context-menu-item--disabled' : ''; + const hasSubmenu = Array.isArray(item.items) && item.items.length > 0; // Get icon - support both formats (HTML string or base64) let iconHtml = ''; @@ -169,12 +187,18 @@ export default class ContextMenuModal { } } + // A submenu row gets a trailing chevron; a Back row a leading one. + const submenuChevron = hasSubmenu + ? '›' + : ''; + return ` - `; }).join(''); @@ -208,18 +232,36 @@ export default class ContextMenuModal { if ( ! itemBtn ) return; const index = parseInt(itemBtn.dataset.index, 10); - const menuItem = this.menuItems[index]; + const menuItem = this.getVisibleItems()[index]; + if ( ! menuItem || menuItem === '-' || menuItem.is_divider ) return; - if ( menuItem && menuItem !== '-' && !menuItem.is_divider ) { - // Support both action formats - const handler = menuItem.action || menuItem.onClick; - if ( handler ) { - this.close(); - // Execute action after close animation starts - setTimeout(() => { - handler(); - }, 50); - } + // Back out of a submenu. + if ( menuItem._isBack ) { + this.menuItems = this._menuStack.pop(); + this.rerenderItems(); + return; + } + + // Disabled items are inert (mirrors the desktop UIContextMenu). + if ( menuItem.disabled ) return; + + // Drill into a submenu (e.g. "New", "Open With") instead of leaving + // it a dead button. + if ( Array.isArray(menuItem.items) && menuItem.items.length > 0 ) { + this._menuStack.push(this.menuItems); + this.menuItems = menuItem.items; + this.rerenderItems(); + return; + } + + // Support both action formats + const handler = menuItem.action || menuItem.onClick; + if ( handler ) { + this.close(); + // Execute action after close animation starts + setTimeout(() => { + handler(); + }, 50); } }; this.modal.addEventListener('click', this.itemClickHandler); diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index 2691be6f8..49dddc875 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -1,6 +1,6 @@ import UIContextMenu from '../UIContextMenu.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; -import { reconcileAppOrder, serializeAppOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; +import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; /** Lowercase app names that must not offer Uninstall in the My Apps tile context menu. */ const APP_NAMES_NO_UNINSTALL = new Set([ @@ -163,11 +163,16 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { try { await puter.perms.revokeApp(appUid, '*'); + // A load fetched before the revoke must not apply — it would + // resurrect the pre-revoke grid. No refetch here either: the + // recommended launch list doesn't know about the revoke, so an + // immediate reload would just re-add a recommended app's tile. + // The saved order intentionally keeps the app's name: + // reconcileAppOrder ignores it while the app is gone and + // restores its position if it comes back. + self._invalidateInFlightLoads(); self._apps = self._apps.filter(a => a.name !== appName); - // Keep the persisted order free of the now-uninstalled app, but - // only if the user already has a custom order (don't create one). - if ( self._hasCustomOrder ) self.saveOrder(); - self.renderApps($el_window, { preservePage: true }); + self.renderApps($el_window, { preservePage: true, instant: true }); } catch ( err ) { console.error('Failed to uninstall app:', err); } @@ -220,6 +225,10 @@ const TabApps = { _drag: null, _justDragged: false, _reduceMotionMQL: undefined, + _loadPromise: null, + _pendingLoad: null, + _savedOrderNames: null, + _orderSavedAtSeq: 0, html () { let h = '
'; @@ -269,9 +278,9 @@ const TabApps = { const appName = $(this).attr('data-app-name'); const targetLink = $(this).attr('data-target-link'); if ( targetLink && targetLink !== '' ) { - window.open(targetLink, '_blank'); + window.open(targetLink, '_blank', 'noopener,noreferrer'); } else if ( appName ) { - window.open(`/app/${appName}`, '_blank'); + window.open(`/app/${appName}`, '_blank', 'noopener,noreferrer'); } }); @@ -292,8 +301,7 @@ const TabApps = { const appName = $(this).attr('data-app-name'); const appTitle = $(this).attr('data-app-title'); const appUid = $(this).attr('data-app-uid'); - const nameLower = (appName || '').toLowerCase(); - const noUninstall = APP_NAMES_NO_UNINSTALL.has(nameLower); + const noUninstall = APP_NAMES_NO_UNINSTALL.has((appName || '').toLowerCase()); const items = noUninstall ? [] @@ -882,11 +890,66 @@ const TabApps = { // Rebuild so pages rebalance to exactly perPage; skip the load fade. this.renderApps(d.$el_window, { preservePage: true, instant: true }); + + this._applyPendingLoad(); + }, + + // A load that resolved mid-drag was stashed rather than rendered (see + // _fetchAndRenderApps). Apply it now; _resolveOrderNames picks between + // the canonical in-memory saved order and the kv snapshot this load + // fetched, based on which is fresher. + _applyPendingLoad () { + const pending = this._pendingLoad; + if ( ! pending ) return; + this._pendingLoad = null; + if ( pending.loadSeq < (this._appliedSeq || 0) ) return; + this._appliedSeq = pending.loadSeq; + + const orderedNames = this._resolveOrderNames(pending.loadSeq, pending.orderedNames); + this._savedOrderNames = orderedNames; + this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0; + this._apps = reconcileAppOrder(pending.merged, orderedNames); + this.renderApps(pending.$el_window, { preservePage: true, instant: true }); + }, + + // Decide which saved-order snapshot a resolving load reconciles against. + // A fetch issued before the user's latest local order save carries a + // pre-save kv snapshot — replaying it would visibly revert the reorder, + // and permanently clobber it after the next save. A fetch issued after + // the save is at least as fresh and may carry a newer arrangement from + // another window, so it wins. Never resolve to the visible-only + // on-screen order: it would tail-append any app returning to the grid. + _resolveOrderNames (loadSeq, fetchedOrderNames) { + const fetchedBeforeSave = loadSeq <= (this._orderSavedAtSeq || 0); + return fetchedBeforeSave && Array.isArray(this._savedOrderNames) + ? this._savedOrderNames + : fetchedOrderNames; + }, + + // A local mutation of _apps (uninstall) must invalidate loads fetched + // before it — applying one would resurrect the pre-mutation state. Loads + // started after this call get a newer seq and still apply. Dropping the + // shared promise lets the next activation fetch fresh instead of joining + // the doomed load. + _invalidateInFlightLoads () { + this._loadSeq = (this._loadSeq || 0) + 1; + this._appliedSeq = this._loadSeq; + this._loadPromise = null; }, saveOrder () { this._hasCustomOrder = true; - const names = serializeAppOrder(this._apps); + // Merge with the previously saved order so names absent from the + // current list (e.g. apps whose installedApps page failed to load + // this session) keep their saved positions — the saved order is the + // only record of them, and stale names are harmless because + // reconcileAppOrder ignores them. + const names = mergeSavedOrder(serializeAppOrder(this._apps), this._savedOrderNames); + this._savedOrderNames = names; + // Loads already in flight fetched kv before this save; mark the + // boundary so their stale snapshot can't replay over it (see + // _resolveOrderNames). + this._orderSavedAtSeq = this._loadSeq || 0; try { const p = puter.kv.set(APPS_ORDER_KV_KEY, JSON.stringify(names)); if ( p && typeof p.catch === 'function' ) { @@ -897,26 +960,80 @@ const TabApps = { } }, - async loadApps ($el_window) { + loadApps ($el_window) { if ( this._drag ) { // Don't fetch/re-render on top of a live drag; cancel a pending // (not-yet-started) pickup so a rebuild can't strand it. if ( this._drag.started ) return; this._endDrag(false); } + // init and the initial-route onActivate both fire on open; join the + // in-flight load instead of issuing a duplicate request trio. + if ( this._loadPromise ) return this._loadPromise; + const p = this._fetchAndRenderApps($el_window).finally(() => { + if ( this._loadPromise === p ) this._loadPromise = null; + }); + this._loadPromise = p; + return p; + }, + + async _fetchAndRenderApps ($el_window) { + // Give each load a monotonically increasing id. An older/slower + // response must not clobber a newer one that already applied — or a + // reorder the user saved while a stale fetch was in flight. We gate on + // "already applied", not "latest started", so the first load to + // resolve still populates _apps (the pager's ResizeObserver needs + // _apps set as soon as any load resolves). + const loadSeq = (this._loadSeq = (this._loadSeq || 0) + 1); const $container = $el_window.find('.myapps-container'); try { - // Fetch the two app lists and the saved order together. - const [installedRes, launchRes, savedOrderRaw] = await Promise.all([ - fetch( - `${window.api_origin}/installedApps?orderBy=name&limit=100`, - { - headers: { 'Authorization': `Bearer ${puter.authToken}` }, - method: 'GET', - }, - ), + // Fetch the two app lists and the saved order together. The + // installedApps endpoint caps `limit` at 100 and paginates, so page + // through it — otherwise a user with >100 apps silently loses the + // rest from the grid and from search. Common case is a single page + // (a short page ends the loop before a second request). + const fetchAllInstalledApps = async () => { + const PAGE_SIZE = 100; + const MAX_PAGES = 50; // 5000 apps — a runaway backstop + const all = []; + for ( let page = 1; page <= MAX_PAGES; page++ ) { + try { + const res = await fetch( + `${window.api_origin}/installedApps?orderBy=name&limit=${PAGE_SIZE}&page=${page}`, + { + headers: { 'Authorization': `Bearer ${puter.authToken}` }, + method: 'GET', + }, + ); + const batch = await res.json(); + // An error payload (e.g. `{"error": ...}` on a 401/500) + // must fail the page — reading it as end-of-pagination + // would silently drop every installed app. + if ( ! Array.isArray(batch) ) { + throw new Error(`installedApps returned a non-array response (status ${res.status})`); + } + if ( batch.length === 0 ) break; + all.push(...batch); + if ( batch.length < PAGE_SIZE ) break; + } catch ( err ) { + // A first-page failure is a failed load. A later page + // failing must not fail the refresh — that would turn + // one flaky request among N into an empty (or frozen) + // grid. Return what we have and flag it incomplete; + // the merge below fills the gap from the previous + // list so the grid and saved order can't shrink. + if ( page === 1 ) throw err; + console.error(`Failed to fetch installedApps page ${page}; got ${all.length} apps before the failure:`, err); + return { apps: all, complete: false }; + } + } + return { apps: all, complete: true }; + }; + + const [installedResult, launchRes, savedOrderRaw] = await Promise.all([ + fetchAllInstalledApps(), fetch( `${window.api_origin}/get-launch-apps?icon_size=128`, { @@ -927,14 +1044,16 @@ const TabApps = { puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null), ]); - const installedApps = await installedRes.json(); + const installedApps = installedResult.apps; const launchData = await launchRes.json(); - // Normalize launch apps (recommended + recent) to same shape - const launchApps = [ - ...(launchData.recommended || []), - ...(launchData.recent || []), - ].map(app => ({ + // Normalize recommended launch apps to the tile shape. The + // recent list is deliberately unused: recents are open history, + // not installs, so they resurrected uninstalled apps' tiles and + // showed merely-visited sites as if installed. Anything the user + // actually uses appears via installedApps (opening an app grants + // it a permission). Recents still power the Home tab. + const launchApps = (launchData.recommended || []).map(app => ({ name: app.name, title: app.title, uid: app.uuid || app.uid || null, @@ -960,6 +1079,20 @@ const TabApps = { merged.push(app); } + // A page beyond the first failed: fill the gap with apps we + // already know about so one flaky request among N can't make + // apps vanish from the grid, from search, or from a subsequently + // saved order. Apps uninstalled remotely may linger until the + // next complete refresh — the same staleness any between-refresh + // window has. + if ( ! installedResult.complete && Array.isArray(this._apps) ) { + for ( const app of this._apps ) { + if ( seen.has(app.name) ) continue; + seen.add(app.name); + merged.push({ ...app }); + } + } + // Overlay the user's saved ordering (if any). New apps are appended // in their default order; stale names are ignored. let orderedNames = null; @@ -972,13 +1105,36 @@ const TabApps = { } catch ( _e ) { orderedNames = null; } - this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0; + // Skip only if a strictly newer load already applied its result. + if ( loadSeq < (this._appliedSeq || 0) ) return; + // A drag began while we were awaiting: rendering now would yank + // the grid out from under it, but the data must not be thrown + // away either — stash it for _endDrag to apply. + if ( this._drag?.started ) { + if ( ! this._pendingLoad || loadSeq > this._pendingLoad.loadSeq ) { + this._pendingLoad = { $el_window, merged, orderedNames, loadSeq }; + } + return; + } + this._pendingLoad = null; + this._appliedSeq = loadSeq; + // 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._apps = reconcileAppOrder(merged, orderedNames); + this._hasCustomOrder = Array.isArray(effectiveOrder) && effectiveOrder.length > 0; + + this._apps = reconcileAppOrder(merged, effectiveOrder); this.renderApps($el_window); } catch (e) { console.error('Failed to load installed apps:', e); - $container.html('

Failed to load apps

'); + // Only show the failure placeholder when nothing has loaded yet; a + // transient re-fetch error must not wipe a grid already on screen. + if ( ! this._apps ) { + $container.html('

Failed to load apps

'); + } } }, diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 93981d379..969ddad85 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -157,6 +157,53 @@ const TabFiles = { const _this = this; window.dashboard_object = _this; + // Refresh an existing row in place from an fs entry. Shared with + // UIDashboard's item.updated socket handler so the two paths can't + // drift — e.g. trashed items must show metadata.original_name, not + // the UID that is their raw name (matching renderItem). + window.UIDashboardFileItemUpdate = function ($row, file) { + // Minimal update payloads may omit fields — only write what the + // event actually carries, so it can't blank a correct name/path + // (or zero a size) that is already on screen. A metadata-only + // payload without original_name resolves to '' and is skipped too. + let displayName = file.name || ''; + try { + const meta = file.metadata ? JSON.parse(file.metadata) : null; + if ( meta && meta.original_name ) displayName = meta.original_name; + } catch { /* keep raw name */ } + if ( displayName ) { + $row.attr('data-name', displayName); + $row.find('.item-name').text(displayName); + $row.find('.item-name-editor').val(displayName); + } + if ( file.path ) $row.attr('data-path', file.path); + if ( typeof file.type !== 'undefined' ) $row.attr('data-type', file.type || ''); + // Refresh the visible Size/Modified cells too, not just the + // hidden data attributes, so a remote overwrite is reflected. + // Only when the payload actually carries the field — a minimal + // update event must not zero out a correct value on screen. + if ( typeof file.size !== 'undefined' ) { + $row.attr('data-size', file.size || 0); + if ( $row.attr('data-is_dir') !== '1' ) { + $row.find('.item-size').text(_this.formatFileSize(file.size)); + } + } + if ( file.modified ) { + $row.attr('data-modified', file.modified); + $row.find('.item-modified').text(window.timeago.format(file.modified * 1000)); + } + if ( + _this.currentView === 'grid' && + typeof file.thumbnail === 'string' && + file.thumbnail.length > 0 + ) { + $row.find('.item-icon img').attr('src', file.thumbnail); + } + // The footer's item-count/total-size line is computed from the + // rows' data-size attributes — keep it in step with the cell. + _this.updateFooterStats(); + }; + // Dashboard-compatible item creator for use by helpers.js and socket handlers. // Wraps renderItem() with a directory check so items are only added // when the user is viewing the relevant directory. @@ -171,27 +218,7 @@ const TabFiles = { // If item already exists in view, update in-place. const $existingRow = $(`.files-tab .files .item[data-uid='${file.uid}']`); if ( $existingRow.length > 0 ) { - // Match renderItem's display name: trashed items show - // metadata.original_name, not the UID that is their raw name. - let displayName = file.name || ''; - try { - const meta = file.metadata ? JSON.parse(file.metadata) : null; - if ( meta && meta.original_name ) displayName = meta.original_name; - } catch { /* keep raw name */ } - $existingRow.attr('data-name', displayName); - $existingRow.attr('data-path', file.path || ''); - $existingRow.attr('data-size', file.size || 0); - $existingRow.attr('data-modified', file.modified || 0); - $existingRow.attr('data-type', file.type || ''); - $existingRow.find('.item-name').text(displayName); - $existingRow.find('.item-name-editor').val(displayName); - if ( - _this.currentView === 'grid' && - typeof file.thumbnail === 'string' && - file.thumbnail.length > 0 - ) { - $existingRow.find('.item-icon img').attr('src', file.thumbnail); - } + window.UIDashboardFileItemUpdate($existingRow, file); return; } @@ -214,6 +241,9 @@ const TabFiles = { // Highlight animation to indicate newly added item $newRow.addClass('item-newly-added'); + + // Reflect the new item in the footer item count / total size. + _this.updateFooterStats(); }; this.renderingDirectory = false; @@ -1046,7 +1076,7 @@ const TabFiles = { const history_item = window.dashboard_nav_history[index]; items.push({ - html: `${history_item === window.home_path ? i18n('home') : path.basename(history_item)}`, + html: `${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}`, val: index, onClick: function (e) { window.dashboard_nav_history_current_position = e.value; @@ -1087,7 +1117,7 @@ const TabFiles = { const history_item = window.dashboard_nav_history[index]; items.push({ - html: `${history_item === window.home_path ? i18n('home') : path.basename(history_item)}`, + html: `${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}`, val: index, onClick: function (e) { window.dashboard_nav_history_current_position = e.value; @@ -1823,6 +1853,14 @@ const TabFiles = { r.classList.remove('selected'); }); + // Drop the shift-click anchor — it points at a row from the directory + // we're leaving, and a stale detached anchor makes the first shift-click + // in the new directory select nothing. + if ( window.latest_selected_item && ! document.body.contains(window.latest_selected_item) ) { + window.latest_selected_item = null; + window.active_element = null; + } + // Determine whether target is a path or uid const isPath = typeof target === 'string' && target.startsWith('/'); const readdirArg = isPath @@ -1836,6 +1874,24 @@ const TabFiles = { // network). Without this, renderingDirectory would stay true and // the guard above would block all further navigation. console.error('Failed to read directory:', err); + // The container was already emptied above; show a message instead of + // leaving a blank pane with no explanation. + this.$el_window.find('.files-tab .files').html(`
This folder couldn't be opened.
`); + // The list was emptied above; without this the footer keeps the + // previous directory's item count over a pane with zero rows. + this.updateFooterStats(); this.hideSpinner(); this.renderingDirectory = false; return; @@ -2035,21 +2091,24 @@ const TabFiles = { row.setAttribute("data-is_dir", file.is_dir ? "1" : "0"); row.setAttribute("data-is_trash", file.is_trash ? "1" : "0"); row.setAttribute("data-has_website", file.has_website ? "1" : "0"); - row.setAttribute("data-website_url", website_url ? html_encode(website_url) : ''); + // setAttribute stores values literally (no HTML parsing), so values must + // stay raw — encoding here would leave e.g. `&` inside data-path and + // break every fs operation that reads the attribute back. + row.setAttribute("data-website_url", website_url || ''); row.setAttribute("data-immutable", file.immutable ? "1" : "0"); row.setAttribute("data-is_shortcut", is_shortcut); - row.setAttribute("data-shortcut_to", html_encode(file.shortcut_to)); - row.setAttribute("data-shortcut_to_path", html_encode(file.shortcut_to_path)); + row.setAttribute("data-shortcut_to", file.shortcut_to ?? ''); + row.setAttribute("data-shortcut_to_path", file.shortcut_to_path ?? ''); row.setAttribute("data-is_worker", is_worker ? "1" : "0"); row.setAttribute("data-worker_url", is_worker ? worker_url : "0"); row.setAttribute("data-sortable", file.sortable ?? 'true'); row.setAttribute("data-metadata", JSON.stringify(metadata)); - row.setAttribute("data-sort_by", html_encode(file.sort_by) ?? 'name'); + row.setAttribute("data-sort_by", file.sort_by ?? 'name'); row.setAttribute("data-size", file.size); - row.setAttribute("data-type", html_encode(file.type) ?? ''); + row.setAttribute("data-type", file.type ?? ''); row.setAttribute("data-modified", file.modified); - row.setAttribute("data-associated_app_name", html_encode(file.associated_app?.name) ?? ''); - row.setAttribute("data-path", html_encode(file.path)); + row.setAttribute("data-associated_app_name", file.associated_app?.name ?? ''); + row.setAttribute("data-path", file.path); row.innerHTML = `
@@ -2140,38 +2199,61 @@ const TabFiles = { return; } - // Handle Shift+Click for range selection - if ( e.shiftKey && window.latest_selected_item && window.latest_selected_item !== el_item ) { - e.preventDefault(); - shift_clicked = true; + // Select the given rows (replacing the current selection unless + // additive) and make el_item the anchor. Shared by the range, + // no-anchor shift-click, and drag-handle paths so their selection + // bookkeeping can't drift apart. + const applySelection = (rows, additive = false) => { + if ( ! additive ) { + el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { + r.classList.remove('selected'); + }); + } + for ( const row of rows ) row.classList.add('selected'); + window.latest_selected_item = el_item; + window.active_element = el_item; + window.active_item_container = el_item.closest('.files'); + _this.updateFooterStats(); + }; + // Handle Shift+Click for range selection. Require the anchor to + // still be in this row list — a detached anchor (indexOf === -1) + // would otherwise set shift_clicked and then select nothing, + // leaving the click a no-op. The row lookup happens only under + // Shift: this handler is the hot path for every click in the list. + if ( e.shiftKey ) { const allRows = $(el_item).parent().find('.row').toArray(); - const clickedIndex = allRows.indexOf(el_item); - const lastSelectedIndex = allRows.indexOf(window.latest_selected_item); + const hasShiftAnchor = window.latest_selected_item + && allRows.indexOf(window.latest_selected_item) !== -1; + if ( hasShiftAnchor && window.latest_selected_item !== el_item ) { + e.preventDefault(); + shift_clicked = true; - if ( clickedIndex !== -1 && lastSelectedIndex !== -1 ) { - const start = Math.min(clickedIndex, lastSelectedIndex); - const end = Math.max(clickedIndex, lastSelectedIndex); + const clickedIndex = allRows.indexOf(el_item); + const lastSelectedIndex = allRows.indexOf(window.latest_selected_item); - // Clear selection if no Ctrl/Cmd held - if ( !e.ctrlKey && !e.metaKey ) { - el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { - r.classList.remove('selected'); - }); + if ( clickedIndex !== -1 && lastSelectedIndex !== -1 ) { + const start = Math.min(clickedIndex, lastSelectedIndex); + const end = Math.max(clickedIndex, lastSelectedIndex); + // Select the whole range; Ctrl/Cmd extends instead of + // replacing. + applySelection(allRows.slice(start, end + 1), e.ctrlKey || e.metaKey); + return; } - - // Select all items in range - for ( let i = start; i <= end; i++ ) { - allRows[i].classList.add('selected'); - } - - // Update latest selected to the clicked item - window.latest_selected_item = el_item; - window.active_element = el_item; - window.active_item_container = el_item.closest('.files'); - _this.updateFooterStats(); + } else if ( ! hasShiftAnchor ) { + // Shift-click with no valid anchor (e.g. the first click + // after navigating to a new directory): select just this + // item and make it the anchor. onclick skips selection + // while Shift is held, so without this the click would + // select nothing. Ctrl/Cmd+Shift extends instead of + // clearing. + e.preventDefault(); + shift_clicked = true; + applySelection([el_item], e.ctrlKey || e.metaKey); return; } + // Shift-click on the current anchor itself: deliberate no-op — + // it must not collapse an existing multi-selection. } // In select mode on mobile, treat taps like Ctrl+click (toggle selection) @@ -2182,15 +2264,8 @@ const TabFiles = { // 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 ) { - el_item.parentElement.querySelectorAll('.row.selected').forEach(r => { - r.classList.remove('selected'); - }); - el_item.classList.add('selected'); - window.latest_selected_item = el_item; - window.active_element = el_item; - window.active_item_container = el_item.closest('.files'); + applySelection([el_item]); itemWasSelectedOnMousedown = true; - _this.updateFooterStats(); return; } @@ -2832,11 +2907,16 @@ const TabFiles = { * @returns {string} Formatted size string (e.g., "1.5 MB") */ formatFileSize (bytes) { - if ( bytes === 0 ) return '0 B'; + const num = Number(bytes); + // Missing/invalid sizes (undefined, null, NaN) and non-positive values + // shouldn't render as "NaN undefined". + if ( ! Number.isFinite(num) || num <= 0 ) return '0 B'; const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100 } ${ sizes[i]}`; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + // Clamp the index so sizes beyond PB don't index past the array (which + // produced "1.5 undefined" for terabyte-plus files). + const i = Math.min(Math.floor(Math.log(num) / Math.log(k)), sizes.length - 1); + return `${Math.round((num / Math.pow(k, i)) * 100) / 100 } ${ sizes[i]}`; }, /** diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 30c5ef53c..49fad800e 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -73,7 +73,7 @@ function buildUsageHTML() { // Your Plan section h += '
'; - h += ''; + h += ''; h += `

${i18n('your_plan')}

`; h += '›'; h += '
'; @@ -235,9 +235,17 @@ const TabHome = { // bypass our dispatch, so we re-pull state + broadcast. const refresh = () => this.loadUsageData($el_window); const refreshAndBroadcast = async () => { - refresh(); + // Pull fresh whoami, then broadcast. The dispatched event is handled + // by the `refresh` listener below, so we don't call refresh() here — + // doing both is what caused every focus to fire duplicate reloads. + // The broadcast is the only thing that triggers the refresh, so cap + // the wait: a whoami that never settles (hung connection) must not + // leave the cards stale for the rest of the session. try { - await window.refresh_user_data?.(puter.authToken); + await Promise.race([ + window.refresh_user_data?.(puter.authToken), + new Promise(resolve => setTimeout(resolve, 8000)), + ]); } catch {} try { window.dispatchEvent( @@ -246,11 +254,22 @@ const TabHome = { } catch {} }; window.addEventListener('puter:subscription:changed', refresh); + // A single return-to-tab fires both `focus` and `visibilitychange`; + // coalesce them so we don't run the refresh (and refresh_user_data) + // twice in a row. + let refreshCoalesceTimer = null; + const scheduleRefreshAndBroadcast = () => { + if (refreshCoalesceTimer) return; + refreshCoalesceTimer = setTimeout(() => { + refreshCoalesceTimer = null; + }, 500); + refreshAndBroadcast(); + }; const onVisibility = () => { - if (document.visibilityState === 'visible') refreshAndBroadcast(); + if (document.visibilityState === 'visible') scheduleRefreshAndBroadcast(); }; document.addEventListener('visibilitychange', onVisibility); - window.addEventListener('focus', refreshAndBroadcast); + window.addEventListener('focus', scheduleRefreshAndBroadcast); // Handle app clicks $el_window.on('click', '.bento-recent-app', function (e) { @@ -259,9 +278,9 @@ const TabHome = { const appName = $(this).attr('data-app-name'); const targetLink = $(this).attr('data-target-link'); if (targetLink && targetLink !== '') { - window.open(targetLink, '_blank'); + window.open(targetLink, '_blank', 'noopener,noreferrer'); } else if (appName) { - window.open(`/app/${appName}`, '_blank'); + window.open(`/app/${appName}`, '_blank', 'noopener,noreferrer'); } }); @@ -386,7 +405,9 @@ const TabHome = { $el_window.find('.bento-plan-upgrade').text('Manage →').show(); } else { $badge.text('Upgrade for more features').addClass('free'); - $el_window.find('.bento-plan-upgrade').show(); + // Reset the label too — otherwise it keeps saying "Manage →" + // after a subscription lapses/cancels. + $el_window.find('.bento-plan-upgrade').text('Upgrade →').show(); } $el_window @@ -405,7 +426,8 @@ const TabHome = { // Load storage data try { const res = await puter.fs.space(); - let usage_percentage = ((res.used / res.capacity) * 100).toFixed(0); + // Guard capacity 0 — 0/0 would render literally as "NaN%". + let usage_percentage = res.capacity ? ((res.used / res.capacity) * 100).toFixed(0) : '0'; usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; let general_used = res.used; diff --git a/src/gui/src/UI/Dashboard/TabUsage.js b/src/gui/src/UI/Dashboard/TabUsage.js index 85e8c9433..4a6527cd7 100644 --- a/src/gui/src/UI/Dashboard/TabUsage.js +++ b/src/gui/src/UI/Dashboard/TabUsage.js @@ -23,6 +23,7 @@ let usageTableSortState = { direction: 'desc', // default descending (highest cost first) }; let usageTableData = []; // Store raw data for sorting +let usageTableRendered = false; // A table (possibly empty) has rendered from a successful load let usageTableExpanded = false; // Track if table is showing all rows const USAGE_TABLE_INITIAL_ROWS = 10; @@ -76,23 +77,17 @@ const TabUsage = {
`; }, init: ($el_window) => { - // Upgrade / Manage Plan button (same logic as billing tab) - const $planBtn = $($el_window).find('.usage-plan-btn'); - const hasSubscription = window.user?.subscription?.active; - if ( hasSubscription ) { - $planBtn.text('Manage Plan').addClass('manage').show(); - } else { - $planBtn.text('Upgrade').addClass('upgrade').show(); - } - $planBtn.on('click', (e) => { + setupPlanButton($el_window); + // Guard the click: UIUpgradeAccount only exists on hosted puter.com, and + // without the guard clicking would throw a TypeError. + $($el_window).find('.usage-plan-btn').on('click', (e) => { e.preventDefault(); - (new window.UIUpgradeAccount()).open_as_window(); + if ( typeof window.UIUpgradeAccount === 'function' ) { + (new window.UIUpgradeAccount()).open_as_window(); + } }); - update_usage_details($el_window); - $($el_window).find('.update-usage-details').on('click', function () { - update_usage_details($el_window); - }); + refreshUsageDetails($el_window); // Click handler for sortable table headers $($el_window).on('click', '.driver-usage-details-content-table th[data-sort]', function () { @@ -121,8 +116,59 @@ const TabUsage = { renderUsageTable(); }); }, + // Refresh whenever the tab is (re)activated — otherwise the usage numbers + // stay frozen at page-load time for the whole session (there is no other + // refresh path), diverging from the Home cards which do refresh. + onActivate: ($el_window) => { + setupPlanButton($el_window); + refreshUsageDetails($el_window); + }, }; +// init and the initial-route onActivate both fire when the dashboard opens +// directly on this tab; share the in-flight refresh instead of issuing +// duplicate request pairs. +let usageRefreshPromise = null; +function refreshUsageDetails ($el_window) { + if ( ! usageRefreshPromise ) { + usageRefreshPromise = update_usage_details($el_window).finally(() => { + usageRefreshPromise = null; + }); + } + return usageRefreshPromise; +} + +let planBtnRetryTimer = null; + +function setupPlanButton ($el_window, retryDelay = 250) { + const $planBtn = $($el_window).find('.usage-plan-btn'); + clearTimeout(planBtnRetryTimer); + // UIUpgradeAccount is only present on hosted puter.com; on a self-hosted + // install the button has no working target, so hide it entirely rather + // than showing a dead control. Hosted deployments can attach it *after* + // the dashboard initializes, though — retry with decaying backoff so a + // load-order race can't hide the button from a subscriber. Give up after + // ~30s: a script that hasn't landed by then never will (self-hosted), and + // onActivate re-checks on every return to the tab anyway. The bound also + // releases the captured $el_window instead of pinning it forever. + if ( typeof window.UIUpgradeAccount !== 'function' ) { + $planBtn.hide(); + if ( retryDelay <= 10000 ) { + planBtnRetryTimer = setTimeout( + () => setupPlanButton($el_window, retryDelay * 1.5), + retryDelay, + ); + } + return; + } + const hasSubscription = window.user?.subscription?.active; + $planBtn + .text(hasSubscription ? 'Manage Plan' : 'Upgrade') + .toggleClass('manage', !!hasSubscription) + .toggleClass('upgrade', !hasSubscription) + .show(); +} + function getSortIcon (column) { const isActive = usageTableSortState.column === column; const direction = usageTableSortState.direction; @@ -197,7 +243,7 @@ function renderUsageTable () { for ( const row of rowsToShow ) { h += ` - ${row.resource} + ${window.html_encode(row.resource.replaceAll('_dot_', '.'))} ${row.formattedUnits} ${row.formattedCost} `; @@ -225,10 +271,6 @@ function renderUsageTable () { } async function update_usage_details ($el_window) { - // Add spinning animation and record start time - const startTime = Date.now(); - $($el_window).find('.update-usage-details-icon').css('animation', 'spin 1s linear infinite'); - const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(res => { let monthlyAllowance = res.allowanceInfo?.monthUsageAllowance; // Actual month-to-date spend. `allowanceInfo.remaining` folds purchased @@ -274,10 +316,24 @@ async function update_usage_details ($el_window) { } renderUsageTable(); + usageTableRendered = true; + }).catch(err => { + console.error('Failed to load monthly usage:', err); + // Only show the failure note when nothing has rendered yet — a + // transient refresh error must not wipe a table the user is already + // looking at (mirrors the Apps grid's error handling). Track "has + // rendered", not data length: an account with zero usage renders a + // legitimately empty table. + if ( ! usageTableRendered ) { + $('.driver-usage-details-content').html( + '

Usage details are unavailable right now.

', + ); + } }); const spacePromise = puter.fs.space().then(res => { - let usage_percentage = (res.used / res.capacity * 100).toFixed(0); + // 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; let general_used = res.used; @@ -308,20 +364,12 @@ async function update_usage_details ($el_window) { width: `${host_usage_percentage }%`, 'background-color': storageColor, }); + }).catch(err => { + console.error('Failed to load storage usage:', err); }); // Wait for both promises to complete await Promise.all([monthlyUsagePromise, spacePromise]); - - // Ensure spinning continues for at least 1 second - const elapsed = Date.now() - startTime; - const minDuration = 1000; // 1 second - if ( elapsed < minDuration ) { - await new Promise(resolve => setTimeout(resolve, minDuration - elapsed)); - } - - // Remove spinning animation - $($el_window).find('.update-usage-details-icon').css('animation', ''); } export default TabUsage; \ No newline at end of file diff --git a/src/gui/src/UI/Dashboard/UIDashboard.js b/src/gui/src/UI/Dashboard/UIDashboard.js index 39e5da5fa..e5655bc88 100644 --- a/src/gui/src/UI/Dashboard/UIDashboard.js +++ b/src/gui/src/UI/Dashboard/UIDashboard.js @@ -71,12 +71,17 @@ async function UIDashboard (options) { // Dispatch 'dashboard-will-open' event to allow extensions to add tabs window.dispatchEvent(new CustomEvent('dashboard-will-open', { detail: { tabs } })); + // True if the untrusted route tab id (from the URL hash) names a real tab. + // The routing paths below ignore unknown ids outright — this also keeps a + // crafted hash (e.g. one containing a quote) from being interpolated into + // a jQuery selector, which throws and would otherwise leave the + // dashboard's event handlers unbound. + const isKnownTabId = tab => tabs.some(t => t !== '-' && t.id === tab); + // Tab to render active on open. Apps is the default (root URL / no hash); // Home is reached via #home. Fall back to Apps for an unknown/absent route. const routeTab = window.dashboard_initial_route?.tab; - const initialTabId = tabs.some(t => t !== '-' && t.id === routeTab) - ? routeTab - : 'apps'; + const initialTabId = isKnownTabId(routeTab) ? routeTab : 'apps'; let h = ''; @@ -273,8 +278,18 @@ async function UIDashboard (options) { // the freshly-added row instead of the stale one. const old_path = resp.old_path ?? resp.from_path; if ( old_path ) { - $(`.item[data-path='${html_encode(old_path)}']`).fadeOut(150, function () { + // Narrow by uid first when the payload carries one (uids are + // UUIDs, safe to interpolate into a selector) — a full-DOM scan + // per socket event is O(rows) on the hot path. The uid matches + // both the old and new rows, so still compare data-path (raw + // value, compared rather than interpolated so quotes/special + // characters in names can't break it) to pick the stale one. + const $candidates = resp.uid ? $(`.item[data-uid='${resp.uid}']`) : $('.item'); + $candidates.filter(function () { + return $(this).attr('data-path') === old_path; + }).fadeOut(150, function () { $(this).remove(); + window.dashboard_object?.updateFooterStats?.(); }); } @@ -288,8 +303,18 @@ async function UIDashboard (options) { if ( item.original_client_socket_id === window.socket.id ) return; if ( item.descendants_only ) return; - $(`.item[data-path='${html_encode(item.path)}']`).fadeOut(150, function () { + // Match by uid when present (O(1) attribute selector; uids are UUIDs, + // safe to interpolate) and fall back to a path scan for minimal + // payloads — a removed item has exactly one row either way. + const $rows = item.uid + ? $(`.item[data-uid='${item.uid}']`) + : $('.item').filter(function () { + return $(this).attr('data-path') === item.path; + }); + $rows.fadeOut(150, function () { $(this).remove(); + // Keep the footer item count / total size in sync with the removal. + window.dashboard_object?.updateFooterStats?.(); }); }); @@ -299,9 +324,9 @@ async function UIDashboard (options) { const $el = $(`.item[data-uid='${item.uid}']`); if ( $el.length === 0 ) return; - // Update data attributes - $el.attr('data-name', html_encode(item.name)); - $el.attr('data-path', html_encode(item.path)); + // Update data attributes (raw values — .attr() stores literally) + $el.attr('data-name', item.name); + $el.attr('data-path', item.path); // Update displayed name $el.find('.item-name').text(item.name); @@ -314,24 +339,11 @@ async function UIDashboard (options) { const $el = $(`.item[data-uid='${item.uid}']`); if ( $el.length === 0 ) return; - // Update data attributes - $el.attr('data-name', html_encode(item.name)); - $el.attr('data-path', html_encode(item.path)); - $el.attr('data-size', item.size); - $el.attr('data-modified', item.modified); - $el.attr('data-type', html_encode(item.type)); - - // Update displayed name - $el.find('.item-name').text(item.name); - $el.find('.item-name-editor').val(item.name); - - if ( - window.dashboard_object?.currentView === 'grid' - && typeof item.thumbnail === 'string' - && item.thumbnail.length > 0 - ) { - $el.find('.item-icon img').attr('src', item.thumbnail); - } + // Delegate to the shared row updater (defined in TabFiles.init) so + // this handler can't drift from renderItem's conventions — an inline + // copy here once showed trashed items' raw UID instead of their + // original name. + window.UIDashboardFileItemUpdate?.($el, item); }); window.socket.on('item.added', async (item) => { @@ -347,8 +359,9 @@ async function UIDashboard (options) { if ( window.dashboard_initial_route ) { const route = window.dashboard_initial_route; - // Activate the correct tab if not home - if ( route.tab && route.tab !== 'home' ) { + // Activate the correct tab if not home. An unknown tab id stays on + // the Apps default rendered above rather than being redirected. + if ( route.tab && route.tab !== 'home' && isKnownTabId(route.tab) ) { const tabId = route.tab; const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tabId}"]`); @@ -373,8 +386,18 @@ async function UIDashboard (options) { // Handle browser back/forward navigation // This handler is called for both hashchange (manual hash changes) and popstate (back/forward) + // A single back/forward fires BOTH popstate and hashchange; track the last + // handled URL so the second event doesn't run onActivate (and its fetches) again. + let lastHandledHref = window.location.href; const handleRouteChange = () => { + if ( window.location.href === lastHandledHref ) return; + lastHandledHref = window.location.href; const route = window.parseDashboardRoute(); + // Ignore unknown tab ids entirely (a stale bookmark hash, an in-page + // anchor): switching the user to another tab out from under them was + // never the behavior, and the early return also keeps untrusted + // hashes out of the selectors below. + if ( ! isKnownTabId(route.tab) ) return; const tab = route.tab; const filePath = route.path; @@ -445,8 +468,13 @@ async function UIDashboard (options) { document.querySelector('.dashboard-content').classList.add(section); // Reflect the current tab in the hash. Root (no hash) defaults to Apps, - // but selecting any tab — including Apps — shows its #tab. - history.pushState(null, '', `#${section}`); + // but selecting any tab — including Apps — shows its #tab. Only push a + // new history entry when the hash actually changes, so re-clicking the + // current tab doesn't stack duplicate entries that make Back a no-op. + if ( window.location.hash !== `#${section}` ) { + history.pushState(null, '', `#${section}`); + lastHandledHref = window.location.href; + } // Scroll content area to top $el_window.find('.dashboard-content').scrollTop(0); diff --git a/src/gui/src/UI/Dashboard/appOrder.js b/src/gui/src/UI/Dashboard/appOrder.js index 4f2c9cf52..5803c0c8f 100644 --- a/src/gui/src/UI/Dashboard/appOrder.js +++ b/src/gui/src/UI/Dashboard/appOrder.js @@ -71,3 +71,49 @@ export function serializeAppOrder (apps) { .map(app => app && app.name) .filter(name => typeof name === 'string' && name.length > 0); } + +/** + * Merge the order being saved with the previously saved one so that names + * absent from `currentNames` (e.g. apps on an installedApps page that failed + * to load this session) keep their saved positions instead of being dropped + * or demoted to the tail. Each missing name keeps its RANK: if k survivors + * (names in both lists) preceded it in the saved order, it is re-inserted + * after the k-th survivor of the new order — so a drag of some visible tile + * neither drags hidden apps along nor pushes them off their slots. Present + * names appear exactly in `currentNames` order. Kept beside + * {@link serializeAppOrder} because it produces the same persisted shape. + * + * @param {string[]} currentNames - the on-screen order being saved + * @param {string[]|null|undefined} previousNames - the last saved order + * @returns {string[]} + */ +export function mergeSavedOrder (currentNames, previousNames) { + const result = Array.isArray(currentNames) ? currentNames.slice() : []; + if ( ! Array.isArray(previousNames) || previousNames.length === 0 ) return result; + + const currentSet = new Set(result); + const prevSet = new Set(previousNames); + // A survivor is a name present in both lists; re-inserted missing names + // and brand-new names never count when advancing past a survivor. + const isSurvivor = name => currentSet.has(name) && prevSet.has(name); + + const seen = new Set(); + // Single forward pointer over `result`: for each survivor in the saved + // order it advances just past the next survivor; each missing name is + // spliced in at the pointer, which puts it right after the same number + // of survivors that preceded it in the saved order — its rank. + let at = 0; + for ( const name of previousNames ) { + if ( typeof name !== 'string' || name.length === 0 ) continue; + if ( seen.has(name) ) continue; + seen.add(name); + if ( currentSet.has(name) ) { + while ( at < result.length && ! isSurvivor(result[at]) ) at++; + at++; + continue; + } + result.splice(at, 0, name); + at++; + } + return result; +} diff --git a/src/gui/src/UI/Dashboard/appOrder.test.js b/src/gui/src/UI/Dashboard/appOrder.test.js index d3fc667b8..3e710cb4e 100644 --- a/src/gui/src/UI/Dashboard/appOrder.test.js +++ b/src/gui/src/UI/Dashboard/appOrder.test.js @@ -18,7 +18,7 @@ */ import { describe, it, expect } from 'vitest'; -import { reconcileAppOrder, serializeAppOrder } from './appOrder.js'; +import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder } from './appOrder.js'; const names = apps => apps.map(a => a.name); const mk = (...ns) => ns.map(n => ({ name: n })); @@ -86,3 +86,58 @@ describe('serializeAppOrder', () => { expect(names(reconcileAppOrder(apps, saved))).toEqual(['d', 'c', 'b', 'a']); }); }); + +describe('mergeSavedOrder', () => { + it('returns the current order when nothing was saved before', () => { + expect(mergeSavedOrder(['a', 'b'], null)).toEqual(['a', 'b']); + expect(mergeSavedOrder(['a', 'b'], [])).toEqual(['a', 'b']); + }); + + it('keeps present names exactly in the current order', () => { + expect(mergeSavedOrder(['c', 'a', 'b'], ['a', 'b', 'c'])).toEqual(['c', 'a', 'b']); + }); + + it('re-inserts a missing name after its surviving predecessor', () => { + // 'm' sat between 'b' and 'c' in the saved order; it must return to + // that slot, not be demoted to the tail. + expect(mergeSavedOrder(['a', 'b', 'c'], ['a', 'b', 'm', 'c'])).toEqual(['a', 'b', 'm', 'c']); + }); + + it('keeps a missing name at the front when it led the saved order', () => { + expect(mergeSavedOrder(['a', 'b'], ['m', 'a', 'b'])).toEqual(['m', 'a', 'b']); + }); + + it('keeps runs of missing names in their saved order', () => { + expect(mergeSavedOrder(['a', 'b'], ['a', 'm1', 'm2', 'b'])).toEqual(['a', 'm1', 'm2', 'b']); + }); + + it('keeps a missing name at its rank when visible tiles are rearranged', () => { + // 'm' was third; the user swapped 'a' and 'b'. 'm' stays third — it + // neither follows 'b' to the front nor gets demoted. + expect(mergeSavedOrder(['b', 'a', 'c'], ['a', 'b', 'm', 'c'])).toEqual(['b', 'a', 'm', 'c']); + }); + + it('preserves a truncated tail after a partial load and a drag', () => { + // Saved order covers 6 apps; only the first 4 loaded (page 2 failed) + // and the user dragged 'd' to the front. The unloaded tail must keep + // its saved position after the surviving 'c', not vanish. + const merged = mergeSavedOrder(['d', 'a', 'b', 'c'], ['a', 'b', 'c', 'd', 'e', 'f']); + expect(merged).toEqual(['d', 'a', 'b', 'c', 'e', 'f']); + // Round-trip: when the full list loads again, 'e' and 'f' come back + // in their saved slots. + expect(names(reconcileAppOrder(mk('a', 'b', 'c', 'd', 'e', 'f'), merged))) + .toEqual(['d', 'a', 'b', 'c', 'e', 'f']); + }); + + it('ignores unusable saved entries and duplicates', () => { + expect(mergeSavedOrder(['a'], ['', null, 'a', 'a', 'm'])).toEqual(['a', 'm']); + }); + + it('does not mutate its inputs', () => { + const current = ['a', 'b']; + const previous = ['b', 'm', 'a']; + mergeSavedOrder(current, previous); + expect(current).toEqual(['a', 'b']); + expect(previous).toEqual(['b', 'm', 'a']); + }); +}); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 171a6b660..c1b342d38 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -952,7 +952,6 @@ input.myapps-search::-webkit-search-decoration { -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; - white-space: nowrap; } /* Uninstall confirmation modal */ @@ -1815,7 +1814,7 @@ body.myapps-reordering .myapps-tile { } .dashboard-section-files .row.folder.ui-droppable-hover, -.dashboard-section-files .row.folder.selected.ui-droppable-over { +.dashboard-section-files .row.folder.selected.ui-droppable-hover { color: var(--dashboard-text); background-color: rgba(59, 130, 246, 0.1); border: 2px dashed var(--select-color); @@ -1901,14 +1900,16 @@ body.myapps-reordering .myapps-tile { } /* Dark mode support for native file drop */ -.window[data-color-scheme="dark"] .dashboard-section-files .files.native-drop-active { - background-color: rgba(100, 180, 255, 0.12); - outline-color: #4da3ff; -} +@media (prefers-color-scheme: dark) { + .dashboard-section-files .files.native-drop-active { + background-color: rgba(100, 180, 255, 0.12); + outline-color: #4da3ff; + } -.window[data-color-scheme="dark"] .dashboard-section-files .directories li.native-drop-target, -.window[data-color-scheme="dark"] .dashboard-section-files .files .row.folder.native-drop-target { - background-color: rgba(100, 180, 255, 0.2); + .dashboard-section-files .directories li.native-drop-target, + .dashboard-section-files .files .row.folder.native-drop-target { + background-color: rgba(100, 180, 255, 0.2); + } } .dashboard-section-files .files-footer { @@ -1926,7 +1927,7 @@ body.myapps-reordering .myapps-tile { align-items: center; justify-content: center; gap: 8px; - color: #666; + color: var(--dashboard-text-secondary); z-index: 10; } @@ -1936,7 +1937,7 @@ body.myapps-reordering .myapps-tile { } .dashboard-section-files .files-footer-separator { - color: #CCC; + color: var(--dashboard-text-muted); } /* Floating Selection Actions Bar */ @@ -2179,11 +2180,9 @@ body.myapps-reordering .myapps-tile { display: flex; align-items: center; justify-content: center; - /* background: #fafafa; */ + background: var(--dashboard-background); border-radius: 8px; overflow: hidden; - background: white; - border-radius: 2px; } .dashboard-section-files .files-tab .files.files-grid-view .row .item-icon img { @@ -2239,15 +2238,11 @@ body.myapps-reordering .myapps-tile { } } -/* Hide .item-more on desktop (non-touch devices) - use right-click context menu instead */ -.dashboard-section-files .files-tab:not(.touch-device) .files.files-list-view .row .item-more, -.dashboard-section-files .files-tab:not(.touch-device) .files.files-grid-view .row .item-more, -@media (hover: hover) { - .dashboard-section-files .files-tab:not(.touch-device) .files.files-grid-view .row:hover .item-more, - .dashboard-section-files .files-tab:not(.touch-device) .header .columns .item-more { - display: none !important; - } -} +/* NOTE: a rule hiding .item-more on desktop (non-touch) used to sit here, but + it was malformed (selector list running into an @media block) and therefore + never in effect — desktop users have always had the visible '⋯' button, and + they use it, so repairing the rule into effect would remove a working + affordance. Intentionally removed instead. */ .dashboard-section-files .files-tab .files.files-grid-view .row .item-name-editor { text-align: center; @@ -2444,12 +2439,6 @@ body.myapps-reordering .myapps-tile { transform: rotate(-45deg) translate(4px, -4px); } -.dashboard-sidebar-separator { - height: 1px; - background: var(--dashboard-border); - margin: 15px 0; -} - /* Responsive: tablet and below */ @media (max-width: 768px) { .dashboard-sidebar-header { @@ -2486,6 +2475,15 @@ body.myapps-reordering .myapps-tile { padding: 64px 16px 24px; } + /* The Files tab keeps its own `padding: 0 0 0 10px` (higher specificity) + so the rule above doesn't clear the fixed hamburger toggle for it. Push + the directories column down instead, so the toggle doesn't sit on top of + the first folder row (the column is hidden below 480px, so this only + affects the tablet range). */ + .dashboard-section-files .directories { + padding-top: 52px; + } + .myapps-search-wrap { padding-top: 60px; margin-bottom: 10px; @@ -3999,8 +3997,27 @@ body.myapps-reordering .myapps-tile { opacity: 0.8; } -/* Desktop - transparent backdrop */ -@media (min-width: 768px) { +/* Disabled item - inert and dimmed (mirrors the desktop context menu) */ +.context-menu-modal-dialog .context-menu-item--disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +/* Submenu chevron, pushed to the trailing edge */ +.context-menu-modal-dialog .context-menu-item-chevron { + margin-left: auto; + padding-left: 0.5rem; + color: var(--dashboard-text-muted, #94a3b8); + font-size: 1rem; +} + +/* Desktop - transparent backdrop. Written as the exact complement of the + mobile rules (all keyed to max-width: 768px) so that no viewport width — + not even a fractional one from browser zoom / DPR scaling — gets desktop + styling with the mobile dimming, or vice versa. (min-width: 769px would + leave widths strictly between 768px and 769px uncovered.) */ +@media not all and (max-width: 768px) { .context-menu-modal-backdrop { background-color: transparent; } diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index ce6c1fc3e..4522cd0ba 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -1814,15 +1814,24 @@ window.move_items = async function (el_items, dest_path, is_undo = false) { // skip next loop iteration because this iteration was successful item_with_same_name_already_exists = false; - // update all shortcut_to_path - $(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', fsentry.path); + // update all shortcut_to_path — compare raw attribute values + // (item rows store paths unencoded, so an html_encode()d + // selector misses names containing & < > " ') + const moved_from_path_lc = String($(el_item).attr('data-path') || '').toLowerCase(); + $('.item[data-shortcut_to_path]').filter(function () { + return String($(this).attr('data-shortcut_to_path')).toLowerCase() === moved_from_path_lc; + }).attr('data-shortcut_to_path', fsentry.path); // Remove all items with matching uids from their OLD location(s). // Exclude any row already at the item's new path: a concurrent // item.moved socket handler may have just created a row at the // destination (e.g. the dashboard file view showing the target // directory), and removing by uid alone would delete it too. - $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(`[data-path="${html_encode(fsentry.path)}" i]`).fadeOut(150, function () { + // Raw case-insensitive compare, for the same reason as above. + const dest_item_path_lc = fsentry.path.toLowerCase(); + $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(function () { + return String($(this).attr('data-path') || '').toLowerCase() === dest_item_path_lc; + }).fadeOut(150, function () { // find all parent windows that contain this item let parent_windows = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).closest('.window'); // remove this item diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index af63f8354..c82d67a1f 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -536,7 +536,16 @@ if (jQuery) { * @returns {{ tab: string, path: string|null }} Route object with tab name and optional file path */ function parseDashboardRoute() { - const hash = decodeURIComponent(window.location.hash.slice(1)); // Remove '#' and decode URL encoding + // decodeURIComponent throws URIError on a malformed percent-sequence (e.g. + // `#100%`). This runs at module load, so an unguarded throw blanks the whole + // GUI — fall back to the raw hash instead. + const rawHash = window.location.hash.slice(1); // Remove '#' + let hash; + try { + hash = decodeURIComponent(rawHash); + } catch { + hash = rawHash; + } if (!hash) return { tab: 'apps', path: null }; const parts = hash.split('/').filter(Boolean); // ['files', 'username', 'Documents']