From 29337723621e98ad4a5db77bf0409ca290eccbcb Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 13 Aug 2026 14:38:45 -0700 Subject: [PATCH] feat: dashboard app search continues into the App Center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search that matches none of the user's own apps now puts the same query to the App Center catalogue and renders the hits under the empty state — debounced, filtered to apps the user doesn't already have, and launchable in place. Co-Authored-By: Claude Fable 5 --- src/gui/src/UI/Dashboard/TabApps.js | 118 +++++++++++++++++++++++++++- src/gui/src/css/dashboard.css | 58 ++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index d1a2c9d2c..7f1fc4ec4 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -58,6 +58,14 @@ const APP_CENTER_APP_NAME = 'app-center'; /** Puter's AI app builder, which generates an app from a description. */ const BUILDER_APP_NAME = 'builder'; +// -- App Center discovery under search -- +// When a search matches none of the user's own apps, the same query is put to +// the App Center catalogue, so "I don't have it" continues into "here it is" +// without leaving the tab. Shown only on the no-local-matches state — a query +// that found the user's apps already answered them. +const DISCOVER_PAGE_SIZE = 8; +const DISCOVER_DEBOUNCE_MS = 250; + // -- Drag-to-reorder tuning -- const DRAG_START_DISTANCE = 5; // px a pointer must travel before a drag begins const MODE_LONGPRESS_MS = 500; // hold on empty grid space before reorder mode engages @@ -827,6 +835,11 @@ const TabApps = { // their tiles (the mirror of _removedLocal). _arriving: null, _pendingInstalls: null, + // Latest App Center answer for the search box ({ query, items }), plus + // the debounce timer and a sequence guard against out-of-order responses. + _discover: null, + _discoverTimer: null, + _discoverSeq: 0, html () { let h = '
'; @@ -920,6 +933,26 @@ const TabApps = { $el_window.on('input', '.myapps-search', function () { self.updateSearchIcons($el_window); self.renderApps($el_window); + self._queueDiscover($el_window); + }); + + // Launch an App Center discovery straight from the search results. + // Distinct from '.myapps-tile': discovery tiles take no part in + // drag-reorder, folders, or the running-dot machinery. + const launchDiscovery = function () { + const name = this.dataset.appName; + if ( ! name || TabApps._launchingApps.has(name) ) return; + TabApps._launchingApps.add(name); + launch_app({ name, maximized: true }) + .catch(err => console.error(`Failed to launch ${name}:`, err)) + .finally(() => TabApps._launchingApps.delete(name)); + }; + $el_window.on('click', '.myapps-discover-tile', launchDiscovery); + $el_window.on('keydown', '.myapps-discover-tile', function (e) { + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + launchDiscovery.call(this); + } }); // Clear search on cross click @@ -1352,7 +1385,7 @@ const TabApps = { this._page = 0; this._pageCount = 0; $container.html(query - ? '

No apps match your search

' + ? `

No apps match your search

${this._discoverHtml(query)}` : buildNoAppsHtml()); return; } @@ -1421,6 +1454,89 @@ const TabApps = { }, { passive: true }); }, + // -- App Center discovery under search -- + + /** + * The "From the App Center" strip for the no-local-matches state, from + * the cached catalogue answer. Empty until the fetch for exactly this + * query has landed — a stale strip would answer the wrong question. + */ + _discoverHtml (query) { + const discover = this._discover; + if ( ! discover || discover.query !== query || discover.items.length === 0 ) { + return ''; + } + let h = '
'; + h += '
From the App Center
'; + h += '
'; + for ( const item of discover.items ) { + h += `
`; + h += item.icon + ? `` + : '
'; + h += `${html_encode(item.title)}`; + h += '
'; + } + h += '
'; + return h; + }, + + /** Debounced catalogue lookup for the current search box contents. */ + _queueDiscover ($el_window) { + clearTimeout(this._discoverTimer); + const query = String($el_window.find('.myapps-search').val() || '').trim(); + if ( ! query ) { + this._discover = null; + return; + } + // Already answered — nothing to fetch, nothing to re-render. + if ( this._discover?.query === query ) return; + const self = this; + this._discoverTimer = setTimeout(async () => { + const seq = ++self._discoverSeq; + let items = []; + try { + const resp = await fetch( + `${window.api_origin}/marketplace/search?q=${encodeURIComponent(query)}&page=1&pageSize=${DISCOVER_PAGE_SIZE}`, + ); + if ( resp.ok ) { + const body = await resp.json(); + items = Array.isArray(body?.items) ? body.items : []; + } + } catch { + // Offline or the catalogue is down — the empty state simply + // stays as it was. + } + // A newer keystroke owns the strip now. + if ( seq !== self._discoverSeq ) return; + + // What the user already has isn't a discovery. + const owned = new Set(); + for ( const app of self._apps || [] ) { + if ( app.name ) owned.add(String(app.name).toLowerCase()); + if ( app.uid || app.uuid ) owned.add(String(app.uid || app.uuid).toLowerCase()); + } + self._discover = { + query, + items: items + .filter(item => item?.item_name + && ! owned.has(String(item.item_name).toLowerCase()) + && ! owned.has(String(item.item_id || '').toLowerCase())) + .map(item => ({ + name: String(item.item_name), + title: String(item.item_title || item.item_name), + icon: typeof item.icon_url === 'string' ? item.icon_url : null, + })), + }; + // Only the empty state renders the strip, so only re-render if + // the query in the box is still the one this answer belongs to. + const current = String($el_window.find('.myapps-search').val() || '').trim(); + if ( current === query ) { + self.renderApps($el_window, { preservePage: true, instant: true }); + } + }, DISCOVER_DEBOUNCE_MS); + }, + // Mark tiles whose app has a live window (visible OR minimized — both // are running) with the macOS-dock-style dot. A folder wears the dot when // anything inside it is running — the apps it hides are exactly the ones diff --git a/src/gui/src/css/dashboard.css b/src/gui/src/css/dashboard.css index e58858cc0..a5089e2b0 100644 --- a/src/gui/src/css/dashboard.css +++ b/src/gui/src/css/dashboard.css @@ -913,6 +913,64 @@ input.myapps-search:disabled { margin: 0; } +/* "From the App Center" strip under a search with no local matches. */ +.myapps-discover { + padding: 0 8px 16px; +} + +.myapps-discover-title { + font-size: 13px; + font-weight: 500; + color: var(--dashboard-text-tertiary); + margin-bottom: 10px; + text-align: center; +} + +.myapps-discover-row { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 14px; +} + +.myapps-discover-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + width: 96px; + padding: 10px 4px; + border-radius: 10px; + cursor: pointer; + text-align: center; +} + +.myapps-discover-tile:hover, +.myapps-discover-tile:focus-visible { + background: var(--dashboard-hover-bg, rgba(127, 127, 127, 0.12)); +} + +.myapps-discover-tile img, +.myapps-discover-tile-blank { + width: 48px; + height: 48px; + border-radius: 10px; + object-fit: cover; +} + +.myapps-discover-tile-blank { + background: rgba(127, 127, 127, 0.15); +} + +.myapps-discover-tile span { + font-size: 12px; + color: var(--dashboard-text-secondary); + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* iOS-style pager: pages snap horizontally, dots below, hover arrows */ .myapps-pager {