From 183ab0780dd46bcd0099dbac6e16a09376b5597e Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Sat, 18 Jul 2026 20:39:57 -0700 Subject: [PATCH] fix: verified Dashboard bug fixes (routing, files, apps, usage, mobile menu, CSS) (#3406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: don't double-refresh Dashboard tabs on browser back/forward A single back/forward navigation fires both popstate and hashchange, so handleRouteChange ran twice, calling the tab's onActivate twice — every back/forward to the Apps tab issued duplicate /installedApps and /get-launch-apps requests and re-rendered the tab twice. Track the last handled URL and skip the second event. * fix: store raw values in Dashboard file-row data attributes renderItem passed html_encode()d strings to setAttribute, which stores them literally — a file named "Tom & Jerry.txt" got data-path="...Tom & Jerry.txt". Every flow that reads the attribute back (keyboard cut/copy/paste, delete-to-trash, drag-and-drop, selection actions) then hit the server with the mangled path and failed with "Entry not found". The socket item.renamed/item.updated handlers had the same problem via jQuery .attr(), and item.moved/item.removed matched rows with html_encode()d selector needles that can't match raw attributes. Store raw values everywhere (matching the desktop convention and the raw readers throughout TabFiles), match paths by comparison instead of selector interpolation, and repair the html_encode(x) ?? fallback expressions that could never take their right-hand side. Verified live: copy/paste of a name containing '&' now succeeds. * fix: escape folder names in Dashboard nav-history menu (stored XSS) The back/forward taphold menus interpolated path.basename(history_item) straight into UIContextMenu item html, which renders it verbatim. A folder named with an HTML/script payload executed when the user opened the history menu after visiting it. The desktop file manager already encodes this value; match it with html_encode(). Verified live: the payload now renders as inert text and its onerror never fires. * fix: harden Dashboard hash routing against malformed and unknown tabs Three routing defects, all reachable from a shareable URL: - A malformed percent-sequence (e.g. `#100%`) made parseDashboardRoute's decodeURIComponent throw at module load, blanking the entire GUI. Guard the decode and fall back to the raw hash. Verified: `#100%` now boots. - A hash whose tab segment contained a quote (`#foo"bar`) was interpolated into a jQuery selector that throws, leaving dashboard event handlers unbound. Resolve the route tab against the known tab set (falling back to Apps) before it ever reaches a selector. This also fixes an unknown hash activating the Apps section without its `.dashboard-content.apps` styling. - Re-clicking the already-active sidebar tab pushed duplicate history entries, so Back became a no-op until pressed repeatedly. Only pushState when the hash actually changes. Verified live for all three. * fix: keep Dashboard file list footer and size cells in sync on live updates Incremental updates only touched hidden data attributes, so the visible UI drifted from the data: - Adding a file via the socket (item.added) or creating one inserted a row but never called updateFooterStats, so the "N items · size" footer stayed frozen at the old count. Removals and moves had the same gap. - item.updated (and the in-place update in UIDashboardFileItem) rewrote data-size/data-modified but not the on-screen .item-size/.item-modified cells, so a remote overwrite left a stale size (e.g. "5 B" for a file that had grown to 2 KB). Refresh the footer after add/remove/move and repaint the size/modified cells on update. Verified live: add, overwrite, and delete now all reflect immediately. * fix: Dashboard Files shift-click selection after navigation and byte formatting Shift-click selection: - renderDirectory cleared the .selected class but left window.latest_selected_item pointing at a now-detached row from the previous directory. The shift handler set shift_clicked=true, found the anchor's index as -1, skipped the range select, and onclick then early-returned on shift_clicked — so the first shift-click in a new directory selected nothing. Reset the anchor when it detaches, only take the range path when the anchor is still in the list, and treat a shift-click with no valid anchor as a plain select that becomes the anchor (onclick otherwise ignores clicks while Shift is held). formatFileSize: - Returned "NaN undefined" for missing/invalid sizes and "1.5 undefined" for terabyte-plus files (sizes array stopped at GB). Guard non-finite and non-positive input, clamp the unit index, and add TB/PB. Verified live: first shift-click selects the item, a second shift-click extends the range, and formatFileSize returns 0 B / 1.5 TB / 2 PB. * fix: show a message instead of a blank pane when a Dashboard folder fails to open renderDirectory empties the file list before awaiting readdir, so a readdir rejection (permission lost, folder deleted from another session, network error) left a completely blank pane with only a console error. Render a centered "This folder couldn't be opened." message on the catch path. Verified live: a simulated readdir failure shows the message, clears the spinner, and leaves navigation working (next folder loads). * fix: Dashboard Usage/Home tab data freshness, errors, and dead controls TabUsage: - The Upgrade/Manage button was shown unconditionally and its click did `new window.UIUpgradeAccount()`, which only exists on hosted puter.com — on self-hosted it threw a TypeError from a dead button. Hide the button when UIUpgradeAccount is absent and guard the click. - The tab had no onActivate and bound its refresh to a nonexistent element, so usage numbers were frozen at page-load for the whole session. Add onActivate to refetch (verified: reactivating refetches). - Resource names rendered the backend's `_dot_` escaping literally ("gemini-2_dot_5-flash"); un-escape for display and html_encode the cell. - The two loaders had no error handling; a failed fetch left the tab blank with an unhandled rejection. Catch each and show a fallback. - Guard capacity 0 so storage no longer renders "NaN%". TabHome: - Plan button kept saying "Manage →" after a subscription lapsed; reset it to "Upgrade →" in the free branch. - Guard capacity 0 ("NaN%") on the storage card. - "Your Plan ›" card header had the arrow affordance but no target and did nothing on click; point it at the Usage tab like its siblings. - Returning to the tab fired both focus and visibilitychange, and refreshAndBroadcast both called refresh() and dispatched the event its own listener handles — up to 4 duplicate reloads per focus. Drop the direct call and coalesce the focus/visibility pair. TabHome + TabApps: - window.open(externalAppUrl, '_blank') lacked noopener, exposing the dashboard tab to reverse tabnabbing from an external app site. Add noopener,noreferrer. Verified live: Usage and Home render without NaN or console errors, the upgrade button is hidden on self-hosted, and 'Your Plan' now navigates. * fix: multiple Dashboard CSS defects (dead rules, contrast, responsive) - The desktop rule that hides the row ⋮ button ended two selectors with a comma before an @media block, so the whole construct was invalid and discarded — the ⋮ showed on every list-view row on desktop instead of deferring to right-click. Verified: it's now hidden (display:none). - Native-drop dark-mode styles keyed off .window[data-color-scheme="dark"], an attribute never set anywhere; converted to @media (prefers-color-scheme: dark) like every other dark rule so they actually apply. - .myapps-tile-label set white-space:nowrap after a 2-line -webkit-line-clamp, defeating the clamp so long app names clipped mid-glyph on one line; removed it. - .dashboard-sidebar-separator was defined a second time with a contradicting box model (background line + full-width margin), rendering a doubled, edge-to-edge divider; removed the duplicate so the inset border rule stands. - Grid-view .item-icon had a hardcoded background:white and border-radius:2px overriding its own border-radius:8px (glaring white tiles in dark mode, square-ish corners); use var(--dashboard-background) and keep 8px. - .files-footer used hardcoded #666/#CCC on theme-variable backgrounds (dim in dark, near-invisible separator in light); use the text/muted vars. - Fixed the .ui-droppable-over typo (jQuery UI emits ui-droppable-hover). - The context-menu backdrop's "desktop transparent" rule used min-width:768px while every mobile rule uses max-width:768px, overlapping at exactly 768px (a common tablet width) — a non-dimming, invisible modal shield; bumped to 769px. - At 481-768px the fixed hamburger toggle sat on top of the Files directories column's first folder because .dashboard-content.files padding wins over the media-query padding; add top clearance to the directories column there. Verified light-mode desktop is unchanged (directories padding still 16px, ⋮ hidden, footer colors resolve). * fix: only offer Uninstall for Dashboard apps where it actually sticks Uninstall was suppressed via a hardcoded 8-name allowlist that had drifted out of sync with the backend's ~26 recommended apps. For the ~18 unlisted recommended apps (Calculator, Code, the games, …) the menu offered Uninstall, revokeApp resolved, the tile vanished — then get-launch-apps re-added it on the next load and it reappeared, so the uninstall silently reverted. Compute uninstallability from the actual lists: an app is uninstallable only if it's in the user's installedApps AND not in the recommended list (and not a protected core app). Recommended apps — installed or not — resurrect on reload, so Uninstall is hidden for them. Verified live: a synthetic installed-not-recommended app shows Uninstall; a recommended app shows none. * fix: guard Dashboard Apps loads against stale overwrites and drag/error clobbering loadApps only checked for an in-progress drag before its await, and its catch wiped whatever was on screen. Three concurrency issues followed: - A slow, older load resolving after a newer one could overwrite the newer app list (and clobber a reorder the user saved while the stale fetch was in flight). Tag each load with an increasing id and skip applying one only when a strictly newer load has already applied — gating on "already applied" (not "latest started") so the first load to resolve still populates the list for the pager's ResizeObserver. - A drag that began while a load was awaiting could have the grid rebuilt out from under it; re-check the drag after the await. - A transient re-fetch error replaced a working grid with "Failed to load apps"; only show that placeholder when nothing has loaded yet. Verified live: a slow older load no longer clobbers a newer one, and the grid still renders normally on activation. * fix: page through all installed apps in the Dashboard instead of capping at 100 The /installedApps endpoint clamps limit to 100 and paginates, but the Apps tab fetched a single page — a user with more than 100 installed apps silently lost the alphabetically-last ones from both the grid and search, with no way to launch or uninstall them from the dashboard. Loop pages until a short one comes back (the common <100-app case still makes a single request). Verified: the request now carries &page=1 and stops after one page for a small account; the loop pulls all pages otherwise. * fix: make the mobile Dashboard context menu handle submenus, disabled items, and positioning The touch context-menu modal (used whenever maxTouchPoints > 0) had three defects: - Items with a submenu and no onClick ("New", "Open With") rendered as plain buttons that did nothing and didn't even close the menu — the two submenu-bearing actions were simply unreachable on touch. Drill into the submenu on tap, with a Back row to return. - disabled items were rendered as active buttons and executed their onClick, so a folder's disabled "Paste Into Folder"/"Publish as website" ran anyway. Render them inert (disabled attribute + dimmed class + a click-handler guard), mirroring the desktop UIContextMenu. - Non-touch devices that still route here (touchscreen laptops) got the modal pinned at a hardcoded left:300px, far from the tapped item on a wide screen. Center it over the target and clamp to the viewport. Verified live (with maxTouchPoints forced): "Open With" opens its submenu and Back returns; disabled "Paste Into Folder" is inert; the modal is positioned near the item. * fix: repair Apps-tab regressions from the Dashboard bug-fix pass - A non-array /installedApps response (an error payload) was read as end-of-pagination, silently rendering the grid without any installed apps; fail the load so the explicit error state shows instead. - A loadApps result that resolved mid-drag was discarded with no retry, freezing the grid on stale data for the rest of the visit; stash it and apply it after the drag ends, reconciled against the drag's final order so it can't undo a reorder whose save is still in flight. - Uninstall disappeared for recommended/recent apps, removing the only UI path that revokes their permissions; offer it again and instead set expectations in the confirm modal (the tile stays for apps that get-launch-apps re-adds). - init and the initial-route onActivate both fired a full load on open; share the in-flight load instead of issuing a duplicate request trio. * fix: repair Files/routing regressions from the Dashboard bug-fix pass - The no-anchor shift-click fallback also fired when shift-clicking the current anchor, collapsing an existing multi-selection to one item, and it ignored Ctrl/Cmd; only run it when there is genuinely no anchor, and keep the existing selection when Ctrl/Cmd is held. - Unknown hash values (a stale bookmark, an in-page anchor) were coerced to 'apps' and yanked the user off their current tab with a refetch and autofocus; ignore them outright, both at boot and on hashchange, while still keeping untrusted hashes out of selectors. - The item.updated socket handler re-implemented the row-refresh from TabFiles and had already drifted (trashed items showed their raw UID instead of metadata.original_name); extract a shared row updater and delegate to it. * fix: repair Usage-tab regressions from the Dashboard bug-fix pass - Hiding the plan button whenever window.UIUpgradeAccount was missing at check time could hide it for the whole session on hosted deployments that attach the script after dashboard init; keep re-checking for a while before concluding the install is self-hosted. - init and the initial-route onActivate both refreshed usage on a direct #usage open; share the in-flight refresh instead of issuing duplicate request pairs. * fix: repair Dashboard CSS regressions from the bug-fix pass - Repairing the malformed item-more rule put display:none into effect for the first time, removing the visible-and-used desktop row '⋯' button; drop the rule instead (it was never in effect on any release). - The desktop context-menu backdrop breakpoint (min-width: 769px) left fractional viewport widths between 768px and 769px with the mobile dimming; use the exact complement of the mobile max-width: 768px rules instead. * fix: make Dashboard app uninstall honest about recents and resilient mid-flight - Recently-opened apps were classified as sticky-removable, but the recent list is built from app-open history that a revoke doesn't touch, so their tiles reappeared on the next load — the very bug the uninstall gating set out to fix. Treat recents like recommended. - The confirm modal claimed every staying tile was 'provided by Puter', which is false for third-party recents; describe the actual reason. - Uninstall now marks in-flight loads stale so a fetch that started before the revoke can't resurrect the removed tile. - A pagination failure after the first page no longer discards the pages already fetched — one flaky request among N used to turn the whole grid into 'Failed to load apps'. * fix: keep the Dashboard Usage tab stable through transient failures - A failed refresh no longer wipes an already-rendered usage table; the unavailable note only shows when there is nothing on screen yet (mirrors the Apps grid's error handling). - The plan-button check now re-checks indefinitely with backoff instead of giving up after ~10s, so an arbitrarily late UIUpgradeAccount attachment can't leave a subscriber without the button all session. * fix: Dashboard Files footer sync and Home refresh resilience - The shared row updater now refreshes the footer's item-count/total line, which is computed from the data-size attributes it just wrote; a remote overwrite no longer leaves the row and footer disagreeing. - Size/Modified are only written when the update payload carries them, so a minimal event can't zero out correct values on screen. - A failed readdir now resets the footer instead of keeping the previous directory's counts over an empty pane. - Home's focus-refresh caps its wait on refresh_user_data, so a whoami that never settles can't leave the plan/usage cards stale forever. * fix: contain partial Apps loads and keep the uninstall flag current - A partial installed-apps list (a page beyond the first failed) now renders only when the grid is empty — it must never replace a complete grid already on screen — and saveOrder refuses to persist while the list is partial, since overwriting the saved order with a truncated one would drop the missing apps' positions for good. - Opening an app from the grid now flips its tile's uninstallable flag: the open puts it in the server-side recent list, so a revoke from that moment on leaves the tile in place, and the modal must not act on the stale load-time snapshot. - The in-flight-load invalidation moved into _invalidateInFlightLoads() so the seq protocol lives in one place. * fix: Usage-tab error guard, dead spinner, and poll decay - The transient-error guard now tracks 'a table has rendered' instead of data length, so an account with legitimately zero usage doesn't get its empty table replaced by the unavailable note on a flaky refresh. - update_usage_details animated a spinner element that exists nowhere in this tab and held every refresh open for an artificial 1s minimum; both halves of the dead control are now gone. - The plan-button recheck backs off to a 60s heartbeat instead of polling at 2s forever on installs where UIUpgradeAccount never appears. * fix: guard minimal file updates; tighten selection and socket hot paths - The shared row updater only writes the fields an update payload actually carries, so a minimal event can't blank a row's name/path — the same hazard the size/modified guards already covered. - The all-rows lookup in the selection handler now happens only under Shift instead of on every click, and the single-select block shared by the no-anchor shift-click and drag-handle paths lives in one selectSingle helper. - item.removed and item.moved narrow by data-uid (O(1) selector) before comparing paths instead of scanning every row per socket event. * fix: replace partial-load and uninstall-flag patches with convergent designs The previous round's containment patches each spawned new corner cases (a persistently failing page froze all refreshes; the partial-load saveOrder bail silently discarded reorders; the tile-open flag flip lost races with in-flight loads, server recording, and popup blockers). Replace both mechanisms: - A partial installed-apps fetch (later page failed) now always applies, supplemented with the apps already known from the previous list — the grid and search can't shrink from one flaky request, refreshes never freeze, and an empty grid still gets the fetched pages. - saveOrder carries over saved names missing from the current list instead of refusing to persist, so no app's saved position is ever dropped; stale names are harmless (reconcileAppOrder ignores them) and preserve the position of apps that return. - The load-time uninstallable snapshot is no longer patched client-side on tile open. Uninstall now revokes, optimistically removes the tile when expected to stick, and refetches — the grid converges to server-side truth instead of guessing at recents state. * fix: don't blank filenames on metadata-only updates; unify range selection - A payload carrying metadata but no name resolved to an empty display name and blanked the row's visible filename — skip the write when the resolved name is empty. - The shift-range branch now goes through the same applySelection helper as the no-anchor and drag-handle paths (it takes the row set), removing the last hand-rolled copy of the selection bookkeeping. * fix: rank-preserving saved-order merge; keep the pager put after uninstall - The saved-order carryover appended missing apps' names at the tail, permanently demoting their saved grid positions — the very thing it claimed to protect. Replace it with mergeSavedOrder in appOrder.js (beside its tested siblings, with unit tests): each missing name keeps its rank among surviving names, so a drag during a partial-load session neither drops hidden apps' positions nor teleports them behind the dragged tile. - The post-uninstall convergence refetch rendered without preservePage, snapping the pager back to page 1 and replaying the load fade; thread render options through loadApps so the background sync keeps the user's page. * fix: keep hidden apps' saved ranks through the mid-drag stash path - _applyPendingLoad reconciled a stashed load against the visible-only on-screen order, tail-appending any app returning to the grid and contradicting the order the drag had just saved — the rank-demotion defect resurfacing through one more path. It now prefers _savedOrderNames, the canonical saved list that saveOrder keeps merged with hidden apps at their ranks, over the possibly pre-drag kv snapshot the stashed load fetched. - The post-uninstall optimistic render now also skips the load fade; it was the one in-place rebuild still hiding the grid behind the opacity-0 icon gate. - mergeSavedOrder's rank rescan collapsed to a single forward insertion pointer (verified output-identical; the 20 unit tests pin the behavior). * fix: gate stale kv-order snapshots on the latest local save A drag that started and ended while a load was in flight escaped the stash path entirely: the load applied normally and replayed its pre-drag kv snapshot, visibly reverting the just-saved reorder (and permanently clobbering it after the next save). Conversely, the stash path unconditionally preferred the local record, discarding a genuinely fresher order fetched from another window when the drag committed nothing. Record the load-seq boundary at each saveOrder; a resolving load (both apply paths, via _resolveOrderNames) replays its fetched kv order only when it was issued after the latest local save — otherwise the canonical in-memory saved list wins. Also rewrites the _applyPendingLoad header, which described the pre-rank-merge implementation. * Simplify app uninstall tile handling Remove the uninstallability flag and modal warning logic, and treat uninstall as a local UI removal after permission revoke. This avoids an immediate reload that could re-add recommended/recent apps, while stale in-flight loads are still invalidated. Also simplifies app loading by dropping unused render options from `loadApps`/`_fetchAndRenderApps` and related merge comments. * fix: match raw item paths in move_items cleanup and shortcut re-point Item rows store data-path/data-shortcut_to_path unencoded, so the html_encode()d attribute selectors missed names containing & < > " ' and the destination-row exclusion guard could remove a legitimate row just created by a concurrent item.moved handler. Compare raw values case-insensitively instead. * fix: bound the Usage plan-button retry poll The UIUpgradeAccount retry chain decayed to a 60s heartbeat but never stopped, leaving self-hosted installs polling forever and pinning the captured $el_window. Give up after ~30s of decaying retries; onActivate re-checks on every return to the tab, so late script loads still get the button. * fix: drop the recent-opens list from the Apps tab grid Recents are open history, not installs: they resurrected uninstalled apps' tiles and showed merely-visited sites as if installed. The grid is now recommended + installedApps; anything the user actually uses still appears because opening an app grants it a permission. Recents still power the Home tab. --- .../UI/Dashboard/ContextMenu/ContextMenu.js | 110 ++++++--- src/gui/src/UI/Dashboard/TabApps.js | 214 +++++++++++++++--- src/gui/src/UI/Dashboard/TabFiles.js | 214 ++++++++++++------ src/gui/src/UI/Dashboard/TabHome.js | 40 +++- src/gui/src/UI/Dashboard/TabUsage.js | 108 ++++++--- src/gui/src/UI/Dashboard/UIDashboard.js | 88 ++++--- src/gui/src/UI/Dashboard/appOrder.js | 46 ++++ src/gui/src/UI/Dashboard/appOrder.test.js | 57 ++++- src/gui/src/css/dashboard.css | 79 ++++--- src/gui/src/helpers.js | 15 +- src/gui/src/initgui.js | 11 +- 11 files changed, 747 insertions(+), 235 deletions(-) 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']