diff --git a/project_docs/THUMBNAIL_V1_DESIGN.md b/project_docs/THUMBNAIL_V1_DESIGN.md new file mode 100644 index 0000000..bb6df42 --- /dev/null +++ b/project_docs/THUMBNAIL_V1_DESIGN.md @@ -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. diff --git a/project_docs/THUMBNAIL_V1_DESIGN.md_OUD b/project_docs/THUMBNAIL_V1_DESIGN.md_OUD new file mode 100644 index 0000000..93099cc --- /dev/null +++ b/project_docs/THUMBNAIL_V1_DESIGN.md_OUD @@ -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. diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index a8e015a..d1c5071 100644 Binary files a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc and b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/webui/backend/app/__pycache__/main.cpython-313.pyc b/webui/backend/app/__pycache__/main.cpython-313.pyc index 83c8942..c7bc97d 100644 Binary files a/webui/backend/app/__pycache__/main.cpython-313.pyc and b/webui/backend/app/__pycache__/main.cpython-313.pyc differ 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 0b53650..e98803c 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__/routes_settings.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_settings.cpython-313.pyc new file mode 100644 index 0000000..49e209c Binary files /dev/null and b/webui/backend/app/api/__pycache__/routes_settings.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 a9f14ee..a775e50 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 a6799d6..20aca79 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -66,6 +66,19 @@ async def video( ) +@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, diff --git a/webui/backend/app/api/routes_settings.py b/webui/backend/app/api/routes_settings.py new file mode 100644 index 0000000..108db37 --- /dev/null +++ b/webui/backend/app/api/routes_settings.py @@ -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) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 863cfeb..df06328 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -94,6 +94,14 @@ class FileInfoResponse(BaseModel): group: str | None = None +class SettingsResponse(BaseModel): + show_thumbnails: bool + + +class SettingsUpdateRequest(BaseModel): + show_thumbnails: bool + + class TaskListItem(BaseModel): id: str operation: str diff --git a/webui/backend/app/db/__pycache__/settings_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/settings_repository.cpython-313.pyc new file mode 100644 index 0000000..af9a6a3 Binary files /dev/null and b/webui/backend/app/db/__pycache__/settings_repository.cpython-313.pyc differ diff --git a/webui/backend/app/db/settings_repository.py b/webui/backend/app/db/settings_repository.py new file mode 100644 index 0000000..9447f00 --- /dev/null +++ b/webui/backend/app/db/settings_repository.py @@ -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() diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 669717e..bae6229 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -5,6 +5,7 @@ from functools import lru_cache 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.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 @@ -15,6 +16,7 @@ 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.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 @@ -47,6 +49,12 @@ def get_history_repository() -> HistoryRepository: 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_task_runner() -> TaskRunner: return TaskRunner( @@ -100,3 +108,7 @@ async def get_history_service() -> HistoryService: 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()) 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 5d90b54..e5ed6bc 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 5c636c0..02fcf30 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -147,6 +147,14 @@ class FilesystemAdapter: 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() diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py index 9b5740b..e07a32e 100644 --- a/webui/backend/app/main.py +++ b/webui/backend/app/main.py @@ -14,6 +14,7 @@ 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.logging import configure_logging @@ -31,6 +32,7 @@ app.include_router(files_router, prefix="/api") app.include_router(copy_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") 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 6e4dec5..4af8890 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/__pycache__/settings_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/settings_service.cpython-313.pyc new file mode 100644 index 0000000..442111e Binary files /dev/null and b/webui/backend/app/services/__pycache__/settings_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 5b3a402..80fc5a4 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -25,6 +25,12 @@ SPECIAL_TEXT_FILENAMES = { "dockerfile": "text/plain", "containerfile": "text/plain", } +THUMBNAIL_CONTENT_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} VIDEO_CONTENT_TYPES = { ".mp4": "video/mp4", ".mkv": "video/x-matroska", @@ -370,6 +376,39 @@ class FileOpsService: "content": self._filesystem.stream_file_range(resolved_target.absolute, start, end), } + def prepare_thumbnail_stream(self, path: str) -> dict: + 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 thumbnail", + status_code=409, + details={"path": resolved_target.relative}, + ) + + content_type = self._thumbnail_content_type_for(resolved_target.absolute) + if content_type is None: + raise AppError( + code="unsupported_type", + message="File type is not supported for thumbnail", + status_code=409, + details={"path": resolved_target.relative}, + ) + + return { + "headers": {"Content-Length": str(int(resolved_target.absolute.stat().st_size))}, + "content_type": content_type, + "content": self._filesystem.stream_file(resolved_target.absolute), + } + @staticmethod def _join_relative(base: str, name: str) -> str: return f"{base}/{name}" if base else name @@ -385,6 +424,10 @@ class FileOpsService: def _video_content_type_for(path: Path) -> str | None: return VIDEO_CONTENT_TYPES.get(path.suffix.lower()) + @staticmethod + def _thumbnail_content_type_for(path: Path) -> str | None: + return THUMBNAIL_CONTENT_TYPES.get(path.suffix.lower()) + def _record_history( self, *, diff --git a/webui/backend/app/services/settings_service.py b/webui/backend/app/services/settings_service.py new file mode 100644 index 0000000..10994fe --- /dev/null +++ b/webui/backend/app/services/settings_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest +from backend.app.db.settings_repository import SettingsRepository + + +class SettingsService: + def __init__(self, repository: SettingsRepository): + self._repository = repository + + def get_settings(self) -> SettingsResponse: + values = self._repository.get_settings() + return SettingsResponse(show_thumbnails=self._as_bool(values.get("show_thumbnails"), default=False)) + + def update_settings(self, request: SettingsUpdateRequest) -> SettingsResponse: + self._repository.set_setting("show_thumbnails", "true" if request.show_thumbnails else "false") + return self.get_settings() + + @staticmethod + def _as_bool(value: str | None, default: bool) -> bool: + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index dc3df1f..ed35ad7 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc new file mode 100644 index 0000000..4a4f4bc Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_settings_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_thumbnail_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_thumbnail_golden.cpython-313.pyc new file mode 100644 index 0000000..5a3fd1e Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_thumbnail_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 3f367d1..76e07f0 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_settings_golden.py b/webui/backend/tests/golden/test_api_settings_golden.py new file mode 100644 index 0000000..57eeb34 --- /dev/null +++ b/webui/backend/tests/golden/test_api_settings_golden.py @@ -0,0 +1,59 @@ +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_settings_service +from backend.app.db.settings_repository import SettingsRepository +from backend.app.main import app +from backend.app.services.settings_service import SettingsService + + +class SettingsApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + repository = SettingsRepository(str(Path(self.temp_dir.name) / "tasks.db")) + service = SettingsService(repository=repository) + + async def _override_settings_service() -> SettingsService: + return service + + app.dependency_overrides[get_settings_service] = _override_settings_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + if method == "GET": + return await client.get(url) + return await client.post(url, json=payload) + + return asyncio.run(_run()) + + def test_settings_default_response(self) -> None: + response = self._request("GET", "/api/settings") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"show_thumbnails": False}) + + def test_settings_update_persistence(self) -> None: + response = self._request("POST", "/api/settings", {"show_thumbnails": True}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"show_thumbnails": True}) + self.assertEqual(self._request("GET", "/api/settings").json(), {"show_thumbnails": True}) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_api_thumbnail_golden.py b/webui/backend/tests/golden/test_api_thumbnail_golden.py new file mode 100644 index 0000000..6e6a03f --- /dev/null +++ b/webui/backend/tests/golden/test_api_thumbnail_golden.py @@ -0,0 +1,83 @@ +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 ThumbnailApiGoldenTest(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) + service = FileOpsService(path_guard=PathGuard({"storage1": str(self.root)}), 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/thumbnail", params={"path": path}) + + return asyncio.run(_run()) + + def test_thumbnail_success_for_supported_image(self) -> None: + image = self.root / "poster.jpg" + image.write_bytes(b"\xff\xd8\xff\xe0thumbnail") + + response = self._request("storage1/poster.jpg") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.headers["content-type"], "image/jpeg") + self.assertEqual(response.content, b"\xff\xd8\xff\xe0thumbnail") + + def test_thumbnail_not_found(self) -> None: + response = self._request("storage1/missing.jpg") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_thumbnail_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_thumbnail_invalid_root_alias(self) -> None: + response = self._request("unknown/file.jpg") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_thumbnail_non_image_blocked(self) -> None: + text_file = self.root / "notes.txt" + text_file.write_text("hello", encoding="utf-8") + + response = self._request("storage1/notes.txt") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "unsupported_type") + + +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 304d8df..44ea591 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -63,6 +63,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="rename-apply-btn"', body) self.assertIn('id="settings-general-tab"', body) self.assertIn('id="settings-logs-tab"', body) + self.assertIn('id="settings-show-thumbnails"', body) + self.assertIn("Show thumbnails", body) self.assertIn('id="settings-logs-list"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) @@ -113,6 +115,12 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) + self.assertIn('async function loadSettings()', app_js) + self.assertIn('await loadSettings();', app_js) + self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) + self.assertIn('"/api/settings"', app_js) + self.assertIn('`/api/files/thumbnail?', app_js) + self.assertIn('function createMediaSlot(entry)', app_js) self.assertIn('function openSearch()', app_js) self.assertIn('async function submitSearch()', app_js) self.assertIn('async function openInfo()', app_js) @@ -149,6 +157,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('#theme-toggle', style_css) self.assertIn('.settings-card', style_css) self.assertIn('.settings-tabs', style_css) + self.assertIn('.entry-media-slot', style_css) + self.assertIn('.entry-media-icon.folder', style_css) + self.assertIn('.entry-media-icon.file', style_css) app_js_url = app.url_path_for("ui", path="/app.js") style_css_url = app.url_path_for("ui", path="/style.css") diff --git a/webui/html/app.js b/webui/html/app.js index 0f82fa4..ef79320 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -47,6 +47,7 @@ let batchMoveState = { let settingsState = { activeTab: "general", logsLoaded: false, + showThumbnails: false, }; let searchState = { pane: "left", @@ -200,6 +201,7 @@ function settingsElements() { generalTab: document.getElementById("settings-general-tab"), logsTab: document.getElementById("settings-logs-tab"), generalPanel: document.getElementById("settings-general-panel"), + showThumbnailsInput: document.getElementById("settings-show-thumbnails"), logsPanel: document.getElementById("settings-logs-panel"), logsList: document.getElementById("settings-logs-list"), logsError: document.getElementById("settings-logs-error"), @@ -357,6 +359,58 @@ function currentRowItem(pane) { return model.visibleItems[model.currentRowIndex]; } +function thumbnailsEnabled() { + return !!settingsState.showThumbnails; +} + +function isThumbnailCandidate(entry) { + if (!entry || entry.kind !== "file") { + return false; + } + const lower = (entry.name || "").toLowerCase(); + return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp"); +} + +function createMediaSlot(entry) { + const slot = document.createElement("span"); + slot.className = "entry-media-slot"; + if (thumbnailsEnabled() && isThumbnailCandidate(entry)) { + const image = document.createElement("img"); + image.className = "entry-thumbnail"; + image.loading = "lazy"; + image.alt = ""; + image.src = `/api/files/thumbnail?${new URLSearchParams({ path: entry.path }).toString()}`; + slot.append(image); + return slot; + } + + const icon = document.createElement("span"); + icon.className = `entry-media-icon ${entry.kind === "directory" ? "folder" : "file"}`; + icon.setAttribute("aria-hidden", "true"); + slot.append(icon); + return slot; +} + +async function loadSettings() { + const data = await apiRequest("GET", "/api/settings"); + settingsState.showThumbnails = !!data.show_thumbnails; + const elements = settingsElements(); + if (elements.showThumbnailsInput) { + elements.showThumbnailsInput.checked = settingsState.showThumbnails; + } +} + +async function saveSettings(update) { + const data = await apiRequest("POST", "/api/settings", update); + settingsState.showThumbnails = !!data.show_thumbnails; + const elements = settingsElements(); + if (elements.showThumbnailsInput) { + elements.showThumbnailsInput.checked = settingsState.showThumbnails; + } + renderPaneItems("left"); + renderPaneItems("right"); +} + function updateActionButtons() { const selectedItems = activePaneState().selectedItems; const count = selectedItems.length; @@ -547,6 +601,7 @@ function createBrowseItem(pane, entry, kind) { const name = document.createElement("span"); name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`; + name.append(createMediaSlot({ ...entry, kind })); if (kind === "directory") { const open = document.createElement("button"); @@ -558,10 +613,12 @@ function createBrowseItem(pane, entry, kind) { setActivePane(pane); navigateTo(pane, entry.path); }; + open.classList.add("entry-label"); name.append(open); } else { const fileName = document.createElement("span"); fileName.textContent = entry.name; + fileName.className = "entry-label"; fileName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); @@ -626,6 +683,11 @@ function renderPaneItems(pane) { renderPaneItems(pane); }; up.append(document.createElement("span")); + const upNameCell = document.createElement("span"); + upNameCell.className = "entry-name entry-dir"; + const upMedia = document.createElement("span"); + upMedia.className = "entry-media-slot"; + upNameCell.append(upMedia); const upName = document.createElement("button"); upName.type = "button"; upName.className = "dir-link"; @@ -635,8 +697,7 @@ function renderPaneItems(pane) { setActivePane(pane); navigateTo(pane, entry.path); }; - const upNameCell = document.createElement("span"); - upNameCell.className = "entry-name entry-dir"; + upName.classList.add("entry-label"); upNameCell.append(upName); up.append(upNameCell); const upSize = document.createElement("span"); @@ -1630,6 +1691,17 @@ async function loadHistoryForSettings() { } } +async function handleShowThumbnailsChange(event) { + const input = event.target; + try { + await saveSettings({ show_thumbnails: !!input.checked }); + } catch (err) { + input.checked = settingsState.showThumbnails; + settingsElements().logsError.textContent = ""; + setError("actions-error", `Settings: ${err.message}`); + } +} + function closeSettings() { settingsElements().overlay.classList.add("hidden"); } @@ -2097,6 +2169,7 @@ function setupEvents() { setSettingsTab("logs"); await loadHistoryForSettings(); }; + settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.overlay.onclick = (event) => { if (event.target === settings.overlay) { closeSettings(); @@ -2204,6 +2277,7 @@ async function init() { applyTheme(preferredTheme()); setActivePane("left"); setupEvents(); + await loadSettings(); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); } diff --git a/webui/html/index.html b/webui/html/index.html index 0bff28a..c629d41 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -90,7 +90,11 @@
General
- + +