From 41fc8d890b03874c12f9a82b59b0131eea4a98ae Mon Sep 17 00:00:00 2001 From: jelveh Date: Fri, 7 Aug 2026 10:34:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(gui):=20play=20the=20tile=20click=E2=86=92?= =?UTF-8?q?morph=E2=86=92open=20intro=20on=20/app/=20landings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct landing on /app/ now plays the same sequence a real Apps-tab tile click does — grid appears, a beat, the tile's icon ghost pops out of its slot, a beat, and the window morphs out of the tile — so the landing shows the user what is being opened and where minimize puts it back. TabApps.beginDeepLinkLaunch waits (3s cap) for the tile to be genuinely visible (list loaded, render done, pager flipped to the tile's page, load-fade revealed, icon painted), paces the beats, and claims the app in _launchingApps so a click mid-intro can't spawn a duplicate. The launch's app-info fetch is prefetched in parallel so the intro never delays the app's own round-trip. No tile, animations off, reduced motion, or a background tab (hidden pages throttle timers and defer rendering) all skip straight to the immediate plain-fade launch. Also fixes the tab title sticking as the app's name after closing a deep-linked app: the landing's replaceState('/') committed the dashboard's history entry while the page still carried the server's app-name title, and Chrome shows an entry's stored title when close/ Back traverses onto it — document.title is now reset before the replaceState so the entry is stamped with the dashboard's own title. --- src/gui/src/UI/Dashboard/TabApps.js | 111 ++++++++++++++++++++++++++++ src/gui/src/initgui.js | 72 ++++++++++++++---- 2 files changed, 169 insertions(+), 14 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index fc235f9a6..151b25bba 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -48,6 +48,26 @@ const TILE_REMOVE_DELAY_MS = 500; // pause between the uninstall modal closing // around each tile is what stops items flickering back and forth at a boundary. const DRAG_HIT_INSET = 0.28; +// How long a /app/ landing waits for the launching app's tile to be +// visible before giving up on the click→morph→open intro and launching with +// the plain fade (see beginDeepLinkLaunch). The wait covers the dashboard +// window, the app-list fetches, a possibly-deferred first render, and the +// grid's load-fade; the launch's own app-info fetch runs in parallel to it +// (initgui prefetches), so this wait is the only thing the intro can cost. +const DEEP_LINK_INTRO_DEADLINE_MS = 3000; +const DEEP_LINK_INTRO_POLL_MS = 50; +// Pacing for the intro itself. On a fast connection the grid reveal, the +// tile's click flourish, and the window's morph would land in the same +// breath and read as an unexplained flash — the beats spread them into a +// sequence the user can follow: see the grid (WHERE you are), see the tile +// acknowledge (WHAT is opening), see the window grow out of it. The grid +// beat runs from the start of the pager's 200ms load-fade (see +// .myapps-pager-loading); the click beat matches the round-trip feel of a +// real tile click, joining the window's morph while the icon ghost is +// still dissolving so the two halves stay one continuous motion. +const DEEP_LINK_INTRO_GRID_BEAT_MS = 500; +const DEEP_LINK_INTRO_CLICK_BEAT_MS = 300; + // Cog on the reorder-mode toggle; _setReorderMode swaps it for "Done" while // the mode is on. const REORDER_BTN_ICON = ''; @@ -1650,6 +1670,97 @@ const TabApps = { setTimeout(() => this.focusSearch($el_window, attempts - 1), 30); } }, + + // A direct landing on /app/ (see initgui's dashboard branch) plays + // the same click→morph→open sequence a real tile click does, so the user + // sees WHICH tile the app came from — and where minimize will put it + // back. This waits for the tile to be genuinely visible (grid loaded, + // pager flipped to the tile's page, load-fade revealed, icon painted — + // the flourish must never play over blank space), then paces out the + // sequence: a beat to take the grid in, the tile's click flourish, a + // beat for it to read, and only then does it resolve for the caller to + // open the window, whose morph grows it out of the tile's slot. + // + // Resolves to the tile element, or null when there is nothing to + // introduce — app not installed (resolved as soon as the app list + // arrives, with no further waiting), grid slower than the deadline, or + // animations that wouldn't play anyway; the landing then launches + // immediately with the plain fade it always had. + // + // Also claims the app in _launchingApps immediately, so a user click on + // the tile mid-intro is swallowed instead of spawning a second instance + // (same guard as the click handler). The caller MUST call + // settleDeepLinkLaunch once its launch attempt settles. + async beginDeepLinkLaunch (appName, $el_window) { + this._launchingApps.add(appName); + // No animations, no intro: the morph and the flourish would both + // no-op, so waiting on the grid would only delay the launch. + if ( ! window.animate_window_opening || this._reduceMotion() ) return null; + const deadline = Date.now() + DEEP_LINK_INTRO_DEADLINE_MS; + const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + // Join the load init() already started rather than racing a duplicate. + try { + await this.loadApps($el_window); + } catch ( _e ) { /* a failed load leaves _apps unset; handled below */ } + if ( ! Array.isArray(this._apps) || ! this._apps.some(a => a.name === appName) ) { + return null; + } + let tile = null; + while ( Date.now() < deadline ) { + // A hidden page (deep link opened in a background tab) can't show + // the intro at all — and won't finish this wait either: Chrome + // throttles the sleep below to 1Hz and defers the deferred-render + // path's ResizeObserver, so waiting just delays the launch. Skip + // to the plain open; the app is simply there when the tab is + // finally brought forward. + if ( document.visibilityState === 'hidden' ) return null; + // Re-query every pass — renders replace tile nodes wholesale, and + // the first render can trail the load itself (the window is + // briefly hidden entering full-page mode, so renderApps defers to + // the ResizeObserver until the container has a size). Scoped to + // the ACTIVE section: if the user has already moved to another + // tab, there is no visible tile to introduce from. + const candidate = $el_window.find('.dashboard-section-apps.active .myapps-tile').toArray() + .find(el => el.dataset.appName === appName); + if ( candidate ) { + // The tile may sit on a later pager page; flip to it now, + // instantly and behind the load-fade, so the grid comes up + // already showing the tile the window is about to grow out of. + const page = $el_window.find('.myapps-page').index($(candidate).closest('.myapps-page')); + if ( page >= 0 && page !== this._page ) { + this.goToPage($el_window, page, false); + } + const revealed = ! $el_window.find('.myapps-pager').hasClass('myapps-pager-loading'); + const img = candidate.querySelector('.myapps-tile-icon img'); + if ( revealed && ( ! img || img.complete ) ) { + tile = candidate; + break; + } + } + await sleep(DEEP_LINK_INTRO_POLL_MS); + } + if ( ! tile ) return null; + // Everything is on screen — now pace the sequence (see the beat + // constants). The beats are also where the user may navigate away: + // a page hidden mid-beat skips the rest of the choreography and + // just opens the app (the flourish would play unseen, and the + // window's morph re-checks tile visibility on its own anyway). + await sleep(DEEP_LINK_INTRO_GRID_BEAT_MS); + if ( document.visibilityState === 'hidden' ) return tile; + begin_dashboard_tile_launch(tile); + await sleep(DEEP_LINK_INTRO_CLICK_BEAT_MS); + return tile; + }, + + // Release beginDeepLinkLaunch's duplicate-launch claim once the landing's + // launch attempt is over — success or failure, tile or no tile — + // mirroring the tile click handler's finally (including its + // settle_dashboard_tile_launch, which un-marks the tile if the window's + // morph never claimed it). + settleDeepLinkLaunch (appName, tile) { + this._launchingApps.delete(appName); + settle_dashboard_tile_launch(tile); + }, }; export default TabApps; diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 682470a14..050ac3775 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -18,6 +18,7 @@ */ import UIDashboard from './UI/Dashboard/UIDashboard.js'; +import TabApps from './UI/Dashboard/TabApps.js'; import UIAlert from './UI/UIAlert.js'; import UIComponentWindow from './UI/UIComponentWindow.js'; import UIDesktop from './UI/UIDesktop.js'; @@ -173,7 +174,7 @@ const postAuthActions = async (action) => { // Dashboard mode // ------------------------------------------------------------------------------------- else if ( window.is_dashboard_mode ) { - UIDashboard(); + const el_dashboard_promise = UIDashboard(); // Direct landing on /app/: open the app in the dashboard the // same way a tile launch does. The dashboard's route is slotted // underneath first (replaceState) and the launch re-claims @@ -183,6 +184,7 @@ const postAuthActions = async (action) => { if ( window.url_paths[0]?.toLocaleLowerCase() === 'app' && window.url_paths[1] && ! window.url_query_params.has('c') ) { + const app_name = window.url_paths[1]; // any query param that doesn't start with 'puter.' is passed // through to the app (mirrors the desktop URL-launch flow) const app_query_params = {}; @@ -204,20 +206,62 @@ const postAuthActions = async (action) => { // forever — preset the title to fall back to when the app's // history entry is popped. window.dashboard_base_title = i18n('window_title_puter'); + // ...and make it the DOCUMENT title before the replaceState + // below commits the dashboard's own entry. Chrome stamps a + // session entry with the document title current at commit and + // shows that stored title in the tab strip whenever a traversal + // lands on the entry — so with the server's app-name title + // still in place, closing the app (whose close consumes the + // /app/ entry via history.back()) left the tab named + // after an app that was no longer on screen: the popstate + // handler's document.title reset updates the DOM title, but the + // tab strip keeps displaying the entry's stored one. + document.title = window.dashboard_base_title; window.history.replaceState(null, '', '/'); - launch_app({ - name: window.url_paths[1], - maximized: true, - params: app_query_params, - readURL: window.url_query_params.get('readURL'), - ...(posargs ? { - args: { - command_line: { args: posargs }, - }, - } : {}), - }).catch((err) => { - console.error(`Failed to launch ${window.url_paths[1]} from URL:`, err); - }); + // Resolve the app's info NOW, in parallel with the tile wait + // below, so the intro never delays the launch's own server + // round-trip; the result is handed to launch_app as app_obj (the + // same object its own fetch would produce). A failed prefetch + // hands nothing over — launch_app refetches and fails exactly + // the way it always did. + const app_info_promise = puter.apps.get(app_name, { icon_size: 64 }) + .catch(() => null); + (async () => { + // If the app already has a tile in the Apps tab, play the + // whole click→morph→open sequence a real tile click plays — + // paced so it can be followed: the grid appears, a beat, the + // tile visibly acknowledges (icon ghost), a beat, and the + // window grows out of its slot — so the landing tells the + // user what is being opened and where minimize puts it back. + // No tile (not installed, grid too slow, apps fetch failed, + // animations off): the launch proceeds immediately with the + // plain fade, as before. + let tile = null; + try { + const el_dashboard = await el_dashboard_promise; + tile = await TabApps.beginDeepLinkLaunch(app_name, $(el_dashboard)); + } catch ( _e ) { + // No dashboard window — no intro; still launch. + } + const app_obj = await app_info_promise; + launch_app({ + name: app_name, + maximized: true, + params: app_query_params, + readURL: window.url_query_params.get('readURL'), + ...(app_obj ? { app_obj } : {}), + ...(posargs ? { + args: { + command_line: { args: posargs }, + }, + } : {}), + window_options: { morph_from_dashboard_tile: true }, + }).catch((err) => { + console.error(`Failed to launch ${app_name} from URL:`, err); + }).finally(() => { + TabApps.settleDeepLinkLaunch(app_name, tile); + }); + })(); } } // -------------------------------------------------------------------------------------