diff --git a/project_docs/UI_VIEW_V1_DESIGN.md b/project_docs/UI_VIEW_V1_DESIGN.md new file mode 100644 index 0000000..69d88c9 --- /dev/null +++ b/project_docs/UI_VIEW_V1_DESIGN.md @@ -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 diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index 6aa47d3..6642e42 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index 36808e6..d5915e9 100644 Binary files a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc and b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc differ diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 35fa518..6a13490 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -2,7 +2,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends -from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse +from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, ViewResponse from backend.app.dependencies import get_file_ops_service from backend.app.services.file_ops_service import FileOpsService @@ -31,3 +31,11 @@ async def delete( service: FileOpsService = Depends(get_file_ops_service), ) -> DeleteResponse: return service.delete(path=request.path) + + +@router.get("/view", response_model=ViewResponse) +async def view( + path: str, + service: FileOpsService = Depends(get_file_ops_service), +) -> ViewResponse: + return service.view(path=path) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index f02d2fc..d9a7f60 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -58,6 +58,16 @@ class DeleteResponse(BaseModel): path: str +class ViewResponse(BaseModel): + path: str + name: str + content_type: str + encoding: str + truncated: bool + size: int + content: str + + class TaskListItem(BaseModel): id: str operation: str diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 414430c..f01de93 100644 Binary files a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc and b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc differ diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index e44e757..1cfb58e 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -59,3 +59,17 @@ class FilesystemAdapter: if on_progress: on_progress(out_f.tell()) shutil.copystat(src, dst, follow_symlinks=False) + + 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) + truncated = size > max_bytes or len(raw) > max_bytes + if truncated: + raw = raw[:max_bytes] + return { + "size": size, + "truncated": truncated, + "content": raw.decode(encoding, errors="replace"), + } diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 1b5ee64..484366e 100644 Binary files a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index fed183c..963dabe 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -3,10 +3,27 @@ from __future__ import annotations from pathlib import Path from backend.app.api.errors import AppError -from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse +from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, ViewResponse from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard +TEXT_PREVIEW_MAX_BYTES = 256 * 1024 +TEXT_CONTENT_TYPES = { + ".txt": "text/plain", + ".log": "text/plain", + ".md": "text/markdown", + ".yml": "text/yaml", + ".yaml": "text/yaml", + ".json": "application/json", + ".js": "text/javascript", + ".css": "text/css", + ".html": "text/html", +} +SPECIAL_TEXT_FILENAMES = { + "dockerfile": "text/plain", + "containerfile": "text/plain", +} + class FileOpsService: def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter): @@ -129,6 +146,64 @@ class FileOpsService: return DeleteResponse(path=resolved_target.relative) + def view(self, path: str) -> ViewResponse: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_dir(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": resolved_target.relative}, + ) + if not resolved_target.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Unsupported path type for view", + status_code=409, + details={"path": resolved_target.relative}, + ) + + content_type = self._content_type_for(resolved_target.absolute) + if content_type is None: + raise AppError( + code="unsupported_type", + message="File type is not supported for preview", + status_code=409, + details={"path": resolved_target.relative}, + ) + + try: + preview = self._filesystem.read_text_preview( + resolved_target.absolute, + max_bytes=TEXT_PREVIEW_MAX_BYTES, + encoding="utf-8", + ) + except OSError as exc: + raise AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + + return ViewResponse( + path=resolved_target.relative, + name=resolved_target.absolute.name, + content_type=content_type, + encoding="utf-8", + truncated=preview["truncated"], + size=preview["size"], + content=preview["content"], + ) + @staticmethod def _join_relative(base: str, name: str) -> str: return f"{base}/{name}" if base else name + + @staticmethod + def _content_type_for(path: Path) -> str | None: + special_name = SPECIAL_TEXT_FILENAMES.get(path.name.lower()) + if special_name: + return special_name + return TEXT_CONTENT_TYPES.get(path.suffix.lower()) diff --git a/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc new file mode 100644 index 0000000..69365aa Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_view_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 18fabcd..4cd27a9 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_view_golden.py b/webui/backend/tests/golden/test_api_view_golden.py new file mode 100644 index 0000000..13dbc05 --- /dev/null +++ b/webui/backend/tests/golden/test_api_view_golden.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +class ViewApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, path: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get("/api/files/view", params={"path": path}) + + return asyncio.run(_run()) + + def test_view_supported_text_success(self) -> None: + file_path = self.root / "notes.md" + file_path.write_text("# title\nhello\n", encoding="utf-8") + + response = self._request("storage1/notes.md") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "path": "storage1/notes.md", + "name": "notes.md", + "content_type": "text/markdown", + "encoding": "utf-8", + "truncated": False, + "size": len("# title\nhello\n".encode("utf-8")), + "content": "# title\nhello\n", + }, + ) + + def test_view_unsupported_type(self) -> None: + (self.root / "report.pdf").write_bytes(b"%PDF-1.4") + + response = self._request("storage1/report.pdf") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "unsupported_type") + + def test_view_directory_type_conflict(self) -> None: + (self.root / "docs").mkdir() + + response = self._request("storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_view_path_not_found(self) -> None: + response = self._request("storage1/missing.txt") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_view_traversal_attempt(self) -> None: + response = self._request("storage1/../etc/passwd") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + def test_view_truncated_response_for_large_file(self) -> None: + content = "x" * (300 * 1024) + (self.root / "big.log").write_text(content, encoding="utf-8") + + response = self._request("storage1/big.log") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertTrue(body["truncated"]) + self.assertEqual(body["size"], len(content.encode("utf-8"))) + self.assertEqual(len(body["content"]), 256 * 1024) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 51f086c..a18c991 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -35,6 +35,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="function-bar"', body) self.assertIn('id="view-btn"', body) self.assertIn('id="edit-btn"', body) + self.assertIn('id="viewer-modal"', body) + self.assertIn('id="viewer-content"', body) self.assertIn('id="mkdir-btn"', body) self.assertIn('id="copy-btn"', body) self.assertIn('id="move-btn"', body) diff --git a/webui/html/app.js b/webui/html/app.js index a58daac..c08fc4c 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -58,6 +58,18 @@ function showActionSummary(action, successes, failures, firstError) { setStatus(base); } +function viewerElements() { + return { + overlay: document.getElementById("viewer-modal"), + title: document.getElementById("viewer-title"), + fileName: document.getElementById("viewer-file-name"), + filePath: document.getElementById("viewer-file-path"), + error: document.getElementById("viewer-error"), + content: document.getElementById("viewer-content"), + closeButton: document.getElementById("viewer-close-btn"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -145,6 +157,7 @@ function updateActionButtons() { const hasSelection = count > 0; const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); + document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("copy-btn").disabled = !allFiles; @@ -633,6 +646,10 @@ function isWildcardPopupOpen() { return !wildcardPopupElements().overlay.classList.contains("hidden"); } +function isViewerOpen() { + return !viewerElements().overlay.classList.contains("hidden"); +} + function escapeRegExp(text) { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } @@ -715,6 +732,40 @@ function openWildcardPopup(mode) { elements.input.focus(); } +function closeViewer() { + const viewer = viewerElements(); + viewer.overlay.classList.add("hidden"); + viewer.error.textContent = ""; + viewer.content.textContent = ""; +} + +async function openViewer() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { + return; + } + const selected = selectedItems[0]; + const viewer = viewerElements(); + viewer.overlay.classList.remove("hidden"); + viewer.title.textContent = "View"; + viewer.fileName.textContent = selected.name; + viewer.filePath.textContent = selected.path; + viewer.error.textContent = ""; + viewer.content.textContent = "Loading..."; + try { + const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path }).toString()}`); + viewer.fileName.textContent = data.name; + viewer.filePath.textContent = data.path; + viewer.content.textContent = data.content; + if (data.truncated) { + viewer.error.textContent = "Preview truncated for safety"; + } + } catch (err) { + viewer.content.textContent = ""; + viewer.error.textContent = err.message; + } +} + function moveCurrentRow(delta) { const pane = state.activePane; const model = paneState(pane); @@ -770,6 +821,13 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isViewerOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeViewer(); + } + return; + } if (isWildcardPopupOpen()) { return; } @@ -851,6 +909,7 @@ function setupEvents() { setupPaneEvents("left"); setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); + document.getElementById("view-btn").onclick = openViewer; document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; @@ -876,6 +935,14 @@ function setupEvents() { closeWildcardPopup(); } }; + + const viewer = viewerElements(); + viewer.closeButton.onclick = closeViewer; + viewer.overlay.onclick = (event) => { + if (event.target === viewer.overlay) { + closeViewer(); + } + }; } async function init() { diff --git a/webui/html/index.html b/webui/html/index.html index b4cdfc5..64c5375 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -88,6 +88,17 @@ +
+