Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5717486197 | ||
|
|
0061fc54ae | ||
|
|
4eefe83072 | ||
|
|
f578a50007 | ||
|
|
f40d9c1abd | ||
|
|
3d8845cf99 | ||
|
|
87e1443e7d | ||
|
|
f45c57e901 | ||
|
|
41686d1dd1 | ||
|
|
cc351bb992 | ||
|
|
c3abbe0a3b | ||
|
|
127caeedea | ||
|
|
113bc56351 | ||
|
|
c7727c72d9 | ||
|
|
85b3aa6b81 | ||
|
|
62b44ddb43 | ||
|
|
60a53ef3d3 | ||
|
|
acd38c2235 | ||
|
|
2f38f15afa | ||
|
|
0fc28e56bb | ||
|
|
22b0e503e0 | ||
|
|
e3a4ecdcc2 | ||
|
|
9f363e3f66 | ||
|
|
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 |
@@ -8,7 +8,7 @@ This is a cutting-edge **file and document server** designed for speed, efficien
|
|||||||
|
|
||||||
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
||||||
|
|
||||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which was not being developed at the time. Now they have picked up pace too, feel free to try both and compare.
|
||||||
|
|
||||||
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
||||||
|
|
||||||
@@ -38,6 +38,46 @@ 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.
|
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 two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||||
|
|
||||||
|
### Public Mode
|
||||||
|
|
||||||
|
In public mode, anyone can read, send and even delete files without without logging in. Users entering the service won't be asked to authenticate. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||||
|
|
||||||
|
### Built-in Password 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.
|
||||||
|
|
||||||
|
### Passkey Authentication and SSO
|
||||||
|
|
||||||
|
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. This allows user account and permission management at the corporate level, without bothering Cista with it.
|
||||||
|
|
||||||
|
Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the Paskia backend on the same machine (to use that default URL):
|
||||||
|
```fish
|
||||||
|
uvx paskia
|
||||||
|
```
|
||||||
|
|
||||||
|
In Paskia mode:
|
||||||
|
- All `/auth/*` requests are proxied to the Paskia backend
|
||||||
|
- Cista backend verifies access by `/auth/api/validate` endpoint and shows a login dialog if needed
|
||||||
|
- Users with `cista:login` permission can access files
|
||||||
|
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||||
|
|
||||||
### Internet Access
|
### 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.
|
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.
|
||||||
@@ -56,9 +96,15 @@ Nxing or other proxy may be similarly used, or alternatively you can place cert
|
|||||||
|
|
||||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||||
|
|
||||||
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
|
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or javascript runtimes.
|
||||||
|
|
||||||
Create `/etc/systemd/system/cista@.service`:
|
Create (edit) a systemd unit:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
sudo systemctl edit --force --full cista@.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -68,6 +114,7 @@ Description=Cista storage %i
|
|||||||
User=storage
|
User=storage
|
||||||
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||||
Restart=always
|
Restart=always
|
||||||
|
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -120,4 +167,4 @@ Building the package for release (frontend + Python wheel/sdist):
|
|||||||
uv build
|
uv build
|
||||||
```
|
```
|
||||||
|
|
||||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
Vue is used to build files in `cista/frontend-build`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
||||||
|
|||||||
+51
-15
@@ -25,6 +25,36 @@ def create_banner():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def create_startup_box(
|
||||||
|
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||||
|
):
|
||||||
|
"""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]
|
||||||
|
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||||
|
if paskia_url:
|
||||||
|
auth_line = f"Auth: Paskia {paskia_url}"
|
||||||
|
else:
|
||||||
|
auth_line = "Auth: Password"
|
||||||
|
if public:
|
||||||
|
auth_line += ", Public"
|
||||||
|
lines.append(auth_line)
|
||||||
|
if dev:
|
||||||
|
lines.append("dev mode")
|
||||||
|
|
||||||
|
# Calculate width based on content
|
||||||
|
inner_width = max(len(line) for line in lines) + 2
|
||||||
|
|
||||||
|
# Build the box
|
||||||
|
box = [f"╭{'─' * inner_width}╮"]
|
||||||
|
for line in lines:
|
||||||
|
box.append(f"│ {line:<{inner_width - 1}}│")
|
||||||
|
box.append(f"╰{'─' * inner_width}╯")
|
||||||
|
return "\n".join(box) + "\n"
|
||||||
|
|
||||||
|
|
||||||
banner = create_banner()
|
banner = create_banner()
|
||||||
|
|
||||||
doc = """\
|
doc = """\
|
||||||
@@ -35,7 +65,7 @@ Usage:
|
|||||||
Options:
|
Options:
|
||||||
-c CONFDIR Custom config directory
|
-c CONFDIR Custom config directory
|
||||||
-l LISTEN-ADDR Listen on
|
-l LISTEN-ADDR Listen on
|
||||||
:8000 (localhost port, plain http)
|
:8989 (localhost port, plain http)
|
||||||
<addr>:3000 (bind another address, port)
|
<addr>:3000 (bind another address, port)
|
||||||
/path/to/unix.sock (unix socket)
|
/path/to/unix.sock (unix socket)
|
||||||
example.com (run on 80 and 443 with LetsEncrypt)
|
example.com (run on 80 and 443 with LetsEncrypt)
|
||||||
@@ -58,7 +88,7 @@ Environment:
|
|||||||
first_time_help = """\
|
first_time_help = """\
|
||||||
No config file found! Get started with:
|
No config file found! Get started with:
|
||||||
cista --user yourname --privileged # If you want user accounts
|
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!
|
See cista --help for other options!
|
||||||
"""
|
"""
|
||||||
@@ -83,8 +113,7 @@ def _main():
|
|||||||
elif "--version" in sys.argv:
|
elif "--version" in sys.argv:
|
||||||
sys.stdout.write(f"cista {cista.__version__}\n")
|
sys.stdout.write(f"cista {cista.__version__}\n")
|
||||||
return 0
|
return 0
|
||||||
else:
|
# Don't print banner yet for normal startup - we'll print the startup box later
|
||||||
sys.stderr.write(banner)
|
|
||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
@@ -119,19 +148,26 @@ def _main():
|
|||||||
if listen:
|
if listen:
|
||||||
settings["listen"] = listen
|
settings["listen"] = listen
|
||||||
elif not exists:
|
elif not exists:
|
||||||
settings["listen"] = ":8000"
|
settings["listen"] = ":8989"
|
||||||
operation = config.update_config(settings)
|
config.update_config(settings)
|
||||||
sys.stderr.write(f"Config {operation}: {config.conffile}\n")
|
|
||||||
# Prepare to serve
|
# Prepare to serve
|
||||||
unix = None
|
url, opts = serve.parse_listen(config.config.listen)
|
||||||
url, _ = serve.parse_listen(config.config.listen)
|
|
||||||
if not config.config.path.is_dir():
|
if not config.config.path.is_dir():
|
||||||
raise ValueError(f"No such directory: {config.config.path}")
|
raise ValueError(f"No such directory: {config.config.path}")
|
||||||
extra = f" ({unix})" if unix else ""
|
|
||||||
dev = args["--dev"]
|
dev = args["--dev"]
|
||||||
if dev:
|
# Check for Paskia SSO
|
||||||
extra += " (dev mode)"
|
from cista.sso import PASKIA_BACKEND_URL
|
||||||
sys.stderr.write(f"Serving {config.config.path} at {url}{extra}\n")
|
|
||||||
|
# Print startup box
|
||||||
|
startup_box = create_startup_box(
|
||||||
|
folder=config.config.path,
|
||||||
|
url=url,
|
||||||
|
unix=opts.get("unix"),
|
||||||
|
dev=dev,
|
||||||
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
public=config.config.public,
|
||||||
|
)
|
||||||
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
serve.run(dev=dev)
|
serve.run(dev=dev)
|
||||||
return 0
|
return 0
|
||||||
@@ -159,7 +195,7 @@ def _user(args):
|
|||||||
# Defaults for new config when user is created
|
# Defaults for new config when user is created
|
||||||
operation = config.update_config(
|
operation = config.update_config(
|
||||||
{
|
{
|
||||||
"listen": ":8000",
|
"listen": ":8989",
|
||||||
"path": Path.home() / "Downloads",
|
"path": Path.home() / "Downloads",
|
||||||
"public": False,
|
"public": False,
|
||||||
}
|
}
|
||||||
@@ -188,7 +224,7 @@ def _user(args):
|
|||||||
|
|
||||||
if operation == "created":
|
if operation == "created":
|
||||||
sys.stderr.write(
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+47
-8
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
import typing
|
||||||
|
from pathlib import PurePosixPath
|
||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
@@ -53,6 +54,9 @@ async def upload(req, ws):
|
|||||||
if pos != req.end:
|
if pos != req.end:
|
||||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
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
|
# Report success
|
||||||
res = StatusMsg(status="ack", req=req)
|
res = StatusMsg(status="ack", req=req)
|
||||||
await asend(ws, res)
|
await asend(ws, res)
|
||||||
@@ -87,6 +91,8 @@ async def control(req, ws):
|
|||||||
while True:
|
while True:
|
||||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
||||||
await asyncio.to_thread(cmd)
|
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))
|
await asend(ws, StatusMsg(status="ack", req=cmd))
|
||||||
|
|
||||||
|
|
||||||
@@ -95,14 +101,19 @@ async def control(req, ws):
|
|||||||
async def watch(req, ws):
|
async def watch(req, ws):
|
||||||
# Build user info from either built-in auth or SSO
|
# Build user info from either built-in auth or SSO
|
||||||
user_info = None
|
user_info = None
|
||||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
if sso.paskia_enabled():
|
||||||
# SSO auth (paskia mode): extract from validation response
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||||
ctx = sso_user.get("ctx", {})
|
try:
|
||||||
perms = ctx.get("permissions", [])
|
await sso.validate_sso_request(req)
|
||||||
user_info = {
|
except Exception:
|
||||||
"username": ctx.get("user", {}).get("display_name", ""),
|
pass # Ignore auth errors, user_info stays None
|
||||||
"privileged": "cista:admin" in perms,
|
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:
|
elif req.ctx.user:
|
||||||
# Built-in auth: use local user database
|
# Built-in auth: use local user database
|
||||||
user_info = {
|
user_info = {
|
||||||
@@ -152,6 +163,17 @@ def subscribe(uuid, ws):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("config")
|
||||||
|
async def get_config(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"name": config.config.name,
|
||||||
|
"public": config.config.public,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.put("config/public")
|
@bp.put("config/public")
|
||||||
async def update_public(request):
|
async def update_public(request):
|
||||||
await auth.verify(request, privileged=True)
|
await auth.verify(request, privileged=True)
|
||||||
@@ -165,3 +187,20 @@ async def update_public(request):
|
|||||||
raise BadRequest(str(e)) from None
|
raise BadRequest(str(e)) from None
|
||||||
config.update_config({"public": public})
|
config.update_config({"public": public})
|
||||||
return json({"message": "Public access setting updated", "public": public})
|
return json({"message": "Public access setting updated", "public": public})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("config/name")
|
||||||
|
async def update_name(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
name = request.json["name"]
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ValueError("name must be a string")
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing name field") from None
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(str(e)) from None
|
||||||
|
config.update_config({"name": name})
|
||||||
|
# Return the effective name (fallback to path.name if empty)
|
||||||
|
effective_name = name or config.config.path.name
|
||||||
|
return json({"message": "Server name updated", "name": effective_name})
|
||||||
|
|||||||
+32
-43
@@ -1,7 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import threading
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from multiprocessing import cpu_count
|
from multiprocessing import cpu_count
|
||||||
from pathlib import Path, PurePath, PurePosixPath
|
from pathlib import Path, PurePath, PurePosixPath
|
||||||
@@ -43,19 +42,22 @@ setproctitle("cista-main")
|
|||||||
async def main_start(app):
|
async def main_start(app):
|
||||||
config.load_config()
|
config.load_config()
|
||||||
setproctitle(f"cista {config.config.path.name}")
|
setproctitle(f"cista {config.config.path.name}")
|
||||||
workers = max(2, min(8, cpu_count()))
|
# Small pool for memory-intensive preview generation
|
||||||
|
preview_workers = max(2, min(8, cpu_count()))
|
||||||
app.ctx.threadexec = ThreadPoolExecutor(
|
app.ctx.threadexec = ThreadPoolExecutor(
|
||||||
max_workers=workers, thread_name_prefix="cista-ioworker"
|
max_workers=preview_workers, thread_name_prefix="cista-preview"
|
||||||
)
|
)
|
||||||
|
# Larger pool for long-running but low-memory zip operations
|
||||||
|
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||||
watching.start(app)
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||||
@app.before_server_stop
|
@app.before_server_stop
|
||||||
async def main_stop(app):
|
async def main_stop(app):
|
||||||
quit.set()
|
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
|
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||||
await sso.close_client()
|
await sso.close_client()
|
||||||
logger.debug("Cista worker threads all finished")
|
logger.debug("Cista worker threads all finished")
|
||||||
|
|
||||||
@@ -170,9 +172,8 @@ def _load_wwwroot(www):
|
|||||||
|
|
||||||
@app.before_server_start
|
@app.before_server_start
|
||||||
async def start(app):
|
async def start(app):
|
||||||
await load_wwwroot(app)
|
if not app.debug:
|
||||||
if app.debug:
|
await load_wwwroot(app)
|
||||||
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
|
|
||||||
|
|
||||||
|
|
||||||
async def 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"])
|
@app.route("/<path:path>", methods=["GET", "HEAD"])
|
||||||
async def wwwroot(req, path=""):
|
async def wwwroot(req, path=""):
|
||||||
"""Frontend files only"""
|
"""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)
|
name = unquote(path)
|
||||||
if name not in www:
|
if name not in www:
|
||||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
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>")
|
@app.get("/zip/<keys>/<zipfile:ext=zip>")
|
||||||
async def zip_download(req, keys, zipfile, ext):
|
async def zip_download(req, keys, zipfile, ext):
|
||||||
"""Download a zip archive of the given keys"""
|
"""Download a zip archive of the given keys"""
|
||||||
if config.config.authentication == "paskia":
|
await auth.verify(req)
|
||||||
await auth.verify_sso(req)
|
|
||||||
else:
|
|
||||||
auth.verify(req)
|
|
||||||
|
|
||||||
wanted = set(keys.split("+"))
|
wanted = set(keys.split("+"))
|
||||||
files = get_files(wanted)
|
files = get_files(wanted)
|
||||||
@@ -291,27 +267,40 @@ async def zip_download(req, keys, zipfile, ext):
|
|||||||
yield chunk
|
yield chunk
|
||||||
assert size == 0
|
assert size == 0
|
||||||
|
|
||||||
|
pending_put = None # Current queue.put future, can be cancelled
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
|
nonlocal pending_put
|
||||||
try:
|
try:
|
||||||
for chunk in stream_zip(local_files(files)):
|
for chunk in stream_zip(local_files(files)):
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
|
future = asyncio.run_coroutine_threadsafe(queue.put(chunk), loop)
|
||||||
|
pending_put = future
|
||||||
|
future.result() # Blocks until queue has space
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("ZIP download cancelled by client disconnect")
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error streaming ZIP")
|
logger.exception("Error streaming ZIP")
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
pending_put = None
|
||||||
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
|
||||||
|
|
||||||
# Don't block the event loop: run in a thread
|
# Don't block the event loop: run in a thread (use larger zip pool)
|
||||||
queue = asyncio.Queue(maxsize=1)
|
queue = asyncio.Queue(maxsize=1)
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
thread = loop.run_in_executor(app.ctx.threadexec, worker)
|
thread = loop.run_in_executor(app.ctx.zipexec, worker)
|
||||||
|
|
||||||
# Stream the response
|
# Stream the response
|
||||||
res = await req.respond(
|
res = await req.respond(
|
||||||
content_type="application/zip",
|
content_type="application/zip",
|
||||||
headers={"cache-control": "no-store"},
|
headers={"cache-control": "no-store"},
|
||||||
)
|
)
|
||||||
while chunk := await queue.get():
|
try:
|
||||||
await res.send(chunk)
|
while chunk := await queue.get():
|
||||||
|
await res.send(chunk)
|
||||||
|
finally:
|
||||||
|
# Cancel any pending put to unblock and stop the worker
|
||||||
|
if pending_put:
|
||||||
|
pending_put.cancel()
|
||||||
|
|
||||||
await thread # If it raises, the response will fail download
|
await thread # If it raises, the response will fail download
|
||||||
|
|||||||
+15
-18
@@ -236,43 +236,40 @@ async def verify(request, *, privileged=False):
|
|||||||
|
|
||||||
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
For paskia mode (PASKIA_BACKEND_URL set), validates against the SSO backend.
|
||||||
For built-in mode, checks session-based authentication.
|
For built-in mode, checks session-based authentication.
|
||||||
For public mode (config.public=True), allows all requests.
|
For public mode (config.public=True), skips auth unless privileged is required.
|
||||||
|
|
||||||
All 401/403 responses include auth.iframe URL for consistent frontend handling
|
|
||||||
via the paskia library's showAuthIframe().
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The Sanic request object
|
request: The Sanic request object
|
||||||
privileged: If True, requires admin privileges
|
privileged: If True, requires admin privileges (always enforced even in public mode)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
Unauthorized: If authentication is required
|
Unauthorized: If authentication is required
|
||||||
Forbidden: If access is denied
|
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()
|
sso = _get_sso()
|
||||||
if sso.paskia_enabled():
|
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"
|
perm = "cista:admin" if privileged else "cista:login"
|
||||||
await sso.validate_sso_request(request, perm=perm)
|
await sso.validate_sso_request(request, perm=perm)
|
||||||
return
|
return
|
||||||
|
|
||||||
user = getattr(request.ctx, "user", None)
|
user = getattr(request.ctx, "user", None)
|
||||||
if privileged:
|
if privileged:
|
||||||
if user:
|
if user and user.privileged:
|
||||||
if user.privileged:
|
return
|
||||||
return
|
raise Forbidden(
|
||||||
raise Forbidden(
|
"Access Forbidden: Only for privileged users",
|
||||||
"Access Forbidden: Only for privileged users",
|
quiet=True,
|
||||||
quiet=True,
|
)
|
||||||
)
|
if user:
|
||||||
elif config.config.public or user:
|
|
||||||
return
|
return
|
||||||
# Return iframe URL for paskia library to show login dialog
|
|
||||||
raise Unauthorized(
|
raise Unauthorized(
|
||||||
f"Login required for {request.path}",
|
f"Login required for {request.path}",
|
||||||
"cookie",
|
"cookie",
|
||||||
context={"auth": {"iframe": "/auth/restricted"}},
|
context={"auth": {"iframe": "/auth/restricted/"}},
|
||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,7 +278,7 @@ async def verify(request, *, privileged=False):
|
|||||||
bp = Blueprint("auth", url_prefix="/auth")
|
bp = Blueprint("auth", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/restricted")
|
@bp.get("/restricted/")
|
||||||
async def login_page(request):
|
async def login_page(request):
|
||||||
"""Login page that works both standalone and in paskia iframe."""
|
"""Login page that works both standalone and in paskia iframe."""
|
||||||
s = session.get(request)
|
s = session.get(request)
|
||||||
|
|||||||
+72
-14
@@ -2,7 +2,10 @@ import asyncio
|
|||||||
import gc
|
import gc
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -25,6 +28,49 @@ pillow_heif.register_heif_opener()
|
|||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CachedPreview:
|
||||||
|
"""Cached preview with headers and body."""
|
||||||
|
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewCache:
|
||||||
|
"""Thread-safe LRU cache for preview responses."""
|
||||||
|
|
||||||
|
def __init__(self, capacity: int = 500):
|
||||||
|
self.capacity = capacity
|
||||||
|
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: str) -> CachedPreview | None:
|
||||||
|
"""Get cached preview, moving it to end (most recently used)."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: CachedPreview) -> None:
|
||||||
|
"""Cache preview, evicting oldest if at capacity."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
else:
|
||||||
|
if len(self._cache) >= self.capacity:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
self._cache[key] = value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._cache)
|
||||||
|
|
||||||
|
|
||||||
|
# Global preview cache instance
|
||||||
|
_preview_cache = PreviewCache(capacity=500)
|
||||||
|
|
||||||
|
|
||||||
@bp.on_request
|
@bp.on_request
|
||||||
async def verify_preview(request):
|
async def verify_preview(request):
|
||||||
"""Verify access to preview routes."""
|
"""Verify access to preview routes."""
|
||||||
@@ -55,6 +101,29 @@ async def preview(req, path):
|
|||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
|
|
||||||
|
if req.headers.if_none_match == etag:
|
||||||
|
# The client has it cached, respond 304 Not Modified
|
||||||
|
return empty(304, headers={"etag": etag})
|
||||||
|
|
||||||
|
# Check in-memory cache first (includes headers)
|
||||||
|
cached = _preview_cache.get(etag)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug(f"Preview cache hit: {rel}")
|
||||||
|
return raw(cached.body, headers=cached.headers)
|
||||||
|
|
||||||
|
if not filepath.is_file():
|
||||||
|
raise NotFound("File not found")
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
|
# Build headers and cache the full response
|
||||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
@@ -64,19 +133,8 @@ async def preview(req, path):
|
|||||||
"content-type": "image/avif",
|
"content-type": "image/avif",
|
||||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
}
|
}
|
||||||
if req.headers.if_none_match == etag:
|
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||||
# The client has it cached, respond 304 Not Modified
|
|
||||||
return empty(304, headers=headers)
|
|
||||||
|
|
||||||
if not filepath.is_file():
|
|
||||||
raise NotFound("File not found")
|
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
|
||||||
)
|
|
||||||
if not img:
|
|
||||||
# Preview generation failed, redirect to the file itself
|
|
||||||
return redirect(f"/files/{path}", status=303)
|
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,9 +147,9 @@ def dispatch(path, quality, maxsize, maxzoom):
|
|||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Cannot generate preview for {path.name}: {e}")
|
logger.warning(f"Cannot generate preview for {path}: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error generating preview for {path.name}: {e}")
|
logger.exception(f"Error generating preview for {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
|
|||||||
+31
-1
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
from pathlib import PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
|||||||
def __call__(self):
|
def __call__(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def affected_paths(self) -> list[str]:
|
||||||
|
"""Return list of paths affected by this operation for change notification."""
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
class MkDir(ControlBase):
|
class MkDir(ControlBase):
|
||||||
path: str
|
path: str
|
||||||
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
|
|||||||
path = config.config.path / filename.sanitize(self.path)
|
path = config.config.path / filename.sanitize(self.path)
|
||||||
path.mkdir(parents=True, exist_ok=False)
|
path.mkdir(parents=True, exist_ok=False)
|
||||||
|
|
||||||
|
def affected_paths(self) -> list[str]:
|
||||||
|
return [filename.sanitize(self.path)]
|
||||||
|
|
||||||
|
|
||||||
class Rename(ControlBase):
|
class Rename(ControlBase):
|
||||||
path: str
|
path: str
|
||||||
@@ -36,6 +44,11 @@ class Rename(ControlBase):
|
|||||||
path = config.config.path / filename.sanitize(self.path)
|
path = config.config.path / filename.sanitize(self.path)
|
||||||
path.rename(path.with_name(to))
|
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):
|
class Rm(ControlBase):
|
||||||
sel: list[str]
|
sel: list[str]
|
||||||
@@ -49,6 +62,9 @@ class Rm(ControlBase):
|
|||||||
else:
|
else:
|
||||||
p.unlink()
|
p.unlink()
|
||||||
|
|
||||||
|
def affected_paths(self) -> list[str]:
|
||||||
|
return [filename.sanitize(p) for p in self.sel]
|
||||||
|
|
||||||
|
|
||||||
class Mv(ControlBase):
|
class Mv(ControlBase):
|
||||||
sel: list[str]
|
sel: list[str]
|
||||||
@@ -63,6 +79,13 @@ class Mv(ControlBase):
|
|||||||
for p in sel:
|
for p in sel:
|
||||||
shutil.move(p, dst)
|
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):
|
class Cp(ControlBase):
|
||||||
sel: list[str]
|
sel: list[str]
|
||||||
@@ -86,6 +109,11 @@ class Cp(ControlBase):
|
|||||||
else:
|
else:
|
||||||
shutil.copy2(p, dst)
|
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
|
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
||||||
|
|
||||||
@@ -118,6 +146,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
|||||||
key: str
|
key: str
|
||||||
mtime: int
|
mtime: int
|
||||||
size: int
|
size: int
|
||||||
|
allocated: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -149,5 +178,6 @@ class UpdateMessage(msgspec.Struct):
|
|||||||
class Space(msgspec.Struct):
|
class Space(msgspec.Struct):
|
||||||
disk: int
|
disk: int
|
||||||
free: int
|
free: int
|
||||||
usage: int
|
used: int
|
||||||
storage: int
|
storage: int
|
||||||
|
allocated: int
|
||||||
|
|||||||
+21
-10
@@ -2,6 +2,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
from sanic import Sanic
|
from sanic import Sanic
|
||||||
|
|
||||||
from cista import config, server80
|
from cista import config, server80
|
||||||
@@ -44,18 +45,28 @@ def check_cert(certdir, domain):
|
|||||||
|
|
||||||
|
|
||||||
def parse_listen(listen):
|
def parse_listen(listen):
|
||||||
if listen.startswith("/"):
|
# Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
|
||||||
unix = Path(listen).resolve()
|
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():
|
if not unix.parent.exists():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Directory for unix socket does not exist: {unix.parent}/",
|
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||||
)
|
)
|
||||||
return "http://localhost", {"unix": unix.as_posix()}
|
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}
|
host, port = ep["host"], ep["port"]
|
||||||
try:
|
# When binding all interfaces, use single_listener=False for Sanic
|
||||||
addr, _port = listen.split(":", 1)
|
if len(endpoints) > 1:
|
||||||
port = int(_port)
|
return f"http://localhost:{port}", {
|
||||||
except Exception:
|
"host": host,
|
||||||
raise ValueError(f"Invalid listen address: {listen}") from None
|
"port": port,
|
||||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
"single_listener": False,
|
||||||
|
}
|
||||||
|
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||||
|
|||||||
+423
-85
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import queue
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -23,7 +24,7 @@ sortkey = natsort_keygen(alg=ns.LOCALE)
|
|||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self._space = Space(0, 0, 0, 0)
|
self._space = Space(0, 0, 0, 0, 0)
|
||||||
self.root: list[FileEntry] = []
|
self.root: list[FileEntry] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -116,6 +117,28 @@ state = State()
|
|||||||
rootpath: Path = None # type: ignore
|
rootpath: Path = None # type: ignore
|
||||||
quit = threading.Event()
|
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
|
## Filesystem scanning
|
||||||
|
|
||||||
|
|
||||||
@@ -125,12 +148,15 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
try:
|
try:
|
||||||
st = stat or path.stat()
|
st = stat or path.stat()
|
||||||
isfile = int(not S_ISDIR(st.st_mode))
|
isfile = int(not S_ISDIR(st.st_mode))
|
||||||
|
# st_blocks is in 512-byte units
|
||||||
|
allocated = st.st_blocks * 512 if isfile else 0
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
key=fuid(st),
|
key=fuid(st),
|
||||||
mtime=int(st.st_mtime),
|
mtime=int(st.st_mtime),
|
||||||
size=st.st_size if isfile else 0,
|
size=st.st_size if isfile else 0,
|
||||||
|
allocated=allocated,
|
||||||
isfile=isfile,
|
isfile=isfile,
|
||||||
)
|
)
|
||||||
if isfile:
|
if isfile:
|
||||||
@@ -158,8 +184,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
level=entry.level,
|
level=entry.level,
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
key=entry.key,
|
key=entry.key,
|
||||||
size=entry.size + child.size,
|
|
||||||
mtime=max(entry.mtime, child.mtime),
|
mtime=max(entry.mtime, child.mtime),
|
||||||
|
size=entry.size + child.size,
|
||||||
|
allocated=entry.allocated + child.allocated,
|
||||||
isfile=entry.isfile,
|
isfile=entry.isfile,
|
||||||
)
|
)
|
||||||
ret.extend(sub)
|
ret.extend(sub)
|
||||||
@@ -204,7 +231,14 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
|||||||
def update_space(loop):
|
def update_space(loop):
|
||||||
"""Called periodically to update the disk usage."""
|
"""Called periodically to update the disk usage."""
|
||||||
du = shutil.disk_usage(rootpath)
|
du = shutil.disk_usage(rootpath)
|
||||||
space = Space(*du, storage=state.root[0].size)
|
root = state.root[0]
|
||||||
|
space = Space(
|
||||||
|
disk=du.total,
|
||||||
|
free=du.free,
|
||||||
|
used=du.used,
|
||||||
|
storage=root.size,
|
||||||
|
allocated=root.allocated,
|
||||||
|
)
|
||||||
# Update only on difference above 1 MB
|
# Update only on difference above 1 MB
|
||||||
tol = 10**6
|
tol = 10**6
|
||||||
old = msgspec.structs.astuple(state.space)
|
old = msgspec.structs.astuple(state.space)
|
||||||
@@ -326,127 +360,431 @@ def format_root(root):
|
|||||||
|
|
||||||
|
|
||||||
def broadcast(msg, loop):
|
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):
|
async def abroadcast(msg):
|
||||||
|
client_count = 0
|
||||||
try:
|
try:
|
||||||
for queue in pubsub.values():
|
for queue in pubsub.values():
|
||||||
queue.put_nowait(msg)
|
queue.put_nowait(msg)
|
||||||
|
client_count += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
# Log because asyncio would silently eat the error
|
# Log because asyncio would silently eat the error
|
||||||
logger.exception("Broadcast error")
|
logger.exception("Broadcast error")
|
||||||
|
return client_count
|
||||||
|
|
||||||
|
|
||||||
## Watcher thread
|
## Watcher thread
|
||||||
|
|
||||||
|
|
||||||
def watcher_inotify(loop):
|
class PathIndex:
|
||||||
"""Inotify watcher thread (Linux only)"""
|
"""O(1) path lookup index for the flat FileEntry tree."""
|
||||||
import inotify.adapters
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Recalculate sizes for ancestor folders (including root)
|
||||||
|
self._recalculate_ancestors(path)
|
||||||
|
|
||||||
|
return new_root
|
||||||
|
|
||||||
|
def _recalculate_ancestors(self, path: PurePosixPath):
|
||||||
|
"""Recalculate size/allocated for all ancestors of path, including root."""
|
||||||
|
# Build list of ancestors from deepest to root
|
||||||
|
ancestors = []
|
||||||
|
current = path.parent if path.parts else PurePosixPath()
|
||||||
|
while True:
|
||||||
|
ancestors.append(current)
|
||||||
|
if not current.parts:
|
||||||
|
break
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
# Process from deepest ancestor to root
|
||||||
|
for ancestor_path in ancestors:
|
||||||
|
if ancestor_path not in self._index:
|
||||||
|
continue
|
||||||
|
start, count = self._index[ancestor_path]
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ancestor = self.root[start]
|
||||||
|
if ancestor.isfile:
|
||||||
|
continue # Files don't aggregate
|
||||||
|
|
||||||
|
# Sum size/allocated of direct children
|
||||||
|
total_size = 0
|
||||||
|
total_allocated = 0
|
||||||
|
i = start + 1
|
||||||
|
while i < start + count:
|
||||||
|
child = self.root[i]
|
||||||
|
if child.level == ancestor.level + 1:
|
||||||
|
total_size += child.size
|
||||||
|
total_allocated += child.allocated
|
||||||
|
# Skip child's subtree
|
||||||
|
child_path = ancestor_path / child.name
|
||||||
|
if child_path in self._index:
|
||||||
|
_, child_count = self._index[child_path]
|
||||||
|
i += child_count
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Update ancestor entry if changed
|
||||||
|
if ancestor.size != total_size or ancestor.allocated != total_allocated:
|
||||||
|
self.root[start] = FileEntry(
|
||||||
|
level=ancestor.level,
|
||||||
|
name=ancestor.name,
|
||||||
|
key=ancestor.key,
|
||||||
|
mtime=ancestor.mtime,
|
||||||
|
size=total_size,
|
||||||
|
allocated=total_allocated,
|
||||||
|
isfile=ancestor.isfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
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
|
# Initialize the tree from filesystem
|
||||||
update_root(loop)
|
update_root(loop)
|
||||||
|
path_index = PathIndex(state.root[:])
|
||||||
|
|
||||||
trefresh = time.monotonic() + 300.0
|
trefresh = time.monotonic() + 300.0
|
||||||
tspace = time.monotonic() + 5.0
|
tspace = time.monotonic() + 5.0
|
||||||
# Watch for changes (frequent wakeups needed for quiting)
|
|
||||||
while not quit.is_set():
|
# Pending changes: path -> {"ws": count, "inotify": count}
|
||||||
t = time.monotonic()
|
dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
|
||||||
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
first_event_time: float | None = None
|
||||||
if t >= trefresh:
|
last_event_time: float | None = None
|
||||||
break
|
|
||||||
# Disk usage update
|
def add_dirty(path: PurePosixPath, source: str) -> bool:
|
||||||
if t >= tspace:
|
"""Add path to dirty set. Returns True if added, False if redundant."""
|
||||||
tspace = time.monotonic() + 5.0
|
nonlocal first_event_time, last_event_time
|
||||||
update_space(loop)
|
# Check if already covered by an existing dirty path
|
||||||
# Inotify events, update the tree
|
for existing in dirty_paths:
|
||||||
dirty = False
|
if path == existing or (
|
||||||
rootmod = state.root[:]
|
len(path.parts) > len(existing.parts)
|
||||||
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
|
and path.parts[: len(existing.parts)] == existing.parts
|
||||||
assert event
|
):
|
||||||
if quit.is_set():
|
# Count the event even if skipped
|
||||||
return
|
dirty_paths[existing][source] = (
|
||||||
interesting = any(f in modified_flags for f in event[1])
|
dirty_paths[existing].get(source, 0) + 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"
|
|
||||||
)
|
)
|
||||||
# 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:
|
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:
|
try:
|
||||||
update = format_update(state.root, fresh)
|
fresh = walk(PurePosixPath())
|
||||||
|
path_index = PathIndex(fresh)
|
||||||
|
update_msg = format_update(state.root, fresh)
|
||||||
with state.lock:
|
with state.lock:
|
||||||
broadcast(update, loop)
|
broadcast(update_msg, loop)
|
||||||
state.root = fresh
|
state.root = fresh
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception("Fallback failed; sending full root")
|
||||||
"Fallback diff failed; sending full root snapshot"
|
|
||||||
)
|
|
||||||
with state.lock:
|
with state.lock:
|
||||||
broadcast(format_root(fresh), loop)
|
broadcast(format_root(fresh), loop)
|
||||||
state.root = fresh
|
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):
|
# Extract relative path
|
||||||
"""Polling version of the watcher thread."""
|
path = PurePosixPath(event[2]) / event[3]
|
||||||
while not quit.is_set():
|
try:
|
||||||
t0 = time.perf_counter()
|
rel_path = path.relative_to(rootpath)
|
||||||
update_root(loop)
|
except ValueError:
|
||||||
update_space(loop)
|
continue
|
||||||
dur = time.perf_counter() - t0
|
|
||||||
if dur > 1.0:
|
# Skip dotfiles
|
||||||
logger.debug(f"Reading the full file list took {dur:.1f}s")
|
if any(part.startswith(".") for part in rel_path.parts):
|
||||||
quit.wait(0.1 + 8 * dur)
|
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):
|
def start(app):
|
||||||
global rootpath
|
global rootpath
|
||||||
config.load_config()
|
config.load_config()
|
||||||
rootpath = config.config.path
|
rootpath = config.config.path
|
||||||
use_inotify = sys.platform == "linux"
|
|
||||||
app.ctx.watcher = threading.Thread(
|
app.ctx.watcher = threading.Thread(
|
||||||
target=watcher_inotify if use_inotify else watcher_poll,
|
target=watcher,
|
||||||
args=[app.loop],
|
args=[app.loop],
|
||||||
# Descriptive name for system monitoring
|
# Descriptive name for system monitoring
|
||||||
name=f"cista-watcher {rootpath}",
|
name=f"cista-watcher {rootpath}",
|
||||||
|
|||||||
+91
-19
@@ -1,20 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="store.error && !store.authInProgress" class="toast-message" @click="store.error = ''">
|
<div v-if="store.toast" class="toast-message" @click="store.clearToast()">
|
||||||
|
{{ store.toast }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="store.error && !store.authInProgress" class="toast-message status" @click="store.error = ''">
|
||||||
{{ store.error }}
|
{{ store.error }}
|
||||||
</div>
|
</div>
|
||||||
<SettingsModal />
|
<SettingsModal />
|
||||||
<UserManagementModal />
|
<UserManagementModal />
|
||||||
<AccessDeniedModal />
|
<AccessDeniedModal />
|
||||||
<header>
|
<header>
|
||||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||||
<HeaderSelected :path="path.pathList" />
|
|
||||||
</HeaderMain>
|
|
||||||
<BreadCrumb :path="path.pathList" primary />
|
<BreadCrumb :path="path.pathList" primary />
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<RouterView :path="path.pathList" :query="path.query" />
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
|
<SelectionToolbar :path="path.pathList" />
|
||||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||||
</footer>
|
</footer>
|
||||||
@@ -34,6 +36,7 @@ import type { SortOrder } from './utils/docsort'
|
|||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
@@ -60,6 +63,7 @@ onUnmounted(watchDisconnect)
|
|||||||
const headerMain = ref<typeof HeaderMain | null>(null)
|
const headerMain = ref<typeof HeaderMain | null>(null)
|
||||||
let vert = 0
|
let vert = 0
|
||||||
let timer: any = null
|
let timer: any = null
|
||||||
|
|
||||||
const globalShortcutHandler = (event: KeyboardEvent) => {
|
const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||||
if (store.dialog) {
|
if (store.dialog) {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
@@ -73,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
const c = fileExplorer.isCursor()
|
const c = fileExplorer.isCursor()
|
||||||
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
const input = (event.target as HTMLElement).tagName === 'INPUT'
|
||||||
const keyup = event.type === 'keyup'
|
const keyup = event.type === 'keyup'
|
||||||
|
|
||||||
|
// Always clear repeat timer on arrow keyup, even if focus moved to input
|
||||||
|
if (keyup && event.key.startsWith('Arrow') && timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
if (event.repeat) {
|
if (event.repeat) {
|
||||||
if (
|
if (
|
||||||
event.key === 'ArrowUp' ||
|
event.key === 'ArrowUp' ||
|
||||||
@@ -88,19 +99,39 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
//console.log("key pressed", event)
|
//console.log("key pressed", event)
|
||||||
/// Long if-else machina for all keys we handle here
|
/// Long if-else machina for all keys we handle here
|
||||||
let arrow = ''
|
let arrow = ''
|
||||||
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
|
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||||
|
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||||
|
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||||
|
const searchInput = inHeader && input
|
||||||
|
const searchHasText = searchInput && (event.target as HTMLInputElement).value
|
||||||
|
if (event.key.startsWith("Arrow")) {
|
||||||
|
const dir = event.key.slice(5).toLowerCase()
|
||||||
|
// In search with text: left/right move cursor, up/down navigate
|
||||||
|
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||||
|
return // Let browser handle cursor movement
|
||||||
|
}
|
||||||
|
arrow = dir
|
||||||
|
}
|
||||||
|
if (arrow) {
|
||||||
|
// Arrow key handling - fall through to bottom
|
||||||
|
}
|
||||||
// Find: process on keydown so that we can bypass the built-in search hotkey
|
// Find: process on keydown so that we can bypass the built-in search hotkey
|
||||||
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Search also on / (UNIX style)
|
// Search also on / (UNIX style) - use code to support any keyboard layout
|
||||||
else if (!input && keyup && event.key === '/') {
|
else if (!input && keyup && event.code === 'Slash') {
|
||||||
|
// Record the actual character for display (varies by keyboard layout)
|
||||||
|
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||||
|
store.prefs.searchHotkey = event.key
|
||||||
|
}
|
||||||
headerMain.value!.toggleSearchInput()
|
headerMain.value!.toggleSearchInput()
|
||||||
}
|
}
|
||||||
// Globally close search, clear errors on Escape
|
// Globally close search, clear errors on Escape
|
||||||
else if (keyup && event.key === 'Escape') {
|
else if (keyup && event.key === 'Escape') {
|
||||||
store.error = ''
|
store.error = ''
|
||||||
headerMain.value!.closeSearch(event)
|
store.clearToast()
|
||||||
|
headerMain.value!.clearSearch(event)
|
||||||
store.focusBreadcrumb()
|
store.focusBreadcrumb()
|
||||||
}
|
}
|
||||||
else if (!input && keyup && event.key === 'Backspace') {
|
else if (!input && keyup && event.key === 'Backspace') {
|
||||||
@@ -139,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
timer = null
|
timer = null
|
||||||
}
|
}
|
||||||
let f: any
|
let f: any
|
||||||
switch (arrow) {
|
// Arrow navigation - always use fileExplorer for repeatable movement
|
||||||
case 'up': f = () => fileExplorer.up(event); break
|
if (arrow && !keyup) {
|
||||||
case 'down': f = () => fileExplorer.down(event); break
|
const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
|
||||||
case 'left': f = () => fileExplorer.left(event); break
|
const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
|
||||||
case 'right': f = () => fileExplorer.right(event); break
|
|
||||||
|
if (inBreadcrumb) {
|
||||||
|
// Breadcrumb: up→header (no repeat), down→files (with repeat)
|
||||||
|
if (arrow === 'up') { focusSearch(); f = null }
|
||||||
|
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
|
||||||
|
} else if (inHeader) {
|
||||||
|
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
|
||||||
|
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
|
||||||
|
const idx = items.indexOf(document.activeElement as HTMLElement)
|
||||||
|
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
|
||||||
|
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
|
||||||
|
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
|
||||||
|
else if (arrow === 'down') { focusBreadcrumb(); f = null }
|
||||||
|
} else {
|
||||||
|
// File explorer: normal navigation with repeat
|
||||||
|
switch (arrow) {
|
||||||
|
case 'up': f = () => fileExplorer.up(event); break
|
||||||
|
case 'down': f = () => fileExplorer.down(event); break
|
||||||
|
case 'left': f = () => fileExplorer.left(event); break
|
||||||
|
case 'right': f = () => fileExplorer.right(event); break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (f && !keyup) {
|
if (f) {
|
||||||
// Initial move, then t0 delay until repeats at tr intervals
|
// Initial move, then t0 delay until repeats at tr intervals
|
||||||
const t0 = 200, tr = event.altKey ? 20 : 100
|
const t0 = 200, tr = event.altKey ? 20 : 100
|
||||||
f()
|
f()
|
||||||
@@ -153,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', globalShortcutHandler)
|
// Use capture phase to handle events before they reach target elements
|
||||||
window.addEventListener('keyup', globalShortcutHandler)
|
window.addEventListener('keydown', globalShortcutHandler, true)
|
||||||
|
window.addEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', globalShortcutHandler)
|
window.removeEventListener('keydown', globalShortcutHandler, true)
|
||||||
window.removeEventListener('keyup', globalShortcutHandler)
|
window.removeEventListener('keyup', globalShortcutHandler, true)
|
||||||
})
|
})
|
||||||
export type { Path }
|
export type { Path }
|
||||||
</script>
|
</script>
|
||||||
@@ -181,4 +234,23 @@ export type { Path }
|
|||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
--transition-time: 0.2s;
|
--transition-time: 0.2s;
|
||||||
/* The following are overridden by responsive layouts */
|
/* The following are overridden by responsive layouts */
|
||||||
--root-font-size: 1rem;
|
--root-font-size: 1rem;
|
||||||
--header-font-size: 1rem;
|
--header-font-size: clamp(0.7rem, 2.5vw, 1rem);
|
||||||
--header-height: 4rem;
|
--header-height: clamp(2.8rem, 10vw, 4rem);
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
@media screen and (min-width: 1000px) {
|
@media screen and (min-width: 1000px) {
|
||||||
:root {
|
:root {
|
||||||
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
--root-font-size: calc(8px + 8 * 100vw / 1000);
|
||||||
|
--header-font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media screen and (min-width: 2000px) {
|
@media screen and (min-width: 2000px) {
|
||||||
@@ -43,44 +44,14 @@
|
|||||||
--root-font-size: 1.5rem;
|
--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) {
|
@media screen and (orientation: landscape) and (min-width: 700px) {
|
||||||
/* Breadcrumbs and buttons side by side */
|
/* Breadcrumbs and buttons side by side */
|
||||||
:root {
|
|
||||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
|
||||||
}
|
|
||||||
header {
|
header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
header .headermain { order: 1; }
|
header .headermain { order: 1; }
|
||||||
header .breadcrumb { align-self: stretch; }
|
header .breadcrumb { align-self: stretch; }
|
||||||
header .action-button {
|
|
||||||
width: 2em;
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
:root {
|
:root {
|
||||||
@@ -241,42 +212,13 @@ header nav.headermain {
|
|||||||
.spacer { flex-grow: 1 }
|
.spacer { flex-grow: 1 }
|
||||||
.smallgap { flex-shrink: 1; width: 2em }
|
.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 {
|
.error-message {
|
||||||
padding: .5em;
|
padding: .5em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
background: var(--accent-color);
|
background: var(--accent-color);
|
||||||
color: #000;
|
color: #000;
|
||||||
}
|
}
|
||||||
|
.ghost {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,9 +14,12 @@
|
|||||||
:class="{ current: !!isCurrent(0) }"
|
:class="{ current: !!isCurrent(0) }"
|
||||||
:aria-current="isCurrent(0)"
|
:aria-current="isCurrent(0)"
|
||||||
@click.prevent="navigate(0)"
|
@click.prevent="navigate(0)"
|
||||||
title="/"
|
@mouseenter="homeTooltip?.startHover"
|
||||||
|
@mousemove="homeTooltip?.updatePosition"
|
||||||
|
@mouseleave="homeTooltip?.endHover"
|
||||||
>
|
>
|
||||||
<component :is="home" />
|
<component :is="home" />
|
||||||
|
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
<template v-for="(location, index) in longest" :key="index">
|
<template v-for="(location, index) in longest" :key="index">
|
||||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
||||||
@@ -24,8 +27,10 @@
|
|||||||
:aria-current="isCurrent(index + 1)"
|
:aria-current="isCurrent(index + 1)"
|
||||||
@click.prevent="navigate(index + 1)"
|
@click.prevent="navigate(index + 1)"
|
||||||
:ref="el => setLinkRef(index + 1, el)"
|
:ref="el => setLinkRef(index + 1, el)"
|
||||||
:title="`/${longest.slice(0, index + 1).join('/')}`"
|
@mouseenter="pathTooltips.get(index)?.startHover"
|
||||||
>{{ location }}</a>
|
@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>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -35,6 +40,7 @@ import { Home } from '@/assets/svg'
|
|||||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
const home = Home
|
const home = Home
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -43,6 +49,13 @@ const links = [] as Array<HTMLElement>
|
|||||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
||||||
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
|
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<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
primary?: boolean
|
primary?: boolean
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<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>
|
<slot></slot>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
// Global activation state - shared across all instances
|
// Global activation state - shared across all instances
|
||||||
let globalActive = false
|
let globalActive = false
|
||||||
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
// Track if we've seen real mouse movement (not touch-simulated)
|
// Track recent touch to suppress touch-triggered mouse events
|
||||||
let hasRealMouse = false
|
let lastTouchTime = 0
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
text: string
|
text: string
|
||||||
@@ -25,6 +25,9 @@ const props = defineProps<{
|
|||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const mouseX = ref(0)
|
const mouseX = ref(0)
|
||||||
const mouseY = 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 settleTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let lastMoveX = 0
|
let lastMoveX = 0
|
||||||
let lastMoveY = 0
|
let lastMoveY = 0
|
||||||
@@ -32,17 +35,51 @@ let lastMoveY = 0
|
|||||||
// Movement threshold (pixels) - cursor must settle within this radius
|
// Movement threshold (pixels) - cursor must settle within this radius
|
||||||
const SETTLE_THRESHOLD = 8
|
const SETTLE_THRESHOLD = 8
|
||||||
|
|
||||||
const tooltipStyle = computed(() => ({
|
const tooltipStyle = computed(() => {
|
||||||
left: `${mouseX.value}px`,
|
// Constrain to viewport
|
||||||
top: `${mouseY.value}px`,
|
const pad = 8
|
||||||
}))
|
let x = mouseX.value
|
||||||
|
let y = mouseY.value
|
||||||
|
|
||||||
// Check if the device likely has a real mouse (fine pointer)
|
// Only constrain if we've measured the tooltip
|
||||||
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches
|
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 = () => {
|
const showTooltip = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
globalActive = true
|
globalActive = true
|
||||||
|
// Measure tooltip after it renders
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (tooltipEl.value) {
|
||||||
|
tooltipWidth.value = tooltipEl.value.offsetWidth
|
||||||
|
tooltipHeight.value = tooltipEl.value.offsetHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleTooltip = () => {
|
const scheduleTooltip = () => {
|
||||||
@@ -56,8 +93,8 @@ const scheduleTooltip = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startHover = (e: MouseEvent) => {
|
const startHover = (e: MouseEvent) => {
|
||||||
// Ignore touch events (no fine pointer and no confirmed real mouse)
|
// Ignore touch-simulated mouse events
|
||||||
if (!hasFinePointer() && !hasRealMouse) return
|
if (isTouchEvent()) return
|
||||||
|
|
||||||
mouseX.value = e.clientX
|
mouseX.value = e.clientX
|
||||||
mouseY.value = e.clientY
|
mouseY.value = e.clientY
|
||||||
@@ -66,9 +103,8 @@ const startHover = (e: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatePosition = (e: MouseEvent) => {
|
const updatePosition = (e: MouseEvent) => {
|
||||||
// Detect real mouse via movement (touch events don't generate continuous mousemove)
|
// Ignore touch-simulated mouse events
|
||||||
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true
|
if (isTouchEvent()) return
|
||||||
if (!hasFinePointer() && !hasRealMouse) return
|
|
||||||
|
|
||||||
mouseX.value = e.clientX
|
mouseX.value = e.clientX
|
||||||
mouseY.value = e.clientY
|
mouseY.value = e.clientY
|
||||||
@@ -122,10 +158,11 @@ defineExpose({
|
|||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
border-radius: 3rem 0 3rem 0;
|
border-radius: 3rem 0 3rem 0;
|
||||||
box-shadow: 0 0 1rem var(--accent-color);
|
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||||
background-color: var(--accent-color);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
color: var(--primary-color);
|
backdrop-filter: blur(4px);
|
||||||
white-space: nowrap;
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
color: #fff;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
<template>
|
||||||
|
<div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
|
||||||
|
<div
|
||||||
|
ref="widgetRef"
|
||||||
|
class="disk-space-widget"
|
||||||
|
:class="{ expanded: isExpanded }"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 150 150" class="pie-svg" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<defs>
|
||||||
|
<filter id="pieShadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="rgba(0,0,0,0.4)" />
|
||||||
|
</filter>
|
||||||
|
<radialGradient id="storageGradient" cx="30%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#93e" />
|
||||||
|
<stop offset="100%" stop-color="#82d" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="otherGradient" cx="30%" cy="30%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#d9f" />
|
||||||
|
<stop offset="100%" stop-color="#c8e" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="highlightOverlay" cx="35%" cy="35%" r="65%">
|
||||||
|
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
|
||||||
|
<stop offset="60%" stop-color="rgba(255,255,255,0)" />
|
||||||
|
<stop offset="100%" stop-color="rgba(0,0,0,0.08)" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
|
||||||
|
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
|
||||||
|
<circle :r="holeRadius" :cx="pieCx" :cy="pieCy" fill="rgba(0,0,0,0.5)" />
|
||||||
|
<text ref="centerLabelRef" :x="pieCx" :y="pieCy" dy="0.35em" class="pie-center-label" text-anchor="middle">GB</text>
|
||||||
|
<circle :r="pieRadius" :cx="pieCx" :cy="pieCy" fill="transparent" class="pie-hitarea" @click="handleClick" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g ref="labelsRef" class="pie-labels">
|
||||||
|
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||||
|
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||||
|
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||||
|
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||||
|
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<text class="pie-label-sub" fill="#93e">
|
||||||
|
<textPath :href="'#' + storageLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">{{ storageName }}</textPath>
|
||||||
|
</text>
|
||||||
|
<text class="pie-label-sub" :fill="freeColor">
|
||||||
|
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||||
|
</text>
|
||||||
|
<text class="pie-label-sub" fill="#d9f">
|
||||||
|
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const widgetRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const labelsRef = ref<SVGGElement | null>(null)
|
||||||
|
const centerLabelRef = ref<SVGTextElement | null>(null)
|
||||||
|
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
let animationFrame: number | null = null
|
||||||
|
|
||||||
|
const BASE_SIZE = 48
|
||||||
|
const EXPANDED_SCALE = 320 / 48
|
||||||
|
const ANIM_DURATION = 200
|
||||||
|
const containerPos = ref({ top: 0, left: 0, width: 0 })
|
||||||
|
|
||||||
|
const formatGB = (bytes: number) => {
|
||||||
|
const gb = bytes / (1024 * 1024 * 1024)
|
||||||
|
return gb < 10 ? gb.toFixed(1) : `${Math.round(gb)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add dot suffix for ambiguous angles (within 15° of horizontal) on numbers that look same upside down
|
||||||
|
const fmtSize = (bytes: number, angle: number) => {
|
||||||
|
const s = formatGB(bytes)
|
||||||
|
const a = Math.abs(angle % 180)
|
||||||
|
return (Math.min(a, 180 - a) < 15 && /^[0689]+$/.test(s)) ? `${s}.` : s
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncateLabel = (name: string, maxLen = 10): string => {
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
const parts = name.split(/[\s\-_.,;:!?()\[\]{}]+/)
|
||||||
|
if (parts[0] && parts[0].length <= maxLen) return parts[0]
|
||||||
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate max label length based on angular gap to neighbor labels
|
||||||
|
const storageMaxLen = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return 10
|
||||||
|
// Sector spans in degrees
|
||||||
|
const storageSpan = (s.allocated / s.disk) * 360
|
||||||
|
const freeSpan = (s.free / s.disk) * 360
|
||||||
|
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
||||||
|
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||||
|
const gapToFree = (storageSpan + freeSpan) / 2
|
||||||
|
const gapToOther = (storageSpan + otherSpan) / 2
|
||||||
|
const minGap = Math.min(gapToFree, gapToOther)
|
||||||
|
// Allow longer names when there's sufficient gap to both neighbors
|
||||||
|
if (minGap > 70) return 18
|
||||||
|
if (minGap > 55) return 14
|
||||||
|
return 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const storageName = computed(() => {
|
||||||
|
const name = store.server.name || 'stored'
|
||||||
|
const maxLen = storageMaxLen.value
|
||||||
|
// Use full name if it fits within the available space
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
return truncateLabel(name, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const TAU = 2 * Math.PI
|
||||||
|
|
||||||
|
const pieCx = 75
|
||||||
|
const pieCy = 75
|
||||||
|
const pieRadius = 55
|
||||||
|
const holeRadius = pieRadius * 0.38
|
||||||
|
const ringWidth = pieRadius - holeRadius
|
||||||
|
const midRadius = (pieRadius + holeRadius) / 2
|
||||||
|
const CIRC = TAU * midRadius
|
||||||
|
|
||||||
|
const pieStorageDash = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
|
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieFreeDash = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
|
return `${(s.free / s.disk) * CIRC} ${CIRC}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pieFreeOffsetVal = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return 0
|
||||||
|
return -(s.allocated / s.disk) * CIRC
|
||||||
|
})
|
||||||
|
|
||||||
|
const freeColor = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return '#6c6'
|
||||||
|
const freePct = s.free / s.disk
|
||||||
|
if (freePct > 0.25) return '#5b5'
|
||||||
|
if (freePct > 0.10) return '#ff0'
|
||||||
|
return '#f00'
|
||||||
|
})
|
||||||
|
|
||||||
|
const PIE_RADIUS = 55
|
||||||
|
const LABEL_RADIUS = 62
|
||||||
|
|
||||||
|
const getPoint = (angle: number, radius: number) => {
|
||||||
|
const rad = TAU * (angle - 90) / 360
|
||||||
|
return { x: pieCx + radius * Math.cos(rad), y: pieCy + radius * Math.sin(rad) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectorInfo = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return {
|
||||||
|
storage: { angle: 45, pct: 0.25 },
|
||||||
|
free: { angle: 180, pct: 0.5 },
|
||||||
|
other: { angle: 270, pct: 0.25 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const storagePct = s.allocated / s.disk
|
||||||
|
const freePct = s.free / s.disk
|
||||||
|
const otherPct = (s.used - s.allocated) / s.disk
|
||||||
|
|
||||||
|
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||||
|
const freeStart = storagePct * 360
|
||||||
|
const freeAngle = freeStart + freePct * 180
|
||||||
|
const otherStart = (storagePct + freePct) * 360
|
||||||
|
const otherAngle = otherStart + otherPct * 180
|
||||||
|
|
||||||
|
return {
|
||||||
|
storage: { angle: storageAngle, pct: storagePct },
|
||||||
|
free: { angle: freeAngle, pct: freePct },
|
||||||
|
other: { angle: otherAngle, pct: otherPct }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rawAngles = computed(() => ({
|
||||||
|
storage: sectorInfo.value.storage.angle,
|
||||||
|
free: sectorInfo.value.free.angle,
|
||||||
|
other: sectorInfo.value.other.angle
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getSizeRotation = (angle: number) => angle < 180 ? angle - 90 : angle + 90
|
||||||
|
const getSizeAnchor = (angle: number) => angle < 180 ? 'end' : 'start'
|
||||||
|
|
||||||
|
const INNER_LABEL_RADIUS = PIE_RADIUS * 0.95
|
||||||
|
const storageInnerPos = computed(() => getPoint(sectorInfo.value.storage.angle, INNER_LABEL_RADIUS))
|
||||||
|
const freeInnerPos = computed(() => getPoint(sectorInfo.value.free.angle, INNER_LABEL_RADIUS))
|
||||||
|
const otherInnerPos = computed(() => getPoint(sectorInfo.value.other.angle, INNER_LABEL_RADIUS))
|
||||||
|
|
||||||
|
// Collision avoidance for curved name labels
|
||||||
|
const labelLengths = computed(() => ({
|
||||||
|
storage: storageName.value.length,
|
||||||
|
free: 4,
|
||||||
|
other: 5
|
||||||
|
}))
|
||||||
|
|
||||||
|
const getGapForPair = (len1: number, len2: number) => {
|
||||||
|
return 35 + Math.max(0, len1 + len2 - 8) * 2.5
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustedLabelAngles = computed(() => {
|
||||||
|
const angles = rawAngles.value
|
||||||
|
const lens = labelLengths.value
|
||||||
|
const labels = [
|
||||||
|
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||||
|
{ id: 'free', angle: angles.free, len: lens.free },
|
||||||
|
{ id: 'other', angle: angles.other, len: lens.other }
|
||||||
|
]
|
||||||
|
labels.sort((a, b) => a.angle - b.angle)
|
||||||
|
|
||||||
|
for (let iterations = 0; iterations < 15; iterations++) {
|
||||||
|
let moved = false
|
||||||
|
for (let i = 0; i < labels.length; i++) {
|
||||||
|
const current = labels[i]!
|
||||||
|
const next = labels[(i + 1) % labels.length]!
|
||||||
|
let angleDiff = next.angle - current.angle
|
||||||
|
if (angleDiff < 0) angleDiff += 360
|
||||||
|
const requiredGap = getGapForPair(current.len, next.len)
|
||||||
|
if (angleDiff < requiredGap) {
|
||||||
|
const push = (requiredGap - angleDiff) / 2
|
||||||
|
current.angle = (current.angle - push + 360) % 360
|
||||||
|
next.angle = (next.angle + push) % 360
|
||||||
|
moved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!moved) break
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, number> = {}
|
||||||
|
for (const l of labels) result[l.id] = l.angle
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
||||||
|
const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||||
|
const radius = LABEL_RADIUS
|
||||||
|
// Scale arc span based on label length: ~6° per character, minimum 45°
|
||||||
|
const arcSpan = Math.max(45, labelLen * 6)
|
||||||
|
const isBottom = centerAngle > 90 && centerAngle <= 270
|
||||||
|
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
|
||||||
|
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
|
||||||
|
const start = getPoint(startAngle, radius)
|
||||||
|
const end = getPoint(endAngle, radius)
|
||||||
|
const sweep = isBottom ? 0 : 1
|
||||||
|
return {
|
||||||
|
id: `label-path-${id}`,
|
||||||
|
d: `M ${start.x} ${start.y} A ${radius} ${radius} 0 0 ${sweep} ${end.x} ${end.y}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
|
||||||
|
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
|
||||||
|
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
|
||||||
|
|
||||||
|
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||||
|
|
||||||
|
const applyAnimState = (t: number, opacity: number) => {
|
||||||
|
const widget = widgetRef.value
|
||||||
|
const labels = labelsRef.value
|
||||||
|
const centerLabel = centerLabelRef.value
|
||||||
|
if (!widget) return
|
||||||
|
|
||||||
|
const scale = 1 + (EXPANDED_SCALE - 1) * t
|
||||||
|
// Move top-right corner of widget to top-right corner of viewport
|
||||||
|
const targetX = window.innerWidth - containerPos.value.left - containerPos.value.width
|
||||||
|
const targetY = -containerPos.value.top
|
||||||
|
|
||||||
|
widget.style.transform = `translate(${targetX * t}px, ${targetY * t}px) scale(${scale})`
|
||||||
|
if (labels) labels.style.opacity = String(opacity)
|
||||||
|
if (centerLabel) centerLabel.style.opacity = String(opacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
const animate = (duration: number, expanding: boolean, onComplete?: () => void) => {
|
||||||
|
const startTime = performance.now()
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const elapsed = now - startTime
|
||||||
|
const progress = Math.min(elapsed / duration, 1)
|
||||||
|
const eased = 1 - Math.pow(1 - progress, 3) // easeOutCubic
|
||||||
|
const t = expanding ? eased : 1 - eased
|
||||||
|
applyAnimState(t, t) // opacity follows position
|
||||||
|
if (progress < 1) {
|
||||||
|
animationFrame = requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
animationFrame = null
|
||||||
|
onComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrame = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expand = () => {
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
if (containerRef.value) {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect()
|
||||||
|
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||||
|
}
|
||||||
|
isExpanded.value = true
|
||||||
|
animate(ANIM_DURATION, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const collapse = () => {
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
if (containerRef.value) {
|
||||||
|
const rect = containerRef.value.getBoundingClientRect()
|
||||||
|
containerPos.value = { top: rect.top, left: rect.left, width: rect.width }
|
||||||
|
}
|
||||||
|
animate(ANIM_DURATION, false, () => {
|
||||||
|
isExpanded.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isExpanded.value) collapse()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
// Initialize labels as hidden
|
||||||
|
if (labelsRef.value) labelsRef.value.style.opacity = '0'
|
||||||
|
if (centerLabelRef.value) centerLabelRef.value.style.opacity = '0'
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
if (animationFrame) cancelAnimationFrame(animationFrame)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.disk-space-container {
|
||||||
|
position: relative;
|
||||||
|
width: 3em;
|
||||||
|
height: 3em;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-container:focus .disk-space-widget:not(.expanded) {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
will-change: transform;
|
||||||
|
filter: brightness(0.85);
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
transform-origin: top right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget:hover,
|
||||||
|
.disk-space-widget:focus {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget.expanded {
|
||||||
|
pointer-events: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disk-space-widget.expanded:hover,
|
||||||
|
.disk-space-widget.expanded:focus {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-hitarea {
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label-inner {
|
||||||
|
fill: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 0.5px;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-center-label {
|
||||||
|
fill: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pie-label-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant: small-caps;
|
||||||
|
text-transform: lowercase;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 1px;
|
||||||
|
paint-order: stroke fill;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
<SvgButton name="download" tooltip="Download" @click="download" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { apiFetch } from '@/repositories/Client'
|
import { apiFetch } from '@/repositories/Client'
|
||||||
import type { SelectedItems } from '@/repositories/Document'
|
import type { SelectedItems } from '@/repositories/Document'
|
||||||
import { reactive } from 'vue';
|
import { zipName } from '@/utils/fileutil'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
@@ -106,7 +106,6 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
++store.dprogress.fileidx
|
++store.dprogress.fileidx
|
||||||
const reader = res.body.getReader()
|
const reader = res.body.getReader()
|
||||||
await writable.truncate(0)
|
await writable.truncate(0)
|
||||||
store.error = "Direct download."
|
|
||||||
store.dprogress.tlast = Date.now()
|
store.dprogress.tlast = Date.now()
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read()
|
const { value, done } = await reader.read()
|
||||||
@@ -131,43 +130,43 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
|||||||
statReset()
|
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
|
const sel = store.selectedFiles
|
||||||
console.log('Download', sel)
|
|
||||||
if (sel.keys.length === 0) {
|
if (sel.keys.length === 0) {
|
||||||
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
|
store.showToast('No existing files selected')
|
||||||
store.error = 'No existing files selected'
|
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
return
|
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)
|
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
|
||||||
if (files.length === 1) {
|
if (files.length === 1) {
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
store.error = "Single file via browser downloads"
|
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
|
||||||
return linkdl(`/files/${files[0]![1]}`)
|
return linkdl(`/files/${files[0]![1]}`)
|
||||||
}
|
}
|
||||||
// Use FileSystem API if multiple files and the browser supports it
|
// Alt+click: download to folder (hidden feature)
|
||||||
if ('showDirectoryPicker' in window) {
|
if (e.altKey && 'showDirectoryPicker' in window) {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const handle = await window.showDirectoryPicker({
|
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
|
||||||
startIn: 'downloads',
|
|
||||||
mode: 'readwrite'
|
|
||||||
})
|
|
||||||
await filesystemdl(sel, handle)
|
await filesystemdl(sel, handle)
|
||||||
store.selected.clear()
|
store.selected.clear()
|
||||||
return
|
|
||||||
} catch (e) {
|
} 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
|
// Default: ZIP download
|
||||||
console.log("Falling back to zip download")
|
zipdl(sel)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="!store.connected">No Connection</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="store.query">No matches!</p>
|
||||||
<p v-else-if="!exists(props.path)">Folder not found</p>
|
<p v-else-if="!exists(props.path)">Folder not found</p>
|
||||||
<p v-else>Empty folder</p>
|
<p v-else>Empty folder</p>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<tr
|
<tr
|
||||||
:id="`file-${doc.key}`"
|
: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"
|
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||||
@contextmenu.prevent="contextMenu($event, doc)"
|
@contextmenu.prevent="contextMenu($event, doc)"
|
||||||
>
|
>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
@@ -124,6 +124,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -134,6 +135,17 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && editing.value === null
|
return store.cursor && editing.value === null
|
||||||
},
|
},
|
||||||
|
focusFirst() {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length > 0) {
|
||||||
|
store.cursor = docs[0]!.key
|
||||||
|
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||||
|
nextTick(() => {
|
||||||
|
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -149,7 +161,12 @@ defineExpose({
|
|||||||
},
|
},
|
||||||
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
|
||||||
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
|
||||||
left(ev: KeyboardEvent) { router.back() },
|
left(ev: KeyboardEvent) {
|
||||||
|
// Only go back if we're in a subfolder (not at root)
|
||||||
|
if (props.path.length > 0) {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
},
|
||||||
right(ev: KeyboardEvent) {
|
right(ev: KeyboardEvent) {
|
||||||
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
|
||||||
if (a) a.click()
|
if (a) a.click()
|
||||||
@@ -189,9 +206,17 @@ defineExpose({
|
|||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
if (moveto === N) focusBreadcrumb()
|
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||||
|
if (moveto === N) {
|
||||||
|
if (d < 0) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const focusHeader = () => {
|
||||||
|
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||||
|
if (el) el.focus()
|
||||||
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
@@ -209,7 +234,7 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (!props.documents.length && store.cursor) {
|
if (!props.documents.length && store.cursor && !store.query) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
@@ -249,9 +274,10 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// We should get an update from watch but this is quicker
|
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
|
store.addGhost(doc)
|
||||||
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
@@ -290,13 +316,96 @@ const allSelected = computed({
|
|||||||
|
|
||||||
const loc = computed(() => props.path.join('/'))
|
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) => {
|
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
ContextMenu.showContextMenu({
|
const items = [
|
||||||
x: ev.x, y: ev.y, items: [
|
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||||
{ label: 'Rename', onClick: () => { editing.value = 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>
|
</script>
|
||||||
|
|
||||||
@@ -392,9 +501,6 @@ tbody tr.cursor {
|
|||||||
.sortcolumn:hover {
|
.sortcolumn:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.sortcolumn:hover::after {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.sortcolumn {
|
.sortcolumn {
|
||||||
padding-right: 1.5rem;
|
padding-right: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<td class="size right" :class=sizeClass>{{ doc.sizedisp }}</td>
|
<td
|
||||||
|
class="size right"
|
||||||
|
:class="sizeClass"
|
||||||
|
@mouseenter="doc.sparseIndicator && tooltip?.startHover($event)"
|
||||||
|
@mousemove="doc.sparseIndicator && tooltip?.updatePosition($event)"
|
||||||
|
@mouseleave="doc.sparseIndicator && tooltip?.endHover()"
|
||||||
|
>
|
||||||
|
<SparseIndicator :doc="doc" class="before-size" />{{ doc.sizedisp }}
|
||||||
|
<CursorTooltip v-if="doc.sparseIndicator" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||||
return +unit ? "bytes" : unit
|
return +unit ? "bytes" : unit
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const tooltipText = computed(() => {
|
||||||
doc: Doc
|
const { size, allocated } = props.doc
|
||||||
}>()
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.before-size {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
.size.empty { color: #555 }
|
.size.empty { color: #555 }
|
||||||
.size.bytes { color: #77a }
|
.size.bytes { color: #77a }
|
||||||
.size.kB { color: #474 }
|
.size.kB { color: #474 }
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { connect, controlUrl } from '@/repositories/WS'
|
import { connect, controlUrl } from '@/repositories/WS'
|
||||||
@@ -67,6 +67,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
@@ -81,6 +82,17 @@ defineExpose({
|
|||||||
isCursor() {
|
isCursor() {
|
||||||
return store.cursor && editing.value === null
|
return store.cursor && editing.value === null
|
||||||
},
|
},
|
||||||
|
focusFirst() {
|
||||||
|
const docs = props.documents
|
||||||
|
if (docs.length > 0) {
|
||||||
|
store.cursor = docs[0]!.key
|
||||||
|
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||||
|
nextTick(() => {
|
||||||
|
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
|
||||||
|
if (a) a.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
cursorRename() {
|
cursorRename() {
|
||||||
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
|
||||||
},
|
},
|
||||||
@@ -143,9 +155,17 @@ defineExpose({
|
|||||||
scrolltimer = null
|
scrolltimer = null
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
if (moveto === N) focusBreadcrumb()
|
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||||
|
if (moveto === N) {
|
||||||
|
if (d < 0) focusBreadcrumb()
|
||||||
|
else focusHeader()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
const focusHeader = () => {
|
||||||
|
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
|
||||||
|
if (el) el.focus()
|
||||||
|
}
|
||||||
const focusBreadcrumb = () => {
|
const focusBreadcrumb = () => {
|
||||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
@@ -161,7 +181,7 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (!props.documents.length && store.cursor) {
|
if (!props.documents.length && store.cursor && !store.query) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
@@ -203,9 +223,10 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// We should get an update from watch but this is quicker
|
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
|
store.addGhost(doc)
|
||||||
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
@@ -213,18 +234,6 @@ const showFolderBreadcrumb = (i: number) => {
|
|||||||
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
|
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({
|
const allSelected = computed({
|
||||||
get: () => {
|
get: () => {
|
||||||
return (
|
return (
|
||||||
@@ -246,13 +255,96 @@ const allSelected = computed({
|
|||||||
|
|
||||||
const loc = computed(() => props.path.join('/'))
|
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) => {
|
const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||||
store.cursor = doc.key
|
store.cursor = doc.key
|
||||||
ContextMenu.showContextMenu({
|
const items = [
|
||||||
x: ev.x, y: ev.y, items: [
|
{ label: '📥 Download', onClick: () => downloadFile(doc) },
|
||||||
{ label: 'Rename', onClick: () => { editing.value = 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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
|
<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
|
@contextmenu.stop
|
||||||
@focus.stop="store.cursor = doc.key"
|
@focus.stop="store.cursor = doc.key"
|
||||||
@click=onclick
|
@click=onclick
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span>{{ doc.name }}</span>
|
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
<div class="tooltip-name">{{ doc.name }}</div>
|
<div class="tooltip-name">{{ doc.name }}</div>
|
||||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
</CursorTooltip>
|
</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,8 +35,10 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
@@ -52,6 +55,11 @@ const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|||||||
|
|
||||||
const tooltipText = computed(() => props.doc.key)
|
const tooltipText = computed(() => props.doc.key)
|
||||||
|
|
||||||
|
const sparseText = computed(() => {
|
||||||
|
const { size, allocated } = props.doc
|
||||||
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
@@ -66,6 +74,13 @@ const onclick = (ev: Event) => {
|
|||||||
.tooltip-details {
|
.tooltip-details {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.tooltip-sparse {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.after-name {
|
||||||
|
margin-left: 0.3em;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
max-height: 15em;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -3,23 +3,24 @@
|
|||||||
<UploadButton :path="props.path" />
|
<UploadButton :path="props.path" />
|
||||||
<SvgButton
|
<SvgButton
|
||||||
name="create-folder"
|
name="create-folder"
|
||||||
data-tooltip="New folder"
|
tooltip="New folder"
|
||||||
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
|
@click="() => { store.fileExplorer!.newFolder() }"
|
||||||
/>
|
/>
|
||||||
<slot></slot>
|
<div class="smallgap"></div>
|
||||||
<div class="spacer smallgap"></div>
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||||
<template v-if="showSearchInput">
|
<div class="search-group">
|
||||||
|
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||||
<input
|
<input
|
||||||
ref="search"
|
ref="search"
|
||||||
type="search"
|
type="search"
|
||||||
:value="query"
|
:value="query"
|
||||||
@input="updateSearch"
|
@input="updateSearch"
|
||||||
placeholder="Find files"
|
@keydown.escape="clearSearch"
|
||||||
class="margin-input"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
</div>
|
||||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
|
<div class="spacer smallgap"></div>
|
||||||
|
<DiskSpace v-if="store.space.disk" />
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,50 +28,66 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
import { useSsoAuthStore } from '@/stores/ssoAuth'
|
||||||
import { ref, nextTick, watchEffect } from 'vue'
|
import { ref } from 'vue'
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
import { showAuthIframe } from 'paskia'
|
import { showAuthIframe } from 'paskia'
|
||||||
import { resumeWatching } from '@/repositories/WS'
|
import { resumeWatching } from '@/repositories/WS'
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
|
import DiskSpace from './DiskSpace.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const ssoStore = useSsoAuthStore()
|
const ssoStore = useSsoAuthStore()
|
||||||
const showSearchInput = ref<boolean>(false)
|
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const searchButton = ref<HTMLButtonElement | null>()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const closeSearch = (ev: Event) => {
|
const clearSearch = (ev: Event) => {
|
||||||
if (!showSearchInput.value) return // Already closing
|
const input = search.value
|
||||||
showSearchInput.value = false
|
if (input) {
|
||||||
|
input.value = ''
|
||||||
|
updateSearch(ev)
|
||||||
|
}
|
||||||
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
||||||
breadcrumb.focus()
|
breadcrumb.focus()
|
||||||
updateSearch(ev)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const focusSearch = () => {
|
||||||
|
search.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pending route update
|
||||||
|
let pendingRouteUpdate: number | null = null
|
||||||
|
|
||||||
const updateSearch = (ev: Event) => {
|
const updateSearch = (ev: Event) => {
|
||||||
const q = (ev.target as HTMLInputElement).value
|
const q = (ev.target as HTMLInputElement).value
|
||||||
let p = props.path.join('/')
|
const loc = props.path.join('/')
|
||||||
p = p ? `/${p}` : ''
|
|
||||||
const url = q ? `${p}//${q}` : (p || '/')
|
// Start search immediately via store (worker handles it async)
|
||||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
store.search(q, loc)
|
||||||
if (!props.query && q) router.push(u)
|
|
||||||
else router.replace(u)
|
// Cancel any pending route update
|
||||||
}
|
if (pendingRouteUpdate !== null) {
|
||||||
const toggleSearchInput = (ev: Event) => {
|
cancelAnimationFrame(pendingRouteUpdate)
|
||||||
showSearchInput.value = !showSearchInput.value
|
}
|
||||||
if (!showSearchInput.value) return closeSearch(ev)
|
|
||||||
nextTick(() => {
|
// Schedule route update - will be cancelled if user types again
|
||||||
const input = search.value
|
pendingRouteUpdate = requestAnimationFrame(() => {
|
||||||
if (input) input.focus()
|
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) => {
|
const settingsMenu = (e: Event) => {
|
||||||
// show the context menu
|
// show the context menu
|
||||||
const items = []
|
const items = []
|
||||||
@@ -94,11 +111,11 @@ const settingsMenu = (e: Event) => {
|
|||||||
|
|
||||||
if (store.user.isLoggedIn) {
|
if (store.user.isLoggedIn) {
|
||||||
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
items.push({ label: '🚪 Logout', onClick: () => store.logout() })
|
||||||
} else if (!ssoStore.isExternalAuth) {
|
} else if (store.server.public) {
|
||||||
// Show login in paskia iframe overlay
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
items.push({ label: '🔐 Login', onClick: async () => {
|
items.push({ label: '🔐 Login', onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await showAuthIframe('/auth/restricted')
|
await showAuthIframe('/auth/restricted/#theme=light')
|
||||||
resumeWatching()
|
resumeWatching()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Login cancelled')
|
console.log('Login cancelled')
|
||||||
@@ -113,7 +130,7 @@ const settingsMenu = (e: Event) => {
|
|||||||
}
|
}
|
||||||
defineExpose({
|
defineExpose({
|
||||||
toggleSearchInput,
|
toggleSearchInput,
|
||||||
closeSearch,
|
clearSearch,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,13 +141,76 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-height: 3em;
|
||||||
}
|
}
|
||||||
input[type='search'] {
|
.search-group {
|
||||||
background: var(--input-background);
|
position: relative;
|
||||||
color: var(--input-color);
|
display: flex;
|
||||||
border: 0;
|
align-items: center;
|
||||||
border-radius: 0.1em;
|
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: 2.5em;
|
||||||
|
max-width: 20em;
|
||||||
|
}
|
||||||
|
.search-group:hover,
|
||||||
|
.search-group:focus-within {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
.search-group:focus-within {
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-color, #f80);
|
||||||
|
}
|
||||||
|
.search-group:hover :deep(button.action-button),
|
||||||
|
.search-group:focus-within :deep(button.action-button) {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.search-group:hover :deep(button.action-button svg),
|
||||||
|
.search-group:focus-within :deep(button.action-button svg) {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
.search-group:focus-within .search-hint {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.search-group :deep(.action-button) {
|
||||||
|
width: 2.2em;
|
||||||
|
height: 2.2em;
|
||||||
|
min-width: 1.5em;
|
||||||
|
min-height: 1.5em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.search-group input[type='search'] {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--header-color);
|
||||||
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
max-width: 15ch;
|
padding: 0.2em 0.5em 0.2em 0;
|
||||||
|
font-size: inherit;
|
||||||
|
flex: 1 1 3em;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.search-hint {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<template>
|
|
||||||
<template v-if="store.selected.size">
|
|
||||||
<div class="smallgap"></div>
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
const props = defineProps({
|
|
||||||
path: Array<string>
|
|
||||||
})
|
|
||||||
|
|
||||||
const dst = computed(() => props.path!.join('/'))
|
|
||||||
const op = (op: string, dst?: string) => {
|
|
||||||
const sel = store.selectedFiles
|
|
||||||
const msg = {
|
|
||||||
op,
|
|
||||||
sel: sel.keys.map(key => {
|
|
||||||
const doc = sel.docs[key]!
|
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
if (dst !== undefined) msg.dst = dst
|
|
||||||
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
|
|
||||||
return
|
|
||||||
} else if (res.status === 'ack') {
|
|
||||||
console.log('Control ack OK', res)
|
|
||||||
control.close()
|
|
||||||
store.selected.clear()
|
|
||||||
return
|
|
||||||
} else console.log('Unknown control response', msg, res)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(JSON.stringify(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.select-text {
|
|
||||||
color: var(--accent-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<div class="selection-bar" v-if="store.selected.size">
|
||||||
|
<div class="select-info">
|
||||||
|
<template v-if="selectionDisplay.folders.length <= 5">
|
||||||
|
<span class="select-folders">
|
||||||
|
<template v-for="(folder, i) in selectionDisplay.folders" :key="folder.path">
|
||||||
|
<span v-if="i > 0" class="folder-sep">, </span>
|
||||||
|
<a :href="'/#/' + folder.path" class="folder-link" @click.prevent="navigateTo(folder.path)">{{ folder.name }}</a>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="select-count">{{ store.selected.size }} items from {{ selectionDisplay.numFolders }} folders</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||||
|
<DownloadButton />
|
||||||
|
<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"
|
||||||
|
>✖ selection</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {connect, controlUrl} from '@/repositories/WS'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const props = defineProps({
|
||||||
|
path: Array<string>
|
||||||
|
})
|
||||||
|
|
||||||
|
const dst = computed(() => props.path!.join('/'))
|
||||||
|
|
||||||
|
const navigateTo = (path: string) => {
|
||||||
|
router.push('/' + path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate long names to reasonable length
|
||||||
|
const truncateName = (name: string, maxLen = 20): string => {
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderInfo {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionDisplay {
|
||||||
|
folders: FolderInfo[]
|
||||||
|
numFolders: number
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
const totalSize = sel.keys.reduce((sum, key) => {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
return sum + (doc ? doc.size : 0)
|
||||||
|
}, 0)
|
||||||
|
const sizeStr = formatSize(totalSize)
|
||||||
|
|
||||||
|
// Group by folder location, storing file names
|
||||||
|
const folderGroups = new Map<string, string[]>()
|
||||||
|
for (const key of sel.keys) {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
if (!doc) continue
|
||||||
|
const loc = doc.loc || ''
|
||||||
|
if (!folderGroups.has(loc)) folderGroups.set(loc, [])
|
||||||
|
folderGroups.get(loc)!.push(doc.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numFolders = folderGroups.size
|
||||||
|
|
||||||
|
const folders = Array.from(folderGroups.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([loc, names]) => {
|
||||||
|
const count = names.length
|
||||||
|
// For single file, display truncated filename; for multiple, display folder name with count
|
||||||
|
let displayName: string
|
||||||
|
if (count === 1) {
|
||||||
|
displayName = truncateName(names[0]!)
|
||||||
|
} else {
|
||||||
|
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
|
||||||
|
displayName = `${truncateName(folderName)} (${count})`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: displayName,
|
||||||
|
path: loc,
|
||||||
|
count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
numFolders,
|
||||||
|
size: sizeStr
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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: 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)
|
||||||
|
control.close()
|
||||||
|
store.selected.clear()
|
||||||
|
return
|
||||||
|
} else console.log('Unknown control response', msg, res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
control.onopen = () => {
|
||||||
|
control.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.selection-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--header-color);
|
||||||
|
font-size: var(--header-font-size);
|
||||||
|
gap: 0.3em;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.select-info {
|
||||||
|
color: var(--accent-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.select-count {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.select-folders {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.folder-link,
|
||||||
|
.folder-link:link,
|
||||||
|
.folder-link:visited,
|
||||||
|
.folder-link:active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.folder-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.folder-sep {
|
||||||
|
color: var(--header-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.select-size {
|
||||||
|
color: var(--header-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -70,7 +70,7 @@ const submit = async (ev: Event) => {
|
|||||||
try {
|
try {
|
||||||
if (form.passwordChange) {
|
if (form.passwordChange) {
|
||||||
if (!form.password) {
|
if (!form.password) {
|
||||||
store.error = '⚠️ Current password is required'
|
store.showToast('⚠️ Current password is required')
|
||||||
password.value!.focus()
|
password.value!.focus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ const submit = async (ev: Event) => {
|
|||||||
close()
|
close()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const httpError = error as ISimpleError
|
const httpError = error as ISimpleError
|
||||||
store.error = httpError.message || '🛑 Unknown error'
|
store.showToast(httpError.message || '🛑 Unknown error')
|
||||||
} finally {
|
} finally {
|
||||||
confirmLoading.value = false
|
confirmLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="doc.sparseIndicator" class="sparse-indicator">{{ doc.sparseIndicator }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sparse-indicator {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="action-button">
|
<button
|
||||||
|
class="action-button"
|
||||||
|
:tabindex="tabindex"
|
||||||
|
@mouseenter="tooltip?.startHover"
|
||||||
|
@mousemove="tooltip?.updatePosition"
|
||||||
|
@mouseleave="tooltip?.endHover"
|
||||||
|
>
|
||||||
<component :is="icons[name]" />
|
<component :is="icons[name]" />
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { icons, type IconName } from '@/assets/svg'
|
import { icons, type IconName } from '@/assets/svg'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
name: IconName
|
name: IconName
|
||||||
|
tooltip?: string
|
||||||
|
tabindex?: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
const tooltipText = props.tooltip ?? ''
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -20,18 +34,29 @@ defineProps<{
|
|||||||
color: #ccc;
|
color: #ccc;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
padding: 0.2em;
|
margin: 0 0.2em;
|
||||||
width: 3em;
|
padding: 0;
|
||||||
height: 3em;
|
width: 2.7em;
|
||||||
|
height: 2.7em;
|
||||||
|
min-width: 1.9em;
|
||||||
|
min-height: 1.9em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.action-button:hover,
|
.action-button:hover,
|
||||||
.action-button:focus {
|
.action-button:focus {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
svg {
|
.action-button svg {
|
||||||
fill: #ccc;
|
fill: #ccc;
|
||||||
transform: fill 0.2s ease;
|
transition: fill 0.2s ease;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
.action-button:hover svg,
|
.action-button:hover svg,
|
||||||
.action-button:focus svg {
|
.action-button:focus svg {
|
||||||
|
|||||||
@@ -53,15 +53,17 @@ const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value <
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.transferprogress {
|
.transferprogress {
|
||||||
--bar: var(--accent-color);
|
--bar: var(--accent-color);
|
||||||
--nobar: var(--header-background);
|
--nobar: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.statustext {
|
.statustext {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
margin: 0 .5em;
|
margin: 0 .5em;
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,20 @@
|
|||||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||||
</template>
|
</template>
|
||||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
|
||||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { connect, uploadUrl } from '@/repositories/WS';
|
import { connect, uploadUrl } from '@/repositories/WS';
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const fileInput = ref()
|
const fileInput = ref()
|
||||||
const folderInput = ref()
|
const folderInput = ref()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -72,24 +76,53 @@ function uploadHandler(event: Event) {
|
|||||||
const uploadFiles = (infiles: File[]) => {
|
const uploadFiles = (infiles: File[]) => {
|
||||||
const loc = props.path!.join('/')
|
const loc = props.path!.join('/')
|
||||||
let files = []
|
let files = []
|
||||||
|
let folderName = ''
|
||||||
for (const file of infiles) {
|
for (const file of infiles) {
|
||||||
|
const relPath = file.webkitRelativePath || file.name
|
||||||
|
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||||
files.push({
|
files.push({
|
||||||
file,
|
file,
|
||||||
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
|
cloudName: loc + '/' + relPath,
|
||||||
cloudPos: 0,
|
cloudPos: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
uploadCloudFiles(files)
|
uploadCloudFiles(files)
|
||||||
|
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
|
||||||
}
|
}
|
||||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||||
if (dotfiles.length) {
|
if (dotfiles.length) {
|
||||||
store.error = "Won't upload dotfiles"
|
store.showToast("Won't upload dotfiles")
|
||||||
console.log("Dotfiles omitted", dotfiles)
|
|
||||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||||
}
|
}
|
||||||
if (!files.length) return
|
if (!files.length) return
|
||||||
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
|
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, allocated: 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, allocated: 0, mtime: now, dir: false }))
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
statsAdd(files)
|
statsAdd(files)
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<h3>Server Settings</h3>
|
<h3>Server Settings</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="serverName">Server name</label>
|
||||||
|
<div class="input-with-hint">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="serverName"
|
||||||
|
v-model="serverSettings.name"
|
||||||
|
@input="debouncedUpdateServerName"
|
||||||
|
:placeholder="store.server.name"
|
||||||
|
/>
|
||||||
|
<small>Leave empty to use the share folder name</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="publicAccess">
|
<label for="publicAccess">
|
||||||
<input
|
<input
|
||||||
@@ -62,7 +75,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
@@ -78,9 +91,12 @@ const users = ref<User[]>([])
|
|||||||
const success = ref('')
|
const success = ref('')
|
||||||
const copyButtonText = ref('📋')
|
const copyButtonText = ref('📋')
|
||||||
const serverSettings = reactive({
|
const serverSettings = reactive({
|
||||||
public: false
|
public: false,
|
||||||
|
name: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
success.value = ''
|
success.value = ''
|
||||||
@@ -93,7 +109,7 @@ const loadUsers = async () => {
|
|||||||
users.value = data.users
|
users.value = data.users
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to load users'
|
store.showToast(httpError.message || 'Failed to load users')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -111,7 +127,7 @@ const addUser = async () => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to add user'
|
store.showToast(httpError.message || 'Failed to add user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +138,7 @@ const toggleAdmin = async (user: User, event: Event) => {
|
|||||||
user.privileged = target.checked
|
user.privileged = target.checked
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to update user'
|
store.showToast(httpError.message || 'Failed to update user')
|
||||||
target.checked = user.privileged // revert
|
target.checked = user.privileged // revert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +158,7 @@ const renameUser = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to rename user'
|
store.showToast(httpError.message || 'Failed to rename user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +172,7 @@ const resetPassword = async (user: User) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to reset password'
|
store.showToast(httpError.message || 'Failed to reset password')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +183,7 @@ const deleteUserAction = async (username: string) => {
|
|||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to delete user'
|
store.showToast(httpError.message || 'Failed to delete user')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,19 +218,52 @@ const updateServerSettings = async () => {
|
|||||||
success.value = 'Server settings updated'
|
success.value = 'Server settings updated'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const httpError = e as ISimpleError
|
const httpError = e as ISimpleError
|
||||||
store.error = httpError.message || 'Failed to update settings'
|
store.showToast(httpError.message || 'Failed to update settings')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServerNameSetting = async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateServerName(serverSettings.name)
|
||||||
|
// Update store with the effective name returned by the server
|
||||||
|
store.server.name = result.name
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to update server name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedUpdateServerName = () => {
|
||||||
|
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
|
||||||
|
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load server config from admin API
|
||||||
|
const loadServerConfig = async () => {
|
||||||
|
try {
|
||||||
|
const config = await getServerConfig()
|
||||||
|
serverSettings.name = config.name
|
||||||
|
serverSettings.public = config.public
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to store values if API fails
|
||||||
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
serverSettings.public = store.server.public || false
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load users when dialog opens (only in built-in auth mode)
|
// Load users and config when dialog opens
|
||||||
watch(() => store.dialog, (newVal) => {
|
watch(() => store.dialog, (newVal) => {
|
||||||
if (newVal === 'usermgmt' && !store.server.paskia) {
|
if (newVal === 'usermgmt') {
|
||||||
loadUsers()
|
loadServerConfig()
|
||||||
|
if (!store.server.paskia) {
|
||||||
|
loadUsers()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -225,4 +274,13 @@ watch(() => store.server.public, (newVal) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
|
.input-with-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.input-with-hint small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatSize, formatUnixDate, haystackFormat } from "@/utils"
|
import { formatSize, formatUnixDate } from "@/utils"
|
||||||
|
|
||||||
export type FUID = string
|
export type FUID = string
|
||||||
|
|
||||||
@@ -7,32 +7,45 @@ export type DocProps = {
|
|||||||
name: string
|
name: string
|
||||||
key: FUID
|
key: FUID
|
||||||
size: number
|
size: number
|
||||||
|
allocated: number
|
||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
|
ghost?: boolean
|
||||||
|
expires?: number // Unix timestamp for ghost expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Doc {
|
export class Doc {
|
||||||
public loc: string = ""
|
public loc: string = ""
|
||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
|
public allocated: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public haystack: string = ""
|
|
||||||
public dir: boolean = false
|
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 */
|
/** @internal Use the name getter/setter instead */
|
||||||
public _name: string = ""
|
public _name: string = ""
|
||||||
|
|
||||||
constructor(props: Partial<DocProps> = {}) {
|
constructor(props: Partial<DocProps> = {}) {
|
||||||
const { name, ...rest } = props
|
const { name, ...rest } = props
|
||||||
Object.assign(this, rest)
|
Object.assign(this, rest)
|
||||||
if (name) this.name = name // Use setter for validation
|
if (name) this._name = name // Skip validation/haystack for bulk loading
|
||||||
}
|
}
|
||||||
get name() { return this._name }
|
get name() { return this._name }
|
||||||
set name(name: string) {
|
set name(name: string) {
|
||||||
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
|
||||||
this._name = name
|
this._name = name
|
||||||
this.haystack = haystackFormat(name)
|
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string { return formatSize(this.size) }
|
||||||
|
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||||
|
get sparseIndicator(): string {
|
||||||
|
if (this.dir || this.size <= this.allocated) return ''
|
||||||
|
if (this.allocated === 0) return '⭕' // exactly zero
|
||||||
|
const ratio = this.allocated / this.size
|
||||||
|
// Round to nearest 25%: ◔◑◕⬤
|
||||||
|
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||||
|
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||||
|
}
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
get modified(): string { return formatUnixDate(this.mtime) }
|
||||||
get url(): string {
|
get url(): string {
|
||||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
@@ -76,9 +89,10 @@ export type FileEntry = [
|
|||||||
number, // level
|
number, // level
|
||||||
string, // name
|
string, // name
|
||||||
FUID,
|
FUID,
|
||||||
number, //mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
number, // isfile
|
number, // allocated (actual disk usage)
|
||||||
|
number, // isfile
|
||||||
]
|
]
|
||||||
|
|
||||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export async function updatePublic(isPublic: boolean) {
|
|||||||
const data = await Client.put('/api/config/public', { public: isPublic })
|
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateServerName(name: string) {
|
||||||
|
const data = await Client.put('/api/config/name', { name })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerConfig() {
|
||||||
|
const data = await Client.get('/api/config')
|
||||||
|
return data as { name: string, public: boolean }
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
|
|||||||
handleUpdateMessage(msg)
|
handleUpdateMessage(msg)
|
||||||
break
|
break
|
||||||
case !!msg.space:
|
case !!msg.space:
|
||||||
console.log('Watch space', msg.space)
|
const store = useMainStore()
|
||||||
|
store.space = msg.space
|
||||||
break
|
break
|
||||||
case !!msg.error:
|
case !!msg.error:
|
||||||
handleError(msg)
|
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
|
||||||
+250
-10
@@ -4,14 +4,78 @@ import { defineStore, type StateTree } from 'pinia'
|
|||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
import { watchConnect, resumeWatching } from '@/repositories/WS'
|
||||||
import { sorted, type SortOrder } from '@/utils/docsort'
|
import { sorted, type SortOrder } from '@/utils/docsort'
|
||||||
|
import SearchWorker from '@/workers/searchWorker?worker'
|
||||||
|
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', {
|
export const useMainStore = defineStore('main', {
|
||||||
state: () => ({
|
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>([]),
|
selected: new Set<FUID>([]),
|
||||||
query: '' as string,
|
query: '' as string,
|
||||||
|
searchResults: [] as Doc[],
|
||||||
|
searchLoading: false,
|
||||||
|
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
fileExplorer: null as any,
|
fileExplorer: null as any,
|
||||||
error: '' as string,
|
error: '' as string, // Permanent status message (e.g., "Reconnecting...")
|
||||||
|
toast: '' as string, // Temporary toast (auto-dismisses)
|
||||||
|
toastTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
connected: false,
|
connected: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
@@ -23,11 +87,19 @@ export const useMainStore = defineStore('main', {
|
|||||||
gallery: false,
|
gallery: false,
|
||||||
sortListing: '' as SortOrder,
|
sortListing: '' as SortOrder,
|
||||||
sortFiltered: '' as SortOrder,
|
sortFiltered: '' as SortOrder,
|
||||||
|
searchHotkey: '/', // Character shown for search hotkey (Slash key)
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
username: '' as string,
|
username: '' as string,
|
||||||
privileged: false as boolean,
|
privileged: false as boolean,
|
||||||
isLoggedIn: false as boolean,
|
isLoggedIn: false as boolean,
|
||||||
|
},
|
||||||
|
space: {
|
||||||
|
disk: 0,
|
||||||
|
free: 0,
|
||||||
|
used: 0,
|
||||||
|
storage: 0,
|
||||||
|
allocated: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
@@ -48,19 +120,157 @@ export const useMainStore = defineStore('main', {
|
|||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(new Doc({
|
docs.push(new Doc({
|
||||||
name,
|
name,
|
||||||
loc: level ? loc.join('/') : '/',
|
loc: level ? loc.join('/') : '/',
|
||||||
key,
|
key,
|
||||||
size,
|
size,
|
||||||
|
allocated,
|
||||||
mtime,
|
mtime,
|
||||||
dir: !isfile,
|
dir: !isfile,
|
||||||
}))
|
}))
|
||||||
loc.push(name)
|
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,
|
||||||
|
allocated: doc.allocated,
|
||||||
|
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) {
|
login(username: string, privileged: boolean) {
|
||||||
this.user.username = username
|
this.user.username = username
|
||||||
@@ -72,7 +282,11 @@ export const useMainStore = defineStore('main', {
|
|||||||
clearSensitiveData() {
|
clearSensitiveData() {
|
||||||
// Clear all sensitive state on logout or auth failure
|
// Clear all sensitive state on logout or auth failure
|
||||||
localStorage.removeItem('cista-files')
|
localStorage.removeItem('cista-files')
|
||||||
this.document = []
|
setDocuments([])
|
||||||
|
this.ghosts = []
|
||||||
|
this.hiddenPaths.clear()
|
||||||
|
this.stopCleanupTimer()
|
||||||
|
this.docVersion++
|
||||||
this.selected.clear()
|
this.selected.clear()
|
||||||
this.user.username = ''
|
this.user.username = ''
|
||||||
this.user.privileged = false
|
this.user.privileged = false
|
||||||
@@ -98,12 +312,25 @@ export const useMainStore = defineStore('main', {
|
|||||||
resumeWatching()
|
resumeWatching()
|
||||||
},
|
},
|
||||||
toggleSort(name: SortOrder) {
|
toggleSort(name: SortOrder) {
|
||||||
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||||
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
|
const newOrder = current === name ? '' : name
|
||||||
|
if (this.query) this.prefs.sortFiltered = newOrder
|
||||||
|
else this.prefs.sortListing = newOrder
|
||||||
|
this.showSortToast(newOrder)
|
||||||
},
|
},
|
||||||
sort(name: SortOrder | '') {
|
sort(name: SortOrder | '') {
|
||||||
if (this.query) this.prefs.sortFiltered = name
|
if (this.query) this.prefs.sortFiltered = name
|
||||||
else this.prefs.sortListing = 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() {
|
focusBreadcrumb() {
|
||||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||||
@@ -118,8 +345,21 @@ export const useMainStore = defineStore('main', {
|
|||||||
getters: {
|
getters: {
|
||||||
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },
|
||||||
isUserLogged(): boolean { return this.user.isLoggedIn },
|
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 {
|
selectedFiles(): SelectedItems {
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void this.docVersion
|
||||||
|
const docs = getDocuments()
|
||||||
const selected = this.selected
|
const selected = this.selected
|
||||||
const found = new Set<FUID>()
|
const found = new Set<FUID>()
|
||||||
const ret: SelectedItems = {
|
const ret: SelectedItems = {
|
||||||
@@ -128,7 +368,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
keys: [],
|
keys: [],
|
||||||
recursive: [],
|
recursive: [],
|
||||||
}
|
}
|
||||||
for (const doc of this.document) {
|
for (const doc of docs) {
|
||||||
if (selected.has(doc.key)) {
|
if (selected.has(doc.key)) {
|
||||||
found.add(doc.key)
|
found.add(doc.key)
|
||||||
ret.keys.push(doc.key)
|
ret.keys.push(doc.key)
|
||||||
@@ -149,7 +389,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
const basepath = base.loc ? `${base.loc}/${base.name}` : base.name
|
||||||
const nremove = base.loc.length
|
const nremove = base.loc.length
|
||||||
add(base.name, basepath, base)
|
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] === '/') {
|
if (doc.loc === basepath || doc.loc.startsWith(basepath) && doc.loc[basepath.length] === '/') {
|
||||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
const rel = full.slice(nremove)
|
const rel = full.slice(nremove)
|
||||||
|
|||||||
@@ -1,8 +1,52 @@
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
|
|
||||||
|
|
||||||
export const exists = (path: string[]) => {
|
export const exists = (path: string[]) => {
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
// Access docVersion to make this reactive
|
||||||
|
void store.docVersion
|
||||||
const p = path.join('/')
|
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
|
<Gallery
|
||||||
v-if="store.prefs.gallery"
|
v-if="store.prefs.gallery"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`gallery-${Router.currentRoute.value.path}`"
|
:key="`gallery-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<FileExplorer
|
<FileExplorer
|
||||||
v-else
|
v-else
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
:key="`explorer-${Router.currentRoute.value.path}`"
|
:key="`explorer-${folderPath}`"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
<EmptyFolder :documents=documents :path=props.path />
|
<EmptyFolder :documents=documents :path=props.path />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watchEffect, ref, computed, watch } from 'vue'
|
import { watchEffect, ref, computed, watch } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import Router from '@/router/index'
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { needleFormat, localeIncludes, collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import FileExplorer from '@/components/FileExplorer.vue'
|
import FileExplorer from '@/components/FileExplorer.vue'
|
||||||
|
|
||||||
@@ -30,51 +31,57 @@ const props = defineProps<{
|
|||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
|
||||||
|
// 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 documents = computed(() => {
|
||||||
const loc = props.path.join('/')
|
const loc = props.path.join('/')
|
||||||
const query = props.query
|
const query = props.query
|
||||||
// List the current location
|
|
||||||
if (!query) return sorted(
|
// List the current location (no search)
|
||||||
store.document.filter(doc => doc.loc === loc),
|
if (!query) {
|
||||||
store.prefs.sortListing,
|
// Access docVersion to make this reactive to document changes
|
||||||
)
|
void store.docVersion
|
||||||
// Find up to 100 newest documents that match the search
|
const hidden = store.hiddenPaths
|
||||||
const needle = needleFormat(query)
|
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||||
let limit = 100
|
// Overlay ghosts for this location (excluding hidden ones)
|
||||||
let docs = []
|
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||||
for (const doc of store.recentDocuments) {
|
// Merge: ghosts that don't conflict with real docs
|
||||||
if (localeIncludes(doc.haystack, needle)) {
|
const realNames = new Set(docs.map(d => d.name))
|
||||||
docs.push(doc)
|
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
|
||||||
if (--limit === 0) break
|
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
|
// Custom sort override in effect? Use grouped sorting to keep folders together
|
||||||
const order = store.prefs.sortFiltered
|
const order = store.prefs.sortFiltered
|
||||||
if (order) return sortedGrouped(docs, order)
|
if (order) return sortedGrouped(docs, order)
|
||||||
// Sort by relevance - current folder, then subfolders, then others
|
|
||||||
docs.sort((a, b) => (
|
// Results are already sorted by relevance in the worker
|
||||||
// @ts-ignore
|
|
||||||
(b.loc === loc) - (a.loc === loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
|
||||||
collator.compare(a.loc, b.loc) ||
|
|
||||||
// @ts-ignore
|
|
||||||
(a.type === 'file') - (b.type === 'file') ||
|
|
||||||
// @ts-ignore
|
|
||||||
b.name.includes(query) - a.name.includes(query) ||
|
|
||||||
collator.compare(a.name, b.name)
|
|
||||||
))
|
|
||||||
return docs
|
return docs
|
||||||
})
|
})
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
store.fileExplorer = fileExplorer.value
|
store.fileExplorer = fileExplorer.value
|
||||||
store.query = props.query
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
// 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)
|
// React to path change or initial document load (0 → non-zero)
|
||||||
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
if (path === oldPath && oldLen !== undefined && oldLen > 0) return
|
||||||
store.prefs.gallery = documents.value.some(d => d.previewable)
|
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;
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
.search-loading {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// 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
|
||||||
|
allocated: 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
|
* - Builds to the Python module's frontend-build directory
|
||||||
*
|
*
|
||||||
* Environment variables (with defaults):
|
* 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"] } = {}) {
|
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ dependencies = [
|
|||||||
"argon2-cffi>=25.1.0",
|
"argon2-cffi>=25.1.0",
|
||||||
"av>=15.0.0",
|
"av>=15.0.0",
|
||||||
"blake3>=1.0.5",
|
"blake3>=1.0.5",
|
||||||
"docopt>=0.6.2",
|
"docopt-ng>=0.9.0",
|
||||||
"fastapi-vue>=0.5.1",
|
"fastapi-vue>=0.5.2",
|
||||||
"fastapi[standard]>=0.128.0",
|
"fastapi[standard]>=0.128.0",
|
||||||
"html5tagger>=1.3.0",
|
"html5tagger>=1.3.0",
|
||||||
"httpx>=0.28.0",
|
"httpx>=0.28.0",
|
||||||
|
|||||||
+25
-18
@@ -2,11 +2,13 @@
|
|||||||
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/devserver.py [frontend] [--backend backend]
|
uv run scripts/devserver.py [-l listen] [--backend backend] [cista_args...]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
frontend Vite frontend endpoint (default: localhost:5173)
|
-l, --listen Vite frontend endpoint (default: localhost:8989)
|
||||||
--backend Cista backend endpoint (default: from config, or :8000)
|
--backend Cista backend endpoint (default: from config, or :8999)
|
||||||
|
|
||||||
|
Any additional arguments are passed to the cista command.
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||||
@@ -26,10 +28,12 @@ from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
|
|||||||
from cista import config
|
from cista import config
|
||||||
from cista.serve import parse_listen
|
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.
|
"""Parse backend listen address and build cista dev command.
|
||||||
|
|
||||||
Returns (url, cmd).
|
Returns (url, cmd).
|
||||||
@@ -40,11 +44,13 @@ def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
|||||||
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
port = opts.get("port", DEFAULT_BACKEND_PORT)
|
||||||
host = opts.get("host", "localhost") or "localhost"
|
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
|
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
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -52,7 +58,7 @@ async def run_devserver(frontend: str | None, backend: str | None) -> None:
|
|||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
frontend_url, npm_install, vite = setup_vite(frontend or "")
|
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
|
# Tell vite where to proxy API requests
|
||||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url
|
||||||
@@ -78,26 +84,27 @@ def main():
|
|||||||
epilog=HELP_EPILOG,
|
epilog=HELP_EPILOG,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"frontend",
|
"-l",
|
||||||
nargs="?",
|
"--listen",
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Vite frontend endpoint (default: localhost:5173)",
|
help="Vite frontend endpoint (default: localhost:8989)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
"-l",
|
|
||||||
metavar="host:port",
|
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):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py # Default ports
|
scripts/devserver.py # Default ports
|
||||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
scripts/devserver.py -l 3000 # Vite on localhost:3000
|
||||||
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
scripts/devserver.py -l :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
|
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 buildutil import find_dev_tool, find_install_tool, logger
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
DEFAULT_VITE_PORT = 5173
|
DEFAULT_VITE_PORT = 8989
|
||||||
DEFAULT_BACKEND_PORT = 5180
|
DEFAULT_BACKEND_PORT = 8999
|
||||||
|
|
||||||
|
|
||||||
class ProcessGroup:
|
class ProcessGroup:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ def decode(data: str):
|
|||||||
|
|
||||||
# Helper function to create a list of FileEntry objects
|
# Helper function to create a list of FileEntry objects
|
||||||
def f(count, start=0):
|
def f(count, start=0):
|
||||||
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)]
|
return [
|
||||||
|
FileEntry(i, str(i), str(i), 0, 0, 0, 0) for i in range(start, start + count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_identical_lists():
|
def test_identical_lists():
|
||||||
@@ -35,8 +37,8 @@ def test_insertions():
|
|||||||
|
|
||||||
|
|
||||||
def test_insertion_at_end():
|
def test_insertion_at_end():
|
||||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
|
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
|
||||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
|
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
|
||||||
new_list = [*old_list, newfile]
|
new_list = [*old_list, newfile]
|
||||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||||
assert decode(format_update(old_list, new_list)) == expected
|
assert decode(format_update(old_list, new_list)) == expected
|
||||||
|
|||||||
Reference in New Issue
Block a user