Compare commits

..

No commits in common. "main" and "v0.4.1" have entirely different histories.
main ... v0.4.1

45 changed files with 861 additions and 2230 deletions

196
README.md
View File

@ -1,118 +1,78 @@
# Cista Web Storage
<img src="https://git.zi.fi/Vasanko/cista-storage/raw/branch/main/docs/cista.webp" align=left width=250>
Cista takes its name from the ancient *cistae*, metal containers used by Greeks and Egyptians to safeguard valuable items. This modern application provides a browser interface for secure and accessible file storage, echoing the trust and reliability of its historical namesake.
This is a cutting-edge **file and document server** designed for speed, efficiency, and unparalleled ease of use. Experience **lightning-fast browsing**, thanks to the file list maintained directly in your browser and updated from server filesystem events, coupled with our highly optimized code. Fully **keyboard-navigable** and with a responsive layout, Cista flawlessly adapts to your devices, providing a seamless experience wherever you are. Our powerful **instant search** means you're always just a few keystrokes away from finding exactly what you need. Press **1/2/3** to switch ordering, navigate with all four arrow keys (+Shift to select). Or click your way around on **breadcrumbs that remember where you were**.
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.
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.
Experience Cista by visiting [Cista Demo](https://drop.zi.fi) for a test run and perhaps upload something...
## Getting Started
### Installation
To install the cista application, use:
```fish
pip install cista
```
Note: Some Linux distributions might need `--break-system-packages` to install Python packages, which are safely installed in the user's home folder. As an alternative to avoid installation, run it with command `pipx run cista`
### Running the Server
Create an account: (or run a public server without authentication)
```fish
cista --user yourname --privileged
```
Serve your files at http://localhost:8000:
```fish
cista -l :8000 /path/to/files
```
The server remembers its settings in the config folder (default `~/.local/share/cista/`), including the listen port and directory, for future runs without arguments.
### Internet Access
To use your own TLS certificates, place them in the config folder and run:
```fish
cista -l cista.example.com
```
Most admins instead find the [Caddy](https://caddyserver.com/) web server convenient for its auto TLS certificates and all. A proxy also allows running multiple web services or Cista instances on the same IP address but different (sub)domains.
`/etc/caddy/Caddyfile`:
```Caddyfile
cista.example.com {
reverse_proxy :8000
}
```
## Development setup
For rapid development, we use the Vite development server for the Vue frontend, while running the backend on port 8000 that Vite proxies backend requests to. Each server live reloads whenever its code or configuration are modified.
```fish
cd frontend
npm install
npm run dev
```
Concurrently, start the backend on another terminal:
```fish
hatch shell
pip install -e '.[dev]'
cista --dev -l :8000 /path/to/files
```
We use `hatch shell` for installing on a virtual environment, to avoid disturbing the rest of the system with our hacking.
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. Running `hatch build` builds the frontend and creates a NodeJS-independent Python package.
## System Deployment
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 cista is installed system-wide or on this account (check with `sudo -u storage -s`). Alternatively, use `pipx run cista` or `hatch run cista` as the ExecStart command.
Create `/etc/systemd/system/cista@.service`:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
ExecStart=cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
Restart=always
[Install]
WantedBy=multi-user.target
```
This setup supports multiple storages, each under `/media/storage/<domain>` for files and `/srv/cista/<domain>/` for configuration. UNIX sockets are used instead of numeric ports for convenience.
```fish
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Public exposure is easiest using the Caddy web server.
`/etc/caddy/Caddyfile`:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```
# Web File Storage
The Python package installs a `cista` executable. Use `hatch shell` to initiate and install in a virtual environment, or `pip install` it on your system. Alternatively `hatch run cista` may be used to skip the shell step but stay virtual. `pip install hatch` first if needed.
Create your user account:
```sh
cista --user admin --privileged
```
## Running the server
Serve your files on localhost:8000:
```sh
cista -l :8000 /path/to/files
```
The Git repository does not contain a frontend build, so you should first do that...
## Build frontend
Frontend needs to be built before using and after any frontend changes:
```sh
cd frontend
npm install
npm run build
```
This will place the front in `cista/wwwroot` from where the backend server delivers it, and that also gets included in the Python package built via `hatch build`.
## Development setup
For rapid turnaround during development, you should run `npm run dev` Vite development server on the Vue frontend. While that is running, start the backend on another terminal `hatch run cista --dev -l :8000` and connect to the frontend.
The backend and the frontend will each reload automatically at any code or config changes.
## System deployment
Clone the repository to `/srv/cista/cista-storage` or other suitable location accessible to the storage user account you plan to use. `sudo -u storage -s` and build the frontend if you hadn't already.
Create **/etc/systemd/system/cista@.service**:
```ini
[Unit]
Description=Cista storage %i
[Service]
User=storage
WorkingDirectory=/srv/cista/cista-storage
ExecStart=hatch run cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/@%i/
TimeoutStopSec=2
Restart=always
[Install]
WantedBy=multi-user.target
```
This assumes you may want to run multiple separate storages, each having their files under `/media/storage/<domain>` and configuration under `/srv/cista/<domain>/`. Instead of numeric ports, we use UNIX sockets for convenience.
```sh
systemctl daemon-reload
systemctl enable --now cista@foo.example.com
systemctl enable --now cista@bar.example.com
```
Exposing this publicly online is the most convenient using the [Caddy](https://caddyserver.com/) web server but you can of course use Nginx or others as well. Or even run the server with `-l domain.example.com` given TLS certificates in the config folder.
**/etc/caddy/Caddyfile**:
```Caddyfile
foo.example.com, bar.example.com {
reverse_proxy unix//srv/cista/{host}/socket
}
```
Using the `{host}` placeholder we can just put all the domains on the same block. That's the full server configuration you need. `systemctl enable --now caddy` or `systemctl restart caddy` for the config to take effect.

View File

@ -1,4 +1,3 @@
import os
import sys
from pathlib import Path
@ -62,17 +61,13 @@ def _main():
path = None
_confdir(args)
exists = config.conffile.exists()
print(config.conffile, exists)
import_droppy = args["--import-droppy"]
necessary_opts = exists or import_droppy or path
necessary_opts = exists or import_droppy or path and listen
if not necessary_opts:
# Maybe run without arguments
print(doc)
print(
"No config file found! Get started with one of:\n"
" cista --user yourname --privileged\n"
" cista --import-droppy\n"
" cista -l :8000 /path/to/files\n"
"No config file found! Get started with:\n cista -l :8000 /path/to/files, or\n cista -l example.com --import-droppy # Uses Droppy files\n",
)
return 1
settings = {}
@ -84,15 +79,8 @@ def _main():
settings = droppy.readconf()
if path:
settings["path"] = path
elif not exists:
settings["path"] = Path.home() / "Downloads"
if listen:
settings["listen"] = listen
elif not exists:
settings["listen"] = ":8000"
if not exists and not import_droppy:
# We have no users, so make it public
settings["public"] = True
operation = config.update_config(settings)
print(f"Config {operation}: {config.conffile}")
# Prepare to serve
@ -119,29 +107,16 @@ def _confdir(args):
raise ValueError("Config path is not a directory")
# Accidentally pointed to the db.toml, use parent
confdir = confdir.parent
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir() # Uses environ if available
config.conffile = confdir / config.conffile.name
def _user(args):
_confdir(args)
if config.conffile.exists():
config.load_config()
operation = False
else:
# Defaults for new config when user is created
operation = config.update_config(
{
"listen": ":8000",
"path": Path.home() / "Downloads",
"public": False,
}
)
print(f"Config {operation}: {config.conffile}\n")
config.load_config()
name = args["--user"]
if not name or not name.isidentifier():
raise ValueError("Invalid username")
config.load_config()
u = config.config.users.get(name)
info = f"User {name}" if u else f"New user {name}"
changes = {}
@ -153,17 +128,12 @@ def _user(args):
info += " (admin)" if oldadmin else ""
if args["--password"] or not u:
changes["password"] = pw = pwgen.generate()
info += f"\n Password: {pw}\n"
res = config.update_user(name, changes)
info += f"\n Password: {pw}"
res = config.update_user(args["--user"], changes)
print(info)
if res == "read":
print(" No changes")
if operation == "created":
print(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n"
)
if __name__ == "__main__":
sys.exit(main())

View File

@ -111,24 +111,13 @@ async def watch(req, ws):
)
uuid = token_bytes(16)
try:
q, space, root = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, subscribe, uuid, ws
)
await ws.send(space)
await ws.send(root)
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
await ws.send(watching.format_space(watching.state.space))
await ws.send(watching.format_root(watching.state.root))
# Send updates
while True:
await ws.send(await q.get())
finally:
del watching.pubsub[uuid]
def subscribe(uuid, ws):
with watching.state.lock:
q = watching.pubsub[uuid] = asyncio.Queue()
# Init with disk usage and full tree
return (
q,
watching.format_space(watching.state.space),
watching.format_root(watching.state.root),
)

View File

@ -1,9 +1,7 @@
import asyncio
import datetime
import mimetypes
import threading
from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath
from stat import S_IFDIR, S_IFREG
from urllib.parse import unquote
@ -12,13 +10,12 @@ from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers
from blake3 import blake3
from sanic import Blueprint, Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from setproctitle import setproctitle
from sanic import Blueprint, Sanic, empty, raw
from sanic.exceptions import Forbidden, NotFound, ServerError
from sanic.log import logging
from stream_zip import ZIP_AUTO, stream_zip
from cista import auth, config, preview, session, watching
from cista import auth, config, session, watching
from cista.api import bp
from cista.util.apphelpers import handle_sanic_exception
@ -27,28 +24,21 @@ sanic.helpers._ENTITY_HEADERS = frozenset()
app = Sanic("cista", strict_slashes=True)
app.blueprint(auth.bp)
app.blueprint(preview.bp)
app.blueprint(bp)
app.exception(Exception)(handle_sanic_exception)
setproctitle("cista-main")
@app.before_server_start
async def main_start(app, loop):
config.load_config()
setproctitle(f"cista {config.config.path.name}")
workers = max(2, min(8, cpu_count()))
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=workers, thread_name_prefix="cista-ioworker"
)
await watching.start(app, loop)
app.ctx.threadexec = ThreadPoolExecutor(
max_workers=8, thread_name_prefix="cista-ioworker"
)
@app.after_server_stop
async def main_stop(app, loop):
quit.set()
await watching.stop(app, loop)
app.ctx.threadexec.shutdown()
@ -130,19 +120,10 @@ def _load_wwwroot(www):
br = False
wwwnew[name] = data, br, headers
if not wwwnew:
msg = f"Web frontend missing from {base}\n Did you forget: hatch build\n"
if not www:
logger.warning(msg)
if not app.debug:
msg = "Web frontend missing. Cista installation is broken.\n"
wwwnew[""] = (
msg.encode(),
False,
{
"etag": "error",
"content-type": "text/plain",
"cache-control": "no-store",
},
raise ServerError(
"Web frontend missing. Did you forget npm run build?",
extra={"wwwroot": str(base)},
quiet=True,
)
return wwwnew
@ -151,7 +132,7 @@ def _load_wwwroot(www):
async def start(app):
await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
app.add_task(refresh_wwwroot())
async def load_wwwroot(app):
@ -161,31 +142,27 @@ async def load_wwwroot(app):
)
quit = threading.Event()
async def refresh_wwwroot():
try:
while not quit.is_set():
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
while True:
await asyncio.sleep(0.5)
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
print(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
print("Error loading wwwroot", e)
if not app.debug:
return
@app.route("/<path:path>", methods=["GET", "HEAD"])
@ -205,12 +182,6 @@ async def wwwroot(req, path=""):
return raw(data, headers=headers)
@app.route("/favicon.ico", methods=["GET", "HEAD"])
async def favicon(req):
# Browsers keep asking for it when viewing files (not HTML with icon link)
return redirect("/assets/logo-97d1d7eb.svg", status=308)
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath()
idx = 0
@ -271,7 +242,7 @@ async def zip_download(req, keys, zipfile, ext):
for chunk in stream_zip(local_files(files)):
asyncio.run_coroutine_threadsafe(queue.put(chunk), loop).result()
except Exception:
logger.exception("Error streaming ZIP")
logging.exception("Error streaming ZIP")
raise
finally:
asyncio.run_coroutine_threadsafe(queue.put(None), loop)

View File

@ -71,7 +71,7 @@ def verify(request, *, privileged=False):
raise Forbidden("Access Forbidden: Only for privileged users", quiet=True)
elif config.config.public or request.ctx.user:
return
raise Unauthorized(f"Login required for {request.path}", "cookie", quiet=True)
raise Unauthorized("Login required", "cookie", quiet=True)
bp = Blueprint("auth")
@ -159,35 +159,3 @@ async def logout_post(request):
res = json({"message": msg})
session.delete(res)
return res
@bp.post("/password-change")
async def change_password(request):
try:
if request.headers.content_type == "application/json":
username = request.json["username"]
pwchange = request.json["passwordChange"]
password = request.json["password"]
else:
username = request.form["username"][0]
pwchange = request.form["passwordChange"][0]
password = request.form["password"][0]
if not username or not password:
raise KeyError
except KeyError:
raise BadRequest(
"Missing username, passwordChange or password",
) from None
try:
user = login(username, password)
set_password(user, pwchange)
except ValueError as e:
raise Forbidden(str(e), context={"redirect": "/login"}) from e
if "text/html" in request.headers.accept:
res = redirect("/")
session.flash(res, "Password updated")
else:
res = json({"message": "Password updated"})
session.create(res, username)
return res

View File

@ -1,9 +1,6 @@
from __future__ import annotations
import os
import secrets
import sys
from contextlib import suppress
from functools import wraps
from hashlib import sha256
from pathlib import Path, PurePath
@ -35,23 +32,7 @@ class Link(msgspec.Struct, omit_defaults=True):
config = None
conffile = None
def init_confdir():
if p := os.environ.get("CISTA_HOME"):
home = Path(p)
else:
xdg = os.environ.get("XDG_CONFIG_HOME")
home = (
Path(xdg).expanduser() / "cista" if xdg else Path.home() / ".config/cista"
)
if not home.is_dir():
home.mkdir(parents=True, exist_ok=True)
home.chmod(0o700)
global conffile
conffile = home / "db.toml"
conffile = Path.home() / ".local/share/cista/db.toml"
def derived_secret(*params, len=8) -> bytes:
@ -79,8 +60,8 @@ def dec_hook(typ, obj):
def config_update(modify):
global config
if conffile is None:
init_confdir()
if not conffile.exists():
conffile.parent.mkdir(parents=True, exist_ok=True)
tmpname = conffile.with_suffix(".tmp")
try:
f = tmpname.open("xb")
@ -94,6 +75,10 @@ def config_update(modify):
old = conffile.read_bytes()
c = msgspec.toml.decode(old, type=Config, dec_hook=dec_hook)
except FileNotFoundError:
# No existing config file, make sure we have a folder...
confdir = conffile.parent
confdir.mkdir(parents=True, exist_ok=True)
confdir.chmod(0o700)
old = b""
c = None
c = modify(c)
@ -105,10 +90,6 @@ def config_update(modify):
return "read"
f.write(new)
f.close()
if sys.platform == "win32":
# Windows doesn't support atomic replace
with suppress(FileNotFoundError):
conffile.unlink()
tmpname.rename(conffile) # Atomic replace
except:
f.close()
@ -136,8 +117,6 @@ def modifies_config(modify):
def load_config():
global config
if conffile is None:
init_confdir()
config = msgspec.toml.decode(conffile.read_bytes(), type=Config, dec_hook=dec_hook)
@ -156,7 +135,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
# Encode into dict, update values with new, convert to Config
try:
u = conf.users[name].__copy__()
except (KeyError, AttributeError):
except KeyError:
u = User()
if "password" in changes:
from . import auth
@ -165,7 +144,7 @@ def update_user(conf: Config, name: str, changes: dict) -> Config:
del changes["password"]
udict = msgspec.to_builtins(u, enc_hook=enc_hook)
udict.update(changes)
settings = msgspec.to_builtins(conf, enc_hook=enc_hook) if conf else {"users": {}}
settings = msgspec.to_builtins(conf, enc_hook=enc_hook)
settings["users"][name] = msgspec.convert(udict, User, dec_hook=dec_hook)
return msgspec.convert(settings, Config, dec_hook=dec_hook)

View File

@ -1,117 +0,0 @@
import asyncio
import gc
import io
import mimetypes
import urllib.parse
from pathlib import PurePosixPath
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
import av
import av.datasets
import fitz # PyMuPDF
from PIL import Image
from sanic import Blueprint, empty, raw
from sanic.exceptions import NotFound
from sanic.log import logger
from cista import config
from cista.util.filename import sanitize
DISPLAYMATRIX = av.stream.SideData.DISPLAYMATRIX
bp = Blueprint("preview", url_prefix="/preview")
@bp.get("/<path:path>")
async def preview(req, path):
"""Preview a file"""
maxsize = int(req.args.get("px", 1024))
maxzoom = float(req.args.get("zoom", 2.0))
quality = int(req.args.get("q", 40))
rel = PurePosixPath(sanitize(unquote(path)))
path = config.config.path / rel
stat = path.lstat()
etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex()
savename = PurePosixPath(path.name).with_suffix(".webp")
headers = {
"etag": etag,
"last-modified": format_date_time(stat.st_mtime),
"cache-control": "max-age=604800, immutable"
+ ("" if config.config.public else ", private"),
"content-type": "image/webp",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
}
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
if not path.is_file():
raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, path, quality, maxsize, maxzoom
)
return raw(img, headers=headers)
def dispatch(path, quality, maxsize, maxzoom):
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
if mimetypes.guess_type(path.name)[0].startswith("video/"):
return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize)
def process_image(path, *, maxsize, quality):
img = Image.open(path)
w, h = img.size
img.thumbnail((min(w, maxsize), min(h, maxsize)))
# Fix rotation based on EXIF data
try:
rotate_values = {3: 180, 6: 270, 8: 90}
orientation = img._getexif().get(274)
if orientation in rotate_values:
logger.debug(f"Rotating preview {path} by {rotate_values[orientation]}")
img = img.rotate(rotate_values[orientation], expand=True)
except AttributeError:
...
except Exception as e:
logger.error(f"Error rotating preview image: {e}")
# Save as webp
imgdata = io.BytesIO()
img.save(imgdata, format="webp", quality=quality, method=4)
return imgdata.getvalue()
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
pdf = fitz.open(path)
page = pdf.load_page(page_number)
w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
return pix.pil_tobytes(format="webp", quality=quality, method=4)
def process_video(path, *, maxsize, quality):
with av.open(str(path)) as container:
stream = container.streams.video[0]
stream.codec_context.skip_frame = "NONKEY"
rot = stream.side_data and stream.side_data.get(DISPLAYMATRIX) or 0
container.seek(container.duration // 8)
img = next(container.decode(stream)).to_image()
del stream
img.thumbnail((maxsize, maxsize))
imgdata = io.BytesIO()
if rot:
img = img.rotate(rot, expand=True)
img.save(imgdata, format="webp", quality=quality, method=4)
del img
ret = imgdata.getvalue()
del imgdata
gc.collect()
return ret

View File

@ -112,7 +112,7 @@ class ErrorMsg(msgspec.Struct):
## Directory listings
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
class FileEntry(msgspec.Struct, array_like=True):
level: int
name: str
key: str
@ -120,11 +120,8 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
size: int
isfile: int
def __str__(self):
return self.key or "FileEntry()"
def __repr__(self):
return f"{self.name} ({self.size}, {self.mtime})"
return self.key or "FileEntry()"
class Update(msgspec.Struct, array_like=True):

View File

@ -26,6 +26,7 @@ def run(*, dev=False):
motd=False,
dev=dev,
auto_reload=dev,
reload_dir={confdir},
access_log=True,
) # type: ignore
if dev:
@ -50,7 +51,7 @@ def parse_listen(listen):
raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/",
)
return "http://localhost", {"unix": unix.as_posix()}
return "http://localhost", {"unix": unix}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
try:

View File

@ -21,6 +21,7 @@ def jres(data, **kwargs):
async def handle_sanic_exception(request, e):
logger.exception(e)
context, code = {}, 500
message = str(e)
if isinstance(e, SanicException):
@ -41,7 +42,7 @@ async def handle_sanic_exception(request, e):
res.cookies.add_cookie("message", message, max_age=5)
return res
# Otherwise use Sanic's default error page
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).render()
return errorpages.HTMLRenderer(request, e, debug=request.app.debug).full()
def websocket_wrapper(handler):
@ -53,14 +54,13 @@ def websocket_wrapper(handler):
auth.verify(request)
await handler(request, ws, *args, **kwargs)
except Exception as e:
logger.exception(e)
context, code, message = {}, 500, str(e) or "Internal Server Error"
if isinstance(e, SanicException):
context = e.context or {}
code = e.status_code
message = f"⚠️ {message}" if code < 500 else f"🛑 {message}"
await asend(ws, ErrorMsg({"code": code, "message": message, **context}))
if not getattr(e, "quiet", False) or code == 500:
logger.exception(f"{code} {e!r}")
raise
return wrapper

View File

@ -1,16 +1,15 @@
import asyncio
import shutil
import stat
import sys
import threading
import time
from contextlib import suppress
from os import stat_result
from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG
import msgspec
from natsort import humansorted, natsort_keygen, ns
from sanic.log import logger
from sanic.log import logging
from cista import config
from cista.fileio import fuid
@ -24,7 +23,7 @@ class State:
def __init__(self):
self.lock = threading.RLock()
self._space = Space(0, 0, 0, 0)
self.root: list[FileEntry] = []
self._listing: list[FileEntry] = []
@property
def space(self):
@ -36,236 +35,260 @@ class State:
with self.lock:
self._space = space
@property
def root(self) -> list[FileEntry]:
with self.lock:
return self._listing[:]
def treeiter(rootmod):
relpath = PurePosixPath()
for i, entry in enumerate(rootmod):
if entry.level > 0:
relpath = PurePosixPath(*relpath.parts[: entry.level - 1]) / entry.name
yield i, relpath, entry
@root.setter
def root(self, listing: list[FileEntry]):
with self.lock:
self._listing = listing
def _slice(self, idx: PurePosixPath | tuple[PurePosixPath, int]):
relpath, relfile = idx if isinstance(idx, tuple) else (idx, 0)
begin, end = 0, len(self._listing)
level = 0
isfile = 0
def treeget(rootmod: list[FileEntry], path: PurePosixPath):
begin = None
ret = []
for i, relpath, entry in treeiter(rootmod):
if begin is None:
if relpath == path:
begin = i
ret.append(entry)
continue
if entry.level <= len(path.parts):
break
ret.append(entry)
return begin, ret
# Special case for root
if not relpath.parts:
return slice(begin, end)
def treeinspos(rootmod: list[FileEntry], relpath: PurePosixPath, relfile: int):
# Find the first entry greater than the new one
# precondition: the new entry doesn't exist
isfile = 0
level = 0
i = 0
for i, rel, entry in treeiter(rootmod):
if entry.level > level:
# We haven't found item at level, skip subdirectories
continue
if entry.level < level:
# We have passed the level, so the new item is the first
return i
if level == 0:
# root
begin += 1
for part in relpath.parts:
level += 1
continue
ename = rel.parts[level - 1]
name = relpath.parts[level - 1]
esort = sortkey(ename)
nsort = sortkey(name)
# Non-leaf are always folders, only use relfile at leaf
isfile = relfile if len(relpath.parts) == level else 0
# First compare by isfile, then by sorting order and if that too matches then case sensitive
cmp = (
entry.isfile - isfile
or (esort > nsort) - (esort < nsort)
or (ename > name) - (ename < name)
)
if cmp > 0:
return i
if cmp < 0:
continue
level += 1
if level > len(relpath.parts):
print("ERROR: insertpos", relpath, i, entry.name, entry.level, level)
break
else:
i += 1
return i
found = False
while begin < end:
entry = self._listing[begin]
if entry.level < level:
break
if entry.level == level:
if entry.name == part:
found = True
if level == len(relpath.parts):
isfile = relfile
else:
begin += 1
break
cmp = entry.isfile - isfile or sortkey(entry.name) > sortkey(part)
if cmp > 0:
break
begin += 1
if not found:
return slice(begin, begin)
# Found the starting point, now find the end of the slice
for end in range(begin + 1, len(self._listing) + 1):
if end == len(self._listing) or self._listing[end].level <= level:
break
return slice(begin, end)
def __getitem__(self, index: PurePosixPath | tuple[PurePosixPath, int]):
with self.lock:
return self._listing[self._slice(index)]
def __setitem__(
self, index: tuple[PurePosixPath, int], value: list[FileEntry]
) -> None:
rel, isfile = index
with self.lock:
if rel.parts:
parent = self._slice(rel.parent)
if parent.start == parent.stop:
raise ValueError(
f"Parent folder {rel.as_posix()} is missing for {rel.name}"
)
self._listing[self._slice(index)] = value
def __delitem__(self, relpath: PurePosixPath):
with self.lock:
del self._listing[self._slice(relpath)]
state = State()
rootpath: Path = None # type: ignore
quit = threading.Event()
## Filesystem scanning
quit = False
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]:
def watcher_thread(loop):
global rootpath
import inotify.adapters
while True:
rootpath = config.config.path
i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
new = walk()
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
# The watching is not entirely reliable, so do a full refresh every 30 seconds
refreshdl = time.monotonic() + 30.0
for event in i.event_gen():
if quit:
return
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
break
# Do a full refresh?
if time.monotonic() > refreshdl:
break
if event is None:
continue
_, flags, path, filename = event
if not any(f in modified_flags for f in flags):
continue
# Update modified path
path = PurePosixPath(path) / filename
try:
update(path.relative_to(rootpath), loop)
except Exception as e:
print("Watching error", e, path, rootpath)
raise
i = None # Free the inotify object
def watcher_thread_poll(loop):
global rootpath
while not quit:
rootpath = config.config.path
new = walk()
with state.lock:
old = state.root
if old != new:
state.root = new
broadcast(format_update(old, new), loop)
# Disk usage update
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
if space != state.space:
state.space = space
broadcast(format_space(space), loop)
time.sleep(2.0)
def walk(rel=PurePosixPath()) -> list[FileEntry]: # noqa: B008
path = rootpath / rel
try:
st = path.stat()
except OSError:
return []
return _walk(rel, int(not stat.S_ISDIR(st.st_mode)), st)
def _walk(rel: PurePosixPath, isfile: int, st: stat_result) -> list[FileEntry]:
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
isfile=isfile,
)
if isfile:
return [entry]
ret = [entry]
path = rootpath / rel
ret = []
try:
st = stat or path.stat()
isfile = int(not S_ISDIR(st.st_mode))
entry = FileEntry(
level=len(rel.parts),
name=rel.name,
key=fuid(st),
mtime=int(st.st_mtime),
size=st.st_size if isfile else 0,
isfile=isfile,
)
if isfile:
return [entry]
# Walk all entries of the directory
ret: list[FileEntry] = [...] # type: ignore
li = []
for f in path.iterdir():
if quit.is_set():
raise SystemExit("quit")
if f.name.startswith("."):
continue # No dotfiles
with suppress(FileNotFoundError):
s = f.lstat()
isfile = S_ISREG(s.st_mode)
isdir = S_ISDIR(s.st_mode)
if not isfile and not isdir:
continue
li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li):
sub = walk(rel / name, stat=s)
child = sub[0]
entry = FileEntry(
level=entry.level,
name=entry.name,
key=entry.key,
size=entry.size + child.size,
mtime=max(entry.mtime, child.mtime),
isfile=entry.isfile,
)
ret.extend(sub)
s = f.stat()
li.append((int(not stat.S_ISDIR(s.st_mode)), f.name, s))
for [isfile, name, s] in humansorted(li):
subtree = _walk(rel / name, isfile, s)
child = subtree[0]
entry.mtime = max(entry.mtime, child.mtime)
entry.size += child.size
ret.extend(subtree)
except FileNotFoundError:
pass # Things may be rapidly in motion
except OSError as e:
if e.errno == 13: # Permission denied
pass
logger.error(f"Watching {path=}: {e!r}")
if ret:
ret[0] = entry
print("OS error walking path", path, e)
return ret
def update_root(loop):
"""Full filesystem scan"""
old = state.root
new = walk(PurePosixPath())
if old != new:
update = format_update(old, new)
with state.lock:
broadcast(update, loop)
state.root = new
def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
"""Called on FS updates, check the filesystem and broadcast any changes."""
def update(relpath: PurePosixPath, loop):
"""Called by inotify updates, check the filesystem and broadcast any changes."""
if rootpath is None or relpath is None:
print("ERROR", rootpath, relpath)
new = walk(relpath)
obegin, old = treeget(rootmod, relpath)
if old == new:
logger.debug(
f"Watch: Event without changes needed {relpath}"
if old
else f"Watch: Event with old and new missing: {relpath}"
)
return
if obegin is not None:
del rootmod[obegin : obegin + len(old)]
if new:
logger.debug(f"Watch: Update {relpath}" if old else f"Watch: Created {relpath}")
i = treeinspos(rootmod, relpath, new[0].isfile)
rootmod[i:i] = new
else:
logger.debug(f"Watch: Removed {relpath}")
def update_space(loop):
"""Called periodically to update the disk usage."""
du = shutil.disk_usage(rootpath)
space = Space(*du, storage=state.root[0].size)
# Update only on difference above 1 MB
tol = 10**6
old = msgspec.structs.astuple(state.space)
new = msgspec.structs.astuple(space)
if any(abs(o - n) > tol for o, n in zip(old, new, strict=True)):
state.space = space
broadcast(format_space(space), loop)
## Messaging
with state.lock:
old = state[relpath]
if old == new:
return
old = state.root
if new:
state[relpath, new[0].isfile] = new
else:
del state[relpath]
broadcast(format_update(old, state.root), loop)
def format_update(old, new):
# Make keep/del/insert diff until one of the lists ends
oidx, nidx = 0, 0
oremain, nremain = set(old), set(new)
update = []
keep_count = 0
while oidx < len(old) and nidx < len(new):
modified = False
# Matching entries are kept
if old[oidx] == new[nidx]:
entry = old[oidx]
oremain.remove(entry)
nremain.remove(entry)
keep_count += 1
oidx += 1
nidx += 1
continue
if keep_count > 0:
modified = True
update.append(UpdKeep(keep_count))
keep_count = 0
# Items only in old are deleted
del_count = 0
while oidx < len(old) and old[oidx] not in nremain:
oremain.remove(old[oidx])
rest = new[nidx:]
while oidx < len(old) and old[oidx] not in rest:
del_count += 1
oidx += 1
if del_count:
update.append(UpdDel(del_count))
continue
# Items only in new are inserted
insert_items = []
while nidx < len(new) and new[nidx] not in oremain:
entry = new[nidx]
nremain.remove(entry)
insert_items.append(entry)
rest = old[oidx:]
while nidx < len(new) and new[nidx] not in rest:
insert_items.append(new[nidx])
nidx += 1
if insert_items:
modified = True
update.append(UpdIns(insert_items))
if not modified:
raise Exception(
f"Infinite loop in diff {nidx=} {oidx=} {len(old)=} {len(new)=}"
)
update.append(UpdIns(insert_items))
# Diff any remaining
if keep_count > 0:
update.append(UpdKeep(keep_count))
if oremain:
update.append(UpdDel(len(oremain)))
elif nremain:
if oidx < len(old):
update.append(UpdDel(len(old) - oidx))
elif nidx < len(new):
update.append(UpdIns(new[nidx:]))
return msgspec.json.encode({"update": update}).decode()
@ -289,108 +312,20 @@ async def abroadcast(msg):
queue.put_nowait(msg)
except Exception:
# Log because asyncio would silently eat the error
logger.exception("Broadcast error")
## Watcher thread
def watcher_inotify(loop):
"""Inotify watcher thread (Linux only)"""
import inotify.adapters
modified_flags = (
"IN_CREATE",
"IN_DELETE",
"IN_DELETE_SELF",
"IN_MODIFY",
"IN_MOVE_SELF",
"IN_MOVED_FROM",
"IN_MOVED_TO",
)
while not quit.is_set():
i = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem
t0 = time.perf_counter()
update_root(loop)
t1 = time.perf_counter()
logger.debug(f"Root update took {t1 - t0:.1f}s")
trefresh = time.monotonic() + 300.0
tspace = time.monotonic() + 5.0
# Watch for changes (frequent wakeups needed for quiting)
while not quit.is_set():
t = time.monotonic()
# The watching is not entirely reliable, so do a full refresh every 30 seconds
if t >= trefresh:
break
# Disk usage update
if t >= tspace:
tspace = time.monotonic() + 5.0
update_space(loop)
# Inotify events, update the tree
dirty = False
rootmod = state.root[:]
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
assert event
if quit.is_set():
return
interesting = any(f in modified_flags for f in event[1])
if event[2] == rootpath.as_posix() and event[3] == "zzz":
logger.debug(f"Watch: {interesting=} {event=}")
if interesting:
# Update modified path
t0 = time.perf_counter()
path = PurePosixPath(event[2]) / event[3]
update_path(rootmod, path.relative_to(rootpath), loop)
t1 = time.perf_counter()
logger.debug(f"Watch: Update {event[3]} took {t1 - t0:.1f}s")
if not dirty:
t = time.monotonic()
dirty = True
# Wait a maximum of 0.5s to push the updates
if dirty and time.monotonic() >= t + 0.5:
break
if dirty and state.root != rootmod:
t0 = time.perf_counter()
update = format_update(state.root, rootmod)
t1 = time.perf_counter()
with state.lock:
broadcast(update, loop)
state.root = rootmod
t2 = time.perf_counter()
logger.debug(
f"Format update took {t1 - t0:.1f}s, broadcast {t2 - t1:.1f}s"
)
del i # Free the inotify object
def watcher_poll(loop):
"""Polling version of the watcher thread."""
while not quit.is_set():
t0 = time.perf_counter()
update_root(loop)
update_space(loop)
dur = time.perf_counter() - t0
if dur > 1.0:
logger.debug(f"Reading the full file list took {dur:.1f}s")
quit.wait(0.1 + 8 * dur)
logging.exception("Broadcast error")
async def start(app, loop):
global rootpath
config.load_config()
rootpath = config.config.path
use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll,
target=watcher_thread if use_inotify else watcher_thread_poll,
args=[loop],
# Descriptive name for system monitoring
name=f"cista-watcher {rootpath}",
)
app.ctx.watcher.start()
async def stop(app, loop):
quit.set()
global quit
quit = True
app.ctx.watcher.join()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@ -1,2 +0,0 @@
audit=false
fund=false

View File

@ -1,46 +1,40 @@
# Cista Vue Frontend
The frontend is a Single-Page App implemented with Vue 3. Development uses the Vite server together with the main Python backend, but in production the latter also serves the prebuilt frontend files.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Hot-Reload for Development
### Run the backend
```fish
hatch shell
cista --dev -l :8000
```
### And the Vite server (in another terminal)
```fish
cd frontend
npm install
npm run dev
```
Browse to Vite, which will proxy API requests to port 8000. Both servers live reload changes.
### Type-Check, Compile and Minify for Production
This is also called by `hatch build` during Python packaging:
```fish
npm run build
```
# cista-front
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```

View File

@ -8,4 +8,5 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script type="module" src="/src/main.ts"></script>
<body id="app">
<div id="app"></div>

View File

@ -12,9 +12,6 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"@imengyu/vue3-context-menu": "^1.3.3",
"@vueuse/core": "^10.4.1",
@ -24,6 +21,7 @@
"pinia": "^2.1.6",
"pinia-plugin-persistedstate": "^3.2.0",
"unplugin-vue-components": "^0.25.2",
"vite-plugin-rewrite-all": "^1.0.1",
"vite-svg-loader": "^4.0.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4"

View File

@ -1,19 +1,14 @@
<template>
<LoginModal />
<SettingsModal />
<header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" primary />
<BreadCrumb :path="path.pathList" tabindex="-1"/>
</header>
<main>
<RouterView :path="path.pathList" :query="path.query" />
</main>
<footer>
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer>
</template>
<script setup lang="ts">
@ -26,8 +21,6 @@ import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import Router from '@/router/index'
import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue'
interface Path {
path: string
@ -55,95 +48,70 @@ const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0
let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => {
if (store.dialog) {
if (timer) {
clearTimeout(timer)
timer = null
}
return
}
const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return
const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup'
if (event.repeat) {
if (
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
(c && event.code === 'Space')
) {
if (!input) event.preventDefault()
event.preventDefault()
}
return
}
//console.log("key pressed", event)
/// Long if-else machina for all keys we handle here
let arrow = ''
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase()
// For up/down implement custom fast repeat
if (event.key === 'ArrowUp') vert = keyup ? 0 : event.altKey ? -10 : -1
else if (event.key === 'ArrowDown') vert = keyup ? 0 : event.altKey ? 10 : 1
// Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput()
}
// Search also on / (UNIX style)
else if (!input && keyup && event.key === '/') {
headerMain.value!.toggleSearchInput()
}
// Globally close search, clear errors on Escape
else if (keyup && event.key === 'Escape') {
store.error = ''
headerMain.value!.closeSearch(event)
store.focusBreadcrumb()
}
else if (!input && keyup && event.key === 'Backspace') {
Router.back()
}
// Select all (toggle); keydown to precede and prevent builtin
else if (!input && !keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
// Select all (toggle); keydown to prevent builtin
else if (!keyup && event.key === 'a' && (event.ctrlKey || event.metaKey)) {
fileExplorer.toggleSelectAll()
}
// G toggles Gallery
else if (!input && keyup && event.key === 'g') {
store.prefs.gallery = !store.prefs.gallery
}
// Keys Backquote-1-2-3 to sort columns
// Keys 1-3 to sort columns
else if (
!input &&
c &&
keyup &&
(event.code === 'Backquote' || event.key === '1' || event.key === '2' || event.key === '3')
(event.key === '1' || event.key === '2' || event.key === '3')
) {
store.sort(['', 'name', 'modified', 'size'][+event.key || 0] as SortOrder)
fileExplorer.toggleSortColumn(+event.key)
}
// Rename
else if (!input && c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
else if (c && keyup && !event.ctrlKey && (event.key === 'F2' || event.key === 'r')) {
fileExplorer.cursorRename()
}
// Toggle selections on file explorer; ignore all spaces to prevent scrolling built-in hotkey
else if (!input && c && event.code === 'Space') {
else if (c && event.code === 'Space') {
if (keyup && !event.altKey && !event.ctrlKey)
fileExplorer.cursorSelect()
}
else return
/// We are handling this!
} else return
event.preventDefault()
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
if (!vert) {
if (timer) {
clearTimeout(timer) // Good for either timeout or interval
timer = null
}
return
}
let f: any
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 (!timer) {
// Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100
f()
timer = setTimeout(() => { timer = setInterval(f, tr) }, t0 - tr)
const select = event.shiftKey
fileExplorer.cursorMove(vert, select)
const t0 = 200,
tr = 30
timer = setTimeout(
() =>
(timer = setInterval(() => {
fileExplorer.cursorMove(vert, select)
}, tr)),
t0 - tr
)
}
}
onMounted(() => {
@ -156,3 +124,4 @@ onUnmounted(() => {
})
export type { Path }
</script>
@/stores/main

View File

@ -14,7 +14,7 @@
/* The following are overridden by responsive layouts */
--root-font-size: 1rem;
--header-font-size: 1rem;
--header-height: 4rem;
--header-height: calc(6.5 * var(--header-font-size));
}
@media (prefers-color-scheme: dark) {
:root {
@ -37,6 +37,12 @@
:root {
--root-font-size: calc(8px + 8 * 100vw / 1000);
}
header .buttons:has(input[type='search']) > div {
display: none;
}
header .buttons > div:has(input[type='search']) {
display: inherit;
}
}
@media screen and (min-width: 2000px) {
:root {
@ -48,7 +54,6 @@
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
@ -73,13 +78,17 @@
}
header {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: end;
}
header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
header .breadcrumb {
flex-shrink: 1;
}
header .breadcrumb > * {
flex-shrink: 1;
padding-top: 1rem !important;
padding-bottom: 1rem !important;
}
}
@media print {
@ -89,11 +98,10 @@
--header-background: none;
--header-color: black;
}
.headermain,
nav,
.menu,
.rename-button,
.suggest-gallery {
display: none !important;
.rename-button {
display: none;
}
.breadcrumb > a {
color: black !important;
@ -108,31 +116,16 @@
}
.breadcrumb svg {
fill: black !important;
margin: 0 .5rem 0 1rem !important;
}
body#app {
height: auto !important;
}
main {
height: auto !important;
padding-bottom: 0 !important;
}
thead tr {
font-size: 1rem !important;
position: static !important;
background: none !important;
border-bottom: 1pt solid black !important;
}
audio::-webkit-media-controls-timeline,
video::-webkit-media-controls-timeline {
display: none;
}
audio::-webkit-media-controls,
video::-webkit-media-controls {
display: none;
}
tr, figure {
page-break-inside: avoid;
}
.selection {
min-width: 0 !important;
padding: 0 !important;
@ -149,13 +142,14 @@
left: 0;
}
}
* {
box-sizing: border-box;
}
html {
font-size: var(--root-font-size);
overflow: hidden;
}
/* Hide scrollbar for all browsers */
main::-webkit-scrollbar {
display: none;
}
main {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
@ -172,7 +166,6 @@ tbody .modified {
font-family: 'Roboto Mono';
}
header {
flex: 0 0 auto;
background-color: var(--header-background);
color: var(--header-color);
font-size: var(--header-font-size);
@ -214,23 +207,21 @@ table {
border: 0;
gap: 0;
}
body#app {
height: 100vh;
#app {
height: 100%;
display: flex;
flex-direction: column;
}
main {
flex: 1 1 auto;
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
text-align: center;
}
header nav.headermain {
/* Position so that tooltips can appear on top of other positioned elements */
position: relative;
z-index: 100;
}
main {
height: calc(100svh - var(--header-height));
padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll;
}
.spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em }

View File

@ -2,19 +2,14 @@
<nav
class="breadcrumb"
aria-label="Breadcrumb"
@keydown.left.stop="move(-1)"
@keydown.right.stop="move(1)"
@keyup.left.stop="move(-1)"
@keyup.right.stop="move(1)"
@keyup.enter="move(0)"
@focus=focusCurrent
tabindex=0
>
<a href="#/"
:ref="el => setLinkRef(0, el)"
class="home"
:class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)"
@click.prevent="navigate(0)"
title="/"
>
<component :is="home" />
</a>
@ -24,7 +19,6 @@
:aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`"
>{{ location }}</a>
</template>
</nav>
@ -32,9 +26,8 @@
<script setup lang="ts">
import home from '@/assets/svg/home.svg'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil'
const router = useRouter()
@ -44,33 +37,22 @@ onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const props = defineProps<{
path: Array<string>
primary?: boolean
}>()
const longest = ref<Array<string>>([])
const isCurrent = (index: number) => index == props.path.length ? 'location' : undefined
const focusCurrent = () => {
nextTick(() => {
const index = props.path.length
if (index < links.length) links[index].focus()
})
}
const navigate = (index: number) => {
const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0])
const url = `/${longest.value.slice(0, index).join('/')}/`
const here = `/${longest.value.join('/')}/`
const current = decodeURIComponent(location.hash.slice(1).split('//')[0])
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history
if (isCurrent(index)) { longest.value.splice(index); router.push(u) }
// Moving along breadcrumbs doesn't create new history
else if (long.startsWith(browser)) router.replace(u)
// Nornal navigation from elsewhere (e.g. search result breadcrumbs)
if (here.startsWith(current)) router.replace(u)
else router.push(u)
link.focus()
}
const move = (dir: number) => {
@ -82,25 +64,13 @@ const move = (dir: number) => {
watchEffect(() => {
const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index])
// Navigated out of previous path, reset longest to current
if (!same) longest.value = props.path
else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length))
}
else {
// Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) {
longest.value = longest.value.slice(0, i)
break
}
}
}
// If needed, focus primary navigation to new location
if (props.primary) nextTick(() => {
const act = document.activeElement as HTMLElement
if (!act || [...links, document.body].includes(act)) focusCurrent()
})
})
watchEffect(() => {
if (links.length) navigate(props.path.length)
})
</script>
@ -115,36 +85,31 @@ watchEffect(() => {
--breadcrumb-transtime: 0.3s;
}
.breadcrumb {
flex: 1 1 auto;
display: flex;
min-width: 20%;
max-width: 100%;
min-height: 2em;
list-style: none;
margin: 0;
padding: 0 1em 0 0;
overflow: hidden;
}
.breadcrumb > a {
flex: 0 4 auto;
display: flex;
align-items: center;
margin: 0 -0.5em 0 -0.5em;
padding: 0;
max-width: 8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
height: 1.5em;
color: var(--breadcrumb-color);
padding: 0.3em 1.5em;
clip-path: polygon(0 0, 1em 50%, 0 100%, 100% 100%, 100% 0, 0 0);
transition: all var(--breadcrumb-transtime);
}
.breadcrumb > a:first-child {
flex: 0 0 auto;
padding-left: 1.5em;
padding-right: 1.7em;
.breadcrumb a:first-child {
margin-left: 0;
padding-left: .2em;
clip-path: none;
}
.breadcrumb > a:last-child {
.breadcrumb a:last-child {
max-width: none;
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@ -155,7 +120,7 @@ watchEffect(() => {
0 0
);
}
.breadcrumb > a:only-child {
.breadcrumb a:only-child {
clip-path: polygon(
0 0,
calc(100% - 1em) 0,
@ -167,9 +132,9 @@ watchEffect(() => {
}
.breadcrumb svg {
/* FIXME: Custom positioning to align it well; needs proper solution */
padding-left: 0.8em;
width: 1.3em;
height: 1.3em;
margin: -.5em;
fill: var(--breadcrumb-color);
transition: fill var(--breadcrumb-transtime);
}
@ -189,6 +154,6 @@ watchEffect(() => {
}
.breadcrumb a:hover { color: var(--breadcrumb-hover-color) }
.breadcrumb a:hover svg { fill: var(--breadcrumb-hover-color) }
.breadcrumb a.current { color: var(--accent-color); max-width: none; flex: 0 1 auto; }
.breadcrumb a.current { color: var(--accent-color) }
.breadcrumb a.current svg { fill: var(--accent-color) }
</style>

View File

@ -1,171 +0,0 @@
<template>
<SvgButton name="download" data-tooltip="Download" @click="download" />
</template>
<script setup lang="ts">
import { useMainStore } from '@/stores/main'
import type { SelectedItems } from '@/repositories/Document'
import { reactive } from 'vue';
const store = useMainStore()
const status_init = {
total: 0,
xfer: 0,
t0: 0,
tlast: 0,
statbytes: 0,
statdur: 0,
files: [] as string[],
filestart: 0,
fileidx: 0,
filecount: 0,
filename: '',
filesize: 0,
filepos: 0,
status: 'idle',
}
store.dprogress = {...status_init}
setInterval(() => {
if (Date.now() - store.dprogress.tlast > 3000) {
// Reset
store.dprogress.statbytes = 0
store.dprogress.statdur = 1
} else {
// Running average by decay
store.dprogress.statbytes *= .9
store.dprogress.statdur *= .9
}
}, 100)
const statReset = () => {
Object.assign(store.dprogress, status_init)
store.dprogress.t0 = Date.now()
store.dprogress.tlast = store.dprogress.t0 + 1
}
const cancelDownloads = () => {
location.reload() // FIXME
}
const linkdl = (href: string) => {
const a = document.createElement('a')
a.href = href
a.download = ''
a.click()
}
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = ''
let h = handle
console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) {
if (doc.dir) continue
store.dprogress.files.push(rel)
++store.dprogress.filecount
store.dprogress.total += doc.size
}
for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories
if (hdir && !rel.startsWith(hdir + '/')) {
hdir = ''
h = handle
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
if (!dir) continue
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) {
console.error('Failed to create directory', hdir, error)
return
}
console.log('Created', hdir)
}
if (doc.dir) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC')
// Download file
let fileHandle
try {
fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error)
return
}
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
if (!res.ok) {
store.error = `Failed to download ${url}: ${res.status} ${res.statusText}`
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
}
if (res.body) {
++store.dprogress.fileidx
const reader = res.body.getReader()
await writable.truncate(0)
store.error = "Direct download."
store.dprogress.tlast = Date.now()
while (true) {
const { value, done } = await reader.read()
if (done) break
await writable.write(value)
const now = Date.now()
const size = value.byteLength
store.dprogress.xfer += size
store.dprogress.filepos += size
store.dprogress.statbytes += size
store.dprogress.statdur += now - store.dprogress.tlast
store.dprogress.tlast = now
}
}
await writable.close()
console.log('Saved', hdir + name)
}
statReset()
}
const download = async () => {
const sel = store.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
store.error = 'No existing files selected'
store.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
store.error = "Single file via browser downloads"
return linkdl(`/files/${files[0][1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle)
store.selected.clear()
return
} catch (e) {
console.error('Download to folder aborted', e)
}
}
// Otherwise, zip and download
console.log("Falling back to zip download")
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.error = "Downloading as ZIP via browser downloads"
store.selected.clear()
}
</script>
<style scoped>
</style>

View File

@ -1,38 +0,0 @@
<template>
<div v-if="!props.path || documents.length === 0" class="empty-container">
<component :is="cog" class="cog"/>
<p v-if="!store.connected">No Connection</p>
<p v-else-if="store.document.length === 0">Waiting for File List</p>
<p v-else-if="store.query">No matches!</p>
<p v-else-if="!exists(props.path)">Folder not found</p>
<p v-else>Empty folder</p>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
import { useMainStore } from '@/stores/main'
import cog from '@/assets/svg/cog.svg'
import { exists } from '@/utils/fileutil'
const store = useMainStore()
const props = defineProps<{
path: string[],
documents: Document[],
}>()
</script>
<style scoped>
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
svg.cog {
width: 10rem;
height: 10rem;
margin: 0 auto;
animation: rotate 10s linear infinite;
filter: drop-shadow(0 0 1rem black);
fill: #888;
}
</style>

View File

@ -28,11 +28,11 @@
<tr
:id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
:class="{ file: !doc.dir, folder: doc.dir, cursor: cursor === doc }"
@click="cursor = cursor === doc ? null : doc"
@contextmenu.prevent="contextMenu($event, doc)"
>
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
<td class="selection" @click.up.stop="cursor = cursor === doc ? doc : null">
<input
type="checkbox"
tabindex="-1"
@ -49,16 +49,22 @@
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
</template>
<template v-else>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }}
</a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button>
<a
:href="doc.url"
tabindex="-1"
@contextmenu.prevent
@focus.stop="cursor = doc"
@keyup.left="router.back()"
@keyup.right.stop="ev => { if (doc.dir) (ev.target as HTMLElement).click() }"
>{{ doc.name }}</a
>
<button v-if="cursor == doc" class="rename-button" @click="() => (editing = doc)">🖊</button>
</template>
</td>
<FileModified :doc=doc :key=nowkey />
<FileSize :doc=doc />
<td class="menu">
<button tabindex=-1 @click.stop="contextMenu($event, doc)"></button>
<button tabindex="-1" @click.stop="contextMenu($event, doc)"></button>
</td>
</tr>
</template>
@ -69,6 +75,7 @@
</tr>
</tbody>
</table>
<div v-else class="empty-container">Nothing to see here</div>
</template>
<script setup lang="ts">
@ -80,6 +87,7 @@ import { connect, controlUrl } from '@/repositories/WS'
import { formatSize } from '@/utils'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
const props = defineProps<{
path: Array<string>
@ -87,6 +95,7 @@ const props = defineProps<{
}>()
const store = useMainStore()
const router = useRouter()
const cursor = shallowRef<Doc | null>(null)
// File rename
const editing = shallowRef<Doc | null>(null)
const rename = (doc: Doc, newName: string) => {
@ -115,7 +124,6 @@ const rename = (doc: Doc, newName: string) => {
}
defineExpose({
newFolder() {
console.log("New folder")
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
@ -125,51 +133,46 @@ defineExpose({
mtime: now,
size: 0,
})
store.cursor = editing.value.key
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
return cursor.value !== null && editing.value === null
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
editing.value = cursor.value
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
const doc = cursor.value
if (!doc) return
if (store.selected.has(doc.key)) {
store.selected.delete(doc.key)
} else {
store.selected.add(key)
store.selected.add(doc.key)
}
this.cursorMove(1, null)
this.cursorMove(1)
},
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() },
right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click()
},
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
cursorMove(d: number, select = false) {
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
cursor.value = null
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : docs.length
cursor.value !== null ? docs.indexOf(cursor.value) : docs.length
const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
cursor.value = docs[moveto] ?? null
const tr = cursor.value ? document.getElementById(`file-${cursor.value.key}`) : null
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
@ -199,18 +202,18 @@ const focusBreadcrumb = () => {
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value?.key
if (store.cursor) {
if (cursor.value && cursor.value !== editing.value) editing.value = null
if (editing.value) cursor.value = editing.value
if (cursor.value) {
const a = document.querySelector(
`#file-${store.cursor} .name a`
`#file-${cursor.value.key} .name a`
) as HTMLAnchorElement | null
if (a) a.focus()
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
store.cursor = ''
if (!props.documents.length && cursor.value) {
cursor.value = null
focusBreadcrumb()
}
})
@ -219,14 +222,7 @@ let modifiedTimer: any = null
const updateModified = () => {
nowkey.value = Math.floor(Date.now() / 1000)
}
onMounted(() => {
updateModified(); modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
})
onMounted(() => { updateModified(); modifiedTimer = setInterval(updateModified, 1000) })
onUnmounted(() => { clearInterval(modifiedTimer) })
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
@ -291,7 +287,7 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
cursor.value = doc
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
@ -314,36 +310,29 @@ tbody tr {
position: relative;
z-index: auto;
}
table thead .selection input[type='checkbox'] {
table thead input[type='checkbox'] {
position: inherit;
width: 1rem;
height: 1rem;
padding: 0;
margin: auto;
width: 1em;
height: 1em;
padding: 0.5rem 0.5em;
}
table tbody .selection input[type='checkbox'] {
table tbody input[type='checkbox'] {
width: 2rem;
height: 2rem;
}
table .selection {
width: 3rem;
width: 2rem;
text-align: center;
text-overflow: clip;
padding: 0;
}
table .selection input {
margin: auto;
}
table .modified {
width: 10rem;
text-overflow: clip;
width: 9em;
}
table .size {
width: 7rem;
text-overflow: clip;
width: 5em;
}
table .menu {
width: 2rem;
width: 1rem;
}
tbody td {
font-size: 1.2rem;
@ -378,7 +367,7 @@ table td {
}
}
thead tr {
font-size: 0.8rem;
font-size: var(--header-font-size);
background: linear-gradient(to bottom, #eee, #fff 30%, #ddd);
color: #000;
box-shadow: 0 0 .2rem black;
@ -399,11 +388,9 @@ tbody tr.cursor {
padding-right: 1.5rem;
}
.sortcolumn::after {
font-size: 1rem;
content: '▸';
color: #888;
margin-left: 0.5rem;
margin-top: -.2rem;
margin-left: 0.5em;
position: absolute;
transition: all var(--transition-time) linear;
}

View File

@ -56,10 +56,4 @@ input#FileRenameInput {
outline: none;
font: inherit;
}
.gallery input#FileRenameInput {
padding: .75em;
font-weight: 600;
width: auto;
}
</style>

View File

@ -1,266 +0,0 @@
<template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)">
<template v-if=showFolderBreadcrumb(index)>
<BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" class="folder-change"/>
<div class="spacer"></div>
</template>
</GalleryFigure>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS'
import { useRouter } from 'vue-router'
import ContextMenu from '@imengyu/vue3-context-menu'
import type { SortOrder } from '@/utils/docsort'
const props = defineProps<{
path: Array<string>
documents: Doc[]
}>()
const store = useMainStore()
const router = useRouter()
// File rename
const editing = shallowRef<Doc | null>(null)
const exit = () => { editing.value = null }
const rename = (doc: Doc, newName: string) => {
const oldName = doc.name
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Rename failed', msg.error.message, msg.error)
doc.name = oldName
} else {
console.log('Rename succeeded', msg)
}
}
})
control.onopen = () => {
control.send(
JSON.stringify({
op: 'rename',
path: `${doc.loc}/${oldName}`,
to: newName
})
)
}
doc.name = newName // We should get an update from watch but this is quicker
}
const gallery = ref<HTMLElement>()
const columns = computed(() => {
if (!gallery.value) return 1
return getComputedStyle(gallery.value).gridTemplateColumns.split(' ').length
})
defineExpose({
newFolder() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New Folder',
dir: true,
mtime: now,
size: 0,
})
store.cursor = editing.value.key
},
toggleSelectAll() {
console.log('Select')
allSelected.value = !allSelected.value
},
toggleSortColumn(column: number) {
const order = ['', 'name', 'modified', 'size', ''][column]
if (order) store.toggleSort(order as SortOrder)
},
isCursor() {
return store.cursor && editing.value === null
},
cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
},
cursorSelect() {
const key = store.cursor
if (!key) return
if (store.selected.has(key)) {
store.selected.delete(key)
} else {
store.selected.add(key)
}
this.cursorMove(1, null)
},
up(ev: KeyboardEvent) { this.cursorMove(-columns.value, ev) },
down(ev: KeyboardEvent) { this.cursorMove(columns.value, ev) },
left(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
right(ev: KeyboardEvent) { this.cursorMove(1, ev) },
cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index =
store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
// Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === "ArrowLeft") return
if (ev && index === N - 1 && ev.key === "ArrowRight") return
// Calculate new position
let moveto
if (index === N) moveto = d > 0 ? 0 : N - 1
else {
moveto = increment(index, d)
// Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
}
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p].key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr)
scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
if (moveto === N) focusBreadcrumb()
}
})
const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus()
}
let scrolltimer: any = null
let scrolltr: any = null
watchEffect(() => {
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
if (editing.value) store.cursor = editing.value.key
if (store.cursor) {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) { a.focus(); a.scrollIntoView({ block: 'center', behavior: 'smooth' }) }
}
})
watchEffect(() => {
if (!props.documents.length && store.cursor) {
store.cursor = ''
focusBreadcrumb()
}
})
onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null
if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' })
active.focus()
}
})
const mkdir = (doc: Doc, name: string) => {
const control = connect(controlUrl, {
open() {
control.send(
JSON.stringify({
op: 'mkdir',
path: `${doc.loc}/${name}`
})
)
},
message(ev: MessageEvent) {
const msg = JSON.parse(ev.data)
if ('error' in msg) {
console.error('Mkdir failed', msg.error.message, msg.error)
editing.value = null
} else {
console.log('mkdir', msg)
router.push(doc.urlrouter)
}
}
})
// We should get an update from watch but this is quicker
doc.name = name
doc.key = crypto.randomUUID()
}
const showFolderBreadcrumb = (i: number) => {
const docs = props.documents
const docloc = docs[i].loc
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1].loc
}
const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.every((doc: Doc) => store.selected.has(doc.key))
)
},
set: (value: boolean) => {
console.log('Setting allSelected', value)
for (const doc of props.documents) {
if (value) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}
}
})
const loc = computed(() => props.path.join('/'))
const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key
ContextMenu.showContextMenu({
x: ev.x, y: ev.y, items: [
{ label: 'Rename', onClick: () => { editing.value = doc } },
],
})
}
</script>
<style scoped>
.gallery {
padding: 1em;
width: 100%;
display: grid;
gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
grid-template-rows: repeat(minmax(auto, 15em));
align-items: end;
}
.breadcrumb {
border-radius: .5em 0 0 .5em;
}
.spacer {
flex: 0 1000000000 4rem;
}
</style>

