Compare commits

...
46 Commits
Author SHA1 Message Date
LeoVasanko 36764885ed Silence pyvips deprecation and INFO spam in preview worker
- AVIF saves: replace deprecated strip=True with keep="none" (libvips
  8.15+; production already runs a version that deprecates strip).
- Set the pyvips logger to WARNING in the worker: its INFO messages
  ("threadpool completed ...") are pure spam on every operation.
2026-08-11 05:11:44 +00:00
LeoVasanko 5a82560cf2 Format preview worker logs like the main process, tagged with worker pid
Worker stderr is now inherited by the parent, so its log lines land in the
server log — but they arrived with the default logging format and a noisy
"preview-worker config=..." line at every spawn.

- Extract the emoji level-prefix formatting from cista.sanic_logging into
  cista.util.logformat, which has no Sanic dependency (the worker must not
  import Sanic: import-time prints could corrupt the stdout protocol).
- Worker configures its stderr handler with the same emoji prefixes plus a
  worker[pid] tag, and the config-loaded info message is removed.
2026-08-11 05:05:07 +00:00
LeoVasanko 7b1c6f6772 Fix preview pool permanently wedging after worker stderr pipe fills
Production symptom: previews of all types (pdf/pyvips/onlyoffice) start
hitting the 10s timeout and never recover until server restart, while the
rest of the server stays healthy.

Root cause (reproduced on Python 3.12): workers were spawned with
stderr=PIPE that nothing drained after startup. Once the OS pipe buffer
filled from accumulated worker tracebacks and library warnings, asyncio
flow control stopped the parent reading it and the worker blocked forever
mid-request on a stderr write. The 10s timeout then fired, but
_replace_worker hung forever in proc.wait() even after kill() — the
flow-control-paused pipe transport never sees EOF — permanently wedging
one dispatcher per stuck worker. Once all dispatchers were stuck, every
preview request timed out. Restart cleared it.

Fixes:
- Spawn workers with inherited stderr (stderr=None) so worker diagnostics
  go straight to the server log and no undrained pipe can exist.
- Bound proc.wait() in worker kill() with a 5s grace timeout so a wedged
  transport can never hang a dispatcher; log the worker pid instead.
- Guard the dispatch loop with an outer exception handler so a dispatcher
  can never die silently and shrink pool capacity.
- Retry failed worker replacement spawns with 1s-30s backoff instead of
  silently shrinking the pool.
- Fix latent crash: except-tuple referenced msgspec.json.DecodeError,
  which does not exist in the installed msgspec; any protocol failure
  would itself raise AttributeError. Use msgspec.DecodeError.
- Worker: redirect Python-level sys.stdout to stderr in persistent mode
  and keep the raw buffer solely for the binary protocol, so a library
  print() can never corrupt the command channel again (cf. the pymupdf
  deprecation warning that crashed workers at startup).
- Worker: close the pymupdf document explicitly in process_pdf.
- Log worker pid on timeout/protocol/checksum failures, and log failed
  kills and replacement retries, for future production diagnostics.

Add tests/test_preview_pool.py with an end-to-end regression recreating
the wedged-worker setup (piped, undrained stderr) plus kill-grace,
respawn-retry and dispatcher-survival tests.
2026-08-11 04:57:20 +00:00
LeoVasanko c025e7af95 Fix deprecation warning from fitz being renamed to pymupdf: update import. 2026-08-11 02:23:48 +00:00
LeoVasanko 3bad311e35 Tell Paskia SSO not to renew session on WebSocket connections where we cannot pass back the refreshed cookie. 2026-08-11 02:08:01 +00:00
LeoVasanko fdc4fe0a3e Forward client user-agent to SSO backend on validation refreshes. Matches function of existing proxy_auth_request (copies all headers) and proxy_auth_websocket (copies user-agent when present). 2026-08-10 14:08:17 +00:00
LeoVasanko 5df2308bdb Silence CPY copyright rule; make format_access_log tail args keyword-only
Newer ruff flagged CPY001 across the codebase (copyright notices are not
wanted here, rule disabled) and PLR0917 on format_access_log. duration_ms
and extra are now keyword-only at the single call site.
2026-07-28 02:37:12 +00:00
LeoVasanko f4c44ce1aa Remove unused frontend test framework
vitest, @vue/test-utils, jsdom and @types/jsdom were installed but no
frontend tests exist or are planned. Removing them also drops the
deprecated glob@10 dependency chain (js-beautify). type-check now uses
tsconfig.app.json.
2026-07-28 02:32:26 +00:00
LeoVasanko 49232f11cc Fix rename flow: KeepAlive-cached view watchers cleared cursor on stale props
Deactivated FileExplorer/Gallery instances stay alive in KeepAlive with
frozen, potentially empty document props. Their empty-folder watcher
cleared store.cursor and yanked focus to the breadcrumb on every cursor
change, breaking rename via gallery pen and keyboard entry into the
file list, and hiding the explorer rename button.

- Guard cursor watchers in FileExplorer/Gallery with an isActive flag
  (set on activated, cleared on deactivated)
- Declare emits in GalleryFigure (rename/menu fell through to the root
  anchor as native listeners)
- Show the explorer rename button on row hover with a delayed fade-in
  instead of only on the keyboard-focused row
2026-07-28 02:16:23 +00:00
LeoVasanko 1258eff42d Fix preview worker pool leak: ffmpeg must not inherit worker stdin
The ffmpeg fallback in the preview worker inherited the worker's stdin
pipe (the framed request protocol). When a slow conversion was killed
at the 10s timeout, the orphaned ffmpeg grandchild kept that pipe open,
so the parent's proc.wait() blocked forever waiting for pipe EOF —
permanently sticking one dispatcher per event until the whole pool
starved and every preview request (pdf, image, office) returned 503.

- Run ffmpeg with stdin=DEVNULL (also stops it eating protocol bytes)
- Drop start_new_session (only needed for group kills, POSIX-only)
- Stop logging the master secret at worker startup
2026-07-28 01:14:15 +00:00
LeoVasanko 718d46e3f9 Fix search in subdirectories (problem saving search field in URL). 2026-06-17 04:01:21 +00:00
LeoVasanko 92d9c40a28 Center file rename input in gallery mode to be more consistent with normal titles. 2026-06-17 03:43:01 +00:00
LeoVasanko 4f646fb344 Fix layout when there is more space than needed to display file explorer (don't scale larger) or gallery (don't bottom align). 2026-06-17 03:30:20 +00:00
LeoVasanko d6304d0029 Frontend linter changes. 2026-06-16 22:13:27 +00:00
LeoVasanko 77e35cf0fc fix(frontend): new file and folder creation hang, empty folder UX
- Replace circular watchEffects in FileExplorer/Gallery with explicit watchers

  to stop recursive Vue updates when creating items in empty folders.

- Move EmptyFolder rendering inside FileExplorer/Gallery so empty/list swaps

  no longer trigger folder slide transitions.

- Keep EmptyFolder text size consistent across list and gallery views.
2026-06-16 22:09:53 +00:00
LeoVasanko bf8a049b92 Use canonical paths for editor and fix route transitions 2026-05-07 02:18:55 +00:00
LeoVasanko 6d7f44bd88 Add view caching and preserve folder/editor UI state 2026-05-07 02:12:03 +00:00
LeoVasanko e2097a1563 Fix empty state vertical centering in explorer 2026-05-07 01:53:16 +00:00
LeoVasanko b864936eaa Lint 2026-05-07 01:44:27 +00:00
LeoVasanko 72b3c0d8ce feat(frontend): add text editor flow and create-file UX
- replace textarea editor with CodeMirror and syntax highlighting\n- integrate editor mode header (save button, hide unused controls)\n- fix breadcrumb/editor navigation behavior and transitions\n- add Create File action with ghost-rename flow and auto-open in editor\n- refine create-file icon shape and plus cutout alignment
2026-05-06 02:17:17 +00:00
LeoVasanko 07daf372e8 Rudimentary text file editing support. 2026-05-06 00:20:19 +00:00
LeoVasanko e979d679b2 feat(frontend): add simultaneous slide transitions on directory navigation
- Detect navigation direction (forward/backward) via router beforeEach guard
- Store transition direction in Pinia for cross-component access
- Wrap ExplorerView content in a CSS grid transition wrapper so old and new
  views overlap in the same grid cell during animation
- Add slide-forward/slide-backward transition classes with translate3d
  for GPU-accelerated, simultaneous enter/leave without gaps
- Keep fixed-position search loader outside the transition
2026-05-05 16:07:07 +00:00
LeoVasanko eea66c0013 Rename selection clear button label 2026-05-05 03:00:10 +00:00
LeoVasanko 9220c457c0 Use link icon for the share link button. 2026-05-05 02:58:43 +00:00
LeoVasanko d5b77932ea Allow anonymous share links in public mode 2026-05-05 02:47:18 +00:00
LeoVasanko 9b9d3e1cc1 Adjust gallery checkbox size and spacing 2026-05-05 02:38:25 +00:00
LeoVasanko 536efc4ce1 Regression: inotify optional on unsupported platforms 2026-05-05 02:32:47 +00:00
LeoVasanko 1b2267587f Fix empty root shown as missing folder 2026-05-05 02:26:41 +00:00
LeoVasanko 2864e9f041 Hide tiny Other slice in disk space chart 2026-05-05 02:20:55 +00:00
LeoVasanko b5a94b5eee Frontend upload batch space check with 512 MiB margin
- Verify single queue: uploads append to upqueue and are processed
  sequentially by one worker (parallel only within a single file).
- Before accepting a new batch, calculate total size minus existing
  files that will be overwritten.
- Reject the whole batch if free space < net need + 512 MiB margin.
- Show a toast with human-readable needed vs available space.
2026-05-05 02:13:18 +00:00
LeoVasanko d4be755d46 Enforce 128 MiB minimum free space on uploads
- Add cista/util/diskspace.py with MIN_FREE_BYTES limit and cached
  check_free_space() helper.
- Check available space in File.write() before ftruncate/write.
- Catch ENOSPC in File.write and re-raise as InsufficientStorageError.
- upload_file_chunk catches both proactive and ENOSPC errors and
  returns HTTP 507 Insufficient Storage.
