Volumes
This commit is contained in:
@@ -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
|
||||||
@@ -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/<naam>` 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/<mount>` 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/<naam>` 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
|
||||||
Binary file not shown.
@@ -11,14 +11,34 @@ class ResolvedPath:
|
|||||||
alias: str
|
alias: str
|
||||||
relative: str
|
relative: str
|
||||||
absolute: Path
|
absolute: Path
|
||||||
|
display_style: str
|
||||||
|
|
||||||
|
|
||||||
class PathGuard:
|
class PathGuard:
|
||||||
def __init__(self, root_aliases: dict[str, str]):
|
def __init__(self, root_aliases: dict[str, str]):
|
||||||
normalized: dict[str, Path] = {}
|
normalized: dict[str, Path] = {}
|
||||||
|
volume_roots_candidates: dict[str, list[tuple[str, Path]]] = {}
|
||||||
for alias, root in root_aliases.items():
|
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._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:
|
def resolve_directory_path(self, input_path: str) -> ResolvedPath:
|
||||||
resolved = self.resolve_path(input_path)
|
resolved = self.resolve_path(input_path)
|
||||||
@@ -50,7 +70,7 @@ class PathGuard:
|
|||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def resolve_path(self, input_path: str) -> ResolvedPath:
|
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]
|
root = self._roots[alias]
|
||||||
|
|
||||||
# Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps
|
# Resolve symlinks for existing prefixes; for not-yet-existing tails strict=False keeps
|
||||||
@@ -66,12 +86,14 @@ class PathGuard:
|
|||||||
|
|
||||||
return ResolvedPath(
|
return ResolvedPath(
|
||||||
alias=alias,
|
alias=alias,
|
||||||
relative=self._format_relative(alias, rel_segments),
|
relative=self._format_relative(alias, rel_segments, display_style),
|
||||||
absolute=resolved_candidate,
|
absolute=resolved_candidate,
|
||||||
|
display_style=display_style,
|
||||||
)
|
)
|
||||||
|
|
||||||
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path]:
|
def resolve_lexical_path(self, input_path: str) -> tuple[str, list[str], Path, str]:
|
||||||
normalized_input = (input_path or "").strip().strip("/")
|
raw_input = (input_path or "").strip()
|
||||||
|
normalized_input = raw_input.strip("/")
|
||||||
if not normalized_input:
|
if not normalized_input:
|
||||||
raise AppError(
|
raise AppError(
|
||||||
code="invalid_request",
|
code="invalid_request",
|
||||||
@@ -80,16 +102,28 @@ class PathGuard:
|
|||||||
)
|
)
|
||||||
|
|
||||||
segments = [seg for seg in normalized_input.split("/") if seg]
|
segments = [seg for seg in normalized_input.split("/") if seg]
|
||||||
alias = segments[0] if segments else ""
|
display_style = "alias"
|
||||||
if alias not in self._roots:
|
alias = ""
|
||||||
raise AppError(
|
rel_segments: list[str] = []
|
||||||
code="invalid_root_alias",
|
root: Path
|
||||||
message="Unknown root alias",
|
|
||||||
status_code=403,
|
if len(segments) >= 2 and segments[0] == "Volumes" and segments[1] in self._volume_roots:
|
||||||
details={"path": input_path},
|
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):
|
if any(seg == ".." for seg in rel_segments):
|
||||||
raise AppError(
|
raise AppError(
|
||||||
code="path_traversal_detected",
|
code="path_traversal_detected",
|
||||||
@@ -98,9 +132,8 @@ class PathGuard:
|
|||||||
details={"path": input_path},
|
details={"path": input_path},
|
||||||
)
|
)
|
||||||
|
|
||||||
root = self._roots[alias]
|
|
||||||
candidate = root.joinpath(*rel_segments)
|
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:
|
def validate_name(self, name: str, field: str) -> str:
|
||||||
normalized = (name or "").strip()
|
normalized = (name or "").strip()
|
||||||
@@ -113,7 +146,7 @@ class PathGuard:
|
|||||||
)
|
)
|
||||||
return normalized
|
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]
|
root = self._roots[alias]
|
||||||
resolved_absolute = absolute.resolve(strict=False)
|
resolved_absolute = absolute.resolve(strict=False)
|
||||||
if not self._is_under_root(resolved_absolute, root):
|
if not self._is_under_root(resolved_absolute, root):
|
||||||
@@ -124,7 +157,7 @@ class PathGuard:
|
|||||||
details={"path": f"{alias}"},
|
details={"path": f"{alias}"},
|
||||||
)
|
)
|
||||||
rel = resolved_absolute.relative_to(root).as_posix()
|
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
|
@staticmethod
|
||||||
def _is_under_root(path: Path, root: Path) -> bool:
|
def _is_under_root(path: Path, root: Path) -> bool:
|
||||||
@@ -134,6 +167,9 @@ class PathGuard:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
def _format_relative(self, alias: str, rel_segments: list[str], display_style: str = "alias") -> str:
|
||||||
def _format_relative(alias: str, rel_segments: list[str]) -> 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)}"
|
return alias if not rel_segments else f"{alias}/{'/'.join(rel_segments)}"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -11,13 +11,22 @@ class BrowseService:
|
|||||||
self._filesystem = filesystem
|
self._filesystem = filesystem
|
||||||
|
|
||||||
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
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)
|
resolved = self._path_guard.resolve_directory_path(path)
|
||||||
directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden)
|
directories_raw, files_raw = self._filesystem.list_directory(resolved.absolute, show_hidden=show_hidden)
|
||||||
|
|
||||||
directories = [
|
directories = [
|
||||||
DirectoryEntry(
|
DirectoryEntry(
|
||||||
name=item["name"],
|
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"],
|
modified=item["modified"],
|
||||||
)
|
)
|
||||||
for item in directories_raw
|
for item in directories_raw
|
||||||
@@ -26,7 +35,9 @@ class BrowseService:
|
|||||||
files = [
|
files = [
|
||||||
FileEntry(
|
FileEntry(
|
||||||
name=item["name"],
|
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"],
|
size=item["size"],
|
||||||
modified=item["modified"],
|
modified=item["modified"],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class CopyTaskService:
|
|||||||
|
|
||||||
def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse:
|
def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse:
|
||||||
resolved_source = self._path_guard.resolve_existing_path(source)
|
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():
|
if lexical_source.is_symlink():
|
||||||
raise AppError(
|
raise AppError(
|
||||||
code="type_conflict",
|
code="type_conflict",
|
||||||
@@ -36,7 +36,11 @@ class CopyTaskService:
|
|||||||
resolved_destination = self._path_guard.resolve_path(destination)
|
resolved_destination = self._path_guard.resolve_path(destination)
|
||||||
|
|
||||||
destination_parent = resolved_destination.absolute.parent
|
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)
|
self._map_directory_validation(parent_relative)
|
||||||
|
|
||||||
if resolved_destination.absolute.exists():
|
if resolved_destination.absolute.exists():
|
||||||
|
|||||||
@@ -68,7 +68,11 @@ class FileOpsService:
|
|||||||
resolved_source = self._path_guard.resolve_existing_path(path)
|
resolved_source = self._path_guard.resolve_existing_path(path)
|
||||||
safe_name = self._path_guard.validate_name(new_name, field="new_name")
|
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)
|
target_relative = self._join_relative(parent_relative, safe_name)
|
||||||
resolved_target = self._path_guard.resolve_path(target_relative)
|
resolved_target = self._path_guard.resolve_path(target_relative)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class MoveTaskService:
|
|||||||
|
|
||||||
def create_move_task(self, source: str, destination: str) -> TaskCreateResponse:
|
def create_move_task(self, source: str, destination: str) -> TaskCreateResponse:
|
||||||
resolved_source = self._path_guard.resolve_existing_path(source)
|
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():
|
if lexical_source.is_symlink():
|
||||||
raise AppError(
|
raise AppError(
|
||||||
@@ -34,7 +34,11 @@ class MoveTaskService:
|
|||||||
|
|
||||||
resolved_destination = self._path_guard.resolve_path(destination)
|
resolved_destination = self._path_guard.resolve_path(destination)
|
||||||
destination_parent = resolved_destination.absolute.parent
|
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)
|
self._map_directory_validation(parent_relative)
|
||||||
|
|
||||||
if resolved_destination.absolute.exists():
|
if resolved_destination.absolute.exists():
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,13 +21,21 @@ from backend.app.services.browse_service import BrowseService
|
|||||||
class BrowseApiGoldenTest(unittest.TestCase):
|
class BrowseApiGoldenTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.temp_dir = tempfile.TemporaryDirectory()
|
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.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 = self.root / "folder"
|
||||||
folder.mkdir()
|
folder.mkdir()
|
||||||
file_path = self.root / "video.mkv"
|
file_path = self.root / "video.mkv"
|
||||||
file_path.write_bytes(b"abc")
|
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 = self.root / ".hidden_dir"
|
||||||
hidden_dir.mkdir()
|
hidden_dir.mkdir()
|
||||||
@@ -35,14 +43,14 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
hidden_file.write_bytes(b"x")
|
hidden_file.write_bytes(b"x")
|
||||||
|
|
||||||
mtime = 1710000000
|
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).touch()
|
||||||
Path(path).chmod(0o755)
|
Path(path).chmod(0o755)
|
||||||
import os
|
import os
|
||||||
os.utime(path, (mtime, mtime))
|
os.utime(path, (mtime, mtime))
|
||||||
|
|
||||||
service = BrowseService(
|
service = BrowseService(
|
||||||
path_guard=PathGuard({"storage1": str(self.root)}),
|
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
|
||||||
filesystem=FilesystemAdapter(),
|
filesystem=FilesystemAdapter(),
|
||||||
)
|
)
|
||||||
async def _override_browse_service() -> BrowseService:
|
async def _override_browse_service() -> BrowseService:
|
||||||
@@ -100,6 +108,49 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
self.assertEqual(directory_names, [".hidden_dir", "folder"])
|
self.assertEqual(directory_names, [".hidden_dir", "folder"])
|
||||||
self.assertEqual(file_names, [".secret", "video.mkv"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn("F6", body)
|
self.assertIn("F6", body)
|
||||||
self.assertIn("F7", body)
|
self.assertIn("F7", body)
|
||||||
self.assertIn("F8", body)
|
self.assertIn("F8", body)
|
||||||
self.assertIn("Alt+R", body)
|
|
||||||
self.assertIn('id="viewer-modal"', body)
|
self.assertIn('id="viewer-modal"', body)
|
||||||
self.assertIn('id="viewer-content"', body)
|
self.assertIn('id="viewer-content"', body)
|
||||||
self.assertIn('id="editor-modal"', body)
|
self.assertIn('id="editor-modal"', body)
|
||||||
self.assertIn('id="editor-content"', body)
|
self.assertIn('id="editor-content"', body)
|
||||||
self.assertIn('id="editor-save-btn"', body)
|
self.assertIn('id="editor-save-btn"', body)
|
||||||
self.assertIn('id="editor-cancel-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="mkdir-btn"', body)
|
||||||
self.assertIn('id="copy-btn"', body)
|
self.assertIn('id="copy-btn"', body)
|
||||||
self.assertIn('id="move-btn"', body)
|
self.assertIn('id="move-btn"', body)
|
||||||
@@ -77,6 +80,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
static_root = Path(mount.app.directory)
|
static_root = Path(mount.app.directory)
|
||||||
self.assertTrue((static_root / "app.js").exists())
|
self.assertTrue((static_root / "app.js").exists())
|
||||||
self.assertTrue((static_root / "style.css").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")
|
app_js_url = app.url_path_for("ui", path="/app.js")
|
||||||
style_css_url = app.url_path_for("ui", path="/style.css")
|
style_css_url = app.url_path_for("ui", path="/style.css")
|
||||||
|
|||||||
Binary file not shown.
@@ -11,6 +11,25 @@ from backend.app.config import get_settings
|
|||||||
|
|
||||||
|
|
||||||
class ConfigTest(unittest.TestCase):
|
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:
|
def test_default_task_db_path_is_backend_data_absolute(self) -> None:
|
||||||
original = os.environ.get("WEBMANAGER_TASK_DB_PATH")
|
original = os.environ.get("WEBMANAGER_TASK_DB_PATH")
|
||||||
try:
|
try:
|
||||||
|
|||||||
+300
-17
@@ -1,7 +1,7 @@
|
|||||||
let state = {
|
let state = {
|
||||||
panes: {
|
panes: {
|
||||||
left: {
|
left: {
|
||||||
currentPath: "storage1",
|
currentPath: "/Volumes",
|
||||||
showHidden: false,
|
showHidden: false,
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
selectedItems: [],
|
selectedItems: [],
|
||||||
@@ -9,7 +9,7 @@ let state = {
|
|||||||
currentRowIndex: -1,
|
currentRowIndex: -1,
|
||||||
},
|
},
|
||||||
right: {
|
right: {
|
||||||
currentPath: "storage1",
|
currentPath: "/Volumes",
|
||||||
showHidden: false,
|
showHidden: false,
|
||||||
selectedItem: null,
|
selectedItem: null,
|
||||||
selectedItems: [],
|
selectedItems: [],
|
||||||
@@ -28,6 +28,14 @@ let editorState = {
|
|||||||
originalContent: "",
|
originalContent: "",
|
||||||
modified: null,
|
modified: null,
|
||||||
};
|
};
|
||||||
|
let renameMoveState = {
|
||||||
|
source: null,
|
||||||
|
destination: "",
|
||||||
|
};
|
||||||
|
let batchMoveState = {
|
||||||
|
destinationBase: "",
|
||||||
|
count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
function paneState(pane) {
|
function paneState(pane) {
|
||||||
return state.panes[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) {
|
async function apiRequest(method, url, body) {
|
||||||
const options = { method, headers: {} };
|
const options = { method, headers: {} };
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
@@ -181,7 +211,7 @@ function updateActionButtons() {
|
|||||||
document.getElementById("rename-btn").disabled = !exactlyOne;
|
document.getElementById("rename-btn").disabled = !exactlyOne;
|
||||||
document.getElementById("delete-btn").disabled = !hasSelection;
|
document.getElementById("delete-btn").disabled = !hasSelection;
|
||||||
document.getElementById("copy-btn").disabled = !allFiles;
|
document.getElementById("copy-btn").disabled = !allFiles;
|
||||||
document.getElementById("move-btn").disabled = !allFiles;
|
document.getElementById("move-btn").disabled = !hasSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableSelection(item) {
|
function isEditableSelection(item) {
|
||||||
@@ -197,23 +227,66 @@ function isEditableSelection(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentParentPath(path) {
|
function currentParentPath(path) {
|
||||||
if (!path.includes("/")) {
|
const normalized = (path || "").trim();
|
||||||
|
if (!normalized) {
|
||||||
return null;
|
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) {
|
if (segments.length === 2) {
|
||||||
return segments[0];
|
return segments[0];
|
||||||
}
|
}
|
||||||
return segments.slice(0, -1).join("/");
|
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) {
|
function renderBreadcrumbs(pane, path) {
|
||||||
const nav = document.getElementById(`${pane}-breadcrumbs`);
|
const nav = document.getElementById(`${pane}-breadcrumbs`);
|
||||||
nav.innerHTML = "";
|
nav.innerHTML = "";
|
||||||
const parts = path.split("/");
|
const normalized = (path || "").trim();
|
||||||
let aggregate = "";
|
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) {
|
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 crumbPath = aggregate;
|
||||||
const crumb = createButton(parts[i], () => {
|
const crumb = createButton(parts[i], () => {
|
||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
@@ -552,7 +625,7 @@ async function deleteSelected() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function defaultDestination(sourcePath, targetBasePath) {
|
function defaultDestination(sourcePath, targetBasePath) {
|
||||||
const sourceName = sourcePath.slice(sourcePath.lastIndexOf("/") + 1);
|
const sourceName = baseName(sourcePath);
|
||||||
return `${targetBasePath}/${sourceName}`;
|
return `${targetBasePath}/${sourceName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,13 +666,15 @@ async function startCopySelected() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startMoveSelected() {
|
async function startMoveSelected() {
|
||||||
|
await executeMoveSelection(paneState(otherPane(state.activePane)).currentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeMoveSelection(baseDestination) {
|
||||||
const sourcePane = state.activePane;
|
const sourcePane = state.activePane;
|
||||||
const destinationPane = otherPane(sourcePane);
|
|
||||||
const selectedItems = [...paneState(sourcePane).selectedItems];
|
const selectedItems = [...paneState(sourcePane).selectedItems];
|
||||||
if (selectedItems.length === 0) {
|
if (selectedItems.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const baseDestination = paneState(destinationPane).currentPath;
|
|
||||||
setError("actions-error", "");
|
setError("actions-error", "");
|
||||||
let successes = 0;
|
let successes = 0;
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
@@ -690,7 +765,7 @@ function actionShortcutHandled(event) {
|
|||||||
return triggerActionButton("copy-btn");
|
return triggerActionButton("copy-btn");
|
||||||
}
|
}
|
||||||
if (event.key === "F6") {
|
if (event.key === "F6") {
|
||||||
return triggerActionButton("move-btn");
|
return openF6Flow();
|
||||||
}
|
}
|
||||||
if (event.key === "F7") {
|
if (event.key === "F7") {
|
||||||
return triggerActionButton("mkdir-btn");
|
return triggerActionButton("mkdir-btn");
|
||||||
@@ -712,7 +787,7 @@ function actionShortcutHandled(event) {
|
|||||||
return triggerActionButton("copy-btn");
|
return triggerActionButton("copy-btn");
|
||||||
}
|
}
|
||||||
if (key === "6") {
|
if (key === "6") {
|
||||||
return triggerActionButton("move-btn");
|
return openF6Flow();
|
||||||
}
|
}
|
||||||
if (key === "7") {
|
if (key === "7") {
|
||||||
return triggerActionButton("mkdir-btn");
|
return triggerActionButton("mkdir-btn");
|
||||||
@@ -720,9 +795,6 @@ function actionShortcutHandled(event) {
|
|||||||
if (key === "8") {
|
if (key === "8") {
|
||||||
return triggerActionButton("delete-btn");
|
return triggerActionButton("delete-btn");
|
||||||
}
|
}
|
||||||
if (key === "r") {
|
|
||||||
return triggerActionButton("rename-btn");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -744,6 +816,14 @@ function isWildcardPopupOpen() {
|
|||||||
return !wildcardPopupElements().overlay.classList.contains("hidden");
|
return !wildcardPopupElements().overlay.classList.contains("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRenameMovePopupOpen() {
|
||||||
|
return !renameMoveElements().overlay.classList.contains("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBatchMovePopupOpen() {
|
||||||
|
return !batchMoveElements().overlay.classList.contains("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
function isViewerOpen() {
|
function isViewerOpen() {
|
||||||
return !viewerElements().overlay.classList.contains("hidden");
|
return !viewerElements().overlay.classList.contains("hidden");
|
||||||
}
|
}
|
||||||
@@ -806,6 +886,155 @@ function closeWildcardPopup() {
|
|||||||
elements.input.value = "";
|
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() {
|
function submitWildcardPopup() {
|
||||||
const elements = wildcardPopupElements();
|
const elements = wildcardPopupElements();
|
||||||
const pattern = elements.input.value.trim();
|
const pattern = elements.input.value.trim();
|
||||||
@@ -1003,6 +1232,31 @@ function clearSelectionForActivePane() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyboardShortcuts(event) {
|
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 (isEditorOpen()) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1108,7 +1362,7 @@ function setupEvents() {
|
|||||||
document.getElementById("rename-btn").onclick = renameSelected;
|
document.getElementById("rename-btn").onclick = renameSelected;
|
||||||
document.getElementById("delete-btn").onclick = deleteSelected;
|
document.getElementById("delete-btn").onclick = deleteSelected;
|
||||||
document.getElementById("copy-btn").onclick = startCopySelected;
|
document.getElementById("copy-btn").onclick = startCopySelected;
|
||||||
document.getElementById("move-btn").onclick = startMoveSelected;
|
document.getElementById("move-btn").onclick = openF6Flow;
|
||||||
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
|
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
|
||||||
|
|
||||||
const wildcard = wildcardPopupElements();
|
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();
|
const viewer = viewerElements();
|
||||||
viewer.closeButton.onclick = closeViewer;
|
viewer.closeButton.onclick = closeViewer;
|
||||||
viewer.overlay.onclick = (event) => {
|
viewer.overlay.onclick = (event) => {
|
||||||
|
|||||||
+28
-1
@@ -66,7 +66,7 @@
|
|||||||
<button id="edit-btn" type="button" disabled><span class="shortcut-hint">F4</span><span>Edit</span></button>
|
<button id="edit-btn" type="button" disabled><span class="shortcut-hint">F4</span><span>Edit</span></button>
|
||||||
<button id="copy-btn" type="button" disabled><span class="shortcut-hint">F5</span><span>Copy</span></button>
|
<button id="copy-btn" type="button" disabled><span class="shortcut-hint">F5</span><span>Copy</span></button>
|
||||||
<button id="move-btn" type="button" disabled><span class="shortcut-hint">F6</span><span>Move</span></button>
|
<button id="move-btn" type="button" disabled><span class="shortcut-hint">F6</span><span>Move</span></button>
|
||||||
<button id="rename-btn" type="button" disabled><span class="shortcut-hint">Alt+R</span><span>Rename</span></button>
|
<button id="rename-btn" type="button" disabled><span>Rename</span></button>
|
||||||
<button id="mkdir-btn" type="button"><span class="shortcut-hint">F7</span><span>MKdir</span></button>
|
<button id="mkdir-btn" type="button"><span class="shortcut-hint">F7</span><span>MKdir</span></button>
|
||||||
<button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button>
|
<button id="delete-btn" type="button" disabled><span class="shortcut-hint">F8</span><span>Delete</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,6 +88,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="rename-move-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="rename-move-title">
|
||||||
|
<div class="popup-card">
|
||||||
|
<h3 id="rename-move-title">Rename/Move</h3>
|
||||||
|
<div id="rename-move-source" class="popup-meta"></div>
|
||||||
|
<label for="rename-move-input" class="popup-label">Destination path</label>
|
||||||
|
<input id="rename-move-input" type="text" autocomplete="off">
|
||||||
|
<div id="rename-move-error" class="error"></div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="rename-move-apply-btn" type="button">OK</button>
|
||||||
|
<button id="rename-move-cancel-btn" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="batch-move-popup" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="batch-move-title">
|
||||||
|
<div class="popup-card">
|
||||||
|
<h3 id="batch-move-title">Batch Move</h3>
|
||||||
|
<div id="batch-move-count" class="popup-meta"></div>
|
||||||
|
<div id="batch-move-destination" class="popup-meta"></div>
|
||||||
|
<div id="batch-move-error" class="error"></div>
|
||||||
|
<div class="popup-actions">
|
||||||
|
<button id="batch-move-apply-btn" type="button">Move</button>
|
||||||
|
<button id="batch-move-cancel-btn" type="button">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="viewer-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="viewer-title">
|
<div id="viewer-modal" class="popup-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="viewer-title">
|
||||||
<div class="popup-card viewer-card">
|
<div class="popup-card viewer-card">
|
||||||
<button id="viewer-close-btn" class="viewer-close" type="button" aria-label="Close viewer">X</button>
|
<button id="viewer-close-btn" class="viewer-close" type="button" aria-label="Close viewer">X</button>
|
||||||
|
|||||||
@@ -372,6 +372,12 @@ button:disabled {
|
|||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#rename-move-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-actions {
|
.popup-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user