Compare commits

..
21 Commits
Author SHA1 Message Date
LeoVasanko 760f7bc35d Fix vite argument passing on deno (fastapi-vue-setup upstream). 2026-02-05 20:13:03 +00:00
LeoVasanko 8480a73839 Fix --listen docopt parsing (only -l was working). 2026-02-05 20:01:26 +00:00
LeoVasanko 302ed684e7 Remove invalid Sanic kwarg when binding to all interfaces (not needed anyway). 2026-02-05 19:56:54 +00:00
LeoVasanko af35e0480a Better proxy header processing, pass original user-agent without httpx overriding it. 2026-02-05 17:53:52 +00:00
LeoVasanko 5717486197 Intl support for search hotkey (where US keyboard has it). 2026-02-05 00:57:42 +00:00
LeoVasanko 0061fc54ae Keyboard navigation fixes (still not perfect but better). 2026-02-05 00:54:15 +00:00
LeoVasanko 4eefe83072 Fix search bar losing focus under specific conditions, mainly that when the search takes more than 50ms, causing results to be emptied, with a file priorly focused, was causing breadcrumbs to get focused. (reactivity is hard) 2026-02-05 00:07:45 +00:00
LeoVasanko f578a50007 Pass the new allocated field through search filtering. Fixes issue where 0 allocated was reported in search results. 2026-02-04 23:55:05 +00:00
LeoVasanko f40d9c1abd Cache previews in server RAM for much faster access (they are about 70kB each). 2026-02-04 20:56:16 +00:00
LeoVasanko 3d8845cf99 Brighter low free space colors. 2026-02-04 20:40:59 +00:00
LeoVasanko 87e1443e7d Auth restricted endpoint is picky about the trailing slash, use it consistently. 2026-02-04 20:35:29 +00:00
LeoVasanko f45c57e901 Auth mode indication on startupbox 2026-02-04 20:24:21 +00:00
LeoVasanko 41686d1dd1 Correctly handle customized server name in admin settings dialog. 2026-02-04 20:15:56 +00:00
LeoVasanko cc351bb992 Space usage widget show full name if possible, allow longer names. 2026-02-04 20:12:49 +00:00
LeoVasanko c3abbe0a3b Rename HeaderSelected to SelectionToolbar. 2026-02-04 20:01:05 +00:00
LeoVasanko 127caeedea Improved selection toolbar UX. 2026-02-04 19:57:31 +00:00
LeoVasanko 113bc56351 README 2026-02-04 19:23:48 +00:00
LeoVasanko c7727c72d9 Make devserver script take --listen argument for vite, while forwarding other args correctly to backend 2026-02-04 19:23:34 +00:00
LeoVasanko 85b3aa6b81 Consistent header and footer sizing. 2026-02-04 19:19:41 +00:00
LeoVasanko 62b44ddb43 Fix free/used disk space handling. Implement file disk usage tracking. Display indicators on FileExplorer for incomplete files (sparse allocation, upload in progress). 2026-02-04 18:54:54 +00:00
LeoVasanko 60a53ef3d3 Implement server name config to override the default of using share folder name. 2026-02-04 18:00:21 +00:00
29 changed files with 932 additions and 350 deletions
+26 -12
View File
@@ -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.
+15 -4
View File
@@ -25,14 +25,22 @@ def create_banner():
""" """
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None): def create_startup_box(
*, folder, url, unix=None, dev=False, paskia_url=None, public=False
):
"""Create a framed startup box with server information.""" """Create a framed startup box with server information."""
title = f"Cista {cista.__version__}" title = f"Cista {cista.__version__}"
listen = unix if unix else url listen = unix if unix else url
location = f"{folder} @ {listen}" location = f"{folder} @ {listen}"
lines = [title, location] lines = [title, location]
# Auth line: Paskia <url> or Password, with optional Public suffix
if paskia_url: if paskia_url:
lines.append(f"Paskia: {paskia_url}") auth_line = f"Auth: Paskia {paskia_url}"
else:
auth_line = "Auth: Password"
if public:
auth_line += ", Public"
lines.append(auth_line)
if dev: if dev:
lines.append("dev mode") lines.append("dev mode")
@@ -53,10 +61,12 @@ doc = """\
Usage: Usage:
cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>] cista [-c <confdir>] [-l <host>] [--import-droppy] [--dev] [<path>]
cista [-c <confdir>] --user <name> [--privileged] [--password] cista [-c <confdir>] --user <name> [--privileged] [--password]
cista --version
Options: Options:
-c CONFDIR Custom config directory -c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on -l, --listen LISTEN-ADDR
Listen on
:8989 (localhost port, plain http) :8989 (localhost port, plain http)
<addr>:3000 (bind another address, port) <addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket) /path/to/unix.sock (unix socket)
@@ -109,7 +119,7 @@ def _main():
args = docopt(doc) args = docopt(doc)
if args["--user"]: if args["--user"]:
return _user(args) return _user(args)
listen = args["-l"] listen = args["--listen"]
# Validate arguments first # Validate arguments first
if args["<path>"]: if args["<path>"]:
path = Path(args["<path>"]).resolve() path = Path(args["<path>"]).resolve()
@@ -157,6 +167,7 @@ def _main():
unix=opts.get("unix"), unix=opts.get("unix"),
dev=dev, dev=dev,
paskia_url=PASKIA_BACKEND_URL or None, paskia_url=PASKIA_BACKEND_URL or None,
public=config.config.public,
) )
sys.stderr.write(startup_box) sys.stderr.write(startup_box)
# Run the server # Run the server
+28
View File
@@ -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
View File
@@ -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)
+70 -12
View File
@@ -2,7 +2,10 @@ import asyncio
import gc import gc
import io import io
import mimetypes import mimetypes
import threading
import urllib.parse import urllib.parse
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import PurePosixPath from pathlib import PurePosixPath
from time import perf_counter from time import perf_counter
from urllib.parse import unquote from urllib.parse import unquote
@@ -25,6 +28,49 @@ pillow_heif.register_heif_opener()
bp = Blueprint("preview", url_prefix="/preview") bp = Blueprint("preview", url_prefix="/preview")
@dataclass(slots=True)
class CachedPreview:
"""Cached preview with headers and body."""
headers: dict[str, str]
body: bytes
class PreviewCache:
"""Thread-safe LRU cache for preview responses."""
def __init__(self, capacity: int = 500):
self.capacity = capacity
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
self._lock = threading.Lock()
def get(self, key: str) -> CachedPreview | None:
"""Get cached preview, moving it to end (most recently used)."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
return self._cache[key]
return None
def set(self, key: str, value: CachedPreview) -> None:
"""Cache preview, evicting oldest if at capacity."""
with self._lock:
if key in self._cache:
self._cache.move_to_end(key)
else:
if len(self._cache) >= self.capacity:
self._cache.popitem(last=False)
self._cache[key] = value
def __len__(self) -> int:
with self._lock:
return len(self._cache)
# Global preview cache instance
_preview_cache = PreviewCache(capacity=500)
@bp.on_request @bp.on_request
async def verify_preview(request): async def verify_preview(request):
"""Verify access to preview routes.""" """Verify access to preview routes."""
@@ -55,6 +101,29 @@ async def preview(req, path):
etag = config.derived_secret( etag = config.derived_secret(
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom "preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
).hex() ).hex()
if req.headers.if_none_match == etag:
# The client has it cached, respond 304 Not Modified
return empty(304, headers={"etag": etag})
# Check in-memory cache first (includes headers)
cached = _preview_cache.get(etag)
if cached is not None:
logger.debug(f"Preview cache hit: {rel}")
return raw(cached.body, headers=cached.headers)
if not filepath.is_file():
raise NotFound("File not found")
# Generate preview
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
)
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
# Build headers and cache the full response
savename = PurePosixPath(filepath.name).with_suffix(".avif") savename = PurePosixPath(filepath.name).with_suffix(".avif")
headers = { headers = {
"etag": etag, "etag": etag,
@@ -64,19 +133,8 @@ async def preview(req, path):
"content-type": "image/avif", "content-type": "image/avif",
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}", "content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
} }
if req.headers.if_none_match == etag: _preview_cache.set(etag, CachedPreview(headers=headers, body=img))
# The client has it cached, respond 304 Not Modified
return empty(304, headers=headers)
if not filepath.is_file():
raise NotFound("File not found")
img = await asyncio.get_event_loop().run_in_executor(
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
)
if not img:
# Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303)
return raw(img, headers=headers) return raw(img, headers=headers)
+3 -1
View File
@@ -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
+1 -6
View File
@@ -62,11 +62,6 @@ def parse_listen(listen):
return "http://localhost", {"unix": unix.as_posix()} return "http://localhost", {"unix": unix.as_posix()}
host, port = ep["host"], ep["port"] host, port = ep["host"], ep["port"]
# When binding all interfaces, use single_listener=False for Sanic
if len(endpoints) > 1: if len(endpoints) > 1:
return f"http://localhost:{port}", { return f"http://localhost:{port}", {"host": host, "port": port}
"host": host,
"port": port,
"single_listener": False,
}
return f"http://{host}:{port}", {"host": host, "port": port} return f"http://{host}:{port}", {"host": host, "port": port}
+12 -2
View File
@@ -48,6 +48,8 @@ async def get_client() -> httpx.AsyncClient:
global _client global _client
if _client is None or _client.is_closed: if _client is None or _client.is_closed:
_client = httpx.AsyncClient(timeout=1.0) _client = httpx.AsyncClient(timeout=1.0)
if "user-agent" in _client.headers:
del _client.headers["user-agent"] # No httpx UA
return _client return _client
@@ -171,10 +173,10 @@ async def proxy_auth_request(request):
"upgrade", "upgrade",
"proxy-authorization", "proxy-authorization",
"proxy-authenticate", "proxy-authenticate",
"forwarded",
"x-forwarded-for", "x-forwarded-for",
"x-forwarded-host", "x-forwarded-host",
"x-forwarded-proto", "x-forwarded-proto",
"forwarded",
} }
headers = [ headers = [
@@ -182,9 +184,17 @@ async def proxy_auth_request(request):
for key, value in request.headers.items() for key, value in request.headers.items()
if key.lower() not in skip_headers if key.lower() not in skip_headers
] ]
headers.append(("x-forwarded-for", request.client_ip))
# Set Forwarded headers (strip IPv6 brackets for x-forwarded-for)
headers.append(("x-forwarded-for", request.client_ip.strip("[]")))
headers.append(("x-forwarded-host", request.host)) headers.append(("x-forwarded-host", request.host))
headers.append(("x-forwarded-proto", request.scheme)) headers.append(("x-forwarded-proto", request.scheme))
headers.append(
(
"forwarded",
f"by=cista;for={request.client_ip};host={request.host};proto={request.scheme}",
)
)
try: try:
async with client.stream( async with client.stream(
+72 -3
View File
@@ -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."""
+64 -14
View File
@@ -16,7 +16,7 @@
<RouterView :path="path.pathList" :query="path.query" /> <RouterView :path="path.pathList" :query="path.query" />
</main> </main>
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total"> <footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<HeaderSelected :path="path.pathList" /> <SelectionToolbar :path="path.pathList" />
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload /> <TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download /> <TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer> </footer>
@@ -36,6 +36,7 @@ import type { SortOrder } from './utils/docsort'
import type SettingsModalVue from './components/SettingsModal.vue' import type SettingsModalVue from './components/SettingsModal.vue'
import UserManagementModal from './components/UserManagementModal.vue' import UserManagementModal from './components/UserManagementModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue' import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue'
interface Path { interface Path {
path: string path: string
@@ -62,6 +63,7 @@ onUnmounted(watchDisconnect)
const headerMain = ref<typeof HeaderMain | null>(null) const headerMain = ref<typeof HeaderMain | null>(null)
let vert = 0 let vert = 0
let timer: any = null let timer: any = null
const globalShortcutHandler = (event: KeyboardEvent) => { const globalShortcutHandler = (event: KeyboardEvent) => {
if (store.dialog) { if (store.dialog) {
if (timer) { if (timer) {
@@ -75,6 +77,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT' const input = (event.target as HTMLElement).tagName === 'INPUT'
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input
if (keyup && event.key.startsWith('Arrow') && timer) {
clearTimeout(timer)
timer = null
}
if (event.repeat) { if (event.repeat) {
if ( if (
event.key === 'ArrowUp' || event.key === 'ArrowUp' ||
@@ -90,13 +99,32 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
//console.log("key pressed", event) //console.log("key pressed", event)
/// Long if-else machina for all keys we handle here /// Long if-else machina for all keys we handle here
let arrow = '' let arrow = ''
if (!input && event.key.startsWith("Arrow")) arrow = event.key.slice(5).toLowerCase() const inHeader = !!(event.target as HTMLElement).closest('.headermain')
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
// Handle arrows: in search input with text, only up/down; otherwise all arrows
const searchInput = inHeader && input
const searchHasText = searchInput && (event.target as HTMLInputElement).value
if (event.key.startsWith("Arrow")) {
const dir = event.key.slice(5).toLowerCase()
// In search with text: left/right move cursor, up/down navigate
if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement
}
arrow = dir
}
if (arrow) {
// Arrow key handling - fall through to bottom
}
// Find: process on keydown so that we can bypass the built-in search hotkey // Find: process on keydown so that we can bypass the built-in search hotkey
else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) { else if (!keyup && event.key === 'f' && (event.ctrlKey || event.metaKey)) {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Search also on / (UNIX style) // Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.key === '/') { else if (!input && keyup && event.code === 'Slash') {
// Record the actual character for display (varies by keyboard layout)
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
store.prefs.searchHotkey = event.key
}
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Globally close search, clear errors on Escape // Globally close search, clear errors on Escape
@@ -142,13 +170,34 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
timer = null timer = null
} }
let f: any let f: any
switch (arrow) { // Arrow navigation - always use fileExplorer for repeatable movement
case 'up': f = () => fileExplorer.up(event); break if (arrow && !keyup) {
case 'down': f = () => fileExplorer.down(event); break const focusSearch = () => (document.querySelector('.headermain input[type="search"]') as HTMLElement)?.focus()
case 'left': f = () => fileExplorer.left(event); break const focusBreadcrumb = () => (document.querySelector('.breadcrumb') as HTMLElement)?.focus()
case 'right': f = () => fileExplorer.right(event); break
if (inBreadcrumb) {
// Breadcrumb: up→header (no repeat), down→files (with repeat)
if (arrow === 'up') { focusSearch(); f = null }
else if (arrow === 'down') { fileExplorer.focusFirst?.(); f = null }
} else if (inHeader) {
// Header: left/right navigate focusable items (buttons without tabindex=-1, search input, disk space)
const items = Array.from(document.querySelectorAll('.headermain button:not([tabindex=\"-1\"]), .headermain input[type=\"search\"], .headermain [tabindex=\"0\"]')) as HTMLElement[]
const idx = items.indexOf(document.activeElement as HTMLElement)
if (arrow === 'left' && idx > 0) { items[idx - 1]?.focus(); f = null }
else if (arrow === 'right' && idx < items.length - 1) { items[idx + 1]?.focus(); f = null }
else if (arrow === 'up') f = () => fileExplorer.up({ shiftKey: false })
else if (arrow === 'down') { focusBreadcrumb(); f = null }
} else {
// File explorer: normal navigation with repeat
switch (arrow) {
case 'up': f = () => fileExplorer.up(event); break
case 'down': f = () => fileExplorer.down(event); break
case 'left': f = () => fileExplorer.left(event); break
case 'right': f = () => fileExplorer.right(event); break
}
}
} }
if (f && !keyup) { if (f) {
// Initial move, then t0 delay until repeats at tr intervals // Initial move, then t0 delay until repeats at tr intervals
const t0 = 200, tr = event.altKey ? 20 : 100 const t0 = 200, tr = event.altKey ? 20 : 100
f() f()
@@ -156,12 +205,13 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
} }
} }
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', globalShortcutHandler) // Use capture phase to handle events before they reach target elements
window.addEventListener('keyup', globalShortcutHandler) window.addEventListener('keydown', globalShortcutHandler, true)
window.addEventListener('keyup', globalShortcutHandler, true)
}) })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', globalShortcutHandler) window.removeEventListener('keydown', globalShortcutHandler, true)
window.removeEventListener('keyup', globalShortcutHandler) window.removeEventListener('keyup', globalShortcutHandler, true)
}) })
export type { Path } export type { Path }
</script> </script>
+45 -15
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="disk-space-container" ref="containerRef"> <div class="disk-space-container" ref="containerRef" tabindex="0" @keydown.enter="handleClick" @keydown.space.prevent="handleClick">
<div <div
ref="widgetRef" ref="widgetRef"
class="disk-space-widget" class="disk-space-widget"
@@ -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()
@@ -327,6 +352,11 @@ onUnmounted(() => {
position: relative; position: relative;
width: 3em; width: 3em;
height: 3em; height: 3em;
outline: none;
}
.disk-space-container:focus .disk-space-widget:not(.expanded) {
filter: brightness(1);
} }
.disk-space-widget { .disk-space-widget {
+29 -4
View File
@@ -72,7 +72,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@@ -124,6 +124,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0,
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -134,6 +135,17 @@ defineExpose({
isCursor() { isCursor() {
return store.cursor && editing.value === null return store.cursor && editing.value === null
}, },
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor} .name a`) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() { cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
@@ -149,7 +161,12 @@ defineExpose({
}, },
up(ev: KeyboardEvent) { this.cursorMove(-1, ev) }, up(ev: KeyboardEvent) { this.cursorMove(-1, ev) },
down(ev: KeyboardEvent) { this.cursorMove(1, ev) }, down(ev: KeyboardEvent) { this.cursorMove(1, ev) },
left(ev: KeyboardEvent) { router.back() }, left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) {
router.back()
}
},
right(ev: KeyboardEvent) { right(ev: KeyboardEvent) {
const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null const a = document.querySelector(`#file-${store.cursor} a`) as HTMLAnchorElement | null
if (a) a.click() if (a) a.click()
@@ -189,9 +206,17 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
if (moveto === N) focusBreadcrumb() // When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
@@ -209,7 +234,7 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
+27 -5
View File
@@ -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 }
+23 -3
View File
@@ -9,7 +9,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted } from 'vue' import { ref, computed, watchEffect, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { connect, controlUrl } from '@/repositories/WS' import { connect, controlUrl } from '@/repositories/WS'
@@ -67,6 +67,7 @@ defineExpose({
dir: true, dir: true,
mtime: now, mtime: now,
size: 0, size: 0,
allocated: 0,
}) })
store.cursor = editing.value.key store.cursor = editing.value.key
}, },
@@ -81,6 +82,17 @@ defineExpose({
isCursor() { isCursor() {
return store.cursor && editing.value === null return store.cursor && editing.value === null
}, },
focusFirst() {
const docs = props.documents
if (docs.length > 0) {
store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
nextTick(() => {
const a = document.querySelector(`#file-${store.cursor}`) as HTMLAnchorElement | null
if (a) a.focus()
})
}
},
cursorRename() { cursorRename() {
editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null editing.value = props.documents.find(doc => doc.key === store.cursor) ?? null
}, },
@@ -143,9 +155,17 @@ defineExpose({
scrolltimer = null scrolltimer = null
}, 300) }, 300)
} }
if (moveto === N) focusBreadcrumb() // When leaving the file list: up goes to breadcrumbs, down goes to header
if (moveto === N) {
if (d < 0) focusBreadcrumb()
else focusHeader()
}
} }
}) })
const focusHeader = () => {
const el = document.querySelector('.headermain input[type="search"]') as HTMLElement | null
if (el) el.focus()
}
const focusBreadcrumb = () => { const focusBreadcrumb = () => {
const el = document.querySelector('.breadcrumb') as HTMLElement | null const el = document.querySelector('.breadcrumb') as HTMLElement | null
if (el) el.focus() if (el) el.focus()
@@ -161,7 +181,7 @@ watchEffect(() => {
} }
}) })
watchEffect(() => { watchEffect(() => {
if (!props.documents.length && store.cursor) { if (!props.documents.length && store.cursor && !store.query) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
+16 -1
View File
@@ -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;
+7 -3
View File
@@ -9,7 +9,7 @@
<div class="smallgap"></div> <div class="smallgap"></div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" /> <SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<div class="search-group"> <div class="search-group">
<SvgButton name="find" @click="focusSearch" tooltip="Search" /> <SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
<input <input
ref="search" ref="search"
type="search" type="search"
@@ -17,7 +17,7 @@
@input="updateSearch" @input="updateSearch"
@keydown.escape="clearSearch" @keydown.escape="clearSearch"
/> />
<span v-if="!query" class="search-hint" @click="focusSearch">/</span> <span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
</div> </div>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" /> <DiskSpace v-if="store.space.disk" />
@@ -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;
@@ -158,6 +159,9 @@ defineExpose({
.search-group:focus-within { .search-group:focus-within {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
} }
.search-group:focus-within {
box-shadow: 0 0 0 2px var(--accent-color, #f80);
}
.search-group:hover :deep(button.action-button), .search-group:hover :deep(button.action-button),
.search-group:focus-within :deep(button.action-button) { .search-group:focus-within :deep(button.action-button) {
transform: scale(1.1); transform: scale(1.1);
@@ -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>
+2
View File
@@ -1,6 +1,7 @@
<template> <template>
<button <button
class="action-button" class="action-button"
:tabindex="tabindex"
@mouseenter="tooltip?.startHover" @mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition" @mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover" @mouseleave="tooltip?.endHover"
@@ -19,6 +20,7 @@ import CursorTooltip from './CursorTooltip.vue'
const props = defineProps<{ const props = defineProps<{
name: IconName name: IconName
tooltip?: string tooltip?: string
tabindex?: string | number
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
+2 -2
View File
@@ -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,15 +222,48 @@ const updateServerSettings = async () => {
} }
} }
const updateServerNameSetting = async () => {
try {
const result = await updateServerName(serverSettings.name)
// Update store with the effective name returned by the server
store.server.name = result.name
} catch (e) {
const httpError = e as ISimpleError
store.showToast(httpError.message || 'Failed to update server name')
}
}
const debouncedUpdateServerName = () => {
if (nameDebounceTimer) clearTimeout(nameDebounceTimer)
nameDebounceTimer = setTimeout(updateServerNameSetting, 400)
}
// Load server config from admin API
const loadServerConfig = async () => {
try {
const config = await getServerConfig()
serverSettings.name = config.name
serverSettings.public = config.public
} catch (e) {
// Fallback to store values if API fails
serverSettings.public = store.server.public || false
serverSettings.name = ''
}
}
onMounted(() => { onMounted(() => {
serverSettings.public = store.server.public || false serverSettings.public = store.server.public || false
serverSettings.name = ''
loading.value = false loading.value = false
}) })
// Load users when dialog opens (only in built-in auth mode) // Load users and config when dialog opens
watch(() => store.dialog, (newVal) => { watch(() => store.dialog, (newVal) => {
if (newVal === 'usermgmt' && !store.server.paskia) { if (newVal === 'usermgmt') {
loadUsers() loadServerConfig()
if (!store.server.paskia) {
loadUsers()
}
} }
}) })
@@ -225,4 +274,13 @@ watch(() => store.server.public, (newVal) => {
<style scoped> <style scoped>
/* Component-specific styles - most styling comes from ModalDialog.vue global styles */ /* Component-specific styles - most styling comes from ModalDialog.vue global styles */
.input-with-hint {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.input-with-hint small {
color: #666;
font-size: 0.75rem;
}
</style> </style>
+15 -3
View File
@@ -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,9 +89,10 @@ export type FileEntry = [
number, // level number, // level
string, // name string, // name
FUID, FUID,
number, //mtime number, // mtime
number, // size number, // size
number, // isfile number, // allocated (actual disk usage)
number, // isfile
] ]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+10
View File
@@ -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 }
}
+6 -2
View File
@@ -87,6 +87,7 @@ export const useMainStore = defineStore('main', {
gallery: false, gallery: false,
sortListing: '' as SortOrder, sortListing: '' as SortOrder,
sortFiltered: '' as SortOrder, sortFiltered: '' as SortOrder,
searchHotkey: '/', // Character shown for search hotkey (Slash key)
}, },
user: { user: {
username: '' as string, username: '' as string,
@@ -96,8 +97,9 @@ export const useMainStore = defineStore('main', {
space: { space: {
disk: 0, disk: 0,
free: 0, free: 0,
usage: 0, used: 0,
storage: 0, storage: 0,
allocated: 0,
} }
}), }),
persist: { persist: {
@@ -118,13 +120,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,
})) }))
@@ -219,6 +222,7 @@ export const useMainStore = defineStore('main', {
name: doc.name, name: doc.name,
key: doc.key, key: doc.key,
size: doc.size, size: doc.size,
allocated: doc.allocated,
mtime: doc.mtime, mtime: doc.mtime,
dir: doc.dir, dir: doc.dir,
})) }))
+1
View File
@@ -6,6 +6,7 @@ interface DocData {
name: string name: string
key: string key: string
size: number size: number
allocated: number
mtime: number mtime: number
dir: boolean dir: boolean
} }
+15 -12
View File
@@ -2,11 +2,11 @@
"""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.
+1 -1
View File
@@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]:
Raises RuntimeError if no runtime is found. Raises RuntimeError if no runtime is found.
""" """
dev_args = { dev_args = {
"deno": ("run", "dev", "--"), "deno": ("run", "-A", "npm:vite"),
"npm": ("--silent", "run", "dev", "--"), "npm": ("--silent", "run", "dev", "--"),
"bun": ("run", "dev", "--"), "bun": ("run", "dev", "--"),
} }
+5 -3
View File
@@ -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