Refactor to make full file list completely non-reactive because pinia persistence was causing long delays especially while searching when there were a lot of files. Implement better ghosts that do not alter the file list.

This commit is contained in:
Leo Vasanko
2026-02-04 02:03:41 +00:00
parent d7aae07af0
commit 0b8462d0dc
10 changed files with 178 additions and 51 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.documentCount === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!exists(props.path)">Folder not found</p>
<p v-else>Empty folder</p>
+3 -4
View File
@@ -251,8 +251,7 @@ const mkdir = (doc: Doc, name: string) => {
})
doc.name = name
doc.key = crypto.randomUUID()
doc.ghost = true
store.document.push(doc)
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
@@ -351,13 +350,13 @@ const copyImage = async (doc: Doc) => {
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
doc.ghost = true
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
doc.ghost = false
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
+3 -4
View File
@@ -205,8 +205,7 @@ const mkdir = (doc: Doc, name: string) => {
})
doc.name = name
doc.key = crypto.randomUUID()
doc.ghost = true
store.document.push(doc)
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
@@ -295,13 +294,13 @@ const copyImage = async (doc: Doc) => {
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
doc.ghost = true
store.hideDoc(path)
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
doc.ghost = false
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
+13 -6
View File
@@ -31,23 +31,30 @@ const props = defineProps({
const dst = computed(() => props.path!.join('/'))
const op = (opName: string, dst?: string) => {
const sel = store.selectedFiles
const paths = sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
const msg = {
op: opName,
sel: sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
if (opName === 'rm' || opName === 'mv')
for (const key of sel.keys) sel.docs[key]!.ghost = true
// Hide items being deleted or moved (optimistic update)
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.hideDoc(path)
}
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Control socket error', msg, res.error)
store.error = res.error.message
// Restore hidden items on error
if (opName === 'rm' || opName === 'mv') {
for (const path of paths) store.unhideDoc(path)
}
return
} else if (res.status === 'ack') {
console.log('Control ack OK', res)
+10 -5
View File
@@ -10,6 +10,7 @@
<script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils';
import { onMounted, onUnmounted, reactive, ref } from 'vue'
@@ -98,7 +99,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000)
const byPath = store.docsByPath
const docs = getDocuments()
const byPath = new Map(docs.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
// Also check existing ghosts
for (const g of store.ghosts) {
byPath.set(g.loc ? `${g.loc}/${g.name}` : g.name, g)
}
const added = new Set<string>()
for (const f of files) {
const lastSlash = f.cloudName.lastIndexOf('/')
@@ -109,14 +115,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.document.push(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true, ghost: true }))
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true }))
added.add(folderPath)
}
}
// Ghost file or update existing
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName)
if (existing) { existing.size = f.file.size; existing.mtime = now; existing.ghost = true }
else store.document.push(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false, ghost: true }))
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false }))
}
// @ts-ignore
upqueue = [...upqueue, ...files]
+2
View File
@@ -10,6 +10,7 @@ export type DocProps = {
mtime: number
dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
}
export class Doc {
@@ -19,6 +20,7 @@ export class Doc {
public mtime: number = 0
public dir: boolean = false
public ghost: boolean = false
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */
public _name: string = ""
+33
View File
@@ -0,0 +1,33 @@
// Non-reactive document storage for the full file list
// This avoids Vue reactivity overhead on large arrays
import type { Doc } from '@/repositories/Document'
import { shallowRef, triggerRef } from 'vue'
// The main document list - shallowRef means only the reference is reactive, not the contents
const documents = shallowRef<Doc[]>([])
// Version counter for manual reactivity triggering
let version = 0
export function getDocuments(): Doc[] {
return documents.value
}
export function setDocuments(docs: Doc[]): void {
documents.value = docs
version++
}
export function getVersion(): number {
return version
}
// Trigger reactivity manually (e.g., after modifications)
export function triggerUpdate(): void {
version++
triggerRef(documents)
}
// For computed dependencies that need to react to document changes
export const documentRef = documents
+91 -23
View File
@@ -5,6 +5,7 @@ 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
@@ -52,9 +53,20 @@ function getSearchWorker(): Worker {
return searchWorker
}
// Ghost expiry time in seconds
const GHOST_TTL = 30
// Periodic cleanup interval
let cleanupInterval: ReturnType<typeof setInterval> | null = null
export const useMainStore = defineStore('main', {
state: () => ({
document: [] as Doc[],
// 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<string, number>(),
// Version counter to trigger reactivity when external document list changes
docVersion: 0,
selected: new Set<FUID>([]),
query: '' as string,
searchResults: [] as Doc[],
@@ -118,10 +130,66 @@ export const useMainStore = defineStore('main', {
}))
loc.push(name)
}
this.document = docs
// 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) {
@@ -145,7 +213,8 @@ export const useMainStore = defineStore('main', {
syncSearchWorker() {
const worker = getSearchWorker()
// Send plain data to worker (no class instances)
const docData = this.document.map(doc => ({
const docs = getDocuments()
const docData = docs.map(doc => ({
loc: doc.loc,
name: doc.name,
key: doc.key,
@@ -209,7 +278,11 @@ export const useMainStore = defineStore('main', {
clearSensitiveData() {
// Clear all sensitive state on logout or auth failure
localStorage.removeItem('cista-files')
this.document = []
setDocuments([])
this.ghosts = []
this.hiddenPaths.clear()
this.stopCleanupTimer()
this.docVersion++
this.selected.clear()
this.user.username = ''
this.user.privileged = false
@@ -268,26 +341,21 @@ export const useMainStore = defineStore('main', {
getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
isUserLogged(): boolean { return this.user.isLoggedIn },
recentDocuments(): Doc[] { return sorted(this.document, 'modified') },
/** Set of all full paths - for O(1) existence checks */
pathSet(): Set<string> {
return new Set(this.document.map(d => d.loc ? `${d.loc}/${d.name}` : d.name))
/** Get documents count (triggers on docVersion change) */
documentCount(): number {
// Access docVersion to make this reactive
void this.docVersion
return getDocuments().length
},
/** Map from location to documents in that folder - for O(1) folder listing */
docsByLoc(): Map<string, Doc[]> {
const map = new Map<string, Doc[]>()
for (const doc of this.document) {
const arr = map.get(doc.loc)
if (arr) arr.push(doc)
else map.set(doc.loc, [doc])
}
return map
},
/** Map from full path to Doc - for O(1) path lookup */
docsByPath(): Map<string, Doc> {
return new Map(this.document.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
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<FUID>()
const ret: SelectedItems = {
@@ -296,7 +364,7 @@ export const useMainStore = defineStore('main', {
keys: [],
recursive: [],
}
for (const doc of this.document) {
for (const doc of docs) {
if (selected.has(doc.key)) {
found.add(doc.key)
ret.keys.push(doc.key)
@@ -317,7 +385,7 @@ export const useMainStore = defineStore('main', {
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
const nremove = base.loc.length
add(base.name, basepath, base)
for (const doc of this.document) {
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)
+5 -1
View File
@@ -1,9 +1,13 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
export const exists = (path: string[]) => {
const store = useMainStore()
return store.pathSet.has(path.join('/'))
// Access docVersion to make this reactive
void store.docVersion
const p = path.join('/')
return getDocuments().some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
}
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+17 -7
View File
@@ -20,6 +20,7 @@
<script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
@@ -50,13 +51,22 @@ const documents = computed(() => {
const query = props.query
// List the current location (no search)
if (!query) return sorted(
store.docsByLoc.get(loc) ?? [],
store.prefs.sortListing,
)
if (!query) {
// Access docVersion to make this reactive to document changes
void store.docVersion
const hidden = store.hiddenPaths
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
// Overlay ghosts for this location (excluding hidden ones)
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
// Merge: ghosts that don't conflict with real docs
const realNames = new Set(docs.map(d => d.name))
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
return sorted(merged, store.prefs.sortListing)
}
// Search results from worker
const docs = store.searchResults
// Search results from worker (also filter hidden)
const hidden = store.hiddenPaths
const docs = store.searchResults.filter(doc => !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
// Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered
@@ -71,7 +81,7 @@ watchEffect(() => {
})
// Only auto-switch gallery mode when entering a new folder or on initial file list load
watch([() => props.path.join('/'), () => store.document.length], ([path, len], [oldPath, oldLen]) => {
watch([() => props.path.join('/'), () => store.documentCount], ([path, len], [oldPath, oldLen]) => {
// React to path change or initial document load (0 → non-zero)
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
store.prefs.gallery = documents.value.some(d => d.previewable)