Compare commits

...

98 Commits

Author SHA1 Message Date
kodi 54e56ab0d8 Fix context menu positioning and remove unnecessary width 2026-03-28 08:12:29 +01:00
kodi 9778dc6c33 Add Phase 3 remote read-only file operations
Introduce dedicated remote file facade for /Clients paths, add agent read/download endpoints, enable remote view/properties/download/image preview in the web UI, and keep remote write operations disabled.
2026-03-27 15:16:01 +01:00
kodi 2fa4a0b291 Implement Remote Client Shares Phase 2 browse support and unify remote agent HTTP + heartbeat 2026-03-27 14:17:55 +01:00
kodi 4062cbf6c8 Add Phase 2 remote browse scaffolding for /Clients 2026-03-27 11:39:26 +01:00
kodi 841318c9e2 Add Phase 1 remote client registry and heartbeat agent
- add remote client register, heartbeat and list endpoints
- add remote client repository and service
- add minimal macOS remote agent with config, register and heartbeat loop
- keep client_id as leading identity
- keep status fields separated: last_seen, status, last_error, reachable_at
- avoid changes to local storage flows, PathGuard and /Volumes behavior
2026-03-27 10:39:54 +01:00
kodi 684f52be4d feat: remote client deel 1 2026-03-26 19:41:58 +01:00
kodi fc4ec39646 Voor remote client agent 2026-03-25 18:21:54 +01:00
kodi 9537a29de3 feat: feedback verbetering - 06 2026-03-15 15:51:13 +01:00
kodi ae6a9d8c45 feat: feedback verbetering 05 2026-03-15 15:30:55 +01:00
kodi 61d0c8de41 feat: feedback verbetering 04 2026-03-15 14:52:33 +01:00
kodi 3d82699535 feat: feedback verbetering 03 2026-03-15 14:16:17 +01:00
kodi 492082c2b7 feat: feedback verbetering 02 2026-03-15 13:52:48 +01:00
kodi 9a7ca4e2db feat: feedback verbetering 01 2026-03-15 13:44:38 +01:00
kodi 66abf991d8 feat: feedback verbetering 2026-03-15 13:28:11 +01:00
kodi a52493459a feat: annuleren taak toegevoegd 2026-03-15 13:06:48 +01:00
kodi 7d910479f9 feat: voortgang delete in headerbar 2026-03-15 11:52:39 +01:00
kodi 73b09d2802 feat: voortgang copy/duplicate/move in headerbar 2026-03-15 11:40:21 +01:00
kodi 9d5fb5a0c9 feat: favicon en logo toegevoegd 2026-03-15 09:12:11 +01:00
kodi c0bd6b647c fix: navigatie 2026-03-15 07:39:57 +01:00
kodi cc5a978e79 feature: duplicate 02 2026-03-14 17:27:24 +01:00
kodi 7f7665880f feature: duplicate 01 2026-03-14 17:20:36 +01:00
kodi 14600dd5b6 fix: log/tasks posities - 02 2026-03-14 16:39:22 +01:00
kodi a816f71ad5 fix: log/tasks posities 2026-03-14 16:33:46 +01:00
kodi 15c85e874c fix: log/tasks rendering 2026-03-14 16:12:40 +01:00
kodi 90b1828160 task bij logs gezet 2026-03-14 16:08:36 +01:00
kodi 5265d6458c download taken zichtbaar gemaakt 2026-03-14 15:57:45 +01:00
kodi e85e51d64a polish 2026-03-14 15:29:50 +01:00
kodi 3fb8528b0e feat: conf toegevoegd 2026-03-14 15:12:35 +01:00
kodi 8af4b1a6b0 feat: B4 - progressbar bij single file 2026-03-14 14:58:07 +01:00
kodi d459f3c524 feat: B4 - progressbar 2026-03-14 14:49:15 +01:00
kodi 2981ac2796 feat: B3 uit voor veilige archive-downloads - cancel knop toegevoegd 2026-03-14 14:39:57 +01:00
kodi d463b3977d feat: B2 uit voor veilige archive-downloads 2026-03-14 14:24:52 +01:00
kodi 592b10acc2 feat: download - download status aan logs toegevoegd 2026-03-14 13:53:53 +01:00
kodi 8ea2bd1498 feat: download - download dwnload limieten in settings 2026-03-14 13:38:44 +01:00
kodi ea337338e3 feat: download - download safeguard 2026-03-14 13:24:17 +01:00
kodi 7e7c2f3958 feat: download - fase 03 2026-03-14 13:10:52 +01:00
kodi dab87878cc feat: download - fase 02 2026-03-14 12:40:41 +01:00
kodi 610a648fd1 feat: download - fase 01 2026-03-14 12:31:11 +01:00
kodi af1d1eea23 feat: contextmenu Open aangepast 2026-03-14 11:51:29 +01:00
kodi 6b4fb34b40 feat: contextmenu Edit toegevoegd 2026-03-14 11:38:52 +01:00
kodi 3dfbc64913 feat: contextmenu Open toegevoegd 2026-03-14 11:23:52 +01:00
kodi 73c539ba4a feat: contextmenu eigenschappen toegevoegd 2026-03-14 11:11:03 +01:00
kodi d08ca24c87 feat: contextmenu copy multiple folders toegevoegd 2026-03-14 11:01:45 +01:00
kodi 4e1288fe47 feat: contextmenu copy folders toegevoegd 2026-03-14 10:34:31 +01:00
kodi 8908b1dce9 feat: contextmenu deel copy toegevoegd 2026-03-14 09:59:42 +01:00
kodi 84f3eedb74 feat: contextmenu deel move toegevoegd 2026-03-14 09:54:21 +01:00
kodi 054e736aa6 feat: contextmenu deel 3a rename en delete 2026-03-14 09:41:16 +01:00
kodi 7bb59a2b65 feat: contextmenu deel 2 2026-03-14 09:31:01 +01:00
kodi 0615324607 feat: contextmenu deel 1 2026-03-14 09:22:24 +01:00
kodi 3987de27e0 feat: delete multiple non empty folders 2026-03-14 08:36:47 +01:00
kodi d84b3da561 feat: delete non empty folders 2026-03-14 07:48:29 +01:00
kodi f092007998 feat: upload progressbar 2026-03-14 07:28:31 +01:00
kodi f0b04fd4ee feat: folder upload - deel 3 2026-03-14 07:10:49 +01:00
kodi 287dddb7b3 feat: folder upload - deel 2 2026-03-14 06:57:18 +01:00
kodi e2e206573d feat: folder upload - deel 1 2026-03-14 06:52:18 +01:00
kodi 360815498e feat: upload - deel 03.02 - Skipp all toegevoegd 2026-03-13 18:30:10 +01:00
kodi 8fe9d0f436 feat: upload - deel 02 2026-03-13 16:21:51 +01:00
kodi 8d1ff79912 upload: deel 01 2026-03-13 13:44:41 +01:00
kodi 24d47dce8c feat: size formatting toegevoegd 2026-03-13 12:35:54 +01:00
kodi e43d49540d feat: selected items 2026-03-13 12:26:17 +01:00
kodi 7ab233be2c feat: vaste kolomheaders 2026-03-13 12:19:13 +01:00
kodi bf4bb3d917 focus balk onder aan paneel 2026-03-13 12:09:40 +01:00
kodi 4ba4020c2a imageviewer 2026-03-13 11:57:49 +01:00
kodi 018c3dcd94 image file info toegevoegd bij CMD+ENTER 2026-03-13 11:37:27 +01:00
kodi 05569576a7 navigatie aangepast 2026-03-13 11:12:03 +01:00
kodi ac18291a3c feat: polish 2026-03-12 20:27:30 +01:00
kodi e25d43200f feat: theme - 02 2026-03-12 20:13:50 +01:00
kodi 09c3e14dea feat: theme - 01 2026-03-12 18:49:13 +01:00
kodi ab83ee3f20 feat: theme 2026-03-12 18:26:29 +01:00
kodi 939a7fd191 feat: monaco editor toegevoegd voor py bestanden 2026-03-12 17:28:23 +01:00
kodi d12319392f feat: monaco editor toegevoegd 2026-03-12 17:13:40 +01:00
kodi aac84a0a7f feat: iconenen aangepast 2026-03-12 15:35:47 +01:00
kodi fc22550e91 feat: pdf viewer toegevoegd 2026-03-12 15:08:30 +01:00
kodi ed34d6202f feat: preffered startup paths 2026-03-12 14:47:39 +01:00
kodi 559b881b6d feat: checkbox verwijderd 2026-03-12 12:46:48 +01:00
kodi 3b376fa8ff feat: thumbnails added 2026-03-12 12:27:47 +01:00
kodi 76f5ed3e98 feat: CMD-ENTER file info toegevoegd 2026-03-12 11:45:56 +01:00
kodi 6f8f884d75 feat: SHIFT-CMD-F zoek functionaliteit toegevoegd 2026-03-12 11:22:24 +01:00
kodi 8c2fbfef74 feat: videoplayer toegevoegd 2026-03-12 10:37:06 +01:00
kodi 5123067100 feat: F6 Move functionaliteit aangepast 2026-03-12 10:01:21 +01:00
kodi 2e897504a8 feat: Renamen functionaliteit aangepast 2026-03-12 09:38:14 +01:00
kodi 8f4263c222 feat: F1 en F2 en Log UI toegevoegd 2026-03-12 07:55:45 +01:00
kodi 9901c77919 feat: logging toegevoegd 2026-03-12 07:32:44 +01:00
kodi ea6eac9536 feat: selectie met toetsen toegevoegd 2026-03-11 18:39:18 +01:00
kodi 401f957f89 feat (ui) CSS - 02 2026-03-11 17:02:50 +01:00
kodi 73e47f0466 feat (ui) CSS 2026-03-11 16:57:08 +01:00
kodi 6e7b3cffae Multiple folder move added 2026-03-11 16:27:21 +01:00
kodi 3e4761f5a7 Folder move added 2026-03-11 16:21:00 +01:00
kodi d1f018a130 Volumes 2026-03-11 15:25:32 +01:00
kodi 6a1a575383 feat: function key mapping 2026-03-11 14:21:58 +01:00
kodi b93cb01879 feat: file edit added 2026-03-11 14:09:44 +01:00
kodi ba6a369f78 feat: file viewer added 2026-03-11 13:53:59 +01:00
kodi 31a42d34c7 feat: menu layout 2026-03-11 13:28:18 +01:00
kodi fa9dc00f61 feat: keyboard functionaliteit shift + / Shift - toegevoegd 2026-03-11 12:56:02 +01:00
kodi df47bd13b3 feat: keyboard functionaliteit cmd/option up/down toegevoegd 2026-03-11 12:35:47 +01:00
kodi 212d55ae54 feat: keyboard functionaliteit toegevoegd 2026-03-11 11:59:53 +01:00
kodi 523395b92a fix: copy and move 2026-03-11 11:45:06 +01:00
kodi 05816751b1 feat (ui): multiselect toegevoegd 2026-03-11 10:19:40 +01:00
188 changed files with 29921 additions and 670 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
*.log
.venv/
venv/
.DS_Store
.sqlite3
+1 -1
View File
@@ -18,7 +18,7 @@ RUN mkdir -p /app/backend /app/html /app/conf /Volumes/8TB /Volumes/8TB_RAID1
# Installeer een lichtgewicht Python API framework (FastAPI)
# We gebruiken --break-system-packages omdat we in een container zitten
RUN pip3 install fastapi uvicorn --break-system-packages
RUN pip3 install fastapi uvicorn python-multipart httpx --break-system-packages
# Exposeer de poort voor de webinterface
EXPOSE 8030
+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
+360
View File
@@ -0,0 +1,360 @@
from __future__ import annotations
import json
import mimetypes
import os
import struct
from dataclasses import dataclass
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
APP_NAME = "Finder Commander Remote Agent"
DEFAULT_PORT = 8765
TEXT_PREVIEW_MAX_BYTES = 256 * 1024
TEXT_CONTENT_TYPES = {
".txt": "text/plain",
".log": "text/plain",
".conf": "text/plain",
".ini": "text/plain",
".cfg": "text/plain",
".md": "text/markdown",
".yml": "text/yaml",
".yaml": "text/yaml",
".json": "application/json",
".js": "text/javascript",
".py": "text/x-python",
".css": "text/css",
".html": "text/html",
}
SPECIAL_TEXT_FILENAMES = {
"dockerfile": "text/plain",
"containerfile": "text/plain",
}
@dataclass(frozen=True)
class AgentRuntimeConfig:
config_path: Path | None
agent_access_token: str
shares: dict[str, str]
display_name: str
endpoint: str
client_id: str
platform: str
def _now_iso() -> str:
return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
def _candidate_config_paths() -> list[Path]:
candidates: list[Path] = []
env_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip()
if env_path:
candidates.append(Path(env_path).expanduser().resolve(strict=False))
base_dir = Path(__file__).resolve().parents[1]
candidates.append(base_dir / "remote_client_agent.launchd.json")
candidates.append(base_dir / "remote_client_agent.example.json")
return candidates
def _load_raw_config() -> tuple[Path | None, dict]:
for candidate in _candidate_config_paths():
if candidate.is_file():
try:
raw = json.loads(candidate.read_text(encoding="utf-8"))
except ValueError as exc:
raise RuntimeError(f"Invalid JSON in config file: {candidate}") from exc
if not isinstance(raw, dict):
raise RuntimeError(f"Config file must contain a JSON object: {candidate}")
return candidate.resolve(strict=False), raw
return None, {}
@lru_cache(maxsize=1)
def get_runtime_config() -> AgentRuntimeConfig:
config_path, raw = _load_raw_config()
shares_raw = raw.get("shares", {})
shares: dict[str, str] = {}
if isinstance(shares_raw, dict):
for key, value in shares_raw.items():
normalized_key = str(key).strip()
normalized_value = str(value).strip()
if normalized_key and normalized_value:
shares[normalized_key] = normalized_value
return AgentRuntimeConfig(
config_path=config_path,
agent_access_token=os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip()
or str(raw.get("agent_access_token", "")).strip(),
shares=shares,
display_name=str(raw.get("display_name", "")).strip(),
endpoint=str(raw.get("public_endpoint", raw.get("endpoint", ""))).strip(),
client_id=str(raw.get("client_id", "")).strip(),
platform=str(raw.get("platform", "macos")).strip() or "macos",
)
def require_agent_auth(request: Request) -> None:
config = get_runtime_config()
if not config.agent_access_token:
return
authorization = request.headers.get("authorization", "").strip()
if authorization != f"Bearer {config.agent_access_token}":
raise_agent_error(
status_code=403,
code="invalid_agent_token",
message="Invalid agent token",
extra={
"config_path": str(config.config_path) if config.config_path else None,
"client_id": config.client_id or None,
"display_name": config.display_name or None,
},
)
def raise_agent_error(status_code: int, code: str, message: str, *, extra: dict | None = None) -> None:
detail = {"code": code, "message": message}
if extra:
detail.update(extra)
raise HTTPException(status_code=status_code, detail=detail)
def get_share_root(share: str) -> Path:
config = get_runtime_config()
normalized_share = (share or "").strip()
if normalized_share not in config.shares:
raise_agent_error(404, "path_not_found", "Share not found")
return Path(config.shares[normalized_share]).expanduser().resolve(strict=False)
def ensure_within_root(root: Path, candidate: Path) -> Path:
try:
candidate.relative_to(root)
except ValueError as exc:
_ = exc
raise_agent_error(403, "path_traversal_detected", "Path escapes share root")
return candidate
def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path:
root = get_share_root(share)
normalized = (raw_path or "").strip().replace("\\", "/")
if normalized.startswith("/") or any(part == ".." for part in normalized.split("/")):
raise_agent_error(400, "invalid_request", "Invalid share-relative path")
candidate = (root / normalized).resolve(strict=False)
candidate = ensure_within_root(root, candidate)
if must_exist and not candidate.exists():
raise_agent_error(404, "path_not_found", "Path not found")
return candidate
def directory_entry_payload(path: Path) -> dict:
stat_result = path.lstat()
return {
"name": path.name,
"kind": "directory" if path.is_dir() else "file",
"size": stat_result.st_size,
"modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
}
def info_payload(path: Path, *, share: str, raw_path: str) -> dict:
stat_result = path.lstat()
kind = "directory" if path.is_dir() else "file"
mime, _ = mimetypes.guess_type(path.name)
width, height = image_dimensions(path) if path.is_file() else (None, None)
return {
"share": share,
"path": raw_path.strip().replace("\\", "/").strip("/"),
"name": path.name,
"kind": kind,
"size": None if path.is_dir() else stat_result.st_size,
"modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
"content_type": mime or "application/octet-stream",
"extension": path.suffix.lower() or None,
"width": width,
"height": height,
"owner": None,
"group": None,
"config_path": str(get_runtime_config().config_path) if get_runtime_config().config_path else None,
}
def list_directory(path: Path, *, show_hidden: bool) -> list[dict]:
try:
children = list(path.iterdir())
except PermissionError as exc:
_ = exc
raise_agent_error(403, "forbidden", "Permission denied by operating system")
filtered = []
for child in children:
if not show_hidden and child.name.startswith("."):
continue
filtered.append(child)
filtered.sort(key=lambda item: (not item.is_dir(), item.name.lower()))
return [directory_entry_payload(child) for child in filtered]
def text_content_type_for_name(name: str) -> str | None:
lowered = (name or "").lower()
special = SPECIAL_TEXT_FILENAMES.get(lowered)
if special:
return special
return TEXT_CONTENT_TYPES.get(Path(name).suffix.lower())
def read_text_preview(path: Path, *, max_bytes: int) -> dict:
size = int(path.stat().st_size)
preview_limit = min(max(1, int(max_bytes)), TEXT_PREVIEW_MAX_BYTES)
with path.open("rb") as handle:
raw = handle.read(preview_limit + 1)
truncated = size > preview_limit or len(raw) > preview_limit
if truncated:
raw = raw[:preview_limit]
if b"\x00" in raw:
raise_agent_error(409, "unsupported_type", "Binary content is not supported for text preview")
try:
content = raw.decode("utf-8")
except UnicodeDecodeError as exc:
_ = exc
raise_agent_error(409, "unsupported_type", "Binary content is not supported for text preview")
return {
"size": size,
"modified": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
"encoding": "utf-8",
"truncated": truncated,
"content": content,
}
def image_dimensions(path: Path) -> tuple[int | None, int | None]:
suffix = path.suffix.lower()
try:
if suffix == ".png":
with path.open("rb") as handle:
header = handle.read(24)
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
return None, None
return struct.unpack(">II", header[16:24])
if suffix == ".gif":
with path.open("rb") as handle:
header = handle.read(10)
if len(header) < 10 or header[:6] not in {b"GIF87a", b"GIF89a"}:
return None, None
return struct.unpack("<HH", header[6:10])
if suffix == ".bmp":
with path.open("rb") as handle:
header = handle.read(26)
if len(header) < 26 or header[:2] != b"BM":
return None, None
width, height = struct.unpack("<ii", header[18:26])
return abs(width), abs(height)
except (OSError, ValueError, struct.error):
return None, None
return None, None
app = FastAPI(title=APP_NAME)
@app.get("/")
def root() -> dict:
config = get_runtime_config()
return {
"ok": True,
"app": APP_NAME,
"time": _now_iso(),
"client_id": config.client_id or None,
"display_name": config.display_name or None,
"config_path": str(config.config_path) if config.config_path else None,
"shares": sorted(config.shares.keys()),
"auth_enabled": bool(config.agent_access_token),
}
@app.get("/health")
def health(request: Request) -> dict:
require_agent_auth(request)
config = get_runtime_config()
return {
"ok": True,
"app": APP_NAME,
"time": _now_iso(),
"client_id": config.client_id or None,
"display_name": config.display_name or None,
"platform": config.platform,
"endpoint": config.endpoint or None,
"shares": sorted(config.shares.keys()),
"config_path": str(config.config_path) if config.config_path else None,
"port_hint": DEFAULT_PORT,
"auth_enabled": bool(config.agent_access_token),
}
@app.get("/api/list")
def api_list(request: Request, share: str, path: str = "", show_hidden: bool = False) -> dict:
require_agent_auth(request)
target = resolve_share_path(share, path)
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
return {
"share": share.strip(),
"path": path.strip().replace("\\", "/").strip("/"),
"entries": list_directory(target, show_hidden=show_hidden),
}
@app.get("/api/info")
def api_info(request: Request, share: str, path: str = "") -> dict:
require_agent_auth(request)
target = resolve_share_path(share, path)
return info_payload(target, share=share.strip(), raw_path=path)
@app.get("/api/read")
def api_read(request: Request, share: str, path: str = "", max_bytes: int = TEXT_PREVIEW_MAX_BYTES) -> dict:
require_agent_auth(request)
target = resolve_share_path(share, path)
if target.is_dir():
raise_agent_error(409, "type_conflict", "Source must be a file")
if not target.is_file():
raise_agent_error(409, "type_conflict", "Unsupported path type for read")
content_type = text_content_type_for_name(target.name)
if content_type is None:
raise_agent_error(409, "unsupported_type", "File type is not supported for text preview")
return {
"name": target.name,
"path": path.strip().replace("\\", "/").strip("/"),
"content_type": content_type,
**read_text_preview(target, max_bytes=max_bytes),
}
@app.get("/api/download")
def api_download(request: Request, share: str, path: str = "") -> FileResponse:
require_agent_auth(request)
target = resolve_share_path(share, path)
if target.is_dir():
raise_agent_error(409, "type_conflict", "Source must be a file")
if not target.is_file():
raise_agent_error(409, "type_conflict", "Unsupported path type for download")
return FileResponse(
path=target,
media_type=mimetypes.guess_type(target.name)[0] or "application/octet-stream",
filename=target.name,
)
@app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
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) -> JSONResponse:
return JSONResponse(status_code=500, content={"ok": False, "detail": str(exc)})
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.webmanager.remote-client-agent</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>-u</string>
<string>/workspace/webmanager-mvp/finder_commander/remote_client_agent.py</string>
<string>--config</string>
<string>/workspace/webmanager-mvp/finder_commander/remote_client_agent.launchd.json</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/com.webmanager.remote-client-agent.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/com.webmanager.remote-client-agent.err.log</string>
</dict>
</plist>
@@ -0,0 +1,15 @@
{
"agent_access_token": "change-me-agent-token",
"client_id": "",
"display_name": "MacBook Pro van Jan",
"endpoint": "http://192.168.1.25:8765",
"heartbeat_interval_seconds": 20,
"platform": "macos",
"registration_token": "change-me-registration-token",
"shares": {
"downloads": "/Users/jan/Downloads",
"movies": "/Users/jan/Movies",
"pictures": "/Users/jan/Pictures"
},
"webmanager_base_url": "http://127.0.0.1:8080"
}
@@ -0,0 +1,15 @@
{
"agent_access_token": "change-me-agent-token",
"client_id": "",
"display_name": "MacBook Pro van Jan",
"endpoint": "http://192.168.1.25:8765",
"heartbeat_interval_seconds": 20,
"platform": "macos",
"registration_token": "change-me-registration-token",
"shares": {
"downloads": "/Users/jan/Downloads",
"movies": "/Users/jan/Movies",
"pictures": "/Users/jan/Pictures"
},
"webmanager_base_url": "http://127.0.0.1:8080"
}
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
import argparse
import json
import sys
import threading
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib import error, request
from urllib.parse import urlparse
import uvicorn
AGENT_VERSION = "1.1.0-phase1"
@dataclass
class AgentConfig:
config_path: Path
webmanager_base_url: str
registration_token: str
agent_access_token: str
display_name: str
endpoint: str
shares: dict[str, str]
heartbeat_interval_seconds: int
client_id: str
platform: str = "macos"
@property
def normalized_base_url(self) -> str:
return self.webmanager_base_url.rstrip("/")
def load_config(config_path: Path) -> AgentConfig:
raw = json.loads(config_path.read_text(encoding="utf-8"))
client_id = str(raw.get("client_id", "")).strip()
if not client_id:
client_id = str(uuid.uuid4())
raw["client_id"] = client_id
config_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
shares_raw = raw.get("shares") or {}
shares: dict[str, str] = {}
if isinstance(shares_raw, dict):
for key, value in shares_raw.items():
normalized_key = str(key).strip()
normalized_value = str(value).strip()
if normalized_key and normalized_value:
shares[normalized_key] = normalized_value
if not shares:
raise ValueError("config requires at least one share")
return AgentConfig(
config_path=config_path,
webmanager_base_url=str(raw.get("webmanager_base_url", "")).strip(),
registration_token=str(raw.get("registration_token", "")).strip(),
agent_access_token=str(raw.get("agent_access_token", "")).strip(),
display_name=str(raw.get("display_name", "")).strip(),
endpoint=str(raw.get("public_endpoint", raw.get("endpoint", ""))).strip(),
shares=shares,
heartbeat_interval_seconds=max(5, int(raw.get("heartbeat_interval_seconds", 20))),
client_id=client_id,
platform=str(raw.get("platform", "macos")).strip() or "macos",
)
def require_non_empty(value: str, field: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError(f"config field '{field}' is required")
return normalized
def build_register_payload(config: AgentConfig) -> dict[str, Any]:
return {
"client_id": config.client_id,
"display_name": config.display_name,
"platform": config.platform,
"agent_version": AGENT_VERSION,
"endpoint": config.endpoint,
"shares": [{"key": key, "label": key.capitalize()} for key in sorted(config.shares.keys())],
}
def build_heartbeat_payload(config: AgentConfig) -> dict[str, Any]:
return {
"client_id": config.client_id,
"agent_version": AGENT_VERSION,
}
def post_json(url: str, token: str, payload: dict[str, Any]) -> dict[str, Any]:
data = json.dumps(payload).encode("utf-8")
req = request.Request(
url,
method="POST",
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
},
)
with request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
def run_heartbeat_loop(config: AgentConfig, stop_event: threading.Event) -> None:
require_non_empty(config.webmanager_base_url, "webmanager_base_url")
require_non_empty(config.registration_token, "registration_token")
require_non_empty(config.agent_access_token, "agent_access_token")
require_non_empty(config.display_name, "display_name")
require_non_empty(config.endpoint, "public_endpoint")
register_url = f"{config.normalized_base_url}/api/clients/register"
heartbeat_url = f"{config.normalized_base_url}/api/clients/heartbeat"
print(f"Starting remote client agent for {config.display_name} ({config.client_id})", flush=True)
print(f"Using config: {config.config_path}", flush=True)
print("agent_access_token is configured for authenticated agent endpoints", flush=True)
while not stop_event.is_set():
try:
post_json(register_url, config.registration_token, build_register_payload(config))
print("register ok", flush=True)
break
except error.HTTPError as exc:
print(f"register failed: HTTP {exc.code}", file=sys.stderr, flush=True)
except error.URLError as exc:
print(f"register failed: {exc.reason}", file=sys.stderr, flush=True)
if stop_event.wait(config.heartbeat_interval_seconds):
return
while not stop_event.is_set():
try:
post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config))
print("heartbeat ok", flush=True)
except error.HTTPError as exc:
print(f"heartbeat failed: HTTP {exc.code}", file=sys.stderr, flush=True)
except error.URLError as exc:
print(f"heartbeat failed: {exc.reason}", file=sys.stderr, flush=True)
if stop_event.wait(config.heartbeat_interval_seconds):
return
def resolve_bind_host(config: AgentConfig, requested_host: str | None) -> str:
normalized = (requested_host or "").strip()
if normalized:
return normalized
return "0.0.0.0"
def resolve_bind_port(config: AgentConfig, requested_port: int | None) -> int:
if requested_port and requested_port > 0:
return requested_port
parsed = urlparse(config.endpoint)
if parsed.port:
return parsed.port
if parsed.scheme == "https":
return 443
if parsed.scheme == "http":
return 80
return 8765
def run(config: AgentConfig, requested_host: str | None, requested_port: int | None) -> None:
stop_event = threading.Event()
heartbeat_thread = threading.Thread(
target=run_heartbeat_loop,
args=(config, stop_event),
daemon=True,
name="remote-client-heartbeat",
)
heartbeat_thread.start()
bind_host = resolve_bind_host(config, requested_host)
bind_port = resolve_bind_port(config, requested_port)
print(f"Starting HTTP agent on {bind_host}:{bind_port}", flush=True)
print(f"Advertised endpoint: {config.endpoint}", flush=True)
try:
import os
os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config.config_path)
uvicorn.run("app.main:app", host=bind_host, port=bind_port)
finally:
stop_event.set()
heartbeat_thread.join(timeout=2)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Remote client agent Phase 1 for WebManager MVP")
parser.add_argument(
"--config",
default=str(Path(__file__).resolve().with_name("remote_client_agent.example.json")),
help="Path to remote client agent config JSON",
)
parser.add_argument("--host", default="", help="Bind host for the HTTP agent, defaults to 0.0.0.0")
parser.add_argument("--port", type=int, default=0, help="Bind port for the HTTP agent, defaults to endpoint port")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
config = load_config(Path(args.config).resolve())
run(config, requested_host=args.host, requested_port=args.port)
except KeyboardInterrupt:
return 130
except Exception as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import argparse
import os
from pathlib import Path
import uvicorn
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run Finder Commander remote agent HTTP API")
parser.add_argument(
"--config",
required=True,
help="Path to remote agent config JSON",
)
parser.add_argument("--host", default="0.0.0.0", help="Listen host")
parser.add_argument("--port", type=int, default=8765, help="Listen port")
return parser.parse_args()
def main() -> int:
args = parse_args()
config_path = Path(args.config).expanduser().resolve(strict=False)
if not config_path.is_file():
raise SystemExit(f"Config file not found: {config_path}")
os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config_path)
print(f"Using config: {config_path}", flush=True)
uvicorn.run("app.main:app", host=args.host, port=args.port)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
from fastapi import HTTPException
from starlette.requests import Request
from finder_commander.app import main as agent_main
class AgentFileEndpointsTest(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.share_root = Path(self.temp_dir.name) / "Downloads"
self.share_root.mkdir(parents=True, exist_ok=True)
self.outside_root = Path(self.temp_dir.name) / "Outside"
self.outside_root.mkdir(parents=True, exist_ok=True)
self.config_path = Path(self.temp_dir.name) / "agent.json"
self.config_path.write_text(
json.dumps(
{
"agent_access_token": "agent-secret",
"client_id": "client-123",
"display_name": "Jan MacBook",
"shares": {"downloads": str(self.share_root)},
}
),
encoding="utf-8",
)
os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(self.config_path)
agent_main.get_runtime_config.cache_clear()
def tearDown(self) -> None:
os.environ.pop("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", None)
agent_main.get_runtime_config.cache_clear()
self.temp_dir.cleanup()
@staticmethod
def _authorized_request() -> Request:
return Request({"type": "http", "headers": [(b"authorization", b"Bearer agent-secret")]})
def test_info_read_and_download_success(self) -> None:
notes = self.share_root / "notes.md"
notes.write_text("# title\nhello\n", encoding="utf-8")
info_response = agent_main.api_info(self._authorized_request(), share="downloads", path="notes.md")
self.assertEqual(info_response["kind"], "file")
self.assertEqual(info_response["extension"], ".md")
read_response = agent_main.api_read(self._authorized_request(), share="downloads", path="notes.md", max_bytes=4)
self.assertTrue(read_response["truncated"])
self.assertEqual(read_response["content"], "# ti")
download_response = agent_main.api_download(self._authorized_request(), share="downloads", path="notes.md")
self.assertEqual(download_response.media_type, "text/markdown")
self.assertIn('attachment; filename="notes.md"', download_response.headers.get("content-disposition", ""))
def test_unknown_share_and_escape_outside_root_are_rejected(self) -> None:
outside_file = self.outside_root / "secret.txt"
outside_file.write_text("secret", encoding="utf-8")
(self.share_root / "escape.txt").symlink_to(outside_file)
with self.assertRaises(HTTPException) as unknown_share:
agent_main.api_info(self._authorized_request(), share="missing", path="notes.md")
self.assertEqual(unknown_share.exception.status_code, 404)
self.assertEqual(unknown_share.exception.detail["code"], "path_not_found")
with self.assertRaises(HTTPException) as escaped:
agent_main.api_info(self._authorized_request(), share="downloads", path="escape.txt")
self.assertEqual(escaped.exception.status_code, 403)
self.assertEqual(escaped.exception.detail["code"], "path_traversal_detected")
if __name__ == "__main__":
unittest.main()
+60 -2
View File
@@ -54,9 +54,12 @@ Success:
Conflict (`already_exists`) + invalid name (`invalid_request`) gebruiken dezelfde error-shape als mkdir.
### `POST /api/files/delete`
Success:
Success (202):
```json
{ "path": "storage1/parent/file_or_empty_dir" }
{
"task_id": "<uuid>",
"status": "queued"
}
```
Non-empty directory:
@@ -74,6 +77,7 @@ Non-empty directory:
### `POST /api/files/copy`
### `POST /api/files/move`
### `POST /api/files/delete`
Success (202):
```json
{
@@ -82,6 +86,13 @@ Success (202):
}
```
Notes:
- Batch move is supported as one task-based operation via `{ "sources": [...], "destination_base": "..." }`.
- Cross-root batch move is supported for file-only selections.
- Cross-root batch move with any directory in the selection remains unsupported in v1.
- Batch delete is supported as one task-based operation via `{ "paths": [...], "recursive_paths": [...] }`.
- Single delete remains supported via `{ "path": "...", "recursive": true|false }`.
## Tasks read endpoints
### `GET /api/tasks`
@@ -125,6 +136,53 @@ Response shape:
}
```
Voor task-based file-actions `copy`, `move`, `duplicate` en `delete` betekenen progressvelden:
- `done_items`: aantal volledig verwerkte bestanden
- `total_items`: exact aantal te verwerken bestanden in de hele task
- `current_item`: taakrelatief bestandspad als beschikbaar, anders bestandsnaam
Voor `move` geldt een expliciete uitzondering:
- file-gebaseerde move-paden rapporteren file-progress
- same-root directory moves behouden directe rename-semantiek en rapporteren daarom grovere item-progress per directory-operatie
Voor `delete` geldt:
- recursive delete van directorytrees rapporteert file-progress per verwijderd bestand
- lege mappen of directory-only deletes houden `done_items = 0`, `total_items = 0` en gebruiken geen kunstmatige file-teller
### `POST /api/tasks/{task_id}/cancel`
Success for cancellable file-action task:
```json
{
"id": "<uuid>",
"operation": "copy",
"status": "cancelling",
"source": "2 items",
"destination": "storage1/dest",
"done_bytes": null,
"total_bytes": null,
"done_items": 0,
"total_items": 2,
"current_item": "storage1/a.txt",
"failed_item": null,
"error_code": null,
"error_message": null,
"created_at": "2026-03-10T10:00:00Z",
"started_at": "2026-03-10T10:00:01Z",
"finished_at": null
}
```
Not cancellable:
```json
{
"error": {
"code": "task_not_cancellable",
"message": "Task cannot be cancelled",
"details": { "task_id": "<uuid>", "status": "completed" }
}
}
```
Task not found:
```json
{
@@ -0,0 +1,190 @@
# Batch Directory Move v1
## 1. Scope
Doel van batch directory move v1 is het gecontroleerd ondersteunen van verplaatsingen voor meerdere geselecteerde items via de bestaande move/task-flow, zonder de huidige file-flow te destabiliseren.
Aanbevolen v1-scope:
- alleen same-root batch move
- meerdere directories ondersteunen
- gemengde selectie van files + directories ondersteunen, maar alleen binnen dezelfde root
- geen cross-root batch directory move
- geen rollback
- geen recursive copy-delete move voor directories
- geen partial rename-semantiek in batchmodus
Niet in scope:
- cross-root batch directory move
- batch move met meerdere roots tegelijk
- batch move met conflictoplossing UI
- cancel/retry
- rollback bij gedeeltelijk succes
- hernoemen per item binnen batch-popup
## 2. Selectiesituaties
### Meerdere directories
- Toegestaan in v1, mits alle geselecteerde directories binnen dezelfde root vallen.
- Bestemming is de current path van het inactieve paneel.
- Elk item krijgt als doelpad: `destination_base + / + item.name`.
### Meerdere files + directories gemengd
- Toegestaan in v1, mits alle geselecteerde items binnen dezelfde root vallen.
- Files en directories worden in dezelfde batch verwerkt.
- Elk item gebruikt dezelfde destination-map semantiek.
### 1 directory + meerdere files
- Valt onder gemengde selectie en is dus toegestaan onder dezelfde same-root-regels.
### Niet toegestaan in v1
- selectie die items uit verschillende roots combineert
- selectie waarbij voor een directory het doelpad in de eigen subtree valt
- selectie met symlink-source-items
- selectie waarbij een doelpad al bestaat
## 3. Same-root versus cross-root
Aanbevolen v1-keuze:
- batch directory move blijft beperkt tot same-root
- cross-root batch directory move wordt expliciet geblokkeerd
Motivatie:
- same-root kan native rename/move gebruiken en sluit aan op de huidige directory move v1
- cross-root voor directories vereist recursieve copy + delete en introduceert veel meer partial-failure-risico
- gemengde batch over roots maakt progress, foutafhandeling en rollback aanzienlijk complexer
Geblokkeerde melding in v1:
- `Cross-root batch directory move is not supported in v1`
## 4. Taskmodel
Aanbevolen richting:
- één task voor de hele batch
Motivatie:
- sluit beter aan op de gebruikersactie: één batch-confirmatie, één batchresultaat
- voorkomt een explosie van losse tasks bij grote selecties
- maakt batchprogress en foutsamenvatting eenvoudiger zichtbaar
Progress:
- `done_items` / `total_items` zijn leidend
- `total_items` = aantal geselecteerde top-level items in de batch
- `done_items` telt per succesvol verplaatst top-level item op
- `done_bytes` / `total_bytes` blijven `null` in v1 voor batch directory move
- ook bij gemengde selectie is het verstandig in v1 item-gebaseerde progress leidend te houden
Failure-semantiek:
- fail-fast binnen de batchtask
- bij eerste runtime-fout stopt verdere verwerking
- task eindigt als `failed`
- reeds verplaatste items blijven verplaatst
- geen rollback in v1
Partial completion model:
- partial completion wordt impliciet zichtbaar via `done_items < total_items`
- `failed_item` bevat het item waarop de batch stopte
- geen aparte `partial_completed` status in v1
## 5. Backend-impact
Hergebruik:
- bestaande move-validatie voor individuele file/directory move
- bestaande task persistence en read-endpoints
- bestaande same-root native move in filesystem adapter
- bestaande path_guard containment en symlink-afwijzing
Waarschijnlijke wijzigingen:
- move service: batchvalidatie en task-creatie voor meerdere items
- task runner: batch move worker
- filesystem adapter: hergebruik van bestaande file move en directory move primitives
- task repository: waarschijnlijk geen schemawijziging nodig als `done_items`, `total_items`, `current_item`, `failed_item` al volstaan
Belangrijk semantisch behoud:
- rename voor exact 1 item blijft apart via bestaande rename-flow/F6-popupbeslislogica
- batchmodus is altijd move, nooit rename
## 6. UI-impact
Gedrag voor Move-knop en F6:
- bij meerdere geselecteerde items mag de bestaande batch move-confirmatie worden hergebruikt
- die confirmatie moet duidelijk tonen:
- aantal geselecteerde items
- destination-map
- dat batch same-root vereist voor directories
- voor gemengde selectie moet dezelfde batch-confirmatie bruikbaar blijven
Benodigde meldingen:
- `Cross-root batch directory move is not supported in v1`
- `Batch move requires all selected items to be in the same root`
- `Destination already exists for one or more items`
- `Destination cannot be inside source`
- `Source must not be a symlink`
Aanbeveling:
- hergebruik bestaande batch confirm-popup
- geen extra popup-type toevoegen in v1
- alleen de validatietekst en submit-logica uitbreiden
## 7. Security en validatie
Per item moeten minimaal deze checks gelden:
- source ligt binnen whitelist
- destination-parent ligt binnen whitelist
- source is geen symlink
- destination bestaat nog niet
- directory destination ligt niet in subtree van source
- same-root verplicht voor directories in batch v1
Gemengde foutgevallen:
- als selectie meerdere roots bevat: directe validatiefout vóór task-creatie
- als één item ongeldig is: hele batchcreatie afwijzen vóór task-creatie
- geen "best effort" pre-validatie waarbij geldige items toch alvast starten
Containment:
- path_guard blijft leidend voor alle bron- en doelpaden
- geen vrije pathconstructie buiten bestaande root mapping
## 8. Teststrategie
Golden tests:
- batch same-root directories success
- batch same-root mixed files + directories success
- batch cross-root directories blocked
- batch mixed-root selection blocked
- batch destination exists blocked
- batch destination inside source blocked
- batch symlink source blocked
- batch task failed shape bij runtime io_error
Regressietests:
- exacte 1 file move-flow blijft ongewijzigd
- exacte 1 directory same-root move blijft werken
- batch file-only move blijft werken
- F6 rename/move voor exact 1 item blijft semantisch gelijk
Securitytests:
- traversal in één van de source paths
- traversal in destination base
- symlink directory source
- symlink file source binnen gemengde selectie
Runtime edge cases:
- eerste item succeeds, tweede faalt -> task failed met `done_items == 1`
- destination conflict ontdekt vóór task-creatie
- empty selection blijft no-op in UI, geen backend-aanroep
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- ondersteun alleen same-root batch move
- ondersteun gemengde selectie van files + directories alleen als alle items in dezelfde root zitten
- gebruik één task per batch
- gebruik item-based progress (`done_items` / `total_items`)
- fail-fast zonder rollback
- hergebruik bestaande batch move-confirmatie in de UI
Waarom deze richting:
- bouwt rechtstreeks voort op de huidige same-root directory move v1 en batch file move
- beperkt backendcomplexiteit tot validatie + batchrunner, zonder cross-root recursieve directorylogica
- houdt de UI voorspelbaar: één batchactie, één task, één resultaat
- minimaliseert regressierisico voor de bestaande single-item file-flow en F6-semantiek
+247
View File
@@ -0,0 +1,247 @@
# Built-in Themes v1.1
## 1. Theme families voor v1
Aanbevolen built-in theme families voor v1.1:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
Deze set is klein genoeg om beheersbaar te blijven en groot genoeg om visueel zinvolle keuze te bieden zonder een theme-explosie te veroorzaken.
## 2. LCARS
`lcars` hoort expliciet niet in v1.1.
Aanbevolen behandeling:
- niet opnemen in deze slice
- later als aparte v2 onderzoeken
Reden:
- `lcars` is visueel extreem uitgesproken
- hoger risico op regressie in leesbaarheid, functiebalkgebruik, row states, modals en paneelcontrast
- grotere kans dat componentstructuur visueel gaat knellen tegen de huidige dual-pane workflow
Conclusie:
- `lcars` beter behandelen als aparte latere theme-slice met eigen UX-validatie
## 3. Theme-model
Het bestaande model blijft leidend:
- `selected_theme` = theme family
- `selected_color_mode` = `dark` of `light`
Beide blijven persistent opgeslagen in SQLite settings.
Voorbeelden van effectieve combinaties:
- `default-dark`
- `default-light`
- `macos-soft-dark`
- `macos-soft-light`
- `midnight-dark`
- `graphite-light`
- `windows11-dark`
Belangrijk:
- theme family en color mode blijven twee gescheiden concepten
- de hoofdinterface-toggle blijft alleen `selected_color_mode` wisselen
- `Settings > Interface` blijft alleen `selected_theme` beheren
## 4. CSS-architectuur
Voorkeursrichting voor v1.1:
- één gedeelde `base.css` voor:
- layout
- spacing
- componentstructuur
- modals
- panelen
- functiebalk
- lijst/tabelstructuur
- algemene componentbasis
- aparte CSS-bestanden per theme-family:
- `theme-default.css`
- `theme-macos-soft.css`
- `theme-midnight.css`
- `theme-graphite.css`
- `theme-windows11.css`
- light/dark binnen dezelfde family geregeld via selectors of tokens
Dus expliciet niet:
- één enorm all-in-one CSS-bestand met alles door elkaar
- per `theme+mode` een volledig apart layoutbestand
Aanbevolen structuur:
- `base.css`
- `theme-default.css`
- `theme-macos-soft.css`
- `theme-midnight.css`
- `theme-graphite.css`
- `theme-windows11.css`
Binnen elk theme-family bestand:
- alleen tokens en beperkte theme-afwerking
- geen duplicatie van layoutregels
- geen alternatieve componentstructuur
Voorbeeldselector-richting:
- `:root[data-theme-family="macos-soft"][data-color-mode="dark"] { ... }`
- `:root[data-theme-family="macos-soft"][data-color-mode="light"] { ... }`
Of equivalent via custom properties.
## 5. Waarom deze architectuur onderhoudbaarder is
Deze architectuur is onderhoudbaarder omdat hij drie dingen schoon van elkaar scheidt:
- wat de UI is: layout en componentstructuur in `base.css`
- welke stijl-family actief is: family-bestand
- welke kleurmodus actief is: dark/light tokens binnen die family
Voordelen:
- layout-CSS staat op één plek
- theme-bestanden blijven klein en thematisch leesbaar
- regressies zijn makkelijker te isoleren per family
- nieuwe family toevoegen vereist minder risico op breken van bestaande layout
- dark/light binnen één family blijft samenhangend beheerd
Hoe duplicatie van layout-CSS voorkomen wordt:
- `base.css` bevat alle structurele regels
- family-bestanden overschrijven alleen tokens en kleine visuele accenten
- geen herhaling van paneelgrid, modal layout, functiebalkstructuur of lijstlayout per family
Hoe theme switching schoon blijft:
- frontend hoeft alleen `selected_theme` en `selected_color_mode` te lezen
- die waarden vertalen naar theme-attributen op `document.documentElement`
- CSS doet de rest via selectors/custom properties
- geen runtime CSS-generatie nodig
- geen vrije bestandsselectie
- geen ingewikkelde asset-resolutie
## 6. Visuele richting per theme
Alle themes behouden exact dezelfde layout en informatiearchitectuur. Alleen visuele stijl verschilt.
### `default`
Doel:
- neutrale baseline
- functioneel
- rustig
- compact
Karakter:
- de huidige standaardstijl, iets verfijnd maar niet uitgesproken
### `macos-soft`
Doel:
- zacht
- verfijnd
- vriendelijk
Karakter:
- subtiele surfaces
- zachte grijstinten
- lichte premium desktop-app indruk
- iets vriendelijkere rounding/shadows, zonder layout te veranderen
### `midnight`
Doel:
- donker
- gefocust
- rustig
Karakter:
- diepere donkere panelen
- koele accenten
- sterke maar nette current/selected contrasten
- geschikt voor langdurig gebruik in dark mode
### `graphite`
Doel:
- sober
- professioneel
- bijna monochroom
Karakter:
- grijs-gedreven palette
- minimale accentkleur
- contrast via luminantie in plaats van felle tinten
### `windows11`
Doel:
- helder
- modern
- clean desktop-app gevoel
Karakter:
- lichtere surfaces
- subtiele border/surface scheiding
- iets luchtiger accentgebruik
- behoud van compacte file-manager ergonomie
## 7. Settings UI
`Settings > Interface` toont een dropdown/select met de theme families:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
Dark/light blijft via de bestaande toggle in de hoofdinterface.
Dus:
- Interface tab = keuze van style family
- Main interface toggle = keuze van color mode
Geen extra theme-complexiteit in v1.1:
- geen preview gallery
- geen import/export
- geen vrije CSS-keuze
- geen uploads
## 8. Backend-impact
Backend-aanpassing blijft klein:
- whitelist van `selected_theme` uitbreiden met:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
- `selected_color_mode` blijft bestaan zoals nu
- geen vrije css-bestandskeuze
- geen uploadmechanisme
- geen nieuwe dependencies
De bestaande settings-opslag en settings-API kunnen verder hetzelfde model blijven gebruiken.
## 9. Regressierisico
Belangrijkste risicos:
- leesbaarheid per theme/mode
- current row / selected row contrast te zwak of te hard
- modals die in bepaalde families te vlak worden
- functiebalk die visueel wegvalt
- editor/viewers die niet goed mee themen
- thumbnail/icon-slot met te weinig contrast
- CSS-fragmentatie als family-bestanden toch structurele regels gaan bevatten
- duplicatie als layout-regels alsnog in family-bestanden belanden
Belangrijk mitigatieprincipe:
- family-bestanden beperken tot visuele tokens en kleine afwerkingsregels
- geen layout-overrides per family
- consistent regressietesten van dezelfde states in alle families
## 10. Aanbeveling
Aanbevolen richting voor deze app:
- ja, `base.css` + aparte CSS per theme-family is de juiste richting
Waarom:
- laag regressierisico
- duidelijke scheiding tussen structuur en uiterlijk
- onderhoudbaar bij toekomstige uitbreiding
- sluit aan op het bestaande model van `selected_theme` + `selected_color_mode`
- voorkomt zowel een gigantisch all-in-one stylesheet als onnodige duplicatie per `theme+mode`
Expliciete aanbevelingen:
- gebruik gedeelde basis-CSS voor layout/componentstructuur
- gebruik aparte CSS per theme-family
- regel dark/light binnen dezelfde family via selectors/tokens
- behandel `lcars` expliciet als aparte latere slice
Conclusie:
- deze architectuur is de juiste basis voor built-in themes in deze app
- `lcars` moet niet worden meegetrokken in v1.1, maar apart worden ontworpen en gevalideerd
+284
View File
@@ -0,0 +1,284 @@
# Built-in Themes v1
## 1. Doel
Built-in themes voegen nu waarde toe omdat de webui functioneel volwassen genoeg is om visuele voorkeuren relevant te maken zonder dat de workflow eerst nog instabiel is. De huidige app heeft al een vaste dual-pane structuur, modals, functiebalk, viewers en editorflow. Dat maakt het logisch om stijlvarianten toe te voegen zolang de interactie en informatiearchitectuur gelijk blijven.
Dit past goed binnen de bestaande `Settings > Interface` structuur, omdat theme-keuze daar een stabiele, globale UI-voorkeur is. De bestaande scheiding tussen theme-family en dark/light mode blijft daarbij bruikbaar:
- `selected_theme` = stijlset / family
- `selected_color_mode` = `dark` of `light` binnen die family
## 2. Scope
Built-in theme families voor v1:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
Expliciet niet in v1:
- `lcars`
- vrije theme-bestanden
- upload of filesystem picker
- layoutvarianten per theme
- component-specifieke thema-engine buiten CSS tokens/selectors
`lcars` hoort beter in een latere v2-slice. Reden: het is visueel extreem uitgesproken, legt druk op contrast, spacing, functiebalkleesbaarheid en waarschijnlijk ook op de dual-pane ergonomie. Dat is een hoger UX-risico dan de rustige families hierboven.
## 3. Theme-model
Het bestaande settingsmodel blijft leidend:
- `selected_theme`: theme-family key
- `selected_color_mode`: `dark` of `light`
Elke built-in family ondersteunt beide modi:
- `default-light`
- `default-dark`
- `macos-soft-light`
- `macos-soft-dark`
- `midnight-light`
- `midnight-dark`
- `graphite-light`
- `graphite-dark`
- `windows11-light`
- `windows11-dark`
De frontend combineert beide settings tot de effectieve UI-state, bijvoorbeeld via een attribuut zoals:
- `data-theme="macos-soft-light"`
- of combinatie van `data-theme-family` + `data-color-mode`
Aanbevolen voor v1: beide attributen zetten.
Reden: duidelijkere CSS-structuur en minder fragiele string-parsing in selectors.
Aanbevolen HTML-state:
- `data-theme-family="macos-soft"`
- `data-color-mode="dark"`
## 4. CSS-architectuur
Aanbevolen richting:
- een gedeelde `base.css` of equivalent voor:
- layout
- spacing
- componentstructuur
- modals
- panelen
- functiebalk
- tabel/lijststructuur
- algemene componentbasis
- aparte CSS-bestanden per theme-family:
- `theme-default.css`
- `theme-macos-soft.css`
- `theme-midnight.css`
- `theme-graphite.css`
- `theme-windows11.css`
- light/dark binnen dezelfde family regelen met selectors en tokens in dat family-bestand
Voorbeeldrichting:
- `base.css`
- `theme-default.css`
- `theme-macos-soft.css`
- `theme-midnight.css`
- `theme-graphite.css`
- `theme-windows11.css`
In elk family-bestand staan alleen tokens en beperkte theme-specifieke afwerkingen, bijvoorbeeld:
- background colors
- surface colors
- border colors
- accent colors
- selection/current row tuning
- shadow/radius tuning waar nodig
Niet in theme-bestanden:
- grid-structuur
- flex-layout van panelen
- componentmarkup-afhankelijke layoutlogica
- duplicatie van modals/paneel/functiebalk CSS
Waarom dit onderhoudbaarder is dan één groot CSS-bestand:
- theme-logica blijft per family lokaal leesbaar
- layout en componentstructuur blijven centraal
- minder kans dat een nieuwe family per ongeluk core layout overschrijft
- eenvoudiger regressietesten per family
- duidelijkere grens tussen “wat is de UI” en “hoe ziet de UI eruit”
Waarom ook niet per theme+mode volledig losse bestanden:
- te veel duplicatie
- onnodig onderhoud van dark/light varianten
- grotere kans op drift tussen light en dark binnen dezelfde family
## 5. Visuele richting per theme
Alle themes behouden exact dezelfde layout en componentstructuur. Alleen styling verschilt.
### `default`
Huidige neutrale baseline.
- rustig
- compact
- functioneel
- donkere modus als primaire baseline
- lichte modus als nette tegenhanger
### `macos-soft`
Doel: zachter, verfijnder, subtiel premium.
- lichtere surfaces
- subtiele separators
- iets zachtere contrasten
- afgeronde panelen/modals iets vriendelijker, maar niet groter
- ingetogen blauw/grijs accent
### `midnight`
Doel: donker, gefocust, licht dramatisch maar nog rustig.
- diepe donkere oppervlakken
- koele blauwe accenten
- duidelijke current row / selected row contrasten
- geschikt voor langdurig gebruik in donkere modus
### `graphite`
Doel: sober, professioneel, bijna monochroom.
- neutraal grijs systeem
- minimale accentkleur
- contrast via value shifts in plaats van kleurigheid
- goed voor gebruikers die een stille UI willen
### `windows11`
Doel: helder, modern, iets luchtiger.
- zachtere paneelsurfaces
- subtiele border+surface lagen
- lichtblauw accent
- iets meer "clean desktop app" gevoel zonder de layout te veranderen
## 6. Settings UI
`Settings > Interface` toont een dropdown/select met alleen de built-in theme families:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
Dark/light blijft in de hoofdinterface via de bestaande toggle.
Die toggle blijft dus een snelle dagelijkse keuze voor kleurmodus, niet voor theme-family.
Geen extra complexiteit in v1:
- geen preview gallery
- geen screenshot previews
- geen themetekstblokken met uitgebreide beschrijvingen
- geen extra subinstellingen per theme
## 7. Backend-impact
Backend-aanpassing is beperkt:
- whitelist voor `selected_theme` uitbreiden van alleen `default` naar:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
- `selected_color_mode` blijft:
- `dark`
- `light`
- settings-opslagmodel blijft verder gelijk
- geen nieuwe dependency
- geen vrije filesystemtoegang
Dit is laag risico omdat alleen validatie van settings-uitbreiding nodig is; de settings-API en SQLite-opslag bestaan al.
## 8. Frontend-impact
Aanbevolen organisatie:
- `base.css` blijft altijd geladen
- alle family-bestanden worden ook geladen, maar zijn strikt gescoped op theme selectors
- of dynamisch geladen/swapped, als dat later nodig blijkt
Aanbevolen v1-richting: alle theme CSS-bestanden statisch laden, maar strikt scopen.
Reden:
- eenvoudiger startup
- minder runtime asset-wisselcomplexiteit
- minder kans op flash of incomplete styling
- aanvaardbaar zolang het aantal families klein blijft
Selector-richting:
- `:root[data-theme-family="macos-soft"][data-color-mode="dark"] { ... }`
- `:root[data-theme-family="macos-soft"][data-color-mode="light"] { ... }`
Of equivalent met custom properties:
- family-bestanden vullen tokens op basis van family+mode
- `base.css` gebruikt alleen tokens
Aanbevolen toepassing:
- startup leest `selected_theme` en `selected_color_mode`
- zet beide attributen vroeg op `document.documentElement`
- bestaande toggle wijzigt alleen `data-color-mode`
- Interface settings wijzigen alleen `data-theme-family`
Dit houdt startup en theme-switching schoon en voorspelbaar.
## 9. Regressierisico
Belangrijkste risicos:
- leesbaarheid en contrast per family/mode
- current row / selected row onvoldoende onderscheid
- actieve paneelrand te zwak of te dominant
- modals en functiebalk die in sommige themes te vlak worden
- thumbnail/icon-slot die wegvalt tegen achtergrond
- CSS-fragmentatie als family-bestanden toch layoutregels gaan bevatten
- editor/viewers die visueel uit de toon vallen als tokens niet breed genoeg zijn
Belangrijk mitigatieprincipe:
- theme-bestanden mogen alleen token- en lichte afwerkingsverschillen bevatten
- geen layout overrides per family
- smoke-validatie en handmatige check op alle states:
- normal row
- current row
- selected row
- current+selected
- inactive selected
- modal
- functiebalk
- editor/viewers
## 10. Teststrategie
### Backend golden tests
- whitelist accepteert alle built-in themes
- ongeldige theme key blijft geblokkeerd
- `selected_color_mode` gedrag blijft intact
- fallback naar `default` en `dark` blijft correct
### UI smoke/regressietests
- `Settings > Interface` bevat alle built-in theme opties
- startup leest theme + color mode uit backend
- hoofdinterface dark/light toggle blijft bestaan
- data-attributen of equivalent theme-state worden correct toegepast
- modals, functiebalk en panelen blijven renderen onder theme-switches
### Handmatige validatie
Per family in light en dark:
- panel readability
- current row zichtbaarheid
- selected row zichtbaarheid
- inactive pane selectie
- viewer/editor modal contrast
- thumbnail/icon-slot contrast
- functiebalk leesbaarheid
- settings modal tabs en form controls
## 11. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- built-in themes alleen als veilige whitelist keys
- families:
- `default`
- `macos-soft`
- `midnight`
- `graphite`
- `windows11`
- `lcars` expliciet uitstellen naar een aparte latere theme-slice
- architectuur:
- gedeelde `base.css`
- aparte CSS per theme-family
- dark/light binnen elke family via selectors/tokens
Expliciete beoordeling van de voorgestelde architectuur:
- `gedeelde base.css`
- `aparte CSS per theme-family`
Dit is de juiste richting voor deze app.
Reden:
- houdt layout en theming schoon gescheiden
- voorkomt één onleesbaar gigantisch CSS-bestand
- voorkomt ook duplicatie van complete layoutbestanden per theme+mode
- past goed bij de bestaande settings-architectuur met `selected_theme` + `selected_color_mode`
- blijft onderhoudbaar als later nog 1-3 families bijkomen
+308
View File
@@ -0,0 +1,308 @@
# DIRECTORY_MOVE_V1_DESIGN.md
## 1. Scope
Doel van Directory Move v1 is om directory-verplaatsing toe te voegen binnen het bestaande move/task/securitymodel, zonder het huidige veiligheidsniveau of de voorspelbaarheid van de backend te verzwakken.
In scope voor v1:
- directory move als expliciete uitbreiding van de bestaande move-operatie
- task-based uitvoering
- conflictcontrole op destination
- beveiligde padvalidatie via bestaand `path_guard`
- duidelijke foutstatussen bij ongeldige of gevaarlijke moves
Out of scope voor v1:
- geen rollbackmechanisme
- geen cancel/retry
- geen gedeeltelijke recovery van half-voltooide cross-root moves
- geen symlink-following buiten whitelist
- geen speciale merge- of overwrite-semantiek
- geen multi-select directory move als aparte batch-semantiek buiten bestaande sequentiële task-aanmaak
---
## 2. Same-root versus cross-root
### Directory move binnen dezelfde root
Dit is de veiligste en simpelste stap.
Kenmerken:
- kan meestal native via `rename()`/`move` op directoryniveau
- snel en atomair binnen hetzelfde filesystem/root
- geen inhoudelijke recursieve file copy nodig
- veel minder kans op partial failure
### Directory move tussen verschillende roots
Dit is aanzienlijk complexer.
Kenmerken:
- vereist recursieve copy van directory tree
- daarna delete van source tree
- progressmeting en foutafhandeling worden lastiger
- partial failure is realistischer
- rollback is ingewikkeld en in v1 ongewenst
### Aanbevolen scopekeuze
Aanbevolen v1:
- **alleen same-root directory move ondersteunen**
- **cross-root directory move nog niet ondersteunen**
Reden:
- sluit goed aan op bestaand taskmodel zonder grote complexiteitsexplosie
- beperkt regressierisico
- voorkomt een half-robuste recursieve copy+delete-implementatie zonder rollback
---
## 3. Semantiek
### Wanneer is het een echte move
Directory move is van toepassing als:
- source een directory is
- destination een volledig doelpad is
- destination nog niet bestaat
Binnen same-root:
- de operatie gebruikt native rename/move op directoryniveau
- dit geldt zowel voor verplaatsen naar andere parent als voor directory move binnen dezelfde root met andere naam
### Destination bestaat al
Gedrag v1:
- destination mag niet bestaan
- response: `already_exists`
Geen merge in v1.
### Nested destinations
Verboden gevallen:
- source naar een child van zichzelf
- source naar een pad in eigen subtree
Voorbeeld:
- source: `/Volumes/8TB/Shows`
- destination: `/Volumes/8TB/Shows/Archive/Shows`
Dit moet direct geblokkeerd worden.
Aanbevolen fout:
- `invalid_request` met duidelijke boodschap zoals `Destination cannot be inside source`
### Verplaatsen van map in zichzelf
Ook verboden:
- destination exact gelijk aan source
- destination onder source subtree
Dit moet vóór task-creatie worden afgewezen.
---
## 4. Taskmodel
Directory move blijft task-based, ook voor same-root moves.
### Progressmeting
Voor same-root native directory move is echte byte-progress meestal niet beschikbaar.
Aanbevolen v1:
- gebruik vooral `done_items` / `total_items`
- voor same-root kan dat minimaal zijn:
- `total_items = 1`
- `done_items = 0 -> 1`
- `done_bytes` / `total_bytes` mogen `null` blijven in v1 voor same-root directory move
### Fail-fast gedrag
Aanbevolen v1:
- fail-fast
- zodra de move-operatie faalt, task -> `failed`
### Partial failure
Voor same-root native directory move is partial failure veel minder waarschijnlijk.
- of de rename slaagt
- of de rename faalt
Dus v1-model:
- geen expliciete partial-successstatus
- eindstatus blijft:
- `completed`
- `failed`
### Rollback
Geen rollback in v1.
Voor same-root native rename is dat meestal niet nodig.
Voor cross-root zou rollback wel relevant worden, maar die scope wordt niet aanbevolen voor v1.
---
## 5. Security
### Symlinkgedrag in directory trees
Aanbevolen v1-beleid:
- source directory zelf mag geen symlink zijn
- bij same-root native rename wordt de tree niet recursief gevolgd of gekopieerd, dus child-symlinks hoeven niet actief gevolgd te worden voor uitvoering
- toch blijft containmentcontrole op source en destination verplicht
### Traversal en containment
- bron- en destination-pad blijven via bestaand `path_guard`
- destination binnen whitelist verplicht
- destination mag niet buiten root escapen
- nested destination-in-source moet expliciet extra gevalideerd worden
### PathGuard-impact
Bestaand `path_guard` blijft basis voor:
- whitelistvalidatie
- traversalblokkade
- symlink containment check op bron/destination-resolutie
Aanvullend nodig voor directory move:
- helper of servicecheck om te bepalen of destination in subtree van source ligt
### Escapes buiten whitelist voorkomen
Bij same-root-only v1:
- containment blijft relatief eenvoudig
- geen recursieve copy over child nodes nodig
- dus ook minder attack surface dan cross-root recursieve tree copy
---
## 6. Backend-impact
Waarschijnlijk te wijzigen delen:
- `webui/backend/app/services/move_task_service.py`
- `webui/backend/app/tasks_runner.py`
- `webui/backend/app/fs/filesystem_adapter.py`
- mogelijk `webui/backend/app/api/schemas.py` alleen als taskdetail-documentatie wordt aangescherpt
- mogelijk kleine aanvulling in `path_guard.py` of move-service validatiehelpers
### Bestaande move-logica die hergebruikt kan worden
Herbruikbaar:
- task-creatie
- repository-persistency
- statusflow `queued -> running -> completed/failed`
- destination-exists checks
- parent directory validation
- algemene error mapping
Nieuw voor directory move:
- source typecheck moet directory toestaan in same-root-case
- same-root task runner moet directory rename kunnen uitvoeren
- nested-destination-validatie
### Rename binnen zelfde parent blijft apart
Ja.
Aanbevolen scheiding:
- `rename` endpoint blijft aparte, directe single-path operatie
- `move` blijft task-based voor echte moves
- ook als een same-root directory move technisch op rename lijkt, blijft het semantisch onderdeel van `move`
Dat houdt UI- en API-rollen duidelijk.
---
## 7. UI-impact
### Move-knop en F6 bij exact 1 directory
Aanbevolen gedrag:
- F6 / Move-knop blijven dezelfde popupflow gebruiken
- exact 1 directory geselecteerd:
- zelfde parent + andere naam -> huidige `rename` route blijft toegestaan
- andere parent binnen dezelfde root -> task-based directory move toegestaan in v1
- cross-root destination -> blokkeren met duidelijke melding, bijvoorbeeld:
- `Cross-root directory move is not supported in v1`
### Multi-select met directories
Aanbevolen v1:
- geen gemengde halfslimme batchflow
- als multi-select directories bevat:
- alleen toestaan als alle geselecteerde directories voldoen aan same-root move-semantiek, of
- eenvoudiger en veiliger voor v1: nog blokkeren met duidelijke melding
Aanbevolen eerste stap:
- **multi-select met directories nog blokkeren**
- melding:
- `Batch directory move is not supported in v1`
Reden:
- beperkt scope en regressierisico
- houdt UI-flow voorspelbaar
---
## 8. Teststrategie
### Golden tests
Toe te voegen voor move-API:
- same-root directory move success
- directory destination exists -> `already_exists`
- directory source not found
- directory source is symlink -> blokkade
- nested destination blocked
- exact same source/destination blocked
- cross-root directory move blocked
### Regressietests
- file move success same-root blijft werken
- file move success cross-root blijft werken als huidige scope dat al ondersteunt
- rename endpoint blijft ongewijzigd
- browse en delete blijven ongewijzigd
### Securitytests
- traversal source
- traversal destination
- symlink source rejection
- destination inside source rejection
- destination outside whitelist rejection
### Runtime edge cases
- rename/move van lege directory
- rename/move van directory met inhoud binnen same-root
- permission failure -> `io_error`
- destination parent ontbreekt -> bestaande foutmapping
---
## 9. Aanbeveling
Aanbevolen v1-richting:
- **alleen same-root directory move ondersteunen**
- **cross-root directory move nog niet ondersteunen**
Korte motivatie:
- laagste complexiteit
- beste aansluiting op bestaand taskmodel
- minimale partial-failure kans
- veel kleiner regressie- en securityrisico dan recursieve cross-root tree copy+delete
Praktische v1-scope:
- exact 1 directory
- same-root move task-based
- nested destination geblokkeerd
- destination exists geblokkeerd
- cross-root directory move expliciet geweigerd
- batch directory move nog niet
Dat is de veiligste eerste stap met duidelijke semantiek en goede testbaarheid.
+170
View File
@@ -0,0 +1,170 @@
# File Info v1 Design
## 1. Doel
File info voegt nu waarde toe omdat de huidige UI sterk gericht is op navigatie en acties, maar nog weinig context geeft over het geselecteerde item zelf. Een compacte read-only infomodal helpt bij controle vóór rename/move/delete, bij het onderscheiden van vergelijkbare items, en bij snelle inspectie van een directory of bestand zonder de workspace te verlaten.
Dit past goed binnen de dual-pane workflow zolang het een lichte, tijdelijke overlay blijft en geen extra paneel of blijvende schermruimte vraagt.
## 2. Startgedrag
Aanbevolen v1-gedrag:
- Mac: `Cmd+Enter`
- Windows/Linux: `Ctrl+Enter`
- Alleen actief bij exact 1 geselecteerd item
- Geen extra zichtbare knop in topbar of functiebalk in v1
Reden:
- De interface blijft rustig
- De feature blijft beschikbaar voor power users
- Het voorkomt nieuwe visuele drukte in de bestaande workspace
Latere uitbreiding:
- Een functiebalkknop zoals `Info` of `F9` kan later logisch zijn, maar hoort niet in deze slice
## 3. Scope
In scope voor v1:
- Exact 1 file
- Exact 1 directory
- Read-only modal
- Geen acties vanuit de modal
- Geen thumbnails
- Geen preview, edit of playback in deze modal
Niet in scope:
- Multi-select info
- Bestandsinhoud tonen
- Directory tree analyse
- Checksums/hashes
- ACL/permissions-editor
## 4. Minimale informatievelden
Aanbevolen minimale velden in v1:
- `name`
- `path`
- `type`
- `size`
- `modified`
- `root`
- `extension` voor files waar zinvol
- `content_type` voor files waar zinvol
- `owner`
- `group`
Toelichting:
- `name`, `path`, `type`, `modified` en `root` zijn bijna altijd nuttig
- `size` is nuttig voor files; voor directories alleen als veilig/goedkoop beschikbaar
- `extension` is nuttig voor file-context
- `content_type` is nuttig als lichte afgeleide metadata, niet als zware contentanalyse
- `owner/group` is nuttig voor troubleshooting op mounted storage
## 5. Directory-info
Veilige v1-richting voor directories:
- Toon:
- naam
- pad
- type = directory
- modified time
- root/context
- owner/group indien beschikbaar
- Toon niet standaard:
- recursieve grootte
- totale child count via diepe scan
Aanbeveling:
- Geen recursieve directorygrootte in v1
- Geen child count tenzij die goedkoop via directe listing kan worden opgehaald en duidelijk als shallow count wordt gelabeld
Motivatie:
- Grote trees kunnen duur zijn
- Dit verhoogt regressierisico en latentie zonder kernwaarde voor v1
## 6. Backend-impact
Aanbevolen backendrichting:
- Nieuw read-only endpoint, bijvoorbeeld:
- `GET /api/files/info?path=...`
Herbruik bestaande infrastructuur:
- `path_guard` voor alle padvalidatie
- bestaande whitelist/root containment
- bestaande not-found/type/security foutmapping
- bestaande filesystem-adapter voor `stat`-achtige metadata
Veiligheidsmodel:
- Alleen metadata lezen
- Geen filesystem-mutatie
- Geen directory traversal buiten whitelist
- Geen volgen van escapes buiten toegestane roots
Waarschijnlijk benodigde backendvelden in response:
- `name`
- `path`
- `type`
- `size`
- `modified`
- `root`
- `extension`
- `content_type`
- `owner`
- `group`
## 7. Frontend-impact
Aanbevolen UI-richting:
- Aparte info-modal
- Geen hergebruik van de tekst-viewer of edit-modal
- Zelfde lichte modalstructuur als bestaande modals, voor consistentie
Gedrag:
- Open via `Cmd+Enter` of `Ctrl+Enter`
- Sluiten via `X` en `Escape`
- Terwijl modal open is, geen paneelkeyboardnavigatie
- Geen interferentie met gewone `Enter`
Samenwerking met bestaande openflows:
- Gewoon `Enter` blijft directory openen en video openen waar nu al afgesproken
- `Cmd/Ctrl+Enter` wordt exclusief voor File Info
- Daardoor blijft bestaand open-gedrag intact
## 8. Regressierisico
Belangrijkste risico's:
- Keyboardconflict met bestaand `Enter`
- Focusconflict met bestaande modals
- Onbedoeld openen bij multi-select of lege selectie
- Verwarring met bestaande view/edit/video flows
Laag-risico aanpak:
- Alleen reageren bij exact 1 selectie
- Alleen `Cmd/Ctrl+Enter`, niet gewone `Enter`
- Eigen modal-open check in de globale keyboard handler
- Geen wijziging aan bestaande browse/selectie/open-directory logica
## 9. Teststrategie
Backend golden tests:
- file info success
- directory info success
- path not found
- traversal blocked
- invalid root alias
- file/directory response shape
UI smoke/regressietests:
- info-modal container aanwezig
- keyboard wiring voor `Cmd/Ctrl+Enter` aanwezig
- geen extra zichtbare knop toegevoegd in functiebalk/topbar
Handmatige validatie:
- Exact 1 file geselecteerd -> info opent
- Exact 1 directory geselecteerd -> info opent
- Multi-select -> niets doen
- Lege selectie -> niets doen
- `Escape` sluit modal
- Gewone `Enter` blijft bestaande open-semantiek houden
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- Nieuw read-only endpoint `GET /api/files/info?path=...`
- Aparte compacte info-modal
- Alleen openen via `Cmd+Enter` op Mac en `Ctrl+Enter` op Windows/Linux
- Alleen bij exact 1 geselecteerd item
- Directory-info beperkt houden tot goedkope metadata
- Geen zichtbare extra knop in deze fase
Dit levert een bruikbare infofunctie op zonder de huidige dual-pane workflow, keyboardflow of bestaande modals onnodig te verstoren.
+196
View File
@@ -0,0 +1,196 @@
# File Type Icons v1
## 1. Doel
Bestandstype-iconen voegen waarde toe omdat de mediaslot links van de naam dan niet alleen een lege placeholder of generiek bestandssymbool is, maar een snelle visuele hint geeft over het soort bestand. Dat maakt de lijst scanbaarder zonder de dual-pane file manager om te vormen tot een media- of documentbrowser.
Dit past goed naast de bestaande image thumbnails:
- afbeeldingen kunnen visueel herkend worden via echte thumbnails
- overige bestandstypen krijgen een compact, voorspelbaar icoon
- de naamkolom blijft strak uitgelijnd
De winst zit vooral in:
- sneller herkennen van directories, videos, pdfs en codebestanden
- rustigere lijst dan met overal dezelfde generieke file-icon
- geen extra backendcomplexiteit
## 2. Scope
Aanbevolen v1-scope voor eigen icon-types:
- directory
- generic file
- image
- video
- pdf
- text
- markdown
- json
- yaml/yml
- css
- javascript
- typescript
- html
- xml
- shell
- python
- dockerfile/containerfile
Niet in v1:
- merk- of app-specifieke iconensets
- tientallen niche-extensies
- dynamische OS-native icon lookup
- thumbnails voor video/pdf/documenten
## 3. Mediaslot gedrag
Aanbevolen gedrag in de bestaande mediaslot links van de naam:
- image + thumbnails aan:
- echte thumbnail
- image + thumbnails uit:
- image-icoon of generiek file-icoon
- directory:
- folder-icoon
- video:
- video-icoon
- pdf:
- pdf-icoon
- text / markdown / config / codebestanden:
- passend type-icoon
- onbekend type:
- generic file-icoon
Belangrijke regel:
- de mediaslot blijft altijd bestaan
- thumbnails en iconen delen exact dezelfde vaste slotbreedte
- de rij-uitlijning verandert dus niet per bestandstype of setting
## 4. Visuele stijl
Aanbevolen stijlrichting:
- compacte, rustige iconen
- eenvoudige vormen
- subtiele kleurcodering per categorie
- geen zware gradients
- geen drukke illustraties
- geen letterlijke macOS-kopie, maar wel dezelfde nette soberheid
Praktische ontwerprichtlijnen:
- folder-icoon iets warmer/neutraal
- image-icoon subtiel groen/blauw
- video-icoon subtiel paars/blauwgrijs of neutraal media-accent
- pdf-icoon ingetogen roodaccent
- code/configtypes vooral via kleine accentkleur en eenvoudig documentvorm-icoon
- generic file-icoon neutraal grijs/blauwgrijs
Doel:
- herkenbaarheid zonder visuele herrie
- lijst moet primair een file manager lijst blijven, niet een dashboard met bonte badges
## 5. Technische richting
Aanbevolen v1-richting:
- frontend-only
- geen backendwijzigingen
- geen nieuwe dependencies
Beste implementatievorm voor v1:
- inline SVG of kleine SVG-templates in frontendcode
- gekoppeld aan CSS classes voor kleur en grootte
Waarom niet via externe icon library:
- extra dependency zonder echte noodzaak
- grotere bundle en meer onderhoud
- minder controle over rustige, consistente stijl
Waarom niet puur CSS-only iconen:
- mogelijk, maar minder flexibel en vaak minder helder voor meerdere bestandscategorieën
Waarom inline SVG waarschijnlijk het best is:
- lichtgewicht
- goed stijlbaar met CSS
- makkelijk compact te houden
- eenvoudig uit te breiden met extra typen later
## 6. Extensie/content-type mapping
Aanbevolen mappingstrategie:
- primair op bestandsnaam/extensie
- speciale bestandsnamen expliciet ondersteunen
Voorgestelde regels:
- directory -> `folder`
- `jpg`, `jpeg`, `png`, `webp`, `gif`, `bmp`, `avif` -> `image`
- `mp4`, `mkv`, `mov`, `avi`, `webm` -> `video`
- `pdf` -> `pdf`
- `txt`, `log`, `ini`, `cfg`, `conf` -> `text`
- `md`, `markdown` -> `markdown`
- `json` -> `json`
- `yaml`, `yml` -> `yaml`
- `css` -> `css`
- `js`, `mjs`, `cjs` -> `javascript`
- `ts`, `tsx` -> `typescript`
- `html`, `htm` -> `html`
- `xml` -> `xml`
- `sh`, `bash`, `zsh`, `fish` -> `shell`
- `py` -> `python`
- `Dockerfile` / `Containerfile` / gelijknamige extensieloze bestandsnamen -> `docker`
- fallback -> `file`
Belangrijk:
- content-type detectie vanuit backend is hier niet nodig voor v1
- frontendmapping is voldoende en onderhoudbaar
- thumbnails voor images blijven de echte afbeelding gebruiken als de setting aan staat
## 7. Regressierisico
Belangrijkste risicos:
- mediaslot-uitlijning verschuift
- iconen worden te dominant of te kleurrijk
- thumbnails en iconen krijgen verschillende afmetingen
- current row / selected row styling verliest contrast
- de lijst wordt visueel drukker in plaats van rustiger
Beheersmaatregelen:
- vaste mediaslotbreedte behouden
- identieke bounding box voor iconen en thumbnails
- iconen klein en ingetogen houden
- selectie/current row styling boven iconstyling laten domineren
- geen extra badges, labels of tekstchips in de rij toevoegen
## 8. Teststrategie
### UI smoke/regressietests
- mediaslot blijft aanwezig
- directory render gebruikt folder-icoon-class
- pdf/video/image/generic typeclasses zijn aanwezig in renderlogica
- thumbnails aan/uit blijft de mediaslot intact houden
- lijst render met current row en selected row blijft bestaan
### Handmatige validatie
- directorys zijn snel herkenbaar
- image zonder thumbnail toont netjes image-icoon
- image met thumbnail toont echte thumbnail zonder layoutverschuiving
- video toont video-icoon
- pdf toont pdf-icoon
- code/configbestanden voelen visueel onderscheidbaar maar niet schreeuwerig
- inactive pane blijft rustig leesbaar
- naamkolom blijft voldoende breed en scanbaar
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- frontend-only
- inline SVG-iconen of kleine herbruikbare SVG-templates
- mapping op extensie/bestandsnaam
- vaste mediaslot behouden
- thumbnails alleen voor images als `Show thumbnails` aan staat
- voor alle overige typen een rustig, compact type-icoon
- onbekende types terug laten vallen op generic file-icoon
Kort samengevat:
- geen ffmpeg
- geen video thumbnails
- geen backendwijzigingen
- geen icon library dependency
- wel een kleine, onderhoudbare uitbreiding van de bestaande mediaslot zodat de lijst visueel slimmer en rustiger wordt
+220
View File
@@ -0,0 +1,220 @@
# Folder Upload v1 Design
## 1. Doel
Folder upload voegt waarde toe omdat de huidige uploadflow al bruikbaar is voor losse bestanden en batches, maar niet voor veelvoorkomende workflows waarbij een gebruiker een complete lokale mapstructuur naar de storage wil kopieren. Dat past logisch binnen de bestaande dual-pane workflow: het actieve paneel bepaalt al de doelmap, en upload is al een expliciete actie in de functiebalk.
De kern van v1 is niet "een nieuwe uploadarchitectuur", maar een gecontroleerde uitbreiding van de bestaande uploadflow zodat een lokale map recursief kan worden ingestuurd naar `currentPath` van het actieve paneel.
## 2. Scope
Folder Upload v1 ondersteunt expliciet:
- selectie van precies een lokale map via de browser
- recursieve upload van de inhoud van die map
- behoud van directorystructuur onder het gekozen doelpad
- target = `currentPath` van het actieve paneel
- hergebruik van de bestaande sequentiele uploadflow en bestaande conflictopties
Niet in scope voor v1:
- meerdere lokale mappen tegelijk
- drag & drop
- resumable upload
- chunked upload
- taskmodel-integratie
- rollback
- backendherontwerp buiten wat strikt nodig is om directorystructuur veilig te ondersteunen
Aanbevolen v1-scope met laag regressierisico:
- precies 1 geselecteerde lokale map
- recursieve upload van alle files daaronder
- directorystructuur behouden
- conflictbehandeling alleen op bestandsniveau via bestaande keuzes
## 3. Browserselectie
Browsermatig is folderselectie geen aparte native "map upload API" zoals bij desktop-apps, maar een file input met directory-selectie-attributen zoals `webkitdirectory`. In de praktijk levert dit een lijst bestanden op met relatieve paden binnen de gekozen map.
Dit past redelijk goed bij de bestaande native file picker flow:
- huidige uploadknop opent al een browser file picker
- voor folder upload kan een aparte, kleine flow dezelfde picker gebruiken, maar dan in directory-selectiemodus
- drag & drop is niet nodig voor v1
Aanbeveling:
- v1 gebruikt browser-native directory picker via input-attributen
- geen drag & drop
- geen extra dependency
## 4. Doelstructuur
De veiligste en meest voorspelbare semantiek voor v1 is:
- de geselecteerde mapnaam zelf wordt meegenomen in de doelstructuur
- dus upload van lokale map `Photos/` naar target `/Volumes/8TB/Uploads` resulteert in:
- `/Volumes/8TB/Uploads/Photos/...`
Dit voorkomt ambiguiteit en sluit aan op gebruikersverwachting uit file managers.
Relatieve paden:
- browser levert per bestand een relatief pad onder de gekozen rootmap
- frontend mag dat relatieve pad gebruiken als beschrijving van directorystructuur
- backend mag die structuur nooit blind vertrouwen zonder per segment validatie
Aanbevolen semantiek:
- geselecteerde mapnaam opnemen
- directorystructuur daaronder behouden
- alle relatieve padsegmenten strikt normaliseren en valideren
## 5. Conflictgedrag
Conflictgedrag moet in v1 voortbouwen op de bestaande uploadconflictflow.
### Bestandsconflicten
Bij een bestaand doelbestand:
- `Overwrite`: huidig bestand overschrijven
- `Overwrite all`: huidige en volgende bestandsconflicten overschrijven
- `Skip`: huidig bestand overslaan
- `Skip all`: huidige en volgende bestandsconflicten overslaan
- `Cancel`: resterende upload stoppen
### Directoryconflicten
Directoryconflict is subtieler. Als de doelmap al bestaat en ook een directory is, hoeft dat in v1 geen fout te zijn. Dat is juist het normale mechanisme om inhoud in een bestaande mapstructuur te laten landen.
Aanbevolen v1-regel:
- bestaande doel-directory: toegestaan, geen conflictmodal
- bestaande doel-directory fungeert als containermap voor verdere recursie
### Typeconflicten
Als een padsegment een typeconflict veroorzaakt, bijvoorbeeld:
- lokale structuur verwacht een directory
- maar op bestemming bestaat daar een file
Dan moet dit als conflict/failure behandeld worden. De bestaande conflictknoppen kunnen dan alleen zinnig worden toegepast als overschrijven echt veilig definieerbaar is. Voor v1 is dat te riskant op directoryniveau.
Aanbevolen v1-regel:
- typeconflict directory-versus-file niet proberen slim op te lossen
- behandel als blokkade/failure voor het huidige bestand
- laat bestaande flow stoppen of conflictueel handelen op bestandsniveau, maar niet op "directory vervangen"
## 6. Backend-impact
De bestaande backend uploadbasis is grotendeels herbruikbaar voor de feitelijke bestandsoverdracht, maar folder upload heeft waarschijnlijk extra backendondersteuning nodig voor directorystructuur.
Het bestaande endpoint ondersteunt nu:
- 1 file per request
- `target_path`
- basename-validatie
Voor folder upload is minimaal een van deze routes nodig:
### Route A: frontend maakt directories expliciet aan
- frontend leest relatieve paden
- frontend zorgt eerst dat directories bestaan via bestaand `mkdir` endpoint
- daarna uploadt frontend elk bestand naar het juiste `target_path`
Voordelen:
- weinig nieuw backendcontract
- hergebruik van bestaande `mkdir` en `upload`
Nadelen:
- meer frontendcoordinatie
- meer requests
### Route B: upload-endpoint accepteert veilige relatieve subpath
- per bestand meegeven:
- `target_path`
- `relative_path`
- `file`
- backend maakt ontbrekende directories aan na validatie
Voordelen:
- schonere folder-uploadflow
- minder frontendcomplexiteit
Nadelen:
- nieuw backendcontract
- iets meer validatielogica
Aanbeveling voor laag regressierisico:
- v1 folder upload liever via Route A ontwerpen:
- frontend maakt directories expliciet aan via bestaande of lichte `mkdir`-flow
- frontend uploadt bestanden daarna via bestaand endpoint
- alleen als dat in praktijk te onhandig blijkt, Route B overwegen
Beide varianten moeten blijven leunen op:
- `path_guard`
- bestaande whitelist/root-containment
- bestaande naamvalidatie per segment
## 7. Frontend-impact
De bestaande sequentiele uploadflow kan worden uitgebreid zonder herontwerp:
- browser levert lijst bestanden uit de gekozen map
- frontend groepeert impliciet op relatieve directorystructuur
- frontend zorgt dat doel-directories bestaan
- frontend uploadt daarna de files sequentieel
Voortgang bij veel bestanden:
- huidige compacte progress UI kan blijven
- tonen:
- aantal totaal
- huidig bestand
- doelpad of huidige relatieve submap indien nuttig
- geen zware task-UI nodig in v1
Aanbevolen v1-richting:
- zelfde uploadmodal/progresscomponent als nu
- alleen uitbreiden met "uploading folder X to path Y"
- geen tweede aparte uploadarchitectuur
## 8. Regressierisico
Belangrijkste risico's:
- security: relatieve paden uit browser niet blind vertrouwen
- diepe mapstructuren: veel requests, langzame voortgang
- gedeeltelijke successen/failures: batch kan halverwege stoppen
- conflictcomplexiteit: directoryconflicten versus bestandsconflicten
- UI-complexiteit: folder upload mag bestaande file upload niet verwarren
Specifiek risico:
- een ogenschijnlijk simpele folder-upload kan ongemerkt uitgroeien tot een mini-sync-engine
- dat moet expliciet vermeden worden
## 9. Teststrategie
### Backend golden tests
Als folder upload later gebouwd wordt, minimaal testen:
- create-mkdir-then-upload flow voor nested directorystructuur
- traversal blokkade op relatieve padsegmenten
- invalid filename segment blokkade
- typeconflict file-versus-directory
- conflict op bestaand bestand
- upload naar bestaande directorystructuur
### UI smoke/regressietests
- folder-upload startpunt aanwezig
- progress UI blijft werken
- conflictopties blijven intact
- actieve-paneel target blijft leidend
### Handmatige validatie
- map met alleen files
- map met nested subdirs
- map met enkele conflicten
- map met typeconflict
- lange/brede directorystructuur
## 10. Aanbeveling
De aanbevolen v1-richting met laag regressierisico is:
- ondersteun precies 1 lokale map
- behoud de geselecteerde mapnaam in de doelstructuur
- gebruik browser-native directory picker
- breid de bestaande sequentiele uploadflow uit in plaats van een nieuwe architectuur te bouwen
- houd conflictbehandeling primair op bestandsniveau
- behandel bestaande directories als toegestaan
- vermijd drag & drop, taskintegratie, chunking en resumable uploads
Concreet aanbevolen technische richting:
- eerst proberen met bestaande architectuur en expliciete directorycreatie vanuit frontend
- alleen als dat te fragiel blijkt een kleine backenduitbreiding voor veilige relatieve paden ontwerpen
Dit houdt folder upload klein, bruikbaar en beheersbaar zonder de bestaande uploadflow opnieuw uit te vinden.
+207
View File
@@ -0,0 +1,207 @@
# History v1 Design
## 1. Doel
History is nuttig om recente bestandsacties snel terug te kunnen zien zonder in logs of taskdetails te hoeven zoeken. In de huidige app is dat vooral relevant omdat er nu zowel directe acties bestaan als task-based acties.
Praktisch nut in v1:
- snel zien wat net is aangemaakt, hernoemd, verwijderd, gekopieerd of verplaatst
- foutgevallen terugvinden zonder afhankelijk te zijn van vluchtige statusmeldingen in de UI
- task-based acties en directe acties in één compact overzicht samenbrengen
Relatie tot het taskmodel:
- tasks beschrijven vooral uitvoering en progress van asynchrone operaties
- history beschrijft afgeronde of mislukte gebruikersacties in een uniform audit-achtig overzicht
- history is dus geen vervanging van tasks, maar een compacte gebruikslaag erboven
## 2. Scope
Acties die in v1 in history komen:
- `mkdir`
- `rename`
- `delete`
- `copy`
- `move`
Voorstel voor opnamegedrag:
- zowel success als failure opnemen
- validatiefouten vóór task-creatie ook opnemen, zodat mislukte gebruikersacties zichtbaar blijven
- task-based runtime failures ook opnemen
Niet in scope in v1:
- browse-navigatie
- bookmark-acties
- view/edit openen
- theme- of UI-acties
## 3. Relatie Tasks Versus History
Aanbevolen richting: history als aparte tabel/model.
Waarom niet alleen afleiden uit tasks:
- `mkdir`, `rename` en `delete` zijn directe acties en hebben nu geen task-record
- een uniforme history uit alleen tasks zou dus onvolledig zijn
- directe acties kunstmatig als tasks modelleren zou scope verbreden en regressierisico verhogen
Aanbevolen model:
- `tasks` blijft bestaan voor asynchrone uitvoering en polling
- `history` wordt een aparte persistente tabel
- directe acties schrijven direct naar `history`
- task-based acties schrijven naar `history`:
- bij task-creatie: history-item aanmaken met `status = queued` of `running` is mogelijk, maar niet nodig voor compacte v1
- aanbevolen eenvoudiger v1: history-item pas definitief vullen bij task completion/failure
Pragmatische v1-keuze:
- bij create van copy/move-task meteen een history-record aanmaken met initiële status `queued`
- task-runner werkt dat record daarna bij naar `completed` of `failed`
- directe acties schrijven direct één afgerond history-record
Dit geeft één consistent overzicht zonder complexe reconstructie.
## 4. Minimale Data Per History-Item
Minimaal benodigde velden:
- `id`
- `operation`
- `status`
- `source`
- `destination`
- `path`
- `error_code`
- `error_message`
- `created_at`
- `finished_at`
Aanbevolen semantiek per veld:
- `id`: intern history-id
- `operation`: `mkdir | rename | delete | copy | move`
- `status`: `completed | failed | queued | running`
- `source`: bronpad voor `copy`, `move`, eventueel `rename`
- `destination`: doelpad voor `copy`, `move`, eventueel impliciet nieuw pad bij `rename`
- `path`: enkelvoudig actiedoel voor directe acties zoals `mkdir` en `delete`
- `error_code`: gestandaardiseerde foutcode indien mislukt
- `error_message`: compacte user-facing fouttekst
- `created_at`: moment waarop actie of task gestart is
- `finished_at`: moment waarop actie of task eindigde
Opmerking:
- niet elk veld is voor elke operatie gevuld
- `path` is vooral nuttig voor enkelvoudige directe acties
- `source` en `destination` zijn vooral nuttig voor `copy/move/rename`
## 5. UI-richting
De dual-pane workspace moet leidend blijven. History moet dus niet als permanent groot paneel in het hoofdscherm verschijnen.
Aanbevolen UI-richting voor v1:
- compacte aparte modal of slide-over vanuit de topbar of functiebalk
- toont een eenvoudige lijst van recente acties
- recentste items bovenaan
- per regel compact:
- operation
- status
- kernpad of source -> destination
- tijdstip
Waarom geen vast zijpaneel:
- dat zou de dual-pane workspace verstoren
- history is ondersteunend, niet primair
Aanbevolen v1-keuze:
- aparte `History` modal met compacte lijstweergave
- optioneel later uitbreidbaar met detailweergave
## 6. Scopebeperking
Niet in scope in v1:
- undo
- retry
- export
- uitgebreide filtering
- full-text search
- pagination, tenzij de lijst onverwacht groot wordt
Aanbevolen eenvoud:
- beperk API en UI in v1 tot recente items, bijvoorbeeld de laatste 100 of 200 records
- sortering: `created_at DESC`
## 7. Backend-impact
Waarschijnlijk geraakte componenten:
- SQLite schema/migratie
- repository-laag voor `history`
- services voor directe acties:
- `mkdir`
- `rename`
- `delete`
- task create-services:
- `copy`
- `move`
- task runner voor statusupdate van task-based history-items
- nieuwe read-endpoint(s) voor history
SQLite-uitbreiding:
- ja, een aparte `history` tabel is nodig
Aanbevolen v1-vulling:
- directe acties:
- service maakt history-record direct bij succes/failure
- task-acties:
- bij task-creatie history-record met `queued`
- task-runner update naar `running`, `completed` of `failed`
Voordeel:
- history wordt niet afgeleid uit meerdere bronnen bij read-time
- minder complexe querylogica
- lagere kans op inconsistent UI-gedrag
## 8. Teststrategie
Golden tests:
- `GET /api/history` lege lijst
- lijstshape voor gemengde directe en task-based entries
- directe success-entry voor `mkdir`
- directe failure-entry voor `rename` of `delete`
- task-based completed-entry voor `copy` of `move`
- task-based failed-entry voor `copy` of `move`
- sortering `created_at DESC`
Regressietests:
- bestaande endpoints behouden hun contract
- toevoegen van history mag directe actie- of taskflows niet veranderen
- taskstatus en historystatus moeten consistent blijven
Handmatige validatie:
- na `mkdir/rename/delete` verschijnt history-item correct
- na `copy/move` verschijnt history-item met juiste status na afloop
- foutmeldingen in history zijn begrijpelijk en compact
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- voeg een aparte `history` tabel toe in SQLite
- vul history expliciet vanuit services en task-runner
- expose één eenvoudige read-API voor recente history-items
- toon history in een compacte aparte modal, niet in het hoofdscherm
Waarom dit de veiligste richting is:
- geen herontwerp van bestaand taskmodel nodig
- directe acties en task-based acties komen uniform samen
- UI blijft compact en de dual-pane workflow blijft centraal
- implementatie is incrementeel en goed testbaar
+217
View File
@@ -0,0 +1,217 @@
# IMAGE_VIEWER_AND_INFO_V1.md
## 1. Doel
Een volledige image viewer voegt nu directe waarde toe omdat de app al image-bestanden kan tonen in de lijst, thumbnails kent, en type-specifieke viewers heeft voor tekst, video en PDF. Voor afbeeldingen ontbreekt nog de logische volgende stap: het geselecteerde bestand volledig bekijken zonder download- of externe viewerstap.
Een kleine uitbreiding van File Info met image-specifieke metadata voegt ook waarde toe. Voor afbeeldingen zijn afmetingen vaak net zo relevant als naam, grootte en modified time. Dat helpt bij snelle selectie, kwaliteitscontrole en onderscheid tussen vergelijkbare bestanden.
Dit past goed binnen de bestaande dual-pane workflow zolang:
- openen een lichte modalactie blijft
- de browse-flow niet verandert
- de info-uitbreiding read-only en goedkoop blijft
## 2. Scope
In scope voor v1:
- volledige image viewer voor:
- `jpg`
- `jpeg`
- `png`
- `webp`
- `gif`
- `bmp`
- `avif` als browser-native rendering zonder extra complexiteit werkt
- aparte image-modal
- read-only
- standaard `fit-to-view`
- basis zoom:
- zoom in
- zoom out
- reset
- File Info uitbreiding met:
- `width`
- `height`
Niet in scope:
- edit
- crop/rotate
- slideshow
- metadata editor
- EXIF-inspectie als brede feature
- thumbnails in de viewer
- multi-image navigation
Aanbevolen v1-richting:
- `jpg/jpeg/png/webp/gif/bmp` volwaardig ondersteunen
- `avif` best-effort, zonder extra garanties
- geen extra dependency alleen om `avif` of exotische metadata te forceren
## 3. Startgedrag
Aanbevolen v1-gedrag:
- `F3` opent de image viewer bij exact 1 geselecteerd image-bestand
- de bestaande `View`-knop gebruikt dezelfde centrale type-dispatch
- gewone `Enter`-semantiek blijft intact
Concreet:
- `F3` / `View` dispatch:
- tekst -> text viewer
- video -> video viewer
- pdf -> pdf viewer
- image -> image viewer
- bij geen selectie of multi-select doet `F3` niets als `View` disabled zou zijn
- directory-open gedrag via gewone `Enter` of directorynaam blijft onaangetast
## 4. Viewer-richting
Aanbevolen v1-richting: aparte image-modal met browser-native afbeeldingselement (`img`) en lichte frontend-zoom.
Waarom:
- geen extra dependency nodig
- laag regressierisico
- goed te combineren met bestaande modalarchitectuur
- voldoende voor een bruikbare eerste viewer
Aanbevolen UX:
- afbeelding centraal in een aparte modal
- standaard `fit-to-view`
- controls:
- `Zoom in`
- `Zoom out`
- `Reset`
- sluiten via:
- `X`
- `Escape`
- overlay-click alleen meenemen als dat geen conflict geeft met zoom/pan-interactie; anders weglaten in v1
Pannen/slepen:
- optioneel in v1
- alleen toevoegen als licht en stabiel
- geen ingewikkelde canvas/viewer-stack bouwen
Aanbevolen minimalistische v1:
- CSS transform zoom
- centreren zolang mogelijk
- eventueel natuurlijke browser-scroll/pan bij grotere zoom, in plaats van custom drag-logica
## 5. Backend-impact
Aanbevolen backendrichting:
- nieuw read-only image endpoint, analoog aan PDF/video, bijvoorbeeld:
- `GET /api/files/image?path=...`
Waarom een apart endpoint beter is dan hergebruik van random file-serving:
- consistente foutmapping
- duidelijke content-type-afhandeling
- hergebruik van bestaande `path_guard`
- expliciete scheiding van concerns per viewertype
Eisen:
- padvalidatie via bestaand `path_guard`
- alleen files
- directory -> bestaande `type_conflict`
- path not found -> bestaande not-found fout
- traversal / invalid root alias / outside whitelist -> bestaande securityfouten
- streaming/serving zonder onnodige buffering
- passend `Content-Type`
Geen nieuwe backendsemantiek nodig buiten een read-only route.
## 6. Frontend-impact
Aanbevolen frontendrichting:
- aparte image-modal
- geen hergebruik van text/video/pdf modalbody
- wel dezelfde modalstructuur en focusregels als bestaande viewers
Waarom een aparte modal:
- image viewing heeft eigen interactie (fit/zoom)
- voorkomt rommelige uitzonderingslogica in de bestaande text viewer
- houdt type-dispatch helder
Focusgedrag:
- terwijl image-modal open is, geen paneelkeyboardnavigatie
- `Escape` sluit modal
- `F3` en `View` blijven via dezelfde dispatch werken
## 7. File Info uitbreiding
Aanbevolen extra velden voor image-bestanden in v1:
- `width`
- `height`
- `content_type`
Optioneel, maar niet nodig voor v1:
- kleurprofiel
- EXIF orientation
- camera metadata
- creation date uit EXIF
Aanbevolen aanpak:
- alleen goedkope metadata
- afmetingen server-side afleiden zonder zware analyse
- geen brede EXIF feature
Voor niet-image bestanden blijven `width` en `height` gewoon `null`.
## 8. Regressierisico
Belangrijkste risico's:
- view-dispatch wordt rommeliger als image niet netjes als eigen type wordt behandeld
- modalfocus kan bestaande keyboardflow blokkeren of laten lekken
- grote afbeeldingen kunnen trager laden of veel viewport-ruimte vragen
- File Info response-uitbreiding moet backward-compatible blijven
Mitigatie:
- aparte image-modal
- eigen `isImageSelection(...)` helper in dezelfde dispatchstijl als video/pdf
- geen wijziging aan gewone `Enter`
- alleen extra velden aan File Info toevoegen, geen bestaande velden wijzigen
- zoom klein en beheersbaar houden
## 9. Teststrategie
Backend golden tests:
- image endpoint success voor ondersteund imagebestand
- directory -> `type_conflict`
- path not found
- traversal blocked
- invalid root alias
- non-image blocked of duidelijke unsupported fout
- File Info success voor imagebestand met `width`/`height`
- File Info voor niet-image met `width`/`height = null`
UI smoke/regressietests:
- image-modal container aanwezig
- image viewer wiring aanwezig in `F3`/`View` dispatch
- text/video/pdf modal containers blijven aanwezig
- File Info modal blijft aanwezig
- geen extra zichtbare knop toegevoegd
Handmatige validatie:
- `F3` opent image viewer bij exact 1 image
- `View` opent dezelfde image viewer
- zoom in/out/reset werkt
- sluiten via `X` en `Escape` werkt
- gewone `Enter` blijft directory/open-semantiek houden
- File Info toont width/height voor images
- grote afbeelding blijft bruikbaar zonder layoutbreuk
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- nieuw read-only image endpoint
- aparte image-modal met browser-native `img`
- lichte zoombediening zonder externe image-viewer library
- `F3` en `View` gebruiken de bestaande centrale type-dispatch
- File Info uitbreiden met alleen goedkope image metadata:
- `width`
- `height`
- bestaand `content_type`
- `avif` alleen best-effort, zonder extra dependency of browsergarantie
Dit houdt de stap klein, veilig en consistent met de bestaande architectuur:
- viewers blijven type-specifiek
- File Info blijft read-only
- browse- en keyboardflow blijven intact
+233
View File
@@ -0,0 +1,233 @@
# Local Upload v1
## 1. Doel
Local upload voegt nu direct waarde toe omdat de app al een bruikbare dual-pane bestandsworkflow heeft, maar nog geen ingang om bestanden vanaf de lokale machine de beheerde storage in te brengen. Dat gat is functioneel groot: browse, rename, move, copy en delete bestaan al, maar import ontbreekt.
Binnen de dual-pane workflow is de meest natuurlijke semantiek:
- bron: lokale machine via de native browser file picker
- doel: `currentPath` van het actieve paneel
Dat houdt het model eenvoudig en voorspelbaar. De gebruiker kiest eerst waar in de storage hij staat, en uploadt daarna naar die locatie.
## 2. Scope
Aanbevolen scope voor v1:
- upload van lokale bestanden via browser naar storage
- target = `currentPath` van het actieve paneel
- native browser file picker gebruiken
- single-file upload
- multi-file upload
- geen folder upload in v1
- geen drag & drop in v1
- geen resumable upload
- geen chunked upload
Motivatie:
- Multi-file upload via de native picker is klein en nuttig.
- Folder upload verhoogt de complexiteit direct sterk: recursie, conflictgedrag, voortgang, directory-creatie, mixed failures.
- Drag & drop is UX-matig aantrekkelijk, maar voegt event-complexiteit en extra foutpaden toe zonder dat het nodig is voor een eerste bruikbare versie.
- Chunking/resume is pas zinvol als gewone multipart upload aantoonbaar onvoldoende is.
## 3. Startgedrag / UI
Voor v1:
- een `Upload` knop links van `F1 Settings` in de onderbalk/topactiezone waar die nu logisch past
- klik op `Upload` opent direct de native browser file picker
- de upload werkt altijd naar het actieve paneel
- de UI toont compact en expliciet:
- `Upload to: <currentPath van actief paneel>`
Aanbevolen flow:
1. gebruiker activeert een paneel
2. gebruiker klikt `Upload`
3. browser opent native file picker
4. gebruiker kiest 1 of meerdere bestanden
5. upload start naar `currentPath` van actief paneel
6. voortgang wordt zichtbaar
7. na afronding wordt het actieve paneel refreshed
Belangrijk:
- de actieve-paneelcontext moet vooraf duidelijk zijn
- de knop hoeft niet disabled te zijn zolang een geldig `currentPath` bestaat
- als een modal open is, moet `Upload` niet tegelijk een nieuwe flow starten
## 4. Voortgang
Aanbevolen v1-model:
- één compacte upload-progress UI per lopende uploadbatch
- globale voortgang over de batch
- daarnaast compacte status per huidig bestand indien nodig
V1 hoeft niet meteen een volledige task-UI te hergebruiken. De eenvoudigste bruikbare richting is:
- één uploadstatusblok of kleine modal
- toont:
- totaal aantal bestanden
- huidig bestand
- globale voortgangsbalk of percentage
Aanbevolen velden in de UI:
- `Uploading 3 files to /Volumes/...`
- `2/3 files`
- huidige bestandsnaam
- percentage of bytes-progress voor de actieve upload
Dit is lichter dan de bestaande task-list volledig integreren in v1.
## 5. Backend-impact
Er is zeer waarschijnlijk een nieuw upload-endpoint nodig, bijvoorbeeld:
- `POST /api/files/upload`
Verwachte vorm:
- multipart/form-data
- target path als apart veld, bijvoorbeeld `target_path`
- één of meerdere file parts
Veiligheidsmodel:
- `target_path` altijd via bestaande `path_guard`
- target moet binnen whitelist/toegestane roots vallen
- target moet bestaan
- target moet een directory zijn
- bestandsnamen niet vertrouwen vanuit clientpad-informatie
- alleen de basename van het gekozen lokale bestand gebruiken
- validatie van naam via bestaande naamregels (`validate_name` of equivalent)
- geen client-side padsegmenten overnemen
Traversalpreventie:
- geen directorystructuur uit de browser aan serverzijde interpreteren in v1
- geen relatieve paden uit multipart metadata vertrouwen
- ieder bestand wordt server-side gemapt naar:
- `target_path / validated_basename`
## 6. Conflictgedrag
Ontwerp voor Engelstalige keuzes:
- `Overwrite`
- `Overwrite all`
- `Skip`
- `Cancel`
Aanbevolen v1-gedrag:
- conflictcontrole gebeurt server-side per bestand
- bij conflict in een batch wordt de batch niet stil doorgezet
- de UI toont een compacte conflictmodal voor het huidige conflicterende bestand
- de gebruiker kiest één actie
Semantiek:
- `Overwrite`: alleen huidig conflicterend bestand overschrijven
- `Overwrite all`: huidig en alle volgende conflicten automatisch overschrijven
- `Skip`: huidig conflicterend bestand overslaan en doorgaan
- `Cancel`: resterende batch stoppen
Aanbevolen v1-realisatie:
- conflict afhandelen per bestand binnen de uploadbatch-flow
- geen complexe vooraf-scan van alle conflicten nodig
- geen rollback
Belangrijk:
- ook directoryconflicten moeten duidelijk zijn
- als target al een directory met dezelfde naam bevat voor een file-upload, moet dat als conflict/typefout behandeld worden
## 7. Grote bestanden / performance
Aanbevolen v1:
- gewone multipart upload
- geen chunking
- geen resumable upload
Motivatie:
- technisch het eenvoudigst
- breed ondersteund door browser en backendstack
- voldoende voor een eerste bruikbare versie
Risico:
- zeer grote bestanden kunnen lang duren of mislukken bij netwerkonderbreking
- dat risico moet in v1 geaccepteerd en netjes gecommuniceerd worden
V1 hoeft daarom niet meer te doen dan:
- voortgang tonen
- foutmelding tonen bij mislukking
- geen herstart of resume bieden
## 8. Relatie met tasks/history
Aanbevolen v1:
- upload opnemen in `history`
- upload niet meteen in het generieke `tasks` model stoppen
Motivatie:
- upload heeft wel auditwaarde, dus history is logisch
- task-integratie maakt de slice groter: background execution, task persistence, progress mapping, polling-UI integratie
- voor een eerste bruikbare upload is een lichtere directe UI-flow met history-opslag pragmatischer
History v1 voor upload zou moeten registreren:
- operation = `upload`
- status = `completed` / `failed`
- destination = doelpad
- path of source-naam waar nuttig
- error_code / error_message bij failure
Als later blijkt dat uploads langlopend worden of meerdere gelijktijdige uploads normaal zijn, kan task-integratie in v2 logisch worden.
## 9. Regressierisico
Belangrijkste risico's:
- security: onbetrouwbare bestandsnamen of target path misbruik
- grote bestanden: timeouts of langlopende requests
- foutafhandeling: deels geslaagde batch zonder duidelijke feedback
- UI-complexiteit: conflictflow kan snel onrustig worden
- actieve-paneelcontext: upload naar verkeerd paneel/pad als context niet duidelijk is
- conflictafhandeling: onduidelijke semantiek rond overwrite/skip
Laag-regressierisico aanpak:
- target altijd expliciet koppelen aan actief paneel
- geen folder upload
- geen drag & drop
- geen chunking/resume
- compacte conflictmodal per bestand
- direct paneelrefresh na succesvolle upload(s)
## 10. Teststrategie
Backend golden tests:
- upload single file success
- upload multi-file success
- target path not found
- target path is file -> type_conflict
- traversal blocked
- invalid root alias
- invalid filename blocked
- conflict -> already_exists of equivalent
- overwrite success
- skip/cancel flow indien servercontract dat nodig maakt
UI smoke/regressietests:
- `Upload` knop aanwezig links van `F1 Settings`
- geen uploadstart als ongeldige UI-context aanwezig is
- targetpaneel-context zichtbaar in uploadflow
- progress UI verschijnt
- conflictkeuze-UI verschijnt met:
- `Overwrite`
- `Overwrite all`
- `Skip`
- `Cancel`
Handmatige validatie:
- upload 1 klein bestand
- upload meerdere bestanden
- conflict op bestaand bestand
- overwrite all werkt over meerdere conflicten
- skip laat batch doorgaan
- cancel stopt batch
- actief paneel bepaalt doelpad correct
- history bevat upload-resultaten
## 11. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- native browser file picker
- single + multi-file upload
- target = `currentPath` van actief paneel
- geen folder upload
- geen drag & drop
- gewone multipart upload
- directe voortgangsweergave in lichte upload-UI
- conflictafhandeling per bestand met:
- `Overwrite`
- `Overwrite all`
- `Skip`
- `Cancel`
- wel history-integratie
- nog geen task-integratie
Dit is de kleinste versie die echt bruikbaar is, zonder meteen te ontsporen in mediaserver- of synchronisatiecomplexiteit.
+178
View File
@@ -0,0 +1,178 @@
# Monaco Editor v1
## 1. Doel
Monaco voegt nu waarde toe omdat de huidige textarea-editor functioneel is, maar beperkt blijft voor code- en configbestanden. Voor deze app is de winst vooral: betere leesbaarheid, syntax highlighting, line numbers, current-line focus en een meer betrouwbare editervaring voor technische bestanden.
Dit past naast de bestaande text editor modal als een vervanging van de edit-UI, niet als een nieuwe backendflow. De bestaande backend read/save-flow bestaat al en moet ongewijzigd worden hergebruikt:
- initial read via de bestaande edit/view-flow
- save via het bestaande save-endpoint
- bestaande conflict- en io_error-semantiek blijven leidend
Het doel is een "VS Code light"-achtige ervaring voor 1 bestand tegelijk, zonder IDE-verbreding.
## 2. Scope
In scope voor v1:
- Monaco Editor in een modal
- F4 opent Monaco voor ondersteunde tekst/codebestanden
- syntax highlighting
- line numbers
- current line highlight
- save via bestaande save-flow
- dirty state behouden
- conflict handling behouden
- light/dark theme-integratie
Niet in scope voor v1:
- LSP
- autocomplete op IDE-niveau
- multi-tab editor
- diff editor
- command palette
- file tree in de modal
- symbol outline
- search panel met geavanceerde editorfeatures
- plugin- of extensionmodel
## 3. Integratierichting
Aanbevolen route: moderne browser-side Monaco-integratie met de officiële ESM-distributie van `monaco-editor`, gebundeld als statische frontend-assets voor deze app.
Aanbevolen technische richting:
- Monaco alleen lazy loaden wanneer de edit modal voor het eerst opent
- editor-instance per modal-open lifecycle aanmaken
- bij sluiten altijd `editor.dispose()` en eventuele model cleanup uitvoeren
- per geopend bestand 1 model tegelijk gebruiken
- bestaande modal DOM behouden, maar de textarea vervangen door een editor host-container
Waarom deze route:
- sluit aan op de huidige lichte frontend zonder volledige frameworkmigratie
- voorkomt dat Monaco initieel alle schermen vertraagt
- houdt lifecycle beheersbaar
Belangrijke lifecycle-regels:
- editor pas initialiseren nadat modal zichtbaar is en afmetingen stabiel zijn
- bij heropenen van een ander bestand geen oude editor hergebruiken zonder expliciete reset
- model en listeners bij sluiten expliciet opruimen
- resize van modal/viewport koppelen aan `editor.layout()` zolang modal open is
## 4. Taalondersteuning
Minimale extensie/bestandsnaammapping voor v1:
- `js`, `mjs`, `cjs` -> `javascript`
- `ts`, `tsx` -> `typescript`
- `json` -> `json`
- `css` -> `css`
- `html`, `htm` -> `html`
- `xml` -> `xml`
- `yml`, `yaml` -> `yaml`
- `py` -> `python`
- `sh`, `bash`, `zsh`, `fish` -> `shell`
- `md`, `markdown` -> `markdown`
- `txt`, `log`, `ini`, `cfg`, `conf` -> `plaintext`
- `Dockerfile`, `Containerfile` -> `dockerfile`
V1-verwachting per taal:
- syntax highlighting voor alle bovenstaande talen
- bracket matching / basic Monaco editor behavior waar Monaco dat standaard biedt
- geen extra taalservices of projectintelligentie buiten wat Monaco standaard client-side levert
Pragmatische keuze:
- v1 focust op highlighting en nette editing
- rijkere taalfeatures zijn een bijeffect van Monaco waar beschikbaar, maar geen contract
## 5. Theme-integratie
Monaco moet aansluiten op de bestaande light/dark mode.
Aanbevolen v1-richting:
- dark mode -> Monaco `vs-dark`
- light mode -> Monaco `vs`
- alleen een custom Monaco theme toevoegen als kleurafwijking zichtbaar storend is
Dit houdt regressierisico laag. De rest van de UI blijft leidend; Monaco hoeft in v1 niet pixel-perfect het app-thema te kopieren, zolang het visueel coherent is.
## 6. Modal-UX
Aanbevolen editor-modal:
- groot, bijna viewport-vullend
- duidelijk groter dan de huidige eenvoudige edit-modal
- behoud van titelbalk met bestandsnaam/pad
- behoud van `Save`, `Cancel` en `X`
Gedrag:
- `Save` gebruikt bestaande save-flow
- `Cancel` sluit alleen zonder prompt als niet dirty
- `X` volgt hetzelfde gedrag als `Cancel`
- `Escape`:
- zonder wijzigingen -> sluit
- met wijzigingen -> bestaande discard-waarschuwing behouden
Keyboard/focus:
- terwijl Monaco open is, mogen paneelshortcuts niet ingrijpen
- editor krijgt focus direct na openen
- modal blijft de enige actieve interactiezone totdat hij sluit
## 7. Regressiebehoud
Moet functioneel behouden blijven:
- bestaande F4 edit-semantiek
- bestaande backend save/conflict-flow
- bestaande optimistic locking / expected_modified gedrag
- bestaande dirty-state logica
- bestaande text/video/pdf viewers
- bestaande browse- en paneelworkflow buiten de modal
Niet doen in v1:
- backend save-contract wijzigen
- nieuwe backend editorfeatures toevoegen
- bestaande foutcodes of response-shapes wijzigen
## 8. Performance en risico
Belangrijkste risico's:
- Monaco vergroot frontend bundle/load
- editor initialisatie is zwaarder dan textarea
- foutieve dispose kan memory leaks geven
- taalbundels kunnen groter zijn dan nodig
Laag-risico aanpak:
- lazy load alleen bij eerste edit-open
- alleen de noodzakelijke Monaco assets shippen
- geen extra taalplugins of worker-uitbreidingen toevoegen buiten wat nodig is
- editor instance altijd disposer'en bij sluiten
Reële tradeoff:
- Monaco is zwaarder dan strikt nodig voor een kleine file manager
- maar de UX-winst voor code/config editing is groot genoeg als de integratie sober blijft
## 9. Teststrategie
UI smoke/regressietests:
- Monaco host-container aanwezig in edit modal
- F4 wiring blijft aanwezig
- `Save`, `Cancel`, `X` blijven aanwezig
- bestaande edit modalflow blijft openen/sluiten
- light/dark theme blijft editor-opening niet breken
Handmatige validatie:
- openen van ondersteund codebestand via F4
- syntax highlighting voor representatieve typen:
- js
- json
- yaml
- py
- markdown
- Dockerfile
- save success
- save conflict
- cancel/escape met dirty state
- herhaald open/close van editor zonder UI-vertraging of kapotte focus
- switch tussen light/dark terwijl editor open of dicht is
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- vervang de huidige textarea-editor UI door een Monaco-modal
- hergebruik ongewijzigd de bestaande backend read/save-flow
- beperk v1 tot syntax highlighting, line numbers, current line highlight en nette modal-UX
- gebruik Monaco lazy-loaded en dispose correct bij sluiten
- gebruik standaard `vs` / `vs-dark` thema's tenzij er een duidelijke mismatch blijkt
Niet proberen in v1:
- IDE-features najagen
- extra backendsemantiek toevoegen
- editorflow verbreden naar meerdere bestanden of workspaceconcepten
De juiste lat voor v1 is: betere editor-ergonomie zonder de app architectonisch zwaarder te maken dan nodig.
+127
View File
@@ -0,0 +1,127 @@
# PDF Viewer v1
## 1. Doel
PDF-viewing voegt nu directe waarde toe omdat PDF-bestanden in de huidige file manager al zichtbaar en selecteerbaar zijn, maar nog niet in de webui bekeken kunnen worden zonder externe tooling of downloadstap. Dat past logisch naast de bestaande text-viewer en video-viewer: elk veelvoorkomend bestandstype krijgt een passende read-only viewer, terwijl de dual-pane workflow intact blijft.
## 2. Startgedrag
Aanbevolen v1-gedrag:
- `F3` opent de PDF viewer voor exact 1 geselecteerd PDF-bestand.
- De bestaande `View`-knop hergebruikt dezelfde centrale view-dispatch en opent bij een PDF dezelfde PDF viewer.
- Dit geldt alleen bij exact 1 geselecteerd item met extensie `.pdf`.
- Bij geen selectie, multi-select, of niet-PDF blijft `F3` het bestaande view-gedrag respecteren of niets doen als `View` disabled zou zijn.
- Gewone `Enter`-semantiek blijft intact en wordt niet gewijzigd voor PDF.
## 3. Scope
In scope voor v1:
- alleen PDF
- read-only
- bekijken in modal
- browser-native scrollen binnen de viewer
- sluiten via `X` en `Escape`
Niet in scope:
- edit
- annotatie
- OCR
- losse downloadfeature
- page thumbnails
- search in PDF-documenten
- print-specifieke UI
## 4. Viewer-richting
Aanbevolen v1-richting: browser-native embedded PDF-view in een aparte modal.
Concreet:
- frontend opent een aparte PDF-modal
- modal bevat een embedded viewer via `iframe` of `embed` naar een read-only backend endpoint
- paginaweergave en scrollen worden aan de browser-PDF-viewer overgelaten
- sluiten werkt via `X`, `Escape`, en eventueel overlay-click als dat consistent is met bestaande modals
Waarom deze richting:
- laag regressierisico
- geen extra dependencies nodig
- geen custom PDF rendering stack nodig
- sluit aan op het bestaande modalmodel
Niet aanbevolen voor v1:
- eigen PDF-rendering met `pdf.js` of vergelijkbaar, tenzij later blijkt dat browser-native gedrag onvoldoende is
- dat zou extra assets, integratie en regressierisico introduceren
## 5. Backend-impact
Aanbevolen: een apart read-only PDF endpoint, analoog aan video/view.
Voorstel:
- `GET /api/files/pdf?path=...`
Eisen:
- padvalidatie via bestaande `path_guard`
- alleen files
- directory -> bestaande `type_conflict`
- path not found -> bestaande not-found fout
- traversal / invalid root alias / outside whitelist -> bestaande securityfouten
- `Content-Type: application/pdf`
- streaming/read-only gedrag zonder onnodige buffering
Waarom een apart endpoint:
- expliciete content-type-afhandeling voor PDF
- scheiding van concerns ten opzichte van tekst-view en video-streaming
- eenvoudige frontend-integratie in een PDF-modal
## 6. Frontend-impact
Aanbevolen:
- aparte PDF-modal, niet hergebruik van text-view modal
- wel hergebruik van bestaande modalstijl en focusregels
Gedrag:
- `F3` en `View` dispatchen op bestandstype
- tekstbestanden blijven naar text viewer gaan
- video blijft naar video viewer gaan
- PDF gaat naar PDF viewer
- editorflow blijft ongemoeid
Dat voorkomt regressie op bestaande viewers en houdt de semantiek per bestandstype helder.
## 7. Risico's
Belangrijkste risico's:
- browserverschillen in ingebouwde PDF-weergave
- sommige browsers of embedded contexten kunnen PDF anders tonen of beperkter ondersteunen
- grote PDF-bestanden kunnen trager laden, maar browser-native streaming is nog steeds lichter dan custom rendering
- security moet strikt read-only blijven via bestaande whitelist/path-guards
- regressierisico in bestaande `F3`/`View` dispatchlogica als filetype-routing rommelig wordt geïmplementeerd
Beperking voor v1:
- geen garantie op identieke UX in elke browser
- wel een veilige, kleine baseline voor moderne browsers met ingebouwde PDF-viewer
## 8. Teststrategie
Backend golden tests:
- PDF endpoint success
- directory -> `type_conflict`
- path not found
- traversal blocked
- invalid root alias
- non-pdf blocked of routed naar duidelijke fout
UI smoke/regressietests:
- PDF-modal container aanwezig
- `F3` / `View` wiring aanwezig voor PDF-flow
- bestaande text/video modal containers blijven aanwezig
- geen regressie op huidige view-dispatch
Handmatige validatie:
- PDF openen via `F3`
- PDF openen via `View`
- sluiten via `X` en `Escape`
- gewone `Enter` blijft ongewijzigd
- text/video viewers blijven correct openen
- groot PDF-bestand laadt zonder de UI te blokkeren
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- gebruik een apart read-only backend endpoint voor PDF
- gebruik browser-native embedded PDF-weergave in een aparte modal
- laat `F3` en `View` dezelfde type-dispatch gebruiken
- houd PDF strikt read-only
- voeg geen externe PDF-library of rendering stack toe in v1
Dit levert de kleinste veilige stap op: bruikbare PDF-viewing, minimale architectuurimpact, en geen onnodige verbreding van scope.
+216
View File
@@ -0,0 +1,216 @@
# Preferred Startup Paths v2
## 1. Doel
Aparte startup paths voor links en rechts voegen waarde toe omdat de huidige app inmiddels duidelijk als dual-pane workspace wordt gebruikt, niet meer als een enkel browservenster met twee toevallige kolommen. In de praktijk wil een gebruiker vaak een vaste bronlocatie links en een vaste doellocatie rechts, of twee verschillende werkcontexten die bij elke start direct beschikbaar zijn.
Dit past goed binnen de huidige dual-pane workflow zolang het startup-model voorspelbaar blijft:
- links en rechts worden onafhankelijk bepaald
- elke paneelstart heeft een veilige fallback
- invalidatie van het ene paneel mag het andere paneel niet blokkeren
Het doel van v2 is dus niet om sessieherstel te bouwen, maar om de bestaande v1-setting uit te breiden naar een net paneelspecifiek model.
## 2. Scope
Voor v2 is de scope expliciet:
- twee aparte settings
- `preferred_startup_path_left`
- `preferred_startup_path_right`
- beide persistent opgeslagen in de bestaande SQLite settings-opslag
- configureerbaar via `F1 -> Settings -> General`
- geen browser storage
- geen profielensysteem
- geen "laatst gebruikte map" logica
Niet in scope:
- startup-profielen
- per root presets
- per gebruiker verschillende defaults
- automatisch synchroniseren van links en rechts
- migratie naar een complexer settings-schema dan nodig
## 3. Paneelgedrag
Aanbevolen v2-keuze:
- linkerpaneel gebruikt `preferred_startup_path_left` als die geldig is
- rechterpaneel gebruikt `preferred_startup_path_right` als die geldig is
- per paneel geldt onafhankelijk:
- leeg -> fallback naar `/Volumes`
- ongeldig -> fallback naar `/Volumes`
Dit is de meest directe uitbreiding op v1 en heeft het laagste regressierisico, omdat het huidige startupgedrag al paneelspecifiek wordt bepaald.
Expliciet niet aanbevolen voor v2:
- één setting die intern naar beide panelen gespiegeld wordt
- een "alleen links instelbaar, rechts afgeleid" model
- complexe koppellogica zoals "rechts gebruikt automatisch andere root"
Die varianten introduceren impliciete regels en maken debugging moeilijker.
## 4. Validatie
Per veld geldt exact dezelfde validatie als in v1:
- pad moet bestaan
- pad moet een directory zijn
- pad moet binnen toegestane roots vallen
- traversal wordt geblokkeerd
- invalid root alias wordt geblokkeerd
- file paths zijn niet toegestaan
Semantiek:
- lege waarde betekent `null`
- `null` betekent: voor dat paneel geen voorkeurpad, dus fallback naar `/Volumes`
Belangrijk onderscheid tussen write en read:
- write-validatie blijft streng
- read-gedrag blijft tolerant
Dat betekent:
- ongeldige invoer wordt niet opgeslagen
- eerder opgeslagen waarden die later ongeldig worden, worden bij startup niet blind vertrouwd maar ook niet direct uit de database verwijderd
## 5. Fallback-gedrag
Veilig en voorspelbaar fallbackmodel voor v2:
- links:
- geldig `preferred_startup_path_left` -> gebruik dat pad
- anders -> `/Volumes`
- rechts:
- geldig `preferred_startup_path_right` -> gebruik dat pad
- anders -> `/Volumes`
Gemengde gevallen moeten expliciet ondersteund worden:
- links geldig, rechts ongeldig -> links op voorkeurpad, rechts op `/Volumes`
- links ongeldig, rechts geldig -> links op `/Volumes`, rechts op voorkeurpad
- beide ongeldig -> beide op `/Volumes`
Dit voorkomt dat één corrupte setting de hele appstart breekt.
## 6. Settings UI
Plaatsing blijft:
- `F1 -> Settings -> General`
Aanbevolen v2-weergave:
- twee aparte tekstvelden
- labels:
- `Preferred startup path (left)`
- `Preferred startup path (right)`
Opslaggedrag:
- opslaan via dezelfde bestaande settings-save flow
- beide velden mogen tegelijk opgeslagen worden
- leegmaken van een veld zet alleen dat veld naar `null`
Aanbevolen foutweergave:
- compact maar duidelijk per veld, of als één compacte General-error als de huidige modal daar al op ingericht is
- bij voorkeur per veld omdat links en rechts onafhankelijk valide kunnen zijn
Als per-veld foutweergave te veel UI-ruis oplevert, is een compacte foutregel in de General-tab acceptabel, mits duidelijk is welk veld faalt.
## 7. Backend-impact
Deze uitbreiding past logisch in de bestaande settings-opslag en settings-API.
Aanbevolen uitbreiding:
- bestaande settings-uitbreiding met:
- `preferred_startup_path_left: string | null`
- `preferred_startup_path_right: string | null`
### Backward compatibility met bestaande single setting
Hier is een expliciete keuze nodig.
Aanbevolen richting met laag regressierisico:
- de oude single setting `preferred_startup_path` tijdelijk blijven ondersteunen als legacy input
- nieuwe code leest primair:
- `preferred_startup_path_left`
- `preferred_startup_path_right`
- als deze ontbreken, maar oude `preferred_startup_path` bestaat nog:
- gebruik die alleen als fallback voor links
- rechts blijft `/Volumes`
- bij de eerste expliciete save vanuit v2 UI schrijft de app alleen de nieuwe left/right keys
- de oude key hoeft in v2 niet direct verwijderd te worden
Waarom dit de beste v2-keuze is:
- bestaande gebruikers van v1 verliezen hun startup-voorkeur niet
- er is geen harde migratiestap nodig
- de migratielogica blijft klein en begrijpelijk
Expliciet niet aanbevolen:
- de oude key hard verwijderen zonder read-compatibiliteit
- een automatische destructieve migratie bij startup
- dezelfde oude key dupliceren naar links én rechts
Dat laatste zou te verrassend zijn, omdat het beide panelen plots op dezelfde map laat starten.
## 8. Frontend-impact
Frontendstartup moet worden uitgebreid van:
- één preferred path voor links
naar:
- twee onafhankelijke preferred paths
Aanbevolen init-volgorde:
1. laad settings via backend
2. bepaal startup path voor links
3. bepaal startup path voor rechts
4. initialiseeer panelen met die twee waarden
5. browse laden
6. per paneel veilig fallbacken naar `/Volumes` als browse init voor dat pad mislukt
Belangrijk voor regressiebehoud:
- dubbele initialisatie vermijden waar mogelijk
- links en rechts los fallbacken
- huidige `/Volumes` defaultlogica niet verwijderen maar als veilige basis behouden
## 9. Regressierisico
Belangrijkste risicos:
- invalid stored path voor één paneel veroorzaakt misleidende first render
- mixed valid/invalid links/rechts levert inconsistente startup op als fallbacklogica niet per paneel gebeurt
- `/Volumes` hostlaag en alias-mapping moeten consistent blijven
- legacy single setting kan per ongeluk genegeerd worden
Laag-risico aanpak:
- per paneel apart resolven en fallbacken
- legacy key alleen als tijdelijke read-fallback voor links
- write alleen naar nieuwe keys
- `/Volumes` als harde veilige fallback behouden
- geen semantische wijziging aan browse buiten startup
## 10. Teststrategie
### Backend golden tests
- `GET /api/settings` met default:
- `preferred_startup_path_left = null`
- `preferred_startup_path_right = null`
- geldig left path opslaan
- geldig right path opslaan
- beide tegelijk opslaan
- file path blokkeren per veld
- traversal blokkeren per veld
- invalid root alias blokkeren per veld
- missing directory blokkeren per veld
- lege waarde zet veld terug naar `null`
### UI smoke/regressietests
- `Settings > General` bevat beide velden
- app init leest beide settings via backend
- linker en rechter paneel krijgen onafhankelijke startupwaarden
- fallback naar `/Volumes` blijft intact per paneel
- legacy v1-pad breekt appinit niet
### Handmatige validatie
- links en rechts verschillende geldige paden opslaan en app herstarten
- links geldig, rechts leeg
- links ongeldig opgeslagen legacy/v2 waarde simuleren
- rechts ongeldig opgeslagen v2 waarde simuleren
- beide ongeldig -> beide `/Volumes`
- v1-upgrade scenario:
- alleen oude `preferred_startup_path` aanwezig
- links start daarop, rechts op `/Volumes`
## 11. Aanbeveling
Aanbevolen v2-richting met laag regressierisico:
- voeg twee nieuwe settings toe:
- `preferred_startup_path_left`
- `preferred_startup_path_right`
- valideer beide op dezelfde manier als v1
- leeg veld = `null`
- fallback per paneel = `/Volumes`
- behoud tijdelijke read-compatibiliteit met de bestaande v1-key `preferred_startup_path`
- alleen als fallback voor links
- schrijf vanuit v2 UI alleen nog de nieuwe left/right settings
Dit geeft een nette evolutie van v1 naar een volwaardige dual-pane startupconfiguratie, zonder bestaande gebruikers onverwacht te breken en zonder onnodige migratiecomplexiteit.
+174
View File
@@ -0,0 +1,174 @@
# Preferred Startup Path v1
## 1. Doel
Een voorkeurpad is nuttig omdat de app nu altijd met een vaste startlocatie opent, terwijl de gebruiker in de praktijk vaak steeds in dezelfde map of subtree werkt. Als de app direct op die plek opent, scheelt dat navigatiestappen en sluit het beter aan op een dual-pane file manager workflow waarin de gebruiker vaak een primaire werkmap heeft.
Binnen de huidige app past dit goed zolang de startup-logica eenvoudig en voorspelbaar blijft. Het doel is niet om sessies volledig te herstellen, maar om een veilige, persistente startlocatie te bieden.
## 2. Scope
Voor v1 is de scope expliciet:
- één voorkeurpad
- persistent opgeslagen in de bestaande SQLite settings-opslag
- configureerbaar via `F1 -> Settings -> General`
- geen browser storage
- geen meervoudige profielen of paneelspecifieke presets
Niet in scope:
- meerdere startup-profielen
- per root een aparte default
- laatst bezochte map onthouden per sessie
- aparte startup-paths voor links en rechts
## 3. Paneelgedrag
Aanbevolen v1-keuze met laag regressierisico:
- één voorkeurpad
- toepassen op het linkerpaneel bij startup
- rechterpaneel blijft starten op de huidige veilige default (`/Volumes`)
Reden:
- dit geeft directe waarde zonder de dual-pane logica complex te maken
- het voorkomt dat beide panelen onbedoeld op exact dezelfde diepe map starten
- het behoudt één paneel als neutrale oriëntatie-/navigatiekolom
- het vermindert regressierisico ten opzichte van twee aparte persistente padinstellingen
Alternatieven die bewust niet aanbevolen worden voor v1:
- hetzelfde voorkeurpad voor beide panelen: eenvoudig, maar minder bruikbaar in dual-pane gebruik
- aparte voorkeurpaden voor links en rechts: functioneel aantrekkelijk, maar meer validatie- en fallbackcomplexiteit
## 4. Validatie
Het voorkeurpad moet aan dezelfde veiligheidsregels voldoen als gewone browse-paden:
- binnen whitelist / toegestane roots
- geldig binnen de bestaande `/Volumes` en root/alias-logica
- padvalidatie via bestaande `path_guard`-infrastructuur of equivalente browse-validatie
Validatieregels:
- pad moet resolven naar een toegestane locatie
- pad moet bestaan
- pad moet browsebaar zijn als directory
- file als startup path is niet toegestaan voor v1
Gedrag bij ongeldigheid:
- als het pad niet meer bestaat: instelling blijft opgeslagen, maar startup valt terug op veilige default
- als het pad niet meer toegankelijk is door whitelist/config wijziging: startup valt terug op veilige default
- als het pad syntactisch ongeldig is: backend weigert opslag
## 5. Fallback-gedrag
Veilige en voorspelbare fallback voor v1:
- als preferred startup path ontbreekt of ongeldig is, start de app op `/Volumes`
- rechterpaneel blijft in alle gevallen op `/Volumes`
- linkerpaneel gebruikt alleen het voorkeurpad als de validatie slaagt
Dit houdt het model simpel:
- geldig voorkeurpad -> links opent daar, rechts opent `/Volumes`
- ongeldig voorkeurpad -> beide panelen openen `/Volumes`
## 6. Settings UI
Plaatsing:
- `F1 -> Settings -> General`
Aanbevolen invoervorm voor v1:
- één tekstveld met het volledige pad
- compact helperlabel, bijvoorbeeld: `Preferred startup path`
Waarom tekstveld de laagste risicokeuze is:
- sluit aan op de bestaande padsemantiek in de app
- geen extra browse-picker of tree-selector nodig
- laagste implementatiecomplexiteit
Opslagflow:
- gebruiker voert pad in
- klik op `Save` of bestaande settings-save flow
- backend valideert
- bij succes blijft waarde persistent opgeslagen
- bij fout toont de modal een compacte validatiefout
Aanbevolen foutweergave:
- inline fout onder het veld of in de General-tab
- voorbeelden:
- `Startup path must be a directory`
- `Startup path is outside allowed roots`
- `Startup path does not exist`
## 7. Backend-impact
Dit past logisch in de bestaande settings-API.
Aanbevolen uitbreiding:
- bestaand `settings` model uitbreiden met:
- `preferred_startup_path: string | null`
Benodigde backendlogica:
- read via bestaande `GET /api/settings`
- write via bestaande `POST /api/settings`
- validatie op write:
- directory
- bestaand pad
- binnen whitelist
Te hergebruiken validatie:
- bestaande `path_guard`
- bestaande browse-/directory-validatie helpers waar aanwezig
Nieuwe endpoint is niet nodig als de bestaande settings-API netjes uitbreidbaar is.
## 8. Frontend-impact
App-initialisatie moet licht aangepast worden:
- app leest settings vroeg in startup
- als `preferred_startup_path` geldig aanwezig is:
- linker paneel start daar
- anders:
- linker paneel start op `/Volumes`
- rechter paneel blijft op `/Volumes`
Belangrijk voor regressiebehoud:
- browse-initialisatie mag niet kapotgaan als settings-call faalt
- bij error of lege response moet de app veilig terugvallen op het huidige gedrag
Aanbevolen init-volgorde:
1. laad settings
2. bepaal startup path voor links
3. initialiseeer panelen
4. laad browse data
## 9. Regressierisico
Belangrijkste risicos:
- ongeldige startup path leidt tot lege of foutieve eerste render
- conflict tussen oude default `/Volumes` en nieuw voorkeurpad
- inconsistent gedrag tussen host-achtige `/Volumes/...` paden en alias-root mapping
- settings laden te laat, waardoor panelen eerst op default en daarna opnieuw renderen
Laag-risico aanpak:
- settings eerst laden
- startup path alleen op links toepassen
- fallback altijd `/Volumes`
- write-validatie streng houden
- read-validatie tolerant houden met veilige fallback
## 10. Teststrategie
Backend golden tests:
- `GET /api/settings` met default `preferred_startup_path = null`
- `POST /api/settings` met geldig startup path
- write-block voor file path i.p.v. directory
- write-block voor traversal / invalid root alias
- write-block voor niet-bestaand pad
UI smoke/regressietests:
- Settings > General bevat veld voor preferred startup path
- app init leest setting uit backend
- geen regressie op huidige `/Volumes` fallback
Handmatige validatie:
- geldig pad opslaan en app herstarten
- ongeldig pad opslaan moet geweigerd worden
- bestaand opgeslagen pad later verwijderen en app opnieuw starten -> fallback naar `/Volumes`
- `/Volumes/...` startup path moet correct resolven en openen
## 11. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- voeg één setting toe: `preferred_startup_path`
- persistent in bestaande SQLite settings-opslag
- configureerbaar via tekstveld in `Settings > General`
- toepassen op alleen het linkerpaneel
- rechterpaneel blijft op `/Volumes`
- fallback altijd `/Volumes`
Dit geeft directe waarde met minimale complexiteit. Het houdt dual-pane gedrag bruikbaar, beperkt regressierisico, en laat later nog ruimte voor een v2 met aparte left/right startup paths als daar echte behoefte aan blijkt.
@@ -0,0 +1,359 @@
# Remote Client Shares Implementation Phases V1.1
## Status
Per huidige repositorystatus zijn de in dit document beschreven implementatiefases afgerond:
- Phase 1: afgerond
- Phase 2: afgerond
- Phase 3: afgerond
Dit document beschrijft geen Phase 4.
De sectie `Later` hieronder blijft expliciet buiten de beschreven fasering van V1.1 en is geen impliciete volgende fase.
## 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.
Opmerking:
- `Later` betekent in dit document: bewust uitgestelde scope, niet een gedefinieerde volgende implementatiefase
---
## 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.
Status:
- deze onderdelen blijven expliciet buiten de afgeronde Phase 1 t/m Phase 3 scope
- voor deze onderdelen bestaat in dit document geen aparte vervolgfase
### 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,717 @@
# Remote Client Shares V1.1 Design
## Status
Dit document beschrijft de V1.1-doelscope voor Remote Client Shares.
Per huidige repositorystatus valt de beschreven V1.1 read-mostly scope onder afgeronde implementatie van:
- client registry
- browse via `/Clients`
- file info
- tekstpreview
- eenvoudige image preview
- download
De expliciet niet in V1.1 opgenomen onderdelen hieronder blijven buiten scope en vormen in dit document geen aparte vervolgfase.
## 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
Status:
- deze lijst blijft expliciet uitgesloten van V1.1
- dit document definieert hiervoor geen Phase 4 of andere vervolgfase
---
## 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.
@@ -0,0 +1,172 @@
# Row Selection Without Checkbox v1
## 1. Doel
De huidige lijstweergave gebruikt tegelijk een checkbox, een mediaslot, row highlight, current row styling en active pane styling. Functioneel werkt dat, maar het kost horizontale ruimte in precies het deel van de UI waar de gebruiker het meest kijkt: de naamkolom. Een compactere lijst zonder checkbox kan de dual-pane file manager rustiger en dichter bij klassieke file manager workflows maken, zolang selectie nog steeds duidelijk en voorspelbaar blijft.
Deze stap gaat daarom niet over nieuwe selectie-logica, maar over de vraag of de bestaande selectie-interacties voldoende sterk zijn om de checkbox uit de rij te verwijderen zonder bruikbaarheid of regressieve verrassingen.
## 2. Scope
Voor deze v1-ontwerpstap is de scope expliciet:
- checkbox verwijderen uit de browse-lijst
- selectie zichtbaar maken via bestaande row highlight, current row en active pane styling
- geen backendwijzigingen
- geen nieuwe dependencies
- geen herontwerp van selectie-state tenzij strikt nodig
Niet in scope:
- nieuwe selectie-features
- backend batch-semantiek
- nieuwe keyboard shortcuts
- drag selection
- shift-click range selection
- checkbox-vervanging door een andere control in dezelfde kolom
## 3. Bestaande selectiepatronen die leidend blijven
De bestaande interacties blijven leidend en worden niet opnieuw ontworpen:
- gewone rij-click = single select
- Cmd+klik op Mac / Ctrl+klik op niet-Mac = toggle selectie
- Shift + ArrowUp / ArrowDown = range-selectie
- Space = toggle selectie op current row
- Escape = clear selection
- directorynaam blijft openen
- Enter/open-gedrag blijft intact
- wildcard-selectie blijft intact
- activePane, currentRowIndex, selectedItems en selectionAnchorIndex blijven de basis
De kern van deze UX-slice is dus: is de checkbox nog nodig als deze bestaande patronen al aanwezig zijn?
## 4. UI-gedrag zonder checkbox
Zonder checkbox moet selectie volledig leesbaar blijven via styling van de rij zelf.
Aanbevolen visuele opbouw:
- current row blijft een subtiele focus-/cursorstaat
- selected rows krijgen een duidelijkere maar nog compacte achtergrond en/of inset accent
- current row + selected moet samen zichtbaar kunnen blijven
- active pane blijft via pane border/outlining zichtbaar, niet via zware pane-achtergrond
- mediaslot links van de naam blijft bestaan en schuift op naar de plek waar nu checkbox + mediaslot samen ruimte innemen
Aanbevolen onderscheid:
- current row: lichte focusband of outline
- selected row: duidelijke achtergrondtint of accentvlak
- current row + selected: gecombineerde staat met iets sterker contrast
- inactive pane selected rows: nog steeds leesbaar, maar iets rustiger dan in active pane
Multi-select moet zonder checkbox nog steeds scanbaar blijven. Dat vraagt om consistente row highlight over de hele breedte van de lijstregel, niet alleen rond de naamtekst.
## 5. Click-target gedrag
Dit deel is kritisch, omdat de checkbox nu nog een heel expliciete select-control is.
Aanbevolen gedrag zonder checkbox:
- klik op directorynaam = open directory
- klik op file-naam = single select, niet openen
- klik op rij buiten de naam = single select
- Cmd/Ctrl+klik op rij buiten de naam = toggle selectie
- Cmd/Ctrl+klik op file-naam = toggle selectie als dat aansluit op bestaande flow, of expliciet dezelfde single-select-regel houden; dit moet consistent zijn
- directorynaam moet navigatie blijven en mag niet per ongeluk selecteren
Veilige v1-richting:
- behoud het huidige onderscheid tussen naam-click en row-click
- maak de row het primaire selectiedoel
- laat directorynaam expliciet het navigatiedoel blijven
- verander zo min mogelijk aan bestaande event routing
## 6. Discoverability en usability-risico
Wat verloren gaat als de checkbox verdwijnt:
- een direct zichtbaar affordance voor “dit item kan geselecteerd worden”
- een bekende multi-select cue voor minder keyboardgerichte gebruikers
- een expliciete toggle-target die modifier-free multi-select intuïtiever maakt
Wat ervoor terugkomt:
- meer ruimte voor naam, thumbnail/icoon en metadata
- rustiger lijstbeeld
- sterker file-manager gevoel als row highlight en current row goed zijn uitgewerkt
Belangrijk risico:
- muisgebruikers die modifier-click niet kennen, verliezen een zeer duidelijke multi-select affordance
- wildcard-selectie, Space, Shift+Arrow en Cmd/Ctrl+klik bestaan al, maar zijn minder zelfverklarend dan een checkbox
Daarom is een kleine hint waarschijnlijk nodig als de checkbox ooit verdwijnt, bijvoorbeeld:
- compacte helpertekst in een settings/shortcuts context
- of subtiele onboarding/hint buiten de lijst zelf
Voor de lijst zelf is het onwenselijk om extra tekst of badges toe te voegen; dat eet de gewonnen rust meteen weer op.
## 7. Thumbnail/interactie relatie
De thumbnail/icon-slot links van de naam maakt checkbox-verwijdering visueel aantrekkelijker, omdat die linkerkant van de rij dan eenvoudiger wordt:
- mediaslot
- naam
- overige kolommen
Dat levert echte UX-winst op:
- meer ruimte voor langere bestandsnamen
- minder visuele ruis links in de rij
- thumbnails en iconen krijgen een natuurlijkere positie
Voorwaarde is wel dat de mediaslot zelf geen select-control wordt. Het slot moet gewoon onderdeel van de rij blijven, niet een nieuwe semi-knop met onduidelijke semantiek.
Aanbevolen regel:
- mediaslot blijft decoratief/informatief
- rij zelf blijft het selectiedoel
- directorynaam blijft het open-doel
## 8. Regressierisico
Belangrijkste risicos:
- multi-select wordt minder discoverable voor muisgebruikers
- selectie zonder checkbox voelt minder expliciet bij gemengde file/directory lijsten
- current row en selected row kunnen visueel te dicht op elkaar komen
- modifier-click gedrag moet consequent blijven, anders voelt selectie fragiel
- bestaande flows als copy/move/delete/rename kunnen indirect onduidelijker worden als selectie minder zichtbaar wordt
Laag-risico observatie:
- technisch is checkbox-verwijdering waarschijnlijk klein, omdat de bestaande selection state al volwassen genoeg is
- UX-matig is het risico groter dan technisch, vooral voor discoverability en vertrouwen
## 9. Teststrategie
Als deze stap later geïmplementeerd wordt, moeten minimaal deze gevallen opnieuw expliciet getest worden.
UI smoke/regressie:
- checkbox-elementen ontbreken uit browse-rows
- mediaslot blijft aanwezig
- lijst blijft renderen met current row, selected row en active pane
- directorynaam blijft klikbaar
- file-naamgedrag blijft intact
Handmatige validatie:
- single select met muis
- Cmd/Ctrl+klik multi-select
- Shift+Arrow range-selectie
- Space toggle
- Escape clear
- wildcard-selectie
- current row zichtbaar in combinatie met selected rows
- copy/move/delete/rename op selectie
- directory-open via naam zonder onbedoelde selectie
Specifiek opnieuw te testen selectiegevallen:
- exact 1 geselecteerde file
- exact 1 geselecteerde directory
- meerdere files
- meerdere directories
- gemengde selectie
- parent entry `..` blijft buiten gewone selectie
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- checkbox-verwijdering nu nog niet uitvoeren
- eerst thumbnails/settings stabiliseren en de huidige lijstpresentatie laten settelen
- checkbox-verwijdering alleen doen als aparte UX-slice met expliciete handmatige validatie op multi-select discoverability
Expliciete beoordeling:
- technisch is checkbox-verwijdering waarschijnlijk haalbaar
- UX-matig is het nu nog borderline te vroeg
- de bestaande selectie-engine is sterk genoeg
- de grotere onzekerheid zit in begrijpelijkheid voor muisgebruikers en in het verlies van een duidelijke multi-select affordance
Daarom is de aanbevolen richting:
- checkbox-verwijdering pas uitvoeren in een aparte, doelbewuste slice
- daar tegelijk current-row/selected-row styling en click-targets definitief aanscherpen
- niet stilzwijgend meenemen in een andere feature
Kort oordeel:
- verantwoord, maar niet zonder aparte UX-focus
- dus: nog niet nu, wel als gerichte vervolgstap met laag technische maar middelmatig UX-risico
+212
View File
@@ -0,0 +1,212 @@
# Search v1
## 1. Doel
Search voegt nu waarde toe omdat de app inmiddels bruikbaar is als dual-pane file manager, maar nog volledig afhankelijk is van handmatige navigatie. Bij grotere directory trees kost dat te veel stappen.
Search moet passen binnen de bestaande dual-pane workflow:
- zoeken gebeurt vanuit het actieve paneel
- resultaten helpen navigatie versnellen
- de browse-workspace blijft het primaire werkvlak
- search is ondersteunend, geen nieuw hoofdscherm
## 2. Scope
Search v1 ondersteunt:
- zoeken op bestandsnaam
- zoeken op mapnaam
- case-insensitive substring matching
Search v1 ondersteunt niet:
- full-text content search
- indexer of achtergrondindexering
- regex
- geavanceerde querytaal
- saved searches
- fuzzy ranking
Aanbevolen zoekmodel voor v1:
- eenvoudige naamzoeking met substring match
- optioneel later uit te breiden naar glob of prefix filters, maar niet in deze slice
## 3. Zoekbereik
Mogelijke richtingen:
- zoeken vanaf `current path`
- zoeken vanaf de root van het actieve paneel
Aanbeveling voor v1:
- standaard zoeken vanaf `current path` van het actieve paneel
Reden:
- laag regressierisico
- voorspelbaar voor de gebruiker
- technisch goedkoper
- minder kans op trage scans van volledige opslagvolumes
- sluit goed aan op de huidige paneel-context
Gedrag:
- `activePane` bepaalt de zoekcontext
- de query zoekt recursief onder `currentPath` van dat paneel
- resultaten horen dus altijd bij de context van het actieve paneel op het moment van starten
Later uitbreidbaar:
- een simpele scope-toggle `Current folder tree` versus `Current root`
## 4. UI-richting
Aanbevolen v1-richting:
- aparte compacte search-modal
- geen verstoring van de dual-pane layout
Startmechanisme:
- alleen keyboard shortcut
- geen zichtbare knop in topbar
- geen zichtbare knop in functiebalk
Aanbevolen shortcut voor v1:
- Mac: `Cmd+Shift+F`
- Windows/Linux: `Ctrl+Shift+F`
Reden:
- sluit goed aan op bestaande zoekverwachtingen
- vermijdt extra visuele drukte in de UI
- vermijdt conflict met bestaande F1-F8 functiebalklogica
Modal-inhoud:
- titel `Search`
- actieve paneelcontext tonen
- zoekveld
- korte contextregel: `Searching under: <current path>`
- resultatenlijst onder het invoerveld
Gedrag bij resultaatklik:
- klik op resultaat opent de parentlocatie in het actieve paneel
- en selecteert het gevonden item
Voor directories:
- resultaatklik navigeert naar die directory in het actieve paneel
Voor files:
- resultaatklik navigeert naar de parent directory in het actieve paneel
- en selecteert de file
Dit sluit aan op de bestaande browse- en selectieflow zonder extra viewer/open-semantiek.
## 5. Resultaatsemantiek
Minimale velden per resultaat:
- `name`
- `path`
- `type` (`file` of `directory`)
- `parent_path`
- `root` of equivalent contextveld
Weergave in v1:
- primaire regel: naam
- secundaire regel: parent path of volledig path
- compacte type-indicatie (`file` / `dir`)
Aanbevolen presentatie:
- naam prominent
- parent path als muted secundaire regel
- type als kleine badge of label
## 6. Backend-impact
Aanbevolen endpoint:
- `GET /api/search?path=...&query=...`
Waarom een nieuw endpoint nodig is:
- browse-endpoint is contractueel directory listing
- search vereist recursieve traversal en resultaatlimieten
- aparte fout- en limietsemantiek is logisch
Securitymodel:
- `path` moet via bestaande `path_guard`
- zoekroot moet binnen whitelist vallen
- traversal en invalid root alias blijven via bestaande validatie lopen
- geen vrije scan buiten geconfigureerde roots
Implementatierichting:
- backend scant recursief vanaf gevalideerde startdirectory
- vergelijkt naam case-insensitive met query
- retourneert gemaximeerde lijst van matches
## 7. Performance en risico
Grootste risico:
- diepe directory trees op grote volumes
Aanbevolen v1-beheersing:
- result limit, bijvoorbeeld `100`
- optionele harde scan limit op aantal bezochte entries
- geen parallelle scanner
- geen indexer
Aanbevolen gedrag bij limiet:
- response bevat `truncated: true/false`
- als limiet bereikt is, toon alleen eerste resultaten
Timeouts:
- geen aparte timeoutsemantiek nodig in eerste slice als result-limiet en current-path scope klein blijven
## 8. Regressierisico
Belangrijkste risicos:
- browse-flow mag niet veranderen
- selectieflow mag niet onverwacht resetten buiten het actieve paneel
- keyboardflow mag niet botsen met bestaande shortcuts
- modal focus moet bestaande paneelnavigatie blokkeren zolang search open is
Beperkende keuzes die risico laag houden:
- aparte modal
- zoeken alleen op naam
- starten vanuit actief paneel
- resultaten openen in bestaand paneelgedrag
## 9. Teststrategie
Backend golden tests:
- lege resultaatlijst
- simpele file match
- simpele directory match
- search onder current path
- traversal geblokkeerd
- path not found
- invalid root alias
- result limit / truncated gedrag
UI smoke/regressietests:
- search-modal container aanwezig
- zoekveld aanwezig
- resultaatlijst container aanwezig
- keyboard wiring voor `Cmd/Ctrl+Shift+F`
Handmatige validatie:
- zoeken vanuit links paneel raakt alleen linkse context
- zoeken vanuit rechts paneel raakt alleen rechtse context
- resultaatklik navigeert correct
- resultaatklik op directory opent directory
- resultaatklik op file opent parent + selecteert file
- `Escape` sluit modal
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- nieuw backend-endpoint `GET /api/search`
- recursieve naamzoeking
- scope = `currentPath` van actief paneel
- case-insensitive substring match
- harde result limit, bijvoorbeeld `100`
- aparte compacte search-modal
- resultaatklik hergebruikt bestaande paneelnavigatie en selectie
Niet aanbevelen voor v1:
- root-brede zoeking als default
- content search
- indexer
- regex
- aparte zoekpagina
Dit levert een bruikbare eerste search op zonder de huidige dual-pane file manager semantiek te verstoren.
+177
View File
@@ -0,0 +1,177 @@
# Theme Selection v1
## 1. Doel
Theme-selectie voegt nu waarde toe omdat de UI al een nette light/dark basis heeft, maar nog geen expliciet onderscheid maakt tussen:
- `theme`: de stijlset
- `mode`: light of dark binnen die stijlset
Dat onderscheid maakt de UI uitbreidbaar zonder de dagelijkse snelle UX kwijt te raken. Dit past logisch in de bestaande Settings-structuur:
- `General` voor functionele voorkeuren
- `Interface` voor theme-keuze
- `Logs` voor recente acties
## 2. Scope
Theme Selection v1 omvat:
- nieuw Settings-tabblad: `Interface`
- daarin alleen een pulldown/select: `Theme`
- bestaande snelle dark/light toggle blijft in de hoofdinterface bestaan
- beide keuzes worden opgeslagen in bestaande SQLite settings-opslag
- app leest beide waarden bij startup via backend en past die direct toe
Niet in scope:
- vrije CSS-bestandskeuze
- padinvoer
- upload van themes
- custom theme editor
- theme packs van externe bron
## 3. Theme-model
Aanbevolen model voor v1:
- werk met een whitelist van toegestane theme keys
- werk daarnaast met een aparte whitelist van toegestane color modes
- sla beide als strings op in settings
Aanbevolen settings voor v1:
- `selected_theme: string | null`
- `selected_color_mode: string | null`
Whitelist v1:
- `selected_theme`
- `default`
- `selected_color_mode`
- `dark`
- `light`
Waarom dit veiliger en eenvoudiger is dan bestandsselectie:
- geen vrije filesystemtoegang nodig
- geen risico op ongeldige of kwaadaardige CSS-inhoud
- geen extra upload- of assetbeheer
- duidelijke validatie in backend mogelijk
- stabiel contract tussen backend setting en frontend rendering
## 4. Settings-opslag
Nieuwe settings in bestaande settings-opslag:
- `selected_theme`
- `selected_color_mode`
Semantiek:
- `selected_theme = null` betekent: fallback naar veilige default `default`
- `selected_color_mode = null` betekent: fallback naar veilige default `dark`
- onbekende opgeslagen waarden betekenen: negeren en fallback toepassen
Aanbevolen effectieve defaults:
- theme -> `default`
- mode -> `dark`
## 5. Settings UI
Tabs in Settings worden:
- `General`
- `Interface`
- `Logs`
`Interface` bevat in v1 alleen:
- label: `Theme`
- een select/pulldown met toegestane themes
Belangrijk:
- geen dark/light selector in `Settings > Interface`
- dark/light blijft een snelle hoofdinterface-actie
Aanbevolen v1-UX:
- select toont huidige theme-keuze
- gebruiker kiest andere waarde
- opslaan gebeurt via bestaande settings-saveflow
- keuze wordt direct toegepast in de UI na succesvolle backend-save
## 6. Frontend-impact
Frontend moet bij startup vroeg settings laden en daaruit beide waarden ophalen:
- `selected_theme`
- `selected_color_mode`
Daarna bepaalt frontend het effectieve UI-theme. Aanbevolen intern model:
- `data-theme="default-dark"`
- `data-theme="default-light"`
Aanbevolen volgorde:
1. `GET /api/settings`
2. bepaal effectief theme + mode
3. zet `document.documentElement.dataset.theme`
4. initialiseer de rest van de UI
Relatie met bestaande light/dark toggle:
- toggle blijft bestaan in de hoofdinterface
- toggle wijzigt alleen `selected_color_mode`
- toggle schrijft dus naar backend, niet naar localStorage
Reden:
- snelle dagelijkse UX blijft behouden
- `Settings > Interface` blijft schoon en beperkt tot theme-keuze
- theme en mode blijven conceptueel gescheiden
## 7. Backend-impact
Bestaande settings-API wordt uitgebreid met:
- `selected_theme`
- `selected_color_mode`
Benodigd:
- whitelistvalidatie op backend
- onbekende waarden blokkeren bij write
- bestaande settings repository/service/API uitbreiden
Niet nodig:
- nieuwe dependency
- vrije filesystemtoegang
- nieuwe asset-uploadroute
## 8. Regressierisico
Belangrijkste risico's:
- startup-volgorde: theme moet vroeg genoeg worden toegepast om flicker te beperken
- bestaande theme-toggle logica conflicteert nu nog met localStorage
- onbekende opgeslagen theme/mode-waarden moeten veilig terugvallen
- Settings-tabcomplexiteit mag niet onnodig toenemen
Belangrijkste mitigaties:
- één centrale frontendfunctie die theme en mode uit backend toepast
- localStorage volledig verwijderen als leidende theme-bron
- backend whitelistvalidatie
- fallback naar `default-dark`
## 9. Teststrategie
Backend golden tests:
- default `selected_theme`
- default `selected_color_mode`
- geldige theme save (`default`)
- geldige color mode save (`dark`, `light`)
- ongeldige theme key wordt geblokkeerd
- ongeldige color mode wordt geblokkeerd
- settings response bevat beide velden
UI smoke/regressietests:
- `Settings` bevat tabs `General`, `Interface`, `Logs`
- `Interface` tab bevat alleen theme select
- hoofdinterface bevat nog steeds dark/light toggle
- app leest beide settings via backend
- fallback bij ontbrekende/ongeldige waarde breekt startup niet
Handmatige validatie:
- theme wijzigen in `Settings > Interface`
- mode wisselen via toggle in de hoofdinterface
- app herladen en controleren dat beide keuzes behouden blijven
- controle dat light/dark correct doorwerken in modals, panelen en editor/viewers
## 10. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- voeg `Interface` tab toe
- voeg `selected_theme` en `selected_color_mode` toe aan bestaande settings-opslag
- werk alleen met veilige whitelists
- houd v1 beperkt tot:
- theme: `default`
- mode: `dark|light`
- laat startup en toggle beide backendpersistente settings gebruiken
- fallback altijd veilig naar `default-dark`
Deze richting is:
- simpel
- veilig
- onderhoudbaar
- duidelijk uitbreidbaar naar extra themes, zonder de dagelijkse dark/light UX opnieuw te moeten ontwerpen
+226
View File
@@ -0,0 +1,226 @@
# Thumbnail v1 Design
## 1. Doel
Thumbnails voegen vooral waarde toe in directories met veel afbeeldingsbestanden: de gebruiker kan sneller visueel herkennen welk bestand relevant is zonder elk bestand afzonderlijk te openen. Binnen de huidige dual-pane workflow moet dit ondersteunend blijven en niet omslaan naar een galerij-UI. De lijst blijft primair een file-managerlijst.
Een aan/uit instelling is nodig omdat thumbnails extra I/O, extra requests en visuele drukte toevoegen. Sommige gebruikers willen maximale performance en een compactere lijst, vooral op grote directories of netwerkvolumes.
Belangrijke afbakening voor deze stap:
- Thumbnail v1 gaat alleen over:
- thumbnails voor image files
- een vaste mediaslot links van de naam
- iconen/placeholder als er geen thumbnail is
- een persistente setting in `Settings > General`
- het voorlopig behouden van het bestaande selectievakje
## 2. Scope
Aanbevolen veilige v1-scope:
- Wel:
- image thumbnails voor `jpg`, `jpeg`, `png`, `webp`
- Niet in v1:
- video thumbnails
- pdf thumbnails
- generieke document thumbnails
- server-side media processing pipeline
- embedded metadata-based speciale rendering
- wijziging van selectiegedrag
- verwijderen van het selectievakje
Aanbeveling: beperk v1 strikt tot browser-native renderbare image files. Dat houdt de backend klein, voorkomt extra dependencies en minimaliseert regressierisico.
## 3. UI-gedrag
### Thumbnail / icoon positie
Thumbnails of iconen komen altijd links van de bestandsnaam, in een vaste visuele slot binnen de naamkolom.
### Vaste uitlijning
Elke rij krijgt altijd dezelfde linker mediaslot:
- thumbnail als beschikbaar en thumbnails ingeschakeld zijn
- anders een passend icoon of placeholder
Dit geldt voor alle rijen, zodat naamkolom en tekstuitlijning stabiel blijven.
Aanbevolen gedrag:
- vaste slotbreedte, bijvoorbeeld 20 tot 28 px voor compacte v1
- thumbnails klein en uniform
- directories gebruiken een folder-icoon
- non-image files gebruiken een file-icoon
- image files zonder thumbnail of met thumbnails uit: ook file-icoon of neutrale image-placeholder
### Gedrag als thumbnails uit staan
Als `Show thumbnails` uit staat:
- directories tonen een folder-icoon
- files tonen een file-icoon
- de mediaslot links blijft bestaan
- de lijstuitlijning blijft identiek aan de toestand met thumbnails aan
Dit voorkomt visuele sprongen tussen beide modi.
## 4. Settings-integratie
De instelling komt in:
- `F1` -> `Settings` -> `General`
- settingnaam: `Show thumbnails`
Belangrijk:
- niet opslaan in browser `localStorage`
- wel persistent opslaan via backend in SQLite
- frontend leest de instelling bij app-start of bij openen van de settingsmodal
- wijziging wordt via backend opgeslagen en daarna direct toegepast in de UI
Aanbevolen model:
- aparte `settings` tabel in SQLite met key/value opslag
- minimaal sleutelgebruik in v1: `show_thumbnails`
## 5. Backend-impact
Aanbevolen minimale backenduitbreiding:
- aparte `settings` tabel
- eenvoudige read/write API, bijvoorbeeld:
- `GET /api/settings`
- `POST /api/settings`
- apart read-only thumbnail-endpoint, bijvoorbeeld:
- `GET /api/files/thumbnail?path=...`
Waarom apart endpoint:
- browse-responses blijven klein
- thumbnail-fetches kunnen lazy gebeuren
- bestaande `path_guard` en whitelist-validatie blijven leidend
## 6. Thumbnail-bron
Aanbevolen v1-richting:
- aparte read-only thumbnail/image route
- geen inline base64 in browse-response
- geen volledige browse-response verrijken met binaire data
Veilige v1-aanpak:
- thumbnails alleen voor ondersteunde image files
- kleine preview in vaste slot
- als server-side downscale zonder dependency niet goed haalbaar is, dan liever een eenvoudige image-serving route gecombineerd met kleine frontendweergave en lazy loading
Geen aparte disk-cache in v1.
## 7. Lijstlayout en selectie-impact
### Mediaslot / icoon-slot
De naamkolom krijgt links een compacte vaste structuur:
- selectievakje
- mediaslot (thumbnail of icoon)
- naamtekst
Dat houdt de UI voorspelbaar en ondersteunt zowel thumbnail- als niet-thumbnailweergave.
### Selectie-UX
De bestaande selectie-UX blijft leidend:
- row highlight voor selectie
- current row styling
- active pane styling
- checkbox-toggle
- `Cmd/Ctrl+klik`
- `Shift+Arrow`
- wildcard-selectie
- keyboardnavigatie
### Checkbox behouden of verwijderen?
Voor Thumbnail v1 is de keuze expliciet:
- **checkbox blijft voorlopig bestaan**
Dit is een tijdelijke concessie voor regressiebeheersing, niet de definitieve eindrichting.
Afweging:
- Screen real estate:
- ja, checkbox + thumbnail-slot kost ruimte
- maar de mediaslot kan compact blijven en de checkbox is al onderdeel van de huidige interactie
- Regressierisico:
- verwijderen van de checkbox verandert zichtbaar en functioneel selectiegedrag
- dat raakt multi-select en discoverability
- Bestaande multi-select flows:
- checkbox is nog steeds een directe, expliciete multi-select affordance
- Keyboardgebruik:
- keyboard blijft werken zonder checkbox, maar checkbox ondersteunt muisgebruikers duidelijk
- Wildcard-selectie / Cmd/Ctrl+klik / Shift+Arrow:
- die blijven belangrijk, maar vervangen de checkbox niet volledig als expliciete UI affordance
Expliciete afbakening:
- checkbox-verwijdering is **niet** in scope voor Thumbnail v1
- checkbox-verwijdering wordt **niet** stilzwijgend meegenomen in deze stap
- checkbox-verwijdering vereist een aparte latere UX-slice met eigen regressie-evaluatie
## 8. Performance en risico
Belangrijkste risico's:
- directories met veel afbeeldingen genereren veel requests
- grote originele afbeeldingen kunnen de lijst vertragen
- netwerkmounts geven extra latency
- checkbox + mediaslot + naam kan horizontale ruimte krapper maken
Aanbevolen mitigaties in v1:
- thumbnails alleen voor ondersteunde image files
- lazy loading aan frontendzijde
- beperkt aantal gelijktijdige thumbnail-requests
- kleine vaste slotgrootte
- geen server-side cachelaag in v1
## 9. Regressierisico
Belangrijkste regressierisico's:
- bestandslijst wordt te druk
- naamkolom wordt te smal
- selectie/current row styling wordt visueel minder duidelijk
- browse-performance daalt in grote directories
- checkbox-verwijdering zou onbedoeld mee kunnen liften op de thumbnailstap
Beheersmaatregelen:
- thumbnails klein houden
- checkbox behouden in v1
- vaste mediaslot gebruiken
- selectie/current row prioriteit geven boven decoratie
- geen wijziging aan klikgedrag, keyboardflow of selection model
## 10. Teststrategie
Backend golden tests:
- settings default response
- settings update persistence
- thumbnail endpoint success voor ondersteund imagebestand
- thumbnail endpoint not found
- traversal blocked
- invalid root alias
- non-image blocked of nette unsupported fout
UI smoke/regressietests:
- `Settings > General` bevat `Show thumbnails`
- instelling wordt opgehaald via backend, niet via localStorage
- mediaslot bestaat ook als thumbnails uit staan
- directories tonen folder-icoon zonder thumbnails
- files tonen file-icoon zonder thumbnails
- lijst blijft renderen met checkbox + mediaslot + naam
- selectie/current row blijven duidelijk
Handmatige validatie:
- toggle aan/uit werkt direct
- instelling blijft behouden na reload/herstart
- grote directory blijft bruikbaar
- image rows tonen thumbnail links van naam
- non-image rows en directories blijven netjes uitgelijnd
- checkbox en selectiegedrag blijven werken
## 11. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- scope:
- alleen `jpg/jpeg/png/webp`
- default instelling:
- `off`
- opslagmodel:
- SQLite `settings` tabel met `show_thumbnails`
- UI:
- vaste mediaslot links van de naam
- thumbnail waar beschikbaar
- anders icoon/placeholder
- checkbox blijft voorlopig bestaan
- backend:
- eenvoudige settings read/write API
- apart read-only thumbnail-endpoint met bestaande path/securityvalidatie
- performance:
- lazy loading
- geen disk-cache of zware thumbnailpipeline in v1
Expliciete positionering:
- de huidige keuze om checkbox te behouden is **tijdelijk** en **regressiegedreven**
- de gewenste compactere eindrichting zonder checkbox kan later apart ontworpen worden
- die stap hoort niet bij Thumbnail v1 en moet als aparte UX-slice worden behandeld
Dit levert een kleine, veilige eerste stap op: thumbnails als optionele verrijking van de bestaande lijst, met stabiele uitlijning, minimale visuele verstoring en zonder selectie-regressies mee te slepen in dezelfde change.
+213
View File
@@ -0,0 +1,213 @@
# Thumbnail v1 Design
## 1. Doel
Thumbnails voegen vooral waarde toe in directories met veel afbeeldingsbestanden: de gebruiker kan sneller visueel herkennen welk bestand relevant is zonder elk bestand afzonderlijk te openen. Binnen de huidige dual-pane workflow moet dit ondersteunend blijven en niet omslaan naar een galerij-UI. De lijst blijft primair een file-managerlijst.
Een aan/uit instelling is nodig omdat thumbnails extra I/O, extra requests en visuele drukte toevoegen. Sommige gebruikers willen maximale performance en een compactere lijst, vooral op grote directories of netwerkvolumes.
## 2. Scope
Aanbevolen veilige v1-scope:
- Wel:
- image thumbnails voor `jpg`, `jpeg`, `png`, `webp`
- Niet in v1:
- video thumbnails
- pdf thumbnails
- generieke document thumbnails
- server-side media processing pipeline
- embedded metadata-based speciale rendering
Aanbeveling: beperk v1 strikt tot browser-native renderbare image files. Dat houdt de backend klein, voorkomt extra dependencies en minimaliseert regressierisico.
## 3. UI-gedrag
### Thumbnail / icoon positie
Thumbnails of iconen komen altijd links van de bestandsnaam, in een vaste visuele slot binnen de naamkolom.
### Vaste uitlijning
Elke rij krijgt altijd dezelfde linker mediaslot:
- thumbnail als beschikbaar en thumbnails ingeschakeld zijn
- anders een passend icoon of placeholder
Dit geldt voor alle rijen, zodat naamkolom en tekstuitlijning stabiel blijven.
Aanbevolen gedrag:
- vaste slotbreedte, bijvoorbeeld 20 tot 28 px voor compacte v1
- thumbnails klein en uniform
- directories gebruiken een folder-icoon
- non-image files gebruiken een file-icoon
- image files zonder thumbnail of met thumbnails uit: ook file-icoon of neutrale image-placeholder
### Gedrag als thumbnails uit staan
Als `Show thumbnails` uit staat:
- directories tonen een folder-icoon
- files tonen een file-icoon
- de mediaslot links blijft bestaan
- de lijstuitlijning blijft identiek aan de toestand met thumbnails aan
Dit voorkomt visuele sprongen tussen beide modi.
## 4. Settings-integratie
De instelling komt in:
- `F1` -> `Settings` -> `General`
- settingnaam: `Show thumbnails`
Belangrijk:
- niet opslaan in browser `localStorage`
- wel persistent opslaan via backend in SQLite
- frontend leest de instelling bij app-start of bij openen van de settingsmodal
- wijziging wordt via backend opgeslagen en daarna direct toegepast in de UI
Aanbevolen model:
- aparte `settings` tabel in SQLite met key/value opslag
- minimaal sleutelgebruik in v1: `show_thumbnails`
## 5. Backend-impact
Aanbevolen minimale backenduitbreiding:
- aparte `settings` tabel
- eenvoudige read/write API, bijvoorbeeld:
- `GET /api/settings`
- `POST /api/settings`
- apart read-only thumbnail-endpoint, bijvoorbeeld:
- `GET /api/files/thumbnail?path=...`
Waarom apart endpoint:
- browse-responses blijven klein
- thumbnail-fetches kunnen lazy gebeuren
- bestaande `path_guard` en whitelist-validatie blijven leidend
## 6. Thumbnail-bron
Aanbevolen v1-richting:
- aparte read-only thumbnail/image route
- geen inline base64 in browse-response
- geen volledige browse-response verrijken met binaire data
Veilige v1-aanpak:
- thumbnails alleen voor ondersteunde image files
- kleine preview in vaste slot
- als server-side downscale zonder dependency niet goed haalbaar is, dan liever een eenvoudige image-serving route gecombineerd met kleine frontendweergave en lazy loading
Geen aparte disk-cache in v1.
## 7. Lijstlayout en selectie-impact
### Mediaslot / icoon-slot
De naamkolom krijgt links een compacte vaste structuur:
- selectievakje
- mediaslot (thumbnail of icoon)
- naamtekst
Dat houdt de UI voorspelbaar en ondersteunt zowel thumbnail- als niet-thumbnailweergave.
### Selectie-UX
De bestaande selectie-UX moet leidend blijven:
- row highlight voor selectie
- current row styling
- active pane styling
- checkbox-toggle
- `Cmd/Ctrl+klik`
- `Shift+Arrow`
- wildcard-selectie
- keyboardnavigatie
### Checkbox behouden of verwijderen?
Aanbevolen keuze voor Thumbnail v1: **checkbox behouden**.
Afweging:
- Screen real estate:
- ja, checkbox + thumbnail-slot kost ruimte
- maar de mediaslot kan compact blijven en de checkbox is al onderdeel van de huidige interactie
- Regressierisico:
- verwijderen van de checkbox verandert zichtbaar en functioneel selectiegedrag
- dat raakt multi-select en discoverability
- Bestaande multi-select flows:
- checkbox is nog steeds een directe, expliciete multi-select affordance
- Keyboardgebruik:
- keyboard blijft werken zonder checkbox, maar checkbox ondersteunt muisgebruikers duidelijk
- Wildcard-selectie / Cmd/Ctrl+klik / Shift+Arrow:
- die blijven belangrijk, maar vervangen de checkbox niet volledig als expliciete UI affordance
Conclusie:
- checkbox nu verwijderen is een aparte UX-beslissing, geen thumbnailbeslissing
- dat verdient een aparte stap met eigen regressie-evaluatie
- voor Thumbnail v1 is behoud van checkbox de veiligste route met laag regressierisico
## 8. Performance en risico
Belangrijkste risico's:
- directories met veel afbeeldingen genereren veel requests
- grote originele afbeeldingen kunnen de lijst vertragen
- netwerkmounts geven extra latency
- checkbox + mediaslot + naam kan horizontale ruimte krapper maken
Aanbevolen mitigaties in v1:
- thumbnails alleen voor ondersteunde image files
- lazy loading aan frontendzijde
- beperkt aantal gelijktijdige thumbnail-requests
- kleine vaste slotgrootte
- geen server-side cachelaag in v1
## 9. Regressierisico
Belangrijkste regressierisico's:
- bestandslijst wordt te druk
- naamkolom wordt te smal
- selectie/current row styling wordt visueel minder duidelijk
- browse-performance daalt in grote directories
- discussie over checkbox-verwijdering wordt onbedoeld meegesleept in de thumbnailstap
Beheersmaatregelen:
- thumbnails klein houden
- checkbox behouden in v1
- vaste mediaslot gebruiken
- selectie/current row prioriteit geven boven decoratie
- geen wijziging aan klikgedrag, keyboardflow of selection model
## 10. Teststrategie
Backend golden tests:
- settings default response
- settings update persistence
- thumbnail endpoint success voor ondersteund imagebestand
- thumbnail endpoint not found
- traversal blocked
- invalid root alias
- non-image blocked of nette unsupported fout
UI smoke/regressietests:
- `Settings > General` bevat `Show thumbnails`
- instelling wordt opgehaald via backend, niet via localStorage
- mediaslot bestaat ook als thumbnails uit staan
- directories tonen folder-icoon zonder thumbnails
- files tonen file-icoon zonder thumbnails
- lijst blijft renderen met checkbox + mediaslot + naam
- selectie/current row blijven duidelijk
Handmatige validatie:
- toggle aan/uit werkt direct
- instelling blijft behouden na reload/herstart
- grote directory blijft bruikbaar
- image rows tonen thumbnail links van naam
- non-image rows en directories blijven netjes uitgelijnd
- checkbox en selectiegedrag blijven werken
## 11. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- scope:
- alleen `jpg/jpeg/png/webp`
- default instelling:
- `off`
- opslagmodel:
- SQLite `settings` tabel met `show_thumbnails`
- UI:
- vaste mediaslot links van de naam
- thumbnail waar beschikbaar
- anders icoon/placeholder
- checkbox behouden in deze fase
- backend:
- eenvoudige settings read/write API
- apart read-only thumbnail-endpoint met bestaande path/securityvalidatie
- performance:
- lazy loading
- geen disk-cache of zware thumbnailpipeline in v1
Expliciete aanbeveling over checkbox:
- **niet verwijderen in Thumbnail v1**
- als wenselijk, behandel checkbox-verwijdering als aparte latere UX-slice
- reden: dat onderwerp raakt selectiegedrag en discoverability te sterk om mee te liften op thumbnails
Dit levert een kleine, veilige eerste stap op: thumbnails als optionele verrijking van de bestaande lijst, met stabiele uitlijning, minimale visuele verstoring en zonder onnodige selectie-regressies.
@@ -0,0 +1,288 @@
# UI_ACTION_SHORTCUTS_V1_DESIGN.md
## 1. Doel en scope
Action shortcuts v1 maken de bestaande functiebalk sneller bruikbaar zonder de UI-semantiek te veranderen. Het doel is niet om nieuwe acties te ontwerpen, maar om de bestaande acties via toetsen te starten op exact dezelfde manier als via de functiebalk.
Doel:
- de huidige functiebalkacties sneller bereikbaar maken
- de dual-pane workflow dichter bij een Midnight Commander-achtige bediening brengen
- een bruikbare eerste set bieden die ook op Mac praktisch inzetbaar blijft
In scope:
- keyboard-start van bestaande functiebalkacties
- centrale shortcut-dispatch op basis van `activePane` en huidige selectie
- veilige focusguards zodat invoervelden en modals niet worden verstoord
- voorbereiding op latere visuele hints in de functiebalk
Out of scope:
- geen backendwijzigingen
- geen nieuwe acties buiten de bestaande functiebalk
- geen globale override van agressieve browser- of OS-shortcuts
- geen nieuwe dependencies
- geen extra keyboardnavigatie buiten action-triggering
---
## 2. Acties in scope
Deze ontwerpstap werkt uit voor:
- `View`
- `Edit`
- `Copy`
- `Move`
- `Rename`
- `MKdir`
- `Delete`
Semantisch uitgangspunt:
- elke shortcut triggert exact dezelfde action-handler als de corresponderende knop in de functiebalk
- enabled/disabled logica blijft centraal en wordt niet dubbel geïmplementeerd in aparte keyboardlogica
- als een knop in de huidige context disabled zou zijn, doet de shortcut geen destructieve fallback en start geen alternatieve flow
---
## 3. Toetsmapping
### Primaire mapping: F3 t/m F8
Voorgestelde primaire mapping:
- `F3` = `View`
- `F4` = `Edit`
- `F5` = `Copy`
- `F6` = `Move`
- `F7` = `MKdir`
- `F8` = `Delete`
Aanvullend:
- `Rename` krijgt in v1 geen vaste F-toets, omdat de klassieke mapping al dicht bezet is en `Rename` functioneel dichter bij contextactie dan bij de vaste MC-kern ligt
- `Rename` krijgt daarom een aparte, browser-veilige alternatieve shortcut
### Mac-vriendelijke alternatieven
F-toetsen zijn op Mac-toetsenborden vaak verborgen achter systeemfuncties of alleen bruikbaar met extra modifiers. Daarom krijgt v1 ook expliciete alternatieven.
Voorstel:
- `Alt+3` = `View`
- `Alt+4` = `Edit`
- `Alt+5` = `Copy`
- `Alt+6` = `Move`
- `Alt+7` = `MKdir`
- `Alt+8` = `Delete`
- `Alt+R` = `Rename`
Motivatie:
- numerieke mapping blijft mentaal gekoppeld aan `F3`-`F8`
- `Alt+R` is kort, semantisch duidelijk en minder conflictgevoelig dan veel andere browsercombinaties
- deze combinaties zijn compacter en realistischer op Mac dan het afdwingen van functietoetsgebruik
### Veiligheid in browsercontext
V1 kiest bewust geen mappings die sterk botsen met standaard browsergedrag, zoals:
- `Cmd+L`
- `Cmd+R`
- `Cmd+W`
- `Cmd+F`
- `Cmd+S`
- `Cmd+P`
- `Cmd+Backspace`
Ook geen pure lettershortcuts zonder modifier, omdat die te snel botsen met focus, tekstinvoer en toekomstige editorinteractie.
Belangrijk:
- `F3`-`F8` worden alleen gebruikt als extra shortcutlaag waar de browser/het OS dit toelaat
- de `Alt+...` varianten vormen de praktische browservriendelijke fallback
---
## 4. Focusregels
Shortcuts zijn alleen actief als de UI in browse-/paneelmodus zit en focus niet in een interactieve control staat.
### Shortcuts actief wanneer
- focus op `body`, paneelcontainer of bestandslijst staat
- geen modal met eigen invoer open is
- geen wildcard-popup open is
- geen editor-textarea focus heeft
- geen inputcontrol focus heeft
### Shortcuts niet actief wanneer focus in
- `input`
- `textarea`
- `select`
- `button`
- checkbox
- elk element met `contenteditable`
### Extra modalregels
- als `View` modal open is: geen action shortcuts voor onderliggende paneelacties
- als `Edit` modal open is: geen action shortcuts voor onderliggende paneelacties
- als wildcard popup open is: action shortcuts uitgeschakeld behalve popup-eigen `Enter`/`Escape`
### Browser/OS-collision mitigatie
- alleen exact ondersteunde combinaties afvangen
- geen brede `preventDefault()` voor andere toetsen
- bij twijfel niet afvangen; browser/OS behoudt dan prioriteit
---
## 5. Relatie met functiebalk en selectie
Kernregel:
- keyboard shortcuts gebruiken exact dezelfde centrale action-functies als de functiebalkknoppen
Dat betekent:
- `View` shortcut roept dezelfde open-viewer flow aan als de `View` knop
- `Edit` shortcut roept dezelfde open-editor flow aan als de `Edit` knop
- `Copy`/`Move` shortcut gebruiken dezelfde batch-startlogica als de bestaande knoppen
- `Rename`, `MKdir`, `Delete` shortcut gebruiken dezelfde validatie- en UI-flow als de bestaande knoppen
### Geen selectie
- `View`: geen actie
- `Edit`: geen actie
- `Copy`: geen actie
- `Move`: geen actie
- `Rename`: geen actie
- `MKdir`: wel toegestaan, op actief paneel
- `Delete`: geen actie
### Exact 1 selectie
- `View`: alleen voor ondersteunde file
- `Edit`: alleen voor ondersteunde tekstfile
- `Copy`: alleen als selectie backend-geldig is
- `Move`: alleen als selectie backend-geldig is
- `Rename`: toegestaan
- `MKdir`: toegestaan
- `Delete`: toegestaan
### Meerdere selecties
- `View`: niet toegestaan
- `Edit`: niet toegestaan
- `Copy`: toegestaan als alle geselecteerde items voldoen aan huidige backend-scope
- `Move`: toegestaan als alle geselecteerde items voldoen aan huidige backend-scope
- `Rename`: niet toegestaan
- `MKdir`: toegestaan
- `Delete`: toegestaan
### Directory/file-selectie
- zolang backend `copy/move` file-only is:
- selectie die directories bevat blokkeert `Copy`
- selectie die directories bevat blokkeert `Move`
- `View` en `Edit` blokkeren directories
- `Delete` volgt bestaande backendregels
- `Rename` volgt bestaande single-selection regel
### Disabled gedrag bij toetsgebruik
- geen verborgen fallback
- geen half-uitgevoerde actie
- optioneel compacte feedback via bestaande statusregel, maar v1 mag ook stil no-op blijven als dit consistenter is met huidige knopdisabled-semantiek
Voorkeur v1:
- als actie disabled is, shortcut doet niets en toont hooguit compacte statusfeedback als dat al in bestaande UI-patterns past
---
## 6. UX-regels
### Visuele hints in de functiebalk
Voorstel v1:
- subtiele shortcut-hints per knop toelaten, maar klein houden
- voorbeeld later mogelijk:
- `F3 View`
- `F4 Edit`
- `F5 Copy`
- Mac-alternatieven hoeven niet permanent zichtbaar in de hoofdbalk om ruis te vermijden
Voor deze ontwerpstap geldt:
- de functiebalk moet voorbereid zijn op zulke hints
- de daadwerkelijke implementatie kan starten zonder direct alle hints zichtbaar te maken
### Niet-beschikbare acties
- als actie disabled is in de huidige context, mag de shortcut die actie niet forceren
- gedrag moet gelijk zijn aan klikken op een disabled knop: er gebeurt functioneel niets
### Unsupported acties
- `View` of `Edit` op niet-ondersteunde filetypes:
- shortcut opent geen alternatieve flow
- zelfde fout- of disabled-behandeling als via knop
- `Copy`/`Move` op directoryselectie terwijl backend dit niet ondersteunt:
- zelfde blokkade als via knop
### Consistentie-eis
- shortcuts zijn geen tweede API-laag
- de functiebalk blijft leidend; keyboard is alleen een alternatieve triggerroute
---
## 7. Impactanalyse
Waarschijnlijk te wijzigen frontendbestanden:
- `webui/html/app.js`
- `webui/html/index.html`
- `webui/html/style.css`
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
### Verwachte code-impact
`app.js`:
- uitbreiding van centrale keyboard dispatcher
- mappingtabel voor action shortcuts
- centrale guards voor modal/input/focusstatus
- hergebruik van bestaande action handlers, niet dupliceren
`index.html`:
- mogelijk kleine markup-aanpassingen om shortcutlabels in functiebalk te kunnen tonen
- geen structurele layoutwijziging nodig
`style.css`:
- eventueel compacte styling voor functietoetsbadges of hints
- geen redesign nodig
### Regressierisico
Belangrijkste risico's:
- conflicten met bestaande keyboard navigation (`Tab`, `Arrow`, `Space`, `Escape`)
- shortcuts vuren terwijl input of modal focus heeft
- dubbele actie-aanroep als keyboard en knoplogica niet centraal gedeeld worden
- browser/OS-shortcuts per platform gedragen zich anders
Mitigatie:
- centrale `shouldHandleActionShortcut(event)` guard
- bestaande knophandlers of action-functions als single source of truth
- alleen exacte combinaties afvangen
- platform-onafhankelijke fallbacklogica beperkt en expliciet houden
---
## 8. Teststrategie
### Smoke/regressietests
Geautomatiseerd uit te breiden waar passend:
- UI smoke test blijft controleren dat functiebalk aanwezig is
- optioneel extra check dat knoplabels of data-attributes voor actions stabiel aanwezig zijn
- geen zware browser-E2E vereist voor deze stap
### Handmatige validatie
Essentieel bij implementatie:
- `F3`-`F8` triggeren de juiste acties waar ondersteund
- `Alt+3`-`Alt+8` werken als Mac-vriendelijke fallback
- `Alt+R` triggert `Rename`
- shortcuts werken alleen op actief paneel
- shortcuts respecteren disabled toestand bij:
- geen selectie
- meerdere selecties
- directoryselectie
- unsupported filetypes
- editor modal, viewer modal en wildcard popup blokkeren onderliggende action shortcuts correct
- bestaande keyboard navigation blijft intact
### Regressiechecks
- klikgedrag in de bestandslijst blijft intact
- current-row navigatie blijft intact
- wildcard popup shortcuts blijven werken
- functiebalkknoppen en keyboardtrigger geven identiek resultaat
+202
View File
@@ -0,0 +1,202 @@
# UI Advanced Selection v1
## 1. Doel
Advanced Selection v1 maakt selectiegedrag in de dual-pane UI natuurlijker voor dagelijks file-managergebruik, zonder de bestaande interacties te breken. De uitbreiding richt zich op twee bekende patronen:
- range-selectie via `Shift + ArrowUp/ArrowDown`
- niet-aangrenzende selectie via `Cmd + klik` op macOS en `Ctrl + klik` op niet-Mac
Het doel is niet om een volledige desktop file manager exact te emuleren, maar om de meest bruikbare selectiepatronen toe te voegen met laag regressierisico.
## 2. Nieuwe Interacties In Scope
In scope voor v1:
- `Shift + ArrowDown`
- `Shift + ArrowUp`
- `Cmd + klik` op Mac
- `Ctrl + klik` op niet-Mac
Niet in scope in deze stap:
- `Shift + klik` range-selectie
- `Ctrl/Cmd + A`
- `Alt`-gebaseerde selectie
- OS-specifieke volledige parity met Finder/Explorer
- backendwijzigingen
## 3. Gewenst Gedrag
### Shift + ArrowDown / ArrowUp
`Shift + ArrowDown` en `Shift + ArrowUp` breiden de selectie uit of verkleinen die vanaf een vast selectie-anker binnen het actieve paneel.
Voorstel:
- elk paneel krijgt naast `currentRowIndex` en `selectedItems` ook `selectionAnchorIndex`
- als nog niets geselecteerd is:
- eerste `Shift + ArrowDown/ArrowUp` selecteert de current row en de eerstvolgende rij in de gekozen richting
- `selectionAnchorIndex` wordt gezet op de oorspronkelijke current row
- als al een range actief is:
- verdere `Shift + ArrowDown/ArrowUp` verplaatst de actieve rand van de selectie
- selectie groeit of krimpt tussen `selectionAnchorIndex` en de nieuwe `currentRowIndex`
- current row blijft het bewegende uiteinde van de range
### Ankerpunt
Het range-anker begint op het moment dat range-selectie start. Dat anker blijft staan totdat een andere actie de selectiemodus logisch reset, bijvoorbeeld:
- gewone rij-click zonder modifier
- klik op filenaam
- klik op directorynaam die navigeert
- `Escape`
- paneelnavigatie naar andere directory
### Current row versus selected items
- `currentRowIndex` blijft altijd exact 1 rij aanwijzen in het actieve paneel
- `selectedItems` kan 0, 1 of meerdere items bevatten
- bij range-selectie hoeft current row niet de enige geselecteerde rij te zijn; current row is alleen de focusrij binnen de geselecteerde set
### Als nog niets geselecteerd is
Voor veilige voorspelbaarheid:
- `Shift + ArrowDown/ArrowUp` gebruikt de huidige current row als startpunt
- current row moet dus al bestaan; in een leeg paneel doet de shortcut niets
### Cmd/Ctrl + klik
Modifier-click werkt als toggle op een individueel item zonder andere selectie te wissen.
Voorstel:
- `Cmd + klik` op Mac: toggle selectie van dat item
- `Ctrl + klik` op niet-Mac: toggle selectie van dat item
- current row verhuist naar het aangeklikte item
- `selectionAnchorIndex` wordt gezet op dat item, zodat een daaropvolgende range-selectie logisch verdergaat vanaf de laatst gemodificeerde rij
Dit geeft willekeurige toevoeging/verwijdering van items zonder checkbox verplicht te maken.
## 4. Interactie Met Bestaande Regels
### Klik op checkbox
Blijft een expliciete toggle van dat ene item.
Aanvulling:
- checkbox-toggle mag ook `selectionAnchorIndex` zetten op het betreffende item
- daarmee sluit checkboxgedrag aan op latere range-selectie
### Klik op rij
Blijft single-selectie op dat item zonder modifier.
Gevolg:
- eerdere multi-selectie wordt vervangen door exact dit item
- `currentRowIndex` en `selectionAnchorIndex` worden beide op deze rij gezet
### Klik op directorynaam
Blijft directory openen.
Gevolg:
- selectie van dat paneel wordt gewist
- current row reset logisch op de eerste zichtbare rij in de nieuwe map
- `selectionAnchorIndex` wordt gewist
### Klik op filenaam
Blijft single-selectie van dat item.
Gevolg:
- eerdere multi-selectie vervalt
- current row en anker worden op die rij gezet
### Space toggle
Blijft toggle op current row.
Aanvulling:
- `Space` zet ook `selectionAnchorIndex` op current row
- zo blijft keyboardselectie consistent met modifier-click en checkbox-toggle
### Escape clear
Blijft selectie van actief paneel wissen.
Aanvulling:
- wist ook `selectionAnchorIndex`
- current row blijft behouden
### currentRowIndex
Blijft de focusrij voor keyboardnavigatie en acties zoals `Space`, `Enter` en toekomstige range-selectie.
### activePane
Alle advanced selection-interacties gelden alleen voor het actieve paneel. Het inactieve paneel behoudt zijn selectie ongewijzigd.
## 5. Scopebeperking
Niet meenemen in v1, tenzij later expliciet gevraagd:
- `Shift + klik` range-selectie
- `Cmd/Ctrl + A`
- drag selection
- desktop-specifieke contextmenu-semantiek
- backendwijzigingen
Aanbeveling: `Shift + klik` nu niet toevoegen. Dat is bruikbaar, maar verhoogt regressierisico in een web-UI met bestaande naam-click, rij-click en checkbox-click verschillen.
## 6. UX-regels
- current row moet visueel zichtbaar blijven, ook binnen een multi-selectie
- range-selectie moet eruitzien als normale multi-selectie; current row blijft herkenbaar als focusrij
- selectiegedrag moet voorspelbaar blijven:
- gewone klik reset selectie
- modifier-click togglet één item
- shift-arrow werkt vanaf een vast anker
- current row moet bij keyboard range-selectie in beeld blijven via bestaande scrolllogica
- in een leeg paneel doen shortcuts niets
## 7. Impactanalyse
Waarschijnlijk te wijzigen frontendbestanden:
- `webui/html/app.js`
- mogelijk beperkt `webui/html/style.css` voor subtielere current-row/selected-row combinatie
- `webui/backend/tests/golden/test_ui_smoke_golden.py` waarschijnlijk niet of nauwelijks
Geen backendimpact verwacht.
Regressierisico:
- medium in `app.js`, omdat selectiegedrag al meerdere paden heeft: checkbox, rij-click, filenaam, directorynaam, keyboard en wildcardselectie
- laag in CSS, zolang alleen bestaande states duidelijker gecombineerd worden
Belangrijkste regressierisico's:
- onbedoeld resetten van multi-selectie bij modifier-click
- current row en selectie die uit sync raken
- range-selectie die een verkeerd anker gebruikt na navigatie of escape
## 8. Teststrategie
### Smoke/regressietests
Kleine frontend regressiechecks zijn zinvol voor:
- aanwezigheid van bestaande dual-pane UI na wijziging
- geen breuk in bestaande rooktesten voor panelen/modals/assets
Headless UI smoke-tests dekken keyboarddetail beperkt. De kernvalidatie zal daarom vooral handmatig zijn.
### Handmatige validatie
Minimaal handmatig verifiëren:
1. gewone rij-click blijft single-selectie
2. checkbox blijft toggle zonder neveneffecten
3. `Cmd/Ctrl + klik` voegt items toe en verwijdert ze weer
4. `Shift + ArrowDown` start een range vanaf current row
5. `Shift + ArrowUp` verkleint of verplaatst de range correct
6. `Escape` wist selectie maar laat current row staan
7. directory openen wist selectie van alleen dat paneel
8. current row blijft zichtbaar bij range-selectie met keyboard
9. bestaande copy/move/delete/rename werken nog op de resulterende selectie
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- voeg alleen `Shift + ArrowUp/ArrowDown` toe voor keyboard range-selectie
- voeg alleen `Cmd + klik` / `Ctrl + klik` toe voor niet-aangrenzende toggle-selectie
- gebruik een expliciete `selectionAnchorIndex` per paneel
- laat gewone click-semantiek ongewijzigd
- voeg nog geen `Shift + klik` toe in deze fase
Deze aanpak is klein genoeg om veilig te implementeren, sluit aan op standaard file manager gedrag en versterkt de bestaande dual-pane workflow zonder de huidige interacties semantisch te herontwerpen.
@@ -0,0 +1,89 @@
# UI Directory Selection Parity v1
## 1. Haalbaarheid
De huidige advanced selection logica is grotendeels generiek genoeg om ook directories te dragen.
Wat al generiek werkt:
- `selectedItems` bevat zowel files als directories
- `currentRowIndex` is niet typegebonden
- `selectionAnchorIndex` is niet typegebonden
- `Shift + ArrowUp/ArrowDown` werkt op `visibleItems` en kan daardoor in principe zowel files als directories meenemen
- checkbox-toggle gebruikt al `{ path, name, kind }` en werkt daarmee voor beide itemtypes
- `Cmd/Ctrl + klik` toggle-logica is ook type-onafhankelijk opgezet
Wat expliciet aandacht vraagt:
- directorynaam heeft een aparte open-semantiek en moet dus buiten selectieclicks blijven vallen
- rij-click en naam-click moeten scherp gescheiden blijven, anders ontstaat onbedoelde navigatie of onbedoelde selectie
- parent-entry `..` mag niet in gewone selectie terechtkomen
Conclusie: directory selection parity is technisch goed haalbaar binnen het huidige model. Er is geen nieuw state-model nodig.
## 2. Gewenst Gedrag Voor Directories
Voor directories moet exact dit gelden:
- gewone rij-click op een directory, buiten de naam:
- single-select van die directory
- checkbox-click op een directory:
- toggle selectie van die directory
- `Cmd + klik` op Mac / `Ctrl + klik` op niet-Mac op een directoryrij, buiten de naam:
- toggle die directory zonder andere selectie te wissen
- `Shift + ArrowUp/ArrowDown`:
- directories mogen normaal onderdeel zijn van een range
- klik op directorynaam:
- opent de directory
- selecteert niet
Dit sluit aan op het bestaande uitgangspunt dat de directorynaam een navigatie-affordance is en de rest van de rij een selectie-affordance.
## 3. Gemengde Selectie
Gemengde selectie van files en directories in dezelfde selectie of range is logisch en veilig binnen de huidige UI, mits dezelfde regels blijven gelden:
- range-selectie volgt de zichtbare volgorde in de lijst, ongeacht itemtype
- `selectedItems` mag dus een mix bevatten van files en directories
- vervolgacties bepalen daarna zelf of de selectie geldig is voor die actie
Dat past al bij de huidige UI:
- delete accepteert gemengde selectie al functioneel volgens backendmogelijkheden
- move/copy tonen of blokkeren al op basis van inhoud en scope
- rename/view/edit blijven enkel- of typegebonden
Conclusie: gemengde selectie is in de huidige UI logisch en hoeft niet apart verboden te worden.
## 4. Regressierisico
Belangrijkste risico's:
- botsing tussen directorynaam = openen en rij-click = selecteren
- modifier-click op of rond de directorynaam die per ongeluk navigeert in plaats van togglet
- inconsistentie waarbij file-naam single-select doet, maar directorynaam opent
Dit risico is beheersbaar zolang de scheiding expliciet blijft:
- directorynaam is alleen navigatie
- checkbox is alleen toggle
- rij buiten de naam is selectie
Het grootste praktische regressierisico zit niet in range-selectie, maar in click-targets binnen de directoryrij. Als event bubbling of target-selectie daar niet scherp genoeg is, voelt directory-selectie inconsistent.
## 5. Aanbeveling
Aanbevolen conclusie voor v1:
- directory selection parity vraagt waarschijnlijk geen herontwerp
- er is waarschijnlijk hooguit een kleine frontend-correctiestap nodig als bij handmatige verificatie blijkt dat modifier-click of row-click rond directorynamen nog niet overal exact hetzelfde aanvoelt als bij files
Concreet advies:
- eerst handmatig valideren of directoryrijen zich nu al correct gedragen voor:
- gewone rij-click
- checkbox-toggle
- `Cmd/Ctrl + klik`
- `Shift + Arrow` range
- alleen als daar inconsistentie blijkt, een kleine gerichte frontend-fix doen in `app.js`
Kort oordeel:
- het huidige model is sterk genoeg voor directory parity
- waarschijnlijk is geen grote codewijziging nodig
- mogelijk is alleen een kleine frontend-correctiestap nodig rond click-target gedrag
+241
View File
@@ -0,0 +1,241 @@
# UI_EDIT_V1_DESIGN.md
## 1. Scope
`Edit v1` is een eenvoudige teksteditor in de webui, gekoppeld aan de functiebalkactie `Edit`.
In scope:
- alleen tekstbestanden
- alleen files, geen directories
- openen, wijzigen en opslaan van tekstinhoud
- eenvoudige modal-editor
Out of scope:
- geen binary files
- geen PDF
- geen rich text
- geen collaborative editing
- geen autosave
---
## 2. Ondersteunde bestandstypen in v1
Voorstel v1:
- `txt`: ja
- `log`: ja
- `md`: ja
- `yml` / `yaml`: ja
- `json`: ja
- `js`: ja
- `css`: ja
- `html`: ja
- `Dockerfile`: ja
- `Containerfile`: ja
De allowlist blijft gelijk aan `View v1`, zodat `View` en `Edit` inhoudelijk consistent zijn.
---
## 3. UI/UX
### Openen
- `Edit` opent via de functiebalk
- alleen geldig bij exact 1 geselecteerde file
- alleen bij ondersteund teksttype
### Modal
- openen in modal boven de bestaande dual-pane UI
- modal bevat:
- titel/header
- bestandsnaam
- volledig pad
- bewerkbaar tekstgebied
- `Save`
- `Cancel`
- rechtsboven `X`
### Sluiten
- `Cancel` sluit zonder opslaan
- `X` sluit zonder opslaan
- `Escape`:
- als geen onopgeslagen wijzigingen: direct sluiten
- als wel onopgeslagen wijzigingen: waarschuwing/bevestiging tonen
### Inhoud
- scrollbaar tekstgebied
- monospace presentatie
- selecteerbaar en bewerkbaar
- geen syntax highlighting als dat extra dependencies vraagt
### Dirty state
- modal houdt een eenvoudige `isDirty` status bij
- verschil tussen originele inhoud en huidige inhoud bepaalt of waarschuwing nodig is
---
## 4. Backend
### Nieuwe endpoint(s)
Voorstel:
- hergebruik `GET /api/files/view?path=...` voor initial read
- nieuw write-endpoint:
- `POST /api/files/save`
Voorstel request shape:
- `path`
- `content`
- `expected_modified` of vergelijkbare timestamp/hash alleen als conflictcheck in v1 wordt gekozen
Voorstel response shape:
- `path`
- `size`
- `modified`
### Relatie met bestaand view-model
`Edit` gebruikt dezelfde type-allowlist en dezelfde padvalidatie als `View`.
Pragmatische lijn:
- `View` blijft read-only preview
- `Edit` leest initieel via `View` of een gedeelde servicefunctie
- `Save` schrijft alleen naar hetzelfde pad binnen whitelist
### Validatie
- alle paden via bestaand `path_guard`
- directories afwijzen
- unsupported types afwijzen
- write alleen binnen whitelisted roots
### Grote bestanden
Voorstel:
- dezelfde leeslimiet als `View` is **niet** voldoende voor edit
- `Edit v1` moet alleen openen tot een veilige editorlimiet, bijvoorbeeld `256 KiB` of `512 KiB`
- boven die limiet:
- openen blokkeren met duidelijke foutmelding
- geen partial edit voor grote bestanden in v1
Reden:
- partial content bewerken zonder volledige file-context is onveilig en verwarrend
---
## 5. Veiligheid en conflictgedrag
### Wijziging intussen op schijf
Voorstel v1:
- **wel** eenvoudige optimistic locking / modified timestamp check
Mechaniek:
- read-response bevat `modified`
- save-request stuurt `expected_modified`
- backend vergelijkt actuele `mtime`
- mismatch geeft conflictfout
Voordeel:
- beperkt risico op stil overschrijven
- technisch klein genoeg voor v1
### Readonly/permissieproblemen
Bij save:
- permissieprobleem of readonly file -> `io_error` of specifieker `permission_denied` als we die foutcode toevoegen
Voorstel:
- als bestaande foutset compact moet blijven, map dit in v1 naar `io_error` met duidelijke boodschap
### Foutmodel
Minimaal:
- `path_not_found`
- `path_traversal_detected`
- `invalid_root_alias`
- `type_conflict`
- `unsupported_type`
- `conflict`
- `io_error`
`conflict` wordt gebruikt voor modified-timestamp mismatch.
---
## 6. Scopebeperking
Niet in v1:
- geen syntax highlighting als dat extra dependencies vraagt
- geen undo/redo systeem buiten browser-native textarea gedrag
- geen find/replace
- geen multi-file edit
- geen directory edit
- geen split view diff
---
## 7. Impactanalyse
Waarschijnlijk te wijzigen backendbestanden:
- `webui/backend/app/api/routes_files.py`
- `webui/backend/app/api/schemas.py`
- `webui/backend/app/services/file_ops_service.py`
- `webui/backend/app/fs/filesystem_adapter.py`
- nieuwe golden tests voor save/edit flow
Waarschijnlijk te wijzigen frontendbestanden:
- `webui/html/index.html`
- `webui/html/app.js`
- `webui/html/style.css`
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
### Regressierisico
- `Edit` enabled/disabled toestand kan verkeerd meelopen met huidige selectie
- modal-keyboardgedrag kan botsen met paneelnavigatie
- save-conflict of dirty-state kan leiden tot onduidelijk UX-gedrag
- onveilige overschrijving zonder conflictcheck moet vermeden worden
Mitigatie:
- dezelfde selectievoorwaarden als `View`
- keyboard shortcuts blokkeren zolang editor open is
- expliciete dirty-state en save-conflict handling
---
## 8. Teststrategie
### Golden tests
Voor backend:
- edit/open success voor ondersteund tekstbestand
- save success
- unsupported type
- directory -> type conflict
- path not found
- traversal attempt
- conflict bij gewijzigde file
- io_error bij write failure
### UI smoke/regressietests
Aanpassen:
- `Edit` knop aanwezig in functiebalk
- edit-modal container aanwezig in HTML
- save/cancel controls aanwezig
### Handmatige validatie
- `Edit` enabled bij exact 1 ondersteunde file
- `Edit` disabled bij:
- geen selectie
- meerdere selectie
- directoryselectie
- unsupported filetype
- modal opent met juiste inhoud
- `Save` schrijft wijziging correct weg
- `Cancel` sluit zonder opslaan
- `Escape` sluit alleen veilig volgens dirty-state regel
- conflictmelding bij tussentijdse externe wijziging
@@ -0,0 +1,224 @@
# UI_F1_SETTINGS_AND_F2_PLACEHOLDER_V1
## 1. Doel
Deze stap voegt een kleine, uitbreidbare UI-structuur toe voor twee functietoets-slots aan de functiebalk onder de panelen:
- `F1 = Settings`
- `F2 = placeholder voor latere Rename`
Doel:
- de functiebalk meer laten aansluiten op een klassieke file-manager workflow
- ruimte reserveren voor toekomstige uitbreiding zonder de huidige dual-pane workflow te verstoren
- een eerste container bieden voor instellingen en history/logweergave zonder nu al een grote instellingen-UI te ontwerpen
Waarom nu `F1` als Settings:
- `F1` is een logische, laag-risico plek voor globale app-functionaliteit
- het conflicteert niet met bestaande file-acties zoals `F3`-`F8`
- een compacte modal houdt de hoofdworkspace schoon
Waarom `F2` nu alvast reserveren:
- de functiebalk wordt voorbereid op een completere functietoets-reeks
- latere `Rename`-invulling kan op een stabiele plek landen
- het voorkomt dat de functiebalk later opnieuw semantisch moet verschuiven
## 2. F1 Settings
`F1` krijgt in v1 zowel een keyboard shortcut als een knop in de menubalk onder de panelen.
Voorstel:
- keyboard shortcut: `F1`
- knoplabel in functiebalk: `Settings`
- positie: links in de functiebalk, vóór de bestaande file-acties
- actie: opent een compacte modal/popup
Eigenschappen van de modal:
- compact genoeg om de dual-pane context niet te verdringen
- groot genoeg voor tabnavigatie en een eenvoudige lijstweergave
- uitbreidbaar zonder later een totaal andere structuur nodig te hebben
## 3. Settings Modal Structuur
De modal fungeert in v1 vooral als container.
Vaste structuur:
- titel: `Settings`
- sluiten via `X` rechtsboven
- sluiten via `Escape`
- tabstrip bovenin of direct onder de titel
Tabs in v1:
- `General`
- `Logs`
Volgorde:
- `General` als eerste tab
- `Logs` direct rechts daarvan
Rol van de tabs:
- `General`: placeholder voor latere instellingen
- `Logs`: eerste echte inhoud, gevoed door de bestaande history API
Belangrijk:
- de modal is in v1 nog geen volledige instellingenpagina
- de tabstructuur wordt nu vooral neergezet zodat latere uitbreidingen logisch kunnen landen
## 4. General Tab
De `General`-tab is in v1 expliciet een placeholder.
Inhoud in v1:
- nette sectieheader of korte toelichting
- compacte placeholdertekst, bijvoorbeeld dat toekomstige instellingen hier komen
- geen echte form controls vereist in deze fase
Doel:
- semantische en visuele voorbereiding op latere app-instellingen
- voorkomen dat de modal nu al volledig op `Logs` leunt en later opnieuw ontworpen moet worden
Niet in scope:
- root-configuratie
- theme-instellingen
- polling- of taskinstellingen
- bookmarkinstellingen
- geavanceerde preferences
## 5. Logs Tab
De `Logs`-tab gebruikt de bestaande `GET /api/history` API.
Doel in v1:
- een compacte lijst van recente acties tonen zonder de hoofdworkspace te verstoren
- dezelfde informatie tonen die voor gebruikers praktisch nuttig is na `mkdir`, `rename`, `delete`, `copy` en `move`
Aanbevolen zichtbare velden in v1:
- `operation`
- `status`
- hoofdpad of `source -> destination`
- tijdstip (`created_at` of compacte datum/tijd)
- foutmelding alleen wanneer `status = failed`
Weergavevoorstel:
- compacte lijstregels
- recentste bovenaan
- status visueel herkenbaar:
- `queued`
- `completed`
- `failed`
- foutgevallen mogen een tweede compacte regel of muted subregel tonen
Belangrijk:
- `Logs` vervangt geen tasklijst of taskdetail
- het is een compacte history-weergave in modalvorm
- de dual-pane workspace blijft onaangetast zolang de modal dicht is
## 6. F2 Placeholder
`F2` wordt in deze stap expliciet gereserveerd voor latere `Rename`.
V1-uitwerking:
- keyboard shortcut: `F2`
- knop in functiebalk met label passend bij de toekomstige rol, bijvoorbeeld `Rename`
- nog geen functionele rename-actie via `F2`
Twee opties:
Optie A:
- knop disabled
- `F2` doet niets
Optie B:
- knop klikbaar
- `F2` en knop tonen compacte melding: `Not implemented yet`
Aanbeveling voor v1:
- gebruik een klikbare placeholder met compacte melding `Not implemented yet`
Motivatie:
- maakt zichtbaar dat de plek bewust gereserveerd is
- voorkomt verwarring waarom `F2` volledig ontbreekt
- blijft lichtgewicht zonder nu al rename-semantiek te verschuiven
Belangrijk:
- deze placeholder mag niets veranderen aan de bestaande rename-flow
- bestaande `Rename`-knop en bestaande file-manager interacties blijven leidend
## 7. Relatie Met Bestaande Shortcuts
`F1 Settings`:
- mag geen bestaande paneel- of file-actieflow breken
- werkt alleen als focus niet in een control zit, volgens bestaande shortcutguards
- terwijl de modal open is, mogen paneelshortcuts eronder niet doorwerken
`F2 Placeholder`:
- mag geen bestaande flow beïnvloeden
- mag vooral `F6 Move` niet raken
- is semantisch gereserveerd, maar nog niet gekoppeld aan echte rename-logica
Bestaande shortcuts blijven intact:
- `F3 = View`
- `F4 = Edit`
- `F5 = Copy`
- `F6 = Move`
- `F7 = MKdir`
- `F8 = Delete`
## 8. UI-impact
Waarschijnlijk te wijzigen frontendbestanden:
- `webui/html/index.html`
- `webui/html/app.js`
- `webui/html/style.css`
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
Verwachte impact:
- `index.html`: extra modalcontainer en functiebalkknoppen voor `F1` en `F2`
- `app.js`: shortcutbinding voor `F1`, placeholderbinding voor `F2`, tabs en history-fetch in settingsmodal
- `style.css`: compacte modal- en tabstyling die past bij de bestaande UI
Backend:
- geen nieuwe backendwijzigingen nodig in deze stap
- `Logs` gebruikt de reeds bestaande history API
## 9. Regressierisico
Belangrijkste risico's:
- keyboard conflicts met bestaande shortcutlaag
- te volle functiebalk onder de panelen
- modal focusregels die bestaande paneelnavigatie per ongeluk laten doorwerken
- te grote settingsmodal die de workspace te zwaar onderbreekt
Mitigatie:
- hergebruik bestaande modal- en focusguards
- houd `Settings` compact en centraal
- laat `F1` en `F2` exact via dezelfde centrale shortcut-dispatch lopen als andere functiebalkacties
- laat `F2` bewust geen echte file-actie starten in deze fase
## 10. Teststrategie
UI smoke/regressietests:
- controleer dat de functiebalk `Settings` bevat
- controleer dat een `F2`-placeholderknop aanwezig is
- controleer dat de settingsmodalcontainer in de HTML aanwezig is
- controleer dat de `General`- en `Logs`-tabs aanwezig zijn
Handmatige validatie:
- `F1` opent en sluit correct via `Escape` en `X`
- `F1` blokkeert paneelkeyboardnavigatie terwijl de modal open is
- `Logs` laadt recente items via `/api/history`
- `F2` toont alleen de placeholderreactie en verandert geen file-flow
- bestaande `F6 Move` en overige functiebalkacties blijven intact
## Aanbevolen v1-richting
Aanbevolen implementatierichting met laag regressierisico:
- voeg `Settings` toe als compacte modal met tabstructuur
- maak `General` een nette placeholder
- gebruik `Logs` als eerste echte inhoud via de bestaande history API
- reserveer `F2` zichtbaar als placeholder met compacte `Not implemented yet` reactie
Waarom dit de veiligste richting is:
- minimale verstoring van de bestaande dual-pane workflow
- geen backenduitbreiding nodig
- future-proof structuur voor settings en historyweergave
- `F2` krijgt een zichtbare, maar nog niet semantisch gevaarlijke plek
+164
View File
@@ -0,0 +1,164 @@
# UI_F2_RENAME_V1
## 1. Doel
`Rename` moet een expliciete eigen UI-flow krijgen via `F2`, los van de bestaande `F6` move-flow.
Waarom:
- `Rename` is conceptueel een andere actie dan `Move`
- een eigen `F2`-flow sluit beter aan op klassieke file-manager bediening
- het maakt de UI voorspelbaarder: `F2` = naam wijzigen, `F6` = verplaatsen
- het vereenvoudigt later de verdere scheiding tussen rename en move in de UI, zonder backendwijziging
Bestaande context:
- rename-functionaliteit bestaat backendmatig al via het bestaande rename-endpoint
- move-functionaliteit bestaat backendmatig al via het bestaande move-endpoint
- `F6`/move-flow bestaat al in de UI
- in deze stap moet dus niets nieuws in backend of API worden ontworpen; alleen een nette eigen rename-flow in de frontend
## 2. Scope
In scope voor v1:
- exact 1 file geselecteerd
- exact 1 directory geselecteerd
- `F2` keyboard shortcut
- `Rename`-knop in de functiebalk
- compacte rename-popup
Out of scope:
- batch rename
- rename van meerdere selectie-items
- herontwerp van `F6`
- backendwijzigingen
- nieuwe dependencies
## 3. Gewenst gedrag
`F2` en de bestaande `Rename`-knop moeten exact dezelfde UI-flow gebruiken.
Voorstel:
- `F2` keyboard shortcut activeert de rename-flow
- `Rename`-knop in functiebalk activeert exact dezelfde flow
- beide openen een compacte rename-popup
Popup-eigenschappen:
- titel: `Rename`
- toont context van het geselecteerde item
- invoerveld bevat alleen de huidige naam
- dus niet het volledige pad
- focus direct in het invoerveld
- tekst vooraf geselecteerd zodat overschrijven snel kan
- `Enter` bevestigt
- `Escape` annuleert
- `X` rechtsboven sluit de popup
Belangrijk semantisch verschil met `F6`:
- `F2 Rename` werkt op naamniveau binnen dezelfde parent
- de popup toont en bewerkt alleen de naam
- geen destination pad, geen implicit move-semantiek
## 4. Validatie
Frontendvalidatie in v1 moet klein blijven en vooral duidelijke UX geven. De backend blijft de bron van waarheid.
Frontend moet minimaal blokkeren of afvangen:
- lege naam
- ongewijzigde naam
- namen met `/`
- namen die triviaal ongeldig zijn zoals `.` of `..`
Aanpak:
- lichte pre-validatie in de popup voor snelle feedback
- daarna altijd het bestaande rename-endpoint gebruiken
- backend-validatie blijft leidend voor definitieve afhandeling
Geen nieuwe rename-semantiek ontwerpen:
- geen padbewerkingen in de popup
- geen move-achtige fallback
- geen root- of parent-wijziging
## 5. Files en directories
Exact 1 file:
- rename toegestaan
- popup opent met huidige bestandsnaam
- bevestigen gebruikt bestaande backend-rename
Exact 1 directory:
- rename toegestaan
- popup opent met huidige mapnaam
- bevestigen gebruikt bestaande backend-rename
Meerdere geselecteerde items:
- in v1 niet ondersteunen
- `F2` en `Rename` doen functioneel niets destructiefs
- aanbevolen UX: knop disabled bij `selectedItems.length !== 1`
- voor keyboardshortcut: geen actie als rename in de huidige context disabled zou zijn
Dit sluit aan op de bestaande regel dat keyboardshortcuts dezelfde enabled/disabled toestand moeten respecteren als de functiebalkknoppen.
## 6. Relatie met bestaande flows
Herbruik:
- bestaande backend rename-functionaliteit hergebruiken
- bestaande move-functionaliteit ongemoeid laten
- bestaande selectie- en active-pane-logica hergebruiken
Regels:
- `F2` en `Rename`-knop delen exact dezelfde frontendflow
- `F6` blijft ongewijzigd in deze stap
- bestaande `F6` rename/move-popup wordt niet herontworpen in deze stap
- geen dubbele implementatie van backendlogica; alleen een aparte UI-laag voor rename
Pragmatische richting:
- introduceer een aparte compacte rename-popup
- submit roept hetzelfde backend-endpoint aan als de huidige renameknop al gebruikt
- succesvolle rename refresht alleen het actieve paneel, zoals nu al logisch is voor rename
## 7. Regressierisico
Belangrijkste risico's:
- selectieflow: `F2` mag niet reageren bij ongeldige selectie
- popup-focus: paneelkeyboard mag niet doorwerken terwijl de rename-popup open is
- interactie met `F6`: geen verwarring of gedeelde state tussen rename-popup en bestaande rename/move-popup
- onbedoeld herbouwen van bestaande rename/move-logica in plaats van hergebruik
Mitigatie:
- `F2` dezelfde disabled-context laten volgen als de `Rename`-knop
- aparte popup-state voor rename, niet hergebruik van de complexere `F6` destination-popup
- bestaande backend-rename endpoint direct blijven gebruiken
- geen aanpassing aan `F6` in deze stap
## 8. Teststrategie
UI smoke/regressietests:
- functiebalk bevat `Rename` met `F2`-hint
- rename-popupcontainer aanwezig in HTML
- rename-popup bevat invoerveld en sluitknop
- `F2` wiring aanwezig in frontendcode
- bestaande `F6` wiring blijft aanwezig
Handmatige validatie:
- exact 1 file geselecteerd -> `F2` opent rename-popup met alleen naam
- exact 1 directory geselecteerd -> `F2` opent rename-popup met alleen naam
- `Enter` bevestigt
- `Escape` sluit
- `X` sluit
- meerdere selectie -> `F2` doet niets / rename blijft disabled
- succesvolle rename refresht actief paneel
- `F6` move-flow blijft ongewijzigd werken
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- voeg een aparte compacte rename-popup toe voor `F2` en de `Rename`-knop
- werk alleen met de naam van exact 1 geselecteerd item
- gebruik het bestaande backend rename-endpoint zonder nieuwe semantiek
- laat `F2` en de `Rename`-knop exact dezelfde flow delen
- laat `F6` volledig ongemoeid in deze stap
Waarom dit de veiligste richting is:
- duidelijke scheiding tussen rename en move in de UI
- minimaal risico op regressie in bestaande move-flow
- geen backendwerk nodig
- sluit goed aan op klassieke file-manager verwachtingen
+189
View File
@@ -0,0 +1,189 @@
# UI_F6_PURE_MOVE_V1
## 1. Doel
`F6` moet in de UI een expliciete pure `Move`-actie worden.
Waarom:
- `F2` heeft nu een eigen rename-flow
- daarmee is de oorspronkelijke gecombineerde `Rename/Move`-semantiek in de UI niet meer nodig
- een pure `Move`-flow maakt de functietoetsen voorspelbaarder:
- `F2 = Rename`
- `F6 = Move`
- dit sluit beter aan op klassieke file-manager verwachtingen en vermindert cognitieve ruis in de popup-flow
Belangrijk bestaande context:
- move-functionaliteit bestaat backendmatig al
- move-functionaliteit bestaat UI-matig al
- file move, directory move binnen huidige scope en batch move binnen huidige scope werken al
- deze stap gaat dus niet over nieuwe move-capaciteit, maar over het vereenvoudigen van de UI-flow rond de bestaande move-actie
## 2. Scope
De pure `Move`-flow in v1 moet de bestaande move-scope UI-matig netjes afdekken voor zover die al ondersteund wordt.
In scope:
- exact 1 file
- exact 1 directory
- meerdere files
- meerdere directories
- gemengde selectie van files + directories
Voorwaarde:
- alleen binnen de bestaande huidige move-scope
- alles wat backendmatig al geblokkeerd is, blijft geblokkeerd
- de UI mag dat duidelijker presenteren, maar mag de scope niet verbreden
Niet in scope:
- nieuwe backend move-capaciteit
- nieuwe tasksemantiek
- nieuwe batch-semantieken buiten de bestaande backend
- rename binnen `F6`
## 3. Gewenst gedrag
`F6` en de `Move`-knop moeten exact dezelfde frontendflow gebruiken.
Kernregel:
- `F6` keyboard shortcut = pure move-flow
- `Move`-knop in functiebalk = exact dezelfde pure move-flow
- beide gebruiken dezelfde centrale frontendhandler
Popupregel:
- de bestaande move-popup wordt een pure move-popup
- geen rename-invoerveld in deze flow
- geen naamwijzigingssemantiek in deze flow
- de popup richt zich alleen op doelpad of doelmap voor verplaatsen
Interactie:
- `Enter` bevestigt
- `Escape` annuleert
- `X` sluit popup
Gedragslijn per context:
- exact 1 item: popup toont expliciete move-context voor dat item
- meerdere items: popup toont batch move-context
- popuptekst en labels moeten spreken in termen van `Move`, niet `Rename/Move`
## 4. Relatie met bestaande move-scope
Wat al bestaat en alleen hergebruikt moet worden:
- single file move
- single directory move binnen de huidige scope
- batch move binnen de huidige scope
- task-based move
- bestaande validaties en blokkades
Dat betekent:
- de pure `Move`-popup hoeft geen nieuwe backendbeslissingen te nemen
- de frontend mag alleen een duidelijkere presentatie en dispatchlaag geven
- validatiefouten zoals cross-root blokkades, subtree-blokkades, mixed-root blokkades en bestaande destination-conflicten blijven door de bestaande backend en bestaande UI-validatie worden afgevangen
Pragmatische consequentie:
- de move-popup moet vooral destination-gericht zijn
- submit moet direct de bestaande move-logica aanroepen
- geen impliciete keuze meer tussen rename en move in deze flow
## 5. Relatie met F2 Rename
Na invoering van pure `F6 Move` geldt:
- `F2` blijft exclusief voor rename
- `Rename`-knop blijft exclusief voor rename
- `F6` wordt exclusief voor move
- `Move`-knop wordt exclusief voor move
Belangrijk ontwerpprincipe:
- geen gedeelde rename/move-popup meer
- `F2` en `F6` krijgen elk een eigen, semantisch heldere popupflow
- dit maakt toekomstige onderhoud en UX-consistentie eenvoudiger
## 6. UI-semantiek
### Welke contextinformatie is nuttig
Voor een pure move-popup is nuttig:
- broninformatie: welk item of hoeveel items geselecteerd zijn
- destination-informatie: waarheen wordt verplaatst
- bij batch move: doelmap in plaats van volledig doelpad per item
### Default destination
Aanbevolen defaultvoorstel:
- gebruik het current path van het inactieve paneel als destination-basis
Voor exact 1 item:
- toon een destination pad of destination map duidelijk, afhankelijk van de bestaande implementatierichting
- aanbevolen: toon volledig destination path, vooraf ingevuld op basis van het inactieve paneel + itemnaam
- dit houdt aan bij de bestaande move-aanroepsemantiek voor single-item move
Voor batch move:
- toon aantal geselecteerde items
- toon doelmap = current path van het inactieve paneel
- geen rename-achtige tekst of naamveld per item
### Foutmeldingen en blokkades
Compact tonen in de popup of bestaande errorzone:
- destination exists
- mixed roots not allowed
- destination inside source not allowed
- cross-root directory move not supported
- andere bestaande backendfouten
Belangrijk:
- foutmeldingen moeten move-gericht geformuleerd zijn
- geen verwijzing naar rename of gecombineerde semantiek
## 7. Regressierisico
Belangrijkste risico's:
- per ongeluk opnieuw bouwen van bestaande move-functionaliteit in plaats van die te hergebruiken
- verwarring tussen oude gecombineerde popup en nieuwe pure move-popup
- inconsistente keyboard- en knopwiring tussen `F6` en `Move`
- onbedoelde impact op de al werkende `F2 Rename`-flow
Mitigatie:
- `F6` en `Move` één centrale pure move-handler laten delen
- bestaande backend move-calls intact laten
- `F2` volledig gescheiden houden
- popup-tekst en labels expliciet move-only maken
- geen backendwijzigingen in deze stap
## 8. Teststrategie
UI smoke/regressietests:
- functiebalk bevat `Move` met `F6`
- `F6` wiring blijft aanwezig
- `Move`-knop gebruikt dezelfde handler als `F6`
- move-popupcontainer aanwezig en move-only geëtiketteerd
- rename-popup blijft apart aanwezig voor `F2`
Handmatige validatie:
- exact 1 file -> `F6` opent pure move-popup
- exact 1 directory -> `F6` opent pure move-popup binnen huidige scope
- meerdere files -> batch move-popup blijft logisch werken
- meerdere directories -> batch move-popup blijft logisch werken binnen huidige scope
- gemengde selectie -> bestaande batch move-scope en blokkades blijven correct
- `F2 Rename` blijft volledig los en ongewijzigd
Bestaande regressies die bewaakt moeten blijven:
- single file move same-root
- single file move cross-root
- single directory move binnen huidige scope
- batch move file-only
- batch move met directories binnen huidige scope
- blokkades voor mixed roots, subtree en symlink source
## 9. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- maak `F6` en `Move` UI-semantisch puur move-only
- verwijder rename-semantiek uit de `F6`-popupflow
- behoud en hergebruik alle bestaande move-logica, validaties en backendcalls
- houd `F2 Rename` volledig apart
Waarom dit de veiligste richting is:
- maximale herbruik van bestaande backend en UI-logica
- minimale semantische overlap tussen `Rename` en `Move`
- duidelijkere functietoetsbetekenis
- laag regressierisico omdat de verandering vooral UI-opschoning is, geen capability-uitbreiding
+259
View File
@@ -0,0 +1,259 @@
# UI_F6_RENAME_MOVE_DESIGN.md
## 1. Doel
Deze stap herontwerpt `F6` naar een gecombineerde `Rename/Move` actie in Midnight Commander-stijl.
Doel:
- `F6` wordt de primaire actie voor zowel hernoemen als verplaatsen
- de gebruiker werkt vanuit één compacte flow in plaats van aparte shortcuts
- een losse `Rename` shortcut zoals `Alt+R` is daarna niet meer gewenst
Uitgangspunt:
- de functiebalk kan `Rename` visueel nog blijven tonen als aparte knop, maar de keyboardflow voor `F6` wordt leidend voor gecombineerde rename/move-semantiek
- deze ontwerpstap verandert nog niets aan backendcontracten
---
## 2. Popupgedrag
Bij `F6` opent de UI een compacte popup.
Popup-eisen:
- één invoerveld
- één contextregel met bronbestand/-map
- één invoerveld met voorgesteld doelpad
- compacte actieknoppen: `OK` en `Cancel` zijn optioneel, maar `Enter` en `Escape` zijn leidend
Keyboardgedrag:
- `Escape` sluit popup zonder actie
- `Enter` voert de actie uit
Semantiek:
- popup is niet alleen een naamveld, maar een doelpadveld
- gebruiker kan dus zowel alleen de naam aanpassen als een volledig ander doelpad kiezen
---
## 3. Defaultwaarde in het invoerveld
De standaardwaarde in het invoerveld wordt:
- `current path` van het **andere paneel**
- plus de huidige naam van het geselecteerde bestand of de geselecteerde map
Voorbeeld:
- actief paneel: `left`
- geselecteerd item: `storage1/docs/report.txt`
- inactief paneel current path: `storage2/archive`
- default invoerveld:
- `storage2/archive/report.txt`
Motivatie:
- dit past bij klassieke dual-pane file-managerverwachting: `F6` suggereert standaard verplaatsen naar de andere kant
- dezelfde popup blijft bruikbaar voor pure rename door het doelpad handmatig terug te brengen naar dezelfde parent met een andere naam
Belangrijk:
- de default is altijd een **volledig doelpad**
- geen impliciete "move into current dir" semantiek buiten wat in het tekstveld staat
---
## 4. Beslislogica
De UI bepaalt op basis van bronpad en ingevoerd doelpad of de actie neerkomt op `rename` of `move`.
### Regel 1: zelfde parent, andere naam = rename
Als:
- bron en doel in dezelfde parent-directory liggen
- en alleen de naam verschilt
dan gebruikt de UI het bestaande `rename` endpoint.
Voorbeeld:
- bron: `storage1/docs/report.txt`
- doel: `storage1/docs/report-final.txt`
- resultaat: `rename`
### Regel 2: ander pad of andere parent = move
Als:
- de doel-parent verschilt van de bron-parent
- of de doel-root/paneelcontext anders is
dan gebruikt de UI het bestaande `move` endpoint.
Voorbeeld:
- bron: `storage1/docs/report.txt`
- doel: `storage2/archive/report.txt`
- resultaat: `move`
### Regel 3: ongewijzigde waarde = move naar andere paneel-locatie
Omdat de defaultwaarde standaard naar het andere paneel wijst, betekent ongewijzigd bevestigen normaal gesproken:
- `move` naar het current path van het andere paneel met dezelfde naam
Voorbeeld:
- bron: `storage1/docs/report.txt`
- default doel: `storage2/archive/report.txt`
- gebruiker drukt direct `Enter`
- resultaat: `move`
### Regel 4: exact gelijk aan bronpad = no-op
Als de gebruiker het invoerveld wijzigt naar exact hetzelfde pad als de bron:
- er wordt geen rename of move gestart
- de popup sluit niet automatisch met een schijnactie
- voorkeur v1: compacte validatiemelding zoals `Destination must differ from source`
Dit voorkomt zinloze requests.
---
## 5. Relatie met huidige backend
### Rename endpoint
Te gebruiken als de UI beslist op `rename`:
- `POST /api/files/rename`
Mapping:
- request gebruikt bestaand model:
- `path = source`
- `new_name = basename(destination)`
Belangrijke beperking:
- bestaand rename-contract werkt alleen binnen dezelfde parent-directory
- de UI moet dat contract respecteren en alleen in die situatie `rename` gebruiken
### Move endpoint
Te gebruiken als de UI beslist op `move`:
- `POST /api/files/move`
Mapping:
- request gebruikt bestaand model:
- `source`
- `destination` als volledig doelpad
### File versus directory
Huidige backend-scope blijft leidend:
- `rename` ondersteunt bestaande rename-semantiek op file/directory zoals nu aanwezig
- `move` is momenteel file-only
Gevolg voor gecombineerde F6-flow:
- file + ander pad -> `move` toegestaan
- file + zelfde parent andere naam -> `rename` toegestaan
- directory + zelfde parent andere naam -> `rename` toegestaan
- directory + ander pad -> niet toegestaan zolang backend directory-move niet ondersteunt
Voor directory-case buiten scope:
- de popup mag wel openen
- maar bevestigen moet blokkeren met duidelijke melding, bijvoorbeeld:
- `Directory move is not supported in v1`
### Huidige scopebeperkingen blijven gelden
Dus expliciet:
- geen directory move
- geen batch rename/move via deze popup in v1
- geen backend-uitbreiding om F6 slimmer te maken
- alle padvalidatie en foutafhandeling blijven backendgedreven
---
## 6. Focus en UX
Popup-eisen:
- compact en centraal
- niet schermvullend
- focus direct in het invoerveld
- volledige doelpadtekst selecteerbaar en bewerkbaar
Keyboardgedrag:
- `Enter` = bevestigen
- `Escape` = annuleren
Interactie-eis:
- terwijl de popup open is, mag paneelkeyboardnavigatie niet interfereren
- bestaande shortcuts voor paneelnavigatie en functiebalkacties moeten tijdelijk uitgeschakeld zijn, behalve popup-eigen `Enter`/`Escape`
Feedback:
- validatiefouten compact in de popup tonen
- backendfouten terugkoppelen zonder de popup-context te verliezen als de actie faalt
---
## 7. Scopebeperking
Niet in deze stap:
- geen implementatie
- geen backendwijzigingen
- geen nieuwe dependencies
- geen directory move ondersteuning
- geen multi-select rename/move popup
- geen extra path picker of browse-in-dialog
Deze ontwerpstap beperkt zich dus tot de UI-semantiek van één gecombineerde `F6` flow.
---
## 8. Impactanalyse
Waarschijnlijk te wijzigen frontendbestanden bij implementatie:
- `webui/html/app.js`
- `webui/html/index.html`
- `webui/html/style.css`
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
### Verwachte aanpassingen
`app.js`:
- nieuwe popup-state voor F6 rename/move
- beslislogica `rename` versus `move`
- verwijdering of aanpassing van losse `Alt+R` keyboardbinding
- hergebruik van bestaande rename- en move-action handlers waar mogelijk
`index.html`:
- compacte popup-markup met invoerveld en foutregel
`style.css`:
- compacte popup-styling, aansluitend op bestaande wildcard/view/edit modals
### Regressierisico
Belangrijkste risico's:
- verwarring tussen bestaande losse `Rename` knop en nieuwe F6-semantiek
- directorycases die per ongeluk op `move` uitkomen terwijl backend dat niet ondersteunt
- dubbele logica tussen functiebalk-`Rename`, functiebalk-`Move` en F6-popup
- keyboardconflict met bestaande `F6 = Move` shortcut uit action-shortcuts v1
Mitigatie:
- één centrale beslisfunctie voor `rename` versus `move`
- `Alt+R` verwijderen zodra F6-flow geïmplementeerd wordt
- bestaande knophandlers alleen hergebruiken waar de semantiek echt gelijk is; anders kleine centrale wrapperfunctie introduceren
---
## 9. Teststrategie
### Smoke/regressietests
Bij implementatie aan te passen:
- UI smoke test controleert aanwezigheid van F6 popup-container
- controle op relevant inputveld en basiscontrols
- bestaande functiebalk- en modalchecks blijven bestaan
### Handmatige validatie
Essentieel:
- `F6` opent popup met defaultwaarde gebaseerd op ander paneel + huidige naam
- `Enter` met default leidt tot `move`
- wijziging naar zelfde parent + andere naam leidt tot `rename`
- directory + cross-path wordt netjes geblokkeerd
- `Escape` sluit popup zonder bijeffecten
- paneelkeyboardnavigatie werkt niet door popup heen
- bestaande `Move` knop blijft werken
- bestaande `Rename` knop blijft werken totdat eventuele latere UI-consolidatie expliciet wordt doorgevoerd
+252
View File
@@ -0,0 +1,252 @@
1 analyse
De repo heeft al een bruikbaar taskmodel voor copy, move, download en duplicate, maar de main WebUI gebruikt dat model voor copy/move nog nauwelijks. In de hoofd-UI ziet de gebruiker na start nu vooral een korte statusregel of summary; live voortgang staat feitelijk alleen in `F1 > Settings > Logs`. Daardoor ontbreekt directe, persistente feedback in de hoofd-UI en is er geen zichtbare rem op dubbel starten.
Belangrijkste conclusie:
- Copy en move hebben al echte backend-tasks met progressvelden.
- De bron van truth voor lopende copy/move-taken is al `/api/tasks`.
- Er bestaat nu geen cancel/abort voor copy of move.
- Een eerlijke abortknop voor copy/move kan dus nu niet frontend-only worden toegevoegd.
- De kleinste veilige stap is een compacte live task-indicator in de bestaande header/toolbar-zone, gevoed door de bestaande task-feed.
2 bestaande functionaliteit
A. Taskmodel / backend
- `copy` en `move` gebruiken hetzelfde taskmechanisme via [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py), [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py), [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py) en [move_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/move_task_service.py).
- Taskstatussen die al bestaan in [task_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/task_repository.py):
- `queued`
- `running`
- `completed`
- `failed`
- daarnaast voor download ook `requested`, `preparing`, `ready`, `cancelled`
- Progressinformatie bestaat al:
- files: `done_bytes`, `total_bytes`, `current_item`
- batch/directory: `done_items`, `total_items`, `current_item`
- Copy:
- file copy gebruikt byte-progress callback
- directory copy is grof: `0/1` naar `1/1`
- batch copy gebruikt item-progress
- Move:
- same-root file move heeft praktisch geen tussentijdse progress, alleen start/einde
- cross-root file move gebruikt copy-progress en delete na afloop
- directory move is grof `0/1` naar `1/1`
- batch move gebruikt item-progress
- Er is al read-API voor tasks:
- `GET /api/tasks`
- `GET /api/tasks/{task_id}`
- Er is geen cancel-API voor copy/move.
- De enige echte cancel in de repo zit nu bij archive-downloads in [archive_download_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/archive_download_task_service.py) en `POST /api/files/download/archive/{task_id}/cancel`.
- Copy/move workers in [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py) hebben geen cooperative cancel checks.
- Copy/move history bestaat al via [history_repository.py](/workspace/webmanager-mvp/webui/backend/app/db/history_repository.py): `queued`, `completed`, `failed`.
B. Bestaande frontend feedback
- In de hoofd-UI starten copy en move vanuit [app.js](/workspace/webmanager-mvp/webui/html/app.js):
- `startCopySelected()`
- `executeMoveSelection()`
- Huidige feedback voor copy/move:
- `setStatus(...)` onderin/headerstatus
- `showActionSummary(...)`
- `openFeedbackModal(...)` via `actions-error`
- Die feedback is niet persistent als live taskweergave.
- Er is nu geen compacte taskindicator in de hoofd-UI.
- `state.selectedTaskId` en `refreshTasksSnapshot()` bestaan al in [app.js](/workspace/webmanager-mvp/webui/html/app.js), maar worden voor copy/move alleen gebruikt om een snapshotcount op te halen; er is geen zichtbare hoofd-UI-component die dit toont.
- Buiten download is er geen modal of popover voor actieve taken in de hoofd-UI.
C. Logs / history / settings
- `F1 > Settings > Logs` toont al twee side-by-side secties:
- `Tasks`
- `History`
- Deze UI gebruikt al de bestaande feeds:
- `/api/tasks`
- `/api/history`
- Polling bestaat al in [app.js](/workspace/webmanager-mvp/webui/html/app.js):
- `loadTasksForSettings()`
- `loadHistoryForSettings()`
- `loadLogsAndTasksForSettings()`
- `scheduleSettingsLogsPolling()`
- De UI rendert taskdetails al compact via `formatTaskLine(task)`:
- status
- source/destination
- `done_items/total_items`
- `current_item`
- Dat betekent dat de repo al een bruikbare frontend formatteringslaag heeft die ook buiten Settings herbruikbaar is.
D. Abort/cancel haalbaarheid
- Copy/move kunnen nu technisch niet veilig worden afgebroken via bestaande code.
- Er is geen taskstatus-overgang of API-contract voor copy/move-cancel.
- Er is geen cooperative worker-check in copy/move loops.
- Er is geen rollback.
- Eerlijke cancelsemantiek voor copy/move zou dus moeten zijn:
- stop resterende verwerking zo snel mogelijk op een checkpunt
- reeds verwerkte bestanden blijven zoals ze zijn
- geen rollback
- Maar die semantiek is nog niet geïmplementeerd.
- Conclusie: een abortknop voor copy/move is nu buiten scope zonder backendwerk.
3 scope
Minimale veilige volgende stap, op basis van wat al bestaat:
- frontend-only hoofd-UI verbetering
- geen layoutwijziging van de dual-pane browse-UI
- geen nieuw vast paneel
- wel een compacte task/status chip in bestaande headerbar of function-bar zone
- alleen zichtbaar als er actieve taken zijn (`queued`, `running`, en eventueel download `requested/preparing`)
- klik opent een kleine popover/dropdown met actieve taken
- popover hergebruikt bestaande taskdata en formattering uit `/api/tasks`
- popover bevat link/actie naar `F1 > Settings > Logs`
- geen abortknop voor copy/move in deze fase
Waarom dit binnen scope past:
- gebruikt bestaande task-feed
- gebruikt bestaande taaksemantiek
- verandert de hoofd-layout niet
- geeft persistente feedback zonder modal-first patroon
- is compatibel met de OneDrive-achtige richting: compacte indicator, detail op aanvraag
4 impact
Positief:
- gebruiker ziet direct in de hoofd-UI dat copy/move loopt
- feedback blijft zichtbaar zolang taak actief is
- minder kans op dubbel starten
- geen extra structureel paneel
- F1 Logs blijft intact als detailbron
Beperkingen:
- zonder backendwerk is er nog geen eerlijke cancel voor copy/move
- progress blijft zo nauwkeurig als bestaande taskdata toelaat
- same-root move en directory move blijven qua progress relatief grof
5 risico
Laag tot middel als alleen de voorgestelde frontendstap wordt gebouwd.
Belangrijkste risicos:
- polling in de hoofd-UI kan onrustig worden als hij niet net zo stabiel wordt gebouwd als de bestaande Settings-polling
- een te opvallende indicator kan visueel concurreren met de bestaande headerstatus
- als een abortknop zonder backendsteun zou worden toegevoegd, zou dat misleidend zijn; dat moet expliciet niet gebeuren
Expliciet risico buiten scope:
- copy/move-cancel vereist backend-aanpassing aan taskmodel, runner en waarschijnlijk history
6 testplan
Voor de minimale frontendstap:
- gerichte UI smoke/golden checks voor:
- indicator aanwezig in header/toolbar markup
- indicator alleen bedoeld voor actieve taken
- popover/dropdown markup aanwezig
- link naar bestaande logs-entrypoint aanwezig
- gerichte JS-checks voor:
- actieve taken worden uit `/api/tasks` gefilterd
- `queued`/`running` tonen indicator
- `completed`/`failed` verdwijnen uit de actieve indicator
- polling start/stop logisch zonder extra layoutreset
- geen backend golden updates nodig zolang `/api/tasks` contract ongewijzigd blijft
Niet nu testen:
- abort voor copy/move, want die functionaliteit bestaat nog niet
7 acceptatiecriteria
Voor de voorgestelde minimale stap:
- Een gestart copy- of move-proces is zichtbaar in de hoofd-UI zonder navigatie naar `F1 > Settings / Logs`.
- De oplossing verandert de dual-panel layout niet structureel.
- De feedback blijft zichtbaar zolang de taak actief is.
- De oplossing gebruikt bestaande taskdata als bron van truth.
- Er wordt geen fake progress getoond.
- Er wordt geen fake cancelknop getoond voor copy/move.
- Bestaande task/log/history-functionaliteit blijft intact.
- API-contract blijft ongewijzigd.
Voor abort/cancel:
- Niet acceptabel in deze fase zonder backendsteun.
- Eerst aparte backendfase nodig.
8 codex-uitvoering / voorstel
Huidige stap:
- Alleen analyse uitgevoerd.
- Geen functionele implementatie gedaan.
Waarom:
- `CHANGE_POLICY.md` zegt dat frontend flow aanpassen eerst een voorstel nodig heeft.
- De opdracht vroeg expliciet om eerst grondige repo-inspectie en pas daarna een minimaal voorstel.
- Cancel/abort voor copy/move is niet eerlijk implementeerbaar zonder backendwerk.
Minimaal wijzigingsvoorstel dat ik hierna zou uitvoeren als vervolgstap:
1. Frontend-only compacte task chip
- plaats in `#title-zone-actions` of direct naast `#status`
- toont bijvoorbeeld:
- `1 task running`
- `3 active tasks`
2. Kleine popover/dropdown
- opent op klik op de chip
- toont alleen actieve taken uit `/api/tasks`
- hergebruikt bestaande `formatTaskLine(task)` of een kleine variant daarop
- toont eerlijke status:
- `queued`
- `running`
- eventueel later download `requested/preparing`
3. Polling hergebruik
- hergebruik bestaande `/api/tasks`
- implementeer lichte polling alleen als er actieve taken zijn of als de popover open is
- gebruik stabiele rerender-aanpak zoals in Settings > Logs
4. Doorgang naar detail
- knop of link `View in Logs`
- opent bestaande `F1 > Settings > Logs`
5. Expliciet nog niet doen
- geen cancelknop voor copy/move
- geen extra paneel
- geen fake progressbar
Vervolgvoorstel voor latere backendfase als abort gewenst is:
- copy/move taskstatus uitbreiden met `cancelled`
- cancel-endpoint voor copy/move
- cooperative checks in `TaskRunner` tussen items/chunks
- eerlijke semantiek:
- stop resterende verwerking
- reeds verwerkte bestanden blijven bestaan
- geen rollback
9 gewijzigde bestanden
- [project_docs/UI_FEEDACK.md](/workspace/webmanager-mvp/project_docs/UI_FEEDACK.md)
10 uitgevoerde tests
Wel gedaan:
- code-inspectie van backend taskmodel, runners, services, routes en frontend task/log UI
Niet gedaan:
- geen functionele tests
- geen implementatiechecks
Reden:
- deze stap is bewust alleen analyse + voorstel, geen implementatie
+188
View File
@@ -0,0 +1,188 @@
# UI_FUNCTION_BAR_V2_DESIGN.md
## 1. Doel en scope
Deze stap beschrijft de evolutie van de huidige onderbalk onder de twee panelen naar een compactere, duidelijkere functiebalk in Midnight Commander-stijl.
Doel:
- de balk onder de panelen wordt visueel en functioneel herkenbaar als vaste actiebalk
- acties blijven direct gekoppeld aan het actieve paneel
- de balk wordt voorbereid op latere functietoets-labeling zonder nu al functietoetsen te implementeren
In scope:
- compactere horizontale functiebalk onder de twee panelen
- vaste knopvolgorde
- duidelijkere relatie tussen actie, actief paneel en selectie
- ontwerpvoorbereiding voor latere F3-F8 koppeling
Out of scope:
- geen implementatie van `View`
- geen implementatie van `Edit`
- geen functietoetsen
- geen backendwijzigingen
- geen nieuwe dependencies
---
## 2. Positie en layout
De functiebalk staat vast onder de twee panelen, op de plek van de huidige onderste actiebalk.
Layoutdoelen:
- horizontaal gecentreerd in de beschikbare breedte
- compact in hoogte
- kleine, gelijkmatige spacing tussen knoppen
- visueel duidelijk gescheiden van de paneelzone, maar zonder grote verticale band
- uitbreidbaar naar extra labels of functietoetsbadges zonder redesign
Voorstel:
- één compacte horizontale rij
- de rij krijgt een eigen container binnen de footerzone
- links en rechts geen brede utilityblokken
- status/fouttekst blijft buiten of onder de functiebalk, zodat de knoppenrij zelf compact blijft
---
## 3. Vaste volgorde van de knoppen
De functiebalk gebruikt exact deze volgorde:
1. `View`
2. `Edit`
3. `Copy`
4. `Move`
5. `Rename`
6. `MKdir`
7. `Delete`
Reden:
- sluit aan op klassieke file-manager verwachtingen
- groepeert navigatie-/inhoudsacties eerst, daarna muterende file-acties
- houdt de destructieve actie `Delete` aan het einde
---
## 4. Relatie met toekomstig functietoetsgebruik
De functiebalk moet later zonder structurele herbouw koppelbaar zijn aan:
- `F3 = View`
- `F4 = Edit`
- `F5 = Copy`
- `F6 = Move`
- `F7 = MKdir`
- `F8 = Delete`
Ontwerpimplicatie:
- elke knop moet later een compacte functietoetsbadge of prefix kunnen tonen
- `Rename` blijft voorlopig zonder vaste F-toets in deze mapping
- deze stap implementeert nog geen keyboardbindingen of badgegedrag
---
## 5. Relatie met actief paneel en selectie
Alle acties in de functiebalk werken altijd vanuit het actieve paneel.
### Geen selectie
- `View`: disabled
- `Edit`: disabled
- `Copy`: disabled
- `Move`: disabled
- `Rename`: disabled
- `MKdir`: enabled
- `Delete`: disabled
### Exact 1 selectie
- `View`: later afhankelijk van file-type; in deze stap nog niet functioneel
- `Edit`: later afhankelijk van file-type; in deze stap nog niet functioneel
- `Copy`: enabled als huidige backendactie geldig is
- `Move`: enabled als huidige backendactie geldig is
- `Rename`: enabled
- `MKdir`: enabled
- `Delete`: enabled
### Meerdere selecties
- `View`: disabled
- `Edit`: disabled
- `Copy`: enabled als alle geselecteerde items compatibel zijn met bestaande backendscope
- `Move`: enabled als alle geselecteerde items compatibel zijn met bestaande backendscope
- `Rename`: disabled
- `MKdir`: enabled
- `Delete`: enabled
### File- of directoryselectie
- `Copy` en `Move` blijven gebonden aan de huidige backendbeperking
- zolang backend `file-only` is voor copy/move:
- selectie met directories blokkeert `Copy`
- selectie met directories blokkeert `Move`
- `Delete` blijft werken volgens bestaande backendregels
- `Rename` volgt bestaande rename-semantiek
Belangrijk:
- de functiebalk toont niet alleen acties, maar reflecteert ook duidelijk welke acties in de huidige context geldig zijn via enabled/disabled toestand
---
## 6. Scopebeperking
Nog niet in deze stap:
- geen `View`-implementatie
- geen `Edit`-implementatie
- geen functietoetsen
- geen backendwijzigingen
- geen extra UI-frameworks
Deze ontwerpstap gaat dus alleen over de functiebalk zelf, niet over nieuwe actiecapaciteit.
---
## 7. Impactanalyse
Waarschijnlijk te wijzigen bestanden:
- `webui/html/index.html`
- `webui/html/style.css`
- `webui/html/app.js`
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
### Regressierisico
Belangrijkste risico's:
- huidige actieknoppen verliezen hun correcte enabled/disabled logica
- de relatie tussen actief paneel en actiebalk wordt visueel minder duidelijk
- keyboard- en klikflows kunnen breken als knop-ids of handlers onzorgvuldig worden vervangen
- extra compactheid kan ten koste gaan van leesbaarheid op smallere schermen
Mitigatie:
- bestaande action handlers hergebruiken waar mogelijk
- ids voor bestaande werkende acties stabiel houden of gecontroleerd migreren
- disabled-logica centraal laten in plaats van per knop ad hoc
- smoke tests uitbreiden met de nieuwe functiebalkstructuur
---
## 8. Teststrategie
### Smoke/regressietests
Aan te passen:
- `test_ui_smoke_golden.py`
Nieuwe of aangepaste checks:
- functiebalkcontainer aanwezig onder de panelen
- knopvolgorde in HTML komt overeen met het ontwerp
- bestaande actieknoppen voor werkende backendacties blijven aanwezig
- assets blijven correct gemount
### Handmatige validatie
Te valideren bij implementatie:
- functiebalk blijft compact op desktop
- functiebalk blijft bruikbaar op smallere breedte
- actief paneel blijft bepalend voor de actiecontext
- disabled/enable toestand klopt bij:
- geen selectie
- 1 selectie
- meerdere selecties
- gemixte file/directory-selectie
- bestaande keyboard- en klikselectieflow blijft intact
@@ -0,0 +1,147 @@
# UI_KEYBOARD_V1_1_AND_WILDCARD_DESIGN.md
## Deel 1: Keyboard Navigation v1.1 (Mac-vriendelijk)
### Doel
Keyboard v1.1 bouwt voort op v1 en maakt navigatie sneller op Mac zonder de bestaande muisflow of backendcontracten te wijzigen.
### Shortcut scope (v1.1)
- `Tab`: wissel actief paneel (`left` <-> `right`)
- `ArrowUp` / `ArrowDown`: verplaats current row
- `Enter`: open directory op current row
- `Space`: toggle selectie op current row
- `Escape`: wis selectie in actief paneel
- `Cmd + ArrowUp`: ga naar eerste rij
- `Cmd + ArrowDown`: ga naar laatste rij
- `Alt + ArrowUp`: grotere stap omhoog
- `Alt + ArrowDown`: grotere stap omlaag
### State impact
Geen nieuw globaal model nodig; uitbreiding op bestaand model:
- per paneel blijft:
- `currentPath`
- `visibleItems`
- `selectedItems`
- `currentRowIndex`
- globaal blijft:
- `activePane`
Aanvullende constante voor stapgrootte:
- `PAGE_STEP` (voorstel: 10 rijen)
### Navigatie- en scrollgedrag
- `Cmd + ArrowUp`: `currentRowIndex = 0`
- `Cmd + ArrowDown`: `currentRowIndex = visibleItems.length - 1`
- `Alt + ArrowUp`: `currentRowIndex = max(0, currentRowIndex - PAGE_STEP)`
- `Alt + ArrowDown`: `currentRowIndex = min(last, currentRowIndex + PAGE_STEP)`
- na elke indexwijziging: `scrollIntoView({ block: "nearest" })`
- bij leeg paneel: alle row-shortcuts zijn no-op
### Focusregels
Shortcuts alleen actief als focus niet in interactieve controls zit:
- `input`, `textarea`, `select`, `button`, `checkbox`, `contenteditable`
Extra voor Mac-combinaties:
- alleen onderscheppen als de combinatie exact matcht met ondersteunde shortcuts
- geen brede override van browser/system shortcuts buiten scope
### Regressierisico
- Mac key-detectie kan verschillen (`metaKey`/`altKey` + `Arrow*` combinaties)
- Overcapturing van `Alt+Arrow` kan botsen met OS/browsergedrag op sommige layouts
- Onbedoelde interactie met bestaande klikselectie bij snelle keyboard/muis mix
Mitigatie:
- centrale keyboard dispatcher met expliciete guard
- alleen exacte combinaties afvangen
- bestaande click handlers ongewijzigd laten
### Teststrategie
Geautomatiseerd (beperkt):
- bestaande UI smoke tests behouden
- optioneel kleine statische regressiecheck op paneelstructuur ongewijzigd
Handmatig (primair):
- `Cmd+ArrowUp/Down` springt naar begin/eind van lijst
- `Alt+ArrowUp/Down` springt met grote stap en scrollt correct mee
- shortcuts werken alleen in actief paneel
- shortcuts doen niets in inputs/checkboxfocus
- bestaande v1 shortcuts blijven correct werken
---
## Deel 2: Wildcard selectie ontwerp
### Doel
Snelle bulkselectie op patroon in het actieve paneel, zonder backendaanpassing.
### Trigger shortcuts
- `Shift + +` (oftewel `Shift` + `=` op veel toetsenborden): selecteer items op patroon
- `Shift + -` : deselecteer items op patroon
### Scopekeuzes (v1)
- Werkt **alleen op actief paneel**
- Werkt **alleen op zichtbare items** (`visibleItems`), niet recursief
- Werkt op **files én directories** in v1 (consistent met zichtbare lijst)
- `..` parent-entry doet niet mee
### Patroonformaat
- Glob-achtig, minimaal:
- `*` = willekeurige reeks
- `?` = één karakter
- Voorbeelden:
- `*.mkv`
- `S??E??*`
- `Project*`
### Case sensitivity
- Voorstel v1: **case-insensitive matching**
- Reden: voorspelbaarder voor eindgebruikers en sluit aan op typische file-manager verwachtingen
### Gedrag met bestaande selectie
- `Shift + +`:
- additief: matchende items worden toegevoegd aan bestaande selectie
- niet-matchende selectie blijft behouden
- `Shift + -`:
- subtractief: alleen matchende items worden uit selectie verwijderd
### Minimale popup UX
Eenvoudige modal/prompt met:
- titel: `Select by pattern` of `Deselect by pattern`
- één inputveld: patroon
- knoppen: `Apply`, `Cancel`
- compacte feedbackregel na apply:
- `N items matched, M changed`
Paneelcontext:
- popup wordt gestart vanuit actief paneel en toont dat expliciet (`Active pane: left/right`)
### Wat expliciet nog niet in scope is
- Geen regex-modus
- Geen include/exclude in één dialoog
- Geen persistente pattern-history
- Geen backend batch-endpoints
- Geen recursieve mapmatching
- Geen geavanceerde filters op size/date/type
### Regressierisico
- Shortcut-conflict met keyboard-layouts (`+` op verschillende toetsen)
- Matchlogica kan onduidelijk zijn bij verborgen bestanden (afhankelijk van `show_hidden`)
- Onbedoelde selectie van directories als gebruiker file-only verwacht
Mitigatie:
- in popup korte hinttekst over scope (`visible items in active pane`)
- heldere result-feedback (`matched/changed`)
- parent-entry expliciet uitsluiten
### Teststrategie
Geautomatiseerd:
- basis smoke: UI laadt, paneelstructuur blijft intact
- (indien kleine JS-tests bestaan) unitniveau voor glob-matcher helper
Handmatig:
- `Shift + +` selecteert matchende zichtbare items in actief paneel
- `Shift + -` deselecteert matchende geselecteerde items
- inactief paneel blijft onaangeraakt
- behavior met gemixte selectie (file+dir) is consistent
- case-insensitive matching bevestigd
+140
View File
@@ -0,0 +1,140 @@
# UI_KEYBOARD_V1_DESIGN.md
## 1) Doel
Keyboard navigation v1 maakt de huidige dual-pane UI efficiënter voor snelle bestandsnavigatie en selectie, in lijn met een Midnight Commander-achtige workflow: minder muisgebruik, voorspelbare focus, snelle pane-switching.
### In scope (v1)
- `Tab` wisselt actief paneel.
- `ArrowUp` / `ArrowDown` verplaatst `current row` in actief paneel.
- `Enter` opent de map van de `current row` als die row een directory is.
- `Space` togglet selectie van `current row`.
- `Escape` wist selectie in actief paneel.
### Out of scope (v1)
- Geen `F3/F4` viewer/editor.
- Geen `F5/F6/F7/F8` keyboard-acties.
- Geen shift/ctrl/insert multi-selectgedrag.
- Geen globale override van onbetrouwbare browser-shortcuts.
---
## 2) Scope Keyboard v1 (klein en veilig)
### Shortcutset
- `Tab`:
- als focus niet in een input/control zit: wissel `activePane` (`left` <-> `right`).
- default browser tab-navigatie blokkeren in dit geval.
- `ArrowUp` / `ArrowDown`:
- beweeg `currentRowIndex` binnen zichtbare lijst van actief paneel.
- clamp tussen `0` en `items.length - 1`.
- geen wrap-around in v1.
- `Enter`:
- als `current row` directory is: navigeer naar die directory.
- als `current row` file is: geen open; behoud selectiegedrag (geen viewer).
- `Space`:
- toggle selectie op `current row` item.
- `Escape`:
- clear alle geselecteerde items in actief paneel.
---
## 3) State model
Per paneel (`left`/`right`) expliciet onderscheid:
- `currentPath`: huidige padcontext.
- `entries`: gecombineerde zichtbare lijst (dirs/files) in rendervolgorde.
- `currentRowIndex`: keyboard-cursorpositie binnen `entries`.
- `selectedItems`: geselecteerde items (set/list op pad).
Globaal:
- `activePane`: `left` of `right`.
### Semantiek
- `current row` is focus/cursor voor keyboardnavigatie.
- `selected items` is actiedoel (rename/delete/copy/move-regels blijven gelden).
- `active pane` bepaalt waar keyboardinput op werkt.
### Bij navigatie
- `ArrowUp/Down` verandert alleen `currentRowIndex` in actief paneel.
- Geen wijziging in ander paneel.
### Bij directory openen (`Enter` op directory)
- Navigeer in actief paneel naar directory.
- Wis selectie in actief paneel.
- Reset `currentRowIndex` naar eerste item (of `null` bij lege lijst).
- Inactief paneel blijft ongewijzigd.
### Bij leeg paneel
- `currentRowIndex = null`.
- `ArrowUp/Down/Enter/Space` doen niets.
- `Escape` blijft selectie-clear uitvoeren (idempotent).
---
## 4) UX-regels
### Focusveiligheid
Shortcuts zijn alleen actief als event target **geen** interactieve invoercontrol is:
- `input`, `textarea`, `select`, `button`, checkbox, of `contenteditable`.
### Wanneer shortcuts actief zijn
- Alleen op documentniveau handler als focus op lijst/paneelcontainer of body staat.
- Als gebruiker expliciet in form/control werkt, geen keyboard-capturing.
### Visualisatie
- `current row` krijgt aparte, subtiele cursorstijl (`is-current-row`).
- `selected rows` behouden bestaande selectiehighlight (`is-selected`).
- Row kan beide states tegelijk hebben: current + selected.
- `activePane` blijft via border/focusrand zichtbaar; geen sterke achtergrondwissel.
---
## 5) Impactanalyse
Waarschijnlijk te wijzigen:
- `webui/html/app.js`
- keyboard event handling
- state uitbreiding met `currentRowIndex`
- paneelgerichte row-navigation helpers
- `webui/html/style.css`
- styling voor `is-current-row` + gecombineerde state met `is-selected`
- `webui/html/index.html`
- alleen kleine aanpassingen indien nodig (bv. paneelcontainer attributes)
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
- alleen bij structurele id/class wijzigingen
### Regressierisico
- Bestaande klik/selectieflow kan botsen met nieuwe current-row state.
- Event bubbling kan dubbele selectie-updates geven.
- Tab-handling kan browser-focusflow verstoren als te agressief.
Mitigatie:
- Centrale `shouldHandleShortcut(event)` guard.
- Centrale helpers voor `setCurrentRow`, `toggleSelectionForCurrentRow`, `openCurrentRowDirectory`.
- Geen backendaanpassingen.
---
## 6) Teststrategie
### Geautomatiseerd (klein)
- Bestaande UI smoke tests behouden (`/ui`, panelen, assets).
- Optioneel kleine regressietest op aanwezigheid van paneelcontainers en lijststructuur; geen zware browser-E2E in v1.
### Handmatige validatie (primair voor v1)
- `Tab` wisselt actief paneel betrouwbaar.
- `ArrowUp/Down` beweegt current row alleen in actief paneel.
- `Enter` opent alleen directory op current row.
- `Space` togglet selectie op current row.
- `Escape` wist selectie in actief paneel.
- Shortcuts doen niets tijdens focus in input/checkbox/button.
- Leeg paneel geeft geen errors bij keyboardinput.
### Regressiechecks
- Bestaand klikgedrag blijft intact:
- checkbox toggle
- rij-click selectie
- directory-naam opent directory
- Onderbalkacties blijven werken op `selectedItems` van actief paneel.
+256
View File
@@ -0,0 +1,256 @@
# UI Theme v1 Design
## 1. Doel
Doel van deze stap is de huidige webui visueel moderner, rustiger en prettiger te maken zonder de dual-pane file manager workflow te verzwakken.
De huidige UI heeft al een functionele structuur:
- compacte topbar
- twee dominante browsepanelen
- compacte functiebalk
- modals voor view/edit/rename-move
De visuele refresh moet daarom niet proberen de informatiearchitectuur opnieuw uit te vinden, maar moet vooral:
- duidelijkere visuele hiërarchie geven
- betere contrastverhoudingen bieden
- light en dark mode ondersteunen
- selectie/current-row/actieve paneelstatus leesbaarder maken
- de interface consistenter laten aanvoelen
De dual-pane bestandslijst blijft het hoofdonderdeel van het scherm. Decoratie is ondergeschikt aan bruikbaarheid.
## 2. Themastructuur
Aanbevolen richting: CSS custom properties met één gedeeld tokenmodel, aangestuurd via een `data-theme` attribuut op `html` of `body`.
### Basistokens
Aanbevolen tokenset:
- `--color-page-bg`
- `--color-surface`
- `--color-surface-elevated`
- `--color-border`
- `--color-border-strong`
- `--color-text-primary`
- `--color-text-muted`
- `--color-accent`
- `--color-accent-contrast`
- `--color-selection-bg`
- `--color-selection-border`
- `--color-current-row-bg`
- `--color-current-row-border`
- `--color-active-pane-border`
- `--color-button-bg`
- `--color-button-hover`
- `--color-button-secondary-bg`
- `--color-danger`
- `--color-danger-bg`
- `--color-overlay-bg`
- `--shadow-elevated`
- `--radius-sm`
- `--radius-md`
### Dark mode voorstel
Dark mode moet geen pure zwarte UI zijn, maar een rustige donkergrijze werkruimte.
Voorstel:
- page background: donker koel grijs-blauw
- panel surface: iets lichter dan page background
- elevated surface: nog een stap lichter voor modals/topbar/footer
- borders: lage contrastgrijze lijnen
- text primary: bijna wit maar niet hard wit
- text muted: gedempt blauwgrijs
- selected row: duidelijke, maar niet schreeuwerige accentvulling
- current row: subtiele focuslaag bovenop de lijst
- active pane border: heldere accentkleur
- primary buttons: rustige accentkleur
- secondary buttons: neutrale dark surface
- danger state: warm rood met goede contrasttekst
- overlay/modal background: transparant donker vlak
### Light mode voorstel
Light mode moet frisser en moderner zijn dan de huidige variant, zonder fel of vlak te worden.
Voorstel:
- page background: zacht koel lichtgrijs
- panel surface: wit of bijna wit
- elevated surface: iets warmere/luxere lichte toon voor topbar/footer/modals
- borders: verfijnde grijsblauwe lijn
- text primary: diep donkerblauwgrijs
- text muted: medium koel grijs
- selected row: zachte accenttint
- current row: subtiele focusrand en lichte achtergrond
- active pane border: sterke accentkleur
- primary buttons: accentkleur met heldere tekst
- secondary buttons: rustige lichte surface
- danger state: donker rood met zachte foutachtergrond
- overlay/modal background: transparant donkergrijs
## 3. UI-onderdelen die moeten meedoen
### Topbar
- achtergrond uit `surface-elevated`
- statusregel leesbaarder maken
- theme toggle rechts in dezelfde balk
- titel en status optisch beter uitlijnen
### Dual-pane panels
- panel surface en border via tokens
- active pane alleen via rand/focus, niet via zware achtergrondwisseling
- subtiele elevation of contrastlaag mogelijk, maar beperkt
### Bestandslijsten
- lijst blijft visueel dominant
- grid header moet in beide themas voldoende contrast houden
- directory-links mogen accent gebruiken, maar niet te fel
### Current row / selected row
- current row en selected row moeten visueel onderscheidbaar blijven
- combinatie current + selected moet in beide themas bruikbaar blijven
- contrast moet ook werken bij lange sessies en veel selectie
### Functiebalk onderaan
- dezelfde visuele taal als topbar/modals
- compacte maar duidelijke knopstates
- disabled buttons goed zichtbaar als niet-beschikbaar, zonder onleesbaar te worden
### Modals
- view/edit/rename-move/wildcard/batch move moeten dezelfde elevated surface gebruiken
- overlay donker genoeg om focus te geven
- popup-card iets zachtere rounding en subtiele shadow
### Meldingen / feedback
- statusregel, fouten in panelen en actiefeedback moeten in beide themas leesbaar zijn
- error-kleur moet duidelijk zijn zonder hard neon-effect
### Breadcrumbs
- breadcrumbs en klikbare delen moeten goed zichtbaar blijven in beide themas
- hover/focus states moeten subtiel maar duidelijk zijn
## 4. Light/dark mode gedrag
### Toggle gedrag
- kleine theme toggle knop met zonnetje/maantje
- positie: in de bovenste balk, rechts van de status/actie tekst
- dus visueel: `titel | statusbericht | theme toggle`
### Interactie
- knop toggelt tussen light en dark
- icoon reflecteert de actie of huidige mode, maar moet consequent gekozen worden
Aanbevolen keuze:
- toon huidige mode als icoon
- dark mode: maantje zichtbaar
- light mode: zonnetje zichtbaar
### Opslag
- keuze opslaan in `localStorage`
- sleutel bijvoorbeeld: `webmanager-theme`
### Default
Aanbevolen default:
- als `localStorage` nog leeg is: volg `prefers-color-scheme`
- fallback daarna naar `dark`
Motivatie:
- voelt moderner en meer app-achtig
- sluit goed aan op een dual-pane file manager werkruimte
- blijft respectvol naar systeeminstellingen
## 5. Visuele principes
### Compacter en rustiger
- minder visuele ruis in de topbar en functiebalk
- consistenter gebruik van spacing
- minder harde contrastwisselingen tussen onderdelen
### Duidelijke hiërarchie
- bestandslijsten zijn primair
- topbar en footer zijn ondersteunend
- modals zijn duidelijk elevated maar niet zwaar gedecoreerd
### Geen zware effecten
- geen sterke gradients
- geen glows of drukke schaduwen
- alleen subtiele shadows waar elevation functioneel is
### Functionele rounding
- lichte rounding op panelen, buttons en modals
- niet overdreven rond; doel is rust en moderniteit, niet speelsheid
### Lijst dominant
- meeste visuele aandacht blijft bij de twee paneellijsten
- kleuren en effecten moeten de lijst leesbaarder maken, niet concurreren met de inhoud
## 6. Regressiebehoud
Stylingwijzigingen mogen niet breken:
- selectiegedrag
- checkbox-hit areas
- current row zichtbaarheid
- keyboard navigation focusgevoel
- active pane herkenning
- popup interactie
- dual-pane layout
Concreet:
- geen layoutwijziging waardoor paneelhoogte of interne scroll verslechtert
- geen topbar-uitbreiding die verticale ruimte van de lijst substantieel opslokt
- geen functiebalk-styling die de onderbalk hoger of drukker maakt dan nodig
## 7. Impactanalyse
Waarschijnlijk te wijzigen frontendbestanden:
- `webui/html/style.css`
- `webui/html/index.html`
- `webui/html/app.js`
Waarom:
- `style.css`: nieuw tokenmodel en themaspecifieke styles
- `index.html`: plaatsing van de theme toggle in de topbar
- `app.js`: thema initialiseren, togglen en opslaan in `localStorage`
Geen backendimpact verwacht.
### Regressierisico
Laag tot middel:
- laag voor backend en API, want geen backendwijzigingen
- middel voor frontend omdat de topbar en globale CSS geraakt worden
- grootste risico zit in contrast, selected/current row zichtbaarheid en behoud van compacte verticale ruimte
## 8. Teststrategie
### UI smoke tests aanpassen
Minimaal toevoegen/controleren:
- theme toggle knop aanwezig in topbar
- status-element blijft aanwezig
- topbar bevat zowel status als toggle
- relevante modalcontainers blijven aanwezig
- statische assets blijven werken
### Handmatige validatie
Nodig voor:
- light mode leesbaarheid van bestandslijsten
- dark mode leesbaarheid van bestandslijsten
- selected row en current row in beide themas
- active pane border in beide themas
- breadcrumbs hover/focus states
- modals in beide themas
- localStorage-persistentie na refresh
- default gedrag bij lege localStorage en system preference
## Aanbevolen implementatierichting
Aanbevolen v1-richting:
- implementeer een klein, stabiel tokenmodel in `style.css`
- gebruik `data-theme="light|dark"` op `document.documentElement`
- voeg een compacte theme toggle toe in de topbar rechts van `#status`
- laat `app.js` de initiële theme bepalen via `localStorage` -> `prefers-color-scheme` -> fallback
- houd layout en spacing grotendeels gelijk, en focus de refresh op kleur, contrast, borders, surfaces en modals
Dit geeft de hoogste UX-winst met het laagste regressierisico.
+223
View File
@@ -0,0 +1,223 @@
# UI_VIEW_V1_DESIGN.md
## 1. Scope
`View v1` is een eenvoudige read-only file viewer in de webui, gekoppeld aan de functiebalkactie `View`.
In scope:
- alleen read-only weergave
- alleen files, geen directories
- openen vanuit de bestaande UI
- eenvoudige modalweergave
Out of scope:
- geen editfunctionaliteit
- geen save
- geen inline rename
- geen compare
- geen syntax-aware editor
Backendwijzigingen:
- een nieuw read-only file-read endpoint is waarschijnlijk nodig
- alleen als veilig en strikt binnen het bestaande whitelist/path_guard model
---
## 2. Ondersteunde bestandstypen in v1
Voorstel v1:
- `txt`: ja
- `log`: ja
- `md`: ja
- `yml` / `yaml`: ja
- `json`: ja
- `js`: ja
- `css`: ja
- `html`: ja
- `Dockerfile`: ja
- `Containerfile`: ja
### PDF
Voorstel: **niet in v1**
Motivatie:
- PDF-preview vraagt om aparte rendering of browser-embedgedrag
- dat vergroot complexiteit, testoppervlak en afhankelijkheid van browserverschillen
- `View v1` blijft daardoor gefocust op tekstbestanden
Gevolg:
- PDF en andere niet-ondersteunde typen geven een duidelijke `unsupported preview` melding in de modal
---
## 3. UI/UX
### Openen
- `View` wordt gestart via de functiebalk
- werkt alleen op het actieve paneel
- alleen geldig bij exact 1 geselecteerd item
- alleen geldig als dat item een file is
### Presentatie
- openen in een modal boven de bestaande dual-pane UI
- modal bevat:
- titel/header
- bestandsnaam
- volledig pad
- read-only contentgebied
- rechtsboven een `X`
### Sluiten
- klik op `X` sluit modal
- `Escape` sluit modal
- klik buiten modal mag optioneel sluiten, maar hoeft niet verplicht in v1
### Inhoud
- contentgebied is verticaal scrollbaar
- tekst blijft selecteerbaar en kopieerbaar
- monospace weergave voor tekstinhoud
- geen bewerkcontrols
---
## 4. Technische aanpak
### Frontend
Voorstel:
- toevoegen van een viewer-modal in `index.html`
- `View` knop wordt enabled bij precies 1 geselecteerde file
- `app.js` opent modal en haalt previewdata op via nieuw backend-endpoint
### Backend
Waarschijnlijk nieuw endpoint nodig:
- `GET /api/files/view?path=...`
Voorstel response shape:
- `path`
- `name`
- `content_type`
- `encoding`
- `truncated`
- `size`
- `content`
Voorbeeldgedrag:
- tekstbestand: inhoud als UTF-8 string terug
- unsupported type: nette 409/400-achtige applicatiefout of expliciete supported=false response
### Preview-keuze / typebepaling
Voorstel v1:
- eerst extensie- en bestandsnaamgebaseerde allowlist
- speciale namen:
- `Dockerfile`
- `Containerfile`
- optioneel secundair op mime gokken, maar niet leidend maken
### Grote bestanden
Voorstel:
- harde limiet op previewgrootte, bijvoorbeeld `256 KB` of `512 KB`
- backend leest maximaal tot die limiet
- response bevat `truncated = true` als bestand groter is
Dit voorkomt:
- grote memory responses
- trage modal-openingen
- onnodige load voor logbestanden
### Unsupported bestandstypen
Voorstel:
- backend of frontend classificeert bestand als niet-previewbaar
- modal toont compacte melding:
- bestandstype niet ondersteund in `View v1`
Geen fallback naar download of externe viewer in v1.
---
## 5. Security en scopebeperking
- alle padvalidatie via bestaand `path_guard`
- alleen paden binnen whitelist
- geen directoryweergave via viewer
- geen write/save-endpoint
- geen downloadmanager
- geen externe viewer libraries in v1
Voor tekstpreview:
- inhoud alleen server-side lezen via gecontroleerde backendroute
- geen directe file-URL of browser file access
---
## 6. Impactanalyse
Waarschijnlijk te wijzigen frontendbestanden:
- `webui/html/index.html`
- `webui/html/app.js`
- `webui/html/style.css`
- `webui/backend/tests/golden/test_ui_smoke_golden.py`
Waarschijnlijk te wijzigen backendbestanden:
- `webui/backend/app/api/routes_files.py` of aparte view-route
- `webui/backend/app/api/schemas.py`
- `webui/backend/app/services/file_ops_service.py` of aparte view service
- `webui/backend/app/fs/filesystem_adapter.py`
- eventueel nieuwe golden tests voor view-endpoint
### Regressierisico
- functiebalk enabled/disabled logica kan fout lopen bij `View`
- modal-keyboardinteractie kan bestaande keyboard shortcuts blokkeren of lekken
- grote bestanden of binair ogende inhoud kunnen previewflow verstoren
- pad/securityvalidatie moet identiek streng blijven als bij browse/file ops
Mitigatie:
- `View` alleen bij exact 1 fileselectie
- modal-open toestand blokkeert gewone navigatieshortcuts
- size limit en type allowlist in backend
---
## 7. Teststrategie
### Golden tests
Voor backend indien endpoint wordt toegevoegd:
- view success voor ondersteund tekstbestand
- unsupported type
- directory geselecteerd -> type conflict
- path not found
- traversal attempt
- invalid root alias
- truncated response voor groot bestand
### UI smoke tests
Aan te passen:
- modalcontainer aanwezig in HTML
- `View` knop aanwezig in functiebalk
Niet nodig in smoke:
- volledige interactieflow headless afdwingen, tenzij de huidige stack dat eenvoudig ondersteunt
### Handmatige validatie
- `View` enabled bij exact 1 geselecteerde file
- `View` disabled bij:
- geen selectie
- meerdere selectie
- directoryselectie
- modal opent correct
- pad en bestandsnaam zichtbaar
- tekstinhoud scrollbaar
- selectie/kopiëren van tekst werkt
- `Escape` en `X` sluiten modal
- unsupported type geeft nette melding
- groot bestand wordt veilig afgekapt
@@ -0,0 +1,151 @@
# UI_VOLUMES_DIRECTORY_VIEW_V1
## 1. Doel
Doel van deze stap is om de webui een host-achtige navigatiestructuur te geven waarbij de gebruiker eerst `/Volumes` ziet en daarna daarbinnen de beschikbare mounts kan openen, zoals:
- `/Volumes/8TB`
- `/Volumes/8TB_RAID1`
Waarom dit gewenst is:
- het sluit beter aan op de werkelijke host- en containerstructuur
- het voorkomt dat technische aliasnamen zoals `storage1` en `storage2` het primaire navigatiemodel bepalen
- het maakt de UI begrijpelijker voor gebruikers die denken in echte mountpunten en directories, niet in app-specifieke labels
---
## 2. Gewenste UI-weergave
Gewenst gedrag in beide panelen:
- een paneel kan op `/Volumes` staan als huidige directoryweergave
- in die weergave ziet de gebruiker de toegestane submappen als normale directory entries
- voor deze case moeten daar minimaal zichtbaar zijn:
- `8TB`
- `8TB_RAID1`
Navigatieflow:
- gebruiker opent of kiest `/Volumes`
- de lijst toont `8TB` en `8TB_RAID1` als directories
- klikken of `Enter` op `8TB` opent `/Volumes/8TB`
- klikken of `Enter` op `8TB_RAID1` opent `/Volumes/8TB_RAID1`
Voor dual-pane gedrag:
- beide panelen moeten onafhankelijk op `/Volumes` of op een onderliggende mount kunnen staan
- er is geen speciaal verschillend gedrag nodig tussen links en rechts
- breadcrumbs moeten `/Volumes` en daarna de mountnaam tonen
---
## 3. Relatie met huidige whitelist/root-configuratie
Huidige situatie:
- de backend werkt met expliciete toegestane roots via aliases
- defaults zijn nu:
- `storage1 -> /Volumes/8TB`
- `storage2 -> /Volumes/8TB_RAID1`
Belangrijk verschil:
- de huidige whitelist geeft alleen directe toegang tot specifieke roots
- `/Volumes` zelf is op dit moment conceptueel geen browsebare root in het bestaande model
Voor het gewenste gedrag is een extra browsebaar niveau nodig:
- niet als volledig vrije root over het hele filesystem
- maar als gecontroleerde containerdirectory die alleen als bovenliggende presentatie-laag dient voor de whitelisted mounts
Cruciale eis:
- als `/Volumes` zichtbaar wordt, mag niet automatisch alle andere inhoud van `/Volumes` browsebaar worden
- alleen de expliciet toegestane mounts onder `/Volumes` mogen zichtbaar zijn
---
## 4. Veiligheidsmodel
Aanbevolen veiligheidsmodel:
- `/Volumes` wordt niet behandeld als een normale vrije root
- `/Volumes` wordt behandeld als een virtuele of gecontroleerde container-directoryweergave boven de bestaande whitelisted roots
Veilige semantiek:
- de UI/backend toont in `/Volumes` alleen de mountnamen die corresponderen met toegestane roots
- voor deze case dus alleen:
- `8TB`
- `8TB_RAID1`
- andere directories onder de echte `/Volumes` mogen niet automatisch zichtbaar worden
Concreet:
- browse van `/Volumes` retourneert een samengestelde directorylisting op basis van toegestane roots
- navigatie naar `/Volumes/<naam>` is alleen geldig als die volledige path overeenkomt met een geconfigureerde root of daarbinnen valt
Passend bij bestaand model:
- alle verdere padresolutie onder `/Volumes/8TB/...` en `/Volumes/8TB_RAID1/...` blijft via bestaand `path_guard`
- traversal en whitelistcontrole blijven dus centraal gehandhaafd
---
## 5. Backend-impact
Dit kan niet netjes alleen frontend-side worden opgelost.
Waarom niet frontend-only:
- de bestaande browse-API verwacht een pad dat door de backend gevalideerd en opgelijst wordt
- als de backend `/Volumes` niet kent als geldige browsecontext, kan de frontend die laag niet betrouwbaar simuleren zonder speciale hardcoded clientlogica
- frontend-only zou ook de securitygrenzen vertroebelen, omdat de UI dan zelf een deel van de directorystructuur zou moeten faken
Backend-aanpassing is dus nodig.
Veiligste en simpelste richting:
- een kleine backend-uitbreiding in de browse-service/path-interpretatie
- introduceer een gecontroleerd browse-niveau voor `/Volumes`
- behandel dat niveau als speciale, beperkte listing van geconfigureerde roots
- behoud voor alle onderliggende operaties het bestaande whitelist/path_guard-model
Pragmatische v1-richting:
- voeg een expliciete conceptuele container-root toe, bijvoorbeeld browsepad `/Volumes`
- browse op `/Volumes` retourneert alleen directory-entries voor de toegestane mount-roots
- browse op `/Volumes/<mount>` mapt naar de bestaande geconfigureerde root
Dat is veiliger dan `/Volumes` volledig als nieuwe whitelist-root toevoegen.
---
## 6. Risico's
### Regressierisico
- browse-contract moet duidelijk blijven voor bestaande paden zoals `storage1/...`
- bestaande UI- en golden-tests zijn nu alias-gebaseerd; die mogen niet onbedoeld breken
- copy/move/rename/delete/bookmarks werken nu op bestaande padrepresentaties; migratie naar `/Volumes/...` moet doordacht gebeuren
### Securitygevolgen
- een onzorgvuldige implementatie zou per ongeluk meer van `/Volumes` kunnen tonen dan toegestaan
- een onzorgvuldige mapping van `/Volumes/<naam>` naar echte paden kan whitelistcontroles verzwakken
### UX-verwarring
- tijdelijke co-existentie van aliaspaden (`storage1/...`) en hostachtige paden (`/Volumes/8TB/...`) kan verwarrend zijn
- zonder heldere keuze ontstaat een hybride model dat lastig te begrijpen en te testen is
---
## 7. Aanbeveling
Aanbevolen implementatierichting voor v1:
1. Niet frontend-only doen.
2. Geen vrije browse-root van heel `/Volumes` toevoegen.
3. Wel een gecontroleerde backend-browseweergave voor `/Volumes` introduceren.
4. Laat die weergave alleen expliciet geconfigureerde mountdirectories tonen.
5. Houd alle echte padvalidatie en verdere navigatie onder die mounts via bestaand `path_guard`-model.
Concreet aanbevolen model:
- `/Volumes` wordt een speciale browse-entrypoint
- listing van `/Volumes` bevat alleen de whitelisted mountnamen
- `/Volumes/8TB/...` en `/Volumes/8TB_RAID1/...` worden daarna normaal browsebaar binnen de bestaande veiligheidsgrenzen
Waarom dit de beste v1-richting is:
- sluit aan op de echte hoststructuur
- behoudt securitycontrole centraal in backend
- vermijdt frontend-hardcoding als primaire oplossing
- is beperkt, uitlegbaar en testbaar
Niet aanbevolen voor v1:
- puur frontend-aliasing
- volledig openstellen van `/Volumes` als generieke root
- tegelijk zowel aliasmodel als hostpathmodel als primaire UX blijven promoten zonder expliciete migratiekeuze
+228
View File
@@ -0,0 +1,228 @@
# Video Streaming v1
## Doel
Video Streaming v1 voegt een kleine, veilige manier toe om videobestanden direct vanuit de webui af te spelen in de browser, zonder eerst een volledige lokale kopie te maken. Dat past bij de bestaande dual-pane workflow: bestanden blijven centraal browsebaar, en video openen wordt een gerichte viewer-actie binnen dezelfde app.
De kern is browser-native streaming via HTTP, niet het bouwen van een mediaserver. De app blijft een file manager met een beperkte preview-/playbackfunctie.
## Scope
Video Streaming v1 ondersteunt:
- `mp4`
- `mkv`
- afspelen in een modal/popup video viewer
- browser-native:
- play/pause
- seek/scrub bar
- volume
- fullscreen
Niet in scope voor v1:
- transcoding
- codecconversie
- adaptive bitrate streaming
- playlists
- thumbnails / chapter support
- picture-in-picture specifieke UI-logica
- ingebedde subtitle-extractie uit containers
Ondertiteling in v1 is alleen kansrijk als losse subtitle-bestanden later eenvoudig gekoppeld kunnen worden; dat is niet de basis van deze eerste slice.
## Open-/Afspeelgedrag in de UI
Aanbevolen v1-gedrag:
- dubbelklik op videobestand = afspelen
- `Enter` op geselecteerd videobestand = afspelen
- gewone single click blijft selectie
- klik op directorynaam blijft directory openen
Dit sluit aan op standaard file-manager gedrag:
- selecteren en openen blijven gescheiden
- directory-open gedrag blijft intact
- video-open is alleen voor videobestanden
Rechtermuisknop/contextmenu blijft buiten scope. Dat zou extra event-complexiteit toevoegen zonder noodzaak voor een eerste bruikbare versie.
## Streamingmodel
De aanbevolen techniek is een read-only HTTP endpoint met `Range` request ondersteuning.
Waarom:
- browsers kunnen dan direct streamen en seeken
- grote bestanden hoeven niet volledig in memory of eerst volledig gedownload te worden
- dit past goed bij `<video src="...">`
Gewenst gedrag:
- browser vraagt een eerste byte-range op
- server serveert alleen de gevraagde byte-range
- bij seeken vraagt de browser een nieuw bereik op
- response gebruikt `206 Partial Content` waar nodig
Dit is beter dan volledige download vooraf omdat:
- startup sneller is
- geheugenverbruik laag blijft
- netwerkverkeer beperkt blijft tot wat de speler echt nodig heeft
## Backend-impact
Aanbevolen nieuw endpoint:
- `GET /api/files/video?path=...`
Aanvullend gedrag:
- alleen files
- alleen binnen bestaand `path_guard` / whitelist model
- directory => `type_conflict`
- niet gevonden => `path_not_found`
- traversal / buiten whitelist => bestaande securityfouten
Belangrijke backendvereisten:
- valideer pad via bestaand `path_guard`
- valideer dat het om een file gaat
- content-type bepalen op basis van extensie / bekende mapping
- `Range` request parsing ondersteunen
- response streamen vanaf filehandle, niet eerst volledig inlezen
- correcte headers:
- `Accept-Ranges: bytes`
- `Content-Range` bij partial content
- `Content-Length`
- passend `Content-Type`
Geheugenverbruik blijft laag door:
- file in chunks te lezen
- geen volledige buffering
- directe streamingresponse
## Frontend-impact
Aanbevolen richting:
- aparte video modal naast bestaande text `View` modal
- geen overbelasting van de huidige tekstviewer
Reden:
- tekst-view en video-view hebben ander gedrag
- regressierisico blijft lager als beide modaltypen gescheiden zijn
UI-richting:
- bestaand selectiemechanisme blijft
- open-actie detecteert videotype
- video opent in aparte modal met `<video controls>`
Dat laat de bestaande tekst-viewflow intact en voorkomt dat `View` v1 voor tekst regressies krijgt.
## MKV / Codec-realiteit
`mkv` als container betekent niet dat elke browser het bestand echt kan afspelen.
Belangrijke realiteit:
- browser support hangt af van codecs in de container
- bijvoorbeeld H.264/AAC in MP4 is meestal kansrijker
- MKV met niet-ondersteunde codecs kan ondanks correcte streaming nog steeds niet afspelen
Veilige v1-verwachting:
- server ondersteunt streaming van `mkv`
- browser probeert native playback
- als browser/codec-combinatie niet ondersteund wordt, toont de UI een nette melding dat afspelen in deze browser niet beschikbaar is
Dus:
- v1 belooft streaming
- v1 belooft geen universele codeccompatibiliteit
## Ondertiteling
Veilige v1-richting:
- geen ingebedde MKV subtitles
- eventueel later alleen losse subtitle-bestanden zoals `.vtt` of `.srt`
Waarom ingebedde subtitles buiten scope zijn:
- vereist parsing/extractie uit container
- verhoogt backendcomplexiteit duidelijk
- browsers ondersteunen losse tracks eenvoudiger dan container-interne subtitles
Conclusie:
- subtitles niet als kern van v1
- als later toegevoegd, begin met losse subtitle-bestanden
## Risico's
Belangrijkste risico's:
- correcte `Range` handling
- browser codec support
- grote files / seek-gedrag
- security op file access
- regressie op bestaande view/open-flow
Specifiek:
- foutieve range-implementatie breekt seeken
- te brede content-type toelating maakt gedrag onduidelijk
- combineren van tekst-view en video-view in één modal verhoogt regressierisico
## Teststrategie
Backend golden tests:
- video endpoint success voor ondersteund pad
- range request geeft partial content
- directory => `type_conflict`
- path not found
- traversal attempt
- invalid/non-video type indien endpoint daarop beperkt wordt
UI smoke/regressietests:
- video modal container aanwezig
- JS wiring voor dubbelklik / `Enter` op videobestand
- bestaande directory-open flow blijft bestaan
- bestaande tekst-viewflow blijft bestaan
Handmatige validatie:
- mp4 opent en speelt af
- seek werkt
- fullscreen werkt
- sluiten modal werkt
- mkv met browser-ondersteunde codec speelt
- mkv met niet-ondersteunde codec faalt netjes
## Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- nieuw backend endpoint `GET /api/files/video?path=...`
- read-only streaming met `Range` support
- aparte video modal in de frontend
- openen via:
- dubbelklik op videobestand
- `Enter` op geselecteerd videobestand
- `mp4` als primaire happy path
- `mkv` technisch toestaan, maar met expliciete verwachting dat browser/codec support kan verschillen
- geen subtitle-feature als kern van v1
Dit is de kleinste bruikbare stap die:
- goed past bij de huidige app
- bestaande security hergebruikt
- geen mediaserver-architectuur introduceert
- regressierisico op browse/view/edit laag houdt
+231
View File
@@ -0,0 +1,231 @@
# Video Thumbnail v1
## 1. Doel
Video thumbnails voegen vooral waarde toe in directories met veel mediabestanden: de gebruiker kan sneller onderscheid maken tussen vergelijkbare videobestanden zonder elk bestand eerst te openen. Binnen de huidige dual-pane workflow moet dit dezelfde ondersteunende rol hebben als image thumbnails: een kleine visuele hint in de bestaande mediaslot links van de naam, niet een aparte mediabrowser.
Dit moet passen binnen de huidige lijstweergave:
- geen galerij-UI
- geen wijziging aan browse- of selectiegedrag
- alleen zichtbaar als `Show thumbnails` is ingeschakeld
## 2. Scope
Aanbevolen v1-scope:
- wel onderzoeken en eventueel ondersteunen:
- `mp4`
- beperkt / best-effort:
- `mkv`
- niet in v1:
- video thumbnail-generatie zonder duidelijke technische basis
- video contact sheets
- hover-preview / animated preview
- pdf thumbnails
- subtitle-preview
- generieke media analysis pipeline
Belangrijke conclusie voor scope:
- `mp4` is de realistische primaire kandidaat
- `mkv` is niet gelijkwaardig aan `mp4`
- als `mkv` wordt meegenomen, moet dat expliciet best-effort zijn en niet als gegarandeerde happy path worden gepresenteerd
## 3. Technische haalbaarheid
### Zonder extra dependency
Echte video thumbnails genereren zonder extra dependency is in de praktijk niet verstandig.
Waarom:
- een browser kan wel video afspelen, maar de backend heeft voor een lijstthumbnail een afbeeldingsrepresentatie nodig
- pure standaardbibliotheek in Python biedt geen bruikbare frame-extractie uit video
- alleen het originele videobestand serveren en hopen dat de browser daar in een `<img>` of simpele lijstweergave iets van maakt is geen robuuste aanpak
Conclusie zonder dependency:
- echte video thumbnails zijn niet netjes haalbaar
- zonder extra tool resteert alleen een video-icoon/placeholder
### Met dependency zoals `ffmpeg`
`ffmpeg` is de technisch logische kandidaat voor frame-extractie.
Voordelen:
- breed inzetbaar
- kan één frame op een gekozen offset extraheren
- werkt voor veel containers/codecs, inclusief veel `mp4`- en `mkv`-varianten
- laat read-only thumbnailgeneratie toe zonder transcoding of volledige media pipeline
Nadelen:
- extra runtime dependency
- operationele afhankelijkheid op container/host
- frame-extractie is CPU- en I/O-werk
- caching- en invalidatievragen worden relevant
- `mkv` blijft afhankelijk van werkelijke codec-ondersteuning in de file, ook al helpt `ffmpeg` veel
### Aanbeveling haalbaarheid
Eerlijke v1-aanbeveling:
- **zonder extra dependency geen echte video thumbnails bouwen**
- als video thumbnails echt gewenst zijn, dan is een beperkte `ffmpeg`-afhankelijke v1 de eerste technische route die verdedigbaar is
- als het project op dit moment dependency-arm moet blijven, is uitstel verstandiger dan een halfwerkende pseudo-thumbnailoplossing
## 4. Thumbnail-bron
Als video thumbnails later wél gebouwd worden, is het eerste frame meestal geen goede keuze:
- eerste frames zijn vaak zwart
- leader-frames geven weinig informatie
Veiligere keuze:
- een klein offsetmoment, bijvoorbeeld rond `00:00:02` of een klein percentage in het begin
- nog steeds read-only: alleen één frame extraheren, geen analysepipeline
Bij grote bestanden en netwerkvolumes betekent dit:
- thumbnailgeneratie veroorzaakt extra read-I/O op het videobestand
- bij veel bestanden in één directory kan dat snel oplopen
- juist daarom is caching of strikte lazy loading relevant zodra echte video thumbnails worden toegevoegd
## 5. UI-gedrag
De video thumbnail moet in dezelfde mediaslot komen als image thumbnails:
- links van de naam
- zelfde vaste slotbreedte
- geen verschuiving van de naamkolom
Gedrag:
- als `Show thumbnails` uit staat:
- video krijgt gewoon het bestaande file-icoon of een video-icoon
- als `Show thumbnails` aan staat:
- ondersteunde video met beschikbare thumbnail: toon thumbnail
- als thumbnail niet beschikbaar is of niet ondersteund wordt: toon passend icoon/placeholder
Belangrijke UI-eis:
- de lijst mag niet instabiel worden
- thumbnails zijn een enhancement, geen vereiste voor nette uitlijning
## 6. Settings-relatie
Video thumbnails moeten alleen zichtbaar zijn als `Show thumbnails` aan staat.
Aanbevolen v1-keuze:
- **geen aparte extra setting voor video thumbnails**
Reden:
- een tweede thumbnailsetting maakt Settings complexer voordat er een bewezen implementatie is
- eerst moet duidelijk zijn of video thumbnails technisch en performance-matig verantwoord zijn
Als video thumbnails later worden toegevoegd, vallen ze in eerste instantie onder dezelfde globale toggle.
## 7. Performance en caching
Belangrijkste risico:
- frame-extractie is duidelijk duurder dan het serveren van bestaande image files
### Risico's
- grote directories met veel videos kunnen browse-performance merkbaar verslechteren
- thumbnails op netwerkvolumes versterken latency
- gelijktijdige extracties kunnen CPU en disk-I/O onnodig belasten
### Lazy loading
Lazy loading is verplicht zodra echte video thumbnails bestaan:
- alleen thumbnails laden voor zichtbare rijen
- beperkte concurrency
- geen eager generatie voor volledige directorylisting
### Caching
Voor echte video thumbnails is een vorm van caching vrijwel onvermijdelijk zodra de feature meer dan experimenteel moet zijn.
Afweging:
- zonder cache: te veel herhaalde frame-extractie
- met disk-cache: meer complexiteit, maar technisch logisch
Aanbevolen v1-richting als `ffmpeg` later wordt toegestaan:
- kleine disk-cache of bestandsgebonden cache-key op pad + mtime
- maar alleen als de implementatieslice expliciet bereid is deze extra complexiteit te dragen
Aanbevolen richting voor nu met laag risico:
- geen caching bouwen zolang de dependency- en implementatiekeuze nog niet definitief is
- eerst expliciet besluiten of echte video thumbnails de extra complexiteit waard zijn
## 8. Backend-impact
Als video thumbnails later worden gebouwd, is een apart read-only endpoint logisch, bijvoorbeeld:
- `GET /api/files/video-thumbnail?path=...`
Waarom apart endpoint:
- scheidt image thumbnails van videothumbnails
- maakt eigen foutafhandeling, typevalidatie en caching later eenvoudiger
- houdt browse-response klein
Bestaande infrastructuur moet leidend blijven:
- `path_guard`
- whitelist/root containment
- bestaande foutmapping voor traversal / invalid root alias / not found / type conflicts waar passend
- read-only gedrag: alleen lezen, geen wijziging aan bronbestand
## 9. MKV-realiteit
`mkv` is een container, geen garantie voor uniforme technische behandeling.
Belangrijke realiteit:
- `mkv`-bestanden variëren sterk in codecs en encoding-eigenschappen
- browser playback en server-side frame-extractie zijn twee verschillende dingen
- zelfs als browserplayback soms lastig is, kan `ffmpeg` vaak nog wel een thumbnail extraheren
- maar dat maakt `mkv` nog niet gelijkwaardig qua voorspelbaarheid aan `mp4`
Aanbevolen v1-positionering:
- `mkv` hooguit best-effort
- `mp4` is de primaire en verwachte happy path
- als thumbnail-extractie voor `mkv` faalt, toon gewoon het video-icoon/placeholder zonder extra dramatische fout in de lijst
## 10. Regressierisico
Belangrijkste regressierisico's:
- browse-performance verslechtert merkbaar
- thumbnail-latency maakt de lijst onrustig
- mediaslot wordt inconsistent tussen image/video/non-image
- externe toolchain verhoogt deploy-complexiteit
- caching kan nieuwe foutbronnen introduceren
Ook belangrijk:
- huidige image thumbnail-flow moet niet vervuild raken met video-specifieke uitzonderingen
- de lijst moet leesbaar blijven, ook als video thumbnails traag of afwezig zijn
## 11. Teststrategie
### Backend golden tests
Als video thumbnails later echt gebouwd worden:
- success voor ondersteunde video met thumbnail
- not found
- traversal blocked
- invalid root alias
- unsupported/non-video type blocked
- nette fallback als thumbnail-extractie niet lukt
### UI smoke/regressietests
- mediaslot blijft bestaan
- lijst blijft renderen met thumbnails aan/uit
- video zonder thumbnail toont icoon/placeholder
- image thumbnails blijven werken
- selectie/current row/active pane styling blijft intact
### Handmatige validatie
- directory met veel videos blijft bruikbaar
- thumbnails verschijnen alleen als setting aan staat
- mp4 thumbnail werkt voorspelbaar
- mkv faalt netjes terug op placeholder waar nodig
- geen merkbare regressie in browse-flow of selectie
## 12. Aanbeveling
Aanbevolen v1-richting met laag regressierisico:
- **nu nog geen echte video thumbnails implementeren zonder extra dependency**
- houd de huidige mediaslot-aanpak aan:
- image thumbnails waar al ondersteund
- voor video voorlopig een video-icoon/placeholder
- als de feature later echt gebouwd moet worden:
- gebruik `ffmpeg` als expliciete dependency
- scopeer eerst op `mp4`
- behandel `mkv` als best-effort
- combineer dit met lazy loading
- overweeg pas daarna een eenvoudige cache
Samengevat:
- eerlijke technische conclusie: echte video thumbnails in v1 zonder dependency zijn niet verstandig
- veiligste huidige richting: uitstellen of beperken tot ontwerpvoorbereiding
- als later toch doorgepakt wordt, dan alleen met expliciete keuze voor `ffmpeg` en een smalle, performance-bewuste scope
+194
View File
@@ -0,0 +1,194 @@
# Research: Remote Single-File Copy To Host
## Relevante file analysis
### Backend
- [routes_files.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_files.py)
Bevat de bestaande lokale upload-route (`POST /api/files/upload`) en de remote read-only Phase 3 routes (`view`, `info`, `download`, `image`) via `RemoteFileService`.
- [routes_copy.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_copy.py)
Bevat de bestaande copy-route (`POST /api/files/copy`) die volledig uitgaat van host-side source en host-side destination.
- [file_ops_service.py](/workspace/webmanager-mvp/webui/backend/app/services/file_ops_service.py)
Bevat lokale file-acties. Relevant is vooral `upload()`, omdat die host-write doet na `PathGuard`-validatie van een doeldirectory.
- [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py)
Bevat task-opbouw, destination-validatie en taakcreatie voor copy, maar gaat uit van een lokale bron die via `PathGuard` naar een host-pad resolveert.
- [remote_file_service.py](/workspace/webmanager-mvp/webui/backend/app/services/remote_file_service.py)
Bevat al de benodigde remote read-path parsing, share-validatie via registry, agent-auth, error mapping en een gestreamde `prepare_download()` naar de agent.
- [filesystem_adapter.py](/workspace/webmanager-mvp/webui/backend/app/fs/filesystem_adapter.py)
Bevat de feitelijke host-write helpers:
- `write_uploaded_file(path, file_stream, overwrite=False)`
- `copy_file(source, destination, on_progress=None)`
`copy_file` vereist een lokale bron op de host en is dus niet bruikbaar voor remote input. `write_uploaded_file` schrijft een inkomende stream naar een hostpad en is conceptueel het dichtstbij.
- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py)
Houdt host-write validatie strikt lokaal. Dat moet zo blijven; remote paden mogen hier niet als bronsemantiek in terechtkomen.
- [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py)
Bevat task-based copy/move uitvoering, maar alleen voor host-side bronpaden. Wel relevant als patroon voor een aparte remote-to-host worker.
- [schemas.py](/workspace/webmanager-mvp/webui/backend/app/api/schemas.py)
Bevat bestaande `CopyRequest` en upload/copy response-modellen. Voor een aparte feature is waarschijnlijk een nieuw requestmodel nodig.
### Frontend
- [app.js](/workspace/webmanager-mvp/webui/html/app.js)
Relevante bestaande flows:
- `uploadFileRequest()` gebruikt uitsluitend `/api/files/upload`
- `startCopySelected()` gebruikt uitsluitend `/api/files/copy`
- remote browse/view/download is al source-aware
- remote copy is nu bewust geblokkeerd
Dit bevestigt dat upload-flow en copy-flow momenteel twee losse UI-contracten zijn.
### Agent
- [finder_commander/app/main.py](/workspace/webmanager-mvp/finder_commander/app/main.py)
Agent heeft al wat voor deze feature nodig is:
- strikte `share + relative path` validatie
- `GET /api/info`
- `GET /api/download`
Voor remote single-file copy naar host is geen nieuwe remote write-API nodig.
## Oordeel over hergebruik van upload-internals
### Bestaande upload-functionaliteit aanpassen?
Nee.
Reden:
- de bestaande upload-route, upload-requestvorm en upload-UI werken al goed
- upload is browser -> host via multipart/form-data
- de gewenste feature is agent/remote -> host via backend-proxy/stream
- dat is een ander contract, andere foutbron en andere bronsemantiek
### Interne host-write logica hergebruiken?
Ja, maar alleen op intern helper/service-niveau.
Concreet oordeel:
- `FilesystemAdapter.copy_file()` is niet geschikt voor hergebruik
Reden: vereist een lokale host-bronpad als source.
- `FilesystemAdapter.write_uploaded_file()` is deels relevant
Reden: dit doet precies de host-write van een inkomende stream naar een doelbestand.
- Direct hergebruik van `FileOpsService.upload()` is niet verstandig
Reden: die methode is semantisch en contractueel gekoppeld aan multipart upload en `UploadFile`.
Best passende richting:
- niet hergebruiken via bestaande upload-endpoints of upload-flow
- wel overwegen om de onderliggende stream-naar-bestand write logica te hergebruiken of te veralgemeniseren in `FilesystemAdapter`
- voorkeur: een nieuwe sibling-helper zoals `write_stream_file(...)` of een kleine interne extractie, zodat upload ongewijzigd blijft en remote copy dezelfde veilige host-write primitief kan gebruiken
## Ontwerpvoorstel
### Feature
`Copy remote file to host`
### Scope
- alleen single file
- alleen source onder `/Clients/...`
- alleen destination op host-side lokale map
- geen mappen
- geen overwrite in eerste change request tenzij expliciet gewenst
- geen upload-route hergebruik
- geen brede refactor
### Backendontwerp
Voeg een aparte backend feature toe, niet via `POST /api/files/upload` en niet via bestaande `POST /api/files/copy`.
Voorkeursvorm:
- nieuwe route, bijvoorbeeld `POST /api/files/remote-copy`
- request bevat:
- `source`: remote bestandspad onder `/Clients/...`
- `destination_dir`: host-directory pad
Nieuwe service, bijvoorbeeld:
- `RemoteCopyToHostService`
Verantwoordelijkheden:
1. valideer dat `source` een remote `/Clients/...` file is
2. valideer dat `destination_dir` een host-directory is via bestaande lokale `PathGuard`
3. haal remote metadata op of resolve remote naam via bestaande `RemoteFileService`
4. bouw destination pad als `destination_dir/<remote-filename>`
5. faal op bestaand doelbestand in eerste versie
6. open remote download-stream via aparte interne helper op `RemoteFileService`
7. schrijf gestreamd naar host met een aparte interne host-write helper
8. map fouten strikt:
- remote unavailable blijft lokale actie-fout
- host permission/path-conflict blijft gewone host-fout
### Aanbevolen interne hergebruikslijn
- laat `RemoteFileService` een interne streaming primitive aanbieden, bijvoorbeeld een variant op de huidige remote download-open logica zonder HTTP-response voor browser-download
- laat `FilesystemAdapter` een aparte stream-write helper aanbieden voor generieke inkomende streams
- laat upload zijn bestaande publieke route en flow behouden
### Frontendontwerp
Geen wijziging aan upload-UI.
Kleine aparte UI-feature:
- toon een aparte actie alleen als:
- bronpane een remote file-selectie heeft van exact 1 bestand
- doelpane op een host/local directory staat
- de actie roept de nieuwe backend-route aan
- na succes:
- refresh beide panes
- toon lokale foutmelding bij falen
Voorkeur:
- aparte actie of expliciete source-aware branch voor "Copy remote file to host"
- niet de bestaande upload-flow hergebruiken
### Agentontwerp
Geen nieuwe agent-endpoints nodig in deze scope.
De bestaande `GET /api/download` is voldoende als read-only bron voor streaming.
## Acceptance criteria
- een enkel bestand onder `/Clients/...` kan naar een host-directory worden gekopieerd
- de destination moet een host/local directory zijn
- mappen als remote bron worden geweigerd
- remote -> remote wordt geweigerd
- host -> remote wordt geweigerd
- overwrite gebeurt niet impliciet; bestaand doelbestand geeft een nette fout
- bestaande upload-route, upload-contract en upload-UI blijven ongewijzigd
- bestaande lokale copy-flow blijft ongewijzigd
- remote fouten blijven lokaal tot deze actie
- host-write blijft onder bestaande lokale `PathGuard`-regels vallen
- data wordt gestreamd; geen volledige file-buffer in memory
## Klein plan
1. Voeg een research-backed change request toe voor een aparte route `POST /api/files/remote-copy`.
2. Voeg een kleine service toe die alleen remote single-file source + local destination_dir ondersteunt.
3. Voeg een interne streaming helper toe in `RemoteFileService` voor remote bestand-inname door backend.
4. Voeg een aparte interne host-write helper toe in `FilesystemAdapter` voor generieke stream-naar-bestand writes, zonder upload-API te wijzigen.
5. Voeg minimale frontend wiring toe voor een aparte "Copy remote file to host"-actie.
6. Test stapsgewijs:
- success path remote file -> local dir
- bestaand doelbestand
- remote directory rejected
- remote failure stays local
- upload-regressie: bestaande `/api/files/upload` blijft ongewijzigd
## Expliciete lijst van wat buiten scope blijft
- remote mappen kopiëren
- remote write-acties
- remote -> remote
- host -> remote
- aanpassing van bestaande upload-routes
- aanpassing van upload-requestcontract
- aanpassing van upload-UI
- brede refactor van copy/upload/task-infrastructuur
- bookmarks/startup paths
- remote task-runner verbreding buiten deze ene actie
+748
View File
@@ -0,0 +1,748 @@
* {
box-sizing: border-box;
}
:root {
--bg-page: #111827;
--text-primary: #e5e7eb;
--text-muted: #94a3b8;
--topbar-bg: #020617;
--topbar-text: #e2e8f0;
--surface: #1f2937;
--surface-elevated: #0f172a;
--surface-subtle: #1e293b;
--border: #334155;
--border-soft: #475569;
--divider: #334155;
--button-primary-bg: #2563eb;
--button-primary-border: #2563eb;
--button-primary-text: #ffffff;
--button-secondary-bg: #334155;
--button-secondary-border: #475569;
--button-secondary-text: #e2e8f0;
--button-secondary-hover-bg: #3b4a61;
--badge-bg: #0b3a6e;
--badge-border: #1d4f85;
--badge-text: #dbeafe;
--list-selected-bg: #1e3a8a;
--season-header-bg: #1e293b;
--season-header-border: #334155;
--danger-text: #f87171;
--overlay-bg: rgba(2, 6, 23, 0.7);
--shadow-lg: 0 8px 22px rgba(2, 6, 23, 0.45);
--series-image-bg: #0b1220;
}
[data-theme="light"] {
--bg-page: #f2f4f8;
--text-primary: #1a1f2b;
--text-muted: #64748b;
--topbar-bg: #0f172a;
--topbar-text: #e2e8f0;
--surface: #ffffff;
--surface-elevated: #ffffff;
--surface-subtle: #f8fafc;
--border: #d7dee9;
--border-soft: #c3cedf;
--divider: #e4eaf2;
--button-primary-bg: #0f172a;
--button-primary-border: #0f172a;
--button-primary-text: #ffffff;
--button-secondary-bg: #e2e8f0;
--button-secondary-border: #c3cedf;
--button-secondary-text: #1a1f2b;
--button-secondary-hover-bg: #eef2f7;
--badge-bg: #dbeafe;
--badge-border: #bfdbfe;
--badge-text: #0f172a;
--list-selected-bg: #e0f2fe;
--season-header-bg: #eef2ff;
--season-header-border: #dbe4fb;
--danger-text: #b91c1c;
--overlay-bg: rgba(2, 6, 23, 0.55);
--shadow-lg: 0 8px 22px rgba(15, 23, 42, 0.12);
--series-image-bg: #f8fafc;
}
body {
margin: 0;
padding: 0;
font-family: "Segoe UI", Tahoma, sans-serif;
background: var(--bg-page);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--topbar-bg);
color: var(--topbar-text);
}
.topbar h1 {
margin: 0;
font-size: 20px;
}
#sessionMeta {
font-size: 12px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.theme-toggle-btn {
border: 1px solid var(--button-secondary-border);
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border-radius: 999px;
width: 34px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(280px, 1fr));
gap: 12px;
padding: 12px;
align-items: start;
flex: 1;
min-height: 0;
overflow: hidden;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
min-height: 420px;
display: flex;
flex-direction: column;
}
#panelSearch,
#panelEpisodes,
#panelSelectedEpisodes,
#panelSelectedFiles {
height: 100%;
min-height: 0;
max-height: 100%;
align-self: stretch;
overflow: hidden;
}
#panelSearch .panel-body {
flex: 1;
min-height: 0;
}
.panel h2 {
margin: 0 0 10px;
font-size: 16px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
min-height: 38px;
}
.panel-head h2 {
margin: 0;
}
.panel-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.panel-head-actions-empty {
min-width: 172px;
}
.panel h3 {
margin: 10px 0 6px;
font-size: 14px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.search-combobox-row input[type="text"] {
flex: 1;
min-width: 0;
}
#searchDropdownBtn {
min-width: 34px;
padding-left: 8px;
padding-right: 8px;
}
.search-combobox {
position: relative;
}
.combobox-dropdown {
position: absolute;
left: 0;
right: 0;
top: calc(100% - 6px);
z-index: 20;
background: var(--surface-elevated);
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
padding: 6px;
}
#rememberedDropdownList {
max-height: 220px;
}
#rememberedDropdownList li {
align-items: center;
}
#rememberedDropdownList .remembered-item-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
#rememberedDropdownList .remembered-remove-btn {
border: none;
background: transparent;
color: var(--text-muted);
width: 22px;
height: 22px;
line-height: 1;
border-radius: 4px;
padding: 0;
font-size: 15px;
cursor: pointer;
}
#rememberedDropdownList .remembered-remove-btn:hover {
background: var(--button-secondary-hover-bg);
color: var(--text-primary);
}
.stack {
display: flex;
flex-direction: column;
}
.panel-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
input[type="text"],
select {
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 6px 8px;
min-width: 160px;
background: var(--surface-subtle);
color: var(--text-primary);
}
button {
border: 1px solid var(--button-primary-border);
background: var(--button-primary-bg);
color: var(--button-primary-text);
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
}
button.secondary {
background: var(--button-secondary-bg);
color: var(--button-secondary-text);
border-color: var(--button-secondary-border);
}
.list {
list-style: none;
margin: 0;
padding: 0;
max-height: 260px;
overflow: auto;
border: 1px solid var(--divider);
border-radius: 6px;
background: var(--surface-elevated);
}
.linked-list-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
}
.linked-list {
flex: 1;
height: 100%;
max-height: none;
overflow-y: auto;
}
#panelEpisodes .panel-body,
#panelSelectedEpisodes .panel-body,
#panelSelectedFiles .panel-body {
flex: 1;
min-height: 0;
overflow: hidden;
}
#panelEpisodes .linked-list-wrap,
#panelSelectedEpisodes .linked-list-wrap,
#panelSelectedFiles .linked-list-wrap {
flex: 1;
min-height: 0;
}
#panelEpisodes .linked-list-wrap .list,
#panelSelectedEpisodes .linked-list-wrap .list,
#panelSelectedFiles .linked-list-wrap .list {
max-height: none;
height: 100%;
}
#episodesList .episode-main {
display: flex;
flex-direction: column;
min-width: 0;
}
#episodesList li.episode-row {
cursor: pointer;
}
#episodesList .episode-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#episodesList .episode-date {
margin-top: 2px;
font-size: 12px;
color: var(--text-muted);
}
#episodesList .episode-date-future {
color: var(--button-primary-bg);
}
#episodesList li.episode-row.episode-anchor {
box-shadow: inset 0 0 0 1px var(--button-primary-border);
}
.badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
color: var(--badge-text);
background: var(--badge-bg);
border: 1px solid var(--badge-border);
border-radius: 999px;
padding: 1px 6px;
margin-right: 6px;
}
.list li.selected {
background: var(--list-selected-bg);
}
.list li.season-header {
background: var(--season-header-bg);
border-bottom: 1px solid var(--season-header-border);
color: var(--text-primary);
font-weight: 700;
justify-content: flex-start;
padding: 8px;
}
.panel-footer {
position: sticky;
bottom: 0;
background: var(--surface);
border-top: 1px solid var(--divider);
padding-top: 8px;
margin-top: 10px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
min-height: 40px;
}
.panel-search-footer {
justify-content: flex-end;
}
.mismatch {
color: var(--danger-text);
font-weight: 700;
}
.modal {
position: fixed;
inset: 0;
background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal.hidden {
display: none;
}
.modal-card {
width: min(1400px, 90vw);
height: 80vh;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
min-height: 0;
}
.tvdb-modal-card {
width: min(1500px, 92vw);
height: 86vh;
}
.tvdb-modal-head {
margin-bottom: 6px;
}
.tvdb-fallback {
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px;
margin-bottom: 8px;
background: var(--surface-subtle);
}
.tvdb-fallback h4 {
margin: 0 0 6px;
}
.tvdb-fallback p {
margin: 0;
color: var(--text-muted);
}
.tvdb-modal-frame {
width: 100%;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.modal-root-row {
margin-bottom: 8px;
}
.modal-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 4px;
flex: 1;
min-height: 0;
}
.modal-pane {
display: flex;
flex-direction: column;
min-height: 0;
}
.modal-pane .list {
flex: 1;
min-height: 0;
max-height: none;
}
#modalFoldersList li,
#modalFilesList li {
cursor: pointer;
}
#modalFilesList li.modal-anchor {
box-shadow: inset 0 0 0 1px var(--button-primary-border);
}
.modal-files-tools {
margin-bottom: 8px;
}
.modal-files-tools input[type="text"] {
flex: 1;
min-width: 180px;
}
.modal-actions {
margin-top: 10px;
margin-bottom: 0;
justify-content: flex-end;
border-top: 1px solid var(--divider);
padding-top: 10px;
}
.settings-card {
width: min(520px, 94vw);
}
.settings-section h4 {
margin: 0 0 8px;
font-size: 14px;
color: var(--text-primary);
}
.settings-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 4px;
}
.settings-field label {
font-size: 12px;
color: var(--text-muted);
}
.settings-field select {
min-width: 0;
width: 100%;
}
.settings-field input[type="number"] {
min-width: 0;
width: 100%;
}
.settings-danger-row {
margin-top: 6px;
}
.settings-check {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary);
}
.settings-actions {
justify-content: flex-end;
margin-top: 12px;
}
#panelSelectedEpisodes .panel-footer button:first-child,
#panelSelectedEpisodes .panel-footer button:last-child,
#panelSelectedFiles .panel-footer button:first-child,
#panelSelectedFiles .panel-footer button:last-child {
border-color: var(--button-primary-border);
background: var(--button-primary-bg);
color: var(--button-primary-text);
}
.list li {
display: flex;
justify-content: space-between;
gap: 8px;
border-bottom: 1px solid var(--divider);
padding: 6px 8px;
font-size: 13px;
}
.list li:last-child {
border-bottom: none;
}
#selectedEpisodesList li,
#selectedFilesList li {
height: 38px;
min-height: 38px;
align-items: center;
}
#selectedEpisodesList li > span,
#selectedFilesList li > span {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#selectedEpisodesList li > div,
#selectedFilesList li > div {
flex-shrink: 0;
}
.muted {
color: var(--text-muted);
font-size: 12px;
margin-bottom: 8px;
}
.hidden {
display: none !important;
}
#panelSearch #searchResults {
margin-bottom: 10px;
height: 220px;
max-height: 220px;
overflow-y: auto;
}
#panelSearch #searchResults li {
min-height: 38px;
height: 38px;
align-items: center;
}
#panelSearch #searchResults li > span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.series-details {
border-top: 1px solid var(--divider);
padding-top: 10px;
}
.series-media {
margin-bottom: 8px;
width: 100%;
padding: 0 4px;
}
.series-media img {
width: 100%;
max-width: none;
height: 92px;
object-fit: contain;
object-position: center center;
display: block;
margin: 0;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--series-image-bg);
}
.series-meta {
font-size: 12px;
color: var(--text-primary);
display: grid;
gap: 4px;
margin-bottom: 8px;
}
.series-meta span:first-child {
color: var(--text-muted);
}
.series-overview {
margin: 0 0 8px;
font-size: 12px;
line-height: 1.35;
color: var(--text-primary);
}
.series-link {
font-size: 12px;
color: var(--text-muted);
text-decoration: none;
}
.series-link:hover {
text-decoration: underline;
}
#outputBox {
margin: 0;
background: var(--surface-subtle);
color: var(--text-primary);
border-radius: 6px;
padding: 10px;
max-height: 320px;
overflow: auto;
font-size: 12px;
border: 1px solid var(--border);
}
.debug-page {
margin: 12px;
}
@media (max-width: 1600px) {
.grid {
grid-template-columns: repeat(2, minmax(280px, 1fr));
}
}
@media (max-width: 900px) {
.grid {
grid-template-columns: 1fr;
}
#panelEpisodes,
#panelSelectedEpisodes,
#panelSelectedFiles {
min-height: 420px;
}
.modal-grid {
grid-template-columns: 1fr;
}
}
-2
View File
@@ -1,2 +0,0 @@
dd
Binary file not shown.
Binary file not shown.
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Header
from backend.app.api.schemas import (
RemoteClientHeartbeatRequest,
RemoteClientItem,
RemoteClientListResponse,
RemoteClientRegisterRequest,
)
from backend.app.dependencies import get_remote_client_service
from backend.app.services.remote_client_service import RemoteClientService
router = APIRouter(prefix="/clients")
@router.get("", response_model=RemoteClientListResponse)
async def list_clients(
service: RemoteClientService = Depends(get_remote_client_service),
) -> RemoteClientListResponse:
return service.list_clients()
@router.post("/register", response_model=RemoteClientItem)
async def register_client(
request: RemoteClientRegisterRequest,
authorization: str | None = Header(default=None),
service: RemoteClientService = Depends(get_remote_client_service),
) -> RemoteClientItem:
return service.register_client(authorization=authorization, request=request)
@router.post("/heartbeat", response_model=RemoteClientItem)
async def heartbeat(
request: RemoteClientHeartbeatRequest,
authorization: str | None = Header(default=None),
service: RemoteClientService = Depends(get_remote_client_service),
) -> RemoteClientItem:
return service.record_heartbeat(authorization=authorization, request=request)
+5
View File
@@ -14,4 +14,9 @@ async def copy_file(
request: CopyRequest,
service: CopyTaskService = Depends(get_copy_task_service),
) -> TaskCreateResponse:
if request.sources is not None:
return service.create_batch_copy_task(
sources=request.sources,
destination_base=request.destination_base,
)
return service.create_copy_task(source=request.source, destination=request.destination)
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from backend.app.api.schemas import DuplicateRequest, TaskCreateResponse
from backend.app.dependencies import get_duplicate_task_service
from backend.app.services.duplicate_task_service import DuplicateTaskService
router = APIRouter(prefix="/files")
@router.post("/duplicate", response_model=TaskCreateResponse, status_code=202)
async def duplicate_paths(
request: DuplicateRequest,
service: DuplicateTaskService = Depends(get_duplicate_task_service),
) -> TaskCreateResponse:
return service.create_duplicate_task(paths=request.paths)
+166 -6
View File
@@ -1,10 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask
from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse
from backend.app.dependencies import get_file_ops_service
from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse
from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service, get_remote_file_service
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.delete_task_service import DeleteTaskService
from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.remote_file_service import RemoteFileService
router = APIRouter(prefix="/files")
@@ -25,9 +30,164 @@ async def rename(
return service.rename(path=request.path, new_name=request.new_name)
@router.post("/delete", response_model=DeleteResponse)
@router.post("/delete", response_model=TaskCreateResponse, status_code=202)
async def delete(
request: DeleteRequest,
service: DeleteTaskService = Depends(get_delete_task_service),
) -> TaskCreateResponse:
if request.paths is not None:
return service.create_batch_delete_task(paths=request.paths, recursive_paths=request.recursive_paths or [])
return service.create_delete_task(path=request.path, recursive=request.recursive)
@router.post("/upload", response_model=UploadResponse)
async def upload(
target_path: str = Form(...),
overwrite: bool = Form(False),
file: UploadFile = File(...),
service: FileOpsService = Depends(get_file_ops_service),
) -> DeleteResponse:
return service.delete(path=request.path)
) -> UploadResponse:
return service.upload(target_path=target_path, upload_file=file, overwrite=overwrite)
@router.get("/view", response_model=ViewResponse)
async def view(
path: str,
for_edit: bool = False,
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> ViewResponse:
if remote_service.handles_path(path):
return remote_service.view(path=path, for_edit=for_edit)
return service.view(path=path, for_edit=for_edit)
@router.get("/info", response_model=FileInfoResponse)
async def info(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> FileInfoResponse:
if remote_service.handles_path(path):
return remote_service.info(path=path)
return service.info(path=path)
@router.get("/download")
async def download(
path: list[str] = Query(...),
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> StreamingResponse:
prepared = remote_service.prepare_download(paths=path) if any(remote_service.handles_path(item) for item in path) else service.prepare_download(paths=path)
response = StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
if prepared.get("cleanup"):
response.background = BackgroundTask(prepared["cleanup"])
return response
@router.post("/download/archive-prepare", response_model=TaskCreateResponse, status_code=202)
async def archive_prepare(
request: ArchivePrepareRequest,
service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service),
) -> TaskCreateResponse:
return service.create_archive_prepare_task(paths=request.paths)
@router.get("/download/archive/{task_id}")
async def archive_download(
task_id: str,
service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service),
) -> StreamingResponse:
prepared = service.prepare_ready_archive_download(task_id=task_id)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
@router.post("/download/archive/{task_id}/cancel", response_model=TaskDetailResponse)
async def archive_cancel(
task_id: str,
service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service),
) -> TaskDetailResponse:
return TaskDetailResponse(**service.cancel_archive_prepare_task(task_id=task_id))
@router.get("/video")
async def video(
path: str,
request: Request,
service: FileOpsService = Depends(get_file_ops_service),
) -> StreamingResponse:
prepared = service.prepare_video_stream(path=path, range_header=request.headers.get("range"))
return StreamingResponse(
prepared["content"],
status_code=prepared["status_code"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
@router.get("/pdf")
async def pdf(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
) -> StreamingResponse:
prepared = service.prepare_pdf_stream(path=path)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
@router.get("/image")
async def image(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> StreamingResponse:
if remote_service.handles_path(path):
prepared = remote_service.prepare_image_stream(path=path)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
prepared = service.prepare_image_stream(path=path)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
@router.get("/thumbnail")
async def thumbnail(
path: str,
service: FileOpsService = Depends(get_file_ops_service),
) -> StreamingResponse:
prepared = service.prepare_thumbnail_stream(path=path)
return StreamingResponse(
prepared["content"],
headers=prepared["headers"],
media_type=prepared["content_type"],
)
@router.post("/save", response_model=SaveResponse)
async def save(
request: SaveRequest,
service: FileOpsService = Depends(get_file_ops_service),
) -> SaveResponse:
return service.save(
path=request.path,
content=request.content,
expected_modified=request.expected_modified,
)
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from backend.app.api.schemas import HistoryListResponse
from backend.app.dependencies import get_history_service
from backend.app.services.history_service import HistoryService
router = APIRouter(prefix="/history")
@router.get("", response_model=HistoryListResponse)
async def list_history(service: HistoryService = Depends(get_history_service)) -> HistoryListResponse:
return service.list_history()
+5
View File
@@ -14,4 +14,9 @@ async def move_file(
request: MoveRequest,
service: MoveTaskService = Depends(get_move_task_service),
) -> TaskCreateResponse:
if request.sources is not None:
return service.create_batch_move_task(
sources=request.sources,
destination_base=request.destination_base,
)
return service.create_move_task(source=request.source, destination=request.destination)
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from backend.app.api.schemas import SearchResponse
from backend.app.dependencies import get_search_service
from backend.app.services.search_service import SearchService
router = APIRouter(prefix="/search")
@router.get("", response_model=SearchResponse)
async def search(
path: str,
query: str,
service: SearchService = Depends(get_search_service),
) -> SearchResponse:
return service.search(path=path, query=query)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
from backend.app.dependencies import get_settings_service
from backend.app.services.settings_service import SettingsService
router = APIRouter(prefix="/settings")
@router.get("", response_model=SettingsResponse)
async def get_settings(
service: SettingsService = Depends(get_settings_service),
) -> SettingsResponse:
return service.get_settings()
@router.post("", response_model=SettingsResponse)
async def update_settings(
request: SettingsUpdateRequest,
service: SettingsService = Depends(get_settings_service),
) -> SettingsResponse:
return service.update_settings(request)
+6 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, status
from backend.app.api.schemas import TaskDetailResponse, TaskListResponse
from backend.app.dependencies import get_task_service
@@ -17,3 +17,8 @@ async def list_tasks(service: TaskService = Depends(get_task_service)) -> TaskLi
@router.get("/{task_id}", response_model=TaskDetailResponse)
async def get_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse:
return service.get_task(task_id)
@router.post("/{task_id}/cancel", response_model=TaskDetailResponse, status_code=status.HTTP_200_OK)
async def cancel_task(task_id: str, service: TaskService = Depends(get_task_service)) -> TaskDetailResponse:
return service.cancel_task(task_id)
+157 -5
View File
@@ -51,13 +51,89 @@ class RenameResponse(BaseModel):
class DeleteRequest(BaseModel):
path: str
path: str | None = None
recursive: bool = False
paths: list[str] | None = None
recursive_paths: list[str] | None = None
class DeleteResponse(BaseModel):
path: str
class UploadResponse(BaseModel):
path: str
size: int
modified: str
class ViewResponse(BaseModel):
path: str
name: str
content_type: str
encoding: str
truncated: bool
size: int
modified: str
content: str
class SaveRequest(BaseModel):
path: str
content: str
expected_modified: str
class SaveResponse(BaseModel):
path: str
size: int
modified: str
class ArchivePrepareRequest(BaseModel):
paths: list[str]
class FileInfoResponse(BaseModel):
name: str
path: str
type: str
size: int | None = None
modified: str
root: str
extension: str | None = None
content_type: str | None = None
owner: str | None = None
group: str | None = None
width: int | None = None
height: int | None = None
class ZipDownloadLimitsResponse(BaseModel):
max_items: int
max_total_input_bytes: int
max_individual_file_bytes: int
scan_timeout_seconds: float
symlink_policy: str
class SettingsResponse(BaseModel):
show_thumbnails: bool
preferred_startup_path_left: str | None = None
preferred_startup_path_right: str | None = None
selected_theme: str
selected_color_mode: str
zip_download_limits: ZipDownloadLimitsResponse
class SettingsUpdateRequest(BaseModel):
show_thumbnails: bool | None = None
preferred_startup_path_left: str | None = None
preferred_startup_path_right: str | None = None
selected_theme: str | None = None
selected_color_mode: str | None = None
class TaskListItem(BaseModel):
id: str
operation: str
@@ -92,8 +168,14 @@ class TaskDetailResponse(BaseModel):
class CopyRequest(BaseModel):
source: str
destination: str
source: str | None = None
destination: str | None = None
sources: list[str] | None = None
destination_base: str | None = None
class DuplicateRequest(BaseModel):
paths: list[str]
class TaskCreateResponse(BaseModel):
@@ -102,8 +184,10 @@ class TaskCreateResponse(BaseModel):
class MoveRequest(BaseModel):
source: str
destination: str
source: str | None = None
destination: str | None = None
sources: list[str] | None = None
destination_base: str | None = None
class BookmarkCreateRequest(BaseModel):
@@ -124,3 +208,71 @@ class BookmarkListResponse(BaseModel):
class BookmarkDeleteResponse(BaseModel):
id: int
class HistoryItem(BaseModel):
id: str
operation: str
status: str
source: str | None = None
destination: str | None = None
path: str | None = None
error_code: str | None = None
error_message: str | None = None
created_at: str
finished_at: str | None = None
class HistoryListResponse(BaseModel):
items: list[HistoryItem]
class SearchResultItem(BaseModel):
name: str
path: str
type: str
parent_path: str
root: str
class SearchResponse(BaseModel):
items: list[SearchResultItem]
truncated: bool
class RemoteClientShare(BaseModel):
key: str
label: str
class RemoteClientRegisterRequest(BaseModel):
client_id: str
display_name: str
platform: str
agent_version: str
endpoint: str
shares: list[RemoteClientShare]
class RemoteClientHeartbeatRequest(BaseModel):
client_id: str
agent_version: str
class RemoteClientItem(BaseModel):
client_id: str
display_name: str
platform: str
agent_version: str
endpoint: str
shares: list[RemoteClientShare]
last_seen: str | None = None
status: str
last_error: str | None = None
reachable_at: str | None = None
created_at: str
updated_at: str
class RemoteClientListResponse(BaseModel):
items: list[RemoteClientItem]
+25 -2
View File
@@ -2,12 +2,18 @@ from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Settings:
root_aliases: dict[str, str]
task_db_path: str
remote_client_registration_token: str
remote_client_offline_timeout_seconds: int
remote_client_agent_auth_header: str
remote_client_agent_auth_scheme: str
remote_client_agent_auth_token: str
DEFAULT_ROOT_ALIASES = {
@@ -35,5 +41,22 @@ def _load_root_aliases() -> dict[str, str]:
def get_settings() -> Settings:
task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", "webui/backend/data/tasks.db").strip()
return Settings(root_aliases=_load_root_aliases(), task_db_path=task_db_path)
default_task_db_path = str(Path(__file__).resolve().parents[1] / "data" / "tasks.db")
task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", default_task_db_path).strip()
if not task_db_path:
task_db_path = default_task_db_path
raw_offline_timeout = os.getenv("WEBMANAGER_REMOTE_CLIENT_OFFLINE_TIMEOUT_SECONDS", "60").strip()
try:
remote_client_offline_timeout_seconds = max(1, int(raw_offline_timeout))
except ValueError:
remote_client_offline_timeout_seconds = 60
return Settings(
root_aliases=_load_root_aliases(),
task_db_path=task_db_path,
remote_client_registration_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_REGISTRATION_TOKEN", "").strip(),
remote_client_offline_timeout_seconds=remote_client_offline_timeout_seconds,
remote_client_agent_auth_header=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_HEADER", "Authorization").strip()
or "Authorization",
remote_client_agent_auth_scheme=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_SCHEME", "Bearer").strip() or "Bearer",
remote_client_agent_auth_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_TOKEN", "").strip(),
)
+190
View File
@@ -0,0 +1,190 @@
from __future__ import annotations
import sqlite3
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed", "cancelled"}
VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload", "download", "duplicate"}
class HistoryRepository:
def __init__(self, db_path: str):
self._db_path = db_path
self._ensure_schema()
def create_entry(
self,
*,
operation: str,
status: str,
source: str | None = None,
destination: str | None = None,
path: str | None = None,
error_code: str | None = None,
error_message: str | None = None,
created_at: str | None = None,
finished_at: str | None = None,
entry_id: str | None = None,
) -> dict:
if operation not in VALID_HISTORY_OPERATIONS:
raise ValueError("invalid operation")
if status not in VALID_HISTORY_STATUSES:
raise ValueError("invalid status")
history_id = entry_id or str(uuid.uuid4())
created_value = created_at or self._now_iso()
with self._connection() as conn:
conn.execute(
"""
INSERT INTO history (
id, operation, status, source, destination, path,
error_code, error_message, created_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
history_id,
operation,
status,
source,
destination,
path,
error_code,
error_message,
created_value,
finished_at,
),
)
row = conn.execute("SELECT * FROM history WHERE id = ?", (history_id,)).fetchone()
return self._to_dict(row)
def update_entry(
self,
*,
entry_id: str,
status: str,
error_code: str | None = None,
error_message: str | None = None,
finished_at: str | None = None,
) -> None:
if status not in VALID_HISTORY_STATUSES:
raise ValueError("invalid status")
finished_value = finished_at or self._now_iso()
with self._connection() as conn:
conn.execute(
"""
UPDATE history
SET status = ?, error_code = ?, error_message = ?, finished_at = ?
WHERE id = ?
""",
(status, error_code, error_message, finished_value, entry_id),
)
def list_history(self, limit: int = 100) -> list[dict]:
max_limit = max(1, min(limit, 200))
with self._connection() as conn:
rows = conn.execute(
"""
SELECT * FROM history
ORDER BY created_at DESC
LIMIT ?
""",
(max_limit,),
).fetchall()
return [self._to_dict(row) for row in rows]
def insert_entry_for_testing(self, entry: dict) -> None:
with self._connection() as conn:
conn.execute(
"""
INSERT INTO history (
id, operation, status, source, destination, path,
error_code, error_message, created_at, finished_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
entry["id"],
entry["operation"],
entry["status"],
entry.get("source"),
entry.get("destination"),
entry.get("path"),
entry.get("error_code"),
entry.get("error_message"),
entry["created_at"],
entry.get("finished_at"),
),
)
def reconcile_entries_failed(
self,
entry_ids: list[str],
*,
error_code: str = "task_interrupted",
error_message: str = "Task was interrupted before completion",
) -> None:
if not entry_ids:
return
finished_at = self._now_iso()
placeholders = ", ".join("?" for _ in entry_ids)
with self._connection() as conn:
conn.execute(
f"""
UPDATE history
SET status = ?, error_code = ?, error_message = ?, finished_at = ?
WHERE id IN ({placeholders})
""",
("failed", error_code, error_message, finished_at, *entry_ids),
)
def _ensure_schema(self) -> None:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
db_path.parent.mkdir(parents=True, exist_ok=True)
with self._connection() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS history (
id TEXT PRIMARY KEY,
operation TEXT NOT NULL,
status TEXT NOT NULL,
source TEXT NULL,
destination TEXT NULL,
path TEXT NULL,
error_code TEXT NULL,
error_message TEXT NULL,
created_at TEXT NOT NULL,
finished_at TEXT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_history_created_at_desc
ON history(created_at DESC)
"""
)
@contextmanager
def _connection(self):
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
@staticmethod
def _to_dict(row: sqlite3.Row | None) -> dict | None:
if row is None:
return None
return dict(row)
@staticmethod
def _now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,201 @@
from __future__ import annotations
import json
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
class RemoteClientRepository:
def __init__(self, db_path: str):
self._db_path = db_path
self._ensure_schema()
def upsert_client(
self,
*,
client_id: str,
display_name: str,
platform: str,
agent_version: str,
endpoint: str,
shares: list[dict[str, str]],
now_iso: str,
) -> dict:
shares_json = self._encode_shares(shares)
with self._connection() as conn:
conn.execute(
"""
INSERT INTO remote_clients (
client_id, display_name, platform, agent_version, endpoint, shares_json,
last_seen, status, last_error, reachable_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(client_id) DO UPDATE SET
display_name = excluded.display_name,
platform = excluded.platform,
agent_version = excluded.agent_version,
endpoint = excluded.endpoint,
shares_json = excluded.shares_json,
last_seen = excluded.last_seen,
status = excluded.status,
last_error = NULL,
updated_at = excluded.updated_at
""",
(
client_id,
display_name,
platform,
agent_version,
endpoint,
shares_json,
now_iso,
"online",
None,
None,
now_iso,
now_iso,
),
)
row = conn.execute("SELECT * FROM remote_clients WHERE client_id = ?", (client_id,)).fetchone()
return self._to_dict(row)
def record_heartbeat(self, *, client_id: str, agent_version: str, now_iso: str) -> dict | None:
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE remote_clients
SET agent_version = ?, last_seen = ?, status = ?, updated_at = ?
WHERE client_id = ?
""",
(agent_version, now_iso, "online", now_iso, client_id),
)
if cursor.rowcount <= 0:
return None
row = conn.execute("SELECT * FROM remote_clients WHERE client_id = ?", (client_id,)).fetchone()
return self._to_dict(row)
def mark_stale_clients_offline(self, *, cutoff_iso: str, now_iso: str) -> None:
with self._connection() as conn:
conn.execute(
"""
UPDATE remote_clients
SET status = ?, updated_at = ?
WHERE status != ? AND last_seen IS NOT NULL AND last_seen < ?
""",
("offline", now_iso, "offline", cutoff_iso),
)
def list_clients(self) -> list[dict]:
with self._connection() as conn:
rows = conn.execute(
"""
SELECT *
FROM remote_clients
ORDER BY LOWER(display_name) ASC, client_id ASC
"""
).fetchall()
return [self._to_dict(row) for row in rows]
def get_client(self, client_id: str) -> dict | None:
with self._connection() as conn:
row = conn.execute(
"""
SELECT *
FROM remote_clients
WHERE client_id = ?
""",
(client_id,),
).fetchone()
if row is None:
return None
return self._to_dict(row)
def _ensure_schema(self) -> None:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
db_path.parent.mkdir(parents=True, exist_ok=True)
with self._connection() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS remote_clients (
client_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
platform TEXT NOT NULL,
agent_version TEXT NOT NULL,
endpoint TEXT NOT NULL,
shares_json TEXT NOT NULL,
last_seen TEXT NULL,
status TEXT NOT NULL,
last_error TEXT NULL,
reachable_at TEXT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_remote_clients_display_name
ON remote_clients(display_name)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_remote_clients_last_seen
ON remote_clients(last_seen)
"""
)
@contextmanager
def _connection(self):
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@classmethod
def _to_dict(cls, row: sqlite3.Row) -> dict:
return {
"client_id": row["client_id"],
"display_name": row["display_name"],
"platform": row["platform"],
"agent_version": row["agent_version"],
"endpoint": row["endpoint"],
"shares": cls._decode_shares(row["shares_json"]),
"last_seen": row["last_seen"],
"status": row["status"],
"last_error": row["last_error"],
"reachable_at": row["reachable_at"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
@staticmethod
def _encode_shares(shares: list[dict[str, str]]) -> str:
return json.dumps(shares, separators=(",", ":"), sort_keys=True)
@staticmethod
def _decode_shares(raw: str) -> list[dict[str, str]]:
parsed = json.loads(raw or "[]")
if not isinstance(parsed, list):
return []
normalized: list[dict[str, str]] = []
for item in parsed:
if not isinstance(item, dict):
continue
key = str(item.get("key", "")).strip()
label = str(item.get("label", "")).strip()
if key and label:
normalized.append({"key": key, "label": label})
return normalized
@staticmethod
def now_iso() -> str:
return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,51 @@
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
class SettingsRepository:
def __init__(self, db_path: str):
self._db_path = db_path
self._ensure_schema()
def get_settings(self) -> dict[str, str]:
with self._connection() as conn:
rows = conn.execute("SELECT key, value FROM settings").fetchall()
return {row["key"]: row["value"] for row in rows}
def set_setting(self, key: str, value: str) -> None:
with self._connection() as conn:
conn.execute(
"""
INSERT INTO settings (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value
""",
(key, value),
)
def _ensure_schema(self) -> None:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
db_path.parent.mkdir(parents=True, exist_ok=True)
with self._connection() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
"""
)
@contextmanager
def _connection(self):
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
+318 -20
View File
@@ -6,8 +6,26 @@ from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
VALID_STATUSES = {"queued", "running", "completed", "failed"}
VALID_OPERATIONS = {"copy", "move"}
VALID_STATUSES = {"queued", "running", "cancelling", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
VALID_OPERATIONS = {"copy", "move", "download", "duplicate", "delete"}
NON_TERMINAL_STATUSES = ("queued", "running", "cancelling", "requested", "preparing")
TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'",
"status": "TEXT NOT NULL DEFAULT 'queued'",
"source": "TEXT NOT NULL DEFAULT ''",
"destination": "TEXT NOT NULL DEFAULT ''",
"done_bytes": "INTEGER NULL",
"total_bytes": "INTEGER NULL",
"done_items": "INTEGER NULL",
"total_items": "INTEGER NULL",
"current_item": "TEXT NULL",
"failed_item": "TEXT NULL",
"error_code": "TEXT NULL",
"error_message": "TEXT NULL",
"created_at": "TEXT NOT NULL",
"started_at": "TEXT NULL",
"finished_at": "TEXT NULL",
}
class TaskRepository:
@@ -15,11 +33,20 @@ class TaskRepository:
self._db_path = db_path
self._ensure_schema()
def create_task(self, operation: str, source: str, destination: str) -> dict:
def create_task(
self,
operation: str,
source: str,
destination: str,
task_id: str | None = None,
status: str = "queued",
) -> dict:
if operation not in VALID_OPERATIONS:
raise ValueError("invalid operation")
if status not in VALID_STATUSES:
raise ValueError("invalid status")
task_id = str(uuid.uuid4())
task_id = task_id or str(uuid.uuid4())
created_at = self._now_iso()
with self._connection() as conn:
@@ -35,7 +62,7 @@ class TaskRepository:
(
task_id,
operation,
"queued",
status,
source,
destination,
None,
@@ -108,40 +135,102 @@ class TaskRepository:
).fetchall()
return [self._to_dict(row) for row in rows]
def mark_running(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None:
def mark_running(
self,
task_id: str,
done_bytes: int | None = None,
total_bytes: int | None = None,
done_items: int | None = None,
total_items: int | None = None,
current_item: str | None = None,
) -> bool:
started_at = self._now_iso()
with self._connection() as conn:
conn.execute(
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, current_item = ?
WHERE id = ?
SET status = ?, started_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ?
WHERE id = ? AND status = ?
""",
("running", started_at, done_bytes, total_bytes, current_item, task_id),
("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id, "queued"),
)
return cursor.rowcount > 0
def update_progress(self, task_id: str, done_bytes: int, total_bytes: int | None, current_item: str | None) -> None:
def mark_preparing(
self,
task_id: str,
done_items: int | None = None,
total_items: int | None = None,
current_item: str | None = None,
) -> bool:
started_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, started_at = COALESCE(started_at, ?), done_items = ?, total_items = ?, current_item = ?
WHERE id = ? AND status = ?
""",
("preparing", started_at, done_items, total_items, current_item, task_id, "requested"),
)
return cursor.rowcount > 0
def update_progress(
self,
task_id: str,
done_bytes: int | None = None,
total_bytes: int | None = None,
done_items: int | None = None,
total_items: int | None = None,
current_item: str | None = None,
) -> None:
with self._connection() as conn:
conn.execute(
"""
UPDATE tasks
SET done_bytes = ?, total_bytes = ?, current_item = ?
SET done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = ?
WHERE id = ?
""",
(done_bytes, total_bytes, current_item, task_id),
(done_bytes, total_bytes, done_items, total_items, current_item, task_id),
)
def mark_completed(self, task_id: str, done_bytes: int | None, total_bytes: int | None) -> None:
def mark_completed(
self,
task_id: str,
done_bytes: int | None = None,
total_bytes: int | None = None,
done_items: int | None = None,
total_items: int | None = None,
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
conn.execute(
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?
WHERE id = ?
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? AND status = ?
""",
("completed", finished_at, done_bytes, total_bytes, task_id),
("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id, "running"),
)
return cursor.rowcount > 0
def mark_ready(
self,
task_id: str,
done_items: int | None = None,
total_items: int | None = None,
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? AND status = ?
""",
("ready", finished_at, done_items, total_items, task_id, "preparing"),
)
return cursor.rowcount > 0
def mark_failed(
self,
@@ -151,18 +240,122 @@ class TaskRepository:
failed_item: str | None,
done_bytes: int | None,
total_bytes: int | None,
done_items: int | None = None,
total_items: int | None = None,
) -> None:
finished_at = self._now_iso()
with self._connection() as conn:
conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?
WHERE id = ?
""",
("failed", finished_at, error_code, error_message, failed_item, done_bytes, total_bytes, task_id),
(
"failed",
finished_at,
error_code,
error_message,
failed_item,
done_bytes,
total_bytes,
done_items,
total_items,
task_id,
),
)
def mark_failed_if_not_cancelled(
self,
task_id: str,
error_code: str,
error_message: str,
failed_item: str | None,
done_bytes: int | None,
total_bytes: int | None,
done_items: int | None = None,
total_items: int | None = None,
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? AND status != ?
""",
(
"failed",
finished_at,
error_code,
error_message,
failed_item,
done_bytes,
total_bytes,
done_items,
total_items,
task_id,
"cancelled",
),
)
return cursor.rowcount > 0
def mark_cancelled(self, task_id: str) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, current_item = NULL
WHERE id = ? AND status IN (?, ?)
""",
("cancelled", finished_at, task_id, "requested", "preparing"),
)
return cursor.rowcount > 0
def request_cancellation(self, task_id: str) -> dict | None:
finished_at = self._now_iso()
with self._connection() as conn:
conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, current_item = NULL
WHERE id = ? AND status = ?
""",
("cancelled", finished_at, task_id, "queued"),
)
conn.execute(
"""
UPDATE tasks
SET status = ?
WHERE id = ? AND status = ?
""",
("cancelling", task_id, "running"),
)
row = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
return self._to_dict(row) if row else None
def finalize_cancelled(
self,
task_id: str,
*,
done_bytes: int | None = None,
total_bytes: int | None = None,
done_items: int | None = None,
total_items: int | None = None,
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? AND status IN (?, ?)
""",
("cancelled", finished_at, done_bytes, total_bytes, done_items, total_items, task_id, "cancelling", "queued"),
)
return cursor.rowcount > 0
def _ensure_schema(self) -> None:
db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}:
@@ -191,12 +384,107 @@ class TaskRepository:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS task_artifacts (
task_id TEXT PRIMARY KEY,
file_path TEXT NOT NULL,
file_name TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_tasks_created_at_desc
ON tasks(created_at DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_task_artifacts_expires_at
ON task_artifacts(expires_at ASC)
"""
)
self._migrate_tasks_columns(conn)
def upsert_artifact(self, *, task_id: str, file_path: str, file_name: str, expires_at: str) -> dict:
created_at = self._now_iso()
with self._connection() as conn:
conn.execute(
"""
INSERT INTO task_artifacts (task_id, file_path, file_name, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(task_id) DO UPDATE SET
file_path = excluded.file_path,
file_name = excluded.file_name,
expires_at = excluded.expires_at
""",
(task_id, file_path, file_name, expires_at, created_at),
)
row = conn.execute("SELECT * FROM task_artifacts WHERE task_id = ?", (task_id,)).fetchone()
return self._artifact_to_dict(row)
def get_artifact(self, task_id: str) -> dict | None:
with self._connection() as conn:
row = conn.execute("SELECT * FROM task_artifacts WHERE task_id = ?", (task_id,)).fetchone()
return self._artifact_to_dict(row) if row else None
def list_artifacts(self) -> list[dict]:
with self._connection() as conn:
rows = conn.execute("SELECT * FROM task_artifacts ORDER BY created_at ASC").fetchall()
return [self._artifact_to_dict(row) for row in rows]
def delete_artifact(self, task_id: str) -> None:
with self._connection() as conn:
conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,))
def reconcile_incomplete_tasks(
self,
*,
error_code: str = "task_interrupted",
error_message: str = "Task was interrupted before completion",
) -> list[str]:
finished_at = self._now_iso()
placeholders = ", ".join("?" for _ in NON_TERMINAL_STATUSES)
with self._connection() as conn:
rows = conn.execute(
f"""
SELECT id
FROM tasks
WHERE status IN ({placeholders})
""",
NON_TERMINAL_STATUSES,
).fetchall()
task_ids = [row["id"] for row in rows]
if not task_ids:
return []
task_placeholders = ", ".join("?" for _ in task_ids)
conn.execute(
f"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, current_item = NULL
WHERE id IN ({task_placeholders})
""",
("failed", finished_at, error_code, error_message, *task_ids),
)
conn.execute(
f"""
DELETE FROM task_artifacts
WHERE task_id IN ({task_placeholders})
""",
task_ids,
)
return task_ids
def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None:
rows = conn.execute("PRAGMA table_info(tasks)").fetchall()
existing_columns = {row["name"] for row in rows}
for column, ddl in TASK_MIGRATION_COLUMNS.items():
if column in existing_columns:
continue
conn.execute(f"ALTER TABLE tasks ADD COLUMN {column} {ddl}")
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
@@ -236,6 +524,16 @@ class TaskRepository:
"finished_at": row["finished_at"],
}
@staticmethod
def _artifact_to_dict(row: sqlite3.Row) -> dict:
return {
"task_id": row["task_id"],
"file_path": row["file_path"],
"file_name": row["file_name"],
"expires_at": row["expires_at"],
"created_at": row["created_at"],
}
@staticmethod
def _now_iso() -> str:
return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
+129 -4
View File
@@ -1,17 +1,30 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from backend.app.config import Settings, get_settings
from backend.app.db.bookmark_repository import BookmarkRepository
from backend.app.db.history_repository import HistoryRepository
from backend.app.db.remote_client_repository import RemoteClientRepository
from backend.app.db.settings_repository import SettingsRepository
from backend.app.db.task_repository import TaskRepository
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
from backend.app.services.bookmark_service import BookmarkService
from backend.app.services.browse_service import BrowseService
from backend.app.services.copy_task_service import CopyTaskService
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.delete_task_service import DeleteTaskService
from backend.app.services.duplicate_task_service import DuplicateTaskService
from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.history_service import HistoryService
from backend.app.services.move_task_service import MoveTaskService
from backend.app.services.remote_browse_service import RemoteBrowseService
from backend.app.services.remote_client_service import RemoteClientService
from backend.app.services.remote_file_service import RemoteFileService
from backend.app.services.search_service import SearchService
from backend.app.services.settings_service import SettingsService
from backend.app.services.task_service import TaskService
from backend.app.tasks_runner import TaskRunner
@@ -38,21 +51,72 @@ def get_bookmark_repository() -> BookmarkRepository:
return BookmarkRepository(db_path=settings.task_db_path)
@lru_cache(maxsize=1)
def get_history_repository() -> HistoryRepository:
settings: Settings = get_settings()
return HistoryRepository(db_path=settings.task_db_path)
@lru_cache(maxsize=1)
def get_settings_repository() -> SettingsRepository:
settings: Settings = get_settings()
return SettingsRepository(db_path=settings.task_db_path)
@lru_cache(maxsize=1)
def get_remote_client_repository() -> RemoteClientRepository:
settings: Settings = get_settings()
return RemoteClientRepository(db_path=settings.task_db_path)
@lru_cache(maxsize=1)
def get_task_runner() -> TaskRunner:
return TaskRunner(repository=get_task_repository(), filesystem=get_filesystem_adapter())
return TaskRunner(
repository=get_task_repository(),
filesystem=get_filesystem_adapter(),
history_repository=get_history_repository(),
)
@lru_cache(maxsize=1)
def get_archive_artifact_root() -> str:
settings: Settings = get_settings()
return str(Path(settings.task_db_path).resolve().parent / "archive_tmp")
async def get_browse_service() -> BrowseService:
return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
return BrowseService(
path_guard=get_path_guard(),
filesystem=get_filesystem_adapter(),
remote_browse_service=await get_remote_browse_service(),
)
async def get_file_ops_service() -> FileOpsService:
return FileOpsService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
return FileOpsService(
path_guard=get_path_guard(),
filesystem=get_filesystem_adapter(),
history_repository=get_history_repository(),
)
async def get_archive_download_task_service() -> ArchiveDownloadTaskService:
return ArchiveDownloadTaskService(
path_guard=get_path_guard(),
repository=get_task_repository(),
runner=get_task_runner(),
history_repository=get_history_repository(),
file_ops_service=FileOpsService(
path_guard=get_path_guard(),
filesystem=get_filesystem_adapter(),
history_repository=get_history_repository(),
),
artifact_root=Path(get_archive_artifact_root()),
)
async def get_task_service() -> TaskService:
return TaskService(repository=get_task_repository())
return TaskService(repository=get_task_repository(), history_repository=get_history_repository())
async def get_copy_task_service() -> CopyTaskService:
@@ -60,6 +124,25 @@ async def get_copy_task_service() -> CopyTaskService:
path_guard=get_path_guard(),
repository=get_task_repository(),
runner=get_task_runner(),
history_repository=get_history_repository(),
)
async def get_delete_task_service() -> DeleteTaskService:
return DeleteTaskService(
path_guard=get_path_guard(),
repository=get_task_repository(),
runner=get_task_runner(),
history_repository=get_history_repository(),
)
async def get_duplicate_task_service() -> DuplicateTaskService:
return DuplicateTaskService(
path_guard=get_path_guard(),
repository=get_task_repository(),
runner=get_task_runner(),
history_repository=get_history_repository(),
)
@@ -68,8 +151,50 @@ async def get_move_task_service() -> MoveTaskService:
path_guard=get_path_guard(),
repository=get_task_repository(),
runner=get_task_runner(),
history_repository=get_history_repository(),
)
async def get_bookmark_service() -> BookmarkService:
return BookmarkService(path_guard=get_path_guard(), repository=get_bookmark_repository())
async def get_history_service() -> HistoryService:
return HistoryService(repository=get_history_repository())
async def get_search_service() -> SearchService:
return SearchService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
async def get_settings_service() -> SettingsService:
return SettingsService(repository=get_settings_repository(), path_guard=get_path_guard())
async def get_remote_client_service() -> RemoteClientService:
settings: Settings = get_settings()
return RemoteClientService(
repository=get_remote_client_repository(),
registration_token=settings.remote_client_registration_token,
offline_timeout_seconds=settings.remote_client_offline_timeout_seconds,
)
async def get_remote_browse_service() -> RemoteBrowseService:
settings: Settings = get_settings()
return RemoteBrowseService(
remote_client_service=await get_remote_client_service(),
agent_auth_header=settings.remote_client_agent_auth_header,
agent_auth_scheme=settings.remote_client_agent_auth_scheme,
agent_auth_token=settings.remote_client_agent_auth_token,
)
async def get_remote_file_service() -> RemoteFileService:
settings: Settings = get_settings()
return RemoteFileService(
remote_client_service=await get_remote_client_service(),
agent_auth_header=settings.remote_client_agent_auth_header,
agent_auth_scheme=settings.remote_client_agent_auth_scheme,
agent_auth_token=settings.remote_client_agent_auth_token,
)
+234
View File
@@ -1,11 +1,42 @@
from __future__ import annotations
import shutil
import mimetypes
import struct
import grp
import pwd
from datetime import datetime, timezone
from pathlib import Path
class FilesystemAdapter:
def stat_info(self, path: Path) -> dict:
stat = path.stat()
owner = None
group = None
try:
owner = pwd.getpwuid(stat.st_uid).pw_name
except (KeyError, ImportError, AttributeError):
owner = None
try:
group = grp.getgrgid(stat.st_gid).gr_name
except (KeyError, ImportError, AttributeError):
group = None
content_type, _ = mimetypes.guess_type(path.name)
width, height = self._image_dimensions(path) if path.is_file() else (None, None)
return {
"name": path.name,
"size": int(stat.st_size) if path.is_file() else None,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
"owner": owner,
"group": group,
"content_type": content_type,
"extension": path.suffix.lower() or None,
"width": width,
"height": height,
}
def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]:
directories: list[dict] = []
files: list[dict] = []
@@ -29,6 +60,29 @@ class FilesystemAdapter:
return directories, files
def search_names(self, directory: Path, query: str, limit: int) -> tuple[list[dict], bool]:
normalized_query = query.lower()
results: list[dict] = []
for root, dirnames, filenames in __import__("os").walk(directory):
dirnames[:] = sorted([name for name in dirnames if not name.startswith(".")], key=str.lower)
filenames = sorted([name for name in filenames if not name.startswith(".")], key=str.lower)
root_path = Path(root)
for name in dirnames:
if normalized_query in name.lower():
results.append({"name": name, "kind": "directory", "absolute": root_path / name})
if len(results) >= limit:
return results, True
for name in filenames:
if normalized_query in name.lower():
results.append({"name": name, "kind": "file", "absolute": root_path / name})
if len(results) >= limit:
return results, True
return results, False
def make_directory(self, path: Path) -> None:
path.mkdir(parents=False, exist_ok=False)
@@ -38,6 +92,9 @@ class FilesystemAdapter:
def move_file(self, source: str, destination: str) -> None:
Path(source).rename(Path(destination))
def move_directory(self, source: str, destination: str) -> None:
Path(source).rename(Path(destination))
def is_directory_empty(self, path: Path) -> bool:
return not any(path.iterdir())
@@ -47,6 +104,9 @@ class FilesystemAdapter:
def delete_empty_directory(self, path: Path) -> None:
path.rmdir()
def delete_directory_recursive(self, path: Path) -> None:
shutil.rmtree(path)
def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None:
src = Path(source)
dst = Path(destination)
@@ -59,3 +119,177 @@ class FilesystemAdapter:
if on_progress:
on_progress(out_f.tell())
shutil.copystat(src, dst, follow_symlinks=False)
def copy_directory(self, source: str, destination: str) -> None:
shutil.copytree(source, destination, symlinks=True, copy_function=shutil.copy2)
def read_text_preview(self, path: Path, max_bytes: int, encoding: str = "utf-8") -> dict:
size = int(path.stat().st_size)
limit = max_bytes + 1
with path.open("rb") as in_f:
raw = in_f.read(limit)
modified = self.modified_iso(path)
truncated = size > max_bytes or len(raw) > max_bytes
if truncated:
raw = raw[:max_bytes]
return {
"size": size,
"modified": modified,
"truncated": truncated,
"content": raw.decode(encoding, errors="replace"),
}
def write_text_file(self, path: Path, content: str, encoding: str = "utf-8") -> dict:
path.write_text(content, encoding=encoding)
return {
"size": int(path.stat().st_size),
"modified": self.modified_iso(path),
}
def write_uploaded_file(self, path: Path, file_stream, chunk_size: int = 1024 * 1024, overwrite: bool = False) -> dict:
mode = "wb" if overwrite else "xb"
with path.open(mode) as handle:
while True:
chunk = file_stream.read(chunk_size)
if not chunk:
break
handle.write(chunk)
return {
"size": int(path.stat().st_size),
"modified": self.modified_iso(path),
}
async def stream_file_range(self, path: Path, start: int, end: int, chunk_size: int = 1024 * 1024):
with path.open("rb") as handle:
handle.seek(start)
remaining = (end - start) + 1
while remaining > 0:
chunk = handle.read(min(chunk_size, remaining))
if not chunk:
break
remaining -= len(chunk)
yield chunk
async def stream_file(self, path: Path, chunk_size: int = 1024 * 1024):
with path.open("rb") as handle:
while True:
chunk = handle.read(chunk_size)
if not chunk:
break
yield chunk
@staticmethod
def modified_iso(path: Path) -> str:
stat = path.stat()
return datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z")
def _image_dimensions(self, path: Path) -> tuple[int | None, int | None]:
suffix = path.suffix.lower()
try:
if suffix == ".png":
return self._png_dimensions(path)
if suffix in {".jpg", ".jpeg"}:
return self._jpeg_dimensions(path)
if suffix == ".gif":
return self._gif_dimensions(path)
if suffix == ".bmp":
return self._bmp_dimensions(path)
if suffix == ".webp":
return self._webp_dimensions(path)
if suffix == ".avif":
return self._avif_dimensions(path)
except (OSError, ValueError, struct.error):
return None, None
return None, None
@staticmethod
def _png_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(24)
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
return None, None
return struct.unpack(">II", header[16:24])
@staticmethod
def _jpeg_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
if handle.read(2) != b"\xff\xd8":
return None, None
while True:
marker_prefix = handle.read(1)
if not marker_prefix:
return None, None
if marker_prefix != b"\xff":
continue
marker = handle.read(1)
while marker == b"\xff":
marker = handle.read(1)
if not marker or marker in {b"\xd8", b"\xd9"}:
return None, None
segment_length_bytes = handle.read(2)
if len(segment_length_bytes) != 2:
return None, None
segment_length = struct.unpack(">H", segment_length_bytes)[0]
if segment_length < 2:
return None, None
if marker in {b"\xc0", b"\xc1", b"\xc2", b"\xc3", b"\xc5", b"\xc6", b"\xc7", b"\xc9", b"\xca", b"\xcb", b"\xcd", b"\xce", b"\xcf"}:
payload = handle.read(5)
if len(payload) != 5:
return None, None
height, width = struct.unpack(">HH", payload[1:5])
return width, height
handle.seek(segment_length - 2, 1)
@staticmethod
def _gif_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(10)
if len(header) < 10 or header[:6] not in {b"GIF87a", b"GIF89a"}:
return None, None
width, height = struct.unpack("<HH", header[6:10])
return width, height
@staticmethod
def _bmp_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(26)
if len(header) < 26 or header[:2] != b"BM":
return None, None
width, height = struct.unpack("<ii", header[18:26])
return abs(width), abs(height)
@staticmethod
def _webp_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
header = handle.read(64)
if len(header) < 30 or header[:4] != b"RIFF" or header[8:12] != b"WEBP":
return None, None
chunk = header[12:16]
if chunk == b"VP8 " and len(header) >= 30:
width, height = struct.unpack("<HH", header[26:30])
return width & 0x3FFF, height & 0x3FFF
if chunk == b"VP8L" and len(header) >= 25:
bits = struct.unpack("<I", header[21:25])[0]
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
return width, height
if chunk == b"VP8X" and len(header) >= 30:
width = 1 + int.from_bytes(header[24:27], "little")
height = 1 + int.from_bytes(header[27:30], "little")
return width, height
return None, None
@staticmethod
def _avif_dimensions(path: Path) -> tuple[int | None, int | None]:
with path.open("rb") as handle:
data = handle.read(256 * 1024)
if b"ftypavif" not in data and b"ftypavis" not in data:
return None, None
index = data.find(b"ispe")
if index == -1 or index + 20 > len(data):
return None, None
width = int.from_bytes(data[index + 12:index + 16], "big")
height = int.from_bytes(data[index + 16:index + 20], "big")
if width <= 0 or height <= 0:
return None, None
return width, height
+20
View File
@@ -10,10 +10,17 @@ from backend.app.api.errors import AppError
from backend.app.api.routes_bookmarks import router as bookmarks_router
from backend.app.api.routes_browse import router as browse_router
from backend.app.api.routes_copy import router as copy_router
from backend.app.api.routes_clients import router as clients_router
from backend.app.api.routes_duplicate import router as duplicate_router
from backend.app.api.routes_files import router as files_router
from backend.app.api.routes_history import router as history_router
from backend.app.api.routes_move import router as move_router
from backend.app.api.routes_search import router as search_router
from backend.app.api.routes_settings import router as settings_router
from backend.app.api.routes_tasks import router as tasks_router
from backend.app.dependencies import get_history_repository, get_task_repository
from backend.app.logging import configure_logging
from backend.app.services.task_recovery_service import reconcile_persisted_incomplete_tasks
configure_logging()
@@ -27,11 +34,24 @@ app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
app.include_router(browse_router, prefix="/api")
app.include_router(files_router, prefix="/api")
app.include_router(copy_router, prefix="/api")
app.include_router(clients_router, prefix="/api")
app.include_router(duplicate_router, prefix="/api")
app.include_router(move_router, prefix="/api")
app.include_router(search_router, prefix="/api")
app.include_router(settings_router, prefix="/api")
app.include_router(bookmarks_router, prefix="/api")
app.include_router(history_router, prefix="/api")
app.include_router(tasks_router, prefix="/api")
@app.on_event("startup")
async def reconcile_incomplete_tasks_on_startup() -> None:
reconcile_persisted_incomplete_tasks(
task_repository=get_task_repository(),
history_repository=get_history_repository(),
)
@app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
+48 -12
View File
@@ -11,14 +11,34 @@ class ResolvedPath:
alias: str
relative: str
absolute: Path
display_style: str
class PathGuard:
def __init__(self, root_aliases: dict[str, str]):
normalized: dict[str, Path] = {}
volume_roots_candidates: dict[str, list[tuple[str, Path]]] = {}
for alias, root in root_aliases.items():
normalized[alias] = Path(root).resolve()
resolved_root = Path(root).resolve()
normalized[alias] = resolved_root
volume_name = resolved_root.name
volume_roots_candidates.setdefault(volume_name, []).append((alias, resolved_root))
self._roots = normalized
self._volume_roots = {
name: entries[0]
for name, entries in volume_roots_candidates.items()
if len(entries) == 1
}
def is_virtual_volumes_path(self, input_path: str) -> bool:
normalized_input = (input_path or "").strip()
return normalized_input == "/Volumes"
def virtual_volumes_entries(self) -> list[dict[str, str]]:
return [
{"name": name, "path": f"/Volumes/{name}"}
for name in sorted(self._volume_roots.keys(), key=str.lower)
]
def resolve_directory_path(self, input_path: str) -> ResolvedPath:
resolved = self.resolve_path(input_path)
@@ -50,7 +70,7 @@ class PathGuard:
return resolved
def resolve_path(self, input_path: str) -> ResolvedPath:
alias, rel_segments, candidate = self.resolve_lexical_path(input_path)
alias, rel_segments, candidate, display_style = self.resolve_lexical_path(input_path)
root = self._roots[alias]
# Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps
@@ -66,12 +86,14 @@ class PathGuard:
return ResolvedPath(
alias=alias,
relative=self._format_relative(alias, rel_segments),
relative=self._format_relative(alias, rel_segments, display_style),
absolute=resolved_candidate,
display_style=display_style,
)
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path]:
normalized_input = (input_path or "").strip().strip("/")
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path, str]:
raw_input = (input_path or "").strip()
normalized_input = raw_input.strip("/")
if not normalized_input:
raise AppError(
code="invalid_request",
@@ -80,6 +102,17 @@ class PathGuard:
)
segments = [seg for seg in normalized_input.split("/") if seg]
display_style = "alias"
alias = ""
rel_segments: list[str] = []
root: Path
if len(segments) >= 2 and segments[0] == "Volumes" and segments[1] in self._volume_roots:
display_style = "virtual_volumes"
volume_name = segments[1]
alias, root = self._volume_roots[volume_name]
rel_segments = segments[2:]
else:
alias = segments[0] if segments else ""
if alias not in self._roots:
raise AppError(
@@ -88,8 +121,9 @@ class PathGuard:
status_code=403,
details={"path": input_path},
)
root = self._roots[alias]
rel_segments = segments[1:]
if any(seg == ".." for seg in rel_segments):
raise AppError(
code="path_traversal_detected",
@@ -98,9 +132,8 @@ class PathGuard:
details={"path": input_path},
)
root = self._roots[alias]
candidate = root.joinpath(*rel_segments)
return alias, rel_segments, candidate
return alias, rel_segments, candidate, display_style
def validate_name(self, name: str, field: str) -> str:
normalized = (name or "").strip()
@@ -113,7 +146,7 @@ class PathGuard:
)
return normalized
def entry_relative_path(self, alias: str, absolute: Path) -> str:
def entry_relative_path(self, alias: str, absolute: Path, display_style: str = "alias") -> str:
root = self._roots[alias]
resolved_absolute = absolute.resolve(strict=False)
if not self._is_under_root(resolved_absolute, root):
@@ -124,7 +157,7 @@ class PathGuard:
details={"path": f"{alias}"},
)
rel = resolved_absolute.relative_to(root).as_posix()
return self._format_relative(alias, [p for p in rel.split("/") if p])
return self._format_relative(alias, [p for p in rel.split("/") if p], display_style)
@staticmethod
def _is_under_root(path: Path, root: Path) -> bool:
@@ -134,6 +167,9 @@ class PathGuard:
except ValueError:
return False
@staticmethod
def _format_relative(alias: str, rel_segments: list[str]) -> str:
def _format_relative(self, alias: str, rel_segments: list[str], display_style: str = "alias") -> str:
if display_style == "virtual_volumes":
root = self._roots[alias]
prefix = f"/Volumes/{root.name}"
return prefix if not rel_segments else f"{prefix}/{'/'.join(rel_segments)}"
return alias if not rel_segments else f"{alias}/{'/'.join(rel_segments)}"

Some files were not shown because too many files have changed in this diff Show More