diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index e951ecf3a..2691be6f8 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -1,5 +1,6 @@ import UIContextMenu from '../UIContextMenu.js'; import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; +import { reconcileAppOrder, serializeAppOrder, 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([ @@ -13,7 +14,7 @@ const APP_NAMES_NO_UNINSTALL = new Set([ 'ai', ]); -/** +/* * Copyright (C) 2024-present Puter Technologies Inc. * * This file is part of Puter. @@ -32,6 +33,20 @@ const APP_NAMES_NO_UNINSTALL = new Set([ * along with this program. If not, see . */ +// -- Drag-to-reorder tuning -- +const DRAG_START_DISTANCE = 5; // px a mouse/pen must travel before a drag begins +const DRAG_TOUCH_CANCEL_DISTANCE = 10; // px of finger travel that reclassifies a press as a scroll +const DRAG_TOUCH_LONGPRESS_MS = 450; // hold time before a touch begins reordering +const DRAG_EDGE_ZONE = 60; // px from a scroller edge that arms a page flip +const DRAG_EDGE_DWELL_MS = 480; // hold time at an edge before the page flips +const DRAG_FLIP_SETTLE_MS = 440; // time to let a page flip's smooth-scroll settle +const DRAG_FLIP_ANIM_MS = 320; // reflow animation duration (iOS-like, unhurried) +const DRAG_FLIP_EASING = 'cubic-bezier(0.34, 1.08, 0.64, 1)'; // gentle spring settle +// A tile only becomes the drop target once the dragged icon's centre is well +// inside it (this fraction is trimmed off every edge). The resulting deadzone +// around each tile is what stops items flickering back and forth at a boundary. +const DRAG_HIT_INSET = 0.28; + // External apps (not owned by a Puter user) can report an opaque app-… id // as their title (uid === name === title); in that case show the hostname // of index_url instead, and open the app's website (index_url) on click — @@ -80,10 +95,10 @@ function buildNoAppsHtml () { // iOS-home-screen-style pager: fixed cols × rows pages in a horizontal // scroll-snap scroller, with page dots below and hover arrows for mouse users. -function buildPagerHtml (apps, layout) { +function buildPagerHtml (apps, layout, instant) { const pageCount = Math.ceil(apps.length / layout.perPage); - let h = `
`; + let h = `
`; h += '
'; for ( let p = 0; p < pageCount; p++ ) { @@ -149,6 +164,9 @@ function showUninstallModal ({ appName, appTitle, appUid, self, $el_window }) { try { await puter.perms.revokeApp(appUid, '*'); 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 }); } catch ( err ) { console.error('Failed to uninstall app:', err); @@ -198,6 +216,10 @@ const TabApps = { _layout: null, _page: 0, _pageCount: 0, + _hasCustomOrder: false, + _drag: null, + _justDragged: false, + _reduceMotionMQL: undefined, html () { let h = '
'; @@ -239,6 +261,11 @@ const TabApps = { $el_window.on('click', '.myapps-tile', function (e) { e.preventDefault(); e.stopPropagation(); + // A click synthesized at the end of a drag must not open the app. + if ( self._justDragged ) { + self._justDragged = false; + return; + } const appName = $(this).attr('data-app-name'); const targetLink = $(this).attr('data-target-link'); if ( targetLink && targetLink !== '' ) { @@ -248,8 +275,20 @@ const TabApps = { } }); + // Start a drag-to-reorder gesture. Kept separate from click so a plain + // click still opens the app (see _onTilePointerDown for the threshold / + // long-press logic that distinguishes the two). + $el_window.on('pointerdown', '.myapps-tile', function (e) { + self._onTilePointerDown($el_window, e, this); + }); + // Context menu on right-click $el_window.on('contextmenu', '.myapps-tile', function (e) { + // Suppress the menu (and any touch long-press callout) mid-drag. + if ( self._drag && self._drag.started ) { + e.preventDefault(); + return; + } const appName = $(this).attr('data-app-name'); const appTitle = $(this).attr('data-app-title'); const appUid = $(this).attr('data-app-uid'); @@ -275,6 +314,12 @@ const TabApps = { if ( items.length === 0 ) return; + // A touch long-press arms a drag pickup (see _onTilePointerDown). If + // the user held rather than dragged, they want this menu — cancel the + // pending pickup so the long-press → Uninstall path keeps working on + // touch. An already-started drag was handled by the guard above. + if ( self._drag ) self._endDrag(false); + e.preventDefault(); e.stopPropagation(); @@ -341,6 +386,13 @@ const TabApps = { clearTimeout(self._resizeTimer); self._resizeTimer = setTimeout(() => { if ( ! self._apps ) return; + if ( self._drag ) { + // Don't rebuild the DOM out from under an in-progress drag. + if ( self._drag.started ) return; + // A press is pending but hasn't become a drag; cancel it so a + // rebuild can't detach the tile the pickup is waiting on. + self._endDrag(false); + } const layout = self.computeLayout($el_window.find('.myapps-container')); if ( ! layout ) return; if ( self._layout && layout.cols === self._layout.cols && layout.rows === self._layout.rows ) { @@ -384,8 +436,8 @@ const TabApps = { // Central renderer: applies the current search query to _apps and rebuilds // the pager (or an empty state). Everything that changes what's shown — - // load, search, uninstall, re-layout — funnels through here. - renderApps ($el_window, { preservePage = false } = {}) { + // load, search, uninstall, re-layout, reorder — funnels through here. + renderApps ($el_window, { preservePage = false, instant = false } = {}) { if ( ! this._apps ) return; const $container = $el_window.find('.myapps-container'); @@ -431,7 +483,7 @@ const TabApps = { this._pageCount = Math.ceil(list.length / layout.perPage); this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1); - $container.html(buildPagerHtml(list, layout)); + $container.html(buildPagerHtml(list, layout, instant)); revealWhenLoaded($container); const scroller = $container.find('.myapps-pager-scroller')[0]; @@ -480,19 +532,384 @@ const TabApps = { const scroller = $el_window.find('.myapps-pager-scroller')[0]; if ( ! scroller || this._pageCount === 0 ) return; const page = Math.max(0, Math.min(this._pageCount - 1, index)); - const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; scroller.scrollTo({ left: page * scroller.clientWidth, - behavior: (smooth && !reduceMotion) ? 'smooth' : 'auto', + behavior: (smooth && !this._reduceMotion()) ? 'smooth' : 'auto', + }); + // Programmatic scrolls don't reliably emit 'scroll' events (so the + // scroll-driven sync above can miss them); track the page eagerly. + this._page = page; + this.updatePagerUI($el_window); + }, + + _reduceMotion () { + // Cache the live MediaQueryList — this is read on every pointermove + // during a drag, and matchMedia() is comparatively expensive. + if ( this._reduceMotionMQL === undefined ) { + this._reduceMotionMQL = window.matchMedia + ? window.matchMedia('(prefers-reduced-motion: reduce)') + : null; + } + return !! (this._reduceMotionMQL && this._reduceMotionMQL.matches); + }, + + // -- Drag-to-reorder -- + + _onTilePointerDown ($el_window, e, tileEl) { + const oe = e.originalEvent || e; + // Primary button / touch / pen only; right-click falls through to the + // context menu. + if ( oe.button !== undefined && oe.button !== 0 ) return; + if ( this._drag ) return; + if ( ! this._apps || this._apps.length < 2 ) return; + // Reordering a filtered subset is ambiguous — only reorder the full list. + const query = String($el_window.find('.myapps-search').val() || '').trim(); + if ( query ) return; + + const pointerType = oe.pointerType || 'mouse'; + const d = this._drag = { + $el_window, + tileEl, + pointerType, + pointerId: oe.pointerId, + startX: oe.clientX, + startY: oe.clientY, + lastClientX: oe.clientX, + lastClientY: oe.clientY, + offsetX: 0, + offsetY: 0, + started: false, + readyToDrag: pointerType !== 'touch', // touch must long-press first + ghost: null, + edgeTimer: null, + edgeDir: 0, + flipping: false, + flipClearTimer: null, + longPressTimer: null, + }; + + // Ignore events from a second pointer (e.g. a stray finger) so it can't + // hijack or prematurely end an in-progress drag. + const isDragPointer = ev => ev.pointerId === undefined || ev.pointerId === d.pointerId; + d.onMove = ev => { if ( isDragPointer(ev) ) this._onDragPointerMove(ev); }; + d.onUp = ev => { if ( isDragPointer(ev) ) this._endDrag(true); }; + d.onCancel = ev => { if ( isDragPointer(ev) ) this._endDrag(false); }; + d.onKey = ev => { if ( ev.key === 'Escape' ) this._endDrag(false); }; + d.onBlur = () => this._endDrag(false); + + document.addEventListener('pointermove', d.onMove, { passive: false }); + document.addEventListener('pointerup', d.onUp); + document.addEventListener('pointercancel', d.onCancel); + document.addEventListener('keydown', d.onKey); + window.addEventListener('blur', d.onBlur); + + // Touch: a long-press *arms* reordering (it doesn't grab the tile yet). + // Moving after that begins the drag; holding still instead lets the + // native long-press context menu (Uninstall) fire. A finger that moves + // before the long-press is a page swipe and cancels the intent (see + // _onDragPointerMove). + if ( pointerType === 'touch' ) { + d.longPressTimer = setTimeout(() => { + if ( this._drag !== d || d.started ) return; + d.readyToDrag = true; + if ( navigator.vibrate ) { + try { navigator.vibrate(8); } catch ( _e ) { /* not supported */ } + } + }, DRAG_TOUCH_LONGPRESS_MS); + } + }, + + _onDragPointerMove (e) { + const d = this._drag; + if ( ! d ) return; + + if ( ! d.started ) { + const dist = Math.hypot(e.clientX - d.startX, e.clientY - d.startY); + if ( ! d.readyToDrag ) { + // Touch, pre-long-press: a moving finger is a page swipe. + if ( dist > DRAG_TOUCH_CANCEL_DISTANCE ) this._endDrag(false); + return; + } + if ( dist <= DRAG_START_DISTANCE ) return; + d.lastClientX = e.clientX; + d.lastClientY = e.clientY; + this._beginDrag(); + // _beginDrag bails (without starting) if the tile was detached by a + // re-render during the pre-start window; don't touch a dead drag. + if ( ! d.started ) return; + // Fall through so this same event also places the tile — a coarse + // (few-event) drag still reorders instead of just lifting. + } + + // Now committed to a drag — stop native scrolling/selection. + e.preventDefault(); + d.lastClientX = e.clientX; + d.lastClientY = e.clientY; + this._positionGhost(e.clientX, e.clientY); + if ( d.flipping ) return; + this._maybeEdgeFlip(e.clientX); + if ( d.flipping ) return; + this._updatePlaceholder(e.clientX, e.clientY); + }, + + _beginDrag () { + const d = this._drag; + if ( ! d || d.started ) return; + // A re-render during the pre-start window can detach the pressed tile; + // starting a drag on a stale node would corrupt the persisted order. + if ( ! d.tileEl.isConnected ) { this._endDrag(false); return; } + d.started = true; + clearTimeout(d.longPressTimer); + d.longPressTimer = null; + + const rect = d.tileEl.getBoundingClientRect(); + d.offsetX = d.startX - rect.left; + d.offsetY = d.startY - rect.top; + d.tileW = rect.width; + d.tileH = rect.height; + + const ghost = d.tileEl.cloneNode(true); + ghost.classList.add('myapps-drag-ghost'); + ghost.classList.remove('has-open-contextmenu'); + ghost.removeAttribute('title'); + ghost.style.width = rect.width + 'px'; + ghost.style.height = rect.height + 'px'; + ghost.style.transformOrigin = `${d.offsetX}px ${d.offsetY}px`; + document.body.appendChild(ghost); + d.ghost = ghost; + this._positionGhost(d.lastClientX, d.lastClientY); + + d.tileEl.classList.add('myapps-tile-dragging'); + document.body.classList.add('myapps-reordering'); + }, + + _positionGhost (x, y) { + const d = this._drag; + if ( ! d || ! d.ghost ) return; + const scale = this._reduceMotion() ? 1 : 1.06; + d.ghost.style.transform = + `translate(${x - d.offsetX}px, ${y - d.offsetY}px) scale(${scale})`; + }, + + _maybeEdgeFlip (px) { + const d = this._drag; + const scroller = d.$el_window.find('.myapps-pager-scroller')[0]; + if ( ! scroller || this._pageCount < 2 ) return; + + const r = scroller.getBoundingClientRect(); + let dir = 0; + if ( px >= r.right - DRAG_EDGE_ZONE ) dir = 1; + else if ( px <= r.left + DRAG_EDGE_ZONE ) dir = -1; + + const atEnd = (dir === 1 && this._page >= this._pageCount - 1); + const atStart = (dir === -1 && this._page <= 0); + if ( dir === 0 || atEnd || atStart ) { + clearTimeout(d.edgeTimer); + d.edgeTimer = null; + d.edgeDir = 0; + return; + } + + if ( d.edgeTimer && d.edgeDir === dir ) return; // already dwelling this way + clearTimeout(d.edgeTimer); + d.edgeDir = dir; + d.edgeTimer = setTimeout(() => { + d.edgeTimer = null; + d.edgeDir = 0; + if ( this._drag !== d ) return; + d.flipping = true; + this.goToPage(d.$el_window, this._page + dir, true); + clearTimeout(d.flipClearTimer); + d.flipClearTimer = setTimeout(() => { + if ( this._drag !== d ) return; + d.flipping = false; + this._updatePlaceholder(d.lastClientX, d.lastClientY); + }, this._reduceMotion() ? 60 : DRAG_FLIP_SETTLE_MS); + }, DRAG_EDGE_DWELL_MS); + }, + + // Slot the (invisible) placeholder into the tile the dragged icon is + // hovering over, animating the displaced tiles with FLIP. The placeholder + // may hop in from another page (cross-page reorder). + // + // Two things keep this from jittering: + // 1. Hit-testing uses each tile's *resting* rect (its final layout box), + // not getBoundingClientRect — mid-FLIP a tile is visually somewhere + // between slots, and testing its live box would swap it straight back. + // 2. A tile only counts as the target when the dragged icon's centre is + // well inside it (DRAG_HIT_INSET), so hovering a boundary does nothing. + _updatePlaceholder (px, py) { + const d = this._drag; + if ( ! d ) return; + const pageEl = d.$el_window.find('.myapps-page').toArray()[this._page]; + if ( ! pageEl ) return; + + // Probe with the dragged icon's centre rather than the fingertip, so the + // drop follows where the tile visually is. + const probeX = px - d.offsetX + d.tileW / 2; + const probeY = py - d.offsetY + d.tileH / 2; + + const tiles = Array.from(pageEl.querySelectorAll('.myapps-tile')); + const phIndex = tiles.indexOf(d.tileEl); + + let overIndex = -1; + for ( let i = 0; i < tiles.length; i++ ) { + const t = tiles[i]; + if ( t === d.tileEl ) continue; + const r = t.__myappsRestRect || t.getBoundingClientRect(); + const insetX = r.width * DRAG_HIT_INSET; + const insetY = r.height * DRAG_HIT_INSET; + if ( probeX >= r.left + insetX && probeX <= r.right - insetX && + probeY >= r.top + insetY && probeY <= r.bottom - insetY ) { + overIndex = i; + break; + } + } + + // In a gap / over the placeholder itself: leave the arrangement alone. + if ( overIndex === -1 ) return; + + // Move the placeholder to that tile's slot; everything between cascades. + // After the move the probe sits over the vacated gap, so it won't bounce. + const overTile = tiles[overIndex]; + const refNode = (phIndex === -1 || overIndex < phIndex) + ? overTile + : overTile.nextElementSibling; + + this._flipMove(tiles.filter(t => t !== d.tileEl), () => { + if ( refNode ) pageEl.insertBefore(d.tileEl, refNode); + else pageEl.appendChild(d.tileEl); }); }, + // First-Last-Invert-Play, interruption-safe. Records each tile's true + // resting rect (transforms cleared first) so an interrupting reorder + // continues smoothly and hit-testing always reads a stable position. + _flipMove (tiles, mutate) { + // FIRST: current visual boxes (may be mid-animation). + const first = new Map(); + for ( const t of tiles ) first.set(t, t.getBoundingClientRect()); + + mutate(); + + // LAST: clear any in-flight transform, then measure the true resting box. + for ( const t of tiles ) { + t.style.transition = 'none'; + t.style.transform = ''; + } + const rest = new Map(); + for ( const t of tiles ) { + const b = t.getBoundingClientRect(); + rest.set(t, b); + t.__myappsRestRect = b; + } + if ( this._reduceMotion() ) return; + + // INVERT: offset each tile from its resting box back to where it was. + const moved = []; + for ( const t of tiles ) { + const a = first.get(t); + const b = rest.get(t); + const dx = a.left - b.left; + const dy = a.top - b.top; + if ( dx === 0 && dy === 0 ) continue; + t.style.transform = `translate(${dx}px, ${dy}px)`; + moved.push(t); + } + if ( moved.length === 0 ) return; + void moved[0].offsetWidth; // one reflow commits every inverted offset + // PLAY: release to the resting box. + for ( const t of moved ) { + t.style.transition = `transform ${DRAG_FLIP_ANIM_MS}ms ${DRAG_FLIP_EASING}`; + t.style.transform = ''; + } + }, + + _teardownDragListeners (d) { + document.removeEventListener('pointermove', d.onMove, { passive: false }); + document.removeEventListener('pointerup', d.onUp); + document.removeEventListener('pointercancel', d.onCancel); + document.removeEventListener('keydown', d.onKey); + window.removeEventListener('blur', d.onBlur); + clearTimeout(d.longPressTimer); + clearTimeout(d.edgeTimer); + clearTimeout(d.flipClearTimer); + }, + + _endDrag (commit) { + const d = this._drag; + if ( ! d ) return; + this._drag = null; + this._teardownDragListeners(d); + document.body.classList.remove('myapps-reordering'); + + if ( ! d.started ) { + // Never became a drag — leave the click to open the app. + return; + } + + d.tileEl.classList.remove('myapps-tile-dragging'); + + let changed = false; + if ( commit ) { + const names = d.$el_window.find('.myapps-page .myapps-tile').toArray() + .map(t => t.getAttribute('data-app-name')); + const current = this._apps.map(a => a.name); + // Only persist when the order actually changed, so an accidental + // long-press or drop-in-place doesn't freeze the default ordering. + changed = names.length !== current.length + || names.some((name, i) => name !== current[i]); + if ( changed ) { + this._apps = reconcileAppOrder(this._apps, names); + this.saveOrder(); + } + } + + // Swallow the click synthesized after this pointerup only when the drag + // actually reordered something — a drift/no-op press should still open + // the app, matching a plain click. + if ( changed ) { + this._justDragged = true; + clearTimeout(this._justDraggedTimer); + this._justDraggedTimer = setTimeout(() => { this._justDragged = false; }, 350); + } + + const ghost = d.ghost; + if ( ghost ) { + ghost.classList.add('myapps-drag-ghost-drop'); + setTimeout(() => ghost.remove(), this._reduceMotion() ? 0 : 160); + } + + // Rebuild so pages rebalance to exactly perPage; skip the load fade. + this.renderApps(d.$el_window, { preservePage: true, instant: true }); + }, + + saveOrder () { + this._hasCustomOrder = true; + const names = serializeAppOrder(this._apps); + try { + const p = puter.kv.set(APPS_ORDER_KV_KEY, JSON.stringify(names)); + if ( p && typeof p.catch === 'function' ) { + p.catch(err => console.error('Failed to save app order:', err)); + } + } catch ( err ) { + console.error('Failed to save app order:', err); + } + }, + async 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); + } + const $container = $el_window.find('.myapps-container'); try { - // Fetch both APIs in parallel - const [installedRes, launchRes] = await Promise.all([ + // 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`, { @@ -507,6 +924,7 @@ const TabApps = { method: 'GET', }, ), + puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null), ]); const installedApps = await installedRes.json(); @@ -542,7 +960,21 @@ const TabApps = { merged.push(app); } - this._apps = merged; + // Overlay the user's saved ordering (if any). New apps are appended + // in their default order; stale names are ignored. + let orderedNames = null; + try { + if ( savedOrderRaw ) { + orderedNames = typeof savedOrderRaw === 'string' + ? JSON.parse(savedOrderRaw) + : savedOrderRaw; + } + } catch ( _e ) { + orderedNames = null; + } + this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0; + + this._apps = reconcileAppOrder(merged, orderedNames); this.renderApps($el_window); } catch (e) { console.error('Failed to load installed apps:', e); diff --git a/src/gui/src/UI/Dashboard/appOrder.js b/src/gui/src/UI/Dashboard/appOrder.js new file mode 100644 index 000000000..4f2c9cf52 --- /dev/null +++ b/src/gui/src/UI/Dashboard/appOrder.js @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** kv key under which the user's custom My Apps ordering is stored. */ +export const APPS_ORDER_KV_KEY = 'dashboard_apps_order'; + +/** + * Reorder `apps` to honour a user-defined order expressed as a list of app + * names. Apps present in `orderedNames` come first, in that order; apps not + * mentioned (e.g. installed since the order was saved) keep their incoming + * relative order and are appended at the end. Names in `orderedNames` that no + * longer correspond to an app are ignored. The input array is not mutated. + * + * @param {Array<{name: string}>} apps + * @param {string[]|null|undefined} orderedNames + * @returns {Array<{name: string}>} + */ +export function reconcileAppOrder (apps, orderedNames) { + if ( ! Array.isArray(apps) ) return []; + if ( ! Array.isArray(orderedNames) || orderedNames.length === 0 ) { + return apps.slice(); + } + + const rank = new Map(); + for ( let i = 0; i < orderedNames.length; i++ ) { + const name = orderedNames[i]; + // First occurrence wins so a corrupt/duplicated entry can't reshuffle. + if ( typeof name === 'string' && ! rank.has(name) ) { + rank.set(name, i); + } + } + + const known = []; + const unknown = []; + for ( const app of apps ) { + if ( rank.has(app.name) ) known.push(app); + else unknown.push(app); + } + + known.sort((a, b) => rank.get(a.name) - rank.get(b.name)); + + return [...known, ...unknown]; +} + +/** + * Serialize the persisted order from a list of apps. Kept beside + * {@link reconcileAppOrder} so the read and write shapes stay in lockstep. + * + * @param {Array<{name: string}>} apps + * @returns {string[]} + */ +export function serializeAppOrder (apps) { + if ( ! Array.isArray(apps) ) return []; + return apps + .map(app => app && app.name) + .filter(name => typeof name === 'string' && name.length > 0); +} diff --git a/src/gui/src/UI/Dashboard/appOrder.test.js b/src/gui/src/UI/Dashboard/appOrder.test.js new file mode 100644 index 000000000..d3fc667b8 --- /dev/null +++ b/src/gui/src/UI/Dashboard/appOrder.test.js @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { reconcileAppOrder, serializeAppOrder } from './appOrder.js'; + +const names = apps => apps.map(a => a.name); +const mk = (...ns) => ns.map(n => ({ name: n })); + +describe('reconcileAppOrder', () => { + it('returns a copy in the original order when there is no saved order', () => { + const apps = mk('a', 'b', 'c'); + const out = reconcileAppOrder(apps, null); + expect(names(out)).toEqual(['a', 'b', 'c']); + expect(out).not.toBe(apps); + }); + + it('treats an empty saved order the same as none', () => { + expect(names(reconcileAppOrder(mk('a', 'b'), []))).toEqual(['a', 'b']); + }); + + it('applies a full saved order', () => { + const out = reconcileAppOrder(mk('a', 'b', 'c'), ['c', 'a', 'b']); + expect(names(out)).toEqual(['c', 'a', 'b']); + }); + + it('appends apps missing from the saved order, preserving their order', () => { + // 'd' and 'e' were installed after the order was saved. + const out = reconcileAppOrder(mk('a', 'b', 'c', 'd', 'e'), ['c', 'a']); + expect(names(out)).toEqual(['c', 'a', 'b', 'd', 'e']); + }); + + it('ignores saved names that no longer correspond to an app', () => { + // 'x' was uninstalled; it must not resurrect or shift anything. + const out = reconcileAppOrder(mk('a', 'b'), ['x', 'b', 'a']); + expect(names(out)).toEqual(['b', 'a']); + }); + + it('is stable against a duplicated saved entry', () => { + const out = reconcileAppOrder(mk('a', 'b', 'c'), ['b', 'b', 'a']); + expect(names(out)).toEqual(['b', 'a', 'c']); + }); + + it('does not mutate the input array', () => { + const apps = mk('a', 'b', 'c'); + reconcileAppOrder(apps, ['c', 'b', 'a']); + expect(names(apps)).toEqual(['a', 'b', 'c']); + }); + + it('handles non-array inputs defensively', () => { + expect(reconcileAppOrder(null, ['a'])).toEqual([]); + expect(names(reconcileAppOrder(mk('a'), 'nonsense'))).toEqual(['a']); + }); +}); + +describe('serializeAppOrder', () => { + it('extracts names in order', () => { + expect(serializeAppOrder(mk('a', 'b', 'c'))).toEqual(['a', 'b', 'c']); + }); + + it('drops entries without a usable name', () => { + const apps = [{ name: 'a' }, { name: '' }, {}, null, { name: 'b' }]; + expect(serializeAppOrder(apps)).toEqual(['a', 'b']); + }); + + it('round-trips with reconcileAppOrder', () => { + const apps = mk('a', 'b', 'c', 'd'); + const reordered = reconcileAppOrder(apps, ['d', 'c', 'b', 'a']); + const saved = serializeAppOrder(reordered); + expect(names(reconcileAppOrder(apps, saved))).toEqual(['d', 'c', 'b', 'a']); + }); +}); diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index 96a05654f..c98ba0152 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -1023,28 +1023,60 @@ input.myapps-search::-webkit-search-decoration { cursor: default; } -/* Custom sort drag-and-drop */ -.myapps-grid-custom .myapps-tile { - cursor: grab; +/* -- Drag-to-reorder -- */ + +/* The tile being dragged is kept in the grid as an invisible placeholder so + the surrounding tiles reflow into the gap; the visible piece is the ghost. */ +.myapps-tile-dragging { + visibility: hidden; } -.myapps-grid-custom .myapps-tile:active { - cursor: grabbing; +/* Floating clone that tracks the pointer. Lives on so the scroller's + overflow can't clip it; positioned entirely via an inline transform. */ +.myapps-drag-ghost { + position: fixed; + top: 0; + left: 0; + margin: 0; + z-index: 100000; + pointer-events: none; + opacity: 0.95; + filter: drop-shadow(0 10px 20px var(--dashboard-shadow-medium)); + transition: opacity 0.16s ease; + will-change: transform; } -.myapps-tile-placeholder { - width: 80px; - border: 2px dashed var(--dashboard-border); - border-radius: 12px; - background: var(--dashboard-hover); - opacity: 0.5; +.myapps-drag-ghost .myapps-tile-icon { + transform: none; } -.ui-sortable-helper.myapps-tile { - transform: scale(1.05); - box-shadow: 0 8px 24px rgba(0,0,0,0.15); - z-index: 1000; - opacity: 0.9; +.myapps-drag-ghost-drop { + opacity: 0; +} + +/* While reordering: no text selection, and a grabbing cursor everywhere. */ +body.myapps-reordering, +body.myapps-reordering * { + cursor: grabbing !important; + -webkit-user-select: none; + user-select: none; +} + +/* Suppress the icon hover/active scaling on the dragged page so it doesn't + fight the FLIP transforms mid-drag. */ +body.myapps-reordering .myapps-tile:hover .myapps-tile-icon, +body.myapps-reordering .myapps-tile:active .myapps-tile-icon { + transform: none; +} + +body.myapps-reordering .myapps-tile { + touch-action: none; +} + +@media (prefers-reduced-motion: reduce) { + .myapps-drag-ghost { + transition: none; + } } /* Dashboard files */ diff --git a/src/gui/vitest.config.js b/src/gui/vitest.config.js new file mode 100644 index 000000000..a09c3e302 --- /dev/null +++ b/src/gui/vitest.config.js @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; +import { defineConfig } from 'vitest/config'; + +// Pin the root to this directory so the include glob resolves against the GUI +// package regardless of the cwd vitest is invoked from. +const guiDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + test: { + root: guiDir, + include: ['src/**/*.test.js'], + environment: 'node', + }, +});