diff --git a/cista/app.py b/cista/app.py index 7b62df0..1b580bf 100644 --- a/cista/app.py +++ b/cista/app.py @@ -49,9 +49,7 @@ async def main_start(app): max_workers=preview_workers, thread_name_prefix="cista-preview" ) # Larger pool for long-running but low-memory zip operations - app.ctx.zipexec = ThreadPoolExecutor( - max_workers=32, thread_name_prefix="cista-zip" - ) + app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip") watching.start(app) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1eb861e..7e201a1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,8 @@ @@ -20,7 +21,7 @@ import { watchEffect, ref, computed, watch } from 'vue' import { useMainStore } from '@/stores/main' import Router from '@/router/index' -import { needleFormat, localeIncludes, collator } from '@/utils' +import { collator } from '@/utils' import { sorted, sortedGrouped } from '@/utils/docsort' import FileExplorer from '@/components/FileExplorer.vue' @@ -30,41 +31,34 @@ const props = defineProps<{ path: Array query: string }>() + +// Trigger search when query changes +watch( + () => [props.query, props.path.join('/')] as const, + ([query, loc]) => { + store.search(query, loc) + }, + { immediate: true } +) + const documents = computed(() => { const loc = props.path.join('/') const query = props.query - // List the current location + + // List the current location (no search) if (!query) return sorted( store.document.filter(doc => doc.loc === loc), store.prefs.sortListing, ) - // Find up to 100 newest documents that match the search - const needle = needleFormat(query) - let limit = 100 - let docs = [] - for (const doc of store.recentDocuments) { - if (localeIncludes(doc.haystack, needle)) { - docs.push(doc) - if (--limit === 0) break - } - } - const locsub = loc + '/' + + // Search results from worker + const docs = store.searchResults + // Custom sort override in effect? Use grouped sorting to keep folders together const order = store.prefs.sortFiltered if (order) return sortedGrouped(docs, order) - // Sort by relevance - current folder, then subfolders, then others - docs.sort((a, b) => ( - // @ts-ignore - (b.loc === loc) - (a.loc === loc) || - // @ts-ignore - (b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) || - collator.compare(a.loc, b.loc) || - // @ts-ignore - (a.type === 'file') - (b.type === 'file') || - // @ts-ignore - b.name.includes(query) - a.name.includes(query) || - collator.compare(a.name, b.name) - )) + + // Results are already sorted by relevance in the worker return docs }) @@ -92,4 +86,15 @@ watch([() => props.path.join('/'), () => store.document.length], ([path, len], [ text-shadow: 0 0 .3rem #000, 0 0 2rem #0008; color: var(--accent-color); } +.search-loading { + position: fixed; + bottom: 1rem; + right: 1rem; + padding: 0.5rem 1rem; + background: var(--accent-color, #007bff); + color: white; + border-radius: 0.25rem; + font-size: 0.875rem; + opacity: 0.9; +} diff --git a/frontend/src/workers/searchWorker.ts b/frontend/src/workers/searchWorker.ts new file mode 100644 index 0000000..496cdc7 --- /dev/null +++ b/frontend/src/workers/searchWorker.ts @@ -0,0 +1,177 @@ +// Search worker - runs search in background thread +// Receives document updates and search queries, returns incremental results + +interface DocData { + loc: string + name: string + key: string + size: number + mtime: number + dir: boolean +} + +interface WorkerDoc extends DocData { + haystack: string +} + +interface SearchMessage { + type: 'search' + query: string + loc: string + id: number +} + +interface UpdateMessage { + type: 'update' + documents: DocData[] +} + +type IncomingMessage = SearchMessage | UpdateMessage + +interface ResultMessage { + type: 'results' + docs: DocData[] + id: number + done: boolean +} + +// Worker state +let documents: WorkerDoc[] = [] +let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending +let currentSearchId = 0 + +// Haystack formatting (same as main thread utils) +function haystackFormat(str: string): string { + const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + return '^' + based + '$' +} + +// Needle formatting +function needleFormat(query: string) { + const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + return { based, words: based.split(/\s+/) } +} + +// Test if haystack includes needle +function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean { + const { based, words } = filter + return haystack.includes(based) || (words && words.every(word => haystack.includes(word))) +} + +// Collator for sorting +const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) + +// Sort by mtime descending +function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] { + return [...docs].sort((a, b) => b.mtime - a.mtime) +} + +// Yield control to check for new messages +function yieldControl(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +// Perform search with incremental results +async function performSearch(query: string, loc: string, searchId: number) { + const needle = needleFormat(query) + const limit = 100 + const batchSize = 1000 + const results: WorkerDoc[] = [] + + for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) { + // Check if search was superseded + if (currentSearchId !== searchId) return + + // Process batch + const end = Math.min(i + batchSize, recentDocuments.length) + for (let j = i; j < end && results.length < limit; j++) { + const doc = recentDocuments[j]! + if (localeIncludes(doc.haystack, needle)) { + results.push(doc) + } + } + + // Post incremental results if we found any in this batch + if (results.length > 0 && currentSearchId === searchId) { + const sortedResults = sortResults(results, query, loc) + postMessage({ + type: 'results', + docs: sortedResults.map(stripHaystack), + id: searchId, + done: false + } as ResultMessage) + } + + // Yield control between batches + if (i + batchSize < recentDocuments.length && results.length < limit) { + await yieldControl() + } + } + + // Post final results + if (currentSearchId === searchId) { + const sortedResults = sortResults(results, query, loc) + postMessage({ + type: 'results', + docs: sortedResults.map(stripHaystack), + id: searchId, + done: true + } as ResultMessage) + } +} + +// Sort results by relevance +function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] { + const locsub = loc + '/' + return [...docs].sort((a, b) => ( + // Current folder first + // @ts-ignore + (b.loc === loc) - (a.loc === loc) || + // Then subfolders + // @ts-ignore + (b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) || + // Then by location + collator.compare(a.loc, b.loc) || + // Files after folders + // @ts-ignore + (a.dir === false) - (b.dir === false) || + // Exact name match first + // @ts-ignore + b.name.includes(query) - a.name.includes(query) || + // Finally by name + collator.compare(a.name, b.name) + )) +} + +// Strip haystack before sending back to main thread +function stripHaystack(doc: WorkerDoc): DocData { + const { haystack, ...rest } = doc + return rest +} + +// Handle incoming messages +self.onmessage = async (e: MessageEvent) => { + const msg = e.data + + if (msg.type === 'update') { + // Update document list with haystacks + documents = msg.documents.map(doc => ({ + ...doc, + haystack: haystackFormat(doc.name) + })) + recentDocuments = sortByRecent(documents) + } else if (msg.type === 'search') { + currentSearchId = msg.id + if (msg.query) { + await performSearch(msg.query, msg.loc, msg.id) + } else { + // Empty query - no results needed (main thread handles folder listing) + postMessage({ + type: 'results', + docs: [], + id: msg.id, + done: true + } as ResultMessage) + } + } +}