Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40d9c1abd | ||
|
|
3d8845cf99 | ||
|
|
87e1443e7d | ||
|
|
f45c57e901 | ||
|
|
41686d1dd1 | ||
|
|
cc351bb992 | ||
|
|
c3abbe0a3b | ||
|
|
127caeedea | ||
|
|
113bc56351 | ||
|
|
c7727c72d9 | ||
|
|
85b3aa6b81 | ||
|
|
62b44ddb43 | ||
|
|
60a53ef3d3 |
@@ -8,7 +8,7 @@ This is a cutting-edge **file and document server** designed for speed, efficien
|
|||||||
|
|
||||||
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
**Built-in document and media previews** let you quickly view files without downloading them. Cista shows PDF and other documents, video and image thumbnails, with **HDR10 support** video previews and image formats, including HEIC and AVIF. It also has a player for music and video files.
|
||||||
|
|
||||||
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which we used and loved despite its numerous bugs. Cista Storage stands out in handling even the most exotic filenames, ensuring a smooth experience where others falter.
|
The Cista project started as an inevitable remake of [Droppy](https://github.com/droppyjs/droppy) which was not being developed at the time. Now they have picked up pace too, feel free to try both and compare.
|
||||||
|
|
||||||
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
All of this is wrapped in an intuitive interface with automatic light and dark themes, making Cista Storage the ideal choice for anyone seeking a reliable, versatile, and quick file storage solution. Quickly setup your own Cista where your files are just a click away, safe, and always accessible.
|
||||||
|
|
||||||
@@ -40,9 +40,13 @@ The server remembers its settings in the config folder (default `~/.local/share/
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Cista supports three authentication modes:
|
Cista supports two authenticatioon mode, each of which supporting ordinary and privileged users. Either one can be combined with the public mode.
|
||||||
|
|
||||||
### Built-in Authentication (default)
|
### Public Mode
|
||||||
|
|
||||||
|
In public mode, anyone can read, send and even delete files without without logging in. Users entering the service won't be asked to authenticate. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
||||||
|
|
||||||
|
### Built-in Password Authentication (default)
|
||||||
|
|
||||||
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
User accounts are managed directly by Cista. Create users with the `--user` flag:
|
||||||
|
|
||||||
@@ -53,23 +57,26 @@ uvx cista --user guest # Create regular user
|
|||||||
|
|
||||||
Privileged users can manage other users and change settings via the Admin Settings menu.
|
Privileged users can manage other users and change settings via the Admin Settings menu.
|
||||||
|
|
||||||
### Public Mode
|
### Passkey Authentication and SSO
|
||||||
|
|
||||||
In public mode, anyone can read, send and even delete files without without logging in. Privileged users can still log in via the menu to access admin settings, from where the public mode can be toggled on or off.
|
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. This allows user account and permission management at the corporate level, without bothering Cista with it.
|
||||||
|
|
||||||
### Paskia SSO Authentication
|
Set the `PASKIA_BACKEND_URL` environment variable:
|
||||||
|
|
||||||
For centralized authentication, Cista can integrate with [Paskia](https://git.zi.fi/LeoVasanko/paskia) SSO server. Set the `PASKIA_BACKEND_URL` environment variable:
|
|
||||||
|
|
||||||
```fish
|
```fish
|
||||||
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
PASKIA_BACKEND_URL=http://localhost:4401 uvx cista
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Run the Paskia backend on the same machine (to use that default URL):
|
||||||
|
```fish
|
||||||
|
uvx paskia
|
||||||
|
```
|
||||||
|
|
||||||
In Paskia mode:
|
In Paskia mode:
|
||||||
- All `/auth/*` requests are proxied to the Paskia backend
|
- All `/auth/*` requests are proxied to the Paskia backend
|
||||||
|
- Cista backend verifies access by `/auth/api/validate` endpoint and shows a login dialog if needed
|
||||||
- Users with `cista:login` permission can access files
|
- Users with `cista:login` permission can access files
|
||||||
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
- Users with `cista:admin` permission get privileged access (Admin Settings)
|
||||||
- Public mode works with Paskia: unauthenticated users can browse, while the menu has option to login
|
|
||||||
|
|
||||||
### Internet Access
|
### Internet Access
|
||||||
|
|
||||||
@@ -89,9 +96,15 @@ Nxing or other proxy may be similarly used, or alternatively you can place cert
|
|||||||
|
|
||||||
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
This setup allows easy addition of storages, each with its own domain, configuration, and files.
|
||||||
|
|
||||||
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or bun/npm.
|
Assuming a restricted user account `storage` for serving files and that UV is installed system-wide or on this account. Only UV is required: this does not use git or javascript runtimes.
|
||||||
|
|
||||||
Create `/etc/systemd/system/cista@.service`:
|
Create (edit) a systemd unit:
|
||||||
|
|
||||||
|
```fish
|
||||||
|
sudo systemctl edit --force --full cista@.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Paste the following:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -101,6 +114,7 @@ Description=Cista storage %i
|
|||||||
User=storage
|
User=storage
|
||||||
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
ExecStart=uvx cista -c /srv/cista/%i -l /srv/cista/%i/socket /media/storage/%i
|
||||||
Restart=always
|
Restart=always
|
||||||
|
#Environment=PASKIA_BACKEND_URL=http://localhost:4401
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -153,4 +167,4 @@ Building the package for release (frontend + Python wheel/sdist):
|
|||||||
uv build
|
uv build
|
||||||
```
|
```
|
||||||
|
|
||||||
Vue is used to build files in `cista/wwwroot`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
Vue is used to build files in `cista/frontend-build`, included prebuilt in the Python package. `uv build` runs the project build hooks to bundle the frontend and produce a NodeJS-independent Python package.
|
||||||
|
|||||||
+11
-2
@@ -25,14 +25,22 @@ def create_banner():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
|
def create_startup_box(
|
||||||
|
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
|
||||||
|
):
|
||||||
"""Create a framed startup box with server information."""
|
"""Create a framed startup box with server information."""
|
||||||
title = f"Cista {cista.__version__}"
|
title = f"Cista {cista.__version__}"
|
||||||
listen = unix if unix else url
|
listen = unix if unix else url
|
||||||
location = f"{folder} @ {listen}"
|
location = f"{folder} @ {listen}"
|
||||||
lines = [title, location]
|
lines = [title, location]
|
||||||
|
# Auth line: Paskia <url> or Password, with optional Public suffix
|
||||||
if paskia_url:
|
if paskia_url:
|
||||||
lines.append(f"Paskia: {paskia_url}")
|
auth_line = f"Auth: Paskia {paskia_url}"
|
||||||
|
else:
|
||||||
|
auth_line = "Auth: Password"
|
||||||
|
if public:
|
||||||
|
auth_line += ", Public"
|
||||||
|
lines.append(auth_line)
|
||||||
if dev:
|
if dev:
|
||||||
lines.append("dev mode")
|
lines.append("dev mode")
|
||||||
|
|
||||||
@@ -157,6 +165,7 @@ def _main():
|
|||||||
unix=opts.get("unix"),
|
unix=opts.get("unix"),
|
||||||
dev=dev,
|
dev=dev,
|
||||||
paskia_url=PASKIA_BACKEND_URL or None,
|
paskia_url=PASKIA_BACKEND_URL or None,
|
||||||
|
public=config.config.public,
|
||||||
)
|
)
|
||||||
sys.stderr.write(startup_box)
|
sys.stderr.write(startup_box)
|
||||||
# Run the server
|
# Run the server
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ def subscribe(uuid, ws):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("config")
|
||||||
|
async def get_config(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
"name": config.config.name,
|
||||||
|
"public": config.config.public,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.put("config/public")
|
@bp.put("config/public")
|
||||||
async def update_public(request):
|
async def update_public(request):
|
||||||
await auth.verify(request, privileged=True)
|
await auth.verify(request, privileged=True)
|
||||||
@@ -176,3 +187,20 @@ async def update_public(request):
|
|||||||
raise BadRequest(str(e)) from None
|
raise BadRequest(str(e)) from None
|
||||||
config.update_config({"public": public})
|
config.update_config({"public": public})
|
||||||
return json({"message": "Public access setting updated", "public": public})
|
return json({"message": "Public access setting updated", "public": public})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.put("config/name")
|
||||||
|
async def update_name(request):
|
||||||
|
await auth.verify(request, privileged=True)
|
||||||
|
try:
|
||||||
|
name = request.json["name"]
|
||||||
|
if not isinstance(name, str):
|
||||||
|
raise ValueError("name must be a string")
|
||||||
|
except KeyError:
|
||||||
|
raise BadRequest("Missing name field") from None
|
||||||
|
except ValueError as e:
|
||||||
|
raise BadRequest(str(e)) from None
|
||||||
|
config.update_config({"name": name})
|
||||||
|
# Return the effective name (fallback to path.name if empty)
|
||||||
|
effective_name = name or config.config.path.name
|
||||||
|
return json({"message": "Server name updated", "name": effective_name})
|
||||||
|
|||||||
+2
-2
@@ -269,7 +269,7 @@ async def verify(request, *, privileged=False):
|
|||||||
raise Unauthorized(
|
raise Unauthorized(
|
||||||
f"Login required for {request.path}",
|
f"Login required for {request.path}",
|
||||||
"cookie",
|
"cookie",
|
||||||
context={"auth": {"iframe": "/auth/restricted"}},
|
context={"auth": {"iframe": "/auth/restricted/"}},
|
||||||
quiet=True,
|
quiet=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ async def verify(request, *, privileged=False):
|
|||||||
bp = Blueprint("auth", url_prefix="/auth")
|
bp = Blueprint("auth", url_prefix="/auth")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/restricted")
|
@bp.get("/restricted/")
|
||||||
async def login_page(request):
|
async def login_page(request):
|
||||||
"""Login page that works both standalone and in paskia iframe."""
|
"""Login page that works both standalone and in paskia iframe."""
|
||||||
s = session.get(request)
|
s = session.get(request)
|
||||||
|
|||||||
+69
-12
@@ -2,7 +2,10 @@ import asyncio
|
|||||||
import gc
|
import gc
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -25,6 +28,48 @@ pillow_heif.register_heif_opener()
|
|||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CachedPreview:
|
||||||
|
"""Cached preview with headers and body."""
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewCache:
|
||||||
|
"""Thread-safe LRU cache for preview responses."""
|
||||||
|
|
||||||
|
def __init__(self, capacity: int = 500):
|
||||||
|
self.capacity = capacity
|
||||||
|
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: str) -> CachedPreview | None:
|
||||||
|
"""Get cached preview, moving it to end (most recently used)."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: CachedPreview) -> None:
|
||||||
|
"""Cache preview, evicting oldest if at capacity."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
else:
|
||||||
|
if len(self._cache) >= self.capacity:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
self._cache[key] = value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._cache)
|
||||||
|
|
||||||
|
|
||||||
|
# Global preview cache instance
|
||||||
|
_preview_cache = PreviewCache(capacity=500)
|
||||||
|
|
||||||
|
|
||||||
@bp.on_request
|
@bp.on_request
|
||||||
async def verify_preview(request):
|
async def verify_preview(request):
|
||||||
"""Verify access to preview routes."""
|
"""Verify access to preview routes."""
|
||||||
@@ -55,6 +100,29 @@ async def preview(req, path):
|
|||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
|
|
||||||
|
if req.headers.if_none_match == etag:
|
||||||
|
# The client has it cached, respond 304 Not Modified
|
||||||
|
return empty(304, headers={"etag": etag})
|
||||||
|
|
||||||
|
# Check in-memory cache first (includes headers)
|
||||||
|
cached = _preview_cache.get(etag)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug(f"Preview cache hit: {rel}")
|
||||||
|
return raw(cached.body, headers=cached.headers)
|
||||||
|
|
||||||
|
if not filepath.is_file():
|
||||||
|
raise NotFound("File not found")
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
|
# Build headers and cache the full response
|
||||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
@@ -64,19 +132,8 @@ async def preview(req, path):
|
|||||||
"content-type": "image/avif",
|
"content-type": "image/avif",
|
||||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
}
|
}
|
||||||
if req.headers.if_none_match == etag:
|
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||||
# The client has it cached, respond 304 Not Modified
|
|
||||||
return empty(304, headers=headers)
|
|
||||||
|
|
||||||
if not filepath.is_file():
|
|
||||||
raise NotFound("File not found")
|
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
|
||||||
)
|
|
||||||
if not img:
|
|
||||||
# Preview generation failed, redirect to the file itself
|
|
||||||
return redirect(f"/files/{path}", status=303)
|
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -146,6 +146,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
|||||||
key: str
|
key: str
|
||||||
mtime: int
|
mtime: int
|
||||||
size: int
|
size: int
|
||||||
|
allocated: int
|
||||||
isfile: int
|
isfile: int
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -177,5 +178,6 @@ class UpdateMessage(msgspec.Struct):
|
|||||||
class Space(msgspec.Struct):
|
class Space(msgspec.Struct):
|
||||||
disk: int
|
disk: int
|
||||||
free: int
|
free: int
|
||||||
usage: int
|
used: int
|
||||||
storage: int
|
storage: int
|
||||||
|
allocated: int
|
||||||
|
|||||||
+72
-3
@@ -24,7 +24,7 @@ sortkey = natsort_keygen(alg=ns.LOCALE)
|
|||||||
class State:
|
class State:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self._space = Space(0, 0, 0, 0)
|
self._space = Space(0, 0, 0, 0, 0)
|
||||||
self.root: list[FileEntry] = []
|
self.root: list[FileEntry] = []
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -148,12 +148,15 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
try:
|
try:
|
||||||
st = stat or path.stat()
|
st = stat or path.stat()
|
||||||
isfile = int(not S_ISDIR(st.st_mode))
|
isfile = int(not S_ISDIR(st.st_mode))
|
||||||
|
# st_blocks is in 512-byte units
|
||||||
|
allocated = st.st_blocks * 512 if isfile else 0
|
||||||
entry = FileEntry(
|
entry = FileEntry(
|
||||||
level=len(rel.parts),
|
level=len(rel.parts),
|
||||||
name=rel.name,
|
name=rel.name,
|
||||||
key=fuid(st),
|
key=fuid(st),
|
||||||
mtime=int(st.st_mtime),
|
mtime=int(st.st_mtime),
|
||||||
size=st.st_size if isfile else 0,
|
size=st.st_size if isfile else 0,
|
||||||
|
allocated=allocated,
|
||||||
isfile=isfile,
|
isfile=isfile,
|
||||||
)
|
)
|
||||||
if isfile:
|
if isfile:
|
||||||
@@ -181,8 +184,9 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
|||||||
level=entry.level,
|
level=entry.level,
|
||||||
name=entry.name,
|
name=entry.name,
|
||||||
key=entry.key,
|
key=entry.key,
|
||||||
size=entry.size + child.size,
|
|
||||||
mtime=max(entry.mtime, child.mtime),
|
mtime=max(entry.mtime, child.mtime),
|
||||||
|
size=entry.size + child.size,
|
||||||
|
allocated=entry.allocated + child.allocated,
|
||||||
isfile=entry.isfile,
|
isfile=entry.isfile,
|
||||||
)
|
)
|
||||||
ret.extend(sub)
|
ret.extend(sub)
|
||||||
@@ -227,7 +231,14 @@ def update_path(rootmod: list[FileEntry], relpath: PurePosixPath, loop):
|
|||||||
def update_space(loop):
|
def update_space(loop):
|
||||||
"""Called periodically to update the disk usage."""
|
"""Called periodically to update the disk usage."""
|
||||||
du = shutil.disk_usage(rootpath)
|
du = shutil.disk_usage(rootpath)
|
||||||
space = Space(*du, storage=state.root[0].size)
|
root = state.root[0]
|
||||||
|
space = Space(
|
||||||
|
disk=du.total,
|
||||||
|
free=du.free,
|
||||||
|
used=du.used,
|
||||||
|
storage=root.size,
|
||||||
|
allocated=root.allocated,
|
||||||
|
)
|
||||||
# Update only on difference above 1 MB
|
# Update only on difference above 1 MB
|
||||||
tol = 10**6
|
tol = 10**6
|
||||||
old = msgspec.structs.astuple(state.space)
|
old = msgspec.structs.astuple(state.space)
|
||||||
@@ -504,8 +515,66 @@ class PathIndex:
|
|||||||
|
|
||||||
self.root = new_root
|
self.root = new_root
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
|
# Recalculate sizes for ancestor folders (including root)
|
||||||
|
self._recalculate_ancestors(path)
|
||||||
|
|
||||||
return new_root
|
return new_root
|
||||||
|
|
||||||
|
def _recalculate_ancestors(self, path: PurePosixPath):
|
||||||
|
"""Recalculate size/allocated for all ancestors of path, including root."""
|
||||||
|
# Build list of ancestors from deepest to root
|
||||||
|
ancestors = []
|
||||||
|
current = path.parent if path.parts else PurePosixPath()
|
||||||
|
while True:
|
||||||
|
ancestors.append(current)
|
||||||
|
if not current.parts:
|
||||||
|
break
|
||||||
|
current = current.parent
|
||||||
|
|
||||||
|
# Process from deepest ancestor to root
|
||||||
|
for ancestor_path in ancestors:
|
||||||
|
if ancestor_path not in self._index:
|
||||||
|
continue
|
||||||
|
start, count = self._index[ancestor_path]
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ancestor = self.root[start]
|
||||||
|
if ancestor.isfile:
|
||||||
|
continue # Files don't aggregate
|
||||||
|
|
||||||
|
# Sum size/allocated of direct children
|
||||||
|
total_size = 0
|
||||||
|
total_allocated = 0
|
||||||
|
i = start + 1
|
||||||
|
while i < start + count:
|
||||||
|
child = self.root[i]
|
||||||
|
if child.level == ancestor.level + 1:
|
||||||
|
total_size += child.size
|
||||||
|
total_allocated += child.allocated
|
||||||
|
# Skip child's subtree
|
||||||
|
child_path = ancestor_path / child.name
|
||||||
|
if child_path in self._index:
|
||||||
|
_, child_count = self._index[child_path]
|
||||||
|
i += child_count
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Update ancestor entry if changed
|
||||||
|
if ancestor.size != total_size or ancestor.allocated != total_allocated:
|
||||||
|
self.root[start] = FileEntry(
|
||||||
|
level=ancestor.level,
|
||||||
|
name=ancestor.name,
|
||||||
|
key=ancestor.key,
|
||||||
|
mtime=ancestor.mtime,
|
||||||
|
size=total_size,
|
||||||
|
allocated=total_allocated,
|
||||||
|
isfile=ancestor.isfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
|
||||||
"""Remove child paths if parent is in set."""
|
"""Remove child paths if parent is in set."""
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<RouterView :path="path.pathList" :query="path.query" />
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||||
<HeaderSelected :path="path.pathList" />
|
<SelectionToolbar :path="path.pathList" />
|
||||||
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
|
||||||
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
|
||||||
</footer>
|
</footer>
|
||||||
@@ -36,6 +36,7 @@ import type { SortOrder } from './utils/docsort'
|
|||||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||||
|
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
|||||||
@@ -36,9 +36,9 @@
|
|||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g ref="labelsRef" class="pie-labels">
|
<g ref="labelsRef" class="pie-labels">
|
||||||
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.storage, sectorInfo.storage.angle) }}</text>
|
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||||
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||||
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.usage - store.space.storage, sectorInfo.other.angle) }}</text>
|
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||||
@@ -98,7 +98,31 @@ const truncateLabel = (name: string, maxLen = 10): string => {
|
|||||||
return name.slice(0, maxLen - 1) + '…'
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageName = computed(() => truncateLabel(store.server.name || 'stored'))
|
// Calculate max label length based on angular gap to neighbor labels
|
||||||
|
const storageMaxLen = computed(() => {
|
||||||
|
const s = store.space
|
||||||
|
if (!s.disk) return 10
|
||||||
|
// Sector spans in degrees
|
||||||
|
const storageSpan = (s.allocated / s.disk) * 360
|
||||||
|
const freeSpan = (s.free / s.disk) * 360
|
||||||
|
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
||||||
|
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||||
|
const gapToFree = (storageSpan + freeSpan) / 2
|
||||||
|
const gapToOther = (storageSpan + otherSpan) / 2
|
||||||
|
const minGap = Math.min(gapToFree, gapToOther)
|
||||||
|
// Allow longer names when there's sufficient gap to both neighbors
|
||||||
|
if (minGap > 70) return 18
|
||||||
|
if (minGap > 55) return 14
|
||||||
|
return 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const storageName = computed(() => {
|
||||||
|
const name = store.server.name || 'stored'
|
||||||
|
const maxLen = storageMaxLen.value
|
||||||
|
// Use full name if it fits within the available space
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
return truncateLabel(name, 10)
|
||||||
|
})
|
||||||
|
|
||||||
const TAU = 2 * Math.PI
|
const TAU = 2 * Math.PI
|
||||||
|
|
||||||
@@ -113,7 +137,7 @@ const CIRC = TAU * midRadius
|
|||||||
const pieStorageDash = computed(() => {
|
const pieStorageDash = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return `0 ${CIRC}`
|
if (!s.disk) return `0 ${CIRC}`
|
||||||
return `${(s.storage / s.disk) * CIRC} ${CIRC}`
|
return `${(s.allocated / s.disk) * CIRC} ${CIRC}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const pieFreeDash = computed(() => {
|
const pieFreeDash = computed(() => {
|
||||||
@@ -125,7 +149,7 @@ const pieFreeDash = computed(() => {
|
|||||||
const pieFreeOffsetVal = computed(() => {
|
const pieFreeOffsetVal = computed(() => {
|
||||||
const s = store.space
|
const s = store.space
|
||||||
if (!s.disk) return 0
|
if (!s.disk) return 0
|
||||||
return -(s.storage / s.disk) * CIRC
|
return -(s.allocated / s.disk) * CIRC
|
||||||
})
|
})
|
||||||
|
|
||||||
const freeColor = computed(() => {
|
const freeColor = computed(() => {
|
||||||
@@ -133,8 +157,8 @@ const freeColor = computed(() => {
|
|||||||
if (!s.disk) return '#6c6'
|
if (!s.disk) return '#6c6'
|
||||||
const freePct = s.free / s.disk
|
const freePct = s.free / s.disk
|
||||||
if (freePct > 0.25) return '#5b5'
|
if (freePct > 0.25) return '#5b5'
|
||||||
if (freePct > 0.10) return '#db3'
|
if (freePct > 0.10) return '#ff0'
|
||||||
return '#d44'
|
return '#f00'
|
||||||
})
|
})
|
||||||
|
|
||||||
const PIE_RADIUS = 55
|
const PIE_RADIUS = 55
|
||||||
@@ -153,9 +177,9 @@ const sectorInfo = computed(() => {
|
|||||||
other: { angle: 270, pct: 0.25 }
|
other: { angle: 270, pct: 0.25 }
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePct = s.storage / s.disk
|
const storagePct = s.allocated / s.disk
|
||||||
const freePct = s.free / s.disk
|
const freePct = s.free / s.disk
|
||||||
const otherPct = (s.usage - s.storage) / s.disk
|
const otherPct = (s.used - s.allocated) / s.disk
|
||||||
|
|
||||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||||
const freeStart = storagePct * 360
|
const freeStart = storagePct * 360
|
||||||
@@ -229,9 +253,10 @@ const adjustedLabelAngles = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
// Arc path for curved text labels (CW for top half, CCW for bottom half)
|
||||||
const createArcPath = (centerAngle: number, id: string) => {
|
const createArcPath = (centerAngle: number, id: string, labelLen: number) => {
|
||||||
const radius = LABEL_RADIUS
|
const radius = LABEL_RADIUS
|
||||||
const arcSpan = 60
|
// Scale arc span based on label length: ~6° per character, minimum 45°
|
||||||
|
const arcSpan = Math.max(45, labelLen * 6)
|
||||||
const isBottom = centerAngle > 90 && centerAngle <= 270
|
const isBottom = centerAngle > 90 && centerAngle <= 270
|
||||||
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
|
const startAngle = isBottom ? centerAngle + arcSpan / 2 : centerAngle - arcSpan / 2
|
||||||
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
|
const endAngle = isBottom ? centerAngle - arcSpan / 2 : centerAngle + arcSpan / 2
|
||||||
@@ -244,9 +269,9 @@ const createArcPath = (centerAngle: number, id: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage'))
|
const storageLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.storage!, 'storage', storageName.value.length))
|
||||||
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free'))
|
const freeLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.free!, 'free', 4))
|
||||||
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other'))
|
const otherLabelPath = computed(() => createArcPath(adjustedLabelAngles.value.other!, 'other', 5))
|
||||||
|
|
||||||
const handleClick = () => isExpanded.value ? collapse() : expand()
|
const handleClick = () => isExpanded.value ? collapse() : expand()
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<td class="size right" :class=sizeClass>{{ doc.sizedisp }}</td>
|
<td
|
||||||
|
class="size right"
|
||||||
|
:class="sizeClass"
|
||||||
|
@mouseenter="doc.sparseIndicator && tooltip?.startHover($event)"
|
||||||
|
@mousemove="doc.sparseIndicator && tooltip?.updatePosition($event)"
|
||||||
|
@mouseleave="doc.sparseIndicator && tooltip?.endHover()"
|
||||||
|
>
|
||||||
|
<SparseIndicator :doc="doc" class="before-size" />{{ doc.sizedisp }}
|
||||||
|
<CursorTooltip v-if="doc.sparseIndicator" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||||
|
</td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
const sizeClass = computed(() => {
|
||||||
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
const unit = props.doc.sizedisp.split('\u202F').slice(-1)[0]!
|
||||||
return +unit ? "bytes" : unit
|
return +unit ? "bytes" : unit
|
||||||
})
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const tooltipText = computed(() => {
|
||||||
doc: Doc
|
const { size, allocated } = props.doc
|
||||||
}>()
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.before-size {
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
.size.empty { color: #555 }
|
.size.empty { color: #555 }
|
||||||
.size.bytes { color: #77a }
|
.size.bytes { color: #77a }
|
||||||
.size.kB { color: #474 }
|
.size.kB { color: #474 }
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ defineExpose({
|
|||||||
dir: true,
|
dir: true,
|
||||||
mtime: now,
|
mtime: now,
|
||||||
size: 0,
|
size: 0,
|
||||||
|
allocated: 0,
|
||||||
})
|
})
|
||||||
store.cursor = editing.value.key
|
store.cursor = editing.value.key
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
|
||||||
<span>{{ doc.name }}</span>
|
<span>{{ doc.name }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
</figcaption>
|
</figcaption>
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
<CursorTooltip ref="tooltip" :text="tooltipText">
|
<CursorTooltip ref="tooltip" :text="tooltipText">
|
||||||
<div class="tooltip-name">{{ doc.name }}</div>
|
<div class="tooltip-name">{{ doc.name }}</div>
|
||||||
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
<div class="tooltip-details">{{ doc.modified }} — {{ doc.sizedisp }}</div>
|
||||||
|
<div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||||
</CursorTooltip>
|
</CursorTooltip>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
@@ -34,8 +35,10 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { Doc } from '@/repositories/Document'
|
import { Doc } from '@/repositories/Document'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
import MediaPreview from '@/components/MediaPreview.vue'
|
import MediaPreview from '@/components/MediaPreview.vue'
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import SparseIndicator from './SparseIndicator.vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
type EditingProp = {
|
type EditingProp = {
|
||||||
@@ -52,6 +55,11 @@ const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|||||||
|
|
||||||
const tooltipText = computed(() => props.doc.key)
|
const tooltipText = computed(() => props.doc.key)
|
||||||
|
|
||||||
|
const sparseText = computed(() => {
|
||||||
|
const { size, allocated } = props.doc
|
||||||
|
return `${formatSize(allocated)} allocated of ${formatSize(size)}`
|
||||||
|
})
|
||||||
|
|
||||||
const onclick = (ev: Event) => {
|
const onclick = (ev: Event) => {
|
||||||
if (m.value!.play()) ev.preventDefault()
|
if (m.value!.play()) ev.preventDefault()
|
||||||
store.cursor = props.doc.key
|
store.cursor = props.doc.key
|
||||||
@@ -66,6 +74,13 @@ const onclick = (ev: Event) => {
|
|||||||
.tooltip-details {
|
.tooltip-details {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.tooltip-sparse {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.after-name {
|
||||||
|
margin-left: 0.3em;
|
||||||
|
}
|
||||||
figure {
|
figure {
|
||||||
max-height: 15em;
|
max-height: 15em;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ const settingsMenu = (e: Event) => {
|
|||||||
// Show login option only in public mode (non-public modes trigger auth automatically)
|
// Show login option only in public mode (non-public modes trigger auth automatically)
|
||||||
items.push({ label: '🔐 Login', onClick: async () => {
|
items.push({ label: '🔐 Login', onClick: async () => {
|
||||||
try {
|
try {
|
||||||
await showAuthIframe('/auth/restricted#theme=light')
|
await showAuthIframe('/auth/restricted/#theme=light')
|
||||||
resumeWatching()
|
resumeWatching()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Login cancelled')
|
console.log('Login cancelled')
|
||||||
@@ -141,6 +141,7 @@ defineExpose({
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
min-height: 3em;
|
||||||
}
|
}
|
||||||
.search-group {
|
.search-group {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="selection-bar" v-if="store.selected.size">
|
|
||||||
<p class="select-text">{{ store.selected.size }} selected</p>
|
|
||||||
<DownloadButton />
|
|
||||||
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
|
||||||
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
|
||||||
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
|
||||||
<button
|
|
||||||
class="action-button unselect"
|
|
||||||
@click="store.selected.clear()"
|
|
||||||
@mouseenter="unselectTooltip?.startHover"
|
|
||||||
@mousemove="unselectTooltip?.updatePosition"
|
|
||||||
@mouseleave="unselectTooltip?.endHover"
|
|
||||||
>❌<CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {connect, controlUrl} from '@/repositories/WS'
|
|
||||||
import { useMainStore } from '@/stores/main'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import CursorTooltip from './CursorTooltip.vue'
|
|
||||||
|
|
||||||
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
|
||||||
|
|
||||||
const store = useMainStore()
|
|
||||||
const props = defineProps({
|
|
||||||
path: Array<string>
|
|
||||||
})
|
|
||||||
|
|
||||||
const dst = computed(() => props.path!.join('/'))
|
|
||||||
const op = (opName: string, dst?: string) => {
|
|
||||||
const sel = store.selectedFiles
|
|
||||||
const paths = sel.keys.map(key => {
|
|
||||||
const doc = sel.docs[key]!
|
|
||||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
|
||||||
})
|
|
||||||
const msg = {
|
|
||||||
op: opName,
|
|
||||||
sel: paths
|
|
||||||
}
|
|
||||||
// @ts-ignore
|
|
||||||
if (dst !== undefined) msg.dst = dst
|
|
||||||
// Hide items being deleted or moved (optimistic update)
|
|
||||||
if (opName === 'rm' || opName === 'mv') {
|
|
||||||
for (const path of paths) store.hideDoc(path)
|
|
||||||
}
|
|
||||||
const control = connect(controlUrl, {
|
|
||||||
message(ev: MessageEvent) {
|
|
||||||
const res = JSON.parse(ev.data)
|
|
||||||
if ('error' in res) {
|
|
||||||
console.error('Control socket error', msg, res.error)
|
|
||||||
store.error = res.error.message
|
|
||||||
// Restore hidden items on error
|
|
||||||
if (opName === 'rm' || opName === 'mv') {
|
|
||||||
for (const path of paths) store.unhideDoc(path)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} else if (res.status === 'ack') {
|
|
||||||
console.log('Control ack OK', res)
|
|
||||||
control.close()
|
|
||||||
store.selected.clear()
|
|
||||||
return
|
|
||||||
} else console.log('Unknown control response', msg, res)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
control.onopen = () => {
|
|
||||||
control.send(JSON.stringify(msg))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.selection-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.3em 0.5em;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--header-color);
|
|
||||||
}
|
|
||||||
.select-text {
|
|
||||||
color: var(--accent-color);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
margin: 0;
|
|
||||||
padding-right: 0.5em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<div class="selection-bar" v-if="store.selected.size">
|
||||||
|
<div class="select-info">
|
||||||
|
<template v-if="selectionDisplay.folders.length <= 5">
|
||||||
|
<span class="select-folders">
|
||||||
|
<template v-for="(folder, i) in selectionDisplay.folders" :key="folder.path">
|
||||||
|
<span v-if="i > 0" class="folder-sep">, </span>
|
||||||
|
<a :href="'/#/' + folder.path" class="folder-link" @click.prevent="navigateTo(folder.path)">{{ folder.name }}</a>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="select-count">{{ store.selected.size }} items from {{ selectionDisplay.numFolders }} folders</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||||
|
<DownloadButton />
|
||||||
|
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||||
|
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||||
|
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||||
|
<button
|
||||||
|
class="action-button unselect"
|
||||||
|
@click="store.selected.clear()"
|
||||||
|
@mouseenter="unselectTooltip?.startHover"
|
||||||
|
@mousemove="unselectTooltip?.updatePosition"
|
||||||
|
@mouseleave="unselectTooltip?.endHover"
|
||||||
|
>✖ selection</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {connect, controlUrl} from '@/repositories/WS'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { formatSize } from '@/utils'
|
||||||
|
import CursorTooltip from './CursorTooltip.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
const store = useMainStore()
|
||||||
|
const props = defineProps({
|
||||||
|
path: Array<string>
|
||||||
|
})
|
||||||
|
|
||||||
|
const dst = computed(() => props.path!.join('/'))
|
||||||
|
|
||||||
|
const navigateTo = (path: string) => {
|
||||||
|
router.push('/' + path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate long names to reasonable length
|
||||||
|
const truncateName = (name: string, maxLen = 20): string => {
|
||||||
|
if (name.length <= maxLen) return name
|
||||||
|
return name.slice(0, maxLen - 1) + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderInfo {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionDisplay {
|
||||||
|
folders: FolderInfo[]
|
||||||
|
numFolders: number
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionDisplay = computed<SelectionDisplay>(() => {
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
const totalSize = sel.keys.reduce((sum, key) => {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
return sum + (doc ? doc.size : 0)
|
||||||
|
}, 0)
|
||||||
|
const sizeStr = formatSize(totalSize)
|
||||||
|
|
||||||
|
// Group by folder location, storing file names
|
||||||
|
const folderGroups = new Map<string, string[]>()
|
||||||
|
for (const key of sel.keys) {
|
||||||
|
const doc = sel.docs[key]
|
||||||
|
if (!doc) continue
|
||||||
|
const loc = doc.loc || ''
|
||||||
|
if (!folderGroups.has(loc)) folderGroups.set(loc, [])
|
||||||
|
folderGroups.get(loc)!.push(doc.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const numFolders = folderGroups.size
|
||||||
|
|
||||||
|
const folders = Array.from(folderGroups.entries())
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([loc, names]) => {
|
||||||
|
const count = names.length
|
||||||
|
// For single file, display truncated filename; for multiple, display folder name with count
|
||||||
|
let displayName: string
|
||||||
|
if (count === 1) {
|
||||||
|
displayName = truncateName(names[0]!)
|
||||||
|
} else {
|
||||||
|
const folderName = loc ? loc.split('/').pop()! : (store.server.name || 'Root')
|
||||||
|
displayName = `${truncateName(folderName)} (${count})`
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: displayName,
|
||||||
|
path: loc,
|
||||||
|
count
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders,
|
||||||
|
numFolders,
|
||||||
|
size: sizeStr
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const op = (opName: string, dst?: string) => {
|
||||||
|
const sel = store.selectedFiles
|
||||||
|
const paths = sel.keys.map(key => {
|
||||||
|
const doc = sel.docs[key]!
|
||||||
|
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
})
|
||||||
|
const msg = {
|
||||||
|
op: opName,
|
||||||
|
sel: paths
|
||||||
|
}
|
||||||
|
// @ts-ignore
|
||||||
|
if (dst !== undefined) msg.dst = dst
|
||||||
|
// Hide items being deleted or moved (optimistic update)
|
||||||
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
|
for (const path of paths) store.hideDoc(path)
|
||||||
|
}
|
||||||
|
const control = connect(controlUrl, {
|
||||||
|
message(ev: MessageEvent) {
|
||||||
|
const res = JSON.parse(ev.data)
|
||||||
|
if ('error' in res) {
|
||||||
|
console.error('Control socket error', msg, res.error)
|
||||||
|
store.error = res.error.message
|
||||||
|
// Restore hidden items on error
|
||||||
|
if (opName === 'rm' || opName === 'mv') {
|
||||||
|
for (const path of paths) store.unhideDoc(path)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if (res.status === 'ack') {
|
||||||
|
console.log('Control ack OK', res)
|
||||||
|
control.close()
|
||||||
|
store.selected.clear()
|
||||||
|
return
|
||||||
|
} else console.log('Unknown control response', msg, res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
control.onopen = () => {
|
||||||
|
control.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.selection-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--header-color);
|
||||||
|
font-size: var(--header-font-size);
|
||||||
|
gap: 0.3em;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.select-info {
|
||||||
|
color: var(--accent-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.select-count {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.select-folders {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.folder-link,
|
||||||
|
.folder-link:link,
|
||||||
|
.folder-link:visited,
|
||||||
|
.folder-link:active {
|
||||||
|
color: var(--accent-color);
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.folder-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
.folder-sep {
|
||||||
|
color: var(--header-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.select-size {
|
||||||
|
color: var(--header-color);
|
||||||
|
opacity: 0.8;
|
||||||
|
font-family: 'Roboto Mono', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="doc.sparseIndicator" class="sparse-indicator">{{ doc.sparseIndicator }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Doc } from '@/repositories/Document'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
doc: Doc
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sparse-indicator {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -115,13 +115,13 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
|||||||
for (let i = 0; i < parts.length; i++) {
|
for (let i = 0; i < parts.length; i++) {
|
||||||
const folderPath = parts.slice(0, i + 1).join('/')
|
const folderPath = parts.slice(0, i + 1).join('/')
|
||||||
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
|
||||||
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true }))
|
store.addGhost(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, allocated: 0, mtime: now, dir: true }))
|
||||||
added.add(folderPath)
|
added.add(folderPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
// Ghost file or update existing (overwrite case doesn't need ghost, file already visible)
|
||||||
const existing = byPath.get(f.cloudName)
|
const existing = byPath.get(f.cloudName)
|
||||||
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false }))
|
if (!existing) store.addGhost(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, allocated: 0, mtime: now, dir: false }))
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
upqueue = [...upqueue, ...files]
|
upqueue = [...upqueue, ...files]
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<h3>Server Settings</h3>
|
<h3>Server Settings</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="serverName">Server name</label>
|
||||||
|
<div class="input-with-hint">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="serverName"
|
||||||
|
v-model="serverSettings.name"
|
||||||
|
@input="debouncedUpdateServerName"
|
||||||
|
:placeholder="store.server.name"
|
||||||
|
/>
|
||||||
|
<small>Leave empty to use the share folder name</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="publicAccess">
|
<label for="publicAccess">
|
||||||
<input
|
<input
|
||||||
@@ -62,7 +75,7 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { listUsers, createUser, updateUser, deleteUser, updatePublic } from '@/repositories/User'
|
import { listUsers, createUser, updateUser, deleteUser, updatePublic, updateServerName, getServerConfig } from '@/repositories/User'
|
||||||
import type { ISimpleError } from '@/repositories/Client'
|
import type { ISimpleError } from '@/repositories/Client'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
|
|
||||||
@@ -78,9 +91,12 @@ const users = ref<User[]>([])
|
|||||||
const success = ref('')
|
const success = ref('')
|
||||||
const copyButtonText = ref('📋')
|
const copyButtonText = ref('📋')
|
||||||
const serverSettings = reactive({
|
const serverSettings = reactive({
|
||||||
public: false
|
public: false,
|
||||||
|
name: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let nameDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
store.dialog = ''
|
store.dialog = ''
|
||||||
success.value = ''
|
success.value = ''
|
||||||
@@ -206,16 +222,49 @@ const updateServerSettings = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateServerNameSetting = async () => {
|
||||||
|
try {
|
||||||
|
const result = await updateServerName(serverSettings.name)
|
||||||
|
// Update store with the effective name returned by the server
|
||||||
|
store.server.name = result.name
|
||||||
|
} catch (e) {
|
||||||
|
const httpError = e as ISimpleError
|
||||||
|
store.showToast(httpError.message || 'Failed to update server name')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedUpdateServerName = () => {
|
||||||
|
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
|
||||||
|
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load server config from admin API
|
||||||
|
const loadServerConfig = async () => {
|
||||||
|
try {
|
||||||
|
const config = await getServerConfig()
|
||||||
|
serverSettings.name = config.name
|
||||||
|
serverSettings.public = config.public
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to store values if API fails
|
||||||
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
serverSettings.public = store.server.public || false
|
serverSettings.public = store.server.public || false
|
||||||
|
serverSettings.name = ''
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load users when dialog opens (only in built-in auth mode)
|
// Load users and config when dialog opens
|
||||||
watch(() => store.dialog, (newVal) => {
|
watch(() => store.dialog, (newVal) => {
|
||||||
if (newVal === 'usermgmt' && !store.server.paskia) {
|
if (newVal === 'usermgmt') {
|
||||||
|
loadServerConfig()
|
||||||
|
if (!store.server.paskia) {
|
||||||
loadUsers()
|
loadUsers()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => store.server.public, (newVal) => {
|
watch(() => store.server.public, (newVal) => {
|
||||||
@@ -225,4 +274,13 @@ watch(() => store.server.public, (newVal) => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */
|
||||||
|
.input-with-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.input-with-hint small {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type DocProps = {
|
|||||||
name: string
|
name: string
|
||||||
key: FUID
|
key: FUID
|
||||||
size: number
|
size: number
|
||||||
|
allocated: number
|
||||||
mtime: number
|
mtime: number
|
||||||
dir: boolean
|
dir: boolean
|
||||||
ghost?: boolean
|
ghost?: boolean
|
||||||
@@ -17,6 +18,7 @@ export class Doc {
|
|||||||
public loc: string = ""
|
public loc: string = ""
|
||||||
public key: FUID = ""
|
public key: FUID = ""
|
||||||
public size: number = 0
|
public size: number = 0
|
||||||
|
public allocated: number = 0
|
||||||
public mtime: number = 0
|
public mtime: number = 0
|
||||||
public dir: boolean = false
|
public dir: boolean = false
|
||||||
public ghost: boolean = false
|
public ghost: boolean = false
|
||||||
@@ -35,6 +37,15 @@ export class Doc {
|
|||||||
this._name = name
|
this._name = name
|
||||||
}
|
}
|
||||||
get sizedisp(): string { return formatSize(this.size) }
|
get sizedisp(): string { return formatSize(this.size) }
|
||||||
|
/** Returns a sparse allocation indicator symbol, or empty string if fully allocated */
|
||||||
|
get sparseIndicator(): string {
|
||||||
|
if (this.dir || this.size <= this.allocated) return ''
|
||||||
|
if (this.allocated === 0) return '⭕' // exactly zero
|
||||||
|
const ratio = this.allocated / this.size
|
||||||
|
// Round to nearest 25%: ◔◑◕⬤
|
||||||
|
const rounded = Math.round(ratio * 4) // 0,1,2,3,4
|
||||||
|
return ['◔', '◔', '◑', '◕', '⬤'][rounded]! // 0 maps to ◔ since we handled exact 0 above
|
||||||
|
}
|
||||||
get modified(): string { return formatUnixDate(this.mtime) }
|
get modified(): string { return formatUnixDate(this.mtime) }
|
||||||
get url(): string {
|
get url(): string {
|
||||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
@@ -78,8 +89,9 @@ export type FileEntry = [
|
|||||||
number, // level
|
number, // level
|
||||||
string, // name
|
string, // name
|
||||||
FUID,
|
FUID,
|
||||||
number, //mtime
|
number, // mtime
|
||||||
number, // size
|
number, // size
|
||||||
|
number, // allocated (actual disk usage)
|
||||||
number, // isfile
|
number, // isfile
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -55,3 +55,13 @@ export async function updatePublic(isPublic: boolean) {
|
|||||||
const data = await Client.put('/api/config/public', { public: isPublic })
|
const data = await Client.put('/api/config/public', { public: isPublic })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateServerName(name: string) {
|
||||||
|
const data = await Client.put('/api/config/name', { name })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerConfig() {
|
||||||
|
const data = await Client.get('/api/config')
|
||||||
|
return data as { name: string, public: boolean }
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,8 +96,9 @@ export const useMainStore = defineStore('main', {
|
|||||||
space: {
|
space: {
|
||||||
disk: 0,
|
disk: 0,
|
||||||
free: 0,
|
free: 0,
|
||||||
usage: 0,
|
used: 0,
|
||||||
storage: 0,
|
storage: 0,
|
||||||
|
allocated: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
persist: {
|
persist: {
|
||||||
@@ -118,13 +119,14 @@ export const useMainStore = defineStore('main', {
|
|||||||
updateRoot(root: FileEntry[]) {
|
updateRoot(root: FileEntry[]) {
|
||||||
const docs = []
|
const docs = []
|
||||||
let loc = [] as string[]
|
let loc = [] as string[]
|
||||||
for (const [level, name, key, mtime, size, isfile] of root) {
|
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||||
loc = loc.slice(0, level - 1)
|
loc = loc.slice(0, level - 1)
|
||||||
docs.push(new Doc({
|
docs.push(new Doc({
|
||||||
name,
|
name,
|
||||||
loc: level ? loc.join('/') : '/',
|
loc: level ? loc.join('/') : '/',
|
||||||
key,
|
key,
|
||||||
size,
|
size,
|
||||||
|
allocated,
|
||||||
mtime,
|
mtime,
|
||||||
dir: !isfile,
|
dir: !isfile,
|
||||||
}))
|
}))
|
||||||
|
|||||||
+13
-10
@@ -2,10 +2,10 @@
|
|||||||
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
"""Run Vite development server for frontend and Cista backend with auto-reload.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
uv run scripts/devserver.py [frontend] [--backend backend] [cista_args...]
|
uv run scripts/devserver.py [-l listen] [--backend backend] [cista_args...]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
frontend Vite frontend endpoint (default: localhost:8989)
|
-l, --listen Vite frontend endpoint (default: localhost:8989)
|
||||||
--backend Cista backend endpoint (default: from config, or :8999)
|
--backend Cista backend endpoint (default: from config, or :8999)
|
||||||
|
|
||||||
Any additional arguments are passed to the cista command.
|
Any additional arguments are passed to the cista command.
|
||||||
@@ -31,7 +31,9 @@ from cista.serve import parse_listen
|
|||||||
DEFAULT_BACKEND_PORT = 8999
|
DEFAULT_BACKEND_PORT = 8999
|
||||||
|
|
||||||
|
|
||||||
def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str, list[str]]:
|
def setup_sanic_backend(
|
||||||
|
listen: str | None, extra_args: list[str]
|
||||||
|
) -> tuple[str, list[str]]:
|
||||||
"""Parse backend listen address and build cista dev command.
|
"""Parse backend listen address and build cista dev command.
|
||||||
|
|
||||||
Returns (url, cmd).
|
Returns (url, cmd).
|
||||||
@@ -46,7 +48,9 @@ def setup_sanic_backend(listen: str | None, extra_args: list[str]) -> tuple[str,
|
|||||||
return f"http://{host}:{port}", cmd
|
return f"http://{host}:{port}", cmd
|
||||||
|
|
||||||
|
|
||||||
async def run_devserver(frontend: str | None, backend: str | None, extra_args: list[str]) -> None:
|
async def run_devserver(
|
||||||
|
frontend: str | None, backend: str | None, extra_args: list[str]
|
||||||
|
) -> None:
|
||||||
reporoot = Path(__file__).parent.parent
|
reporoot = Path(__file__).parent.parent
|
||||||
front = reporoot / "frontend"
|
front = reporoot / "frontend"
|
||||||
if not (front / "package.json").exists():
|
if not (front / "package.json").exists():
|
||||||
@@ -80,26 +84,25 @@ def main():
|
|||||||
epilog=HELP_EPILOG,
|
epilog=HELP_EPILOG,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"frontend",
|
"-l",
|
||||||
nargs="?",
|
"--listen",
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Vite frontend endpoint (default: localhost:8989)",
|
help="Vite frontend endpoint (default: localhost:8989)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--backend",
|
"--backend",
|
||||||
"-l",
|
|
||||||
metavar="host:port",
|
metavar="host:port",
|
||||||
help="Cista backend endpoint (default: from config, or :8999)",
|
help="Cista backend endpoint (default: from config, or :8999)",
|
||||||
)
|
)
|
||||||
args, unknown = parser.parse_known_args()
|
args, unknown = parser.parse_known_args()
|
||||||
with contextlib.suppress(KeyboardInterrupt):
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
asyncio.run(run_devserver(args.frontend, args.backend, unknown))
|
asyncio.run(run_devserver(args.listen, args.backend, unknown))
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
scripts/devserver.py # Default ports
|
scripts/devserver.py # Default ports
|
||||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
scripts/devserver.py -l 3000 # Vite on localhost:3000
|
||||||
scripts/devserver.py :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
scripts/devserver.py -l :3000 --backend 8080 # Vite on *:3000, backend on :8080
|
||||||
|
|
||||||
Additional arguments are passed to the cista backend command.
|
Additional arguments are passed to the cista backend command.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ def decode(data: str):
|
|||||||
|
|
||||||
# Helper function to create a list of FileEntry objects
|
# Helper function to create a list of FileEntry objects
|
||||||
def f(count, start=0):
|
def f(count, start=0):
|
||||||
return [FileEntry(i, str(i), str(i), 0, 0, 0) for i in range(start, start + count)]
|
return [
|
||||||
|
FileEntry(i, str(i), str(i), 0, 0, 0, 0) for i in range(start, start + count)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_identical_lists():
|
def test_identical_lists():
|
||||||
@@ -35,8 +37,8 @@ def test_insertions():
|
|||||||
|
|
||||||
|
|
||||||
def test_insertion_at_end():
|
def test_insertion_at_end():
|
||||||
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 1)]
|
old_list = [*f(3), FileEntry(1, "xxx", "xxx", 0, 0, 0, 1)]
|
||||||
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 1)
|
newfile = FileEntry(1, "yyy", "yyy", 0, 0, 0, 1)
|
||||||
new_list = [*old_list, newfile]
|
new_list = [*old_list, newfile]
|
||||||
expected = [UpdKeep(4), UpdIns([newfile])]
|
expected = [UpdKeep(4), UpdIns([newfile])]
|
||||||
assert decode(format_update(old_list, new_list)) == expected
|
assert decode(format_update(old_list, new_list)) == expected
|
||||||
|
|||||||
Reference in New Issue
Block a user