Compare commits

...
17 Commits
Author SHA1 Message Date
LeoVasanko 7b1c6f6772 Fix preview pool permanently wedging after worker stderr pipe fills
Production symptom: previews of all types (pdf/pyvips/onlyoffice) start
hitting the 10s timeout and never recover until server restart, while the
rest of the server stays healthy.

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

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

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

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

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

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

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

  no longer trigger folder slide transitions.

- Keep EmptyFolder text size consistent across list and gallery views.
2026-06-16 22:09:53 +00:00
LeoVasanko bf8a049b92 Use canonical paths for editor and fix route transitions 2026-05-07 02:18:55 +00:00
LeoVasanko 6d7f44bd88 Add view caching and preserve folder/editor UI state 2026-05-07 02:12:03 +00:00
LeoVasanko e2097a1563 Fix empty state vertical centering in explorer 2026-05-07 01:53:16 +00:00
LeoVasanko b864936eaa Lint 2026-05-07 01:44:27 +00:00
28 changed files with 789 additions and 369 deletions
+3 -1
View File
@@ -40,7 +40,9 @@ async def watch(req, ws):
if sso.paskia_enabled(): if sso.paskia_enabled():
# SSO auth: call validation to get user info (don't enforce auth in public mode) # SSO auth: call validation to get user info (don't enforce auth in public mode)
try: try:
await sso.validate_sso_request(req) # WebSocket cannot forward Set-Cookie, so ask the auth backend not to
# renew the session here; renewal happens on the HTTP side instead.
await sso.validate_sso_request(req, renew=False)
except Exception as e: except Exception as e:
logger.debug("watch SSO validation failed: %s", e) logger.debug("watch SSO validation failed: %s", e)
if sso_user := getattr(req.ctx, "sso_user", None): if sso_user := getattr(req.ctx, "sso_user", None):
+1 -1
View File
@@ -86,7 +86,7 @@ async def log_access(req, res):
path = f"{path}?{qs}" path = f"{path}?{qs}"
extra = getattr(req.ctx, "log_extra", None) extra = getattr(req.ctx, "log_extra", None)
line = format_access_log( line = format_access_log(
client, res.status, req.method, host, path, duration_ms, extra=extra client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
) )
access_logger.info(line) access_logger.info(line)
return res return res
+1 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -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
View File
@@ -62,12 +62,18 @@ async def close_client():
_client = None _client = None
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None: async def validate_sso_request(
request, *, perm: str = "cista:login", renew: bool = True
) -> dict | None:
"""Validate an SSO request against the auth backend. """Validate an SSO request against the auth backend.
Args: Args:
request: The Sanic request object request: The Sanic request object
perm: Permission to validate (default: cista:login, privileged also cista:admin) perm: Permission to validate (default: cista:login, privileged also cista:admin)
renew: Whether to allow the auth backend to renew the session cookie.
Use ``False`` for WebSocket validation where Set-Cookie cannot be
forwarded to the client; this makes the request read-only and avoids
resetting the backend renewal timeout.
Returns: Returns:
User info dict if valid, None if validation fails with auth required response User info dict if valid, None if validation fails with auth required response
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
headers["cookie"] = request.headers["cookie"] headers["cookie"] = request.headers["cookie"]
if "authorization" in request.headers: if "authorization" in request.headers:
headers["authorization"] = request.headers["authorization"] headers["authorization"] = request.headers["authorization"]
if "user-agent" in request.headers:
headers["user-agent"] = request.headers["user-agent"]
headers["accept"] = "application/json" headers["accept"] = "application/json"
headers["x-forwarded-for"] = request.client_ip headers["x-forwarded-for"] = request.client_ip
headers["x-forwarded-host"] = request.host headers["x-forwarded-host"] = request.host
headers["x-forwarded-proto"] = request.scheme headers["x-forwarded-proto"] = request.scheme
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}" url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
if not renew:
url += "&renew=0"
try: try:
response = await client.post( response = await client.post(
+1 -6
View File
@@ -6,9 +6,8 @@
"dev": "vite", "dev": "vite",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", "type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"lint": "biome lint .", "lint": "biome lint .",
"format": "biome format --write .", "format": "biome format --write .",
"format:check": "biome format --check .", "format:check": "biome format --check .",
@@ -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
View File
@@ -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,
() => { () => {
+2
View File
@@ -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,
+1 -2
View File
@@ -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 {
+8 -2
View File
@@ -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())
+22 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div v-if="!props.path || documents.length === 0" class="empty-container"> <div v-if="showEmpty" class="empty-container">
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/> <component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
<p v-if="store.dialog === 'accessdenied'">Access Denied</p> <p v-if="store.dialog === 'accessdenied'">Access Denied</p>
<p v-else-if="!store.connected">No Connection</p> <p v-else-if="!store.connected">No Connection</p>
@@ -14,6 +14,7 @@
import { Cog } from '@/assets/svg' import { Cog } from '@/assets/svg'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
import { computed } from 'vue'
const cog = Cog const cog = Cog
const store = useMainStore() const store = useMainStore()
@@ -21,9 +22,29 @@ const props = defineProps<{
path: string[] path: string[]
documents: Document[] documents: Document[]
}>() }>()
const showEmpty = computed(() => {
const loc = props.path.join('/')
const hasVisibleGhost = store.ghosts.some(g => {
const full = g.loc ? `${g.loc}/${g.name}` : g.name
return g.loc === loc && !store.hiddenPaths.has(full)
})
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
})
</script> </script>
<style scoped> <style scoped>
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
font-size: 2rem;
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
color: var(--accent-color);
}
@keyframes rotate { @keyframes rotate {
0% { transform: rotate(0deg); } 0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); } 100% { transform: rotate(360deg); }
+62 -23
View File
@@ -1,4 +1,5 @@
<template> <template>
<div class="file-explorer">
<table v-if="props.documents.length || editing"> <table v-if="props.documents.length || editing">
<thead> <thead>
<tr> <tr>
@@ -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>
+71 -25
View File
@@ -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;
+5 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
-6
View File
@@ -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',
+10 -3
View File
@@ -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.) */
+40 -23
View File
@@ -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;
+47 -22
View File
@@ -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>
-3
View File
@@ -6,9 +6,6 @@
}, },
{ {
"path": "./tsconfig.app.json" "path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
} }
] ]
} }
+1 -7
View File
@@ -1,12 +1,6 @@
{ {
"extends": "@tsconfig/node18/tsconfig.json", "extends": "@tsconfig/node18/tsconfig.json",
"include": [ "include": ["vite.config.*"],
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"module": "ESNext", "module": "ESNext",
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.app.json",
"exclude": [],
"compilerOptions": {
"composite": true,
"types": ["node", "jsdom"]
}
}
+2 -1
View File
@@ -132,6 +132,7 @@ ignore = [
"ANN205", # legacy codebase: no full runtime annotation coverage yet "ANN205", # legacy codebase: no full runtime annotation coverage yet
"BLE001", # broad catch remains in boundary/proxy/error-handling paths "BLE001", # broad catch remains in boundary/proxy/error-handling paths
"C901", # legacy complexity; keep other correctness rules enabled "C901", # legacy complexity; keep other correctness rules enabled
"CPY", # copyright notices not wanted in this codebase
"D100", # legacy docs not yet standardized "D100", # legacy docs not yet standardized
"D101", # legacy docs not yet standardized "D101", # legacy docs not yet standardized
"D102", # legacy docs not yet standardized "D102", # legacy docs not yet standardized
@@ -160,7 +161,7 @@ ignore = [
"TRY003", # exception-message strictness too noisy on legacy handlers "TRY003", # exception-message strictness too noisy on legacy handlers
] ]
isort.known-first-party = ["cista"] isort.known-first-party = ["cista"]
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001"] per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001", "SLF001"]
per-file-ignores."scripts/*" = ["T20"] per-file-ignores."scripts/*" = ["T20"]
[dependency-groups] [dependency-groups]
+175
View File
@@ -0,0 +1,175 @@
"""Tests for the preview worker pool resilience.
Regression context: a piped worker stderr that nobody drains used to block
the worker mid-request once the OS pipe buffer filled, and asyncio's
proc.wait() then never resolved even after kill() — wedging one dispatcher
per stuck worker until all preview traffic timed out permanently.
"""
import asyncio
import sys
import textwrap
import time
from pathlib import Path
from unittest.mock import AsyncMock, Mock
import pytest
from cista import preview
FAKE_WORKER = textwrap.dedent(
"""
import json
import struct
import sys
import blake3
def read_exact(n):
buf = b""
while len(buf) < n:
chunk = sys.stdin.buffer.read(n - len(buf))
if not chunk:
raise EOFError
buf += chunk
return buf
sys.stdout.buffer.write(b"\\x01")
sys.stdout.buffer.flush()
while True:
header = sys.stdin.buffer.read(8)
if not header or len(header) < 8:
break
meta_len, payload_len = struct.unpack("<II", header)
meta = read_exact(meta_len)
read_exact(payload_len)
req = json.loads(meta)
if req["path"].endswith(".block"):
# Simulate a worker stuck on an undrained stderr pipe:
# flood stderr past the OS pipe buffer, then never respond.
import os
import time
try:
os.write(2, b"x" * 10_000_000)
except OSError:
pass
while True:
time.sleep(3600)
resp = json.dumps({"ok": True, "mime": "image/avif", "backend": "fake"}).encode()
payload = b"FAKEIMG"
packet = struct.pack("<II", len(resp), len(payload)) + resp + payload
sys.stdout.buffer.write(blake3.blake3(packet).digest())
sys.stdout.buffer.write(packet)
sys.stdout.buffer.flush()
"""
)
@pytest.mark.asyncio
async def test_pool_recovers_from_wedged_worker(monkeypatch, tmp_path):
"""A worker wedged on an undrained stderr pipe must not kill the pool.
Recreates the old production setup (stderr=PIPE, never drained) and
verifies the request times out, the stuck worker's kill() cannot hang
the dispatcher, and the pool serves the next request normally.
"""
monkeypatch.setattr(preview, "PREVIEW_TIMEOUT", 1.0)
monkeypatch.setattr(preview, "WORKER_KILL_GRACE", 0.5)
monkeypatch.setattr(preview, "WORKER_RESPAWN_DELAY", 0.05)
script = tmp_path / "fake_worker.py"
script.write_text(FAKE_WORKER)
async def fake_spawn(self):
proc = await asyncio.create_subprocess_exec(
sys.executable,
str(script),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
# Deliberately piped-and-undrained, recreating the old
# production setup that wedges a worker on stderr writes.
stderr=asyncio.subprocess.PIPE,
)
preview._active_procs.add(proc)
await asyncio.wait_for(proc.stdout.readexactly(1), timeout=10)
return preview._PreviewWorker(proc)
monkeypatch.setattr(preview._PreviewWorkerPool, "_spawn_worker", fake_spawn)
pool = preview._PreviewWorkerPool(1)
await pool.start()
try:
with pytest.raises(preview.PreviewTimeoutError):
await pool.run(Path("wedged.block"), 60, 512, 2.0)
out, resp = await asyncio.wait_for(
pool.run(Path("ok.jpg"), 60, 512, 2.0), timeout=10
)
assert out == b"FAKEIMG"
assert resp.ok
assert all(not task.done() for task in pool._dispatchers)
finally:
await pool.close()
@pytest.mark.asyncio
async def test_worker_kill_grace_when_wait_hangs(monkeypatch):
"""kill() must return even if asyncio never resolves proc.wait()."""
monkeypatch.setattr(preview, "WORKER_KILL_GRACE", 0.1)
proc = Mock()
proc.returncode = None
proc.pid = 1234
never = asyncio.Future()
async def wait():
await never
proc.wait = wait
worker = preview._PreviewWorker(proc)
preview._active_procs.add(proc)
start = time.monotonic()
await worker.kill()
assert time.monotonic() - start < 2
assert proc not in preview._active_procs
never.cancel()
@pytest.mark.asyncio
async def test_replace_worker_retries_failed_spawn(monkeypatch):
"""A failed replacement spawn must be retried, not silently dropped."""
monkeypatch.setattr(preview, "WORKER_RESPAWN_DELAY", 0.01)
pool = preview._PreviewWorkerPool(1)
old_worker = Mock()
old_worker.proc = Mock(pid=4321)
old_worker.kill = AsyncMock()
attempts = 0
async def add_worker():
nonlocal attempts
attempts += 1
if attempts < 3:
raise OSError("too many open files")
pool._add_worker = add_worker
await pool._replace_worker(old_worker)
assert attempts == 3
@pytest.mark.asyncio
async def test_dispatch_loop_survives_body_errors():
"""Exceptions escaping a dispatch cycle must not kill the dispatcher."""
pool = preview._PreviewWorkerPool(1)
calls = 0
async def dispatch_one():
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("boom")
raise asyncio.CancelledError
pool._dispatch_one = dispatch_one
await pool._dispatch_loop()
assert calls == 2