Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8270dd0cc2 | ||
|
|
1af6cd82fe | ||
|
|
0bc2a12cfa | ||
|
|
9e07a3f40d | ||
|
|
2867b1075d | ||
|
|
4fc8e29cc1 | ||
|
|
55f9117b3e | ||
|
|
2b6746c55e | ||
|
|
1ed3779933 | ||
|
|
a95cf7be94 | ||
|
|
d18501f633 | ||
|
|
ed697db871 | ||
|
|
856e8c4cc8 | ||
|
|
f8a6eacb06 | ||
|
|
e9a82e84ad | ||
|
|
ccd05b53f4 | ||
|
|
032ad13b97 | ||
|
|
4eae75c84b | ||
|
|
252b31a293 | ||
|
|
feff202161 | ||
|
|
d15bfc86c4 | ||
|
|
f94ccc01c6 | ||
|
|
bc355d991d | ||
|
|
e7ebad3c36 | ||
|
|
036c4342de | ||
|
|
160f929e0c | ||
|
|
1970d40d8a | ||
|
|
1a164e0a08 | ||
|
|
46d222006a | ||
|
|
98949e6b30 | ||
|
|
8f76d770ee |
@@ -38,6 +38,39 @@ pip install cista --break-system-packages
|
||||
|
||||
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
|
||||
|
||||
## Authentication
|
||||
|
||||
Cista supports three authentication modes:
|
||||
|
||||
### Built-in Authentication (default)
|
||||
|
||||
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||
|
||||
```fish
|
||||
uvx cista --user admin --privileged # Create admin user
|
||||
uvx cista --user guest # Create regular user
|
||||
```
|
||||
|
||||
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||
|
||||
### Public Mode
|
||||
|
||||
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||
|
||||
### Paskia SSO Authentication
|
||||
|
||||
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
||||
|
||||
```fish
|
||||
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||
```
|
||||
|
||||
In Paskia mode:
|
||||
- All `/auth/*` requests are proxied to the Paskia backend
|
||||
- Users with `cista:login` permission can access files
|
||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
||||
|
||||
### Internet Access
|
||||
|
||||
Most admins find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
|
||||
|
||||
+42
-15
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+19
-8
@@ -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))
|
||||
|
||||
|
||||
@@ -95,14 +101,19 @@ async def control(req, ws):
|
||||
async def watch(req, ws):
|
||||
# Build user info from either built-in auth or SSO
|
||||
user_info = None
|
||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||
# SSO auth (paskia mode): extract from validation response
|
||||
ctx = sso_user.get("ctx", {})
|
||||
perms = ctx.get("permissions", [])
|
||||
user_info = {
|
||||
"username": ctx.get("user", {}).get("display_name", ""),
|
||||
"privileged": "cista:admin" in perms,
|
||||
}
|
||||
if sso.paskia_enabled():
|
||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||
try:
|
||||
await sso.validate_sso_request(req)
|
||||
except Exception:
|
||||
pass # Ignore auth errors, user_info stays None
|
||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||
ctx = sso_user.get("ctx", {})
|
||||
perms = ctx.get("permissions", [])
|
||||
user_info = {
|
||||
"username": ctx.get("user", {}).get("display_name", ""),
|
||||
"privileged": "cista:admin" in perms,
|
||||
}
|
||||
elif req.ctx.user:
|
||||
# Built-in auth: use local user database
|
||||
user_info = {
|
||||
|
||||
+32
-43
@@ -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})
|
||||
@@ -257,10 +236,7 @@ def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
|
||||
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||
async def zip_download(req, keys, zipfile, ext):
|
||||
"""Download a zip archive of the given keys"""
|
||||
if config.config.authentication == "paskia":
|
||||
await auth.verify_sso(req)
|
||||
else:
|
||||
auth.verify(req)
|
||||
await auth.verify(req)
|
||||
|
||||
wanted = set(keys.split("+"))
|
||||
files = get_files(wanted)
|
||||
@@ -291,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
|
||||
|
||||
+13
-16
@@ -236,39 +236,36 @@ async def verify(request, *, privileged=False):
|
||||
|
||||
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
||||
For built-in mode, checks session-based authentication.
|
||||
For public mode (config.public=True), allows all requests.
|
||||
|
||||
All 401/403 responses include auth.iframe URL for consistent frontend handling
|
||||
via the paskia library's showAuthIframe().
|
||||
For public mode (config.public=True), skips auth unless privileged is required.
|
||||
|
||||
Args:
|
||||
request: The Sanic request object
|
||||
privileged: If True, requires admin privileges
|
||||
privileged: If True, requires admin privileges (always enforced even in public mode)
|
||||
|
||||
Raises:
|
||||
Unauthorized: If authentication is required
|
||||
Forbidden: If access is denied
|
||||
"""
|
||||
# Public mode: skip auth unless privileged access is required
|
||||
if config.config.public and not privileged:
|
||||
return
|
||||
|
||||
sso = _get_sso()
|
||||
if sso.paskia_enabled():
|
||||
# SSO validation against auth backend
|
||||
# Always check cista:login; privileged flag comes from response perm list
|
||||
perm = "cista:admin" if privileged else "cista:login"
|
||||
await sso.validate_sso_request(request, perm=perm)
|
||||
return
|
||||
|
||||
user = getattr(request.ctx, "user", None)
|
||||
if privileged:
|
||||
if user:
|
||||
if user.privileged:
|
||||
return
|
||||
raise Forbidden(
|
||||
"Access Forbidden: Only for privileged users",
|
||||
quiet=True,
|
||||
)
|
||||
elif config.config.public or user:
|
||||
if user and user.privileged:
|
||||
return
|
||||
raise Forbidden(
|
||||
"Access Forbidden: Only for privileged users",
|
||||
quiet=True,
|
||||
)
|
||||
if user:
|
||||
return
|
||||
# Return iframe URL for paskia library to show login dialog
|
||||
raise Unauthorized(
|
||||
f"Login required for {request.path}",
|
||||
"cookie",
|
||||
|
||||
+2
-2
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 = []
|
||||
@@ -94,11 +200,11 @@ const settingsMenu = (e: Event) => {
|
||||
|
||||
if (store.user.isLoggedIn) {
|
||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||
} else if (!ssoStore.isExternalAuth) {
|
||||
// Show login in paskia iframe overlay
|
||||
} else if (store.server.public) {
|
||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||
items.push({ label: '🔐 Login', onClick: async () => {
|
||||
try {
|
||||
await showAuthIframe('/auth/restricted')
|
||||
await showAuthIframe('/auth/restricted#theme=light')
|
||||
resumeWatching()
|
||||
} catch (e) {
|
||||
console.log('Login cancelled')
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
"""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user