diff --git a/project_docs/UI_F6_RENAME_MOVE_DESIGN.md b/project_docs/UI_F6_RENAME_MOVE_DESIGN.md new file mode 100644 index 0000000..c7f3dd7 --- /dev/null +++ b/project_docs/UI_F6_RENAME_MOVE_DESIGN.md @@ -0,0 +1,259 @@ +# UI_F6_RENAME_MOVE_DESIGN.md + +## 1. Doel + +Deze stap herontwerpt `F6` naar een gecombineerde `Rename/Move` actie in Midnight Commander-stijl. + +Doel: +- `F6` wordt de primaire actie voor zowel hernoemen als verplaatsen +- de gebruiker werkt vanuit één compacte flow in plaats van aparte shortcuts +- een losse `Rename` shortcut zoals `Alt+R` is daarna niet meer gewenst + +Uitgangspunt: +- de functiebalk kan `Rename` visueel nog blijven tonen als aparte knop, maar de keyboardflow voor `F6` wordt leidend voor gecombineerde rename/move-semantiek +- deze ontwerpstap verandert nog niets aan backendcontracten + +--- + +## 2. Popupgedrag + +Bij `F6` opent de UI een compacte popup. + +Popup-eisen: +- één invoerveld +- één contextregel met bronbestand/-map +- één invoerveld met voorgesteld doelpad +- compacte actieknoppen: `OK` en `Cancel` zijn optioneel, maar `Enter` en `Escape` zijn leidend + +Keyboardgedrag: +- `Escape` sluit popup zonder actie +- `Enter` voert de actie uit + +Semantiek: +- popup is niet alleen een naamveld, maar een doelpadveld +- gebruiker kan dus zowel alleen de naam aanpassen als een volledig ander doelpad kiezen + +--- + +## 3. Defaultwaarde in het invoerveld + +De standaardwaarde in het invoerveld wordt: + +- `current path` van het **andere paneel** +- plus de huidige naam van het geselecteerde bestand of de geselecteerde map + +Voorbeeld: +- actief paneel: `left` +- geselecteerd item: `storage1/docs/report.txt` +- inactief paneel current path: `storage2/archive` +- default invoerveld: + - `storage2/archive/report.txt` + +Motivatie: +- dit past bij klassieke dual-pane file-managerverwachting: `F6` suggereert standaard verplaatsen naar de andere kant +- dezelfde popup blijft bruikbaar voor pure rename door het doelpad handmatig terug te brengen naar dezelfde parent met een andere naam + +Belangrijk: +- de default is altijd een **volledig doelpad** +- geen impliciete "move into current dir" semantiek buiten wat in het tekstveld staat + +--- + +## 4. Beslislogica + +De UI bepaalt op basis van bronpad en ingevoerd doelpad of de actie neerkomt op `rename` of `move`. + +### Regel 1: zelfde parent, andere naam = rename + +Als: +- bron en doel in dezelfde parent-directory liggen +- en alleen de naam verschilt + +dan gebruikt de UI het bestaande `rename` endpoint. + +Voorbeeld: +- bron: `storage1/docs/report.txt` +- doel: `storage1/docs/report-final.txt` +- resultaat: `rename` + +### Regel 2: ander pad of andere parent = move + +Als: +- de doel-parent verschilt van de bron-parent +- of de doel-root/paneelcontext anders is + +dan gebruikt de UI het bestaande `move` endpoint. + +Voorbeeld: +- bron: `storage1/docs/report.txt` +- doel: `storage2/archive/report.txt` +- resultaat: `move` + +### Regel 3: ongewijzigde waarde = move naar andere paneel-locatie + +Omdat de defaultwaarde standaard naar het andere paneel wijst, betekent ongewijzigd bevestigen normaal gesproken: +- `move` naar het current path van het andere paneel met dezelfde naam + +Voorbeeld: +- bron: `storage1/docs/report.txt` +- default doel: `storage2/archive/report.txt` +- gebruiker drukt direct `Enter` +- resultaat: `move` + +### Regel 4: exact gelijk aan bronpad = no-op + +Als de gebruiker het invoerveld wijzigt naar exact hetzelfde pad als de bron: +- er wordt geen rename of move gestart +- de popup sluit niet automatisch met een schijnactie +- voorkeur v1: compacte validatiemelding zoals `Destination must differ from source` + +Dit voorkomt zinloze requests. + +--- + +## 5. Relatie met huidige backend + +### Rename endpoint + +Te gebruiken als de UI beslist op `rename`: +- `POST /api/files/rename` + +Mapping: +- request gebruikt bestaand model: + - `path = source` + - `new_name = basename(destination)` + +Belangrijke beperking: +- bestaand rename-contract werkt alleen binnen dezelfde parent-directory +- de UI moet dat contract respecteren en alleen in die situatie `rename` gebruiken + +### Move endpoint + +Te gebruiken als de UI beslist op `move`: +- `POST /api/files/move` + +Mapping: +- request gebruikt bestaand model: + - `source` + - `destination` als volledig doelpad + +### File versus directory + +Huidige backend-scope blijft leidend: +- `rename` ondersteunt bestaande rename-semantiek op file/directory zoals nu aanwezig +- `move` is momenteel file-only + +Gevolg voor gecombineerde F6-flow: +- file + ander pad -> `move` toegestaan +- file + zelfde parent andere naam -> `rename` toegestaan +- directory + zelfde parent andere naam -> `rename` toegestaan +- directory + ander pad -> niet toegestaan zolang backend directory-move niet ondersteunt + +Voor directory-case buiten scope: +- de popup mag wel openen +- maar bevestigen moet blokkeren met duidelijke melding, bijvoorbeeld: + - `Directory move is not supported in v1` + +### Huidige scopebeperkingen blijven gelden + +Dus expliciet: +- geen directory move +- geen batch rename/move via deze popup in v1 +- geen backend-uitbreiding om F6 slimmer te maken +- alle padvalidatie en foutafhandeling blijven backendgedreven + +--- + +## 6. Focus en UX + +Popup-eisen: +- compact en centraal +- niet schermvullend +- focus direct in het invoerveld +- volledige doelpadtekst selecteerbaar en bewerkbaar + +Keyboardgedrag: +- `Enter` = bevestigen +- `Escape` = annuleren + +Interactie-eis: +- terwijl de popup open is, mag paneelkeyboardnavigatie niet interfereren +- bestaande shortcuts voor paneelnavigatie en functiebalkacties moeten tijdelijk uitgeschakeld zijn, behalve popup-eigen `Enter`/`Escape` + +Feedback: +- validatiefouten compact in de popup tonen +- backendfouten terugkoppelen zonder de popup-context te verliezen als de actie faalt + +--- + +## 7. Scopebeperking + +Niet in deze stap: +- geen implementatie +- geen backendwijzigingen +- geen nieuwe dependencies +- geen directory move ondersteuning +- geen multi-select rename/move popup +- geen extra path picker of browse-in-dialog + +Deze ontwerpstap beperkt zich dus tot de UI-semantiek van één gecombineerde `F6` flow. + +--- + +## 8. Impactanalyse + +Waarschijnlijk te wijzigen frontendbestanden bij implementatie: +- `webui/html/app.js` +- `webui/html/index.html` +- `webui/html/style.css` +- `webui/backend/tests/golden/test_ui_smoke_golden.py` + +### Verwachte aanpassingen + +`app.js`: +- nieuwe popup-state voor F6 rename/move +- beslislogica `rename` versus `move` +- verwijdering of aanpassing van losse `Alt+R` keyboardbinding +- hergebruik van bestaande rename- en move-action handlers waar mogelijk + +`index.html`: +- compacte popup-markup met invoerveld en foutregel + +`style.css`: +- compacte popup-styling, aansluitend op bestaande wildcard/view/edit modals + +### Regressierisico + +Belangrijkste risico's: +- verwarring tussen bestaande losse `Rename` knop en nieuwe F6-semantiek +- directorycases die per ongeluk op `move` uitkomen terwijl backend dat niet ondersteunt +- dubbele logica tussen functiebalk-`Rename`, functiebalk-`Move` en F6-popup +- keyboardconflict met bestaande `F6 = Move` shortcut uit action-shortcuts v1 + +Mitigatie: +- één centrale beslisfunctie voor `rename` versus `move` +- `Alt+R` verwijderen zodra F6-flow geïmplementeerd wordt +- bestaande knophandlers alleen hergebruiken waar de semantiek echt gelijk is; anders kleine centrale wrapperfunctie introduceren + +--- + +## 9. Teststrategie + +### Smoke/regressietests + +Bij implementatie aan te passen: +- UI smoke test controleert aanwezigheid van F6 popup-container +- controle op relevant inputveld en basiscontrols +- bestaande functiebalk- en modalchecks blijven bestaan + +### Handmatige validatie + +Essentieel: +- `F6` opent popup met defaultwaarde gebaseerd op ander paneel + huidige naam +- `Enter` met default leidt tot `move` +- wijziging naar zelfde parent + andere naam leidt tot `rename` +- directory + cross-path wordt netjes geblokkeerd +- `Escape` sluit popup zonder bijeffecten +- paneelkeyboardnavigatie werkt niet door popup heen +- bestaande `Move` knop blijft werken +- bestaande `Rename` knop blijft werken totdat eventuele latere UI-consolidatie expliciet wordt doorgevoerd diff --git a/project_docs/UI_VOLUMES_DIRECTORY_VIEW_V1.md b/project_docs/UI_VOLUMES_DIRECTORY_VIEW_V1.md new file mode 100644 index 0000000..b0cf077 --- /dev/null +++ b/project_docs/UI_VOLUMES_DIRECTORY_VIEW_V1.md @@ -0,0 +1,151 @@ +# UI_VOLUMES_DIRECTORY_VIEW_V1 + +## 1. Doel + +Doel van deze stap is om de webui een host-achtige navigatiestructuur te geven waarbij de gebruiker eerst `/Volumes` ziet en daarna daarbinnen de beschikbare mounts kan openen, zoals: + +- `/Volumes/8TB` +- `/Volumes/8TB_RAID1` + +Waarom dit gewenst is: +- het sluit beter aan op de werkelijke host- en containerstructuur +- het voorkomt dat technische aliasnamen zoals `storage1` en `storage2` het primaire navigatiemodel bepalen +- het maakt de UI begrijpelijker voor gebruikers die denken in echte mountpunten en directories, niet in app-specifieke labels + +--- + +## 2. Gewenste UI-weergave + +Gewenst gedrag in beide panelen: +- een paneel kan op `/Volumes` staan als huidige directoryweergave +- in die weergave ziet de gebruiker de toegestane submappen als normale directory entries +- voor deze case moeten daar minimaal zichtbaar zijn: + - `8TB` + - `8TB_RAID1` + +Navigatieflow: +- gebruiker opent of kiest `/Volumes` +- de lijst toont `8TB` en `8TB_RAID1` als directories +- klikken of `Enter` op `8TB` opent `/Volumes/8TB` +- klikken of `Enter` op `8TB_RAID1` opent `/Volumes/8TB_RAID1` + +Voor dual-pane gedrag: +- beide panelen moeten onafhankelijk op `/Volumes` of op een onderliggende mount kunnen staan +- er is geen speciaal verschillend gedrag nodig tussen links en rechts +- breadcrumbs moeten `/Volumes` en daarna de mountnaam tonen + +--- + +## 3. Relatie met huidige whitelist/root-configuratie + +Huidige situatie: +- de backend werkt met expliciete toegestane roots via aliases +- defaults zijn nu: + - `storage1 -> /Volumes/8TB` + - `storage2 -> /Volumes/8TB_RAID1` + +Belangrijk verschil: +- de huidige whitelist geeft alleen directe toegang tot specifieke roots +- `/Volumes` zelf is op dit moment conceptueel geen browsebare root in het bestaande model + +Voor het gewenste gedrag is een extra browsebaar niveau nodig: +- niet als volledig vrije root over het hele filesystem +- maar als gecontroleerde containerdirectory die alleen als bovenliggende presentatie-laag dient voor de whitelisted mounts + +Cruciale eis: +- als `/Volumes` zichtbaar wordt, mag niet automatisch alle andere inhoud van `/Volumes` browsebaar worden +- alleen de expliciet toegestane mounts onder `/Volumes` mogen zichtbaar zijn + +--- + +## 4. Veiligheidsmodel + +Aanbevolen veiligheidsmodel: +- `/Volumes` wordt niet behandeld als een normale vrije root +- `/Volumes` wordt behandeld als een virtuele of gecontroleerde container-directoryweergave boven de bestaande whitelisted roots + +Veilige semantiek: +- de UI/backend toont in `/Volumes` alleen de mountnamen die corresponderen met toegestane roots +- voor deze case dus alleen: + - `8TB` + - `8TB_RAID1` +- andere directories onder de echte `/Volumes` mogen niet automatisch zichtbaar worden + +Concreet: +- browse van `/Volumes` retourneert een samengestelde directorylisting op basis van toegestane roots +- navigatie naar `/Volumes/` is alleen geldig als die volledige path overeenkomt met een geconfigureerde root of daarbinnen valt + +Passend bij bestaand model: +- alle verdere padresolutie onder `/Volumes/8TB/...` en `/Volumes/8TB_RAID1/...` blijft via bestaand `path_guard` +- traversal en whitelistcontrole blijven dus centraal gehandhaafd + +--- + +## 5. Backend-impact + +Dit kan niet netjes alleen frontend-side worden opgelost. + +Waarom niet frontend-only: +- de bestaande browse-API verwacht een pad dat door de backend gevalideerd en opgelijst wordt +- als de backend `/Volumes` niet kent als geldige browsecontext, kan de frontend die laag niet betrouwbaar simuleren zonder speciale hardcoded clientlogica +- frontend-only zou ook de securitygrenzen vertroebelen, omdat de UI dan zelf een deel van de directorystructuur zou moeten faken + +Backend-aanpassing is dus nodig. + +Veiligste en simpelste richting: +- een kleine backend-uitbreiding in de browse-service/path-interpretatie +- introduceer een gecontroleerd browse-niveau voor `/Volumes` +- behandel dat niveau als speciale, beperkte listing van geconfigureerde roots +- behoud voor alle onderliggende operaties het bestaande whitelist/path_guard-model + +Pragmatische v1-richting: +- voeg een expliciete conceptuele container-root toe, bijvoorbeeld browsepad `/Volumes` +- browse op `/Volumes` retourneert alleen directory-entries voor de toegestane mount-roots +- browse op `/Volumes/` mapt naar de bestaande geconfigureerde root + +Dat is veiliger dan `/Volumes` volledig als nieuwe whitelist-root toevoegen. + +--- + +## 6. Risico's + +### Regressierisico +- browse-contract moet duidelijk blijven voor bestaande paden zoals `storage1/...` +- bestaande UI- en golden-tests zijn nu alias-gebaseerd; die mogen niet onbedoeld breken +- copy/move/rename/delete/bookmarks werken nu op bestaande padrepresentaties; migratie naar `/Volumes/...` moet doordacht gebeuren + +### Securitygevolgen +- een onzorgvuldige implementatie zou per ongeluk meer van `/Volumes` kunnen tonen dan toegestaan +- een onzorgvuldige mapping van `/Volumes/` naar echte paden kan whitelistcontroles verzwakken + +### UX-verwarring +- tijdelijke co-existentie van aliaspaden (`storage1/...`) en hostachtige paden (`/Volumes/8TB/...`) kan verwarrend zijn +- zonder heldere keuze ontstaat een hybride model dat lastig te begrijpen en te testen is + +--- + +## 7. Aanbeveling + +Aanbevolen implementatierichting voor v1: + +1. Niet frontend-only doen. +2. Geen vrije browse-root van heel `/Volumes` toevoegen. +3. Wel een gecontroleerde backend-browseweergave voor `/Volumes` introduceren. +4. Laat die weergave alleen expliciet geconfigureerde mountdirectories tonen. +5. Houd alle echte padvalidatie en verdere navigatie onder die mounts via bestaand `path_guard`-model. + +Concreet aanbevolen model: +- `/Volumes` wordt een speciale browse-entrypoint +- listing van `/Volumes` bevat alleen de whitelisted mountnamen +- `/Volumes/8TB/...` en `/Volumes/8TB_RAID1/...` worden daarna normaal browsebaar binnen de bestaande veiligheidsgrenzen + +Waarom dit de beste v1-richting is: +- sluit aan op de echte hoststructuur +- behoudt securitycontrole centraal in backend +- vermijdt frontend-hardcoding als primaire oplossing +- is beperkt, uitlegbaar en testbaar + +Niet aanbevolen voor v1: +- puur frontend-aliasing +- volledig openstellen van `/Volumes` als generieke root +- tegelijk zowel aliasmodel als hostpathmodel als primaire UX blijven promoten zonder expliciete migratiekeuze diff --git a/webui/backend/app/security/__pycache__/path_guard.cpython-313.pyc b/webui/backend/app/security/__pycache__/path_guard.cpython-313.pyc index dade0d9..8a9dc70 100644 Binary files a/webui/backend/app/security/__pycache__/path_guard.cpython-313.pyc and b/webui/backend/app/security/__pycache__/path_guard.cpython-313.pyc differ diff --git a/webui/backend/app/security/path_guard.py b/webui/backend/app/security/path_guard.py index 78fd6c8..fd7eb63 100644 --- a/webui/backend/app/security/path_guard.py +++ b/webui/backend/app/security/path_guard.py @@ -11,14 +11,34 @@ class ResolvedPath: alias: str relative: str absolute: Path + display_style: str class PathGuard: def __init__(self, root_aliases: dict[str, str]): normalized: dict[str, Path] = {} + volume_roots_candidates: dict[str, list[tuple[str, Path]]] = {} for alias, root in root_aliases.items(): - normalized[alias] = Path(root).resolve() + resolved_root = Path(root).resolve() + normalized[alias] = resolved_root + volume_name = resolved_root.name + volume_roots_candidates.setdefault(volume_name, []).append((alias, resolved_root)) self._roots = normalized + self._volume_roots = { + name: entries[0] + for name, entries in volume_roots_candidates.items() + if len(entries) == 1 + } + + def is_virtual_volumes_path(self, input_path: str) -> bool: + normalized_input = (input_path or "").strip() + return normalized_input == "/Volumes" + + def virtual_volumes_entries(self) -> list[dict[str, str]]: + return [ + {"name": name, "path": f"/Volumes/{name}"} + for name in sorted(self._volume_roots.keys(), key=str.lower) + ] def resolve_directory_path(self, input_path: str) -> ResolvedPath: resolved = self.resolve_path(input_path) @@ -50,7 +70,7 @@ class PathGuard: return resolved def resolve_path(self, input_path: str) -> ResolvedPath: - alias, rel_segments, candidate = self.resolve_lexical_path(input_path) + alias, rel_segments, candidate, display_style = self.resolve_lexical_path(input_path) root = self._roots[alias] # Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps @@ -66,12 +86,14 @@ class PathGuard: return ResolvedPath( alias=alias, - relative=self._format_relative(alias, rel_segments), + relative=self._format_relative(alias, rel_segments, display_style), absolute=resolved_candidate, + display_style=display_style, ) - def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path]: - normalized_input = (input_path or "").strip().strip("/") + def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path, str]: + raw_input = (input_path or "").strip() + normalized_input = raw_input.strip("/") if not normalized_input: raise AppError( code="invalid_request", @@ -80,16 +102,28 @@ class PathGuard: ) segments = [seg for seg in normalized_input.split("/") if seg] - alias = segments[0] if segments else "" - if alias not in self._roots: - raise AppError( - code="invalid_root_alias", - message="Unknown root alias", - status_code=403, - details={"path": input_path}, - ) + display_style = "alias" + alias = "" + rel_segments: list[str] = [] + root: Path + + if len(segments) >= 2 and segments[0] == "Volumes" and segments[1] in self._volume_roots: + display_style = "virtual_volumes" + volume_name = segments[1] + alias, root = self._volume_roots[volume_name] + rel_segments = segments[2:] + else: + alias = segments[0] if segments else "" + if alias not in self._roots: + raise AppError( + code="invalid_root_alias", + message="Unknown root alias", + status_code=403, + details={"path": input_path}, + ) + root = self._roots[alias] + rel_segments = segments[1:] - rel_segments = segments[1:] if any(seg == ".." for seg in rel_segments): raise AppError( code="path_traversal_detected", @@ -98,9 +132,8 @@ class PathGuard: details={"path": input_path}, ) - root = self._roots[alias] candidate = root.joinpath(*rel_segments) - return alias, rel_segments, candidate + return alias, rel_segments, candidate, display_style def validate_name(self, name: str, field: str) -> str: normalized = (name or "").strip() @@ -113,7 +146,7 @@ class PathGuard: ) return normalized - def entry_relative_path(self, alias: str, absolute: Path) -> str: + def entry_relative_path(self, alias: str, absolute: Path, display_style: str = "alias") -> str: root = self._roots[alias] resolved_absolute = absolute.resolve(strict=False) if not self._is_under_root(resolved_absolute, root): @@ -124,7 +157,7 @@ class PathGuard: details={"path": f"{alias}"}, ) rel = resolved_absolute.relative_to(root).as_posix() - return self._format_relative(alias, [p for p in rel.split("/") if p]) + return self._format_relative(alias, [p for p in rel.split("/") if p], display_style) @staticmethod def _is_under_root(path: Path, root: Path) -> bool: @@ -134,6 +167,9 @@ class PathGuard: except ValueError: return False - @staticmethod - def _format_relative(alias: str, rel_segments: list[str]) -> str: + def _format_relative(self, alias: str, rel_segments: list[str], display_style: str = "alias") -> str: + if display_style == "virtual_volumes": + root = self._roots[alias] + prefix = f"/Volumes/{root.name}" + return prefix if not rel_segments else f"{prefix}/{'/'.join(rel_segments)}" return alias if not rel_segments else f"{alias}/{'/'.join(rel_segments)}" diff --git a/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc index 086f8d3..69bc2e8 100644 Binary files a/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc index 1a0d587..2150f43 100644 Binary files a/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc differ 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 f1f9a05..dc589c3 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__/move_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc index eb9e9d2..dec6bc1 100644 Binary files a/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/move_task_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/browse_service.py b/webui/backend/app/services/browse_service.py index c379f52..e516208 100644 --- a/webui/backend/app/services/browse_service.py +++ b/webui/backend/app/services/browse_service.py @@ -11,13 +11,22 @@ class BrowseService: self._filesystem = filesystem def browse(self, path: str, show_hidden: bool) -> BrowseResponse: + if self._path_guard.is_virtual_volumes_path(path): + directories = [ + DirectoryEntry(name=item["name"], path=item["path"], modified="") + for item in self._path_guard.virtual_volumes_entries() + ] + return BrowseResponse(path="/Volumes", directories=directories, files=[]) + resolved = self._path_guard.resolve_directory_path(path) directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden) directories = [ DirectoryEntry( name=item["name"], - path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]), + path=self._path_guard.entry_relative_path( + resolved.alias, item["absolute"], display_style=resolved.display_style + ), modified=item["modified"], ) for item in directories_raw @@ -26,7 +35,9 @@ class BrowseService: files = [ FileEntry( name=item["name"], - path=self._path_guard.entry_relative_path(resolved.alias, item["absolute"]), + path=self._path_guard.entry_relative_path( + resolved.alias, item["absolute"], display_style=resolved.display_style + ), size=item["size"], modified=item["modified"], ) diff --git a/webui/backend/app/services/copy_task_service.py b/webui/backend/app/services/copy_task_service.py index e1b00d7..7a514b0 100644 --- a/webui/backend/app/services/copy_task_service.py +++ b/webui/backend/app/services/copy_task_service.py @@ -17,7 +17,7 @@ class CopyTaskService: def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse: resolved_source = self._path_guard.resolve_existing_path(source) - _, _, lexical_source = self._path_guard.resolve_lexical_path(source) + _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) if lexical_source.is_symlink(): raise AppError( code="type_conflict", @@ -36,7 +36,11 @@ class CopyTaskService: resolved_destination = self._path_guard.resolve_path(destination) destination_parent = resolved_destination.absolute.parent - parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent) + parent_relative = self._path_guard.entry_relative_path( + resolved_destination.alias, + destination_parent, + display_style=resolved_destination.display_style, + ) self._map_directory_validation(parent_relative) if resolved_destination.absolute.exists(): diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 67eaedd..7fe07cd 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -68,7 +68,11 @@ class FileOpsService: resolved_source = self._path_guard.resolve_existing_path(path) safe_name = self._path_guard.validate_name(new_name, field="new_name") - parent_relative = self._path_guard.entry_relative_path(resolved_source.alias, resolved_source.absolute.parent) + parent_relative = self._path_guard.entry_relative_path( + resolved_source.alias, + resolved_source.absolute.parent, + display_style=resolved_source.display_style, + ) target_relative = self._join_relative(parent_relative, safe_name) resolved_target = self._path_guard.resolve_path(target_relative) diff --git a/webui/backend/app/services/move_task_service.py b/webui/backend/app/services/move_task_service.py index 9d072ec..1bf30b9 100644 --- a/webui/backend/app/services/move_task_service.py +++ b/webui/backend/app/services/move_task_service.py @@ -15,7 +15,7 @@ class MoveTaskService: def create_move_task(self, source: str, destination: str) -> TaskCreateResponse: resolved_source = self._path_guard.resolve_existing_path(source) - _, _, lexical_source = self._path_guard.resolve_lexical_path(source) + _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) if lexical_source.is_symlink(): raise AppError( @@ -34,7 +34,11 @@ class MoveTaskService: resolved_destination = self._path_guard.resolve_path(destination) destination_parent = resolved_destination.absolute.parent - parent_relative = self._path_guard.entry_relative_path(resolved_destination.alias, destination_parent) + parent_relative = self._path_guard.entry_relative_path( + resolved_destination.alias, + destination_parent, + display_style=resolved_destination.display_style, + ) self._map_directory_validation(parent_relative) if resolved_destination.absolute.exists(): diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 675cd66..83e172a 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_browse_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc index 4c56257..78bdec6 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_api_browse_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 c67e555..b6847a3 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_browse_golden.py b/webui/backend/tests/golden/test_api_browse_golden.py index b06886f..03d2fbb 100644 --- a/webui/backend/tests/golden/test_api_browse_golden.py +++ b/webui/backend/tests/golden/test_api_browse_golden.py @@ -21,13 +21,21 @@ from backend.app.services.browse_service import BrowseService class BrowseApiGoldenTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() - self.root = Path(self.temp_dir.name) / "root" + self.volumes_root = Path(self.temp_dir.name) / "Volumes" + self.volumes_root.mkdir(parents=True, exist_ok=True) + self.root = self.volumes_root / "8TB" self.root.mkdir(parents=True, exist_ok=True) + self.second_root = self.volumes_root / "8TB_RAID1" + self.second_root.mkdir(parents=True, exist_ok=True) + self.unconfigured_root = self.volumes_root / "Other" + self.unconfigured_root.mkdir(parents=True, exist_ok=True) folder = self.root / "folder" folder.mkdir() file_path = self.root / "video.mkv" file_path.write_bytes(b"abc") + second_file = self.second_root / "archive.txt" + second_file.write_text("z", encoding="utf-8") hidden_dir = self.root / ".hidden_dir" hidden_dir.mkdir() @@ -35,14 +43,14 @@ class BrowseApiGoldenTest(unittest.TestCase): hidden_file.write_bytes(b"x") mtime = 1710000000 - for path in [folder, file_path, hidden_dir, hidden_file]: + for path in [folder, file_path, hidden_dir, hidden_file, second_file]: Path(path).touch() Path(path).chmod(0o755) import os os.utime(path, (mtime, mtime)) service = BrowseService( - path_guard=PathGuard({"storage1": str(self.root)}), + path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}), filesystem=FilesystemAdapter(), ) async def _override_browse_service() -> BrowseService: @@ -100,6 +108,49 @@ class BrowseApiGoldenTest(unittest.TestCase): self.assertEqual(directory_names, [".hidden_dir", "folder"]) self.assertEqual(file_names, [".secret", "video.mkv"]) + def test_browse_virtual_volumes_lists_only_configured_mounts(self) -> None: + response = self._get("/Volumes") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + { + "path": "/Volumes", + "directories": [ + {"name": "8TB", "path": "/Volumes/8TB", "modified": ""}, + {"name": "8TB_RAID1", "path": "/Volumes/8TB_RAID1", "modified": ""}, + ], + "files": [], + }, + ) + + def test_browse_virtual_mount_maps_to_configured_root(self) -> None: + response = self._get("/Volumes/8TB") + + self.assertEqual(response.status_code, 200) + modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z") + self.assertEqual( + response.json(), + { + "path": "/Volumes/8TB", + "directories": [ + { + "name": "folder", + "path": "/Volumes/8TB/folder", + "modified": modified, + } + ], + "files": [ + { + "name": "video.mkv", + "path": "/Volumes/8TB/video.mkv", + "size": 3, + "modified": modified, + } + ], + }, + ) + 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 77f02ee..e735a93 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -41,13 +41,16 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("F6", body) self.assertIn("F7", body) self.assertIn("F8", body) - self.assertIn("Alt+R", body) self.assertIn('id="viewer-modal"', body) self.assertIn('id="viewer-content"', body) self.assertIn('id="editor-modal"', body) self.assertIn('id="editor-content"', body) self.assertIn('id="editor-save-btn"', body) self.assertIn('id="editor-cancel-btn"', body) + self.assertIn('id="rename-move-popup"', body) + self.assertIn('id="rename-move-input"', body) + self.assertIn('id="batch-move-popup"', body) + self.assertIn('id="batch-move-apply-btn"', body) self.assertIn('id="mkdir-btn"', body) self.assertIn('id="copy-btn"', body) self.assertIn('id="move-btn"', body) @@ -77,6 +80,8 @@ class UiSmokeGoldenTest(unittest.TestCase): static_root = Path(mount.app.directory) self.assertTrue((static_root / "app.js").exists()) self.assertTrue((static_root / "style.css").exists()) + app_js = (static_root / "app.js").read_text(encoding="utf-8") + self.assertIn('currentPath: "/Volumes"', app_js) 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/backend/tests/unit/__pycache__/test_config.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc index 0bd205a..b55c031 100644 Binary files a/webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc and b/webui/backend/tests/unit/__pycache__/test_config.cpython-313.pyc differ diff --git a/webui/backend/tests/unit/test_config.py b/webui/backend/tests/unit/test_config.py index 2171290..fac618c 100644 --- a/webui/backend/tests/unit/test_config.py +++ b/webui/backend/tests/unit/test_config.py @@ -11,6 +11,25 @@ from backend.app.config import get_settings class ConfigTest(unittest.TestCase): + def test_default_root_aliases_include_storage1_and_storage2(self) -> None: + original = os.environ.get("WEBMANAGER_ROOT_ALIASES") + try: + os.environ.pop("WEBMANAGER_ROOT_ALIASES", None) + settings = get_settings() + finally: + if original is None: + os.environ.pop("WEBMANAGER_ROOT_ALIASES", None) + else: + os.environ["WEBMANAGER_ROOT_ALIASES"] = original + + self.assertEqual( + settings.root_aliases, + { + "storage1": "/Volumes/8TB", + "storage2": "/Volumes/8TB_RAID1", + }, + ) + def test_default_task_db_path_is_backend_data_absolute(self) -> None: original = os.environ.get("WEBMANAGER_TASK_DB_PATH") try: diff --git a/webui/html/app.js b/webui/html/app.js index 40dc26c..8c7cd42 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -1,7 +1,7 @@ let state = { panes: { left: { - currentPath: "storage1", + currentPath: "/Volumes", showHidden: false, selectedItem: null, selectedItems: [], @@ -9,7 +9,7 @@ let state = { currentRowIndex: -1, }, right: { - currentPath: "storage1", + currentPath: "/Volumes", showHidden: false, selectedItem: null, selectedItems: [], @@ -28,6 +28,14 @@ let editorState = { originalContent: "", modified: null, }; +let renameMoveState = { + source: null, + destination: "", +}; +let batchMoveState = { + destinationBase: "", + count: 0, +}; function paneState(pane) { return state.panes[pane]; @@ -89,6 +97,28 @@ function editorElements() { }; } +function renameMoveElements() { + return { + overlay: document.getElementById("rename-move-popup"), + source: document.getElementById("rename-move-source"), + input: document.getElementById("rename-move-input"), + error: document.getElementById("rename-move-error"), + applyButton: document.getElementById("rename-move-apply-btn"), + cancelButton: document.getElementById("rename-move-cancel-btn"), + }; +} + +function batchMoveElements() { + return { + overlay: document.getElementById("batch-move-popup"), + count: document.getElementById("batch-move-count"), + destination: document.getElementById("batch-move-destination"), + error: document.getElementById("batch-move-error"), + applyButton: document.getElementById("batch-move-apply-btn"), + cancelButton: document.getElementById("batch-move-cancel-btn"), + }; +} + async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { @@ -181,7 +211,7 @@ function updateActionButtons() { document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("copy-btn").disabled = !allFiles; - document.getElementById("move-btn").disabled = !allFiles; + document.getElementById("move-btn").disabled = !hasSelection; } function isEditableSelection(item) { @@ -197,23 +227,66 @@ function isEditableSelection(item) { } function currentParentPath(path) { - if (!path.includes("/")) { + const normalized = (path || "").trim(); + if (!normalized) { return null; } - const segments = path.split("/"); + if (normalized === "/Volumes") { + return null; + } + if (normalized.startsWith("/")) { + const segments = normalized.split("/").filter(Boolean); + if (segments.length <= 1) { + return null; + } + if (segments.length === 2) { + return `/${segments[0]}`; + } + return `/${segments.slice(0, -1).join("/")}`; + } + if (!normalized.includes("/")) { + return null; + } + const segments = normalized.split("/"); if (segments.length === 2) { return segments[0]; } return segments.slice(0, -1).join("/"); } +function baseName(path) { + const index = path.lastIndexOf("/"); + return index >= 0 ? path.slice(index + 1) : path; +} + function renderBreadcrumbs(pane, path) { const nav = document.getElementById(`${pane}-breadcrumbs`); nav.innerHTML = ""; - const parts = path.split("/"); - let aggregate = ""; + const normalized = (path || "").trim(); + const isHostPath = normalized.startsWith("/"); + const parts = normalized.split("/").filter(Boolean); + if (isHostPath) { + const rootCrumb = createButton("/", () => { + setActivePane(pane); + navigateTo(pane, "/Volumes"); + }); + rootCrumb.type = "button"; + rootCrumb.onclick = (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + setActivePane(pane); + navigateTo(pane, "/Volumes"); + }; + nav.append(rootCrumb); + if (parts.length > 0) { + const sep = document.createElement("span"); + sep.textContent = "/"; + nav.append(sep); + } + } + let aggregate = isHostPath ? "" : ""; for (let i = 0; i < parts.length; i += 1) { - aggregate = i === 0 ? parts[i] : `${aggregate}/${parts[i]}`; + aggregate = isHostPath ? `/${parts.slice(0, i + 1).join("/")}` : (i === 0 ? parts[i] : `${aggregate}/${parts[i]}`); const crumbPath = aggregate; const crumb = createButton(parts[i], () => { setActivePane(pane); @@ -552,7 +625,7 @@ async function deleteSelected() { } function defaultDestination(sourcePath, targetBasePath) { - const sourceName = sourcePath.slice(sourcePath.lastIndexOf("/") + 1); + const sourceName = baseName(sourcePath); return `${targetBasePath}/${sourceName}`; } @@ -593,13 +666,15 @@ async function startCopySelected() { } async function startMoveSelected() { + await executeMoveSelection(paneState(otherPane(state.activePane)).currentPath); +} + +async function executeMoveSelection(baseDestination) { const sourcePane = state.activePane; - const destinationPane = otherPane(sourcePane); const selectedItems = [...paneState(sourcePane).selectedItems]; if (selectedItems.length === 0) { return; } - const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); let successes = 0; let failures = 0; @@ -690,7 +765,7 @@ function actionShortcutHandled(event) { return triggerActionButton("copy-btn"); } if (event.key === "F6") { - return triggerActionButton("move-btn"); + return openF6Flow(); } if (event.key === "F7") { return triggerActionButton("mkdir-btn"); @@ -712,7 +787,7 @@ function actionShortcutHandled(event) { return triggerActionButton("copy-btn"); } if (key === "6") { - return triggerActionButton("move-btn"); + return openF6Flow(); } if (key === "7") { return triggerActionButton("mkdir-btn"); @@ -720,9 +795,6 @@ function actionShortcutHandled(event) { if (key === "8") { return triggerActionButton("delete-btn"); } - if (key === "r") { - return triggerActionButton("rename-btn"); - } } return false; @@ -744,6 +816,14 @@ function isWildcardPopupOpen() { return !wildcardPopupElements().overlay.classList.contains("hidden"); } +function isRenameMovePopupOpen() { + return !renameMoveElements().overlay.classList.contains("hidden"); +} + +function isBatchMovePopupOpen() { + return !batchMoveElements().overlay.classList.contains("hidden"); +} + function isViewerOpen() { return !viewerElements().overlay.classList.contains("hidden"); } @@ -806,6 +886,155 @@ function closeWildcardPopup() { elements.input.value = ""; } +function showDirectoryMoveNotSupported() { + const message = "Directory move is not supported in v1"; + setError("actions-error", message); + setStatus(message); +} + +function resetRenameMoveState() { + renameMoveState = { + source: null, + destination: "", + }; +} + +function resetBatchMoveState() { + batchMoveState = { + destinationBase: "", + count: 0, + }; +} + +function closeRenameMovePopup() { + const elements = renameMoveElements(); + elements.overlay.classList.add("hidden"); + elements.error.textContent = ""; + elements.input.value = ""; + resetRenameMoveState(); +} + +function closeBatchMovePopup() { + const elements = batchMoveElements(); + elements.overlay.classList.add("hidden"); + elements.error.textContent = ""; + resetBatchMoveState(); +} + +function openRenameMovePopup() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1) { + return false; + } + const source = selectedItems[0]; + const destination = defaultDestination(source.path, paneState(otherPane(state.activePane)).currentPath); + const elements = renameMoveElements(); + renameMoveState.source = source; + renameMoveState.destination = destination; + elements.source.textContent = `Source: ${source.path}`; + elements.input.value = destination; + elements.error.textContent = ""; + elements.overlay.classList.remove("hidden"); + elements.input.focus(); + elements.input.select(); + return true; +} + +function openBatchMovePopup(selectedItems) { + if (selectedItems.length === 0) { + return false; + } + const destinationBase = paneState(otherPane(state.activePane)).currentPath; + const elements = batchMoveElements(); + batchMoveState.destinationBase = destinationBase; + batchMoveState.count = selectedItems.length; + elements.count.textContent = `${selectedItems.length} selected item(s)`; + elements.destination.textContent = `Destination: ${destinationBase}`; + elements.error.textContent = ""; + elements.overlay.classList.remove("hidden"); + elements.applyButton.focus(); + return true; +} + +function openF6Flow() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length === 0) { + return false; + } + if (selectedItems.length === 1) { + return openRenameMovePopup(); + } + if (selectedItems.some((item) => item.kind !== "file")) { + showDirectoryMoveNotSupported(); + return true; + } + return openBatchMovePopup(selectedItems); +} + +async function submitRenameMovePopup() { + const elements = renameMoveElements(); + const source = renameMoveState.source; + if (!source) { + return; + } + const destination = elements.input.value.trim(); + const sourceParent = currentParentPath(source.path); + const destinationParent = currentParentPath(destination); + const destinationName = baseName(destination); + + elements.error.textContent = ""; + if (!destination) { + elements.error.textContent = "Destination path is required"; + return; + } + if (destination === source.path) { + elements.error.textContent = "Destination must differ from source"; + return; + } + if (source.kind === "directory" && destinationParent !== sourceParent) { + elements.error.textContent = "Directory move is not supported in v1"; + return; + } + + try { + if (destinationParent === sourceParent) { + await apiRequest("POST", "/api/files/rename", { + path: source.path, + new_name: destinationName, + }); + closeRenameMovePopup(); + setSelectedItem(state.activePane, null); + await loadBrowsePane(state.activePane); + setStatus(`Renamed ${source.path}`); + return; + } + + const result = await apiRequest("POST", "/api/files/move", { + source: source.path, + destination, + }); + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + closeRenameMovePopup(); + setSelectedItem(state.activePane, null); + await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); + setStatus(`Move: 1 success, 0 failed`); + } catch (err) { + elements.error.textContent = err.message; + } +} + +async function submitBatchMovePopup() { + const elements = batchMoveElements(); + elements.error.textContent = ""; + try { + await executeMoveSelection(batchMoveState.destinationBase); + closeBatchMovePopup(); + } catch (err) { + elements.error.textContent = err.message; + } +} + function submitWildcardPopup() { const elements = wildcardPopupElements(); const pattern = elements.input.value.trim(); @@ -1003,6 +1232,31 @@ function clearSelectionForActivePane() { } function handleKeyboardShortcuts(event) { + if (isBatchMovePopupOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeBatchMovePopup(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + submitBatchMovePopup(); + return; + } + return; + } + if (isRenameMovePopupOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeRenameMovePopup(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + submitRenameMovePopup(); + } + return; + } if (isEditorOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -1108,7 +1362,7 @@ function setupEvents() { document.getElementById("rename-btn").onclick = renameSelected; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; - document.getElementById("move-btn").onclick = startMoveSelected; + document.getElementById("move-btn").onclick = openF6Flow; document.getElementById("mkdir-btn").onclick = createFolderForActivePane; const wildcard = wildcardPopupElements(); @@ -1131,6 +1385,35 @@ function setupEvents() { } }; + const renameMove = renameMoveElements(); + renameMove.cancelButton.onclick = closeRenameMovePopup; + renameMove.applyButton.onclick = submitRenameMovePopup; + renameMove.input.onkeydown = (event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitRenameMovePopup(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + closeRenameMovePopup(); + } + }; + renameMove.overlay.onclick = (event) => { + if (event.target === renameMove.overlay) { + closeRenameMovePopup(); + } + }; + + const batchMove = batchMoveElements(); + batchMove.cancelButton.onclick = closeBatchMovePopup; + batchMove.applyButton.onclick = submitBatchMovePopup; + batchMove.overlay.onclick = (event) => { + if (event.target === batchMove.overlay) { + closeBatchMovePopup(); + } + }; + const viewer = viewerElements(); viewer.closeButton.onclick = closeViewer; viewer.overlay.onclick = (event) => { diff --git a/webui/html/index.html b/webui/html/index.html index d7230ba..c01dc27 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -66,7 +66,7 @@ - + @@ -88,6 +88,33 @@ + + + +