feat: CMD-ENTER file info toegevoegd

This commit is contained in:
kodi
2026-03-12 11:45:56 +01:00
parent 6f8f884d75
commit 76f5ed3e98
16 changed files with 476 additions and 2 deletions
+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.
+9 -1
View File
@@ -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,
+13
View File
@@ -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
@@ -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] = []
+18 -1
View File
@@ -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)
@@ -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()
@@ -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)
+80
View File
@@ -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;
+9
View File
@@ -111,6 +111,15 @@
</div>
</div>
<div id="info-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="info-title">
<div class="popup-card info-card">
<button id="info-close-btn" class="viewer-close" type="button" aria-label="Close info">X</button>
<h3 id="info-title">Info</h3>
<div id="info-error" class="error"></div>
<div id="info-grid" class="info-grid"></div>
</div>
</div>
<div id="wildcard-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="wildcard-popup-title">
<div class="popup-card">
<h3 id="wildcard-popup-title">Wildcard Select</h3>
+23
View File
@@ -528,6 +528,29 @@ button:disabled {
width: min(680px, calc(100vw - 32px));
}
.info-card {
width: min(620px, calc(100vw - 32px));
}
.info-grid {
display: grid;
grid-template-columns: minmax(110px, 150px) minmax(0, 1fr);
gap: 10px 14px;
margin-top: 12px;
}
.info-label {
color: var(--color-muted);
font-size: 0.88rem;
font-weight: 600;
}
.info-value {
color: var(--color-text);
min-width: 0;
overflow-wrap: anywhere;
}
.search-input {
width: 100%;
}