feat(ui): Cloud page renders from cache — per-peer incremental fan-in, live FIPS/Tor badges, per-path folder cache
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m24s
Some checks failed
Demo images / Build & push demo images (push) Failing after 3m24s
Part B3 of docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md — the worst fetch-on-every-navigation offender converted to the cached-resource layer: - section counts / peer nodes / my files / paid items are cached resources: revisits paint instantly, refresh happens behind the content (sticky-ready), errors keep last-known data - peer files: per-peer cached browse entries replace the all-or-nothing Promise.allSettled — each peer's rows render the moment it answers, with "still fetching from N peers" + unreachable counts; browse-peer runs with maxRetries:1 so one dead peer costs its timeout once, not ×3 - peer cards get a live transport badge (FIPS green / Tor amber, with measured latency) from the transport field the browse response already carried — the per-peer FIPS-uptime view, for free - cloud store: per-path listing cache with stale-while-revalidate navigate() and a last-wins guard; CloudFolder no longer reset()s the store on every folder entry (that wipe forced a spinner each time) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
67454974b2
commit
c83bade022
@ -8,6 +8,11 @@ export const useCloudStore = defineStore('cloud', () => {
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(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<string, FileBrowserItem[]>()
|
||||
// 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<void> {
|
||||
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 {
|
||||
|
||||
@ -194,7 +194,7 @@
|
||||
Open Federation
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div v-else-if="filteredPeerFiles.length === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
<div v-else-if="filteredPeerFiles.length === 0 && peerFilesPending === 0" class="glass-card p-8 text-center text-white/40 text-sm">
|
||||
{{ selectedCategory === 'all' ? 'Your peers are not sharing any files yet.' : 'No peer files in this category.' }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
@ -216,6 +216,13 @@
|
||||
<span class="text-[10px] px-2 py-0.5 rounded-full bg-purple-500/15 text-purple-400 shrink-0">{{ f.peerName }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="peerFilesPending > 0" class="text-[11px] text-white/35 text-center mt-3 flex items-center justify-center gap-2">
|
||||
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Still fetching from {{ peerFilesPending }} peer{{ peerFilesPending === 1 ? '' : 's' }}…
|
||||
</p>
|
||||
<p v-if="peerFilesErrors > 0" class="text-[11px] text-white/35 text-center mt-3">
|
||||
{{ peerFilesErrors }} peer{{ peerFilesErrors === 1 ? '' : 's' }} unreachable — showing what answered.
|
||||
</p>
|
||||
@ -301,12 +308,23 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peer.trust_level === 'trusted' ? 'bg-green-400' : 'bg-purple-400'"></span>
|
||||
{{ peer.trust_level }}
|
||||
</span>
|
||||
<span class="text-white/30">Peer Node</span>
|
||||
<!-- Live transport badge — which route actually served the last
|
||||
browse (FIPS = direct mesh, fast; Tor = fallback, slow). -->
|
||||
<span
|
||||
v-if="peerTransport(peer.onion)"
|
||||
class="inline-flex items-center gap-1.5 px-2 py-1 rounded-full"
|
||||
:class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-500/15 text-emerald-300' : 'bg-amber-500/15 text-amber-300'"
|
||||
:title="`Last browse served via ${peerTransport(peer.onion)!.transport.toUpperCase()}`"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="peerTransport(peer.onion)!.transport === 'fips' ? 'bg-emerald-400' : 'bg-amber-400'"></span>
|
||||
{{ peerTransport(peer.onion)!.transport.toUpperCase() }} · {{ (peerTransport(peer.onion)!.latencyMs / 1000).toFixed(1) }}s
|
||||
</span>
|
||||
<span v-else class="text-white/30">Peer Node</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="peersLoading && peerNodes.length > 0"
|
||||
v-if="(peersLoading || peersRefreshing) && peerNodes.length > 0"
|
||||
class="glass-card p-3 text-center text-white/45 text-xs md:col-span-2 lg:col-span-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
|
||||
@ -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<Record<string, number>>({})
|
||||
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<Record<string, number>>({
|
||||
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<TabId>('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<PaidItem[]>([])
|
||||
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<PaidItem[]>({
|
||||
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<PeerNode[]>([])
|
||||
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<PeerNode[]>({
|
||||
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<string, string[]> = {
|
||||
@ -579,42 +626,51 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
// ── My Files (flat list of every own file across the sections) ──────────────
|
||||
const myFiles = ref<FileBrowserItem[]>([])
|
||||
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<FileBrowserItem[]>({
|
||||
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<FileBrowserItem[]> {
|
||||
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<void> {
|
||||
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<PeerFileEntry[]>([])
|
||||
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<void> {
|
||||
return resources.refresh<PeerBrowse>(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<PeerBrowse>(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<PeerFileEntry[]>(() => {
|
||||
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<Record<string, number>> {
|
||||
if (!fileBrowserRunning.value) return {}
|
||||
const ok = await fileBrowserClient.login()
|
||||
if (!ok) throw new Error('File Browser login failed')
|
||||
const counts: Record<string, number> = {}
|
||||
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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user