feat: preffered startup paths
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
# Preferred Startup Paths v2
|
||||
|
||||
## 1. Doel
|
||||
Aparte startup paths voor links en rechts voegen waarde toe omdat de huidige app inmiddels duidelijk als dual-pane workspace wordt gebruikt, niet meer als een enkel browservenster met twee toevallige kolommen. In de praktijk wil een gebruiker vaak een vaste bronlocatie links en een vaste doellocatie rechts, of twee verschillende werkcontexten die bij elke start direct beschikbaar zijn.
|
||||
|
||||
Dit past goed binnen de huidige dual-pane workflow zolang het startup-model voorspelbaar blijft:
|
||||
- links en rechts worden onafhankelijk bepaald
|
||||
- elke paneelstart heeft een veilige fallback
|
||||
- invalidatie van het ene paneel mag het andere paneel niet blokkeren
|
||||
|
||||
Het doel van v2 is dus niet om sessieherstel te bouwen, maar om de bestaande v1-setting uit te breiden naar een net paneelspecifiek model.
|
||||
|
||||
## 2. Scope
|
||||
Voor v2 is de scope expliciet:
|
||||
- twee aparte settings
|
||||
- `preferred_startup_path_left`
|
||||
- `preferred_startup_path_right`
|
||||
- beide persistent opgeslagen in de bestaande SQLite settings-opslag
|
||||
- configureerbaar via `F1 -> Settings -> General`
|
||||
- geen browser storage
|
||||
- geen profielensysteem
|
||||
- geen "laatst gebruikte map" logica
|
||||
|
||||
Niet in scope:
|
||||
- startup-profielen
|
||||
- per root presets
|
||||
- per gebruiker verschillende defaults
|
||||
- automatisch synchroniseren van links en rechts
|
||||
- migratie naar een complexer settings-schema dan nodig
|
||||
|
||||
## 3. Paneelgedrag
|
||||
Aanbevolen v2-keuze:
|
||||
- linkerpaneel gebruikt `preferred_startup_path_left` als die geldig is
|
||||
- rechterpaneel gebruikt `preferred_startup_path_right` als die geldig is
|
||||
- per paneel geldt onafhankelijk:
|
||||
- leeg -> fallback naar `/Volumes`
|
||||
- ongeldig -> fallback naar `/Volumes`
|
||||
|
||||
Dit is de meest directe uitbreiding op v1 en heeft het laagste regressierisico, omdat het huidige startupgedrag al paneelspecifiek wordt bepaald.
|
||||
|
||||
Expliciet niet aanbevolen voor v2:
|
||||
- één setting die intern naar beide panelen gespiegeld wordt
|
||||
- een "alleen links instelbaar, rechts afgeleid" model
|
||||
- complexe koppellogica zoals "rechts gebruikt automatisch andere root"
|
||||
|
||||
Die varianten introduceren impliciete regels en maken debugging moeilijker.
|
||||
|
||||
## 4. Validatie
|
||||
Per veld geldt exact dezelfde validatie als in v1:
|
||||
- pad moet bestaan
|
||||
- pad moet een directory zijn
|
||||
- pad moet binnen toegestane roots vallen
|
||||
- traversal wordt geblokkeerd
|
||||
- invalid root alias wordt geblokkeerd
|
||||
- file paths zijn niet toegestaan
|
||||
|
||||
Semantiek:
|
||||
- lege waarde betekent `null`
|
||||
- `null` betekent: voor dat paneel geen voorkeurpad, dus fallback naar `/Volumes`
|
||||
|
||||
Belangrijk onderscheid tussen write en read:
|
||||
- write-validatie blijft streng
|
||||
- read-gedrag blijft tolerant
|
||||
|
||||
Dat betekent:
|
||||
- ongeldige invoer wordt niet opgeslagen
|
||||
- eerder opgeslagen waarden die later ongeldig worden, worden bij startup niet blind vertrouwd maar ook niet direct uit de database verwijderd
|
||||
|
||||
## 5. Fallback-gedrag
|
||||
Veilig en voorspelbaar fallbackmodel voor v2:
|
||||
- links:
|
||||
- geldig `preferred_startup_path_left` -> gebruik dat pad
|
||||
- anders -> `/Volumes`
|
||||
- rechts:
|
||||
- geldig `preferred_startup_path_right` -> gebruik dat pad
|
||||
- anders -> `/Volumes`
|
||||
|
||||
Gemengde gevallen moeten expliciet ondersteund worden:
|
||||
- links geldig, rechts ongeldig -> links op voorkeurpad, rechts op `/Volumes`
|
||||
- links ongeldig, rechts geldig -> links op `/Volumes`, rechts op voorkeurpad
|
||||
- beide ongeldig -> beide op `/Volumes`
|
||||
|
||||
Dit voorkomt dat één corrupte setting de hele appstart breekt.
|
||||
|
||||
## 6. Settings UI
|
||||
Plaatsing blijft:
|
||||
- `F1 -> Settings -> General`
|
||||
|
||||
Aanbevolen v2-weergave:
|
||||
- twee aparte tekstvelden
|
||||
- labels:
|
||||
- `Preferred startup path (left)`
|
||||
- `Preferred startup path (right)`
|
||||
|
||||
Opslaggedrag:
|
||||
- opslaan via dezelfde bestaande settings-save flow
|
||||
- beide velden mogen tegelijk opgeslagen worden
|
||||
- leegmaken van een veld zet alleen dat veld naar `null`
|
||||
|
||||
Aanbevolen foutweergave:
|
||||
- compact maar duidelijk per veld, of als één compacte General-error als de huidige modal daar al op ingericht is
|
||||
- bij voorkeur per veld omdat links en rechts onafhankelijk valide kunnen zijn
|
||||
|
||||
Als per-veld foutweergave te veel UI-ruis oplevert, is een compacte foutregel in de General-tab acceptabel, mits duidelijk is welk veld faalt.
|
||||
|
||||
## 7. Backend-impact
|
||||
Deze uitbreiding past logisch in de bestaande settings-opslag en settings-API.
|
||||
|
||||
Aanbevolen uitbreiding:
|
||||
- bestaande settings-uitbreiding met:
|
||||
- `preferred_startup_path_left: string | null`
|
||||
- `preferred_startup_path_right: string | null`
|
||||
|
||||
### Backward compatibility met bestaande single setting
|
||||
Hier is een expliciete keuze nodig.
|
||||
|
||||
Aanbevolen richting met laag regressierisico:
|
||||
- de oude single setting `preferred_startup_path` tijdelijk blijven ondersteunen als legacy input
|
||||
- nieuwe code leest primair:
|
||||
- `preferred_startup_path_left`
|
||||
- `preferred_startup_path_right`
|
||||
- als deze ontbreken, maar oude `preferred_startup_path` bestaat nog:
|
||||
- gebruik die alleen als fallback voor links
|
||||
- rechts blijft `/Volumes`
|
||||
- bij de eerste expliciete save vanuit v2 UI schrijft de app alleen de nieuwe left/right keys
|
||||
- de oude key hoeft in v2 niet direct verwijderd te worden
|
||||
|
||||
Waarom dit de beste v2-keuze is:
|
||||
- bestaande gebruikers van v1 verliezen hun startup-voorkeur niet
|
||||
- er is geen harde migratiestap nodig
|
||||
- de migratielogica blijft klein en begrijpelijk
|
||||
|
||||
Expliciet niet aanbevolen:
|
||||
- de oude key hard verwijderen zonder read-compatibiliteit
|
||||
- een automatische destructieve migratie bij startup
|
||||
- dezelfde oude key dupliceren naar links én rechts
|
||||
|
||||
Dat laatste zou te verrassend zijn, omdat het beide panelen plots op dezelfde map laat starten.
|
||||
|
||||
## 8. Frontend-impact
|
||||
Frontendstartup moet worden uitgebreid van:
|
||||
- één preferred path voor links
|
||||
naar:
|
||||
- twee onafhankelijke preferred paths
|
||||
|
||||
Aanbevolen init-volgorde:
|
||||
1. laad settings via backend
|
||||
2. bepaal startup path voor links
|
||||
3. bepaal startup path voor rechts
|
||||
4. initialiseeer panelen met die twee waarden
|
||||
5. browse laden
|
||||
6. per paneel veilig fallbacken naar `/Volumes` als browse init voor dat pad mislukt
|
||||
|
||||
Belangrijk voor regressiebehoud:
|
||||
- dubbele initialisatie vermijden waar mogelijk
|
||||
- links en rechts los fallbacken
|
||||
- huidige `/Volumes` defaultlogica niet verwijderen maar als veilige basis behouden
|
||||
|
||||
## 9. Regressierisico
|
||||
Belangrijkste risico’s:
|
||||
- invalid stored path voor één paneel veroorzaakt misleidende first render
|
||||
- mixed valid/invalid links/rechts levert inconsistente startup op als fallbacklogica niet per paneel gebeurt
|
||||
- `/Volumes` hostlaag en alias-mapping moeten consistent blijven
|
||||
- legacy single setting kan per ongeluk genegeerd worden
|
||||
|
||||
Laag-risico aanpak:
|
||||
- per paneel apart resolven en fallbacken
|
||||
- legacy key alleen als tijdelijke read-fallback voor links
|
||||
- write alleen naar nieuwe keys
|
||||
- `/Volumes` als harde veilige fallback behouden
|
||||
- geen semantische wijziging aan browse buiten startup
|
||||
|
||||
## 10. Teststrategie
|
||||
### Backend golden tests
|
||||
- `GET /api/settings` met default:
|
||||
- `preferred_startup_path_left = null`
|
||||
- `preferred_startup_path_right = null`
|
||||
- geldig left path opslaan
|
||||
- geldig right path opslaan
|
||||
- beide tegelijk opslaan
|
||||
- file path blokkeren per veld
|
||||
- traversal blokkeren per veld
|
||||
- invalid root alias blokkeren per veld
|
||||
- missing directory blokkeren per veld
|
||||
- lege waarde zet veld terug naar `null`
|
||||
|
||||
### UI smoke/regressietests
|
||||
- `Settings > General` bevat beide velden
|
||||
- app init leest beide settings via backend
|
||||
- linker en rechter paneel krijgen onafhankelijke startupwaarden
|
||||
- fallback naar `/Volumes` blijft intact per paneel
|
||||
- legacy v1-pad breekt appinit niet
|
||||
|
||||
### Handmatige validatie
|
||||
- links en rechts verschillende geldige paden opslaan en app herstarten
|
||||
- links geldig, rechts leeg
|
||||
- links ongeldig opgeslagen legacy/v2 waarde simuleren
|
||||
- rechts ongeldig opgeslagen v2 waarde simuleren
|
||||
- beide ongeldig -> beide `/Volumes`
|
||||
- v1-upgrade scenario:
|
||||
- alleen oude `preferred_startup_path` aanwezig
|
||||
- links start daarop, rechts op `/Volumes`
|
||||
|
||||
## 11. Aanbeveling
|
||||
Aanbevolen v2-richting met laag regressierisico:
|
||||
- voeg twee nieuwe settings toe:
|
||||
- `preferred_startup_path_left`
|
||||
- `preferred_startup_path_right`
|
||||
- valideer beide op dezelfde manier als v1
|
||||
- leeg veld = `null`
|
||||
- fallback per paneel = `/Volumes`
|
||||
- behoud tijdelijke read-compatibiliteit met de bestaande v1-key `preferred_startup_path`
|
||||
- alleen als fallback voor links
|
||||
- schrijf vanuit v2 UI alleen nog de nieuwe left/right settings
|
||||
|
||||
Dit geeft een nette evolutie van v1 naar een volwaardige dual-pane startupconfiguratie, zonder bestaande gebruikers onverwacht te breken en zonder onnodige migratiecomplexiteit.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Preferred Startup Path v1
|
||||
|
||||
## 1. Doel
|
||||
Een voorkeurpad is nuttig omdat de app nu altijd met een vaste startlocatie opent, terwijl de gebruiker in de praktijk vaak steeds in dezelfde map of subtree werkt. Als de app direct op die plek opent, scheelt dat navigatiestappen en sluit het beter aan op een dual-pane file manager workflow waarin de gebruiker vaak een primaire werkmap heeft.
|
||||
|
||||
Binnen de huidige app past dit goed zolang de startup-logica eenvoudig en voorspelbaar blijft. Het doel is niet om sessies volledig te herstellen, maar om een veilige, persistente startlocatie te bieden.
|
||||
|
||||
## 2. Scope
|
||||
Voor v1 is de scope expliciet:
|
||||
- één voorkeurpad
|
||||
- persistent opgeslagen in de bestaande SQLite settings-opslag
|
||||
- configureerbaar via `F1 -> Settings -> General`
|
||||
- geen browser storage
|
||||
- geen meervoudige profielen of paneelspecifieke presets
|
||||
|
||||
Niet in scope:
|
||||
- meerdere startup-profielen
|
||||
- per root een aparte default
|
||||
- laatst bezochte map onthouden per sessie
|
||||
- aparte startup-paths voor links en rechts
|
||||
|
||||
## 3. Paneelgedrag
|
||||
Aanbevolen v1-keuze met laag regressierisico:
|
||||
- één voorkeurpad
|
||||
- toepassen op het linkerpaneel bij startup
|
||||
- rechterpaneel blijft starten op de huidige veilige default (`/Volumes`)
|
||||
|
||||
Reden:
|
||||
- dit geeft directe waarde zonder de dual-pane logica complex te maken
|
||||
- het voorkomt dat beide panelen onbedoeld op exact dezelfde diepe map starten
|
||||
- het behoudt één paneel als neutrale oriëntatie-/navigatiekolom
|
||||
- het vermindert regressierisico ten opzichte van twee aparte persistente padinstellingen
|
||||
|
||||
Alternatieven die bewust niet aanbevolen worden voor v1:
|
||||
- hetzelfde voorkeurpad voor beide panelen: eenvoudig, maar minder bruikbaar in dual-pane gebruik
|
||||
- aparte voorkeurpaden voor links en rechts: functioneel aantrekkelijk, maar meer validatie- en fallbackcomplexiteit
|
||||
|
||||
## 4. Validatie
|
||||
Het voorkeurpad moet aan dezelfde veiligheidsregels voldoen als gewone browse-paden:
|
||||
- binnen whitelist / toegestane roots
|
||||
- geldig binnen de bestaande `/Volumes` en root/alias-logica
|
||||
- padvalidatie via bestaande `path_guard`-infrastructuur of equivalente browse-validatie
|
||||
|
||||
Validatieregels:
|
||||
- pad moet resolven naar een toegestane locatie
|
||||
- pad moet bestaan
|
||||
- pad moet browsebaar zijn als directory
|
||||
- file als startup path is niet toegestaan voor v1
|
||||
|
||||
Gedrag bij ongeldigheid:
|
||||
- als het pad niet meer bestaat: instelling blijft opgeslagen, maar startup valt terug op veilige default
|
||||
- als het pad niet meer toegankelijk is door whitelist/config wijziging: startup valt terug op veilige default
|
||||
- als het pad syntactisch ongeldig is: backend weigert opslag
|
||||
|
||||
## 5. Fallback-gedrag
|
||||
Veilige en voorspelbare fallback voor v1:
|
||||
- als preferred startup path ontbreekt of ongeldig is, start de app op `/Volumes`
|
||||
- rechterpaneel blijft in alle gevallen op `/Volumes`
|
||||
- linkerpaneel gebruikt alleen het voorkeurpad als de validatie slaagt
|
||||
|
||||
Dit houdt het model simpel:
|
||||
- geldig voorkeurpad -> links opent daar, rechts opent `/Volumes`
|
||||
- ongeldig voorkeurpad -> beide panelen openen `/Volumes`
|
||||
|
||||
## 6. Settings UI
|
||||
Plaatsing:
|
||||
- `F1 -> Settings -> General`
|
||||
|
||||
Aanbevolen invoervorm voor v1:
|
||||
- één tekstveld met het volledige pad
|
||||
- compact helperlabel, bijvoorbeeld: `Preferred startup path`
|
||||
|
||||
Waarom tekstveld de laagste risicokeuze is:
|
||||
- sluit aan op de bestaande padsemantiek in de app
|
||||
- geen extra browse-picker of tree-selector nodig
|
||||
- laagste implementatiecomplexiteit
|
||||
|
||||
Opslagflow:
|
||||
- gebruiker voert pad in
|
||||
- klik op `Save` of bestaande settings-save flow
|
||||
- backend valideert
|
||||
- bij succes blijft waarde persistent opgeslagen
|
||||
- bij fout toont de modal een compacte validatiefout
|
||||
|
||||
Aanbevolen foutweergave:
|
||||
- inline fout onder het veld of in de General-tab
|
||||
- voorbeelden:
|
||||
- `Startup path must be a directory`
|
||||
- `Startup path is outside allowed roots`
|
||||
- `Startup path does not exist`
|
||||
|
||||
## 7. Backend-impact
|
||||
Dit past logisch in de bestaande settings-API.
|
||||
|
||||
Aanbevolen uitbreiding:
|
||||
- bestaand `settings` model uitbreiden met:
|
||||
- `preferred_startup_path: string | null`
|
||||
|
||||
Benodigde backendlogica:
|
||||
- read via bestaande `GET /api/settings`
|
||||
- write via bestaande `POST /api/settings`
|
||||
- validatie op write:
|
||||
- directory
|
||||
- bestaand pad
|
||||
- binnen whitelist
|
||||
|
||||
Te hergebruiken validatie:
|
||||
- bestaande `path_guard`
|
||||
- bestaande browse-/directory-validatie helpers waar aanwezig
|
||||
|
||||
Nieuwe endpoint is niet nodig als de bestaande settings-API netjes uitbreidbaar is.
|
||||
|
||||
## 8. Frontend-impact
|
||||
App-initialisatie moet licht aangepast worden:
|
||||
- app leest settings vroeg in startup
|
||||
- als `preferred_startup_path` geldig aanwezig is:
|
||||
- linker paneel start daar
|
||||
- anders:
|
||||
- linker paneel start op `/Volumes`
|
||||
- rechter paneel blijft op `/Volumes`
|
||||
|
||||
Belangrijk voor regressiebehoud:
|
||||
- browse-initialisatie mag niet kapotgaan als settings-call faalt
|
||||
- bij error of lege response moet de app veilig terugvallen op het huidige gedrag
|
||||
|
||||
Aanbevolen init-volgorde:
|
||||
1. laad settings
|
||||
2. bepaal startup path voor links
|
||||
3. initialiseeer panelen
|
||||
4. laad browse data
|
||||
|
||||
## 9. Regressierisico
|
||||
Belangrijkste risico’s:
|
||||
- ongeldige startup path leidt tot lege of foutieve eerste render
|
||||
- conflict tussen oude default `/Volumes` en nieuw voorkeurpad
|
||||
- inconsistent gedrag tussen host-achtige `/Volumes/...` paden en alias-root mapping
|
||||
- settings laden te laat, waardoor panelen eerst op default en daarna opnieuw renderen
|
||||
|
||||
Laag-risico aanpak:
|
||||
- settings eerst laden
|
||||
- startup path alleen op links toepassen
|
||||
- fallback altijd `/Volumes`
|
||||
- write-validatie streng houden
|
||||
- read-validatie tolerant houden met veilige fallback
|
||||
|
||||
## 10. Teststrategie
|
||||
Backend golden tests:
|
||||
- `GET /api/settings` met default `preferred_startup_path = null`
|
||||
- `POST /api/settings` met geldig startup path
|
||||
- write-block voor file path i.p.v. directory
|
||||
- write-block voor traversal / invalid root alias
|
||||
- write-block voor niet-bestaand pad
|
||||
|
||||
UI smoke/regressietests:
|
||||
- Settings > General bevat veld voor preferred startup path
|
||||
- app init leest setting uit backend
|
||||
- geen regressie op huidige `/Volumes` fallback
|
||||
|
||||
Handmatige validatie:
|
||||
- geldig pad opslaan en app herstarten
|
||||
- ongeldig pad opslaan moet geweigerd worden
|
||||
- bestaand opgeslagen pad later verwijderen en app opnieuw starten -> fallback naar `/Volumes`
|
||||
- `/Volumes/...` startup path moet correct resolven en openen
|
||||
|
||||
## 11. Aanbeveling
|
||||
Aanbevolen v1-richting met laag regressierisico:
|
||||
- voeg één setting toe: `preferred_startup_path`
|
||||
- persistent in bestaande SQLite settings-opslag
|
||||
- configureerbaar via tekstveld in `Settings > General`
|
||||
- toepassen op alleen het linkerpaneel
|
||||
- rechterpaneel blijft op `/Volumes`
|
||||
- fallback altijd `/Volumes`
|
||||
|
||||
Dit geeft directe waarde met minimale complexiteit. Het houdt dual-pane gedrag bruikbaar, beperkt regressierisico, en laat later nog ruimte voor een v2 met aparte left/right startup paths als daar echte behoefte aan blijkt.
|
||||
Binary file not shown.
Binary file not shown.
@@ -96,10 +96,14 @@ class FileInfoResponse(BaseModel):
|
||||
|
||||
class SettingsResponse(BaseModel):
|
||||
show_thumbnails: bool
|
||||
preferred_startup_path_left: str | None = None
|
||||
preferred_startup_path_right: str | None = None
|
||||
|
||||
|
||||
class SettingsUpdateRequest(BaseModel):
|
||||
show_thumbnails: bool
|
||||
show_thumbnails: bool | None = None
|
||||
preferred_startup_path_left: str | None = None
|
||||
preferred_startup_path_right: str | None = None
|
||||
|
||||
|
||||
class TaskListItem(BaseModel):
|
||||
|
||||
@@ -111,4 +111,4 @@ async def get_search_service() -> SearchService:
|
||||
|
||||
|
||||
async def get_settings_service() -> SettingsService:
|
||||
return SettingsService(repository=get_settings_repository())
|
||||
return SettingsService(repository=get_settings_repository(), path_guard=get_path_guard())
|
||||
|
||||
Binary file not shown.
@@ -2,22 +2,54 @@ from __future__ import annotations
|
||||
|
||||
from backend.app.api.schemas import SettingsResponse, SettingsUpdateRequest
|
||||
from backend.app.db.settings_repository import SettingsRepository
|
||||
from backend.app.security.path_guard import PathGuard
|
||||
|
||||
|
||||
class SettingsService:
|
||||
def __init__(self, repository: SettingsRepository):
|
||||
def __init__(self, repository: SettingsRepository, path_guard: PathGuard):
|
||||
self._repository = repository
|
||||
self._path_guard = path_guard
|
||||
|
||||
def get_settings(self) -> SettingsResponse:
|
||||
values = self._repository.get_settings()
|
||||
return SettingsResponse(show_thumbnails=self._as_bool(values.get("show_thumbnails"), default=False))
|
||||
preferred_left = self._as_optional_str(values.get("preferred_startup_path_left"))
|
||||
preferred_right = self._as_optional_str(values.get("preferred_startup_path_right"))
|
||||
legacy_preferred = self._as_optional_str(values.get("preferred_startup_path"))
|
||||
return SettingsResponse(
|
||||
show_thumbnails=self._as_bool(values.get("show_thumbnails"), default=False),
|
||||
preferred_startup_path_left=preferred_left or legacy_preferred,
|
||||
preferred_startup_path_right=preferred_right,
|
||||
)
|
||||
|
||||
def update_settings(self, request: SettingsUpdateRequest) -> SettingsResponse:
|
||||
self._repository.set_setting("show_thumbnails", "true" if request.show_thumbnails else "false")
|
||||
if request.show_thumbnails is not None:
|
||||
self._repository.set_setting("show_thumbnails", "true" if request.show_thumbnails else "false")
|
||||
|
||||
if request.preferred_startup_path_left is not None:
|
||||
self._set_directory_setting("preferred_startup_path_left", request.preferred_startup_path_left)
|
||||
|
||||
if request.preferred_startup_path_right is not None:
|
||||
self._set_directory_setting("preferred_startup_path_right", request.preferred_startup_path_right)
|
||||
|
||||
return self.get_settings()
|
||||
|
||||
def _set_directory_setting(self, key: str, value: str) -> None:
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
self._repository.set_setting(key, "")
|
||||
return
|
||||
resolved = self._path_guard.resolve_directory_path(normalized)
|
||||
self._repository.set_setting(key, resolved.relative)
|
||||
|
||||
@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"}
|
||||
|
||||
@staticmethod
|
||||
def _as_optional_str(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized or None
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -13,14 +13,22 @@ 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.security.path_guard import PathGuard
|
||||
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)
|
||||
self.root_path = Path(self.temp_dir.name) / "storage1"
|
||||
self.root_path.mkdir()
|
||||
(self.root_path / "docs").mkdir()
|
||||
(self.root_path / "file.txt").write_text("sample", encoding="utf-8")
|
||||
repository = SettingsRepository(str(Path(self.temp_dir.name) / "settings.db"))
|
||||
service = SettingsService(
|
||||
repository=repository,
|
||||
path_guard=PathGuard({"storage1": str(self.root_path)}),
|
||||
)
|
||||
|
||||
async def _override_settings_service() -> SettingsService:
|
||||
return service
|
||||
@@ -45,14 +53,118 @@ class SettingsApiGoldenTest(unittest.TestCase):
|
||||
response = self._request("GET", "/api/settings")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"show_thumbnails": False})
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"show_thumbnails": False,
|
||||
"preferred_startup_path_left": None,
|
||||
"preferred_startup_path_right": None,
|
||||
},
|
||||
)
|
||||
|
||||
def test_settings_update_persistence(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"show_thumbnails": True})
|
||||
def test_settings_legacy_single_path_is_used_only_for_left_fallback(self) -> None:
|
||||
repository = SettingsRepository(str(Path(self.temp_dir.name) / "settings.db"))
|
||||
repository.set_setting("preferred_startup_path", "storage1/docs")
|
||||
|
||||
response = self._request("GET", "/api/settings")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"show_thumbnails": True})
|
||||
self.assertEqual(self._request("GET", "/api/settings").json(), {"show_thumbnails": True})
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"show_thumbnails": False,
|
||||
"preferred_startup_path_left": "storage1/docs",
|
||||
"preferred_startup_path_right": None,
|
||||
},
|
||||
)
|
||||
|
||||
def test_settings_update_persistence_left_and_right(self) -> None:
|
||||
response = self._request(
|
||||
"POST",
|
||||
"/api/settings",
|
||||
{
|
||||
"show_thumbnails": True,
|
||||
"preferred_startup_path_left": "storage1/docs",
|
||||
"preferred_startup_path_right": "storage1/docs",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(),
|
||||
{
|
||||
"show_thumbnails": True,
|
||||
"preferred_startup_path_left": "storage1/docs",
|
||||
"preferred_startup_path_right": "storage1/docs",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
self._request("GET", "/api/settings").json(),
|
||||
{
|
||||
"show_thumbnails": True,
|
||||
"preferred_startup_path_left": "storage1/docs",
|
||||
"preferred_startup_path_right": "storage1/docs",
|
||||
},
|
||||
)
|
||||
|
||||
def test_settings_preferred_startup_path_left_persistence(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_left": "storage1/docs"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["preferred_startup_path_left"], "storage1/docs")
|
||||
self.assertEqual(response.json()["preferred_startup_path_right"], None)
|
||||
|
||||
def test_settings_preferred_startup_path_right_persistence(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "storage1/docs"})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["preferred_startup_path_left"], None)
|
||||
self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs")
|
||||
|
||||
def test_settings_preferred_startup_path_empty_string_resets_only_left_to_null(self) -> None:
|
||||
self._request(
|
||||
"POST",
|
||||
"/api/settings",
|
||||
{
|
||||
"preferred_startup_path_left": "storage1/docs",
|
||||
"preferred_startup_path_right": "storage1/docs",
|
||||
},
|
||||
)
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_left": " "})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["preferred_startup_path_left"], None)
|
||||
self.assertEqual(response.json()["preferred_startup_path_right"], "storage1/docs")
|
||||
|
||||
def test_settings_preferred_startup_path_left_rejects_file_path(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_left": "storage1/file.txt"})
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_type_conflict")
|
||||
|
||||
def test_settings_preferred_startup_path_right_rejects_file_path(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "storage1/file.txt"})
|
||||
|
||||
self.assertEqual(response.status_code, 409)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_type_conflict")
|
||||
|
||||
def test_settings_preferred_startup_path_left_rejects_traversal(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_left": "storage1/../etc"})
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_traversal_detected")
|
||||
|
||||
def test_settings_preferred_startup_path_right_rejects_invalid_root_alias(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_right": "unknown/docs"})
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual(response.json()["error"]["code"], "invalid_root_alias")
|
||||
|
||||
def test_settings_preferred_startup_path_left_rejects_missing_directory(self) -> None:
|
||||
response = self._request("POST", "/api/settings", {"preferred_startup_path_left": "storage1/missing"})
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.json()["error"]["code"], "path_not_found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -65,6 +65,11 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('id="settings-logs-tab"', body)
|
||||
self.assertIn('id="settings-show-thumbnails"', body)
|
||||
self.assertIn("Show thumbnails", body)
|
||||
self.assertIn('id="settings-startup-path-left"', body)
|
||||
self.assertIn('id="settings-startup-path-right"', body)
|
||||
self.assertIn("Preferred startup path (left)", body)
|
||||
self.assertIn("Preferred startup path (right)", body)
|
||||
self.assertIn('id="settings-general-save-btn"', body)
|
||||
self.assertIn('id="settings-logs-list"', body)
|
||||
self.assertIn('id="viewer-content"', body)
|
||||
self.assertIn('id="editor-modal"', body)
|
||||
@@ -118,6 +123,13 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
||||
self.assertIn('async function loadSettings()', app_js)
|
||||
self.assertIn('await loadSettings();', app_js)
|
||||
self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js)
|
||||
self.assertIn('settings.generalSaveButton.onclick = handlePreferredStartupPathSave;', app_js)
|
||||
self.assertIn('preferredStartupPathLeft', app_js)
|
||||
self.assertIn('preferredStartupPathRight', app_js)
|
||||
self.assertIn('preferred_startup_path_left', app_js)
|
||||
self.assertIn('preferred_startup_path_right', app_js)
|
||||
self.assertIn('paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";', app_js)
|
||||
self.assertIn('paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";', app_js)
|
||||
self.assertIn('"/api/settings"', app_js)
|
||||
self.assertIn('`/api/files/thumbnail?', app_js)
|
||||
self.assertIn('function createMediaSlot(entry)', app_js)
|
||||
|
||||
+55
-1
@@ -48,6 +48,8 @@ let settingsState = {
|
||||
activeTab: "general",
|
||||
logsLoaded: false,
|
||||
showThumbnails: false,
|
||||
preferredStartupPathLeft: null,
|
||||
preferredStartupPathRight: null,
|
||||
};
|
||||
let searchState = {
|
||||
pane: "left",
|
||||
@@ -202,6 +204,10 @@ function settingsElements() {
|
||||
logsTab: document.getElementById("settings-logs-tab"),
|
||||
generalPanel: document.getElementById("settings-general-panel"),
|
||||
showThumbnailsInput: document.getElementById("settings-show-thumbnails"),
|
||||
startupPathLeftInput: document.getElementById("settings-startup-path-left"),
|
||||
startupPathRightInput: document.getElementById("settings-startup-path-right"),
|
||||
generalError: document.getElementById("settings-general-error"),
|
||||
generalSaveButton: document.getElementById("settings-general-save-btn"),
|
||||
logsPanel: document.getElementById("settings-logs-panel"),
|
||||
logsList: document.getElementById("settings-logs-list"),
|
||||
logsError: document.getElementById("settings-logs-error"),
|
||||
@@ -394,21 +400,38 @@ function createMediaSlot(entry) {
|
||||
async function loadSettings() {
|
||||
const data = await apiRequest("GET", "/api/settings");
|
||||
settingsState.showThumbnails = !!data.show_thumbnails;
|
||||
settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null;
|
||||
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
|
||||
const elements = settingsElements();
|
||||
if (elements.showThumbnailsInput) {
|
||||
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
||||
}
|
||||
if (elements.startupPathLeftInput) {
|
||||
elements.startupPathLeftInput.value = settingsState.preferredStartupPathLeft || "";
|
||||
}
|
||||
if (elements.startupPathRightInput) {
|
||||
elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || "";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings(update) {
|
||||
const data = await apiRequest("POST", "/api/settings", update);
|
||||
settingsState.showThumbnails = !!data.show_thumbnails;
|
||||
settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null;
|
||||
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null;
|
||||
const elements = settingsElements();
|
||||
if (elements.showThumbnailsInput) {
|
||||
elements.showThumbnailsInput.checked = settingsState.showThumbnails;
|
||||
}
|
||||
if (elements.startupPathLeftInput) {
|
||||
elements.startupPathLeftInput.value = settingsState.preferredStartupPathLeft || "";
|
||||
}
|
||||
if (elements.startupPathRightInput) {
|
||||
elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || "";
|
||||
}
|
||||
renderPaneItems("left");
|
||||
renderPaneItems("right");
|
||||
return data;
|
||||
}
|
||||
|
||||
function updateActionButtons() {
|
||||
@@ -1679,6 +1702,22 @@ async function handleShowThumbnailsChange(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreferredStartupPathSave() {
|
||||
const settings = settingsElements();
|
||||
const leftValue = settings.startupPathLeftInput ? settings.startupPathLeftInput.value : "";
|
||||
const rightValue = settings.startupPathRightInput ? settings.startupPathRightInput.value : "";
|
||||
settings.generalError.textContent = "";
|
||||
try {
|
||||
await saveSettings({
|
||||
preferred_startup_path_left: leftValue,
|
||||
preferred_startup_path_right: rightValue,
|
||||
});
|
||||
setStatus("Preferred startup paths saved");
|
||||
} catch (err) {
|
||||
settings.generalError.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
settingsElements().overlay.classList.add("hidden");
|
||||
}
|
||||
@@ -1686,6 +1725,7 @@ function closeSettings() {
|
||||
async function openSettings(tab = "general") {
|
||||
const elements = settingsElements();
|
||||
elements.overlay.classList.remove("hidden");
|
||||
elements.generalError.textContent = "";
|
||||
setSettingsTab(tab);
|
||||
if (settingsState.activeTab === "logs") {
|
||||
await loadHistoryForSettings();
|
||||
@@ -2147,6 +2187,7 @@ function setupEvents() {
|
||||
await loadHistoryForSettings();
|
||||
};
|
||||
settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;
|
||||
settings.generalSaveButton.onclick = handlePreferredStartupPathSave;
|
||||
settings.overlay.onclick = (event) => {
|
||||
if (event.target === settings.overlay) {
|
||||
closeSettings();
|
||||
@@ -2255,7 +2296,20 @@ async function init() {
|
||||
setActivePane("left");
|
||||
setupEvents();
|
||||
await loadSettings();
|
||||
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
||||
paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes";
|
||||
paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes";
|
||||
await loadBrowsePane("left");
|
||||
if (paneState("left").currentPath !== "/Volumes" && document.getElementById("left-browse-error").textContent) {
|
||||
setError("left-browse-error", "");
|
||||
paneState("left").currentPath = "/Volumes";
|
||||
await loadBrowsePane("left");
|
||||
}
|
||||
await loadBrowsePane("right");
|
||||
if (paneState("right").currentPath !== "/Volumes" && document.getElementById("right-browse-error").textContent) {
|
||||
setError("right-browse-error", "");
|
||||
paneState("right").currentPath = "/Volumes";
|
||||
await loadBrowsePane("right");
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
@@ -95,6 +95,18 @@
|
||||
<span>Show thumbnails</span>
|
||||
</label>
|
||||
<div class="popup-meta">Image thumbnails are available for jpg, jpeg, png and webp files.</div>
|
||||
<label class="settings-field" for="settings-startup-path-left">
|
||||
<span>Preferred startup path (left)</span>
|
||||
<input id="settings-startup-path-left" type="text" autocomplete="off" placeholder="/Volumes or storage1/path">
|
||||
</label>
|
||||
<label class="settings-field" for="settings-startup-path-right">
|
||||
<span>Preferred startup path (right)</span>
|
||||
<input id="settings-startup-path-right" type="text" autocomplete="off" placeholder="/Volumes or storage1/path">
|
||||
</label>
|
||||
<div id="settings-general-error" class="error"></div>
|
||||
<div class="settings-actions">
|
||||
<button id="settings-general-save-btn" type="button">Save</button>
|
||||
</div>
|
||||
</section>
|
||||
<section id="settings-logs-panel" class="settings-panel hidden" role="tabpanel" aria-labelledby="settings-logs-tab">
|
||||
<div id="settings-logs-error" class="error"></div>
|
||||
|
||||
@@ -417,6 +417,21 @@ button:disabled {
|
||||
margin: 10px 0 8px;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.settings-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.list li.is-selected {
|
||||
background: var(--color-selection-bg);
|
||||
box-shadow: inset 0 0 0 1px var(--color-selection-border);
|
||||
|
||||
Reference in New Issue
Block a user