From 54e56ab0d817f09e3d1150fdf5a0c64949d65bcd Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 28 Mar 2026 08:12:29 +0100 Subject: [PATCH] Fix context menu positioning and remove unnecessary width --- ...OTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md | 21 ++ .../REMOTE_CLIENT_SHARES_V1_DESIGN.md | 20 ++ project_docs/research_fase_4.md | 194 ++++++++++++++++++ webui/backend/data/tasks.db | Bin 421888 -> 430080 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 99319 -> 99681 bytes .../tests/golden/test_ui_smoke_golden.py | 26 +-- webui/html/app.js | 51 ++++- webui/html/base.css | 20 +- webui/html/index.html | 1 - 9 files changed, 299 insertions(+), 34 deletions(-) create mode 100644 project_docs/research_fase_4.md diff --git a/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md b/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md index e5d0cf8..eb2b3ac 100644 --- a/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md +++ b/project_docs/REMOTE_CLIENT_SHARES_IMPLEMENTATION_PHASES.md @@ -1,5 +1,17 @@ # Remote Client Shares Implementation Phases V1.1 +## Status + +Per huidige repositorystatus zijn de in dit document beschreven implementatiefases afgerond: + +- Phase 1: afgerond +- Phase 2: afgerond +- Phase 3: afgerond + +Dit document beschrijft geen Phase 4. + +De sectie `Later` hieronder blijft expliciet buiten de beschreven fasering van V1.1 en is geen impliciete volgende fase. + ## Doel Dit document splitst `REMOTE_CLIENT_SHARES_V1_DESIGN.md` op in pragmatische implementatiefases. @@ -32,6 +44,10 @@ Info, tekstpreview, eenvoudige image preview en download voor remote shares. Alle write-acties, bookmarks/startup paths en cross-source flows. +Opmerking: + +- `Later` betekent in dit document: bewust uitgestelde scope, niet een gedefinieerde volgende implementatiefase + --- ## Phase 1: Client Registry @@ -285,6 +301,11 @@ Nieuwe endpoints: Deze onderdelen horen niet in V1.1. +Status: + +- deze onderdelen blijven expliciet buiten de afgeronde Phase 1 t/m Phase 3 scope +- voor deze onderdelen bestaat in dit document geen aparte vervolgfase + ### Write-acties - mkdir diff --git a/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md b/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md index fc51acf..9432ded 100644 --- a/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md +++ b/project_docs/REMOTE_CLIENT_SHARES_V1_DESIGN.md @@ -1,5 +1,20 @@ # Remote Client Shares V1.1 Design +## Status + +Dit document beschrijft de V1.1-doelscope voor Remote Client Shares. + +Per huidige repositorystatus valt de beschreven V1.1 read-mostly scope onder afgeronde implementatie van: + +- client registry +- browse via `/Clients` +- file info +- tekstpreview +- eenvoudige image preview +- download + +De expliciet niet in V1.1 opgenomen onderdelen hieronder blijven buiten scope en vormen in dit document geen aparte vervolgfase. + ## Doel Een gebruiker van WebManager moet naast de bestaande server-side storage-roots ook een beperkte set lokale mappen van zijn eigen client-Mac kunnen benaderen, zonder de hele homefolder bloot te geven. @@ -88,6 +103,11 @@ Daarom mogen remote client shares niet in hetzelfde model worden gestopt als `ro - automatische LAN discovery - multi-user auth met OS user mapping +Status: + +- deze lijst blijft expliciet uitgesloten van V1.1 +- dit document definieert hiervoor geen Phase 4 of andere vervolgfase + --- ## Gewenste gebruikerservaring diff --git a/project_docs/research_fase_4.md b/project_docs/research_fase_4.md new file mode 100644 index 0000000..d1c596f --- /dev/null +++ b/project_docs/research_fase_4.md @@ -0,0 +1,194 @@ +# Research: Remote Single-File Copy To Host + +## Relevante file analysis + +### Backend + +- [routes_files.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_files.py) + Bevat de bestaande lokale upload-route (`POST /api/files/upload`) en de remote read-only Phase 3 routes (`view`, `info`, `download`, `image`) via `RemoteFileService`. +- [routes_copy.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_copy.py) + Bevat de bestaande copy-route (`POST /api/files/copy`) die volledig uitgaat van host-side source en host-side destination. +- [file_ops_service.py](/workspace/webmanager-mvp/webui/backend/app/services/file_ops_service.py) + Bevat lokale file-acties. Relevant is vooral `upload()`, omdat die host-write doet na `PathGuard`-validatie van een doeldirectory. +- [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py) + Bevat task-opbouw, destination-validatie en taakcreatie voor copy, maar gaat uit van een lokale bron die via `PathGuard` naar een host-pad resolveert. +- [remote_file_service.py](/workspace/webmanager-mvp/webui/backend/app/services/remote_file_service.py) + Bevat al de benodigde remote read-path parsing, share-validatie via registry, agent-auth, error mapping en een gestreamde `prepare_download()` naar de agent. +- [filesystem_adapter.py](/workspace/webmanager-mvp/webui/backend/app/fs/filesystem_adapter.py) + Bevat de feitelijke host-write helpers: + - `write_uploaded_file(path, file_stream, overwrite=False)` + - `copy_file(source, destination, on_progress=None)` + `copy_file` vereist een lokale bron op de host en is dus niet bruikbaar voor remote input. `write_uploaded_file` schrijft een inkomende stream naar een hostpad en is conceptueel het dichtstbij. +- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py) + Houdt host-write validatie strikt lokaal. Dat moet zo blijven; remote paden mogen hier niet als bronsemantiek in terechtkomen. +- [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py) + Bevat task-based copy/move uitvoering, maar alleen voor host-side bronpaden. Wel relevant als patroon voor een aparte remote-to-host worker. +- [schemas.py](/workspace/webmanager-mvp/webui/backend/app/api/schemas.py) + Bevat bestaande `CopyRequest` en upload/copy response-modellen. Voor een aparte feature is waarschijnlijk een nieuw requestmodel nodig. + +### Frontend + +- [app.js](/workspace/webmanager-mvp/webui/html/app.js) + Relevante bestaande flows: + - `uploadFileRequest()` gebruikt uitsluitend `/api/files/upload` + - `startCopySelected()` gebruikt uitsluitend `/api/files/copy` + - remote browse/view/download is al source-aware + - remote copy is nu bewust geblokkeerd + Dit bevestigt dat upload-flow en copy-flow momenteel twee losse UI-contracten zijn. + +### Agent + +- [finder_commander/app/main.py](/workspace/webmanager-mvp/finder_commander/app/main.py) + Agent heeft al wat voor deze feature nodig is: + - strikte `share + relative path` validatie + - `GET /api/info` + - `GET /api/download` + Voor remote single-file copy naar host is geen nieuwe remote write-API nodig. + +## Oordeel over hergebruik van upload-internals + +### Bestaande upload-functionaliteit aanpassen? + +Nee. + +Reden: + +- de bestaande upload-route, upload-requestvorm en upload-UI werken al goed +- upload is browser -> host via multipart/form-data +- de gewenste feature is agent/remote -> host via backend-proxy/stream +- dat is een ander contract, andere foutbron en andere bronsemantiek + +### Interne host-write logica hergebruiken? + +Ja, maar alleen op intern helper/service-niveau. + +Concreet oordeel: + +- `FilesystemAdapter.copy_file()` is niet geschikt voor hergebruik + Reden: vereist een lokale host-bronpad als source. +- `FilesystemAdapter.write_uploaded_file()` is deels relevant + Reden: dit doet precies de host-write van een inkomende stream naar een doelbestand. +- Direct hergebruik van `FileOpsService.upload()` is niet verstandig + Reden: die methode is semantisch en contractueel gekoppeld aan multipart upload en `UploadFile`. + +Best passende richting: + +- niet hergebruiken via bestaande upload-endpoints of upload-flow +- wel overwegen om de onderliggende stream-naar-bestand write logica te hergebruiken of te veralgemeniseren in `FilesystemAdapter` +- voorkeur: een nieuwe sibling-helper zoals `write_stream_file(...)` of een kleine interne extractie, zodat upload ongewijzigd blijft en remote copy dezelfde veilige host-write primitief kan gebruiken + +## Ontwerpvoorstel + +### Feature + +`Copy remote file to host` + +### Scope + +- alleen single file +- alleen source onder `/Clients/...` +- alleen destination op host-side lokale map +- geen mappen +- geen overwrite in eerste change request tenzij expliciet gewenst +- geen upload-route hergebruik +- geen brede refactor + +### Backendontwerp + +Voeg een aparte backend feature toe, niet via `POST /api/files/upload` en niet via bestaande `POST /api/files/copy`. + +Voorkeursvorm: + +- nieuwe route, bijvoorbeeld `POST /api/files/remote-copy` +- request bevat: + - `source`: remote bestandspad onder `/Clients/...` + - `destination_dir`: host-directory pad + +Nieuwe service, bijvoorbeeld: + +- `RemoteCopyToHostService` + +Verantwoordelijkheden: + +1. valideer dat `source` een remote `/Clients/...` file is +2. valideer dat `destination_dir` een host-directory is via bestaande lokale `PathGuard` +3. haal remote metadata op of resolve remote naam via bestaande `RemoteFileService` +4. bouw destination pad als `destination_dir/` +5. faal op bestaand doelbestand in eerste versie +6. open remote download-stream via aparte interne helper op `RemoteFileService` +7. schrijf gestreamd naar host met een aparte interne host-write helper +8. map fouten strikt: + - remote unavailable blijft lokale actie-fout + - host permission/path-conflict blijft gewone host-fout + +### Aanbevolen interne hergebruikslijn + +- laat `RemoteFileService` een interne streaming primitive aanbieden, bijvoorbeeld een variant op de huidige remote download-open logica zonder HTTP-response voor browser-download +- laat `FilesystemAdapter` een aparte stream-write helper aanbieden voor generieke inkomende streams +- laat upload zijn bestaande publieke route en flow behouden + +### Frontendontwerp + +Geen wijziging aan upload-UI. + +Kleine aparte UI-feature: + +- toon een aparte actie alleen als: + - bronpane een remote file-selectie heeft van exact 1 bestand + - doelpane op een host/local directory staat +- de actie roept de nieuwe backend-route aan +- na succes: + - refresh beide panes + - toon lokale foutmelding bij falen + +Voorkeur: + +- aparte actie of expliciete source-aware branch voor "Copy remote file to host" +- niet de bestaande upload-flow hergebruiken + +### Agentontwerp + +Geen nieuwe agent-endpoints nodig in deze scope. + +De bestaande `GET /api/download` is voldoende als read-only bron voor streaming. + +## Acceptance criteria + +- een enkel bestand onder `/Clients/...` kan naar een host-directory worden gekopieerd +- de destination moet een host/local directory zijn +- mappen als remote bron worden geweigerd +- remote -> remote wordt geweigerd +- host -> remote wordt geweigerd +- overwrite gebeurt niet impliciet; bestaand doelbestand geeft een nette fout +- bestaande upload-route, upload-contract en upload-UI blijven ongewijzigd +- bestaande lokale copy-flow blijft ongewijzigd +- remote fouten blijven lokaal tot deze actie +- host-write blijft onder bestaande lokale `PathGuard`-regels vallen +- data wordt gestreamd; geen volledige file-buffer in memory + +## Klein plan + +1. Voeg een research-backed change request toe voor een aparte route `POST /api/files/remote-copy`. +2. Voeg een kleine service toe die alleen remote single-file source + local destination_dir ondersteunt. +3. Voeg een interne streaming helper toe in `RemoteFileService` voor remote bestand-inname door backend. +4. Voeg een aparte interne host-write helper toe in `FilesystemAdapter` voor generieke stream-naar-bestand writes, zonder upload-API te wijzigen. +5. Voeg minimale frontend wiring toe voor een aparte "Copy remote file to host"-actie. +6. Test stapsgewijs: + - success path remote file -> local dir + - bestaand doelbestand + - remote directory rejected + - remote failure stays local + - upload-regressie: bestaande `/api/files/upload` blijft ongewijzigd + +## Expliciete lijst van wat buiten scope blijft + +- remote mappen kopiƫren +- remote write-acties +- remote -> remote +- host -> remote +- aanpassing van bestaande upload-routes +- aanpassing van upload-requestcontract +- aanpassing van upload-UI +- brede refactor van copy/upload/task-infrastructuur +- bookmarks/startup paths +- remote task-runner verbreding buiten deze ene actie diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 27b01ecadd40c152729364302fe57edb7e32970f..5bb2c164091a34c3a2c3d98fbd610d78c8243fca 100644 GIT binary patch delta 7544 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 delta 2043 zcmZWpYfx3!6+Ub2eV+H+bFLUrP{Qk?hy*UeMNlITqm9-^DHt1C;S#ZNMx6m1jd5DM z_((z;BbJFPP22dGw5HP_2$7ShV?i1X)g)*$F;T|=sm)aD2nrg}NcV-A^hf^eIeV@3 zJe&Pd2+b|+7j3etnZ`wBB3Uai5}I1N$N8As>JmgpEb6i#31W8V zPm-f#mJv?lY#7fg%B$Bn(`>jDfMq0Cf?snDxK6`(*ITrp5~^4k`l+oF>YVc*;FCZB zzs%{%9q>9$)v(O%DZ(8R-_M`tPw_-{pPi?OAY{=~K`4%p`{mQ}ALaG(LOF&H@iQo> z&~)AlLJ>3sJT+rn$+hTp8n?ok74d3|TBnw&e$}P)Deo(E&2iJ=hDXjdtn3oSZ&1LD=V~R`X=dPeTkl^-Pg`(?`T_;!^%tep`5LzNq3bD z#gKdCHn=TY>IHt4ALF~#XZUjvr*7kOc{sZ+zsfoov32(6EYg10-e#BBK3lOa<4vo@ zh_PN)E?K2krUsVb?#WYSjCQxl&$?fAuf!_+kvqq2n^&RBJZApZTy5r>;cTAkx~s!Q zu63??u1Mpiamx6EHr-gS4;c&f1JvFRS#td7Z>8SiUWCsmZzCij9HqmXL$X9}j`*=+EE6 zHY&OYzb5Z6Orz(Ap@#mx5fb1Ut$rW~JVQcZ@dgmZ$7$VtI7p>J9Wy05{s4;U#XEv` z*N{LKJP?P`-zC&5he4%J?}^xzhXQUDKK}e@LAzSU&Fo7-)iS5ig;_kPF7~lFU##72&(5Un-+@soWA@MzgJk@NMUV{|L ziO)kF9&N$1Sb>XR0lonN=!1{oklQi`&5LGxB=b|PgO9KhoJfH}jHI$coWT7haF{md zV;)7VKsVhl#rb3w;38UED7IJ2Z~|>C#Au=du^o+_F2(UwUw|rnuxFll%SS`2Gg5#Z zdZ-_(ML#O&Y#Dm!#+$P0c7+% z>odq#&BA5>%o6cr#AhTmRx#h`K4~=N{G||OcSvQcP^k&2gi+5ksM5kRTrvhpt7g+F zubS!9Tg4`|1(3(`icWTje+=;6w(1{cE8NO_PPIigZMQ|J= zoa$q^i>tg>S_N?u#kS*74=ZQ+ERF&8YUNtK7DxMn@)9Sm1KWW*w#)T&;w)G7)U50b zpD(MV%&G0fIL2zFaQg2_j3dt()SORFVG&UIb~zC8c~SWMeAK6h>Q9OE`hVc=9jpjn zAayBAa8ksAqCqKR4cZla1*0ci`J zjoVpX%^DwPg?O6OW|l_T&8)(sC-Vuqq+QSsu^ZYBwN99b#xaGpX+q&<3k%=Lp5Wh* zn#VJoIeEO6|KCLKR8eBj=R>cs#q1!d-TX1i>gIE4V>eG@HLTcqyPG$KTo;dVhR+xC z_0xZH|1a2F`3k8%IhAJm59w$0{m!p^^1nh#+ed*;pI?q&#GjV) zC1+w#p2k}P9uGMAJLJyI+6?t;$-ZDWzV1h~}j?UGAV?y1beHKcl!Xo~M= z3kCckIBUX`-?=9m>N&|?;r`M*U|Ozq##v*!{)+aldQP4 fRz`MeW_o&hhMzifmFYhWJ0rQu&9tx|l$ZPuKyiH_ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 640885b79dda07c1c99334e16ec360c569b4df8b..0995f63993de7a9acb9e333aaa8c4bb6e1a01cdc 100644 GIT binary patch delta 1333 zcmZ`&Z%h+s81Jtw?X^H#R7$BN6e#~(C^&&Ip+$(eNW?I)OjIP59(Y@OcedS_#7&)B zOx(t1F77pPejxM15S*A!Zz`KJ8M%^NW#o>^QrUTsOi>CX8qxH4Gk*IuqU3z58x*#Duq%(TLBCdSWo6 zK|XFqccwJ?d#?U@bbTa^CQCEU_%rIX(mQvnWW61`Ay4Gd5r~)vJK36#3h2qG4kZ{T zfjpGzCj16zZL&mnK4tMKxsgxl3w}nWC;cYXUi6#E5hqHKslvEFOSPZ*vlWX=^eu0n1T!P5*fxRKNi{l4F ztjjyRk&Ch6=&qVx&&wPiba}mAS8CP1a-i6C@Sux}ZeznjjP2&wA#$S$c_i70 z5%iIfCREvqB5JUU4bc)#;ECLX+jyP4=f%F2O8N5m-(J+BgRJr48Mo4Ht*fle^^_r$ z`HLHe*O3o=aFFs1C?N0q&_qkW^PwT{DbkgV!?ncMjC?g4?TYVSv8+F>BY|dYLL2#{ z8HN87E+oG;qx{)6daE$Y$D*FF@G9H-N-QSuo*)-hHb{51{BCLOTsdn4PQ$uBwekoX z73uC-@>L6}3hDxU|2`q&2?|4eNC>1`f8haCc>^w7& zDtH2>M7%|MM4B@#(s6c%=s5g-tbu9H-kI38W1i_DIj>QjD-kM@ZX?e|u)>0m<)07Z nD2$!Pj5a(D%Aq&03#zH%O3S-O@k~w16b<$E=*De|* z1jDkRxIoAS9up)%Lx^TWGpif|9~O-|vq0Kdbszj7d?7I*S>hreB(2wL!v`;UpWmPR z-Sa;8=Dl-L|KNn)^0(P+V$^5v%GK!Q;G*Rihi#e9xa!a4#!DMAKP}8V@o6S~%czs* zvK!^roFLy<*Un#s%u?=%p1szW@vR!%;N;prT=L|@W_d@!mAUfpC}c}flmmrMc~3!< z=L*)6MwMJFc;wZJ>n>Wgw>yiSTKb%drS#q6?UFwGBTBlS9r;J@EqJI7c$tm?LHX9Y zn^sh!Y-e#=+ll_>(_cEZIP{fNK#yi+P7ep<4=>eI z3lBj)e?uc4x@piR5Glyx4!vVU3we#|5EqP?rGyDx)MY9sl*=Z(TCKb!;yvJF3fbVn z1r3zt$JFiVq8%c|>}ZAl>{c{!^HLcXB!i}}NW6w$NJb66luR`2fQPwVO={)Uydqnx#}Mt7chBscRLhRyuQLqh1nJ`7PBpz*S{c z>Gcxz2sfT#OILwmoXi_c>rJJ<0mC+Q5Ou&Mgc9L{$<$L5$+r{nWN1QJ>cA_8>cMa_ zk)C!Y-;5;4Yhz!v)6k0&|5(@R1R(s+($dekGn-GgpCOMC!sy4Z^iv=V@c#sX-;Hku8f z@sYb#>FdRL*k33Qb;LOyPmZ@cC!_Jm?$F_g^C;clfz9>p|N9lESwVV;AJ7Dr*H ze_w>CxCZb;0~VBfVYp5D;PPaimz3HBvaBJTjU3AJaTpZSY zGcfEnRQ2P*>&kC47=?F4g!d! lower.endsWith(suffix));', app_js) self.assertIn('if (!item || item.kind !== "file") {', app_js) - self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js) + self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection);', app_js) self.assertIn('elements.editButton.disabled = !editableSingle;', app_js) - self.assertIn('const downloadableSelection = items.length > 0;', app_js) + self.assertIn('const downloadableSelection = items.length === 1 && items[0].kind === "file";', app_js) self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js) self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js) - self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) + self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);', app_js) self.assertIn('elements.duplicateButton.classList.remove("hidden");', app_js) - self.assertIn('elements.duplicateButton.disabled = items.length === 0;', app_js) + self.assertIn('elements.duplicateButton.disabled = remoteSelection || items.length === 0;', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) - self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js) + self.assertIn('elements.copyButton.disabled = remoteSelection || items.length === 0;', app_js) self.assertIn('elements.moveButton.classList.remove("hidden");', app_js) + self.assertIn('elements.moveButton.disabled = remoteSelection || items.length === 0;', app_js) self.assertIn('elements.propertiesButton.classList.remove("hidden");', app_js) self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js) self.assertIn('openCurrentDirectory();', app_js) @@ -1207,8 +1209,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const created = await createArchiveDownloadTask(selectedPaths);', app_js) self.assertIn('const task = await waitForArchiveDownloadReady(created.task_id);', app_js) self.assertIn('startArchiveDownload(task.id, task.destination);', app_js) - self.assertIn('const { blob, fileName } = await downloadFileRequest(selectedPaths);', app_js) - self.assertIn('anchor.download = fileName || selected.name;', app_js) + self.assertIn('const response = await downloadFileRequest(selectedPaths);', app_js) + self.assertIn('anchor.download = response.fileName || selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('const result = await createDuplicateTask(selectedItems.map((item) => item.path));', app_js) self.assertIn('showActionSummary("Duplicate", 1, 0, null);', app_js) @@ -1233,7 +1235,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('renderInfoField("Selected items", selectedItems.length);', app_js) self.assertIn('renderInfoField("Files", fileCount);', app_js) self.assertIn('renderInfoField("Directories", directoryCount);', app_js) - self.assertIn('document.getElementById("copy-btn").disabled = !hasSelection;', app_js) + self.assertIn('document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection;', app_js) self.assertNotIn('Only files are supported for copy', app_js) self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js) self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 7cc282f..3f97ecb 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -436,7 +436,6 @@ function contextMenuElements() { return { menu: document.getElementById("context-menu"), scope: document.getElementById("context-menu-scope"), - target: document.getElementById("context-menu-target"), openButton: document.getElementById("context-menu-open-btn"), editButton: document.getElementById("context-menu-edit-btn"), downloadButton: document.getElementById("context-menu-download-btn"), @@ -774,7 +773,8 @@ function closeContextMenu() { } elements.menu.classList.add("hidden"); elements.scope.textContent = ""; - elements.target.textContent = ""; + elements.menu.style.left = ""; + elements.menu.style.top = ""; } function openContextMenu(pane, entry, event) { @@ -800,7 +800,6 @@ function openContextMenu(pane, entry, event) { const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]); const downloadableSelection = items.length === 1 && items[0].kind === "file"; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; - elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection); @@ -819,13 +818,47 @@ function openContextMenu(pane, entry, event) { elements.propertiesButton.classList.remove("hidden"); elements.propertiesButton.disabled = items.length === 0; - const menuWidth = 220; - const menuHeight = 120; - const x = Math.min(event.clientX, window.innerWidth - menuWidth - 12); - const y = Math.min(event.clientY, window.innerHeight - menuHeight - 12); - elements.menu.style.left = `${Math.max(8, x)}px`; - elements.menu.style.top = `${Math.max(8, y)}px`; elements.menu.classList.remove("hidden"); + positionContextMenu(elements.menu, event.currentTarget, event); +} + +function positionContextMenu(menu, rowElement, event) { + if (!menu) { + return; + } + const paneElement = rowElement instanceof Element ? rowElement.closest(".pane") : null; + const paneRect = paneElement ? paneElement.getBoundingClientRect() : null; + const rowRect = rowElement instanceof Element ? rowElement.getBoundingClientRect() : null; + const menuRect = menu.getBoundingClientRect(); + const viewportPadding = 8; + const panePadding = 8; + + const minLeft = paneRect ? Math.max(viewportPadding, paneRect.left + panePadding) : viewportPadding; + const maxLeft = paneRect + ? Math.max(minLeft, Math.min(window.innerWidth - viewportPadding - menuRect.width, paneRect.right - panePadding - menuRect.width)) + : Math.max(minLeft, window.innerWidth - viewportPadding - menuRect.width); + const preferredLeft = rowRect ? rowRect.left + 12 : event.clientX; + const left = Math.max(minLeft, Math.min(maxLeft, preferredLeft)); + + const paneTop = paneRect ? paneRect.top + panePadding : viewportPadding; + const paneBottom = paneRect ? paneRect.bottom - panePadding : window.innerHeight - viewportPadding; + const rowTop = rowRect ? rowRect.top : event.clientY; + const rowBottom = rowRect ? rowRect.bottom : event.clientY; + const spaceBelow = paneBottom - rowBottom; + const spaceAbove = rowTop - paneTop; + + let top; + if (spaceBelow >= menuRect.height || spaceBelow >= spaceAbove) { + top = rowBottom; + } else if (spaceAbove >= menuRect.height) { + top = rowTop - menuRect.height; + } else { + top = Math.max(paneTop, Math.min(paneBottom - menuRect.height, rowBottom)); + } + top = Math.max(paneTop, Math.min(top, paneBottom - menuRect.height)); + + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; } function applyContextMenuSelection() { diff --git a/webui/html/base.css b/webui/html/base.css index ba84c69..9f90867 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -907,7 +907,11 @@ button:disabled { .context-menu { position: fixed; - min-width: 220px; + display: inline-flex; + flex-direction: column; + align-items: stretch; + width: max-content; + max-width: min(196px, calc(100vw - 24px)); padding: 8px; border: 1px solid var(--color-border); border-radius: var(--radius-sm); @@ -924,24 +928,16 @@ button:disabled { color: var(--color-text-muted); } -.context-menu-target { - margin-top: 4px; - font-size: 12px; - color: var(--color-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .context-menu-separator { height: 1px; - margin: 8px 0; + margin: 6px 0 8px; background: var(--color-border); } .context-menu button { - width: 100%; + width: auto; justify-content: flex-start; + white-space: nowrap; } #upload-modal .popup-card { diff --git a/webui/html/index.html b/webui/html/index.html index 4c8835d..36f2b20 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -161,7 +161,6 @@