- Add tests for low-disk rejection and ENOSPC handling.
2026-05-05 01:46:30 +00:00
LeoVasanko 07089aa9a7 frontend: add About dialog and unify global dialog backdrop 2026-05-03 23:40:54 +00:00
LeoVasanko c3146744b7 PageUp/PgDn/Home/End navigation; fix rename API path; immediate local rename update 2026-05-03 22:58:51 +00:00
LeoVasanko 48d7435d0b Allow portrait documents to render somewhat taller because gaps were being left between them depending on window width. 2026-05-03 18:36:14 +00:00
LeoVasanko 3e80325053 Smoother scrolling in gallery when using keyboard navigation. 2026-05-03 18:33:19 +00:00
LeoVasanko 31fc02ddbf Keep the file extension always visible but discreetly as part of the filename. Fix Escape in rename field. 2026-05-03 18:22:03 +00:00
LeoVasanko 2406ea87b0 Gallery mode file extension and filename display improvements. 2026-05-03 17:26:25 +00:00
LeoVasanko 3a1dd2b7da Show document icon when no preview is available or has failed. While loading, pulse the icon. 2026-05-02 19:54:38 +00:00
LeoVasanko 3df6b079c9 Better logging of ffmpeg errors 2026-05-02 19:49:06 +00:00
LeoVasanko af804e2c9f Less noisy preview timeout logging 2026-05-02 19:30:16 +00:00
LeoVasanko 1dc0c4441a Fixes on server startup and shutdown with Sanic internals. Parallel shutdown tasks as non-blocking async tasks to avoid blocking the event loop and causing a warning of that. 2026-05-02 19:22:23 +00:00
LeoVasanko c7ba0d5a04 Dynamically adjusting layout to maximize screen space used for document previews. Row width changes with aspect ratio of items on that row. Server side tracking of size as part of the main listing. 2026-05-02 18:45:44 +00:00
LeoVasanko 0071058b29 Avoid gallery layout collapse while previews are still loading. Maintain the boxes fixed size regardless. 2026-05-02 16:19:42 +00:00
LeoVasanko 338c74de69 Avoid noisy output on normal WebSocket closing. 2026-05-02 16:12:42 +00:00
LeoVasanko b13f08eab2 Avoid InvalidStateError on conversion tasks trying to set their result after preview cancelled. 2026-05-02 16:04:21 +00:00
LeoVasanko 5d566d6deb Restore CISTA_HOME internal environment passing of config dir removed in commit 31e0197, that was not being passed across processes. 2026-05-02 05:47:24 +00:00
55 changed files with 2884 additions and 615 deletions
+3 -1
View File
@@ -185,7 +185,9 @@ def _resolve_confdir(args):
def _confdir(args): def _confdir(args):
confdir = _resolve_confdir(args) confdir = _resolve_confdir(args)
config.init_confdir(confdir) if confdir is not None:
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir()
def _user(args): def _user(args):
+3 -1
View File
@@ -40,7 +40,9 @@ async def watch(req, ws):
if sso.paskia_enabled(): if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode) # SSO auth: call validation to get user info (don't enforce auth in public mode)
try: try:
await sso.validate_sso_request(req) # WebSocket cannot forward Set-Cookie, so ask the auth backend not to
# renew the session here; renewal happens on the HTTP side instead.
await sso.validate_sso_request(req, renew=False)
except Exception as e: except Exception as e:
logger.debug("watch SSO validation failed: %s", e) logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None): if sso_user := getattr(req.ctx, "sso_user", None):
+11 -7
View File
@@ -86,7 +86,7 @@ async def log_access(req, res):
path = f"{path}?{qs}" path = f"{path}?{qs}"
extra = getattr(req.ctx, "log_extra", None) extra = getattr(req.ctx, "log_extra", None)
line = format_access_log( line = format_access_log(
client, res.status, req.method, host, path, duration_ms, extra=extra client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
) )
access_logger.info(line) access_logger.info(line)
return res return res
@@ -148,12 +148,16 @@ async def main_after_start(app):
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop @app.before_server_stop
async def main_stop(app): async def main_stop(app):
watching.stop(app) async with asyncio.TaskGroup() as tg:
await onlyoffice.close_oo_client() tg.create_task(asyncio.to_thread(watching.stop, app))
await shutdown_preview_workers() tg.create_task(onlyoffice.close_oo_client())
app.ctx.threadexec.shutdown() tg.create_task(shutdown_preview_workers())
app.ctx.zipexec.shutdown(cancel_futures=True) tg.create_task(sso.close_client())
await sso.close_client()
async with asyncio.TaskGroup() as tg:
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
logger.debug("Cista worker threads all finished") logger.debug("Cista worker threads all finished")
+34 -6
View File
@@ -574,6 +574,13 @@ def _basic_auth_login(request):
if username == "token": if username == "token":
token = config.config.tokens.get(password) token = config.config.tokens.get(password)
if token: if token:
if _allow_anonymous_share_token(token):
request.ctx.session = None
request.ctx.username = None
request.ctx.user = None
request.ctx.auth_token_id = password
request.ctx.auth_token = token
return None
user = config.config.users.get(token.username) user = config.config.users.get(token.username)
if user: if user:
request.ctx.session = None request.ctx.session = None
@@ -873,14 +880,16 @@ async def verify(request, *, privileged=False):
""" """
hydrate_request_auth_context(request, source="auth.verify") hydrate_request_auth_context(request, source="auth.verify")
# Public mode: skip auth unless privileged access is required
if config.config.public and not privileged:
return
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
has_auth_header = bool(auth_header) has_auth_header = bool(auth_header)
scheme = auth_header.split()[0].lower() if has_auth_header else None scheme = auth_header.split()[0].lower() if has_auth_header else None
# Public mode: skip auth unless privileged access is required.
# Still parse explicit Authorization headers so share-token URLs can
# activate share scoping even while public access is enabled.
if config.config.public and not privileged and not has_auth_header:
return
# Concise auth flow for diagnostics (populated by use_session + verify) # Concise auth flow for diagnostics (populated by use_session + verify)
auth_flow = list(getattr(request.ctx, "auth_flow", ["session:skipped"])) auth_flow = list(getattr(request.ctx, "auth_flow", ["session:skipped"]))
tried: list[str] = [] tried: list[str] = []
@@ -941,6 +950,13 @@ async def verify(request, *, privileged=False):
quiet=True, quiet=True,
) )
return return
token = request_share_token(request)
if (
token is not None
and _allow_anonymous_share_token(token)
and not privileged
):
return
elif scheme in ("ntlm", "negotiate"): elif scheme in ("ntlm", "negotiate"):
tried.append("ntlm") tried.append("ntlm")
try: try:
@@ -1275,6 +1291,17 @@ def _token_belongs_to_user(token, username, sso_user_id):
return bool(sso_user_id is not None and token.sso_user_id == sso_user_id) return bool(sso_user_id is not None and token.sso_user_id == sso_user_id)
def _is_anonymous_share_token(token: config.Token) -> bool:
return (
sharefs.is_share_token(token) and not token.username and not token.sso_user_id
)
def _allow_anonymous_share_token(token: config.Token) -> bool:
# Anonymous share links are intentionally coupled to public mode.
return config.config.public and _is_anonymous_share_token(token)
def request_token(request) -> config.Token | None: def request_token(request) -> config.Token | None:
token = getattr(request.ctx, "auth_token", None) token = getattr(request.ctx, "auth_token", None)
return token if isinstance(token, config.Token) else None return token if isinstance(token, config.Token) else None
@@ -1439,10 +1466,11 @@ async def create_share_token_handler(request):
raise BadRequest("Could not determine SSO user") raise BadRequest("Could not determine SSO user")
else: else:
username = current_username or "" username = current_username or ""
if not username: if username:
raise BadRequest("Could not determine user")
if username not in config.config.users: if username not in config.config.users:
raise BadRequest("User does not exist") raise BadRequest("User does not exist")
elif not config.config.public:
raise BadRequest("Could not determine user")
token = secrets.token_urlsafe(12) token = secrets.token_urlsafe(12)
changes = { changes = {
+3 -3
View File
@@ -63,10 +63,10 @@ config: Config
conffile: Path conffile: Path
def init_confdir(confdir: Path | str | None = None) -> None: def init_confdir() -> None:
global conffile global conffile
if confdir is not None: if p := os.environ.get("CISTA_HOME"):
home = Path(confdir).expanduser() home = Path(p)
else: else:
xdg = os.environ.get("XDG_CONFIG_HOME") xdg = os.environ.get("XDG_CONFIG_HOME")
home = ( home = (
+13
View File
@@ -1,9 +1,11 @@
import errno
import os import os
import threading import threading
from pathlib import Path from pathlib import Path
from cista import config from cista import config
from cista.util import filename from cista.util import filename
from cista.util.diskspace import InsufficientStorageError, check_free_space
from cista.util.lrucache import LRUCache from cista.util.lrucache import LRUCache
@@ -34,13 +36,24 @@ class File:
self.open_rw() self.open_rw()
if self.fd is None: if self.fd is None:
raise RuntimeError("file descriptor is not available for write") raise RuntimeError("file descriptor is not available for write")
check_free_space(self.path)
if file_size is not None: if file_size is not None:
if pos + len(buffer) > file_size: if pos + len(buffer) > file_size:
raise ValueError("write exceeds declared file size") raise ValueError("write exceeds declared file size")
try:
os.ftruncate(self.fd, file_size) os.ftruncate(self.fd, file_size)
except OSError as e:
if e.errno == errno.ENOSPC:
raise InsufficientStorageError("No space left on device") from e
raise
if buffer: if buffer:
os.lseek(self.fd, pos, os.SEEK_SET) os.lseek(self.fd, pos, os.SEEK_SET)
try:
os.write(self.fd, buffer) os.write(self.fd, buffer)
except OSError as e:
if e.errno == errno.ENOSPC:
raise InsufficientStorageError("No space left on device") from e
raise
def __getitem__(self, slc): def __getitem__(self, slc):
if self.fd is None: if self.fd is None:
+12 -1
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import contextlib import contextlib
import errno
import mimetypes import mimetypes
import os import os
import re import re
@@ -12,11 +13,12 @@ from urllib.parse import unquote, urlparse
from wsgiref.handlers import format_date_time from wsgiref.handlers import format_date_time
from sanic import Blueprint, HTTPResponse, empty, json from sanic import Blueprint, HTTPResponse, empty, json
from sanic.exceptions import BadRequest, NotFound from sanic.exceptions import BadRequest, NotFound, SanicException
from cista import auth, config, sharefs, watching from cista import auth, config, sharefs, watching
from cista.api import fileserver from cista.api import fileserver
from cista.util import filename from cista.util import filename
from cista.util.diskspace import InsufficientStorageError
bp = Blueprint("fileserver", url_prefix="/files") bp = Blueprint("fileserver", url_prefix="/files")
@@ -52,6 +54,7 @@ async def upload_file_chunk(request, name):
rel, path = _safe_relpath(name, request=request) rel, path = _safe_relpath(name, request=request)
rel_name = rel.as_posix() rel_name = rel.as_posix()
try:
upload_info = await asyncio.to_thread( upload_info = await asyncio.to_thread(
fileserver.upload_info, fileserver.upload_info,
rel_name, rel_name,
@@ -59,6 +62,14 @@ async def upload_file_chunk(request, name):
body, body,
total, total,
) )
except InsufficientStorageError as e:
raise SanicException(str(e), status_code=507, quiet=True) from e
except OSError as e:
if e.errno == errno.ENOSPC:
raise SanicException(
"No space left on device", status_code=507, quiet=True
) from e
raise
extras = [] extras = []
chunk_len = end - start chunk_len = end - start
whole_file = start == 0 and end == total whole_file = start == 0 and end == total
+3 -1
View File
@@ -129,7 +129,9 @@ def log_reachable_info() -> None:
def setup_docker(confdir: Path | None = None) -> int: def setup_docker(confdir: Path | None = None) -> int:
"""Build and run the patched OnlyOffice Docker image.""" """Build and run the patched OnlyOffice Docker image."""
config.init_confdir(confdir) if confdir is not None:
os.environ["CISTA_HOME"] = confdir.as_posix()
config.init_confdir()
if config.conffile.exists(): if config.conffile.exists():
config.load_config() config.load_config()
else: else:
+123 -51
View File
@@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.log import logger from sanic.log import logger
from cista import auth, config, onlyoffice, sharefs from cista import auth, config, onlyoffice, sharefs, watching
from cista.fileio import fuid
from cista.preview_worker import ( from cista.preview_worker import (
DOC_PREVIEW_SUFFIXES, DOC_PREVIEW_SUFFIXES,
OFFICE_PREVIEW_SUFFIXES, OFFICE_PREVIEW_SUFFIXES,
@@ -76,6 +77,9 @@ _preview_cache = PreviewCache(capacity=500)
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
PREVIEW_WORKERS = max(2, min(8, cpu_count())) PREVIEW_WORKERS = max(2, min(8, cpu_count()))
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
WORKER_RESPAWN_DELAY = 1.0 # initial delay before retrying a failed worker spawn
WORKER_RESPAWN_DELAY_MAX = 30.0
_active_procs: set[asyncio.subprocess.Process] = set() _active_procs: set[asyncio.subprocess.Process] = set()
_preview_pool = None _preview_pool = None
_preview_pool_lock = asyncio.Lock() _preview_pool_lock = asyncio.Lock()
@@ -143,10 +147,24 @@ class _PreviewWorker:
return payload or None, resp return payload or None, resp
async def kill(self) -> None: async def kill(self) -> None:
try:
if self.proc.returncode is None: if self.proc.returncode is None:
# Safe to hard-kill: the worker is stateless per request.
# proc.wait() must not be awaited unaided: if a pipe
# transport is flow-control paused (e.g. an undrained stderr
# pipe), asyncio may never resolve wait() even after SIGKILL,
# which would permanently wedge the calling dispatcher.
with contextlib.suppress(ProcessLookupError): with contextlib.suppress(ProcessLookupError):
self.proc.kill() self.proc.kill()
await self.proc.wait() try:
await asyncio.wait_for(self.proc.wait(), timeout=WORKER_KILL_GRACE)
except TimeoutError:
logger.error(
"Preview worker pid=%s not reaped within %ds of kill",
self.proc.pid,
int(WORKER_KILL_GRACE),
)
finally:
_active_procs.discard(self.proc) _active_procs.discard(self.proc)
@@ -162,23 +180,19 @@ class _PreviewWorkerPool:
self._seq = 0 self._seq = 0
self._closed = False self._closed = False
async def _read_startup_stderr(self, proc: asyncio.subprocess.Process) -> str:
if proc.stderr is None:
return ""
with contextlib.suppress(TimeoutError):
data = await asyncio.wait_for(proc.stderr.read(), timeout=0.5)
return data.decode(errors="replace").strip()
return ""
async def _spawn_worker(self) -> _PreviewWorker: async def _spawn_worker(self) -> _PreviewWorker:
# stderr is inherited, not piped: a piped stderr that nobody drains
# eventually fills its OS buffer, blocking the worker mid-request,
# and its flow-control-paused transport makes proc.wait() hang even
# after kill() — together this used to permanently wedge the pool.
# Inheriting sends worker diagnostics straight to the server log.
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
sys.executable, sys.executable,
"-m", "-m",
"cista.preview_worker", "cista.preview_worker",
stdin=asyncio.subprocess.PIPE, stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=None,
start_new_session=True,
) )
_active_procs.add(proc) _active_procs.add(proc)
try: try:
@@ -188,21 +202,14 @@ class _PreviewWorkerPool:
proc.kill() proc.kill()
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
await proc.wait() await proc.wait()
stderr = await self._read_startup_stderr(proc)
if stderr:
raise WorkerProtocolError( raise WorkerProtocolError(
"preview worker failed to become ready: " + stderr.splitlines()[-1] "preview worker failed to become ready"
" (worker stderr goes to the server log)"
) from err ) from err
raise WorkerProtocolError("preview worker failed to become ready") from err
except asyncio.IncompleteReadError as err: except asyncio.IncompleteReadError as err:
stderr = await self._read_startup_stderr(proc)
if stderr:
raise WorkerProtocolError(
"preview worker exited before signalling readiness: "
+ stderr.splitlines()[-1]
) from err
raise WorkerProtocolError( raise WorkerProtocolError(
"preview worker exited before signalling readiness" "preview worker exited before signalling readiness"
" (worker stderr goes to the server log)"
) from err ) from err
if ready != b"\x01": if ready != b"\x01":
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}") raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
@@ -215,28 +222,48 @@ class _PreviewWorkerPool:
async def _replace_worker(self, worker: _PreviewWorker) -> None: async def _replace_worker(self, worker: _PreviewWorker) -> None:
self._workers.discard(worker) self._workers.discard(worker)
try:
await worker.kill() await worker.kill()
if self._closed: except Exception:
return logger.exception("Failed to kill preview worker pid=%s", worker.proc.pid)
# Keep retrying until a replacement is up: a pool that silently
# shrinks degrades all preview traffic to timeouts.
delay = WORKER_RESPAWN_DELAY
while not self._closed:
try: try:
await self._add_worker() await self._add_worker()
except Exception: except Exception:
logger.exception("Failed to replace preview worker") logger.exception(
"Failed to replace preview worker (pool %d/%d); retrying in %ds",
async def _dispatch_loop(self) -> None: len(self._workers),
while True: self.size,
try: int(delay),
_priority, _seq, future, args = await self._pending.get() )
except asyncio.CancelledError: await asyncio.sleep(delay)
delay = min(delay * 2, WORKER_RESPAWN_DELAY_MAX)
else:
return return
async def _dispatch_loop(self) -> None:
# Nothing may escape the loop body: a dispatcher that dies silently
# permanently shrinks pool capacity and degrades all preview
# traffic to timeouts.
while True:
try:
await self._dispatch_one()
except asyncio.CancelledError:
return
except Exception:
logger.exception("Preview dispatcher error; continuing")
async def _dispatch_one(self) -> None:
_priority, _seq, future, args = await self._pending.get()
if future.cancelled(): if future.cancelled():
continue return
try: try:
worker = await asyncio.wait_for( worker = await asyncio.wait_for(self._idle.get(), timeout=PREVIEW_TIMEOUT)
self._idle.get(), timeout=PREVIEW_TIMEOUT
)
except TimeoutError: except TimeoutError:
logger.warning( logger.warning(
"Preview worker unavailable (%ds) for %s", "Preview worker unavailable (%ds) for %s",
@@ -244,8 +271,13 @@ class _PreviewWorkerPool:
args[0].name, args[0].name,
) )
if not future.done(): if not future.done():
future.set_exception(PreviewTimeoutError(args[0].name)) future.set_exception(
continue PreviewTimeoutError(
args[0].name,
backend=_expected_preview_backend(args[0]),
)
)
return
filepath = args[0] filepath = args[0]
replace = False replace = False
@@ -259,13 +291,25 @@ class _PreviewWorkerPool:
except TimeoutError: except TimeoutError:
replace = True replace = True
logger.warning( logger.warning(
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name "Preview worker pid=%s timed out (%ds) on %s; replacing it",
worker.proc.pid,
int(PREVIEW_TIMEOUT),
filepath.name,
) )
if not future.done(): if not future.done():
future.set_exception(PreviewTimeoutError(filepath.name)) future.set_exception(
PreviewTimeoutError(
filepath.name,
backend=_expected_preview_backend(filepath),
)
)
except WorkerChecksumError: except WorkerChecksumError:
replace = True replace = True
logger.error("Preview checksum mismatch for %s", filepath.name) logger.error(
"Preview checksum mismatch for %s (worker pid=%s); replacing it",
filepath.name,
worker.proc.pid,
)
if not future.done(): if not future.done():
future.set_exception( future.set_exception(
PreviewError(f"worker checksum mismatch for {filepath.name}") PreviewError(f"worker checksum mismatch for {filepath.name}")
@@ -280,23 +324,22 @@ class _PreviewWorkerPool:
ConnectionResetError, ConnectionResetError,
OSError, OSError,
ValueError, ValueError,
msgspec.json.DecodeError, msgspec.DecodeError,
) as e: ) as e:
replace = True replace = True
logger.warning( logger.warning(
"Preview worker protocol failure for %s: %s", filepath.name, e "Preview worker pid=%s protocol failure for %s: %s",
worker.proc.pid,
filepath.name,
e,
) )
if not future.done(): if not future.done():
future.set_exception( future.set_exception(
PreviewError( PreviewError(f"worker protocol failure for {filepath.name}: {e}")
f"worker protocol failure for {filepath.name}: {e}"
)
) )
except Exception: except Exception:
replace = True replace = True
logger.exception( logger.exception("Unexpected preview worker error for %s", filepath.name)
"Unexpected preview worker error for %s", filepath.name
)
if not future.done(): if not future.done():
future.set_exception( future.set_exception(
PreviewError(f"unexpected worker error for {filepath.name}") PreviewError(f"unexpected worker error for {filepath.name}")
@@ -410,6 +453,10 @@ async def verify_preview(request):
class PreviewTimeoutError(Exception): class PreviewTimeoutError(Exception):
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT.""" """Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
def __init__(self, message: str, *, backend: str | None = None):
super().__init__(message)
self.backend = backend
class PreviewError(Exception): class PreviewError(Exception):
"""Raised when the preview subprocess exits with a non-zero status.""" """Raised when the preview subprocess exits with a non-zero status."""
@@ -466,10 +513,12 @@ class OOConversionManager:
filepath, request_timeout=5.0 filepath, request_timeout=5.0
) )
except Exception as e: except Exception as e:
if not future.done():
future.set_exception(e) future.set_exception(e)
async with self._lock: async with self._lock:
self._in_flight.pop(key, None) self._in_flight.pop(key, None)
else: else:
if not future.done():
future.set_result(png_bytes) future.set_result(png_bytes)
async with self._lock: async with self._lock:
self._in_flight.pop(key, None) self._in_flight.pop(key, None)
@@ -548,6 +597,21 @@ def _preview_job_priority(path) -> int:
return 4 return 4
def _expected_preview_backend(path: Path) -> str:
"""Best-effort backend label used for timeout/access logging."""
suffix = path.suffix.lower()
if suffix in OFFICE_PREVIEW_SUFFIXES:
return "onlyoffice"
if suffix in DOC_PREVIEW_SUFFIXES:
return "pdf"
mime_type, _ = mimetypes.guess_type(path.name)
if mime_type and mime_type.startswith("video/"):
return "video"
if mime_type and mime_type.startswith("image/"):
return "pyvips"
return "preview"
def is_previewable_path(path) -> bool: def is_previewable_path(path) -> bool:
suffix = path.suffix.lower() suffix = path.suffix.lower()
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES: if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
@@ -609,10 +673,12 @@ async def preview(req, path):
timeout=PREVIEW_TIMEOUT, timeout=PREVIEW_TIMEOUT,
) )
except TimeoutError: except TimeoutError:
logger.warning("Preview timeout for %s", filepath) req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
return empty(503) return empty(503)
except PreviewTimeoutError: except PreviewTimeoutError as e:
logger.warning("Preview worker timeout for %s", filepath) req.ctx.log_extra = (
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
)
return empty(503) return empty(503)
except httpx.HTTPStatusError: except httpx.HTTPStatusError:
req.ctx.log_extra = "onlyoffice N/A" req.ctx.log_extra = "onlyoffice N/A"
@@ -654,6 +720,12 @@ async def preview(req, path):
# Preview generation failed, redirect to the file itself # Preview generation failed, redirect to the file itself
return redirect(f"/files/{path}", status=303) return redirect(f"/files/{path}", status=303)
# Store aspect ratio if the worker returned dimensions
if preview_resp and preview_resp.width and preview_resp.height:
ar = round(preview_resp.height / preview_resp.width, 2)
fuid_str = fuid(stat)
watching.notify_ar(fuid_str, ar)
# Build headers and cache the full response # Build headers and cache the full response
preview_mime = ( preview_mime = (
preview_resp.mime preview_resp.mime
+92 -19
View File
@@ -17,6 +17,8 @@ import gc
import io import io
import logging import logging
import mimetypes import mimetypes
import os
import shlex
import struct import struct
import subprocess import subprocess
import sys import sys
@@ -25,16 +27,26 @@ from pathlib import Path
from time import perf_counter from time import perf_counter
import av import av
import fitz # PyMuPDF
import msgspec import msgspec
import numpy as np import numpy as np
import pymupdf
import pyvips import pyvips
from blake3 import blake3 from blake3 import blake3
from cista import config from cista import config
from cista.util.logformat import format_level_prefix
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class _WorkerLogFormatter(logging.Formatter):
"""Emoji level prefix like the main process, tagged with the worker pid."""
def format(self, record: logging.LogRecord) -> str:
prefix = format_level_prefix(record.levelno)
return f"{prefix}worker[{os.getpid()}]: {record.getMessage()}"
AVIF_FAST_EFFORT = 0 AVIF_FAST_EFFORT = 0
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"} DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
@@ -95,6 +107,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
timings: list[float] | None = None timings: list[float] | None = None
error: str | None = None error: str | None = None
stderr: str | None = None stderr: str | None = None
width: int | None = None
height: int | None = None
_enc = msgspec.json.Encoder() _enc = msgspec.json.Encoder()
@@ -125,13 +139,20 @@ def _read_request() -> tuple[PreviewRequest, bytes] | None:
return req, data return req, data
# Raw stdout buffer reserved for the binary protocol once main() redirects
# Python-level stdout to stderr. None means "use sys.stdout.buffer as-is"
# (CLI single-shot mode, where real stdout is wanted).
_protocol_out = None
def _write_response(resp: PreviewResponse, payload: bytes) -> None: def _write_response(resp: PreviewResponse, payload: bytes) -> None:
out = _protocol_out if _protocol_out is not None else sys.stdout.buffer
meta_bytes = _enc.encode(resp) meta_bytes = _enc.encode(resp)
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
checksum = blake3(packet).digest() checksum = blake3(packet).digest()
sys.stdout.buffer.write(checksum) out.write(checksum)
sys.stdout.buffer.write(packet) out.write(packet)
sys.stdout.buffer.flush() out.flush()
def dispatch(path, quality, maxsize, maxzoom, data=None): def dispatch(path, quality, maxsize, maxzoom, data=None):
@@ -173,6 +194,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
""" """
try: try:
img = pyvips.Image.new_from_file(str(path)) img = pyvips.Image.new_from_file(str(path))
img = img.autorot()
except pyvips.error.Error: except pyvips.error.Error:
return None return None
else: else:
@@ -214,7 +236,29 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
cmd.insert(4, "-s") cmd.insert(4, "-s")
cmd.insert(5, f"{new_w}x{new_h}") cmd.insert(5, f"{new_w}x{new_h}")
try: try:
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603 try:
# stdin=DEVNULL is critical: ffmpeg must not inherit the worker's
# stdin, which carries the framed request protocol. An inherited
# stdin lets ffmpeg eat protocol bytes and, if the worker is
# killed mid-conversion, keeps the orphaned ffmpeg holding the
# pipe open so the parent's proc.wait() hangs forever.
subprocess.run( # noqa: S603
cmd,
capture_output=True,
check=True,
shell=False,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as e:
shell_cmd = shlex.join(cmd)
stderr = (e.stderr or b"").decode(errors="replace").strip()
if stderr:
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
) from e
raise RuntimeError(
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
) from e
with Path(tmp_path).open("rb") as f: with Path(tmp_path).open("rb") as f:
return f.read() return f.read()
finally: finally:
@@ -228,6 +272,8 @@ def process_image_pyvips(path, *, maxsize, quality):
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly; # HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
# skip pyvips entirely. # skip pyvips entirely.
if suffix in (".heic", ".heif"): if suffix in (".heic", ".heif"):
heic_dims = _get_image_dimensions(path)
width, height = heic_dims or (None, None)
ret = _image_via_ffmpeg(path, maxsize, quality) ret = _image_via_ffmpeg(path, maxsize, quality)
t_end = perf_counter() t_end = perf_counter()
return ret, PreviewResponse( return ret, PreviewResponse(
@@ -235,13 +281,17 @@ def process_image_pyvips(path, *, maxsize, quality):
mime="image/avif", mime="image/avif",
backend="ffmpeg", backend="ffmpeg",
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=width,
height=height,
) )
# Other image formats: pyvips first, ffmpeg fallback. # Other image formats: pyvips first, ffmpeg fallback.
load_opts = {"access": "sequential"} load_opts = {"access": "sequential"}
orig_w = orig_h = None
try: try:
img = pyvips.Image.new_from_file(str(path), **load_opts) img = pyvips.Image.new_from_file(str(path), **load_opts)
img = img.autorot() img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0) scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0: if scale < 1.0:
img = img.resize(scale) img = img.resize(scale)
@@ -249,10 +299,11 @@ def process_image_pyvips(path, *, maxsize, quality):
".avif", ".avif",
Q=quality, Q=quality,
effort=AVIF_FAST_EFFORT, effort=AVIF_FAST_EFFORT,
strip=True, keep="none",
) )
backend = "pyvips" backend = "pyvips"
except pyvips.error.Error: except pyvips.error.Error:
orig_w, orig_h = None, None
ret = _image_via_ffmpeg(path, maxsize, quality) ret = _image_via_ffmpeg(path, maxsize, quality)
backend = "ffmpeg" backend = "ffmpeg"
t_end = perf_counter() t_end = perf_counter()
@@ -262,6 +313,8 @@ def process_image_pyvips(path, *, maxsize, quality):
mime="image/avif", mime="image/avif",
backend=backend, backend=backend,
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
) )
@@ -270,6 +323,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
t_start = perf_counter() t_start = perf_counter()
img = pyvips.Image.new_from_buffer(data, "") img = pyvips.Image.new_from_buffer(data, "")
img = img.autorot() img = img.autorot()
orig_w, orig_h = img.width, img.height
scale = min(maxsize / img.width, maxsize / img.height, 1.0) scale = min(maxsize / img.width, maxsize / img.height, 1.0)
if scale < 1.0: if scale < 1.0:
img = img.resize(scale) img = img.resize(scale)
@@ -277,7 +331,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
".avif", ".avif",
Q=quality, Q=quality,
effort=AVIF_FAST_EFFORT, effort=AVIF_FAST_EFFORT,
strip=True, keep="none",
) )
t_end = perf_counter() t_end = perf_counter()
@@ -286,16 +340,18 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
mime="image/avif", mime="image/avif",
backend="pyvips", backend="pyvips",
timings=[round((t_end - t_start) * 1000, 1)], timings=[round((t_end - t_start) * 1000, 1)],
width=orig_w,
height=orig_h,
) )
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0): def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
t_load_start = perf_counter() t_load_start = perf_counter()
pdf = fitz.open(path) with pymupdf.open(path) as pdf:
page = pdf.load_page(page_number) page = pdf.load_page(page_number)
w, h = page.rect[2:4] w, h = page.rect[2:4]
zoom = min(maxsize / w, maxsize / h, maxzoom) zoom = min(maxsize / w, maxsize / h, maxzoom)
mat = fitz.Matrix(zoom, zoom) mat = pymupdf.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat) pix = page.get_pixmap(matrix=mat)
t_load_end = perf_counter() t_load_end = perf_counter()
@@ -303,7 +359,7 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
img = pyvips.Image.new_from_memory( img = pyvips.Image.new_from_memory(
pix.samples_mv, pix.width, pix.height, pix.n, "uchar" pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
) )
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True) ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none")
backend = "pdf+pyvips" backend = "pdf+pyvips"
t_save_end = perf_counter() t_save_end = perf_counter()
@@ -315,6 +371,8 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
round((t_load_end - t_load_start) * 1000, 1), round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1), round((t_save_end - t_save_start) * 1000, 1),
], ],
width=round(w),
height=round(h),
) )
@@ -347,6 +405,11 @@ def process_video(path, *, maxsize, quality):
raise RuntimeError("No frames found in video") raise RuntimeError("No frames found in video")
# Resize frame to thumbnail size # Resize frame to thumbnail size
# Capture display dimensions before resize (accounting for rotation)
disp_w = frame.width
disp_h = frame.height
if frame.rotation in (90, 270):
disp_w, disp_h = disp_h, disp_w
if frame.width > maxsize or frame.height > maxsize: if frame.width > maxsize or frame.height > maxsize:
scale_factor = min(maxsize / frame.width, maxsize / frame.height) scale_factor = min(maxsize / frame.width, maxsize / frame.height)
new_width = int(frame.width * scale_factor) new_width = int(frame.width * scale_factor)
@@ -442,6 +505,8 @@ def process_video(path, *, maxsize, quality):
round((t_load_end - t_load_start) * 1000, 1), round((t_load_end - t_load_start) * 1000, 1),
round((t_save_end - t_save_start) * 1000, 1), round((t_save_end - t_save_start) * 1000, 1),
], ],
width=disp_w,
height=disp_h,
) )
del imgdata, istream, ostream, icc, occ, frame del imgdata, istream, ostream, icc, occ, frame
gc.collect() gc.collect()
@@ -500,24 +565,32 @@ def _run_loop() -> None:
def main() -> None: def main() -> None:
# Configure all log output to stderr before any imports that may emit logs. # Configure all log output to stderr before any imports that may emit
logging.basicConfig(stream=sys.stderr, level=logging.INFO) # logs. stderr is inherited by the parent, so this lands in the server
# log, formatted like the main process and tagged with the worker pid.
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(_WorkerLogFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
logging.getLogger("pyvips").setLevel(logging.WARNING)
try: try:
config.load_config() config.load_config()
logger.warning(
"preview-worker config=%s master_secret=%s",
config.conffile,
config.config.secret,
)
except Exception: except Exception:
logger.exception("preview-worker failed to load config at startup") logger.exception("preview-worker failed to load config at startup")
if len(sys.argv) > 1: if len(sys.argv) > 1:
_run_once() _run_once()
return return
# The command channel is a binary protocol on fd 1. Anything printed to
# stdout by Python code (e.g. a library emitting a warning via print())
# would corrupt the protocol, so redirect Python-level stdout to stderr
# (the server log) and keep the raw buffer solely for protocol traffic.
global _protocol_out
_protocol_out = sys.stdout.buffer
sys.stdout = sys.stderr
# Eagerly import heavy modules before signalling readiness so the parent # Eagerly import heavy modules before signalling readiness so the parent
# does not hand us a request while we are still initialising. # does not hand us a request while we are still initialising.
sys.stdout.buffer.write(b"\x01") _protocol_out.write(b"\x01")
sys.stdout.buffer.flush() _protocol_out.flush()
_run_loop() _run_loop()
+2 -1
View File
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
## Directory listings ## Directory listings
class FileEntry(msgspec.Struct, array_like=True, frozen=True): class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
level: int level: int
name: str name: str
key: str key: str
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
size: int size: int
allocated: int allocated: int
isfile: int isfile: int
ar: float | None = None
def __str__(self): def __str__(self):
return self.key or "FileEntry()" return self.key or "FileEntry()"
+44 -32
View File
@@ -1,14 +1,51 @@
"""Custom access logging middleware for Sanic.""" """Custom access logging middleware for Sanic."""
import logging import logging
import os
import sys import sys
import unicodedata
from ipaddress import IPv6Address from ipaddress import IPv6Address
from sanic.log import LOGGING_CONFIG_DEFAULTS from sanic.log import LOGGING_CONFIG_DEFAULTS
from cista.util.logformat import EmojiFormatter as _EmojiFormatter
from cista.util.logformat import display_width as _display_width
logger = logging.getLogger("cista.access") logger = logging.getLogger("cista.access")
class ReentrantSafeStreamHandler(logging.StreamHandler):
"""Stream handler that degrades gracefully on signal-time reentrant writes.
Python's buffered text streams are not reentrant. If a signal handler logs
while another log write is in progress, StreamHandler.emit can raise:
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
Instead of letting logging emit a long "--- Logging error ---" traceback,
we fall back to a best-effort os.write to the same file descriptor.
"""
def emit(self, record: logging.LogRecord) -> None:
msg = ""
try:
msg = self.format(record)
stream = self.stream
stream.write(msg + self.terminator)
self.flush()
except RuntimeError as exc:
if "reentrant call inside" not in str(exc):
self.handleError(record)
return
stream = self.stream
fd = stream.fileno()
encoding = getattr(stream, "encoding", None) or "utf-8"
data = (msg + self.terminator).encode(encoding, errors="replace")
os.write(fd, data)
except RecursionError:
raise
except Exception:
self.handleError(record)
_RESET = "\033[0m" _RESET = "\033[0m"
_STATUS_INFO = "\033[32m" # 1xx (green) _STATUS_INFO = "\033[32m" # 1xx (green)
_STATUS_OK = "\033[1;92m" # 2xx (bright green) _STATUS_OK = "\033[1;92m" # 2xx (bright green)
@@ -97,14 +134,6 @@ def format_duration_ms(duration_ms: float) -> str:
return f"{hours}h{minutes}m" return f"{hours}h{minutes}m"
def _display_width(text: str) -> int:
return sum(
1 + (unicodedata.east_asian_width(c) in "FW")
for c in text
if unicodedata.category(c) != "Mn"
)
def _format_left(label: str) -> str: def _format_left(label: str) -> str:
return label[:19].ljust(19) return label[:19].ljust(19)
@@ -121,6 +150,7 @@ def format_access_log(
method: str, method: str,
host: str, host: str,
path: str, path: str,
*,
duration_ms: float, duration_ms: float,
extra: str | None = None, extra: str | None = None,
) -> str: ) -> str:
@@ -236,41 +266,23 @@ def log_ws_close(
def configure_access_logging() -> None: def configure_access_logging() -> None:
"""Configure the cista.access logger to output to stderr.""" """Configure the cista.access logger to output to stderr."""
handler = logging.StreamHandler(sys.stderr) handler = ReentrantSafeStreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s")) handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler) logger.addHandler(handler)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
_LEVEL_EMOJI = {
logging.DEBUG: "🔍",
logging.INFO: "", # noqa: RUF001
logging.WARNING: "⚠️",
logging.ERROR: "🛑",
logging.CRITICAL: "🛑",
}
def _format_level_prefix(levelno: int) -> str:
emoji = _LEVEL_EMOJI.get(levelno, "▪️")
prefix = f"{emoji} "
return prefix + (" " * max(0, 3 - _display_width(prefix)))
class _EmojiFormatter(logging.Formatter):
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
def format(self, record: logging.LogRecord) -> str:
return _format_level_prefix(record.levelno) + record.getMessage()
def configure_main_logging() -> None: def configure_main_logging() -> None:
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format. """Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
call Sanic makes during serve_single() / serve(). call Sanic makes during serve_single() / serve().
""" """
for handler_name in ("console", "error_console", "access_console"):
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
"cista.sanic_logging.ReentrantSafeStreamHandler"
)
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = { LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
"class": "cista.sanic_logging._EmojiFormatter", "class": "cista.sanic_logging._EmojiFormatter",
} }
+7 -1
View File
@@ -4,11 +4,17 @@ from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic from sanic import Sanic
from sanic.worker.loader import AppLoader
from cista import config, server80 from cista import config, server80
from cista.app import app from cista.app import app
def load_app() -> Sanic:
"""Return the app instance for spawned Sanic worker/reloader processes."""
return app
def run(*, dev=False): def run(*, dev=False):
"""Run Sanic main process that spawns worker processes to serve HTTP requests.""" """Run Sanic main process that spawns worker processes to serve HTTP requests."""
_url, opts = parse_listen(config.config.listen) _url, opts = parse_listen(config.config.listen)
@@ -29,7 +35,7 @@ def run(*, dev=False):
access_log=False, access_log=False,
) # type: ignore[call-arg] ) # type: ignore[call-arg]
if dev: if dev:
Sanic.serve() Sanic.serve(app_loader=AppLoader(factory=load_app))
else: else:
Sanic.serve_single() Sanic.serve_single()
+11 -1
View File
@@ -62,12 +62,18 @@ async def close_client():
_client = None _client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None: async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
"""Validate an SSO request against the auth backend. """Validate an SSO request against the auth backend.
Args: Args:
request: The Sanic request object request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin) perm: Permission to validate (default: cista:login, privileged also cista:admin)
renew: Whether to allow the auth backend to renew the session cookie.
Use ``False`` for WebSocket validation where Set-Cookie cannot be
forwarded to the client; this makes the request read-only and avoids
resetting the backend renewal timeout.
Returns: Returns:
User info dict if valid, None if validation fails with auth required response User info dict if valid, None if validation fails with auth required response
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
headers["cookie"] = request.headers["cookie"] headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers: if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"] headers["authorization"] = request.headers["authorization"]
if "user-agent" in request.headers:
headers["user-agent"] = request.headers["user-agent"]
headers["accept"] = "application/json" headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}" url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
if not renew:
url += "&renew=0"
try: try:
response = await client.post( response = await client.post(
+7
View File
@@ -2,6 +2,7 @@ import time
from functools import wraps from functools import wraps
import msgspec import msgspec
import websockets.exceptions
from sanic import errorpages from sanic import errorpages
from sanic.exceptions import SanicException from sanic.exceptions import SanicException
from sanic.log import logger from sanic.log import logger
@@ -67,6 +68,12 @@ def websocket_wrapper(handler):
try: try:
await auth.verify(request) await auth.verify(request)
await handler(request, ws, *args, **kwargs) await handler(request, ws, *args, **kwargs)
except (
websockets.exceptions.ConnectionClosedOK,
websockets.exceptions.ConnectionClosedError,
):
# Normal websocket closure - already logged in access log
pass
except Exception as e: except Exception as e:
context, code, message = {}, 500, str(e) or "Internal Server Error" context, code, message = {}, 500, str(e) or "Internal Server Error"
if isinstance(e, SanicException): if isinstance(e, SanicException):
+49
View File
@@ -0,0 +1,49 @@
import shutil
import threading
import time
from pathlib import Path
MIN_FREE_BYTES = 128 * 1024 * 1024
_CHECK_CACHE_TTL = 1.0
class InsufficientStorageError(Exception):
"""Raised when there is not enough disk space for an operation."""
_cache: dict[Path, tuple[float, int]] = {}
_lock = threading.Lock()
def check_free_space(path: Path) -> None:
"""Raise InsufficientStorageError if free space on the filesystem containing *path*
is below MIN_FREE_BYTES. Results are cached per directory for 1 second.
"""
check_path = path.parent if path.parent.exists() else path
check_path = check_path.resolve()
now = time.monotonic()
with _lock:
ts, free = _cache.get(check_path, (0, 0))
if now - ts < _CHECK_CACHE_TTL:
if free < MIN_FREE_BYTES:
raise InsufficientStorageError(
f"Insufficient storage: {free} bytes free, "
f"need at least {MIN_FREE_BYTES} bytes"
)
return
try:
free = shutil.disk_usage(check_path).free
except OSError as e:
raise InsufficientStorageError(f"Cannot check disk usage: {e}") from e
with _lock:
_cache[check_path] = (now, free)
if free < MIN_FREE_BYTES:
raise InsufficientStorageError(
f"Insufficient storage: {free} bytes free, "
f"need at least {MIN_FREE_BYTES} bytes"
)
+37
View File
@@ -0,0 +1,37 @@
"""Shared log formatting helpers with no Sanic dependency.
Used by the main process (cista.sanic_logging) and by the preview worker
subprocess, which must not import Sanic.
"""
import logging
import unicodedata
LEVEL_EMOJI = {
logging.DEBUG: "🔍",
logging.INFO: "", # noqa: RUF001
logging.WARNING: "⚠️",
logging.ERROR: "🛑",
logging.CRITICAL: "🛑",
}
def display_width(text: str) -> int:
return sum(
1 + (unicodedata.east_asian_width(c) in "FW")
for c in text
if unicodedata.category(c) != "Mn"
)
def format_level_prefix(levelno: int) -> str:
emoji = LEVEL_EMOJI.get(levelno, "▪️")
prefix = f"{emoji} "
return prefix + (" " * max(0, 3 - display_width(prefix)))
class EmojiFormatter(logging.Formatter):
"""Compact formatter: emoji + message, no timestamp/level text/logger name."""
def format(self, record: logging.LogRecord) -> str:
return format_level_prefix(record.levelno) + record.getMessage()
+79 -5
View File
@@ -9,7 +9,6 @@ from os import stat_result
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from stat import S_ISDIR, S_ISREG from stat import S_ISDIR, S_ISREG
import inotify.adapters
import msgspec import msgspec
from natsort import humansorted, natsort_keygen, ns from natsort import humansorted, natsort_keygen, ns
from sanic.log import logger from sanic.log import logger
@@ -18,6 +17,11 @@ from cista import config
from cista.fileio import fuid from cista.fileio import fuid
from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep from cista.protocol import FileEntry, Space, UpdDel, UpdIns, UpdKeep
try:
import inotify.adapters as inotify_adapters
except Exception:
inotify_adapters = None
# Platform-specific allocated size calculation # Platform-specific allocated size calculation
if sys.platform == "win32": if sys.platform == "win32":
import ctypes import ctypes
@@ -55,6 +59,10 @@ class FormatUpdateLoopError(RuntimeError):
pass pass
class _WatcherStoppingError(Exception):
"""Internal control-flow exception for quick watcher shutdown."""
class State: class State:
def __init__(self): def __init__(self):
self.lock = threading.RLock() self.lock = threading.RLock()
@@ -154,6 +162,17 @@ stop_event = threading.Event()
# Thread-safe queue for signaling path updates from websockets # Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue() _update_queue: queue.Queue[PurePosixPath] = queue.Queue()
# Thread-safe queue for AR updates from the preview worker
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
_ar_map: dict[str, float] = {}
def notify_ar(fuid_key: str, ar: float) -> None:
"""Called from preview handler to update the AR for a file."""
_ar_queue.put_nowait((fuid_key, ar))
def notify_change(*paths: PurePosixPath | str): def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets.""" """Signal that paths have changed. Called from control/upload websockets."""
@@ -186,14 +205,16 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
except Exception: except Exception:
logger.exception(f"get_allocated_size failed for {path}") logger.exception(f"get_allocated_size failed for {path}")
allocated = st.st_size if isfile else 0 allocated = st.st_size if isfile else 0
key = fuid(st)
entry = FileEntry( entry = FileEntry(
level=len(rel.parts), level=len(rel.parts),
name=rel.name, name=rel.name,
key=fuid(st), key=key,
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, allocated=allocated,
isfile=isfile, isfile=isfile,
ar=_ar_map.get(key) if isfile else None,
) )
if isfile: if isfile:
return [entry] return [entry]
@@ -202,7 +223,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
li = [] li = []
for f in path.iterdir(): for f in path.iterdir():
if stop_event.is_set(): if stop_event.is_set():
raise SystemExit("quit") raise _WatcherStoppingError
if f.name.startswith("."): if f.name.startswith("."):
continue # No dotfiles continue # No dotfiles
with suppress(FileNotFoundError): with suppress(FileNotFoundError):
@@ -214,7 +235,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
li.append((int(isfile), f.name, s)) li.append((int(isfile), f.name, s))
# Build the tree as a list of FileEntries # Build the tree as a list of FileEntries
for [_, name, s] in humansorted(li): for [_, name, s] in humansorted(li):
if stop_event.is_set():
raise _WatcherStoppingError
sub = walk(rel / name, stat=s) sub = walk(rel / name, stat=s)
if not sub:
continue
child = sub[0] child = sub[0]
entry = FileEntry( entry = FileEntry(
level=entry.level, level=entry.level,
@@ -644,10 +669,13 @@ DEBOUNCE_MAX = 0.1 # But no more than 100ms total
def watcher(loop): def watcher(loop):
"""Unified watcher thread handling inotify, websocket signals, and periodic scans.""" """Unified watcher thread handling inotify, websocket signals, and periodic scans."""
use_inotify = sys.platform == "linux" use_inotify = sys.platform == "linux" and inotify_adapters is not None
inotify_tree = None inotify_tree = None
modified_flags = frozenset() modified_flags = frozenset()
if sys.platform == "linux" and inotify_adapters is None:
logger.warning("inotify unavailable; falling back to periodic scanning")
if use_inotify: if use_inotify:
modified_flags = frozenset( modified_flags = frozenset(
( (
@@ -663,10 +691,13 @@ def watcher(loop):
while not stop_event.is_set(): while not stop_event.is_set():
if use_inotify: if use_inotify:
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix()) inotify_tree = inotify_adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
try:
update_root(loop) update_root(loop)
except _WatcherStoppingError:
return
path_index = PathIndex(state.root[:]) path_index = PathIndex(state.root[:])
trefresh = time.monotonic() + 300.0 trefresh = time.monotonic() + 300.0
@@ -750,7 +781,10 @@ def watcher(loop):
# Process each collapsed path # Process each collapsed path
new_root = path_index.root new_root = path_index.root
for path in collapsed: for path in collapsed:
try:
new_entries = walk(path) new_entries = walk(path)
except _WatcherStoppingError:
return
new_root = path_index.apply_update(path, new_entries) new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed # Broadcast if changed
@@ -769,12 +803,51 @@ def watcher(loop):
with state.lock: with state.lock:
broadcast(update_msg, loop) broadcast(update_msg, loop)
state.root = fresh state.root = fresh
except _WatcherStoppingError:
return
except Exception: except Exception:
logger.exception("Fallback failed; sending full root") logger.exception("Fallback failed; sending full root")
with state.lock: with state.lock:
broadcast(format_root(fresh), loop) broadcast(format_root(fresh), loop)
state.root = fresh state.root = fresh
# Drain AR updates from preview worker (immediate, no debounce)
ar_new_root: list[FileEntry] | None = None
try:
while True:
fuid_key, ar = _ar_queue.get_nowait()
_ar_map[fuid_key] = ar
# Patch the matching entry in the current root
root_to_patch = (
ar_new_root if ar_new_root is not None else path_index.root
)
for i, entry in enumerate(root_to_patch):
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
if ar_new_root is None:
ar_new_root = root_to_patch[:]
ar_new_root[i] = FileEntry(
level=entry.level,
name=entry.name,
key=entry.key,
mtime=entry.mtime,
size=entry.size,
allocated=entry.allocated,
isfile=entry.isfile,
ar=ar,
)
break
except queue.Empty:
pass
if ar_new_root is not None:
try:
update_msg = format_update(state.root, ar_new_root)
with state.lock:
broadcast(update_msg, loop)
state.root = ar_new_root
path_index = PathIndex(ar_new_root)
except Exception:
logger.exception("AR update broadcast failed")
# Collect events from websocket signals (non-blocking) # Collect events from websocket signals (non-blocking)
try: try:
while True: while True:
@@ -820,6 +893,7 @@ def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
stop_event.clear()
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher, target=watcher,
args=[app.loop], args=[app.loop],
+4 -6
View File
@@ -6,9 +6,8 @@
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "biome lint .", "lint": "biome lint .",
"format": "biome format --write .", "format": "biome format --write .",
"format:check": "biome format --check .", "format:check": "biome format --check .",
@@ -18,8 +17,11 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"dependencies": { "dependencies": {
"@codemirror/language-data": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@imengyu/vue3-context-menu": "^1.5.3", "@imengyu/vue3-context-menu": "^1.5.3",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"codemirror": "^6.0.2",
"esbuild": "^0.27.2", "esbuild": "^0.27.2",
"lodash": "^4.17.23", "lodash": "^4.17.23",
"lodash-es": "^4.17.23", "lodash-es": "^4.17.23",
@@ -34,17 +36,13 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@tsconfig/node18": "^18.2.6", "@tsconfig/node18": "^18.2.6",
"@types/jsdom": "^27.0.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^25.1.0", "@types/node": "^25.1.0",
"@vitejs/plugin-vue": "^6.0.3", "@vitejs/plugin-vue": "^6.0.3",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4" "vue-tsc": "^3.2.4"
} }
} }
+145 -13
View File
@@ -8,13 +8,36 @@
<SettingsModal /> <SettingsModal />
<UserManagementModal /> <UserManagementModal />
<UserTokensModal /> <UserTokensModal />
<AboutModal />
<AccessDeniedModal /> <AccessDeniedModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" /> <HeaderMain
<BreadCrumb :path="path.pathList" primary /> ref="headerMain"
:path="path.pathList"
:query="path.query"
:editor-mode="path.isEditorPath"
/>
<BreadCrumb
:path="path.breadcrumbPathList"
:links="path.breadcrumbLinks"
primary
/>
</header> </header>
<main> <main class="transition-wrapper">
<RouterView :path="path.pathList" :query="path.query" /> <Transition
:name="routeTransitionName"
@after-enter="store.transitionDirection = 'none'"
>
<div :key="routeViewKey" class="explorer-content">
<KeepAlive>
<component
:is="routeViewComponent"
:key="routeViewKey"
v-bind="routeViewProps"
/>
</KeepAlive>
</div>
</Transition>
</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">
<SelectionToolbar :path="path.pathList" /> <SelectionToolbar :path="path.pathList" />
@@ -26,42 +49,108 @@
<script setup lang="ts"> <script setup lang="ts">
import type HeaderMain from '@/components/HeaderMain.vue' import type HeaderMain from '@/components/HeaderMain.vue'
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS' import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
import { onMounted, onUnmounted, ref, watchEffect } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView } from 'vue-router'
import Router from '@/router/index' import Router from '@/router/index'
import { computed } from 'vue' import { computed } from 'vue'
import AboutModal from './components/AboutModal.vue'
import AccessDeniedModal from './components/AccessDeniedModal.vue' import AccessDeniedModal from './components/AccessDeniedModal.vue'
import SelectionToolbar from './components/SelectionToolbar.vue' import SelectionToolbar from './components/SelectionToolbar.vue'
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 UserTokensModal from './components/UserTokensModal.vue' import UserTokensModal from './components/UserTokensModal.vue'
import type { SortOrder } from './utils/docsort' import type { SortOrder } from './utils/docsort'
import ExplorerView from './views/ExplorerView.vue'
import TextEditorView from './views/TextEditorView.vue'
interface Path { interface Path {
path: string path: string
canonicalPath: string
isEditorPath: boolean
pathList: string[] pathList: string[]
breadcrumbPathList: string[]
breadcrumbLinks?: string[]
query: string query: string
} }
const store = useMainStore() const store = useMainStore()
const getDocByPath = (fullPath: string) =>
getDocuments().find(
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
)
const path: ComputedRef<Path> = computed(() => { const path: ComputedRef<Path> = computed(() => {
const p = decodeURIComponent(Router.currentRoute.value.path).split('//') const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
const pathList = (p[0] ?? '').split('/').filter(value => value !== '') const rawPath = p[0] ?? ''
const routePathList = rawPath.split('/').filter(value => value !== '')
const query = p.slice(1).join('//') const query = p.slice(1).join('//')
const fullPath = routePathList.join('/')
// Access docVersion to make route mode reactive to tree updates
void store.docVersion
const doc = fullPath ? getDocByPath(fullPath) : null
const isEditorPath = !!(doc && !doc.dir && doc.text)
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
const canonicalPath = query
? `${rawPath}//${query}` // keep search URL shape untouched
: canonicalBase
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
const breadcrumbPathList = routePathList
const breadcrumbLinks = isEditorPath
? [
'/',
...routePathList
.slice(0, -1)
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
`/${fullPath}`
]
: undefined
return { return {
path: p[0] ?? '', path: rawPath,
canonicalPath,
isEditorPath,
pathList, pathList,
breadcrumbPathList,
breadcrumbLinks,
query query
} }
}) })
watchEffect(() => { const routeTransitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
})
const routeViewComponent = computed(() =>
path.value.isEditorPath ? TextEditorView : ExplorerView
)
const routeViewKey = computed(() => {
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
})
const routeViewProps = computed(() =>
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
)
watch(
() => path.value.canonicalPath,
canonical => {
const current = decodeURIComponent(Router.currentRoute.value.path)
if (canonical && current !== canonical) {
Router.replace(canonical.replaceAll('?', '%3F').replaceAll('#', '%23'))
}
},
{ immediate: true }
)
watch(
() => path.value.path,
() => {
document.title = document.title =
path.value.path.replace(/\/$/, '').split('/').pop() || path.value.path.replace(/\/$/, '').split('/').pop() ||
store.server.name || store.server.name ||
'Cista Storage' 'Cista Storage'
}) },
{ immediate: true }
)
onMounted(loadSession) onMounted(loadSession)
onMounted(watchConnect) onMounted(watchConnect)
onUnmounted(watchDisconnect) onUnmounted(watchDisconnect)
@@ -80,7 +169,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
const fileExplorer = store.fileExplorer as any const fileExplorer = store.fileExplorer as any
if (!fileExplorer) return if (!fileExplorer) return
const c = fileExplorer.isCursor() const c = fileExplorer.isCursor()
const input = (event.target as HTMLElement).tagName === 'INPUT' const target = event.target as HTMLElement
const input =
['INPUT', 'TEXTAREA'].includes(target.tagName) || !!target.closest('.cm-editor')
const keyup = event.type === 'keyup' const keyup = event.type === 'keyup'
// Always clear repeat timer on arrow keyup, even if focus moved to input // Always clear repeat timer on arrow keyup, even if focus moved to input
@@ -95,6 +186,8 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
event.key === 'ArrowDown' || event.key === 'ArrowDown' ||
event.key === 'ArrowLeft' || event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' || event.key === 'ArrowRight' ||
event.key === 'PageUp' ||
event.key === 'PageDown' ||
(c && event.code === 'Space') (c && event.code === 'Space')
) { ) {
if (!input) event.preventDefault() if (!input) event.preventDefault()
@@ -104,6 +197,7 @@ 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 = ''
let paging = ''
const inHeader = !!(event.target as HTMLElement).closest('.headermain') const inHeader = !!(event.target as HTMLElement).closest('.headermain')
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb') const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
// Handle arrows: in search input with text, only up/down; otherwise all arrows // Handle arrows: in search input with text, only up/down; otherwise all arrows
@@ -115,17 +209,35 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
if (searchHasText && (dir === 'left' || dir === 'right')) { if (searchHasText && (dir === 'left' || dir === 'right')) {
return // Let browser handle cursor movement return // Let browser handle cursor movement
} }
// Don't intercept arrows for non-search inputs (e.g. rename input)
if (input && !searchInput) return
arrow = dir arrow = dir
} else if (
event.key === 'PageUp' ||
event.key === 'PageDown' ||
event.key === 'Home' ||
event.key === 'End'
) {
if (input) return
paging = event.key
} }
if (arrow) { if (arrow) {
// Arrow key handling - fall through to bottom // Arrow key handling - fall through to bottom
} else if (paging) {
// Paging/navigation 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 (
!path.value.isEditorPath &&
!input &&
!keyup &&
event.key === 'f' &&
(event.ctrlKey || event.metaKey)
) {
headerMain.value!.toggleSearchInput() headerMain.value!.toggleSearchInput()
} }
// Search also on / (UNIX style) - use code to support any keyboard layout // Search also on / (UNIX style) - use code to support any keyboard layout
else if (!input && keyup && event.code === 'Slash') { else if (!path.value.isEditorPath && !input && keyup && event.code === 'Slash') {
// Record the actual character for display (varies by keyboard layout) // Record the actual character for display (varies by keyboard layout)
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) { if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
store.prefs.searchHotkey = event.key store.prefs.searchHotkey = event.key
@@ -136,7 +248,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
store.clearToast() store.clearToast()
// Keep rename and other non-search inputs isolated from search behavior.
if (input && !searchInput) return
if (!path.value.isEditorPath) {
headerMain.value!.clearSearch(event) headerMain.value!.clearSearch(event)
}
store.focusBreadcrumb() store.focusBreadcrumb()
} else if (!input && keyup && event.key === 'Backspace') { } else if (!input && keyup && event.key === 'Backspace') {
Router.back() Router.back()
@@ -235,12 +351,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
break break
} }
} }
} else if (paging && !keyup && !inHeader && !inBreadcrumb) {
switch (paging) {
case 'PageUp':
f = () => fileExplorer.pageUp?.(event)
break
case 'PageDown':
f = () => fileExplorer.pageDown?.(event)
break
case 'Home':
f = () => fileExplorer.home?.(event)
break
case 'End':
f = () => fileExplorer.end?.(event)
break
}
} }
if (f) { 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, const t0 = 200,
tr = event.altKey ? 20 : 100 tr = event.altKey ? 20 : 100
f() f()
if (paging === 'Home' || paging === 'End') return
timer = setTimeout(() => { timer = setTimeout(() => {
timer = setInterval(f, tr) timer = setInterval(f, tr)
}, t0 - tr) }, t0 - tr)
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" fill="#f80"/><path fill="#fff" d="M381 298h-84V167h-66L339 35l108 132h-66zm-168-84h-84v131H63l108 132 108-132h-66z"/></svg>

After

Width:  |  Height:  |  Size: 242 B

+64
View File
@@ -57,6 +57,68 @@
align-self: stretch; align-self: stretch;
} }
} }
/* Directory navigation slide transitions */
.transition-wrapper {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
height: 100%;
}
.explorer-content {
grid-area: 1 / 1;
height: 100%;
}
.slide-forward-enter-active,
.slide-backward-enter-active {
z-index: 2;
}
.slide-forward-leave-active,
.slide-backward-leave-active {
z-index: 1;
}
.slide-forward-enter-active,
.slide-forward-leave-active,
.slide-backward-enter-active,
.slide-backward-leave-active {
transition: transform 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
.slide-forward-enter-from {
transform: translate3d(100%, 0, 0);
}
.slide-forward-enter-to {
transform: translate3d(0, 0, 0);
}
.slide-forward-leave-from {
transform: translate3d(0, 0, 0);
}
.slide-forward-leave-to {
transform: translate3d(-100%, 0, 0);
}
.slide-backward-enter-from {
transform: translate3d(-100%, 0, 0);
}
.slide-backward-enter-to {
transform: translate3d(0, 0, 0);
}
.slide-backward-leave-from {
transform: translate3d(0, 0, 0);
}
.slide-backward-leave-to {
transform: translate3d(100%, 0, 0);
}
@media print { @media print {
:root { :root {
--primary-color: black; --primary-color: black;
@@ -206,6 +268,8 @@ main {
min-height: 0; /* Allow flex child to shrink below content size */ min-height: 0; /* Allow flex child to shrink below content size */
padding-bottom: 3em; /* convenience space on the bottom */ padding-bottom: 3em; /* convenience space on the bottom */
overflow-y: scroll; overflow-y: scroll;
overflow-x: hidden;
position: relative;
text-align: center; text-align: center;
} }
+3 -1
View File
@@ -1 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zm3 15c0 .2-.2.4-.4.4h-4.4v4.4c0 .2-.2.4-.4.4h-2.4c-.2 0-.4-.2-.4-.4V18H9.9c-.2 0-.4-.2-.4-.4v-2.4c0-.2.2-.4.4-.4h4.4v-4.4c0-.2.2-.4.4-.4H17c.2 0 .4.2.4.4v4.4h4.4c.2 0 .4.2.4.4v2.4z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28">
<path fill-rule="evenodd" d="M19.2 2.6H6.1V29h19.8V9.3l-6.7-6.7zM22.75 18.55c0 .2625-.175.4375-.4375.4375h-4.55v4.55c0 .2625-.175.4375-.4375.4375h-2.45c-.2625 0-.4375-.175-.4375-.4375v-4.55h-4.55c-.2625 0-.4375-.175-.4375-.4375V16.1c0-.2625.175-.4375.4375-.4375h4.55v-4.55c0-.2625.175-.4375.4375-.4375h2.45c.2625 0 .4375.175.4375.4375v4.55h4.55c.2625 0 .4375.175.4375.4375v2.45z" />
</svg>

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 452 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M384 128h-69c24 16 46.5 44.5 53.5 64h15c32.5 0 64 32 64 64s-32.5 64-64 64h-96c-31.5 0-64-32-64-64 0-11.5 3.5-22.5 9-32H164c-2.5 10.5-4 21-4 32 0 64 63.5 128 127.5 128H384c64 0 128-64 128-128s-64-128-128-128zM143.5 320h-15c-32.5 0-64-32-64-64s32.5-64 64-64h96c31.5 0 64 32 64 64 0 11.5-3.5 22.5-9 32H348c2.5-10.5 4-21 4-32 0-64-63.5-128-127.5-128H128C64 128 0 192 0 256s64 128 128 128h69c-24-16-46.5-44.5-53.5-64z"/></svg>

Before

Width:  |  Height:  |  Size: 517 B

After

Width:  |  Height:  |  Size: 492 B

+110
View File
@@ -0,0 +1,110 @@
<template>
<ModalDialog name="about" title="">
<div class="about-content">
<div class="about-logo-pane">
<img :src="logoUrl" alt="Cista Storage logo" class="about-logo" />
</div>
<div class="about-details">
<h3 class="about-name">Cista {{ softwareVersion }}</h3>
<p class="about-link">
<a :href="projectUrl" target="_blank" rel="noopener noreferrer">{{ displayProjectUrl }}</a>
</p>
<div class="dialog-buttons about-actions">
<div class="spacer"></div>
<input id="close" type="reset" value="Close" class="button" @click="close" />
</div>
</div>
</div>
</ModalDialog>
</template>
<script setup lang="ts">
import logoUrl from '@/assets/logo-square.svg?url'
import ModalDialog from '@/components/ModalDialog.vue'
import { useMainStore } from '@/stores/main'
import { computed } from 'vue'
const store = useMainStore()
const softwareVersion = computed(() => store.server.version || 'unknown')
const projectUrl = 'https://git.zi.fi/Vasanko/cista-storage'
const displayProjectUrl = projectUrl.replace(/^https?:\/\//, '')
const close = () => {
store.dialog = ''
}
</script>
<style scoped>
:deep(#about.modal-dialog) {
overflow: hidden;
}
.about-content {
display: grid;
grid-template-columns: 11rem minmax(0, 1fr);
align-items: stretch;
width: min(35rem, 92vw);
min-width: 0;
min-height: 0;
margin: -1rem;
overflow: hidden;
}
.about-logo-pane {
display: block;
padding: 0;
overflow: hidden;
}
.about-logo {
width: 100%;
height: auto;
aspect-ratio: 1 / 1;
margin: 0;
display: block;
}
.about-details {
display: flex;
flex-direction: column;
justify-content: center;
padding: 1.25rem;
}
.about-name {
margin: 0;
}
.about-link {
margin: 0.65rem 0 1rem;
word-break: break-word;
}
.about-actions {
margin-top: auto;
}
@media (max-width: 40rem) {
.about-content {
grid-template-columns: 1fr;
width: min(24rem, 90vw);
}
.about-logo-pane {
width: 100%;
aspect-ratio: 1 / 1;
}
.about-logo {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
}
.about-details {
padding: 0.85rem;
}
}
</style>
+4 -17
View File
@@ -1,32 +1,19 @@
<template> <template>
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay"> <ModalDialog name="accessdenied" title="">
<div class="modal-dialog" id="accessdenied"> <div class="access-denied">
<div class="modal-content access-denied">
<p class="icon"></p> <p class="icon"></p>
<p class="message">Access Denied</p> <p class="message">Access Denied</p>
<button @click="reload" class="button">Reload</button> <button @click="reload" class="button">Reload</button>
</div> </div>
</div> </ModalDialog>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import ModalDialog from '@/components/ModalDialog.vue'
import { holdGlobalBackdrop } from 'paskia'
import { watchEffect } from 'vue'
const store = useMainStore()
const reload = () => { const reload = () => {
location.reload() location.reload()
} }
// Keep backdrop active when this dialog shows
watchEffect(() => {
if (store.dialog === 'accessdenied') {
holdGlobalBackdrop()
}
})
</script> </script>
<style scoped> <style scoped>
+24 -5
View File
@@ -8,7 +8,7 @@
@focus=focusCurrent @focus=focusCurrent
tabindex=0 tabindex=0
> >
<a href="#/" <a :href="`/#${urlAt(0)}`"
:ref="el => setLinkRef(0, el)" :ref="el => setLinkRef(0, el)"
class="home" class="home"
:class="{ current: !!isCurrent(0) }" :class="{ current: !!isCurrent(0) }"
@@ -22,7 +22,7 @@
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip> <CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a> </a>
<template v-for="(location, index) in longest" :key="index"> <template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`" <a :href="`/#${urlAt(index + 1)}`"
:class="{ current: !!isCurrent(index + 1) }" :class="{ current: !!isCurrent(index + 1) }"
:aria-current="isCurrent(index + 1)" :aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)" @click.prevent="navigate(index + 1)"
@@ -62,10 +62,17 @@ const setPathTooltipRef = (index: number, el: any) => {
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
links?: Array<string>
primary?: boolean primary?: boolean
}>() }>()
const longest = ref<Array<string>>([]) const longest = ref<Array<string>>([])
const longestLinks = ref<Array<string>>(['/'])
const defaultLinks = (segments: Array<string>) => [
'/',
...segments.map((_, index) => `/${segments.slice(0, index + 1).join('/')}/`)
]
const isCurrent = (index: number) => const isCurrent = (index: number) =>
index == props.path.length ? 'location' : undefined index == props.path.length ? 'location' : undefined
@@ -77,16 +84,22 @@ const focusCurrent = () => {
}) })
} }
const urlAt = (index: number) => {
const explicit = longestLinks.value[index]
return explicit ?? (index ? `/${longest.value.slice(0, index).join('/')}/` : '/')
}
const navigate = (index: number) => { const navigate = (index: number) => {
const link = links[index] const link = links[index]
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`) if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/' const url = urlAt(index)
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/' const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '') const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
// Clicking on current link clears the rest of the path and adds new history // Clicking on current link clears the rest of the path and adds new history
if (isCurrent(index)) { if (isCurrent(index)) {
longest.value.splice(index) longest.value.splice(index)
longestLinks.value.splice(index + 1)
router.push(u) router.push(u)
} }
// Moving along breadcrumbs doesn't create new history // Moving along breadcrumbs doesn't create new history
@@ -102,20 +115,26 @@ const move = (dir: number) => {
} }
watchEffect(() => { watchEffect(() => {
const currentLinks = props.links ?? defaultLinks(props.path)
const longcut = longest.value.slice(0, props.path.length) const longcut = longest.value.slice(0, props.path.length)
const same = longcut.every((value, index) => value === props.path[index]) const same = longcut.every((value, index) => value === props.path[index])
// Navigated out of previous path, reset longest to current // Navigated out of previous path, reset longest to current
if (!same) longest.value = props.path if (!same) {
else if (props.path.length > longcut.length) { longest.value = props.path
longestLinks.value = currentLinks
} else if (props.path.length > longcut.length) {
longest.value = longcut.concat(props.path.slice(longcut.length)) longest.value = longcut.concat(props.path.slice(longcut.length))
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
} else { } else {
// Prune deleted folders from longest // Prune deleted folders from longest
for (let i = props.path.length; i < longest.value.length; ++i) { for (let i = props.path.length; i < longest.value.length; ++i) {
if (!exists(longest.value.slice(0, i + 1))) { if (!exists(longest.value.slice(0, i + 1))) {
longest.value = longest.value.slice(0, i) longest.value = longest.value.slice(0, i)
longestLinks.value = longestLinks.value.slice(0, i + 1)
break break
} }
} }
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
} }
// If needed, focus primary navigation to new location // If needed, focus primary navigation to new location
if (props.primary) if (props.primary)
+33 -15
View File
@@ -26,7 +26,7 @@
</defs> </defs>
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'"> <g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="showOtherCategory ? 'url(#otherGradient)' : freeColor" :stroke-width="ringWidth" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="freeColor" :stroke-width="ringWidth" :stroke-dasharray="pieFreeDash" :stroke-dashoffset="pieFreeOffsetVal" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#storageGradient)" :stroke-width="ringWidth" :stroke-dasharray="pieStorageDash" :transform="`rotate(-90 ${pieCx} ${pieCy})`" />
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" /> <circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#highlightOverlay)" :stroke-width="ringWidth" />
@@ -38,12 +38,12 @@
<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.allocated, 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.used - store.space.allocated, sectorInfo.other.angle) }}</text> <text v-if="showOtherCategory" :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" />
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" /> <path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" /> <path v-if="showOtherCategory" :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
</defs> </defs>
<text class="pie-label-sub" fill="#93e"> <text class="pie-label-sub" fill="#93e">
@@ -52,7 +52,7 @@
<text class="pie-label-sub" :fill="freeColor"> <text class="pie-label-sub" :fill="freeColor">
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath> <textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
</text> </text>
<text class="pie-label-sub" fill="#d9f"> <text v-if="showOtherCategory" class="pie-label-sub" fill="#d9f">
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath> <textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
</text> </text>
</g> </g>
@@ -98,18 +98,30 @@ const truncateLabel = (name: string, maxLen = 10): string => {
return name.slice(0, maxLen - 1) + '…' return name.slice(0, maxLen - 1) + '…'
} }
const otherBytes = computed(() => Math.max(0, store.space.used - store.space.allocated))
const showOtherCategory = computed(() => {
const s = store.space
return !!s.disk && otherBytes.value / s.disk >= 0.01
})
const freeSliceBytes = computed(() =>
showOtherCategory.value
? store.space.free
: Math.max(0, store.space.disk - store.space.allocated)
)
// Calculate max label length based on angular gap to neighbor labels // Calculate max label length based on angular gap to neighbor labels
const storageMaxLen = computed(() => { const storageMaxLen = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return 10 if (!s.disk) return 10
// Sector spans in degrees // Sector spans in degrees
const storageSpan = (s.allocated / s.disk) * 360 const storageSpan = (s.allocated / s.disk) * 360
const freeSpan = (s.free / s.disk) * 360 const freeSpan = (freeSliceBytes.value / s.disk) * 360
const otherSpan = ((s.used - s.allocated) / s.disk) * 360 const otherSpan = (otherBytes.value / s.disk) * 360
// Angular gap from storage label midpoint to neighbor label midpoints // Angular gap from storage label midpoint to neighbor label midpoints
const gapToFree = (storageSpan + freeSpan) / 2 const gapToFree = (storageSpan + freeSpan) / 2
const gapToOther = (storageSpan + otherSpan) / 2 const minGap = showOtherCategory.value
const minGap = Math.min(gapToFree, gapToOther) ? Math.min(gapToFree, (storageSpan + otherSpan) / 2)
: gapToFree
// Allow longer names when there's sufficient gap to both neighbors // Allow longer names when there's sufficient gap to both neighbors
if (minGap > 70) return 18 if (minGap > 70) return 18
if (minGap > 55) return 14 if (minGap > 55) return 14
@@ -143,7 +155,7 @@ const pieStorageDash = computed(() => {
const pieFreeDash = computed(() => { const pieFreeDash = computed(() => {
const s = store.space const s = store.space
if (!s.disk) return `0 ${CIRC}` if (!s.disk) return `0 ${CIRC}`
return `${(s.free / s.disk) * CIRC} ${CIRC}` return `${(freeSliceBytes.value / s.disk) * CIRC} ${CIRC}`
}) })
const pieFreeOffsetVal = computed(() => { const pieFreeOffsetVal = computed(() => {
@@ -179,8 +191,8 @@ const sectorInfo = computed(() => {
} }
const storagePct = s.allocated / s.disk const storagePct = s.allocated / s.disk
const freePct = s.free / s.disk const freePct = freeSliceBytes.value / s.disk
const otherPct = (s.used - s.allocated) / s.disk const otherPct = showOtherCategory.value ? otherBytes.value / s.disk : 0
const storageAngle = storagePct * 180 // midpoint of storage sector const storageAngle = storagePct * 180 // midpoint of storage sector
const freeStart = storagePct * 360 const freeStart = storagePct * 360
@@ -198,7 +210,7 @@ const sectorInfo = computed(() => {
const rawAngles = computed(() => ({ const rawAngles = computed(() => ({
storage: sectorInfo.value.storage.angle, storage: sectorInfo.value.storage.angle,
free: sectorInfo.value.free.angle, free: sectorInfo.value.free.angle,
other: sectorInfo.value.other.angle ...(showOtherCategory.value ? { other: sectorInfo.value.other.angle } : {})
})) }))
const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90) const getSizeRotation = (angle: number) => (angle < 180 ? angle - 90 : angle + 90)
@@ -219,7 +231,7 @@ const otherInnerPos = computed(() =>
const labelLengths = computed(() => ({ const labelLengths = computed(() => ({
storage: storageName.value.length, storage: storageName.value.length,
free: 4, free: 4,
other: 5 ...(showOtherCategory.value ? { other: 5 } : {})
})) }))
const getGapForPair = (len1: number, len2: number) => { const getGapForPair = (len1: number, len2: number) => {
@@ -232,7 +244,9 @@ const adjustedLabelAngles = computed(() => {
const labels = [ const labels = [
{ id: 'storage', angle: angles.storage, len: lens.storage }, { id: 'storage', angle: angles.storage, len: lens.storage },
{ id: 'free', angle: angles.free, len: lens.free }, { id: 'free', angle: angles.free, len: lens.free },
{ id: 'other', angle: angles.other, len: lens.other } ...(showOtherCategory.value
? [{ id: 'other', angle: angles.other!, len: lens.other! }]
: [])
] ]
labels.sort((a, b) => a.angle - b.angle) labels.sort((a, b) => a.angle - b.angle)
@@ -283,7 +297,11 @@ const freeLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.free!, 'free', 4) createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
) )
const otherLabelPath = computed(() => const otherLabelPath = computed(() =>
createArcPath(adjustedLabelAngles.value.other!, 'other', 5) createArcPath(
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
'other',
5
)
) )
const handleClick = () => (isExpanded.value ? collapse() : expand()) const handleClick = () => (isExpanded.value ? collapse() : expand())
+22 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="!props.path || documents.length === 0" class="empty-container"> <div v-if="showEmpty" class="empty-container">
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/> <component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p> <p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p> <p v-else-if="!store.connected">No Connection</p>
@@ -14,6 +14,7 @@
import { Cog } from '@/assets/svg' import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
import { computed } from 'vue'
const cog = Cog const cog = Cog
const store = useMainStore() const store = useMainStore()
@@ -21,9 +22,29 @@ const props = defineProps<{
path: string[] path: string[]
documents: Document[] documents: Document[]
}>() }>()
const showEmpty = computed(() => {
const loc = props.path.join('/')
const hasVisibleGhost = store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return g.loc === loc && !store.hiddenPaths.has(full)
})
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
})
</script> </script>
<style scoped> <style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
@keyframes rotate { @keyframes rotate {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
+205 -70
View File
@@ -1,4 +1,5 @@
<template> <template>
<div class="file-explorer">
<table v-if="props.documents.length || editing"> <table v-if="props.documents.length || editing">
<thead> <thead>
<tr> <tr>
@@ -12,10 +13,10 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="editing?.key === 'new'" class="folder"> <tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
<td class="selection"></td> <td class="selection"></td>
<td class="name"> <td class="name">
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" /> <FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
</td> </td>
<FileModified :doc=editing :now=nowkey /> <FileModified :doc=editing :now=nowkey />
<FileSize :doc=editing /> <FileSize :doc=editing />
@@ -46,13 +47,13 @@
</td> </td>
<td class="name"> <td class="name">
<template v-if="editing === doc"> <template v-if="editing === doc">
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" /> <FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
</template> </template>
<template v-else> <template v-else>
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key"> <a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
{{ doc.name }} {{ doc.name }}
</a> </a>
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊</button> <button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊</button>
</template> </template>
</td> </td>
<FileModified :doc=doc :now=nowkey /> <FileModified :doc=doc :now=nowkey />
@@ -69,6 +70,8 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -76,15 +79,18 @@ import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
nextTick, nextTick,
onActivated,
onDeactivated,
onMounted, onMounted,
onUnmounted, onUnmounted,
ref, ref,
shallowRef, shallowRef,
watchEffect watch
} from 'vue' } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import FileRenameInput from './FileRenameInput.vue' import FileRenameInput from './FileRenameInput.vue'
@@ -112,27 +118,116 @@ const parseErrorMessage = async (res: Response) => {
} }
} }
const getCursorIndex = () =>
store.cursor
? props.documents.findIndex(doc => doc.key === store.cursor)
: props.documents.length
const getDocElement = (key: string) =>
document.getElementById(`file-${key}`) as HTMLElement | null
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
const select = !!ev?.shiftKey
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = getCursorIndex()
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? getDocElement(store.cursor) : null
if (select) {
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
keepCursorVisibleSmooth(tr)
if (moveto === N) {
if (index > moveto) focusBreadcrumb()
else focusHeader()
}
}
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
const docs = props.documents
if (docs.length === 0) return
const scroller =
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
const currentIndex = getCursorIndex()
const currentEl = store.cursor ? getDocElement(store.cursor) : null
const currentCenter = currentEl
? currentEl.getBoundingClientRect().top +
currentEl.getBoundingClientRect().height / 2
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
const targetCenter =
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
let bestIndex = direction > 0 ? docs.length - 1 : 0
let bestDistance = Number.POSITIVE_INFINITY
for (let i = 0; i < docs.length; i++) {
if (
currentIndex !== docs.length &&
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
)
continue
const el = getDocElement(docs[i]!.key)
if (!el) continue
const center =
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
const distance = Math.abs(center - targetCenter)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
markKeyboardFollow()
moveCursorTo(bestIndex, ev)
}
// File rename // File rename
const editing = shallowRef<Doc | null>(null) const editing = shallowRef<Doc | null>(null)
const exitEditing = () => {
editing.value = null
}
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try { try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/' const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch( const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`, const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) { } catch (err) {
console.error('Rename failed', err) console.error('Rename failed', err)
doc.name = oldName doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed') store.showToast(err instanceof Error ? err.message : 'Rename failed')
} }
} }
defineExpose({ defineExpose({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() { newFolder() {
console.log('New folder')
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
loc: loc.value, loc: loc.value,
@@ -156,7 +251,7 @@ defineExpose({
const docs = props.documents const docs = props.documents
if (docs.length > 0) { if (docs.length > 0) {
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => { nextTick(() => {
const a = document.querySelector( const a = document.querySelector(
`#file-${store.cursor} .name a` `#file-${store.cursor} .name a`
@@ -176,14 +271,33 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
markKeyboardFollow()
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { up(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-1, ev) this.cursorMove(-1, ev)
}, },
down(ev: KeyboardEvent) { down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(1, ev) this.cursorMove(1, ev)
}, },
pageUp(ev: KeyboardEvent) {
pageMove(-1, ev)
},
pageDown(ev: KeyboardEvent) {
pageMove(1, ev)
},
home(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(0, ev)
},
end(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(props.documents.length - 1, ev)
},
left(ev: KeyboardEvent) { left(ev: KeyboardEvent) {
// Only go back if we're in a subfolder (not at root) // Only go back if we're in a subfolder (not at root)
if (props.path.length > 0) { if (props.path.length > 0) {
@@ -197,8 +311,6 @@ defineExpose({
if (a) a.click() if (a) a.click()
}, },
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
store.cursor = '' store.cursor = ''
@@ -207,35 +319,9 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = store.cursor const index = getCursorIndex()
? docs.findIndex(doc => doc.key === store.cursor)
: docs.length
const moveto = increment(index, d) const moveto = increment(index, d)
store.cursor = docs[moveto]?.key ?? '' moveCursorTo(moveto, ev)
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
// 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 focusHeader = () => {
@@ -248,24 +334,44 @@ 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()
} }
let scrolltimer: any = null const keyboardFollowScroll = createKeyboardFollowScroll()
let scrolltr: any = null const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
watchEffect(() => { const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null // Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
if (editing.value) store.cursor = editing.value?.key // stale props - their watchers must not react to global store changes.
if (store.cursor) { let isActive = true
const a = document.querySelector( watch(
`#file-${store.cursor} .name a` () => store.cursor,
) as HTMLAnchorElement | null cursor => {
if (a) a.focus() if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exitEditing()
} }
}) }
watchEffect(() => { )
if (!props.documents.length && store.cursor && !store.query) { watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(
`#file-${cursor} .name a`
) as HTMLAnchorElement | null
if (a) a.focus({ preventScroll: true })
}
},
{ flush: 'post' }
)
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
}) }
)
let nowkey = ref(0) let nowkey = ref(0)
let modifiedTimer: any = null let modifiedTimer: any = null
const updateModified = () => { const updateModified = () => {
@@ -276,26 +382,51 @@ onMounted(() => {
modifiedTimer = setInterval(updateModified, 1000) modifiedTimer = setInterval(updateModified, 1000)
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.focus({ preventScroll: true })
active.focus()
} }
}) })
onActivated(() => {
isActive = true
})
onDeactivated(() => {
isActive = false
if (editing.value) exitEditing()
})
onUnmounted(() => { onUnmounted(() => {
keyboardFollowScroll.cancel()
clearInterval(modifiedTimer) clearInterval(modifiedTimer)
}) })
const mkdir = async (doc: Doc, name: string) => { const editRoute = (path: string) =>
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const createItem = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null store.cursor = doc.key
exitEditing()
const path = doc.loc ? `${doc.loc}/${name}` : name const path = doc.loc ? `${doc.loc}/${name}` : name
try { try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' }) const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
if (doc.dir) {
router.push(doc.urlrouter) router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) { } catch (err) {
console.error('Mkdir failed', err) console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed') store.showToast(err instanceof Error ? err.message : 'Create failed')
} }
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
@@ -429,9 +560,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
</script> </script>
<style scoped> <style scoped>
.file-explorer {
height: 100%;
width: 100%;
}
table { table {
width: 100%; width: 100%;
table-layout: fixed; table-layout: fixed;
height: auto;
} }
thead tr { thead tr {
position: sticky; position: sticky;
@@ -492,6 +628,12 @@ table td {
.name .rename-button { .name .rename-button {
position: absolute; position: absolute;
right: 0; right: 0;
opacity: 0;
visibility: hidden;
}
tbody tr:hover .name .rename-button {
opacity: 1;
visibility: visible;
animation: appear calc(5 * var(--transition-time)) linear; animation: appear calc(5 * var(--transition-time)) linear;
} }
@keyframes appear { @keyframes appear {
@@ -562,12 +704,6 @@ tbody .selection input {
content: '📁'; content: '📁';
font-size: 1.5rem; font-size: 1.5rem;
} }
.empty-container {
padding-top: 3rem;
text-align: center;
font-size: 3rem;
color: var(--accent-color);
}
.folder-change { .folder-change {
margin-left: -.5rem; margin-left: -.5rem;
} }
@@ -578,4 +714,3 @@ tbody .selection input {
color: #888; color: #888;
} }
</style> </style>
@/stores/main
@@ -60,6 +60,7 @@ input#FileRenameInput {
padding: .75em; padding: .75em;
font-weight: 600; font-weight: 600;
width: auto; width: auto;
text-align: center;
} }
</style> </style>
+325 -67
View File
@@ -1,11 +1,19 @@
<template> <template>
<div v-if="props.documents.length || editing" class="gallery" ref="gallery"> <div v-if="props.documents.length || editing" class="gallery" ref="gallery">
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" /> <GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: createItem, exit}" />
<template v-for="(doc, index) in documents" :key=doc.key> <template v-for="(doc, index) in documents" :key=doc.key>
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/> <BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" /> <GalleryFigure
:doc=doc
:editing="editing === doc ? {rename, exit} : null"
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
@menu="contextMenu($event, doc)"
@rename="onFigureRename(doc)"
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
/>
</template> </template>
</div> </div>
<EmptyFolder v-else :documents="documents" :path="props.path" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -13,15 +21,18 @@ import { apiFetch } from '@/repositories/Client'
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import type { SortOrder } from '@/utils/docsort' import type { SortOrder } from '@/utils/docsort'
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { import {
computed, computed,
nextTick, nextTick,
onActivated,
onDeactivated,
onMounted, onMounted,
onUnmounted, onUnmounted,
ref, ref,
shallowRef, shallowRef,
watchEffect watch
} from 'vue' } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -53,32 +64,222 @@ const editing = shallowRef<Doc | null>(null)
const exit = () => { const exit = () => {
editing.value = null editing.value = null
} }
const onFigureRename = (doc: Doc) => {
editing.value = doc
store.cursor = doc.key
}
const rename = async (doc: Doc, newName: string) => { const rename = async (doc: Doc, newName: string) => {
const oldName = doc.name const oldName = doc.name
doc.name = newName // We should get an update from watch but this is quicker doc.name = newName // We should get an update from watch but this is quicker
store.documentsChanged()
try { try {
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/' const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
const res = await apiFetch( const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`, const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
{ method: 'POST' }
)
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
} catch (err) { } catch (err) {
console.error('Rename failed', err) console.error('Rename failed', err)
doc.name = oldName doc.name = oldName
store.documentsChanged()
store.showToast(err instanceof Error ? err.message : 'Rename failed') store.showToast(err instanceof Error ? err.message : 'Rename failed')
} }
} }
const gallery = ref<HTMLElement>() const gallery = ref<HTMLElement>()
const columnCount = ref(1) const columnCount = ref(1)
const columnWidthPx = ref(240)
const emPx = ref(16)
const aspectByKey = ref<Record<string, number>>({})
const optimalRowHeightPx = (ratios: number[]) => {
const w = Math.max(1, columnWidthPx.value)
const minH = Math.max(1, Math.round(7 * emPx.value))
const maxH = Math.max(minH, Math.round(30 * emPx.value))
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
if (usable.length === 0) return Math.round(15 * emPx.value)
let bestH = Math.round(15 * emPx.value)
let bestScore = -1
for (let h = minH; h <= maxH; h++) {
let score = 0
for (const ar of usable) {
let shownW = w
let shownH = w * ar
if (shownH > h) {
shownH = h
shownW = h / ar
}
// Fill efficiency in the row cell (0..1)
score += (shownW * shownH) / (w * h)
}
if (score > bestScore) {
bestScore = score
bestH = h
}
}
return bestH
}
const setAspect = (key: string, ar: number) => {
if (!Number.isFinite(ar) || ar <= 0) return
if (aspectByKey.value[key] === ar) return
aspectByKey.value = {
...aspectByKey.value,
[key]: ar
}
}
const rowHeightsByKey = computed<Record<string, string>>(() => {
const docs = props.documents
const cols = Math.max(1, columnCount.value)
const byKey = aspectByKey.value
const out: Record<string, string> = {}
const assignRows = (group: Doc[]) => {
for (let start = 0; start < group.length; start += cols) {
const row = group.slice(start, start + cols)
const ratios = row
.filter(doc => doc.previewable)
.map(doc => byKey[doc.key])
.filter((ar): ar is number => ar != null)
const height = `${optimalRowHeightPx(ratios)}px`
for (const doc of row) out[doc.key] = height
}
}
let group: Doc[] = []
for (let i = 0; i < docs.length; i++) {
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
assignRows(group)
group = []
}
group.push(docs[i]!)
}
assignRows(group)
return out
})
// Seed collected ratios from server-provided ar values on docs
const seedFromDocs = () => {
for (const doc of props.documents)
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
}
const onImgLoad = (e: Event) => {
const img = e.target as HTMLImageElement
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
if (!anchor) return
const key = anchor.id.slice('file-'.length)
if (!key) return
setAspect(key, img.naturalHeight / img.naturalWidth)
}
const updateColumns = () => { const updateColumns = () => {
if (!gallery.value) return if (!gallery.value) return
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split( if (gallery.value.getBoundingClientRect().width <= 0) return
' ' const style = getComputedStyle(gallery.value)
).length const templates = style.gridTemplateColumns
.split(' ')
.filter(part => !!part && part !== 'none')
columnCount.value = Math.max(1, templates.length)
const first = templates[0]
if (first && first.endsWith('px')) {
const parsed = Number.parseFloat(first)
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
}
const parsedEm = Number.parseFloat(style.fontSize)
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
} }
const columns = computed(() => columnCount.value) const columns = computed(() => columnCount.value)
const getCursorIndex = () =>
store.cursor
? props.documents.findIndex(doc => doc.key === store.cursor)
: props.documents.length
const getDocElement = (key: string) =>
document.getElementById(`file-${key}`) as HTMLElement | null
const moveCursorTo = (moveto: number, ev: KeyboardEvent | null) => {
const select = !!ev?.shiftKey
const docs = props.documents
if (docs.length === 0) {
store.cursor = ''
return
}
const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = getCursorIndex()
store.cursor = docs[moveto]?.key ?? ''
const tr = store.cursor ? getDocElement(store.cursor) : null
if (select) {
let [begin, end] = moveto >= index ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
keepCursorVisibleSmooth(tr)
if (moveto === N) {
if (index > moveto) focusBreadcrumb()
else focusHeader()
}
}
const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
const docs = props.documents
if (docs.length === 0) return
const scroller =
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
const currentIndex = getCursorIndex()
const currentEl = store.cursor ? getDocElement(store.cursor) : null
const currentCenter = currentEl
? currentEl.getBoundingClientRect().top +
currentEl.getBoundingClientRect().height / 2
: scroller.getBoundingClientRect().top + scroller.clientHeight / 2
const targetCenter =
currentCenter + direction * Math.max(120, scroller.clientHeight - 140)
let bestIndex = direction > 0 ? docs.length - 1 : 0
let bestDistance = Number.POSITIVE_INFINITY
for (let i = 0; i < docs.length; i++) {
if (
currentIndex !== docs.length &&
((direction > 0 && i <= currentIndex) || (direction < 0 && i >= currentIndex))
)
continue
const el = getDocElement(docs[i]!.key)
if (!el) continue
const center =
el.getBoundingClientRect().top + el.getBoundingClientRect().height / 2
const distance = Math.abs(center - targetCenter)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
markKeyboardFollow()
moveCursorTo(bestIndex, ev)
}
defineExpose({ defineExpose({
newFile() {
const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({
loc: loc.value,
key: 'new',
name: 'New File.txt',
dir: false,
mtime: now,
size: 0,
allocated: 0
})
store.cursor = editing.value.key
},
newFolder() { newFolder() {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
editing.value = new Doc({ editing.value = new Doc({
@@ -107,7 +308,7 @@ defineExpose({
const docs = props.documents const docs = props.documents
if (docs.length > 0) { if (docs.length > 0) {
store.cursor = docs[0]!.key store.cursor = docs[0]!.key
// Also focus the element directly (watchEffect won't trigger if cursor unchanged) // Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
nextTick(() => { nextTick(() => {
const a = document.querySelector( const a = document.querySelector(
`#file-${store.cursor}` `#file-${store.cursor}`
@@ -127,23 +328,42 @@ defineExpose({
} else { } else {
store.selected.add(key) store.selected.add(key)
} }
markKeyboardFollow()
this.cursorMove(1, null) this.cursorMove(1, null)
}, },
up(ev: KeyboardEvent) { up(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-columns.value, ev) this.cursorMove(-columns.value, ev)
}, },
down(ev: KeyboardEvent) { down(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(columns.value, ev) this.cursorMove(columns.value, ev)
}, },
left(ev: KeyboardEvent) { left(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(-1, ev) this.cursorMove(-1, ev)
}, },
right(ev: KeyboardEvent) { right(ev: KeyboardEvent) {
markKeyboardFollow()
this.cursorMove(1, ev) this.cursorMove(1, ev)
}, },
pageUp(ev: KeyboardEvent) {
pageMove(-1, ev)
},
pageDown(ev: KeyboardEvent) {
pageMove(1, ev)
},
home(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(0, ev)
},
end(ev: KeyboardEvent) {
if (!props.documents.length) return
markKeyboardFollow()
moveCursorTo(props.documents.length - 1, ev)
},
cursorMove(d: number, ev: KeyboardEvent | null) { cursorMove(d: number, ev: KeyboardEvent | null) {
const select = !!ev?.shiftKey
// Move cursor up or down (keyboard navigation)
const docs = props.documents const docs = props.documents
if (docs.length === 0) { if (docs.length === 0) {
store.cursor = '' store.cursor = ''
@@ -152,7 +372,7 @@ defineExpose({
const N = docs.length const N = docs.length
const mod = (a: number, b: number) => ((a % b) + b) % b const mod = (a: number, b: number) => ((a % b) + b) % b
const increment = (i: number, d: number) => mod(i + d, N + 1) const increment = (i: number, d: number) => mod(i + d, N + 1)
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N const index = getCursorIndex()
// Stop navigation sideways away from the grid (only with up/down) // Stop navigation sideways away from the grid (only with up/down)
if (ev && index === 0 && ev.key === 'ArrowLeft') return if (ev && index === 0 && ev.key === 'ArrowLeft') return
if (ev && index === N - 1 && ev.key === 'ArrowRight') return if (ev && index === N - 1 && ev.key === 'ArrowRight') return
@@ -164,31 +384,7 @@ defineExpose({
// Wrapping either end, just land outside the list // Wrapping either end, just land outside the list
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
} }
store.cursor = docs[moveto]?.key ?? '' moveCursorTo(moveto, ev)
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
if (select) {
// Go forwards, possibly wrapping over the end; the last entry is not toggled
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
for (let p = begin; p !== end; p = increment(p, 1)) {
if (p === N) continue
const key = docs[p]!.key
if (store.selected.has(key)) store.selected.delete(key)
else store.selected.add(key)
}
}
// @ts-ignore
scrolltr = tr
if (!scrolltimer) {
scrolltimer = setTimeout(() => {
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
scrolltimer = null
}, 300)
}
// 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 focusHeader = () => {
@@ -201,56 +397,117 @@ 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()
} }
let scrolltimer: any = null const keyboardFollowScroll = createKeyboardFollowScroll()
let scrolltr: any = null const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
watchEffect(() => { const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null // Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
if (editing.value) store.cursor = editing.value.key // stale props - their watchers must not react to global store changes.
if (store.cursor) { let isActive = true
const a = document.querySelector( watch(
`#file-${store.cursor}` () => store.cursor,
) as HTMLAnchorElement | null cursor => {
if (!isActive) return
if (cursor && editing.value && cursor !== editing.value.key) {
exit()
}
}
)
watch(
() => store.cursor,
cursor => {
if (!isActive) return
if (cursor && !editing.value) {
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
if (a) { if (a) {
a.focus() a.focus({ preventScroll: true })
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
} }
} }
}) },
watchEffect(() => { { flush: 'post' }
if (!props.documents.length && store.cursor && !store.query) { )
watch(
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
([len, cursor, query, editingDoc]) => {
if (!isActive) return
if (!len && cursor && !query && !editingDoc) {
store.cursor = '' store.cursor = ''
focusBreadcrumb() focusBreadcrumb()
} }
}) }
)
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
const attachGalleryObservers = () => {
if (!gallery.value || resizeObserver) return
resizeObserver = new ResizeObserver(updateColumns)
resizeObserver.observe(gallery.value)
gallery.value.addEventListener('load', onImgLoad, { capture: true })
}
const detachGalleryObservers = () => {
resizeObserver?.disconnect()
resizeObserver = null
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
}
onMounted(() => { onMounted(() => {
const active = document.querySelector('.cursor') as HTMLElement | null const active = document.querySelector('.cursor') as HTMLElement | null
if (active) { if (active) {
active.scrollIntoView({ block: 'center', behavior: 'instant' }) active.focus({ preventScroll: true })
active.focus()
} }
updateColumns() updateColumns()
if (gallery.value) { seedFromDocs()
resizeObserver = new ResizeObserver(updateColumns) attachGalleryObservers()
resizeObserver.observe(gallery.value) })
} onActivated(() => {
isActive = true
nextTick(() => {
updateColumns()
attachGalleryObservers()
})
})
onDeactivated(() => {
isActive = false
detachGalleryObservers()
if (editing.value) exit()
}) })
onUnmounted(() => { onUnmounted(() => {
resizeObserver?.disconnect() keyboardFollowScroll.cancel()
detachGalleryObservers()
}) })
const mkdir = async (doc: Doc, name: string) => {
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
watch(() => props.documents, seedFromDocs)
const editRoute = (path: string) =>
'/' +
path
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
const createItem = async (doc: Doc, name: string) => {
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
store.addGhost(doc) store.addGhost(doc)
editing.value = null store.cursor = doc.key
exit()
const path = doc.loc ? `${doc.loc}/${name}` : name const path = doc.loc ? `${doc.loc}/${name}` : name
try { try {
const res = await apiFetch(filesUrl(path), { method: 'MKCOL' }) const res = doc.dir
? await apiFetch(filesUrl(path), { method: 'MKCOL' })
: await apiFetch(filesUrl(path), {
method: 'PUT',
body: '',
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) throw new Error(await parseErrorMessage(res)) if (!res.ok) throw new Error(await parseErrorMessage(res))
if (doc.dir) {
router.push(doc.urlrouter) router.push(doc.urlrouter)
} else {
router.push(editRoute(path))
}
} catch (err) { } catch (err) {
console.error('Mkdir failed', err) console.error('Create failed', err)
store.showToast(err instanceof Error ? err.message : 'Mkdir failed') store.showToast(err instanceof Error ? err.message : 'Create failed')
} }
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
@@ -380,7 +637,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
display: grid; display: grid;
gap: .5em; gap: .5em;
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr)); grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
align-items: end; align-items: start;
align-content: start;
} }
.folder-indicator { .folder-indicator {
grid-column: 1 / -1; grid-column: 1 / -1;
+134 -20
View File
@@ -10,21 +10,33 @@
> >
<figure> <figure>
<slot></slot> <slot></slot>
<MediaPreview ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" /> <MediaPreview :key="snap.ext" ref=m :doc="doc" tabindex=-1 quality="sz=512" class="figcontent" />
<div class="titlespacer"></div> <div class="titlespacer"></div>
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)"> <figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
<template v-if="editing"> <template v-if="editing">
<SelectBox :doc=doc @click="store.cursor = doc.key"/>
<div class="filename-row rename-row">
<div class="rename-wrap">
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit /> <FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
</div>
</div>
<div class=namespacer></div>
</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 }}<SparseIndicator :doc="doc" class="after-name" /></span> <div class="filename-row">
<span class="filename-group">
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
</span>
<button class="rename-btn" @click="emit('rename')" title="Rename"></button>
</div>
<div class=namespacer></div> <div class=namespacer></div>
</template> </template>
</figcaption> </figcaption>
</figure> </figure>
<CursorTooltip ref="tooltip" :text="tooltipText"> <CursorTooltip ref="tooltip" :text="tooltipText">
<div class="tooltip-name">{{ doc.name }}</div> <div class="tooltip-name">{{ snap.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> <div v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
</CursorTooltip> </CursorTooltip>
@@ -37,12 +49,14 @@ import { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { formatSize } from '@/utils' import { formatSize } from '@/utils'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import CursorTooltip from './CursorTooltip.vue' import CursorTooltip from './CursorTooltip.vue'
import SparseIndicator from './SparseIndicator.vue' import SparseIndicator from './SparseIndicator.vue'
const store = useMainStore() const store = useMainStore()
const router = useRouter()
type EditingProp = { type EditingProp = {
rename: (name: string) => void rename: (doc: Doc, newName: string) => void
exit: () => void exit: () => void
} }
@@ -50,6 +64,10 @@ const props = defineProps<{
doc: Doc doc: Doc
editing?: EditingProp editing?: EditingProp
}>() }>()
const emit = defineEmits<{
(e: 'rename'): void
(e: 'menu', ev: MouseEvent): void
}>()
const m = ref<typeof MediaPreview | null>(null) const m = ref<typeof MediaPreview | null>(null)
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null) const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
@@ -60,8 +78,27 @@ const sparseText = computed(() => {
return `${formatSize(allocated)} allocated of ${formatSize(size)}` return `${formatSize(allocated)} allocated of ${formatSize(size)}`
}) })
// Single subscription to docVersion; all doc-derived values come from here.
// This is needed because Doc instances are non-reactive plain objects, so
// mutating doc.name alone won't invalidate computed caches.
const snap = computed(() => {
void store.docVersion
const { name, ext } = props.doc
const base = ext ? name.slice(0, name.length - ext.length - 1) : name
return {
name,
ext,
displayName: base.replace(/[_.]+/g, ' ')
}
})
const onclick = (ev: Event) => { const onclick = (ev: Event) => {
if (m.value!.play()) ev.preventDefault() if (m.value!.play()) {
ev.preventDefault()
} else if (props.doc.text) {
ev.preventDefault()
router.push(props.doc.editurl.replace('/#', ''))
}
store.cursor = props.doc.key store.cursor = props.doc.key
} }
</script> </script>
@@ -81,8 +118,78 @@ const onclick = (ev: Event) => {
.after-name { .after-name {
margin-left: 0.3em; margin-left: 0.3em;
} }
.filename-row {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0;
flex: 0 1 auto;
min-width: 0;
position: relative;
overflow: visible;
max-width: calc(100% - 4.5em);
}
.filename-row::after {
content: '';
position: absolute;
left: 100%;
top: 0;
width: 1.4em;
height: 100%;
}
.filename-group {
display: inline-flex;
align-items: baseline;
min-width: 0;
max-width: 100%;
}
.filename {
cursor: default;
padding: .5em 0;
color: #fff;
font-size: 0.8em;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 0 1 auto;
min-width: 0;
}
.file-ext {
color: rgba(255, 255, 255, 0.8);
font-size: 0.8em;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
padding: 0 .15em 0 0;
white-space: nowrap;
flex: 0 0 auto;
}
.rename-btn {
position: absolute;
left: 100%;
top: 50%;
transform: translate(0.2em, -50%);
z-index: 2;
background: none;
border: none;
padding: 0;
cursor: pointer;
font-size: 0.8em;
line-height: 1;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.12s ease;
}
.filename-row:hover .rename-btn {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
figure { figure {
max-height: 15em; height: var(--gallery-figure-height, 15em);
max-height: var(--gallery-figure-height, 15em);
position: relative; position: relative;
border-radius: .5em; border-radius: .5em;
overflow: hidden; overflow: hidden;
@@ -92,12 +199,13 @@ figure {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
transition: height 0.4s ease, max-height 0.4s ease;
} }
figure > article { figure > article {
flex: 0 0 auto; flex: 0 0 auto;
} }
figure :deep(.video-container) { figure :deep(.video-container) {
height: 15em; height: var(--gallery-figure-height, 15em);
} }
.titlespacer { .titlespacer {
flex-shrink: 100000; flex-shrink: 100000;
@@ -114,9 +222,9 @@ figcaption {
width: 100%; width: 100%;
} }
figcaption input[type='checkbox'] { figcaption input[type='checkbox'] {
width: 1.5em; width: 1.1em;
height: 1.5em; height: 1.1em;
margin: .25em 0 .25em .25em; margin: .25em .4em .25em .35em;
opacity: 0; opacity: 0;
flex-shrink: 0; flex-shrink: 0;
transition: opacity var(--transition-time) ease-in-out; transition: opacity var(--transition-time) ease-in-out;
@@ -124,17 +232,10 @@ figcaption input[type='checkbox'] {
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] { figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
opacity: 1; opacity: 1;
} }
figcaption span { .cursor .filename {
cursor: default; color: var(--accent-color);
padding: .5em;
color: #fff;
font-weight: 600;
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
} }
.cursor figcaption span { .cursor .file-ext {
color: var(--accent-color); color: var(--accent-color);
} }
figcaption .namespacer { figcaption .namespacer {
@@ -142,4 +243,17 @@ figcaption .namespacer {
height: 2em; height: 2em;
width: 2em; width: 2em;
} }
.rename-wrap {
font-size: 0.8em;
width: auto;
min-width: 0;
max-width: 100%;
}
.rename-row {
max-width: calc(100% - 4.5em);
}
.rename-wrap :deep(#FileRenameInput) {
min-width: 0;
max-width: 100%;
}
</style> </style>
+23 -1
View File
@@ -1,6 +1,12 @@
<template> <template>
<nav class="headermain buttons"> <nav class="headermain buttons">
<template v-if="!props.editorMode">
<UploadButton :path="props.path" /> <UploadButton :path="props.path" />
<SvgButton
name="create-file"
tooltip="New file"
@click="() => { store.fileExplorer!.newFile() }"
/>
<SvgButton <SvgButton
name="create-folder" name="create-folder"
tooltip="New folder" tooltip="New folder"
@@ -19,12 +25,19 @@
/> />
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span> <span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
</div> </div>
<div v-if="showSortHints" class="sort-hints"> </template>
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
<span class="sort-label">Order</span> <span class="sort-label">Order</span>
<span class="keycap">1</span> <span class="keycap">1</span>
<span class="keycap">2</span> <span class="keycap">2</span>
<span class="keycap">3</span> <span class="keycap">3</span>
</div> </div>
<SvgButton
v-if="props.editorMode"
name="disk"
tooltip="Save (Ctrl/Cmd+S)"
@click="store.editorSave?.()"
/>
<div class="spacer smallgap"></div> <div class="spacer smallgap"></div>
<DiskSpace v-if="store.space.disk" /> <DiskSpace v-if="store.space.disk" />
<SvgButton name="cog" @click="settingsMenu" /> <SvgButton name="cog" @click="settingsMenu" />
@@ -49,6 +62,7 @@ const textInputFocused = ref(false)
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
editorMode?: boolean
}>() }>()
const isInputElement = (el: Element | null): boolean => { const isInputElement = (el: Element | null): boolean => {
@@ -164,6 +178,14 @@ const settingsMenu = (e: Event) => {
} }
}) })
} }
items.push({
label: '️ About Cista...',
onClick: () => {
store.dialog = 'about'
}
})
ContextMenu.showContextMenu({ ContextMenu.showContextMenu({
// @ts-ignore // @ts-ignore
x: e.target.getBoundingClientRect().right, x: e.target.getBoundingClientRect().right,
+119 -22
View File
@@ -1,9 +1,35 @@
<template> <template>
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending"> <div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
<SpinnerIcon /> <span
v-show="activeImageLoading"
class="file icon"
:class="[`ext-${doc.ext}`, 'loading-pulse']"
:style="loadingPulseStyle"
></span>
<img
v-if="showPreviewImage"
:src="previewSrc"
alt=""
:class="{ ready: !previewImageLoading }"
@load="onPreviewImageLoad"
@error="onPreviewImageError"
>
<img
v-else
:src="doc.url"
alt=""
:class="{ ready: !nativeImageLoading }"
@load="onNativeImageLoad"
@error="onNativeImageError"
>
</div>
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
<span
class="file icon"
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
:style="loadingPulseStyle"
></span>
</div> </div>
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
<img v-else-if=doc.img :src=doc.url alt="">
<span v-else-if=doc.dir class="folder icon"></span> <span v-else-if=doc.dir class="folder icon"></span>
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }"> <div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video> <video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
@@ -18,10 +44,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg' import { Play as PlayIcon } from '@/assets/svg'
import type { Doc } from '@/repositories/Document' import type { Doc } from '@/repositories/Document'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed, ref } from 'vue' import { computed, ref, watch } from 'vue'
const aud = ref<HTMLAudioElement | null>(null) const aud = ref<HTMLAudioElement | null>(null)
const vid = ref<HTMLVideoElement | null>(null) const vid = ref<HTMLVideoElement | null>(null)
@@ -30,11 +56,58 @@ const props = defineProps<{
doc: Doc doc: Doc
quality: string quality: string
}>() }>()
const previewImageFailed = ref(false)
const nativeImageFailed = ref(false)
const previewImageLoading = ref(true)
const nativeImageLoading = ref(true)
const previewSrc = computed(() => const previewSrc = computed(() =>
props.doc.previewurl props.doc.previewurl
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}` ? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
: '' : ''
) )
const showPreviewImage = computed(
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
)
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
const activeImageLoading = computed(() =>
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
)
const previewLoadFailed = computed(
() => previewImageFailed.value || nativeImageFailed.value
)
const loadingPulseDelayMs = computed(() => {
let hash = 0
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
return hash % 1800
})
const loadingPulseStyle = computed(() => ({
animationDelay: `${-loadingPulseDelayMs.value}ms`
}))
const onPreviewImageLoad = () => {
previewImageLoading.value = false
}
const onPreviewImageError = () => {
previewImageLoading.value = false
previewImageFailed.value = true
}
const onNativeImageLoad = () => {
nativeImageLoading.value = false
}
const onNativeImageError = () => {
nativeImageLoading.value = false
nativeImageFailed.value = true
}
watch(
() => props.doc.key,
() => {
previewImageFailed.value = false
nativeImageFailed.value = false
previewImageLoading.value = true
nativeImageLoading.value = true
}
)
const onplay = () => { const onplay = () => {
if (!media.value) return if (!media.value) return
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
border-radius: calc(.5em / 8); border-radius: calc(.5em / 8);
} }
.preview-progress { .preview-progress {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
max-height: 100%; max-height: 100%;
aspect-ratio: 1; aspect-ratio: 1;
} }
.preview-progress :deep(svg) { .preview-progress .icon {
width: 4.5em; opacity: 0.9;
height: 4.5em;
opacity: 0.8;
animation: media-preview-spin 0.9s linear infinite;
} }
@keyframes media-preview-spin { .preview-image-shell {
from { position: relative;
transform: rotate(0deg); display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
}
.preview-image-shell img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-width: 0;
object-fit: contain;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.preview-image-shell img.ready {
opacity: 1;
}
.loading-pulse {
animation: media-preview-pulse 1.8s ease-in-out infinite;
}
@keyframes media-preview-pulse {
0% {
transform: scale(1);
opacity: 0.86;
} }
to { 50% {
transform: rotate(360deg); transform: scale(1.04);
opacity: 0.98;
}
100% {
transform: scale(1);
opacity: 0.86;
} }
} }
.folder::before { .folder::before {
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
figure.cursor .icon { figure.cursor .icon {
filter: brightness(1); filter: brightness(1);
} }
img::before {
/* broken image */
text-shadow: 0 0 .5rem #000;
filter: grayscale(1);
content: '❌';
}
.video-container { .video-container {
position: relative; position: relative;
display: flex; display: flex;
+65 -3
View File
@@ -15,15 +15,55 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia'
import { nextTick, ref, watchEffect } from 'vue' import { nextTick, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
const overlay = ref<HTMLDivElement | null>(null) const overlay = ref<HTMLDivElement | null>(null)
const dialog = ref<HTMLDivElement | null>(null) const dialog = ref<HTMLDivElement | null>(null)
const store = useMainStore() const store = useMainStore()
let backdropHeld = false
const ensureGlobalBackdropStyles = () => {
if (typeof document === 'undefined') return
if (document.getElementById('paskia-dialog')) return
const style = document.createElement('style')
style.id = 'paskia-dialog'
style.textContent = `body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#paskia-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
`
document.head.insertBefore(style, document.head.firstChild)
}
const close = () => { const close = () => {
store.dialog = '' store.dialog = ''
releaseGlobalBackdrop()
} }
const props = defineProps<{ const props = defineProps<{
@@ -33,7 +73,6 @@ const props = defineProps<{
const show = () => { const show = () => {
store.dialog = props.name store.dialog = props.name
holdGlobalBackdrop()
nextTick(() => { nextTick(() => {
overlay.value?.focus() overlay.value?.focus()
const input = dialog.value?.querySelector('input') const input = dialog.value?.querySelector('input')
@@ -41,6 +80,29 @@ const show = () => {
}) })
} }
defineExpose({ show, close }) defineExpose({ show, close })
watch(
() => store.dialog === props.name,
isOpen => {
if (isOpen && !backdropHeld) {
ensureGlobalBackdropStyles()
holdGlobalBackdrop()
backdropHeld = true
} else if (!isOpen && backdropHeld) {
releaseGlobalBackdrop()
backdropHeld = false
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (backdropHeld) {
releaseGlobalBackdrop()
backdropHeld = false
}
})
watchEffect(() => { watchEffect(() => {
if (overlay.value) { if (overlay.value) {
overlay.value.focus() overlay.value.focus()
+5 -5
View File
@@ -15,11 +15,11 @@
</div> </div>
<span class="select-size">{{ selectionDisplay.size }}</span> <span class="select-size">{{ selectionDisplay.size }}</span>
<DownloadButton /> <DownloadButton />
<button <SvgButton
class="action-button" name="link"
title="Copy share link (Alt-click for read/write)" tooltip="Copy share link (Alt-click for read/write)"
@click="copyShareLink" @click="copyShareLink"
>share</button> />
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" /> <SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" /> <SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" /> <SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
@@ -29,7 +29,7 @@
@mouseenter="unselectTooltip?.startHover" @mouseenter="unselectTooltip?.startHover"
@mousemove="unselectTooltip?.updatePosition" @mousemove="unselectTooltip?.updatePosition"
@mouseleave="unselectTooltip?.endHover" @mouseleave="unselectTooltip?.endHover"
> selection</button> > deselect</button>
</div> </div>
</template> </template>
+24 -1
View File
@@ -11,7 +11,7 @@
import { Doc } from '@/repositories/Document' import { Doc } from '@/repositories/Document'
import { getDocuments } from '@/stores/documentStore' import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { collator } from '@/utils' import { collator, formatSize } from '@/utils'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -44,6 +44,7 @@ type InflightBlock = {
} }
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
function pasteHandler(event: ClipboardEvent) { function pasteHandler(event: ClipboardEvent) {
const items = Array.from(event.clipboardData?.items ?? []) const items = Array.from(event.clipboardData?.items ?? [])
const infiles = [] as File[] const infiles = [] as File[]
@@ -116,6 +117,28 @@ const uploadCloudFiles = (files: CloudFile[]) => {
} }
if (!files.length) return if (!files.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Space check: reject the whole batch if there isn't enough free space.
const batchTotal = files.reduce((sum, f) => sum + f.file.size, 0)
const allDocs = getDocuments()
const docByPath = new Map<string, Doc>()
for (const d of allDocs) {
const path = d.loc ? `${d.loc}/${d.name}` : d.name
docByPath.set(path, d)
}
let overwriteSize = 0
for (const f of files) {
const existing = docByPath.get(f.cloudName)
if (existing && !existing.dir) overwriteSize += existing.size
}
const netNeed = batchTotal - overwriteSize
if (store.space.free < netNeed + UPLOAD_MARGIN_BYTES) {
store.showToast(
`Not enough free space (need ${formatSize(netNeed + UPLOAD_MARGIN_BYTES)}, have ${formatSize(store.space.free)})`
)
return
}
// Optimistic update: ghost folders and files // Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const docs = getDocuments() const docs = getDocuments()
+12 -1
View File
@@ -13,6 +13,7 @@ export type DocProps = {
dir: boolean dir: boolean
ghost?: boolean ghost?: boolean
expires?: number // Unix timestamp for ghost expiry expires?: number // Unix timestamp for ghost expiry
ar?: number // Aspect ratio (height/width) from server, if known
} }
export class Doc { export class Doc {
@@ -26,6 +27,7 @@ export class Doc {
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry) public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
/** @internal Use the name getter/setter instead */ /** @internal Use the name getter/setter instead */
public _name: string = '' public _name: string = ''
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
@@ -87,6 +89,14 @@ export class Doc {
get print(): boolean { get print(): boolean {
return (FILE_TYPES.print as readonly string[]).includes(this.ext) return (FILE_TYPES.print as readonly string[]).includes(this.ext)
} }
get text(): boolean {
return (FILE_TYPES.text as readonly string[]).includes(this.ext)
}
get editurl(): string {
if (!this.text) return ''
const p = this.loc ? `${this.loc}/${this.name}` : this.name
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
}
get complete(): boolean { get complete(): boolean {
return !this.ghost && (this.dir || this.size <= this.allocated) return !this.ghost && (this.dir || this.size <= this.allocated)
} }
@@ -130,7 +140,8 @@ export type FileEntry = [
number, // mtime number, // mtime
number, // size number, // size
number, // allocated (actual disk usage) number, // allocated (actual disk usage)
number // isfile number, // isfile
number? // ar: aspect ratio (height/width), present if known
] ]
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
+5
View File
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
case !!msg.update: case !!msg.update:
handleUpdateMessage(msg) handleUpdateMessage(msg)
break break
case !!msg.ar: {
const store = useMainStore()
store.updateAr(msg.ar as Record<string, number>)
break
}
case !!msg.space: case !!msg.space:
const store = useMainStore() const store = useMainStore()
store.space = msg.space store.space = msg.space
+19
View File
@@ -1,6 +1,12 @@
import { useMainStore } from '@/stores/main'
import ExplorerView from '@/views/ExplorerView.vue' import ExplorerView from '@/views/ExplorerView.vue'
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
function getPathDepth(path: string): number {
const pathPart = decodeURIComponent(path).split('//')[0] ?? ''
return pathPart.split('/').filter(Boolean).length
}
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [ routes: [
@@ -12,4 +18,17 @@ const router = createRouter({
] ]
}) })
router.beforeEach((to, from) => {
const store = useMainStore()
const toDepth = getPathDepth(to.path)
const fromDepth = getPathDepth(from.path)
if (toDepth > fromDepth) {
store.transitionDirection = 'forward'
} else if (toDepth < fromDepth) {
store.transitionDirection = 'backward'
} else {
store.transitionDirection = 'none'
}
})
export default router export default router
+30 -4
View File
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
import { type SortOrder, sorted } from '@/utils/docsort' import { type SortOrder, sorted } from '@/utils/docsort'
import SearchWorker from '@/workers/searchWorker?worker' import SearchWorker from '@/workers/searchWorker?worker'
import { type StateTree, defineStore } from 'pinia' import { type StateTree, defineStore } from 'pinia'
import { documentRef, getDocuments, setDocuments } from './documentStore' import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
// Singleton search worker instance // Singleton search worker instance
let searchWorker: Worker | null = null let searchWorker: Worker | null = null
@@ -84,7 +84,7 @@ export const useMainStore = defineStore('main', {
paskia?: boolean paskia?: boolean
office_previews?: boolean office_previews?: boolean
}, },
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens', dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
uprogress: {} as any, uprogress: {} as any,
dprogress: {} as any, dprogress: {} as any,
prefs: { prefs: {
@@ -98,6 +98,8 @@ export const useMainStore = defineStore('main', {
privileged: false as boolean, privileged: false as boolean,
isLoggedIn: false as boolean isLoggedIn: false as boolean
}, },
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
editorSave: null as null | (() => void),
space: { space: {
disk: 0, disk: 0,
free: 0, free: 0,
@@ -124,7 +126,7 @@ 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, allocated, isfile] of root) { for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
loc = loc.slice(0, level - 1) loc = loc.slice(0, level - 1)
docs.push( docs.push(
new Doc({ new Doc({
@@ -134,7 +136,8 @@ export const useMainStore = defineStore('main', {
size, size,
allocated, allocated,
mtime, mtime,
dir: !isfile dir: !isfile,
ar
}) })
) )
loc.push(name) loc.push(name)
@@ -157,6 +160,22 @@ export const useMainStore = defineStore('main', {
// Sync documents to search worker // Sync documents to search worker
this.syncSearchWorker() this.syncSearchWorker()
}, },
/** Patch aspect ratios on existing docs from a server ar update message */
updateAr(arMap: Record<string, number>) {
const docs = getDocuments()
let changed = false
for (const doc of docs) {
const ar = arMap[doc.key]
if (ar != null && doc.ar !== ar) {
doc.ar = ar
changed = true
}
}
if (changed) {
triggerUpdate()
this.docVersion++
}
},
/** Add a ghost file/folder for optimistic UI updates */ /** Add a ghost file/folder for optimistic UI updates */
addGhost(doc: Doc) { addGhost(doc: Doc) {
doc.ghost = true doc.ghost = true
@@ -236,6 +255,12 @@ export const useMainStore = defineStore('main', {
})) }))
worker.postMessage({ type: 'update', documents: docData }) worker.postMessage({ type: 'update', documents: docData })
}, },
/** Notify UI/search that existing document objects were mutated in-place */
documentsChanged() {
triggerUpdate()
this.docVersion++
this.syncSearchWorker()
},
search(query: string, loc: string) { search(query: string, loc: string) {
const worker = getSearchWorker() const worker = getSearchWorker()
const id = ++searchId const id = ++searchId
@@ -303,6 +328,7 @@ export const useMainStore = defineStore('main', {
this.connected = false this.connected = false
this.dialog = '' this.dialog = ''
this.cursor = '' this.cursor = ''
this.editorSave = null
}, },
async logout() { async logout() {
console.log('Logout') console.log('Logout')
+11 -3
View File
@@ -5,10 +5,18 @@ export const exists = (path: string[]) => {
const store = useMainStore() const store = useMainStore()
// Access docVersion to make this reactive // Access docVersion to make this reactive
void store.docVersion void store.docVersion
if (path.length === 0) return true
const p = path.join('/') const p = path.join('/')
return getDocuments().some( const hidden = store.hiddenPaths
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p const inDocs = getDocuments().some(doc => {
) const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
return full === p && !hidden.has(full)
})
if (inDocs) return true
return store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return full === p && !hidden.has(full)
})
} }
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */ /** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
+59 -1
View File
@@ -77,7 +77,65 @@ export const FILE_TYPES = {
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'], imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
// Images that require server-side preview (browsers cannot display them natively) // Images that require server-side preview (browsers cannot display them natively)
image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'], image: ['bmp', 'heic', 'heif', 'ico', 'tif', 'tiff'],
print: ['epub', 'mobi', 'pdf'] print: ['epub', 'mobi', 'pdf'],
text: [
'txt',
'md',
'json',
'xml',
'yaml',
'yml',
'toml',
'ini',
'conf',
'config',
'cfg',
'log',
'csv',
'tsv',
'py',
'js',
'ts',
'jsx',
'tsx',
'html',
'htm',
'css',
'scss',
'sass',
'less',
'vue',
'php',
'rb',
'go',
'rs',
'java',
'c',
'cpp',
'h',
'hpp',
'cs',
'swift',
'kt',
'sh',
'bash',
'zsh',
'fish',
'ps1',
'bat',
'cmd',
'sql',
'lua',
'r',
'pl',
'dockerfile',
'makefile',
'gitignore',
'gitattributes',
'env',
'diff',
'patch'
]
} as const } as const
export type FileCategory = keyof typeof FILE_TYPES export type FileCategory = keyof typeof FILE_TYPES
+124
View File
@@ -0,0 +1,124 @@
type ScrollOptions = {
topPad?: number
bottomPad?: number
keyboardWindowMs?: number
getScrollContainer?: () => HTMLElement | null
}
export function createKeyboardFollowScroll(options: ScrollOptions = {}) {
const {
topPad = 84,
bottomPad = 84,
keyboardWindowMs = 260,
getScrollContainer = () =>
(document.querySelector('main') as HTMLElement | null) ?? document.documentElement
} = options
let scrollAnimationFrame: number | null = null
let scrollTargetY: number | null = null
let scrollVelocity = 0
let keyboardFollowUntil = 0
const markKeyboardFollow = () => {
keyboardFollowUntil = performance.now() + keyboardWindowMs
}
const keyboardFollowActive = () => performance.now() < keyboardFollowUntil
const clampScrollY = (y: number, scroller: HTMLElement) => {
const maxY = Math.max(0, scroller.scrollHeight - scroller.clientHeight)
return Math.min(maxY, Math.max(0, y))
}
const cursorScrollTarget = (el: HTMLElement): number | null => {
const scroller = getScrollContainer() ?? document.documentElement
const rect = el.getBoundingClientRect()
const scrollerRect = scroller.getBoundingClientRect()
const visibleTop = scrollerRect.top + topPad
const visibleBottom = scrollerRect.bottom - bottomPad
if (rect.top >= visibleTop && rect.bottom <= visibleBottom) return null
if (rect.top < visibleTop) {
return clampScrollY(scroller.scrollTop + (rect.top - visibleTop), scroller)
}
return clampScrollY(scroller.scrollTop + (rect.bottom - visibleBottom), scroller)
}
const runSmoothCursorScroll = () => {
if (scrollAnimationFrame != null) return
const step = () => {
const scroller = getScrollContainer() ?? document.documentElement
if (scrollTargetY == null) {
scrollVelocity *= 0.68
if (Math.abs(scrollVelocity) > 0.05) {
const next = clampScrollY(scroller.scrollTop + scrollVelocity, scroller)
scroller.scrollTop = next
scrollAnimationFrame = requestAnimationFrame(step)
return
}
scrollVelocity = 0
scrollAnimationFrame = null
return
}
const current = scroller.scrollTop
const delta = scrollTargetY - current
const absDelta = Math.abs(delta)
if (absDelta < 0.6 && Math.abs(scrollVelocity) < 0.08) {
scroller.scrollTop = scrollTargetY
scrollVelocity = 0
scrollTargetY = null
scrollAnimationFrame = null
return
}
const stiffness = Math.min(0.022, 0.01 + absDelta / 10000)
const damping = 0.76
scrollVelocity += delta * stiffness
scrollVelocity *= damping
const next = clampScrollY(current + scrollVelocity, scroller)
if (next === current) scrollVelocity = 0
scroller.scrollTop = next
scrollAnimationFrame = requestAnimationFrame(step)
}
scrollAnimationFrame = requestAnimationFrame(step)
}
const keepVisible = (el: HTMLElement | null) => {
if (!keyboardFollowActive()) {
scrollTargetY = null
scrollVelocity = 0
return
}
if (!el) {
scrollTargetY = null
return
}
const target = cursorScrollTarget(el)
if (target == null) {
scrollTargetY = null
return
}
scrollTargetY = target
runSmoothCursorScroll()
}
const cancel = () => {
if (scrollAnimationFrame != null) cancelAnimationFrame(scrollAnimationFrame)
scrollAnimationFrame = null
scrollTargetY = null
scrollVelocity = 0
keyboardFollowUntil = 0
}
return { markKeyboardFollow, keepVisible, cancel }
}
+53 -22
View File
@@ -1,29 +1,32 @@
<template> <template>
<Gallery <div class="transition-wrapper">
v-if="store.prefs.gallery" <Transition
:name="transitionName"
@after-enter="onAfterEnter"
>
<KeepAlive>
<component
:is="store.prefs.gallery ? Gallery : FileExplorer"
:key="cacheKey"
ref="fileExplorer" ref="fileExplorer"
:key="`gallery-${folderPath}`" class="explorer-content"
:path="props.path"
:documents="documents"
/>
<FileExplorer
v-else
ref="fileExplorer"
:key="`explorer-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
</KeepAlive>
</Transition>
</div>
<div v-if="store.searchLoading" class="search-loading">Searching...</div> <div v-if="store.searchLoading" class="search-loading">Searching...</div>
<EmptyFolder :documents=documents :path=props.path />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
import Gallery from '@/components/Gallery.vue'
import { getDocuments } from '@/stores/documentStore' import { getDocuments } from '@/stores/documentStore'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import { computed, ref, watch, watchEffect } from 'vue' import { computed, nextTick, ref, watch, watchEffect } from 'vue'
const store = useMainStore() const store = useMainStore()
const fileExplorer = ref() const fileExplorer = ref()
@@ -34,6 +37,31 @@ const props = defineProps<{
// Folder path for component keys - only recreate component when folder changes, not search // Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/')) const folderPath = computed(() => props.path.join('/'))
const cacheKey = computed(
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
)
const transitionName = computed(() => {
if (store.transitionDirection === 'forward') return 'slide-forward'
if (store.transitionDirection === 'backward') return 'slide-backward'
return ''
})
const folderScrollTop = new Map<string, number>()
const scrollKey = (path: string) => path || '/'
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
const restoreScroll = (path: string) => {
const scroller = getMainScroller()
if (!scroller) return
const top = folderScrollTop.get(scrollKey(path)) ?? 0
scroller.scrollTop = top
}
const onAfterEnter = () => {
store.transitionDirection = 'none'
restoreScroll(folderPath.value)
}
// Handle route-based search changes (back/forward navigation, direct URL) // Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing) // Skip if store.query already matches (means we triggered this via typing)
@@ -87,6 +115,19 @@ watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
}) })
watch(
folderPath,
async (path, oldPath) => {
const scroller = getMainScroller()
if (scroller && oldPath !== undefined) {
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
}
await nextTick()
requestAnimationFrame(() => restoreScroll(path))
},
{ immediate: true }
)
// Only auto-switch gallery mode when entering a new folder or on initial file list load // Only auto-switch gallery mode when entering a new folder or on initial file list load
watch( watch(
[() => props.path.join('/'), () => store.documentCount], [() => props.path.join('/'), () => store.documentCount],
@@ -100,16 +141,6 @@ watch(
</script> </script>
<style scoped> <style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
.search-loading { .search-loading {
position: fixed; position: fixed;
bottom: 1rem; bottom: 1rem;
+255
View File
@@ -0,0 +1,255 @@
<template>
<div class="text-editor">
<div class="editor-body">
<div v-if="loading" class="status">Loading</div>
<div v-else-if="error" class="status error">{{ error }}</div>
<div v-else ref="editorHost" class="editor-host"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { apiFetch } from '@/repositories/Client'
import { useMainStore } from '@/stores/main'
import { indentWithTab } from '@codemirror/commands'
import { LanguageDescription } from '@codemirror/language'
import { languages } from '@codemirror/language-data'
import { Compartment, EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, keymap } from '@codemirror/view'
import { basicSetup } from 'codemirror'
import {
computed,
nextTick,
onActivated,
onDeactivated,
onMounted,
onUnmounted,
ref
} from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const store = useMainStore()
const MAX_SIZE = 1024 * 1024 // 1 MiB
const filePath = computed(() => {
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
return raw.replace(/^\//, '').replace(/\/$/, '')
})
const filename = computed(() => filePath.value.split('/').pop() || '')
const filesUrl = computed(() => {
return (
'/files/' +
filePath.value
.split('/')
.map(part => encodeURIComponent(part))
.join('/')
)
})
const content = ref('')
const original = ref('')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const editorHost = ref<HTMLDivElement | null>(null)
let editorView: EditorView | null = null
const languageCompartment = new Compartment()
const dirty = computed(() => content.value !== original.value)
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty.value) return
event.preventDefault()
event.returnValue = ''
}
let beforeUnloadActive = false
const activateEditorBindings = () => {
store.editorSave = save
if (!beforeUnloadActive) {
window.addEventListener('beforeunload', beforeUnload)
beforeUnloadActive = true
}
}
const deactivateEditorBindings = () => {
if (store.editorSave === save) {
store.editorSave = null
}
if (beforeUnloadActive) {
window.removeEventListener('beforeunload', beforeUnload)
beforeUnloadActive = false
}
}
const detectLanguage = async () => {
const language = LanguageDescription.matchFilename(languages, filename.value)
if (!language) return []
try {
return [await language.load()]
} catch {
return []
}
}
const initEditor = async (text: string) => {
if (!editorHost.value) return
const languageExtensions = await detectLanguage()
const state = EditorState.create({
doc: text,
extensions: [
basicSetup,
oneDark,
languageCompartment.of(languageExtensions),
EditorView.updateListener.of(update => {
if (update.docChanged) {
content.value = update.state.doc.toString()
}
}),
keymap.of([
{
key: 'Mod-s',
run: () => {
void save()
return true
}
},
indentWithTab
])
]
})
editorView = new EditorView({ state, parent: editorHost.value })
editorView.focus()
}
const save = async () => {
if (saving.value || loading.value) return
saving.value = true
try {
const res = await apiFetch(filesUrl.value, {
method: 'PUT',
body: content.value,
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.message || data.detail || `${res.status} ${res.statusText}`)
}
original.value = content.value
store.showToast(`Saved ${filename.value}`)
} catch (err) {
console.error('Save failed', err)
store.showToast(err instanceof Error ? err.message : 'Save failed')
} finally {
saving.value = false
}
}
onMounted(async () => {
activateEditorBindings()
loading.value = true
error.value = ''
try {
const res = await fetch(filesUrl.value, { method: 'HEAD' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const size = Number(res.headers.get('content-length') || '0')
if (size > MAX_SIZE) {
throw new Error(
`File is too large to edit (${(size / 1024 / 1024).toFixed(1)} MB)`
)
}
const textRes = await fetch(filesUrl.value)
if (!textRes.ok) throw new Error(`${textRes.status} ${textRes.statusText}`)
const text = await textRes.text()
content.value = text
original.value = text
loading.value = false
await nextTick()
await initEditor(text)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load file'
} finally {
if (loading.value) loading.value = false
}
})
onActivated(() => {
activateEditorBindings()
})
onDeactivated(() => {
deactivateEditorBindings()
})
onUnmounted(() => {
deactivateEditorBindings()
editorView?.destroy()
editorView = null
})
</script>
<style scoped>
.text-editor {
display: flex;
flex-direction: column;
height: 100%;
background: #1a1a1a;
color: #ddd;
text-align: left;
}
.editor-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.editor-host {
flex: 1;
min-height: 0;
}
.editor-host :deep(.cm-editor) {
flex: 1;
height: 100%;
border: none;
outline: none;
}
.editor-host :deep(.cm-scroller) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem;
line-height: 1.5;
text-align: left;
}
.editor-host :deep(.cm-content) {
padding: 1rem;
text-align: left;
}
.editor-host :deep(.cm-selectionBackground) {
background: var(--soft-color, #146) !important;
}
.editor-host :deep(.cm-focused .cm-selectionBackground) {
background: var(--soft-color, #146) !important;
}
.editor-host :deep(.cm-content ::selection) {
background: var(--soft-color, #146);
}
.editor-host :deep(.cm-content, .cm-gutter) {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.editor-host :deep(.cm-line, .cm-gutters, .cm-gutterElement) {
text-align: left;
}
.status {
padding: 2rem;
text-align: center;
font-size: 1rem;
color: #888;
}
.status.error {
color: #f55;
}
</style>
-3
View File
@@ -6,9 +6,6 @@
}, },
{ {
"path": "./tsconfig.app.json" "path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
} }
] ]
} }
+1 -7
View File
@@ -1,12 +1,6 @@
{ {
"extends": "@tsconfig/node18/tsconfig.json", "extends": "@tsconfig/node18/tsconfig.json",
"include": [ "include": ["vite.config.*"],
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"types": ["node", "jsdom"]
}
}
+2 -1
View File
@@ -132,6 +132,7 @@ ignore = [
"ANN205", # legacy codebase: no full runtime annotation coverage yet "ANN205", # legacy codebase: no full runtime annotation coverage yet
"BLE001", # broad catch remains in boundary/proxy/error-handling paths "BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled "C901", # legacy complexity; keep other correctness rules enabled
"CPY", # copyright notices not wanted in this codebase
"D100", # legacy docs not yet standardized "D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized "D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized "D102", # legacy docs not yet standardized
@@ -160,7 +161,7 @@ ignore = [
"TRY003", # exception-message strictness too noisy on legacy handlers "TRY003", # exception-message strictness too noisy on legacy handlers
] ]
isort.known-first-party = ["cista"] isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001"] per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001", "SLF001"]
per-file-ignores."scripts/*" = ["T20"] per-file-ignores."scripts/*" = ["T20"]
[dependency-groups] [dependency-groups]
+65
View File
@@ -0,0 +1,65 @@
import errno
from pathlib import Path
from typing import NamedTuple
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from sanic import Sanic
from cista import config, watching
from cista.api import fileserver
from cista.fileserver import bp as fileserver_bp
class Usage(NamedTuple):
total: int
used: int
free: int
def _low_disk_usage(*args, **kwargs):
return Usage(total=1000, used=900, free=10)
@pytest.fixture
def setup_storage(tmp_path: Path):
config.config = config.Config(path=tmp_path, listen=":0", public=True)
watching.state.root = []
watching.rootpath = tmp_path
yield tmp_path
watching.state.root = []
@pytest_asyncio.fixture()
async def client(setup_storage: Path):
app = Sanic(f"disk-space-test-{uuid4().hex}", strict_slashes=True)
app.router.ALLOWED_METHODS = (
*app.router.ALLOWED_METHODS,
"MKCOL",
"MOVE",
"COPY",
"PROPFIND",
)
app.blueprint(fileserver_bp)
await fileserver.start()
yield app.asgi_client
await fileserver.stop()
@pytest.mark.asyncio
async def test_upload_rejected_when_disk_low(client):
with patch("cista.util.diskspace.shutil.disk_usage", side_effect=_low_disk_usage):
_, res = await client.put("/files/test.txt", data=b"hello world")
assert res.status_code == 507
@pytest.mark.asyncio
async def test_upload_rejected_on_enospc(client):
with patch(
"cista.fileio.os.write",
side_effect=OSError(errno.ENOSPC, "No space left on device"),
):
_, res = await client.put("/files/test.txt", data=b"hello world")
assert res.status_code == 507
+24
View File
@@ -115,6 +115,12 @@ def setup_storage(tmp_path: Path):
mode="rw", mode="rw",
share_paths=["docs"], share_paths=["docs"],
) )
share_anon = config.Token(
key="share_anon_123",
kind="share",
mode="ro",
share_paths=["docs"],
)
config.config = config.Config( config.config = config.Config(
path=tmp_path, path=tmp_path,
listen=":0", listen=":0",
@@ -124,6 +130,7 @@ def setup_storage(tmp_path: Path):
"test_token_123": token, "test_token_123": token,
"share_ro_123": share_ro, "share_ro_123": share_ro,
"share_rw_123": share_rw, "share_rw_123": share_rw,
"share_anon_123": share_anon,
}, },
) )
watching.state.root = [] watching.state.root = []
@@ -285,3 +292,20 @@ async def test_share_token_rw_allows_writes_in_scope_only(client):
"/files/secret.txt", headers=_basic_auth("token", "share_rw_123") "/files/secret.txt", headers=_basic_auth("token", "share_rw_123")
) )
assert res.status_code == 404 assert res.status_code == 404
@pytest.mark.asyncio
async def test_anonymous_share_token_requires_public_mode(client):
config.config.public = True
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
)
assert res.status_code == 200
assert res.body == b"A"
config.config.public = False
_, res = await client.get(
"/files/docs/a.txt", headers=_basic_auth("token", "share_anon_123")
)
assert res.status_code == 401
+175
View File
@@ -0,0 +1,175 @@
"""Tests for the preview worker pool resilience.
Regression context: a piped worker stderr that nobody drains used to block
the worker mid-request once the OS pipe buffer filled, and asyncio's
proc.wait() then never resolved even after kill() — wedging one dispatcher
per stuck worker until all preview traffic timed out permanently.
"""
import asyncio
import sys
import textwrap
import time
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from cista import preview
FAKE_WORKER = textwrap.dedent(
"""
import json
import struct
import sys
import blake3
def read_exact(n):
buf = b""
while len(buf) < n:
chunk = sys.stdin.buffer.read(n - len(buf))
if not chunk:
raise EOFError
buf += chunk
return buf
sys.stdout.buffer.write(b"\\x01")
sys.stdout.buffer.flush()
while True:
header = sys.stdin.buffer.read(8)
if not header or len(header) < 8:
break
meta_len, payload_len = struct.unpack("<II", header)
meta = read_exact(meta_len)
read_exact(payload_len)
req = json.loads(meta)
if req["path"].endswith(".block"):
# Simulate a worker stuck on an undrained stderr pipe:
# flood stderr past the OS pipe buffer, then never respond.
import os
import time
try:
os.write(2, b"x" * 10_000_000)
except OSError:
pass
while True:
time.sleep(3600)
resp = json.dumps({"ok": True, "mime": "image/avif", "backend": "fake"}).encode()
payload = b"FAKEIMG"
packet = struct.pack("<II", len(resp), len(payload)) + resp + payload
sys.stdout.buffer.write(blake3.blake3(packet).digest())
sys.stdout.buffer.write(packet)
sys.stdout.buffer.flush()
"""
)
@pytest.mark.asyncio
async def test_pool_recovers_from_wedged_worker(monkeypatch, tmp_path):
"""A worker wedged on an undrained stderr pipe must not kill the pool.
Recreates the old production setup (stderr=PIPE, never drained) and
verifies the request times out, the stuck worker's kill() cannot hang
the dispatcher, and the pool serves the next request normally.
"""
monkeypatch.setattr(preview, "PREVIEW_TIMEOUT", 1.0)
monkeypatch.setattr(preview, "WORKER_KILL_GRACE", 0.5)
monkeypatch.setattr(preview, "WORKER_RESPAWN_DELAY", 0.05)
script = tmp_path / "fake_worker.py"
script.write_text(FAKE_WORKER)
async def fake_spawn(self):
proc = await asyncio.create_subprocess_exec(
sys.executable,
str(script),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
# Deliberately piped-and-undrained, recreating the old
# production setup that wedges a worker on stderr writes.
stderr=asyncio.subprocess.PIPE,
)
preview._active_procs.add(proc)
await asyncio.wait_for(proc.stdout.readexactly(1), timeout=10)
return preview._PreviewWorker(proc)
monkeypatch.setattr(preview._PreviewWorkerPool, "_spawn_worker", fake_spawn)
pool = preview._PreviewWorkerPool(1)
await pool.start()
try:
with pytest.raises(preview.PreviewTimeoutError):
await pool.run(Path("wedged.block"), 60, 512, 2.0)
out, resp = await asyncio.wait_for(
pool.run(Path("ok.jpg"), 60, 512, 2.0), timeout=10
)
assert out == b"FAKEIMG"
assert resp.ok
assert all(not task.done() for task in pool._dispatchers)
finally:
await pool.close()
@pytest.mark.asyncio
async def test_worker_kill_grace_when_wait_hangs(monkeypatch):
"""kill() must return even if asyncio never resolves proc.wait()."""
monkeypatch.setattr(preview, "WORKER_KILL_GRACE", 0.1)
proc = Mock()
proc.returncode = None
proc.pid = 1234
never = asyncio.Future()
async def wait():
await never
proc.wait = wait
worker = preview._PreviewWorker(proc)
preview._active_procs.add(proc)
start = time.monotonic()
await worker.kill()
assert time.monotonic() - start < 2
assert proc not in preview._active_procs
never.cancel()
@pytest.mark.asyncio
async def test_replace_worker_retries_failed_spawn(monkeypatch):
"""A failed replacement spawn must be retried, not silently dropped."""
monkeypatch.setattr(preview, "WORKER_RESPAWN_DELAY", 0.01)
pool = preview._PreviewWorkerPool(1)
old_worker = Mock()
old_worker.proc = Mock(pid=4321)
old_worker.kill = AsyncMock()
attempts = 0
async def add_worker():
nonlocal attempts
attempts += 1
if attempts < 3:
raise OSError("too many open files")
pool._add_worker = add_worker
await pool._replace_worker(old_worker)
assert attempts == 3
@pytest.mark.asyncio
async def test_dispatch_loop_survives_body_errors():
"""Exceptions escaping a dispatch cycle must not kill the dispatcher."""
pool = preview._PreviewWorkerPool(1)
calls = 0
async def dispatch_one():
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("boom")
raise asyncio.CancelledError
pool._dispatch_one = dispatch_one
await pool._dispatch_loop()
assert calls == 2
+18 -1
View File
@@ -1,3 +1,4 @@
import os
from pathlib import Path, PurePath from pathlib import Path, PurePath
from uuid import uuid4 from uuid import uuid4
@@ -23,7 +24,8 @@ def _persist_config():
@pytest.fixture @pytest.fixture
def setup_storage(tmp_path: Path): def setup_storage(tmp_path: Path):
config.init_confdir(tmp_path) os.environ["CISTA_HOME"] = str(tmp_path)
config.init_confdir()
user = config.User() user = config.User()
auth.set_password(user, "secret") auth.set_password(user, "secret")
admin = config.User(privileged=True) admin = config.User(privileged=True)
@@ -213,3 +215,18 @@ async def test_create_share_token(client):
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"] share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
assert len(share_tokens) == 1 assert len(share_tokens) == 1
assert share_tokens[0]["mode"] == "ro" assert share_tokens[0]["mode"] == "ro"
@pytest.mark.asyncio
async def test_create_share_token_public_anonymous(client):
config.config = msgspec.structs.replace(config.config, public=True)
_, res = await client.post(
"/api/share-tokens",
json={"paths": ["hello.txt"], "mode": "ro", "name": "public-share"},
)
assert res.status_code == 200
data = res.json
assert data["kind"] == "share"
assert data["username"] == ""
assert data["sso_user_id"] == ""