Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e07a3f40d | ||
|
|
2867b1075d | ||
|
|
4fc8e29cc1 | ||
|
|
55f9117b3e | ||
|
|
2b6746c55e | ||
|
|
1ed3779933 | ||
|
|
a95cf7be94 | ||
|
|
d18501f633 | ||
|
|
ed697db871 | ||
|
|
856e8c4cc8 | ||
|
|
f8a6eacb06 | ||
|
|
e9a82e84ad | ||
|
|
ccd05b53f4 | ||
|
|
032ad13b97 | ||
|
|
4eae75c84b | ||
|
|
252b31a293 | ||
|
|
feff202161 | ||
|
|
d15bfc86c4 |
+6
-6
@@ -57,7 +57,7 @@ Usage:
|
||||
Options:
|
||||
-c CONFDIR Custom config directory
|
||||
-l LISTEN-ADDR Listen on
|
||||
:8000 (localhost port, plain http)
|
||||
:8989 (localhost port, plain http)
|
||||
<addr>:3000 (bind another address, port)
|
||||
/path/to/unix.sock (unix socket)
|
||||
example.com (run on 80 and 443 with LetsEncrypt)
|
||||
@@ -80,7 +80,7 @@ Environment:
|
||||
first_time_help = """\
|
||||
No config file found! Get started with:
|
||||
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!
|
||||
"""
|
||||
@@ -140,8 +140,8 @@ def _main():
|
||||
if listen:
|
||||
settings["listen"] = listen
|
||||
elif not exists:
|
||||
settings["listen"] = ":8000"
|
||||
operation = config.update_config(settings)
|
||||
settings["listen"] = ":8989"
|
||||
config.update_config(settings)
|
||||
# Prepare to serve
|
||||
url, opts = serve.parse_listen(config.config.listen)
|
||||
if not config.config.path.is_dir():
|
||||
@@ -186,7 +186,7 @@ def _user(args):
|
||||
# Defaults for new config when user is created
|
||||
operation = config.update_config(
|
||||
{
|
||||
"listen": ":8000",
|
||||
"listen": ":8989",
|
||||
"path": Path.home() / "Downloads",
|
||||
"public": False,
|
||||
}
|
||||
@@ -215,7 +215,7 @@ def _user(args):
|
||||
|
||||
if operation == "created":
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import typing
|
||||
from pathlib import PurePosixPath
|
||||
from secrets import token_bytes
|
||||
|
||||
import msgspec
|
||||
@@ -53,6 +54,9 @@ async def upload(req, ws):
|
||||
if pos != req.end:
|
||||
d = f"{len(data)} bytes" if isinstance(data, bytes) else data
|
||||
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
|
||||
res = StatusMsg(status="ack", req=req)
|
||||
await asend(ws, res)
|
||||
@@ -87,6 +91,8 @@ async def control(req, ws):
|
||||
while True:
|
||||
cmd = msgspec.json.decode(await ws.recv(), type=ControlTypes)
|
||||
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))
|
||||
|
||||
|
||||
|
||||
+7
-32
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import mimetypes
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from multiprocessing import cpu_count
|
||||
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)
|
||||
@app.before_server_stop
|
||||
async def main_stop(app):
|
||||
quit.set()
|
||||
watching.stop(app)
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
@@ -174,9 +172,8 @@ def _load_wwwroot(www):
|
||||
|
||||
@app.before_server_start
|
||||
async def start(app):
|
||||
await load_wwwroot(app)
|
||||
if app.debug:
|
||||
app.add_task(refresh_wwwroot(), name="refresh_wwwroot")
|
||||
if not app.debug:
|
||||
await 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"])
|
||||
async def wwwroot(req, path=""):
|
||||
"""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)
|
||||
if name not in www:
|
||||
raise NotFound(f"File not found: /{path}", extra={"name": name})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import msgspec
|
||||
@@ -16,6 +17,10 @@ class ControlBase(msgspec.Struct, tag_field="op", tag=str.lower):
|
||||
def __call__(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
"""Return list of paths affected by this operation for change notification."""
|
||||
return []
|
||||
|
||||
|
||||
class MkDir(ControlBase):
|
||||
path: str
|
||||
@@ -24,6 +29,9 @@ class MkDir(ControlBase):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
path.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(self.path)]
|
||||
|
||||
|
||||
class Rename(ControlBase):
|
||||
path: str
|
||||
@@ -36,6 +44,11 @@ class Rename(ControlBase):
|
||||
path = config.config.path / filename.sanitize(self.path)
|
||||
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):
|
||||
sel: list[str]
|
||||
@@ -49,6 +62,9 @@ class Rm(ControlBase):
|
||||
else:
|
||||
p.unlink()
|
||||
|
||||
def affected_paths(self) -> list[str]:
|
||||
return [filename.sanitize(p) for p in self.sel]
|
||||
|
||||
|
||||
class Mv(ControlBase):
|
||||
sel: list[str]
|
||||
@@ -63,6 +79,13 @@ class Mv(ControlBase):
|
||||
for p in sel:
|
||||
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):
|
||||
sel: list[str]
|
||||
@@ -86,6 +109,11 @@ class Cp(ControlBase):
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
+21
-10
@@ -2,6 +2,7 @@ import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from sanic import Sanic
|
||||
|
||||
from cista import config, server80
|
||||
@@ -44,18 +45,28 @@ def check_cert(certdir, domain):
|
||||
|
||||
|
||||
def parse_listen(listen):
|
||||
if listen.startswith("/"):
|
||||
unix = Path(listen).resolve()
|
||||
# Domain name (e.g. example.com) -> HTTPS with LetsEncrypt
|
||||
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():
|
||||
raise ValueError(
|
||||
f"Directory for unix socket does not exist: {unix.parent}/",
|
||||
)
|
||||
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}
|
||||
try:
|
||||
addr, _port = listen.split(":", 1)
|
||||
port = int(_port)
|
||||
except Exception:
|
||||
raise ValueError(f"Invalid listen address: {listen}") from None
|
||||
return f"http://localhost:{port}", {"host": addr, "port": port}
|
||||
|
||||
host, port = ep["host"], ep["port"]
|
||||
# When binding all interfaces, use single_listener=False for Sanic
|
||||
if len(endpoints) > 1:
|
||||
return f"http://localhost:{port}", {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"single_listener": False,
|
||||
}
|
||||
return f"http://{host}:{port}", {"host": host, "port": port}
|
||||
|
||||
+351
-82
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import shutil
|
||||
import sys
|
||||
import threading
|
||||
@@ -116,6 +117,28 @@ state = State()
|
||||
rootpath: Path = None # type: ignore
|
||||
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
|
||||
|
||||
|
||||
@@ -326,127 +349,373 @@ def format_root(root):
|
||||
|
||||
|
||||
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):
|
||||
client_count = 0
|
||||
try:
|
||||
for queue in pubsub.values():
|
||||
queue.put_nowait(msg)
|
||||
client_count += 1
|
||||
except Exception:
|
||||
# Log because asyncio would silently eat the error
|
||||
logger.exception("Broadcast error")
|
||||
return client_count
|
||||
|
||||
|
||||
## Watcher thread
|
||||
|
||||
|
||||
def watcher_inotify(loop):
|
||||
"""Inotify watcher thread (Linux only)"""
|
||||
import inotify.adapters
|
||||
class PathIndex:
|
||||
"""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
|
||||
|
||||
modified_flags = frozenset(
|
||||
(
|
||||
"IN_CREATE",
|
||||
"IN_DELETE",
|
||||
"IN_DELETE_SELF",
|
||||
"IN_MODIFY",
|
||||
"IN_MOVE_SELF",
|
||||
"IN_MOVED_FROM",
|
||||
"IN_MOVED_TO",
|
||||
)
|
||||
)
|
||||
|
||||
modified_flags = (
|
||||
"IN_CREATE",
|
||||
"IN_DELETE",
|
||||
"IN_DELETE_SELF",
|
||||
"IN_MODIFY",
|
||||
"IN_MOVE_SELF",
|
||||
"IN_MOVED_FROM",
|
||||
"IN_MOVED_TO",
|
||||
)
|
||||
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
|
||||
update_root(loop)
|
||||
path_index = PathIndex(state.root[:])
|
||||
|
||||
trefresh = time.monotonic() + 300.0
|
||||
tspace = time.monotonic() + 5.0
|
||||
# Watch for changes (frequent wakeups needed for quiting)
|
||||
while not quit.is_set():
|
||||
t = time.monotonic()
|
||||
# The watching is not entirely reliable, so do a full refresh every 30 seconds
|
||||
if t >= trefresh:
|
||||
break
|
||||
# Disk usage update
|
||||
if t >= tspace:
|
||||
tspace = time.monotonic() + 5.0
|
||||
update_space(loop)
|
||||
# Inotify events, update the tree
|
||||
dirty = False
|
||||
rootmod = state.root[:]
|
||||
for event in i.event_gen(yield_nones=False, timeout_s=0.1):
|
||||
assert event
|
||||
if quit.is_set():
|
||||
return
|
||||
interesting = any(f in modified_flags for f in event[1])
|
||||
if interesting:
|
||||
# Update modified path
|
||||
path = PurePosixPath(event[2]) / event[3]
|
||||
try:
|
||||
rel_path = path.relative_to(rootpath)
|
||||
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:
|
||||
broadcast(update, loop)
|
||||
state.root = rootmod
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"format_update failed; falling back to full rescan"
|
||||
|
||||
# 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
|
||||
)
|
||||
# Fallback: full rescan and try diff again; last resort send full root
|
||||
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():
|
||||
now = time.monotonic()
|
||||
|
||||
# Full refresh every 300s
|
||||
if now >= trefresh:
|
||||
break
|
||||
|
||||
# Disk usage update every 5s
|
||||
if now >= tspace:
|
||||
tspace = now + 5.0
|
||||
update_space(loop)
|
||||
|
||||
# Check if we should flush pending changes
|
||||
should_flush = False
|
||||
if dirty_paths:
|
||||
time_since_last = now - last_event_time if last_event_time else 0
|
||||
time_since_first = now - first_event_time if first_event_time else 0
|
||||
if (
|
||||
time_since_last >= DEBOUNCE_DELAY
|
||||
or time_since_first >= DEBOUNCE_MAX
|
||||
):
|
||||
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:
|
||||
fresh = walk(PurePosixPath())
|
||||
update_msg = format_update(state.root, new_root)
|
||||
with state.lock:
|
||||
broadcast(update_msg, loop)
|
||||
state.root = new_root
|
||||
except Exception:
|
||||
logger.exception("format_update failed; full rescan")
|
||||
try:
|
||||
update = format_update(state.root, fresh)
|
||||
fresh = walk(PurePosixPath())
|
||||
path_index = PathIndex(fresh)
|
||||
update_msg = format_update(state.root, fresh)
|
||||
with state.lock:
|
||||
broadcast(update, loop)
|
||||
broadcast(update_msg, loop)
|
||||
state.root = fresh
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Fallback diff failed; sending full root snapshot"
|
||||
)
|
||||
logger.exception("Fallback failed; sending full root")
|
||||
with state.lock:
|
||||
broadcast(format_root(fresh), loop)
|
||||
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):
|
||||
"""Polling version of the watcher thread."""
|
||||
while not quit.is_set():
|
||||
t0 = time.perf_counter()
|
||||
update_root(loop)
|
||||
update_space(loop)
|
||||
dur = time.perf_counter() - t0
|
||||
if dur > 1.0:
|
||||
logger.debug(f"Reading the full file list took {dur:.1f}s")
|
||||
quit.wait(0.1 + 8 * dur)
|
||||
# Extract relative path
|
||||
path = PurePosixPath(event[2]) / event[3]
|
||||
try:
|
||||
rel_path = path.relative_to(rootpath)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Skip dotfiles
|
||||
if any(part.startswith(".") for part in rel_path.parts):
|
||||
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):
|
||||
global rootpath
|
||||
config.load_config()
|
||||
rootpath = config.config.path
|
||||
use_inotify = sys.platform == "linux"
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher_inotify if use_inotify else watcher_poll,
|
||||
target=watcher,
|
||||
args=[app.loop],
|
||||
# Descriptive name for system monitoring
|
||||
name=f"cista-watcher {rootpath}",
|
||||
|
||||
+19
-5
@@ -9,15 +9,14 @@
|
||||
<UserManagementModal />
|
||||
<AccessDeniedModal />
|
||||
<header>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query">
|
||||
<HeaderSelected :path="path.pathList" />
|
||||
</HeaderMain>
|
||||
<HeaderMain ref="headerMain" :path="path.pathList" :query="path.query" />
|
||||
<BreadCrumb :path="path.pathList" primary />
|
||||
</header>
|
||||
<main>
|
||||
<RouterView :path="path.pathList" :query="path.query" />
|
||||
</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.dprogress @cancel=store.cancelDownloads class=download />
|
||||
</footer>
|
||||
@@ -104,7 +103,7 @@ const globalShortcutHandler = (event: KeyboardEvent) => {
|
||||
else if (keyup && event.key === 'Escape') {
|
||||
store.error = ''
|
||||
store.clearToast()
|
||||
headerMain.value!.closeSearch(event)
|
||||
headerMain.value!.clearSearch(event)
|
||||
store.focusBreadcrumb()
|
||||
}
|
||||
else if (!input && keyup && event.key === 'Backspace') {
|
||||
@@ -189,4 +188,19 @@ export type { Path }
|
||||
background: #555;
|
||||
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>
|
||||
|
||||
@@ -43,44 +43,14 @@
|
||||
--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) {
|
||||
/* Breadcrumbs and buttons side by side */
|
||||
:root {
|
||||
--header-font-size: calc(8px + 8 * 100vh / 600); /* 16px (1rem nominal) at 600px height */
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
header .headermain { order: 1; }
|
||||
header .breadcrumb { align-self: stretch; }
|
||||
header .action-button {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
}
|
||||
@media print {
|
||||
:root {
|
||||
@@ -241,42 +211,13 @@ header nav.headermain {
|
||||
.spacer { flex-grow: 1 }
|
||||
.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 {
|
||||
padding: .5em;
|
||||
font-weight: bold;
|
||||
background: var(--accent-color);
|
||||
color: #000;
|
||||
}
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
:class="{ current: !!isCurrent(0) }"
|
||||
:aria-current="isCurrent(0)"
|
||||
@click.prevent="navigate(0)"
|
||||
title="/"
|
||||
@mouseenter="homeTooltip?.startHover"
|
||||
@mousemove="homeTooltip?.updatePosition"
|
||||
@mouseleave="homeTooltip?.endHover"
|
||||
>
|
||||
<component :is="home" />
|
||||
<CursorTooltip ref="homeTooltip" text="/">/</CursorTooltip>
|
||||
</a>
|
||||
<template v-for="(location, index) in longest" :key="index">
|
||||
<a :href="`/#/${longest.slice(0, index + 1).join('/')}/`"
|
||||
@@ -24,8 +27,10 @@
|
||||
:aria-current="isCurrent(index + 1)"
|
||||
@click.prevent="navigate(index + 1)"
|
||||
:ref="el => setLinkRef(index + 1, el)"
|
||||
:title="`/${longest.slice(0, index + 1).join('/')}`"
|
||||
>{{ location }}</a>
|
||||
@mouseenter="pathTooltips.get(index)?.startHover"
|
||||
@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>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -35,6 +40,7 @@ import { Home } from '@/assets/svg'
|
||||
import { nextTick, onBeforeUpdate, ref, watchEffect } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { exists } from '@/utils/fileutil'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const home = Home
|
||||
const router = useRouter()
|
||||
@@ -43,6 +49,13 @@ const links = [] as Array<HTMLElement>
|
||||
const setLinkRef = (index: number, el: any) => { if (el) links[index] = el }
|
||||
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<{
|
||||
path: Array<string>
|
||||
primary?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -10,12 +10,12 @@
|
||||
// Global activation state - shared across all instances
|
||||
let globalActive = false
|
||||
let globalDeactivateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// Track if we've seen real mouse movement (not touch-simulated)
|
||||
let hasRealMouse = false
|
||||
// Track recent touch to suppress touch-triggered mouse events
|
||||
let lastTouchTime = 0
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
@@ -25,6 +25,9 @@ const props = defineProps<{
|
||||
const visible = ref(false)
|
||||
const mouseX = 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 lastMoveX = 0
|
||||
let lastMoveY = 0
|
||||
@@ -32,17 +35,51 @@ let lastMoveY = 0
|
||||
// Movement threshold (pixels) - cursor must settle within this radius
|
||||
const SETTLE_THRESHOLD = 8
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${mouseX.value}px`,
|
||||
top: `${mouseY.value}px`,
|
||||
}))
|
||||
const tooltipStyle = computed(() => {
|
||||
// Constrain to viewport
|
||||
const pad = 8
|
||||
let x = mouseX.value
|
||||
let y = mouseY.value
|
||||
|
||||
// Check if the device likely has a real mouse (fine pointer)
|
||||
const hasFinePointer = () => window.matchMedia('(pointer: fine)').matches
|
||||
// Only constrain if we've measured the tooltip
|
||||
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 = () => {
|
||||
visible.value = true
|
||||
globalActive = true
|
||||
// Measure tooltip after it renders
|
||||
requestAnimationFrame(() => {
|
||||
if (tooltipEl.value) {
|
||||
tooltipWidth.value = tooltipEl.value.offsetWidth
|
||||
tooltipHeight.value = tooltipEl.value.offsetHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scheduleTooltip = () => {
|
||||
@@ -56,8 +93,8 @@ const scheduleTooltip = () => {
|
||||
}
|
||||
|
||||
const startHover = (e: MouseEvent) => {
|
||||
// Ignore touch events (no fine pointer and no confirmed real mouse)
|
||||
if (!hasFinePointer() && !hasRealMouse) return
|
||||
// Ignore touch-simulated mouse events
|
||||
if (isTouchEvent()) return
|
||||
|
||||
mouseX.value = e.clientX
|
||||
mouseY.value = e.clientY
|
||||
@@ -66,9 +103,8 @@ const startHover = (e: MouseEvent) => {
|
||||
}
|
||||
|
||||
const updatePosition = (e: MouseEvent) => {
|
||||
// Detect real mouse via movement (touch events don't generate continuous mousemove)
|
||||
if (e.movementX !== 0 || e.movementY !== 0) hasRealMouse = true
|
||||
if (!hasFinePointer() && !hasRealMouse) return
|
||||
// Ignore touch-simulated mouse events
|
||||
if (isTouchEvent()) return
|
||||
|
||||
mouseX.value = e.clientX
|
||||
mouseY.value = e.clientY
|
||||
@@ -122,10 +158,11 @@ defineExpose({
|
||||
z-index: 10000;
|
||||
padding: .5rem 1rem;
|
||||
border-radius: 3rem 0 3rem 0;
|
||||
box-shadow: 0 0 1rem var(--accent-color);
|
||||
background-color: var(--accent-color);
|
||||
color: var(--primary-color);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.5);
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
pointer-events: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<SvgButton name="download" data-tooltip="Download" @click="download" />
|
||||
<SvgButton name="download" tooltip="Download" @click="download" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -7,7 +7,6 @@ import { useMainStore } from '@/stores/main'
|
||||
import { apiFetch } from '@/repositories/Client'
|
||||
import type { SelectedItems } from '@/repositories/Document'
|
||||
import { zipName } from '@/utils/fileutil'
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const store = useMainStore()
|
||||
|
||||
@@ -131,39 +130,7 @@ const filesystemdl = async (sel: SelectedItems, handle: FileSystemDirectoryHandl
|
||||
statReset()
|
||||
}
|
||||
|
||||
const download = async () => {
|
||||
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 zipdl = (sel: SelectedItems) => {
|
||||
const items = sel.keys.map(k => sel.docs[k]!)
|
||||
const name = zipName(items)
|
||||
linkdl(`/zip/${Array.from(sel.keys).join('+')}/${name}.zip`)
|
||||
@@ -171,6 +138,37 @@ const download = async () => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<tr
|
||||
: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"
|
||||
@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.key = crypto.randomUUID()
|
||||
doc.ghost = true
|
||||
store.document.push(doc)
|
||||
editing.value = null
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
@@ -290,13 +292,96 @@ const allSelected = computed({
|
||||
|
||||
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) => {
|
||||
store.cursor = doc.key
|
||||
ContextMenu.showContextMenu({
|
||||
x: ev.x, y: ev.y, items: [
|
||||
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
||||
],
|
||||
})
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(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>
|
||||
|
||||
@@ -392,9 +477,6 @@ tbody tr.cursor {
|
||||
.sortcolumn:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sortcolumn:hover::after {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
.sortcolumn {
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -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.key = crypto.randomUUID()
|
||||
doc.ghost = true
|
||||
store.document.push(doc)
|
||||
editing.value = null
|
||||
}
|
||||
const showFolderBreadcrumb = (i: number) => {
|
||||
const docs = props.documents
|
||||
@@ -213,18 +215,6 @@ const showFolderBreadcrumb = (i: number) => {
|
||||
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({
|
||||
get: () => {
|
||||
return (
|
||||
@@ -246,13 +236,96 @@ const allSelected = computed({
|
||||
|
||||
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) => {
|
||||
store.cursor = doc.key
|
||||
ContextMenu.showContextMenu({
|
||||
x: ev.x, y: ev.y, items: [
|
||||
{ label: 'Rename', onClick: () => { editing.value = doc } },
|
||||
],
|
||||
})
|
||||
const items = [
|
||||
{ label: '📥 Download', onClick: () => downloadFile(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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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
|
||||
@focus.stop="store.cursor = doc.key"
|
||||
@click=onclick
|
||||
|
||||
@@ -3,23 +3,57 @@
|
||||
<UploadButton :path="props.path" />
|
||||
<SvgButton
|
||||
name="create-folder"
|
||||
data-tooltip="New folder"
|
||||
@click="() => { console.log('New', store.fileExplorer); store.fileExplorer!.newFolder(); console.log('Done')}"
|
||||
tooltip="New folder"
|
||||
@click="() => { store.fileExplorer!.newFolder() }"
|
||||
/>
|
||||
<slot></slot>
|
||||
<div class="spacer smallgap"></div>
|
||||
<template v-if="showSearchInput">
|
||||
<div class="smallgap"></div>
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" tooltip="Details/Gallery" />
|
||||
<div class="search-group">
|
||||
<SvgButton name="find" @click="focusSearch" tooltip="Search" />
|
||||
<input
|
||||
ref="search"
|
||||
type="search"
|
||||
:value="query"
|
||||
@input="updateSearch"
|
||||
placeholder="Find files"
|
||||
class="margin-input"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
</template>
|
||||
<SvgButton ref="searchButton" name="find" @click.prevent="toggleSearchInput" />
|
||||
<SvgButton name="eye" @click="store.prefs.gallery = !store.prefs.gallery" />
|
||||
<span v-if="!query" class="search-hint" @click="focusSearch">/</span>
|
||||
</div>
|
||||
<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" />
|
||||
</nav>
|
||||
</template>
|
||||
@@ -27,58 +61,122 @@
|
||||
<script setup lang="ts">
|
||||
import { useMainStore } from '@/stores/main'
|
||||
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 { showAuthIframe } from 'paskia'
|
||||
import { resumeWatching } from '@/repositories/WS'
|
||||
import router from '@/router';
|
||||
import { formatSize } from '@/utils'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
const store = useMainStore()
|
||||
const ssoStore = useSsoAuthStore()
|
||||
const showSearchInput = ref<boolean>(false)
|
||||
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<{
|
||||
path: Array<string>
|
||||
query: string
|
||||
}>()
|
||||
|
||||
const closeSearch = (ev: Event) => {
|
||||
if (!showSearchInput.value) return // Already closing
|
||||
showSearchInput.value = false
|
||||
const clearSearch = (ev: Event) => {
|
||||
const input = search.value
|
||||
if (input) {
|
||||
input.value = ''
|
||||
updateSearch(ev)
|
||||
}
|
||||
const breadcrumb = document.querySelector('.breadcrumb') as HTMLElement
|
||||
breadcrumb.focus()
|
||||
updateSearch(ev)
|
||||
}
|
||||
|
||||
const focusSearch = () => {
|
||||
search.value?.focus()
|
||||
}
|
||||
|
||||
// Track pending route update
|
||||
let pendingRouteUpdate: number | null = null
|
||||
|
||||
const updateSearch = (ev: Event) => {
|
||||
const q = (ev.target as HTMLInputElement).value
|
||||
let p = props.path.join('/')
|
||||
p = p ? `/${p}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
const loc = props.path.join('/')
|
||||
|
||||
// Start search immediately via store (worker handles it async)
|
||||
store.search(q, props.path.join('/'))
|
||||
store.search(q, loc)
|
||||
|
||||
// Update route in next frame to keep typing responsive
|
||||
requestAnimationFrame(() => {
|
||||
if (!props.query && q) router.push(u)
|
||||
else router.replace(u)
|
||||
// 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}` : ''
|
||||
const url = q ? `${p}//${q}` : (p || '/')
|
||||
const u = url.replaceAll('?', '%3F').replaceAll('#', '%23')
|
||||
// Use replace to avoid building up history for each keystroke
|
||||
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()
|
||||
})
|
||||
|
||||
const toggleSearchInput = () => {
|
||||
search.value?.focus()
|
||||
}
|
||||
watchEffect(() => {
|
||||
if (props.query) showSearchInput.value = true
|
||||
})
|
||||
const settingsMenu = (e: Event) => {
|
||||
// show the context menu
|
||||
const items = []
|
||||
@@ -121,7 +219,7 @@ const settingsMenu = (e: Event) => {
|
||||
}
|
||||
defineExpose({
|
||||
toggleSearchInput,
|
||||
closeSearch,
|
||||
clearSearch,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -133,12 +231,72 @@ defineExpose({
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
input[type='search'] {
|
||||
background: var(--input-background);
|
||||
color: var(--input-color);
|
||||
border: 0;
|
||||
border-radius: 0.1em;
|
||||
.search-group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
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>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
<template>
|
||||
<template v-if="store.selected.size">
|
||||
<div class="smallgap"></div>
|
||||
<p class="select-text">{{ store.selected.size }} selected ➤</p>
|
||||
<div class="selection-bar" v-if="store.selected.size">
|
||||
<p class="select-text">{{ store.selected.size }} selected</p>
|
||||
<DownloadButton />
|
||||
<SvgButton name="copy" data-tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" data-tooltip="Move here" @click="op('mv', dst)" />
|
||||
<SvgButton name="trash" data-tooltip="Delete ⚠️" @click="op('rm')" />
|
||||
<button class="action-button unselect" data-tooltip="Unselect all" @click="store.selected.clear()">❌</button>
|
||||
</template>
|
||||
<SvgButton name="copy" tooltip="Copy here" @click="op('cp', dst)" />
|
||||
<SvgButton name="paste" tooltip="Move here" @click="op('mv', dst)" />
|
||||
<SvgButton name="trash" tooltip="Delete ⚠️" @click="op('rm')" />
|
||||
<button
|
||||
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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {connect, controlUrl} from '@/repositories/WS'
|
||||
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 props = defineProps({
|
||||
@@ -21,10 +29,10 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const dst = computed(() => props.path!.join('/'))
|
||||
const op = (op: string, dst?: string) => {
|
||||
const op = (opName: string, dst?: string) => {
|
||||
const sel = store.selectedFiles
|
||||
const msg = {
|
||||
op,
|
||||
op: opName,
|
||||
sel: sel.keys.map(key => {
|
||||
const doc = sel.docs[key]!
|
||||
return doc.loc ? `${doc.loc}/${doc.name}` : doc.name
|
||||
@@ -32,6 +40,8 @@ const op = (op: string, dst?: string) => {
|
||||
}
|
||||
// @ts-ignore
|
||||
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, {
|
||||
message(ev: MessageEvent) {
|
||||
const res = JSON.parse(ev.data)
|
||||
@@ -55,11 +65,20 @@ const op = (op: string, dst?: string) => {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.selection-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.3em 0.5em;
|
||||
background: transparent;
|
||||
color: var(--header-color);
|
||||
}
|
||||
.select-text {
|
||||
color: var(--accent-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<template>
|
||||
<button class="action-button">
|
||||
<button
|
||||
class="action-button"
|
||||
@mouseenter="tooltip?.startHover"
|
||||
@mousemove="tooltip?.updatePosition"
|
||||
@mouseleave="tooltip?.endHover"
|
||||
>
|
||||
<component :is="icons[name]" />
|
||||
<slot></slot>
|
||||
<CursorTooltip v-if="tooltipText" ref="tooltip" :text="tooltipText">{{ tooltipText }}</CursorTooltip>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { icons, type IconName } from '@/assets/svg'
|
||||
import { ref } from 'vue'
|
||||
import CursorTooltip from './CursorTooltip.vue'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
name: IconName
|
||||
tooltip?: string
|
||||
}>()
|
||||
|
||||
const tooltip = ref<InstanceType<typeof CursorTooltip> | null>(null)
|
||||
const tooltipText = props.tooltip ?? ''
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -53,15 +53,17 @@ const speeddisp = computed(() => speed.value ? speed.value.toFixed(speed.value <
|
||||
<style scoped>
|
||||
.transferprogress {
|
||||
--bar: var(--accent-color);
|
||||
--nobar: var(--header-background);
|
||||
--nobar: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
color: var(--primary-color);
|
||||
width: 100%;
|
||||
}
|
||||
.statustext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 .5em;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,19 @@
|
||||
<input ref="fileInput" @change="uploadHandler" type="file" multiple>
|
||||
<input ref="folderInput" @change="uploadHandler" type="file" webkitdirectory>
|
||||
</template>
|
||||
<SvgButton name="add-file" data-tooltip="Upload files" @click="fileInput.click()" />
|
||||
<SvgButton name="add-folder" data-tooltip="Upload folder" @click="folderInput.click()" />
|
||||
<SvgButton name="add-file" tooltip="Upload files" @click="fileInput.click()" />
|
||||
<SvgButton name="add-folder" tooltip="Upload folder" @click="folderInput.click()" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { connect, uploadUrl } from '@/repositories/WS';
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { Doc } from '@/repositories/Document'
|
||||
import { collator } from '@/utils';
|
||||
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const fileInput = ref()
|
||||
const folderInput = ref()
|
||||
const store = useMainStore()
|
||||
@@ -72,24 +75,49 @@ function uploadHandler(event: Event) {
|
||||
const uploadFiles = (infiles: File[]) => {
|
||||
const loc = props.path!.join('/')
|
||||
let files = []
|
||||
let folderName = ''
|
||||
for (const file of infiles) {
|
||||
const relPath = file.webkitRelativePath || file.name
|
||||
if (!folderName && file.webkitRelativePath) folderName = relPath.split('/')[0] ?? ''
|
||||
files.push({
|
||||
file,
|
||||
cloudName: loc + '/' + (file.webkitRelativePath || file.name),
|
||||
cloudName: loc + '/' + relPath,
|
||||
cloudPos: 0,
|
||||
})
|
||||
}
|
||||
uploadCloudFiles(files)
|
||||
if (folderName) router.push('/' + (loc ? loc + '/' : '') + folderName + '/')
|
||||
}
|
||||
const uploadCloudFiles = (files: CloudFile[]) => {
|
||||
const dotfiles = files.filter(f => f.cloudName.includes('/.'))
|
||||
if (dotfiles.length) {
|
||||
store.showToast("Won't upload dotfiles")
|
||||
console.log("Dotfiles omitted", dotfiles)
|
||||
files = files.filter(f => !f.cloudName.includes('/.'))
|
||||
}
|
||||
if (!files.length) return
|
||||
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
|
||||
upqueue = [...upqueue, ...files]
|
||||
statsAdd(files)
|
||||
|
||||
@@ -9,6 +9,7 @@ export type DocProps = {
|
||||
size: number
|
||||
mtime: number
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
|
||||
export class Doc {
|
||||
@@ -17,6 +18,7 @@ export class Doc {
|
||||
public size: number = 0
|
||||
public mtime: number = 0
|
||||
public dir: boolean = false
|
||||
public ghost: boolean = false
|
||||
/** @internal Use the name getter/setter instead */
|
||||
public _name: string = ""
|
||||
|
||||
|
||||
@@ -164,7 +164,8 @@ const handleWatchMessage = (event: MessageEvent) => {
|
||||
handleUpdateMessage(msg)
|
||||
break
|
||||
case !!msg.space:
|
||||
console.log('Watch space', msg.space)
|
||||
const store = useMainStore()
|
||||
store.space = msg.space
|
||||
break
|
||||
case !!msg.error:
|
||||
handleError(msg)
|
||||
|
||||
@@ -10,6 +10,9 @@ import SearchWorker from '@/workers/searchWorker?worker'
|
||||
let searchWorker: Worker | null = null
|
||||
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 {
|
||||
if (!searchWorker) {
|
||||
@@ -18,14 +21,30 @@ function getSearchWorker(): Worker {
|
||||
searchWorker.onmessage = (e) => {
|
||||
if (!searchStore || e.data.id !== searchId) return // Stale result
|
||||
|
||||
// Convert plain data back to Doc instances (constructor is now lightweight)
|
||||
const docs = []
|
||||
for (const d of e.data.docs) {
|
||||
docs.push(new Doc(d))
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -61,6 +80,12 @@ export const useMainStore = defineStore('main', {
|
||||
username: '' as string,
|
||||
privileged: false as boolean,
|
||||
isLoggedIn: false as boolean,
|
||||
},
|
||||
space: {
|
||||
disk: 0,
|
||||
free: 0,
|
||||
usage: 0,
|
||||
storage: 0,
|
||||
}
|
||||
}),
|
||||
persist: {
|
||||
@@ -135,13 +160,43 @@ export const useMainStore = defineStore('main', {
|
||||
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) {
|
||||
// Clear results only when search is closed
|
||||
this.searchResults = []
|
||||
this.searchLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
this.searchLoading = true
|
||||
// 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
|
||||
}
|
||||
loadingTimer = null
|
||||
}, 100)
|
||||
|
||||
worker.postMessage({ type: 'search', query, loc, id })
|
||||
},
|
||||
login(username: string, privileged: boolean) {
|
||||
@@ -180,12 +235,25 @@ export const useMainStore = defineStore('main', {
|
||||
resumeWatching()
|
||||
},
|
||||
toggleSort(name: SortOrder) {
|
||||
if (this.query) this.prefs.sortFiltered = this.prefs.sortFiltered === name ? '' : name
|
||||
else this.prefs.sortListing = this.prefs.sortListing === name ? '' : name
|
||||
const current = this.query ? this.prefs.sortFiltered : this.prefs.sortListing
|
||||
const newOrder = current === name ? '' : name
|
||||
if (this.query) this.prefs.sortFiltered = newOrder
|
||||
else this.prefs.sortListing = newOrder
|
||||
this.showSortToast(newOrder)
|
||||
},
|
||||
sort(name: SortOrder | '') {
|
||||
if (this.query) this.prefs.sortFiltered = 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() {
|
||||
(document.querySelector('.breadcrumb') as HTMLAnchorElement).focus()
|
||||
|
||||
@@ -33,17 +33,14 @@ const props = defineProps<{
|
||||
|
||||
// Folder path for component keys - only recreate component when folder changes, not search
|
||||
const folderPath = computed(() => props.path.join('/'))
|
||||
// Trigger search when query changes (from route, e.g., page load or back button)
|
||||
// Note: Direct typing triggers search immediately via HeaderMain, this is for route-based changes
|
||||
|
||||
// Handle route-based search changes (back/forward navigation, direct URL)
|
||||
// Skip if store.query already matches (means we triggered this via typing)
|
||||
watch(
|
||||
() => [props.query, props.path.join('/')] as const,
|
||||
([query, loc]) => {
|
||||
// Only trigger if results don't match current query (avoid duplicate searches)
|
||||
if (query && store.searchResults.length === 0) {
|
||||
store.search(query, loc)
|
||||
} else if (!query) {
|
||||
store.search('', loc) // Clear search results
|
||||
}
|
||||
if (store.query === query) return // Already searching this query
|
||||
store.search(query, loc)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -71,7 +68,6 @@ const documents = computed(() => {
|
||||
|
||||
watchEffect(() => {
|
||||
store.fileExplorer = fileExplorer.value
|
||||
store.query = props.query
|
||||
})
|
||||
|
||||
// Only auto-switch gallery mode when entering a new folder or on initial file list load
|
||||
|
||||
@@ -36,58 +36,120 @@ interface ResultMessage {
|
||||
}
|
||||
|
||||
// Worker state
|
||||
let documents: WorkerDoc[] = []
|
||||
let recentDocuments: WorkerDoc[] = [] // Sorted by mtime descending
|
||||
let currentSearchId = 0
|
||||
|
||||
// Haystack formatting (same as main thread utils)
|
||||
function haystackFormat(str: string): string {
|
||||
const based = str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return '^' + based + '$'
|
||||
// Search result cache - cleared when documents change
|
||||
interface CacheEntry {
|
||||
query: string // Normalized query string
|
||||
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 needleFormat(query: string) {
|
||||
const based = query.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
return { based, words: based.split(/\s+/) }
|
||||
function normalizeQuery(str: string): string {
|
||||
return str.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
// Test if haystack includes needle
|
||||
function localeIncludes(haystack: string, filter: { based: string; words: string[] }): boolean {
|
||||
const { based, words } = filter
|
||||
return haystack.includes(based) || (words && words.every(word => haystack.includes(word)))
|
||||
// Test if document matches search query
|
||||
function matches(haystack: string, query: string, words: string[]): boolean {
|
||||
return haystack.includes(query) || words.every(word => haystack.includes(word))
|
||||
}
|
||||
|
||||
// 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
|
||||
function sortByRecent(docs: WorkerDoc[]): WorkerDoc[] {
|
||||
return [...docs].sort((a, b) => b.mtime - a.mtime)
|
||||
// Yield control to allow new messages to be processed
|
||||
const yieldControl = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// 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
|
||||
function yieldControl(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
// Add result to cache
|
||||
function addToCache(query: string, results: WorkerDoc[], complete: boolean) {
|
||||
// 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
|
||||
async function performSearch(query: string, loc: string, searchId: number) {
|
||||
const needle = needleFormat(query)
|
||||
const limit = 100
|
||||
const batchSize = 500 // Smaller batches for faster incremental feedback
|
||||
async function performSearch(rawQuery: string, loc: string, searchId: number) {
|
||||
const query = normalizeQuery(rawQuery)
|
||||
const words = query.split(/\s+/)
|
||||
const results: WorkerDoc[] = []
|
||||
let lastResultCount = 0
|
||||
|
||||
for (let i = 0; i < recentDocuments.length && results.length < limit; i += batchSize) {
|
||||
// Check if search was superseded
|
||||
if (currentSearchId !== searchId) return
|
||||
// Check cache for exact match
|
||||
const exactMatch = searchCache.find(e => e.query === query)
|
||||
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
|
||||
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]!
|
||||
if (localeIncludes(doc.haystack, needle)) {
|
||||
if (matches(doc.haystack, query, words)) {
|
||||
results.push(doc)
|
||||
}
|
||||
}
|
||||
@@ -95,85 +157,69 @@ async function performSearch(query: string, loc: string, searchId: number) {
|
||||
// Post incremental results if we found new matches
|
||||
if (results.length > lastResultCount && currentSearchId === searchId) {
|
||||
lastResultCount = results.length
|
||||
const sortedResults = sortResults(results, query, loc)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
docs: sortedResults.map(stripHaystack),
|
||||
id: searchId,
|
||||
done: false
|
||||
} as ResultMessage)
|
||||
postResults(results, rawQuery, loc, searchId, false)
|
||||
}
|
||||
|
||||
// Yield control between batches to allow new search requests to interrupt
|
||||
if (i + batchSize < recentDocuments.length && results.length < limit) {
|
||||
// Yield control between batches
|
||||
if (i + batchSize < recentDocuments.length && results.length < RESULT_LIMIT) {
|
||||
await yieldControl()
|
||||
}
|
||||
}
|
||||
|
||||
// Post final results
|
||||
// Cache and post final results
|
||||
addToCache(query, results, results.length < RESULT_LIMIT)
|
||||
if (currentSearchId === searchId) {
|
||||
const sortedResults = sortResults(results, query, loc)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
docs: sortedResults.map(stripHaystack),
|
||||
id: searchId,
|
||||
done: true
|
||||
} as ResultMessage)
|
||||
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({
|
||||
type: 'results',
|
||||
docs: sorted.map(({ haystack, ...rest }) => rest),
|
||||
id,
|
||||
done
|
||||
} as ResultMessage)
|
||||
}
|
||||
|
||||
// Sort results by relevance
|
||||
function sortResults(docs: WorkerDoc[], query: string, loc: string): WorkerDoc[] {
|
||||
const locsub = loc + '/'
|
||||
return [...docs].sort((a, b) => (
|
||||
// Current folder first
|
||||
// @ts-ignore
|
||||
(b.loc === loc) - (a.loc === loc) ||
|
||||
Number(b.loc === loc) - Number(a.loc === loc) ||
|
||||
// Then subfolders
|
||||
// @ts-ignore
|
||||
(b.loc.slice(0, locsub.length) === locsub) - (a.loc.slice(0, locsub.length) === locsub) ||
|
||||
Number(b.loc.startsWith(locsub)) - Number(a.loc.startsWith(locsub)) ||
|
||||
// Then by location
|
||||
collator.compare(a.loc, b.loc) ||
|
||||
// Files after folders
|
||||
// @ts-ignore
|
||||
(a.dir === false) - (b.dir === false) ||
|
||||
// Folders before files
|
||||
Number(b.dir) - Number(a.dir) ||
|
||||
// Exact name match first
|
||||
// @ts-ignore
|
||||
b.name.includes(query) - a.name.includes(query) ||
|
||||
Number(b.name.includes(query)) - Number(a.name.includes(query)) ||
|
||||
// Finally by 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
|
||||
self.onmessage = async (e: MessageEvent<IncomingMessage>) => {
|
||||
const msg = e.data
|
||||
|
||||
if (msg.type === 'update') {
|
||||
// Update document list with haystacks
|
||||
documents = msg.documents.map(doc => ({
|
||||
...doc,
|
||||
haystack: haystackFormat(doc.name)
|
||||
}))
|
||||
recentDocuments = sortByRecent(documents)
|
||||
// Update document list with haystacks, sorted by mtime descending
|
||||
recentDocuments = msg.documents
|
||||
.map(doc => ({ ...doc, haystack: normalizeHaystack(doc.name) }))
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
clearCache()
|
||||
} else if (msg.type === 'search') {
|
||||
currentSearchId = msg.id
|
||||
if (msg.query) {
|
||||
await performSearch(msg.query, msg.loc, msg.id)
|
||||
} else {
|
||||
// Empty query - no results needed (main thread handles folder listing)
|
||||
postMessage({
|
||||
type: 'results',
|
||||
docs: [],
|
||||
id: msg.id,
|
||||
done: true
|
||||
} as ResultMessage)
|
||||
// Empty query - no results needed
|
||||
postMessage({ type: 'results', docs: [], id: msg.id, done: true } as ResultMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* 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"] } = {}) {
|
||||
// Build proxy configuration for each path
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ dependencies = [
|
||||
"av>=15.0.0",
|
||||
"blake3>=1.0.5",
|
||||
"docopt-ng>=0.9.0",
|
||||
"fastapi-vue>=0.5.1",
|
||||
"fastapi-vue>=0.5.2",
|
||||
"fastapi[standard]>=0.128.0",
|
||||
"html5tagger>=1.3.0",
|
||||
"httpx>=0.28.0",
|
||||
|
||||
@@ -5,8 +5,8 @@ Usage:
|
||||
uv run scripts/devserver.py [frontend] [--backend backend]
|
||||
|
||||
Options:
|
||||
frontend Vite frontend endpoint (default: localhost:5173)
|
||||
--backend Cista backend endpoint (default: from config, or :8000)
|
||||
frontend Vite frontend endpoint (default: localhost:8989)
|
||||
--backend Cista backend endpoint (default: from config, or :8999)
|
||||
|
||||
Environment:
|
||||
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.serve import parse_listen
|
||||
|
||||
DEFAULT_BACKEND_PORT = 8000
|
||||
DEFAULT_BACKEND_PORT = 8999
|
||||
|
||||
|
||||
def setup_sanic_backend(listen: str | None) -> tuple[str, list[str]]:
|
||||
@@ -81,13 +81,13 @@ def main():
|
||||
"frontend",
|
||||
nargs="?",
|
||||
metavar="host:port",
|
||||
help="Vite frontend endpoint (default: localhost:5173)",
|
||||
help="Vite frontend endpoint (default: localhost:8989)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
"-l",
|
||||
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()
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
|
||||
@@ -7,8 +7,8 @@ import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
DEFAULT_VITE_PORT = 5173
|
||||
DEFAULT_BACKEND_PORT = 5180
|
||||
DEFAULT_VITE_PORT = 8989
|
||||
DEFAULT_BACKEND_PORT = 8999
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
|
||||
Reference in New Issue
Block a user