import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document' import { Doc } from '@/repositories/Document' import { defineStore, type StateTree } from 'pinia' import { collator } from '@/utils' import { watchConnect, resumeWatching } from '@/repositories/WS' import { sorted, type SortOrder } from '@/utils/docsort' import SearchWorker from '@/workers/searchWorker?worker' import { getDocuments, setDocuments, documentRef } from './documentStore' // Singleton search worker instance let searchWorker: Worker | null = null let searchId = 0 let searchStore: ReturnType | null = null let loadingTimer: ReturnType | null = null let clearOldResultsTimer: ReturnType | null = null let lastResultUpdate = 0 function getSearchWorker(): Worker { if (!searchWorker) { searchWorker = new SearchWorker() // Set up message handler once searchWorker.onmessage = (e) => { if (!searchStore || e.data.id !== searchId) return // Stale result // Convert plain data back to Doc instances const docs = e.data.docs.map((d: any) => new Doc(d)) // Cancel the clear-old-results timer since we have new results if (clearOldResultsTimer) { clearTimeout(clearOldResultsTimer) clearOldResultsTimer = null } // Throttle rapid intermediate updates to reduce UI flicker const now = performance.now() if (!e.data.done && now - lastResultUpdate < 50) { return // Skip intermediate update if too recent } lastResultUpdate = now searchStore.searchResults = docs if (e.data.done) { // Clear the loading timer and hide spinner if (loadingTimer) { clearTimeout(loadingTimer) loadingTimer = null } searchStore.searchLoading = false } } } return searchWorker } // Ghost expiry time in seconds const GHOST_TTL = 30 // Periodic cleanup interval let cleanupInterval: ReturnType | null = null export const useMainStore = defineStore('main', { state: () => ({ // Ghosts are temporary optimistic-update files/folders shown until server confirms ghosts: [] as Doc[], // Hidden paths for optimistic delete (path -> expiry timestamp) hiddenPaths: new Map(), // Version counter to trigger reactivity when external document list changes docVersion: 0, selected: new Set([]), query: '' as string, searchResults: [] as Doc[], searchLoading: false, _searchRouteTimer: null as ReturnType | null, fileExplorer: null as any, error: '' as string, // Permanent status message (e.g., "Reconnecting...") toast: '' as string, // Temporary toast (auto-dismisses) toastTimeout: null as ReturnType | null, connected: false, authInProgress: false, cursor: '' as string, server: {} as Record & { public?: boolean, paskia?: boolean }, dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied', uprogress: {} as any, dprogress: {} as any, prefs: { gallery: false, sortListing: '' as SortOrder, sortFiltered: '' as SortOrder, searchHotkey: '/', // Character shown for search hotkey (Slash key) }, user: { username: '' as string, privileged: false as boolean, isLoggedIn: false as boolean, }, space: { disk: 0, free: 0, used: 0, storage: 0, allocated: 0, } }), persist: { pick: ['prefs', 'cursor', 'selected'], serializer: { deserialize: (data: string): StateTree => { const ret = JSON.parse(data) ret.selected = new Set(ret.selected) return ret }, serialize: (tree: StateTree): string => { tree.selected = Array.from(tree.selected) return JSON.stringify(tree) } }, }, actions: { updateRoot(root: FileEntry[]) { const docs = [] let loc = [] as string[] for (const [level, name, key, mtime, size, allocated, isfile] of root) { loc = loc.slice(0, level - 1) docs.push(new Doc({ name, loc: level ? loc.join('/') : '/', key, size, allocated, mtime, dir: !isfile, })) loc.push(name) } // Store in non-reactive external storage setDocuments(docs) // Clear ghosts that now exist in the real list const realPaths = new Set(docs.map(d => d.loc ? `${d.loc}/${d.name}` : d.name)) this.ghosts = this.ghosts.filter(g => !realPaths.has(g.loc ? `${g.loc}/${g.name}` : g.name)) // Clear hidden paths that no longer exist (deletion confirmed) for (const path of this.hiddenPaths.keys()) { if (!realPaths.has(path)) this.hiddenPaths.delete(path) } // Start cleanup timer if not running this.startCleanupTimer() // Bump version to trigger reactive updates this.docVersion++ // Sync documents to search worker this.syncSearchWorker() }, /** Add a ghost file/folder for optimistic UI updates */ addGhost(doc: Doc) { doc.ghost = true doc.expires = Math.floor(Date.now() / 1000) + GHOST_TTL this.ghosts.push(doc) }, /** Clear all ghosts (e.g., on navigation or refresh) */ clearGhosts() { this.ghosts = [] }, /** Hide a document path (optimistic delete) */ hideDoc(path: string) { this.hiddenPaths.set(path, Math.floor(Date.now() / 1000) + GHOST_TTL) }, /** Unhide a document path (delete failed, restore visibility) */ unhideDoc(path: string) { this.hiddenPaths.delete(path) }, /** Start the periodic cleanup timer */ startCleanupTimer() { if (cleanupInterval) return cleanupInterval = setInterval(() => this.cleanupExpired(), 5000) }, /** Stop the cleanup timer */ stopCleanupTimer() { if (cleanupInterval) { clearInterval(cleanupInterval) cleanupInterval = null } }, /** Remove expired ghosts and hidden paths */ cleanupExpired() { const now = Math.floor(Date.now() / 1000) const ghostsBefore = this.ghosts.length const hiddenBefore = this.hiddenPaths.size this.ghosts = this.ghosts.filter(g => g.expires > now) for (const [path, expires] of this.hiddenPaths) { if (expires <= now) this.hiddenPaths.delete(path) } // Stop timer if nothing to clean up if (this.ghosts.length === 0 && this.hiddenPaths.size === 0) { this.stopCleanupTimer() } }, /** Show a temporary toast message that auto-dismisses */ showToast(message: string, duration = 3000) { if (this.toastTimeout) { clearTimeout(this.toastTimeout) this.toastTimeout = null } this.toast = message this.toastTimeout = setTimeout(() => { this.toast = '' this.toastTimeout = null }, duration) }, /** Clear the current toast immediately */ clearToast() { if (this.toastTimeout) { clearTimeout(this.toastTimeout) this.toastTimeout = null } this.toast = '' }, syncSearchWorker() { const worker = getSearchWorker() // Send plain data to worker (no class instances) const docs = getDocuments() const docData = docs.map(doc => ({ loc: doc.loc, name: doc.name, key: doc.key, size: doc.size, allocated: doc.allocated, mtime: doc.mtime, dir: doc.dir, })) worker.postMessage({ type: 'update', documents: docData }) }, search(query: string, loc: string) { const worker = getSearchWorker() const id = ++searchId searchStore = this // Store reference for worker callback // Update query immediately so watchers know we're handling this this.query = query // Cancel pending timers if (loadingTimer) { clearTimeout(loadingTimer) loadingTimer = null } if (clearOldResultsTimer) { clearTimeout(clearOldResultsTimer) clearOldResultsTimer = null } if (!query) { // Clear results only when search is closed this.searchResults = [] this.searchLoading = false return } // Keep old results briefly to avoid flicker on fast cached searches // But clear them after 50ms if no new results have arrived clearOldResultsTimer = setTimeout(() => { if (searchId === id) { this.searchResults = [] } clearOldResultsTimer = null }, 50) // Delay showing loading indicator to avoid flicker on fast searches loadingTimer = setTimeout(() => { if (searchId === id) { // Still the current search this.searchLoading = true } loadingTimer = null }, 100) worker.postMessage({ type: 'search', query, loc, id }) }, login(username: string, privileged: boolean) { this.user.username = username this.user.privileged = privileged this.user.isLoggedIn = true this.dialog = '' if (!this.connected) resumeWatching() }, clearSensitiveData() { // Clear all sensitive state on logout or auth failure localStorage.removeItem('cista-files') setDocuments([]) this.ghosts = [] this.hiddenPaths.clear() this.stopCleanupTimer() this.docVersion++ this.selected.clear() this.user.username = '' this.user.privileged = false this.user.isLoggedIn = false this.connected = false this.dialog = '' this.cursor = '' }, async logout() { console.log("Logout") try { const res = await fetch('/auth/api/logout', { method: 'POST' }) if (!res.ok) { const data = await res.json().catch(() => ({})) this.error = data.message || data.detail || 'Logout failed' return } } catch (e) { this.error = 'Logout failed' return } this.clearSensitiveData() resumeWatching() }, toggleSort(name: SortOrder) { const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing const newOrder = current === name ? '' : name if (this.query) this.prefs.sortFiltered = newOrder else this.prefs.sortListing = newOrder this.showSortToast(newOrder) }, sort(name: SortOrder | '') { if (this.query) this.prefs.sortFiltered = name else this.prefs.sortListing = name this.showSortToast(name) }, showSortToast(order: SortOrder | '') { const labels: Record = { '': 'Folders first', 'name': 'Alphabetical order', 'modified': 'Newest first', 'size': 'Largest first', } this.showToast(labels[order] || order, 1200) }, focusBreadcrumb() { (document.querySelector('.breadcrumb') as HTMLAnchorElement).focus() }, cancelDownloads() { location.reload() // FIXME }, cancelUploads() { location.reload() // FIXME }, }, getters: { sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing }, isUserLogged(): boolean { return this.user.isLoggedIn }, /** Get documents count (triggers on docVersion change) */ documentCount(): number { // Access docVersion to make this reactive void this.docVersion return getDocuments().length }, recentDocuments(): Doc[] { // Access docVersion to make this reactive void this.docVersion return sorted(getDocuments(), 'modified') }, selectedFiles(): SelectedItems { // Access docVersion to make this reactive void this.docVersion const docs = getDocuments() const selected = this.selected const found = new Set() const ret: SelectedItems = { missing: new Set(), docs: {}, keys: [], recursive: [], } for (const doc of docs) { if (selected.has(doc.key)) { found.add(doc.key) ret.keys.push(doc.key) ret.docs[doc.key] = doc } } // What did we not select? for (const key of selected) if (!found.has(key)) ret.missing.add(key) // Build a flat list including contents recursively for (const key of ret.keys) { const base = ret.docs[key]! const basepath = base.loc ? `${base.loc}/${base.name}` : base.name const nremove = base.loc.length ret.recursive.push([base.name, basepath, base]) for (const doc of docs) { if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') { const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name const rel = full.slice(nremove) ret.recursive.push([rel, full, doc]) } } } // Sort by rel (name stored as on download) ret.recursive.sort((a, b) => collator.compare(a[0], b[0])) return ret } } })