Compare commits

...
4 Commits
11 changed files with 411 additions and 59 deletions
+36 -9
View File
@@ -25,6 +25,28 @@ def create_banner():
""" """
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
"""Create a framed startup box with server information."""
title = f"Cista {cista.__version__}"
listen = f"{url} ({unix})" if unix else url
location = f"{folder} @ {listen}"
lines = [title, location]
if paskia_url:
lines.append(f"Paskia: {paskia_url}")
if dev:
lines.append("dev mode")
# Calculate width based on content
inner_width = max(len(line) for line in lines) + 2
# Build the box
box = [f"{'' * inner_width}"]
for line in lines:
box.append(f"{line:<{inner_width - 1}}")
box.append(f"{'' * inner_width}")
return "\n".join(box) + "\n"
banner = create_banner() banner = create_banner()
doc = """\ doc = """\
@@ -83,8 +105,7 @@ def _main():
elif "--version" in sys.argv: elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n") sys.stdout.write(f"cista {cista.__version__}\n")
return 0 return 0
else: # Don't print banner yet for normal startup - we'll print the startup box later
sys.stderr.write(banner)
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
@@ -121,17 +142,23 @@ def _main():
elif not exists: elif not exists:
settings["listen"] = ":8000" settings["listen"] = ":8000"
operation = config.update_config(settings) operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
# Prepare to serve # Prepare to serve
unix = None url, opts = serve.parse_listen(config.config.listen)
url, _ = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir(): if not config.config.path.is_dir():
raise ValueError(f"No such directory: {config.config.path}") raise ValueError(f"No such directory: {config.config.path}")
extra = f" ({unix})" if unix else ""
dev = args["--dev"] dev = args["--dev"]
if dev: # Check for Paskia SSO
extra += " (dev mode)" from cista.sso import PASKIA_BACKEND_URL
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Print startup box
startup_box = create_startup_box(
folder=config.config.path,
url=url,
unix=opts.get("unix"),
dev=dev,
paskia_url=PASKIA_BACKEND_URL or None,
)
sys.stderr.write(startup_box)
# Run the server # Run the server
serve.run(dev=dev) serve.run(dev=dev)
return 0 return 0
+24 -7
View File
@@ -43,10 +43,13 @@ setproctitle("cista-main")
async def main_start(app): async def main_start(app):
config.load_config() config.load_config()
setproctitle(f"cista {config.config.path.name}") setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count())) # Small pool for memory-intensive preview generation
preview_workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor( app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker" 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")
watching.start(app) watching.start(app)
@@ -56,6 +59,7 @@ async def main_stop(app):
quit.set() quit.set()
watching.stop(app) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client() await sso.close_client()
logger.debug("Cista worker threads all finished") logger.debug("Cista worker threads all finished")
@@ -288,27 +292,40 @@ async def zip_download(req, keys, zipfile, ext):
yield chunk yield chunk
assert size == 0 assert size == 0
pending_put = None # Current queue.put future, can be cancelled
def worker(): def worker():
nonlocal pending_put
try: try:
for chunk in stream_zip(local_files(files)): for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result() future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
pending_put = future
future.result() # Blocks until queue has space
except asyncio.CancelledError:
logger.info("ZIP download cancelled by client disconnect")
except Exception: except Exception:
logger.exception("Error streaming ZIP") logger.exception("Error streaming ZIP")
raise raise
finally: finally:
pending_put = None
asyncio.run_coroutine_threadsafe(queue.put(None), loop) asyncio.run_coroutine_threadsafe(queue.put(None), loop)
# Don't block the event loop: run in a thread # Don't block the event loop: run in a thread (use larger zip pool)
queue = asyncio.Queue(maxsize=1) queue = asyncio.Queue(maxsize=1)
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
thread = loop.run_in_executor(app.ctx.threadexec, worker) thread = loop.run_in_executor(app.ctx.zipexec, worker)
# Stream the response # Stream the response
res = await req.respond( res = await req.respond(
content_type="application/zip", content_type="application/zip",
headers={"cache-control": "no-store"}, headers={"cache-control": "no-store"},
) )
while chunk := await queue.get(): try:
await res.send(chunk) while chunk := await queue.get():
await res.send(chunk)
finally:
# Cancel any pending put to unblock and stop the worker
if pending_put:
pending_put.cancel()
await thread # If it raises, the response will fail download await thread # If it raises, the response will fail download
+9 -1
View File
@@ -1,5 +1,8 @@
<template> <template>
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''"> <div v-if="store.toast" class="toast-message" @click="store.clearToast()">
{{ store.toast }}
</div>
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
{{ store.error }} {{ store.error }}
</div> </div>
<SettingsModal /> <SettingsModal />
@@ -100,6 +103,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
// Globally close search, clear errors on Escape // Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
store.clearToast()
headerMain.value!.closeSearch(event) headerMain.value!.closeSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} }
@@ -181,4 +185,8 @@ export type { Path }
max-width: 90vw; max-width: 90vw;
text-align: center; text-align: center;
} }
.toast-message.status {
background: #555;
color: #fff;
}
</style> </style>
+6 -5
View File
@@ -6,6 +6,7 @@
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
import { zipName } from '@/utils/fileutil'
import { reactive } from 'vue'; import { reactive } from 'vue';
const store = useMainStore() const store = useMainStore()
@@ -106,7 +107,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
++store.dprogress.fileidx ++store.dprogress.fileidx
const reader = res.body.getReader() const reader = res.body.getReader()
await writable.truncate(0) await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now() store.dprogress.tlast = Date.now()
while (true) { while (true) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
@@ -136,7 +136,7 @@ const download = async () => {
console.log('Download', sel) console.log('Download', sel)
if (sel.keys.length === 0) { if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing) console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
store.error = 'No existing files selected' store.showToast('No existing files selected')
store.selected.clear() store.selected.clear()
return return
} }
@@ -144,7 +144,7 @@ const download = async () => {
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir) const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) { if (files.length === 1) {
store.selected.clear() store.selected.clear()
store.error = "Single file via browser downloads" store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0]![1]}`) return linkdl(`/files/${files[0]![1]}`)
} }
// Use FileSystem API if multiple files and the browser supports it // Use FileSystem API if multiple files and the browser supports it
@@ -164,9 +164,10 @@ const download = async () => {
} }
// Otherwise, zip and download // Otherwise, zip and download
console.log("Falling back to zip download") console.log("Falling back to zip download")
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]!]!.name : 'download' const items = sel.keys.map(k => sel.docs[k]!)
const name = zipName(items)
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.error = "Downloading as ZIP via browser downloads" store.showToast(`Downloading ${name}.zip`)
store.selected.clear() store.selected.clear()
} }
+2 -2
View File
@@ -70,7 +70,7 @@ const submit = async (ev: Event) => {
try { try {
if (form.passwordChange) { if (form.passwordChange) {
if (!form.password) { if (!form.password) {
store.error = '⚠️ Current password is required' store.showToast('⚠️ Current password is required')
password.value!.focus() password.value!.focus()
return return
} }
@@ -79,7 +79,7 @@ const submit = async (ev: Event) => {
close() close()
} catch (error) { } catch (error) {
const httpError = error as ISimpleError const httpError = error as ISimpleError
store.error = httpError.message || '🛑 Unknown error' store.showToast(httpError.message || '🛑 Unknown error')
} finally { } finally {
confirmLoading.value = false confirmLoading.value = false
} }
+1 -1
View File
@@ -84,7 +84,7 @@ const uploadFiles = (infiles: File[]) => {
const uploadCloudFiles = (files: CloudFile[]) => { const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.')) const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) { if (dotfiles.length) {
store.error = "Won't upload dotfiles" store.showToast("Won't upload dotfiles")
console.log("Dotfiles omitted", dotfiles) console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.')) files = files.filter(f => !f.cloudName.includes('/.'))
} }
@@ -93,7 +93,7 @@ const loadUsers = async () => {
users.value = data.users users.value = data.users
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to load users' store.showToast(httpError.message || 'Failed to load users')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -111,7 +111,7 @@ const addUser = async () => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to add user' store.showToast(httpError.message || 'Failed to add user')
} }
} }
@@ -122,7 +122,7 @@ const toggleAdmin = async (user: User, event: Event) => {
user.privileged = target.checked user.privileged = target.checked
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update user' store.showToast(httpError.message || 'Failed to update user')
target.checked = user.privileged // revert target.checked = user.privileged // revert
} }
} }
@@ -142,7 +142,7 @@ const renameUser = async (user: User) => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to rename user' store.showToast(httpError.message || 'Failed to rename user')
} }
} }
@@ -156,7 +156,7 @@ const resetPassword = async (user: User) => {
} }
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to reset password' store.showToast(httpError.message || 'Failed to reset password')
} }
} }
@@ -167,7 +167,7 @@ const deleteUserAction = async (username: string) => {
await loadUsers() await loadUsers()
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to delete user' store.showToast(httpError.message || 'Failed to delete user')
} }
} }
@@ -202,7 +202,7 @@ const updateServerSettings = async () => {
success.value = 'Server settings updated' success.value = 'Server settings updated'
} catch (e) { } catch (e) {
const httpError = e as ISimpleError const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update settings' store.showToast(httpError.message || 'Failed to update settings')
} }
} }
+77 -1
View File
@@ -4,14 +4,30 @@ import { defineStore, type StateTree } from 'pinia'
import { collator } from '@/utils' import { collator } from '@/utils'
import { watchConnect, resumeWatching } from '@/repositories/WS' import { watchConnect, resumeWatching } from '@/repositories/WS'
import { sorted, type SortOrder } from '@/utils/docsort' import { sorted, type SortOrder } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker'
// Singleton search worker instance
let searchWorker: Worker | null = null
let searchId = 0
function getSearchWorker(): Worker {
if (!searchWorker) {
searchWorker = new SearchWorker()
}
return searchWorker
}
export const useMainStore = defineStore('main', { export const useMainStore = defineStore('main', {
state: () => ({ state: () => ({
document: [] as Doc[], document: [] as Doc[],
selected: new Set<FUID>([]), selected: new Set<FUID>([]),
query: '' as string, query: '' as string,
searchResults: [] as Doc[],
searchLoading: false,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses)
toastTimeout: null as ReturnType<typeof setTimeout> | null,
connected: false, connected: false,
authInProgress: false, authInProgress: false,
cursor: '' as string, cursor: '' as string,
@@ -61,6 +77,66 @@ export const useMainStore = defineStore('main', {
loc.push(name) loc.push(name)
} }
this.document = docs this.document = docs
// Sync documents to search worker
this.syncSearchWorker()
},
/** 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 docData = this.document.map(doc => ({
loc: doc.loc,
name: doc.name,
key: doc.key,
size: doc.size,
mtime: doc.mtime,
dir: doc.dir,
}))
worker.postMessage({ type: 'update', documents: docData })
},
search(query: string, loc: string) {
const worker = getSearchWorker()
const id = ++searchId
if (!query) {
this.searchResults = []
this.searchLoading = false
return
}
this.searchLoading = true
worker.onmessage = (e) => {
if (e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances
this.searchResults = e.data.docs.map((d: any) => new Doc(d))
if (e.data.done) {
this.searchLoading = false
}
}
worker.postMessage({ type: 'search', query, loc, id })
}, },
login(username: string, privileged: boolean) { login(username: string, privileged: boolean) {
this.user.username = username this.user.username = username
+41
View File
@@ -6,3 +6,44 @@ export const exists = (path: string[]) => {
const p = path.join('/') const p = path.join('/')
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p) return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
} }
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
export const stripExt = (name: string): string => {
// Common compound extensions
const compoundExts = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']
const lower = name.toLowerCase()
for (const ext of compoundExts) {
if (lower.endsWith(ext)) return name.slice(0, -ext.length)
}
// Regular extension: only strip if the extension looks like one (2-5 chars, alphanumeric)
const lastDot = name.lastIndexOf('.')
if (lastDot > 0) {
const ext = name.slice(lastDot + 1)
if (ext.length >= 2 && ext.length <= 5 && /^[a-zA-Z0-9]+$/.test(ext)) {
return name.slice(0, lastDot)
}
}
return name
}
/** Generate a sensible zip filename for a selection of items */
export const zipName = (items: { name: string; loc: string }[]): string => {
const names = items.map(d => d.name)
if (names.length === 1) {
// Single item - use its name
return stripExt(names[0]!)
}
// Check if all items share the same direct parent folder
const locs = items.map(d => d.loc)
const sameLoc = locs.every(loc => loc === locs[0])
if (sameLoc && locs[0]) {
// All items in same folder - use folder name
return locs[0].split('/').pop()!
}
if (names.length <= 3) {
// Few items from different folders - join basenames with dot
return names.map(stripExt).join('.')
}
// Many items from different folders - first basename + indicator
return `${stripExt(names[0]!)}.etc`
}
+31 -26
View File
@@ -13,6 +13,7 @@
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path /> <EmptyFolder :documents=documents :path=props.path />
</template> </template>
@@ -20,7 +21,7 @@
import { watchEffect, ref, computed, watch } from 'vue' import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index' import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
@@ -30,41 +31,34 @@ const props = defineProps<{
path: Array<string> path: Array<string>
query: string 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 documents = computed(() => {
const loc = props.path.join('/') const loc = props.path.join('/')
const query = props.query const query = props.query
// List the current location
// List the current location (no search)
if (!query) return sorted( if (!query) return sorted(
store.document.filter(doc => doc.loc === loc), store.document.filter(doc => doc.loc === loc),
store.prefs.sortListing, store.prefs.sortListing,
) )
// Find up to 100 newest documents that match the search
const needle = needleFormat(query) // Search results from worker
let limit = 100 const docs = store.searchResults
let docs = []
for (const doc of store.recentDocuments) {
if (localeIncludes(doc.haystack, needle)) {
docs.push(doc)
if (--limit === 0) break
}
}
const locsub = loc + '/'
// Custom sort override in effect? Use grouped sorting to keep folders together // Custom sort override in effect? Use grouped sorting to keep folders together
const order = store.prefs.sortFiltered const order = store.prefs.sortFiltered
if (order) return sortedGrouped(docs, order) if (order) return sortedGrouped(docs, order)
// Sort by relevance - current folder, then subfolders, then others
docs.sort((a, b) => ( // Results are already sorted by relevance in the worker
// @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)
))
return docs 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; text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color); 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;
}
</style> </style>
+177
View File
@@ -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<void> {
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<IncomingMessage>) => {
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)
}
}
}