Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ac571463a | ||
|
|
d52b24372a | ||
|
|
742b3006c9 | ||
|
|
01a979c00a | ||
|
|
f1e16b7abe | ||
|
|
0ebff0ec17 | ||
|
|
c8ab06d864 | ||
|
|
d5ff7757c8 | ||
|
|
cd604eb10a | ||
|
|
abcf5d9940 | ||
|
|
0190bda853 | ||
|
|
fc48500412 | ||
|
|
d31ad0b525 | ||
|
|
da4bba95be | ||
|
|
e07ab220cb | ||
|
|
3da2f6e6c3 | ||
|
|
ded7ce65bc | ||
|
|
17550be698 | ||
|
|
0b269aef7f | ||
|
|
497de296f2 | ||
|
|
134b216f4c | ||
|
|
06759b3c12 | ||
|
|
c51552ea29 | ||
|
|
00645fc8ff | ||
|
|
760f7bc35d | ||
|
|
8480a73839 | ||
|
|
302ed684e7 | ||
|
|
af35e0480a | ||
|
|
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 |
@@ -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.
|
||||||
|
|
||||||
@@ -20,6 +20,11 @@ Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and
|
|||||||
|
|
||||||
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
We recommend using [UV](https://docs.astral.sh/uv/getting-started/installation/) to directly run Cista:
|
||||||
|
|
||||||
|
Try it out locally at http://localhost:8000 (serves the current directory):
|
||||||
|
```fish
|
||||||
|
uvx cista
|
||||||
|
```
|
||||||
|
|
||||||
Create an account: (otherwise the server is public for all)
|
Create an account: (otherwise the server is public for all)
|
||||||
```fish
|
```fish
|
||||||
uvx cista --user yourname --privileged
|
uvx cista --user yourname --privileged
|
||||||
@@ -40,9 +45,13 @@ The server remembers its settings in the config folder (default `~/.local/share/
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Cista supports three authentication modes:
|
Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||||
|
|
||||||
### Built-in Authentication (default)
|
### 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:
|
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||||
|
|
||||||
@@ -53,23 +62,26 @@ uvx cista --user guest # Create regular user
|
|||||||
|
|
||||||
Privileged users can manage other users and change settings via the Admin Settings menu.
|
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||||
|
|
||||||
### Public Mode
|
### Passkey Authentication and SSO
|
||||||
|
|
||||||
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
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.
|
||||||
|
|
||||||
### Paskia SSO Authentication
|
Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
|
|
||||||
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
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:
|
In Paskia mode:
|
||||||
- All `/auth/*` requests are proxied to the Paskia backend
|
- 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:login` permission can access files
|
||||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||||
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
|
||||||
|
|
||||||
### Internet Access
|
### Internet Access
|
||||||
|
|
||||||
@@ -89,9 +101,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]
|
||||||
@@ -101,6 +119,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
|
||||||
@@ -153,4 +172,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.
|
||||||
|
|||||||
+15
-4
@@ -25,14 +25,22 @@ def create_banner():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
def create_startup_box(
|
||||||
|
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||||
|
):
|
||||||
"""Create a framed startup box with server information."""
|
"""Create a framed startup box with server information."""
|
||||||
title = f"Cista {cista.__version__}"
|
title = f"Cista {cista.__version__}"
|
||||||
listen = unix if unix else url
|
listen = unix if unix else url
|
||||||
location = f"{folder} @ {listen}"
|
location = f"{folder} @ {listen}"
|
||||||
lines = [title, location]
|
lines = [title, location]
|
||||||
|
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||||
if paskia_url:
|
if paskia_url:
|
||||||
lines.append(f"Paskia: {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:
|
if dev:
|
||||||
lines.append("dev mode")
|
lines.append("dev mode")
|
||||||
|
|
||||||
@@ -53,10 +61,12 @@ doc = """\
|
|||||||
Usage:
|
Usage:
|
||||||
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
|
||||||
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
cista [-c <confdir>] --user <name> [--privileged] [--password]
|
||||||
|
cista --version
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c CONFDIR Custom config directory
|
-c CONFDIR Custom config directory
|
||||||
-l LISTEN-ADDR Listen on
|
-l, --listen LISTEN-ADDR
|
||||||
|
Listen on
|
||||||
:8989 (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)
|
||||||
@@ -109,7 +119,7 @@ def _main():
|
|||||||
args = docopt(doc)
|
args = docopt(doc)
|
||||||
if args["--user"]:
|
if args["--user"]:
|
||||||
return _user(args)
|
return _user(args)
|
||||||
listen = args["-l"]
|
listen = args["--listen"]
|
||||||
# Validate arguments first
|
# Validate arguments first
|
||||||
if args["<path>"]:
|
if args["<path>"]:
|
||||||
path = Path(args["<path>"]).resolve()
|
path = Path(args["<path>"]).resolve()
|
||||||
@@ -157,6 +167,7 @@ def _main():
|
|||||||
unix=opts.get("unix"),
|
unix=opts.get("unix"),
|
||||||
dev=dev,
|
dev=dev,
|
||||||
paskia_url=PASKIA_BACKEND_URL or None,
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
public=config.config.public,
|
||||||
)
|
)
|
||||||
sys.stderr.write(startup_box)
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
|
|||||||
+29
-61
@@ -1,5 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import typing
|
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ from sanic.exceptions import BadRequest
|
|||||||
|
|
||||||
from cista import __version__, auth, config, sso, watching
|
from cista import __version__, auth, config, sso, watching
|
||||||
from cista.fileio import FileServer
|
from cista.fileio import FileServer
|
||||||
from cista.protocol import ControlTypes, FileRange, StatusMsg
|
from cista.protocol import ControlTypes, StatusMsg
|
||||||
from cista.util.apphelpers import asend, websocket_wrapper
|
from cista.util.apphelpers import asend, websocket_wrapper
|
||||||
|
|
||||||
bp = Blueprint("api", url_prefix="/api")
|
bp = Blueprint("api", url_prefix="/api")
|
||||||
@@ -26,65 +25,6 @@ async def stop_fileserver(app):
|
|||||||
await fileserver.stop()
|
await fileserver.stop()
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("upload")
|
|
||||||
@websocket_wrapper
|
|
||||||
async def upload(req, ws):
|
|
||||||
alink = fileserver.alink
|
|
||||||
while True:
|
|
||||||
req = None
|
|
||||||
text = await ws.recv()
|
|
||||||
if not isinstance(text, str):
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
|
||||||
)
|
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
|
||||||
pos = req.start
|
|
||||||
while True:
|
|
||||||
data = await ws.recv()
|
|
||||||
if not isinstance(data, bytes):
|
|
||||||
break
|
|
||||||
if len(data) > req.end - pos:
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected up to {req.end - pos} bytes, got {len(data)} bytes"
|
|
||||||
)
|
|
||||||
sentsize = await alink(("upload", req.name, pos, data, req.size))
|
|
||||||
pos += typing.cast(int, sentsize)
|
|
||||||
if pos >= req.end:
|
|
||||||
break
|
|
||||||
if pos != req.end:
|
|
||||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
|
||||||
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
|
|
||||||
# Signal the watcher about the uploaded file and its parent directories
|
|
||||||
path = PurePosixPath(req.name)
|
|
||||||
watching.notify_change(path, *path.parents)
|
|
||||||
# Report success
|
|
||||||
res = StatusMsg(status="ack", req=req)
|
|
||||||
await asend(ws, res)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("download")
|
|
||||||
@websocket_wrapper
|
|
||||||
async def download(req, ws):
|
|
||||||
alink = fileserver.alink
|
|
||||||
while True:
|
|
||||||
req = None
|
|
||||||
text = await ws.recv()
|
|
||||||
if not isinstance(text, str):
|
|
||||||
raise ValueError(
|
|
||||||
f"Expected JSON control, got binary len(data) = {len(text)}",
|
|
||||||
)
|
|
||||||
req = msgspec.json.decode(text, type=FileRange)
|
|
||||||
pos = req.start
|
|
||||||
while pos < req.end:
|
|
||||||
end = min(req.end, pos + (1 << 20))
|
|
||||||
data = typing.cast(bytes, await alink(("download", req.name, pos, end)))
|
|
||||||
await asend(ws, data)
|
|
||||||
pos += len(data)
|
|
||||||
# Report success
|
|
||||||
res = StatusMsg(status="ack", req=req)
|
|
||||||
await asend(ws, res)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.websocket("control")
|
@bp.websocket("control")
|
||||||
@websocket_wrapper
|
@websocket_wrapper
|
||||||
async def control(req, ws):
|
async def control(req, ws):
|
||||||
@@ -163,6 +103,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)
|
||||||
@@ -176,3 +127,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})
|
||||||
|
|||||||
+122
-6
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
|
import time
|
||||||
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
|
||||||
@@ -10,21 +12,27 @@ from wsgiref.handlers import format_date_time
|
|||||||
|
|
||||||
import sanic.helpers
|
import sanic.helpers
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, Sanic, empty, raw, redirect
|
from sanic import Blueprint, Sanic, empty, json, raw, redirect
|
||||||
from sanic.exceptions import Forbidden, NotFound
|
from sanic.exceptions import BadRequest, Forbidden, NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
from setproctitle import setproctitle
|
from setproctitle import setproctitle
|
||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, preview, session, sso, watching
|
from cista import auth, config, preview, session, sso, watching
|
||||||
from cista.api import bp
|
from cista.preview import shutdown_preview_workers, start_preview_workers
|
||||||
|
from cista.api import bp, fileserver
|
||||||
|
from cista.sanic_logging import configure_access_logging, configure_main_logging, format_access_log
|
||||||
|
from cista.sanic_logging import logger as access_logger
|
||||||
from cista.util.apphelpers import handle_sanic_exception
|
from cista.util.apphelpers import handle_sanic_exception
|
||||||
|
|
||||||
# Workaround until Sanic PR #2824 is merged
|
# Workaround until Sanic PR #2824 is merged
|
||||||
sanic.helpers._ENTITY_HEADERS = frozenset()
|
sanic.helpers._ENTITY_HEADERS = frozenset()
|
||||||
|
|
||||||
|
configure_access_logging()
|
||||||
|
|
||||||
app = Sanic("cista", strict_slashes=True)
|
app = Sanic("cista", strict_slashes=True)
|
||||||
|
configure_main_logging()
|
||||||
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
# Register either SSO proxy or built-in auth routes based on PASKIA_BACKEND_URL
|
||||||
if sso.paskia_enabled():
|
if sso.paskia_enabled():
|
||||||
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
app.blueprint(sso.bp) # SSO proxy for /auth/* routes
|
||||||
@@ -42,13 +50,12 @@ 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}")
|
||||||
# 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=preview_workers, thread_name_prefix="cista-preview"
|
max_workers=4, thread_name_prefix="cista-worker"
|
||||||
)
|
)
|
||||||
# Larger pool for long-running but low-memory zip operations
|
# Larger pool for long-running but low-memory zip operations
|
||||||
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
app.ctx.zipexec = ThreadPoolExecutor(max_workers=32, thread_name_prefix="cista-zip")
|
||||||
|
await start_preview_workers()
|
||||||
watching.start(app)
|
watching.start(app)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +63,7 @@ async def main_start(app):
|
|||||||
@app.before_server_stop
|
@app.before_server_stop
|
||||||
async def main_stop(app):
|
async def main_stop(app):
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
|
await shutdown_preview_workers()
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||||
await sso.close_client()
|
await sso.close_client()
|
||||||
@@ -64,6 +72,7 @@ async def main_stop(app):
|
|||||||
|
|
||||||
@app.on_request
|
@app.on_request
|
||||||
async def use_session(req):
|
async def use_session(req):
|
||||||
|
req.ctx._log_start = time.perf_counter()
|
||||||
req.ctx.session = session.get(req)
|
req.ctx.session = session.get(req)
|
||||||
try:
|
try:
|
||||||
req.ctx.username = req.ctx.session["username"] # type: ignore
|
req.ctx.username = req.ctx.session["username"] # type: ignore
|
||||||
@@ -81,6 +90,27 @@ async def use_session(req):
|
|||||||
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
raise Forbidden("Invalid origin: Cross-Site requests not permitted")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_response
|
||||||
|
async def log_access(req, res):
|
||||||
|
"""Log HTTP access in a clean single-line format."""
|
||||||
|
if req.headers.get("upgrade", "").lower() == "websocket":
|
||||||
|
return res
|
||||||
|
start = getattr(req.ctx, "_log_start", None)
|
||||||
|
duration_ms = (time.perf_counter() - start) * 1000 if start is not None else 0.0
|
||||||
|
client = req.client_ip or "-"
|
||||||
|
host = req.host or "-"
|
||||||
|
path = req.path
|
||||||
|
if req.query_string:
|
||||||
|
qs = req.query_string
|
||||||
|
if isinstance(qs, bytes):
|
||||||
|
qs = qs.decode(errors="replace")
|
||||||
|
path = f"{path}?{qs}"
|
||||||
|
extra = getattr(req.ctx, "_log_extra", None)
|
||||||
|
line = format_access_log(client, res.status, req.method, host, path, duration_ms, extra=extra)
|
||||||
|
access_logger.info(line)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
@app.on_response
|
@app.on_response
|
||||||
async def forward_sso_cookies(req, res):
|
async def forward_sso_cookies(req, res):
|
||||||
"""Forward Set-Cookie headers from SSO validation to client."""
|
"""Forward Set-Cookie headers from SSO validation to client."""
|
||||||
@@ -98,6 +128,68 @@ def http_fileserver(app):
|
|||||||
"""Verify access to file server routes."""
|
"""Verify access to file server routes."""
|
||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
|
|
||||||
|
@bp.put("/files/<name:path>")
|
||||||
|
async def upload_file_chunk(request, *args, **kwargs):
|
||||||
|
body = request.body
|
||||||
|
header = request.headers.get("content-range")
|
||||||
|
if header:
|
||||||
|
start, end, total = _parse_content_range(header, len(body))
|
||||||
|
else:
|
||||||
|
start = 0
|
||||||
|
end = len(body)
|
||||||
|
total = end
|
||||||
|
raw_name = kwargs.get("name")
|
||||||
|
if raw_name is None and args:
|
||||||
|
raw_name = args[0]
|
||||||
|
if not isinstance(raw_name, str) or not raw_name:
|
||||||
|
prefix = "/files/"
|
||||||
|
if not request.path.startswith(prefix):
|
||||||
|
raise BadRequest("Invalid upload path")
|
||||||
|
raw_name = request.path[len(prefix) :]
|
||||||
|
rel_name = unquote(raw_name)
|
||||||
|
upload_info = await asyncio.to_thread(
|
||||||
|
fileserver.upload_info,
|
||||||
|
rel_name,
|
||||||
|
start,
|
||||||
|
body,
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
extras = []
|
||||||
|
chunk_len = end - start
|
||||||
|
whole_file = start == 0 and end == total
|
||||||
|
if not whole_file:
|
||||||
|
start_mib = _to_mib_int(start)
|
||||||
|
chunk_mib = _to_mib_int(chunk_len)
|
||||||
|
# Keep range logs compact for fixed-size upload blocks.
|
||||||
|
if chunk_mib == 16:
|
||||||
|
extras.append(f"{start_mib}MiB")
|
||||||
|
else:
|
||||||
|
extras.append(f"{start_mib}+{chunk_mib}MiB")
|
||||||
|
if upload_info.get("created"):
|
||||||
|
extras.append(f"created {_to_mib_int(total)}MiB")
|
||||||
|
size_before = upload_info.get("size_before")
|
||||||
|
size_after = upload_info.get("size_after")
|
||||||
|
if (
|
||||||
|
size_before is not None
|
||||||
|
and size_after is not None
|
||||||
|
and size_before != size_after
|
||||||
|
):
|
||||||
|
extras.append("resized")
|
||||||
|
request.ctx._log_extra = " ".join(extras) if extras else None
|
||||||
|
path = PurePosixPath(rel_name)
|
||||||
|
watching.notify_change(path, *path.parents)
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"status": "ack",
|
||||||
|
"req": {
|
||||||
|
"name": rel_name,
|
||||||
|
"size": total,
|
||||||
|
"start": start,
|
||||||
|
"end": end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
bp.static(
|
bp.static(
|
||||||
"/files/",
|
"/files/",
|
||||||
config.config.path,
|
config.config.path,
|
||||||
@@ -109,6 +201,30 @@ def http_fileserver(app):
|
|||||||
|
|
||||||
|
|
||||||
www = {}
|
www = {}
|
||||||
|
_CONTENT_RANGE_RE = re.compile(r"^bytes (\d+)-(\d+)/(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_content_range(header: str, body_len: int) -> tuple[int, int, int]:
|
||||||
|
m = _CONTENT_RANGE_RE.fullmatch(header.strip())
|
||||||
|
if m is None:
|
||||||
|
raise BadRequest("Invalid Content-Range format")
|
||||||
|
start, end_inclusive, total = (int(v) for v in m.groups())
|
||||||
|
if total <= 0:
|
||||||
|
raise BadRequest("Invalid Content-Range total size")
|
||||||
|
if start > end_inclusive:
|
||||||
|
raise BadRequest("Invalid Content-Range range")
|
||||||
|
if end_inclusive >= total:
|
||||||
|
raise BadRequest("Content-Range exceeds total size")
|
||||||
|
expected_len = end_inclusive - start + 1
|
||||||
|
if expected_len != body_len:
|
||||||
|
raise BadRequest(
|
||||||
|
f"Content length mismatch for range: expected {expected_len}, got {body_len}"
|
||||||
|
)
|
||||||
|
return start, end_inclusive + 1, total
|
||||||
|
|
||||||
|
|
||||||
|
def _to_mib_int(value_bytes: int) -> int:
|
||||||
|
return round(value_bytes / (1 << 20))
|
||||||
|
|
||||||
|
|
||||||
def _load_wwwroot(www):
|
def _load_wwwroot(www):
|
||||||
|
|||||||
+2
-2
@@ -269,7 +269,7 @@ async def verify(request, *, privileged=False):
|
|||||||
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -278,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)
|
||||||
|
|||||||
+23
-30
@@ -1,9 +1,8 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
from cista import config
|
from cista import config
|
||||||
from cista.util import filename
|
from cista.util import filename
|
||||||
from cista.util.asynclink import AsyncLink
|
|
||||||
from cista.util.lrucache import LRUCache
|
from cista.util.lrucache import LRUCache
|
||||||
|
|
||||||
|
|
||||||
@@ -62,38 +61,32 @@ class File:
|
|||||||
|
|
||||||
class FileServer:
|
class FileServer:
|
||||||
async def start(self):
|
async def start(self):
|
||||||
self.alink = AsyncLink()
|
|
||||||
self.worker = asyncio.get_event_loop().run_in_executor(
|
|
||||||
None,
|
|
||||||
self.worker_thread,
|
|
||||||
self.alink.to_sync,
|
|
||||||
)
|
|
||||||
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
self.cache = LRUCache(File, capacity=10, maxage=5.0)
|
||||||
|
self.cache_lock = threading.Lock()
|
||||||
|
self.file_locks: dict[str, threading.Lock] = {}
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
await self.alink.stop()
|
self.cache.close()
|
||||||
await self.worker
|
|
||||||
|
|
||||||
def worker_thread(self, slink):
|
@staticmethod
|
||||||
|
def _stat_size(path):
|
||||||
try:
|
try:
|
||||||
for req in slink:
|
return os.stat(path).st_size
|
||||||
with req as (command, *args):
|
except FileNotFoundError:
|
||||||
if command == "upload":
|
return None
|
||||||
req.set_result(self.upload(*args))
|
|
||||||
elif command == "download":
|
|
||||||
req.set_result(self.download(*args))
|
|
||||||
else:
|
|
||||||
raise NotImplementedError(f"Unhandled {command=} {args}")
|
|
||||||
finally:
|
|
||||||
self.cache.close()
|
|
||||||
|
|
||||||
def upload(self, name, pos, data, file_size):
|
def upload_info(self, name, pos, data, file_size):
|
||||||
name = filename.sanitize(name)
|
name = filename.sanitize(name)
|
||||||
f = self.cache[name]
|
with self.cache_lock:
|
||||||
f.write(pos, data, file_size=file_size)
|
f = self.cache[name]
|
||||||
return len(data)
|
lock = self.file_locks.setdefault(name, threading.Lock())
|
||||||
|
with lock:
|
||||||
def download(self, name, start, end):
|
size_before = self._stat_size(f.path)
|
||||||
name = filename.sanitize(name)
|
f.write(pos, data, file_size=file_size)
|
||||||
f = self.cache[name]
|
size_after = self._stat_size(f.path)
|
||||||
return f[start:end]
|
return {
|
||||||
|
"written": len(data),
|
||||||
|
"created": size_before is None,
|
||||||
|
"size_before": size_before,
|
||||||
|
"size_after": size_after,
|
||||||
|
}
|
||||||
|
|||||||
+421
-77
@@ -2,45 +2,332 @@ import asyncio
|
|||||||
import gc
|
import gc
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from multiprocessing import cpu_count
|
||||||
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
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
|
||||||
import av
|
import av
|
||||||
import fitz # PyMuPDF
|
import fitz # PyMuPDF
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pillow_heif
|
import pyvips
|
||||||
from PIL import Image
|
from blake3 import blake3
|
||||||
from sanic import Blueprint, empty, raw, redirect
|
from sanic import Blueprint, empty, raw, redirect
|
||||||
from sanic.exceptions import NotFound
|
from sanic.exceptions import NotFound
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
from cista import auth, config
|
from cista import auth, config
|
||||||
|
from cista.preview_worker import PreviewRequest, PreviewResponse
|
||||||
from cista.util.filename import sanitize
|
from cista.util.filename import sanitize
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
PREVIEW_TIMEOUT = 3.0 # seconds until preview subprocess is killed
|
||||||
|
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||||
|
_active_procs: set[asyncio.subprocess.Process] = set()
|
||||||
|
_preview_pool = None
|
||||||
|
_preview_pool_lock = asyncio.Lock()
|
||||||
|
AVIF_FAST_EFFORT = 0
|
||||||
|
WORKER_CHECKSUM_BYTES = 32
|
||||||
|
WORKER_MAX_JSON_BYTES = 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerChecksumError(Exception):
|
||||||
|
"""Raised when worker response checksum does not match the packet."""
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerProtocolError(Exception):
|
||||||
|
"""Raised when worker response packet is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
class _PreviewWorker:
|
||||||
|
def __init__(self, proc: asyncio.subprocess.Process):
|
||||||
|
self.proc = proc
|
||||||
|
|
||||||
|
async def request(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
||||||
|
if self.proc.returncode is not None:
|
||||||
|
raise WorkerProtocolError("worker already exited")
|
||||||
|
if self.proc.stdin is None or self.proc.stdout is None:
|
||||||
|
raise WorkerProtocolError("worker streams not available")
|
||||||
|
|
||||||
|
line = (
|
||||||
|
msgspec.json.encode(
|
||||||
|
PreviewRequest(
|
||||||
|
path=str(filepath),
|
||||||
|
quality=quality,
|
||||||
|
maxsize=maxsize,
|
||||||
|
maxzoom=maxzoom,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ b"\n"
|
||||||
|
)
|
||||||
|
self.proc.stdin.write(line)
|
||||||
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
|
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
||||||
|
header = await self.proc.stdout.readexactly(8)
|
||||||
|
json_size, data_size = struct.unpack("<II", header)
|
||||||
|
if json_size > WORKER_MAX_JSON_BYTES:
|
||||||
|
raise WorkerProtocolError(f"worker JSON too large: {json_size}")
|
||||||
|
meta_raw = await self.proc.stdout.readexactly(json_size)
|
||||||
|
payload = await self.proc.stdout.readexactly(data_size)
|
||||||
|
packet = header + meta_raw + payload
|
||||||
|
if blake3(packet).digest() != checksum:
|
||||||
|
raise WorkerChecksumError("worker checksum mismatch")
|
||||||
|
|
||||||
|
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
|
||||||
|
if not resp.ok:
|
||||||
|
raise PreviewError(
|
||||||
|
resp.error or "preview worker error",
|
||||||
|
stderr=resp.stderr,
|
||||||
|
backend=resp.backend,
|
||||||
|
)
|
||||||
|
return payload or None, resp
|
||||||
|
|
||||||
|
async def kill(self) -> None:
|
||||||
|
if self.proc.returncode is None:
|
||||||
|
try:
|
||||||
|
self.proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await self.proc.wait()
|
||||||
|
_active_procs.discard(self.proc)
|
||||||
|
|
||||||
|
|
||||||
|
class _PreviewWorkerPool:
|
||||||
|
def __init__(self, size: int):
|
||||||
|
self.size = size
|
||||||
|
self._idle: asyncio.Queue[_PreviewWorker] = asyncio.Queue()
|
||||||
|
self._workers: set[_PreviewWorker] = set()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
async def _spawn_worker(self) -> _PreviewWorker:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"cista.preview_worker",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
_active_procs.add(proc)
|
||||||
|
return _PreviewWorker(proc)
|
||||||
|
|
||||||
|
async def _add_worker(self) -> None:
|
||||||
|
worker = await self._spawn_worker()
|
||||||
|
self._workers.add(worker)
|
||||||
|
await self._idle.put(worker)
|
||||||
|
|
||||||
|
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||||
|
self._workers.discard(worker)
|
||||||
|
await worker.kill()
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._add_worker()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to replace preview worker")
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
for _ in range(self.size):
|
||||||
|
await self._add_worker()
|
||||||
|
|
||||||
|
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
||||||
|
if self._closed:
|
||||||
|
raise PreviewError("preview worker pool closed")
|
||||||
|
worker = await self._idle.get()
|
||||||
|
replace = False
|
||||||
|
try:
|
||||||
|
out, resp = await asyncio.wait_for(
|
||||||
|
worker.request(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
return out, resp
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
replace = True
|
||||||
|
logger.warning(
|
||||||
|
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
||||||
|
)
|
||||||
|
raise PreviewTimeout(filepath.name)
|
||||||
|
except WorkerChecksumError:
|
||||||
|
replace = True
|
||||||
|
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||||
|
raise PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||||
|
except PreviewError:
|
||||||
|
raise
|
||||||
|
except (
|
||||||
|
WorkerProtocolError,
|
||||||
|
asyncio.IncompleteReadError,
|
||||||
|
BrokenPipeError,
|
||||||
|
ConnectionResetError,
|
||||||
|
OSError,
|
||||||
|
ValueError,
|
||||||
|
msgspec.json.DecodeError,
|
||||||
|
) as e:
|
||||||
|
replace = True
|
||||||
|
logger.warning(
|
||||||
|
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||||
|
)
|
||||||
|
raise PreviewError(
|
||||||
|
f"worker protocol failure for {filepath.name}: {e}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if replace:
|
||||||
|
await self._replace_worker(worker)
|
||||||
|
else:
|
||||||
|
if worker.proc.returncode is None:
|
||||||
|
await self._idle.put(worker)
|
||||||
|
else:
|
||||||
|
await self._replace_worker(worker)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self._closed = True
|
||||||
|
workers = list(self._workers)
|
||||||
|
self._workers.clear()
|
||||||
|
while not self._idle.empty():
|
||||||
|
try:
|
||||||
|
self._idle.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
await asyncio.gather(
|
||||||
|
*(worker.kill() for worker in workers), return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_preview_workers() -> None:
|
||||||
|
"""Warm up persistent preview workers during server startup."""
|
||||||
|
global _preview_pool
|
||||||
|
if _preview_pool is not None:
|
||||||
|
return
|
||||||
|
async with _preview_pool_lock:
|
||||||
|
if _preview_pool is not None:
|
||||||
|
return
|
||||||
|
pool = _PreviewWorkerPool(PREVIEW_WORKERS)
|
||||||
|
await pool.start()
|
||||||
|
_preview_pool = pool
|
||||||
|
logger.info("Started %d persistent preview workers", PREVIEW_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_preview_workers() -> None:
|
||||||
|
"""Kill persistent preview workers (called during server shutdown)."""
|
||||||
|
global _preview_pool
|
||||||
|
async with _preview_pool_lock:
|
||||||
|
pool = _preview_pool
|
||||||
|
_preview_pool = None
|
||||||
|
if pool is not None:
|
||||||
|
await pool.close()
|
||||||
|
if not _active_procs:
|
||||||
|
return
|
||||||
|
for proc in list(_active_procs):
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
pass
|
||||||
|
await asyncio.gather(
|
||||||
|
*(proc.wait() for proc in list(_active_procs)), return_exceptions=True
|
||||||
|
)
|
||||||
|
_active_procs.clear()
|
||||||
|
|
||||||
|
|
||||||
@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."""
|
||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
|
|
||||||
|
|
||||||
# Map EXIF Orientation value to a corresponding PIL transpose
|
class PreviewTimeout(Exception):
|
||||||
EXIF_ORI = {
|
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
|
||||||
3: Image.Transpose.ROTATE_180,
|
|
||||||
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
class PreviewError(Exception):
|
||||||
5: Image.Transpose.TRANSPOSE,
|
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||||
6: Image.Transpose.ROTATE_270,
|
|
||||||
7: Image.Transpose.TRANSVERSE,
|
def __init__(
|
||||||
8: Image.Transpose.ROTATE_90,
|
self,
|
||||||
}
|
message: str,
|
||||||
|
*,
|
||||||
|
stderr: str | None = None,
|
||||||
|
backend: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.stderr = stderr
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_preview_process(
|
||||||
|
filepath, quality: int, maxsize: int, maxzoom: float
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Run preview request in a persistent worker process."""
|
||||||
|
await start_preview_workers()
|
||||||
|
if _preview_pool is None:
|
||||||
|
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
||||||
|
return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
|
||||||
|
|
||||||
|
|
||||||
|
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
||||||
|
|
||||||
|
|
||||||
|
def is_previewable_path(path) -> bool:
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
|
return True
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
if not mime_type:
|
||||||
|
return False
|
||||||
|
return mime_type.startswith("image/") or mime_type.startswith("video/")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<path:path>")
|
@bp.get("/<path:path>")
|
||||||
@@ -51,82 +338,122 @@ async def preview(req, path):
|
|||||||
quality = int(req.args.get("q", 60))
|
quality = int(req.args.get("q", 60))
|
||||||
rel = PurePosixPath(sanitize(unquote(path)))
|
rel = PurePosixPath(sanitize(unquote(path)))
|
||||||
filepath = config.config.path / rel
|
filepath = config.config.path / rel
|
||||||
stat = filepath.lstat()
|
try:
|
||||||
|
stat = filepath.lstat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise NotFound() from None
|
||||||
|
|
||||||
|
if not is_previewable_path(filepath):
|
||||||
|
return empty(415)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
try:
|
||||||
|
img, preview_resp = await _run_preview_process(
|
||||||
|
filepath, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
|
except PreviewTimeout:
|
||||||
|
return empty(504)
|
||||||
|
except PreviewError as e:
|
||||||
|
if e.backend:
|
||||||
|
req.ctx._log_extra = e.backend
|
||||||
|
detail = str(e)
|
||||||
|
if detail == "preview worker error" and e.stderr:
|
||||||
|
captured = e.stderr.strip()
|
||||||
|
if captured:
|
||||||
|
detail = captured.splitlines()[0]
|
||||||
|
logger.error("%s preview: %s", filepath, detail)
|
||||||
|
return empty(422)
|
||||||
|
if preview_resp and preview_resp.backend:
|
||||||
|
if preview_resp.timings:
|
||||||
|
timing_detail = "/".join(
|
||||||
|
str(int(round(value))) for value in preview_resp.timings
|
||||||
|
)
|
||||||
|
req.ctx._log_extra = f"{preview_resp.backend} {timing_detail} ➛"
|
||||||
|
else:
|
||||||
|
req.ctx._log_extra = preview_resp.backend
|
||||||
|
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
|
||||||
|
preview_mime = (
|
||||||
|
preview_resp.mime
|
||||||
|
if preview_resp is not None and preview_resp.mime is not None
|
||||||
|
else "image/avif"
|
||||||
|
)
|
||||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
"last-modified": format_date_time(stat.st_mtime),
|
"last-modified": format_date_time(stat.st_mtime),
|
||||||
"cache-control": "max-age=604800, immutable"
|
"cache-control": "max-age=604800, immutable"
|
||||||
+ ("" if config.config.public else ", private"),
|
+ ("" if config.config.public else ", private"),
|
||||||
"content-type": "image/avif",
|
"content-type": preview_mime,
|
||||||
"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)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom):
|
def dispatch(path, quality, maxsize, maxzoom):
|
||||||
|
backend = "unknown"
|
||||||
try:
|
try:
|
||||||
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
|
if path.suffix.lower() in DOC_PREVIEW_SUFFIXES:
|
||||||
|
backend = "pdf"
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
type, _ = mimetypes.guess_type(path.name)
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
if type and type.startswith("video/"):
|
if mime_type and mime_type.startswith("video/"):
|
||||||
|
backend = "video"
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
if mime_type and mime_type.startswith("image/"):
|
||||||
|
backend = "pyvips"
|
||||||
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
logger.warning(f"Cannot generate preview for {path}: {e}")
|
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"Error generating preview for {path}: {e}")
|
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
||||||
|
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
|
||||||
|
|
||||||
|
|
||||||
def process_image(path, *, maxsize, quality):
|
def process_image(path, *, maxsize, quality):
|
||||||
t_load = perf_counter()
|
return process_image_pyvips(path, maxsize=maxsize, quality=quality)
|
||||||
with Image.open(path) as img:
|
|
||||||
# Force decode to include I/O in load timing
|
|
||||||
img.load()
|
|
||||||
t_proc = perf_counter()
|
|
||||||
# Resize
|
|
||||||
w, h = img.size
|
|
||||||
img.thumbnail((min(w, maxsize), min(h, maxsize)))
|
|
||||||
# Transpose pixels according to EXIF Orientation
|
|
||||||
orientation = img.getexif().get(274, 1)
|
|
||||||
if orientation in EXIF_ORI:
|
|
||||||
img = img.transpose(EXIF_ORI[orientation])
|
|
||||||
# Save as AVIF
|
|
||||||
imgdata = io.BytesIO()
|
|
||||||
t_save = perf_counter()
|
|
||||||
img.save(imgdata, format="avif", quality=quality, speed=10, max_threads=1)
|
|
||||||
|
|
||||||
t_end = perf_counter()
|
|
||||||
ret = imgdata.getvalue()
|
|
||||||
|
|
||||||
load_ms = (t_proc - t_load) * 1000
|
def process_image_pyvips(path, *, maxsize, quality):
|
||||||
proc_ms = (t_save - t_proc) * 1000
|
t_start = perf_counter()
|
||||||
save_ms = (t_end - t_save) * 1000
|
img = pyvips.Image.new_from_file(str(path), access="sequential")
|
||||||
logger.debug(
|
img = img.autorot()
|
||||||
"Preview image %s: load=%.1fms process=%.1fms save=%.1fms",
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
path.name,
|
if scale < 1.0:
|
||||||
load_ms,
|
img = img.resize(scale)
|
||||||
proc_ms,
|
ret = img.write_to_buffer(
|
||||||
save_ms,
|
".avif",
|
||||||
|
Q=quality,
|
||||||
|
effort=AVIF_FAST_EFFORT,
|
||||||
|
strip=True,
|
||||||
)
|
)
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
return ret
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="pyvips",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
@@ -140,16 +467,24 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
ret = pix.pil_tobytes(format="avif", quality=quality, speed=10, max_threads=1)
|
img = pyvips.Image.new_from_memory(
|
||||||
|
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||||
|
)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True
|
||||||
|
)
|
||||||
|
backend = "pdf+pyvips"
|
||||||
t_save_end = perf_counter()
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
logger.debug(
|
return ret, PreviewResponse(
|
||||||
"Preview pdf %s: load+render=%.1fms save=%.1fms",
|
ok=True,
|
||||||
path.name,
|
mime="image/avif",
|
||||||
(t_load_end - t_load_start) * 1000,
|
backend=backend,
|
||||||
(t_save_end - t_save_start) * 1000,
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def process_video(path, *, maxsize, quality):
|
def process_video(path, *, maxsize, quality):
|
||||||
@@ -162,7 +497,13 @@ def process_video(path, *, maxsize, quality):
|
|||||||
t_save_start = t_load_start
|
t_save_start = t_load_start
|
||||||
t_save_end = t_load_start
|
t_save_end = t_load_start
|
||||||
with (
|
with (
|
||||||
av.open(str(path)) as icontainer,
|
av.open(
|
||||||
|
str(path),
|
||||||
|
options={
|
||||||
|
"analyzeduration": "1000000", # 1 second (in microseconds)
|
||||||
|
"fflags": "fastseek",
|
||||||
|
},
|
||||||
|
) as icontainer,
|
||||||
av.open(imgdata, "w", format="avif") as ocontainer,
|
av.open(imgdata, "w", format="avif") as ocontainer,
|
||||||
):
|
):
|
||||||
istream = icontainer.streams.video[0]
|
istream = icontainer.streams.video[0]
|
||||||
@@ -254,14 +595,17 @@ def process_video(path, *, maxsize, quality):
|
|||||||
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
ocontainer.mux(ostream.encode(None)) # Flush the stream
|
||||||
t_save_end = perf_counter()
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
# Capture frame dimensions before cleanup
|
# Capture result before cleanup
|
||||||
ret = imgdata.getvalue()
|
ret = imgdata.getvalue()
|
||||||
logger.debug(
|
resp = PreviewResponse(
|
||||||
"Preview video %s: load+decode=%.1fms save=%.1fms",
|
ok=True,
|
||||||
path.name,
|
mime="image/avif",
|
||||||
(t_load_end - t_load_start) * 1000,
|
backend="video",
|
||||||
(t_save_end - t_save_start) * 1000,
|
timings=[
|
||||||
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
del imgdata, istream, ostream, icc, occ, frame
|
del imgdata, istream, ostream, icc, occ, frame
|
||||||
gc.collect()
|
gc.collect()
|
||||||
return ret
|
return ret, resp
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Preview generation worker subprocess.
|
||||||
|
|
||||||
|
Two modes are supported:
|
||||||
|
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||||
|
2) Long-lived mode: read JSONL commands from stdin and write framed responses.
|
||||||
|
|
||||||
|
Framed response format:
|
||||||
|
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||||
|
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
from blake3 import blake3
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewRequest(msgspec.Struct, omit_defaults=True):
|
||||||
|
path: str
|
||||||
|
quality: int
|
||||||
|
maxsize: int
|
||||||
|
maxzoom: float
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||||
|
ok: bool
|
||||||
|
mime: str | None = None
|
||||||
|
backend: str | None = None
|
||||||
|
timings: list[float] | None = None
|
||||||
|
error: str | None = None
|
||||||
|
stderr: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_enc = msgspec.json.Encoder()
|
||||||
|
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||||
|
meta_bytes = _enc.encode(resp)
|
||||||
|
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||||
|
checksum = blake3(packet).digest()
|
||||||
|
sys.stdout.buffer.write(checksum)
|
||||||
|
sys.stdout.buffer.write(packet)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_once() -> None:
|
||||||
|
if len(sys.argv) != 5:
|
||||||
|
sys.stderr.write(f"Usage: {sys.argv[0]} <path> <quality> <maxsize> <maxzoom>\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
from cista.preview import dispatch
|
||||||
|
|
||||||
|
path = Path(sys.argv[1])
|
||||||
|
quality = int(sys.argv[2])
|
||||||
|
maxsize = int(sys.argv[3])
|
||||||
|
maxzoom = float(sys.argv[4])
|
||||||
|
result, _ = dispatch(path, quality, maxsize, maxzoom)
|
||||||
|
if result:
|
||||||
|
sys.stdout.buffer.write(result)
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_loop() -> None:
|
||||||
|
from cista.preview import dispatch
|
||||||
|
|
||||||
|
while True:
|
||||||
|
line = sys.stdin.buffer.readline()
|
||||||
|
if not line:
|
||||||
|
return
|
||||||
|
stderr_capture = io.StringIO()
|
||||||
|
handler = logging.StreamHandler(stderr_capture)
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stderr(stderr_capture):
|
||||||
|
req = _dec_req.decode(line)
|
||||||
|
result, resp = dispatch(
|
||||||
|
Path(req.path), req.quality, req.maxsize, req.maxzoom
|
||||||
|
)
|
||||||
|
if not resp.ok:
|
||||||
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
if captured:
|
||||||
|
resp = PreviewResponse(
|
||||||
|
ok=False,
|
||||||
|
backend=resp.backend,
|
||||||
|
error=resp.error,
|
||||||
|
stderr=captured,
|
||||||
|
)
|
||||||
|
_write_response(resp, result or b"")
|
||||||
|
except Exception as e:
|
||||||
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
_write_response(
|
||||||
|
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
root_logger.removeHandler(handler)
|
||||||
|
handler.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
# Configure all log output to stderr before any imports that may emit logs.
|
||||||
|
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
_run_once()
|
||||||
|
return
|
||||||
|
_run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+4
-13
@@ -12,7 +12,6 @@ from cista.util import filename
|
|||||||
|
|
||||||
## Control commands
|
## Control commands
|
||||||
|
|
||||||
|
|
||||||
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
||||||
def __call__(self):
|
def __call__(self):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -118,19 +117,9 @@ class Cp(ControlBase):
|
|||||||
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
ControlTypes = MkDir | Rename | Rm | Mv | Cp
|
||||||
|
|
||||||
|
|
||||||
## File uploads and downloads
|
|
||||||
|
|
||||||
|
|
||||||
class FileRange(msgspec.Struct):
|
|
||||||
name: str
|
|
||||||
size: int
|
|
||||||
start: int
|
|
||||||
end: int
|
|
||||||
|
|
||||||
|
|
||||||
class StatusMsg(msgspec.Struct):
|
class StatusMsg(msgspec.Struct):
|
||||||
status: str
|
status: str
|
||||||
req: FileRange
|
req: Any
|
||||||
|
|
||||||
|
|
||||||
class ErrorMsg(msgspec.Struct):
|
class ErrorMsg(msgspec.Struct):
|
||||||
@@ -146,6 +135,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):
|
||||||
@@ -177,5 +167,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
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Custom access logging middleware for Sanic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from ipaddress import IPv6Address
|
||||||
|
|
||||||
|
logger = logging.getLogger("cista.access")
|
||||||
|
|
||||||
|
_RESET = "\033[0m"
|
||||||
|
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||||
|
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||||
|
_STATUS_REDIRECT = "\033[32m" # 3xx (green)
|
||||||
|
_STATUS_CLIENT_ERR = "\033[0;31m" # 4xx (red)
|
||||||
|
_STATUS_SERVER_ERR = "\033[1;91m" # 5xx (bold bright red)
|
||||||
|
_METHOD_READ = "\033[0;34m" # GET, HEAD, OPTIONS (blue)
|
||||||
|
_METHOD_WRITE = "\033[1;94m" # POST, PUT, DELETE, PATCH (bold bright blue)
|
||||||
|
_HOST = "\033[38;5;242m" # hostname (dark grey)
|
||||||
|
_PATH = "\033[38;5;250m" # path (light grey)
|
||||||
|
_TIMING = "\033[38;5;242m" # timing (dark grey)
|
||||||
|
_WS_OPEN = "\033[1;93m" # WebSocket connect (bold bright yellow)
|
||||||
|
_WS_CLOSE = "\033[33m" # WebSocket disconnect (yellow)
|
||||||
|
_WS_STATUS = "\033[38;5;250m" # WebSocket close status (normal white)
|
||||||
|
|
||||||
|
|
||||||
|
def format_ipv6_network(ip: str) -> str:
|
||||||
|
"""Format IPv6 address to show only network part (first 64 bits)."""
|
||||||
|
try:
|
||||||
|
ip = ip.strip("[]")
|
||||||
|
if "%" in ip:
|
||||||
|
ip = ip.split("%")[0]
|
||||||
|
addr = IPv6Address(ip)
|
||||||
|
if addr.is_loopback:
|
||||||
|
return "::1"
|
||||||
|
if addr.is_unspecified:
|
||||||
|
return "::"
|
||||||
|
if addr.ipv4_mapped:
|
||||||
|
return str(addr.ipv4_mapped)
|
||||||
|
if addr.is_link_local:
|
||||||
|
return str(addr)
|
||||||
|
network_int = int(addr) >> 64
|
||||||
|
groups = []
|
||||||
|
for _ in range(4):
|
||||||
|
groups.insert(0, format(network_int & 0xFFFF, "x"))
|
||||||
|
network_int >>= 16
|
||||||
|
result = ":".join(groups) + "::"
|
||||||
|
return str(IPv6Address(result + "0")).removesuffix("::")
|
||||||
|
except Exception:
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
def format_client_ip(ip: str) -> str:
|
||||||
|
"""Format client IP, compressing IPv6 to network part only."""
|
||||||
|
if not ip or ip == "-":
|
||||||
|
return "-"
|
||||||
|
stripped = ip.strip("[]")
|
||||||
|
if ":" in stripped:
|
||||||
|
return format_ipv6_network(stripped)
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
|
def status_color(status: int) -> str:
|
||||||
|
if status < 200:
|
||||||
|
return _STATUS_INFO
|
||||||
|
if status < 300:
|
||||||
|
return _STATUS_OK
|
||||||
|
if status < 400:
|
||||||
|
return _STATUS_REDIRECT
|
||||||
|
if status < 500:
|
||||||
|
return _STATUS_CLIENT_ERR
|
||||||
|
return _STATUS_SERVER_ERR
|
||||||
|
|
||||||
|
|
||||||
|
def method_color(method: str) -> str:
|
||||||
|
if method in ("GET", "HEAD", "OPTIONS"):
|
||||||
|
return _METHOD_READ
|
||||||
|
return _METHOD_WRITE
|
||||||
|
|
||||||
|
|
||||||
|
def format_duration_ms(duration_ms: float) -> str:
|
||||||
|
rounded_ms = round(duration_ms)
|
||||||
|
if rounded_ms < 2000:
|
||||||
|
return f"{rounded_ms}ms"
|
||||||
|
total_s = round(duration_ms / 1000)
|
||||||
|
if total_s < 60:
|
||||||
|
return f"{total_s}s"
|
||||||
|
if total_s <= 3600:
|
||||||
|
minutes, seconds = divmod(total_s, 60)
|
||||||
|
return f"{minutes}m{seconds}s"
|
||||||
|
hours, remainder = divmod(total_s, 3600)
|
||||||
|
minutes = round(remainder / 60)
|
||||||
|
if minutes == 60:
|
||||||
|
hours += 1
|
||||||
|
minutes = 0
|
||||||
|
return f"{hours}h{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
def _display_width(text: str) -> int:
|
||||||
|
width = 0
|
||||||
|
for char in text:
|
||||||
|
width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
|
||||||
|
return width
|
||||||
|
|
||||||
|
|
||||||
|
def _format_left(label: str) -> str:
|
||||||
|
return label[:19].ljust(19)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_method_label(label: str, *, color: str | None = None) -> str:
|
||||||
|
color_value = _METHOD_WRITE if color is None else color
|
||||||
|
padding = max(0, 7 - _display_width(label))
|
||||||
|
return f"{color_value}{label}{' ' * padding}{_RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_access_log(
|
||||||
|
client: str, status: int, method: str, host: str, path: str, duration_ms: float,
|
||||||
|
extra: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
ip = _format_left(format_client_ip(client))
|
||||||
|
status_str = f"{status_color(status)}{str(status).rjust(3)}{_RESET}"
|
||||||
|
method_str = _format_method_label(method, color=method_color(method))
|
||||||
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
|
timing_str = f"{_TIMING}{format_duration_ms(duration_ms)}{_RESET}"
|
||||||
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
return f"{ip} {status_str} {method_str} {host_str}{path_str}{extra_str} {timing_str}"
|
||||||
|
|
||||||
|
|
||||||
|
_ws_counter = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _next_ws_id() -> int:
|
||||||
|
global _ws_counter
|
||||||
|
ws_id = _ws_counter
|
||||||
|
_ws_counter += 1
|
||||||
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
|
def _format_ws_id(ws_id: int, *, bright: bool = False) -> str:
|
||||||
|
value = str(ws_id) if ws_id >= 100 else f"{ws_id:02d}"
|
||||||
|
color = _WS_OPEN if bright else _WS_CLOSE
|
||||||
|
return f"{color}{value.rjust(3)}{_RESET}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_ws_open(request, extra: str | None = None) -> int:
|
||||||
|
"""Log WebSocket connection open. Returns connection ID for use in log_ws_close."""
|
||||||
|
ws_id = _next_ws_id()
|
||||||
|
|
||||||
|
client = request.client_ip or "-"
|
||||||
|
host = request.host or "-"
|
||||||
|
path = request.path
|
||||||
|
origin = request.headers.get("origin")
|
||||||
|
|
||||||
|
ip = _format_left(format_client_ip(client))
|
||||||
|
id_str = _format_ws_id(ws_id, bright=True)
|
||||||
|
|
||||||
|
origin_host = origin.split("://", 1)[-1] if origin else None
|
||||||
|
show_origin = origin_host and origin_host != host
|
||||||
|
|
||||||
|
method_str = _format_method_label("🔌", color=_WS_OPEN)
|
||||||
|
host_str = f"{_HOST}{host}{_RESET}"
|
||||||
|
path_str = f"{_PATH}{path}{_RESET}"
|
||||||
|
origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
|
||||||
|
extra_str = f" {_TIMING}{extra}{_RESET}" if extra else ""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s %s %s %s%s%s",
|
||||||
|
ip,
|
||||||
|
id_str,
|
||||||
|
method_str,
|
||||||
|
host_str,
|
||||||
|
path_str,
|
||||||
|
origin_str + extra_str,
|
||||||
|
)
|
||||||
|
return ws_id
|
||||||
|
|
||||||
|
|
||||||
|
WS_CLOSE_CODES = {
|
||||||
|
1000: "ok",
|
||||||
|
1001: "going away",
|
||||||
|
1002: "protocol error",
|
||||||
|
1003: "unsupported",
|
||||||
|
1005: "no status",
|
||||||
|
1006: "abnormal",
|
||||||
|
1007: "invalid data",
|
||||||
|
1008: "policy violation",
|
||||||
|
1009: "too large",
|
||||||
|
1010: "extension required",
|
||||||
|
1011: "server error",
|
||||||
|
1012: "restarting",
|
||||||
|
1013: "try again",
|
||||||
|
1014: "bad gateway",
|
||||||
|
1015: "tls error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
|
||||||
|
"""Log WebSocket connection close with duration and status."""
|
||||||
|
id_str = _format_ws_id(ws_id)
|
||||||
|
timing = format_duration_ms(duration * 1000)
|
||||||
|
|
||||||
|
if close_code is None:
|
||||||
|
code = "----"
|
||||||
|
status = "unknown"
|
||||||
|
else:
|
||||||
|
code = str(close_code)
|
||||||
|
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
|
||||||
|
|
||||||
|
method_str = _format_method_label("closed", color=_TIMING)
|
||||||
|
status_str = f"{_WS_STATUS}{code} {status}{_RESET}"
|
||||||
|
timing_str = f"{_TIMING}{timing}{_RESET}"
|
||||||
|
|
||||||
|
logger.info("%s %s %s %s %s", " " * 19, id_str, method_str, status_str, timing_str)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_access_logging() -> None:
|
||||||
|
"""Configure the cista.access logger to output to stderr."""
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||||
|
logger.addHandler(handler)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
_LEVEL_EMOJI = {
|
||||||
|
logging.DEBUG: "🔍",
|
||||||
|
logging.INFO: "ℹ️",
|
||||||
|
logging.WARNING: "⚠️",
|
||||||
|
logging.ERROR: "🛑",
|
||||||
|
logging.CRITICAL: "🛑",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _EmojiFormatter(logging.Formatter):
|
||||||
|
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
emoji = _LEVEL_EMOJI.get(record.levelno, "▪️")
|
||||||
|
sep = " " if record.levelno in (logging.INFO, logging.WARNING) else " "
|
||||||
|
return f"{emoji}{sep}{record.getMessage()}"
|
||||||
|
|
||||||
|
|
||||||
|
def configure_main_logging() -> None:
|
||||||
|
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
|
||||||
|
|
||||||
|
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||||
|
call Sanic makes during serve_single() / serve().
|
||||||
|
"""
|
||||||
|
from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||||
|
|
||||||
|
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
||||||
|
"class": "cista.sanic_logging._EmojiFormatter",
|
||||||
|
}
|
||||||
|
# Also reformat any handlers already attached (covers the initial Sanic() call)
|
||||||
|
for name in ("sanic.root", "sanic.error", "sanic.server", "sanic.websockets"):
|
||||||
|
for handler in logging.getLogger(name).handlers:
|
||||||
|
handler.setFormatter(_EmojiFormatter())
|
||||||
+2
-7
@@ -27,7 +27,7 @@ def run(*, dev=False):
|
|||||||
motd=False,
|
motd=False,
|
||||||
dev=dev,
|
dev=dev,
|
||||||
auto_reload=dev,
|
auto_reload=dev,
|
||||||
access_log=True,
|
access_log=False,
|
||||||
) # type: ignore
|
) # type: ignore
|
||||||
if dev:
|
if dev:
|
||||||
Sanic.serve()
|
Sanic.serve()
|
||||||
@@ -62,11 +62,6 @@ def parse_listen(listen):
|
|||||||
return "http://localhost", {"unix": unix.as_posix()}
|
return "http://localhost", {"unix": unix.as_posix()}
|
||||||
|
|
||||||
host, port = ep["host"], ep["port"]
|
host, port = ep["host"], ep["port"]
|
||||||
# When binding all interfaces, use single_listener=False for Sanic
|
|
||||||
if len(endpoints) > 1:
|
if len(endpoints) > 1:
|
||||||
return f"http://localhost:{port}", {
|
return f"http://localhost:{port}", {"host": host, "port": port}
|
||||||
"host": host,
|
|
||||||
"port": port,
|
|
||||||
"single_listener": False,
|
|
||||||
}
|
|
||||||
return f"http://{host}:{port}", {"host": host, "port": port}
|
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||||
|
|||||||
+30
-38
@@ -15,7 +15,8 @@ import re
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
from sanic import Blueprint
|
from sanic import Blueprint, json
|
||||||
|
from sanic import raw as raw_response
|
||||||
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
from sanic.exceptions import Forbidden, SanicException, Unauthorized
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ async def get_client() -> httpx.AsyncClient:
|
|||||||
global _client
|
global _client
|
||||||
if _client is None or _client.is_closed:
|
if _client is None or _client.is_closed:
|
||||||
_client = httpx.AsyncClient(timeout=1.0)
|
_client = httpx.AsyncClient(timeout=1.0)
|
||||||
|
if "user-agent" in _client.headers:
|
||||||
|
del _client.headers["user-agent"] # No httpx UA
|
||||||
return _client
|
return _client
|
||||||
|
|
||||||
|
|
||||||
@@ -171,10 +174,10 @@ async def proxy_auth_request(request):
|
|||||||
"upgrade",
|
"upgrade",
|
||||||
"proxy-authorization",
|
"proxy-authorization",
|
||||||
"proxy-authenticate",
|
"proxy-authenticate",
|
||||||
"forwarded",
|
|
||||||
"x-forwarded-for",
|
"x-forwarded-for",
|
||||||
"x-forwarded-host",
|
"x-forwarded-host",
|
||||||
"x-forwarded-proto",
|
"x-forwarded-proto",
|
||||||
|
"forwarded",
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = [
|
headers = [
|
||||||
@@ -182,9 +185,17 @@ async def proxy_auth_request(request):
|
|||||||
for key, value in request.headers.items()
|
for key, value in request.headers.items()
|
||||||
if key.lower() not in skip_headers
|
if key.lower() not in skip_headers
|
||||||
]
|
]
|
||||||
headers.append(("x-forwarded-for", request.client_ip))
|
|
||||||
|
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
|
||||||
|
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
|
||||||
headers.append(("x-forwarded-host", request.host))
|
headers.append(("x-forwarded-host", request.host))
|
||||||
headers.append(("x-forwarded-proto", request.scheme))
|
headers.append(("x-forwarded-proto", request.scheme))
|
||||||
|
headers.append(
|
||||||
|
(
|
||||||
|
"forwarded",
|
||||||
|
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
@@ -210,8 +221,6 @@ async def proxy_auth_request(request):
|
|||||||
if key.lower() not in resp_hop_by_hop
|
if key.lower() not in resp_hop_by_hop
|
||||||
]
|
]
|
||||||
|
|
||||||
from sanic import raw as raw_response
|
|
||||||
|
|
||||||
return raw_response(
|
return raw_response(
|
||||||
raw_content,
|
raw_content,
|
||||||
status=response.status_code,
|
status=response.status_code,
|
||||||
@@ -221,35 +230,31 @@ async def proxy_auth_request(request):
|
|||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"Auth proxy request failed: {e}")
|
logger.error(f"Auth proxy request failed: {e}")
|
||||||
from sanic import json
|
|
||||||
|
|
||||||
return json(
|
return json(
|
||||||
{"detail": "Authentication service unavailable", "error": str(e)},
|
{"detail": "Authentication service unavailable"},
|
||||||
status=503,
|
status=503,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def proxy_auth_websocket(request, ws):
|
async def proxy_auth_websocket(request, ws):
|
||||||
"""Proxy a WebSocket connection to the auth backend."""
|
"""Proxy a WebSocket connection to the auth backend."""
|
||||||
path = request.path
|
url = f"ws{PASKIA_BACKEND_URL.removeprefix('http')}{request.path}"
|
||||||
query_string = request.query_string
|
if request.query_string:
|
||||||
ws_backend = PASKIA_BACKEND_URL.replace("http://", "ws://").replace(
|
url = f"{url}?{request.query_string}"
|
||||||
"https://", "wss://"
|
|
||||||
)
|
|
||||||
url = f"{ws_backend}{path}"
|
|
||||||
if query_string:
|
|
||||||
url = f"{url}?{query_string}"
|
|
||||||
|
|
||||||
additional_headers = {}
|
additional_headers = {}
|
||||||
if "cookie" in request.headers:
|
if "cookie" in request.headers:
|
||||||
additional_headers["cookie"] = request.headers["cookie"]
|
additional_headers["cookie"] = request.headers["cookie"]
|
||||||
if "authorization" in request.headers:
|
if "authorization" in request.headers:
|
||||||
additional_headers["authorization"] = request.headers["authorization"]
|
additional_headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "host" in request.headers:
|
||||||
|
additional_headers["host"] = request.headers["host"]
|
||||||
if "origin" in request.headers:
|
if "origin" in request.headers:
|
||||||
additional_headers["origin"] = request.headers["origin"]
|
additional_headers["origin"] = request.headers["origin"]
|
||||||
if "user-agent" in request.headers:
|
if "user-agent" in request.headers:
|
||||||
additional_headers["user-agent"] = request.headers["user-agent"]
|
additional_headers["user-agent"] = request.headers["user-agent"]
|
||||||
additional_headers["x-forwarded-for"] = request.ip
|
additional_headers["x-forwarded-for"] = request.client_ip.strip("[]")
|
||||||
additional_headers["x-forwarded-host"] = request.host
|
additional_headers["x-forwarded-host"] = request.host
|
||||||
additional_headers["x-forwarded-proto"] = request.scheme
|
additional_headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
@@ -281,23 +286,20 @@ async def proxy_auth_websocket(request, ws):
|
|||||||
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
logger.error(f"WebSocket proxy to {url} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _is_websocket_request(request) -> bool:
|
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
||||||
"""Check if the request is a WebSocket upgrade request."""
|
bp = Blueprint("sso", url_prefix="/auth")
|
||||||
connection = request.headers.get("connection", "").lower()
|
|
||||||
upgrade = request.headers.get("upgrade", "").lower()
|
|
||||||
connection_tokens = [t.strip() for t in connection.split(",")]
|
|
||||||
return "upgrade" in connection_tokens and upgrade == "websocket"
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_websocket_upgrade(request):
|
@bp.websocket("/ws/<path:path>")
|
||||||
"""Handle WebSocket upgrade and proxy the connection."""
|
async def auth_websocket_proxy(request, ws, path=""):
|
||||||
protocol = request.transport.get_protocol()
|
"""Proxy WebSocket connections to the auth backend."""
|
||||||
ws = await protocol.websocket_handshake(request, subprotocols=None)
|
|
||||||
await proxy_auth_websocket(request, ws)
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
# Blueprint for auth proxy routes (only registered when paskia_enabled())
|
@bp.websocket("/ws/")
|
||||||
bp = Blueprint("sso", url_prefix="/auth")
|
async def auth_websocket_proxy_root(request, ws):
|
||||||
|
"""Proxy root WebSocket connections to the auth backend."""
|
||||||
|
await proxy_auth_websocket(request, ws)
|
||||||
|
|
||||||
|
|
||||||
@bp.route(
|
@bp.route(
|
||||||
@@ -305,20 +307,10 @@ bp = Blueprint("sso", url_prefix="/auth")
|
|||||||
)
|
)
|
||||||
async def auth_proxy(request, path=""):
|
async def auth_proxy(request, path=""):
|
||||||
"""Proxy all auth requests to the auth backend."""
|
"""Proxy all auth requests to the auth backend."""
|
||||||
if _is_websocket_request(request):
|
|
||||||
await _handle_websocket_upgrade(request)
|
|
||||||
from sanic import empty
|
|
||||||
|
|
||||||
return empty()
|
|
||||||
return await proxy_auth_request(request)
|
return await proxy_auth_request(request)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
@bp.route("/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"])
|
||||||
async def auth_proxy_root(request):
|
async def auth_proxy_root(request):
|
||||||
"""Proxy root auth requests to the auth backend."""
|
"""Proxy root auth requests to the auth backend."""
|
||||||
if _is_websocket_request(request):
|
|
||||||
await _handle_websocket_upgrade(request)
|
|
||||||
from sanic import empty
|
|
||||||
|
|
||||||
return empty()
|
|
||||||
return await proxy_auth_request(request)
|
return await proxy_auth_request(request)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
@@ -8,6 +9,7 @@ from sanic.response import raw, redirect
|
|||||||
|
|
||||||
from cista import auth
|
from cista import auth
|
||||||
from cista.protocol import ErrorMsg
|
from cista.protocol import ErrorMsg
|
||||||
|
from cista.sanic_logging import log_ws_close, log_ws_open
|
||||||
|
|
||||||
|
|
||||||
def asend(ws, msg):
|
def asend(ws, msg):
|
||||||
@@ -54,6 +56,10 @@ def websocket_wrapper(handler):
|
|||||||
|
|
||||||
@wraps(handler)
|
@wraps(handler)
|
||||||
async def wrapper(request, ws, *args, **kwargs):
|
async def wrapper(request, ws, *args, **kwargs):
|
||||||
|
username = getattr(request.ctx, "username", None)
|
||||||
|
extra = username if username else None
|
||||||
|
start = time.perf_counter()
|
||||||
|
ws_id = log_ws_open(request, extra=extra)
|
||||||
try:
|
try:
|
||||||
await auth.verify(request)
|
await auth.verify(request)
|
||||||
await handler(request, ws, *args, **kwargs)
|
await handler(request, ws, *args, **kwargs)
|
||||||
@@ -67,5 +73,19 @@ def websocket_wrapper(handler):
|
|||||||
if not getattr(e, "quiet", False) or code == 500:
|
if not getattr(e, "quiet", False) or code == 500:
|
||||||
logger.exception(f"{code} {e!r}")
|
logger.exception(f"{code} {e!r}")
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
duration = time.perf_counter() - start
|
||||||
|
close_code = None
|
||||||
|
try:
|
||||||
|
p = ws.ws_proto
|
||||||
|
if p.close_rcvd is not None:
|
||||||
|
close_code = p.close_rcvd.code
|
||||||
|
elif p.close_sent is not None:
|
||||||
|
close_code = p.close_sent.code
|
||||||
|
elif getattr(p, "close_code", None) is not None:
|
||||||
|
close_code = p.close_code
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
log_ws_close(ws_id, close_code, duration)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
+102
-3
@@ -17,6 +17,33 @@ from cista import config
|
|||||||
from cista.fileio import fuid
|
from cista.fileio import fuid
|
||||||
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
|
||||||
|
|
||||||
|
# Platform-specific allocated size calculation
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ctypes
|
||||||
|
from ctypes import wintypes
|
||||||
|
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
GetCompressedFileSizeW = kernel32.GetCompressedFileSizeW
|
||||||
|
GetCompressedFileSizeW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
|
||||||
|
GetCompressedFileSizeW.restype = wintypes.DWORD
|
||||||
|
INVALID_FILE_SIZE = 0xFFFFFFFF
|
||||||
|
|
||||||
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
|
"""Get actual disk allocation on Windows using GetCompressedFileSizeW."""
|
||||||
|
high = wintypes.DWORD()
|
||||||
|
low = GetCompressedFileSizeW(str(path), ctypes.byref(high))
|
||||||
|
if low == INVALID_FILE_SIZE and ctypes.get_last_error() != 0:
|
||||||
|
raise OSError(f"GetCompressedFileSizeW failed for {path}")
|
||||||
|
return (high.value << 32) + low
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def get_allocated_size(path: Path, st: stat_result) -> int:
|
||||||
|
"""Get actual disk allocation on Unix using st_blocks."""
|
||||||
|
# st_blocks is in 512-byte units
|
||||||
|
return st.st_blocks * 512
|
||||||
|
|
||||||
|
|
||||||
pubsub = {}
|
pubsub = {}
|
||||||
sortkey = natsort_keygen(alg=ns.LOCALE)
|
sortkey = natsort_keygen(alg=ns.LOCALE)
|
||||||
|
|
||||||
@@ -24,7 +51,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
|
||||||
@@ -148,12 +175,18 @@ 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))
|
||||||
|
try:
|
||||||
|
allocated = get_allocated_size(path, st) if isfile else 0
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"get_allocated_size failed for {path}")
|
||||||
|
allocated = st.st_size 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:
|
||||||
@@ -181,8 +214,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)
|
||||||
@@ -227,7 +261,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)
|
||||||
@@ -504,8 +545,66 @@ class PathIndex:
|
|||||||
|
|
||||||
self.root = new_root
|
self.root = new_root
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
|
# Recalculate sizes for ancestor folders (including root)
|
||||||
|
self._recalculate_ancestors(path)
|
||||||
|
|
||||||
return new_root
|
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]:
|
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
||||||
"""Remove child paths if parent is in set."""
|
"""Remove child paths if parent is in set."""
|
||||||
|
|||||||
+64
-14
@@ -16,7 +16,7 @@
|
|||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<RouterView :path="path.pathList" :query="path.query" />
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<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>
|
||||||
@@ -36,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
|
||||||
@@ -62,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) {
|
||||||
@@ -75,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' ||
|
||||||
@@ -90,13 +99,32 @@ 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
|
||||||
@@ -142,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()
|
||||||
@@ -156,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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -251,8 +276,7 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
})
|
})
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
doc.ghost = true
|
store.addGhost(doc)
|
||||||
store.document.push(doc)
|
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -351,13 +375,13 @@ const copyImage = async (doc: Doc) => {
|
|||||||
|
|
||||||
const deleteFile = (doc: Doc) => {
|
const deleteFile = (doc: Doc) => {
|
||||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
doc.ghost = true
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Delete failed', res.error)
|
console.error('Delete failed', res.error)
|
||||||
doc.ghost = false
|
store.unhideDoc(path)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.showToast(res.error.message || 'Delete failed')
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -205,8 +225,7 @@ const mkdir = (doc: Doc, name: string) => {
|
|||||||
})
|
})
|
||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
doc.ghost = true
|
store.addGhost(doc)
|
||||||
store.document.push(doc)
|
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
const showFolderBreadcrumb = (i: number) => {
|
const showFolderBreadcrumb = (i: number) => {
|
||||||
@@ -295,13 +314,13 @@ const copyImage = async (doc: Doc) => {
|
|||||||
|
|
||||||
const deleteFile = (doc: Doc) => {
|
const deleteFile = (doc: Doc) => {
|
||||||
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
doc.ghost = true
|
store.hideDoc(path)
|
||||||
const control = connect(controlUrl, {
|
const control = connect(controlUrl, {
|
||||||
message(ev: MessageEvent) {
|
message(ev: MessageEvent) {
|
||||||
const res = JSON.parse(ev.data)
|
const res = JSON.parse(ev.data)
|
||||||
if ('error' in res) {
|
if ('error' in res) {
|
||||||
console.error('Delete failed', res.error)
|
console.error('Delete failed', res.error)
|
||||||
doc.ghost = false
|
store.unhideDoc(path)
|
||||||
store.showToast(res.error.message || 'Delete failed')
|
store.showToast(res.error.message || 'Delete failed')
|
||||||
} else if (res.status === 'ack') {
|
} else if (res.status === 'ack') {
|
||||||
store.showToast(`🗑️ Deleted ${doc.name}`)
|
store.showToast(`🗑️ Deleted ${doc.name}`)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="smallgap"></div>
|
<div class="smallgap"></div>
|
||||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||||
<div class="search-group">
|
<div class="search-group">
|
||||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||||
<input
|
<input
|
||||||
ref="search"
|
ref="search"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -17,43 +17,10 @@
|
|||||||
@input="updateSearch"
|
@input="updateSearch"
|
||||||
@keydown.escape="clearSearch"
|
@keydown.escape="clearSearch"
|
||||||
/>
|
/>
|
||||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="spacer smallgap"></div>
|
<div class="spacer smallgap"></div>
|
||||||
<div v-if="store.space.disk" class="disk-space"
|
<DiskSpace v-if="store.space.disk" />
|
||||||
@mouseenter="diskTooltip?.startHover"
|
|
||||||
@mousemove="diskTooltip?.updatePosition"
|
|
||||||
@mouseleave="diskTooltip?.endHover"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 32 32" class="pie-mini">
|
|
||||||
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
|
|
||||||
<circle r="16" cx="16" cy="16" fill="#c8e" />
|
|
||||||
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
|
|
||||||
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
|
|
||||||
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
|
|
||||||
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
|
|
||||||
<!-- Subtle inner circle for depth -->
|
|
||||||
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
|
|
||||||
</svg>
|
|
||||||
<CursorTooltip ref="diskTooltip" text="Disk space">
|
|
||||||
<div class="disk-tooltip">
|
|
||||||
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
|
|
||||||
<!-- Pie chart centered at 40,40 -->
|
|
||||||
<circle r="32" cx="40" cy="40" fill="#c8e" />
|
|
||||||
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
|
|
||||||
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
|
|
||||||
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
|
|
||||||
<!-- Labels on the right -->
|
|
||||||
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
|
|
||||||
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
|
|
||||||
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
|
|
||||||
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
|
|
||||||
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
|
|
||||||
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</CursorTooltip>
|
|
||||||
</div>
|
|
||||||
<SvgButton name="cog" @click="settingsMenu" />
|
<SvgButton name="cog" @click="settingsMenu" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,74 +28,18 @@
|
|||||||
<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, computed } 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 { formatSize } from '@/utils'
|
import DiskSpace from './DiskSpace.vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const ssoStore = useSsoAuthStore()
|
const ssoStore = useSsoAuthStore()
|
||||||
const search = ref<HTMLInputElement | null>()
|
const search = ref<HTMLInputElement | null>()
|
||||||
const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
||||||
|
|
||||||
const CIRC = 50.27 // 2π×8
|
const props = defineProps<{
|
||||||
|
|
||||||
// Storage segment (starts at top, -90°)
|
|
||||||
const pieStorage = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
|
||||||
const pct = s.storage / s.disk
|
|
||||||
return `${pct * CIRC} ${CIRC}`
|
|
||||||
})
|
|
||||||
|
|
||||||
// Free segment (starts after storage, goes clockwise to bottom area)
|
|
||||||
const pieFree = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
|
||||||
const pct = s.free / s.disk
|
|
||||||
return `${pct * CIRC} ${CIRC}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const pieFreeOffset = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return 0
|
|
||||||
// Start after storage segment
|
|
||||||
const storagePct = s.storage / s.disk
|
|
||||||
return -storagePct * CIRC
|
|
||||||
})
|
|
||||||
|
|
||||||
// Free space color: green when plenty, yellow when moderate, red when low
|
|
||||||
const freeColor = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return '#6c6'
|
|
||||||
const freePct = s.free / s.disk
|
|
||||||
if (freePct > 0.25) return '#5b5' // Green: > 25% free
|
|
||||||
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
|
|
||||||
return '#d44' // Red: < 10% free
|
|
||||||
})
|
|
||||||
|
|
||||||
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
|
|
||||||
const CIRC_LG = 100.53
|
|
||||||
const pieStorageLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC_LG}`
|
|
||||||
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
|
|
||||||
})
|
|
||||||
const pieFreeLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return `0 ${CIRC_LG}`
|
|
||||||
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
|
|
||||||
})
|
|
||||||
const pieFreeOffsetLg = computed(() => {
|
|
||||||
const s = store.space
|
|
||||||
if (!s.disk) return 0
|
|
||||||
return -(s.storage / s.disk) * CIRC_LG
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
path: Array<string>
|
path: Array<string>
|
||||||
query: string
|
query: string
|
||||||
}>()
|
}>()
|
||||||
@@ -204,7 +115,7 @@ const settingsMenu = (e: Event) => {
|
|||||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
// 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#theme=light')
|
await showAuthIframe('/auth/restricted/#theme=light')
|
||||||
resumeWatching()
|
resumeWatching()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Login cancelled')
|
console.log('Login cancelled')
|
||||||
@@ -230,6 +141,7 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-height: 3em;
|
||||||
}
|
}
|
||||||
.search-group {
|
.search-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -240,12 +152,24 @@ defineExpose({
|
|||||||
padding: 0 0.3em;
|
padding: 0 0.3em;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 5.5em;
|
min-width: 2.5em;
|
||||||
max-width: 20em;
|
max-width: 20em;
|
||||||
}
|
}
|
||||||
|
.search-group:hover,
|
||||||
.search-group:focus-within {
|
.search-group:focus-within {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
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 {
|
.search-group:focus-within .search-hint {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -253,6 +177,8 @@ defineExpose({
|
|||||||
.search-group :deep(.action-button) {
|
.search-group :deep(.action-button) {
|
||||||
width: 2.2em;
|
width: 2.2em;
|
||||||
height: 2.2em;
|
height: 2.2em;
|
||||||
|
min-width: 1.5em;
|
||||||
|
min-height: 1.5em;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.search-group input[type='search'] {
|
.search-group input[type='search'] {
|
||||||
@@ -261,9 +187,10 @@ defineExpose({
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0.2em 0.5em 0.2em 0;
|
padding: 0.2em 0.5em 0.2em 0;
|
||||||
font-size: var(--header-font-size);
|
font-size: inherit;
|
||||||
flex: 1 1 3em;
|
flex: 1 1 3em;
|
||||||
min-width: 3em;
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.search-hint {
|
.search-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -279,24 +206,11 @@ defineExpose({
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
.disk-space {
|
@media (hover: hover) and (pointer: fine) {
|
||||||
display: flex;
|
.search-hint {
|
||||||
align-items: center;
|
display: block;
|
||||||
cursor: default;
|
}
|
||||||
}
|
|
||||||
.pie-mini {
|
|
||||||
width: 1.4em;
|
|
||||||
height: 1.4em;
|
|
||||||
}
|
|
||||||
.disk-tooltip {
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.pie-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.pie-tooltip .pie-label {
|
|
||||||
fill: #fff;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="selection-bar" v-if="store.selected.size">
|
|
||||||
<p class="select-text">{{ store.selected.size }} selected</p>
|
|
||||||
<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"
|
|
||||||
>❌<CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
|
|
||||||
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
const props = defineProps({
|
|
||||||
path: Array<string>
|
|
||||||
})
|
|
||||||
|
|
||||||
const dst = computed(() => props.path!.join('/'))
|
|
||||||
const op = (opName: string, dst?: string) => {
|
|
||||||
const sel = store.selectedFiles
|
|
||||||
const msg = {
|
|
||||||
op: opName,
|
|
||||||
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
|
|
||||||
if (opName === 'rm' || opName === 'mv')
|
|
||||||
for (const key of sel.keys) sel.docs[key]!.ghost = true
|
|
||||||
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>
|
|
||||||
.selection-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.3em 0.5em;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--header-color);
|
|
||||||
}
|
|
||||||
.select-text {
|
|
||||||
color: var(--accent-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0;
|
|
||||||
padding-right: 0.5em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
|
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||||
|
<SpinnerIcon />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
|
||||||
<img v-else-if=doc.img :src=doc.url alt="">
|
<img v-else-if=doc.img :src=doc.url alt="">
|
||||||
<span v-else-if=doc.dir class="folder icon"></span>
|
<span v-else-if=doc.dir class="folder icon"></span>
|
||||||
<div v-else-if=video() class="video-container">
|
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
||||||
<video ref=vid :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
|
<video v-else ref=vid :src=doc.url preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||||
<div class="play-overlay"><PlayIcon /></div>
|
<div class="play-overlay"><PlayIcon /></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if=audio() class="audio icon">
|
<div v-else-if=audio() class="audio icon">
|
||||||
@@ -16,16 +20,16 @@
|
|||||||
<script setup lang=ts>
|
<script setup lang=ts>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
import { Play as PlayIcon } from '@/assets/svg'
|
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
const vid = ref<HTMLVideoElement | null>(null)
|
const vid = ref<HTMLVideoElement | null>(null)
|
||||||
const media = computed(() => aud.value || vid.value)
|
const media = computed(() => aud.value || vid.value)
|
||||||
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
doc: Doc
|
doc: Doc
|
||||||
quality: string
|
quality: string
|
||||||
}>()
|
}>()
|
||||||
|
const previewSrc = computed(() => props.doc.previewurl ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` : '')
|
||||||
|
|
||||||
const onplay = () => {
|
const onplay = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
@@ -37,6 +41,13 @@ const onpaused = () => {
|
|||||||
media.value.controls = false
|
media.value.controls = false
|
||||||
media.value.removeAttribute('data-playing')
|
media.value.removeAttribute('data-playing')
|
||||||
}
|
}
|
||||||
|
const applyPoster = (el: HTMLVideoElement) => {
|
||||||
|
if (props.doc.complete) {
|
||||||
|
el.poster = previewSrc.value
|
||||||
|
} else {
|
||||||
|
el.removeAttribute('poster')
|
||||||
|
}
|
||||||
|
}
|
||||||
let fscurrent: HTMLVideoElement | null = null
|
let fscurrent: HTMLVideoElement | null = null
|
||||||
const next = () => {
|
const next = () => {
|
||||||
if (!media.value) return
|
if (!media.value) return
|
||||||
@@ -70,7 +81,7 @@ const next = () => {
|
|||||||
if (!elem.paused) fscurrent.play()
|
if (!elem.paused) fscurrent.play()
|
||||||
fscurrent = null
|
fscurrent = null
|
||||||
elem.src = props.doc.url
|
elem.src = props.doc.url
|
||||||
elem.poster = poster.value
|
applyPoster(elem)
|
||||||
onpaused()
|
onpaused()
|
||||||
}, {once: true})
|
}, {once: true})
|
||||||
}
|
}
|
||||||
@@ -104,6 +115,7 @@ defineExpose({
|
|||||||
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].includes(props.doc.ext)
|
||||||
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
||||||
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
const archive = () => ['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||||
|
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||||
const preview = () => (
|
const preview = () => (
|
||||||
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
|
['bmp', 'ico', 'tif', 'tiff', 'heic', 'heif', 'pdf', 'epub', 'mobi'].includes(props.doc.ext) ||
|
||||||
props.doc.size > 500000 &&
|
props.doc.size > 500000 &&
|
||||||
@@ -120,6 +132,29 @@ img, embed, .icon, audio, video {
|
|||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
border-radius: calc(.5em / 8);
|
border-radius: calc(.5em / 8);
|
||||||
}
|
}
|
||||||
|
.preview-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 50%;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
}
|
||||||
|
.preview-progress :deep(svg) {
|
||||||
|
width: 4.5em;
|
||||||
|
height: 4.5em;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: media-preview-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes media-preview-spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
.folder::before {
|
.folder::before {
|
||||||
content: '📁';
|
content: '📁';
|
||||||
}
|
}
|
||||||
@@ -175,9 +210,14 @@ img::before {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 50%;
|
min-width: 50%;
|
||||||
|
min-height: 6em;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
.video-container.pending {
|
||||||
|
background: color-mix(in srgb, var(--header-bg) 55%, transparent);
|
||||||
|
}
|
||||||
.video-container video {
|
.video-container video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="action-button"
|
class="action-button"
|
||||||
|
:tabindex="tabindex"
|
||||||
@mouseenter="tooltip?.startHover"
|
@mouseenter="tooltip?.startHover"
|
||||||
@mousemove="tooltip?.updatePosition"
|
@mousemove="tooltip?.updatePosition"
|
||||||
@mouseleave="tooltip?.endHover"
|
@mouseleave="tooltip?.endHover"
|
||||||
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
name: IconName
|
name: IconName
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
tabindex?: string | number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
@@ -32,18 +34,29 @@ const tooltipText = props.tooltip ?? ''
|
|||||||
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 {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
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 { Doc } from '@/repositories/Document'
|
||||||
import { collator } from '@/utils';
|
import { collator } from '@/utils';
|
||||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -28,6 +28,22 @@ type CloudFile = {
|
|||||||
cloudName: string
|
cloudName: string
|
||||||
cloudPos: number
|
cloudPos: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UploadRange = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type InflightBlock = {
|
||||||
|
name: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
startedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||||
function pasteHandler(event: ClipboardEvent) {
|
function pasteHandler(event: ClipboardEvent) {
|
||||||
const items = Array.from(event.clipboardData?.items ?? [])
|
const items = Array.from(event.clipboardData?.items ?? [])
|
||||||
const infiles = [] as File[]
|
const infiles = [] as File[]
|
||||||
@@ -45,7 +61,8 @@ function pasteHandler(event: ClipboardEvent) {
|
|||||||
if (infiles.length || dirs.length) {
|
if (infiles.length || dirs.length) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
uploadFiles(infiles)
|
uploadFiles(infiles)
|
||||||
for (const entry of dirs) pasteDirectory(entry, `${props.path!.join('/')}/${entry.name}`)
|
const base = props.path!.join('/')
|
||||||
|
for (const entry of dirs) pasteDirectory(entry, `${base ? `${base}/` : ''}${entry.name}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
const pasteDirectory = async (entry: FileSystemDirectoryEntry, loc: string) => {
|
||||||
@@ -81,7 +98,7 @@ const uploadFiles = (infiles: File[]) => {
|
|||||||
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||||
files.push({
|
files.push({
|
||||||
file,
|
file,
|
||||||
cloudName: loc + '/' + relPath,
|
cloudName: `${loc ? `${loc}/` : ''}${relPath}`,
|
||||||
cloudPos: 0,
|
cloudPos: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -98,7 +115,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
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
|
// Optimistic update: ghost folders and files
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
const byPath = new Map(store.document.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
|
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>()
|
const added = new Set<string>()
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
const lastSlash = f.cloudName.lastIndexOf('/')
|
const lastSlash = f.cloudName.lastIndexOf('/')
|
||||||
@@ -109,14 +131,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const folderPath = parts.slice(0, i + 1).join('/')
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||||
store.document.push(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true, ghost: true }))
|
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)
|
added.add(folderPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost file or update existing
|
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||||
const existing = byPath.get(f.cloudName)
|
const existing = byPath.get(f.cloudName)
|
||||||
if (existing) { existing.size = f.file.size; existing.mtime = now; existing.ghost = true }
|
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||||
else store.document.push(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false, ghost: true }))
|
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
@@ -125,7 +146,12 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelUploads = () => {
|
const cancelUploads = () => {
|
||||||
|
uploadRunId += 1
|
||||||
upqueue = []
|
upqueue = []
|
||||||
|
blockQueue = []
|
||||||
|
inflightBlocks.clear()
|
||||||
|
uploadedBytes.clear()
|
||||||
|
store.uprogress.status = 'idle'
|
||||||
statReset()
|
statReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,27 +172,100 @@ const uprogress_init = {
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
}
|
}
|
||||||
store.uprogress = {...uprogress_init}
|
store.uprogress = {...uprogress_init}
|
||||||
|
// Track uploaded bytes for each file to handle out-of-order uploads
|
||||||
|
const uploadedBytes = new Map<string, Set<number>>()
|
||||||
|
const inflightBlocks = new Map<string, InflightBlock>()
|
||||||
|
let smoothedBlockMs = 1500
|
||||||
|
let lastProgressTick = Date.now()
|
||||||
|
let lastVisualUploaded = 0
|
||||||
|
|
||||||
|
const inflightKey = (name: string, start: number) => `${name}:${start}`
|
||||||
|
|
||||||
|
const completedUploadedBytes = (name: string, size: number) => {
|
||||||
|
const uploaded = uploadedBytes.get(name)
|
||||||
|
if (!uploaded) return 0
|
||||||
|
const blockSize = UPLOAD_BLOCK_SIZE
|
||||||
|
let total = 0
|
||||||
|
for (let i = 0; i < size; i += blockSize) {
|
||||||
|
if (uploaded.has(i)) total += Math.min(blockSize, size - i)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulatedInflightBytes = (name: string, now: number) => {
|
||||||
|
let total = 0
|
||||||
|
for (const block of inflightBlocks.values()) {
|
||||||
|
if (block.name !== name) continue
|
||||||
|
const size = block.end - block.start
|
||||||
|
const elapsed = Math.max(0, now - block.startedAt)
|
||||||
|
const fraction = Math.min(0.98, elapsed / Math.max(200, smoothedBlockMs))
|
||||||
|
total += size * fraction
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshProgress = (now: number) => {
|
||||||
|
const name = store.uprogress.filename
|
||||||
|
const size = store.uprogress.filesize
|
||||||
|
if (!name || !size) {
|
||||||
|
lastProgressTick = now
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const completed = completedUploadedBytes(name, size)
|
||||||
|
const estimated = simulatedInflightBytes(name, now)
|
||||||
|
const visualUploaded = Math.min(size, Math.round(completed + estimated))
|
||||||
|
const delta = Math.max(0, visualUploaded - lastVisualUploaded)
|
||||||
|
const dt = Math.max(1, now - lastProgressTick)
|
||||||
|
|
||||||
|
store.uprogress.filepos = visualUploaded
|
||||||
|
store.uprogress.xfer = store.uprogress.filestart + visualUploaded
|
||||||
|
|
||||||
|
if (delta > 0) {
|
||||||
|
store.uprogress.statbytes += delta
|
||||||
|
store.uprogress.statdur += dt
|
||||||
|
store.uprogress.tlast = now
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVisualUploaded = visualUploaded
|
||||||
|
lastProgressTick = now
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Date.now() - store.uprogress.tlast > 3000) {
|
const now = Date.now()
|
||||||
// Reset
|
const delta = refreshProgress(now)
|
||||||
|
if (delta > 0) return
|
||||||
|
if (now - store.uprogress.tlast > 3000) {
|
||||||
store.uprogress.statbytes = 0
|
store.uprogress.statbytes = 0
|
||||||
store.uprogress.statdur = 1
|
store.uprogress.statdur = 1
|
||||||
} else {
|
} else {
|
||||||
// Running average by decay
|
store.uprogress.statbytes *= .95
|
||||||
store.uprogress.statbytes *= .9
|
store.uprogress.statdur *= .95
|
||||||
store.uprogress.statdur *= .9
|
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
|
|
||||||
|
const statUpdate = ({name, size, start, end}: UploadRange) => {
|
||||||
if (name !== store.uprogress.filename) return // If stats have been reset
|
if (name !== store.uprogress.filename) return // If stats have been reset
|
||||||
const now = Date.now()
|
|
||||||
store.uprogress.xfer = store.uprogress.filestart + end
|
// Track which bytes have been uploaded (using start to end range)
|
||||||
store.uprogress.filepos = end
|
if (!uploadedBytes.has(name)) uploadedBytes.set(name, new Set())
|
||||||
store.uprogress.statbytes += end - start
|
const uploaded = uploadedBytes.get(name)!
|
||||||
store.uprogress.statdur += now - store.uprogress.tlast
|
const blockSize = UPLOAD_BLOCK_SIZE
|
||||||
store.uprogress.tlast = now
|
|
||||||
// File finished?
|
// Mark all bytes in this block as uploaded
|
||||||
if (end === size) {
|
for (let i = start; i < end; i += blockSize) {
|
||||||
|
uploaded.add(i)
|
||||||
|
}
|
||||||
|
refreshProgress(Date.now())
|
||||||
|
|
||||||
|
// Check if file is fully uploaded by examining the block queue
|
||||||
|
const currentUpload = blockQueue[0]
|
||||||
|
if (!currentUpload) return
|
||||||
|
|
||||||
|
if (currentUpload.file.cloudName === name && currentUpload.completed >= currentUpload.blocks.length) {
|
||||||
|
// All blocks for this file have been uploaded
|
||||||
|
uploadedBytes.delete(name) // Clean up tracking
|
||||||
store.uprogress.filestart += size
|
store.uprogress.filestart += size
|
||||||
statNextFile()
|
statNextFile()
|
||||||
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
|
||||||
@@ -178,11 +277,15 @@ const statNextFile = () => {
|
|||||||
store.uprogress.filepos = 0
|
store.uprogress.filepos = 0
|
||||||
store.uprogress.filesize = f.file.size
|
store.uprogress.filesize = f.file.size
|
||||||
store.uprogress.filename = f.cloudName
|
store.uprogress.filename = f.cloudName
|
||||||
|
lastVisualUploaded = 0
|
||||||
|
lastProgressTick = Date.now()
|
||||||
}
|
}
|
||||||
const statReset = () => {
|
const statReset = () => {
|
||||||
Object.assign(store.uprogress, uprogress_init)
|
Object.assign(store.uprogress, uprogress_init)
|
||||||
store.uprogress.t0 = Date.now()
|
store.uprogress.t0 = Date.now()
|
||||||
store.uprogress.tlast = store.uprogress.t0 + 1
|
store.uprogress.tlast = store.uprogress.t0 + 1
|
||||||
|
lastVisualUploaded = 0
|
||||||
|
lastProgressTick = store.uprogress.t0
|
||||||
}
|
}
|
||||||
const statsAdd = (f: CloudFile[]) => {
|
const statsAdd = (f: CloudFile[]) => {
|
||||||
if (store.uprogress.files.length === 0) statReset()
|
if (store.uprogress.files.length === 0) statReset()
|
||||||
@@ -192,69 +295,201 @@ const statsAdd = (f: CloudFile[]) => {
|
|||||||
statNextFile()
|
statNextFile()
|
||||||
}
|
}
|
||||||
let upqueue = [] as CloudFile[]
|
let upqueue = [] as CloudFile[]
|
||||||
|
const MAX_PARALLEL_REQUESTS = 4
|
||||||
|
const RETRY_DELAY_MS = 400
|
||||||
|
|
||||||
// TODO: Rewrite as WebSocket class
|
// Helper function to get upload blocks for a file, prioritizing final 4 blocks if file >= 32 MiB
|
||||||
const WSCreate = async () => await new Promise<WebSocket>(resolve => {
|
const getUploadBlocks = (file: CloudFile): {start: number, end: number}[] => {
|
||||||
const ws = connect(uploadUrl, {
|
const BLOCK_SIZE = UPLOAD_BLOCK_SIZE
|
||||||
open(ev: Event) { resolve(ws) },
|
const MIN_SIZE_FOR_REORDER = 32 * BLOCK_SIZE // 32 MiB = 33554432 bytes
|
||||||
error(ev: Event) {
|
const FINAL_BLOCKS_COUNT = 2
|
||||||
console.error('Upload socket error', ev)
|
|
||||||
store.error = 'Upload socket error'
|
const fileSize = file.file.size
|
||||||
},
|
const blocks: {start: number, end: number}[] = []
|
||||||
message(ev: MessageEvent) {
|
|
||||||
const res = JSON.parse(ev!.data)
|
if (fileSize >= MIN_SIZE_FOR_REORDER) {
|
||||||
if ('error' in res) {
|
// File is large enough, prioritize final blocks
|
||||||
console.error('Upload socket error', res.error)
|
const finalBlocksStart = fileSize - (FINAL_BLOCKS_COUNT * BLOCK_SIZE)
|
||||||
store.error = res.error.message
|
|
||||||
return
|
// Add final blocks first
|
||||||
}
|
for (let i = 0; i < FINAL_BLOCKS_COUNT; i++) {
|
||||||
if (res.status === 'ack') {
|
const start = finalBlocksStart + (i * BLOCK_SIZE)
|
||||||
statUpdate(res.req)
|
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||||
} else console.log('Unknown upload response', res)
|
blocks.push({start, end})
|
||||||
},
|
}
|
||||||
})
|
|
||||||
// @ts-ignore
|
// Add remaining blocks from beginning
|
||||||
ws.sendMsg = (msg: any) => ws.send(JSON.stringify(msg))
|
for (let start = 0; start < finalBlocksStart; start += BLOCK_SIZE) {
|
||||||
// @ts-ignore
|
const end = Math.min(start + BLOCK_SIZE, finalBlocksStart)
|
||||||
ws.sendData = async (data: any) => {
|
blocks.push({start, end})
|
||||||
// Wait until the WS is ready to send another message
|
}
|
||||||
store.uprogress.status = "uploading"
|
} else {
|
||||||
await new Promise(resolve => {
|
// File is smaller, use sequential upload
|
||||||
const t = setInterval(() => {
|
for (let start = 0; start < fileSize; start += BLOCK_SIZE) {
|
||||||
if (ws.bufferedAmount > 1<<20) return
|
const end = Math.min(start + BLOCK_SIZE, fileSize)
|
||||||
resolve(undefined)
|
blocks.push({start, end})
|
||||||
clearInterval(t)
|
}
|
||||||
}, 1)
|
|
||||||
})
|
|
||||||
store.uprogress.status = "processing"
|
|
||||||
ws.send(data)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
const worker = async () => {
|
return blocks
|
||||||
const ws = await WSCreate()
|
|
||||||
while (upqueue.length) {
|
|
||||||
const f = upqueue[0]!
|
|
||||||
const start = f.cloudPos
|
|
||||||
const end = Math.min(f.file.size, start + (1<<20))
|
|
||||||
const control = { name: f.cloudName, size: f.file.size, start, end }
|
|
||||||
const data = f.file.slice(start, end)
|
|
||||||
f.cloudPos = end
|
|
||||||
// Note: files may get modified during I/O
|
|
||||||
// @ts-ignore FIXME proper WebSocket class, avoid attaching functions to WebSocket object
|
|
||||||
ws.sendMsg(control)
|
|
||||||
// @ts-ignore
|
|
||||||
await ws.sendData(data)
|
|
||||||
if (f.cloudPos === f.file.size) upqueue.shift()
|
|
||||||
}
|
|
||||||
if (upqueue.length) startWorker()
|
|
||||||
store.uprogress.status = "idle"
|
|
||||||
workerRunning = false
|
|
||||||
}
|
}
|
||||||
let workerRunning: any = false
|
|
||||||
|
type BlockUpload = {
|
||||||
|
file: CloudFile
|
||||||
|
blocks: {start: number, end: number}[]
|
||||||
|
nextIndex: number
|
||||||
|
completed: number
|
||||||
|
runId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let blockQueue = [] as BlockUpload[]
|
||||||
|
let workerRunning = false
|
||||||
|
let uploadRunId = 0
|
||||||
|
|
||||||
|
const enqueuePendingUploads = () => {
|
||||||
|
while (upqueue.length) {
|
||||||
|
const file = upqueue.shift()!
|
||||||
|
const blocks = getUploadBlocks(file)
|
||||||
|
blockQueue.push({ file, blocks, nextIndex: 0, completed: 0, runId: uploadRunId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadUrlForFile = (cloudName: string) => {
|
||||||
|
const normalized = cloudName.replace(/^\/+/, '')
|
||||||
|
const encoded = normalized.split('/').map(encodeURIComponent).join('/')
|
||||||
|
return `/files/${encoded}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadBlock = async (upload: BlockUpload, block: {start: number, end: number}) => {
|
||||||
|
const body = upload.file.file.slice(block.start, block.end)
|
||||||
|
const range = `bytes ${block.start}-${block.end - 1}/${upload.file.file.size}`
|
||||||
|
const fallbackReq = {
|
||||||
|
name: upload.file.cloudName,
|
||||||
|
size: upload.file.file.size,
|
||||||
|
start: block.start,
|
||||||
|
end: block.end,
|
||||||
|
}
|
||||||
|
let attempt = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
attempt += 1
|
||||||
|
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
|
||||||
|
try {
|
||||||
|
const res = await fetch(uploadUrlForFile(upload.file.cloudName), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Range': range,
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await res.text().catch(() => '')
|
||||||
|
const retryable = res.status >= 500 || res.status === 408 || res.status === 429
|
||||||
|
if (!retryable) throw new Error(message || `HTTP ${res.status}`)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const payload = await res.json().catch(() => null)
|
||||||
|
return payload?.status === 'ack' && payload.req ? payload.req : fallbackReq
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
if (message === 'Upload cancelled') throw err
|
||||||
|
if (upload.runId !== uploadRunId) throw new Error('Upload cancelled')
|
||||||
|
if (attempt % 10 === 0) {
|
||||||
|
console.warn(`Upload retry ${attempt} for ${upload.file.cloudName}: ${message}`)
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startInflightBlock = (name: string, block: {start: number, end: number}) => {
|
||||||
|
inflightBlocks.set(inflightKey(name, block.start), {
|
||||||
|
name,
|
||||||
|
start: block.start,
|
||||||
|
end: block.end,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishInflightBlock = (name: string, block: {start: number, end: number}) => {
|
||||||
|
const key = inflightKey(name, block.start)
|
||||||
|
const info = inflightBlocks.get(key)
|
||||||
|
if (!info) return
|
||||||
|
const elapsed = Math.max(1, Date.now() - info.startedAt)
|
||||||
|
smoothedBlockMs = smoothedBlockMs * 0.85 + elapsed * 0.15
|
||||||
|
inflightBlocks.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = async (runId: number) => {
|
||||||
|
try {
|
||||||
|
while (runId === uploadRunId) {
|
||||||
|
enqueuePendingUploads()
|
||||||
|
if (!blockQueue.length) break
|
||||||
|
|
||||||
|
const upload = blockQueue[0]!
|
||||||
|
const inflight = new Set<Promise<void>>()
|
||||||
|
|
||||||
|
while (runId === uploadRunId && upload.completed < upload.blocks.length) {
|
||||||
|
while (
|
||||||
|
runId === uploadRunId
|
||||||
|
&& upload.nextIndex < upload.blocks.length
|
||||||
|
&& inflight.size < MAX_PARALLEL_REQUESTS
|
||||||
|
) {
|
||||||
|
const block = upload.blocks[upload.nextIndex++]!
|
||||||
|
store.uprogress.status = 'uploading'
|
||||||
|
startInflightBlock(upload.file.cloudName, block)
|
||||||
|
let task: Promise<void>
|
||||||
|
task = uploadBlock(upload, block)
|
||||||
|
.then(req => {
|
||||||
|
finishInflightBlock(upload.file.cloudName, block)
|
||||||
|
upload.completed += 1
|
||||||
|
statUpdate(req)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
finishInflightBlock(upload.file.cloudName, block)
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(task)
|
||||||
|
})
|
||||||
|
inflight.add(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inflight.size) break
|
||||||
|
await Promise.race(inflight)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runId !== uploadRunId) return
|
||||||
|
|
||||||
|
if (upload.completed >= upload.blocks.length) {
|
||||||
|
blockQueue.shift()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (runId !== uploadRunId) return
|
||||||
|
console.error('Upload error', err)
|
||||||
|
store.error = err?.message || 'Upload failed'
|
||||||
|
uploadRunId += 1
|
||||||
|
upqueue = []
|
||||||
|
blockQueue = []
|
||||||
|
inflightBlocks.clear()
|
||||||
|
} finally {
|
||||||
|
store.uprogress.status = 'idle'
|
||||||
|
workerRunning = false
|
||||||
|
if (upqueue.length) startWorker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const startWorker = () => {
|
const startWorker = () => {
|
||||||
if (workerRunning === false) workerRunning = setTimeout(() => {
|
if (workerRunning) return
|
||||||
workerRunning = true
|
workerRunning = true
|
||||||
worker()
|
const runId = uploadRunId
|
||||||
|
setTimeout(() => {
|
||||||
|
void worker(runId)
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
@@ -206,15 +222,48 @@ const updateServerSettings = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ 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
|
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 dir: boolean = false
|
public dir: boolean = false
|
||||||
public ghost: 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 = ""
|
||||||
|
|
||||||
@@ -33,6 +37,15 @@ export class Doc {
|
|||||||
this._name = name
|
this._name = 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
|
||||||
@@ -46,6 +59,9 @@ export class Doc {
|
|||||||
if (this.dir) return false
|
if (this.dir) return false
|
||||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'heic', 'heif', 'svg'].includes(this.ext)
|
||||||
}
|
}
|
||||||
|
get complete(): boolean {
|
||||||
|
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||||
|
}
|
||||||
get previewable(): boolean {
|
get previewable(): boolean {
|
||||||
// Folders cannot be previewable
|
// Folders cannot be previewable
|
||||||
if (this.dir) return false
|
if (this.dir) return false
|
||||||
@@ -54,6 +70,7 @@ export class Doc {
|
|||||||
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
|
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(this.ext)
|
||||||
}
|
}
|
||||||
get previewurl(): string {
|
get previewurl(): string {
|
||||||
|
if (!this.complete || !this.previewable) return ''
|
||||||
return this.url.replace(/^\/files/, '/preview')
|
return this.url.replace(/^\/files/, '/preview')
|
||||||
}
|
}
|
||||||
get ext(): string {
|
get ext(): string {
|
||||||
@@ -76,9 +93,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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { showAuthIframe, AuthCancelledError, isAuthIframeOpen } from 'paskia'
|
|||||||
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
import type { FileEntry, UpdateEntry, errorEvent } from "./Document"
|
||||||
|
|
||||||
export const controlUrl = '/api/control'
|
export const controlUrl = '/api/control'
|
||||||
export const uploadUrl = '/api/upload'
|
|
||||||
export const watchUrl = '/api/watch'
|
export const watchUrl = '/api/watch'
|
||||||
|
|
||||||
let tree = [] as FileEntry[]
|
let tree = [] as FileEntry[]
|
||||||
|
|||||||
@@ -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
|
||||||
+101
-17
@@ -5,6 +5,7 @@ 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 SearchWorker from '@/workers/searchWorker?worker'
|
||||||
|
import { getDocuments, setDocuments, documentRef } from './documentStore'
|
||||||
|
|
||||||
// Singleton search worker instance
|
// Singleton search worker instance
|
||||||
let searchWorker: Worker | null = null
|
let searchWorker: Worker | null = null
|
||||||
@@ -52,9 +53,20 @@ function getSearchWorker(): Worker {
|
|||||||
return searchWorker
|
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[],
|
searchResults: [] as Doc[],
|
||||||
@@ -75,6 +87,7 @@ 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,
|
||||||
@@ -84,8 +97,9 @@ export const useMainStore = defineStore('main', {
|
|||||||
space: {
|
space: {
|
||||||
disk: 0,
|
disk: 0,
|
||||||
free: 0,
|
free: 0,
|
||||||
usage: 0,
|
used: 0,
|
||||||
storage: 0,
|
storage: 0,
|
||||||
|
allocated: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
@@ -106,22 +120,79 @@ 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
|
// Sync documents to search worker
|
||||||
this.syncSearchWorker()
|
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 */
|
/** Show a temporary toast message that auto-dismisses */
|
||||||
showToast(message: string, duration = 3000) {
|
showToast(message: string, duration = 3000) {
|
||||||
if (this.toastTimeout) {
|
if (this.toastTimeout) {
|
||||||
@@ -145,11 +216,13 @@ export const useMainStore = defineStore('main', {
|
|||||||
syncSearchWorker() {
|
syncSearchWorker() {
|
||||||
const worker = getSearchWorker()
|
const worker = getSearchWorker()
|
||||||
// Send plain data to worker (no class instances)
|
// Send plain data to worker (no class instances)
|
||||||
const docData = this.document.map(doc => ({
|
const docs = getDocuments()
|
||||||
|
const docData = docs.map(doc => ({
|
||||||
loc: doc.loc,
|
loc: doc.loc,
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
key: doc.key,
|
key: doc.key,
|
||||||
size: doc.size,
|
size: doc.size,
|
||||||
|
allocated: doc.allocated,
|
||||||
mtime: doc.mtime,
|
mtime: doc.mtime,
|
||||||
dir: doc.dir,
|
dir: doc.dir,
|
||||||
}))
|
}))
|
||||||
@@ -209,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
|
||||||
@@ -268,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 = {
|
||||||
@@ -278,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)
|
||||||
@@ -288,22 +378,16 @@ export const useMainStore = defineStore('main', {
|
|||||||
// What did we not select?
|
// What did we not select?
|
||||||
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
for (const key of selected) if (!found.has(key)) ret.missing.add(key)
|
||||||
// Build a flat list including contents recursively
|
// Build a flat list including contents recursively
|
||||||
const relnames = new Set<string>()
|
|
||||||
function add(rel: string, full: string, doc: Doc) {
|
|
||||||
if (!doc.dir && relnames.has(rel)) throw Error(`Multiple selections conflict for: ${rel}`)
|
|
||||||
relnames.add(rel)
|
|
||||||
ret.recursive.push([rel, full, doc])
|
|
||||||
}
|
|
||||||
for (const key of ret.keys) {
|
for (const key of ret.keys) {
|
||||||
const base = ret.docs[key]!
|
const base = ret.docs[key]!
|
||||||
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)
|
ret.recursive.push([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)
|
||||||
add(rel, full, doc)
|
ret.recursive.push([rel, full, doc])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<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 { getDocuments } from '@/stores/documentStore'
|
||||||
import { 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'
|
||||||
@@ -50,13 +51,22 @@ const documents = computed(() => {
|
|||||||
const query = props.query
|
const query = props.query
|
||||||
|
|
||||||
// List the current location (no search)
|
// List the current location (no search)
|
||||||
if (!query) return sorted(
|
if (!query) {
|
||||||
store.document.filter(doc => doc.loc === loc),
|
// Access docVersion to make this reactive to document changes
|
||||||
store.prefs.sortListing,
|
void store.docVersion
|
||||||
)
|
const hidden = store.hiddenPaths
|
||||||
|
const docs = getDocuments().filter(doc => doc.loc === loc && !hidden.has(doc.loc ? `${doc.loc}/${doc.name}` : doc.name))
|
||||||
|
// Overlay ghosts for this location (excluding hidden ones)
|
||||||
|
const ghosts = store.ghosts.filter(g => g.loc === loc && !hidden.has(g.loc ? `${g.loc}/${g.name}` : g.name))
|
||||||
|
// Merge: ghosts that don't conflict with real docs
|
||||||
|
const realNames = new Set(docs.map(d => d.name))
|
||||||
|
const merged = [...docs, ...ghosts.filter(g => !realNames.has(g.name))]
|
||||||
|
return sorted(merged, store.prefs.sortListing)
|
||||||
|
}
|
||||||
|
|
||||||
// Search results from worker
|
// Search results from worker (also filter hidden)
|
||||||
const docs = store.searchResults
|
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
|
||||||
@@ -71,7 +81,7 @@ watchEffect(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 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)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface DocData {
|
|||||||
name: string
|
name: string
|
||||||
key: string
|
key: string
|
||||||
size: number
|
size: number
|
||||||
|
allocated: number
|
||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -41,10 +41,12 @@ dependencies = [
|
|||||||
"pillow-heif>=1.1.0",
|
"pillow-heif>=1.1.0",
|
||||||
"pyjwt>=2.10.1",
|
"pyjwt>=2.10.1",
|
||||||
"pymupdf>=1.26.3",
|
"pymupdf>=1.26.3",
|
||||||
|
"pyvips[binary]>=3.1.1",
|
||||||
"sanic>=25.12.0",
|
"sanic>=25.12.0",
|
||||||
"setproctitle>=1.3.6",
|
"setproctitle>=1.3.6",
|
||||||
"stream-zip>=0.0.83",
|
"stream-zip>=0.0.83",
|
||||||
"tomli_w>=1.2.0",
|
"tomli_w>=1.2.0",
|
||||||
|
"tracerite>=2.3.1",
|
||||||
"zstandard>=0.24.0",
|
"zstandard>=0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -114,6 +116,7 @@ filterwarnings = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
|
extend-select = ["E402"]
|
||||||
isort.known-first-party = ["cista"]
|
isort.known-first-party = ["cista"]
|
||||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004"]
|
||||||
per-file-ignores."scripts/*" = ["T20"]
|
per-file-ignores."scripts/*" = ["T20"]
|
||||||
@@ -121,16 +124,13 @@ per-file-ignores."scripts/*" = ["T20"]
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.4.1",
|
"pytest>=8.4.1",
|
||||||
|
"pytest-asyncio>=0.25.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
"ruff>=0.8.0",
|
"ruff>=0.8.0",
|
||||||
"mypy>=1.13.0",
|
"mypy>=1.13.0",
|
||||||
"pre-commit>=4.0.0",
|
"pre-commit>=4.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
]
|
]
|
||||||
test = [
|
|
||||||
"pytest>=8.4.1",
|
|
||||||
"pytest-cov>=6.0.0",
|
|
||||||
"pytest-asyncio>=0.25.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["cista"]
|
source = ["cista"]
|
||||||
|
|||||||
+22
-15
@@ -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:8989)
|
-l, --listen Vite frontend endpoint (default: localhost:8989)
|
||||||
--backend Cista backend endpoint (default: from config, or :8999)
|
--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).
|
||||||
@@ -29,7 +31,9 @@ from cista.serve import parse_listen
|
|||||||
DEFAULT_BACKEND_PORT = 8999
|
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:8989)",
|
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 :8999)",
|
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
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
|
|||||||
Raises RuntimeError if no runtime is found.
|
Raises RuntimeError if no runtime is found.
|
||||||
"""
|
"""
|
||||||
dev_args = {
|
dev_args = {
|
||||||
"deno": ("run", "dev", "--"),
|
"deno": ("run", "-A", "npm:vite"),
|
||||||
"npm": ("--silent", "run", "dev", "--"),
|
"npm": ("--silent", "run", "dev", "--"),
|
||||||
"bun": ("run", "dev", "--"),
|
"bun": ("run", "dev", "--"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import argparse
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cista.preview import process_image_with_timing
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Generate image previews for all files in a folder, one at a time.",
|
||||||
|
)
|
||||||
|
parser.add_argument("folder", type=Path, help="Folder to scan recursively")
|
||||||
|
parser.add_argument(
|
||||||
|
"--px",
|
||||||
|
type=int,
|
||||||
|
default=1024,
|
||||||
|
help="Maximum preview dimension in pixels (default: 1024)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--quality",
|
||||||
|
type=int,
|
||||||
|
default=60,
|
||||||
|
help="AVIF quality passed to preview generation (default: 60)",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def is_image_file(path: Path) -> bool:
|
||||||
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
|
return bool(mime_type and mime_type.startswith("image/"))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
folder = args.folder.resolve()
|
||||||
|
if not folder.is_dir():
|
||||||
|
raise SystemExit(f"Not a directory: {folder}")
|
||||||
|
|
||||||
|
files = sorted(
|
||||||
|
path for path in folder.rglob("*") if path.is_file() and is_image_file(path)
|
||||||
|
)
|
||||||
|
if not files:
|
||||||
|
print(f"No image files found under {folder}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total_files = 0
|
||||||
|
total_bytes = 0
|
||||||
|
total_timing_slots: list[float] = []
|
||||||
|
total_preview_ms: float = 0.0
|
||||||
|
failures = 0
|
||||||
|
|
||||||
|
print(f"Scanning {folder}")
|
||||||
|
print(f"Generating previews for {len(files)} image files")
|
||||||
|
|
||||||
|
for path in files:
|
||||||
|
total_files += 1
|
||||||
|
rel = path.relative_to(folder)
|
||||||
|
try:
|
||||||
|
preview, timing = process_image_with_timing(
|
||||||
|
path,
|
||||||
|
maxsize=args.px,
|
||||||
|
quality=args.quality,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
failures += 1
|
||||||
|
print(f"FAIL {rel} error={exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_bytes += len(preview)
|
||||||
|
timings = timing.timings or []
|
||||||
|
if len(total_timing_slots) < len(timings):
|
||||||
|
total_timing_slots.extend([0.0] * (len(timings) - len(total_timing_slots)))
|
||||||
|
for i, value in enumerate(timings):
|
||||||
|
total_timing_slots[i] += value
|
||||||
|
total_ms = sum(timings)
|
||||||
|
total_preview_ms += total_ms
|
||||||
|
|
||||||
|
detail = " / ".join(f"{value:.1f}ms" for value in timings)
|
||||||
|
if detail:
|
||||||
|
detail = f"timings={detail} total={total_ms:.1f}ms"
|
||||||
|
else:
|
||||||
|
detail = f"total={total_ms:.1f}ms"
|
||||||
|
print(f"OK {rel} backend={timing.backend} bytes={len(preview)} {detail}")
|
||||||
|
|
||||||
|
completed = total_files - failures
|
||||||
|
print()
|
||||||
|
print("Summary")
|
||||||
|
print(f" files={total_files}")
|
||||||
|
print(f" completed={completed}")
|
||||||
|
print(f" failed={failures}")
|
||||||
|
print(f" preview_bytes={total_bytes}")
|
||||||
|
if completed:
|
||||||
|
if total_timing_slots:
|
||||||
|
for i, value in enumerate(total_timing_slots, start=1):
|
||||||
|
print(f" timing{i}_total_ms={value:.1f}")
|
||||||
|
print(f" preview_total_ms={total_preview_ms:.1f}")
|
||||||
|
print(f" preview_avg_ms={total_preview_ms / completed:.1f}")
|
||||||
|
return 0 if failures == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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