View File

@ -1,109 +0,0 @@
<template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }"
@contextmenu.stop
@focus.stop="store.cursor = doc.key"
@click=onclick
>
<figure>
<slot></slot>
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
<div class="titlespacer"></div>
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
<template v-if="editing">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
</template>
<template v-else>
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
<span :title="doc.name + '\n' + doc.modified + '\n' + doc.sizedisp">{{ doc.name }}</span>
<div class=namespacer></div>
</template>
</figcaption>
</figure>
</a>
</template>
<script setup lang=ts>
import { ref } from 'vue'
import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import MediaPreview from '@/components/MediaPreview.vue'
const store = useMainStore()
type EditingProp = {
rename: (name: string) => void;
exit: () => void;
}
const props = defineProps<{
doc: Doc,
editing?: EditingProp,
}>()
const m = ref<typeof MediaPreview | null>(null)
const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault()
store.cursor = props.doc.key
}
</script>
<style scoped>
figure {
max-height: 15em;
position: relative;
border-radius: .5em;
overflow: hidden;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: end;
overflow: hidden;
}
figure > article {
flex: 0 0 auto;
}
.titlespacer {
flex-shrink: 100000;
width: 100%;
height: 2em;
}
figcaption {
position: absolute;
overflow: hidden;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
figcaption input[type='checkbox'] {
width: 1.5em;
height: 1.5em;
margin: .25em 0 .25em .25em;
opacity: 0;
flex-shrink: 0;
transition: opacity var(--transition-time) ease-in-out;
}
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
opacity: 1;
}
figcaption span {
cursor: default;
padding: .5em;
color: #fff;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.cursor figcaption span {
color: var(--accent-color);
}
figcaption .namespacer {
flex-shrink: 100000;
height: 2em;
width: 2em;
}
</style>

