diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06b3256 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.log +.venv/ +venv/ +.DS_Store +.sqlite3 diff --git a/finder_commander/README.md b/finder_commander/README.md new file mode 100644 index 0000000..fd79c46 --- /dev/null +++ b/finder_commander/README.md @@ -0,0 +1,67 @@ +# Finder Commander + +Lokale webapp die aanvoelt als een Midnight Commander-achtige file manager voor macOS/Linux home directories. + +## Wat zit erin + +- twee panelen naast elkaar +- actieve panel met duidelijke focusrand +- keyboard shortcuts: + - `Tab` wissel panel + - `↑ / ↓ / PgUp / PgDn` navigatie + - `Enter` open map of view bestand + - `Backspace` omhoog + - `Space` markeer bestand/map + - `F3` view + - `F4` edit + - `Shift+F4` nieuw bestand + - `F5` copy naar ander panel + - `F6` move naar ander panel + - `Shift+F6` rename + - `F7` nieuwe map + - `F8` delete naar `~/.Trash` + - `Ctrl+H` toggle verborgen bestanden + - `Ctrl+R` refresh + - `Alt+X` focus command line +- command line onderin met beperkte veilige commando's: + - `cd ` + - `mkdir ` + - `touch ` + - `select ` + - `help` +- upload per panel +- viewer voor tekst en afbeeldingen +- editor voor tekstbestanden +- padbeveiliging: alles blijft binnen `~` + +## Starten + +```bash +./run-local.sh +``` + +Het script kiest automatisch `python3.14` als dat aanwezig is. Bestaat er al een `.venv` met een andere Python minor-versie, dan wordt die automatisch opnieuw aangemaakt. + +Open daarna: + +```text +http://127.0.0.1:8765/ +``` + +## Python 3.14-notes + +Deze build is opgeschoond voor Python 3.14.x: + +- minimale Uvicorn-installatie (`uvicorn` i.p.v. `uvicorn[standard]`) +- geen optionele C-extensies nodig om te starten +- dependencies ondersteunen Python 3.14 + +## Opmerking + +Dit is een **MC-like v1**, geen volledige Midnight Commander clone. Bewust niet ingebouwd: + +- shell/subshell uitvoering +- chmod/chown dialogs +- archive browsing als directory +- remote FTP/SFTP panels +- diff/compare directories diff --git a/finder_commander/app/main.py b/finder_commander/app/main.py new file mode 100644 index 0000000..e7ed3a8 --- /dev/null +++ b/finder_commander/app/main.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import fnmatch +import html +import mimetypes +import os +import secrets +import shutil +import stat +import time +from datetime import datetime +from pathlib import Path +from typing import Literal, Optional + +from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse, HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel, Field + +APP_NAME = "Finder Commander" +HOME_ROOT = Path.home().resolve() +TRASH_DIR = HOME_ROOT / ".Trash" +MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024 +CSRF_TOKEN = secrets.token_urlsafe(32) + +app = FastAPI(title=APP_NAME) +app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static") +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) + + +class PathsPayload(BaseModel): + paths: list[str] = Field(default_factory=list) + destination_dir: Optional[str] = None + + +class RenamePayload(BaseModel): + path: str + new_name: str + + +class DeletePayload(BaseModel): + paths: list[str] = Field(default_factory=list) + mode: Literal["trash", "permanent"] = "trash" + + +class CommandPayload(BaseModel): + command: str + cwd: str = "" + +PathsPayload.model_rebuild() +RenamePayload.model_rebuild() +DeletePayload.model_rebuild() +CommandPayload.model_rebuild() + +TEXT_SUFFIXES = { + ".md", + ".txt", + ".py", + ".js", + ".ts", + ".tsx", + ".jsx", + ".css", + ".html", + ".json", + ".yaml", + ".yml", + ".toml", + ".ini", + ".env", + ".log", + ".xml", + ".sh", + ".zsh", + ".bash", + ".c", + ".cpp", + ".h", + ".java", + ".go", + ".rs", + ".sql", + ".conf", + ".service", + ".container", + ".network", + ".pod", + ".kube", +} + + +def _now_iso() -> str: + return datetime.utcnow().isoformat(timespec="seconds") + "Z" + + + +def rel_from_home(path: Path) -> str: + return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT)) + + + +def ensure_within_home(candidate: Path) -> Path: + try: + candidate.relative_to(HOME_ROOT) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Path escapes home directory") from exc + return candidate + + + +def sanitize_name(name: str) -> str: + name = (name or "").strip() + if not name or name in {".", ".."} or "/" in name: + raise HTTPException(status_code=400, detail="Invalid name") + return name + + + +def resolve_user_path(raw_path: Optional[str], *, must_exist: bool = True) -> Path: + raw_path = (raw_path or "").strip() + candidate = (HOME_ROOT / raw_path).resolve(strict=False) + candidate = ensure_within_home(candidate) + if must_exist and not candidate.exists(): + raise HTTPException(status_code=404, detail="Path not found") + return candidate + + + +def check_origin(request: Request) -> None: + origin = request.headers.get("origin") + if not origin: + return + expected = str(request.base_url).rstrip("/") + if origin.rstrip("/") != expected: + raise HTTPException(status_code=403, detail="Origin not allowed") + + + +def check_csrf(request: Request) -> None: + token = request.headers.get("x-csrf-token") + if token != CSRF_TOKEN: + raise HTTPException(status_code=403, detail="Invalid CSRF token") + + + +def perms_string(mode: int) -> str: + return stat.filemode(mode) + + + +def can_preview_text(path: Path) -> bool: + if path.is_dir(): + return False + if path.stat().st_size > MAX_TEXT_PREVIEW_BYTES: + return False + mime, _ = mimetypes.guess_type(path.name) + if mime and ( + mime.startswith("text/") + or mime in {"application/json", "application/xml", "application/javascript"} + ): + return True + return path.suffix.lower() in TEXT_SUFFIXES + + + +def entry_payload(path: Path) -> dict: + st = path.lstat() + kind = "directory" if path.is_dir() else "file" + mime, _ = mimetypes.guess_type(path.name) + return { + "name": path.name, + "rel_path": rel_from_home(path), + "parent_rel_path": rel_from_home(path.parent), + "kind": kind, + "is_symlink": path.is_symlink(), + "size": st.st_size, + "modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), + "mime": mime or "application/octet-stream", + "perms": perms_string(st.st_mode), + "can_preview_text": can_preview_text(path) if path.is_file() else False, + } + + + +def sorted_entries(path: Path, show_hidden: bool = False) -> list[dict]: + try: + children = list(path.iterdir()) + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc + filtered = [] + for child in children: + if not show_hidden and child.name.startswith('.'): + continue + filtered.append(child) + filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower())) + return [entry_payload(child) for child in filtered] + + + +def move_to_trash(path: Path) -> Path: + TRASH_DIR.mkdir(parents=True, exist_ok=True) + target = TRASH_DIR / path.name + if target.exists(): + target = TRASH_DIR / f"{target.stem}-{int(time.time())}{target.suffix}" + shutil.move(str(path), str(target)) + return target + + + +def copy_entry(source: Path, destination_dir: Path) -> Path: + destination = destination_dir / source.name + if destination.exists(): + raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}") + if source.is_dir(): + shutil.copytree(source, destination, symlinks=True) + else: + shutil.copy2(source, destination, follow_symlinks=False) + return destination + + + +def move_entry(source: Path, destination_dir: Path) -> Path: + destination = destination_dir / source.name + if destination.exists(): + raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}") + shutil.move(str(source), str(destination)) + return destination + + + +def select_paths_or_current(paths: list[str], cwd: str) -> list[Path]: + result = [resolve_user_path(p) for p in paths] + if not result: + raise HTTPException(status_code=400, detail="No paths selected") + return result + + + +def resolve_from_cwd(cwd_path: Path, raw: str, *, must_exist: bool = True) -> Path: + raw = (raw or "").strip() + candidate = (cwd_path / raw).resolve(strict=False) + candidate = ensure_within_home(candidate) + if must_exist and not candidate.exists(): + raise HTTPException(status_code=404, detail="Path not found") + return candidate + + +def run_command(command: str, cwd: str) -> dict: + command = (command or "").strip() + if not command: + raise HTTPException(status_code=400, detail="Empty command") + cwd_path = resolve_user_path(cwd) + if not cwd_path.is_dir(): + raise HTTPException(status_code=400, detail="CWD is not a directory") + + parts = command.split() + verb = parts[0].lower() + args = parts[1:] + + if verb == "cd": + raw_target = " ".join(args) if args else "" + target = resolve_user_path(raw_target) if raw_target.startswith("/") else resolve_from_cwd(cwd_path, raw_target or ".") + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Target is not a directory") + return {"ok": True, "action": "cd", "cwd": rel_from_home(target), "message": str(target)} + + if verb == "mkdir": + name = sanitize_name(" ".join(args)) + target = resolve_from_cwd(cwd_path, name, must_exist=False) + target.mkdir(exist_ok=False) + return {"ok": True, "action": "mkdir", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"} + + if verb == "touch": + name = sanitize_name(" ".join(args)) + target = resolve_from_cwd(cwd_path, name, must_exist=False) + target.touch(exist_ok=False) + return {"ok": True, "action": "touch", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"} + + if verb == "select": + pattern = " ".join(args).strip() or "*" + entries = sorted_entries(cwd_path, show_hidden=True) + matches = [e["rel_path"] for e in entries if fnmatch.fnmatch(e["name"], pattern)] + return { + "ok": True, + "action": "select", + "cwd": rel_from_home(cwd_path), + "message": f"Matched {len(matches)} item(s)", + "matches": matches, + } + + if verb == "help": + return { + "ok": True, + "action": "help", + "cwd": rel_from_home(cwd_path), + "message": "Commands: cd , mkdir , touch , select , help", + } + + raise HTTPException(status_code=400, detail="Unsupported command") + + +@app.middleware("http") +async def harden_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "DENY" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; " + "connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + ) + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + +@app.get("/health") +def health() -> dict: + return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)} + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request): + return templates.TemplateResponse( + request, + "index.html", + { + "app_name": APP_NAME, + "home_root": str(HOME_ROOT), + "csrf_token": CSRF_TOKEN, + }, + ) + + +@app.get("/api/list") +def api_list(path: str = "", show_hidden: bool = False) -> dict: + target = resolve_user_path(path) + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Path is not a directory") + return { + "cwd": rel_from_home(target), + "absolute": str(target), + "parent": "" if target == HOME_ROOT else rel_from_home(target.parent), + "entries": sorted_entries(target, show_hidden=show_hidden), + } + + +@app.get("/api/read") +def api_read(path: str) -> dict: + target = resolve_user_path(path) + if target.is_dir(): + raise HTTPException(status_code=400, detail="Cannot read a directory as text") + if not can_preview_text(target): + raise HTTPException(status_code=415, detail="File is not previewable as text") + try: + content = target.read_text(encoding="utf-8") + encoding = "utf-8" + except UnicodeDecodeError: + content = target.read_text(encoding="utf-8", errors="replace") + encoding = "utf-8 (lossy)" + return { + "path": rel_from_home(target), + "name": target.name, + "encoding": encoding, + "content": content, + "size": target.stat().st_size, + } + + +@app.get("/api/meta") +def api_meta(path: str) -> dict: + target = resolve_user_path(path) + payload = entry_payload(target) + payload["absolute"] = str(target) + return payload + + +@app.get("/api/download") +def api_download(path: str): + target = resolve_user_path(path) + if target.is_dir(): + raise HTTPException(status_code=400, detail="Cannot download a directory") + return FileResponse(path=target, filename=target.name) + + +@app.get("/api/preview") +def api_preview(path: str): + target = resolve_user_path(path) + if target.is_dir(): + raise HTTPException(status_code=400, detail="Cannot preview a directory") + mime, _ = mimetypes.guess_type(target.name) + if not mime or not mime.startswith("image/"): + raise HTTPException(status_code=415, detail="Preview only supports images") + return FileResponse(path=target, media_type=mime) + + +@app.put("/api/write") +async def api_write(request: Request, path: str = Form(...), content: str = Form(...)) -> dict: + check_origin(request) + check_csrf(request) + target = resolve_user_path(path, must_exist=False) + ensure_within_home(target.parent.resolve(strict=False)) + if target.exists() and target.is_dir(): + raise HTTPException(status_code=400, detail="Cannot overwrite a directory") + target.parent.mkdir(parents=True, exist_ok=True) + tmp = target.with_name(target.name + ".tmp-write") + tmp.write_text(content, encoding="utf-8") + os.replace(tmp, target) + return {"ok": True, "path": rel_from_home(target)} + + +@app.post("/api/mkdir") +async def api_mkdir(request: Request, path: str = Form(...), name: str = Form(...)) -> dict: + check_origin(request) + check_csrf(request) + base = resolve_user_path(path) + if not base.is_dir(): + raise HTTPException(status_code=400, detail="Base path is not a directory") + child = resolve_user_path(str(Path(rel_from_home(base)) / sanitize_name(name)), must_exist=False) + child.mkdir(parents=False, exist_ok=False) + return {"ok": True, "path": rel_from_home(child)} + + +@app.post("/api/upload") +async def api_upload(request: Request, path: str = Form(...), files: list[UploadFile] = File(...)) -> dict: + check_origin(request) + check_csrf(request) + base = resolve_user_path(path) + if not base.is_dir(): + raise HTTPException(status_code=400, detail="Upload target is not a directory") + saved: list[str] = [] + for upload in files: + filename = Path(upload.filename or "").name + if not filename: + continue + destination = resolve_user_path(str(Path(rel_from_home(base)) / filename), must_exist=False) + with destination.open("wb") as f: + while chunk := await upload.read(1024 * 1024): + f.write(chunk) + saved.append(rel_from_home(destination)) + return {"ok": True, "saved": saved} + + +@app.post("/api/rename") +async def api_rename(request: Request, payload: RenamePayload) -> dict: + check_origin(request) + check_csrf(request) + source = resolve_user_path(payload.path) + destination = resolve_user_path(str(Path(rel_from_home(source.parent)) / sanitize_name(payload.new_name)), must_exist=False) + if destination.exists(): + raise HTTPException(status_code=409, detail="Destination already exists") + os.replace(source, destination) + return {"ok": True, "old_path": rel_from_home(source), "new_path": rel_from_home(destination)} + + +@app.post("/api/copy") +async def api_copy(request: Request, payload: PathsPayload) -> dict: + check_origin(request) + check_csrf(request) + if payload.destination_dir is None: + raise HTTPException(status_code=400, detail="Missing destination_dir") + destination_dir = resolve_user_path(payload.destination_dir) + if not destination_dir.is_dir(): + raise HTTPException(status_code=400, detail="Destination is not a directory") + results = [] + for source in select_paths_or_current(payload.paths, payload.destination_dir): + copied = copy_entry(source, destination_dir) + results.append(rel_from_home(copied)) + return {"ok": True, "copied": results} + + +@app.post("/api/move") +async def api_move(request: Request, payload: PathsPayload) -> dict: + check_origin(request) + check_csrf(request) + if payload.destination_dir is None: + raise HTTPException(status_code=400, detail="Missing destination_dir") + destination_dir = resolve_user_path(payload.destination_dir) + if not destination_dir.is_dir(): + raise HTTPException(status_code=400, detail="Destination is not a directory") + results = [] + for source in select_paths_or_current(payload.paths, payload.destination_dir): + moved = move_entry(source, destination_dir) + results.append(rel_from_home(moved)) + return {"ok": True, "moved": results} + + +@app.post("/api/delete") +async def api_delete(request: Request, payload: DeletePayload) -> dict: + check_origin(request) + check_csrf(request) + paths = select_paths_or_current(payload.paths, "") + deleted = [] + for target in paths: + if target == HOME_ROOT: + raise HTTPException(status_code=400, detail="Refusing to delete home root") + if payload.mode == "trash": + moved = move_to_trash(target) + deleted.append(str(moved)) + else: + if target.is_dir(): + shutil.rmtree(target) + else: + target.unlink() + deleted.append(rel_from_home(target)) + return {"ok": True, "mode": payload.mode, "deleted": deleted} + + +@app.post("/api/command") +async def api_command(request: Request, payload: CommandPayload) -> dict: + check_origin(request) + check_csrf(request) + return run_command(payload.command, payload.cwd) + + +@app.exception_handler(HTTPException) +async def http_exception_handler(_: Request, exc: HTTPException): + return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail}) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(_: Request, exc: Exception): + return JSONResponse(status_code=500, content={"ok": False, "detail": html.escape(str(exc))}) diff --git a/finder_commander/requirements.txt b/finder_commander/requirements.txt new file mode 100644 index 0000000..c1e2eef --- /dev/null +++ b/finder_commander/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.128.8,<1.0 +uvicorn>=0.39,<1.0 +jinja2>=3.1.6,<4.0 +python-multipart>=0.0.22,<1.0 diff --git a/finder_commander/run-local.sh b/finder_commander/run-local.sh new file mode 100755 index 0000000..7bac349 --- /dev/null +++ b/finder_commander/run-local.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +if command -v python3.14 >/dev/null 2>&1; then + PYTHON_BIN=python3.14 +else + PYTHON_BIN=python3 +fi + +echo "Using Python: $($PYTHON_BIN --version 2>&1)" + +TARGET_MM=$("$PYTHON_BIN" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")') +CURRENT_MM="" +if [ -x .venv/bin/python ]; then + CURRENT_MM=$(.venv/bin/python -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")') +fi + +if [ ! -d .venv ] || [ "$CURRENT_MM" != "$TARGET_MM" ]; then + rm -rf .venv + "$PYTHON_BIN" -m venv .venv +fi + +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +exec python -m uvicorn app.main:app --host 127.0.0.1 --port 8765 diff --git a/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md b/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md new file mode 100644 index 0000000..e5d0cf8 --- /dev/null +++ b/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md @@ -0,0 +1,338 @@ +# Remote Client Shares Implementation Phases V1.1 + +## Doel + +Dit document splitst `REMOTE_CLIENT_SHARES_V1_DESIGN.md` op in pragmatische implementatiefases. + +Uitgangspunten: + +- geen overengineering +- elke fase moet zelfstandig waarde leveren +- WebManager mag nooit blokkeren op remote agents +- bestaande storage-functionaliteit moet intact blijven +- `/Clients` blijft een aparte source, geen uitbreiding van lokale filesystem roots + +--- + +## Overzicht + +### Phase 1 + +Client registry, identiteit en statusmodel. + +### Phase 2 + +Browse van remote client shares via virtuele `Clients` root. + +### Phase 3 + +Info, tekstpreview, eenvoudige image preview en download voor remote shares. + +### Later + +Alle write-acties, bookmarks/startup paths en cross-source flows. + +--- + +## Phase 1: Client Registry + +## Doel + +WebManager moet remote agents kennen, identificeren en hun status betrouwbaar kunnen bijhouden. + +## Resultaat + +De backend en UI kunnen een lijst van bekende clients tonen, inclusief stabiele identiteit en basisstatus. + +## In scope + +- remote client registratie +- heartbeat endpoint +- opslag van client metadata +- statusmodel met gescheiden velden +- lijstendpoint voor bekende clients +- registratie-auth +- agent-access-auth contract vastleggen + +## Niet in scope + +- browsen in shares +- file operations +- download +- rename/delete/mkdir + +## Beslissingen + +- `client_id` is leidend +- `display_name` is niet leidend +- browse-routing mag niet afhankelijk zijn van alleen displaynaam +- `last_seen`, `status`, `last_error` en `reachable_at` blijven logisch gescheiden + +## Backendwerk + +Nieuwe onderdelen: + +- repository voor remote clients +- service voor registratie en heartbeat +- statusafleiding +- opslag van auth- en endpointmetadata +- routes: + - `POST /api/clients/register` + - `POST /api/clients/heartbeat` + - `GET /api/clients` + +Waarschijnlijk te wijzigen: + +- [main.py](/workspace/webmanager-mvp/webui/backend/app/main.py) +- [dependencies.py](/workspace/webmanager-mvp/webui/backend/app/dependencies.py) + +Waarschijnlijk nieuw: + +- `webui/backend/app/api/routes_clients.py` +- `webui/backend/app/services/remote_client_service.py` +- `webui/backend/app/db/remote_client_repository.py` + +## Agentwerk + +- vaste config inlezen +- `client_id` beheren +- registratie naar WebManager +- periodieke heartbeat +- agent-access-token config toevoegen + +## UI-werk + +Minimaal: + +- geen browse-integratie nodig +- een eenvoudige clientlijst of debug-status is voldoende + +## Acceptatiecriteria + +- agent kan zich registreren +- client verschijnt in `GET /api/clients` +- `last_seen` wordt bijgewerkt +- `status` wordt afgeleid zonder te flappen +- `last_error` en `reachable_at` bestaan als apart concept +- server blijft normaal werken als er geen agents bestaan + +--- + +## Phase 2: Browse via `/Clients` + +## Doel + +Remote clients en hun shares moeten zichtbaar worden in dezelfde browse-ervaring als server storage, zonder lokale services te vervormen. + +## Resultaat + +De gebruiker kan navigeren naar: + +- `/Clients` +- `/Clients/` +- `/Clients//` + +## In scope + +- virtuele root `/Clients` +- clientlijst als directories +- sharelijst per client als directories +- browse binnen share +- offline foutafhandeling +- agent-auth op browsecalls + +## Niet in scope + +- view/download +- edit +- rename/delete/mkdir +- bookmarks/startup paths + +## Beslissingen + +- `/Clients` wordt vroeg in de backend-route afgehandeld +- remote paden mogen niet in gewone lokale `PathGuard` resolution terechtkomen +- lokale browse-services blijven verantwoordelijk voor alleen lokale server sources + +## Backendwerk + +Waarschijnlijk te wijzigen: + +- [routes_browse.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_browse.py) + +Liever niet verbreden: + +- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py) + +Nieuwe onderdelen: + +- browse-facade voor remote client paden +- agent HTTP client met korte timeouts en auth + +## UI-werk + +Waarschijnlijk te wijzigen: + +- [app.js](/workspace/webmanager-mvp/webui/html/app.js) + +Benodigd: + +- rootnavigatie voor `/Clients` +- breadcrumbs voor client/share-paden +- render van client/status/share directories +- nette foutmelding bij offline client + +## Agentwerk + +Nieuwe browse endpoint(s): + +- `GET /health` +- `GET /api/list?share=...&path=...` + +## Acceptatiecriteria + +- `/Clients` toont bekende clients +- `/Clients/` toont alleen toegestane shares +- `/Clients//` toont directory-inhoud +- offline client geeft een snelle fout, geen hang +- `/Volumes` gedrag blijft intact +- lokale browse-code blijft logisch gescheiden van remote browse-code + +--- + +## Phase 3: Info, Preview, Download + +## Doel + +Remote shares moeten read-only bruikbaar worden voor dagelijkse taken. + +## Resultaat + +Gebruiker kan bestanden in remote shares inspecteren, bekijken en downloaden. + +## In scope + +- file info +- tekstpreview +- eenvoudige image preview +- download van remote bestanden +- expliciete resource-limieten + +## Niet in scope + +- edit +- rename/delete/mkdir +- upload +- cross-source copy/move + +## Beslissingen + +- tekstpreview krijgt een harde limiet +- text/binary-detectie moet expliciet zijn +- downloads worden gestreamd +- geen grote in-memory buffering voor download + +## Backendwerk + +Nieuwe facade of routes voor remote file actions: + +- info +- read/view +- download + +Belangrijk: + +- backend vertaalt WebManager-pad naar agent-call +- timeouts en foutmapping blijven streng +- source-aware afhandeling blijft gescheiden van lokale file ops + +Waarschijnlijk geraakt: + +- `routes_files.py` of parallelle remote-fileroute +- aparte service-laag voor remote file proxying + +## UI-werk + +Waarschijnlijk te wijzigen: + +- [app.js](/workspace/webmanager-mvp/webui/html/app.js) + +Benodigd: + +- source-aware afhandeling voor `View` +- downloadknop moet remote paths ondersteunen +- properties/info moet ook werken voor remote paden + +## Agentwerk + +Nieuwe endpoints: + +- `GET /api/info` +- `GET /api/read` +- `GET /api/download` + +## Acceptatiecriteria + +- file info werkt voor remote paden +- tekstbestand kan bekeken worden binnen limieten +- afbeelding kan bekeken worden als ondersteund +- download van remote bestand werkt via streaming +- foutafhandeling blijft lokaal tot betreffende pane/actie + +--- + +## Later + +Deze onderdelen horen niet in V1.1. + +### Write-acties + +- mkdir +- rename +- delete +- upload + +### UI-integraties + +- bookmarks voor `/Clients/...` +- startup paths voor `/Clients/...` + +### Cross-source flows + +- `/Volumes/...` naar `/Clients/...` +- `/Clients/...` naar `/Volumes/...` + +Dit vereist expliciete transfersemantiek en hoort niet in de eerste read-mostly release. + +### Zwaardere netwerkmodellen + +- reverse-connect +- tunnelmodel +- relay-infrastructuur + +### Sterkere pairing + +- pair codes +- per-agent secret rotation +- signed registration + +--- + +## Aanbevolen volgorde + +1. Phase 1 volledig afronden. +2. Daarna Phase 2 volledig afronden. +3. Daarna Phase 3 read-only afronden. +4. Alles daarna alleen oppakken als een concrete productbehoefte dat rechtvaardigt. + +--- + +## Beslisadvies + +Als er snel waarde geleverd moet worden, is de beste minimale keten: + +1. registry +2. browse +3. info/preview/download + +Daarmee ontstaat een bruikbare remote client bron zonder write-complexiteit, contractbreuk in lokale services of half-afgewerkte transferlogica. diff --git a/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md b/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md new file mode 100644 index 0000000..fc51acf --- /dev/null +++ b/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md @@ -0,0 +1,697 @@ +# Remote Client Shares V1.1 Design + +## Doel + +Een gebruiker van WebManager moet naast de bestaande server-side storage-roots ook een beperkte set lokale mappen van zijn eigen client-Mac kunnen benaderen, zonder de hele homefolder bloot te geven. + +Voorbeelden van toegestane client-shares: + +- `Downloads` +- `Movies` +- `Pictures` + +De oplossing moet: + +- simpel blijven +- veilig blijven +- de bestaande storage-workflow niet breken +- WebManager niet laten vastlopen als een remote helper-agent offline is + +--- + +## Kernbeslissingen voor V1.1 + +Deze beslissingen liggen in V1.1 vast. + +- Remote client shares worden niet opgenomen in `root_aliases`. +- `/Clients` wordt een aparte virtuele bron naast `/Volumes`. +- Remote paden lopen niet door de bestaande lokale filesystem-resolutie. +- `client_id` is intern de enige leidende identiteit. +- `display_name` is alleen voor UI-weergave. +- De agent werkt alleen met `share key + relatief pad`. +- Alle agent-calls vereisen authenticatie, niet alleen registratie. +- Offline agents mogen alleen hun eigen subtree beïnvloeden, nooit de rest van de app. +- V1 blijft read-mostly: registry, browse, info, preview, download. + +--- + +## Waarom niet als gewone root alias + +De huidige backend gaat uit van server-side whitelisted filesystem roots. + +Dat model werkt voor: + +- `/Volumes/...` +- gemounte server storage +- container-side toegankelijke paden + +Dat model werkt niet goed voor: + +- de lokale schijf van de browsergebruiker +- een remote Mac die buiten de server draait +- clients die offline kunnen zijn +- clients die dynamische IP-adressen hebben + +Daarom mogen remote client shares niet in hetzelfde model worden gestopt als `root_aliases`. + +--- + +## Scope V1.1 + +### In scope + +- beperkte client-shares: `Downloads`, `Movies`, `Pictures` +- lokale helper-agent op macOS +- agent registratie in WebManager +- heartbeat/status tracking +- virtuele `Clients` bron in de WebUI +- browse van remote shares +- bestand-info +- tekstpreview +- image preview waar triviaal +- download van bestanden +- nette offline-afhandeling + +### Expliciet niet in V1.1 + +- hele homefolder +- willekeurige custom paths buiten de toegestane sharelijst +- shell/subprocess execution +- rename +- mkdir +- delete +- upload naar remote share +- bookmarks voor `/Clients/...` +- startup paths voor `/Clients/...` +- cross-source copy of move +- complete taakrunner-integratie zoals server copy/move tasks +- automatische LAN discovery +- multi-user auth met OS user mapping + +--- + +## Gewenste gebruikerservaring + +In de WebUI komt naast server-storage een extra virtuele bron: + +- `/Volumes` +- `/Clients` + +Onder `/Clients` ziet de gebruiker geregistreerde clients, bijvoorbeeld: + +- `MacBook Pro van Jan` +- `iMac Woonkamer` + +Onder een client ziet de gebruiker alleen de toegestane shares: + +- `Downloads` +- `Movies` +- `Pictures` + +Voor de gebruiker kan dat eruitzien als: + +- `/Clients/MacBook-Pro-van-Jan/Downloads` +- `/Clients/MacBook-Pro-van-Jan/Movies` +- `/Clients/MacBook-Pro-van-Jan/Pictures` + +Maar intern mag routing niet op `display_name` leunen. + +Intern moet WebManager werken met een stabiele client-identiteit en een mappinglaag: + +- `client_id` voor routing en opslag +- `display_name` voor weergave +- optioneel een afgeleide slug voor browse-url-presentatie + +--- + +## Architectuuroverzicht + +Er zijn drie componenten. + +### 1. WebManager backend + +Verantwoordelijk voor: + +- registry van bekende remote clients +- status- en heartbeat-tracking +- virtuele browse-root `Clients` +- proxying van requests naar agents +- timeouts en foutafhandeling +- scheiding tussen local-source en remote-source afhandeling + +### 2. WebUI frontend + +Verantwoordelijk voor: + +- tonen van `Clients` als extra bron +- navigeren binnen client/share paden +- offline status tonen +- requests afvuren naar gewone WebManager backend-routes + +### 3. Remote helper-agent op macOS + +Verantwoordelijk voor: + +- toegang tot vaste lokale shares +- strikte padvalidatie binnen die shares +- simpele browse/info/read/download endpoints +- zichzelf registreren bij WebManager +- heartbeat sturen +- auth afdwingen op alle agent-endpoints + +--- + +## Bereikbaarheidsmodel + +Dit is de eerste harde productbeslissing. + +### V1.1-keuze + +V1.1 gaat uit van een omgeving waarin WebManager de agent rechtstreeks kan bereiken. + +Dat betekent praktisch: + +- dezelfde LAN +- of een expliciet configureerbaar agent-endpoint +- of een deployment waar server en client netwerkmatig direct verbonden zijn + +### Waarom deze keuze + +Dit is het simpelste model dat functioneel klopt zonder reverse tunnels, websockets als transportlaag, of extra relay-infrastructuur. + +### Wat V1.1 niet probeert op te lossen + +Deze versie garandeert niet dat een agent achter willekeurige NAT/firewall altijd bereikbaar is. + +Dus: + +- self-registration blijft het discoverymodel +- direct bereikbare agent-endpoint blijft het V1-transportmodel +- reverse-connect of tunnelmodellen zijn uitgesteld + +### Fallback + +Een handmatige endpoint override blijft toegestaan als operationele fallback, bijvoorbeeld: + +- `http://192.168.1.25:8765` + +Maar dat is geen hoofdmodel en geen productbelofte. + +--- + +## Hoe de remote agent bekend wordt in WebManager + +### Gekozen model: agent registreert zichzelf + +De agent meldt zichzelf actief aan bij WebManager. Niet andersom. + +Dat betekent: + +- geen handmatig client-IP nodig als hoofdmodel +- geen server-naar-client discovery nodig +- geen afhankelijkheid van LAN-broadcasting +- geen probleem als het client-IP wisselt, zolang het geregistreerde endpoint actueel is + +### Registratiestroom + +Bij starten van de agent: + +1. de agent leest lokale config +2. de agent bepaalt: + - `client_id` + - `display_name` + - `shares` + - `endpoint` +3. de agent registreert zich bij WebManager +4. WebManager slaat client-record op of werkt het bij +5. de agent stuurt periodieke heartbeats + +### Benodigde velden bij registratie + +Voorstel: + +```json +{ + "client_id": "f4b2c8f8-2b1b-4d89-9ed2-8d6d7b1f3abc", + "display_name": "MacBook Pro van Jan", + "platform": "macos", + "agent_version": "1.1.0", + "endpoint": "http://192.168.1.25:8765", + "shares": [ + { "key": "downloads", "label": "Downloads" }, + { "key": "movies", "label": "Movies" }, + { "key": "pictures", "label": "Pictures" } + ] +} +``` + +### Backend bewaart per client + +- `client_id` +- `display_name` +- `platform` +- `agent_version` +- `endpoint` +- `shares` +- `last_seen` +- `status` +- `last_error` +- `reachable_at` +- eventueel `registration_token_id` + +### Heartbeat + +De agent stuurt elke 15-30 seconden een heartbeat. + +Bijvoorbeeld: + +```json +{ + "client_id": "f4b2c8f8-2b1b-4d89-9ed2-8d6d7b1f3abc", + "agent_version": "1.1.0" +} +``` + +### Statusmodel + +Deze velden moeten logisch gescheiden blijven: + +- `last_seen` + Laatste succesvolle heartbeat van de agent. +- `status` + Afgeleide UI-status, bijvoorbeeld `online` of `offline`. +- `last_error` + Laatste connect- of browsefout richting agent. +- `reachable_at` + Laatste moment waarop een directe agent-call echt succesvol was. + +Belangrijk: + +- een heartbeat bepaalt niet automatisch dat elke browse-call werkt +- een enkele browse-timeout mag niet blind `last_seen` overschrijven +- status mag niet gaan flappen op basis van één los incident + +### Aanbevolen statusregels + +- `online` als `last_seen` recent is +- `offline` als heartbeat-timeout overschreden is +- extra foutdetails via `last_error` +- optioneel UI-label zoals `online with recent errors` later, maar niet nodig in V1.1 + +--- + +## Authenticatie en beveiliging + +### Backend registratie-auth + +Registratie vereist een bearer token. + +Bijvoorbeeld: + +- `Authorization: Bearer ` + +### Agent endpoint-auth + +Alle agent-calls vereisen authenticatie. Niet alleen registratie. + +Dus ook: + +- `/health` +- `/api/list` +- `/api/info` +- `/api/read` +- `/api/download` + +moeten beschermd zijn. + +### V1.1 minimum + +Voor V1.1 volstaat een eenvoudige gedeelde agent-token, bijvoorbeeld: + +- WebManager bewaart een secret per client of per installatie +- backend stuurt dat token mee op elke agent-call +- agent weigert requests zonder geldig token + +Voorbeeld: + +- `Authorization: Bearer ` + +### Niet doen in V1.1 + +- open agent-HTTP API zonder auth +- browse/download endpoints publiek bereikbaar maken op het LAN + +--- + +## Virtueel padmodel + +Remote client shares krijgen een aparte namespace. + +Voorstel voor de gebruikersweergave: + +- `/Clients` +- `/Clients/` +- `/Clients//` +- `/Clients///subdir/file.ext` + +Intern moet de backend dit mappen naar: + +- `client_id` +- `share_key` +- relatief share-pad + +Belangrijk: + +- dit zijn logische WebManager-paden +- het zijn geen echte lokale backend filesystem-paden +- ze mogen niet door de bestaande lokale `PathGuard` resolved worden + +### Consequentie voor de codebasis + +`/Clients/...` moet vroeg in routing worden onderschept door een aparte browse- of source-facade. + +Dus: + +- niet de lokale `PathGuard` uitbreiden tot remote sources +- niet overal `if remote` in bestaande lokale services strooien +- wel een duidelijke scheiding tussen local source en remote source + +--- + +## Share-validatie in de agent + +De agent werkt niet met vrije absolute paden. + +De agent heeft een vaste share-map, bijvoorbeeld: + +```json +{ + "downloads": "/Users/jan/Downloads", + "movies": "/Users/jan/Movies", + "pictures": "/Users/jan/Pictures" +} +``` + +Een request bevat dan: + +- `share = downloads` +- `path = Some/Subdir/file.txt` + +Niet: + +- `/Users/jan/...` + +### Validatieregels + +- onbekende `share` weigeren +- `..` weigeren +- pad resolven binnen de gekozen share-root +- symlink escape blokkeren +- alleen toegestane bestandshandelingen toestaan + +--- + +## Read, preview en download limieten + +V1.1 moet resource-grenzen expliciet vastleggen. + +### Tekstpreview + +- maximum grootte voor tekstpreview vastleggen +- voorstel: zelfde orde als huidige server-side preview/edit-limieten, of kleiner +- grote tekstbestanden niet volledig in memory laden voor preview + +### Binary versus text + +- agent moet tekstpreview alleen teruggeven voor ondersteunde teksttypes +- binaire content mag niet per ongeluk als tekst in JSON-responses worden gepusht + +### Download + +- downloads moeten gestreamd worden +- geen volledige bestand-buffering in memory + +### Image preview + +- alleen triviale image preview in V1.1 +- geen zware thumbnail-pipeline in deze fase + +--- + +## Offline gedrag + +Dit is een harde eis. + +WebManager mag niet vastlopen als de agent niet draait. + +### Backendregels + +- alle agent-calls krijgen korte timeouts, bijvoorbeeld 1-3 seconden +- connect- of timeoutfouten worden vertaald naar nette app-fouten +- offline agent blokkeert nooit globale pagina-initialisatie +- browse- en file-fouten blijven lokaal tot betreffende request + +### Frontendregels + +- `/Clients` mag laden, ook als sommige clients offline zijn +- offline clients mogen zichtbaar blijven in de lijst +- browsen in offline subtree toont foutmelding +- andere panes blijven bruikbaar +- geen endless spinner + +--- + +## API-ontwerp + +## 1. Backend registry endpoints + +### `POST /api/clients/register` + +Registreert of update een remote agent. + +### `POST /api/clients/heartbeat` + +Werkt `last_seen` bij. + +### `GET /api/clients` + +Geeft bekende clients terug met: + +- `client_id` +- `display_name` +- `status` +- `last_seen` +- `last_error` +- `shares` + +--- + +## 2. Backend browse facade voor UI + +De frontend blijft praten met gewone WebManager-routes. + +### `GET /api/browse?path=/Clients` + +Geeft alle bekende clients terug als directories. + +### `GET /api/browse?path=/Clients//` + +Geeft shares van die client terug als directories. + +### `GET /api/browse?path=/Clients///...` + +Backend vertaalt dit naar een agent-call. + +Belangrijk: + +- browse facade bepaalt eerst of pad onder `/Clients` valt +- alleen niet-remote paden mogen daarna naar bestaande lokale browse-paths + +--- + +## 3. Agent endpoints + +Eenvoudig houden. Geen shell. + +### `GET /health` + +Gezondheidscheck met auth. + +### `GET /api/list?share=downloads&path=subdir` + +Directory-inhoud binnen een share. + +### `GET /api/info?share=downloads&path=file.txt` + +Metadata. + +### `GET /api/read?share=downloads&path=file.txt` + +Tekstpreview. + +### `GET /api/download?share=downloads&path=file.txt` + +Gestreamde download. + +--- + +## Haalbaarheid + +## Goed haalbaar in V1.1 + +- client registry +- heartbeat online/offline +- virtuele `Clients` root +- browse +- file info +- tekstpreview +- eenvoudige image preview +- gestreamde download + +## Bewust uitgesteld + +- rename +- mkdir +- delete +- upload +- bookmarks/startup paths +- cross-source copy +- cross-source move +- unified history +- task-runner integratie + +--- + +## Veranderingen per gebied + +## Backend + +Nieuwe onderdelen: + +- client registry repository +- client registry service +- routes voor register/heartbeat/list +- browse/source facade voor `Clients/...` +- agent HTTP client met harde timeouts en auth + +Bestaande onderdelen die waarschijnlijk geraakt worden: + +- [routes_browse.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_browse.py) + Om `/Clients` vroeg te routeren. +- [dependencies.py](/workspace/webmanager-mvp/webui/backend/app/dependencies.py) + Voor nieuwe registry- en agent-services. +- [app/main.py](/workspace/webmanager-mvp/webui/backend/app/main.py) + Voor nieuwe routers. + +Liever niet verbreden: + +- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py) + Deze hoort lokaal filesystemgericht te blijven. +- [file_ops_service.py](/workspace/webmanager-mvp/webui/backend/app/services/file_ops_service.py) + Deze service is nu server-filesystemgericht en moet niet vervuild raken met remote transportlogica. + +## Frontend + +Waarschijnlijk aanpassen: + +- [app.js](/workspace/webmanager-mvp/webui/html/app.js) + Voor: + - extra virtuele root + - render van clients en shares + - offline status + - source-aware browse/view/download/info flows +- [index.html](/workspace/webmanager-mvp/webui/html/index.html) + Alleen als extra statuslabels of clientindicatoren nodig zijn + +## Remote agent + +Te baseren op: + +- [finder_commander/app/main.py](/workspace/webmanager-mvp/finder_commander/app/main.py) +- [finder_commander/run-local.sh](/workspace/webmanager-mvp/finder_commander/run-local.sh) +- [finder_commander/requirements.txt](/workspace/webmanager-mvp/finder_commander/requirements.txt) + +Maar vereenvoudigd: + +- geen shell command endpoint +- geen hele home-root +- alleen `share key + relatief pad` +- registratie en heartbeat toevoegen +- auth afdwingen op alle endpoints + +--- + +## Minimale agent-config + +Voorstel lokaal configbestand: + +```json +{ + "webmanager_base_url": "https://webmanager.example.com", + "registration_token": "registration-secret", + "agent_access_token": "agent-secret", + "client_id": "f4b2c8f8-2b1b-4d89-9ed2-8d6d7b1f3abc", + "display_name": "MacBook Pro van Jan", + "shares": { + "downloads": "/Users/jan/Downloads", + "movies": "/Users/jan/Movies", + "pictures": "/Users/jan/Pictures" + }, + "listen_host": "0.0.0.0", + "listen_port": 8765, + "public_endpoint": "http://192.168.1.25:8765" +} +``` + +Opmerking: + +- `public_endpoint` is het endpoint dat WebManager gebruikt +- `listen_host` en `public_endpoint` hoeven niet identiek te zijn + +--- + +## Open keuzes die bewust zijn uitgesteld + +Deze keuzes zijn echt later werk, niet meer V1.1: + +- reverse-connect of tunnelmodel +- cross-source copy +- cross-source move +- bookmarks/startup paths voor `/Clients/...` +- write-acties op remote shares +- sterkere pairing of key rotation + +--- + +## Beslisadvies + +Aanbevolen implementatievolgorde voor V1.1: + +1. agent registry + heartbeat +2. virtuele `Clients` root in browse +3. online/offline status met gescheiden statusvelden +4. browse/info/preview/download voor remote shares + +Niet in V1.1: + +5. write-acties +6. bookmarks/startup paths +7. cross-source flows + +--- + +## Samenvatting + +De juiste V1.1-richting is: + +- geen hele homefolder +- wel beperkte shares zoals `Downloads`, `Movies`, `Pictures` +- remote helper-agent op macOS +- agent registreert zichzelf bij WebManager +- WebManager bewaart `client_id`-geleide registry en status +- `/Clients` wordt een aparte virtuele bron +- remote paden blijven buiten lokale filesystem services +- alle agent-calls vereisen auth +- offline agents mogen nooit de rest van WebManager verstoren + +Dit model is haalbaar, beperkt in scope, en houdt de bestaande lokale storage-architectuur schoon. diff --git a/webui/backend/data/archive_tmp/b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip b/webui/backend/data/archive_tmp/b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip deleted file mode 100644 index 4a3ca91..0000000 Binary files a/webui/backend/data/archive_tmp/b00fba24-e752-405f-98a7-6f2c21fa3a1c.zip and /dev/null differ diff --git a/webui/backend/data/archive_tmp/ece0fef1-0632-4242-b038-ebe6b2c28c3d.zip b/webui/backend/data/archive_tmp/ece0fef1-0632-4242-b038-ebe6b2c28c3d.zip new file mode 100644 index 0000000..8d76e8d Binary files /dev/null and b/webui/backend/data/archive_tmp/ece0fef1-0632-4242-b038-ebe6b2c28c3d.zip differ diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index e74a73f..3869ff4 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 6f0e9fa..640885b 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 29c703d..a67fea6 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -259,13 +259,23 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "formatTaskLine"), self._extract_js_function(app_js, "isActiveTask"), self._extract_js_function(app_js, "activeTasksFromItems"), + self._extract_js_function(app_js, "isTerminalOperationTask"), + self._extract_js_function(app_js, "statusBadgeLabel"), + self._extract_js_function(app_js, "terminalOperationChipLabel"), + self._extract_js_function(app_js, "visibleOperationSortKey"), + self._extract_js_function(app_js, "sortVisibleOperations"), self._extract_js_function(app_js, "taskIsCancellable"), self._extract_js_function(app_js, "cancelTaskRequest"), self._extract_js_function(app_js, "formatTaskOperationLabel"), + self._extract_js_function(app_js, "statusBadgeLabel"), self._extract_js_function(app_js, "hasMeaningfulItemProgress"), self._extract_js_function(app_js, "canShowChipItemProgress"), self._extract_js_function(app_js, "compactTaskCurrentItem"), self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "isTerminalOperationTask"), + self._extract_js_function(app_js, "terminalOperationChipLabel"), + self._extract_js_function(app_js, "visibleOperationSortKey"), + self._extract_js_function(app_js, "sortVisibleOperations"), self._extract_js_function(app_js, "taskProgressText"), self._extract_js_function(app_js, "taskProgressSubtext"), self._extract_js_function(app_js, "headerTaskRenderKey"), @@ -360,16 +370,23 @@ class UiSmokeGoldenTest(unittest.TestCase): }} async function refreshTasksSnapshot() {{}} + async function loadBrowsePane() {{}} function setError() {{}} let headerTaskState = {{ activeItems: [], + visibleItems: [], + recentItems: [], popoverOpen: false, pollTimer: null, lastRenderKey: "", + knownStatuses: {{}}, + recentExpiryMs: 4000, + paneRefreshPromise: null, }}; const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); + const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]); {functions} @@ -412,8 +429,10 @@ class UiSmokeGoldenTest(unittest.TestCase): const deleteRow = elements["header-task-popover-list"].children[3]; const deleteProgress = deleteRow.children[3]; const deleteCurrent = deleteRow.children[4]; + const deleteBadge = deleteRow.children[0].children[1]; assert(deleteProgress.textContent === "2/5", "Delete operations should show done/total progress when available"); assert(deleteCurrent.textContent === "folder/delete-me.txt", "Delete operations should show compact current item"); + assert(deleteBadge.textContent === "Running", "Delete operations should expose a readable status badge"); const cancellingRow = elements["header-task-popover-list"].children[4]; const cancellingProgress = cancellingRow.children[3]; const cancellingCurrent = cancellingRow.children[4]; @@ -485,10 +504,15 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "taskIsCancellable"), self._extract_js_function(app_js, "cancelTaskRequest"), self._extract_js_function(app_js, "formatTaskOperationLabel"), + self._extract_js_function(app_js, "statusBadgeLabel"), self._extract_js_function(app_js, "hasMeaningfulItemProgress"), self._extract_js_function(app_js, "canShowChipItemProgress"), self._extract_js_function(app_js, "compactTaskCurrentItem"), self._extract_js_function(app_js, "activeTaskChipLabel"), + self._extract_js_function(app_js, "isTerminalOperationTask"), + self._extract_js_function(app_js, "terminalOperationChipLabel"), + self._extract_js_function(app_js, "visibleOperationSortKey"), + self._extract_js_function(app_js, "sortVisibleOperations"), self._extract_js_function(app_js, "taskProgressText"), self._extract_js_function(app_js, "taskProgressSubtext"), self._extract_js_function(app_js, "headerTaskRenderKey"), @@ -499,6 +523,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "renderHeaderTaskPopover"), self._extract_js_function(app_js, "renderHeaderTaskChip"), self._extract_js_function(app_js, "updateHeaderTaskState"), + self._extract_js_function(app_js, "refreshOperationPanes"), self._extract_js_function(app_js, "applyTaskSnapshot"), ] ) @@ -583,17 +608,25 @@ class UiSmokeGoldenTest(unittest.TestCase): }} async function refreshTasksSnapshot() {{}} + const paneRefreshCalls = []; + async function loadBrowsePane(pane) {{ paneRefreshCalls.push(pane); }} function setError() {{}} let state = {{ lastTaskCount: 0 }}; let headerTaskState = {{ activeItems: [], + visibleItems: [], + recentItems: [], popoverOpen: false, pollTimer: null, lastRenderKey: "", + knownStatuses: {{}}, + recentExpiryMs: 4000, + paneRefreshPromise: null, }}; const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); + const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]); {functions} @@ -603,12 +636,16 @@ class UiSmokeGoldenTest(unittest.TestCase): assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Running task should make chip visible"); assert(elements["header-task-chip-label"].textContent === "Copy running", "Single active task should show compact task status"); assert(headerTaskState.activeItems.length === 1, "Snapshot should store active task state"); + assert(paneRefreshCalls.length === 0, "Running progress should not refresh panes"); applyTaskSnapshot([ {{ id: "copy-1", operation: "copy", status: "completed", source: "/src", destination: "/dst" }}, ]); - assert(elements["header-task-chip-container"].classList.contains("hidden"), "Chip should hide when latest task snapshot has no active tasks"); + assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Terminal operations should remain briefly visible"); assert(headerTaskState.activeItems.length === 0, "Active task state should be reset when tasks are completed"); + assert(headerTaskState.recentItems.length === 1, "Completed operations should stay briefly visible as recent"); + assert(elements["header-task-chip-label"].textContent === "1 recent operation", "Chip should surface a brief recent-operation state"); + assert(paneRefreshCalls.length === 2, "Terminal operation should refresh both visible panes once"); assert(state.lastTaskCount === 1, "Total task snapshot should still reflect fetched tasks list length"); """ ) @@ -961,6 +998,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn(".header-task-chip {", base_css) self.assertIn(".header-task-popover {", base_css) self.assertIn(".header-task-popover-list {", base_css) + self.assertIn(".header-task-item-heading {", base_css) + self.assertIn(".header-task-status-badge {", base_css) self.assertIn("width: min(1180px, calc(100vw - 32px));", base_css) self.assertIn(".settings-activity-grid {", base_css) self.assertIn("grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);", base_css) @@ -1006,6 +1045,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function headerTaskElements()', app_js) self.assertIn('function isActiveTask(task)', app_js) self.assertIn('function activeTasksFromItems(items)', app_js) + self.assertIn('function isTerminalOperationTask(task)', app_js) + self.assertIn('function statusBadgeLabel(task)', app_js) + self.assertIn('function terminalOperationChipLabel(items)', app_js) + self.assertIn('function sortVisibleOperations(items)', app_js) self.assertIn('function taskIsCancellable(task)', app_js) self.assertIn('async function cancelTaskRequest(taskId)', app_js) self.assertIn('function formatTaskOperationLabel(task)', app_js) @@ -1021,15 +1064,18 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function renderHeaderTaskPopover(items)', app_js) self.assertIn('function renderHeaderTaskChip(items)', app_js) self.assertIn('function updateHeaderTaskState(taskItems)', app_js) + self.assertIn('function refreshOperationPanes()', app_js) self.assertIn('function applyTaskSnapshot(taskItems)', app_js) self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js) + self.assertIn('return `${count} recent operation${count === 1 ? "" : "s"}`;', app_js) self.assertIn('return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";', app_js) self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js) self.assertIn('return `${action} running`;', app_js) self.assertIn('return "Stopping after current item...";', app_js) self.assertIn('ACTIVE_OPERATION_OPERATIONS.has(task.operation)', app_js) - self.assertIn('headerTaskState.activeItems = activeTasksFromItems(taskItems);', app_js) - self.assertIn('const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0;', app_js) + self.assertIn('headerTaskState.activeItems = activeTasksFromItems(items);', app_js) + self.assertIn('headerTaskState.visibleItems = sortVisibleOperations([...headerTaskState.activeItems, ...headerTaskState.recentItems]);', app_js) + self.assertIn('const open = Boolean(nextOpen) && headerTaskState.visibleItems.length > 0;', app_js) self.assertIn('const headerTasks = headerTaskElements();', app_js) self.assertIn('headerTasks.chipButton.onclick = (event) => {', app_js) self.assertIn('headerTasks.logsButton.onclick = () => {', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 76f803e..35e8e29 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -116,13 +116,19 @@ let settingsState = { }; let headerTaskState = { activeItems: [], + visibleItems: [], + recentItems: [], popoverOpen: false, pollTimer: null, lastRenderKey: "", + knownStatuses: {}, + recentExpiryMs: 4000, + paneRefreshPromise: null, }; // The header chip/popover reflects user-visible file operations, not every task-backed file action. const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); +const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]); const VALID_THEME_FAMILIES = [ "default", "macos-soft", @@ -3879,6 +3885,75 @@ function activeTasksFromItems(items) { return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : []; } +function isTerminalOperationTask(task) { + return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && TERMINAL_OPERATION_STATUSES.has(task.status); +} + +function statusBadgeLabel(task) { + switch (task?.status) { + case "queued": + return "Queued"; + case "running": + return "Running"; + case "cancelling": + return "Cancelling"; + case "completed": + return "Completed"; + case "cancelled": + return "Cancelled"; + case "failed": + return "Failed"; + default: + return formatTaskStatusLabel(task); + } +} + +function terminalOperationChipLabel(items) { + const count = Array.isArray(items) ? items.length : 0; + if (count <= 0) { + return ""; + } + return `${count} recent operation${count === 1 ? "" : "s"}`; +} + +function visibleOperationSortKey(task) { + const statusOrder = { + running: 0, + cancelling: 1, + queued: 2, + completed: 3, + cancelled: 4, + failed: 5, + }; + return statusOrder[task?.status] ?? 9; +} + +function sortVisibleOperations(items) { + return [...items].sort((left, right) => { + const statusDelta = visibleOperationSortKey(left) - visibleOperationSortKey(right); + if (statusDelta !== 0) { + return statusDelta; + } + const leftTime = left.finished_at || left.created_at || ""; + const rightTime = right.finished_at || right.created_at || ""; + return rightTime.localeCompare(leftTime); + }); +} + +async function refreshOperationPanes() { + if (headerTaskState.paneRefreshPromise) { + return headerTaskState.paneRefreshPromise; + } + headerTaskState.paneRefreshPromise = Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]) + .catch((err) => { + setError("actions-error", `Refresh panes: ${err.message}`); + }) + .finally(() => { + headerTaskState.paneRefreshPromise = null; + }); + return headerTaskState.paneRefreshPromise; +} + function taskIsCancellable(task) { return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status); } @@ -3979,7 +4054,7 @@ function headerTaskRenderKey(items) { } function shouldPollHeaderTasks() { - return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0; + return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0 || headerTaskState.recentItems.length > 0; } function stopHeaderTaskPolling() { @@ -4002,7 +4077,7 @@ function scheduleHeaderTaskPolling() { function setHeaderTaskPopoverOpen(nextOpen) { const elements = headerTaskElements(); - const open = Boolean(nextOpen) && headerTaskState.activeItems.length > 0; + const open = Boolean(nextOpen) && headerTaskState.visibleItems.length > 0; headerTaskState.popoverOpen = open; if (elements.chipButton) { elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false"); @@ -4035,17 +4110,23 @@ function renderHeaderTaskPopover(items) { for (const task of items) { const line = formatTaskLine(task); const row = document.createElement("div"); - row.className = "header-task-item"; + row.className = `header-task-item status-${task.status}`; + const heading = document.createElement("div"); + heading.className = "header-task-item-heading"; const title = document.createElement("div"); title.className = "header-task-item-title"; - title.textContent = line.title; + title.textContent = formatTaskOperationLabel(task); + const badge = document.createElement("span"); + badge.className = `header-task-status-badge status-${task.status}`; + badge.textContent = statusBadgeLabel(task); + heading.append(title, badge); const path = document.createElement("div"); path.className = "header-task-item-path"; path.textContent = line.path; const meta = document.createElement("div"); meta.className = "header-task-item-meta"; meta.textContent = line.meta; - row.append(title, path, meta); + row.append(heading, path, meta); const progressText = taskProgressText(task); if (progressText) { const progress = document.createElement("div"); @@ -4102,27 +4183,71 @@ function renderHeaderTaskChip(items) { if (!elements.container || !elements.chipLabel) { return; } - const hasActiveTasks = Array.isArray(items) && items.length > 0; - elements.container.classList.toggle("hidden", !hasActiveTasks); - elements.chipLabel.textContent = activeTaskChipLabel(items); - if (!hasActiveTasks) { + const activeItems = Array.isArray(items) ? items : []; + const recentItems = Array.isArray(headerTaskState.recentItems) ? headerTaskState.recentItems : []; + const visibleItems = activeItems.length > 0 ? activeItems : recentItems; + const hasVisibleItems = visibleItems.length > 0; + elements.container.classList.toggle("hidden", !hasVisibleItems); + elements.chipLabel.textContent = activeItems.length > 0 ? activeTaskChipLabel(activeItems) : terminalOperationChipLabel(recentItems); + if (!hasVisibleItems) { headerTaskState.lastRenderKey = ""; setHeaderTaskPopoverOpen(false); return; } - renderHeaderTaskPopover(items); + renderHeaderTaskPopover(visibleItems); } function updateHeaderTaskState(taskItems) { - headerTaskState.activeItems = activeTasksFromItems(taskItems); + const items = Array.isArray(taskItems) ? taskItems : []; + headerTaskState.activeItems = activeTasksFromItems(items); + headerTaskState.visibleItems = sortVisibleOperations([...headerTaskState.activeItems, ...headerTaskState.recentItems]); renderHeaderTaskChip(headerTaskState.activeItems); scheduleHeaderTaskPolling(); } function applyTaskSnapshot(taskItems) { const items = Array.isArray(taskItems) ? taskItems : []; + const now = Date.now(); + const activeById = new Set(); + const nextKnownStatuses = {}; + const nextRecentItems = []; + let shouldRefreshPanes = false; + + for (const task of items) { + if (!task?.id) { + continue; + } + nextKnownStatuses[task.id] = task.status || ""; + const previousStatus = headerTaskState.knownStatuses[task.id] || ""; + if (isActiveTask(task)) { + activeById.add(task.id); + } + if (isTerminalOperationTask(task)) { + if (previousStatus && !TERMINAL_OPERATION_STATUSES.has(previousStatus)) { + shouldRefreshPanes = true; + nextRecentItems.push({ ...task, _recent_until: now + headerTaskState.recentExpiryMs }); + } + } + } + + for (const recentTask of headerTaskState.recentItems) { + if (!recentTask?.id || activeById.has(recentTask.id)) { + continue; + } + if ((recentTask._recent_until || 0) > now) { + nextRecentItems.push(recentTask); + } + } + + headerTaskState.recentItems = sortVisibleOperations( + nextRecentItems.filter((task, index, collection) => collection.findIndex((entry) => entry.id === task.id) === index) + ); + headerTaskState.knownStatuses = nextKnownStatuses; state.lastTaskCount = items.length; updateHeaderTaskState(items); + if (shouldRefreshPanes) { + void refreshOperationPanes(); + } return items; } diff --git a/webui/html/base.css b/webui/html/base.css index cd541ec..ba84c69 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -143,11 +143,56 @@ body { padding: 10px 12px; } +.header-task-item-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + .header-task-item-title { font-size: 12px; font-weight: 700; } +.header-task-status-badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + font-weight: 700; + white-space: nowrap; + border: 1px solid var(--color-border); + background: var(--color-surface-elevated); + color: var(--color-text-primary); +} + +.header-task-status-badge.status-queued { + background: color-mix(in srgb, var(--color-surface-elevated) 72%, var(--color-accent) 28%); +} + +.header-task-status-badge.status-running { + background: color-mix(in srgb, var(--color-surface-elevated) 60%, var(--color-accent) 40%); +} + +.header-task-status-badge.status-cancelling { + background: color-mix(in srgb, var(--color-surface-elevated) 70%, var(--color-warning, #c08a00) 30%); +} + +.header-task-status-badge.status-completed { + background: color-mix(in srgb, var(--color-surface-elevated) 72%, var(--color-success, #2f855a) 28%); +} + +.header-task-status-badge.status-cancelled { + background: color-mix(in srgb, var(--color-surface-elevated) 78%, var(--color-text-muted) 22%); +} + +.header-task-status-badge.status-failed { + background: color-mix(in srgb, var(--color-surface-elevated) 68%, var(--color-danger) 32%); + color: var(--color-danger-text, var(--color-text-primary)); +} + .header-task-item-path, .header-task-item-meta, .header-task-item-empty {