Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f94ccc01c6 | ||
|
|
bc355d991d | ||
|
|
e7ebad3c36 | ||
|
|
036c4342de | ||
|
|
160f929e0c | ||
|
|
1970d40d8a | ||
|
|
1a164e0a08 | ||
|
|
46d222006a |
+36
-9
@@ -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 = 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
@@ -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
|
||||||
|
|||||||
+2
-2
@@ -89,9 +89,9 @@ def dispatch(path, quality, maxsize, maxzoom):
|
|||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Cannot generate preview for {path.name}: {e}")
|
logger.warning(f"Cannot generate preview for {path}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error generating preview for {path.name}: {e}")
|
logger.exception(f"Error generating preview for {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
|
|||||||
@@ -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,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,14 +51,22 @@ const closeSearch = (ev: Event) => {
|
|||||||
breadcrumb.focus()
|
breadcrumb.focus()
|
||||||
updateSearch(ev)
|
updateSearch(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSearch = (ev: Event) => {
|
const updateSearch = (ev: Event) => {
|
||||||
const q = (ev.target as HTMLInputElement).value
|
const q = (ev.target as HTMLInputElement).value
|
||||||
let p = props.path.join('/')
|
let p = props.path.join('/')
|
||||||
p = p ? `/${p}` : ''
|
p = p ? `/${p}` : ''
|
||||||
const url = q ? `${p}//${q}` : (p || '/')
|
const url = q ? `${p}//${q}` : (p || '/')
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
if (!props.query && q) router.push(u)
|
|
||||||
else router.replace(u)
|
// Start search immediately via store (worker handles it async)
|
||||||
|
store.search(q, props.path.join('/'))
|
||||||
|
|
||||||
|
// Update route in next frame to keep typing responsive
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!props.query && q) router.push(u)
|
||||||
|
else router.replace(u)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const toggleSearchInput = (ev: Event) => {
|
const toggleSearchInput = (ev: Event) => {
|
||||||
showSearchInput.value = !showSearchInput.value
|
showSearchInput.value = !showSearchInput.value
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
import { formatSize, formatUnixDate } from "@/utils"
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ export class Doc {
|
|||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public haystack: string = ""
|
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
/** @internal Use the name getter/setter instead */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ""
|
public _name: string = ""
|
||||||
@@ -24,13 +23,12 @@ export class Doc {
|
|||||||
constructor(props: Partial<DocProps> = {}) {
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
const { name, ...rest } = props
|
const { name, ...rest } = props
|
||||||
Object.assign(this, rest)
|
Object.assign(this, rest)
|
||||||
if (name) this.name = name // Use setter for validation
|
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||||
}
|
}
|
||||||
get name() { return this._name }
|
get name() { return this._name }
|
||||||
set name(name: string) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
this._name = name
|
this._name = name
|
||||||
this.haystack = haystackFormat(name)
|
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string { return formatSize(this.size) }
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
get modified(): string { return formatUnixDate(this.mtime) }
|
||||||
|
|||||||
@@ -4,14 +4,47 @@ 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
|
||||||
|
let searchStore: ReturnType<typeof useMainStore> | null = null
|
||||||
|
|
||||||
|
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 (constructor is now lightweight)
|
||||||
|
const docs = []
|
||||||
|
for (const d of e.data.docs) {
|
||||||
|
docs.push(new Doc(d))
|
||||||
|
}
|
||||||
|
searchStore.searchResults = docs
|
||||||
|
|
||||||
|
if (e.data.done) {
|
||||||
|
searchStore.searchLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
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 +94,55 @@ 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
|
||||||
|
searchStore = this // Store reference for worker callback
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
this.searchResults = []
|
||||||
|
this.searchLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchLoading = true
|
||||||
|
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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,25 @@
|
|||||||
<Gallery
|
<Gallery
|
||||||
v-if="store.prefs.gallery"
|
v-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<FileExplorer
|
<FileExplorer
|
||||||
v-else
|
v-else
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`explorer-${Router.currentRoute.value.path}`"
|
:key="`explorer-${folderPath}`"
|
||||||
: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>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { collator } from '@/utils'
|
||||||
import { needleFormat, localeIncludes, 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 +30,42 @@ const props = defineProps<{
|
|||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
// Trigger search when query changes (from route, e.g., page load or back button)
|
||||||
|
// Note: Direct typing triggers search immediately via HeaderMain, this is for route-based changes
|
||||||
|
watch(
|
||||||
|
() => [props.query, props.path.join('/')] as const,
|
||||||
|
([query, loc]) => {
|
||||||
|
// Only trigger if results don't match current query (avoid duplicate searches)
|
||||||
|
if (query && store.searchResults.length === 0) {
|
||||||
|
store.search(query, loc)
|
||||||
|
} else if (!query) {
|
||||||
|
store.search('', loc) // Clear search results
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ 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 +93,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>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// 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 = 500 // Smaller batches for faster incremental feedback
|
||||||
|
const results: WorkerDoc[] = []
|
||||||
|
let lastResultCount = 0
|
||||||
|
|
||||||
|
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 new matches
|
||||||
|
if (results.length > lastResultCount && currentSearchId === searchId) {
|
||||||
|
lastResultCount = results.length
|
||||||
|
const sortedResults = sortResults(results, query, loc)
|
||||||
|
postMessage({
|
||||||
|
type: 'results',
|
||||||
|
docs: sortedResults.map(stripHaystack),
|
||||||
|
id: searchId,
|
||||||
|
done: false
|
||||||
|
} as ResultMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yield control between batches to allow new search requests to interrupt
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"av>=15.0.0",
|
"av>=15.0.0",
|
||||||
"blake3>=1.0.5",
|
"blake3>=1.0.5",
|
||||||
"docopt>=0.6.2",
|
"docopt-ng>=0.9.0",
|
||||||
"fastapi-vue>=0.5.1",
|
"fastapi-vue>=0.5.1",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"html5tagger>=1.3.0",
|
"html5tagger>=1.3.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user