Compare commits

..
22 Commits
Author SHA1 Message Date
LeoVasanko 9e07a3f40d Require new fastapi-vue for unix socket compatibility. 2026-02-01 07:10:21 +00:00
LeoVasanko 2867b1075d Disk space pie chart. 2026-02-01 06:59:15 +00:00
LeoVasanko 4fc8e29cc1 Implement a larger set of file menu options. 2026-02-01 06:33:34 +00:00
LeoVasanko 55f9117b3e Remove refresh wwwroot support and static files serving, only using vite to serve frontend in dev mode (avoid accidentally using stale builds). 2026-02-01 06:14:35 +00:00
LeoVasanko 2b6746c55e Change default port to 8989 like Droppy. Using 8999 for backend in dev mode. 2026-02-01 06:09:56 +00:00
LeoVasanko 1ed3779933 Use fastapi-vue parse_endpoint instead of our own logic. 2026-02-01 06:00:30 +00:00
LeoVasanko a95cf7be94 Better UI feedback on search order changes. 2026-02-01 05:48:15 +00:00
LeoVasanko d18501f633 Fix a search caching issue. 2026-02-01 05:38:48 +00:00
LeoVasanko ed697db871 New header/footer layout and styles. Use cursorTooltip in more places, make it prettier. Fix tooltip still sometimes activating with touch (now only with mouse). 2026-02-01 05:19:42 +00:00
LeoVasanko 856e8c4cc8 Remove download to folder, just download zip or single file. The folder download is still accessible by Alt+Click on the download button (in the few browsers that support it). 2026-02-01 03:47:14 +00:00
LeoVasanko f8a6eacb06 Automatically navigate into newly uploaded folders. 2026-02-01 03:27:13 +00:00
LeoVasanko e9a82e84ad Implement ghost items reflecting pending changes from frontend (e.g. files being uploaded) until the server acknowledges them. This gives immediate UI feedback and is clearer in cases. 2026-02-01 03:23:43 +00:00
LeoVasanko ccd05b53f4 Remove dead code. 2026-02-01 02:49:50 +00:00
LeoVasanko 032ad13b97 Unified watcher that *may* receive events from inotify and other sources. Added change notify messages from control and upload WebSockets. Cleanup debug printouts. 2026-02-01 02:44:52 +00:00
LeoVasanko 4eae75c84b New inotify watcher with efficient algorithms. 2026-01-31 23:46:43 +00:00
LeoVasanko 252b31a293 Performance profile file change watching. 2026-01-31 22:53:17 +00:00
LeoVasanko feff202161 Debouncing to avoid UI flicker while typing in search. 2026-01-31 22:42:16 +00:00
LeoVasanko d15bfc86c4 Search optimizations and fixes. Prevent re-renders on hash change (redux). Clear previous search results at the start of a new search. 2026-01-31 22:37:47 +00:00
LeoVasanko f94ccc01c6 Optimizing the search faster; don't trigger full re render by URL changes. 2026-01-31 22:17:05 +00:00
LeoVasanko bc355d991d Simplified startup box unix socket formatting. 2026-01-31 22:09:57 +00:00
LeoVasanko e7ebad3c36 Upgrade to docopt-ng because docopt is no longer maintained. 2026-01-31 22:06:03 +00:00
LeoVasanko 036c4342de Better error logging on preview generation errors. 2026-01-31 22:00:26 +00:00
29 changed files with 1254 additions and 454 deletions
+7 -7
View File
@@ -28,7 +28,7 @@ def create_banner():
def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None): def create_startup_box(*, folder, url, unix=None, dev=False, paskia_url=None):
"""Create a framed startup box with server information.""" """Create a framed startup box with server information."""
title = f"Cista {cista.__version__}" title = f"Cista {cista.__version__}"
listen = f"{url} ({unix})" if unix else url listen = unix if unix else url
location = f"{folder} @ {listen}" location = f"{folder} @ {listen}"
lines = [title, location] lines = [title, location]
if paskia_url: if paskia_url:
@@ -57,7 +57,7 @@ Usage:
Options: Options:
-c CONFDIR Custom config directory -c CONFDIR Custom config directory
-l LISTEN-ADDR Listen on -l LISTEN-ADDR Listen on
:8000 (localhost port, plain http) :8989 (localhost port, plain http)
<addr>:3000 (bind another address, port) <addr>:3000 (bind another address, port)
/path/to/unix.sock (unix socket) /path/to/unix.sock (unix socket)
example.com (run on 80 and 443 with LetsEncrypt) example.com (run on 80 and 443 with LetsEncrypt)
@@ -80,7 +80,7 @@ Environment:
first_time_help = """\ first_time_help = """\
No config file found! Get started with: No config file found! Get started with:
cista --user yourname --privileged # If you want user accounts cista --user yourname --privileged # If you want user accounts
cista -l :8000 /path/to/files # Run the server on localhost:8000 cista -l :8989 /path/to/files # Run the server on localhost:8989
See cista --help for other options! See cista --help for other options!
""" """
@@ -140,8 +140,8 @@ def _main():
if listen: if listen:
settings["listen"] = listen settings["listen"] = listen
elif not exists: elif not exists:
settings["listen"] = ":8000" settings["listen"] = ":8989"
operation = config.update_config(settings) config.update_config(settings)
# Prepare to serve # Prepare to serve
url, opts = serve.parse_listen(config.config.listen) url, opts = serve.parse_listen(config.config.listen)
if not config.config.path.is_dir(): if not config.config.path.is_dir():
@@ -186,7 +186,7 @@ def _user(args):
# Defaults for new config when user is created # Defaults for new config when user is created
operation = config.update_config( operation = config.update_config(
{ {
"listen": ":8000", "listen": ":8989",
"path": Path.home() / "Downloads", "path": Path.home() / "Downloads",
"public": False, "public": False,
} }
@@ -215,7 +215,7 @@ def _user(args):
if operation == "created": if operation == "created":
sys.stderr.write( sys.stderr.write(
"Now you can run the server:\n cista # defaults set: -l :8000 ~/Downloads\n" "Now you can run the server:\n cista # defaults set: -l :8989 ~/Downloads\n"
) )
+6
View File
@@ -1,5 +1,6 @@
import asyncio import asyncio
import typing import typing
from pathlib import PurePosixPath
from secrets import token_bytes from secrets import token_bytes
import msgspec import msgspec
@@ -53,6 +54,9 @@ async def upload(req, ws):
if pos != req.end: if pos != req.end:
d = f"{len(data)} bytes" if isinstance(data, bytes) else data d = f"{len(data)} bytes" if isinstance(data, bytes) else data
raise ValueError(f"Expected {req.end - pos} more bytes, got {d}") raise ValueError(f"Expected {req.end - pos} more bytes, got {d}")
# Signal the watcher about the uploaded file and its parent directories
path = PurePosixPath(req.name)
watching.notify_change(path, *path.parents)
# Report success # Report success
res = StatusMsg(status="ack", req=req) res = StatusMsg(status="ack", req=req)
await asend(ws, res) await asend(ws, res)
@@ -87,6 +91,8 @@ async def control(req, ws):
while True: while True:
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes) cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
await asyncio.to_thread(cmd) await asyncio.to_thread(cmd)
# Signal the watcher about affected paths
watching.notify_change(*cmd.affected_paths())
await asend(ws, StatusMsg(status="ack", req=cmd)) await asend(ws, StatusMsg(status="ack", req=cmd))
+6 -31
View File
@@ -1,7 +1,6 @@
import asyncio import asyncio
import datetime import datetime
import mimetypes import mimetypes
import threading
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from multiprocessing import cpu_count from multiprocessing import cpu_count
from pathlib import Path, PurePath, PurePosixPath from pathlib import Path, PurePath, PurePosixPath
@@ -56,7 +55,6 @@ async def main_start(app):
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers) # Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
@app.before_server_stop @app.before_server_stop
async def main_stop(app): async def main_stop(app):
quit.set()
watching.stop(app) watching.stop(app)
app.ctx.threadexec.shutdown() app.ctx.threadexec.shutdown()
app.ctx.zipexec.shutdown(cancel_futures=True) app.ctx.zipexec.shutdown(cancel_futures=True)
@@ -174,9 +172,8 @@ def _load_wwwroot(www):
@app.before_server_start @app.before_server_start
async def start(app): async def start(app):
if not app.debug:
await load_wwwroot(app) await load_wwwroot(app)
if app.debug:
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
async def load_wwwroot(app): async def load_wwwroot(app):
@@ -186,36 +183,14 @@ async def load_wwwroot(app):
) )
quit = threading.Event()
async def refresh_wwwroot():
try:
while not quit.is_set():
try:
wwwold = www
await load_wwwroot(app)
changes = ""
for name in sorted(www):
attr = www[name]
if wwwold.get(name) == attr:
continue
headers = attr[2]
changes += f"{headers['last-modified']} {headers['etag']} /{name}\n"
for name in sorted(set(wwwold) - set(www)):
changes += f"Deleted /{name}\n"
if changes:
logger.info(f"Updated wwwroot:\n{changes}", end="", flush=True)
except Exception as e:
logger.error(f"Error loading wwwroot: {e!r}")
await asyncio.sleep(0.5)
except asyncio.CancelledError:
pass
@app.route("/<path:path>", methods=["GET", "HEAD"]) @app.route("/<path:path>", methods=["GET", "HEAD"])
async def wwwroot(req, path=""): async def wwwroot(req, path=""):
"""Frontend files only""" """Frontend files only"""
if app.debug:
raise NotFound(
"Dev mode: frontend-build is not served on backend (you should connect vite)",
extra={"name": path},
)
name = unquote(path) name = unquote(path)
if name not in www: if name not in www:
raise NotFound(f"File not found: /{path}", extra={"name": name}) raise NotFound(f"File not found: /{path}", extra={"name": name})
+2 -2
View File
@@ -89,9 +89,9 @@ def dispatch(path, quality, maxsize, maxzoom):
return process_video(path, quality=quality, maxsize=maxsize) return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize) return process_image(path, quality=quality, maxsize=maxsize)
except ValueError as e: except ValueError as e:
logger.warning(f"Cannot generate preview for {path.name}: {e}") logger.warning(f"Cannot generate preview for {path}: {e}")
except Exception as e: except Exception as e:
logger.exception(f"Error generating preview for {path.name}: {e}") logger.exception(f"Error generating preview for {path}: {e}")
def process_image(path, *, maxsize, quality): def process_image(path, *, maxsize, quality):
+28
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import shutil import shutil
from pathlib import PurePosixPath
from typing import Any from typing import Any
import msgspec import msgspec
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
def __call__(self): def __call__(self):
raise NotImplementedError raise NotImplementedError
def affected_paths(self) -> list[str]:
"""Return list of paths affected by this operation for change notification."""
return []
class MkDir(ControlBase): class MkDir(ControlBase):
path: str path: str
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
path = config.config.path / filename.sanitize(self.path) path = config.config.path / filename.sanitize(self.path)
path.mkdir(parents=True, exist_ok=False) path.mkdir(parents=True, exist_ok=False)
def affected_paths(self) -> list[str]:
return [filename.sanitize(self.path)]
class Rename(ControlBase): class Rename(ControlBase):
path: str path: str
@@ -36,6 +44,11 @@ class Rename(ControlBase):
path = config.config.path / filename.sanitize(self.path) path = config.config.path / filename.sanitize(self.path)
path.rename(path.with_name(to)) path.rename(path.with_name(to))
def affected_paths(self) -> list[str]:
sanitized = filename.sanitize(self.path)
new_path = str(PurePosixPath(sanitized).with_name(filename.sanitize(self.to)))
return [sanitized, new_path]
class Rm(ControlBase): class Rm(ControlBase):
sel: list[str] sel: list[str]
@@ -49,6 +62,9 @@ class Rm(ControlBase):
else: else:
p.unlink() p.unlink()
def affected_paths(self) -> list[str]:
return [filename.sanitize(p) for p in self.sel]
class Mv(ControlBase): class Mv(ControlBase):
sel: list[str] sel: list[str]
@@ -63,6 +79,13 @@ class Mv(ControlBase):
for p in sel: for p in sel:
shutil.move(p, dst) shutil.move(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
paths = [filename.sanitize(p) for p in self.sel]
# Include new locations in dst
paths.extend(f"{dst}/{PurePosixPath(p).name}" for p in self.sel)
return paths
class Cp(ControlBase): class Cp(ControlBase):
sel: list[str] sel: list[str]
@@ -86,6 +109,11 @@ class Cp(ControlBase):
else: else:
shutil.copy2(p, dst) shutil.copy2(p, dst)
def affected_paths(self) -> list[str]:
dst = filename.sanitize(self.dst)
# Only destinations are new (sources unchanged)
return [f"{dst}/{PurePosixPath(filename.sanitize(p)).name}" for p in self.sel]
ControlTypes = MkDir | Rename | Rm | Mv | Cp ControlTypes = MkDir | Rename | Rm | Mv | Cp
+21 -10
View File
@@ -2,6 +2,7 @@ import os
import re import re
from pathlib import Path from pathlib import Path
from fastapi_vue.hostutil import parse_endpoint
from sanic import Sanic from sanic import Sanic
from cista import config, server80 from cista import config, server80
@@ -44,18 +45,28 @@ def check_cert(certdir, domain):
def parse_listen(listen): def parse_listen(listen):
if listen.startswith("/"): # Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
unix = Path(listen).resolve() if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True}
# Use fastapi_vue's parse_endpoint for everything else
endpoints = parse_endpoint(listen, default_port=8989)
ep = endpoints[0]
if "uds" in ep:
unix = Path(ep["uds"]).resolve()
if not unix.parent.exists(): if not unix.parent.exists():
raise ValueError( raise ValueError(
f"Directory for unix socket does not exist: {unix.parent}/", f"Directory for unix socket does not exist: {unix.parent}/",
) )
return "http://localhost", {"unix": unix.as_posix()} return "http://localhost", {"unix": unix.as_posix()}
if re.fullmatch(r"(\w+(-\w+)*\.)+\w{2,}", listen, re.UNICODE):
return f"https://{listen}", {"host": listen, "port": 443, "ssl": True} host, port = ep["host"], ep["port"]
try: # When binding all interfaces, use single_listener=False for Sanic
addr, _port = listen.split(":", 1) if len(endpoints) > 1:
port = int(_port) return f"http://localhost:{port}", {
except Exception: "host": host,
raise ValueError(f"Invalid listen address: {listen}") from None "port": port,
return f"http://localhost:{port}", {"host": addr, "port": port} "single_listener": False,
}
return f"http://{host}:{port}", {"host": host, "port": port}
+337 -68
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import queue
import shutil import shutil
import sys import sys
import threading import threading
@@ -116,6 +117,28 @@ state = State()
rootpath: Path = None # type: ignore rootpath: Path = None # type: ignore
quit = threading.Event() quit = threading.Event()
# Thread-safe queue for signaling path updates from websockets
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
def notify_change(*paths: PurePosixPath | str):
"""Signal that paths have changed. Called from control/upload websockets."""
for path in paths:
if isinstance(path, str):
path = PurePosixPath(path)
# Convert absolute paths to relative (strip leading /)
if path.is_absolute():
path = (
PurePosixPath(*path.parts[1:])
if len(path.parts) > 1
else PurePosixPath()
)
# Skip root paths (empty, '.') to avoid full tree walks
if not path.parts or path.parts == (".",):
continue
_update_queue.put(path)
## Filesystem scanning ## Filesystem scanning
@@ -326,26 +349,204 @@ def format_root(root):
def broadcast(msg, loop): def broadcast(msg, loop):
return asyncio.run_coroutine_threadsafe(abroadcast(msg), loop).result() fut = asyncio.run_coroutine_threadsafe(abroadcast(msg), loop)
return fut.result()
async def abroadcast(msg): async def abroadcast(msg):
client_count = 0
try: try:
for queue in pubsub.values(): for queue in pubsub.values():
queue.put_nowait(msg) queue.put_nowait(msg)
client_count += 1
except Exception: except Exception:
# Log because asyncio would silently eat the error # Log because asyncio would silently eat the error
logger.exception("Broadcast error") logger.exception("Broadcast error")
return client_count
## Watcher thread ## Watcher thread
def watcher_inotify(loop): class PathIndex:
"""Inotify watcher thread (Linux only)""" """O(1) path lookup index for the flat FileEntry tree."""
def __init__(self, root: list[FileEntry]):
self.root = root
self._index: dict[PurePosixPath, tuple[int, int]] = {}
self._rebuild()
def _rebuild(self):
"""Build path -> (start_idx, count) mapping in single O(n) pass."""
index: dict[PurePosixPath, tuple[int, int]] = {}
path_stack: list[tuple[PurePosixPath, int]] = [] # (path, start_idx)
for i, entry in enumerate(self.root):
# Pop completed paths from stack
while path_stack and entry.level <= len(path_stack[-1][0].parts):
completed_path, start_idx = path_stack.pop()
index[completed_path] = (start_idx, i - start_idx)
# Build current path
if entry.level == 0:
current_path = PurePosixPath()
else:
parent = path_stack[-1][0] if path_stack else PurePosixPath()
current_path = parent / entry.name
path_stack.append((current_path, i))
# Close remaining paths
for path, start_idx in path_stack:
index[path] = (start_idx, len(self.root) - start_idx)
self._index = index
def get(self, path: PurePosixPath) -> tuple[int | None, list[FileEntry]]:
"""O(1) lookup: returns (start_idx, entries) or (None, [])."""
if path not in self._index:
return None, []
start, count = self._index[path]
return start, self.root[start : start + count]
def find_insert_pos(self, path: PurePosixPath, isfile: int) -> int:
"""Find insertion position using index + binary search."""
if not path.parts:
return 0
parent = path.parent
name = path.name
# Find parent's range
if parent == PurePosixPath():
# Insert at root level - scan root's direct children
start, count = 0, len(self.root)
target_level = 1
elif parent in self._index:
start, count = self._index[parent]
start += 1 # Skip parent entry itself
count -= 1
target_level = len(parent.parts) + 1
else:
# Parent doesn't exist, shouldn't happen
return len(self.root)
# Binary search among direct children at target_level
# Collect children indices first
children = []
i = start
end = start + count
while i < end:
entry = self.root[i]
if entry.level == target_level:
children.append(i)
i += 1
if not children:
return start
# Binary search for insertion point
nsort = sortkey(name)
lo, hi = 0, len(children)
while lo < hi:
mid = (lo + hi) // 2
idx = children[mid]
entry = self.root[idx]
ename = entry.name
esort = sortkey(ename)
# Compare: isfile, then sort key, then case-sensitive
cmp = (
entry.isfile - isfile
or (esort > nsort) - (esort < nsort)
or (ename > name) - (ename < name)
)
if cmp < 0:
lo = mid + 1
else:
hi = mid
if lo < len(children):
return children[lo]
elif children:
# Insert after last child's subtree
last_idx = children[-1]
last_entry = self.root[last_idx]
if last_entry.isfile:
return last_idx + 1
# Find end of last child's subtree
last_path = parent / last_entry.name
if last_path in self._index:
s, c = self._index[last_path]
return s + c
return last_idx + 1
return start
def apply_update(
self, path: PurePosixPath, new_entries: list[FileEntry]
) -> list[FileEntry]:
"""Apply an update and return the new root. Rebuilds index."""
start, old_entries = self.get(path)
if old_entries == new_entries:
return self.root
new_root = self.root[:]
if start is not None:
del new_root[start : start + len(old_entries)]
if new_entries:
# Rebuild index on modified list to find insert pos
self.root = new_root
self._rebuild()
insert_pos = self.find_insert_pos(path, new_entries[0].isfile)
new_root[insert_pos:insert_pos] = new_entries
self.root = new_root
self._rebuild()
return new_root
def collapse_paths(paths: set[PurePosixPath]) -> set[PurePosixPath]:
"""Remove child paths if parent is in set."""
if not paths:
return paths
# Filter out root paths (empty or '.') which would cause full tree walks
paths = {p for p in paths if p.parts and p.parts != (".",)}
if not paths:
return set()
# Sort by depth (fewest parts first)
sorted_paths = sorted(paths, key=lambda p: len(p.parts))
result = set()
for path in sorted_paths:
# Check if any ancestor is already in result
is_child = False
for i in range(len(path.parts)):
ancestor = PurePosixPath(*path.parts[:i]) if i > 0 else PurePosixPath()
if ancestor in result:
is_child = True
break
if not is_child:
result.add(path)
return result
# Debounce settings
DEBOUNCE_DELAY = 0.01 # Wait 10ms after last event
DEBOUNCE_MAX = 0.1 # But no more than 100ms total
def watcher(loop):
"""Unified watcher thread handling inotify, websocket signals, and periodic scans."""
use_inotify = sys.platform == "linux"
inotify_tree = None
modified_flags = frozenset()
if use_inotify:
import inotify.adapters import inotify.adapters
modified_flags = ( modified_flags = frozenset(
(
"IN_CREATE", "IN_CREATE",
"IN_DELETE", "IN_DELETE",
"IN_DELETE_SELF", "IN_DELETE_SELF",
@@ -354,99 +555,167 @@ def watcher_inotify(loop):
"IN_MOVED_FROM", "IN_MOVED_FROM",
"IN_MOVED_TO", "IN_MOVED_TO",
) )
)
while not quit.is_set(): while not quit.is_set():
i = inotify.adapters.InotifyTree(rootpath.as_posix()) if use_inotify:
import inotify.adapters
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
# Initialize the tree from filesystem # Initialize the tree from filesystem
update_root(loop) update_root(loop)
path_index = PathIndex(state.root[:])
trefresh = time.monotonic() + 300.0 trefresh = time.monotonic() + 300.0
tspace = time.monotonic() + 5.0 tspace = time.monotonic() + 5.0
# Watch for changes (frequent wakeups needed for quiting)
# Pending changes: path -> {"ws": count, "inotify": count}
dirty_paths: dict[PurePosixPath, dict[str, int]] = {}
first_event_time: float | None = None
last_event_time: float | None = None
def add_dirty(path: PurePosixPath, source: str) -> bool:
"""Add path to dirty set. Returns True if added, False if redundant."""
nonlocal first_event_time, last_event_time
# Check if already covered by an existing dirty path
for existing in dirty_paths:
if path == existing or (
len(path.parts) > len(existing.parts)
and path.parts[: len(existing.parts)] == existing.parts
):
# Count the event even if skipped
dirty_paths[existing][source] = (
dirty_paths[existing].get(source, 0) + 1
)
return False
# Remove any paths that would be covered by this new one
covered = {
p
for p in dirty_paths
if len(p.parts) > len(path.parts)
and p.parts[: len(path.parts)] == path.parts
}
# Aggregate counts from covered paths
counts: dict[str, int] = {source: 1}
for p in covered:
for s, c in dirty_paths[p].items():
counts[s] = counts.get(s, 0) + c
del dirty_paths[p]
dirty_paths[path] = counts
now = time.monotonic()
if first_event_time is None:
first_event_time = now
last_event_time = now
return True
while not quit.is_set(): while not quit.is_set():
t = time.monotonic() now = time.monotonic()
# The watching is not entirely reliable, so do a full refresh every 30 seconds
if t >= trefresh: # Full refresh every 300s
if now >= trefresh:
break break
# Disk usage update
if t >= tspace: # Disk usage update every 5s
tspace = time.monotonic() + 5.0 if now >= tspace:
tspace = now + 5.0
update_space(loop) update_space(loop)
# Inotify events, update the tree
dirty = False # Check if we should flush pending changes
rootmod = state.root[:] should_flush = False
for event in i.event_gen(yield_nones=False, timeout_s=0.1): if dirty_paths:
assert event time_since_last = now - last_event_time if last_event_time else 0
if quit.is_set(): time_since_first = now - first_event_time if first_event_time else 0
return if (
interesting = any(f in modified_flags for f in event[1]) time_since_last >= DEBOUNCE_DELAY
if interesting: or time_since_first >= DEBOUNCE_MAX
# Update modified path ):
path = PurePosixPath(event[2]) / event[3] should_flush = True
if should_flush:
paths_to_process = dirty_paths.copy()
dirty_paths.clear()
first_event_time = None
last_event_time = None
# Collapse paths (remove children if parent present)
collapsed = collapse_paths(set(paths_to_process.keys()))
# Process each collapsed path
new_root = path_index.root
for path in collapsed:
new_entries = walk(path)
new_root = path_index.apply_update(path, new_entries)
# Broadcast if changed
if new_root != state.root:
try: try:
rel_path = path.relative_to(rootpath) update_msg = format_update(state.root, new_root)
update_path(rootmod, rel_path, loop)
except Exception as e:
logger.error(
f"Error processing inotify event for path {path}: {e}"
)
raise
if not dirty:
t = time.monotonic()
dirty = True
# Wait a maximum of 0.2s to push the updates
if dirty and time.monotonic() >= t + 0.2:
break
if dirty and state.root != rootmod:
try:
update = format_update(state.root, rootmod)
with state.lock: with state.lock:
broadcast(update, loop) broadcast(update_msg, loop)
state.root = rootmod state.root = new_root
except Exception: except Exception:
logger.exception( logger.exception("format_update failed; full rescan")
"format_update failed; falling back to full rescan"
)
# Fallback: full rescan and try diff again; last resort send full root
try: try:
fresh = walk(PurePosixPath()) fresh = walk(PurePosixPath())
try: path_index = PathIndex(fresh)
update = format_update(state.root, fresh) update_msg = format_update(state.root, fresh)
with state.lock: with state.lock:
broadcast(update, loop) broadcast(update_msg, loop)
state.root = fresh state.root = fresh
except Exception: except Exception:
logger.exception( logger.exception("Fallback failed; sending full root")
"Fallback diff failed; sending full root snapshot"
)
with state.lock: with state.lock:
broadcast(format_root(fresh), loop) broadcast(format_root(fresh), loop)
state.root = fresh state.root = fresh
except Exception:
logger.exception(
"Full rescan failed; dropping this batch of updates"
)
del i # Free the inotify object # Collect events from websocket signals (non-blocking)
try:
while True:
path = _update_queue.get_nowait()
add_dirty(path, "ws")
except queue.Empty:
pass
# Collect inotify events if available (short timeout for responsiveness)
if inotify_tree:
for event in inotify_tree.event_gen(yield_nones=False, timeout_s=0.05):
if quit.is_set():
return
if not (modified_flags & set(event[1])):
continue
def watcher_poll(loop): # Extract relative path
"""Polling version of the watcher thread.""" path = PurePosixPath(event[2]) / event[3]
while not quit.is_set(): try:
t0 = time.perf_counter() rel_path = path.relative_to(rootpath)
update_root(loop) except ValueError:
update_space(loop) continue
dur = time.perf_counter() - t0
if dur > 1.0: # Skip dotfiles
logger.debug(f"Reading the full file list took {dur:.1f}s") if any(part.startswith(".") for part in rel_path.parts):
quit.wait(0.1 + 8 * dur) continue
add_dirty(rel_path, "inotify")
# Don't block too long collecting events
now = time.monotonic()
if first_event_time and now - first_event_time >= DEBOUNCE_MAX:
break
else:
# No inotify, just sleep briefly for responsiveness
time.sleep(0.05)
if inotify_tree:
del inotify_tree
def start(app): def start(app):
global rootpath global rootpath
config.load_config() config.load_config()
rootpath = config.config.path rootpath = config.config.path
use_inotify = sys.platform == "linux"
app.ctx.watcher = threading.Thread( app.ctx.watcher = threading.Thread(
target=watcher_inotify if use_inotify else watcher_poll, target=watcher,
args=[app.loop], args=[app.loop],
# Descriptive name for system monitoring # Descriptive name for system monitoring
name=f"cista-watcher {rootpath}", name=f"cista-watcher {rootpath}",
+19 -5
View File
@@ -9,15 +9,14 @@
<UserManagementModal /> <UserManagementModal />
<AccessDeniedModal /> <AccessDeniedModal />
<header> <header>
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query"> <HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
<HeaderSelected :path="path.pathList" />
</HeaderMain>
<BreadCrumb :path="path.pathList" primary /> <BreadCrumb :path="path.pathList" primary />
</header> </header>
<main> <main>
<RouterView :path="path.pathList" :query="path.query" /> <RouterView :path="path.pathList" :query="path.query" />
</main> </main>
<footer> <footer v-if="store.selected.size || store.uprogress.total || store.dprogress.total">
<HeaderSelected :path="path.pathList" />
<TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload /> <TransferBar :status=store.uprogress @cancel=store.cancelUploads class=upload />
<TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download /> <TransferBar :status=store.dprogress @cancel=store.cancelDownloads class=download />
</footer> </footer>
@@ -104,7 +103,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
else if (keyup && event.key === 'Escape') { else if (keyup && event.key === 'Escape') {
store.error = '' store.error = ''
store.clearToast() store.clearToast()
headerMain.value!.closeSearch(event) headerMain.value!.clearSearch(event)
store.focusBreadcrumb() store.focusBreadcrumb()
} }
else if (!input && keyup && event.key === 'Backspace') { else if (!input && keyup && event.key === 'Backspace') {
@@ -189,4 +188,19 @@ export type { Path }
background: #555; background: #555;
color: #fff; color: #fff;
} }
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
z-index: 50;
}
footer > * {
justify-content: center;
}
</style> </style>
+4 -63
View File
@@ -43,44 +43,14 @@
--root-font-size: 1.5rem; --root-font-size: 1.5rem;
} }
} }
/* Low (landscape) screens: smaller header */
@media screen and (max-height: 600px) {
:root {
--header-font-size: calc(10px + 10 * 100vh / 600); /* 20px at 600px height */
--root-font-size: 0.8rem;
--header-height: 2rem;
}
header .breadcrumb > * {
padding-top: calc(8 + 8 * 100vh / 600) !important;
padding-bottom: calc(8 + 8 * 100vh / 600) !important;
}
}
@media screen and (max-height: 300px) {
:root {
--header-font-size: 15px; /* Don't go smaller than this, no benefit */
--header-height: calc(1.75 * 16px);
--root-font-size: 0.6rem;
}
header .breadcrumb > * {
padding-top: 14px !important;
padding-bottom: 14px !important;
}
}
@media screen and (orientation: landscape) and (min-width: 700px) { @media screen and (orientation: landscape) and (min-width: 700px) {
/* Breadcrumbs and buttons side by side */ /* Breadcrumbs and buttons side by side */
:root {
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
}
header { header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
header .headermain { order: 1; } header .headermain { order: 1; }
header .breadcrumb { align-self: stretch; } header .breadcrumb { align-self: stretch; }
header .action-button {
width: 2em;
height: 2em;
}
} }
@media print { @media print {
:root { :root {
@@ -241,42 +211,13 @@ header nav.headermain {
.spacer { flex-grow: 1 } .spacer { flex-grow: 1 }
.smallgap { flex-shrink: 1; width: 2em } .smallgap { flex-shrink: 1; width: 2em }
[data-tooltip]:hover:after {
z-index: 101;
content: attr(data-tooltip);
position: absolute;
pointer-events: none;
font-size: 1rem;
text-align: center;
padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color);
transform: translate(calc(1rem + -50%), 150%);
background-color: var(--accent-color);
color: var(--primary-color);
white-space: pre;
animation: appearbriefly calc(10 * var(--transition-time)) linear forwards;
}
@keyframes appearbriefly {
from {
opacity: 0;
}
30% {
opacity: 0;
}
40% {
opacity: 1;
}
90% {
opacity: 1;
}
to {
opacity: 0;
}
}
.error-message { .error-message {
padding: .5em; padding: .5em;
font-weight: bold; font-weight: bold;
background: var(--accent-color); background: var(--accent-color);
color: #000; color: #000;
} }
.ghost {
opacity: 0.5;
}
+16 -3
View File
@@ -14,9 +14,12 @@
:class="{ current: !!isCurrent(0) }" :class="{ current: !!isCurrent(0) }"
:aria-current="isCurrent(0)" :aria-current="isCurrent(0)"
@click.prevent="navigate(0)" @click.prevent="navigate(0)"
title="/" @mouseenter="homeTooltip?.startHover"
@mousemove="homeTooltip?.updatePosition"
@mouseleave="homeTooltip?.endHover"
> >
<component :is="home" /> <component :is="home" />
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
</a> </a>
<template v-for="(location, index) in longest" :key="index"> <template v-for="(location, index) in longest" :key="index">
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`" <a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
@@ -24,8 +27,10 @@
:aria-current="isCurrent(index + 1)" :aria-current="isCurrent(index + 1)"
@click.prevent="navigate(index + 1)" @click.prevent="navigate(index + 1)"
:ref="el => setLinkRef(index + 1, el)" :ref="el => setLinkRef(index + 1, el)"
:title="`/${longest.slice(0, index + 1).join('/')}`" @mouseenter="pathTooltips.get(index)?.startHover"
>{{ location }}</a> @mousemove="pathTooltips.get(index)?.updatePosition"
@mouseleave="pathTooltips.get(index)?.endHover"
>{{ location }}<CursorTooltip :ref="el => setPathTooltipRef(index, el)" :text="`/${longest.slice(0, index + 1).join('/')}`">{{ `/${longest.slice(0, index + 1).join('/')}` }}</CursorTooltip></a>
</template> </template>
</nav> </nav>
</template> </template>
@@ -35,6 +40,7 @@ import { Home } from '@/assets/svg'
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue' import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { exists } from '@/utils/fileutil' import { exists } from '@/utils/fileutil'
import CursorTooltip from './CursorTooltip.vue'
const home = Home const home = Home
const router = useRouter() const router = useRouter()
@@ -43,6 +49,13 @@ const links = [] as Array<HTMLElement>
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el } const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home onBeforeUpdate(() => { links.length = 1 }) // 1 to keep home
const homeTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const pathTooltips = ref<Map<number, InstanceType<typeof CursorTooltip>>>(new Map())
const setPathTooltipRef = (index: number, el: any) => {
if (el) pathTooltips.value.set(index, el)
else pathTooltips.value.delete(index)
}
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
primary?: boolean primary?: boolean
+56 -19
View File
@@ -1,6 +1,6 @@
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div v-if="visible" class="cursor-tooltip" :style="tooltipStyle"> <div v-if="visible" ref="tooltipEl" class="cursor-tooltip" :style="tooltipStyle">
<slot></slot> <slot></slot>
</div> </div>
</Teleport> </Teleport>
@@ -10,12 +10,12 @@
// Global activation state - shared across all instances // Global activation state - shared across all instances
let globalActive = false let globalActive = false
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
// Track if we've seen real mouse movement (not touch-simulated) // Track recent touch to suppress touch-triggered mouse events
let hasRealMouse = false let lastTouchTime = 0
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{ const props = defineProps<{
text: string text: string
@@ -25,6 +25,9 @@ const props = defineProps<{
const visible = ref(false) const visible = ref(false)
const mouseX = ref(0) const mouseX = ref(0)
const mouseY = ref(0) const mouseY = ref(0)
const tooltipWidth = ref(0)
const tooltipHeight = ref(0)
const tooltipEl = ref<HTMLElement | null>(null)
let settleTimer: ReturnType<typeof setTimeout> | null = null let settleTimer: ReturnType<typeof setTimeout> | null = null
let lastMoveX = 0 let lastMoveX = 0
let lastMoveY = 0 let lastMoveY = 0
@@ -32,17 +35,51 @@ let lastMoveY = 0
// Movement threshold (pixels) - cursor must settle within this radius // Movement threshold (pixels) - cursor must settle within this radius
const SETTLE_THRESHOLD = 8 const SETTLE_THRESHOLD = 8
const tooltipStyle = computed(() => ({ const tooltipStyle = computed(() => {
left: `${mouseX.value}px`, // Constrain to viewport
top: `${mouseY.value}px`, const pad = 8
})) let x = mouseX.value
let y = mouseY.value
// Check if the device likely has a real mouse (fine pointer) // Only constrain if we've measured the tooltip
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches if (tooltipWidth.value > 0 && tooltipHeight.value > 0) {
// Adjust horizontal position if tooltip would overflow right edge
if (x + tooltipWidth.value + pad > window.innerWidth) {
x = window.innerWidth - tooltipWidth.value - pad
}
// Adjust vertical position if tooltip would overflow bottom edge
if (y + tooltipHeight.value + pad > window.innerHeight) {
y = window.innerHeight - tooltipHeight.value - pad
}
// Don't go past left/top edges
x = Math.max(pad, x)
y = Math.max(pad, y)
}
return {
left: `${x}px`,
top: `${y}px`,
}
})
// Track touch events globally to suppress touch-simulated mouse events
const onTouchStart = () => { lastTouchTime = Date.now() }
onMounted(() => document.addEventListener('touchstart', onTouchStart, { passive: true }))
onUnmounted(() => document.removeEventListener('touchstart', onTouchStart))
// Check if event is likely from touch (touch happened within last 500ms)
const isTouchEvent = () => Date.now() - lastTouchTime < 500
const showTooltip = () => { const showTooltip = () => {
visible.value = true visible.value = true
globalActive = true globalActive = true
// Measure tooltip after it renders
requestAnimationFrame(() => {
if (tooltipEl.value) {
tooltipWidth.value = tooltipEl.value.offsetWidth
tooltipHeight.value = tooltipEl.value.offsetHeight
}
})
} }
const scheduleTooltip = () => { const scheduleTooltip = () => {
@@ -56,8 +93,8 @@ const scheduleTooltip = () => {
} }
const startHover = (e: MouseEvent) => { const startHover = (e: MouseEvent) => {
// Ignore touch events (no fine pointer and no confirmed real mouse) // Ignore touch-simulated mouse events
if (!hasFinePointer() && !hasRealMouse) return if (isTouchEvent()) return
mouseX.value = e.clientX mouseX.value = e.clientX
mouseY.value = e.clientY mouseY.value = e.clientY
@@ -66,9 +103,8 @@ const startHover = (e: MouseEvent) => {
} }
const updatePosition = (e: MouseEvent) => { const updatePosition = (e: MouseEvent) => {
// Detect real mouse via movement (touch events don't generate continuous mousemove) // Ignore touch-simulated mouse events
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true if (isTouchEvent()) return
if (!hasFinePointer() && !hasRealMouse) return
mouseX.value = e.clientX mouseX.value = e.clientX
mouseY.value = e.clientY mouseY.value = e.clientY
@@ -122,10 +158,11 @@ defineExpose({
z-index: 10000; z-index: 10000;
padding: .5rem 1rem; padding: .5rem 1rem;
border-radius: 3rem 0 3rem 0; border-radius: 3rem 0 3rem 0;
box-shadow: 0 0 1rem var(--accent-color); box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
background-color: var(--accent-color); background: rgba(0, 0, 0, 0.3);
color: var(--primary-color); backdrop-filter: blur(4px);
white-space: nowrap; -webkit-backdrop-filter: blur(4px);
color: #fff;
pointer-events: none; pointer-events: none;
font-size: 1rem; font-size: 1rem;
} }
+33 -35
View File
@@ -1,5 +1,5 @@
<template> <template>
<SvgButton name="download" data-tooltip="Download" @click="download" /> <SvgButton name="download" tooltip="Download" @click="download" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -7,7 +7,6 @@ import { useMainStore } from '@/stores/main'
import { apiFetch } from '@/repositories/Client' import { apiFetch } from '@/repositories/Client'
import type { SelectedItems } from '@/repositories/Document' import type { SelectedItems } from '@/repositories/Document'
import { zipName } from '@/utils/fileutil' import { zipName } from '@/utils/fileutil'
import { reactive } from 'vue';
const store = useMainStore() const store = useMainStore()
@@ -131,39 +130,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
statReset() statReset()
} }
const download = async () => { const zipdl = (sel: SelectedItems) => {
const sel = store.selectedFiles
console.log('Download', sel)
if (sel.keys.length === 0) {
console.warn('Attempted download but no files found. Missing selected keys:', sel.missing)
store.showToast('No existing files selected')
store.selected.clear()
return
}
// Plain old a href download if only one file (ignoring any folders)
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0]![1]}`)
}
// Use FileSystem API if multiple files and the browser supports it
if ('showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({
startIn: 'downloads',
mode: 'readwrite'
})
await filesystemdl(sel, handle)
store.selected.clear()
return
} catch (e) {
console.error('Download to folder aborted', e)
}
}
// Otherwise, zip and download
console.log("Falling back to zip download")
const items = sel.keys.map(k => sel.docs[k]!) const items = sel.keys.map(k => sel.docs[k]!)
const name = zipName(items) const name = zipName(items)
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`) linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
@@ -171,6 +138,37 @@ const download = async () => {
store.selected.clear() store.selected.clear()
} }
const download = async (e: MouseEvent) => {
const sel = store.selectedFiles
if (sel.keys.length === 0) {
store.showToast('No existing files selected')
store.selected.clear()
return
}
// Single file: direct download
const files = sel.recursive.filter(([rel, full, doc]) => !doc.dir)
if (files.length === 1) {
store.selected.clear()
store.showToast(`Downloading ${files[0]![0].split('/').pop()}`)
return linkdl(`/files/${files[0]![1]}`)
}
// Alt+click: download to folder (hidden feature)
if (e.altKey && 'showDirectoryPicker' in window) {
try {
// @ts-ignore
const handle = await window.showDirectoryPicker({ startIn: 'downloads', mode: 'readwrite' })
await filesystemdl(sel, handle)
store.selected.clear()
} catch (e) {
console.error('Download to folder failed', e)
store.showToast('Download to folder failed')
}
return
}
// Default: ZIP download
zipdl(sel)
}
</script> </script>
<style scoped> <style scoped>
+92 -10
View File
@@ -28,7 +28,7 @@
<tr <tr
:id="`file-${doc.key}`" :id="`file-${doc.key}`"
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === 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" @click="store.cursor = store.cursor === doc.key ? '' : doc.key"
@contextmenu.prevent="contextMenu($event, doc)" @contextmenu.prevent="contextMenu($event, doc)"
> >
@@ -249,9 +249,11 @@ const mkdir = (doc: Doc, name: string) => {
} }
} }
}) })
// We should get an update from watch but this is quicker
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
doc.ghost = true
store.document.push(doc)
editing.value = null
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
const docs = props.documents const docs = props.documents
@@ -290,13 +292,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
doc.ghost = true
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
doc.ghost = false
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => { const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
ContextMenu.showContextMenu({ const items = [
x: ev.x, y: ev.y, items: [ { label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: 'Rename', onClick: () => { editing.value = doc } }, { label: '🔗 Copy Link', onClick: () => copyLink(doc) },
], ]
}) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
</script> </script>
@@ -392,9 +477,6 @@ tbody tr.cursor {
.sortcolumn:hover { .sortcolumn:hover {
cursor: pointer; cursor: pointer;
} }
.sortcolumn:hover::after {
color: var(--accent-color);
}
.sortcolumn { .sortcolumn {
padding-right: 1.5rem; padding-right: 1.5rem;
} }
+91 -18
View File
@@ -203,9 +203,11 @@ const mkdir = (doc: Doc, name: string) => {
} }
} }
}) })
// We should get an update from watch but this is quicker
doc.name = name doc.name = name
doc.key = crypto.randomUUID() doc.key = crypto.randomUUID()
doc.ghost = true
store.document.push(doc)
editing.value = null
} }
const showFolderBreadcrumb = (i: number) => { const showFolderBreadcrumb = (i: number) => {
const docs = props.documents const docs = props.documents
@@ -213,18 +215,6 @@ const showFolderBreadcrumb = (i: number) => {
return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc return i === 0 ? docloc !== loc.value : docloc !== docs[i - 1]!.loc
} }
const selectionIndeterminate = computed({
get: () => {
return (
props.documents.length > 0 &&
props.documents.some((doc: Doc) => store.selected.has(doc.key)) &&
!allSelected.value
)
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
set: (value: boolean) => {}
})
const allSelected = computed({ const allSelected = computed({
get: () => { get: () => {
return ( return (
@@ -246,13 +236,96 @@ const allSelected = computed({
const loc = computed(() => props.path.join('/')) const loc = computed(() => props.path.join('/'))
const downloadFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
if (doc.dir) {
// Download folder as ZIP
const a = document.createElement('a')
a.href = `/zip/${doc.key}/${doc.name}.zip`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}.zip`)
} else {
// Download single file
const a = document.createElement('a')
a.href = `/files/${path}`
a.download = ''
a.click()
store.showToast(`Downloading ${doc.name}`)
}
}
const copyLink = async (doc: Doc) => {
const url = new URL(doc.url, window.location.origin).href
try {
await navigator.clipboard.writeText(url)
store.showToast('📋 Link copied!')
} catch {
store.showToast('Failed to copy link')
}
}
const copyImage = async (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
try {
store.showToast('Copying image...')
const res = await fetch(`/files/${path}`)
const blob = await res.blob()
// Convert to PNG if needed (clipboard only supports PNG)
if (blob.type !== 'image/png') {
const img = new Image()
img.src = URL.createObjectURL(blob)
await new Promise(r => img.onload = r)
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
canvas.getContext('2d')!.drawImage(img, 0, 0)
const pngBlob = await new Promise<Blob>(r => canvas.toBlob(b => r(b!), 'image/png'))
URL.revokeObjectURL(img.src)
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
} else {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
}
store.showToast('📋 Image copied!')
} catch (e) {
console.error('Copy image failed', e)
store.showToast('Failed to copy image')
}
}
const deleteFile = (doc: Doc) => {
const path = doc.loc ? `${doc.loc}/${doc.name}` : doc.name
doc.ghost = true
const control = connect(controlUrl, {
message(ev: MessageEvent) {
const res = JSON.parse(ev.data)
if ('error' in res) {
console.error('Delete failed', res.error)
doc.ghost = false
store.showToast(res.error.message || 'Delete failed')
} else if (res.status === 'ack') {
store.showToast(`🗑️ Deleted ${doc.name}`)
control.close()
}
}
})
control.onopen = () => {
control.send(JSON.stringify({ op: 'rm', sel: [path] }))
}
}
const contextMenu = (ev: MouseEvent, doc: Doc) => { const contextMenu = (ev: MouseEvent, doc: Doc) => {
store.cursor = doc.key store.cursor = doc.key
ContextMenu.showContextMenu({ const items = [
x: ev.x, y: ev.y, items: [ { label: '📥 Download', onClick: () => downloadFile(doc) },
{ label: 'Rename', onClick: () => { editing.value = doc } }, { label: '🔗 Copy Link', onClick: () => copyLink(doc) },
], ]
}) if (doc.img) items.push({ label: '📋 Copy Image', onClick: () => copyImage(doc) })
items.push(
{ label: '✏️ Rename', onClick: () => { editing.value = doc } },
{ label: '🗑️ Delete', onClick: () => deleteFile(doc) },
)
ContextMenu.showContextMenu({ x: ev.x, y: ev.y, items })
} }
</script> </script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<a :id="`file-${doc.key}`" :href=doc.url tabindex=-1 <a :id="`file-${doc.key}`" :href=doc.url tabindex=-1
:class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key }" :class="{ file: !doc.dir, folder: doc.dir, cursor: store.cursor === doc.key, ghost: doc.ghost }"
@contextmenu.stop @contextmenu.stop
@focus.stop="store.cursor = doc.key" @focus.stop="store.cursor = doc.key"
@click=onclick @click=onclick
+203 -37
View File
@@ -3,23 +3,57 @@
<UploadButton :path="props.path" /> <UploadButton :path="props.path" />
<SvgButton <SvgButton
name="create-folder" name="create-folder"
data-tooltip="New folder" tooltip="New folder"
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}" @click="() => { store.fileExplorer!.newFolder() }"
/> />
<slot></slot> <div class="smallgap"></div>
<div class="spacer smallgap"></div> <SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
<template v-if="showSearchInput"> <div class="search-group">
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
<input <input
ref="search" ref="search"
type="search" type="search"
:value="query" :value="query"
@input="updateSearch" @input="updateSearch"
placeholder="Find files" @keydown.escape="clearSearch"
class="margin-input"
/> />
</template> <span v-if="!query" class="search-hint" @click="focusSearch">/</span>
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" /> </div>
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" /> <div class="spacer smallgap"></div>
<div v-if="store.space.disk" class="disk-space"
@mouseenter="diskTooltip?.startHover"
@mousemove="diskTooltip?.updatePosition"
@mouseleave="diskTooltip?.endHover"
>
<svg viewBox="0 0 32 32" class="pie-mini">
<!-- Base: 'other' usage (light purple - appears on left, before 12 o'clock) -->
<circle r="16" cx="16" cy="16" fill="#c8e" />
<!-- Middle ring: free space (dynamic color - appears at bottom) -->
<circle r="8" cx="16" cy="16" fill="transparent" :stroke="freeColor" stroke-width="16" :stroke-dasharray="pieFree" :stroke-dashoffset="pieFreeOffset" transform="rotate(-90 16 16)" />
<!-- Top ring: storage (deep purple - appears on right after 12 o'clock) -->
<circle r="8" cx="16" cy="16" fill="transparent" stroke="#82d" stroke-width="16" :stroke-dasharray="pieStorage" transform="rotate(-90 16 16)" />
<!-- Subtle inner circle for depth -->
<circle r="2" cx="16" cy="16" fill="rgba(255,255,255,0.2)" />
</svg>
<CursorTooltip ref="diskTooltip" text="Disk space">
<div class="disk-tooltip">
<svg viewBox="0 0 160 80" width="160" height="80" class="pie-tooltip">
<!-- Pie chart centered at 40,40 -->
<circle r="32" cx="40" cy="40" fill="#c8e" />
<circle r="16" cx="40" cy="40" fill="transparent" :stroke="freeColor" stroke-width="32" :stroke-dasharray="pieFreeLg" :stroke-dashoffset="pieFreeOffsetLg" transform="rotate(-90 40 40)" />
<circle r="16" cx="40" cy="40" fill="transparent" stroke="#82d" stroke-width="32" :stroke-dasharray="pieStorageLg" transform="rotate(-90 40 40)" />
<circle r="4" cx="40" cy="40" fill="rgba(255,255,255,0.25)" />
<!-- Labels on the right -->
<rect x="78" y="10" width="10" height="10" fill="#82d" rx="2"/>
<text x="92" y="19" class="pie-label">{{ formatSize(store.space.storage) }} stored</text>
<rect x="78" y="30" width="10" height="10" fill="#c8e" rx="2"/>
<text x="92" y="39" class="pie-label">{{ formatSize(store.space.usage - store.space.storage) }} other</text>
<rect x="78" y="50" width="10" height="10" :fill="freeColor" rx="2"/>
<text x="92" y="59" class="pie-label">{{ formatSize(store.space.free) }} free</text>
</svg>
</div>
</CursorTooltip>
</div>
<SvgButton name="cog" @click="settingsMenu" /> <SvgButton name="cog" @click="settingsMenu" />
</nav> </nav>
</template> </template>
@@ -27,50 +61,122 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { useSsoAuthStore } from '@/stores/ssoAuth' import { useSsoAuthStore } from '@/stores/ssoAuth'
import { ref, nextTick, watchEffect } from 'vue' import { ref, nextTick, watchEffect, computed } from 'vue'
import ContextMenu from '@imengyu/vue3-context-menu' import ContextMenu from '@imengyu/vue3-context-menu'
import { showAuthIframe } from 'paskia' import { showAuthIframe } from 'paskia'
import { resumeWatching } from '@/repositories/WS' import { resumeWatching } from '@/repositories/WS'
import router from '@/router'; import router from '@/router';
import { formatSize } from '@/utils'
import CursorTooltip from './CursorTooltip.vue'
const store = useMainStore() const store = useMainStore()
const ssoStore = useSsoAuthStore() const ssoStore = useSsoAuthStore()
const showSearchInput = ref<boolean>(false)
const search = ref<HTMLInputElement | null>() const search = ref<HTMLInputElement | null>()
const searchButton = ref<HTMLButtonElement | null>() const diskTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const CIRC = 50.27 // 2π×8
// Storage segment (starts at top, -90°)
const pieStorage = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.storage / s.disk
return `${pct * CIRC} ${CIRC}`
})
// Free segment (starts after storage, goes clockwise to bottom area)
const pieFree = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC}`
const pct = s.free / s.disk
return `${pct * CIRC} ${CIRC}`
})
const pieFreeOffset = computed(() => {
const s = store.space
if (!s.disk) return 0
// Start after storage segment
const storagePct = s.storage / s.disk
return -storagePct * CIRC
})
// Free space color: green when plenty, yellow when moderate, red when low
const freeColor = computed(() => {
const s = store.space
if (!s.disk) return '#6c6'
const freePct = s.free / s.disk
if (freePct > 0.25) return '#5b5' // Green: > 25% free
if (freePct > 0.10) return '#db3' // Yellow: 10-25% free
return '#d44' // Red: < 10% free
})
// Large pie for tooltip (circumference = 2π×16 ≈ 100.53)
const CIRC_LG = 100.53
const pieStorageLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.storage / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeLg = computed(() => {
const s = store.space
if (!s.disk) return `0 ${CIRC_LG}`
return `${(s.free / s.disk) * CIRC_LG} ${CIRC_LG}`
})
const pieFreeOffsetLg = computed(() => {
const s = store.space
if (!s.disk) return 0
return -(s.storage / s.disk) * CIRC_LG
})
const props = defineProps<{ const props = defineProps<{
path: Array<string> path: Array<string>
query: string query: string
}>() }>()
const closeSearch = (ev: Event) => { const clearSearch = (ev: Event) => {
if (!showSearchInput.value) return // Already closing const input = search.value
showSearchInput.value = false if (input) {
input.value = ''
updateSearch(ev)
}
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
breadcrumb.focus() breadcrumb.focus()
updateSearch(ev)
} }
const focusSearch = () => {
search.value?.focus()
}
// Track pending route update
let pendingRouteUpdate: number | null = null
const updateSearch = (ev: Event) => { const updateSearch = (ev: Event) => {
const q = (ev.target as HTMLInputElement).value const q = (ev.target as HTMLInputElement).value
let p = props.path.join('/') const loc = props.path.join('/')
// Start search immediately via store (worker handles it async)
store.search(q, loc)
// Cancel any pending route update
if (pendingRouteUpdate !== null) {
cancelAnimationFrame(pendingRouteUpdate)
}
// Schedule route update - will be cancelled if user types again
pendingRouteUpdate = requestAnimationFrame(() => {
pendingRouteUpdate = null
let p = loc
p = p ? `/${p}` : '' p = p ? `/${p}` : ''
const url = q ? `${p}//${q}` : (p || '/') const url = q ? `${p}//${q}` : (p || '/')
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23') const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
if (!props.query && q) router.push(u) // Use replace to avoid building up history for each keystroke
else router.replace(u) router.replace(u)
}
const toggleSearchInput = (ev: Event) => {
showSearchInput.value = !showSearchInput.value
if (!showSearchInput.value) return closeSearch(ev)
nextTick(() => {
const input = search.value
if (input) input.focus()
}) })
} }
watchEffect(() => {
if (props.query) showSearchInput.value = true const toggleSearchInput = () => {
}) search.value?.focus()
}
const settingsMenu = (e: Event) => { const settingsMenu = (e: Event) => {
// show the context menu // show the context menu
const items = [] const items = []
@@ -113,7 +219,7 @@ const settingsMenu = (e: Event) => {
} }
defineExpose({ defineExpose({
toggleSearchInput, toggleSearchInput,
closeSearch, clearSearch,
}) })
</script> </script>
@@ -125,12 +231,72 @@ defineExpose({
align-items: center; align-items: center;
z-index: 10; z-index: 10;
} }
input[type='search'] { .search-group {
background: var(--input-background); position: relative;
color: var(--input-color); display: flex;
border: 0; align-items: center;
border-radius: 0.1em; background: rgba(255, 255, 255, 0.1);
border-radius: 1.5em;
padding: 0 0.3em;
transition: background 0.2s ease;
flex: 1 1 auto;
min-width: 5.5em;
max-width: 20em;
}
.search-group:focus-within {
background: rgba(255, 255, 255, 0.2);
}
.search-group:focus-within .search-hint {
opacity: 0;
pointer-events: none;
}
.search-group :deep(.action-button) {
width: 2.2em;
height: 2.2em;
flex-shrink: 0;
}
.search-group input[type='search'] {
background: transparent;
color: var(--header-color);
border: none;
outline: none; outline: none;
max-width: 15ch; padding: 0.2em 0.5em 0.2em 0;
font-size: var(--header-font-size);
flex: 1 1 3em;
min-width: 3em;
}
.search-hint {
position: absolute;
right: 0.5em;
font-family: system-ui, sans-serif;
font-size: 1em;
font-weight: 700;
color: #333;
background: #ccc;
border: 1px solid #999;
border-radius: 0.3em;
padding: 0 0.45em;
line-height: 1.4;
cursor: pointer;
transition: opacity 0.15s ease;
}
.disk-space {
display: flex;
align-items: center;
cursor: default;
}
.pie-mini {
width: 1.4em;
height: 1.4em;
}
.disk-tooltip {
line-height: 1.5;
}
.pie-tooltip {
display: block;
}
.pie-tooltip .pie-label {
fill: #fff;
font-size: 9px;
} }
</style> </style>
+30 -11
View File
@@ -1,19 +1,27 @@
<template> <template>
<template v-if="store.selected.size"> <div class="selection-bar" v-if="store.selected.size">
<div class="smallgap"></div> <p class="select-text">{{ store.selected.size }} selected</p>
<p class="select-text">{{ store.selected.size }} selected </p>
<DownloadButton /> <DownloadButton />
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" /> <SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" /> <SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
<SvgButton name="trash" data-tooltip="Delete " @click="op('rm')" /> <SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()"></button> <button
</template> class="action-button unselect"
@click="store.selected.clear()"
@mouseenter="unselectTooltip?.startHover"
@mousemove="unselectTooltip?.updatePosition"
@mouseleave="unselectTooltip?.endHover"
><CursorTooltip ref="unselectTooltip" text="Unselect all">Unselect all</CursorTooltip></button>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {connect, controlUrl} from '@/repositories/WS' import {connect, controlUrl} from '@/repositories/WS'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { computed } from 'vue' import { computed, ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
const unselectTooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const store = useMainStore() const store = useMainStore()
const props = defineProps({ const props = defineProps({
@@ -21,10 +29,10 @@ const props = defineProps({
}) })
const dst = computed(() => props.path!.join('/')) const dst = computed(() => props.path!.join('/'))
const op = (op: string, dst?: string) => { const op = (opName: string, dst?: string) => {
const sel = store.selectedFiles const sel = store.selectedFiles
const msg = { const msg = {
op, op: opName,
sel: sel.keys.map(key => { sel: sel.keys.map(key => {
const doc = sel.docs[key]! const doc = sel.docs[key]!
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
@@ -32,6 +40,8 @@ const op = (op: string, dst?: string) => {
} }
// @ts-ignore // @ts-ignore
if (dst !== undefined) msg.dst = dst if (dst !== undefined) msg.dst = dst
if (opName === 'rm' || opName === 'mv')
for (const key of sel.keys) sel.docs[key]!.ghost = true
const control = connect(controlUrl, { const control = connect(controlUrl, {
message(ev: MessageEvent) { message(ev: MessageEvent) {
const res = JSON.parse(ev.data) const res = JSON.parse(ev.data)
@@ -55,11 +65,20 @@ const op = (op: string, dst?: string) => {
</script> </script>
<style> <style>
.selection-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 0.3em 0.5em;
background: transparent;
color: var(--header-color);
}
.select-text { .select-text {
color: var(--accent-color); color: var(--accent-color);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
margin: 0; margin: 0;
padding-right: 0.5em;
} }
</style> </style>
+14 -2
View File
@@ -1,16 +1,28 @@
<template> <template>
<button class="action-button"> <button
class="action-button"
@mouseenter="tooltip?.startHover"
@mousemove="tooltip?.updatePosition"
@mouseleave="tooltip?.endHover"
>
<component :is="icons[name]" /> <component :is="icons[name]" />
<slot></slot> <slot></slot>
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { icons, type IconName } from '@/assets/svg' import { icons, type IconName } from '@/assets/svg'
import { ref } from 'vue'
import CursorTooltip from './CursorTooltip.vue'
defineProps<{ const props = defineProps<{
name: IconName name: IconName
tooltip?: string
}>() }>()
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
const tooltipText = props.tooltip ?? ''
</script> </script>
<style> <style>
+3 -1
View File
@@ -53,15 +53,17 @@ const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value <
<style scoped> <style scoped>
.transferprogress { .transferprogress {
--bar: var(--accent-color); --bar: var(--accent-color);
--nobar: var(--header-background); --nobar: transparent;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
color: var(--primary-color); color: var(--primary-color);
width: 100%; width: 100%;
} }
.statustext { .statustext {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
margin: 0 .5em; margin: 0 .5em;
padding: 0.5rem 0; padding: 0.5rem 0;
} }
+32 -4
View File
@@ -3,16 +3,19 @@
<input ref="fileInput" @change="uploadHandler" type="file" multiple> <input ref="fileInput" @change="uploadHandler" type="file" multiple>
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory> <input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
</template> </template>
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" /> <SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" /> <SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { connect, uploadUrl } from '@/repositories/WS'; import { connect, uploadUrl } from '@/repositories/WS';
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import { Doc } from '@/repositories/Document'
import { collator } from '@/utils'; import { collator } from '@/utils';
import { onMounted, onUnmounted, reactive, ref } from 'vue' import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const fileInput = ref() const fileInput = ref()
const folderInput = ref() const folderInput = ref()
const store = useMainStore() const store = useMainStore()
@@ -72,24 +75,49 @@ function uploadHandler(event: Event) {
const uploadFiles = (infiles: File[]) => { const uploadFiles = (infiles: File[]) => {
const loc = props.path!.join('/') const loc = props.path!.join('/')
let files = [] let files = []
let folderName = ''
for (const file of infiles) { for (const file of infiles) {
const relPath = file.webkitRelativePath || file.name
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
files.push({ files.push({
file, file,
cloudName: loc + '/' + (file.webkitRelativePath || file.name), cloudName: loc + '/' + relPath,
cloudPos: 0, cloudPos: 0,
}) })
} }
uploadCloudFiles(files) uploadCloudFiles(files)
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
} }
const uploadCloudFiles = (files: CloudFile[]) => { const uploadCloudFiles = (files: CloudFile[]) => {
const dotfiles = files.filter(f => f.cloudName.includes('/.')) const dotfiles = files.filter(f => f.cloudName.includes('/.'))
if (dotfiles.length) { if (dotfiles.length) {
store.showToast("Won't upload dotfiles") store.showToast("Won't upload dotfiles")
console.log("Dotfiles omitted", dotfiles)
files = files.filter(f => !f.cloudName.includes('/.')) files = files.filter(f => !f.cloudName.includes('/.'))
} }
if (!files.length) return if (!files.length) return
files.sort((a, b) => collator.compare(a.cloudName, b.cloudName)) files.sort((a, b) => collator.compare(a.cloudName, b.cloudName))
// Optimistic update: ghost folders and files
const now = Math.floor(Date.now() / 1000)
const byPath = new Map(store.document.map(d => [d.loc ? `${d.loc}/${d.name}` : d.name, d]))
const added = new Set<string>()
for (const f of files) {
const lastSlash = f.cloudName.lastIndexOf('/')
const loc = lastSlash > 0 ? f.cloudName.slice(0, lastSlash) : ''
const name = f.cloudName.slice(lastSlash + 1)
// Ghost folders for intermediate directories
const parts = loc.split('/')
for (let i = 0; i < parts.length; i++) {
const folderPath = parts.slice(0, i + 1).join('/')
if (folderPath && !byPath.has(folderPath) && !added.has(folderPath)) {
store.document.push(new Doc({ loc: parts.slice(0, i).join('/'), name: parts[i], key: crypto.randomUUID(), size: 0, mtime: now, dir: true, ghost: true }))
added.add(folderPath)
}
}
// Ghost file or update existing
const existing = byPath.get(f.cloudName)
if (existing) { existing.size = f.file.size; existing.mtime = now; existing.ghost = true }
else store.document.push(new Doc({ loc, name, key: crypto.randomUUID(), size: f.file.size, mtime: now, dir: false, ghost: true }))
}
// @ts-ignore // @ts-ignore
upqueue = [...upqueue, ...files] upqueue = [...upqueue, ...files]
statsAdd(files) statsAdd(files)
+4 -4
View File
@@ -1,4 +1,4 @@
import { formatSize, formatUnixDate, haystackFormat } from "@/utils" import { formatSize, formatUnixDate } from "@/utils"
export type FUID = string export type FUID = string
@@ -9,6 +9,7 @@ export type DocProps = {
size: number size: number
mtime: number mtime: number
dir: boolean dir: boolean
ghost?: boolean
} }
export class Doc { export class Doc {
@@ -16,21 +17,20 @@ export class Doc {
public key: FUID = "" public key: FUID = ""
public size: number = 0 public size: number = 0
public mtime: number = 0 public mtime: number = 0
public haystack: string = ""
public dir: boolean = false public dir: boolean = false
public ghost: boolean = false
/** @internal Use the name getter/setter instead */ /** @internal Use the name getter/setter instead */
public _name: string = "" public _name: string = ""
constructor(props: Partial<DocProps> = {}) { constructor(props: Partial<DocProps> = {}) {
const { name, ...rest } = props const { name, ...rest } = props
Object.assign(this, rest) Object.assign(this, rest)
if (name) this.name = name // Use setter for validation if (name) this._name = name // Skip validation/haystack for bulk loading
} }
get name() { return this._name } get name() { return this._name }
set name(name: string) { set name(name: string) {
if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`) if (name.includes('/') || name.startsWith('.')) throw Error(`Invalid name: ${name}`)
this._name = name this._name = name
this.haystack = haystackFormat(name)
} }
get sizedisp(): string { return formatSize(this.size) } get sizedisp(): string { return formatSize(this.size) }
get modified(): string { return formatUnixDate(this.mtime) } get modified(): string { return formatUnixDate(this.mtime) }
+2 -1
View File
@@ -164,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
handleUpdateMessage(msg) handleUpdateMessage(msg)
break break
case !!msg.space: case !!msg.space:
console.log('Watch space', msg.space) const store = useMainStore()
store.space = msg.space
break break
case !!msg.error: case !!msg.error:
handleError(msg) handleError(msg)
+86 -12
View File
@@ -9,10 +9,45 @@ import SearchWorker from '@/workers/searchWorker?worker'
// Singleton search worker instance // Singleton search worker instance
let searchWorker: Worker | null = null let searchWorker: Worker | null = null
let searchId = 0 let searchId = 0
let searchStore: ReturnType<typeof useMainStore> | null = null
let loadingTimer: ReturnType<typeof setTimeout> | null = null
let clearOldResultsTimer: ReturnType<typeof setTimeout> | null = null
let lastResultUpdate = 0
function getSearchWorker(): Worker { function getSearchWorker(): Worker {
if (!searchWorker) { if (!searchWorker) {
searchWorker = new SearchWorker() searchWorker = new SearchWorker()
// Set up message handler once
searchWorker.onmessage = (e) => {
if (!searchStore || e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances
const docs = e.data.docs.map((d: any) => new Doc(d))
// Cancel the clear-old-results timer since we have new results
if (clearOldResultsTimer) {
clearTimeout(clearOldResultsTimer)
clearOldResultsTimer = null
}
// Throttle rapid intermediate updates to reduce UI flicker
const now = performance.now()
if (!e.data.done && now - lastResultUpdate < 50) {
return // Skip intermediate update if too recent
}
lastResultUpdate = now
searchStore.searchResults = docs
if (e.data.done) {
// Clear the loading timer and hide spinner
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
searchStore.searchLoading = false
}
}
} }
return searchWorker return searchWorker
} }
@@ -24,6 +59,7 @@ export const useMainStore = defineStore('main', {
query: '' as string, query: '' as string,
searchResults: [] as Doc[], searchResults: [] as Doc[],
searchLoading: false, searchLoading: false,
_searchRouteTimer: null as ReturnType<typeof setTimeout> | null,
fileExplorer: null as any, fileExplorer: null as any,
error: '' as string, // Permanent status message (e.g., "Reconnecting...") error: '' as string, // Permanent status message (e.g., "Reconnecting...")
toast: '' as string, // Temporary toast (auto-dismisses) toast: '' as string, // Temporary toast (auto-dismisses)
@@ -44,6 +80,12 @@ export const useMainStore = defineStore('main', {
username: '' as string, username: '' as string,
privileged: false as boolean, privileged: false as boolean,
isLoggedIn: false as boolean, isLoggedIn: false as boolean,
},
space: {
disk: 0,
free: 0,
usage: 0,
storage: 0,
} }
}), }),
persist: { persist: {
@@ -116,25 +158,44 @@ export const useMainStore = defineStore('main', {
search(query: string, loc: string) { search(query: string, loc: string) {
const worker = getSearchWorker() const worker = getSearchWorker()
const id = ++searchId const id = ++searchId
searchStore = this // Store reference for worker callback
// Update query immediately so watchers know we're handling this
this.query = query
// Cancel pending timers
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
if (clearOldResultsTimer) {
clearTimeout(clearOldResultsTimer)
clearOldResultsTimer = null
}
if (!query) { if (!query) {
// Clear results only when search is closed
this.searchResults = [] this.searchResults = []
this.searchLoading = false this.searchLoading = false
return return
} }
// Keep old results briefly to avoid flicker on fast cached searches
// But clear them after 50ms if no new results have arrived
clearOldResultsTimer = setTimeout(() => {
if (searchId === id) {
this.searchResults = []
}
clearOldResultsTimer = null
}, 50)
// Delay showing loading indicator to avoid flicker on fast searches
loadingTimer = setTimeout(() => {
if (searchId === id) { // Still the current search
this.searchLoading = true this.searchLoading = true
worker.onmessage = (e) => {
if (e.data.id !== searchId) return // Stale result
// Convert plain data back to Doc instances
this.searchResults = e.data.docs.map((d: any) => new Doc(d))
if (e.data.done) {
this.searchLoading = false
}
} }
loadingTimer = null
}, 100)
worker.postMessage({ type: 'search', query, loc, id }) worker.postMessage({ type: 'search', query, loc, id })
}, },
@@ -174,12 +235,25 @@ export const useMainStore = defineStore('main', {
resumeWatching() resumeWatching()
}, },
toggleSort(name: SortOrder) { toggleSort(name: SortOrder) {
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name const newOrder = current === name ? '' : name
if (this.query) this.prefs.sortFiltered = newOrder
else this.prefs.sortListing = newOrder
this.showSortToast(newOrder)
}, },
sort(name: SortOrder | '') { sort(name: SortOrder | '') {
if (this.query) this.prefs.sortFiltered = name if (this.query) this.prefs.sortFiltered = name
else this.prefs.sortListing = name else this.prefs.sortListing = name
this.showSortToast(name)
},
showSortToast(order: SortOrder | '') {
const labels: Record<string, string> = {
'': 'Folders first',
'name': 'Alphabetical order',
'modified': 'Newest first',
'size': 'Largest first',
}
this.showToast(labels[order] || order, 1200)
}, },
focusBreadcrumb() { focusBreadcrumb() {
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus() (document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
+8 -5
View File
@@ -2,14 +2,14 @@
<Gallery <Gallery
v-if="store.prefs.gallery" v-if="store.prefs.gallery"
ref="fileExplorer" ref="fileExplorer"
:key="`gallery-${Router.currentRoute.value.path}`" :key="`gallery-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
<FileExplorer <FileExplorer
v-else v-else
ref="fileExplorer" ref="fileExplorer"
:key="`explorer-${Router.currentRoute.value.path}`" :key="`explorer-${folderPath}`"
:path="props.path" :path="props.path"
:documents="documents" :documents="documents"
/> />
@@ -20,7 +20,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect, ref, computed, watch } from 'vue' import { watchEffect, ref, computed, watch } from 'vue'
import { useMainStore } from '@/stores/main' import { useMainStore } from '@/stores/main'
import Router from '@/router/index'
import { collator } from '@/utils' import { collator } from '@/utils'
import { sorted, sortedGrouped } from '@/utils/docsort' import { sorted, sortedGrouped } from '@/utils/docsort'
import FileExplorer from '@/components/FileExplorer.vue' import FileExplorer from '@/components/FileExplorer.vue'
@@ -32,10 +31,15 @@ const props = defineProps<{
query: string query: string
}>() }>()
// Trigger search when query changes // Folder path for component keys - only recreate component when folder changes, not search
const folderPath = computed(() => props.path.join('/'))
// Handle route-based search changes (back/forward navigation, direct URL)
// Skip if store.query already matches (means we triggered this via typing)
watch( watch(
() => [props.query, props.path.join('/')] as const, () => [props.query, props.path.join('/')] as const,
([query, loc]) => { ([query, loc]) => {
if (store.query === query) return // Already searching this query
store.search(query, loc) store.search(query, loc)
}, },
{ immediate: true } { immediate: true }
@@ -64,7 +68,6 @@ const documents = computed(() => {
watchEffect(() => { watchEffect(() => {
store.fileExplorer = fileExplorer.value store.fileExplorer = fileExplorer.value
store.query = props.query
}) })
// 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
+121 -73
View File
@@ -36,88 +36,152 @@ interface ResultMessage {
} }
// Worker state // Worker state
let documents: WorkerDoc[] = []
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
let currentSearchId = 0 let currentSearchId = 0
// Haystack formatting (same as main thread utils) // Search result cache - cleared when documents change
function haystackFormat(str: string): string { interface CacheEntry {
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() query: string // Normalized query string
return '^' + based + '$' results: WorkerDoc[] // Matched results (up to limit)
complete: boolean // True if search scanned all documents
}
const searchCache: CacheEntry[] = []
const MAX_CACHE_SIZE = 10
const RESULT_LIMIT = 100
// Normalize string for search (remove diacritics, lowercase)
// Haystack adds ^ and $ markers to allow matching start/end of name
function normalizeHaystack(str: string): string {
return '^' + str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + '$'
} }
// Needle formatting function normalizeQuery(str: string): string {
function needleFormat(query: string) { return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
return { based, words: based.split(/\s+/) }
} }
// Test if haystack includes needle // Test if document matches search query
function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean { function matches(haystack: string, query: string, words: string[]): boolean {
const { based, words } = filter return haystack.includes(query) || words.every(word => haystack.includes(word))
return haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
} }
// Collator for sorting // Collator for sorting
const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true, usage: 'search' }) const collator = new Intl.Collator('en', { sensitivity: 'base', numeric: true })
// Sort by mtime descending // Yield control to allow new messages to be processed
function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] { const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
return [...docs].sort((a, b) => b.mtime - a.mtime)
// Find best cache entry to filter from
// Returns entry if new query's results are guaranteed to be a subset of cached results
// Only valid if the cached search was complete (scanned all documents)
function findCacheSubset(query: string): CacheEntry | null {
// Look for a cached query that the new query starts with
// e.g., cached "foo" can be used for "foobar" or "foo bar"
// The longer the prefix, the better (fewer items to filter)
// IMPORTANT: Only use complete cache entries - incomplete ones may have
// missed results that would match the more specific query
let best: CacheEntry | null = null
for (const entry of searchCache) {
if (entry.complete && query.startsWith(entry.query)) {
if (!best || entry.query.length > best.query.length) {
best = entry
}
}
}
return best
} }
// Yield control to check for new messages // Add result to cache
function yieldControl(): Promise<void> { function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
return new Promise(resolve => setTimeout(resolve, 0)) // Remove existing entry for same query if any
const idx = searchCache.findIndex(e => e.query === query)
if (idx !== -1) searchCache.splice(idx, 1)
// Add to front (most recent)
searchCache.unshift({ query, results, complete })
// Trim cache
if (searchCache.length > MAX_CACHE_SIZE) searchCache.pop()
}
// Clear cache (called when documents change)
function clearCache() {
searchCache.length = 0
} }
// Perform search with incremental results // Perform search with incremental results
async function performSearch(query: string, loc: string, searchId: number) { async function performSearch(rawQuery: string, loc: string, searchId: number) {
const needle = needleFormat(query) const query = normalizeQuery(rawQuery)
const limit = 100 const words = query.split(/\s+/)
const batchSize = 1000
const results: WorkerDoc[] = [] const results: WorkerDoc[] = []
let lastResultCount = 0
for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) { // Check cache for exact match
// Check if search was superseded const exactMatch = searchCache.find(e => e.query === query)
if (currentSearchId !== searchId) return if (exactMatch) {
if (currentSearchId === searchId) {
postResults(exactMatch.results, rawQuery, loc, searchId, true)
}
return
}
// Check if we can filter from a cached superset
const cacheEntry = findCacheSubset(query)
if (cacheEntry) {
// Fast path: filter from cached results (only used for complete cache entries)
for (const doc of cacheEntry.results) {
if (matches(doc.haystack, query, words)) {
results.push(doc)
}
}
// Cache entry was complete, so filtered results are also complete
addToCache(query, results, true)
if (currentSearchId === searchId) {
postResults(results, rawQuery, loc, searchId, true)
}
return
}
// Slow path: scan all documents
const batchSize = 500
for (let i = 0; i < recentDocuments.length && results.length < RESULT_LIMIT; i += batchSize) {
if (currentSearchId !== searchId) return // Superseded
// Process batch // Process batch
const end = Math.min(i + batchSize, recentDocuments.length) const end = Math.min(i + batchSize, recentDocuments.length)
for (let j = i; j < end && results.length < limit; j++) { for (let j = i; j < end && results.length < RESULT_LIMIT; j++) {
const doc = recentDocuments[j]! const doc = recentDocuments[j]!
if (localeIncludes(doc.haystack, needle)) { if (matches(doc.haystack, query, words)) {
results.push(doc) results.push(doc)
} }
} }
// Post incremental results if we found any in this batch // Post incremental results if we found new matches
if (results.length > 0 && currentSearchId === searchId) { if (results.length > lastResultCount && currentSearchId === searchId) {
const sortedResults = sortResults(results, query, loc) lastResultCount = results.length
postMessage({ postResults(results, rawQuery, loc, searchId, false)
type: 'results',
docs: sortedResults.map(stripHaystack),
id: searchId,
done: false
} as ResultMessage)
} }
// Yield control between batches // Yield control between batches
if (i + batchSize < recentDocuments.length && results.length < limit) { if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
await yieldControl() await yieldControl()
} }
} }
// Post final results // Cache and post final results
addToCache(query, results, results.length < RESULT_LIMIT)
if (currentSearchId === searchId) { if (currentSearchId === searchId) {
const sortedResults = sortResults(results, query, loc) postResults(results, rawQuery, loc, searchId, true)
}
}
// Post results to main thread
function postResults(docs: WorkerDoc[], query: string, loc: string, id: number, done: boolean) {
const sorted = sortResults(docs, query, loc)
postMessage({ postMessage({
type: 'results', type: 'results',
docs: sortedResults.map(stripHaystack), docs: sorted.map(({ haystack, ...rest }) => rest),
id: searchId, id,
done: true done
} as ResultMessage) } as ResultMessage)
}
} }
// Sort results by relevance // Sort results by relevance
@@ -125,53 +189,37 @@ function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[]
const locsub = loc + '/' const locsub = loc + '/'
return [...docs].sort((a, b) => ( return [...docs].sort((a, b) => (
// Current folder first // Current folder first
// @ts-ignore Number(b.loc === loc) - Number(a.loc === loc) ||
(b.loc === loc) - (a.loc === loc) ||
// Then subfolders // Then subfolders
// @ts-ignore Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
// Then by location // Then by location
collator.compare(a.loc, b.loc) || collator.compare(a.loc, b.loc) ||
// Files after folders // Folders before files
// @ts-ignore Number(b.dir) - Number(a.dir) ||
(a.dir === false) - (b.dir === false) ||
// Exact name match first // Exact name match first
// @ts-ignore Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
b.name.includes(query) - a.name.includes(query) ||
// Finally by name // Finally by name
collator.compare(a.name, b.name) collator.compare(a.name, b.name)
)) ))
} }
// Strip haystack before sending back to main thread
function stripHaystack(doc: WorkerDoc): DocData {
const { haystack, ...rest } = doc
return rest
}
// Handle incoming messages // Handle incoming messages
self.onmessage = async (e: MessageEvent<IncomingMessage>) => { self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
const msg = e.data const msg = e.data
if (msg.type === 'update') { if (msg.type === 'update') {
// Update document list with haystacks // Update document list with haystacks, sorted by mtime descending
documents = msg.documents.map(doc => ({ recentDocuments = msg.documents
...doc, .map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
haystack: haystackFormat(doc.name) .sort((a, b) => b.mtime - a.mtime)
})) clearCache()
recentDocuments = sortByRecent(documents)
} else if (msg.type === 'search') { } else if (msg.type === 'search') {
currentSearchId = msg.id currentSearchId = msg.id
if (msg.query) { if (msg.query) {
await performSearch(msg.query, msg.loc, msg.id) await performSearch(msg.query, msg.loc, msg.id)
} else { } else {
// Empty query - no results needed (main thread handles folder listing) // Empty query - no results needed
postMessage({ postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
type: 'results',
docs: [],
id: msg.id,
done: true
} as ResultMessage)
} }
} }
} }
+2 -2
View File
@@ -6,10 +6,10 @@
* - Builds to the Python module's frontend-build directory * - Builds to the Python module's frontend-build directory
* *
* Environment variables (with defaults): * Environment variables (with defaults):
* FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying * FASTAPI_VUE_BACKEND_URL=http://localhost:8999 - Backend API URL for proxying
*/ */
const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180" const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:8999"
export default function fastapiVue({ paths = ["/api"] } = {}) { export default function fastapiVue({ paths = ["/api"] } = {}) {
// Build proxy configuration for each path // Build proxy configuration for each path
+2 -2
View File
@@ -27,8 +27,8 @@ dependencies = [
"argon2-cffi>=25.1.0", "argon2-cffi>=25.1.0",
"av>=15.0.0", "av>=15.0.0",
"blake3>=1.0.5", "blake3>=1.0.5",
"docopt>=0.6.2", "docopt-ng>=0.9.0",
"fastapi-vue>=0.5.1", "fastapi-vue>=0.5.2",
"fastapi[standard]>=0.128.0", "fastapi[standard]>=0.128.0",
"html5tagger>=1.3.0", "html5tagger>=1.3.0",
"httpx>=0.28.0", "httpx>=0.28.0",
+5 -5
View File
@@ -5,8 +5,8 @@ Usage:
uv run scripts/devserver.py [frontend] [--backend backend] uv run scripts/devserver.py [frontend] [--backend backend]
Options: Options:
frontend Vite frontend endpoint (default: localhost:5173) frontend Vite frontend endpoint (default: localhost:8989)
--backend Cista backend endpoint (default: from config, or :8000) --backend Cista backend endpoint (default: from config, or :8999)
Environment: Environment:
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun). JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
@@ -26,7 +26,7 @@ from devutil import ProcessGroup, logger, ready, setup_vite # type: ignore
from cista import config from cista import config
from cista.serve import parse_listen from cista.serve import parse_listen
DEFAULT_BACKEND_PORT = 8000 DEFAULT_BACKEND_PORT = 8999
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]: def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
@@ -81,13 +81,13 @@ def main():
"frontend", "frontend",
nargs="?", nargs="?",
metavar="host:port", metavar="host:port",
help="Vite frontend endpoint (default: localhost:5173)", help="Vite frontend endpoint (default: localhost:8989)",
) )
parser.add_argument( parser.add_argument(
"--backend", "--backend",
"-l", "-l",
metavar="host:port", metavar="host:port",
help="Cista backend endpoint (default: from config, or :8000)", help="Cista backend endpoint (default: from config, or :8999)",
) )
args = parser.parse_args() args = parser.parse_args()
with contextlib.suppress(KeyboardInterrupt): with contextlib.suppress(KeyboardInterrupt):
+2 -2
View File
@@ -7,8 +7,8 @@ import httpx
from buildutil import find_dev_tool, find_install_tool, logger from buildutil import find_dev_tool, find_install_tool, logger
from fastapi_vue.hostutil import parse_endpoint from fastapi_vue.hostutil import parse_endpoint
DEFAULT_VITE_PORT = 5173 DEFAULT_VITE_PORT = 8989
DEFAULT_BACKEND_PORT = 5180 DEFAULT_BACKEND_PORT = 8999
class ProcessGroup: class ProcessGroup: