Compare commits

..
29 Commits
Author SHA1 Message Date
LeoVasanko 8270dd0cc2 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. 2026-02-04 02:03:22 +00:00
LeoVasanko 1af6cd82fe Prebuild lookup structures to reduce UI lag on very large file lists. 2026-02-04 00:18:28 +00:00
LeoVasanko 0bc2a12cfa Make devserver script pass extra args to cista CLI. 2026-02-04 00:01:02 +00:00
LeoVasanko 9e07a3f40d Require new fastapi-vue for unix socket compatibility. 2026-02-01 07:10:21 +00:00
LeoVasanko 2867b1075d Disk space pie chart. 2026-02-01 06:59:15 +00:00
LeoVasanko 4fc8e29cc1 Implement a larger set of file menu options. 2026-02-01 06:33:34 +00:00
LeoVasanko 55f9117b3e Remove refresh wwwroot support and static files serving, only using vite to serve frontend in dev mode (avoid accidentally using stale builds). 2026-02-01 06:14:35 +00:00
LeoVasanko 2b6746c55e Change default port to 8989 like Droppy. Using 8999 for backend in dev mode. 2026-02-01 06:09:56 +00:00
LeoVasanko 1ed3779933 Use fastapi-vue parse_endpoint instead of our own logic. 2026-02-01 06:00:30 +00:00
LeoVasanko a95cf7be94 Better UI feedback on search order changes. 2026-02-01 05:48:15 +00:00
LeoVasanko d18501f633 Fix a search caching issue. 2026-02-01 05:38:48 +00:00
LeoVasanko ed697db871 New header/footer layout and styles. Use cursorTooltip in more places, make it prettier. Fix tooltip still sometimes activating with touch (now only with mouse). 2026-02-01 05:19:42 +00:00
LeoVasanko 856e8c4cc8 Remove download to folder, just download zip or single file. The folder download is still accessible by Alt+Click on the download button (in the few browsers that support it). 2026-02-01 03:47:14 +00:00
LeoVasanko f8a6eacb06 Automatically navigate into newly uploaded folders. 2026-02-01 03:27:13 +00:00
LeoVasanko e9a82e84ad Implement ghost items reflecting pending changes from frontend (e.g. files being uploaded) until the server acknowledges them. This gives immediate UI feedback and is clearer in cases. 2026-02-01 03:23:43 +00:00
LeoVasanko ccd05b53f4 Remove dead code. 2026-02-01 02:49:50 +00:00
LeoVasanko 032ad13b97 Unified watcher that *may* receive events from inotify and other sources. Added change notify messages from control and upload WebSockets. Cleanup debug printouts. 2026-02-01 02:44:52 +00:00
LeoVasanko 4eae75c84b New inotify watcher with efficient algorithms. 2026-01-31 23:46:43 +00:00
LeoVasanko 252b31a293 Performance profile file change watching. 2026-01-31 22:53:17 +00:00
LeoVasanko feff202161 Debouncing to avoid UI flicker while typing in search. 2026-01-31 22:42:16 +00:00
LeoVasanko d15bfc86c4 Search optimizations and fixes. Prevent re-renders on hash change (redux). Clear previous search results at the start of a new search. 2026-01-31 22:37:47 +00:00
LeoVasanko f94ccc01c6 Optimizing the search faster; don't trigger full re render by URL changes. 2026-01-31 22:17:05 +00:00
LeoVasanko bc355d991d Simplified startup box unix socket formatting. 2026-01-31 22:09:57 +00:00
LeoVasanko e7ebad3c36 Upgrade to docopt-ng because docopt is no longer maintained. 2026-01-31 22:06:03 +00:00
LeoVasanko 036c4342de Better error logging on preview generation errors. 2026-01-31 22:00:26 +00:00
LeoVasanko 160f929e0c Basic details in startup box. 2026-01-31 21:53:39 +00:00
LeoVasanko 1970d40d8a Implement background worker to make search response faster and avoid hanging the UI when there are a lot of files. Implement better toast messages for transitional events that automatically disappear. 2026-01-31 21:02:16 +00:00
LeoVasanko 1a164e0a08 Smarter ZIP download naming. 2026-01-31 20:59:43 +00:00
LeoVasanko 46d222006a More robust zip download processing; don't let aborted requests hang threads, use a larger threadpool separate of preview generation. 2026-01-31 19:43:23 +00:00
34 changed files with 1731 additions and 431 deletions
+42 -15
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 = 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()
doc = """\
@@ -35,7 +57,7 @@ Usage:
Options:
-c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on
:8000 (localhost port, plain http)
:8989 (localhost port, plain http)
<addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket)
example.com (run on 80 and 443 with LetsEncrypt)
@@ -58,7 +80,7 @@ Environment:
first_time_help = """\
No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts
cista -l :8000 /path/to/files # Run the server on localhost:8000
cista -l :8989 /path/to/files # Run the server on localhost:8989
See cista --help for other options!
"""
@@ -83,8 +105,7 @@ def _main():
elif "--version" in sys.argv:
sys.stdout.write(f"cista {cista.__version__}\n")
return 0
else:
sys.stderr.write(banner)
# Don't print banner yet for normal startup - we'll print the startup box later
args = docopt(doc)
if args["--user"]:
return _user(args)
@@ -119,19 +140,25 @@ def _main():
if listen:
settings["listen"] = listen
elif not exists:
settings["listen"] = ":8000"
operation = config.update_config(settings)
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
settings["listen"] = ":8989"
config.update_config(settings)
# Prepare to serve
unix = None
url, _ = serve.parse_listen(config.config.listen)
url, opts = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir():
raise ValueError(f"No such directory: {config.config.path}")
extra = f" ({unix})" if unix else ""
dev = args["--dev"]
if dev:
extra += " (dev mode)"
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
# Check for Paskia SSO
from cista.sso import PASKIA_BACKEND_URL
# 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
serve.run(dev=dev)
return 0
@@ -159,7 +186,7 @@ def _user(args):
# Defaults for new config when user is created
operation = config.update_config(
{
"listen": ":8000",
"listen": ":8989",
"path": Path.home() / "Downloads",
"public": False,
}
@@ -188,7 +215,7 @@ def _user(args):
if operation == "created":
sys.stderr.write(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
"Now you can run the server:\n cista # defaults set: -l :8989 ~/Downloads\n"
)
+6
View File
@@ -1,5 +1,6 @@
import asyncio
import typing
from pathlib import PurePosixPath
from secrets import token_bytes
import msgspec
@@ -53,6 +54,9 @@ async def upload(req, ws):
if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
# Signal the watcher about the uploaded file and its parent directories
path = PurePosixPath(req.name)
watching.notify_change(path, *path.parents)
# Report success
res = StatusMsg(status="ack", req=req)
await asend(ws, res)
@@ -87,6 +91,8 @@ async def control(req, ws):
while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(cmd)
# Signal the watcher about affected paths
watching.notify_change(*cmd.affected_paths())
await asend(ws, StatusMsg(status="ack", req=cmd))
+31 -39
View File
@@ -1,7 +1,6 @@
import asyncio
import datetime
import mimetypes
import threading
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath
@@ -43,19 +42,22 @@ setproctitle("cista-main")
async def main_start(app):
config.load_config()
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(
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)
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop
async def main_stop(app):
quit.set()
watching.stop(app)
app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True)
await sso.close_client()
logger.debug("Cista worker threads all finished")
@@ -170,9 +172,8 @@ def _load_wwwroot(www):
@app.before_server_start
async def start(app):
await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
if not app.debug:
await load_wwwroot(app)
async def load_wwwroot(app):
@@ -182,36 +183,14 @@ async def load_wwwroot(app):
)
quit = threading.Event()
async def refresh_wwwroot():
try:
while not quit.is_set():
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
logger.error(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
@app.route("/<path:path>", methods=["GET", "HEAD"])
async def wwwroot(req, path=""):
"""Frontend files only"""
if app.debug:
raise NotFound(
"Dev mode: frontend-build is not served on backend (you should connect vite)",
extra={"name": path},
)
name = unquote(path)
if name not in www:
raise NotFound(f"File not found: /{path}", extra={"name": name})
@@ -288,27 +267,40 @@ async def zip_download(req, keys, zipfile, ext):
yield chunk
assert size == 0
pending_put = None # Current queue.put future, can be cancelled
def worker():
nonlocal pending_put
try:
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:
logger.exception("Error streaming ZIP")
raise
finally:
pending_put = None
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)
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
res = await req.respond(
content_type="application/zip",
headers={"cache-control": "no-store"},
)
while chunk := await queue.get():
await res.send(chunk)
try:
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
+2 -2
View File
@@ -89,9 +89,9 @@ def dispatch(path, quality, maxsize, maxzoom):
return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize)
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:
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):
+28
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import shutil
from pathlib import PurePosixPath
from typing import Any
import msgspec
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self):
raise NotImplementedError
def affected_paths(self) -> list[str]:
"""Return list of paths affected by this operation for change notification."""
return []
class MkDir(ControlBase):
path: str
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=True, exist_ok=False)
def affected_paths(self) -> list[str]:
return [filename.sanitize(self.path)]
class Rename(ControlBase):
path: str
@@ -36,6 +44,11 @@ class Rename(ControlBase):
path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to))
def affected_paths(self) -> list[str]:
sanitized = filename.sanitize(self.path)
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
return [sanitized, new_path]
class Rm(ControlBase):
sel: list[str]
@@ -49,6 +62,9 @@ class Rm(ControlBase):
else:
p.unlink()
def affected_paths(self) -> list[str]:
return [filename.sanitize(p) for p in self.sel]
class Mv(ControlBase):
sel: list[str]
@@ -63,6 +79,13 @@ class Mv(ControlBase):
for p in sel:
shutil.move(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
paths = [filename.sanitize(p) for p in self.sel]
# Include new locations in dst
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
return paths
class Cp(ControlBase):
sel: list[str]
@@ -86,6 +109,11 @@ class Cp(ControlBase):
else:
shutil.copy2(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
# Only destinations are new (sources unchanged)
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
ControlTypes = MkDir | Rename | Rm | Mv | Cp
+21 -10
View File
@@ -2,6 +2,7 @@ import os
import re
from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic
from cista import config, server80
@@ -44,18 +45,28 @@ def check_cert(certdir, domain):
def parse_listen(listen):
if listen.startswith("/"):
unix = Path(listen).resolve()
# Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
# Use fastapi_vue's parse_endpoint for everything else
endpoints = parse_endpoint(listen, default_port=8989)
ep = endpoints[0]
if "uds" in ep:
unix = Path(ep["uds"]).resolve()
if not unix.parent.exists():
raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/",
)
return "http://localhost", {"unix": unix.as_posix()}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
try:
addr, _port = listen.split(":", 1)
port = int(_port)
except Exception:
raise ValueError(f"Invalid listen address: {listen}") from None
return f"http://localhost:{port}", {"host": addr, "port": port}
host, port = ep["host"], ep["port"]
# When binding all interfaces, use single_listener=False for Sanic
if len(endpoints) > 1:
return f"http://localhost:{port}", {
"host": host,
"port": port,
"single_listener": False,
}
return f"http://{host}:{port}", {"host": host, "port": port}
+351 -82
View File
@@ -1,4 +1,5 @@
import asyncio
import queue
import shutil
import sys
import threading
@@ -116,6 +117,28 @@ state = State()
rootpath: Path = None # type: ignore
quit = threading.Event()
# Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets."""
for path in paths:
if isinstance(path, str):
path = PurePosixPath(path)
# Convert absolute paths to relative (strip leading /)
if path.is_absolute():
path = (
PurePosixPath(*path.parts[1:])
if len(path.parts) > 1
else PurePosixPath()
)
# Skip root paths (empty, '.') to avoid full tree walks
if not path.parts or path.parts == (".",):
continue
_update_queue.put(path)
## Filesystem scanning
@@ -326,127 +349,373 @@ def format_root(root):
def broadcast(msg, loop):
return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result()
fut = asyncio.run_coroutine_threadsafe(abroadcast(msg), loop)
return fut.result()
async def abroadcast(msg):
client_count = 0
try:
for queue in pubsub.values():
queue.put_nowait(msg)
client_count += 1
except Exception:
# Log because asyncio would silently eat the error
logger.exception("Broadcast error")
return client_count
## Watcher thread
def watcher_inotify(loop):
"""Inotify watcher thread (Linux only)"""
import inotify.adapters
class PathIndex:
"""O(1) path lookup index for the flat FileEntry tree."""
def __init__(self, root: list[FileEntry]):
self.root = root
self._index: dict[PurePosixPath, tuple[int, int]] = {}
self._rebuild()
def _rebuild(self):
"""Build path -> (start_idx, count) mapping in single O(n) pass."""
index: dict[PurePosixPath, tuple[int, int]] = {}
path_stack: list[tuple[PurePosixPath, int]] = [] # (path, start_idx)
for i, entry in enumerate(self.root):
# Pop completed paths from stack
while path_stack and entry.level <= len(path_stack[-1][0].parts):
completed_path, start_idx = path_stack.pop()
index[completed_path] = (start_idx, i - start_idx)
# Build current path
if entry.level == 0:
current_path = PurePosixPath()
else:
parent = path_stack[-1][0] if path_stack else PurePosixPath()
current_path = parent / entry.name
path_stack.append((current_path, i))
# Close remaining paths
for path, start_idx in path_stack:
index[path] = (start_idx, len(self.root) - start_idx)
self._index = index
def get(self, path: PurePosixPath) -> tuple[int | None, list[FileEntry]]:
"""O(1) lookup: returns (start_idx, entries) or (None, [])."""
if path not in self._index:
return None, []
start, count = self._index[path]
return start, self.root[start : start + count]
def find_insert_pos(self, path: PurePosixPath, isfile: int) -> int:
"""Find insertion position using index + binary search."""
if not path.parts:
return 0
parent = path.parent
name = path.name
# Find parent's range
if parent == PurePosixPath():
# Insert at root level - scan root's direct children
start, count = 0, len(self.root)
target_level = 1
elif parent in self._index:
start, count = self._index[parent]
start += 1 # Skip parent entry itself
count -= 1
target_level = len(parent.parts) + 1
else:
# Parent doesn't exist, shouldn't happen
return len(self.root)
# Binary search among direct children at target_level
# Collect children indices first
children = []
i = start
end = start + count
while i < end:
entry = self.root[i]
if entry.level == target_level:
children.append(i)
i += 1
if not children:
return start
# Binary search for insertion point
nsort = sortkey(name)
lo, hi = 0, len(children)
while lo < hi:
mid = (lo + hi) // 2
idx = children[mid]
entry = self.root[idx]
ename = entry.name
esort = sortkey(ename)
# Compare: isfile, then sort key, then case-sensitive
cmp = (
entry.isfile - isfile
or (esort > nsort) - (esort < nsort)
or (ename > name) - (ename < name)
)
if cmp < 0:
lo = mid + 1
else:
hi = mid
if lo < len(children):
return children[lo]
elif children:
# Insert after last child's subtree
last_idx = children[-1]
last_entry = self.root[last_idx]
if last_entry.isfile:
return last_idx + 1
# Find end of last child's subtree
last_path = parent / last_entry.name
if last_path in self._index:
s, c = self._index[last_path]
return s + c
return last_idx + 1
return start
def apply_update(
self, path: PurePosixPath, new_entries: list[FileEntry]
) -> list[FileEntry]:
"""Apply an update and return the new root. Rebuilds index."""
start, old_entries = self.get(path)
if old_entries == new_entries:
return self.root
new_root = self.root[:]
if start is not None:
del new_root[start : start + len(old_entries)]
if new_entries:
# Rebuild index on modified list to find insert pos
self.root = new_root
self._rebuild()
insert_pos = self.find_insert_pos(path, new_entries[0].isfile)
new_root[insert_pos:insert_pos] = new_entries
self.root = new_root
self._rebuild()
return new_root
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
"""Remove child paths if parent is in set."""
if not paths:
return paths
# Filter out root paths (empty or '.') which would cause full tree walks
paths = {p for p in paths if p.parts and p.parts != (".",)}
if not paths:
return set()
# Sort by depth (fewest parts first)
sorted_paths = sorted(paths, key=lambda p: len(p.parts))
result = set()
for path in sorted_paths:
# Check if any ancestor is already in result
is_child = False
for i in range(len(path.parts)):
ancestor = PurePosixPath(*path.parts[:i]) if i > 0 else PurePosixPath()
if ancestor in result:
is_child = True
break
if not is_child:
result.add(path)
return result
# Debounce settings
DEBOUNCE_DELAY = 0.01 # Wait 10ms after last event
DEBOUNCE_MAX = 0.1 # But no more than 100ms total
def watcher(loop):
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
use_inotify = sys.platform == "linux"
inotify_tree = None
modified_flags = frozenset()
if use_inotify:
import inotify.adapters
modified_flags = frozenset(
(
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
)
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
while not quit.is_set():
i = inotify.adapters.InotifyTree(rootpath.as_posix())
if use_inotify:
import inotify.adapters
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
update_root(loop)
path_index = PathIndex(state.root[:])
trefresh = time.monotonic() + 300.0
tspace = time.monotonic() + 5.0
# Watch for changes (frequent wakeups needed for quiting)
while not quit.is_set():
t = time.monotonic()
# The watching is not entirely reliable, so do a full refresh every 30 seconds
if t >= trefresh:
break
# Disk usage update
if t >= tspace:
tspace = time.monotonic() + 5.0
update_space(loop)
# Inotify events, update the tree
dirty = False
rootmod = state.root[:]
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
assert event
if quit.is_set():
return
interesting = any(f in modified_flags for f in event[1])
if interesting:
# Update modified path
path = PurePosixPath(event[2]) / event[3]
try:
rel_path = path.relative_to(rootpath)
update_path(rootmod, rel_path, loop)
except Exception as e:
logger.error(
f"Error processing inotify event for path {path}: {e}"
)
raise
if not dirty:
t = time.monotonic()
dirty = True
# Wait a maximum of 0.2s to push the updates
if dirty and time.monotonic() >= t + 0.2:
break
if dirty and state.root != rootmod:
try:
update = format_update(state.root, rootmod)
with state.lock:
broadcast(update, loop)
state.root = rootmod
except Exception:
logger.exception(
"format_update failed; falling back to full rescan"
# Pending changes: path -> {"ws": count, "inotify": count}
dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
first_event_time: float | None = None
last_event_time: float | None = None
def add_dirty(path: PurePosixPath, source: str) -> bool:
"""Add path to dirty set. Returns True if added, False if redundant."""
nonlocal first_event_time, last_event_time
# Check if already covered by an existing dirty path
for existing in dirty_paths:
if path == existing or (
len(path.parts) > len(existing.parts)
and path.parts[: len(existing.parts)] == existing.parts
):
# Count the event even if skipped
dirty_paths[existing][source] = (
dirty_paths[existing].get(source, 0) + 1
)
# Fallback: full rescan and try diff again; last resort send full root
return False
# Remove any paths that would be covered by this new one
covered = {
p
for p in dirty_paths
if len(p.parts) > len(path.parts)
and p.parts[: len(path.parts)] == path.parts
}
# Aggregate counts from covered paths
counts: dict[str, int] = {source: 1}
for p in covered:
for s, c in dirty_paths[p].items():
counts[s] = counts.get(s, 0) + c
del dirty_paths[p]
dirty_paths[path] = counts
now = time.monotonic()
if first_event_time is None:
first_event_time = now
last_event_time = now
return True
while not quit.is_set():
now = time.monotonic()
# Full refresh every 300s
if now >= trefresh:
break
# Disk usage update every 5s
if now >= tspace:
tspace = now + 5.0
update_space(loop)
# Check if we should flush pending changes
should_flush = False
if dirty_paths:
time_since_last = now - last_event_time if last_event_time else 0
time_since_first = now - first_event_time if first_event_time else 0
if (
time_since_last >= DEBOUNCE_DELAY
or time_since_first >= DEBOUNCE_MAX
):
should_flush = True
if should_flush:
paths_to_process = dirty_paths.copy()
dirty_paths.clear()
first_event_time = None
last_event_time = None
# Collapse paths (remove children if parent present)
collapsed = collapse_paths(set(paths_to_process.keys()))
# Process each collapsed path
new_root = path_index.root
for path in collapsed:
new_entries = walk(path)
new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed
if new_root != state.root:
try:
fresh = walk(PurePosixPath())
update_msg = format_update(state.root, new_root)
with state.lock:
broadcast(update_msg, loop)
state.root = new_root
except Exception:
logger.exception("format_update failed; full rescan")
try:
update = format_update(state.root, fresh)
fresh = walk(PurePosixPath())
path_index = PathIndex(fresh)
update_msg = format_update(state.root, fresh)
with state.lock:
broadcast(update, loop)
broadcast(update_msg, loop)
state.root = fresh
except Exception:
logger.exception(
"Fallback diff failed; sending full root snapshot"
)
logger.exception("Fallback failed; sending full root")
with state.lock:
broadcast(format_root(fresh), loop)
state.root = fresh
except Exception:
logger.exception(
"Full rescan failed; dropping this batch of updates"
)
del i # Free the inotify object
# Collect events from websocket signals (non-blocking)
try:
while True:
path = _update_queue.get_nowait()
add_dirty(path, "ws")
except queue.Empty:
pass
# Collect inotify events if available (short timeout for responsiveness)
if inotify_tree:
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
if quit.is_set():
return
if not (modified_flags & set(event[1])):
continue
def watcher_poll(loop):
"""Polling version of the watcher thread."""
while not quit.is_set():
t0 = time.perf_counter()
update_root(loop)
update_space(loop)
dur = time.perf_counter() - t0
if dur > 1.0:
logger.debug(f"Reading the full file list took {dur:.1f}s")
quit.wait(0.1 + 8 * dur)
# Extract relative path
path = PurePosixPath(event[2]) / event[3]
try:
rel_path = path.relative_to(rootpath)
except ValueError:
continue
# Skip dotfiles
if any(part.startswith(".") for part in rel_path.parts):
continue
add_dirty(rel_path, "inotify")
# Don't block too long collecting events
now = time.monotonic()
if first_event_time and now - first_event_time >= DEBOUNCE_MAX:
break
else:
# No inotify, just sleep briefly for responsiveness
time.sleep(0.05)
if inotify_tree:
del inotify_tree
def start(app):
global rootpath
config.load_config()
rootpath = config.config.path
use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll,
target=watcher,
args=[app.loop],
# Descriptive name for system monitoring
name=f"cista-watcher {rootpath}",
+28 -6
View File
@@ -1,20 +1,22 @@
<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 }}
</div>
<SettingsModal />
<UserManagementModal />
<AccessDeniedModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
<BreadCrumb :path="path.pathList" primary />
</header>
<main>
<RouterView :path="path.pathList" :query="path.query" />
</main>
<footer>
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<HeaderSelected :path="path.pathList" />
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer>
@@ -100,7 +102,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
// Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') {
store.error = ''
headerMain.value!.closeSearch(event)
store.clearToast()
headerMain.value!.clearSearch(event)
store.focusBreadcrumb()
}
else if (!input && keyup && event.key === 'Backspace') {
@@ -181,4 +184,23 @@ export type { Path }
max-width: 90vw;
text-align: center;
}
.toast-message.status {
background: #555;
color: #fff;
}
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
z-index: 50;
}
footer > * {
justify-content: center;
}
</style>
+4 -63
View File
@@ -43,44 +43,14 @@
--root-font-size: 1.5rem;
}
}
/* Low (landscape) screens: smaller header */
@media screen and (max-height: 600px) {
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
}
}
@media screen and (max-height: 300px) {
:root {
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
--header-height: calc(1.75 * 16px);
--root-font-size: 0.6rem;
}
header .breadcrumb > * {
padding-top: 14px !important;
padding-bottom: 14px !important;
}
}
@media screen and (orientation: landscape) and (min-width: 700px) {
/* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header {
display: flex;
justify-content: space-between;
}
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
}
}
@media print {
:root {
@@ -241,42 +211,13 @@ header nav.headermain {
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
position: absolute;
pointer-events: none;
font-size: 1rem;
text-align: center;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
}
@keyframes appearbriefly {
from {
opacity: 0;
}
30% {
opacity: 0;
}
40% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
opacity: 0;
}
}
.error-message {
padding: .5em;
font-weight: bold;
background: var(--accent-color);
color: #000;
}
.ghost {
opacity: 0.5;
}
+16 -3
View File
@@ -14,9 +14,12 @@
:class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)"
@click.prevent="navigate(0)"
title="/"
@mouseenter="homeTooltip?.startHover"
@mousemove="homeTooltip?.updatePosition"
@mouseleave="homeTooltip?.endHover"
>
<component :is="home" />
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a>
<template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
@@ -24,8 +27,10 @@
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`"
>{{ location }}</a>
@mouseenter="pathTooltips.get(index)?.startHover"
@mousemove="pathTooltips.get(index)?.updatePosition"
@mouseleave="pathTooltips.get(index)?.endHover"
>{{ location }}<CursorTooltip :ref="el => setPathTooltipRef(index, el)" :text="`/${longest.slice(0, index + 1).join('/')}`">{{ `/${longest.slice(0, index + 1).join('/')}` }}</CursorTooltip></a>
</template>
</nav>
</template>
@@ -35,6 +40,7 @@ import { Home } from '@/assets/svg'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
import CursorTooltip from './CursorTooltip.vue'
const home = Home
const router = useRouter()
@@ -43,6 +49,13 @@ const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
const setPathTooltipRef = (index: number, el: any) => {
if (el) pathTooltips.value.set(index, el)
else pathTooltips.value.delete(index)
}
const props = defineProps<{
path: Array<string>
primary?: boolean
+56 -19
View File
@@ -1,6 +1,6 @@
<template>
<Teleport to="body">
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle">
<div v-if="visible" ref="tooltipEl" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot>
</div>
</Teleport>
@@ -10,12 +10,12 @@
// Global activation state - shared across all instances
let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
// Track if we've seen real mouse movement (not touch-simulated)
let hasRealMouse = false
// Track recent touch to suppress touch-triggered mouse events
let lastTouchTime = 0
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
text: string
@@ -25,6 +25,9 @@ const props = defineProps<{
const visible = ref(false)
const mouseX = ref(0)
const mouseY = ref(0)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const tooltipEl = ref<HTMLElement | null>(null)
let settleTimer: ReturnType<typeof setTimeout> | null = null
let lastMoveX = 0
let lastMoveY = 0
@@ -32,17 +35,51 @@ let lastMoveY = 0
// Movement threshold (pixels) - cursor must settle within this radius
const SETTLE_THRESHOLD = 8
const tooltipStyle = computed(() => ({
left: `${mouseX.value}px`,
top: `${mouseY.value}px`,
}))
const tooltipStyle = computed(() => {
// Constrain to viewport
const pad = 8
let x = mouseX.value
let y = mouseY.value
// Check if the device likely has a real mouse (fine pointer)
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches
// Only constrain if we've measured the tooltip
if (tooltipWidth.value > 0 && tooltipHeight.value > 0) {
// Adjust horizontal position if tooltip would overflow right edge
if (x + tooltipWidth.value + pad > window.innerWidth) {
x = window.innerWidth - tooltipWidth.value - pad
}
// Adjust vertical position if tooltip would overflow bottom edge
if (y + tooltipHeight.value + pad > window.innerHeight) {
y = window.innerHeight - tooltipHeight.value - pad
}
// Don't go past left/top edges
x = Math.max(pad, x)
y = Math.max(pad, y)
}
return {
left: `${x}px`,
top: `${y}px`,
}
})
// Track touch events globally to suppress touch-simulated mouse events
const onTouchStart = () => { lastTouchTime = Date.now() }
onMounted(() => document.addEventListener('touchstart', onTouchStart, { passive: true }))
onUnmounted(() => document.removeEventListener('touchstart', onTouchStart))
// Check if event is likely from touch (touch happened within last 500ms)
const isTouchEvent = () => Date.now() - lastTouchTime < 500
const showTooltip = () => {
visible.value = true
globalActive = true
// Measure tooltip after it renders
requestAnimationFrame(() => {
if (tooltipEl.value) {
tooltipWidth.value = tooltipEl.value.offsetWidth
tooltipHeight.value = tooltipEl.value.offsetHeight
}
})
}
const scheduleTooltip = () => {
@@ -56,8 +93,8 @@ const scheduleTooltip = () => {
}
const startHover = (e: MouseEvent) => {
// Ignore touch events (no fine pointer and no confirmed real mouse)
if (!hasFinePointer() && !hasRealMouse) return
// Ignore touch-simulated mouse events
if (isTouchEvent()) return
mouseX.value = e.clientX
mouseY.value = e.clientY
@@ -66,9 +103,8 @@ const startHover = (e: MouseEvent) => {
}
const updatePosition = (e: MouseEvent) => {
// Detect real mouse via movement (touch events don't generate continuous mousemove)
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true
if (!hasFinePointer() && !hasRealMouse) return
// Ignore touch-simulated mouse events
if (isTouchEvent()) return
mouseX.value = e.clientX
mouseY.value = e.clientY
@@ -122,10 +158,11 @@ defineExpose({
z-index: 10000;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: nowrap;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: #fff;
pointer-events: none;
font-size: 1rem;
}
+22 -23
View File
@@ -1,12 +1,12 @@
<template>
<SvgButton name="download" data-tooltip="Download" @click="download" />
<SvgButton name="download" tooltip="Download" @click="download" />
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document'
import { reactive } from 'vue';
import { zipName } from '@/utils/fileutil'
const store = useMainStore()
@@ -106,7 +106,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
@@ -131,43 +130,43 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
statReset()
}
const download = async () => {
const zipdl = (sel: SelectedItems) => {
const items = sel.keys.map(k => sel.docs[k]!)
const name = zipName(items)
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.showToast(`Downloading ${name}.zip`)
store.selected.clear()
}
const download = async (e: MouseEvent) => {
const sel = store.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
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()
return
}
// Plain old a href download if only one file (ignoring any folders)
// Single file: direct download
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
store.error = "Single file via browser downloads"
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0]![1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
// Alt+click: download to folder (hidden feature)
if (e.altKey && 'showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
await filesystemdl(sel, handle)
store.selected.clear()
return
} catch (e) {
console.error('Download to folder aborted', e)
console.error('Download to folder failed', e)
store.showToast('Download to folder failed')
}
return
}
// Otherwise, zip and download
console.log("Falling back to zip download")
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]!]!.name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.error = "Downloading as ZIP via browser downloads"
store.selected.clear()
// Default: ZIP download
zipdl(sel)
}
</script>
+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>
+91 -10
View File
@@ -28,7 +28,7 @@
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)"
>
@@ -249,9 +249,10 @@ const mkdir = (doc: Doc, name: string) => {
}
}
})
// We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
@@ -290,13 +291,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
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)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
}
</script>
@@ -392,9 +476,6 @@ tbody tr.cursor {
.sortcolumn:hover {
cursor: pointer;
}
.sortcolumn:hover::after {
color: var(--accent-color);
}
.sortcolumn {
padding-right: 1.5rem;
}
+90 -18
View File
@@ -203,9 +203,10 @@ const mkdir = (doc: Doc, name: string) => {
}
}
})
// We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
store.addGhost(doc)
editing.value = null
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
@@ -213,18 +214,6 @@ const showFolderBreadcrumb = (i: number) => {
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
}
const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return (
@@ -246,13 +235,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
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)
store.unhideDoc(path)
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
const items = [
{ label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: '🔗 Copy Link', onClick: () => copyLink(doc) },
]
if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
}
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@contextmenu.stop
@focus.stop="store.cursor = doc.key"
@click=onclick
+206 -40
View File
@@ -3,23 +3,57 @@
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
tooltip="New folder"
@click="() => { store.fileExplorer!.newFolder() }"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group">
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
placeholder="Find files"
class="margin-input"
@keydown.escape="clearSearch"
/>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
</div>
<div class="spacer smallgap"></div>
<div v-if="store.space.disk" class="disk-space"
@mouseenter="diskTooltip?.startHover"
@mousemove="diskTooltip?.updatePosition"
@mouseleave="diskTooltip?.endHover"
>
<svg viewBox="0 0 32 32" class="pie-mini">
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
<circle r="16" cx="16" cy="16" fill="#c8e" />
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
<!-- Subtle inner circle for depth -->
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
</svg>
<CursorTooltip ref="diskTooltip" text="Disk space">
<div class="disk-tooltip">
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
<!-- Pie chart centered at 40,40 -->
<circle r="32" cx="40" cy="40" fill="#c8e" />
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
<!-- Labels on the right -->
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
</svg>
</div>
</CursorTooltip>
</div>
<SvgButton name="cog" @click="settingsMenu" />
</nav>
</template>
@@ -27,50 +61,122 @@
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect } from 'vue'
import { ref, nextTick, watchEffect, computed } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS'
import router from '@/router';
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
const store = useMainStore()
const ssoStore = useSsoAuthStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>()
const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const CIRC = 50.27 // 2π×8
// Storage segment (starts at top, -90°)
const pieStorage = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.storage / s.disk
return `${pct * CIRC} ${CIRC}`
})
// Free segment (starts after storage, goes clockwise to bottom area)
const pieFree = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.free / s.disk
return `${pct * CIRC} ${CIRC}`
})
const pieFreeOffset = computed(() => {
const s = store.space
if (!s.disk) return 0
// Start after storage segment
const storagePct = s.storage / s.disk
return -storagePct * CIRC
})
// Free space color: green when plenty, yellow when moderate, red when low
const freeColor = computed(() => {
const s = store.space
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' // Green: > 25% free
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
return '#d44' // Red: < 10% free
})
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
const CIRC_LG = 100.53
const pieStorageLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeOffsetLg = computed(() => {
const s = store.space
if (!s.disk) return 0
return -(s.storage / s.disk) * CIRC_LG
})
const props = defineProps<{
path: Array<string>
query: string
}>()
const closeSearch = (ev: Event) => {
if (!showSearchInput.value) return // Already closing
showSearchInput.value = false
const clearSearch = (ev: Event) => {
const input = search.value
if (input) {
input.value = ''
updateSearch(ev)
}
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus()
updateSearch(ev)
}
const focusSearch = () => {
search.value?.focus()
}
// Track pending route update
let pendingRouteUpdate: number | null = null
const updateSearch = (ev: Event) => {
const q = (ev.target as HTMLInputElement).value
let p = props.path.join('/')
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (!props.query && q) router.push(u)
else router.replace(u)
}
const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch(ev)
nextTick(() => {
const input = search.value
if (input) input.focus()
const loc = props.path.join('/')
// Start search immediately via store (worker handles it async)
store.search(q, loc)
// Cancel any pending route update
if (pendingRouteUpdate !== null) {
cancelAnimationFrame(pendingRouteUpdate)
}
// Schedule route update - will be cancelled if user types again
pendingRouteUpdate = requestAnimationFrame(() => {
pendingRouteUpdate = null
let p = loc
p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Use replace to avoid building up history for each keystroke
router.replace(u)
})
}
watchEffect(() => {
if (props.query) showSearchInput.value = true
})
const toggleSearchInput = () => {
search.value?.focus()
}
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
@@ -113,7 +219,7 @@ const settingsMenu = (e: Event) => {
}
defineExpose({
toggleSearchInput,
closeSearch,
clearSearch,
})
</script>
@@ -125,12 +231,72 @@ defineExpose({
align-items: center;
z-index: 10;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
border: 0;
border-radius: 0.1em;
.search-group {
position: relative;
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 1.5em;
padding: 0 0.3em;
transition: background 0.2s ease;
flex: 1 1 auto;
min-width: 5.5em;
max-width: 20em;
}
.search-group:focus-within {
background: rgba(255, 255, 255, 0.2);
}
.search-group:focus-within .search-hint {
opacity: 0;
pointer-events: none;
}
.search-group :deep(.action-button) {
width: 2.2em;
height: 2.2em;
flex-shrink: 0;
}
.search-group input[type='search'] {
background: transparent;
color: var(--header-color);
border: none;
outline: none;
max-width: 15ch;
padding: 0.2em 0.5em 0.2em 0;
font-size: var(--header-font-size);
flex: 1 1 3em;
min-width: 3em;
}
.search-hint {
position: absolute;
right: 0.5em;
font-family: system-ui, sans-serif;
font-size: 1em;
font-weight: 700;
color: #333;
background: #ccc;
border: 1px solid #999;
border-radius: 0.3em;
padding: 0 0.45em;
line-height: 1.4;
cursor: pointer;
transition: opacity 0.15s ease;
}
.disk-space {
display: flex;
align-items: center;
cursor: default;
}
.pie-mini {
width: 1.4em;
height: 1.4em;
}
.disk-tooltip {
line-height: 1.5;
}
.pie-tooltip {
display: block;
}
.pie-tooltip .pie-label {
fill: #fff;
font-size: 9px;
}
</style>
+41 -15
View File
@@ -1,19 +1,27 @@
<template>
<template v-if="store.selected.size">
<div class="smallgap"></div>
<p class="select-text">{{ store.selected.size }} selected </p>
<div class="selection-bar" v-if="store.selected.size">
<p class="select-text">{{ store.selected.size }} selected</p>
<DownloadButton />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" />
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()"></button>
</template>
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
<button
class="action-button unselect"
@click="store.selected.clear()"
@mouseenter="unselectTooltip?.startHover"
@mousemove="unselectTooltip?.updatePosition"
@mouseleave="unselectTooltip?.endHover"
><CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
</div>
</template>
<script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const store = useMainStore()
const props = defineProps({
@@ -21,23 +29,32 @@ const props = defineProps({
})
const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => {
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,
sel: sel.keys.map(key => {
const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
})
op: opName,
sel: paths
}
// @ts-ignore
if (dst !== undefined) msg.dst = dst
// 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)
@@ -55,11 +72,20 @@ const op = (op: string, dst?: string) => {
</script>
<style>
.selection-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.3em 0.5em;
background: transparent;
color: var(--header-color);
}
.select-text {
color: var(--accent-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
padding-right: 0.5em;
}
</style>
+2 -2
View File
@@ -70,7 +70,7 @@ const submit = async (ev: Event) => {
try {
if (form.passwordChange) {
if (!form.password) {
store.error = '⚠️ Current password is required'
store.showToast('⚠️ Current password is required')
password.value!.focus()
return
}
@@ -79,7 +79,7 @@ const submit = async (ev: Event) => {
close()
} catch (error) {
const httpError = error as ISimpleError
store.error = httpError.message || '🛑 Unknown error'
store.showToast(httpError.message || '🛑 Unknown error')
} finally {
confirmLoading.value = false
}
+14 -2
View File
@@ -1,16 +1,28 @@
<template>
<button class="action-button">
<button
class="action-button"
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>
<component :is="icons[name]" />
<slot></slot>
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</button>
</template>
<script setup lang="ts">
import { icons, type IconName } from '@/assets/svg'
import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
defineProps<{
const props = defineProps<{
name: IconName
tooltip?: string
}>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = props.tooltip ?? ''
</script>
<style>
+3 -1
View File
@@ -53,15 +53,17 @@ const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value <
<style scoped>
.transferprogress {
--bar: var(--accent-color);
--nobar: var(--header-background);
--nobar: transparent;
display: flex;
flex-direction: column;
justify-content: center;
color: var(--primary-color);
width: 100%;
}
.statustext {
display: flex;
align-items: center;
justify-content: center;
margin: 0 .5em;
padding: 0.5rem 0;
}
+38 -5
View File
@@ -3,16 +3,20 @@
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
</template>
<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'
import { useRouter } from 'vue-router'
const router = useRouter()
const fileInput = ref()
const folderInput = ref()
const store = useMainStore()
@@ -72,24 +76,53 @@ function uploadHandler(event: Event) {
const uploadFiles = (infiles: File[]) => {
const loc = props.path!.join('/')
let files = []
let folderName = ''
for (const file of infiles) {
const relPath = file.webkitRelativePath || file.name
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
files.push({
file,
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
cloudName: loc + '/' + relPath,
cloudPos: 0,
})
}
uploadCloudFiles(files)
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
}
const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) {
store.error = "Won't upload dotfiles"
console.log("Dotfiles omitted", dotfiles)
store.showToast("Won't upload dotfiles")
files = files.filter(f => !f.cloudName.includes('/.'))
}
if (!files.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000)
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('/')
const loc = lastSlash > 0 ? f.cloudName.slice(0, lastSlash) : ''
const name = f.cloudName.slice(lastSlash + 1)
// Ghost folders for intermediate directories
const parts = loc.split('/')
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.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 (overwrite case doesn't need ghost, file already visible)
const existing = byPath.get(f.cloudName)
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false }))
}
// @ts-ignore
upqueue = [...upqueue, ...files]
statsAdd(files)
@@ -93,7 +93,7 @@ const loadUsers = async () => {
users.value = data.users
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to load users'
store.showToast(httpError.message || 'Failed to load users')
} finally {
loading.value = false
}
@@ -111,7 +111,7 @@ const addUser = async () => {
}
} catch (e) {
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
} catch (e) {
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
}
}
@@ -142,7 +142,7 @@ const renameUser = async (user: User) => {
}
} catch (e) {
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) {
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()
} catch (e) {
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'
} catch (e) {
const httpError = e as ISimpleError
store.error = httpError.message || 'Failed to update settings'
store.showToast(httpError.message || 'Failed to update settings')
}
}
+6 -4
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
import { formatSize, formatUnixDate } from "@/utils"
export type FUID = string
@@ -9,6 +9,8 @@ export type DocProps = {
size: number
mtime: number
dir: boolean
ghost?: boolean
expires?: number // Unix timestamp for ghost expiry
}
export class Doc {
@@ -16,21 +18,21 @@ export class Doc {
public key: FUID = ""
public size: number = 0
public mtime: number = 0
public haystack: string = ""
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 = ""
constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props
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 }
set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name
this.haystack = haystackFormat(name)
}
get sizedisp(): string { return formatSize(this.size) }
get modified(): string { return formatUnixDate(this.mtime) }
+2 -1
View File
@@ -164,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
handleUpdateMessage(msg)
break
case !!msg.space:
console.log('Watch space', msg.space)
const store = useMainStore()
store.space = msg.space
break
case !!msg.error:
handleError(msg)
+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
+245 -9
View File
@@ -4,14 +4,78 @@ 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<typeof useMainStore> | null = null
let loadingTimer: ReturnType<typeof setTimeout> | null = null
let clearOldResultsTimer: ReturnType<typeof setTimeout> | 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<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[],
searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
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,
authInProgress: false,
cursor: '' as string,
@@ -28,6 +92,12 @@ export const useMainStore = defineStore('main', {
username: '' as string,
privileged: false as boolean,
isLoggedIn: false as boolean,
},
space: {
disk: 0,
free: 0,
usage: 0,
storage: 0,
}
}),
persist: {
@@ -60,7 +130,143 @@ 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) {
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,
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
@@ -72,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
@@ -98,12 +308,25 @@ export const useMainStore = defineStore('main', {
resumeWatching()
},
toggleSort(name: SortOrder) {
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
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<string, string> = {
'': 'Folders first',
'name': 'Alphabetical order',
'modified': 'Newest first',
'size': 'Largest first',
}
this.showToast(labels[order] || order, 1200)
},
focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
@@ -118,8 +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') },
/** 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<FUID>()
const ret: SelectedItems = {
@@ -128,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)
@@ -149,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)
+45 -1
View File
@@ -1,8 +1,52 @@
import { useMainStore } from '@/stores/main'
import { getDocuments } from '@/stores/documentStore'
export const exists = (path: string[]) => {
const store = useMainStore()
// Access docVersion to make this reactive
void store.docVersion
const p = path.join('/')
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
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.) */
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`
}
+52 -34
View File
@@ -2,25 +2,26 @@
<Gallery
v-if="store.prefs.gallery"
ref="fileExplorer"
:key="`gallery-${Router.currentRoute.value.path}`"
:key="`gallery-${folderPath}`"
:path="props.path"
:documents="documents"
/>
<FileExplorer
v-else
ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`"
:key="`explorer-${folderPath}`"
:path="props.path"
:documents="documents"
/>
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path />
</template>
<script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main'
import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils'
import { getDocuments } from '@/stores/documentStore'
import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
@@ -30,51 +31,57 @@ const props = defineProps<{
path: Array<string>
query: string
}>()
// Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
// Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing)
watch(
() => [props.query, props.path.join('/')] as const,
([query, loc]) => {
if (store.query === query) return // Already searching this query
store.search(query, loc)
},
{ immediate: true }
)
const documents = computed(() => {
const loc = props.path.join('/')
const query = props.query
// List the current location
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
}
// List the current location (no search)
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)
}
const locsub = loc + '/'
// 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
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
})
watchEffect(() => {
store.fileExplorer = fileExplorer.value
store.query = props.query
})
// 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)
@@ -92,4 +99,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;
}
</style>
+225
View File
@@ -0,0 +1,225 @@
// 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 recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0
// Search result cache - cleared when documents change
interface CacheEntry {
query: string // Normalized query string
results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents
}
const searchCache: CacheEntry[] = []
const MAX_CACHE_SIZE = 10
const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string {
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
}
function normalizeQuery(str: string): string {
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
// Test if document matches search query
function matches(haystack: string, query: string, words: string[]): boolean {
return haystack.includes(query) || words.every(word => haystack.includes(word))
}
// Collator for sorting
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
// Yield control to allow new messages to be processed
const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
// Find best cache entry to filter from
// Returns entry if new query's results are guaranteed to be a subset of cached results
// Only valid if the cached search was complete (scanned all documents)
function findCacheSubset(query: string): CacheEntry | null {
// Look for a cached query that the new query starts with
// e.g., cached "foo" can be used for "foobar" or "foo bar"
// The longer the prefix, the better (fewer items to filter)
// IMPORTANT: Only use complete cache entries - incomplete ones may have
// missed results that would match the more specific query
let best: CacheEntry | null = null
for (const entry of searchCache) {
if (entry.complete && query.startsWith(entry.query)) {
if (!best || entry.query.length > best.query.length) {
best = entry
}
}
}
return best
}
// Add result to cache
function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
// Remove existing entry for same query if any
const idx = searchCache.findIndex(e => e.query === query)
if (idx !== -1) searchCache.splice(idx, 1)
// Add to front (most recent)
searchCache.unshift({ query, results, complete })
// Trim cache
if (searchCache.length > MAX_CACHE_SIZE) searchCache.pop()
}
// Clear cache (called when documents change)
function clearCache() {
searchCache.length = 0
}
// Perform search with incremental results
async function performSearch(rawQuery: string, loc: string, searchId: number) {
const query = normalizeQuery(rawQuery)
const words = query.split(/\s+/)
const results: WorkerDoc[] = []
let lastResultCount = 0
// Check cache for exact match
const exactMatch = searchCache.find(e => e.query === query)
if (exactMatch) {
if (currentSearchId === searchId) {
postResults(exactMatch.results, rawQuery, loc, searchId, true)
}
return
}
// Check if we can filter from a cached superset
const cacheEntry = findCacheSubset(query)
if (cacheEntry) {
// Fast path: filter from cached results (only used for complete cache entries)
for (const doc of cacheEntry.results) {
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Cache entry was complete, so filtered results are also complete
addToCache(query, results, true)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
return
}
// Slow path: scan all documents
const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
if (currentSearchId !== searchId) return // Superseded
// Process batch
const end = Math.min(i + batchSize, recentDocuments.length)
for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
const doc = recentDocuments[j]!
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Post incremental results if we found new matches
if (results.length > lastResultCount && currentSearchId === searchId) {
lastResultCount = results.length
postResults(results, rawQuery, loc, searchId, false)
}
// Yield control between batches
if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
await yieldControl()
}
}
// Cache and post final results
addToCache(query, results, results.length < RESULT_LIMIT)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
}
// Post results to main thread
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
const sorted = sortResults(docs, query, loc)
postMessage({
type: 'results',
docs: sorted.map(({ haystack, ...rest }) => rest),
id,
done
} 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
Number(b.loc === loc) - Number(a.loc === loc) ||
// Then subfolders
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
// Then by location
collator.compare(a.loc, b.loc) ||
// Folders before files
Number(b.dir) - Number(a.dir) ||
// Exact name match first
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
// Finally by name
collator.compare(a.name, b.name)
))
}
// Handle incoming messages
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
const msg = e.data
if (msg.type === 'update') {
// Update document list with haystacks, sorted by mtime descending
recentDocuments = msg.documents
.map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
.sort((a, b) => b.mtime - a.mtime)
clearCache()
} 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
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
}
}
}
+2 -2
View File
@@ -6,10 +6,10 @@
* - Builds to the Python module's frontend-build directory
*
* Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
* FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
*/
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180"
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
export default function fastapiVue({ paths = ["/api"] } = {}) {
// Build proxy configuration for each path
+2 -2
View File
@@ -27,8 +27,8 @@ dependencies = [
"argon2-cffi>=25.1.0",
"av>=15.0.0",
"blake3>=1.0.5",
"docopt>=0.6.2",
"fastapi-vue>=0.5.1",
"docopt-ng>=0.9.0",
"fastapi-vue>=0.5.2",
"fastapi[standard]>=0.128.0",
"html5tagger>=1.3.0",
"httpx>=0.28.0",
+16 -12
View File
@@ -2,11 +2,13 @@
"""Run Vite development server for frontend and Cista backend with auto-reload.
Usage:
uv run scripts/devserver.py [frontend] [--backend backend]
uv run scripts/devserver.py [frontend] [--backend backend] [cista_args...]
Options:
frontend Vite frontend endpoint (default: localhost:5173)
--backend Cista backend endpoint (default: from config, or :8000)
frontend Vite frontend endpoint (default: localhost:8989)
--backend Cista backend endpoint (default: from config, or :8999)
Any additional arguments are passed to the cista command.
Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
@@ -26,10 +28,10 @@ from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
from cista import config
from cista.serve import parse_listen
DEFAULT_BACKEND_PORT = 8000
DEFAULT_BACKEND_PORT = 8999
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str, list[str]]:
"""Parse backend listen address and build cista dev command.
Returns (url, cmd).
@@ -40,11 +42,11 @@ def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
port = opts.get("port", DEFAULT_BACKEND_PORT)
host = opts.get("host", "localhost") or "localhost"
cmd = ["cista", "--dev", "-l", listen]
cmd = ["cista", "--dev", "-l", listen] + extra_args
return f"http://{host}:{port}", cmd
async def run_devserver(frontend: str | None, backend: str | None) -> None:
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> None:
reporoot = Path(__file__).parent.parent
front = reporoot / "frontend"
if not (front / "package.json").exists():
@@ -52,7 +54,7 @@ async def run_devserver(frontend: str | None, backend: str | None) -> None:
raise SystemExit(1)
frontend_url, npm_install, vite = setup_vite(frontend or "")
backend_url, sanic_cmd = setup_sanic_backend(backend)
backend_url, sanic_cmd = setup_sanic_backend(backend, extra_args)
# Tell vite where to proxy API requests
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
@@ -81,17 +83,17 @@ def main():
"frontend",
nargs="?",
metavar="host:port",
help="Vite frontend endpoint (default: localhost:5173)",
help="Vite frontend endpoint (default: localhost:8989)",
)
parser.add_argument(
"--backend",
"-l",
metavar="host:port",
help="Cista backend endpoint (default: from config, or :8000)",
help="Cista backend endpoint (default: from config, or :8999)",
)
args = parser.parse_args()
args, unknown = parser.parse_known_args()
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(run_devserver(args.frontend, args.backend))
asyncio.run(run_devserver(args.frontend, args.backend, unknown))
HELP_EPILOG = """
@@ -99,6 +101,8 @@ HELP_EPILOG = """
scripts/devserver.py 3000 # Vite on localhost:3000
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
Additional arguments are passed to the cista backend command.
JS_RUNTIME environment variable can be used to select the JS runtime
"""
+2 -2
View File
@@ -7,8 +7,8 @@ import httpx
from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint
DEFAULT_VITE_PORT = 5173
DEFAULT_BACKEND_PORT = 5180
DEFAULT_VITE_PORT = 8989
DEFAULT_BACKEND_PORT = 8999
class ProcessGroup: