Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36764885ed | ||
|
|
5a82560cf2 | ||
|
|
7b1c6f6772 | ||
|
|
c025e7af95 | ||
|
|
3bad311e35 | ||
|
|
fdc4fe0a3e | ||
|
|
5df2308bdb | ||
|
|
f4c44ce1aa | ||
|
|
49232f11cc | ||
|
|
1258eff42d | ||
|
|
718d46e3f9 | ||
|
|
92d9c40a28 | ||
|
|
4f646fb344 | ||
|
|
d6304d0029 | ||
|
|
77e35cf0fc | ||
|
|
bf8a049b92 | ||
|
|
6d7f44bd88 | ||
|
|
e2097a1563 | ||
|
|
b864936eaa | ||
|
|
72b3c0d8ce | ||
|
|
07daf372e8 | ||
|
|
e979d679b2 | ||
|
|
eea66c0013 | ||
|
|
9220c457c0 | ||
|
|
d5b77932ea | ||
|
|
9b9d3e1cc1 | ||
|
|
536efc4ce1 | ||
|
|
1b2267587f | ||
|
|
2864e9f041 | ||
|
|
b5a94b5eee | ||
|
|
d4be755d46 | ||
|
|
07089aa9a7 | ||
|
|
c3146744b7 | ||
|
|
48d7435d0b | ||
|
|
3e80325053 | ||
|
|
31fc02ddbf | ||
|
|
2406ea87b0 | ||
|
|
3a1dd2b7da | ||
|
|
3df6b079c9 |
+3
-1
@@ -40,7 +40,9 @@ async def watch(req, ws):
|
||||
if sso.paskia_enabled():
|
||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||
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:
|
||||
logger.debug("watch SSO validation failed: %s", e)
|
||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ async def log_access(req, res):
|
||||
path = f"{path}?{qs}"
|
||||
extra = getattr(req.ctx, "log_extra", None)
|
||||
line = format_access_log(
|
||||
client, res.status, req.method, host, path, duration_ms, extra=extra
|
||||
client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
|
||||
)
|
||||
access_logger.info(line)
|
||||
return res
|
||||
|
||||
+35
-7
@@ -574,6 +574,13 @@ def _basic_auth_login(request):
|
||||
if username == "token":
|
||||
token = config.config.tokens.get(password)
|
||||
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)
|
||||
if user:
|
||||
request.ctx.session = None
|
||||
@@ -873,14 +880,16 @@ async def verify(request, *, privileged=False):
|
||||
"""
|
||||
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", "")
|
||||
has_auth_header = bool(auth_header)
|
||||
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)
|
||||
auth_flow = list(getattr(request.ctx, "auth_flow", ["session:skipped"]))
|
||||
tried: list[str] = []
|
||||
@@ -941,6 +950,13 @@ async def verify(request, *, privileged=False):
|
||||
quiet=True,
|
||||
)
|
||||
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"):
|
||||
tried.append("ntlm")
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
token = getattr(request.ctx, "auth_token", 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")
|
||||
else:
|
||||
username = current_username or ""
|
||||
if not username:
|
||||
if username:
|
||||
if username not in config.config.users:
|
||||
raise BadRequest("User does not exist")
|
||||
elif not config.config.public:
|
||||
raise BadRequest("Could not determine user")
|
||||
if username not in config.config.users:
|
||||
raise BadRequest("User does not exist")
|
||||
|
||||
token = secrets.token_urlsafe(12)
|
||||
changes = {
|
||||
|
||||
+15
-2
@@ -1,9 +1,11 @@
|
||||
import errno
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from cista import config
|
||||
from cista.util import filename
|
||||
from cista.util.diskspace import InsufficientStorageError, check_free_space
|
||||
from cista.util.lrucache import LRUCache
|
||||
|
||||
|
||||
@@ -34,13 +36,24 @@ class File:
|
||||
self.open_rw()
|
||||
if self.fd is None:
|
||||
raise RuntimeError("file descriptor is not available for write")
|
||||
check_free_space(self.path)
|
||||
if file_size is not None:
|
||||
if pos + len(buffer) > file_size:
|
||||
raise ValueError("write exceeds declared file size")
|
||||
os.ftruncate(self.fd, file_size)
|
||||
try:
|
||||
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:
|
||||
os.lseek(self.fd, pos, os.SEEK_SET)
|
||||
os.write(self.fd, buffer)
|
||||
try:
|
||||
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):
|
||||
if self.fd is None:
|
||||
|
||||
+19
-8
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import errno
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
@@ -12,11 +13,12 @@ from urllib.parse import unquote, urlparse
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
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.api import fileserver
|
||||
from cista.util import filename
|
||||
from cista.util.diskspace import InsufficientStorageError
|
||||
|
||||
bp = Blueprint("fileserver", url_prefix="/files")
|
||||
|
||||
@@ -52,13 +54,22 @@ async def upload_file_chunk(request, name):
|
||||
|
||||
rel, path = _safe_relpath(name, request=request)
|
||||
rel_name = rel.as_posix()
|
||||
upload_info = await asyncio.to_thread(
|
||||
fileserver.upload_info,
|
||||
rel_name,
|
||||
start,
|
||||
body,
|
||||
total,
|
||||
)
|
||||
try:
|
||||
upload_info = await asyncio.to_thread(
|
||||
fileserver.upload_info,
|
||||
rel_name,
|
||||
start,
|
||||
body,
|
||||
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 = []
|
||||
chunk_len = end - start
|
||||
whole_file = start == 0 and end == total
|
||||
|
||||
+154
-119
@@ -77,6 +77,9 @@ _preview_cache = PreviewCache(capacity=500)
|
||||
|
||||
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
||||
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()
|
||||
_preview_pool = None
|
||||
_preview_pool_lock = asyncio.Lock()
|
||||
@@ -144,11 +147,25 @@ class _PreviewWorker:
|
||||
return payload or None, resp
|
||||
|
||||
async def kill(self) -> None:
|
||||
if self.proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
self.proc.kill()
|
||||
await self.proc.wait()
|
||||
_active_procs.discard(self.proc)
|
||||
try:
|
||||
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):
|
||||
self.proc.kill()
|
||||
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)
|
||||
|
||||
|
||||
class _PreviewWorkerPool:
|
||||
@@ -163,23 +180,19 @@ class _PreviewWorkerPool:
|
||||
self._seq = 0
|
||||
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:
|
||||
# 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(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"cista.preview_worker",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
stderr=None,
|
||||
)
|
||||
_active_procs.add(proc)
|
||||
try:
|
||||
@@ -189,21 +202,14 @@ class _PreviewWorkerPool:
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
stderr = await self._read_startup_stderr(proc)
|
||||
if stderr:
|
||||
raise WorkerProtocolError(
|
||||
"preview worker failed to become ready: " + stderr.splitlines()[-1]
|
||||
) from err
|
||||
raise WorkerProtocolError("preview worker failed to become ready") from err
|
||||
raise WorkerProtocolError(
|
||||
"preview worker failed to become ready"
|
||||
" (worker stderr goes to the server log)"
|
||||
) from 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(
|
||||
"preview worker exited before signalling readiness"
|
||||
" (worker stderr goes to the server log)"
|
||||
) from err
|
||||
if ready != b"\x01":
|
||||
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
|
||||
@@ -216,106 +222,135 @@ class _PreviewWorkerPool:
|
||||
|
||||
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||
self._workers.discard(worker)
|
||||
await worker.kill()
|
||||
if self._closed:
|
||||
return
|
||||
try:
|
||||
await self._add_worker()
|
||||
await worker.kill()
|
||||
except Exception:
|
||||
logger.exception("Failed to replace preview worker")
|
||||
|
||||
async def _dispatch_loop(self) -> None:
|
||||
while True:
|
||||
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:
|
||||
_priority, _seq, future, args = await self._pending.get()
|
||||
except asyncio.CancelledError:
|
||||
await self._add_worker()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to replace preview worker (pool %d/%d); retrying in %ds",
|
||||
len(self._workers),
|
||||
self.size,
|
||||
int(delay),
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, WORKER_RESPAWN_DELAY_MAX)
|
||||
else:
|
||||
return
|
||||
|
||||
if future.cancelled():
|
||||
continue
|
||||
|
||||
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:
|
||||
worker = await asyncio.wait_for(
|
||||
self._idle.get(), timeout=PREVIEW_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Preview worker unavailable (%ds) for %s",
|
||||
int(PREVIEW_TIMEOUT),
|
||||
args[0].name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
args[0].name,
|
||||
backend=_expected_preview_backend(args[0]),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
filepath = args[0]
|
||||
replace = False
|
||||
try:
|
||||
out, resp = await asyncio.wait_for(
|
||||
worker.request(*args),
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_result((out, resp))
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
filepath.name,
|
||||
backend=_expected_preview_backend(filepath),
|
||||
)
|
||||
)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||
)
|
||||
except PreviewError as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
except (
|
||||
WorkerProtocolError,
|
||||
asyncio.IncompleteReadError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
ValueError,
|
||||
msgspec.json.DecodeError,
|
||||
) as e:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(
|
||||
f"worker protocol failure for {filepath.name}: {e}"
|
||||
)
|
||||
)
|
||||
await self._dispatch_one()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception:
|
||||
replace = True
|
||||
logger.exception(
|
||||
"Unexpected preview worker error for %s", filepath.name
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||
logger.exception("Preview dispatcher error; continuing")
|
||||
|
||||
async def _dispatch_one(self) -> None:
|
||||
_priority, _seq, future, args = await self._pending.get()
|
||||
|
||||
if future.cancelled():
|
||||
return
|
||||
|
||||
try:
|
||||
worker = await asyncio.wait_for(self._idle.get(), timeout=PREVIEW_TIMEOUT)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Preview worker unavailable (%ds) for %s",
|
||||
int(PREVIEW_TIMEOUT),
|
||||
args[0].name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
args[0].name,
|
||||
backend=_expected_preview_backend(args[0]),
|
||||
)
|
||||
finally:
|
||||
if replace:
|
||||
await self._replace_worker(worker)
|
||||
elif worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
await self._replace_worker(worker)
|
||||
)
|
||||
return
|
||||
|
||||
filepath = args[0]
|
||||
replace = False
|
||||
try:
|
||||
out, resp = await asyncio.wait_for(
|
||||
worker.request(*args),
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_result((out, resp))
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker pid=%s timed out (%ds) on %s; replacing it",
|
||||
worker.proc.pid,
|
||||
int(PREVIEW_TIMEOUT),
|
||||
filepath.name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
filepath.name,
|
||||
backend=_expected_preview_backend(filepath),
|
||||
)
|
||||
)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error(
|
||||
"Preview checksum mismatch for %s (worker pid=%s); replacing it",
|
||||
filepath.name,
|
||||
worker.proc.pid,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||
)
|
||||
except PreviewError as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
except (
|
||||
WorkerProtocolError,
|
||||
asyncio.IncompleteReadError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
ValueError,
|
||||
msgspec.DecodeError,
|
||||
) as e:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker pid=%s protocol failure for %s: %s",
|
||||
worker.proc.pid,
|
||||
filepath.name,
|
||||
e,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
|
||||
)
|
||||
except Exception:
|
||||
replace = True
|
||||
logger.exception("Unexpected preview worker error for %s", filepath.name)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||
)
|
||||
finally:
|
||||
if replace:
|
||||
await self._replace_worker(worker)
|
||||
elif worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
await self._replace_worker(worker)
|
||||
|
||||
async def start(self) -> None:
|
||||
workers = await asyncio.gather(
|
||||
|
||||
+77
-28
@@ -17,6 +17,8 @@ import gc
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shlex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -25,16 +27,26 @@ from pathlib import Path
|
||||
from time import perf_counter
|
||||
|
||||
import av
|
||||
import fitz # PyMuPDF
|
||||
import msgspec
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
import pyvips
|
||||
from blake3 import blake3
|
||||
|
||||
from cista import config
|
||||
from cista.util.logformat import format_level_prefix
|
||||
|
||||
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
|
||||
|
||||
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
||||
@@ -127,13 +139,20 @@ def _read_request() -> tuple[PreviewRequest, bytes] | None:
|
||||
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:
|
||||
out = _protocol_out if _protocol_out is not None else sys.stdout.buffer
|
||||
meta_bytes = _enc.encode(resp)
|
||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||
checksum = blake3(packet).digest()
|
||||
sys.stdout.buffer.write(checksum)
|
||||
sys.stdout.buffer.write(packet)
|
||||
sys.stdout.buffer.flush()
|
||||
out.write(checksum)
|
||||
out.write(packet)
|
||||
out.flush()
|
||||
|
||||
|
||||
def dispatch(path, quality, maxsize, maxzoom, data=None):
|
||||
@@ -217,7 +236,29 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||
cmd.insert(4, "-s")
|
||||
cmd.insert(5, f"{new_w}x{new_h}")
|
||||
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:
|
||||
return f.read()
|
||||
finally:
|
||||
@@ -258,7 +299,7 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
".avif",
|
||||
Q=quality,
|
||||
effort=AVIF_FAST_EFFORT,
|
||||
strip=True,
|
||||
keep="none",
|
||||
)
|
||||
backend = "pyvips"
|
||||
except pyvips.error.Error:
|
||||
@@ -290,7 +331,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||
".avif",
|
||||
Q=quality,
|
||||
effort=AVIF_FAST_EFFORT,
|
||||
strip=True,
|
||||
keep="none",
|
||||
)
|
||||
t_end = perf_counter()
|
||||
|
||||
@@ -306,19 +347,19 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||
|
||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
t_load_start = perf_counter()
|
||||
pdf = fitz.open(path)
|
||||
page = pdf.load_page(page_number)
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
t_load_end = perf_counter()
|
||||
with pymupdf.open(path) as pdf:
|
||||
page = pdf.load_page(page_number)
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
t_load_end = perf_counter()
|
||||
|
||||
t_save_start = perf_counter()
|
||||
img = pyvips.Image.new_from_memory(
|
||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||
)
|
||||
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
|
||||
t_save_start = perf_counter()
|
||||
img = pyvips.Image.new_from_memory(
|
||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||
)
|
||||
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none")
|
||||
backend = "pdf+pyvips"
|
||||
t_save_end = perf_counter()
|
||||
|
||||
@@ -524,24 +565,32 @@ def _run_loop() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# Configure all log output to stderr before any imports that may emit logs.
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
# Configure all log output to stderr before any imports that may emit
|
||||
# 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:
|
||||
config.load_config()
|
||||
logger.warning(
|
||||
"preview-worker config=%s master_secret=%s",
|
||||
config.conffile,
|
||||
config.config.secret,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("preview-worker failed to load config at startup")
|
||||
if len(sys.argv) > 1:
|
||||
_run_once()
|
||||
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
|
||||
# does not hand us a request while we are still initialising.
|
||||
sys.stdout.buffer.write(b"\x01")
|
||||
sys.stdout.buffer.flush()
|
||||
_protocol_out.write(b"\x01")
|
||||
_protocol_out.flush()
|
||||
_run_loop()
|
||||
|
||||
|
||||
|
||||
+4
-31
@@ -3,11 +3,13 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from ipaddress import IPv6Address
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -132,14 +134,6 @@ def format_duration_ms(duration_ms: float) -> str:
|
||||
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:
|
||||
return label[:19].ljust(19)
|
||||
|
||||
@@ -156,6 +150,7 @@ def format_access_log(
|
||||
method: str,
|
||||
host: str,
|
||||
path: str,
|
||||
*,
|
||||
duration_ms: float,
|
||||
extra: str | None = None,
|
||||
) -> str:
|
||||
@@ -278,28 +273,6 @@ def configure_access_logging() -> None:
|
||||
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:
|
||||
"""Replace Sanic's verbose 'Main yyyy-mm-dd INFO:' prefix with emoji-only format.
|
||||
|
||||
|
||||
+11
-1
@@ -62,12 +62,18 @@ async def close_client():
|
||||
_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.
|
||||
|
||||
Args:
|
||||
request: The Sanic request object
|
||||
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:
|
||||
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"]
|
||||
if "authorization" in request.headers:
|
||||
headers["authorization"] = request.headers["authorization"]
|
||||
if "user-agent" in request.headers:
|
||||
headers["user-agent"] = request.headers["user-agent"]
|
||||
headers["accept"] = "application/json"
|
||||
headers["x-forwarded-for"] = request.client_ip
|
||||
headers["x-forwarded-host"] = request.host
|
||||
headers["x-forwarded-proto"] = request.scheme
|
||||
|
||||
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||
if not renew:
|
||||
url += "&renew=0"
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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()
|
||||
+10
-3
@@ -9,7 +9,6 @@ from os import stat_result
|
||||
from pathlib import Path, PurePosixPath
|
||||
from stat import S_ISDIR, S_ISREG
|
||||
|
||||
import inotify.adapters
|
||||
import msgspec
|
||||
from natsort import humansorted, natsort_keygen, ns
|
||||
from sanic.log import logger
|
||||
@@ -18,6 +17,11 @@ from cista import config
|
||||
from cista.fileio import fuid
|
||||
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
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
@@ -665,10 +669,13 @@ DEBOUNCE_MAX = 0.1 # But no more than 100ms total
|
||||
|
||||
def watcher(loop):
|
||||
"""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
|
||||
modified_flags = frozenset()
|
||||
|
||||
if sys.platform == "linux" and inotify_adapters is None:
|
||||
logger.warning("inotify unavailable; falling back to periodic scanning")
|
||||
|
||||
if use_inotify:
|
||||
modified_flags = frozenset(
|
||||
(
|
||||
@@ -684,7 +691,7 @@ def watcher(loop):
|
||||
|
||||
while not stop_event.is_set():
|
||||
if use_inotify:
|
||||
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
inotify_tree = inotify_adapters.InotifyTree(rootpath.as_posix())
|
||||
|
||||
# Initialize the tree from filesystem
|
||||
try:
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"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 .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format --check .",
|
||||
@@ -18,8 +17,11 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@imengyu/vue3-context-menu": "^1.5.3",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"esbuild": "^0.27.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lodash-es": "^4.17.23",
|
||||
@@ -34,17 +36,13 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tsconfig/node18": "^18.2.6",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
+149
-17
@@ -8,13 +8,36 @@
|
||||
<SettingsModal />
|
||||
<UserManagementModal />
|
||||
<UserTokensModal />
|
||||
<AboutModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||
<BreadCrumb :path="path.pathList" primary />
|
||||
<HeaderMain
|
||||
ref="headerMain"
|
||||
:path="path.pathList"
|
||||
:query="path.query"
|
||||
:editor-mode="path.isEditorPath"
|
||||
/>
|
||||
<BreadCrumb
|
||||
:path="path.breadcrumbPathList"
|
||||
:links="path.breadcrumbLinks"
|
||||
primary
|
||||
/>
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
<main class="transition-wrapper">
|
||||
<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>
|
||||
<footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
|
||||
<SelectionToolbar :path="path.pathList" />
|
||||
@@ -26,42 +49,108 @@
|
||||
<script setup lang="ts">
|
||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import Router from '@/router/index'
|
||||
import { computed } from 'vue'
|
||||
import AboutModal from './components/AboutModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
import UserManagementModal from './components/UserManagementModal.vue'
|
||||
import UserTokensModal from './components/UserTokensModal.vue'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import ExplorerView from './views/ExplorerView.vue'
|
||||
import TextEditorView from './views/TextEditorView.vue'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
canonicalPath: string
|
||||
isEditorPath: boolean
|
||||
pathList: string[]
|
||||
breadcrumbPathList: string[]
|
||||
breadcrumbLinks?: string[]
|
||||
query: string
|
||||
}
|
||||
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 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 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 {
|
||||
path: p[0] ?? '',
|
||||
path: rawPath,
|
||||
canonicalPath,
|
||||
isEditorPath,
|
||||
pathList,
|
||||
breadcrumbPathList,
|
||||
breadcrumbLinks,
|
||||
query
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
document.title =
|
||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||
store.server.name ||
|
||||
'Cista Storage'
|
||||
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 =
|
||||
path.value.path.replace(/\/$/, '').split('/').pop() ||
|
||||
store.server.name ||
|
||||
'Cista Storage'
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
onMounted(loadSession)
|
||||
onMounted(watchConnect)
|
||||
onUnmounted(watchDisconnect)
|
||||
@@ -80,7 +169,9 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
const fileExplorer = store.fileExplorer as any
|
||||
if (!fileExplorer) return
|
||||
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'
|
||||
|
||||
// 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 === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'PageUp' ||
|
||||
event.key === 'PageDown' ||
|
||||
(c && event.code === 'Space')
|
||||
) {
|
||||
if (!input) event.preventDefault()
|
||||
@@ -104,6 +197,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
//console.log("key pressed", event)
|
||||
/// Long if-else machina for all keys we handle here
|
||||
let arrow = ''
|
||||
let paging = ''
|
||||
const inHeader = !!(event.target as HTMLElement).closest('.headermain')
|
||||
const inBreadcrumb = !!(event.target as HTMLElement).closest('.breadcrumb')
|
||||
// Handle arrows: in search input with text, only up/down; otherwise all arrows
|
||||
@@ -115,17 +209,35 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
if (searchHasText && (dir === 'left' || dir === 'right')) {
|
||||
return // Let browser handle cursor movement
|
||||
}
|
||||
// Don't intercept arrows for non-search inputs (e.g. rename input)
|
||||
if (input && !searchInput) return
|
||||
arrow = dir
|
||||
} else if (
|
||||
event.key === 'PageUp' ||
|
||||
event.key === 'PageDown' ||
|
||||
event.key === 'Home' ||
|
||||
event.key === 'End'
|
||||
) {
|
||||
if (input) return
|
||||
paging = event.key
|
||||
}
|
||||
if (arrow) {
|
||||
// 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
|
||||
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()
|
||||
}
|
||||
// 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)
|
||||
if (event.key.length === 1 && event.key !== store.prefs.searchHotkey) {
|
||||
store.prefs.searchHotkey = event.key
|
||||
@@ -136,7 +248,11 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
store.error = ''
|
||||
store.clearToast()
|
||||
headerMain.value!.clearSearch(event)
|
||||
// Keep rename and other non-search inputs isolated from search behavior.
|
||||
if (input && !searchInput) return
|
||||
if (!path.value.isEditorPath) {
|
||||
headerMain.value!.clearSearch(event)
|
||||
}
|
||||
store.focusBreadcrumb()
|
||||
} else if (!input && keyup && event.key === 'Backspace') {
|
||||
Router.back()
|
||||
@@ -235,12 +351,28 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
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) {
|
||||
// Initial move, then t0 delay until repeats at tr intervals
|
||||
const t0 = 200,
|
||||
tr = event.altKey ? 20 : 100
|
||||
f()
|
||||
if (paging === 'Home' || paging === 'End') return
|
||||
timer = setTimeout(() => {
|
||||
timer = setInterval(f, tr)
|
||||
}, t0 - tr)
|
||||
|
||||
@@ -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 |
@@ -57,6 +57,68 @@
|
||||
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 {
|
||||
:root {
|
||||
--primary-color: black;
|
||||
@@ -206,6 +268,8 @@ main {
|
||||
min-height: 0; /* Allow flex child to shrink below content size */
|
||||
padding-bottom: 3em; /* convenience space on the bottom */
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<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 |
@@ -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>
|
||||
@@ -1,32 +1,19 @@
|
||||
<template>
|
||||
<div v-if="store.dialog === 'accessdenied'" class="modal-overlay">
|
||||
<div class="modal-dialog" id="accessdenied">
|
||||
<div class="modal-content access-denied">
|
||||
<p class="icon">⛔</p>
|
||||
<p class="message">Access Denied</p>
|
||||
<button @click="reload" class="button">Reload</button>
|
||||
</div>
|
||||
<ModalDialog name="accessdenied" title="">
|
||||
<div class="access-denied">
|
||||
<p class="icon">⛔</p>
|
||||
<p class="message">Access Denied</p>
|
||||
<button @click="reload" class="button">Reload</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { holdGlobalBackdrop } from 'paskia'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
const store = useMainStore()
|
||||
import ModalDialog from '@/components/ModalDialog.vue'
|
||||
|
||||
const reload = () => {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
// Keep backdrop active when this dialog shows
|
||||
watchEffect(() => {
|
||||
if (store.dialog === 'accessdenied') {
|
||||
holdGlobalBackdrop()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
@focus=focusCurrent
|
||||
tabindex=0
|
||||
>
|
||||
<a href="#/"
|
||||
<a :href="`/#${urlAt(0)}`"
|
||||
:ref="el => setLinkRef(0, el)"
|
||||
class="home"
|
||||
:class="{ current: !!isCurrent(0) }"
|
||||
@@ -22,7 +22,7 @@
|
||||
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||
</a>
|
||||
<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) }"
|
||||
:aria-current="isCurrent(index + 1)"
|
||||
@click.prevent="navigate(index + 1)"
|
||||
@@ -62,10 +62,17 @@ const setPathTooltipRef = (index: number, el: any) => {
|
||||
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
links?: Array<string>
|
||||
primary?: boolean
|
||||
}>()
|
||||
|
||||
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) =>
|
||||
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 link = links[index]
|
||||
if (!link) throw Error(`No link at index ${index} (path: ${props.path})`)
|
||||
const url = index ? `/${longest.value.slice(0, index).join('/')}/` : '/'
|
||||
const url = urlAt(index)
|
||||
const long = longest.value.length ? `/${longest.value.join('/')}/` : '/'
|
||||
const browser = decodeURIComponent(location.hash.slice(1).split('//')[0] ?? '')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Clicking on current link clears the rest of the path and adds new history
|
||||
if (isCurrent(index)) {
|
||||
longest.value.splice(index)
|
||||
longestLinks.value.splice(index + 1)
|
||||
router.push(u)
|
||||
}
|
||||
// Moving along breadcrumbs doesn't create new history
|
||||
@@ -102,20 +115,26 @@ const move = (dir: number) => {
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
const currentLinks = props.links ?? defaultLinks(props.path)
|
||||
const longcut = longest.value.slice(0, props.path.length)
|
||||
const same = longcut.every((value, index) => value === props.path[index])
|
||||
// Navigated out of previous path, reset longest to current
|
||||
if (!same) longest.value = props.path
|
||||
else if (props.path.length > longcut.length) {
|
||||
if (!same) {
|
||||
longest.value = props.path
|
||||
longestLinks.value = currentLinks
|
||||
} else if (props.path.length > longcut.length) {
|
||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||
} else {
|
||||
// Prune deleted folders from longest
|
||||
for (let i = props.path.length; i < longest.value.length; ++i) {
|
||||
if (!exists(longest.value.slice(0, i + 1))) {
|
||||
longest.value = longest.value.slice(0, i)
|
||||
longestLinks.value = longestLinks.value.slice(0, i + 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||
}
|
||||
// If needed, focus primary navigation to new location
|
||||
if (props.primary)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</defs>
|
||||
|
||||
<g :filter="isExpanded ? 'url(#pieShadow)' : 'none'">
|
||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" stroke="url(#otherGradient)" :stroke-width="ringWidth" />
|
||||
<circle :r="midRadius" :cx="pieCx" :cy="pieCy" fill="transparent" :stroke="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="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" />
|
||||
@@ -38,12 +38,12 @@
|
||||
<g ref="labelsRef" class="pie-labels">
|
||||
<text :x="storageInnerPos.x" :y="storageInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.storage.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.storage.angle)} ${storageInnerPos.x} ${storageInnerPos.y})`">{{ fmtSize(store.space.allocated, sectorInfo.storage.angle) }}</text>
|
||||
<text :x="freeInnerPos.x" :y="freeInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.free.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.free.angle)} ${freeInnerPos.x} ${freeInnerPos.y})`">{{ fmtSize(store.space.free, sectorInfo.free.angle) }}</text>
|
||||
<text :x="otherInnerPos.x" :y="otherInnerPos.y" class="pie-label-inner" :text-anchor="getSizeAnchor(sectorInfo.other.angle)" dominant-baseline="middle" :transform="`rotate(${getSizeRotation(sectorInfo.other.angle)} ${otherInnerPos.x} ${otherInnerPos.y})`">{{ fmtSize(store.space.used - store.space.allocated, sectorInfo.other.angle) }}</text>
|
||||
<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>
|
||||
<path :id="storageLabelPath.id" :d="storageLabelPath.d" fill="none" />
|
||||
<path :id="freeLabelPath.id" :d="freeLabelPath.d" fill="none" />
|
||||
<path :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||
<path v-if="showOtherCategory" :id="otherLabelPath.id" :d="otherLabelPath.d" fill="none" />
|
||||
</defs>
|
||||
|
||||
<text class="pie-label-sub" fill="#93e">
|
||||
@@ -52,7 +52,7 @@
|
||||
<text class="pie-label-sub" :fill="freeColor">
|
||||
<textPath :href="'#' + freeLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">free</textPath>
|
||||
</text>
|
||||
<text class="pie-label-sub" fill="#d9f">
|
||||
<text v-if="showOtherCategory" class="pie-label-sub" fill="#d9f">
|
||||
<textPath :href="'#' + otherLabelPath.id" startOffset="50%" text-anchor="middle" dominant-baseline="middle">other</textPath>
|
||||
</text>
|
||||
</g>
|
||||
@@ -98,18 +98,30 @@ const truncateLabel = (name: string, maxLen = 10): string => {
|
||||
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
|
||||
const storageMaxLen = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return 10
|
||||
// Sector spans in degrees
|
||||
const storageSpan = (s.allocated / s.disk) * 360
|
||||
const freeSpan = (s.free / s.disk) * 360
|
||||
const otherSpan = ((s.used - s.allocated) / s.disk) * 360
|
||||
const freeSpan = (freeSliceBytes.value / s.disk) * 360
|
||||
const otherSpan = (otherBytes.value / s.disk) * 360
|
||||
// Angular gap from storage label midpoint to neighbor label midpoints
|
||||
const gapToFree = (storageSpan + freeSpan) / 2
|
||||
const gapToOther = (storageSpan + otherSpan) / 2
|
||||
const minGap = Math.min(gapToFree, gapToOther)
|
||||
const minGap = showOtherCategory.value
|
||||
? Math.min(gapToFree, (storageSpan + otherSpan) / 2)
|
||||
: gapToFree
|
||||
// Allow longer names when there's sufficient gap to both neighbors
|
||||
if (minGap > 70) return 18
|
||||
if (minGap > 55) return 14
|
||||
@@ -143,7 +155,7 @@ const pieStorageDash = computed(() => {
|
||||
const pieFreeDash = computed(() => {
|
||||
const s = store.space
|
||||
if (!s.disk) return `0 ${CIRC}`
|
||||
return `${(s.free / s.disk) * CIRC} ${CIRC}`
|
||||
return `${(freeSliceBytes.value / s.disk) * CIRC} ${CIRC}`
|
||||
})
|
||||
|
||||
const pieFreeOffsetVal = computed(() => {
|
||||
@@ -179,8 +191,8 @@ const sectorInfo = computed(() => {
|
||||
}
|
||||
|
||||
const storagePct = s.allocated / s.disk
|
||||
const freePct = s.free / s.disk
|
||||
const otherPct = (s.used - s.allocated) / s.disk
|
||||
const freePct = freeSliceBytes.value / s.disk
|
||||
const otherPct = showOtherCategory.value ? otherBytes.value / s.disk : 0
|
||||
|
||||
const storageAngle = storagePct * 180 // midpoint of storage sector
|
||||
const freeStart = storagePct * 360
|
||||
@@ -198,7 +210,7 @@ const sectorInfo = computed(() => {
|
||||
const rawAngles = computed(() => ({
|
||||
storage: sectorInfo.value.storage.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)
|
||||
@@ -219,7 +231,7 @@ const otherInnerPos = computed(() =>
|
||||
const labelLengths = computed(() => ({
|
||||
storage: storageName.value.length,
|
||||
free: 4,
|
||||
other: 5
|
||||
...(showOtherCategory.value ? { other: 5 } : {})
|
||||
}))
|
||||
|
||||
const getGapForPair = (len1: number, len2: number) => {
|
||||
@@ -232,7 +244,9 @@ const adjustedLabelAngles = computed(() => {
|
||||
const labels = [
|
||||
{ id: 'storage', angle: angles.storage, len: lens.storage },
|
||||
{ id: 'free', angle: angles.free, len: lens.free },
|
||||
{ id: 'other', angle: angles.other, len: lens.other }
|
||||
...(showOtherCategory.value
|
||||
? [{ id: 'other', angle: angles.other!, len: lens.other! }]
|
||||
: [])
|
||||
]
|
||||
labels.sort((a, b) => a.angle - b.angle)
|
||||
|
||||
@@ -283,7 +297,11 @@ const freeLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 }]"/>
|
||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||
<p v-else-if="!store.connected">No Connection</p>
|
||||
@@ -14,6 +14,7 @@
|
||||
import { Cog } from '@/assets/svg'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const cog = Cog
|
||||
const store = useMainStore()
|
||||
@@ -21,9 +22,29 @@ const props = defineProps<{
|
||||
path: string[]
|
||||
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>
|
||||
|
||||
<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 {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
|
||||
@@ -1,74 +1,77 @@
|
||||
<template>
|
||||
<table v-if="props.documents.length || editing">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||
<th class="menu"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="editing?.key === 'new'" class="folder">
|
||||
<td class="selection"></td>
|
||||
<td class="name">
|
||||
<FileRenameInput :doc="editing" :rename="mkdir" :exit="() => {editing = null}" />
|
||||
</td>
|
||||
<FileModified :doc=editing :now=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
<div class="file-explorer">
|
||||
<table v-if="props.documents.length || editing">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||
<th class="menu"></th>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||
<td class="selection"></td>
|
||||
<td class="name">
|
||||
<template v-if="editing === doc">
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href=doc.url tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
{{ doc.name }}
|
||||
</a>
|
||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc :now=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||
</td>
|
||||
<FileModified :doc=editing :now=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr class="summary" v-if="props.documents.length > 1">
|
||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
<td class="name">
|
||||
<template v-if="editing === doc">
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
{{ doc.name }}
|
||||
</a>
|
||||
<button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc :now=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr class="summary" v-if="props.documents.length > 1">
|
||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -76,15 +79,18 @@ import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exitEditing = () => {
|
||||
editing.value = null
|
||||
}
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
store.documentsChanged()
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.documentsChanged()
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
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() {
|
||||
console.log('New folder')
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
loc: loc.value,
|
||||
@@ -156,7 +251,7 @@ defineExpose({
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
@@ -176,14 +271,33 @@ defineExpose({
|
||||
} else {
|
||||
store.selected.add(key)
|
||||
}
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
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) {
|
||||
// Only go back if we're in a subfolder (not at root)
|
||||
if (props.path.length > 0) {
|
||||
@@ -197,8 +311,6 @@ defineExpose({
|
||||
if (a) a.click()
|
||||
},
|
||||
cursorMove(d: number, ev: KeyboardEvent | null) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
store.cursor = ''
|
||||
@@ -207,35 +319,9 @@ defineExpose({
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index = store.cursor
|
||||
? docs.findIndex(doc => doc.key === store.cursor)
|
||||
: docs.length
|
||||
const index = getCursorIndex()
|
||||
const moveto = increment(index, d)
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
if (select) {
|
||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||
if (p === N) continue
|
||||
const key = docs[p]!.key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
moveCursorTo(moveto, ev)
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
@@ -248,24 +334,44 @@ const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value?.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus()
|
||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||
// stale props - their watchers must not react to global store changes.
|
||||
let isActive = true
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||
exitEditing()
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
)
|
||||
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 = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
let nowkey = ref(0)
|
||||
let modifiedTimer: any = null
|
||||
const updateModified = () => {
|
||||
@@ -276,26 +382,51 @@ onMounted(() => {
|
||||
modifiedTimer = setInterval(updateModified, 1000)
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
active.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
onActivated(() => {
|
||||
isActive = true
|
||||
})
|
||||
onDeactivated(() => {
|
||||
isActive = false
|
||||
if (editing.value) exitEditing()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
keyboardFollowScroll.cancel()
|
||||
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.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
store.cursor = doc.key
|
||||
exitEditing()
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
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))
|
||||
router.push(doc.urlrouter)
|
||||
if (doc.dir) {
|
||||
router.push(doc.urlrouter)
|
||||
} else {
|
||||
router.push(editRoute(path))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Mkdir failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
||||
console.error('Create failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||
}
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
@@ -429,9 +560,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-explorer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
height: auto;
|
||||
}
|
||||
thead tr {
|
||||
position: sticky;
|
||||
@@ -492,6 +628,12 @@ table td {
|
||||
.name .rename-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
tbody tr:hover .name .rename-button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
animation: appear calc(5 * var(--transition-time)) linear;
|
||||
}
|
||||
@keyframes appear {
|
||||
@@ -562,12 +704,6 @@ tbody .selection input {
|
||||
content: '📁';
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.empty-container {
|
||||
padding-top: 3rem;
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.folder-change {
|
||||
margin-left: -.5rem;
|
||||
}
|
||||
@@ -578,4 +714,3 @@ tbody .selection input {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -60,6 +60,7 @@ input#FileRenameInput {
|
||||
padding: .75em;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="props.documents.length || editing" class="gallery" ref="gallery">
|
||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||
<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>
|
||||
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||
<GalleryFigure
|
||||
@@ -8,10 +8,12 @@
|
||||
: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>
|
||||
</div>
|
||||
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -19,16 +21,18 @@ import { apiFetch } from '@/repositories/Client'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import type { SortOrder } from '@/utils/docsort'
|
||||
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -60,19 +64,23 @@ const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => {
|
||||
editing.value = null
|
||||
}
|
||||
const onFigureRename = (doc: Doc) => {
|
||||
editing.value = doc
|
||||
store.cursor = doc.key
|
||||
}
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
store.documentsChanged()
|
||||
try {
|
||||
const dstUrl = doc.loc ? filesUrl(doc.loc) : '/files/'
|
||||
const res = await apiFetch(
|
||||
`${dstUrl}?mv=${doc.key}&to=${encodeURIComponent(newName)}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
const targetUrl = `${dstUrl}${dstUrl.endsWith('/') ? '' : '/'}${encodeURIComponent(newName)}`
|
||||
const res = await apiFetch(`${targetUrl}?mv=${doc.key}`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error(await parseErrorMessage(res))
|
||||
} catch (err) {
|
||||
console.error('Rename failed', err)
|
||||
doc.name = oldName
|
||||
store.documentsChanged()
|
||||
store.showToast(err instanceof Error ? err.message : 'Rename failed')
|
||||
}
|
||||
}
|
||||
@@ -85,7 +93,7 @@ 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(25 * 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)
|
||||
|
||||
@@ -168,6 +176,7 @@ const onImgLoad = (e: Event) => {
|
||||
}
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
if (gallery.value.getBoundingClientRect().width <= 0) return
|
||||
const style = getComputedStyle(gallery.value)
|
||||
const templates = style.gridTemplateColumns
|
||||
.split(' ')
|
||||
@@ -182,7 +191,95 @@ const updateColumns = () => {
|
||||
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
||||
}
|
||||
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({
|
||||
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() {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
editing.value = new Doc({
|
||||
@@ -211,7 +308,7 @@ defineExpose({
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
@@ -231,23 +328,42 @@ defineExpose({
|
||||
} else {
|
||||
store.selected.add(key)
|
||||
}
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(1, null)
|
||||
},
|
||||
up(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(-columns.value, ev)
|
||||
},
|
||||
down(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(columns.value, ev)
|
||||
},
|
||||
left(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
this.cursorMove(-1, ev)
|
||||
},
|
||||
right(ev: KeyboardEvent) {
|
||||
markKeyboardFollow()
|
||||
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) {
|
||||
const select = !!ev?.shiftKey
|
||||
// Move cursor up or down (keyboard navigation)
|
||||
const docs = props.documents
|
||||
if (docs.length === 0) {
|
||||
store.cursor = ''
|
||||
@@ -256,7 +372,7 @@ defineExpose({
|
||||
const N = docs.length
|
||||
const mod = (a: number, b: number) => ((a % b) + b) % b
|
||||
const increment = (i: number, d: number) => mod(i + d, N + 1)
|
||||
const index = store.cursor ? docs.findIndex(doc => doc.key === store.cursor) : N
|
||||
const index = getCursorIndex()
|
||||
// Stop navigation sideways away from the grid (only with up/down)
|
||||
if (ev && index === 0 && ev.key === 'ArrowLeft') return
|
||||
if (ev && index === N - 1 && ev.key === 'ArrowRight') return
|
||||
@@ -268,31 +384,7 @@ defineExpose({
|
||||
// Wrapping either end, just land outside the list
|
||||
if (Math.abs(d) >= N || Math.sign(d) !== Math.sign(moveto - index)) moveto = N
|
||||
}
|
||||
store.cursor = docs[moveto]?.key ?? ''
|
||||
const tr = store.cursor ? document.getElementById(`file-${store.cursor}`) : ''
|
||||
if (select) {
|
||||
// Go forwards, possibly wrapping over the end; the last entry is not toggled
|
||||
let [begin, end] = d > 0 ? [index, moveto] : [moveto, index]
|
||||
for (let p = begin; p !== end; p = increment(p, 1)) {
|
||||
if (p === N) continue
|
||||
const key = docs[p]!.key
|
||||
if (store.selected.has(key)) store.selected.delete(key)
|
||||
else store.selected.add(key)
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
scrolltr = tr
|
||||
if (!scrolltimer) {
|
||||
scrolltimer = setTimeout(() => {
|
||||
if (scrolltr) scrolltr.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
scrolltimer = null
|
||||
}, 300)
|
||||
}
|
||||
// When leaving the file list: up goes to breadcrumbs, down goes to header
|
||||
if (moveto === N) {
|
||||
if (d < 0) focusBreadcrumb()
|
||||
else focusHeader()
|
||||
}
|
||||
moveCursorTo(moveto, ev)
|
||||
}
|
||||
})
|
||||
const focusHeader = () => {
|
||||
@@ -305,62 +397,117 @@ const focusBreadcrumb = () => {
|
||||
const el = document.querySelector('.breadcrumb') as HTMLElement | null
|
||||
if (el) el.focus()
|
||||
}
|
||||
let scrolltimer: any = null
|
||||
let scrolltr: any = null
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus()
|
||||
a.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||
// stale props - their watchers must not react to global store changes.
|
||||
let isActive = true
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||
exit()
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
)
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && !editing.value) {
|
||||
const a = document.querySelector(`#file-${cursor}`) 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 = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
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(() => {
|
||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||
if (active) {
|
||||
active.scrollIntoView({ block: 'center', behavior: 'instant' })
|
||||
active.focus()
|
||||
active.focus({ preventScroll: true })
|
||||
}
|
||||
updateColumns()
|
||||
seedFromDocs()
|
||||
if (gallery.value) {
|
||||
resizeObserver = new ResizeObserver(updateColumns)
|
||||
resizeObserver.observe(gallery.value)
|
||||
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||
}
|
||||
attachGalleryObservers()
|
||||
})
|
||||
onActivated(() => {
|
||||
isActive = true
|
||||
nextTick(() => {
|
||||
updateColumns()
|
||||
attachGalleryObservers()
|
||||
})
|
||||
})
|
||||
onDeactivated(() => {
|
||||
isActive = false
|
||||
detachGalleryObservers()
|
||||
if (editing.value) exit()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||
keyboardFollowScroll.cancel()
|
||||
detachGalleryObservers()
|
||||
})
|
||||
|
||||
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||
watch(() => props.documents, seedFromDocs)
|
||||
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.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
store.cursor = doc.key
|
||||
exit()
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
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))
|
||||
router.push(doc.urlrouter)
|
||||
if (doc.dir) {
|
||||
router.push(doc.urlrouter)
|
||||
} else {
|
||||
router.push(editRoute(path))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Mkdir failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Mkdir failed')
|
||||
console.error('Create failed', err)
|
||||
store.showToast(err instanceof Error ? err.message : 'Create failed')
|
||||
}
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
@@ -490,7 +637,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||
align-items: end;
|
||||
align-items: start;
|
||||
align-content: start;
|
||||
}
|
||||
.folder-indicator {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
@@ -10,21 +10,33 @@
|
||||
>
|
||||
<figure>
|
||||
<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>
|
||||
<figcaption @click.prevent @contextmenu.prevent="$emit('menu', $event)">
|
||||
<template v-if="editing">
|
||||
<FileRenameInput :doc=doc :rename=editing.rename :exit=editing.exit />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
<div class=namespacer></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<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 v-if="doc.sparseIndicator" class="tooltip-sparse">{{ sparseText }}</div>
|
||||
</CursorTooltip>
|
||||
@@ -37,12 +49,14 @@ import { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { formatSize } from '@/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
import SparseIndicator from './SparseIndicator.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const router = useRouter()
|
||||
type EditingProp = {
|
||||
rename: (name: string) => void
|
||||
rename: (doc: Doc, newName: string) => void
|
||||
exit: () => void
|
||||
}
|
||||
|
||||
@@ -50,6 +64,10 @@ const props = defineProps<{
|
||||
doc: Doc
|
||||
editing?: EditingProp
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename'): void
|
||||
(e: 'menu', ev: MouseEvent): void
|
||||
}>()
|
||||
const m = ref<typeof MediaPreview | null>(null)
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
@@ -60,8 +78,27 @@ const sparseText = computed(() => {
|
||||
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) => {
|
||||
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
|
||||
}
|
||||
</script>
|
||||
@@ -81,6 +118,75 @@ const onclick = (ev: Event) => {
|
||||
.after-name {
|
||||
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 {
|
||||
height: var(--gallery-figure-height, 15em);
|
||||
max-height: var(--gallery-figure-height, 15em);
|
||||
@@ -116,9 +222,9 @@ figcaption {
|
||||
width: 100%;
|
||||
}
|
||||
figcaption input[type='checkbox'] {
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
margin: .25em 0 .25em .25em;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
margin: .25em .4em .25em .35em;
|
||||
opacity: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--transition-time) ease-in-out;
|
||||
@@ -126,17 +232,10 @@ figcaption input[type='checkbox'] {
|
||||
figcaption input[type='checkbox']:checked, figcaption:hover input[type='checkbox'] {
|
||||
opacity: 1;
|
||||
}
|
||||
figcaption span {
|
||||
cursor: default;
|
||||
padding: .5em;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 .2em #000, 0 0 .2em #000;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
.cursor .filename {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.cursor figcaption span {
|
||||
.cursor .file-ext {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
figcaption .namespacer {
|
||||
@@ -144,4 +243,17 @@ figcaption .namespacer {
|
||||
height: 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>
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
<template>
|
||||
<nav class="headermain buttons">
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
tooltip="New folder"
|
||||
@click="() => { store.fileExplorer!.newFolder() }"
|
||||
/>
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
<template v-if="!props.editorMode">
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-file"
|
||||
tooltip="New file"
|
||||
@click="() => { store.fileExplorer!.newFile() }"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
<div v-if="showSortHints" class="sort-hints">
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
tooltip="New folder"
|
||||
@click="() => { store.fileExplorer!.newFolder() }"
|
||||
/>
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" tabindex="-1" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">{{ store.prefs.searchHotkey }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!props.editorMode && showSortHints" class="sort-hints">
|
||||
<span class="sort-label">Order</span>
|
||||
<span class="keycap">1</span>
|
||||
<span class="keycap">2</span>
|
||||
<span class="keycap">3</span>
|
||||
</div>
|
||||
<SvgButton
|
||||
v-if="props.editorMode"
|
||||
name="disk"
|
||||
tooltip="Save (Ctrl/Cmd+S)"
|
||||
@click="store.editorSave?.()"
|
||||
/>
|
||||
<div class="spacer smallgap"></div>
|
||||
<DiskSpace v-if="store.space.disk" />
|
||||
<SvgButton name="cog" @click="settingsMenu" />
|
||||
@@ -49,6 +62,7 @@ const textInputFocused = ref(false)
|
||||
const props = defineProps<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
editorMode?: 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({
|
||||
// @ts-ignore
|
||||
x: e.target.getBoundingClientRect().right,
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
<template>
|
||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||
<SpinnerIcon />
|
||||
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -18,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<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 { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
@@ -30,11 +56,58 @@ const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
const previewImageFailed = ref(false)
|
||||
const nativeImageFailed = ref(false)
|
||||
const previewImageLoading = ref(true)
|
||||
const nativeImageLoading = ref(true)
|
||||
const previewSrc = computed(() =>
|
||||
props.doc.previewurl
|
||||
? `${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 = () => {
|
||||
if (!media.value) return
|
||||
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
|
||||
border-radius: calc(.5em / 8);
|
||||
}
|
||||
.preview-progress {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.preview-progress :deep(svg) {
|
||||
width: 4.5em;
|
||||
height: 4.5em;
|
||||
opacity: 0.8;
|
||||
animation: media-preview-spin 0.9s linear infinite;
|
||||
.preview-progress .icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@keyframes media-preview-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.preview-image-shell {
|
||||
position: relative;
|
||||
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 {
|
||||
transform: rotate(360deg);
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.98;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.86;
|
||||
}
|
||||
}
|
||||
.folder::before {
|
||||
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
|
||||
figure.cursor .icon {
|
||||
filter: brightness(1);
|
||||
}
|
||||
img::before {
|
||||
/* broken image */
|
||||
text-shadow: 0 0 .5rem #000;
|
||||
filter: grayscale(1);
|
||||
content: '❌';
|
||||
}
|
||||
.video-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -15,15 +15,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
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 dialog = ref<HTMLDivElement | null>(null)
|
||||
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 = () => {
|
||||
store.dialog = ''
|
||||
releaseGlobalBackdrop()
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -33,7 +73,6 @@ const props = defineProps<{
|
||||
|
||||
const show = () => {
|
||||
store.dialog = props.name
|
||||
holdGlobalBackdrop()
|
||||
nextTick(() => {
|
||||
overlay.value?.focus()
|
||||
const input = dialog.value?.querySelector('input')
|
||||
@@ -41,6 +80,29 @@ const show = () => {
|
||||
})
|
||||
}
|
||||
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(() => {
|
||||
if (overlay.value) {
|
||||
overlay.value.focus()
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
</div>
|
||||
<span class="select-size">{{ selectionDisplay.size }}</span>
|
||||
<DownloadButton />
|
||||
<button
|
||||
class="action-button"
|
||||
title="Copy share link (Alt-click for read/write)"
|
||||
<SvgButton
|
||||
name="link"
|
||||
tooltip="Copy share link (Alt-click for read/write)"
|
||||
@click="copyShareLink"
|
||||
>share</button>
|
||||
/>
|
||||
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||
@@ -29,7 +29,7 @@
|
||||
@mouseenter="unselectTooltip?.startHover"
|
||||
@mousemove="unselectTooltip?.updatePosition"
|
||||
@mouseleave="unselectTooltip?.endHover"
|
||||
>✖ selection</button>
|
||||
>✖ deselect</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
import { collator, formatSize } from '@/utils'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -44,6 +44,7 @@ type InflightBlock = {
|
||||
}
|
||||
|
||||
const UPLOAD_BLOCK_SIZE = 16 << 20 // 16 MiB
|
||||
const UPLOAD_MARGIN_BYTES = 512 * 1024 * 1024 // 512 MiB
|
||||
function pasteHandler(event: ClipboardEvent) {
|
||||
const items = Array.from(event.clipboardData?.items ?? [])
|
||||
const infiles = [] as File[]
|
||||
@@ -116,6 +117,28 @@ const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
}
|
||||
if (!files.length) return
|
||||
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
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const docs = getDocuments()
|
||||
|
||||
@@ -89,6 +89,14 @@ export class Doc {
|
||||
get print(): boolean {
|
||||
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 {
|
||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import ExplorerView from '@/views/ExplorerView.vue'
|
||||
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({
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
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
|
||||
|
||||
@@ -84,7 +84,7 @@ export const useMainStore = defineStore('main', {
|
||||
paskia?: boolean
|
||||
office_previews?: boolean
|
||||
},
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens' | 'about',
|
||||
uprogress: {} as any,
|
||||
dprogress: {} as any,
|
||||
prefs: {
|
||||
@@ -98,6 +98,8 @@ export const useMainStore = defineStore('main', {
|
||||
privileged: false as boolean,
|
||||
isLoggedIn: false as boolean
|
||||
},
|
||||
transitionDirection: 'none' as 'forward' | 'backward' | 'none',
|
||||
editorSave: null as null | (() => void),
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
@@ -253,6 +255,12 @@ export const useMainStore = defineStore('main', {
|
||||
}))
|
||||
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) {
|
||||
const worker = getSearchWorker()
|
||||
const id = ++searchId
|
||||
@@ -320,6 +328,7 @@ export const useMainStore = defineStore('main', {
|
||||
this.connected = false
|
||||
this.dialog = ''
|
||||
this.cursor = ''
|
||||
this.editorSave = null
|
||||
},
|
||||
async logout() {
|
||||
console.log('Logout')
|
||||
|
||||
@@ -5,10 +5,18 @@ export const exists = (path: string[]) => {
|
||||
const store = useMainStore()
|
||||
// Access docVersion to make this reactive
|
||||
void store.docVersion
|
||||
if (path.length === 0) return true
|
||||
const p = path.join('/')
|
||||
return getDocuments().some(
|
||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
|
||||
)
|
||||
const hidden = store.hiddenPaths
|
||||
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.) */
|
||||
|
||||
@@ -77,7 +77,65 @@ export const FILE_TYPES = {
|
||||
imageBrowser: ['avif', 'gif', 'jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||
// Images that require server-side preview (browsers cannot display them natively)
|
||||
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
|
||||
|
||||
export type FileCategory = keyof typeof FILE_TYPES
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,29 +1,32 @@
|
||||
<template>
|
||||
<Gallery
|
||||
v-if="store.prefs.gallery"
|
||||
ref="fileExplorer"
|
||||
:key="`gallery-${folderPath}`"
|
||||
:path="props.path"
|
||||
:documents="documents"
|
||||
/>
|
||||
<FileExplorer
|
||||
v-else
|
||||
ref="fileExplorer"
|
||||
:key="`explorer-${folderPath}`"
|
||||
:path="props.path"
|
||||
:documents="documents"
|
||||
/>
|
||||
<div class="transition-wrapper">
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
@after-enter="onAfterEnter"
|
||||
>
|
||||
<KeepAlive>
|
||||
<component
|
||||
:is="store.prefs.gallery ? Gallery : FileExplorer"
|
||||
:key="cacheKey"
|
||||
ref="fileExplorer"
|
||||
class="explorer-content"
|
||||
:path="props.path"
|
||||
:documents="documents"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</div>
|
||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||
<EmptyFolder :documents=documents :path=props.path />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FileExplorer from '@/components/FileExplorer.vue'
|
||||
import Gallery from '@/components/Gallery.vue'
|
||||
import { getDocuments } from '@/stores/documentStore'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { collator } from '@/utils'
|
||||
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 fileExplorer = ref()
|
||||
@@ -34,6 +37,31 @@ const props = defineProps<{
|
||||
|
||||
// Folder path for component keys - only recreate component when folder changes, not search
|
||||
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)
|
||||
// Skip if store.query already matches (means we triggered this via typing)
|
||||
@@ -87,6 +115,19 @@ watchEffect(() => {
|
||||
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
|
||||
watch(
|
||||
[() => props.path.join('/'), () => store.documentCount],
|
||||
@@ -100,16 +141,6 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 2rem;
|
||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.search-loading {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
|
||||
@@ -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>
|
||||
@@ -6,9 +6,6 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"include": ["vite.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -132,6 +132,7 @@ ignore = [
|
||||
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||
"C901", # legacy complexity; keep other correctness rules enabled
|
||||
"CPY", # copyright notices not wanted in this codebase
|
||||
"D100", # legacy docs not yet standardized
|
||||
"D101", # 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
|
||||
]
|
||||
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"]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -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
|
||||
@@ -115,6 +115,12 @@ def setup_storage(tmp_path: Path):
|
||||
mode="rw",
|
||||
share_paths=["docs"],
|
||||
)
|
||||
share_anon = config.Token(
|
||||
key="share_anon_123",
|
||||
kind="share",
|
||||
mode="ro",
|
||||
share_paths=["docs"],
|
||||
)
|
||||
config.config = config.Config(
|
||||
path=tmp_path,
|
||||
listen=":0",
|
||||
@@ -124,6 +130,7 @@ def setup_storage(tmp_path: Path):
|
||||
"test_token_123": token,
|
||||
"share_ro_123": share_ro,
|
||||
"share_rw_123": share_rw,
|
||||
"share_anon_123": share_anon,
|
||||
},
|
||||
)
|
||||
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")
|
||||
)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -215,3 +215,18 @@ async def test_create_share_token(client):
|
||||
share_tokens = [t for t in res.json["tokens"] if t.get("kind") == "share"]
|
||||
assert len(share_tokens) == 1
|
||||
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"] == ""
|
||||
|
||||
Reference in New Issue
Block a user