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 @@
diff --git a/webui/html/style.css b/webui/html/style.css
index 59ab436..fbb24ac 100644
--- a/webui/html/style.css
+++ b/webui/html/style.css
@@ -318,6 +318,104 @@ button:disabled {
background: var(--color-list-row-hover);
}
+.entry-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+
+.entry-media-slot {
+ width: 28px;
+ min-width: 28px;
+ height: 28px;
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--color-button-secondary-bg);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.entry-media-slot img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.entry-media-icon {
+ width: 14px;
+ height: 14px;
+ position: relative;
+ display: inline-block;
+}
+
+.entry-media-icon.folder::before {
+ content: "";
+ position: absolute;
+ left: 1px;
+ top: 4px;
+ width: 12px;
+ height: 8px;
+ border: 1.5px solid var(--color-text-muted);
+ border-radius: 2px;
+ background: transparent;
+}
+
+.entry-media-icon.folder::after {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 2px;
+ width: 5px;
+ height: 3px;
+ border: 1.5px solid var(--color-text-muted);
+ border-bottom: 0;
+ border-radius: 2px 2px 0 0;
+ background: transparent;
+}
+
+.entry-media-icon.file::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 1px;
+ width: 9px;
+ height: 12px;
+ border: 1.5px solid var(--color-text-muted);
+ border-radius: 2px;
+ background: transparent;
+}
+
+.entry-media-icon.file::after {
+ content: "";
+ position: absolute;
+ right: 1px;
+ top: 1px;
+ width: 4px;
+ height: 4px;
+ background: var(--color-button-secondary-bg);
+ border-top: 1.5px solid var(--color-text-muted);
+ border-right: 1.5px solid var(--color-text-muted);
+ transform: skew(-12deg, -12deg);
+}
+
+.entry-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.settings-option {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 10px 0 8px;
+}
+
.list li.is-selected {
background: var(--color-selection-bg);
}