From d1f018a1307369bc9e6a44c1985456db0be8126f Mon Sep 17 00:00:00 2001 From: kodi Date: Wed, 11 Mar 2026 15:25:32 +0100 Subject: [PATCH] Volumes --- project_docs/UI_F6_RENAME_MOVE_DESIGN.md | 259 ++++++++++++++ project_docs/UI_VOLUMES_DIRECTORY_VIEW_V1.md | 151 +++++++++ .../__pycache__/path_guard.cpython-313.pyc | Bin 6456 -> 8402 bytes webui/backend/app/security/path_guard.py | 76 +++-- .../browse_service.cpython-313.pyc | Bin 2168 -> 2630 bytes .../copy_task_service.cpython-313.pyc | Bin 3868 -> 3930 bytes .../file_ops_service.cpython-313.pyc | Bin 10666 -> 10725 bytes .../move_task_service.cpython-313.pyc | Bin 3926 -> 3988 bytes webui/backend/app/services/browse_service.py | 15 +- .../backend/app/services/copy_task_service.py | 8 +- .../backend/app/services/file_ops_service.py | 6 +- .../backend/app/services/move_task_service.py | 8 +- webui/backend/data/tasks.db | Bin 16384 -> 36864 bytes .../test_api_browse_golden.cpython-313.pyc | Bin 6276 -> 8387 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 5666 -> 6076 bytes .../tests/golden/test_api_browse_golden.py | 57 +++- .../tests/golden/test_ui_smoke_golden.py | 7 +- .../__pycache__/test_config.cpython-313.pyc | Bin 2200 -> 3174 bytes webui/backend/tests/unit/test_config.py | 19 ++ webui/html/app.js | 317 +++++++++++++++++- webui/html/index.html | 29 +- webui/html/style.css | 6 + 22 files changed, 909 insertions(+), 49 deletions(-) create mode 100644 project_docs/UI_F6_RENAME_MOVE_DESIGN.md create mode 100644 project_docs/UI_VOLUMES_DIRECTORY_VIEW_V1.md 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 dade0d9338d1f234a38af2dbcb79a1d958191dac..8a9dc70574584ab8bb60ed5e9aa8fe02a1cf6cc7 100644 GIT binary patch delta 4377 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 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 086f8d3b2364d7d34807b4b5c598dac875ca612f..69bc2e855780adbc2a1dbb137ad9ae2b56cb3d21 100644 GIT binary patch delta 1417 zcmb_b&2Jk;6rcUF*WT>f&iXU9Q{yxcw`n8PfY|gDHW?k^ac5wjgtnO_RmlUplY7{ z#U|Rq@O7HMar98pEzV@;kjbiOIgC(~TlF5>mAMi_UEyTuS>j5(#=Xzph$1AR3~KUV z?K`$E31xl`l{^}E5tUK#SPUl_<9v)nnryl)fQDiop<-Y}0MD{e44xxpOJ0rLglbzK zl|5(B=l=u!DJ;a#Np1CvdRA$jC3@vA*eBphhe>Y1WG(TENJco#nGnhgg#sz&d+2sun|pe4fwxr zIeDeSH>(Z3!?&u|dI#k=9D^V@4y+?KHfqM2q1U#>xmz3c&4zA*exQwpP9Wt1s)nr z#CpAI8C!bJhv}LQO2KKW;tfl0;3T;sp|&PoYbtHnsrinJC%Q$D)a;{sXR*+cJh4=g)Z#ozm0zV-XrUAVehkF?mi9P z{4G^*1tiB^1a^H9g|oZck3ZcneqgKR{ju^NYRplmY<0>}7i@Kb>3A<;evr@mZyumTw-?2GeHmYw`fv~x4H&!&n_ z3>*{bVMf^t83AtARgq6|5#;H|QE+niEoW-po|=Dh*$%Aki>rs-MFtvi*c}HdkX*{}KVDjtmZtfit}h0bF7YrEh0cra#7d4QjwdD!){rwTjML=MQ(|Vx4e#4b emh5=HV%{WI#W-`Fyd!1a?cUx#nTMMQFQC0$t2BQrzrVx!kkVu;vV~M8NMONvynJr+r z;9p=Q6C)QBZ{CdF)Qdk3%F)JyWd8zBrnDR+@!*?^sKJ-)o9}z`-skPidv|aKUD;1#0-AM?y$weH9RT z$R==%WyK!iNO_dSYzl`2g-uBzNy@5x9TIawT3iqjs-DT?5V}HKF@;<)gwBm3DRVo1^>N(zTZ@6Swm;0U6rO z9p8iM1e=g!lAr4An|zeS`HswzBzcfM_K1T{0 z@t(EY^|-mEVBOgCL1{xrTDXCrb2EwJy{&-S;cFV8fT1J3i>)eMD|;}J_}Zb>>0O#8 z0}TfEHUda98VDF$VH7;@EDC|5MQ9@UDa!KSMzE_e(!MnKkT<$sNo_(k9&_9%Fh}D}H z2bAB(I zK7G8o-F>>Y`(S%#7d}Yq63-S@z?1E4mBSg!CaaNmTkZo-NDa74YQJYso1 zD$9+Z7@YV|OW03NenazFvdd z>VB!G7gN2I=nObLtCm{xi8Vivk$ruDh^@uOKtn1${Hv}Ccn;cn4L9MX{;qbG_}W=) z-Z<(@(UYBLQ74kv;*sdq=n&n7`?M)F`925l=?$qa-od9EFreRY2w!p&cm;mtD#jwl M7fulGk;%ya0FQHp=Kufz delta 513 zcmca5H%E^5GcPX}0}$*C+mKnhk+*?~am(aBrUGte1_lL&0@qO1Aoj@%S(G>HG0QUY znKFTtGBD(Egfaw)PkzWGJh_fViBS@$!jPLim?M}um?e}gNNVz37HLM=$v;`-8Rda; zQcOV#KuUPB2P+Sw(&Rv9VM$epLC9nzH!Dy&S{rDU9G590L@AKV&8Df&W67ex5UmR{Dio?1Nb`a;1@l=lsW52rPmX4* znEZq-g-s18SfoDLk6oHu6U5X85}M+ZOV~RmGji0zq+2+W8MP+A;0R~DFxi1qMoM1! zy1f2HdHw71E*Irp4uoBn_qoU+)4|wbIJtszj>rZ3;Oq907wsc2MCV?X%Io02!NIfH ziYtzZ(RA`m9z8~<$tQT0=z*=a1=|lIK&)GAMX4pFMR`SRAZM@?dBG%#d?(N1bz^e{ z@l7W`<8@-wWA$bHo-kRD@3vG3NK_I;hyjUP95%W6DWy57c11ChmH9RJ9T^#gJ~4nu Gup$8I-EGPM 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 f1f9a05ebf85e65a60000a9e33f9a873eb356218..dc589c3076524f2b20e803daa4940c06585dc481 100644 GIT binary patch delta 1121 zcmaiz-%C?r7{}jtXFrbf%&ButH|Nf5YaKQ6C$dsQ&@w73k27lJn(k~4=9b5a7{Lsp z0=tO^g6uztuw-;mP()==7m+t9>$m`<(Cl{hsG}54`uq&!Xvv z$z&wTbLaAM{MoYUWQj(@z-Y<3Gr$a$;l|EKWc*B07l6%ORv0nvhKEKA+)~!A3JW~9 zTCKK%UDS#?Q7;+-hM{VB;k3F~zgD7kBtVAj1<&?6QKpuVBwN&h6HQ{7 zXbyS{o&eiH^L3%8s3dyXD2`A(5dWW%O@=z&fc!(>49>n80r*n0NGIUBy<5Mi2-C0B6Ml2gWY0RDX!XNrx&YOD8y$t?yigiO zA%wUH^L%RtyU1$$2CNzpO^AJnt!V^C_UsfY{x=!g;|g+2LQ3ZJQgS>Mk>mu*R+PEP zR3r{9?hYD*m^(^8X5YIr8fwW7dS)q&!yB)c+Tg2q$+mzQR)L_+PvJ*l0haevkEHNZ z+ObqBA*(jLf&yD)QxB*H<}j6yVpW1py?k{`#k=r#A6z~wU4RbX>Aj~=J%^Y=Tv3qY zrqkhce0)+$PoyGp3+i!L@;T@{yz}kXHR-y4p3naFF|;j-d+M2QV08^qj+jR%_kqYY c3UX|h@;7D@QjdHW+J%@o!_b4@2~suv0WcQgwg3PC delta 956 zcmaiyO=uHA6vub6*s>qOm7`F%xQh@Q28m9GcUJ8H9?+ zGsKSRUff?qoJ3?00-_g@f=FPU%)-0CAbDc!2gU>o#r!(ZP#?Jg_d_xJO$;UxQ}8;} zYsJEP4#!gHVY?WwcVYf7X!>7k)C&w%eqPn87FC-r&8YeuOtzmVd3fHQCm#)|qiiB> z<6+klAq60jF#F%p$V+7nFN+K@bFFUzgEiQV_#GP7H0MgCc@}u0zR`n$4}?2t)l}R` z3^9woa+u-+YdA-<-oT7UYp@W#;u**7Rm1{f87fhQP4_L@Ldqa?_Xyp>;IEXC=;ldZ s5sf*-J;W-)gD4~L`RmOLRdJwLnpvDz&*@L06q|J~^W^Lg4tXH`0Xxpln*aa+ 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 eb9e9d2def3e9432fc0462a9274b57304908266a..dec6bc1d2166557e34d01d1e77cd0f9be29a977d 100644 GIT binary patch delta 657 zcmZus&ubGw6rRb>{>*MxENPobn{AewG-(sHNF^9*6-_FlLYDOsL8FaQ8e`d{&=v$Q zLay=@JXBAj=aQork6t`1MK=WeA5trL5pgD%L&10W=6mmb?|W~)C0<&QBg4=Ti#NOb z>vQ^kWFsW~77%)c;Ds;&&G0mIL+4e3&&=pKvsWJ|i#4%Ma2u;OBl^7(+ z7~Cc{j>93LU2AA^s245pR)Bk6PHfu7|Z(yp~Oym|PkR0@AELeEf- zCIXuEX*QrMK3(}Ven3|{X3tIq_M&eu26oxE%b%qK`Q9zc@f7u64|ThE$`Q)3!r+3Et~@T!OFqyZrF@Wqx*=18057tGBnj zrpJGuA>Q>-!@CI|jFMF1`4Su%m!)&!4g9MC7b0J%%d3+dq73><@$r+jtww#>y92g) O$}D4i@(1EL6#WIGzmO~d delta 544 zcmbOte@%|>GcPX}0}yPB*pPXIdm~>q6XT1?RZIok%nS?)3IP1DTaJOESwb z@|iM$)G{#SafC7iiBAq^F=dnl$xdF+!q3ef%n{5S%o55LBsKXYi!`I`WJWeQrXYEs z0x6~-g~^Jn@{CH8Ke7l*szOXbCWARcnSuJ|vx-VfA&W!U!CaQid0e5)lNDJ+Co8as za#?u*9l;HBgc`^|SxrnWpg zK1(JQ22K9S)oc}$S=dw9Kn^WZo1Dk4#;pNjY6A&P@yYYqJ0|OJ)WW3Kb0qVCTyTq} zB(XTVNNchjXAtA7$%ULUQu4~z<@GPh>tC05xhU^)AndZd&qWTI4#p0{$tyYMh+MD_ zzHT3R(LVA*bna!Tybk^w96Xz|xhj|#O(#F$(PMO)EX=z^59|tCuv0(;h;@stD7B=t zD6a_QY)zITFPKD;@8qYvZfvd~zUgE`J|{KNDU&Pt&Ps)VL?u9kD3G|tVUwGm aQks)$R}?)tfnSy1nUPWG69b3@D*^xk^KYF1 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 675cd662354ec714098de4e6a1831c7d7a03764b..83e172ad51efe25cca5c214386bbfa66c571d44d 100644 GIT binary patch literal 36864 zcmeI5Pi$PrdBC|OMTy*9u4Rg*ZCT>V|0Dx_%lSWV2qZ2=(vDD?QfNwwU!rc`Ux~5A zm6ud%wN8PhmF7^OE#Q-zLm(jM7QH2CFRpN%IyUMe&=NH}5y!d^7XSe8YFYzP7a+R-;Dqj=Ni>BhQQ!3M0Q=t&WV0d<0*U z@b&mJ3O|fL{tx_H==eG7$B#y8fAB%M^2Nwl>CKVSKURJU|Ky*XKu#bhkQ2xW#Owr>b3_%j?xR|AqZRt1GMTF2B)}@_Qc@j?c}_75?P+aUJaTS8bc3AiB?}j_j0Gq3CuWt0I?`~t) zy=|O2E*h4Rcg%}6P3_-nHpA`Rx=!DQk_kC-w{C;VnIsLH%|^5CH-g8cGYd^p-U)Yh z+?xXvcPMDAL<5zgne>r?Z??6)wR0=(PWwrFwDOHNMknUJGFKRh`{K?Aw}C|6y|>$l z|KIMIIPMQ)U~W!mlXDEd#w$P9KTcNur}B%+eCy*1!3H(|TIPp4E`}i67HgO8R9h;hh?-wRN9Vybu|16D^|DtlE{I$|gr*D=v zCjYMVqtYAGvz5OpeyjMGl|P>T>g1=ze=2^j!loWg|M~R4mFFw}T%4NvO8MvI4=Tmd zAC`VG^=DJ3rhYvAlc}iuITV|JasoMloIp+>Cy*1!3FHKZA#h>rYC(9MDHL%FAsJar z2E_8b(6*2lGSBmPB>eD#PDmpzSmRV-9~ z=vk2$3MkQ)mgfhd<%OPLf$$ZHID$tr99W8qz!D_jkxK$0X;9S(mG4EK9Yz)>J^|pa4Mc5bDZ!MBIaW$~ z^Ex47Eby7wrp2p@3S55fVj;BNoZCIw6B*>4vz#>owcx&le3VX!jf$uRTi3~*aQe0x>+JwP-L;>gn10At!>@$l41XmGG zk%vylB_ify4q_H5z!NB_qmMj`BiBVr1wkami#j0=ISPT0<)IKmLXTR?6V&2RoltPh z6qPUNge;6e|DfSOiQv(M2bL#5nHcsM77>>o;?L`Z)Q(&v8MCBq!}Ap=g$gOLs4t>0 zhy=2kH>VQ@DBvsz80ZYK;jLW?iVJ!KtpNHIN}ELLx%kmQ3P+$Wiokm?5h-|UCM+3Z z8>;|RAyl(EA>pnFm?wZEXa>XZR4-tlLd;VVad?7?PQ{PLiZKdr?FP`L;ANnhC5O%x z%E%UW=u_95(Ftvz>wXkiA*ayRNQ96DDj}%NIb>lj7th9zCRDmAfFh6&z&YrH>j$2t zl#2pCpf<+*q)zBjp%it6#ck%pqkX6zXs#tClL9)o3q9uBg?j3Xlq6S=-@(t%9M0M0meZEo^4T^fTf6_ zdH|ML#AALKxnPXKsHhV{zR;#Vm=vfrbS}^gkAj7PUWEc^bx+`EN++~A@_kQ8iy#L2 z7=dx|LNHf>jiDVd^m)oAb;1a{0@_CRYXxe?wE?uj6o3)Lwr2+sQsIfD#1J7Hlp9G2 z34Q2=9_2vPh9@hApr`xCbwVFr0xK||in!XO5CtUy9#HN`hJJu;9!}_lQgK@aU>c>6 z041^m%LaY00vZYx`P`0_drT)JI1I3>TnkYIkEXf_Jpw&kNh)oou<(L$olsE8-2f~O zTU7b{J z$T2_wgXlv-B0NxB4wZ0~pkOL+6p?uSUo4Ci3&nHArJ`GG6(1HqD?Ta}O6N*TCAZWn zJuH1zdQ>iy&y|By(O) zk>f>zYna$VaOYgA922M?p@=%wEIG1a?aQ3`R5>CwN~wVQoz0X3JtUknDRQ*PWlg|J zjWOq?Ou3LY#m?zoIa7<6*BD3G7S4;Qa%9(-fJGE`UPzJKy8WSjYc-T5(&bc=Q8C2!TQgc?Uhq|5%_@%0#=O9sZ2Q);InXMQsi=xiyB8li6?AZ?42E{9P#u~6S^0GF@25VPJazo9CsxM)W$e4~%ltF)z?pjP>g)@x z$=1vXxB#JbvUT#z-u>m}WfO1KoyK>-1Ezygc6J+0h#$cV@7{8o5XbRW<8}}>cNSI} zzrTIE;RZVko7Y!YmoHy=uYL{JmoKlsdwK2B%F^bg%j>l}2D$IJcS+wOj%_SlYuvsE zaV!h==EcKRF3 z`HeewA;|4lLc5-Zex;FWo9Q$W44{J2biPQtpq;(oM9ddDZOr$Dgs+L)cu2mWJb2;i zrTRNp*ETP`ySlczys0}fL@8c+dx##~r-Yf$XFfOU5QFN{$+By-OBcbmi9CS$NiK?Q zm_zM-`*@6bwly1L*2l|YH6HAAKMdw8YfI~v&Ir33-o2Z!pD1ChBf_@LO=aIQtnXXj z9|CpXveJmQrw(Z%f+S3<(_v#GV8Gt{y@?ohu?>4Bfo-yJF(mB1?=^Tpi`B4i8Rne( z7SqFVfA*Csf_bkzXqIDr*10&$sid>#9E%YjZ<~!ug4krs$8X%dKZIHC)87pN_bI{L zV)QAbhv5FyHC2QK?0EO;FXV#FQJ8T%dmH01+_6@<05?driV0kkZL%Tt_xd~ar7M@} zD;M9cw@rk^w6^=L&QZM7?Zxol<~I!ZZMfev!r^Zis291rD5&v7_aKum1!{Ydlm=BOSRWnG)P1N3_GYp3F&UkKseipy)HDB^ zH_x;CQFr|h@(^HX~iE%O62p`h8v|nJb*6W0svz6gSKv0uZD9K7b zRiu|7DV8Iq%rQ*slJ)`6rXWo_8uX zyfdxIcgj~ zC!7h#%qk45NQWZoWpbxCGgTxB6$huhPjbP5C;`hjZzc_LU~quz(2!wivE^ubst69H!8|No1Scp7@I9Hvml&KGJPh9qwHbzQMiJ&p9P`a6C5=>jOCnWdBXEeP z@`Y2ha0VBQ#4~9|!sMvskbFS{`}{r5qlB|RJo`iQ{+Cfm55fH{{#20-1I~ekYXVp2QkW=!yHW)2 z%>4b((sJM8{PlZE3ym_Xw{8{L6ly$zz@^v~Yc&%|B0koc2Y`tKoVC!j%8 z*YIivSB+l0ytJ`0{LW##_n4Y}W?^Q*Slv=fIH*@}I7J`sN)B(OiYVc#d*Es30*!+s zKM$Oj(vMU~gAh1P)pTalZI_GiF=Ycf=BWEurU&KzQQlM$%zxk%Xu8OTvlietdOoJy z<-s=zbsmy(KT1ba=Qy`H;ei$D!qs^mI#om<3b)&D#FAWaLI_Sd@7lm+1Id^}?pKi>i2LWs(?vFHdv+_ay{L0BIAu?r2QNN18=q4B=Dx3p!aUU^yj}w~ zol(UvmEqBSGNZ~!L`b5hhCa89QsOeqCba!Z=<)_(dZT(JY~I=0+0hsJ2jTWs7#N>G z?f0OR=~71WHlMC35$g;l=u(bEq!@&%bSp#i@y#m!LSrGecJGhQ#l3yL^+s%4&nLazJwd zc3?Yw`?$WezEZz_X>Da$Z#N9LSJ<=HkX71H@dqZ=>!REl#-1volBMlL=-_t^D)>ir<%$2iiZK)&jUWVbM(tS)M2dtdmU*c+m|S$iWGzS@quv#EK3X-<_d86 zfb-zu*?1of_9o+jWwJGSW^W4y7L&^V47rAb8ksq^- z=5-FCIUD(a{ir5_k``DD{30mqB+r???BRRtASH#8Ubocf7IWER-DjzzmLZSR&{Uq1 zg}iMFwmi-vK%O)kVZ$DBjyp6@UtLoPG?--5-AoZv*k|Y}Pr5}~kTF)&7hcgDOmgbc zT!af(kz3Ub@&D>rtvm7&KI}1qD^ICrgegfPl4{fDnYx*0Bc@^Ag`S(Fd$7z_>Sq~Y zuD{tp-r+oJl$L_Rjg?delj;mBP3e-*^Ukyo5yCBoz4Dw{iI|7k5w@fe(d1c#>_|kj z0mEmub&s_hEP_~ilDDc|p7J7Q_nE>qU|B0$BzG@sEN1qwkzk}uB+nR|vPr&(ZJ3d~ zw4a2_T-qA3&UPA};U^7NdCqnl91<>}pg*Kp&SjF>l&nkzSN3pjQuu_E`XS5=M4<@l_+*fSbrXm~o7 zi6_!a(GH)50(i|(LwM20yqiTHWN<4uz zP8aNo9LuERB};9VrcW;>maH zRJVXl6y5%UyFKr2-{AA^9`ZB6KT~XMDKvKH8@o4N$u~X?;eI{bpBMZE;ZR;UbW`1o zZvh)G2HFdO!F*tFb2=ZG0N+EpFL)J%w!F}`G4@zHf~%DChO#lO=NuuAgrocijoa=?a>P1*IK=EakW0Mp6Ys<}+kO?i8~kbd!`6H8 zLTD}@n%h1d%?IYUeGBCK*7t{@nS(rrvf&8|8Xrr_3mEqTw*XIPmxByGLS+T|T%;v^ z)z-xvA@A6Fm{afGw|&eoC&;fIFC7j+j5(G}(0;@(QURX;c!7e#Q=rGPzD~o(s|J|k zBvti*=^`O#M-XyXT=6vc)2WKY`FZ*_N;EFIB&(XA4qZ17^ceZ2vx~m`&(0XLCQ^@2 z?@_>T>2EU{tL7e=yZZZ|?(f%_^Lh*As*P^$Tg)WmGQMrXeGmdG)Pxl6{KbSE$Msaq z)$RGNB;s&R`fV3%2$`EzZ@^^mX@cq+kUd{c9XByUS!UY2eo(TKEGG@Rc1zn6r75) zGCdC{Dvkmq0Q9|F2G#@sw+1%@z=r6$F91&hqyR95pjkgutdQgGQKpl;;*K#g`IGxS zf2=&us1ri)buuBueD*#f|8~xi4VTZvge~w9Sd$pZBykcinR`u=MG{J^kL^Q}wbxXc zv~+CQLOG$YJhw{ghR?$zhoS3(nT2qiDzTSBu5Q1jG|2EJu&z=Fvat3L!U`1>nCM3B zM(Hq7qZe`JWkrszB(R!YNJfnrm81hwiDuHt)#$!ql*(jbex9M-9{0DEd+VClw>Okq?pN(GnXUG@MPsqB;$ zC=e`)BgMAvhZfG`-7%Tz0o0*DcTpTIiUH7DdC*%L>K?jk;Y{4+Goj;gkiH5J0l-|u z!vOshXdiabhsK36X&LLYw9{M~W#ixjw=`!{R4jf?)Lpq=X#o9( zW`TVJ;5C5PNzmJPx&tUQhhAM=4GaJ-kOCjQ*U>K9&cDFyh8UI?KNqZg>#o*1pTR#OfAb~!Pq2*bMrWa+E8ozyi$K~r!y(RbEw^o|FLI9WOujn# N?Xx>3#5L&q_zxO!+l~ML delta 1529 zcmZuxO;8(E9Di>=lFcRoLIU{!!t&iEPy-}VQ29`-PCHP{PMoxn77Pmn!Uo?4m5c|b zQ?)bYP<`V}d#XoAduV0UPRB#XUi2n%KzA6&qmGx(lu<8seE-E1b=<>mfA8h@|Ndw1 zk6-Gmdg%4ah&?}_x|M(Ay_P4ben5%2s}dPU39#vm2P2)KH@&6UkV-cS3W#p zaA_`84XOP5(Hu#9c%z^+O4-T&N8#*sBV_Ptp57B$n*UEB*$N>oq^jQ+qlzXQVx~5e zNC%Osg2O9qD&Qs+x<9x%1_6{K;|eN&7+~yvf)fSQ^AV)(L1B3 z#RQk+4sR4Rc?KajQcyNr(PSGcO!H=&=rkXtBfPW{$CVJ-=87QsH_@twxK)={lXfMr zR?Ye>EenQ}maf)j#H1T2m9%eYamQ&nV}`FuFU6&<$i$VS&_v$hD9! z=oi-JN=vj~?rJ{H?noOz3j-6Tqg0+T(n>ev?&DDJADd`Dr0j(Tz6%ZPhLV?sE$@CH zbS-eB>3Y*%IJOgx?FM>2dVMF5q<_jGy6PUF_uXUsZeWZ`o~Ihe@qq{VmHnXdVfW44 zt@ba}+v+FAw=K`?21n?!=WYU8sfvVc3LOPGQMq zyfK`lm%P1rc>7cDj~EZq_o~l!JqtFDTp_O;CGtEwkU@Y62Bye>)wj9H%zZU|c$hv{ z^Aql%?Y_<^_&$=7)1VhXKRXN2mV9k^h;I2}EZQAk4o|{<{PHyzMHr~q;!5SRBNmSj z$Kw`0XZO2YU76Yw3&p~`PE?+#U;-&w(rR%&zmV7GNj+WiH#VrkKYkR2I#-xspDAm^7=Bz_M9!dNmlJ*8`2T7&wV1 z=;wi(tb$zK49-9WmQW(=I%_TThiCv906WXGz~J9w7ehk8>KU*^r-7dX$O6nVP#yMs z$Q%s?PvBPiQZR>S=x4z@FQ}ZoqV_l$?_gp}m?|1Nu?O!sY=vx7H@8tq?103cKT0(6MI;WJhZw*;ns)0yB^-yM0CXiMJ1_cIvhG31!yExm$H2tc$GV@YWEA%o-a&vyMmX@UHTCAKL z&b4c@1b1z0l~`tqtx{2HUSe*lZf<^As%}AkL1}>!rg&yvL1~Fnl_*GkQesJR28wb? zG~vX8f}G096SyDBO9S0gBm*K;K!hrYPy-RlAVPif1fCE^#+{p=^13lj4(C^x9LaAp zxs9J6ghh2%lw9F3xxvBH&)dm+LCkQ2$rTQZ$vymUSkDMb`EpLK6%c1S$2ggfMP>3X zfnyq>Ou>x0+zbpsyg;MTKrnMKizQM2w^}X!1-}5E4-?k^ov$Bnc#{#FI;l zfMH(}kXVvorJ$rAmY-9an_8?i*+j@Dma9kvq!8rHA`TFt2_m#Wgf@uK0TH@D;ucS7 zQBHgT(2V%B{GuWQppYh8kswIg2qev2T#{IlnH*n~pI>r|4Hz@=S;dog3(2su0c8~? z-xjK1^8>Pq4^EC0j#R%PB)fp)qL9XQLF0>p#yi3<2pV4ybnRe+$AbO_F;FaQ5{_eZ znJg&c#%2Ms$#HV5h%}?~Vq~kH0 zakY!7`Biac=B1=o=w+1T=2WqkmZa%gXih%NwTq3_F{ea3XmSmA-Q*aaJMxl1gNvj< zgc68Q1`#SCLJ>r$ZVu-SVPx#yyoS$>k$pn?jFKxHDwALHCr|DYcr15XTFOV5C7BVZ z5(Zd-3}yxf#?NhxllfRwCa)Jf77@(?lMUj5u@JNpLojnNOE9Y?Qyw!BD%rp)Swb0t z_$Pl96c%I-W;bPmn5Mu06IEpi=0J$1GiY-9Rk0-&6zF9YS8*1XROY1WB^MV@o+51H z%~2!*)LjH}Vi5<3PzMnjAVL#J+~O%M%84&XEXjyZ%P%U@0}5%f772o+4M5V|#U+U) znaS}*`S~T2bwy-YKmjn>Po#p)8^|c0HTjH4 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 0bd205a8dff2bdba981316555939a31bd1506050..b55c0316000b62397941a2f8ac75d09831d42ed7 100644 GIT binary patch delta 696 zcmbVHO=uHA7@gVO*-Vp7bZcxD8*0;PH6oj~h*2p*QbR3j3)xf;ird9(!pdfs&TK9P z@npf9Feo0qdn&zn67e7uPw|qnrwZc5OQjXP2+mY0Jv;C{-ur&u_svWFtC87Fr;~uc zkKJ|8RM#_W&^*iKb8rt3XaOQwkjR#5NhiQVg(#fK`wUqMQQN4PU~k|V=`Gy&3^B|s zfqF`~{eA44G=eS3k}buQ*rxns2`$9AJGJVq%0gwXR=4Ymiw(PSbG~BLtW~XaJM_C9 zmzFLys;h&Cm0hpQUz;jQF{V*SoVGjlk4^Ino+|m7IOS04lBl-Qb^KT(VHnwt?>W?^ zMLAZe>$hSY60hwAypE|GMPAURROY8L1I;}yM=p)*rrUD5eqx4qzj-)*|~!E)OP zng?P!uFi%*%WF6IxcQ$R831HJU$Z`1KD~{#J~n#T*wV6ntu=|L@!p0}0LzrM+)QhSinohCd#BgbX(_J25zm;IY1f+RTjl&w)lXR5)Igv(X zVzTAL-P9;glam4p>_g(bS>!ZUI*u0*VMj~>VsngVI$^WxyI08-E+G%P!bb6pV+B}# WX7u&3o<6n*xY)T4*(01&#ONO{0>Fd- delta 310 zcmaDRF+-5=GcPX}0}%YN+L*bDc_UvNqlz4mJCz}Zp@=b*y%~nYUPS6EpL` zCKaiJq(CO!;;_lhPbtkwwJS0Pav6cRxM1>IZf{*aM)L_0*9Da>3MzeJ0CGMkF|hD- ZR9$A4yurfL?%U+s>i>b6fkg^z769W7Oz{8! 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 @@ + + + +