Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1c6f6772 | ||
|
|
c025e7af95 | ||
|
|
3bad311e35 | ||
|
|
fdc4fe0a3e | ||
|
|
5df2308bdb | ||
|
|
f4c44ce1aa | ||
|
|
49232f11cc | ||
|
|
1258eff42d | ||
|
|
718d46e3f9 | ||
|
|
92d9c40a28 | ||
|
|
4f646fb344 | ||
|
|
d6304d0029 | ||
|
|
77e35cf0fc | ||
|
|
bf8a049b92 | ||
|
|
6d7f44bd88 | ||
|
|
e2097a1563 | ||
|
|
b864936eaa |
+3
-1
@@ -40,7 +40,9 @@ async def watch(req, ws):
|
|||||||
if sso.paskia_enabled():
|
if sso.paskia_enabled():
|
||||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||||
try:
|
try:
|
||||||
await sso.validate_sso_request(req)
|
# WebSocket cannot forward Set-Cookie, so ask the auth backend not to
|
||||||
|
# renew the session here; renewal happens on the HTTP side instead.
|
||||||
|
await sso.validate_sso_request(req, renew=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("watch SSO validation failed: %s", e)
|
logger.debug("watch SSO validation failed: %s", e)
|
||||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||||
|
|||||||
+1
-1
@@ -86,7 +86,7 @@ async def log_access(req, res):
|
|||||||
path = f"{path}?{qs}"
|
path = f"{path}?{qs}"
|
||||||
extra = getattr(req.ctx, "log_extra", None)
|
extra = getattr(req.ctx, "log_extra", None)
|
||||||
line = format_access_log(
|
line = format_access_log(
|
||||||
client, res.status, req.method, host, path, duration_ms, extra=extra
|
client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
|
||||||
)
|
)
|
||||||
access_logger.info(line)
|
access_logger.info(line)
|
||||||
return res
|
return res
|
||||||
|
|||||||
+1
-3
@@ -1293,9 +1293,7 @@ def _token_belongs_to_user(token, username, sso_user_id):
|
|||||||
|
|
||||||
def _is_anonymous_share_token(token: config.Token) -> bool:
|
def _is_anonymous_share_token(token: config.Token) -> bool:
|
||||||
return (
|
return (
|
||||||
sharefs.is_share_token(token)
|
sharefs.is_share_token(token) and not token.username and not token.sso_user_id
|
||||||
and not token.username
|
|
||||||
and not token.sso_user_id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+79
-44
@@ -77,6 +77,9 @@ _preview_cache = PreviewCache(capacity=500)
|
|||||||
|
|
||||||
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
||||||
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||||
|
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
|
||||||
|
WORKER_RESPAWN_DELAY = 1.0 # initial delay before retrying a failed worker spawn
|
||||||
|
WORKER_RESPAWN_DELAY_MAX = 30.0
|
||||||
_active_procs: set[asyncio.subprocess.Process] = set()
|
_active_procs: set[asyncio.subprocess.Process] = set()
|
||||||
_preview_pool = None
|
_preview_pool = None
|
||||||
_preview_pool_lock = asyncio.Lock()
|
_preview_pool_lock = asyncio.Lock()
|
||||||
@@ -144,10 +147,24 @@ class _PreviewWorker:
|
|||||||
return payload or None, resp
|
return payload or None, resp
|
||||||
|
|
||||||
async def kill(self) -> None:
|
async def kill(self) -> None:
|
||||||
|
try:
|
||||||
if self.proc.returncode is None:
|
if self.proc.returncode is None:
|
||||||
|
# Safe to hard-kill: the worker is stateless per request.
|
||||||
|
# proc.wait() must not be awaited unaided: if a pipe
|
||||||
|
# transport is flow-control paused (e.g. an undrained stderr
|
||||||
|
# pipe), asyncio may never resolve wait() even after SIGKILL,
|
||||||
|
# which would permanently wedge the calling dispatcher.
|
||||||
with contextlib.suppress(ProcessLookupError):
|
with contextlib.suppress(ProcessLookupError):
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
await self.proc.wait()
|
try:
|
||||||
|
await asyncio.wait_for(self.proc.wait(), timeout=WORKER_KILL_GRACE)
|
||||||
|
except TimeoutError:
|
||||||
|
logger.error(
|
||||||
|
"Preview worker pid=%s not reaped within %ds of kill",
|
||||||
|
self.proc.pid,
|
||||||
|
int(WORKER_KILL_GRACE),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
_active_procs.discard(self.proc)
|
_active_procs.discard(self.proc)
|
||||||
|
|
||||||
|
|
||||||
@@ -163,23 +180,19 @@ class _PreviewWorkerPool:
|
|||||||
self._seq = 0
|
self._seq = 0
|
||||||
self._closed = False
|
self._closed = False
|
||||||
|
|
||||||
async def _read_startup_stderr(self, proc: asyncio.subprocess.Process) -> str:
|
|
||||||
if proc.stderr is None:
|
|
||||||
return ""
|
|
||||||
with contextlib.suppress(TimeoutError):
|
|
||||||
data = await asyncio.wait_for(proc.stderr.read(), timeout=0.5)
|
|
||||||
return data.decode(errors="replace").strip()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
async def _spawn_worker(self) -> _PreviewWorker:
|
async def _spawn_worker(self) -> _PreviewWorker:
|
||||||
|
# stderr is inherited, not piped: a piped stderr that nobody drains
|
||||||
|
# eventually fills its OS buffer, blocking the worker mid-request,
|
||||||
|
# and its flow-control-paused transport makes proc.wait() hang even
|
||||||
|
# after kill() — together this used to permanently wedge the pool.
|
||||||
|
# Inheriting sends worker diagnostics straight to the server log.
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"cista.preview_worker",
|
"cista.preview_worker",
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=None,
|
||||||
start_new_session=True,
|
|
||||||
)
|
)
|
||||||
_active_procs.add(proc)
|
_active_procs.add(proc)
|
||||||
try:
|
try:
|
||||||
@@ -189,21 +202,14 @@ class _PreviewWorkerPool:
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
stderr = await self._read_startup_stderr(proc)
|
|
||||||
if stderr:
|
|
||||||
raise WorkerProtocolError(
|
raise WorkerProtocolError(
|
||||||
"preview worker failed to become ready: " + stderr.splitlines()[-1]
|
"preview worker failed to become ready"
|
||||||
|
" (worker stderr goes to the server log)"
|
||||||
) from err
|
) from err
|
||||||
raise WorkerProtocolError("preview worker failed to become ready") from err
|
|
||||||
except asyncio.IncompleteReadError as err:
|
except asyncio.IncompleteReadError as err:
|
||||||
stderr = await self._read_startup_stderr(proc)
|
|
||||||
if stderr:
|
|
||||||
raise WorkerProtocolError(
|
|
||||||
"preview worker exited before signalling readiness: "
|
|
||||||
+ stderr.splitlines()[-1]
|
|
||||||
) from err
|
|
||||||
raise WorkerProtocolError(
|
raise WorkerProtocolError(
|
||||||
"preview worker exited before signalling readiness"
|
"preview worker exited before signalling readiness"
|
||||||
|
" (worker stderr goes to the server log)"
|
||||||
) from err
|
) from err
|
||||||
if ready != b"\x01":
|
if ready != b"\x01":
|
||||||
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
|
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
|
||||||
@@ -216,28 +222,48 @@ class _PreviewWorkerPool:
|
|||||||
|
|
||||||
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||||
self._workers.discard(worker)
|
self._workers.discard(worker)
|
||||||
|
try:
|
||||||
await worker.kill()
|
await worker.kill()
|
||||||
if self._closed:
|
except Exception:
|
||||||
return
|
logger.exception("Failed to kill preview worker pid=%s", worker.proc.pid)
|
||||||
|
# Keep retrying until a replacement is up: a pool that silently
|
||||||
|
# shrinks degrades all preview traffic to timeouts.
|
||||||
|
delay = WORKER_RESPAWN_DELAY
|
||||||
|
while not self._closed:
|
||||||
try:
|
try:
|
||||||
await self._add_worker()
|
await self._add_worker()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to replace preview worker")
|
logger.exception(
|
||||||
|
"Failed to replace preview worker (pool %d/%d); retrying in %ds",
|
||||||
async def _dispatch_loop(self) -> None:
|
len(self._workers),
|
||||||
while True:
|
self.size,
|
||||||
try:
|
int(delay),
|
||||||
_priority, _seq, future, args = await self._pending.get()
|
)
|
||||||
except asyncio.CancelledError:
|
await asyncio.sleep(delay)
|
||||||
|
delay = min(delay * 2, WORKER_RESPAWN_DELAY_MAX)
|
||||||
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
async def _dispatch_loop(self) -> None:
|
||||||
|
# Nothing may escape the loop body: a dispatcher that dies silently
|
||||||
|
# permanently shrinks pool capacity and degrades all preview
|
||||||
|
# traffic to timeouts.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self._dispatch_one()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Preview dispatcher error; continuing")
|
||||||
|
|
||||||
|
async def _dispatch_one(self) -> None:
|
||||||
|
_priority, _seq, future, args = await self._pending.get()
|
||||||
|
|
||||||
if future.cancelled():
|
if future.cancelled():
|
||||||
continue
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
worker = await asyncio.wait_for(
|
worker = await asyncio.wait_for(self._idle.get(), timeout=PREVIEW_TIMEOUT)
|
||||||
self._idle.get(), timeout=PREVIEW_TIMEOUT
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Preview worker unavailable (%ds) for %s",
|
"Preview worker unavailable (%ds) for %s",
|
||||||
@@ -251,7 +277,7 @@ class _PreviewWorkerPool:
|
|||||||
backend=_expected_preview_backend(args[0]),
|
backend=_expected_preview_backend(args[0]),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
return
|
||||||
|
|
||||||
filepath = args[0]
|
filepath = args[0]
|
||||||
replace = False
|
replace = False
|
||||||
@@ -264,6 +290,12 @@ class _PreviewWorkerPool:
|
|||||||
future.set_result((out, resp))
|
future.set_result((out, resp))
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
replace = True
|
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():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewTimeoutError(
|
PreviewTimeoutError(
|
||||||
@@ -273,7 +305,11 @@ class _PreviewWorkerPool:
|
|||||||
)
|
)
|
||||||
except WorkerChecksumError:
|
except WorkerChecksumError:
|
||||||
replace = True
|
replace = True
|
||||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
logger.error(
|
||||||
|
"Preview checksum mismatch for %s (worker pid=%s); replacing it",
|
||||||
|
filepath.name,
|
||||||
|
worker.proc.pid,
|
||||||
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||||
@@ -288,23 +324,22 @@ class _PreviewWorkerPool:
|
|||||||
ConnectionResetError,
|
ConnectionResetError,
|
||||||
OSError,
|
OSError,
|
||||||
ValueError,
|
ValueError,
|
||||||
msgspec.json.DecodeError,
|
msgspec.DecodeError,
|
||||||
) as e:
|
) as e:
|
||||||
replace = True
|
replace = True
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Preview worker protocol failure for %s: %s", filepath.name, e
|
"Preview worker pid=%s protocol failure for %s: %s",
|
||||||
|
worker.proc.pid,
|
||||||
|
filepath.name,
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewError(
|
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
|
||||||
f"worker protocol failure for {filepath.name}: {e}"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
replace = True
|
replace = True
|
||||||
logger.exception(
|
logger.exception("Unexpected preview worker error for %s", filepath.name)
|
||||||
"Unexpected preview worker error for %s", filepath.name
|
|
||||||
)
|
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewError(f"unexpected worker error for {filepath.name}")
|
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||||
|
|||||||
+35
-14
@@ -26,9 +26,9 @@ from pathlib import Path
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
|
||||||
import av
|
import av
|
||||||
import fitz # PyMuPDF
|
|
||||||
import msgspec
|
import msgspec
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pymupdf
|
||||||
import pyvips
|
import pyvips
|
||||||
from blake3 import blake3
|
from blake3 import blake3
|
||||||
|
|
||||||
@@ -128,13 +128,20 @@ def _read_request() -> tuple[PreviewRequest, bytes] | None:
|
|||||||
return req, data
|
return req, data
|
||||||
|
|
||||||
|
|
||||||
|
# Raw stdout buffer reserved for the binary protocol once main() redirects
|
||||||
|
# Python-level stdout to stderr. None means "use sys.stdout.buffer as-is"
|
||||||
|
# (CLI single-shot mode, where real stdout is wanted).
|
||||||
|
_protocol_out = None
|
||||||
|
|
||||||
|
|
||||||
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||||
|
out = _protocol_out if _protocol_out is not None else sys.stdout.buffer
|
||||||
meta_bytes = _enc.encode(resp)
|
meta_bytes = _enc.encode(resp)
|
||||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||||
checksum = blake3(packet).digest()
|
checksum = blake3(packet).digest()
|
||||||
sys.stdout.buffer.write(checksum)
|
out.write(checksum)
|
||||||
sys.stdout.buffer.write(packet)
|
out.write(packet)
|
||||||
sys.stdout.buffer.flush()
|
out.flush()
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom, data=None):
|
def dispatch(path, quality, maxsize, maxzoom, data=None):
|
||||||
@@ -219,7 +226,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
|||||||
cmd.insert(5, f"{new_w}x{new_h}")
|
cmd.insert(5, f"{new_w}x{new_h}")
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
# 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:
|
except subprocess.CalledProcessError as e:
|
||||||
shell_cmd = shlex.join(cmd)
|
shell_cmd = shlex.join(cmd)
|
||||||
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
||||||
@@ -318,11 +336,11 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
|||||||
|
|
||||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
t_load_start = perf_counter()
|
t_load_start = perf_counter()
|
||||||
pdf = fitz.open(path)
|
with pymupdf.open(path) as pdf:
|
||||||
page = pdf.load_page(page_number)
|
page = pdf.load_page(page_number)
|
||||||
w, h = page.rect[2:4]
|
w, h = page.rect[2:4]
|
||||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
mat = fitz.Matrix(zoom, zoom)
|
mat = pymupdf.Matrix(zoom, zoom)
|
||||||
pix = page.get_pixmap(matrix=mat)
|
pix = page.get_pixmap(matrix=mat)
|
||||||
t_load_end = perf_counter()
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
@@ -540,20 +558,23 @@ def main() -> None:
|
|||||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||||
try:
|
try:
|
||||||
config.load_config()
|
config.load_config()
|
||||||
logger.warning(
|
logger.info("preview-worker config=%s", config.conffile)
|
||||||
"preview-worker config=%s master_secret=%s",
|
|
||||||
config.conffile,
|
|
||||||
config.config.secret,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("preview-worker failed to load config at startup")
|
logger.exception("preview-worker failed to load config at startup")
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
_run_once()
|
_run_once()
|
||||||
return
|
return
|
||||||
|
# The command channel is a binary protocol on fd 1. Anything printed to
|
||||||
|
# stdout by Python code (e.g. a library emitting a warning via print())
|
||||||
|
# would corrupt the protocol, so redirect Python-level stdout to stderr
|
||||||
|
# (the server log) and keep the raw buffer solely for protocol traffic.
|
||||||
|
global _protocol_out
|
||||||
|
_protocol_out = sys.stdout.buffer
|
||||||
|
sys.stdout = sys.stderr
|
||||||
# Eagerly import heavy modules before signalling readiness so the parent
|
# Eagerly import heavy modules before signalling readiness so the parent
|
||||||
# does not hand us a request while we are still initialising.
|
# does not hand us a request while we are still initialising.
|
||||||
sys.stdout.buffer.write(b"\x01")
|
_protocol_out.write(b"\x01")
|
||||||
sys.stdout.buffer.flush()
|
_protocol_out.flush()
|
||||||
_run_loop()
|
_run_loop()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ def format_access_log(
|
|||||||
method: str,
|
method: str,
|
||||||
host: str,
|
host: str,
|
||||||
path: str,
|
path: str,
|
||||||
|
*,
|
||||||
duration_ms: float,
|
duration_ms: float,
|
||||||
extra: str | None = None,
|
extra: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
|||||||
+11
-1
@@ -62,12 +62,18 @@ async def close_client():
|
|||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
|
async def validate_sso_request(
|
||||||
|
request, *, perm: str = "cista:login", renew: bool = True
|
||||||
|
) -> dict | None:
|
||||||
"""Validate an SSO request against the auth backend.
|
"""Validate an SSO request against the auth backend.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The Sanic request object
|
request: The Sanic request object
|
||||||
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
||||||
|
renew: Whether to allow the auth backend to renew the session cookie.
|
||||||
|
Use ``False`` for WebSocket validation where Set-Cookie cannot be
|
||||||
|
forwarded to the client; this makes the request read-only and avoids
|
||||||
|
resetting the backend renewal timeout.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User info dict if valid, None if validation fails with auth required response
|
User info dict if valid, None if validation fails with auth required response
|
||||||
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
|||||||
headers["cookie"] = request.headers["cookie"]
|
headers["cookie"] = request.headers["cookie"]
|
||||||
if "authorization" in request.headers:
|
if "authorization" in request.headers:
|
||||||
headers["authorization"] = request.headers["authorization"]
|
headers["authorization"] = request.headers["authorization"]
|
||||||
|
if "user-agent" in request.headers:
|
||||||
|
headers["user-agent"] = request.headers["user-agent"]
|
||||||
headers["accept"] = "application/json"
|
headers["accept"] = "application/json"
|
||||||
headers["x-forwarded-for"] = request.client_ip
|
headers["x-forwarded-for"] = request.client_ip
|
||||||
headers["x-forwarded-host"] = request.host
|
headers["x-forwarded-host"] = request.host
|
||||||
headers["x-forwarded-proto"] = request.scheme
|
headers["x-forwarded-proto"] = request.scheme
|
||||||
|
|
||||||
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||||
|
if not renew:
|
||||||
|
url += "&renew=0"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:unit": "vitest",
|
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format --check .",
|
"format:check": "biome format --check .",
|
||||||
@@ -37,17 +36,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@tsconfig/node18": "^18.2.6",
|
"@tsconfig/node18": "^18.2.6",
|
||||||
"@types/jsdom": "^27.0.0",
|
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^25.1.0",
|
"@types/node": "^25.1.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/test-utils": "^2.4.6",
|
|
||||||
"@vue/tsconfig": "^0.8.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.18",
|
|
||||||
"vue-tsc": "^3.2.4"
|
"vue-tsc": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-14
@@ -29,7 +29,13 @@
|
|||||||
@after-enter="store.transitionDirection = 'none'"
|
@after-enter="store.transitionDirection = 'none'"
|
||||||
>
|
>
|
||||||
<div :key="routeViewKey" class="explorer-content">
|
<div :key="routeViewKey" class="explorer-content">
|
||||||
<RouterView :path="path.pathList" :query="path.query" />
|
<KeepAlive>
|
||||||
|
<component
|
||||||
|
:is="routeViewComponent"
|
||||||
|
:key="routeViewKey"
|
||||||
|
v-bind="routeViewProps"
|
||||||
|
/>
|
||||||
|
</KeepAlive>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
@@ -43,10 +49,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type HeaderMain from '@/components/HeaderMain.vue'
|
import type HeaderMain from '@/components/HeaderMain.vue'
|
||||||
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
import { loadSession, watchConnect, watchDisconnect } from '@/repositories/WS'
|
||||||
|
import { getDocuments } from '@/stores/documentStore'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
|
||||||
|
|
||||||
import Router from '@/router/index'
|
import Router from '@/router/index'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
@@ -57,9 +63,12 @@ import type SettingsModalVue from './components/SettingsModal.vue'
|
|||||||
import UserManagementModal from './components/UserManagementModal.vue'
|
import UserManagementModal from './components/UserManagementModal.vue'
|
||||||
import UserTokensModal from './components/UserTokensModal.vue'
|
import UserTokensModal from './components/UserTokensModal.vue'
|
||||||
import type { SortOrder } from './utils/docsort'
|
import type { SortOrder } from './utils/docsort'
|
||||||
|
import ExplorerView from './views/ExplorerView.vue'
|
||||||
|
import TextEditorView from './views/TextEditorView.vue'
|
||||||
|
|
||||||
interface Path {
|
interface Path {
|
||||||
path: string
|
path: string
|
||||||
|
canonicalPath: string
|
||||||
isEditorPath: boolean
|
isEditorPath: boolean
|
||||||
pathList: string[]
|
pathList: string[]
|
||||||
breadcrumbPathList: string[]
|
breadcrumbPathList: string[]
|
||||||
@@ -67,26 +76,40 @@ interface Path {
|
|||||||
query: string
|
query: string
|
||||||
}
|
}
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
|
const getDocByPath = (fullPath: string) =>
|
||||||
|
getDocuments().find(
|
||||||
|
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
|
||||||
|
)
|
||||||
|
|
||||||
const path: ComputedRef<Path> = computed(() => {
|
const path: ComputedRef<Path> = computed(() => {
|
||||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||||
const routePathList = (p[0] ?? '').split('/').filter(value => value !== '')
|
const rawPath = p[0] ?? ''
|
||||||
|
const routePathList = rawPath.split('/').filter(value => value !== '')
|
||||||
const query = p.slice(1).join('//')
|
const query = p.slice(1).join('//')
|
||||||
const isEditorPath = routePathList[0] === 'edit'
|
const fullPath = routePathList.join('/')
|
||||||
const pathList = isEditorPath ? routePathList.slice(1, -1) : routePathList
|
// Access docVersion to make route mode reactive to tree updates
|
||||||
const breadcrumbPathList = isEditorPath
|
void store.docVersion
|
||||||
? routePathList.slice(1)
|
const doc = fullPath ? getDocByPath(fullPath) : null
|
||||||
: routePathList
|
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
|
const breadcrumbLinks = isEditorPath
|
||||||
? [
|
? [
|
||||||
'/',
|
'/',
|
||||||
...routePathList
|
...routePathList
|
||||||
.slice(1, -1)
|
.slice(0, -1)
|
||||||
.map((_, index) => `/${routePathList.slice(1, index + 2).join('/')}/`),
|
.map((_, index) => `/${routePathList.slice(0, index + 1).join('/')}/`),
|
||||||
`/${routePathList.join('/')}`
|
`/${fullPath}`
|
||||||
]
|
]
|
||||||
: undefined
|
: undefined
|
||||||
return {
|
return {
|
||||||
path: p[0] ?? '',
|
path: rawPath,
|
||||||
|
canonicalPath,
|
||||||
isEditorPath,
|
isEditorPath,
|
||||||
pathList,
|
pathList,
|
||||||
breadcrumbPathList,
|
breadcrumbPathList,
|
||||||
@@ -99,10 +122,25 @@ const routeTransitionName = computed(() => {
|
|||||||
if (store.transitionDirection === 'backward') return 'slide-backward'
|
if (store.transitionDirection === 'backward') return 'slide-backward'
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
const routeViewComponent = computed(() =>
|
||||||
|
path.value.isEditorPath ? TextEditorView : ExplorerView
|
||||||
|
)
|
||||||
const routeViewKey = computed(() => {
|
const routeViewKey = computed(() => {
|
||||||
const route = Router.currentRoute.value
|
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
|
||||||
return route.name === 'editor' ? route.path : String(route.name ?? route.path)
|
|
||||||
})
|
})
|
||||||
|
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(
|
watch(
|
||||||
() => path.value.path,
|
() => path.value.path,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -62,10 +62,12 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explorer-content {
|
.explorer-content {
|
||||||
grid-area: 1 / 1;
|
grid-area: 1 / 1;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-forward-enter-active,
|
.slide-forward-enter-active,
|
||||||
|
|||||||
@@ -122,8 +122,7 @@ watchEffect(() => {
|
|||||||
if (!same) {
|
if (!same) {
|
||||||
longest.value = props.path
|
longest.value = props.path
|
||||||
longestLinks.value = currentLinks
|
longestLinks.value = currentLinks
|
||||||
}
|
} else if (props.path.length > longcut.length) {
|
||||||
else if (props.path.length > longcut.length) {
|
|
||||||
longest.value = longcut.concat(props.path.slice(longcut.length))
|
longest.value = longcut.concat(props.path.slice(longcut.length))
|
||||||
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -104,7 +104,9 @@ const showOtherCategory = computed(() => {
|
|||||||
return !!s.disk && otherBytes.value / s.disk >= 0.01
|
return !!s.disk && otherBytes.value / s.disk >= 0.01
|
||||||
})
|
})
|
||||||
const freeSliceBytes = computed(() =>
|
const freeSliceBytes = computed(() =>
|
||||||
showOtherCategory.value ? store.space.free : Math.max(0, store.space.disk - store.space.allocated)
|
showOtherCategory.value
|
||||||
|
? store.space.free
|
||||||
|
: Math.max(0, store.space.disk - store.space.allocated)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculate max label length based on angular gap to neighbor labels
|
// Calculate max label length based on angular gap to neighbor labels
|
||||||
@@ -295,7 +297,11 @@ const freeLabelPath = computed(() =>
|
|||||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||||
)
|
)
|
||||||
const otherLabelPath = computed(() =>
|
const otherLabelPath = computed(() =>
|
||||||
createArcPath(adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle, 'other', 5)
|
createArcPath(
|
||||||
|
adjustedLabelAngles.value.other ?? sectorInfo.value.other.angle,
|
||||||
|
'other',
|
||||||
|
5
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
const handleClick = () => (isExpanded.value ? collapse() : expand())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
<div v-if="showEmpty" class="empty-container">
|
||||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||||
<p v-else-if="!store.connected">No Connection</p>
|
<p v-else-if="!store.connected">No Connection</p>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
import { Cog } from '@/assets/svg'
|
import { Cog } from '@/assets/svg'
|
||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { exists } from '@/utils/fileutil'
|
import { exists } from '@/utils/fileutil'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const cog = Cog
|
const cog = Cog
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
@@ -21,9 +22,29 @@ const props = defineProps<{
|
|||||||
path: string[]
|
path: string[]
|
||||||
documents: Document[]
|
documents: Document[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const showEmpty = computed(() => {
|
||||||
|
const loc = props.path.join('/')
|
||||||
|
const hasVisibleGhost = store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return g.loc === loc && !store.hiddenPaths.has(full)
|
||||||
|
})
|
||||||
|
|
||||||
|
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.empty-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 2rem;
|
||||||
|
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="file-explorer">
|
||||||
<table v-if="props.documents.length || editing">
|
<table v-if="props.documents.length || editing">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||||
<td class="selection"></td>
|
<td class="selection"></td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<FileRenameInput :doc="editing" :rename="createItem" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=editing :now=nowkey />
|
<FileModified :doc=editing :now=nowkey />
|
||||||
<FileSize :doc=editing />
|
<FileSize :doc=editing />
|
||||||
@@ -46,13 +47,13 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="name">
|
<td class="name">
|
||||||
<template v-if="editing === doc">
|
<template v-if="editing === doc">
|
||||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||||
{{ doc.name }}
|
{{ doc.name }}
|
||||||
</a>
|
</a>
|
||||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
<button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<FileModified :doc=doc :now=nowkey />
|
<FileModified :doc=doc :now=nowkey />
|
||||||
@@ -69,6 +70,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -81,11 +84,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
|
|||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watchEffect
|
watch
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import FileRenameInput from './FileRenameInput.vue'
|
import FileRenameInput from './FileRenameInput.vue'
|
||||||
@@ -189,6 +194,9 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
|||||||
|
|
||||||
// File rename
|
// File rename
|
||||||
const editing = shallowRef<Doc | null>(null)
|
const editing = shallowRef<Doc | null>(null)
|
||||||
|
const exitEditing = () => {
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
@@ -243,7 +251,7 @@ defineExpose({
|
|||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
store.cursor = docs[0]!.key
|
store.cursor = docs[0]!.key
|
||||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${store.cursor} .name a`
|
||||||
@@ -329,22 +337,41 @@ const focusBreadcrumb = () => {
|
|||||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
// stale props - their watchers must not react to global store changes.
|
||||||
if (editing.value) store.cursor = editing.value?.key
|
let isActive = true
|
||||||
if (store.cursor) {
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
|
exitEditing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (cursor && !editing.value) {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor} .name a`
|
`#file-${cursor} .name a`
|
||||||
) as HTMLAnchorElement | null
|
) as HTMLAnchorElement | null
|
||||||
if (a) a.focus({ preventScroll: true })
|
if (a) a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
watchEffect(() => {
|
{ flush: 'post' }
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
let nowkey = ref(0)
|
let nowkey = ref(0)
|
||||||
let modifiedTimer: any = null
|
let modifiedTimer: any = null
|
||||||
const updateModified = () => {
|
const updateModified = () => {
|
||||||
@@ -358,12 +385,19 @@ onMounted(() => {
|
|||||||
active.focus({ preventScroll: true })
|
active.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
onActivated(() => {
|
||||||
|
isActive = true
|
||||||
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
isActive = false
|
||||||
|
if (editing.value) exitEditing()
|
||||||
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
keyboardFollowScroll.cancel()
|
keyboardFollowScroll.cancel()
|
||||||
clearInterval(modifiedTimer)
|
clearInterval(modifiedTimer)
|
||||||
})
|
})
|
||||||
const editRoute = (path: string) =>
|
const editRoute = (path: string) =>
|
||||||
'/edit/' +
|
'/' +
|
||||||
path
|
path
|
||||||
.split('/')
|
.split('/')
|
||||||
.map(part => encodeURIComponent(part))
|
.map(part => encodeURIComponent(part))
|
||||||
@@ -373,7 +407,8 @@ const createItem = async (doc: Doc, name: string) => {
|
|||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
store.cursor = doc.key
|
||||||
|
exitEditing()
|
||||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
try {
|
try {
|
||||||
const res = doc.dir
|
const res = doc.dir
|
||||||
@@ -525,9 +560,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.file-explorer {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
thead tr {
|
thead tr {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
@@ -588,6 +628,12 @@ table td {
|
|||||||
.name .rename-button {
|
.name .rename-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
tbody tr:hover .name .rename-button {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
animation: appear calc(5 * var(--transition-time)) linear;
|
animation: appear calc(5 * var(--transition-time)) linear;
|
||||||
}
|
}
|
||||||
@keyframes appear {
|
@keyframes appear {
|
||||||
@@ -658,12 +704,6 @@ tbody .selection input {
|
|||||||
content: '📁';
|
content: '📁';
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
.empty-container {
|
|
||||||
padding-top: 3rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.folder-change {
|
.folder-change {
|
||||||
margin-left: -.5rem;
|
margin-left: -.5rem;
|
||||||
}
|
}
|
||||||
@@ -674,4 +714,3 @@ tbody .selection input {
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@/stores/main
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ input#FileRenameInput {
|
|||||||
padding: .75em;
|
padding: .75em;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
width: auto;
|
width: auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
:editing="editing === doc ? {rename, exit} : null"
|
:editing="editing === doc ? {rename, exit} : null"
|
||||||
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||||
@menu="contextMenu($event, doc)"
|
@menu="contextMenu($event, doc)"
|
||||||
@rename="editing = doc; store.cursor = doc.key"
|
@rename="onFigureRename(doc)"
|
||||||
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -25,12 +26,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
|
|||||||
import {
|
import {
|
||||||
computed,
|
computed,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
ref,
|
ref,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watch,
|
watch
|
||||||
watchEffect
|
|
||||||
} from 'vue'
|
} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -62,6 +64,10 @@ const editing = shallowRef<Doc | null>(null)
|
|||||||
const exit = () => {
|
const exit = () => {
|
||||||
editing.value = null
|
editing.value = null
|
||||||
}
|
}
|
||||||
|
const onFigureRename = (doc: Doc) => {
|
||||||
|
editing.value = doc
|
||||||
|
store.cursor = doc.key
|
||||||
|
}
|
||||||
const rename = async (doc: Doc, newName: string) => {
|
const rename = async (doc: Doc, newName: string) => {
|
||||||
const oldName = doc.name
|
const oldName = doc.name
|
||||||
doc.name = newName // We should get an update from watch but this is quicker
|
doc.name = newName // We should get an update from watch but this is quicker
|
||||||
@@ -170,6 +176,7 @@ const onImgLoad = (e: Event) => {
|
|||||||
}
|
}
|
||||||
const updateColumns = () => {
|
const updateColumns = () => {
|
||||||
if (!gallery.value) return
|
if (!gallery.value) return
|
||||||
|
if (gallery.value.getBoundingClientRect().width <= 0) return
|
||||||
const style = getComputedStyle(gallery.value)
|
const style = getComputedStyle(gallery.value)
|
||||||
const templates = style.gridTemplateColumns
|
const templates = style.gridTemplateColumns
|
||||||
.split(' ')
|
.split(' ')
|
||||||
@@ -301,7 +308,7 @@ defineExpose({
|
|||||||
const docs = props.documents
|
const docs = props.documents
|
||||||
if (docs.length > 0) {
|
if (docs.length > 0) {
|
||||||
store.cursor = docs[0]!.key
|
store.cursor = docs[0]!.key
|
||||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const a = document.querySelector(
|
const a = document.querySelector(
|
||||||
`#file-${store.cursor}`
|
`#file-${store.cursor}`
|
||||||
@@ -393,25 +400,55 @@ const focusBreadcrumb = () => {
|
|||||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||||
watchEffect(() => {
|
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
// stale props - their watchers must not react to global store changes.
|
||||||
if (editing.value) store.cursor = editing.value.key
|
let isActive = true
|
||||||
if (store.cursor && !editing.value) {
|
watch(
|
||||||
const a = document.querySelector(
|
() => store.cursor,
|
||||||
`#file-${store.cursor}`
|
cursor => {
|
||||||
) as HTMLAnchorElement | null
|
if (!isActive) return
|
||||||
|
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||||
|
exit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => store.cursor,
|
||||||
|
cursor => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (cursor && !editing.value) {
|
||||||
|
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
|
||||||
if (a) {
|
if (a) {
|
||||||
a.focus({ preventScroll: true })
|
a.focus({ preventScroll: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
watchEffect(() => {
|
{ flush: 'post' }
|
||||||
if (!props.documents.length && store.cursor && !store.query) {
|
)
|
||||||
|
watch(
|
||||||
|
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||||
|
([len, cursor, query, editingDoc]) => {
|
||||||
|
if (!isActive) return
|
||||||
|
if (!len && cursor && !query && !editingDoc) {
|
||||||
store.cursor = ''
|
store.cursor = ''
|
||||||
focusBreadcrumb()
|
focusBreadcrumb()
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
const attachGalleryObservers = () => {
|
||||||
|
if (!gallery.value || resizeObserver) return
|
||||||
|
resizeObserver = new ResizeObserver(updateColumns)
|
||||||
|
resizeObserver.observe(gallery.value)
|
||||||
|
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const detachGalleryObservers = () => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
resizeObserver = null
|
||||||
|
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const active = document.querySelector('.cursor') as HTMLElement | null
|
const active = document.querySelector('.cursor') as HTMLElement | null
|
||||||
if (active) {
|
if (active) {
|
||||||
@@ -419,22 +456,29 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
updateColumns()
|
updateColumns()
|
||||||
seedFromDocs()
|
seedFromDocs()
|
||||||
if (gallery.value) {
|
attachGalleryObservers()
|
||||||
resizeObserver = new ResizeObserver(updateColumns)
|
})
|
||||||
resizeObserver.observe(gallery.value)
|
onActivated(() => {
|
||||||
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
isActive = true
|
||||||
}
|
nextTick(() => {
|
||||||
|
updateColumns()
|
||||||
|
attachGalleryObservers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
onDeactivated(() => {
|
||||||
|
isActive = false
|
||||||
|
detachGalleryObservers()
|
||||||
|
if (editing.value) exit()
|
||||||
})
|
})
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
keyboardFollowScroll.cancel()
|
keyboardFollowScroll.cancel()
|
||||||
resizeObserver?.disconnect()
|
detachGalleryObservers()
|
||||||
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||||
watch(() => props.documents, seedFromDocs)
|
watch(() => props.documents, seedFromDocs)
|
||||||
const editRoute = (path: string) =>
|
const editRoute = (path: string) =>
|
||||||
'/edit/' +
|
'/' +
|
||||||
path
|
path
|
||||||
.split('/')
|
.split('/')
|
||||||
.map(part => encodeURIComponent(part))
|
.map(part => encodeURIComponent(part))
|
||||||
@@ -444,7 +488,8 @@ const createItem = async (doc: Doc, name: string) => {
|
|||||||
doc.name = name
|
doc.name = name
|
||||||
doc.key = crypto.randomUUID()
|
doc.key = crypto.randomUUID()
|
||||||
store.addGhost(doc)
|
store.addGhost(doc)
|
||||||
editing.value = null
|
store.cursor = doc.key
|
||||||
|
exit()
|
||||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||||
try {
|
try {
|
||||||
const res = doc.dir
|
const res = doc.dir
|
||||||
@@ -592,7 +637,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: .5em;
|
gap: .5em;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||||
align-items: end;
|
align-items: start;
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
.folder-indicator {
|
.folder-indicator {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||||
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||||
</span>
|
</span>
|
||||||
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
<button class="rename-btn" @click="emit('rename')" title="Rename">✏️</button>
|
||||||
</div>
|
</div>
|
||||||
<div class=namespacer></div>
|
<div class=namespacer></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,6 +64,10 @@ const props = defineProps<{
|
|||||||
doc: Doc
|
doc: Doc
|
||||||
editing?: EditingProp
|
editing?: EditingProp
|
||||||
}>()
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'rename'): void
|
||||||
|
(e: 'menu', ev: MouseEvent): void
|
||||||
|
}>()
|
||||||
const m = ref<typeof MediaPreview | null>(null)
|
const m = ref<typeof MediaPreview | null>(null)
|
||||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export class Doc {
|
|||||||
get editurl(): string {
|
get editurl(): string {
|
||||||
if (!this.text) return ''
|
if (!this.text) return ''
|
||||||
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
const p = this.loc ? `${this.loc}/${this.name}` : this.name
|
||||||
return '/#/edit/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
|
return '/#/' + p.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||||
}
|
}
|
||||||
get complete(): boolean {
|
get complete(): boolean {
|
||||||
return !this.ghost && (this.dir || this.size <= this.allocated)
|
return !this.ghost && (this.dir || this.size <= this.allocated)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import ExplorerView from '@/views/ExplorerView.vue'
|
import ExplorerView from '@/views/ExplorerView.vue'
|
||||||
import TextEditorView from '@/views/TextEditorView.vue'
|
|
||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
function getPathDepth(path: string): number {
|
function getPathDepth(path: string): number {
|
||||||
@@ -11,11 +10,6 @@ function getPathDepth(path: string): number {
|
|||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
|
||||||
path: '/edit/:pathMatch(.*)*',
|
|
||||||
name: 'editor',
|
|
||||||
component: TextEditorView
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'explorer',
|
name: 'explorer',
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ export const exists = (path: string[]) => {
|
|||||||
void store.docVersion
|
void store.docVersion
|
||||||
if (path.length === 0) return true
|
if (path.length === 0) return true
|
||||||
const p = path.join('/')
|
const p = path.join('/')
|
||||||
return getDocuments().some(
|
const hidden = store.hiddenPaths
|
||||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
|
const inDocs = getDocuments().some(doc => {
|
||||||
)
|
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
|
if (inDocs) return true
|
||||||
|
return store.ghosts.some(g => {
|
||||||
|
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||||
|
return full === p && !hidden.has(full)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||||
|
|||||||
@@ -2,23 +2,18 @@
|
|||||||
<div class="transition-wrapper">
|
<div class="transition-wrapper">
|
||||||
<Transition
|
<Transition
|
||||||
:name="transitionName"
|
:name="transitionName"
|
||||||
@after-enter="store.transitionDirection = 'none'"
|
@after-enter="onAfterEnter"
|
||||||
>
|
>
|
||||||
<div :key="folderPath" class="explorer-content">
|
<KeepAlive>
|
||||||
<Gallery
|
<component
|
||||||
v-if="store.prefs.gallery"
|
:is="store.prefs.gallery ? Gallery : FileExplorer"
|
||||||
|
:key="cacheKey"
|
||||||
ref="fileExplorer"
|
ref="fileExplorer"
|
||||||
|
class="explorer-content"
|
||||||
:path="props.path"
|
:path="props.path"
|
||||||
:documents="documents"
|
:documents="documents"
|
||||||
/>
|
/>
|
||||||
<FileExplorer
|
</KeepAlive>
|
||||||
v-else
|
|
||||||
ref="fileExplorer"
|
|
||||||
:path="props.path"
|
|
||||||
:documents="documents"
|
|
||||||
/>
|
|
||||||
<EmptyFolder :documents="documents" :path="props.path" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||||
@@ -31,7 +26,7 @@ import { getDocuments } from '@/stores/documentStore'
|
|||||||
import { useMainStore } from '@/stores/main'
|
import { useMainStore } from '@/stores/main'
|
||||||
import { collator } from '@/utils'
|
import { collator } from '@/utils'
|
||||||
import { sorted, sortedGrouped } from '@/utils/docsort'
|
import { sorted, sortedGrouped } from '@/utils/docsort'
|
||||||
import { computed, ref, watch, watchEffect } from 'vue'
|
import { computed, nextTick, ref, watch, watchEffect } from 'vue'
|
||||||
|
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
const fileExplorer = ref()
|
const fileExplorer = ref()
|
||||||
@@ -42,6 +37,9 @@ const props = defineProps<{
|
|||||||
|
|
||||||
// Folder path for component keys - only recreate component when folder changes, not search
|
// Folder path for component keys - only recreate component when folder changes, not search
|
||||||
const folderPath = computed(() => props.path.join('/'))
|
const folderPath = computed(() => props.path.join('/'))
|
||||||
|
const cacheKey = computed(
|
||||||
|
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
|
||||||
|
)
|
||||||
|
|
||||||
const transitionName = computed(() => {
|
const transitionName = computed(() => {
|
||||||
if (store.transitionDirection === 'forward') return 'slide-forward'
|
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||||
@@ -49,6 +47,22 @@ const transitionName = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const folderScrollTop = new Map<string, number>()
|
||||||
|
const scrollKey = (path: string) => path || '/'
|
||||||
|
const getMainScroller = () => document.querySelector('main') as HTMLElement | null
|
||||||
|
|
||||||
|
const restoreScroll = (path: string) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (!scroller) return
|
||||||
|
const top = folderScrollTop.get(scrollKey(path)) ?? 0
|
||||||
|
scroller.scrollTop = top
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAfterEnter = () => {
|
||||||
|
store.transitionDirection = 'none'
|
||||||
|
restoreScroll(folderPath.value)
|
||||||
|
}
|
||||||
|
|
||||||
// Handle route-based search changes (back/forward navigation, direct URL)
|
// Handle route-based search changes (back/forward navigation, direct URL)
|
||||||
// Skip if store.query already matches (means we triggered this via typing)
|
// Skip if store.query already matches (means we triggered this via typing)
|
||||||
watch(
|
watch(
|
||||||
@@ -101,6 +115,19 @@ watchEffect(() => {
|
|||||||
store.fileExplorer = fileExplorer.value
|
store.fileExplorer = fileExplorer.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
folderPath,
|
||||||
|
async (path, oldPath) => {
|
||||||
|
const scroller = getMainScroller()
|
||||||
|
if (scroller && oldPath !== undefined) {
|
||||||
|
folderScrollTop.set(scrollKey(oldPath), scroller.scrollTop)
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
requestAnimationFrame(() => restoreScroll(path))
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||||
watch(
|
watch(
|
||||||
[() => props.path.join('/'), () => store.documentCount],
|
[() => props.path.join('/'), () => store.documentCount],
|
||||||
@@ -114,16 +141,6 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.empty-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 2rem;
|
|
||||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
.search-loading {
|
.search-loading {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { apiFetch } from '@/repositories/Client'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import { indentWithTab } from '@codemirror/commands'
|
import { indentWithTab } from '@codemirror/commands'
|
||||||
import { LanguageDescription } from '@codemirror/language'
|
import { LanguageDescription } from '@codemirror/language'
|
||||||
import { languages } from '@codemirror/language-data'
|
import { languages } from '@codemirror/language-data'
|
||||||
@@ -16,17 +18,26 @@ import { Compartment, EditorState } from '@codemirror/state'
|
|||||||
import { oneDark } from '@codemirror/theme-one-dark'
|
import { oneDark } from '@codemirror/theme-one-dark'
|
||||||
import { EditorView, keymap } from '@codemirror/view'
|
import { EditorView, keymap } from '@codemirror/view'
|
||||||
import { basicSetup } from 'codemirror'
|
import { basicSetup } from 'codemirror'
|
||||||
import { apiFetch } from '@/repositories/Client'
|
import {
|
||||||
import { useMainStore } from '@/stores/main'
|
computed,
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
nextTick,
|
||||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref
|
||||||
|
} from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useMainStore()
|
const store = useMainStore()
|
||||||
|
|
||||||
const MAX_SIZE = 1024 * 1024 // 1 MiB
|
const MAX_SIZE = 1024 * 1024 // 1 MiB
|
||||||
|
|
||||||
const filePath = computed(() => decodeURIComponent(route.path.slice(6))) // strip /edit/
|
const filePath = computed(() => {
|
||||||
|
const raw = decodeURIComponent(route.path).split('//')[0] ?? ''
|
||||||
|
return raw.replace(/^\//, '').replace(/\/$/, '')
|
||||||
|
})
|
||||||
const filename = computed(() => filePath.value.split('/').pop() || '')
|
const filename = computed(() => filePath.value.split('/').pop() || '')
|
||||||
|
|
||||||
const filesUrl = computed(() => {
|
const filesUrl = computed(() => {
|
||||||
@@ -50,21 +61,32 @@ const languageCompartment = new Compartment()
|
|||||||
|
|
||||||
const dirty = computed(() => content.value !== original.value)
|
const dirty = computed(() => content.value !== original.value)
|
||||||
|
|
||||||
onBeforeRouteLeave((_to, _from, next) => {
|
|
||||||
if (!dirty.value) {
|
|
||||||
next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const discard = window.confirm('You have unsaved changes. Discard them?')
|
|
||||||
next(discard)
|
|
||||||
})
|
|
||||||
|
|
||||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||||
if (!dirty.value) return
|
if (!dirty.value) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.returnValue = ''
|
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 detectLanguage = async () => {
|
||||||
const language = LanguageDescription.matchFilename(languages, filename.value)
|
const language = LanguageDescription.matchFilename(languages, filename.value)
|
||||||
if (!language) return []
|
if (!language) return []
|
||||||
@@ -129,8 +151,7 @@ const save = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
store.editorSave = save
|
activateEditorBindings()
|
||||||
window.addEventListener('beforeunload', beforeUnload)
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
@@ -152,19 +173,23 @@ onMounted(async () => {
|
|||||||
await initEditor(text)
|
await initEditor(text)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
if (loading.value) loading.value = false
|
if (loading.value) loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
activateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
deactivateEditorBindings()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (store.editorSave === save) {
|
deactivateEditorBindings()
|
||||||
store.editorSave = null
|
|
||||||
}
|
|
||||||
editorView?.destroy()
|
editorView?.destroy()
|
||||||
editorView = null
|
editorView = null
|
||||||
window.removeEventListener('beforeunload', beforeUnload)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.vitest.json"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": "@tsconfig/node18/tsconfig.json",
|
"extends": "@tsconfig/node18/tsconfig.json",
|
||||||
"include": [
|
"include": ["vite.config.*"],
|
||||||
"vite.config.*",
|
|
||||||
"vitest.config.*",
|
|
||||||
"cypress.config.*",
|
|
||||||
"nightwatch.conf.*",
|
|
||||||
"playwright.config.*"
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
|
|||||||
@@ -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
|
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||||
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||||
"C901", # legacy complexity; keep other correctness rules enabled
|
"C901", # legacy complexity; keep other correctness rules enabled
|
||||||
|
"CPY", # copyright notices not wanted in this codebase
|
||||||
"D100", # legacy docs not yet standardized
|
"D100", # legacy docs not yet standardized
|
||||||
"D101", # legacy docs not yet standardized
|
"D101", # legacy docs not yet standardized
|
||||||
"D102", # legacy docs not yet standardized
|
"D102", # legacy docs not yet standardized
|
||||||
@@ -160,7 +161,7 @@ ignore = [
|
|||||||
"TRY003", # exception-message strictness too noisy on legacy handlers
|
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||||
]
|
]
|
||||||
isort.known-first-party = ["cista"]
|
isort.known-first-party = ["cista"]
|
||||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001"]
|
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001", "SLF001"]
|
||||||
per-file-ignores."scripts/*" = ["T20"]
|
per-file-ignores."scripts/*" = ["T20"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user