From 2e29a41627db63792be4b55f2a65834e2ae196ab Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 29 Mar 2026 00:13:39 +0000 Subject: [PATCH] feat: persistent app install state across navigation (#9) Move installingApps from local refs in Marketplace/Discover to the global server store. Install progress now persists when navigating between views. My Apps shows installing overlay with progress bar for apps being installed from the Marketplace. Changes: - server.ts: add installingApps Map + helpers to store - Marketplace.vue: use store's installingApps instead of local ref - Discover.vue: same - Apps.vue: pass isInstalling + installProgress to AppCard - AppCard.vue: add amber installing overlay with progress bar 522 tests pass, vue-tsc clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- neode-ui/src/stores/server.ts | 32 ++++++++++++++++- neode-ui/src/views/Apps.vue | 4 +++ neode-ui/src/views/Discover.vue | 50 +++++++++++++------------- neode-ui/src/views/Marketplace.vue | 55 +++++++++++++++-------------- neode-ui/src/views/apps/AppCard.vue | 19 ++++++++++ 5 files changed, 108 insertions(+), 52 deletions(-) diff --git a/neode-ui/src/stores/server.ts b/neode-ui/src/stores/server.ts index 0567a805..ca046939 100644 --- a/neode-ui/src/stores/server.ts +++ b/neode-ui/src/stores/server.ts @@ -1,13 +1,37 @@ // Server store — computed server state and RPC action proxies import { defineStore } from 'pinia' -import { computed } from 'vue' +import { computed, ref } from 'vue' import { rpcClient } from '../api/rpc-client' import { useSyncStore } from './sync' +import type { InstallProgress } from '../views/marketplace/marketplaceData' export const useServerStore = defineStore('server', () => { const sync = useSyncStore() + // Global install tracking — persists across navigation + const installingApps = ref>(new Map()) + + function setInstallProgress(appId: string, progress: Partial & { id: string; title: string }) { + const existing = installingApps.value.get(appId) + installingApps.value.set(appId, { + status: 'downloading', + progress: 0, + message: 'Preparing...', + attempt: 0, + ...existing, + ...progress, + }) + } + + function clearInstallProgress(appId: string) { + installingApps.value.delete(appId) + } + + function isInstalling(appId: string): boolean { + return installingApps.value.has(appId) + } + // Computed — derived from sync store's data const serverName = computed(() => sync.serverInfo?.name || 'Archipelago') const isRestarting = computed(() => sync.serverInfo?.['status-info']?.restarting || false) @@ -70,6 +94,12 @@ export const useServerStore = defineStore('server', () => { isShuttingDown, isOffline, + // Install tracking (global, persists across navigation) + installingApps, + setInstallProgress, + clearInstallProgress, + isInstalling, + // Actions installPackage, uninstallPackage, diff --git a/neode-ui/src/views/Apps.vue b/neode-ui/src/views/Apps.vue index 7760271a..7b32c70d 100644 --- a/neode-ui/src/views/Apps.vue +++ b/neode-ui/src/views/Apps.vue @@ -101,6 +101,8 @@ :index="index" :show-stagger="showStagger" :is-loading="!!actions.loadingActions.value[id as string]" + :is-installing="serverStore.isInstalling(id as string)" + :install-progress="serverStore.installingApps.get(id as string)" :is-uninstalling="actions.uninstallingApps.value.has(id as string)" @go-to-app="goToApp" @launch="launchApp" @@ -141,6 +143,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue' import { useRouter, useRoute, RouterLink } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' +import { useServerStore } from '@/stores/server' import { useAppLauncherStore } from '@/stores/appLauncher' import type { PackageDataEntry } from '@/types/api' import AppCard from './apps/AppCard.vue' @@ -155,6 +158,7 @@ const { t } = useI18n() const router = useRouter() const route = useRoute() const store = useAppStore() +const serverStore = useServerStore() const actions = useAppsActions() // Only stagger-animate on first mount diff --git a/neode-ui/src/views/Discover.vue b/neode-ui/src/views/Discover.vue index fc7ae9c0..ca184c34 100644 --- a/neode-ui/src/views/Discover.vue +++ b/neode-ui/src/views/Discover.vue @@ -140,6 +140,7 @@ let discoverAnimationDone = false import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { useRouter, RouterLink } from 'vue-router' import { useAppStore } from '@/stores/app' +import { useServerStore } from '@/stores/server' import { rpcClient } from '@/api/rpc-client' import { useMarketplaceApp } from '@/composables/useMarketplaceApp' import { useAppLauncherStore } from '@/stores/appLauncher' @@ -147,11 +148,12 @@ import DiscoverHero from './discover/DiscoverHero.vue' import FeaturedApps from './discover/FeaturedApps.vue' import AppGrid from './discover/AppGrid.vue' import FilterModal from './discover/FilterModal.vue' -import type { MarketplaceApp, FeaturedApp, InstallProgress } from './discover/types' +import type { MarketplaceApp, FeaturedApp } from './discover/types' import { getCuratedAppList, INSTALLED_ALIASES, FEATURED_DEFINITIONS, categorizeCommunityApp } from './discover/curatedApps' const router = useRouter() const store = useAppStore() +const serverStore = useServerStore() const showStagger = !discoverAnimationDone const { setCurrentApp } = useMarketplaceApp() @@ -173,20 +175,20 @@ const categories = computed(() => [ { id: 'other', name: 'Other' } ]) -// Installation state -const installingApps = ref>(new Map()) +// Installation state — uses global store so it persists across navigation +const installingApps = serverStore.installingApps const maxAttempts = ref(60) watch(() => store.packages, (packages) => { if (!packages) return for (const [appId, pkg] of Object.entries(packages)) { const progress = pkg['install-progress'] - if (progress && pkg.state === 'installing' && installingApps.value.has(appId)) { - const current = installingApps.value.get(appId)! + if (progress && pkg.state === 'installing' && installingApps.has(appId)) { + const current = installingApps.get(appId)! const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0 const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1) const totalMB = (progress.size / (1024 * 1024)).toFixed(1) - installingApps.value.set(appId, { + installingApps.set(appId, { ...current, status: 'downloading', progress: Math.min(pct, 95), @@ -409,50 +411,50 @@ onBeforeUnmount(() => { function startInstallPolling(appId: string, statusMessage: string) { const interval = trackInterval(() => { - const current = installingApps.value.get(appId) + const current = installingApps.get(appId) if (!current) { clearTrackedInterval(interval); return } const newAttempt = current.attempt + 1 - installingApps.value.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage }) + installingApps.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), message: statusMessage }) if (isInstalled(appId)) { clearTrackedInterval(interval) - installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' }) - trackTimeout(() => { installingApps.value.delete(appId) }, 2000) + installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' }) + trackTimeout(() => { installingApps.delete(appId) }, 2000) } else if (newAttempt >= maxAttempts.value) { clearTrackedInterval(interval) - installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' }) - trackTimeout(() => { installingApps.value.delete(appId) }, 5000) + installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' }) + trackTimeout(() => { installingApps.delete(appId) }, 5000) } }, 1000) } async function installApp(app: MarketplaceApp) { - if (installingApps.value.has(app.id) || isInstalled(app.id)) return - installingApps.value.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 }) + if (installingApps.has(app.id) || isInstalled(app.id)) return + installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 }) try { const installUrl = app.url || app.manifestUrl || app.s9pkUrl - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' }) await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } }) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' }) startInstallPolling(app.id, 'Starting application...') } catch (err) { if (import.meta.env.DEV) console.error('Installation failed:', err) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) - trackTimeout(() => { installingApps.value.delete(app.id) }, 5000) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) + trackTimeout(() => { installingApps.delete(app.id) }, 5000) } } async function installCommunityApp(app: MarketplaceApp) { - if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return - installingApps.value.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 }) + if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return + installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 }) try { - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' }) await rpcClient.call({ method: 'package.install', params: { id: app.id, dockerImage: app.dockerImage, version: app.version }, timeout: 180000 }) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' }) startInstallPolling(app.id, 'Initializing application...') } catch (err) { if (import.meta.env.DEV) console.error('[Discover] Installation failed:', err) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) - trackTimeout(() => { installingApps.value.delete(app.id) }, 5000) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) + trackTimeout(() => { installingApps.delete(app.id) }, 5000) } } diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue index 596396f2..0ffa77b6 100644 --- a/neode-ui/src/views/Marketplace.vue +++ b/neode-ui/src/views/Marketplace.vue @@ -112,6 +112,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { useRouter, useRoute, RouterLink } from 'vue-router' import { useI18n } from 'vue-i18n' import { useAppStore } from '@/stores/app' +import { useServerStore } from '@/stores/server' import { rpcClient } from '@/api/rpc-client' import { useMarketplaceApp } from '@/composables/useMarketplaceApp' import { useAppLauncherStore } from '@/stores/appLauncher' @@ -119,7 +120,6 @@ import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue' import MarketplaceFilterModal from './marketplace/MarketplaceFilterModal.vue' import { type MarketplaceApp, - type InstallProgress, INSTALLED_ALIASES, getAppTier, categorizeCommunityApp, @@ -129,6 +129,7 @@ import { const router = useRouter() const route = useRoute() const store = useAppStore() +const server = useServerStore() const { t } = useI18n() const showStagger = !marketplaceAnimationDone @@ -152,8 +153,8 @@ const categories = computed(() => [ { id: 'other', name: t('marketplace.other') } ]) -// Installation state - support multiple concurrent installations -const installingApps = ref>(new Map()) +// Installation state — uses global store so it persists across navigation +const installingApps = server.installingApps const maxAttempts = ref(60) // Watch WebSocket data for real install progress from backend @@ -162,8 +163,8 @@ watch(() => store.packages, (packages) => { for (const [appId, pkg] of Object.entries(packages)) { if ((pkg.state as string) === 'installing') { const progress = pkg['install-progress'] - if (!installingApps.value.has(appId)) { - installingApps.value.set(appId, { + if (!installingApps.has(appId)) { + installingApps.set(appId, { id: appId, title: pkg.manifest?.title || appId, status: 'downloading', @@ -173,19 +174,19 @@ watch(() => store.packages, (packages) => { }) } if (progress) { - const current = installingApps.value.get(appId)! + const current = installingApps.get(appId)! const pct = progress.size > 0 ? Math.round((progress.downloaded / progress.size) * 100) : 0 const downloadedMB = (progress.downloaded / (1024 * 1024)).toFixed(1) const totalMB = (progress.size / (1024 * 1024)).toFixed(1) - installingApps.value.set(appId, { + installingApps.set(appId, { ...current, status: 'downloading', progress: Math.min(pct, 95), message: progress.size > 0 ? `Downloading: ${downloadedMB} / ${totalMB} MB (${pct}%)` : t('marketplace.downloading'), }) } - } else if (installingApps.value.has(appId) && (pkg.state as string) !== 'installing') { - installingApps.value.delete(appId) + } else if (installingApps.has(appId) && (pkg.state as string) !== 'installing') { + installingApps.delete(appId) } } }, { deep: true }) @@ -402,11 +403,11 @@ onBeforeUnmount(() => { function startInstallPolling(appId: string, statusMessage: string) { const interval = trackInterval(() => { - const current = installingApps.value.get(appId) + const current = installingApps.get(appId) if (!current) { clearTrackedInterval(interval); return } const newAttempt = current.attempt + 1 - installingApps.value.set(appId, { + installingApps.set(appId, { ...current, attempt: newAttempt, progress: Math.min(60 + (newAttempt * 0.5), 95), @@ -415,49 +416,49 @@ function startInstallPolling(appId: string, statusMessage: string) { if (isInstalled(appId)) { clearTrackedInterval(interval) - installingApps.value.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' }) - trackTimeout(() => { installingApps.value.delete(appId) }, 2000) + installingApps.set(appId, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' }) + trackTimeout(() => { installingApps.delete(appId) }, 2000) } else if (newAttempt >= maxAttempts.value) { clearTrackedInterval(interval) - installingApps.value.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' }) - trackTimeout(() => { installingApps.value.delete(appId) }, 5000) + installingApps.set(appId, { ...current, status: 'error', progress: 0, message: 'Installation timeout' }) + trackTimeout(() => { installingApps.delete(appId) }, 5000) } }, 1000) } async function installApp(app: MarketplaceApp) { - if (installingApps.value.has(app.id) || isInstalled(app.id)) return + if (installingApps.has(app.id) || isInstalled(app.id)) return - installingApps.value.set(app.id, { + installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Preparing installation...', attempt: 0 }) try { const installUrl = app.url || app.manifestUrl || app.s9pkUrl - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 30, message: 'Downloading package...' }) await rpcClient.call({ method: 'package.install', params: { id: app.id, url: installUrl, version: app.version } }) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Installing package...' }) startInstallPolling(app.id, 'Starting application...') } catch (err) { if (import.meta.env.DEV) console.error('Installation failed:', err) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) - trackTimeout(() => { installingApps.value.delete(app.id) }, 5000) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) + trackTimeout(() => { installingApps.delete(app.id) }, 5000) } } async function installCommunityApp(app: MarketplaceApp) { - if (installingApps.value.has(app.id) || isInstalled(app.id) || !app.dockerImage) return + if (installingApps.has(app.id) || isInstalled(app.id) || !app.dockerImage) return - installingApps.value.set(app.id, { + installingApps.set(app.id, { id: app.id, title: app.title ?? app.id, status: 'downloading', progress: 10, message: 'Pulling Docker image...', attempt: 0 }) try { - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'downloading', progress: 20, message: 'Downloading container image...' }) await rpcClient.call({ method: 'package.install', @@ -465,13 +466,13 @@ async function installCommunityApp(app: MarketplaceApp) { timeout: 180000 }) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' }) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'installing', progress: 60, message: 'Starting container...' }) startInstallPolling(app.id, 'Initializing application...') } catch (err) { if (import.meta.env.DEV) console.error('[Marketplace] Installation failed:', err) - installingApps.value.set(app.id, { ...installingApps.value.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) - trackTimeout(() => { installingApps.value.delete(app.id) }, 5000) + installingApps.set(app.id, { ...installingApps.get(app.id)!, status: 'error', progress: 0, message: `Failed: ${err}` }) + trackTimeout(() => { installingApps.delete(app.id) }, 5000) } } diff --git a/neode-ui/src/views/apps/AppCard.vue b/neode-ui/src/views/apps/AppCard.vue index 563a0a10..f596464a 100644 --- a/neode-ui/src/views/apps/AppCard.vue +++ b/neode-ui/src/views/apps/AppCard.vue @@ -10,6 +10,23 @@ @click="$emit('goToApp', id)" @keydown.enter="$emit('goToApp', id)" > + +
+
+ + + + + {{ installProgress?.message || t('common.installing') }}... +
+
+
+
+
+
()