diff --git a/project_docs/FILE_INFO_V1_DESIGN.md b/project_docs/FILE_INFO_V1_DESIGN.md new file mode 100644 index 0000000..91aabf0 --- /dev/null +++ b/project_docs/FILE_INFO_V1_DESIGN.md @@ -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. 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 4754228..0b53650 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 15aee84..a9f14ee 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 87befbf..a6799d6 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request from fastapi.responses import StreamingResponse -from backend.app.api.schemas import DeleteRequest, DeleteResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse +from backend.app.api.schemas import DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, ViewResponse from backend.app.dependencies import get_file_ops_service from backend.app.services.file_ops_service import FileOpsService @@ -43,6 +43,14 @@ async def view( 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), +) -> FileInfoResponse: + return service.info(path=path) + + @router.get("/video") async def video( path: str, diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index d011282..863cfeb 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -81,6 +81,19 @@ class SaveResponse(BaseModel): modified: 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 + + 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 a49c9cd..5d90b54 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 f934be8..5c636c0 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -1,11 +1,38 @@ from __future__ import annotations import shutil +import mimetypes +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) + 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, + } + def list_directory(self, directory: Path, show_hidden: bool) -> tuple[list[dict], list[dict]]: directories: list[dict] = [] files: list[dict] = [] 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 452d404..6e4dec5 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 fe354af..5b3a402 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path from backend.app.api.errors import AppError -from backend.app.api.schemas import DeleteResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse +from backend.app.api.schemas import DeleteResponse, FileInfoResponse, MkdirResponse, RenameResponse, SaveResponse, ViewResponse from backend.app.db.history_repository import HistoryRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard @@ -245,6 +245,23 @@ class FileOpsService: content=preview["content"], ) + def info(self, path: str) -> FileInfoResponse: + resolved_target = self._path_guard.resolve_existing_path(path) + metadata = self._filesystem.stat_info(resolved_target.absolute) + + return FileInfoResponse( + name=metadata["name"], + path=resolved_target.relative, + type="directory" if resolved_target.absolute.is_dir() else "file", + size=metadata["size"], + modified=metadata["modified"], + root=resolved_target.alias, + extension=metadata["extension"], + content_type=metadata["content_type"], + owner=metadata["owner"], + group=metadata["group"], + ) + def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: resolved_target = self._path_guard.resolve_existing_path(path) diff --git a/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_info_golden.cpython-313.pyc new file mode 100644 index 0000000..5cf52ad Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_info_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 996853e..3f367d1 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_info_golden.py b/webui/backend/tests/golden/test_api_info_golden.py new file mode 100644 index 0000000..9071e19 --- /dev/null +++ b/webui/backend/tests/golden/test_api_info_golden.py @@ -0,0 +1,121 @@ +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 FileInfoApiGoldenTest(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) + self.service = FileOpsService(path_guard=PathGuard({"storage1": str(self.root)}), filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return self.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/info", params={"path": path}) + + return asyncio.run(_run()) + + def test_file_info_success(self) -> None: + file_path = self.root / "docs.txt" + file_path.write_text("hello", encoding="utf-8") + + response = self._request("storage1/docs.txt") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["name"], "docs.txt") + self.assertEqual(payload["path"], "storage1/docs.txt") + self.assertEqual(payload["type"], "file") + self.assertEqual(payload["size"], 5) + self.assertEqual(payload["root"], "storage1") + self.assertEqual(payload["extension"], ".txt") + self.assertEqual(payload["content_type"], "text/plain") + self.assertIn("modified", payload) + self.assertIn("owner", payload) + self.assertIn("group", payload) + + def test_directory_info_success(self) -> None: + directory = self.root / "Media" + directory.mkdir() + + response = self._request("storage1/Media") + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["name"], "Media") + self.assertEqual(payload["path"], "storage1/Media") + self.assertEqual(payload["type"], "directory") + self.assertIsNone(payload["size"]) + self.assertEqual(payload["root"], "storage1") + self.assertIsNone(payload["extension"]) + + def test_info_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_info_traversal_blocked(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_info_invalid_root_alias(self) -> None: + response = self._request("unknown/item.txt") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_info_response_shape(self) -> None: + file_path = self.root / "movie.mp4" + file_path.write_bytes(b"012345") + + response = self._request("storage1/movie.mp4") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + set(response.json().keys()), + { + "name", + "path", + "type", + "size", + "modified", + "root", + "extension", + "content_type", + "owner", + "group", + }, + ) + + +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 65a49f2..304d8df 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -57,6 +57,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="search-modal"', body) self.assertIn('id="search-input"', body) self.assertIn('id="search-results"', body) + self.assertIn('id="info-modal"', body) self.assertIn('id="rename-popup"', body) self.assertIn('id="rename-input"', body) self.assertIn('id="rename-apply-btn"', body) @@ -84,6 +85,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="wildcard-popup"', body) self.assertIn('id="wildcard-pattern-input"', body) self.assertNotIn('id="search-btn"', body) + self.assertNotIn('id="info-btn"', body) self.assertNotIn('id="bookmarks-panel"', body) self.assertNotIn('id="tasks-panel"', body) @@ -113,10 +115,14 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) self.assertIn('function openSearch()', app_js) self.assertIn('async function submitSearch()', app_js) + self.assertIn('async function openInfo()', app_js) + self.assertIn('document.getElementById("info-modal")', app_js) + self.assertIn("`/api/files/info?", app_js) self.assertIn('document.getElementById("search-input")', app_js) self.assertIn("`/api/search?", app_js) self.assertIn('event.key.toLowerCase() === "f"', app_js) self.assertIn('(event.metaKey || event.ctrlKey)', app_js) + self.assertIn('const isInfoShortcut = event.key === "Enter"', app_js) self.assertIn('if (event.key === "F1") {', app_js) self.assertIn('if (event.key === "F2") {', app_js) self.assertIn('function openSettings(tab = "general")', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 658d4c8..0f82fa4 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -217,6 +217,15 @@ function searchElements() { }; } +function infoElements() { + return { + overlay: document.getElementById("info-modal"), + closeButton: document.getElementById("info-close-btn"), + error: document.getElementById("info-error"), + grid: document.getElementById("info-grid"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -1410,6 +1419,55 @@ function closeVideoViewer() { video.player.load(); } +function isInfoOpen() { + return !infoElements().overlay.classList.contains("hidden"); +} + +function closeInfo() { + const elements = infoElements(); + elements.overlay.classList.add("hidden"); + elements.error.textContent = ""; + elements.grid.innerHTML = ""; +} + +function renderInfoField(label, value) { + const grid = infoElements().grid; + const labelNode = document.createElement("div"); + labelNode.className = "info-label"; + labelNode.textContent = label; + const valueNode = document.createElement("div"); + valueNode.className = "info-value"; + valueNode.textContent = value == null || value === "" ? "-" : String(value); + grid.append(labelNode, valueNode); +} + +async function openInfo() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1) { + return; + } + const selected = selectedItems[0]; + const elements = infoElements(); + elements.overlay.classList.remove("hidden"); + elements.error.textContent = ""; + elements.grid.innerHTML = ""; + try { + const data = await apiRequest("GET", `/api/files/info?${new URLSearchParams({ path: selected.path }).toString()}`); + renderInfoField("Name", data.name); + renderInfoField("Path", data.path); + renderInfoField("Type", data.type); + renderInfoField("Size", data.size); + renderInfoField("Modified", formatModified(data.modified)); + renderInfoField("Root", data.root); + renderInfoField("Extension", data.extension); + renderInfoField("Content type", data.content_type); + renderInfoField("Owner", data.owner); + renderInfoField("Group", data.group); + } catch (err) { + elements.error.textContent = err.message; + } +} + function isSearchOpen() { return !searchElements().overlay.classList.contains("hidden"); } @@ -1804,6 +1862,13 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isInfoOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeInfo(); + } + return; + } if (isSearchOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -1891,6 +1956,13 @@ function handleKeyboardShortcuts(event) { return; } + const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); + if (isInfoShortcut) { + event.preventDefault(); + openInfo(); + return; + } + const isSearchShortcut = event.key.toLowerCase() === "f" && event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); if (isSearchShortcut) { event.preventDefault(); @@ -2039,6 +2111,14 @@ function setupEvents() { } }; + const info = infoElements(); + info.closeButton.onclick = closeInfo; + info.overlay.onclick = (event) => { + if (event.target === info.overlay) { + closeInfo(); + } + }; + const wildcard = wildcardPopupElements(); wildcard.cancelButton.onclick = closeWildcardPopup; wildcard.applyButton.onclick = submitWildcardPopup; diff --git a/webui/html/index.html b/webui/html/index.html index 2398b07..0bff28a 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -111,6 +111,15 @@ + +