diff --git a/neode-ui/src/stores/cloud.ts b/neode-ui/src/stores/cloud.ts index 88877ea8..119ff8c5 100644 --- a/neode-ui/src/stores/cloud.ts +++ b/neode-ui/src/stores/cloud.ts @@ -8,6 +8,11 @@ export const useCloudStore = defineStore('cloud', () => { const loading = ref(false) const error = ref(null) const authenticated = ref(false) + // Per-path listing cache: re-entering a folder paints the last listing + // immediately (no spinner) while the fresh listing loads behind it. + const pathCache = new Map() + // Last-wins guard for overlapping navigations (fast folder hopping). + let navSeq = 0 const breadcrumbs = computed(() => { const parts = currentPath.value.split('/').filter(Boolean) @@ -36,7 +41,22 @@ export const useCloudStore = defineStore('cloud', () => { } async function navigate(path: string): Promise { - loading.value = true + const seq = ++navSeq + const apply = (p: string, result: FileBrowserItem[]) => { + pathCache.set(p, result) + if (seq !== navSeq) return // a newer navigation superseded this one + items.value = result + currentPath.value = p + } + // Stale-while-revalidate: show the cached listing for this path + // immediately (no spinner), then refresh it underneath. + const cached = pathCache.get(path) + if (cached) { + items.value = cached + currentPath.value = path + } else { + loading.value = true + } error.value = null try { if (!authenticated.value) { @@ -47,9 +67,7 @@ export const useCloudStore = defineStore('cloud', () => { } } try { - const result = await fileBrowserClient.listDirectory(path) - items.value = result - currentPath.value = path + apply(path, await fileBrowserClient.listDirectory(path)) } catch { // Directory may not exist — try to create it, then retry if (path !== '/') { @@ -57,23 +75,20 @@ export const useCloudStore = defineStore('cloud', () => { const parentPath = path.substring(0, path.lastIndexOf('/')) || '/' const dirName = path.substring(path.lastIndexOf('/') + 1) await fileBrowserClient.createFolder(parentPath, dirName) - const result = await fileBrowserClient.listDirectory(path) - items.value = result - currentPath.value = path + apply(path, await fileBrowserClient.listDirectory(path)) } catch { // Fall back to root - const result = await fileBrowserClient.listDirectory('/') - items.value = result - currentPath.value = '/' + apply('/', await fileBrowserClient.listDirectory('/')) } } else { throw new Error('Failed to list root directory') } } } catch (e) { - error.value = e instanceof Error ? e.message : 'Failed to load files' + // Keep showing the cached listing on a failed revalidate. + if (!cached) error.value = e instanceof Error ? e.message : 'Failed to load files' } finally { - loading.value = false + if (seq === navSeq) loading.value = false } } @@ -112,6 +127,7 @@ export const useCloudStore = defineStore('cloud', () => { items.value = [] loading.value = false error.value = null + pathCache.clear() } return { diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index b314767c..1b77f585 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -194,7 +194,7 @@ Open Federation -
+
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
@@ -216,6 +216,13 @@ {{ f.peerName }}
+

+ + + + + Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}… +

{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.

@@ -301,12 +308,23 @@ {{ peer.trust_level }} - Peer Node + + + + {{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s + + Peer Node
@@ -388,6 +406,8 @@ import { computed, ref, watch, onMounted } from 'vue' import { useRouter, RouterLink } from 'vue-router' import { useAppStore } from '../stores/app' import { useCloudStore } from '../stores/cloud' +import { useResourcesStore } from '../stores/resources' +import { useCachedResource } from '../composables/useCachedResource' import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client' import { rpcClient } from '@/api/rpc-client' import { getFileCategory } from '../composables/useFileType' @@ -399,9 +419,19 @@ import MediaLightbox from '../components/cloud/MediaLightbox.vue' const router = useRouter() const store = useAppStore() const cloudStore = useCloudStore() +const resources = useResourcesStore() const audioPlayer = useAudioPlayer() -const sectionCounts = ref>({}) -const countsLoading = ref(false) + +// Section counts — cached: revisits render the last-known counts instantly +// and refresh in the background (sticky-ready never regresses to "Loading"). +const countsResource = useCachedResource>({ + key: 'cloud.section-counts', + fetcher: fetchCounts, + ttlMs: 30_000, + immediate: false, // gated on fileBrowserRunning; kicked from onMounted/watch +}) +const sectionCounts = computed(() => countsResource.entry.data ?? {}) +const countsLoading = computed(() => countsResource.entry.loadState === 'loading') // ── Tabs / categories / search state ──────────────────────────────────────── type TabId = 'folders' | 'mine' | 'peers' | 'paid' @@ -424,14 +454,18 @@ const activeTab = ref('folders') // ── Paid Files tab ────────────────────────────────────────────────────────── interface PaidItem { onion: string; content_id: string; filename: string; mime_type: string; size_bytes: number; paid_sats: number; purchased_at: string } -const paidItems = ref([]) -const paidLoading = ref(false) -async function loadPaidItems() { - paidLoading.value = true - try { - const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list' }) - paidItems.value = (res.items || []).slice().reverse() - } catch { paidItems.value = [] } finally { paidLoading.value = false } +const paidResource = useCachedResource({ + key: 'cloud.paid-items', + fetcher: async (signal) => { + const res = await rpcClient.call<{ items: PaidItem[] }>({ method: 'content.owned-list', signal, dedup: true }) + return (res.items || []).slice().reverse() + }, + immediate: false, // loaded when the Paid tab is opened +}) +const paidItems = computed(() => paidResource.entry.data ?? []) +const paidLoading = computed(() => paidResource.entry.loadState === 'loading') +function loadPaidItems() { + return paidResource.refresh() } async function viewPaidItem(it: PaidItem) { try { @@ -473,8 +507,21 @@ interface PeerNode { trust_level: string } -const peerNodes = ref([]) -const peersLoading = ref(true) +// Federation peers — cached so the Folders tab's peer cards paint instantly +// on revisit while the list revalidates behind them. +const peersResource = useCachedResource({ + key: 'cloud.peer-nodes', + fetcher: async (signal) => { + const result = await rpcClient.federationListNodes() + void signal + return result?.nodes ?? [] + }, + ttlMs: 30_000, + immediate: false, // kicked from onMounted (keeps the legacy load order) +}) +const peerNodes = computed(() => peersResource.entry.data ?? []) +const peersLoading = computed(() => peersResource.entry.loadState === 'loading') +const peersRefreshing = computed(() => peersResource.entry.loadState === 'refreshing') const loadError = ref('') const APP_ALIASES: Record = { @@ -579,42 +626,51 @@ function formatSize(bytes: number): string { } // ── My Files (flat list of every own file across the sections) ────────────── -const myFiles = ref([]) -const myFilesLoading = ref(false) -const myFilesLoaded = ref(false) +// Cached: revisiting the tab renders the last walk instantly and re-walks in +// the background only when stale. +const myFilesResource = useCachedResource({ + key: 'cloud.my-files', + fetcher: fetchMyFiles, + ttlMs: 60_000, + immediate: false, // loaded when the My Files tab (or search) needs it +}) +const myFiles = computed(() => myFilesResource.entry.data ?? []) +const myFilesLoading = computed(() => myFilesResource.entry.loadState === 'loading') /** Depth-limited walk of the section folders; flat file list, capped. */ -async function loadMyFiles(force = false) { - if (myFilesLoading.value || (myFilesLoaded.value && !force)) return - if (!fileBrowserRunning.value) { myFilesLoaded.value = true; return } - myFilesLoading.value = true - try { - const ok = await cloudStore.init() - if (!ok) return - const out: FileBrowserItem[] = [] - for (const [sectionId, root] of Object.entries(SECTION_PATHS)) { - if (sectionId === 'files') continue // '/' would double-visit the sections - const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }] - while (queue.length > 0 && out.length < 500) { - const { path, depth } = queue.shift()! - let items: FileBrowserItem[] - try { items = await fileBrowserClient.listDirectory(path) } catch { continue } - for (const item of items) { - const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}` - if (item.isDir) { - if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 }) - } else { - out.push({ ...item, path: itemPath }) - } +async function fetchMyFiles(): Promise { + if (!fileBrowserRunning.value) return [] + const ok = await cloudStore.init() + if (!ok) return [] + const out: FileBrowserItem[] = [] + for (const [sectionId, root] of Object.entries(SECTION_PATHS)) { + if (sectionId === 'files') continue // '/' would double-visit the sections + const queue: Array<{ path: string; depth: number }> = [{ path: root, depth: 0 }] + while (queue.length > 0 && out.length < 500) { + const { path, depth } = queue.shift()! + let items: FileBrowserItem[] + try { items = await fileBrowserClient.listDirectory(path) } catch { continue } + for (const item of items) { + const itemPath = item.path || `${path.replace(/\/$/, '')}/${item.name}` + if (item.isDir) { + if (depth < 3) queue.push({ path: itemPath, depth: depth + 1 }) + } else { + out.push({ ...item, path: itemPath }) } } } - out.sort((a, b) => a.name.localeCompare(b.name)) - myFiles.value = out - myFilesLoaded.value = true - } finally { - myFilesLoading.value = false } + out.sort((a, b) => a.name.localeCompare(b.name)) + return out +} + +/** Load if never fetched or stale; `force` always re-walks. */ +function loadMyFiles(force = false): Promise { + if (force) return myFilesResource.refresh() + if (myFilesResource.entry.data === null || myFilesResource.isStale.value) { + return myFilesResource.refresh() + } + return Promise.resolve() } const filteredMyFiles = computed(() => @@ -668,7 +724,8 @@ function handlePreview(path: string, context: FileBrowserItem[]) { async function handleDelete(path: string) { try { await cloudStore.deleteItem(path) - myFiles.value = myFiles.value.filter(f => f.path !== path) + // Delete confirmed — update the cache in place (no rollback needed). + myFilesResource.optimistic((cur) => (cur ?? []).filter(f => f.path !== path)) searchResults.value = searchResults.value.filter(r => r.item?.path !== path) } catch (e) { loadError.value = e instanceof Error ? e.message : 'Delete failed' @@ -686,11 +743,6 @@ interface PeerFileEntry { peerOnion: string } -const peerFiles = ref([]) -const peerFilesLoading = ref(false) -const peerFilesLoaded = ref(false) -const peerFilesErrors = ref(0) - interface CatalogItem { id: string filename: string @@ -704,46 +756,96 @@ function priceOf(access: CatalogItem['access']): number { return typeof access === 'object' && access?.paid ? access.paid.price_sats : 0 } -/** Fan out content.browse-peer over every federation node; tolerate stragglers. */ -async function loadPeerFiles(force = false) { - if (peerFilesLoading.value || (peerFilesLoaded.value && !force)) return - peerFilesLoading.value = true - peerFilesErrors.value = 0 - try { - if (peerNodes.value.length === 0) await loadPeers() - const results = await Promise.allSettled( - peerNodes.value.map(async (peer) => { - const res = await rpcClient.call<{ items?: CatalogItem[] }>({ - method: 'content.browse-peer', - params: { onion: peer.onion }, - timeout: 30000, - }) - return { peer, items: res?.items ?? [] } - }), - ) - const merged: PeerFileEntry[] = [] - for (const r of results) { - if (r.status !== 'fulfilled') { peerFilesErrors.value++; continue } - const { peer, items } = r.value - const peerName = peer.name || peerDisplayName(peer.did) - for (const item of items) { - merged.push({ - key: `${peer.onion}:${item.id}`, - filename: item.filename, - sizeBytes: item.size_bytes, - priceSats: priceOf(item.access), - category: categoryOf(item.mime_type || item.filename), - peerName, - peerOnion: peer.onion, - }) - } +// Per-peer browse results live as individual cached entries so (a) each +// peer's card/rows render the moment THAT peer answers — no more blocking on +// the slowest peer via Promise.allSettled — and (b) revisits paint from +// cache. The response's `transport` (fips/tor) + measured latency ride +// along, giving every peer a live transport badge. +interface PeerBrowse { + items: CatalogItem[] + transport: string | null + latencyMs: number +} + +const peerBrowseKey = (onion: string) => `cloud.peer-browse:${onion}` + +function browsePeer(peer: PeerNode): Promise { + return resources.refresh(peerBrowseKey(peer.onion), async () => { + const t0 = Date.now() + const res = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({ + method: 'content.browse-peer', + params: { onion: peer.onion }, + timeout: 30000, + // One slow/unreachable peer must cost its timeout ONCE, not ×3 — + // the retry loop is why one dead peer meant a 90s spinner. + maxRetries: 1, + dedup: true, + }) + return { items: res?.items ?? [], transport: res?.transport ?? null, latencyMs: Date.now() - t0 } + }) +} + +function peerBrowseEntry(onion: string) { + return resources.entry(peerBrowseKey(onion)) +} + +/** Transport badge data for a peer (null until its first browse resolves). */ +function peerTransport(onion: string): { transport: string; latencyMs: number } | null { + const e = peerBrowseEntry(onion) + if (!e.data?.transport) return null + return { transport: e.data.transport, latencyMs: e.data.latencyMs } +} + +/** Aggregated peer files, incrementally updated as each peer resolves. */ +const peerFiles = computed(() => { + const merged: PeerFileEntry[] = [] + for (const peer of peerNodes.value) { + const e = peerBrowseEntry(peer.onion) + if (!e.data) continue + const peerName = peer.name || peerDisplayName(peer.did) + for (const item of e.data.items) { + merged.push({ + key: `${peer.onion}:${item.id}`, + filename: item.filename, + sizeBytes: item.size_bytes, + priceSats: priceOf(item.access), + category: categoryOf(item.mime_type || item.filename), + peerName, + peerOnion: peer.onion, + }) } - merged.sort((a, b) => a.filename.localeCompare(b.filename)) - peerFiles.value = merged - peerFilesLoaded.value = true - } finally { - peerFilesLoading.value = false } + merged.sort((a, b) => a.filename.localeCompare(b.filename)) + return merged +}) + +/** Peers still on their first in-flight browse (nothing cached yet). */ +const peerFilesPending = computed(() => + peerNodes.value.filter(p => { + const s = peerBrowseEntry(p.onion).loadState + return s === 'loading' || s === 'idle' + }).length, +) +/** Peers whose browse failed with no cached data to show. */ +const peerFilesErrors = computed(() => + peerNodes.value.filter(p => peerBrowseEntry(p.onion).loadState === 'error').length, +) +/** All-or-nothing spinner ONLY when nothing has ever been cached. */ +const peerFilesLoading = computed(() => + peerNodes.value.length > 0 && peerFiles.value.length === 0 && peerFilesPending.value > 0 && peerFilesErrors.value < peerNodes.value.length, +) + +/** Fan out content.browse-peer; each peer renders as it resolves. */ +async function loadPeerFiles(force = false) { + if (peerNodes.value.length === 0) await loadPeers() + const targets = peerNodes.value.filter(p => { + if (force) return true + const e = peerBrowseEntry(p.onion) + const stale = e.fetchedAt === null || Date.now() - e.fetchedAt > 30_000 + return e.loadState === 'idle' || e.loadState === 'error' ? true : stale + }) + // Fire-and-collect: the computed aggregation updates per resolution. + await Promise.allSettled(targets.map(p => browsePeer(p))) } const filteredPeerFiles = computed(() => @@ -821,47 +923,50 @@ const searchMineItems = computed(() => ) // ── Existing counts / peers loading ────────────────────────────────────────── -async function loadCounts() { - if (!fileBrowserRunning.value) return - countsLoading.value = true - try { - const ok = await fileBrowserClient.login() - if (!ok) return - for (const section of contentSections) { - const path = SECTION_PATHS[section.id] - if (!path) continue - try { - const items = await fileBrowserClient.listDirectory(path) - sectionCounts.value[section.id] = items.length - } catch { - sectionCounts.value[section.id] = 0 - } +async function fetchCounts(): Promise> { + if (!fileBrowserRunning.value) return {} + const ok = await fileBrowserClient.login() + if (!ok) throw new Error('File Browser login failed') + const counts: Record = {} + for (const section of contentSections) { + const path = SECTION_PATHS[section.id] + if (!path) continue + try { + counts[section.id] = (await fileBrowserClient.listDirectory(path)).length + } catch { + counts[section.id] = 0 } - } catch (e) { - loadError.value = e instanceof Error ? e.message : 'Failed to load file counts' - if (import.meta.env.DEV) console.warn('FileBrowser count loading failed', e) - } finally { - countsLoading.value = false + } + return counts +} + +function loadCounts() { + if (countsResource.entry.data === null || countsResource.isStale.value) { + void countsResource.refresh() } } -onMounted(() => { +onMounted(async () => { loadCounts() - loadPeers() + await loadPeers() + // Warm the per-peer browse cache in the background: peer cards get their + // FIPS/Tor badge and the Peer Files tab is instant. Staleness-gated, so + // quick revisits don't refetch. + void loadPeerFiles() +}) + +// File Browser can finish its startup scan after we mount — pick counts up +// the moment it becomes available instead of showing a permanent blank. +watch(fileBrowserRunning, (running) => { + if (running) loadCounts() }) async function loadPeers() { - const hadPeers = peerNodes.value.length > 0 - peersLoading.value = true - try { - const result = await rpcClient.federationListNodes() - peerNodes.value = result?.nodes ?? [] - } catch (e) { - if (!hadPeers) peerNodes.value = [] - loadError.value = e instanceof Error ? e.message : 'Failed to load peer nodes' - } finally { - peersLoading.value = false - } + await peersResource.refresh() + // Surface refresh failures in the banner — the cached peer list stays + // visible either way (keep-last-known-value). + const e = peersResource.entry + if (e.error) loadError.value = e.error } function peerDisplayName(did: string): string { diff --git a/neode-ui/src/views/CloudFolder.vue b/neode-ui/src/views/CloudFolder.vue index 67064603..c58cef93 100644 --- a/neode-ui/src/views/CloudFolder.vue +++ b/neode-ui/src/views/CloudFolder.vue @@ -306,12 +306,12 @@ const backLabel = computed(() => { return atSectionRoot.value ? 'Back to Cloud' : 'Back to Parent Folder' }) -// Initialize native file browser when entering a native-UI section +// Initialize native file browser when entering a native-UI section. +// No reset() here: navigate() serves the per-path cache instantly and +// revalidates underneath — resetting wiped the listing and forced a +// spinner on every folder entry. watch([useNativeUI, section, routeFolderPath], async ([native, sec, path]) => { if (native && sec) { - if (cloudStore.currentPath !== path) { - cloudStore.reset() - } const ok = await cloudStore.init() if (ok) { await cloudStore.navigate(path)