View File

@ -1,30 +1,32 @@
<template>
<nav class="headermain buttons">
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
/>
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
placeholder="Find files"
class="margin-input"
<nav class="headermain">
<div class="buttons">
<template v-if="store.error">
<div class="error-message" @click="store.error = ''">{{ store.error }}</div>
<div class="smallgap"></div>
</template>
<UploadButton :path="props.path" />
<SvgButton
name="create-folder"
data-tooltip="New folder"
@click="() => store.fileExplorer!.newFolder()"
/>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
<SvgButton name="cog" @click="settingsMenu" />
<slot></slot>
<div class="spacer smallgap"></div>
<template v-if="showSearchInput">
<input
ref="search"
type="search"
:value="query"
@input="updateSearch"
placeholder="Search words"
class="margin-input"
@keyup.escape="closeSearch"
/>
</template>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
<SvgButton name="cog" @click="settingsMenu" />
</div>
</nav>
</template>
@ -73,7 +75,6 @@ watchEffect(() => {
const settingsMenu = (e: Event) => {
// show the context menu
const items = []
items.push({ label: 'Settings', onClick: () => { store.dialog = 'settings' }})
if (store.user.isLoggedIn) {
items.push({ label: `Logout ${store.user.username ?? ''}`, onClick: () => store.logout() })
} else {
@ -93,18 +94,24 @@ defineExpose({
<style scoped>
.buttons {
flex: 1000 0 auto;
padding: 0;
display: flex;
align-items: center;
height: 3.5em;
z-index: 10;
}
.buttons > * {
flex-shrink: 1;
}
input[type='search'] {
background: var(--input-background);
color: var(--input-color);
border: 0;
border-radius: 0.1em;
padding: 0.5em;
outline: none;
max-width: 15ch;
font-size: 1.5em;
max-width: 30vw;
}
</style>
@/stores/main

View File

@ -2,7 +2,7 @@
<template v-if="store.selected.size">
<div class="smallgap"></div>
<p class="select-text">{{ store.selected.size }} selected </p>
<DownloadButton />
<SvgButton name="download" data-tooltip="Download" @click="download" />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" />
@ -14,6 +14,7 @@
import {connect, controlUrl} from '@/repositories/WS'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
import type { SelectedItems } from '@/repositories/Document'
const store = useMainStore()
const props = defineProps({
@ -52,6 +53,95 @@ const op = (op: string, dst?: string) => {
}
}
const linkdl = (href: string) => {
const a = document.createElement('a')
a.href = href
a.download = ''
a.click()
}
const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandle) => {
let hdir = ''
let h = handle
console.log('Downloading to filesystem', sel.recursive)
for (const [rel, full, doc] of sel.recursive) {
// Create any missing directories
if (hdir && !rel.startsWith(hdir + '/')) {
hdir = ''
h = handle
}
const r = rel.slice(hdir.length)
for (const dir of r.split('/').slice(0, doc.dir ? undefined : -1)) {
hdir += `${dir}/`
try {
h = await h.getDirectoryHandle(dir.normalize('NFC'), { create: true })
} catch (error) {
console.error('Failed to create directory', hdir, error)
return
}
console.log('Created', hdir)
}
if (doc.dir) continue // Target was a folder and was created
const name = rel.split('/').pop()!.normalize('NFC')
// Download file
let fileHandle
try {
fileHandle = await h.getFileHandle(name, { create: true })
} catch (error) {
console.error('Failed to create file', rel, full, hdir + name, error)
return
}
const writable = await fileHandle.createWritable()
const url = `/files/${rel}`
console.log('Fetching', url)
const res = await fetch(url)
if (!res.ok)
throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`)
if (res.body) await res.body.pipeTo(writable)
else {
// Zero-sized files don't have a body, so we need to create an empty file
await writable.truncate(0)
await writable.close()
}
console.log('Saved', hdir + name)
}
}
const download = async () => {
const sel = store.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
store.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
return linkdl(`/files/${files[0][1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
filesystemdl(sel, handle).then(() => {
store.selected.clear()
})
return
} catch (e) {
console.error('Download to folder aborted', e)
}
}
// Otherwise, zip and download
const name = sel.keys.length === 1 ? sel.docs[sel.keys[0]].name : 'download'
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
store.selected.clear()
}
</script>
<style>
@ -60,6 +150,6 @@ const op = (op: string, dst?: string) => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin: 0;
}
</style>
@/stores/main

View File

@ -1,5 +1,5 @@
<template>
<ModalDialog name="login" title="Authentication required">
<ModalDialog v-if="store.user.isOpenLoginModal" title="Authentication required" @blur="store.user.isOpenLoginModal = false">
<form @submit.prevent="login">
<div class="login-container">
<label for="username">Username:</label>
@ -99,3 +99,4 @@ const login = async () => {
height: 1em;
}
</style>
@/stores/main

View File

@ -1,168 +0,0 @@
<template>
<img v-if=preview() :src="`${doc.previewurl}?${quality}&t=${doc.mtime}`" alt="">
<img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span>
<video ref=vid v-else-if=video() :src=doc.url :poster=poster preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
<div v-else-if=audio() class="audio icon">
<audio ref=aud :src=doc.url class=icon preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></audio>
</div>
<span v-else-if=archive() class="archive icon"></span>
<span v-else class="file icon" :class="`ext-${doc.ext}`"></span>
</template>
<script setup lang=ts>
import { computed, ref } from 'vue'
import type { Doc } from '@/repositories/Document'
const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null)
const media = computed(() => aud.value || vid.value)
const poster = computed(() => `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`)
const props = defineProps<{
doc: Doc
quality: string
}>()
const onplay = () => {
if (!media.value) return
media.value.controls = true
media.value.setAttribute('data-playing', '')
}
const onpaused = () => {
if (!media.value) return
media.value.controls = false
media.value.removeAttribute('data-playing')
}
let fscurrent: HTMLVideoElement | null = null
const next = () => {
if (!media.value) return
media.value.load() // Restore poster
const medias = Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]
if (medias.length === 0) return
let el: HTMLAudioElement | HTMLVideoElement | null = null
for (const i in medias) {
if (medias[i] === (fscurrent || media.value)) {
el = medias[+i + 1] || medias[0]
break
}
}
if (!el) return
if (el.tagName === "VIDEO" && document.fullscreenElement === media.value) {
// Fullscreen needs to use the current video element for the next video
// because we are not allowed to fullscreen the next one.
// FIXME: Write our own player to avoid this problem...
const elem = media.value as HTMLVideoElement
const playing = el as HTMLVideoElement
if (elem === playing) {
playing.play() // Only one video, just replay
return
}
if (!fscurrent) {
elem.addEventListener('fullscreenchange', ev => {
if (!fscurrent) return
// Restore the original video element and continue with the one that was playing
fscurrent.currentTime = elem.currentTime
fscurrent.click()
if (!elem.paused) fscurrent.play()
fscurrent = null
elem.src = props.doc.url
elem.poster = poster.value
onpaused()
}, {once: true})
}
fscurrent = playing
elem.src = playing.src
elem.poster = ''
elem.play()
} else {
document.exitFullscreen()
el.click()
}
}
defineExpose({
play() {
if (!media.value) return false
if (media.value.paused) {
media.value.play()
for (const el of Array.from(document.querySelectorAll('video, audio')) as (HTMLAudioElement | HTMLVideoElement)[]) {
if (el === media.value) continue
el.pause()
}
} else {
media.value.pause()
}
return true
},
media,
})
const video = () => ['mkv', 'mp4', 'webm', 'mov', 'avi'].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 preview = () => (
['bmp', 'ico', 'tif', 'tiff', 'pdf'].includes(props.doc.ext) ||
props.doc.size > 500000 &&
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext)
)
</script>
<style scoped>
img, embed, .icon, audio, video {
font-size: 8em;
overflow: hidden;
min-width: 50%;
max-width: 100%;
max-height: 100%;
border-radius: calc(.5em / 8);
}
.folder::before {
content: '📁';
}
.folder:hover::before, .cursor .folder::before {
content: '📂';
}
.archive::before {
content: '📦';
}
.file::before {
content: '📄';
}
.ext-img::before {
content: '💿';
}
.ext-exe::before, .ext-msi::before, .ext-dmg::before, .ext-pkg::before {
content: '⚙️';
}
.ext-torrent::before {
content: '🏴‍☠️';
}
.audio audio {
opacity: 0;
transition: opacity var(--transition-time) ease-in-out;
}
.audio:hover audio {
opacity: 1;
}
.audio.icon::before {
width: 100%;
content: '🔈';
}
.audio.icon:has(audio[data-playing])::before {
position: absolute;
content: '🔊';
bottom: 0;
}
.icon {
filter: brightness(0.9);
}
figure.cursor .icon {
filter: brightness(1);
}
img::before {
/* broken image */
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
</style>

View File

@ -1,45 +1,34 @@
<template>
<dialog v-if="store.dialog === name" ref="dialog" :id=props.name @keydown.escape=close>
<dialog ref="dialog">
<h1 v-if="props.title">{{ props.title }}</h1>
<div>
<slot>
Dialog with no content
<button @click=close>OK</button>
<button onclick="dialog.close()">OK</button>
</slot>
</div>
</dialog>
</template>
<script setup lang="ts">
import { ref, onMounted, watchEffect, nextTick } from 'vue'
import { useMainStore } from '@/stores/main'
import { ref, onMounted } from 'vue'
const dialog = ref<HTMLDialogElement | null>(null)
const store = useMainStore()
const close = () => {
dialog.value!.close()
store.dialog = ''
}
const props = defineProps<{
title: string,
name: typeof store.dialog,
}>()
const props = withDefaults(
defineProps<{
title: string
}>(),
{
title: ''
}
)
const show = () => {
store.dialog = props.name
setTimeout(() => {
dialog.value!.showModal()
nextTick(() => {
const input = dialog.value!.querySelector('input')
if (input) input.focus()
})
}, 0)
dialog.value!.showModal()
}
defineExpose({ show, close })
watchEffect(() => {
if (dialog.value) show()
defineExpose({ show })
onMounted(() => {
show()
})
</script>

View File

@ -1,22 +0,0 @@
<template>
<input type=checkbox tabindex=-1 :checked="store.selected.has(doc.key)" @click.stop
@change="ev => {
if ((ev.target as HTMLInputElement).checked) {
store.selected.add(doc.key)
} else {
store.selected.delete(doc.key)
}
}"
>
</template>
<script setup lang=ts>
import { useMainStore } from '@/stores/main'
import type { Doc } from '@/repositories/Document'
const props = defineProps<{
doc: Doc
}>()
const store = useMainStore()
</script>

View File

@ -1,127 +0,0 @@
<template>
<ModalDialog name=settings title="Settings">
<form>
<template v-if="store.user.isLoggedIn">
<h3>Update your authentication</h3>
<div class="login-container">
<label for="username">New password:</label>
<input
ref="passwordChange"
id="passwordChange"
type="password"
autocomplete="new-password"
spellcheck="false"
autocorrect="off"
v-model="form.passwordChange"
/>
<label for="password">Current password:</label>
<input
ref="password"
id="password"
name="password"
type="password"
autocomplete="current-password"
spellcheck="false"
autocorrect="off"
v-model="form.password"
/>
</div>
<h3 class="error-text">
{{ form.error || '\u00A0' }}
</h3>
<div class="dialog-buttons">
<input id="close" type="reset" value="Close" class="button" @click=close />
<div class="spacer"></div>
<input id="submit" type="submit" value="Submit" class="button" @click.prevent="submit" />
</div>
</template>
<template v-else>
<p>No settings are available because you have not logged in.</p>
<div class="dialog-buttons">
<div class="spacer"></div>
<input id="close" type="reset" value="Close" class="button" @click=close />
</div>
</template>
</form>
</ModalDialog>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { changePassword } from '@/repositories/User'
import type { ISimpleError } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
const confirmLoading = ref<boolean>(false)
const store = useMainStore()
const passwordChange = ref()
const password = ref()
const form = reactive({
passwordChange: '',
password: '',
error: ''
})
const close = () => {
form.passwordChange = ''
form.password = ''
form.error = ''
store.dialog = ''
}
const submit = async (ev: Event) => {
ev.preventDefault()
try {
form.error = ''
if (form.passwordChange) {
if (!form.password) {
form.error = '⚠️ Current password is required'
password.value!.focus()
return
}
await changePassword(store.user.username, form.passwordChange, form.password)
}
close()
} catch (error) {
const httpError = error as ISimpleError
form.error = httpError.message || '🛑 Unknown error'
} finally {
confirmLoading.value = false
}
}
</script>
<style scoped>
.login-container {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 2fr;
justify-content: center;
align-items: center;
margin: 1rem 0;
}
.dialog-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
.button-login {
color: #fff;
background: var(--soft-color);
cursor: pointer;
font-weight: bold;
border: 0;
border-radius: .5rem;
padding: .5rem 2rem;
margin-left: auto;
transition: all var(--transition-time) linear;
}
.button-login:hover, .button-login:focus {
background: var(--accent-color);
box-shadow: 0 0 .3rem #000;
}
.error-text {
color: var(--red-color);
height: 1em;
}
</style>

View File

@ -6,7 +6,7 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import { defineAsyncComponent, defineProps } from 'vue'
const props = defineProps<{
name: string

View File

@ -1,94 +0,0 @@
<template>
<div class="transferprogress" v-if="status.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
<div class="statustext">
<span v-if="status.filecount > 1" class="index">
[{{ status.fileidx }}/{{ status.filecount }}]
</span>
<span class="filename">{{ status.filename.split('/').pop() }}
<span v-if="status.filesize > 1e7" class="percent">
{{ (status.filepos / status.filesize * 100).toFixed(0) + '\u202F%' }}
</span>
</span>
<span class="position" v-if="status.total > 1e7">
{{ (status.xfer / 1e6).toFixed(0) + '\u202F/\u202F' + (status.total / 1e6).toFixed(0) + '\u202FMB' }}
</span>
<span class="speed">{{ speeddisp }}</span>
<button class="close" @click="$emit('cancel')"></button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
defineEmits(['cancel'])
const props = defineProps<{
status: {
total: number
xfer: number
filecount: number
fileidx: number
filesize: number
filepos: number
filename: string
statbytes: number
statdur: number
tlast: number
}
}>()
const percent = computed(() => props.status.xfer / props.status.total * 100)
const speed = computed(() => {
let s = props.status.statbytes / props.status.statdur / 1e3
const tsince = (Date.now() - props.status.tlast) / 1e3
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed"
})
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
</script>
<style scoped>
.transferprogress {
--bar: var(--accent-color);
--nobar: var(--header-background);
display: flex;
flex-direction: column;
color: var(--primary-color);
width: 100%;
}
.statustext {
display: flex;
align-items: center;
margin: 0 .5em;
padding: 0.5rem 0;
}
span {
color: #ccc;
white-space: nowrap;
text-align: right;
padding: 0 0.5em;
}
.filename {
color: #fff;
flex: 1 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.index { min-width: 3.5em }
.position { min-width: 4em }
.speed { min-width: 4em }
.upload .statustext::before {
font-size: 1.5em;
content: '🔺'
}
.download .statustext::before {
font-size: 1.5em;
content: '🔻'
}
</style>

View File

@ -1,17 +1,8 @@
<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
</template>
<script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main'
import { collator } from '@/utils';
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
const fileInput = ref()
const folderInput = ref()
@ -103,7 +94,7 @@ const cancelUploads = () => {
const uprogress_init = {
total: 0,
xfer: 0,
uploaded: 0,
t0: 0,
tlast: 0,
statbytes: 0,
@ -117,50 +108,59 @@ const uprogress_init = {
filepos: 0,
status: 'idle',
}
store.uprogress = {...uprogress_init}
const uprogress = reactive({...uprogress_init})
const percent = computed(() => uprogress.uploaded / uprogress.total * 100)
const speed = computed(() => {
let s = uprogress.statbytes / uprogress.statdur / 1e3
const tsince = (Date.now() - uprogress.tlast) / 1e3
if (tsince > 5 / s) return 0 // Less than fifth of previous speed => stalled
if (tsince > 1 / s) return 1 / tsince // Next block is late or not coming, decay
return s // "Current speed"
})
const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value < 10 ? 1 : 0) + '\u202FMB/s': 'stalled')
setInterval(() => {
if (Date.now() - store.uprogress.tlast > 3000) {
if (Date.now() - uprogress.tlast > 3000) {
// Reset
store.uprogress.statbytes = 0
store.uprogress.statdur = 1
uprogress.statbytes = 0
uprogress.statdur = 1
} else {
// Running average by decay
store.uprogress.statbytes *= .9
store.uprogress.statdur *= .9
uprogress.statbytes *= .9
uprogress.statdur *= .9
}
}, 100)
const statUpdate = ({name, size, start, end}: {name: string, size: number, start: number, end: number}) => {
if (name !== store.uprogress.filename) return // If stats have been reset
if (name !== uprogress.filename) return // If stats have been reset
const now = Date.now()
store.uprogress.xfer = store.uprogress.filestart + end
store.uprogress.filepos = end
store.uprogress.statbytes += end - start
store.uprogress.statdur += now - store.uprogress.tlast
store.uprogress.tlast = now
uprogress.uploaded = uprogress.filestart + end
uprogress.filepos = end
uprogress.statbytes += end - start
uprogress.statdur += now - uprogress.tlast
uprogress.tlast = now
// File finished?
if (end === size) {
store.uprogress.filestart += size
uprogress.filestart += size
statNextFile()
if (++store.uprogress.fileidx >= store.uprogress.filecount) statReset()
if (++uprogress.fileidx >= uprogress.filecount) statReset()
}
}
const statNextFile = () => {
const f = store.uprogress.files.shift()
const f = uprogress.files.shift()
if (!f) return statReset()
store.uprogress.filepos = 0
store.uprogress.filesize = f.file.size
store.uprogress.filename = f.cloudName
uprogress.filepos = 0
uprogress.filesize = f.file.size
uprogress.filename = f.cloudName
}
const statReset = () => {
Object.assign(store.uprogress, uprogress_init)
store.uprogress.t0 = Date.now()
store.uprogress.tlast = store.uprogress.t0 + 1
Object.assign(uprogress, uprogress_init)
uprogress.t0 = Date.now()
uprogress.tlast = uprogress.t0 + 1
}
const statsAdd = (f: CloudFile[]) => {
if (store.uprogress.files.length === 0) statReset()
store.uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
store.uprogress.filecount += f.length
store.uprogress.files = [...store.uprogress.files, ...f]
if (uprogress.files.length === 0) statReset()
uprogress.total += f.reduce((a, b) => a + b.file.size, 0)
uprogress.filecount += f.length
uprogress.files = [...uprogress.files, ...f]
statNextFile()
}
let upqueue = [] as CloudFile[]
@ -190,7 +190,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
// @ts-ignore
ws.sendData = async (data: any) => {
// Wait until the WS is ready to send another message
store.uprogress.status = "uploading"
uprogress.status = "uploading"
await new Promise(resolve => {
const t = setInterval(() => {
if (ws.bufferedAmount > 1<<20) return
@ -198,7 +198,7 @@ const WSCreate = async () => await new Promise<WebSocket>(resolve => {
clearInterval(t)
}, 1)
})
store.uprogress.status = "processing"
uprogress.status = "processing"
ws.send(data)
}
})
@ -219,7 +219,7 @@ const worker = async () => {
if (f.cloudPos === f.file.size) upqueue.shift()
}
if (upqueue.length) startWorker()
store.uprogress.status = "idle"
uprogress.status = "idle"
workerRunning = false
}
let workerRunning: any = false
@ -242,3 +242,64 @@ onUnmounted(() => {
removeEventListener('drop', uploadHandler)
})
</script>
<template>
<template>
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
<div class="uploadprogress" v-if="uprogress.total" :style="`background: linear-gradient(to right, var(--bar) 0, var(--bar) ${percent}%, var(--nobar) ${percent}%, var(--nobar) 100%);`">
<div class="statustext">
<span v-if="uprogress.filecount > 1" class="index">
[{{ uprogress.fileidx }}/{{ uprogress.filecount }}]
</span>
<span class="filename">{{ uprogress.filename.split('/').pop() }}
<span v-if="uprogress.filesize > 1e7" class="percent">
{{ (uprogress.filepos / uprogress.filesize * 100).toFixed(0) + '\u202F%' }}
</span>
</span>
<span class="position" v-if="uprogress.total > 1e7">
{{ (uprogress.uploaded / 1e6).toFixed(0) + '\u202F/\u202F' + (uprogress.total / 1e6).toFixed(0) + '\u202FMB' }}
</span>
<span class="speed">{{ speeddisp }}</span>
<button class="close" @click="cancelUploads"></button>
</div>
</div>
</template>
<style scoped>
.uploadprogress {
--bar: var(--accent-color);
--nobar: var(--header-background);
display: flex;
flex-direction: column;
color: var(--primary-color);
position: fixed;
left: 0;
bottom: 0;
width: 100vw;
}
.statustext {
display: flex;
padding: 0.5rem 0;
}
span {
color: #ccc;
white-space: nowrap;
text-align: right;
padding: 0 0.5em;
}
.filename {
color: #fff;
flex: 1 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.index { min-width: 3.5em }
.position { min-width: 4em }
.speed { min-width: 4em }
</style>
@/stores/main

View File

@ -36,23 +36,6 @@ export class Doc {
get urlrouter(): string {
return this.url.replace(/^\/#/, '')
}
get img(): boolean {
const ext = this.name.split('.').pop()?.toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg'].includes(ext || '')
}
get previewable(): boolean {
if (this.img) return true
const ext = this.name.split('.').pop()?.toLowerCase()
// Not a comprehensive list, but good enough for now
return ['mp4', 'mkv', 'webm', 'ogg', 'mp3', 'flac', 'aac', 'pdf'].includes(ext || '')
}
get previewurl(): string {
return this.url.replace(/^\/files/, '/preview')
}
get ext(): string {
const ext = this.name.split('.').pop()
return ext ? ext.toLowerCase() : ''
}
}
export type errorEvent = {
error: {

View File

@ -1,8 +1,6 @@
import Client from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
export const url_login = '/login'
export const url_logout = '/logout'
export const url_password = '/password-change'
export const url_logout = '/logout '
export async function loginUser(username: string, password: string) {
const user = await Client.post(url_login, {
@ -15,12 +13,3 @@ export async function logoutUser() {
const data = await Client.post(url_logout)
return data
}
export async function changePassword(username: string, passwordChange: string, password: string) {
const data = await Client.post(url_password, {
username,
passwordChange,
password
})
return data
}

View File

@ -53,7 +53,7 @@ export const watchConnect = () => {
if ('error' in msg) {
if (msg.error.code === 401) {
store.user.isLoggedIn = false
store.dialog = 'login'
store.user.isOpenLoginModal = true
} else {
store.error = msg.error.message
}
@ -67,7 +67,7 @@ export const watchConnect = () => {
store.error = ''
if (msg.user) store.login(msg.user.username, msg.user.privileged)
else if (store.isUserLogged) store.logout()
if (!msg.server.public && !msg.user) store.dialog = 'login'
if (!msg.server.public && !msg.user) store.user.isOpenLoginModal = true
}
})
}
@ -87,14 +87,9 @@ const watchReconnect = (event: MessageEvent) => {
store.connected = false
store.error = 'Reconnecting...'
}
if (watchTimeout !== null) clearTimeout(watchTimeout)
// Don't hammer the server while on login dialog
if (store.dialog === 'login') {
watchTimeout = setTimeout(watchReconnect, 100)
return
}
reconnDelay = Math.min(5000, reconnDelay + 500)
// The server closes the websocket after errors, so we need to reopen it
if (watchTimeout !== null) clearTimeout(watchTimeout)
watchTimeout = setTimeout(watchConnect, reconnDelay)
}
@ -153,8 +148,8 @@ function handleUpdateMessage(updateData: { update: UpdateEntry[] }) {
function handleError(msg: errorEvent) {
const store = useMainStore()
if (msg.error.code === 401) {
store.user.isOpenLoginModal = true
store.user.isLoggedIn = false
store.dialog = 'login'
return
}
}

View File

@ -1,50 +1,42 @@
import type { FileEntry, FUID, SelectedItems } from '@/repositories/Document'
import { Doc } from '@/repositories/Document'
import { defineStore, type StateTree } from 'pinia'
import { defineStore } from 'pinia'
import { collator } from '@/utils'
import { logoutUser } from '@/repositories/User'
import { watchConnect } from '@/repositories/WS'
import { shallowRef } from 'vue'
import { sorted, type SortOrder } from '@/utils/docsort'
type User = {
username: string
privileged: boolean
isOpenLoginModal: boolean
isLoggedIn: boolean
}
export const useMainStore = defineStore({
id: 'main',
state: () => ({
document: shallowRef<Doc[]>([]),
selected: new Set<FUID>([]),
selected: new Set<FUID>(),
query: '' as string,
fileExplorer: null as any,
error: '' as string,
connected: false,
cursor: '' as string,
server: {} as Record<string, any>,
dialog: '' as '' | 'login' | 'settings',
uprogress: {} as any,
dprogress: {} as any,
prefs: {
gallery: false,
sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder,
},
user: {
username: '' as string,
privileged: false as boolean,
isLoggedIn: false as boolean,
}
username: '',
privileged: false,
isLoggedIn: false,
isOpenLoginModal: false
} as User
}),
persist: {
paths: ['prefs', 'cursor', 'selected'],
serializer: {
deserialize: (data: string): StateTree => {
const ret = JSON.parse(data)
ret.selected = new Set(ret.selected)
return ret
},
serialize: (tree: StateTree): string => {
tree.selected = Array.from(tree.selected)
return JSON.stringify(tree)
}
},
paths: ['prefs'],
},
actions: {
updateRoot(root: FileEntry[]) {
@ -68,11 +60,11 @@ export const useMainStore = defineStore({
this.user.username = username
this.user.privileged = privileged
this.user.isLoggedIn = true
this.dialog = ''
this.user.isOpenLoginModal = false
if (!this.connected) watchConnect()
},
loginDialog() {
this.dialog = 'login'
this.user.isOpenLoginModal = true
},
async logout() {
console.log("Logout")
@ -85,19 +77,6 @@ export const useMainStore = defineStore({
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
},
sort(name: SortOrder | '') {
if (this.query) this.prefs.sortFiltered = name
else this.prefs.sortListing = name
},
focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
},
cancelDownloads() {
location.reload() // FIXME
},
cancelUploads() {
location.reload() // FIXME
},
},
getters: {
sortOrder(): SortOrder { return this.query ? this.prefs.sortFiltered : this.prefs.sortListing },

View File

@ -1,8 +0,0 @@
import { useMainStore } from '@/stores/main'
export const exists = (path: string[]) => {
const store = useMainStore()
const p = path.join('/')
return store.document.some(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p)
}

View File

@ -1,32 +1,19 @@
<template>
<Gallery
v-if="store.prefs.gallery"
ref="fileExplorer"
:key="`gallery-${Router.currentRoute.value.path}`"
:path="props.path"
:documents="documents"
/>
<FileExplorer
v-else
ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`"
:key="Router.currentRoute.value.path"
:path="props.path"
:documents="documents"
v-if="props.path"
/>
<div v-if="!store.prefs.gallery && documents.some(doc => doc.previewable)" class="suggest-gallery">
<SvgButton name="eye" taborder=0 @click="() => { store.prefs.gallery = true }"></SvgButton>
Gallery View
</div>
<EmptyFolder :documents=documents :path=props.path />
</template>
<script setup lang="ts">
import { watchEffect, ref, computed } from 'vue'
import { useMainStore } from '@/stores/main'
import Router from '@/router/index'
import { needleFormat, localeIncludes, collator } from '@/utils'
import { sorted } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue'
import { needleFormat, localeIncludes, collator } from '@/utils';
import { sorted } from '@/utils/docsort';
const store = useMainStore()
const fileExplorer = ref()
@ -77,27 +64,3 @@ watchEffect(() => {
store.query = props.query
})
</script>
<style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
.suggest-gallery p {
font-size: 2rem;
color: var(--accent-color);
}
.suggest-gallery {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>

View File

@ -4,6 +4,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// @ts-ignore
import pluginRewriteAll from 'vite-plugin-rewrite-all'
import svgLoader from 'vite-svg-loader'
import Components from 'unplugin-vue-components/vite'
@ -20,6 +21,7 @@ const dev_backend = {
export default defineConfig({
plugins: [
vue(),
pluginRewriteAll(),
svgLoader(), // import svg files
Components(), // auto import components
],
@ -42,9 +44,7 @@ export default defineConfig({
"/files": dev_backend,
"/login": dev_backend,
"/logout": dev_backend,
"/password-change": dev_backend,
"/zip": dev_backend,
"/preview": dev_backend,
}
},
build: {

View File

@ -22,12 +22,8 @@ dependencies = [
"msgspec",
"natsort",
"pathvalidate",
"pillow",
"pyav",
"pyjwt",
"pymupdf",
"sanic",
"setproctitle",
"stream-zip",
"tomli_w",
]
@ -49,7 +45,7 @@ source = "vcs"
[tool.hatch.build]
artifacts = ["cista/wwwroot"]
targets.sdist.hooks.custom.path = "scripts/build-frontend.py"
hooks.custom.path = "scripts/build-frontend.py"
hooks.vcs.version-file = "cista/_version.py"
hooks.vcs.template = """
# This file is automatically generated by hatch build.

View File

@ -1,8 +1,5 @@
# noqa: INP001
import os
import shutil
import subprocess
from sys import stderr
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
@ -10,18 +7,6 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version, build_data):
super().initialize(version, build_data)
stderr.write(">>> Building Cista frontend\n")
npm = shutil.which("npm")
if npm is None:
raise RuntimeError(
"NodeJS `npm` is required for building Cista but it was not found"
)
# npm --prefix doesn't work on Windows, so we chdir instead
os.chdir("frontend")
try:
stderr.write("### npm install\n")
subprocess.run([npm, "install"], check=True) # noqa: S603
stderr.write("\n### npm run build\n")
subprocess.run([npm, "run", "build"], check=True) # noqa: S603
finally:
os.chdir("..")
print("Building Cista frontend...")
subprocess.run("npm install --prefix frontend".split(" "), check=True) # noqa: S603
subprocess.run("npm run build --prefix frontend".split(" "), check=True) # noqa: S603

View File

@ -1,7 +1,10 @@
from pathlib import PurePosixPath
import msgspec
import pytest
from cista.protocol import FileEntry, UpdateMessage, UpdDel, UpdIns, UpdKeep
from cista.watching import format_update
from cista.watching import State, format_update
def decode(data: str):
@ -34,14 +37,6 @@ def test_insertions():
assert decode(format_update(old_list, new_list)) == expected
def test_insertion_at_end():
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
new_list = [*old_list, newfile]
expected = [UpdKeep(4), UpdIns([newfile])]
assert decode(format_update(old_list, new_list)) == expected
def test_deletions():
old_list = f(3)
new_list = [old_list[0], old_list[2]]
@ -88,3 +83,54 @@ def test_longer_lists():
def sortkey(name):
# Define the sorting key for names here
return name.lower()
@pytest.fixture()
def state():
entries = [
FileEntry(0, "", "root", 0, 0, 0),
FileEntry(1, "bar", "bar", 0, 0, 0),
FileEntry(2, "baz", "bar/baz", 0, 0, 0),
FileEntry(1, "foo", "foo", 0, 0, 0),
FileEntry(1, "xxx", "xxx", 0, 0, 0),
FileEntry(2, "yyy", "xxx/yyy", 0, 0, 1),
]
s = State()
s._listing = entries
return s
def test_existing_directory(state):
path = PurePosixPath("bar")
expected_slice = slice(1, 3) # Includes 'bar' and 'baz'
assert state._slice(path) == expected_slice
def test_existing_file(state):
path = PurePosixPath("xxx/yyy")
expected_slice = slice(5, 6) # Only includes 'yyy'
assert state._slice(path) == expected_slice
def test_nonexistent_directory(state):
path = PurePosixPath("zzz")
expected_slice = slice(6, 6) # 'zzz' would be inserted at end
assert state._slice(path) == expected_slice
def test_nonexistent_file(state):
path = (PurePosixPath("bar/mmm"), 1)
expected_slice = slice(3, 3) # A file would be inserted after 'baz' under 'bar'
assert state._slice(path) == expected_slice
def test_root_directory(state):
path = PurePosixPath()
expected_slice = slice(0, 6) # Entire tree
assert state._slice(path) == expected_slice
def test_directory_with_subdirs_and_files(state):
path = PurePosixPath("xxx")
expected_slice = slice(4, 6) # Includes 'xxx' and 'yyy'
assert state._slice(path) == expected_slice