Reworked the Files view toggle into a context menu with List, Compact Grid (`grid-sm`), and Grid modes, and refactored view handling into shared helpers (`isGridView`, `viewClass`, `setView`, `applyViewMode`) so grid behavior is applied consistently across rendering and drag previews. Added compact-grid CSS overrides to reduce tile, icon, text, and badge sizing while reusing existing grid layout behavior.
Switches the Files tab header navigation buttons to new thin arrow SVG assets and aligns icon styling with a lighter visual weight. It also updates several header action SVG paths (new folder, upload, list/grid, sort, select) and sets path action icon color to #5c5c5c so the controls look consistent.
Apps tab: tile rows only budget one label line, so two-line app names
made flexbox shrink the icon box into a landscape crop and clip the
label mid-glyph. Clamp labels to a single line and exempt the icon box
from flex shrinking.
Files tab: header hover states (nav arrows, breadcrumbs, action
buttons) now use a neutral #EEE chip and no longer recolor the glyph;
nav arrows also render at full opacity when enabled.
Prevent app tile icons from shrinking when labels overflow, and clamp tile labels to a single line so they fit within the fixed tile height. This keeps the grid layout stable while preserving full app names in the existing tooltip.
* 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.
On iOS/WebKit the pager's `touch-action: pan-x` made horizontal touchmove
non-cancelable, so the moment a long-press drag moved the finger the
browser started a native page-pan and fired pointercancel, aborting the
reorder before it visibly began. Preventing pointermove doesn't stop
native scrolling on iOS, and the reordering `touch-action: none` rule was
applied only after the drag began — too late, since touch-action is
latched at gesture start.
Drop the explicit pan-x (back to auto) so horizontal touchmove is
cancelable, and install a non-passive touchmove listener that
preventDefaults only once reordering is armed (post long-press). Before
arming it's a no-op, so a quick swipe still flips pages natively.
* feat(dashboard): drag-to-reorder apps in the Apps tab
Let users arrange their My Apps tiles in any order via drag-and-drop.
The order is stored per-user in puter.kv (`dashboard_apps_order`) as a
list of app names and re-applied on load; apps installed since the order
was saved are appended, and uninstalled ones are dropped.
Interaction:
- Mouse/pen: drag past a small threshold to reorder; a plain click still
opens the app.
- Touch: long-press to pick up a tile (a quick swipe still flips pages).
- Dragging to a scroller edge dwells, then flips to the adjacent page so
tiles can move across pages; the floating drag image lives on <body> so
the pager's overflow can't clip it, and displaced tiles reflow with a
FLIP animation.
- Escape (or losing the window) cancels and reverts; reordering is
disabled while a search filter is active; prefers-reduced-motion is
honored.
Also fixes a latent pager bug where programmatic page changes (arrows,
dots, wheel) didn't update the active page indicator, because those
scrolls don't reliably emit 'scroll' events; goToPage now tracks the
page eagerly.
The order-reconciliation logic is extracted into a pure module
(appOrder.js) with unit tests.
* fix(dashboard): ignore stray pointers and no-op drags
- Filter drag pointermove/up/cancel by pointerId so a second finger can't
hijack or prematurely end an in-progress reorder.
- Only persist a new order when the drop actually changed it, so an
accidental long-press or drop-in-place doesn't freeze the default
ordering into a custom one.
* fix(dashboard): address review of app drag-to-reorder
Correctness:
- Preserve the touch long-press → Uninstall path. A long-press now *arms*
reordering instead of immediately grabbing the tile; moving begins the
drag, while holding still lets the native context menu fire (which
cancels the pending pickup). This restores the only touch route to
Uninstall that the first cut had removed.
- Only swallow the post-drag click when the drop actually reordered
something, so a small pointer drift or a drop-in-place still opens the
app like a plain click.
- Guard the pre-start "intent" window: cancel a pending pickup before any
re-render (ResizeObserver / loadApps), and bail out of _beginDrag if the
pressed tile was detached, so a layout change mid-press can't reinsert a
stale node and persist a corrupted order.
Cleanup:
- Reuse reconcileAppOrder for the drop path instead of a duplicate helper.
- Read the saved order in parallel with the app-list fetches.
- Cache the reduced-motion MediaQueryList instead of re-querying per move.
- Fold the touch-scroll cancel into _endDrag(false); drop _abortDragIntent.
* feat(dashboard): smoother, more tolerant app reordering
Make drag-to-reorder feel like the iOS home screen instead of snapping
and flickering:
- Slow the reflow to 320ms with a gentle spring easing.
- Hit-test against each tile's *resting* box, not its live
getBoundingClientRect. Mid-animation a tile sits between slots, so
testing its live box was dropping it under the pointer and swapping it
straight back — the source of the back-and-forth jitter.
- Require the dragged icon's centre to be well inside a tile before it
becomes the drop target (a deadzone around each tile), so hovering a
boundary no longer thrashes.
- Probe with the dragged icon's centre rather than the fingertip, and
batch the FLIP into a single reflow so large grids stay smooth.
* redesign: compact, scannable Dashboard Session Manager
The session list rendered each entry as a bulky card with a 5-row
Created/Last active/Expires/IP/Client key-value table and a large filled-red
Revoke button, so only a couple of sessions fit on screen and destructive
actions dominated the layout.
Reworked it into an icon-led, compact list:
- One row per session: a device/kind icon tile (laptop / phone / globe by OS,
bolt for workers, key for API tokens, the real app icon for app sessions),
the title + badges, and two tight meta lines (client · IP, then a fainter
last-active · created · expires with absolute-time tooltips).
- Destructive actions are calm: per-row Revoke and the rename pencil are quiet
and reveal on hover (always shown on touch); "Revoke all other sessions" is a
subtle ghost button instead of a filled block competing with search.
- Current session gets a green accent, tint, and pill, and has no revoke button.
- Toolbar search gains an inline icon + focus ring; added a session count line;
restyled kind badges; nested child sessions get a proper tree line.
Styling uses the existing dashboard design tokens. All behavior is preserved:
search filtering, parent/child tree with expand/collapse, inline rename
(optimistic + rollback), per-session revoke (access-token vs session routing),
revoke-all, current-session guard, and focus/interval refresh.
Adds ui_session_count_one / ui_session_count_other to en.js (other locales
fall back to en).
* refactor: render Session Manager as a responsive modal, not a UIWindow
Replaces the draggable UIWindow shell with a self-contained DOM modal:
- Backdrop + centered card on desktop (max 680px / 90vh); full-screen sheet
on phones (<=640px). Fade/scale in, respects prefers-reduced-motion.
- Adds a header bar (title + close), backdrop-click and Escape to close, and
a scroll-contained body.
- Confirmations no longer use UIAlert (itself a UIWindow). Revoke, revoke-all,
and the rename-error path now use in-modal confirm/alert sheets, so there is
no cross-window z-index juggling — important because this can be opened from
a stay_on_top window (UIWindowCopyToken).
Drops the UIWindow and UIAlert imports entirely. All behavior is preserved:
search, parent/child tree + expand/collapse, inline rename (optimistic +
rollback), per-session revoke (access-token vs session routing), revoke-all,
current-session guard, and the focus/60s-interval refresh (cleaned up on close).
* fix: prevent crash rendering website files without a workers array in Dashboard Files
TabFiles.renderItem accessed `file.workers.length` unguarded when deciding
whether to show the website badge. Directory entries that have a published
website but no `workers` array (the common case) made this throw
`Cannot read properties of undefined (reading 'length')`, so the row failed
to render — the file silently disappeared from the list (swallowed by the
Promise.allSettled batch) or was never added on socket-driven updates.
The sibling `is_worker` derivation on the same entry already guards with
`file.workers?.length`, and the desktop reference (UIItem.js) normalizes
`workers` to an array and shows the badge via `!is_worker`. Reuse the safe
`is_worker` flag here instead of touching `file.workers.length` directly.
* fix: correct Dashboard usage that showed negative spend for users with credits
The Usage tab and Home usage card derived month-to-date spend as
`monthUsageAllowance - allowanceInfo.remaining`. But the metering service
computes `remaining = remainingAllowance + remainingPurchasedCredits`
(MeteringService.getAllowedUsage), so `remaining` exceeds the monthly
allowance whenever a user holds purchased credits — making the displayed
spend and percentage negative (e.g. "-$9.90 used of $0.25", "-3960%").
Use the actual reported spend (`res.usage.total`, already returned by the
same endpoint) and clamp the percentage/bar to 100%. For a free user with no
credits and no overage this yields the identical value as before.
* fix: make Dashboard Files Download button work for a single selection
In mobile select mode the floating action bar appears with one item selected
(minCountForActionBar is 1) and shows an enabled Download button, but the
click handler only acted when 2+ rows were selected — so downloading a single
file did nothing.
Gate on `> 0` instead. window.zipItems already normalizes a single item to an
array, so the one-file case works. On desktop the bar only appears at 2+
selections, so this changes nothing there.
* fix: auto-fit column width in Dashboard Files no longer scales with row count
Double-clicking a column resize handle is meant to fit the column to its
longest cell. The per-row loop did `maxWidth = Math.max(maxWidth + 10, textWidth)`,
which forces `maxWidth` to grow by at least 10px on every row regardless of
content — so a folder with N files produced a column ~10*N px wide and
persisted that runaway width to KV storage.
Compare against the running max only (`Math.max(maxWidth, textWidth)`);
`textWidth` already includes cell padding.
* fix: clear empty-directory placeholder when a file is added incrementally
When a directory is empty, renderDirectory appends an absolutely-positioned
"No files in this directory." placeholder. If a file then appears via an
incremental path (socket item.added/item.moved -> UIDashboardFileItem), the
new row was inserted but the placeholder was never removed, so the "No files"
text overlapped the real item. insertAtSortedPosition can't clear it because
the placeholder has no `.item.row` class.
Remove the placeholder in UIDashboardFileItem before inserting the row,
matching what the in-app New Folder / New File flows already do.
* fix: stop leaking a document keydown handler per uninstall-modal dismissal
showUninstallModal bound `keydown.uninstall-modal` on document but only
detached it on the Escape and Confirm paths. Dismissing via Cancel or a
backdrop click called `close()`, which just removed the overlay and left the
handler attached (closing over a now-detached overlay). Each open→cancel
cycle stacked another document-level listener for the page lifetime.
Move the `.off('keydown.uninstall-modal')` into `close()` so every dismissal
path cleans up, and drop the now-redundant explicit detaches.
* fix: resolve 2FA setup promise on every close so Dashboard toggle can't freeze
UIWindow2FASetup only resolved its promise from the Done button and, in
on_before_exit, when setup had NOT succeeded. Clicking "Enable" sets
setup_succeeded=true and advances to the recovery-codes screen without
resolving; if the user then closes that screen via the backdrop or Escape
instead of Done, the promise never settles.
The Dashboard Security tab is the only caller: it disables the 2FA toggle and
`await`s this promise, so the hang leaves the toggle permanently disabled and
stuck in the pending state for the rest of the session. Resolve with
setup_succeeded on any exit; resolve_promise is idempotent so a prior
Done-resolve is unaffected.
* fix: refresh Dashboard tabs when navigating via browser back/forward
The sidebar-click and initial-route handlers both call the target tab's
onActivate (its refresh hook), but handleRouteChange — wired to hashchange
and popstate — only swapped the active classes. Since tab switches use
history.pushState (which doesn't fire popstate/hashchange), the only way to
reach handleRouteChange is browser back/forward, and doing so left tabs like
Home/Apps showing stale data.
Look up the tab by id and call onActivate after switching, matching the
sidebar-click path.
* fix: refresh Home "Recently used" apps instead of freezing them for the session
loadRecentApps only hit /get-launch-apps when window.launch_apps.recent was
empty. Once populated (typically at page load), every subsequent init/onActivate
/focus just re-rendered the same cached array, so apps launched during the
session never showed up under "Recently used" until a hard reload.
Drop the empty-list guard so the list refetches on the same triggers that
already refresh the usage cards. The fetch can only make the list fresher.
* fix: sync window.user.otp when enabling 2FA from the Dashboard
The disable path clears window.user.otp (in UIWindowDisable2FA), but the
enable path updated only the card's DOM, never the in-memory flag. TabSecurity
html() reads user.otp to decide the toggle/badge state, so after enabling 2FA
a re-render of the Security tab from the cached window.user showed it as
disabled for an account that actually had 2FA on.
Set window.user.otp = enabling on success so both directions stay consistent.
renderItem() showed the shortcut badge whenever file.is_shortcut !== 0.
Directory listings return is_shortcut as 0, but the puter.fs.upload()
result used to incrementally render newly-created items omits the field,
so undefined !== 0 was true and every new file got a phantom shortcut
badge. Normalize is_shortcut to 0/1 before using it.
* Restore Files tab in dashboard
Reinstates the Files tab (TabFiles) that was removed in 973d046d, bringing
back the dashboard file browser: directory navigation, list/grid views,
sorting, drag-and-drop, context menus, upload, and trash support.
- Restore src/gui/src/UI/Dashboard/TabFiles.js (identical to pre-removal)
- Re-wire UIDashboard.js: tab registration, initial file path, item socket
handlers, and route-based file navigation
- Re-add file-tab CSS in dashboard.css and the selection-area rule in style.css
- Re-add dashboard-mode item creation hooks in helpers.js move_items
- Re-add #files/<path> deep-link parsing in initgui.js parseDashboardRoute,
while preserving Apps as the default dashboard tab
* Only remove stale rows when moving items
* Fix Dashboard Files tab bugs
- Escape filenames in renderItem to prevent stored XSS via HTML in file
names (matches UIItem); raw name is still preserved for display/rename.
- Read clone data-id from the nested .row in drag drop handlers so
multi-select drag moves all selected items, not just the grabbed one.
- Resolve sidebar drop target from the element's data-path so dragging
onto Public/Home works (they aren't keyed in user.directories); fix the
matching UID-vs-path comparison in the drag-out handler.
- Set data-is_worker/data-worker_url from is_worker itself instead of an
always-true "!== undefined" check, so worker context-menu behavior only
applies to actual workers.
- Give delete-confirmation buttons explicit values so permanent delete
works in non-English locales.
- Refresh the view after a context-menu move-paste, mirroring Ctrl+V.
- Guard the item.updated socket handler against the client's own echo,
matching the other handlers and UIDesktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Prevent Files tab lockup when readdir fails
renderDirectory sets renderingDirectory=true then awaits puter.fs.readdir
with no error handling. readdir rejects on any backend error (permission,
deleted directory, network), leaving renderingDirectory stuck true so the
re-entry guard blocks all future navigation and the spinner never clears.
Catch the rejection and reset state.
* Isolate render failures so one bad entry can't lock the Files tab
renderItem parsed file.metadata with an unguarded JSON.parse, but metadata
is client-writable and stored verbatim, so '', undefined, or malformed
values throw (item_icon.js guards the identical call). Combined with
Promise.all, one bad entry rejected the whole render and left the tab
frozen. Guard the parse and use Promise.allSettled for per-item isolation.
* Clear selection when clicking empty area in Files tab
The empty-area click handler is an arrow function, so `this` was the
TabFiles object rather than the container element, making `e.target === this`
always false. Only clicks landing exactly on `.files` deselected. Use
e.currentTarget so clicks on the container background deselect as intended.
* Avoid stacking image-preview document click handlers
showImagePreview bound a click.imagepreview handler without removing a
prior one. It is re-invoked during image arrow-navigation while a preview
is open, so identical document handlers accumulated until close. Remove
any existing handler before binding.
* Sort live-inserted items by display name
insertAtSortedPosition compared the new item's raw file.name against
existing rows' data-name (the display name). For trashed items, name is
the UID while the real name is in metadata.original_name, so newly trashed
items landed in the wrong position in Trash until a full re-render. Compare
on the new row's data-name to match sortFiles and the existing rows.
* Use display name in in-place item update
UIDashboardFileItem's in-place update path set data-name and the shown
name from raw file.name, unlike renderItem which uses
metadata.original_name || file.name. An update to an already-visible
trashed item replaced its name with the UID and corrupted data-name
(which sort and type-to-search read). Compute the display name to match.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test: tests for puter.js
* fix: ship lockfile for coverage devDeps; tolerate missing base coverage
npm ci failed on CI because package.json gained the babel/istanbul
devDependencies without the matching package-lock.json update. Also make
the coverage workflow's base leg best-effort so a base ref that predates
the coverage script reports without the comparison column instead of
failing the run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* typeify subdomains wip
* initial (untested) logic for LocalWorkerService
* Make it work, add lifecycle expiry since workers are process heavy in current implementation
* fix type errors
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Show a subtle centered loading spinner for the Apps dashboard when fetching apps if the request is slow. Implements a 1.5s delay before showing the spinner and guarantees a minimum 1.5s visible time to avoid blinking; the spinner is only shown when the container is empty (so background refreshes keep existing tiles). Adds a finish helper to clear the spinner timer and await the minimum visible time before rendering results or errors. Also adds CSS for the spinner element and its animations.