From d84b3da561c791157cbf9c3f71399f76e86c3536 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 07:48:29 +0100 Subject: [PATCH] feat: delete non empty folders --- .../__pycache__/routes_files.cpython-313.pyc | Bin 5872 -> 5924 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 9450 -> 9530 bytes webui/backend/app/api/routes_files.py | 2 +- webui/backend/app/api/schemas.py | 1 + .../filesystem_adapter.cpython-313.pyc | Bin 18098 -> 18331 bytes webui/backend/app/fs/filesystem_adapter.py | 3 + .../file_ops_service.cpython-313.pyc | Bin 25881 -> 26054 bytes .../backend/app/services/file_ops_service.py | 19 ++- webui/backend/data/tasks.db | Bin 159744 -> 163840 bytes .../test_api_file_ops_golden.cpython-313.pyc | Bin 13562 -> 14490 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 27592 -> 29039 bytes .../tests/golden/test_api_file_ops_golden.py | 16 ++ .../tests/golden/test_ui_smoke_golden.py | 18 +++ webui/html/app.js | 143 +++++++++++++++++- webui/html/base.css | 4 + webui/html/index.html | 23 +++ 16 files changed, 218 insertions(+), 11 deletions(-) diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index bf8324cc3f4b7a360709fb64a07a1b0543c4c028..6953a9ad0004994437e173795ac327dc8b1208a0 100644 GIT binary patch delta 340 zcmeyMyF`!oGcPX}0}$jfY|XUZ$h(PsattTuXL> z0?JR$;%L*<1}W4961P~=GILUk6oAZP79gPj1Q4_IfV?8T$&#FNWDP+~1t3w(3?$$x zj6lLhlP__Wv1>ByvAe(M67nc-4txL+!&neQM z>?)|qXt+6Fa1EmX*wJ9y93Td23p+A4OwJYltmVacgIDSUGYg{^;{#sl53C@@2Q~&a XkuU5Z=4WOGCi4$WAm-*)k!&UaYgmLFqku^F|K6t zTgh0Y0#vKXbc-z|H7B(s70fM?1Bw+h0SN_#B1Iroqy-~nK{7Hx;w3`^!v`(~9^rnw zPP-Z9^J`|-bl6?s*15o<^Rt)*sGvzohOvrWzqBAHKQTqmEg2*R1e2F@6tn378AW=N z6*y(t^g&Gh$y=B;1pvs1_>BX{=-?uRHQMvlFOgbWb;WbKPE=a%@VwBEMg$N zx0s7dii-3=ES9AF{G1|#$t8lCj7FR139exj06P|Jt^>rZC}Bs&>dAA2Kdb8A;FbEo m%)+SqfLHnhE0F%c#=s`>g&oNH%*?=K{(%X|+PqUFn+X8pnNeu~ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index e9dce69aa04461558d5ad5e26e4b25f061585cb4..cdd28a314ed30e4bc008ef5821bed231a56d4023 100644 GIT binary patch delta 1693 zcmZvcT}+!*7{@uLYvJt&Ev4Trw0!8smN`uP7<1UU4Jj+#m_tRJv_OUORXlI;qjI4# zD_o#tk6ALegk=kcn0Z0`$kbh6{K#%hTr|jti5Fg}7shNCdgu8+Ef|bV-d~^hIsfOJ z=XuUK?{BfsI;^*?R;^6^GG!lUuB=#xG_jYO9eYeBX&frwRYC7+HU72@Lw`0qwoxI| z15;AZypWa_6_VIxqPX2JT1o8iHXTqN3EZz9-J$Si*}7((iLJW>uf}F`YleVyvW8Nw zDD5%ZMGJjbzS9?l6KH{PAHcdOiE1AuZ3i5-?#Z!iYVc%QpUh6^>0evvecOY+!%zaP zFv0-G9p<>W=fIOXSVi$nbPHk)24(erS8B%jp!)QPNp!WQU{Vz-+@%Fz9pVQ8BD$ zdrRbLq54s^^7qxvLg$o6Ap`R2(Rb4!Zoy zv}Onudo{(G-sAqeaTZDl6)^UVwm?-CBo)EuaAcEdoFDTZkTc@eY7t#$mt@uaSmeuL^^Jbs@iIwiLYBK3&)C;&+&{gf4^f%jVEM} zVyRZT?vJ}T9S5AKUA_%0a82_tfhBq*&?4ryrS?_8Bd*dC^@Y5K z7fU+ddFX&^FkS-qj-2O!KSEyHb@;Xx#N^?NZ~++8O$w@g<>ta;#n_9Sbf)IL)>Sq1 ze`jV}H=*GQUWYXUpbT|DI%^yJTf6>WP4WCJLJ6$W2enDD$Z03SuHd)Imv@Y1^jvSd zLRP*U%caLp%gxFomk|j-siTiL`N1_quvo=quHuJqs_rI~+~^Q2&h-gcdx57ExaBw| z61YjzktVTJ+V(@cZz*5i&8a+|PRW5`^~szX%l(@Ve+y;D=0wYw2)IREbpf$V<8?hF zQ8@YI%HPXQymkTnVM@Xp2KdtRt>&x6*MyJzJVyD<@!8;w=Z)kRayPhwW!hICQb#mV X|Ayr*5nPDfDN}!Uv=4u-H;aD&yi9-M delta 1648 zcmZvce@t6d6vw$qDcx(|qoqG+X-j`l;=-oOH0l^x6^BS!ZP*yPVGI^%u-H-d-rE+K zCQ|~;glr=xB)AEVgh=B47>g!Ejak%x`2+u18WI!#)qnhB`$rQK@Ao`l!Pw+|dfvU? zbMHClp8LM*{v)G4R#l}+{%rPdin9;Y5rvj)2k9#%Lccn~p*A_A124jO9`N!IJ@CIu zA35Ekm9`uQLum{GtuUVI(sbGvqIIp!qxFrK%K70l1!KHCW)yxuL4Rv6huR?rPQZu* zynZ|TExE3WlN3^0?Ax}%a=AoZYFg=l2slZ%)K<~4vqOw}4k&atpin^byK@jZfJUI3 z+O(re7ABCR%UWDycRVxVEXUG=Y2Yl3IKZ*=ux_aKXVFj3xZf0=bjjW6JdgQKpr3wp zd#SqGSKEtMpkF>lo_Zr0y}ZITPtjh4qKp3Y1jISA*L`Xq#J~`V>Yy?J6DZO~ z-7zu1P6xb`&t8;cxk)D~5dScqD~}cQJhbNkYXMlpK!Mi1u?C*@{O>e&8o>;3k(|EQ z#faSLhM1t=e6PuePoMT#WOjJD}le7XQAXHDZ#n`i~(g}Tq38xMGN&W$rv{4kJlmVZN)J2x>*<= z$eX%O!C*>UrDMUvR)7lP(t%l;3zEzL&E`&`73yd-qT$e}k|7i(SX<8Z&QvFi;%kSmE4!9diIghD$=Gth%SN6<6)dDJ$*kyy9mKgPv76 zpR1O!2nA;bcFH$e=(c#FE=6T3~d-B;0LQ$tO z6{j}SZ3QvUzF~We+%*dQ6lqDW!z%^w8E|Fzf)K_~aW2mS-hg#_A(|2keAySH0kJ{% zqe0u{9bISMdj4*0V3ZRT6WH-tN#8 DeleteResponse: - return service.delete(path=request.path) + return service.delete(path=request.path, recursive=request.recursive) @router.post("/upload", response_model=UploadResponse) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 52e1ad2..1a7b8a2 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -52,6 +52,7 @@ class RenameResponse(BaseModel): class DeleteRequest(BaseModel): path: str + recursive: bool = False class DeleteResponse(BaseModel): diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 3e9eebce130af0717db7142723088a1b1b5964af..05cd24de1141bdcf4ceddaf19e04bba3666325e4 100644 GIT binary patch delta 1981 zcmZuxZA?>F7`~?;mqKZQegLJV7FdBR2o55{rD`eAWhRQ_MB_FWQs53QR50h(ZMr3F z(JWD?PEQC+h!V+?#cgqs8{%TJfBVqsGUp^&?);(u#(r(OEpgkAz3*uUG~p(F`n=D3 z-simM+;i^2X)=43xE{M)MG`zrTGSq&bKRLr6G@tu4oHkpiCHLNR$5IhRnnM^*=U$r z1+&u))FzlrBh)Tf0j;63U=CVK3j`|!<`Ari*3m-2oKR6DSTQiCU@lp5CLhES*Ey+ct5H-@$TvSFCx{;RUA`fk%&D4{Nybx`o-dyB6;HNFrhtU>XEIh27 zwo*STtOByFv^<-g%B3rXSdmu}5L#uPRwcARo)#2ZRh||SS};$mrXdy{aVeY4vZR() z^&@&lHTGD{^2Q-cwhe(+K=_fzn}JMAFR!HfE&TUFKP6k>mEjYSMkMwu5XEB3`sm?| zmNsQ}FvC=psUYj<8i_F61G8#aO{*DoU|3`7P-c=H8G!%8OxIph&1N2}479D28Alg4 zz}wP&hx@GSr0f0RB}b5L2Wj_^jmKMkMpxhgv9sf#a3%N-nJ_*H#w_Fn|E0P|J`E~6 z!`s3;OQ(UFR+f65c~HE--wXGVH;wz@K|<#Fj>x8pInWgwYr}Lq!bOC6ek{^UP8l~N z7f91dR6YVw3Ru<~g|i3>LI7bS!W=(Wn;uhQ6UR~5a?BzE0A|IYOlo}dW ztl3=={mG+hYFK4Do|Q=M6|*mKbPXrXUVgpBEq@E4{tAECvDGMOea;Fq^tJiPELYo( z)vq%Ty9eXhB7jnun~A&INBDzyfS+kEA$N_7?FRPQ-_h>*5r$p1uQtS%_)JH&{58nA z4!-Mfli!T{9iNgU?$vS?0Q(6*oXW&5<^+D!8q>68JP?Y!x|eKa?V=~sni|yEU;I*s zSN;Zu<|g>N(=9(izeBLZP9wSXXG>Vj6bErRj9|kwLjUv0B>REyh*kLR{;%!Z(-~oZ zLd2f%u~;;QCu14Ux~PEces%|?Ab=^SwM-_h<{i5=UGiJ8kP}}FTSA!Ok77}BgFlQ{ z^3B^yEEZyPZTkoJZd*Lwi5-~ML2V>^4%aN)w7sfL3}=_GppgtW9d<_ChO2xr-b8MK zX@gt=y1oeO_VSvpW#Zw^L^ohxLIFIIh{zro!2EnJQ5DKkG_qxlWONp)k1r)Qgw8{J zv}7oiPNxRbYUHiR?#T%?JHXFNy8}~~ut4-Pk8JNsy%${_Yh1;R75Vi>TG%NlP3BdI zt0KHVLt7vAEqW8)1S{(D=eUOBM8)ST>jIe-k9pMWAvsllhGTP8VH8VOn%<%HFMg+WK>?;I3>I{z~yMb_%FZTq)W!OG` z9K|=6IglX~!%zQ}*Y-YR|3fDB5Pz}vd*^Y?MiFlF_GA+&;{(Y`66S9tn@aZENcAHL HQP}+lZy?Ee delta 1772 zcmZuxZA@EL81CENUdl&5U_haiQtaAsS3XoQzyghYN0YgkhGx-iE&0v^v8VPQ*60r{qgj9zn}Bo zbML|_`1}p9|8BPznef-xyqtLZwEgCM3{0lCOv5G)%*4$MxP^I{*<+eexWa0gMKUY% zF-0$!x5i6-ZWy%qCe8dw>;6R*V%zl9eDUmdwrqY$vlLpI0V;J6Mo~ zn1e!SInmn1oLiQQH8732wyaXNn}u2FmQ{vUBP-job_|!ZCbomDN9c(7;}xu#m6L~8 zVzil67^3ds@(FZS8k|)!(QWWmOJ9}2=aIf@gRe&VJO-ba)$rP}60JqwXTDRZmw5gH zTSiTd&!rPdVe=k^N&Td^%M8cFHDAAaoK&9>rFDI_*678otPfuLTI7A z@Sc7+^ei+`56zsPja~A3bLCj`_pS(hpm#Q$09X)fS^z%Pf7HAdSk{%MGFp$jr7@B} z!a-U4N-fC&3T3 zw?4Rzi62?>%J?lRcN0`0q^t-i@IQ%HP-2z1*zSN${c`(7ct)!8Q2KY2P7-_fN{L4- zU5Ug7T@6jmub1ccB86rw(O8P#rPBXRS`(|CA@xs+%;GXS^x&TFNi=kO;fC;ayUV|& zn1XO{e}|v{hJ^ni9_tQv(T&(}Y>OV^-;k>YA)_V}>2xw~xNh4tiKT9DF+HN}B80fr z9fYfLcg~2@e9xr&Bma;NZ_lG0q#$F7CC0M%Z`(05%JYfo(d=R6JvQTK4EG@4f=l8| zPgp$FRxNJ#IH6qssRsZY;^*Ez#O6K?@lc;%bzliE6)*RBp3YKgp=4XAq)>WYl(h#< z&BiCmdw?LBV{alm;v9)4lhIf*?tjt$#LRR&TUsi9?W>+YN2yZdVy;MbcG5iCwt4dH zmF@4BY38qC{h*;jzCW_}$JASD&G!bX2pDJRIW@~Uwj@^iD?p-KR+9$fCd4oO0q_ae z!1(?%RQEo?1%giqz99G#Aybk{N7IRssd##PW|Y$hiZ>9f5UdhhAy^~0O7NAqF;D|` zu`%F^z`A?OhY0 GO!^mPv61Zn diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index 96809f4..ab69c90 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -104,6 +104,9 @@ class FilesystemAdapter: def delete_empty_directory(self, path: Path) -> None: path.rmdir() + def delete_directory_recursive(self, path: Path) -> None: + shutil.rmtree(path) + def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: src = Path(source) dst = Path(destination) 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 3414f7fd96ca7eeb89f8b95b67d2afa2dbb1fdcc..4ff0c43a39786cefcc48a5da7c95631329798e38 100644 GIT binary patch delta 2217 zcmaKtc~BE~6vy{Rl1(@jk{A*YVhBh`If_C>0-9Ko7UWzg)N~3+RwtGcW&?;y)z(&f zAfRuwti29M)YtM zF-%PeJQtftBrr>x2*+YmCyEQE~!ks5fqSPlB= ziWn{|kG6Hr922L~VgmGQwK8c9QeO6B zo44YS%j12Nn3)VF_T5dIzzwi zAW=edTxFcj#W(Y9e7(yp@J($VVO2dnw+mi(2M>qhEM`_nq_P6-&jdn)A%R0@HNryS zWhwNai8qJ-Os81SjD}=DN&Kp1m*R{U5>iHH<&GFFBdO+*Wb@ng!|6r-^rACH`}I&+ zqHdItX?3F!j9NRaGWu1^%!N559ZPDJwks{H-s!h&FX3z7U>P$Af9+4MS0Ck(oJe4s)bQj^{+%S;-)qOYK36$&oBMkt}nh`3e$V zma?5V_$mk(hDZnOO-d(g;8Kz!ekFAW)OL3>U*78Fc%j4H#Amd(HhUUfXMI*$dr||Td}kZq;&ppkgkI>* zoRbz*2}lKxr%iYb^Lr7mBX%NoA>M!=GP~rJbe+BLF<{Nr`8H-nNTnNT4E{Em!@Z3i zys(L0txf`KTH49@O8Ds;w$*D#H)p&sH(!v9q(`ah-al7~WKLZHvK5=s}} zBp-sc)JEp`o-JKTNFV%JmP{T1L;2N{ib`=Nq1m-t%2Nz1GV&>@m;MoNXQU`Icv!?u+({f z_y?G?577=gohjPg)C|bHZGyX%o?4sfgD7soA-L$&gdf4g0fZFpIVq7|@fmpRpfo{blxA=aE4c-k(iK*e3Ie9`{ zh2O*I8tJ?AWu6vZoFgE0xtrRiHd4_+Y4y3FCjQHWG8s}1t@UbFY@&ph>J#TF@vfX0 z5-stbL`&Rs(Yh9l;`Wc_$>H1jWcz;>iw)E`N66Tc;a$?1#N8C{cmSoueU?LMLxT}N ztHLXYw-9>}pCC>mP9x5Ozd>WdPmXX9aRPA);YU1-Sd1t}9ERw|IQrnFH)bntb&*V2 z?5(A~<&8Zdj%^qQ#0A7xh%1Qi5#JzwLc}755Ge?JwgL*vlO9)lGhZkqfyH%tnh;9N JHyEUt^bhQ7KUx3) delta 2049 zcmai!du&s66vywcxA(5y*rR)O>mKdOV3cm%m~3=z1va1sHn+j0xVY(7XvLCcU2e-5 zI0PauaT}0hFrvKF2x>5rI|L!}h-@lTw*_&dfWt_LL_($rM2ykjxqARHO8@xu>}vUT44VC6s3Ww*bDpT#KXSabcO2$n30f$dAK(( z9NtZcCJH!gh=rltF!(-Ul3ZDqsC|$nTG(%l;Z%a!Iow&L${wjk>JmBD$t+C4jyP3S zj4-Z%H837VU>ewB_IXmB3QnDwQ;zn)Or3hJQ|TeqhEd}ojLX8%_G-;&Mu-wLLbMQ5 zJ$^LfWb?>yoI?bzK-ut!U?>U>Z-mIC<0Kl+B`tty$%T4O)YCDM>NBE%ScE8|@N*Qf zJK3!6p~~x+A%z+Gl2^^VtTPUzuCeFKXJc$bM^OpSAKr)B@>1ik28>7tWr}oShzOx3Qfn&74HGGcljW zU6CfcmF==}cAIoH^>roRgmzZKn(-G%0EF}`vI^FxJCaw>bTH2Dt@l(k`UQ{JEBUE8f@)85z|-LO`WnPG$j*6U@|avuDf)Z?aToTtA>Ks1h1iYQ1FbnNA>}CF z!N66APBKjyr&MmBHP}Dd%==wU9&sZq&ohN>!TwE%&9Emgmq(-GRydrb)jVXsnPyEI zYJYsJ*iPdhTkmNI@}7pJKDS5Q0eSheNGq($w~+O4Fh7sDq#yFbIkEvx*)1VWUbn|b z*QFKh;$^q3aHhz?Z^KR7A;R_y{W-ig133!qvGkv9Djvqj$bdfl8vA*u|$7D zyWeNUqc}7o(pzeREhWaVPcb*5cBCYOs$D5rM81Kbqgnc3RxB&qn96UYZL2fEm#2pW zM+A?8;#oiurz2JOkO&r15M78bpv_T6Zb?B$D=IB3&83cZmF7GUnDc+b*=2JTkqWrv zSP4UAx5*K>_EZVUkuu6x64DNPD<+Vs&{=WKFo3%*Aub}Gr10Y#W)Sh66OX}Hm1a^1 zcPh_5uC)^`F32V`K)vwv<667n#zM1t7QMB^AL$m*RK5)RgqX*T^}u<-L>%y!@HhVp zy1NMq=k(Fg?wm9jK|wBF)m)DkDqI!=dV@ z$y(rQri681&&LQq6xU2lJV3)B=MRY9M*7uQ?+u9ZIUR>}HQKPxu<;~90dLpj@TV|5 z4X?ze@F#H42;J32vPMdM_A((H{P~=|kfT4Th!?X-IeZ6|^yGI)7Wi;Xii1D%p$PX+ZhkQmH+^?%k$BR?kiFgNb2yq7SCE{yD zFTCW^n%XfuhUi3eBYF_?5M_w@h!b$ql|=tEL07Ko_ZE`LCETr%VwbclEP&-5h#;aL j@e|@YVi0iyk$^Z4z3v2(1~=T_M*7sm{2PN5C(_zKDJm5} diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 187de64..7aa8637 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -158,7 +158,7 @@ class FileOpsService: self._record_history_error(operation="rename", source=path, destination=new_name, path=path, error=error) raise error - def delete(self, path: str) -> DeleteResponse: + def delete(self, path: str, recursive: bool = False) -> DeleteResponse: try: resolved_target = self._path_guard.resolve_existing_path(path) @@ -166,13 +166,16 @@ class FileOpsService: self._filesystem.delete_file(resolved_target.absolute) elif resolved_target.absolute.is_dir(): if not self._filesystem.is_directory_empty(resolved_target.absolute): - raise AppError( - code="directory_not_empty", - message="Directory is not empty", - status_code=409, - details={"path": resolved_target.relative}, - ) - self._filesystem.delete_empty_directory(resolved_target.absolute) + if not recursive: + raise AppError( + code="directory_not_empty", + message="Directory is not empty", + status_code=409, + details={"path": resolved_target.relative}, + ) + self._filesystem.delete_directory_recursive(resolved_target.absolute) + else: + self._filesystem.delete_empty_directory(resolved_target.absolute) else: raise AppError( code="type_conflict", diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index f8c1e17ce976c13128ffdcdda861de739c152cc7..5c03607846c2d6d34c2bab9a0c34e457a5a20887 100644 GIT binary patch delta 2860 zcma)8Yitx%6u$S)?)I@WJG5w_qEHcN8@)I)_s*+Q3P><90s>Z(QX}(3NeTQwNeIa@ z_&^d3R1+s+0vd=BDTPv?MEy|=Au%ChXaqxoDKtb;Fg$F92+=#UO<0ze?EXC8+4J4+ zJZD3H*@g|}TPGxk004FEHBE4+YuTIS#0%m%@fY!wctSib z_I6j)>=3!yfOH?<_wbl-#{itVvG;HaQPrjDalbOWD z#J7p|#OsNsL=|_1JB9|~$>?=?zk042X%^Kb$C0t+TNG33_}KPL6T1#28c__xHf^vs z_H+zt+qQ-^M#ph<5__&~gM)S9ds|>_&*cF)p3g3Y&47EJdmPX9SpO`0nDy)Xh!gInz zelYPUyu+V>ecT#;C%=wg#Mh<&gS$&Q(x=mV6UVsgsY-5p`knOBw2}^&UxlaQeAq-$ z4BwP6F^I+rBvWju4#A$Kcq$>LPIVU?iBwvI8j_;gd?;oj7%@{<`U4Nj6l+ zQ5{wk6y+N>RI&t^4?aB68PHVn&QN!B&XFlARYUC>p>HN%h2Oo8R$$@6e}*fkbS z2_GAPrQsDBO-A60aMA!=AKqfvG~0J5Wd+8rZ8GILu~@@tE>Mnp7LW~BLg*z}JLe&<~H7c@yRAgU1(wfuCh@xpP{p(gupVS?3cvHm(=*Fk%hr^xC|Qx(oxGH+Pe|M*bQIo(Gr;=xV6hp@ z2uQlQx*C9$#8lN{de!yXyft1J?1VV{sSlQggN;asG(0M!u}!RwEWIqMk{7j@wjMF+ zl3Y#hZIzrYm>tM`bM+(ug4qD5YB2*5b9{VlQ&rfjqiJFGG^`81p(qp4u!`JA!)(Ev zKpLibHlitJgWp55Fzh@FAILB@czT$jEMh2T^WSCYAIF6J@do0C_fs$%OBBsZ VR5716g%rcxFuWjBjEgPE{{g+50K@4g}={y9AL0 zXaqk576a`A!~>fF`2%tTNdq4OBLejS&yj&=mnY!?td~^s0VoEc(0sx_x zP2vHl1Stbm0++E90we+egO|?o0eq9!zbTi6j{zvR?Bf9#3{Fi0o&W_H2ZIKU57!T_ z4}}k04>J!62h|1X4&elF4!8vF4vz$y4rT^B2Qdyk4ipXR4Z;LQ4Veva4Mz$3UCTn3O={sKmxZ10tI8SfnNoej7$O&w+c=I`VkZe2WkKg_zma`#tS|Q UTnTgtY6ohufl>#zYG(qL2$Qyj)Bpeg diff --git a/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_file_ops_golden.cpython-313.pyc index 1976108f9e36a563c6f1886f00682c2e75e0fbd9..2bbe150c522c068b47b461a8286609582b8faa4f 100644 GIT binary patch delta 797 zcmZwD&1(}u7zXf}-A%H)*=&+-wrLVA=?Aq*o5ZvgS`h5Xn_nGERfJm7wNx~1XVZdT z)T35G%1f~b;-%_AJd`LNZNWotHYix4sQ-aN3qlX-y94&3b9ng8*E8(yPClG5w?iSF z;J8U%O)g|tO--8rjMF7@o^Z+tml)+TYhqH8TvE7#l7)OM!4x4suK9!lD8G=33J7T^ zRY*r^LP3-+WT1jVA(SChGLa#{Fv=8)pu$2?R79u&6&12j4MH)LC1j&wLX9X}D9##r z(?mRztbUi|F1lOY&b_7ZHKx%ySZf=A=`I!YoUW9mVF?DCW8Q*4O@<{VLnN07D9p$F z0}_n2H{tg&m1#`RONS)y2NF`2hslrz>k*5}@J+U95Ekqf^qTB7X_mp%gY@rdtR+KX zJZ;0GVu`0ewTI?I$Lm&ksyymknXX(N<&aLN2QW6BADnmZ20+m?-RAKFgV8D!^kQGA_ zs|v-e8dPP~p&Cm`;d{LA#IlRR{iPx0U02j(oD F`vYN2QZ4`h 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 ca384f275f9235753ae5b9081b568298414c093f..adc5126fd2e7d0f2a767a33d4396919850328869 100644 GIT binary patch delta 1504 zcmZ{je@q)?7{`0P9&0J(%4iD=smGNLE_#fqm4KGPpxc~a4BaKD>EO7&j7odgT#qTu zkU!NKvmcL(`)7(jx1k{H+#G)_@jp$Bi9bkYO#H`a0x?FCxy+ahy?gKIB*9DW{k+fj zdEW0mce&@wo5UYK6P34ozH*0g4Z5yoo;$Ksxj{Pns?Tyw`a`b!eUPF8fU1UIJ{W*h zyWfoU+f}bP!PAKY;BBr2hH&u>JDv(!`Ixr}F4%kSF-se;NW2J7Q4u(2M5g_+&7Uk1 zC*ZAMq-t6qV@|U_hl6qKaU@@Y?}9HAGy1ckL9)A8ok_>pF@(~iqBOzhl(d*-i@~iT zDip+Hh%MG^k)^CsK>EeH7&TL@Gc#$FMH=EIMIOtjxlONzZBk-BpPk}IHJL4jw@FK) zETOEGukXXI%$Sl*Bb5>5G-EtKL$X%b_jjdvI5(mH5*~398+xMgniD#kX=rYtVONBP zCyc!};?rxIz96BKrQreN-q=l-J-m2i#oYYo4&Ra_}qmyD^zvCS2g(A2HT$ zzL9)eusCb;9fPx_?0dHQzReE|uD3RlAKLLnfx<4AY+e@1a6BjM!2C#XV}2}nFz1DG z%mu-V`H4_rl30YnUf#2mypq&AoN^@`?QNu%lAk7ZSm=!!`c=gXT>-Prbn1fr^`}@9kj?|A$%955*WM*?r5?Pcpf4~;57%3%{JdzLCb2MO% zQA^I68?M+;D#A0fwTgrA!86gQHm)i!Fp`Qy4fW(R2~}0p78Mopid;YsGHFp0Iha5A zZh}Rs+A1k&#KhxqhD~QwBx#B|H7qOIFv{h%DYnR(%~*reYfYooQAR_#*1V{V>-~qm za?LQMzO2!;Yyb0l(Ne|#IGSc$krNA3vUJa`FgcpbXxlWv6=|d0NwttUjyfquQzwxg z8hG8mW6l`2!s!ZqF4g4(Sq#IE5)G@vOj-Q;@fG(0_-FVKEQ>V0&D39`w2np=2v|+i zO;*hh?ut{sSAE}vdOm-2#eKLGHeU*@xZ7Y+tktVW+uWW(C)Ivu+|^6!{m&mJYK~Gw Yf;jKKS<8J}%l+xV;rf0r%um$+3qcC(-2eap delta 719 zcmX}pTS!x3902h1olEX^wlkZ~Ey}HJ#uC>yD;cR!X%pv#Je|meL6>3nV30u;xM7wp#1*Ne_WRJw!be3Gl#48WY3^t#iKf(f9KEU%vl${%{t)p|^9$ zS`bAOqn@#~>7J$br`9!&Z7K$Dr5{K6auH_i4%iu!pi6K-k~*M^^Y$zY?@WGZtCiro zBduLVeUP*JprTG9qjySld+S7g8zY~C52b^sA0KpfaQL@7EZ`x}Nfr&`)y>aYXb#x$ zLf|6@Nxuk|t;?IfZ*iX}=Z_<8KY9dJj$u%tX#@6Ft_qNgIKX9;U_Ru44D~&AG@{o_ zOu8{@7c~^UNakNgCE|H?TkV}rv+gBoTlIEQ|4DPvLi%{Vx|YNBHD_6E8o!AhK-x7N z+LaSxdXsuf_2_ON(z`>nI!wK-`saUlM6bf@y@$w>p@s@1kXJ_) z-7#ey$;Xu>ggR$&a+4Sdrx14p#pg9;TZKKE^BYSr)1iY50*CM;qa5 z=X(65WtzvXj%!lUgnG5_ml#4yyfbJSfe+y0S A@c;k- diff --git a/webui/backend/tests/golden/test_api_file_ops_golden.py b/webui/backend/tests/golden/test_api_file_ops_golden.py index 6732a5d..f12247c 100644 --- a/webui/backend/tests/golden/test_api_file_ops_golden.py +++ b/webui/backend/tests/golden/test_api_file_ops_golden.py @@ -300,6 +300,22 @@ class FileOpsApiGoldenTest(unittest.TestCase): }, ) + def test_delete_non_empty_directory_recursive_success(self) -> None: + target = self.scope / "non_empty_recursive" + target.mkdir() + nested = target / "nested" + nested.mkdir() + (nested / "a.txt").write_text("a", encoding="utf-8") + + response = self._post( + "/api/files/delete", + {"path": "storage1/scope/non_empty_recursive", "recursive": True}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), {"path": "storage1/scope/non_empty_recursive"}) + self.assertFalse(target.exists()) + def test_delete_invalid_path(self) -> None: response = self._post( "/api/files/delete", diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 68480d0..e8ac8de 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -65,6 +65,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="upload-modal-count"', body) self.assertIn('id="upload-modal-status"', body) self.assertIn('id="upload-modal-cancel-btn"', body) + self.assertIn('id="feedback-modal"', body) + self.assertIn('id="feedback-message"', body) + self.assertIn('id="feedback-close-btn"', body) self.assertIn('id="settings-btn"', body) self.assertIn('id="rename-btn"', body) self.assertIn('id="view-btn"', body) @@ -132,6 +135,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn(">Target path", body) self.assertIn('id="batch-move-popup"', body) self.assertIn('id="batch-move-apply-btn"', body) + self.assertIn('id="delete-confirm-modal"', body) + self.assertIn('id="delete-confirm-apply-btn"', body) + self.assertIn('id="delete-confirm-cancel-btn"', body) + self.assertIn("Delete folder and contents?", body) self.assertIn('id="mkdir-btn"', body) self.assertIn('id="copy-btn"', body) self.assertIn('id="move-btn"', body) @@ -189,12 +196,18 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn("document.documentElement.dataset.theme", app_js) self.assertIn('document.getElementById("theme-toggle").onclick = toggleTheme;', app_js) self.assertIn('document.getElementById("upload-btn").onclick = openUploadPicker;', app_js) + self.assertIn('function feedbackElements()', app_js) + self.assertIn('function openFeedbackModal(message)', app_js) + self.assertIn('function closeFeedbackModal()', app_js) self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js) self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js) + self.assertIn('throw createApiError(response, data);', app_js) self.assertIn('function closeUploadMenu()', app_js) self.assertIn('function toggleUploadMenu()', app_js) self.assertNotIn('if (event.altKey) {', app_js) self.assertIn('document.getElementById("settings-btn").onclick = () => openSettings("general");', app_js) + self.assertIn('err.code === "directory_not_empty"', app_js) + self.assertIn('openDeleteConfirmModal(item.path);', app_js) self.assertIn('async function loadSettings()', app_js) self.assertIn('await loadSettings();', app_js) self.assertIn('settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;', app_js) @@ -225,6 +238,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('async function ensureFolderDirectoryExists(path)', app_js) self.assertIn('async function executeFolderUploadPlan(plan)', app_js) self.assertIn('async function handleFolderSelection(event)', app_js) + self.assertIn('function deleteConfirmElements()', app_js) + self.assertIn('function openDeleteConfirmModal(path)', app_js) + self.assertIn('async function submitDeleteConfirmModal()', app_js) + self.assertIn('recursive: true', app_js) + self.assertIn('err.code === "directory_not_empty"', app_js) self.assertIn('input.setAttribute("webkitdirectory", "")', app_js) self.assertIn('await apiRequest("POST", "/api/files/mkdir", {', app_js) self.assertIn('await uploadFileRequest(targetPath, entry.file, overwrite);', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 1ff42c8..8007f56 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -47,6 +47,9 @@ let renameState = { source: null, name: "", }; +let deleteConfirmState = { + path: null, +}; let batchMoveState = { destinationBase: "", count: 0, @@ -164,6 +167,15 @@ function setStatus(msg) { } function setError(id, msg) { + if (id === "actions-error") { + document.getElementById(id).textContent = ""; + if (msg) { + openFeedbackModal(msg); + } else { + closeFeedbackModal(); + } + return; + } document.getElementById(id).textContent = msg || ""; } @@ -281,6 +293,24 @@ function batchMoveElements() { }; } +function deleteConfirmElements() { + return { + overlay: document.getElementById("delete-confirm-modal"), + path: document.getElementById("delete-confirm-path"), + error: document.getElementById("delete-confirm-error"), + applyButton: document.getElementById("delete-confirm-apply-btn"), + cancelButton: document.getElementById("delete-confirm-cancel-btn"), + }; +} + +function feedbackElements() { + return { + overlay: document.getElementById("feedback-modal"), + message: document.getElementById("feedback-message"), + closeButton: document.getElementById("feedback-close-btn"), + }; +} + function settingsElements() { return { overlay: document.getElementById("settings-modal"), @@ -347,6 +377,28 @@ function uploadModalElements() { }; } +function isFeedbackModalOpen() { + return !feedbackElements().overlay.classList.contains("hidden"); +} + +function openFeedbackModal(message) { + const elements = feedbackElements(); + if (!elements.overlay) { + return; + } + elements.message.textContent = message || ""; + elements.overlay.classList.remove("hidden"); +} + +function closeFeedbackModal() { + const elements = feedbackElements(); + if (!elements.overlay) { + return; + } + elements.message.textContent = ""; + elements.overlay.classList.add("hidden"); +} + function setUploadModalVisible(visible) { const elements = uploadModalElements(); if (!elements.overlay) { @@ -486,8 +538,7 @@ async function apiRequest(method, url, body) { const response = await fetch(url, options); const data = await response.json().catch(() => ({})); if (!response.ok) { - const error = data.error || {}; - throw new Error(error.message || `HTTP ${response.status}`); + throw createApiError(response, data); } return data; } @@ -1831,6 +1882,40 @@ async function renameSelected() { } } +function closeDeleteConfirmModal() { + const elements = deleteConfirmElements(); + deleteConfirmState.path = null; + elements.error.textContent = ""; + elements.overlay.classList.add("hidden"); +} + +function openDeleteConfirmModal(path) { + const elements = deleteConfirmElements(); + deleteConfirmState.path = path; + elements.path.textContent = path; + elements.error.textContent = ""; + elements.overlay.classList.remove("hidden"); +} + +async function submitDeleteConfirmModal() { + const path = deleteConfirmState.path; + if (!path) { + return; + } + const elements = deleteConfirmElements(); + elements.error.textContent = ""; + try { + await apiRequest("POST", "/api/files/delete", { path, recursive: true }); + closeDeleteConfirmModal(); + setSelectedItem(state.activePane, null); + await loadBrowsePane(state.activePane); + setStatus("Delete: 1 success, 0 failed"); + setError("actions-error", ""); + } catch (err) { + elements.error.textContent = err.message; + } +} + async function deleteSelected() { const pane = state.activePane; const selectedItems = [...paneState(pane).selectedItems]; @@ -1849,6 +1934,16 @@ async function deleteSelected() { await apiRequest("POST", "/api/files/delete", { path: item.path }); successes += 1; } catch (err) { + if ( + err.code === "directory_not_empty" + && selectedItems.length === 1 + && item.kind === "directory" + ) { + failures = 0; + firstError = null; + openDeleteConfirmModal(item.path); + return; + } failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; @@ -2089,6 +2184,10 @@ function isBatchMovePopupOpen() { return !batchMoveElements().overlay.classList.contains("hidden"); } +function isDeleteConfirmModalOpen() { + return !deleteConfirmElements().overlay.classList.contains("hidden"); +} + function isUploadConflictModalOpen() { return isUploadConflictOpen(); } @@ -3150,6 +3249,13 @@ function handleKeyboardShortcuts(event) { closeUploadMenu(); return; } + if (isFeedbackModalOpen()) { + if (event.key === "Escape" || event.key === "Enter") { + event.preventDefault(); + closeFeedbackModal(); + } + return; + } if (isInfoOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -3204,6 +3310,19 @@ function handleKeyboardShortcuts(event) { } return; } + if (isDeleteConfirmModalOpen()) { + if (event.key === "Escape") { + event.preventDefault(); + closeDeleteConfirmModal(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + submitDeleteConfirmModal(); + return; + } + return; + } if (isUploadConflictModalOpen()) { if (event.key === "Escape") { event.preventDefault(); @@ -3393,6 +3512,17 @@ function setupEvents() { if (modalCancel) { modalCancel.onclick = requestUploadCancel; } + const feedback = feedbackElements(); + if (feedback.closeButton) { + feedback.closeButton.onclick = closeFeedbackModal; + } + if (feedback.overlay) { + feedback.overlay.onclick = (event) => { + if (event.target === feedback.overlay) { + closeFeedbackModal(); + } + }; + } document.addEventListener("click", (event) => { const elements = uploadElements(); if (!elements.menu || elements.menu.contains(event.target)) { @@ -3514,6 +3644,15 @@ function setupEvents() { } }; + const deleteConfirm = deleteConfirmElements(); + deleteConfirm.cancelButton.onclick = closeDeleteConfirmModal; + deleteConfirm.applyButton.onclick = submitDeleteConfirmModal; + deleteConfirm.overlay.onclick = (event) => { + if (event.target === deleteConfirm.overlay) { + closeDeleteConfirmModal(); + } + }; + const viewer = viewerElements(); viewer.closeButton.onclick = closeViewer; viewer.overlay.onclick = (event) => { diff --git a/webui/html/base.css b/webui/html/base.css index 2448b44..a0c0a37 100644 --- a/webui/html/base.css +++ b/webui/html/base.css @@ -656,6 +656,10 @@ button:disabled { box-shadow: var(--shadow-elevated); } +.feedback-card { + width: min(440px, calc(100vw - 24px)); +} + #upload-modal .popup-card { max-width: 320px; padding: 12px 14px; diff --git a/webui/html/index.html b/webui/html/index.html index ae00d78..9b09bca 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -108,6 +108,16 @@ + + + +