Voor remote client agent

This commit is contained in:
kodi
2026-03-25 18:21:54 +01:00
parent 9537a29de3
commit fc4ec39646
14 changed files with 1892 additions and 14 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.log
.venv/
venv/
.DS_Store
.sqlite3
+67
View File
@@ -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 <path>`
- `mkdir <name>`
- `touch <name>`
- `select <glob>`
- `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
+522
View File
@@ -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 <path>, mkdir <name>, touch <name>, select <glob>, 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))})
+4
View File
@@ -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
+27
View File
@@ -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
@@ -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/<client>`
- `/Clients/<client>/<share>`
## 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/<client>` toont alleen toegestane shares
- `/Clients/<client>/<share>` 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.
@@ -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 <registration-token>`
### 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 <agent-access-token>`
### 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/<client-display>`
- `/Clients/<client-display>/<share-label>`
- `/Clients/<client-display>/<share-label>/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/<client>/`
Geeft shares van die client terug als directories.
### `GET /api/browse?path=/Clients/<client>/<share>/...`
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.
Binary file not shown.
@@ -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)
+136 -11
View File
@@ -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;
}
+45
View File
@@ -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 {