feat: file viewer added

This commit is contained in:
kodi
2026-03-11 13:53:59 +01:00
parent 31a42d34c7
commit ba6a369f78
16 changed files with 550 additions and 2 deletions
+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
+9 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends 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.dependencies import get_file_ops_service
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
@@ -31,3 +31,11 @@ async def delete(
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
) -> DeleteResponse: ) -> DeleteResponse:
return service.delete(path=request.path) 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)
+10
View File
@@ -58,6 +58,16 @@ class DeleteResponse(BaseModel):
path: str path: str
class ViewResponse(BaseModel):
path: str
name: str
content_type: str
encoding: str
truncated: bool
size: int
content: str
class TaskListItem(BaseModel): class TaskListItem(BaseModel):
id: str id: str
operation: str operation: str
@@ -59,3 +59,17 @@ class FilesystemAdapter:
if on_progress: if on_progress:
on_progress(out_f.tell()) on_progress(out_f.tell())
shutil.copystat(src, dst, follow_symlinks=False) 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"),
}
+76 -1
View File
@@ -3,10 +3,27 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from backend.app.api.errors import AppError 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.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard 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: class FileOpsService:
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter): def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
@@ -129,6 +146,64 @@ class FileOpsService:
return DeleteResponse(path=resolved_target.relative) 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 @staticmethod
def _join_relative(base: str, name: str) -> str: def _join_relative(base: str, name: str) -> str:
return f"{base}/{name}" if base else name 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())
@@ -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()
@@ -35,6 +35,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="function-bar"', body) self.assertIn('id="function-bar"', body)
self.assertIn('id="view-btn"', body) self.assertIn('id="view-btn"', body)
self.assertIn('id="edit-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="mkdir-btn"', body)
self.assertIn('id="copy-btn"', body) self.assertIn('id="copy-btn"', body)
self.assertIn('id="move-btn"', body) self.assertIn('id="move-btn"', body)
+67
View File
@@ -58,6 +58,18 @@ function showActionSummary(action, successes, failures, firstError) {
setStatus(base); 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) { async function apiRequest(method, url, body) {
const options = { method, headers: {} }; const options = { method, headers: {} };
if (body !== undefined) { if (body !== undefined) {
@@ -145,6 +157,7 @@ function updateActionButtons() {
const hasSelection = count > 0; const hasSelection = count > 0;
const exactlyOne = count === 1; const exactlyOne = count === 1;
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
document.getElementById("view-btn").disabled = !exactlyOne || !allFiles;
document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("rename-btn").disabled = !exactlyOne;
document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("delete-btn").disabled = !hasSelection;
document.getElementById("copy-btn").disabled = !allFiles; document.getElementById("copy-btn").disabled = !allFiles;
@@ -633,6 +646,10 @@ function isWildcardPopupOpen() {
return !wildcardPopupElements().overlay.classList.contains("hidden"); return !wildcardPopupElements().overlay.classList.contains("hidden");
} }
function isViewerOpen() {
return !viewerElements().overlay.classList.contains("hidden");
}
function escapeRegExp(text) { function escapeRegExp(text) {
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
} }
@@ -715,6 +732,40 @@ function openWildcardPopup(mode) {
elements.input.focus(); 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) { function moveCurrentRow(delta) {
const pane = state.activePane; const pane = state.activePane;
const model = paneState(pane); const model = paneState(pane);
@@ -770,6 +821,13 @@ function clearSelectionForActivePane() {
} }
function handleKeyboardShortcuts(event) { function handleKeyboardShortcuts(event) {
if (isViewerOpen()) {
if (event.key === "Escape") {
event.preventDefault();
closeViewer();
}
return;
}
if (isWildcardPopupOpen()) { if (isWildcardPopupOpen()) {
return; return;
} }
@@ -851,6 +909,7 @@ function setupEvents() {
setupPaneEvents("left"); setupPaneEvents("left");
setupPaneEvents("right"); setupPaneEvents("right");
document.addEventListener("keydown", handleKeyboardShortcuts); document.addEventListener("keydown", handleKeyboardShortcuts);
document.getElementById("view-btn").onclick = openViewer;
document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("rename-btn").onclick = renameSelected;
document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("delete-btn").onclick = deleteSelected;
document.getElementById("copy-btn").onclick = startCopySelected; document.getElementById("copy-btn").onclick = startCopySelected;
@@ -876,6 +935,14 @@ function setupEvents() {
closeWildcardPopup(); closeWildcardPopup();
} }
}; };
const viewer = viewerElements();
viewer.closeButton.onclick = closeViewer;
viewer.overlay.onclick = (event) => {
if (event.target === viewer.overlay) {
closeViewer();
}
};
} }
async function init() { async function init() {
+11
View File
@@ -88,6 +88,17 @@
</div> </div>
</div> </div>
<div id="viewer-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="viewer-title">
<div class="popup-card viewer-card">
<button id="viewer-close-btn" class="viewer-close" type="button" aria-label="Close viewer">X</button>
<h3 id="viewer-title">View</h3>
<div id="viewer-file-name" class="popup-meta"></div>
<div id="viewer-file-path" class="popup-meta"></div>
<div id="viewer-error" class="error"></div>
<pre id="viewer-content" class="viewer-content"></pre>
</div>
</div>
<script src="/ui/app.js"></script> <script src="/ui/app.js"></script>
</body> </body>
</html> </html>
+31
View File
@@ -366,6 +366,37 @@ button:disabled {
justify-content: flex-end; justify-content: flex-end;
} }
.viewer-card {
position: relative;
width: min(960px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
display: flex;
flex-direction: column;
}
.viewer-close {
position: absolute;
top: 10px;
right: 10px;
min-width: 32px;
padding: 2px 8px;
}
.viewer-content {
margin: 6px 0 0 0;
padding: 10px;
min-height: 240px;
max-height: calc(100vh - 180px);
overflow: auto;
border: 1px solid var(--border);
background: #f8fafc;
color: var(--text);
font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", monospace;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.workspace { .workspace {
grid-template-columns: 1fr; grid-template-columns: 1fr;