Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1c6f6772 | ||
|
|
c025e7af95 | ||
|
|
3bad311e35 | ||
|
|
fdc4fe0a3e | ||
|
|
5df2308bdb | ||
|
|
f4c44ce1aa | ||
|
|
49232f11cc | ||
|
|
1258eff42d | ||
|
|
718d46e3f9 | ||
|
|
92d9c40a28 | ||
|
|
4f646fb344 | ||
|
|
d6304d0029 | ||
|
|
77e35cf0fc |
+3
-1
@@ -40,7 +40,9 @@ async def watch(req, ws):
|
||||
if sso.paskia_enabled():
|
||||
# SSO auth: call validation to get user info (don't enforce auth in public mode)
|
||||
try:
|
||||
await sso.validate_sso_request(req)
|
||||
# WebSocket cannot forward Set-Cookie, so ask the auth backend not to
|
||||
# renew the session here; renewal happens on the HTTP side instead.
|
||||
await sso.validate_sso_request(req, renew=False)
|
||||
except Exception as e:
|
||||
logger.debug("watch SSO validation failed: %s", e)
|
||||
if sso_user := getattr(req.ctx, "sso_user", None):
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ async def log_access(req, res):
|
||||
path = f"{path}?{qs}"
|
||||
extra = getattr(req.ctx, "log_extra", None)
|
||||
line = format_access_log(
|
||||
client, res.status, req.method, host, path, duration_ms, extra=extra
|
||||
client, res.status, req.method, host, path, duration_ms=duration_ms, extra=extra
|
||||
)
|
||||
access_logger.info(line)
|
||||
return res
|
||||
|
||||
+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:
|
||||
return (
|
||||
sharefs.is_share_token(token)
|
||||
and not token.username
|
||||
and not token.sso_user_id
|
||||
sharefs.is_share_token(token) and not token.username and not token.sso_user_id
|
||||
)
|
||||
|
||||
|
||||
|
||||
+154
-119
@@ -77,6 +77,9 @@ _preview_cache = PreviewCache(capacity=500)
|
||||
|
||||
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
||||
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
|
||||
WORKER_RESPAWN_DELAY = 1.0 # initial delay before retrying a failed worker spawn
|
||||
WORKER_RESPAWN_DELAY_MAX = 30.0
|
||||
_active_procs: set[asyncio.subprocess.Process] = set()
|
||||
_preview_pool = None
|
||||
_preview_pool_lock = asyncio.Lock()
|
||||
@@ -144,11 +147,25 @@ class _PreviewWorker:
|
||||
return payload or None, resp
|
||||
|
||||
async def kill(self) -> None:
|
||||
if self.proc.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
self.proc.kill()
|
||||
await self.proc.wait()
|
||||
_active_procs.discard(self.proc)
|
||||
try:
|
||||
if self.proc.returncode is None:
|
||||
# Safe to hard-kill: the worker is stateless per request.
|
||||
# proc.wait() must not be awaited unaided: if a pipe
|
||||
# transport is flow-control paused (e.g. an undrained stderr
|
||||
# pipe), asyncio may never resolve wait() even after SIGKILL,
|
||||
# which would permanently wedge the calling dispatcher.
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
self.proc.kill()
|
||||
try:
|
||||
await asyncio.wait_for(self.proc.wait(), timeout=WORKER_KILL_GRACE)
|
||||
except TimeoutError:
|
||||
logger.error(
|
||||
"Preview worker pid=%s not reaped within %ds of kill",
|
||||
self.proc.pid,
|
||||
int(WORKER_KILL_GRACE),
|
||||
)
|
||||
finally:
|
||||
_active_procs.discard(self.proc)
|
||||
|
||||
|
||||
class _PreviewWorkerPool:
|
||||
@@ -163,23 +180,19 @@ class _PreviewWorkerPool:
|
||||
self._seq = 0
|
||||
self._closed = False
|
||||
|
||||
async def _read_startup_stderr(self, proc: asyncio.subprocess.Process) -> str:
|
||||
if proc.stderr is None:
|
||||
return ""
|
||||
with contextlib.suppress(TimeoutError):
|
||||
data = await asyncio.wait_for(proc.stderr.read(), timeout=0.5)
|
||||
return data.decode(errors="replace").strip()
|
||||
return ""
|
||||
|
||||
async def _spawn_worker(self) -> _PreviewWorker:
|
||||
# stderr is inherited, not piped: a piped stderr that nobody drains
|
||||
# eventually fills its OS buffer, blocking the worker mid-request,
|
||||
# and its flow-control-paused transport makes proc.wait() hang even
|
||||
# after kill() — together this used to permanently wedge the pool.
|
||||
# Inheriting sends worker diagnostics straight to the server log.
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"cista.preview_worker",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
stderr=None,
|
||||
)
|
||||
_active_procs.add(proc)
|
||||
try:
|
||||
@@ -189,21 +202,14 @@ class _PreviewWorkerPool:
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
stderr = await self._read_startup_stderr(proc)
|
||||
if stderr:
|
||||
raise WorkerProtocolError(
|
||||
"preview worker failed to become ready: " + stderr.splitlines()[-1]
|
||||
) from err
|
||||
raise WorkerProtocolError("preview worker failed to become ready") from err
|
||||
raise WorkerProtocolError(
|
||||
"preview worker failed to become ready"
|
||||
" (worker stderr goes to the server log)"
|
||||
) from err
|
||||
except asyncio.IncompleteReadError as err:
|
||||
stderr = await self._read_startup_stderr(proc)
|
||||
if stderr:
|
||||
raise WorkerProtocolError(
|
||||
"preview worker exited before signalling readiness: "
|
||||
+ stderr.splitlines()[-1]
|
||||
) from err
|
||||
raise WorkerProtocolError(
|
||||
"preview worker exited before signalling readiness"
|
||||
" (worker stderr goes to the server log)"
|
||||
) from err
|
||||
if ready != b"\x01":
|
||||
raise WorkerProtocolError(f"preview worker ready signal invalid: {ready!r}")
|
||||
@@ -216,106 +222,135 @@ class _PreviewWorkerPool:
|
||||
|
||||
async def _replace_worker(self, worker: _PreviewWorker) -> None:
|
||||
self._workers.discard(worker)
|
||||
await worker.kill()
|
||||
if self._closed:
|
||||
return
|
||||
try:
|
||||
await self._add_worker()
|
||||
await worker.kill()
|
||||
except Exception:
|
||||
logger.exception("Failed to replace preview worker")
|
||||
|
||||
async def _dispatch_loop(self) -> None:
|
||||
while True:
|
||||
logger.exception("Failed to kill preview worker pid=%s", worker.proc.pid)
|
||||
# Keep retrying until a replacement is up: a pool that silently
|
||||
# shrinks degrades all preview traffic to timeouts.
|
||||
delay = WORKER_RESPAWN_DELAY
|
||||
while not self._closed:
|
||||
try:
|
||||
_priority, _seq, future, args = await self._pending.get()
|
||||
except asyncio.CancelledError:
|
||||
await self._add_worker()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to replace preview worker (pool %d/%d); retrying in %ds",
|
||||
len(self._workers),
|
||||
self.size,
|
||||
int(delay),
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, WORKER_RESPAWN_DELAY_MAX)
|
||||
else:
|
||||
return
|
||||
|
||||
if future.cancelled():
|
||||
continue
|
||||
|
||||
async def _dispatch_loop(self) -> None:
|
||||
# Nothing may escape the loop body: a dispatcher that dies silently
|
||||
# permanently shrinks pool capacity and degrades all preview
|
||||
# traffic to timeouts.
|
||||
while True:
|
||||
try:
|
||||
worker = await asyncio.wait_for(
|
||||
self._idle.get(), timeout=PREVIEW_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Preview worker unavailable (%ds) for %s",
|
||||
int(PREVIEW_TIMEOUT),
|
||||
args[0].name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
args[0].name,
|
||||
backend=_expected_preview_backend(args[0]),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
filepath = args[0]
|
||||
replace = False
|
||||
try:
|
||||
out, resp = await asyncio.wait_for(
|
||||
worker.request(*args),
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_result((out, resp))
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
filepath.name,
|
||||
backend=_expected_preview_backend(filepath),
|
||||
)
|
||||
)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||
)
|
||||
except PreviewError as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
except (
|
||||
WorkerProtocolError,
|
||||
asyncio.IncompleteReadError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
ValueError,
|
||||
msgspec.json.DecodeError,
|
||||
) as e:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker protocol failure for %s: %s", filepath.name, e
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(
|
||||
f"worker protocol failure for {filepath.name}: {e}"
|
||||
)
|
||||
)
|
||||
await self._dispatch_one()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
except Exception:
|
||||
replace = True
|
||||
logger.exception(
|
||||
"Unexpected preview worker error for %s", filepath.name
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||
logger.exception("Preview dispatcher error; continuing")
|
||||
|
||||
async def _dispatch_one(self) -> None:
|
||||
_priority, _seq, future, args = await self._pending.get()
|
||||
|
||||
if future.cancelled():
|
||||
return
|
||||
|
||||
try:
|
||||
worker = await asyncio.wait_for(self._idle.get(), timeout=PREVIEW_TIMEOUT)
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
"Preview worker unavailable (%ds) for %s",
|
||||
int(PREVIEW_TIMEOUT),
|
||||
args[0].name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
args[0].name,
|
||||
backend=_expected_preview_backend(args[0]),
|
||||
)
|
||||
finally:
|
||||
if replace:
|
||||
await self._replace_worker(worker)
|
||||
elif worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
await self._replace_worker(worker)
|
||||
)
|
||||
return
|
||||
|
||||
filepath = args[0]
|
||||
replace = False
|
||||
try:
|
||||
out, resp = await asyncio.wait_for(
|
||||
worker.request(*args),
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_result((out, resp))
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker pid=%s timed out (%ds) on %s; replacing it",
|
||||
worker.proc.pid,
|
||||
int(PREVIEW_TIMEOUT),
|
||||
filepath.name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
filepath.name,
|
||||
backend=_expected_preview_backend(filepath),
|
||||
)
|
||||
)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error(
|
||||
"Preview checksum mismatch for %s (worker pid=%s); replacing it",
|
||||
filepath.name,
|
||||
worker.proc.pid,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
||||
)
|
||||
except PreviewError as e:
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
except (
|
||||
WorkerProtocolError,
|
||||
asyncio.IncompleteReadError,
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
OSError,
|
||||
ValueError,
|
||||
msgspec.DecodeError,
|
||||
) as e:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview worker pid=%s protocol failure for %s: %s",
|
||||
worker.proc.pid,
|
||||
filepath.name,
|
||||
e,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
|
||||
)
|
||||
except Exception:
|
||||
replace = True
|
||||
logger.exception("Unexpected preview worker error for %s", filepath.name)
|
||||
if not future.done():
|
||||
future.set_exception(
|
||||
PreviewError(f"unexpected worker error for {filepath.name}")
|
||||
)
|
||||
finally:
|
||||
if replace:
|
||||
await self._replace_worker(worker)
|
||||
elif worker.proc.returncode is None:
|
||||
await self._idle.put(worker)
|
||||
else:
|
||||
await self._replace_worker(worker)
|
||||
|
||||
async def start(self) -> None:
|
||||
workers = await asyncio.gather(
|
||||
|
||||
+44
-23
@@ -26,9 +26,9 @@ from pathlib import Path
|
||||
from time import perf_counter
|
||||
|
||||
import av
|
||||
import fitz # PyMuPDF
|
||||
import msgspec
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
import pyvips
|
||||
from blake3 import blake3
|
||||
|
||||
@@ -128,13 +128,20 @@ def _read_request() -> tuple[PreviewRequest, bytes] | None:
|
||||
return req, data
|
||||
|
||||
|
||||
# Raw stdout buffer reserved for the binary protocol once main() redirects
|
||||
# Python-level stdout to stderr. None means "use sys.stdout.buffer as-is"
|
||||
# (CLI single-shot mode, where real stdout is wanted).
|
||||
_protocol_out = None
|
||||
|
||||
|
||||
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||
out = _protocol_out if _protocol_out is not None else sys.stdout.buffer
|
||||
meta_bytes = _enc.encode(resp)
|
||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||
checksum = blake3(packet).digest()
|
||||
sys.stdout.buffer.write(checksum)
|
||||
sys.stdout.buffer.write(packet)
|
||||
sys.stdout.buffer.flush()
|
||||
out.write(checksum)
|
||||
out.write(packet)
|
||||
out.flush()
|
||||
|
||||
|
||||
def dispatch(path, quality, maxsize, maxzoom, data=None):
|
||||
@@ -219,7 +226,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||
cmd.insert(5, f"{new_w}x{new_h}")
|
||||
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:
|
||||
shell_cmd = shlex.join(cmd)
|
||||
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
||||
@@ -318,18 +336,18 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||
|
||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
t_load_start = perf_counter()
|
||||
pdf = fitz.open(path)
|
||||
page = pdf.load_page(page_number)
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
mat = fitz.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
t_load_end = perf_counter()
|
||||
with pymupdf.open(path) as pdf:
|
||||
page = pdf.load_page(page_number)
|
||||
w, h = page.rect[2:4]
|
||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat)
|
||||
t_load_end = perf_counter()
|
||||
|
||||
t_save_start = perf_counter()
|
||||
img = pyvips.Image.new_from_memory(
|
||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||
)
|
||||
t_save_start = perf_counter()
|
||||
img = pyvips.Image.new_from_memory(
|
||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
||||
)
|
||||
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, strip=True)
|
||||
backend = "pdf+pyvips"
|
||||
t_save_end = perf_counter()
|
||||
@@ -540,20 +558,23 @@ def main() -> None:
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
try:
|
||||
config.load_config()
|
||||
logger.warning(
|
||||
"preview-worker config=%s master_secret=%s",
|
||||
config.conffile,
|
||||
config.config.secret,
|
||||
)
|
||||
logger.info("preview-worker config=%s", config.conffile)
|
||||
except Exception:
|
||||
logger.exception("preview-worker failed to load config at startup")
|
||||
if len(sys.argv) > 1:
|
||||
_run_once()
|
||||
return
|
||||
# The command channel is a binary protocol on fd 1. Anything printed to
|
||||
# stdout by Python code (e.g. a library emitting a warning via print())
|
||||
# would corrupt the protocol, so redirect Python-level stdout to stderr
|
||||
# (the server log) and keep the raw buffer solely for protocol traffic.
|
||||
global _protocol_out
|
||||
_protocol_out = sys.stdout.buffer
|
||||
sys.stdout = sys.stderr
|
||||
# Eagerly import heavy modules before signalling readiness so the parent
|
||||
# does not hand us a request while we are still initialising.
|
||||
sys.stdout.buffer.write(b"\x01")
|
||||
sys.stdout.buffer.flush()
|
||||
_protocol_out.write(b"\x01")
|
||||
_protocol_out.flush()
|
||||
_run_loop()
|
||||
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ def format_access_log(
|
||||
method: str,
|
||||
host: str,
|
||||
path: str,
|
||||
*,
|
||||
duration_ms: float,
|
||||
extra: str | None = None,
|
||||
) -> str:
|
||||
|
||||
+11
-1
@@ -62,12 +62,18 @@ async def close_client():
|
||||
_client = None
|
||||
|
||||
|
||||
async def validate_sso_request(request, *, perm: str = "cista:login") -> dict | None:
|
||||
async def validate_sso_request(
|
||||
request, *, perm: str = "cista:login", renew: bool = True
|
||||
) -> dict | None:
|
||||
"""Validate an SSO request against the auth backend.
|
||||
|
||||
Args:
|
||||
request: The Sanic request object
|
||||
perm: Permission to validate (default: cista:login, privileged also cista:admin)
|
||||
renew: Whether to allow the auth backend to renew the session cookie.
|
||||
Use ``False`` for WebSocket validation where Set-Cookie cannot be
|
||||
forwarded to the client; this makes the request read-only and avoids
|
||||
resetting the backend renewal timeout.
|
||||
|
||||
Returns:
|
||||
User info dict if valid, None if validation fails with auth required response
|
||||
@@ -88,12 +94,16 @@ async def validate_sso_request(request, *, perm: str = "cista:login") -> dict |
|
||||
headers["cookie"] = request.headers["cookie"]
|
||||
if "authorization" in request.headers:
|
||||
headers["authorization"] = request.headers["authorization"]
|
||||
if "user-agent" in request.headers:
|
||||
headers["user-agent"] = request.headers["user-agent"]
|
||||
headers["accept"] = "application/json"
|
||||
headers["x-forwarded-for"] = request.client_ip
|
||||
headers["x-forwarded-host"] = request.host
|
||||
headers["x-forwarded-proto"] = request.scheme
|
||||
|
||||
url = f"{PASKIA_BACKEND_URL}/auth/api/validate?perm={perm}"
|
||||
if not renew:
|
||||
url += "&renew=0"
|
||||
|
||||
try:
|
||||
response = await client.post(
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||
"lint": "biome lint .",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome format --check .",
|
||||
@@ -37,17 +36,13 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@tsconfig/node18": "^18.2.6",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-15
@@ -58,13 +58,13 @@ import Router from '@/router/index'
|
||||
import { computed } from 'vue'
|
||||
import AboutModal from './components/AboutModal.vue'
|
||||
import AccessDeniedModal from './components/AccessDeniedModal.vue'
|
||||
import ExplorerView from './views/ExplorerView.vue'
|
||||
import SelectionToolbar from './components/SelectionToolbar.vue'
|
||||
import TextEditorView from './views/TextEditorView.vue'
|
||||
import type SettingsModalVue from './components/SettingsModal.vue'
|
||||
import UserManagementModal from './components/UserManagementModal.vue'
|
||||
import UserTokensModal from './components/UserTokensModal.vue'
|
||||
import type { SortOrder } from './utils/docsort'
|
||||
import ExplorerView from './views/ExplorerView.vue'
|
||||
import TextEditorView from './views/TextEditorView.vue'
|
||||
|
||||
interface Path {
|
||||
path: string
|
||||
@@ -78,7 +78,9 @@ interface Path {
|
||||
const store = useMainStore()
|
||||
|
||||
const getDocByPath = (fullPath: string) =>
|
||||
getDocuments().find(doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath)
|
||||
getDocuments().find(
|
||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === fullPath
|
||||
)
|
||||
|
||||
const path: ComputedRef<Path> = computed(() => {
|
||||
const p = decodeURIComponent(Router.currentRoute.value.path).split('//')
|
||||
@@ -90,13 +92,9 @@ const path: ComputedRef<Path> = computed(() => {
|
||||
void store.docVersion
|
||||
const doc = fullPath ? getDocByPath(fullPath) : null
|
||||
const isEditorPath = !!(doc && !doc.dir && doc.text)
|
||||
const canonicalBase = !fullPath
|
||||
? '/'
|
||||
: doc?.dir
|
||||
? `/${fullPath}/`
|
||||
: `/${fullPath}`
|
||||
const canonicalBase = !fullPath ? '/' : doc?.dir ? `/${fullPath}/` : `/${fullPath}`
|
||||
const canonicalPath = query
|
||||
? rawPath // keep search URL shape untouched
|
||||
? `${rawPath}//${query}` // keep search URL shape untouched
|
||||
: canonicalBase
|
||||
const pathList = isEditorPath ? routePathList.slice(0, -1) : routePathList
|
||||
const breadcrumbPathList = routePathList
|
||||
@@ -128,14 +126,10 @@ const routeViewComponent = computed(() =>
|
||||
path.value.isEditorPath ? TextEditorView : ExplorerView
|
||||
)
|
||||
const routeViewKey = computed(() => {
|
||||
return path.value.isEditorPath
|
||||
? `editor:${path.value.path}`
|
||||
: 'explorer'
|
||||
return path.value.isEditorPath ? `editor:${path.value.path}` : 'explorer'
|
||||
})
|
||||
const routeViewProps = computed(() =>
|
||||
path.value.isEditorPath
|
||||
? {}
|
||||
: { path: path.value.pathList, query: path.value.query }
|
||||
path.value.isEditorPath ? {} : { path: path.value.pathList, query: path.value.query }
|
||||
)
|
||||
watch(
|
||||
() => path.value.canonicalPath,
|
||||
|
||||
@@ -122,8 +122,7 @@ watchEffect(() => {
|
||||
if (!same) {
|
||||
longest.value = props.path
|
||||
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))
|
||||
longestLinks.value.splice(0, currentLinks.length, ...currentLinks)
|
||||
} else {
|
||||
|
||||
@@ -104,7 +104,9 @@ const showOtherCategory = computed(() => {
|
||||
return !!s.disk && otherBytes.value / s.disk >= 0.01
|
||||
})
|
||||
const freeSliceBytes = computed(() =>
|
||||
showOtherCategory.value ? store.space.free : Math.max(0, store.space.disk - store.space.allocated)
|
||||
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
|
||||
@@ -295,7 +297,11 @@ const freeLabelPath = computed(() =>
|
||||
createArcPath(adjustedLabelAngles.value.free!, 'free', 4)
|
||||
)
|
||||
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())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="!props.path || documents.length === 0" class="empty-container">
|
||||
<div v-if="showEmpty" class="empty-container">
|
||||
<component :is="cog" :class="['cog', { stopped: store.dialog === 'accessdenied' || store.authInProgress }]"/>
|
||||
<p v-if="store.dialog === 'accessdenied'">Access Denied</p>
|
||||
<p v-else-if="!store.connected">No Connection</p>
|
||||
@@ -14,6 +14,7 @@
|
||||
import { Cog } from '@/assets/svg'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const cog = Cog
|
||||
const store = useMainStore()
|
||||
@@ -21,9 +22,29 @@ const props = defineProps<{
|
||||
path: string[]
|
||||
documents: Document[]
|
||||
}>()
|
||||
|
||||
const showEmpty = computed(() => {
|
||||
const loc = props.path.join('/')
|
||||
const hasVisibleGhost = store.ghosts.some(g => {
|
||||
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||
return g.loc === loc && !store.hiddenPaths.has(full)
|
||||
})
|
||||
|
||||
return !props.path || (props.documents.length === 0 && !hasVisibleGhost)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 2rem;
|
||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
|
||||
@@ -1,74 +1,77 @@
|
||||
<template>
|
||||
<table v-if="props.documents.length || editing">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||
<th class="menu"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||
<td class="selection"></td>
|
||||
<td class="name">
|
||||
<FileRenameInput :doc="editing" :rename="createItem" :exit="() => {editing = null}" />
|
||||
</td>
|
||||
<FileModified :doc=editing :now=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
<div class="file-explorer">
|
||||
<table v-if="props.documents.length || editing">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="selection">
|
||||
<input type="checkbox" tabindex="-1" v-model="allSelected" :indeterminate="selectionIndeterminate">
|
||||
</th>
|
||||
<th class="sortcolumn" :class="{ sortactive: store.sortOrder === 'name' }" @click="store.toggleSort('name')">Name</th>
|
||||
<th class="sortcolumn modified right" :class="{ sortactive: store.sortOrder === 'modified' }" @click="store.toggleSort('modified')">Modified</th>
|
||||
<th class="sortcolumn size right" :class="{ sortactive: store.sortOrder === 'size' }" @click="store.toggleSort('size')">Size</th>
|
||||
<th class="menu"></th>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="editing?.key === 'new'" :class="editing.dir ? 'folder' : 'file'">
|
||||
<td class="selection"></td>
|
||||
<td class="name">
|
||||
<template v-if="editing === doc">
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="() => {editing = null}" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
{{ doc.name }}
|
||||
</a>
|
||||
<button tabindex=-1 v-if="store.cursor == doc.key" class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc :now=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
<FileRenameInput :doc="editing" :rename="createItem" :exit="exitEditing" />
|
||||
</td>
|
||||
<FileModified :doc=editing :now=nowkey />
|
||||
<FileSize :doc=editing />
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr class="summary" v-if="props.documents.length > 1">
|
||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-for="(doc, index) in documents" :key="doc.key">
|
||||
<tr class="folder-change" v-if="showFolderBreadcrumb(index)">
|
||||
<th colspan="5"><BreadCrumb :path="doc.loc ? doc.loc.split('/') : []" /></th>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
:id="`file-${doc.key}`"
|
||||
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
|
||||
@click="store.cursor = store.cursor === doc.key ? '' : doc.key"
|
||||
@contextmenu.prevent="contextMenu($event, doc)"
|
||||
>
|
||||
<td class="selection" @click.up.stop="store.cursor = store.cursor === doc.key ? doc.key : ''">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="-1"
|
||||
:checked="store.selected.has(doc.key)"
|
||||
@change="
|
||||
($event.target as HTMLInputElement).checked
|
||||
? store.selected.add(doc.key)
|
||||
: store.selected.delete(doc.key)
|
||||
"
|
||||
/>
|
||||
</td>
|
||||
<td class="name">
|
||||
<template v-if="editing === doc">
|
||||
<FileRenameInput :doc="doc" :rename="rename" :exit="exitEditing" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a :href="doc.text ? doc.editurl : doc.url" tabindex=-1 @contextmenu.stop @focus.stop="store.cursor = doc.key">
|
||||
{{ doc.name }}
|
||||
</a>
|
||||
<button tabindex=-1 class="rename-button" @click="() => (editing = doc)">🖊️</button>
|
||||
</template>
|
||||
</td>
|
||||
<FileModified :doc=doc :now=nowkey />
|
||||
<FileSize :doc=doc />
|
||||
<td class="menu">
|
||||
<button tabindex=-1 @click.stop="contextMenu($event, doc)">⋮</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr class="summary" v-if="props.documents.length > 1">
|
||||
<td colspan="3" class="right">{{props.documents.length}} items</td>
|
||||
<td class="size right">{{ formatSize(props.documents.reduce((a, b) => a + b.size, 0)) }}</td>
|
||||
<td class="menu"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -81,11 +84,13 @@ import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watchEffect
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import FileRenameInput from './FileRenameInput.vue'
|
||||
@@ -189,6 +194,9 @@ const pageMove = (direction: 1 | -1, ev: KeyboardEvent) => {
|
||||
|
||||
// File rename
|
||||
const editing = shallowRef<Doc | null>(null)
|
||||
const exitEditing = () => {
|
||||
editing.value = null
|
||||
}
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
@@ -243,7 +251,7 @@ defineExpose({
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
@@ -329,22 +337,41 @@ const focusBreadcrumb = () => {
|
||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value?.key
|
||||
if (store.cursor) {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus({ preventScroll: true })
|
||||
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||
// stale props - their watchers must not react to global store changes.
|
||||
let isActive = true
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||
exitEditing()
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
)
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && !editing.value) {
|
||||
const a = document.querySelector(
|
||||
`#file-${cursor} .name a`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) a.focus({ preventScroll: true })
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
watch(
|
||||
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||
([len, cursor, query, editingDoc]) => {
|
||||
if (!isActive) return
|
||||
if (!len && cursor && !query && !editingDoc) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
let nowkey = ref(0)
|
||||
let modifiedTimer: any = null
|
||||
const updateModified = () => {
|
||||
@@ -358,6 +385,13 @@ onMounted(() => {
|
||||
active.focus({ preventScroll: true })
|
||||
}
|
||||
})
|
||||
onActivated(() => {
|
||||
isActive = true
|
||||
})
|
||||
onDeactivated(() => {
|
||||
isActive = false
|
||||
if (editing.value) exitEditing()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
keyboardFollowScroll.cancel()
|
||||
clearInterval(modifiedTimer)
|
||||
@@ -373,7 +407,8 @@ const createItem = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
store.cursor = doc.key
|
||||
exitEditing()
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
try {
|
||||
const res = doc.dir
|
||||
@@ -525,9 +560,14 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-explorer {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
height: auto;
|
||||
}
|
||||
thead tr {
|
||||
position: sticky;
|
||||
@@ -588,6 +628,12 @@ table td {
|
||||
.name .rename-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
tbody tr:hover .name .rename-button {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
animation: appear calc(5 * var(--transition-time)) linear;
|
||||
}
|
||||
@keyframes appear {
|
||||
@@ -658,12 +704,6 @@ tbody .selection input {
|
||||
content: '📁';
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.empty-container {
|
||||
padding-top: 3rem;
|
||||
text-align: center;
|
||||
font-size: 3rem;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.folder-change {
|
||||
margin-left: -.5rem;
|
||||
}
|
||||
@@ -674,4 +714,3 @@ tbody .selection input {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@/stores/main
|
||||
|
||||
@@ -60,6 +60,7 @@ input#FileRenameInput {
|
||||
padding: .75em;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
:editing="editing === doc ? {rename, exit} : null"
|
||||
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||
@menu="contextMenu($event, doc)"
|
||||
@rename="editing = doc; store.cursor = doc.key"
|
||||
@rename="onFigureRename(doc)"
|
||||
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<EmptyFolder v-else :documents="documents" :path="props.path" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -23,16 +24,15 @@ import type { SortOrder } from '@/utils/docsort'
|
||||
import { createKeyboardFollowScroll } from '@/utils/keyboardFollowScroll'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import {
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
computed,
|
||||
nextTick,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
watch
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -64,6 +64,10 @@ const editing = shallowRef<Doc | null>(null)
|
||||
const exit = () => {
|
||||
editing.value = null
|
||||
}
|
||||
const onFigureRename = (doc: Doc) => {
|
||||
editing.value = doc
|
||||
store.cursor = doc.key
|
||||
}
|
||||
const rename = async (doc: Doc, newName: string) => {
|
||||
const oldName = doc.name
|
||||
doc.name = newName // We should get an update from watch but this is quicker
|
||||
@@ -304,7 +308,7 @@ defineExpose({
|
||||
const docs = props.documents
|
||||
if (docs.length > 0) {
|
||||
store.cursor = docs[0]!.key
|
||||
// Also focus the element directly (watchEffect won't trigger if cursor unchanged)
|
||||
// Also focus the element directly (post-flush watcher won't trigger if cursor unchanged)
|
||||
nextTick(() => {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
@@ -396,24 +400,41 @@ const focusBreadcrumb = () => {
|
||||
const keyboardFollowScroll = createKeyboardFollowScroll()
|
||||
const markKeyboardFollow = keyboardFollowScroll.markKeyboardFollow
|
||||
const keepCursorVisibleSmooth = keyboardFollowScroll.keepVisible
|
||||
watchEffect(() => {
|
||||
if (store.cursor && store.cursor !== editing.value?.key) editing.value = null
|
||||
if (editing.value) store.cursor = editing.value.key
|
||||
if (store.cursor && !editing.value) {
|
||||
const a = document.querySelector(
|
||||
`#file-${store.cursor}`
|
||||
) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus({ preventScroll: true })
|
||||
// Deactivated (KeepAlive-cached) instances stay alive with frozen, potentially
|
||||
// stale props - their watchers must not react to global store changes.
|
||||
let isActive = true
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && editing.value && cursor !== editing.value.key) {
|
||||
exit()
|
||||
}
|
||||
}
|
||||
})
|
||||
watchEffect(() => {
|
||||
if (!props.documents.length && store.cursor && !store.query) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
)
|
||||
watch(
|
||||
() => store.cursor,
|
||||
cursor => {
|
||||
if (!isActive) return
|
||||
if (cursor && !editing.value) {
|
||||
const a = document.querySelector(`#file-${cursor}`) as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
a.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
watch(
|
||||
() => [props.documents.length, store.cursor, store.query, editing.value] as const,
|
||||
([len, cursor, query, editingDoc]) => {
|
||||
if (!isActive) return
|
||||
if (!len && cursor && !query && !editingDoc) {
|
||||
store.cursor = ''
|
||||
focusBreadcrumb()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
const attachGalleryObservers = () => {
|
||||
if (!gallery.value || resizeObserver) return
|
||||
@@ -438,13 +459,16 @@ onMounted(() => {
|
||||
attachGalleryObservers()
|
||||
})
|
||||
onActivated(() => {
|
||||
isActive = true
|
||||
nextTick(() => {
|
||||
updateColumns()
|
||||
attachGalleryObservers()
|
||||
})
|
||||
})
|
||||
onDeactivated(() => {
|
||||
isActive = false
|
||||
detachGalleryObservers()
|
||||
if (editing.value) exit()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
keyboardFollowScroll.cancel()
|
||||
@@ -464,7 +488,8 @@ const createItem = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
store.addGhost(doc)
|
||||
editing.value = null
|
||||
store.cursor = doc.key
|
||||
exit()
|
||||
const path = doc.loc ? `${doc.loc}/${name}` : name
|
||||
try {
|
||||
const res = doc.dir
|
||||
@@ -612,7 +637,8 @@ const contextMenu = (ev: MouseEvent, doc: Doc) => {
|
||||
display: grid;
|
||||
gap: .5em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15em, 1fr));
|
||||
align-items: end;
|
||||
align-items: start;
|
||||
align-content: start;
|
||||
}
|
||||
.folder-indicator {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<span class="filename">{{ snap.displayName }}<SparseIndicator :doc="doc" class="after-name" /></span>
|
||||
<span v-if="snap.ext" class="file-ext">.{{ snap.ext }}</span>
|
||||
</span>
|
||||
<button class="rename-btn" @click="$emit('rename')" title="Rename">✏️</button>
|
||||
<button class="rename-btn" @click="emit('rename')" title="Rename">✏️</button>
|
||||
</div>
|
||||
<div class=namespacer></div>
|
||||
</template>
|
||||
@@ -64,6 +64,10 @@ const props = defineProps<{
|
||||
doc: Doc
|
||||
editing?: EditingProp
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'rename'): void
|
||||
(e: 'menu', ev: MouseEvent): void
|
||||
}>()
|
||||
const m = ref<typeof MediaPreview | null>(null)
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
|
||||
|
||||
@@ -7,9 +7,16 @@ export const exists = (path: string[]) => {
|
||||
void store.docVersion
|
||||
if (path.length === 0) return true
|
||||
const p = path.join('/')
|
||||
return getDocuments().some(
|
||||
doc => (doc.loc ? `${doc.loc}/${doc.name}` : doc.name) === p
|
||||
)
|
||||
const hidden = store.hiddenPaths
|
||||
const inDocs = getDocuments().some(doc => {
|
||||
const full = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
return full === p && !hidden.has(full)
|
||||
})
|
||||
if (inDocs) return true
|
||||
return store.ghosts.some(g => {
|
||||
const full = g.loc ? `${g.loc}/${g.name}` : g.name
|
||||
return full === p && !hidden.has(full)
|
||||
})
|
||||
}
|
||||
|
||||
/** Strip file extension intelligently (handles .tar.gz, name.with.dots.pdf, etc.) */
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
<EmptyFolder :documents="documents" :path="props.path" />
|
||||
</div>
|
||||
<div v-if="store.searchLoading" class="search-loading">Searching...</div>
|
||||
</template>
|
||||
@@ -38,7 +37,9 @@ const props = defineProps<{
|
||||
|
||||
// Folder path for component keys - only recreate component when folder changes, not search
|
||||
const folderPath = computed(() => props.path.join('/'))
|
||||
const cacheKey = computed(() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`)
|
||||
const cacheKey = computed(
|
||||
() => `${store.prefs.gallery ? 'gallery' : 'list'}:${folderPath.value}`
|
||||
)
|
||||
|
||||
const transitionName = computed(() => {
|
||||
if (store.transitionDirection === 'forward') return 'slide-forward'
|
||||
@@ -140,16 +141,6 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
font-size: 2rem;
|
||||
text-shadow: 0 0 .3rem #000, 0 0 2rem #0008;
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.search-loading {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
import { LanguageDescription } from '@codemirror/language'
|
||||
import { languages } from '@codemirror/language-data'
|
||||
@@ -16,8 +18,6 @@ import { Compartment, EditorState } from '@codemirror/state'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView, keymap } from '@codemirror/view'
|
||||
import { basicSetup } from 'codemirror'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -173,8 +173,7 @@ onMounted(async () => {
|
||||
await initEditor(text)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load file'
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
if (loading.value) loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,9 +6,6 @@
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
{
|
||||
"extends": "@tsconfig/node18/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"include": ["vite.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -132,6 +132,7 @@ ignore = [
|
||||
"ANN205", # legacy codebase: no full runtime annotation coverage yet
|
||||
"BLE001", # broad catch remains in boundary/proxy/error-handling paths
|
||||
"C901", # legacy complexity; keep other correctness rules enabled
|
||||
"CPY", # copyright notices not wanted in this codebase
|
||||
"D100", # legacy docs not yet standardized
|
||||
"D101", # legacy docs not yet standardized
|
||||
"D102", # legacy docs not yet standardized
|
||||
@@ -160,7 +161,7 @@ ignore = [
|
||||
"TRY003", # exception-message strictness too noisy on legacy handlers
|
||||
]
|
||||
isort.known-first-party = ["cista"]
|
||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001"]
|
||||
per-file-ignores."tests/*" = ["S", "ANN", "D", "INP", "PLR2004", "ARG001", "SLF001"]
|
||||
per-file-ignores."scripts/*" = ["T20"]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -0,0 +1,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