From 9537a29de3fa4b1dd241d32b4991af807cdf4649 Mon Sep 17 00:00:00 2001 From: kodi Date: Sun, 15 Mar 2026 15:51:13 +0100 Subject: [PATCH] feat: feedback verbetering - 06 --- project_docs/API_GOLDEN.md | 2 + .../__pycache__/tasks_runner.cpython-313.pyc | Bin 31712 -> 35144 bytes .../__pycache__/routes_files.cpython-313.pyc | Bin 8468 -> 8699 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 10371 -> 10472 bytes webui/backend/app/api/routes_files.py | 2 + webui/backend/app/api/schemas.py | 4 +- .../delete_task_service.cpython-313.pyc | Bin 7041 -> 9875 bytes .../app/services/delete_task_service.py | 93 ++++++++++++++-- webui/backend/app/tasks_runner.py | 88 +++++++++++++++ webui/backend/data/tasks.db | Bin 368640 -> 372736 bytes .../test_api_file_ops_golden.cpython-313.pyc | Bin 27490 -> 33362 bytes .../test_api_history_golden.cpython-313.pyc | Bin 31848 -> 32943 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 92597 -> 95765 bytes .../tests/golden/test_api_file_ops_golden.py | 102 ++++++++++++++++++ .../tests/golden/test_api_history_golden.py | 14 +++ .../tests/golden/test_ui_smoke_golden.py | 64 ++++++++--- webui/html/app.js | 36 ++++--- 17 files changed, 368 insertions(+), 37 deletions(-) diff --git a/project_docs/API_GOLDEN.md b/project_docs/API_GOLDEN.md index 817945b..10bd06e 100644 --- a/project_docs/API_GOLDEN.md +++ b/project_docs/API_GOLDEN.md @@ -90,6 +90,8 @@ Notes: - Batch move is supported as one task-based operation via `{ "sources": [...], "destination_base": "..." }`. - Cross-root batch move is supported for file-only selections. - Cross-root batch move with any directory in the selection remains unsupported in v1. +- Batch delete is supported as one task-based operation via `{ "paths": [...], "recursive_paths": [...] }`. +- Single delete remains supported via `{ "path": "...", "recursive": true|false }`. ## Tasks read endpoints diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index 1b285864fbb8b3d56a16abfad2b81a8b8a542803..f32105d96672f302efabb9191f0db560090648ec 100644 GIT binary patch delta 7002 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 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 3e487360aab71bd92f4ce5eb61bc9b62238618cf..1ae7df527311b9d1a4a29cf1b83965e5900b6e41 100644 GIT binary patch delta 1333 zcmZ`&UuauZ7(XXB_cpn=$!*i-Uul=rG`c(6MRZWcTDBP1b~;C`yE>dR-Zpn@Tw>z6 z>4d`QpBLZe!?&S0HV_$fgH2~a5rpnR_T)pt9#Zl!eDGzkhrk3KzIeWq(3A}?#adT40#r(@}1 zy4;moDi=ytZvWJRn-FkopDPEsl^udLZAhI!im(qhw6E!5NOy@m z97A{$wtB`i>;l6PvRU}MCn0~p=^2Ri{!ZVA$GxNU6hs3_Im^{gzRLvYMG|g?!E~Wt|EiT(K2$0)sW-Cg#juLZMy4w|!ALhiBO8dl<|h zd#>ys`(LKB@UTB5=ed0ip7zglpXO{?N#%>yIU8m}=jmDaB{;lik;{(6As&x2gTEWH zaA5_`FTp>-8G0Jhp=Iv=Ybg1`^X}ft;!<(`Q!8I!B_47Wq%ejY4}V3c;m`1hBC7ig zzVrrF#vK^K28=~ksRMT+u@wi!5C=nLIQE9bd%+GNbI+Gb1vZHecoADIh#?a}5zY)x zt3?%54qv)yZ^6c1o6>mqAttv#%l~%ibJ86cHb*faD*a-1Kk)~sB$N#TA zjAiUDSPa(7)zN1wZl@BLv-7FeKZ2ow75xTxPJ=jfC3Vn}m`v#g4l0STVnlh56LX^l~T}You#C4oD QT|y3Qt`A+3+-p4Re*{Y?WB>pF delta 1086 zcmZ`&O-vI}5bm?xO1o_-^bab9Hqb~B)DR+|m8ca&B}BtdL}Iki1&TB+Z%a+^K)HBQ z6CF(CU}8c-(2Fr79z1z6F&b0C0ka3>>d&EulkwurYn1xKCj0I8X1+IX_RY+X$igYx zw$*CZ;dArOi}-u@bDPNw4%2WGZ$z7B;>zd@SK}Eaq}R$vxe`q)S7XYQf}eC$y)IO| z87|7c_7>dW$5-8t@JW}|<;^;aV?z$E$-$M@XW=K|SFIb7{EXRLAGn}(NGM}U+!7=z|4X_Sy z*w*)IQYxCBP~nTMmUTgCX;!*`iU=%}eq|Aux7V`Mn7RvI+RLOq^z=c_{@UJ2p}Gjp z5Ol+GS(7|Sl(OKxuvu0sT|znx#`1S;0M^SJ+2DOgP`ZM~iKPYy3y2tpN&Y)fNY8f+ zXW_iF$|!t2kZ^iydQqzy5by^Jf2c{BcuYz15j3V?*}3UDfsFr?-J46T3Mr6$D_(Q$=OqT@Dvbd$W^GmG*iPmqnl8&8## zK;I&K_H@fb$Y!NOYBZxjz~+X&iW{sG-d5BZMQTaBQK!sPs1mRY&dMyCh6j~3bJJvW zBZSPH3a;tZH0N!^3?s?p6hBG=YRAG=*ANq%Dv*Ur3hlF~)hQ}y%3A8Lh6AX8@dCiwYSxz7>cx6`*-`GVg$Gy}G!pW+Aq0S11^_Oj zjbmSNPKY>JU8~))Qn}K(`d(8XFcQfDxrN$XHGwV!Yyoxv-GC0ffuqrQQ6NsgxRRnn zExMVGxy!{M-Ee=P^*{*>QmoJP=!m&;v?J#D-Ni+hLjcU_g_aE(4rD)0z<%VU=P^}!U1-3 zk3Ho^H7O{81GKZCTBP_(lLbTK2vrnTiAowSTqQo4{FZOc` zI&O>4Er_=J)|iXfW%S$ zdTFSY{wT8f&#HNK1dz;rQ*Ka0Dh`%=d5ZTBxyVm|Pq`6}!vxOKd%?Bh`0O^!FLqlm zz_|^`^yCSc06QM{paPI|T;-g@J0-e%hS} zH_zA{hwC$YdMaC0*Rmg!|CtqK(^qcZG5HzA9UlK_i~+>S&jG$Kr?a`$ifdFAUa6gd z2e?N2!U1uH-QR>;!jo{s06s%L%`k>v0*6q6b`B;mNp+D$;v5H~X}*pW1g@+3l$-By zRWjMzAD6v#rrzHnkMM;)k5~XY`7Xc-o}XRh^{CG}h51Yt{KbnH15J=ARd?R|g z_7)t^Xt}4hjBEH1YHldB-V^O+L%RhYx6J?4na;a}7~mFpO16tjvl)L^;`RTk=2I>% zyH@@GNXj>#s5*KXp#WqC^2Hqr-nIlY)n8U?rNX~K#l?hRaT1-dRswPCgyRZK0AeFZ zcU)fjXOUA};cOm;!t`@kqsEv|+@qdY(25JrPnB#${Nk$^1NRVbS3oAwX-jm*;W^xx zN#`m}$J&OgA*}`Y-$OI3R)8O6Je&MD^5e$u4!s3*+omPy5vR+#31nOX-}7{$%%1pI#{^XkziG%_3I3U-w{+`tu;~;%iG*ihlu% CVg&R6 delta 1867 zcmZXUZA?>V6vufsEO6V>LV>hH>d>6H0{^Nc25qQ7!0HncC;n%i!xR+jcWX?s6^X+g%>tPkjx z-lp#w(oO6BYGsr8Kv5J5O6%jT@jkit)@5J-m)&G zcv>)!GOaGtlHHjFgGp<S{8)-dL zppRI3+DsW~8Kav;ErIv2E(RolUZ4-?R|W8Coe+Tpy;*FCPTu=ObR`vd>O`8FJX6{> zxBzMT)3aP`W0z=&Sw0K{vYK(3+l>zs*7l*Dz3F6Xz>LcTT`EZ_--V_1HVHqIk24?w z!!%ynC=A}~PU-0Kaixoo+R-s{p~zkEO#s;qAX&f=?Jf%k*o^-9zZrpzzygfZA7$&s z2!*|$i%B}?U8mCauXlx*z=d36a04dQ&s;`{V1z#Km54FbeQn|}{pPE$*asiru=+hh z{Z-4_%WvnFXA82Aqgd~M;ywaTZjg^5?WWm9C8C@90xzpWj|Ccv_bPQp)hj72y+4t( zO-J$We7#=~af|vIqD?;i-GRhvxD} zdqZ`lZ097wNq!9+<4PQb2%M&ep$2hO#oI&!SBD?ukuz9j^Twvxz%77glf0Wo3UpB>9z6 z&RrQ2_K@FD(&6!+f(T%p{1)J+aY}8tfxZZP#SDEPuGNq!3Cz%JI3P~*c|Eo;9dc>( zYNRH70}hn{7Yu>$@RDaB?L{QoS%|<5Iv0tGvpknP6=i{&N;g`}d0%H3J9Z}Jj+mwI z?3R<9IL;RUKY0M)OP=Qqu2lH!ze1Ni9V;Ri@D0FV<|(M0<=gkSW*1)I>#th$UE}X? z&1!)jcE1V{xJRv(HR7srU&AH2 zROu5B=$Fcn9nXXduHhSaK%uIjxJGNL-W_j(lIJ@nHVK9ymM; z_@(nx=EueT&%Mo^${oe+!sV8z9hZX>Avs9CI$y?-L+)y?_=%QO?{mB$LSM!n=cs>= LwTnMi*NXoDvPRHR diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 2ccbb8f..23252d7 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -34,6 +34,8 @@ async def delete( request: DeleteRequest, service: DeleteTaskService = Depends(get_delete_task_service), ) -> TaskCreateResponse: + if request.paths is not None: + return service.create_batch_delete_task(paths=request.paths, recursive_paths=request.recursive_paths or []) return service.create_delete_task(path=request.path, recursive=request.recursive) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index afb46c4..e350fc8 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -51,8 +51,10 @@ class RenameResponse(BaseModel): class DeleteRequest(BaseModel): - path: str + path: str | None = None recursive: bool = False + paths: list[str] | None = None + recursive_paths: list[str] | None = None class DeleteResponse(BaseModel): diff --git a/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/delete_task_service.cpython-313.pyc index c85e51e632a8a43e65d8d8596f2bc112963ad422..e5fd94769584287b597f6047acdc803788c281e3 100644 GIT binary patch delta 4295 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 F{u`Bjo>Bk+ delta 2032 zcmZWqUu+ab7@ygl-Mv4(Yp=a)uYb7i^&dTi7HUhq7Md0c2$2>#T_9Z3^XzT$%5|^I zT>&MA!h=?y(2*w}G|>lm5ls_)Lj<3EP#a82HYUomiKsL&A<_8F-d(9Ocl*unoA2Mu z_kBCdk6!8bKlb@N2*%>AFEZDIpZgQ|G6VKHnnDay#3YKD96{6*L7RWnOXUVMN9Hgi zw1WC5DRBz*Q$=8=(n_j2NTXF$bC7GwO#{>=aG!_@gL$Z%dCy3(YCAA$X3fINKll>v zo|D8TBFI1!xTv_9o~oueE?IIyF0Jw#(PsXuTo-a}Rx9EOFJFz;1SLbJvI0w#7=$Wu zVkriH7z~Ahlc>5O)#5}KVf}ntsN;a4P?sX$8qb8OKm8M62Y>D zhEjVFvQ#IG2vWfxk)znh_bSnvP+1ESRtX6TBxHV038~evG0m~?2nz!n+C(iMAvOHA z(%a;VtFSrEgwj~*h&qdB(O$V3&Ei- z7V}IOjFd^oB-SY)$rQ|a_8h>{ii2%+HkHYm>6q$RJ8MkmjanamogdelDl_X)6?omGF}awGEru>n|oZ5DqX;+AUg!<E?W;m}t30DU^4*61{Q>!2zz6tVWTbL*hy2YB1(=y~(ZLIH!vj2iY)ky_ zrY_zP^vbd!^Uh#A(1TDy$em%x6NE>Vnx;}a3BD}bnBOCzAjMr$H?~MOvJ@&=>iBJN z_gyfqy#=i!TPpX5>M;ZeodmiR+NwpMJJ3X(sF7R>5-kN;gf3n3-(EM+co<;JWvhi0J7X4DE3h^>o3YX?%CFQ} zUN+#YSgCoFKdIY4X3JUAifOD?xX=ZO3WZ8$=5yKeW;$7NbWDhj3o;HQFM35U4%R4E z<3Q{-{(W;j|19iPj|e4`e;wZ0@>3-GQTJNQz-r6D{m9^*+}gqE)q~S(2WM8{A4>la zGnnNH(WEr7xj{>z5+af_!Uir^r#yp^ zJSnZzG;WjdNj|jYBEG=C+|m(x5e!7~^Ep;9)9f^`>?D7*C0yrZ-IdBG7jpT`MRrOk zYEZpMq-m?DQ8Be5$u_x=%AVaw@Y2#!F~Js3MMm&>{&D2fq%JH}L9%5e-tEF7vurY! zv&@)W6we84-Xe3P4RD=pWi9ID)~_$PnOVwj?QJ} zL9}(*`xo)JPvAe0L&M1JTb_Ksm+$`3)xh`YAL5(*XT1jx@#<&?esd)r9mG2q!3Bd? z4i%jb1F{>O4~IFK%AchsJD-^`yOLJ!LNb%jy~96gxp7n%`eNV3ZX~bda#^+@c=5e} zTTT~vLxEiptdQFzotY`b2ou{c=3s$8Z0)LZjL*R$T$>JdvQoi2+6G$>2wl;@Y)X(J zAa;ege={@lX5rObnho=}+ZrSPJBnW@3sMPBewyEH>kn*K!s}DGL-DL1!_`X5dM6LG o_v1l+xc#?=|C-ybJ@dKrV%8jD@ABgvy8>@uoW_45XL1tuFHpOX2LJ#7 diff --git a/webui/backend/app/services/delete_task_service.py b/webui/backend/app/services/delete_task_service.py index 9dc3308..f152e8b 100644 --- a/webui/backend/app/services/delete_task_service.py +++ b/webui/backend/app/services/delete_task_service.py @@ -25,7 +25,13 @@ class DeleteTaskService: self._runner = runner self._history_repository = history_repository - def create_delete_task(self, path: str, recursive: bool = False) -> TaskCreateResponse: + def create_delete_task(self, path: str | None, recursive: bool = False) -> TaskCreateResponse: + if not path: + raise AppError( + code="invalid_request", + message="Query parameter 'path' is required", + status_code=400, + ) try: item = self._build_delete_item(path=path, recursive=recursive) @@ -71,11 +77,82 @@ class DeleteTaskService: ) raise error - def _build_delete_item(self, path: str, recursive: bool) -> dict: + def create_batch_delete_task(self, paths: list[str] | None, recursive_paths: list[str] | None = None) -> TaskCreateResponse: + if not paths or len(paths) < 2: + raise AppError( + code="invalid_request", + message="Batch delete requires at least 2 paths", + status_code=400, + ) + + recursive_paths_set = set(recursive_paths or []) + invalid_recursive = sorted(path for path in recursive_paths_set if path not in paths) + if invalid_recursive: + raise AppError( + code="invalid_request", + message="Recursive delete paths must be included in the batch selection", + status_code=400, + details={"path": invalid_recursive[0]}, + ) + + try: + items = [ + self._build_delete_item( + path=path, + recursive=path in recursive_paths_set, + include_root_prefix=True, + ) + for path in paths + ] + + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="delete", + source=f"{len(items)} items", + destination="", + task_id=task_id, + ) + self._record_history( + entry_id=task_id, + operation="delete", + status="queued", + path=f"{len(items)} items", + ) + self._runner.enqueue_delete_batch(task_id=task["id"], items=items) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + except AppError as exc: + self._record_history( + operation="delete", + status="failed", + path=f"{len(paths or [])} items", + error_code=exc.code, + error_message=exc.message, + finished_at=self._now_iso(), + ) + raise + except OSError as exc: + error = AppError( + code="io_error", + message="Filesystem operation failed", + status_code=500, + details={"reason": str(exc)}, + ) + self._record_history( + operation="delete", + status="failed", + path=f"{len(paths or [])} items", + error_code=error.code, + error_message=error.message, + finished_at=self._now_iso(), + ) + raise error + + def _build_delete_item(self, path: str, recursive: bool, include_root_prefix: bool = False) -> dict: resolved_target = self._path_guard.resolve_existing_path(path) if resolved_target.absolute.is_file(): - files = [{"path": str(resolved_target.absolute), "label": resolved_target.absolute.name}] + label = resolved_target.absolute.name + files = [{"path": str(resolved_target.absolute), "label": label}] directories: list[str] = [] kind = "file" elif resolved_target.absolute.is_dir(): @@ -88,7 +165,10 @@ class DeleteTaskService: details={"path": resolved_target.relative}, ) if recursive: - files, directories = self._build_recursive_delete_plan(resolved_target.absolute) + files, directories = self._build_recursive_delete_plan( + resolved_target.absolute, + include_root_prefix=include_root_prefix, + ) else: files = [] directories = [str(resolved_target.absolute)] @@ -111,9 +191,10 @@ class DeleteTaskService: "progress_label": files[0]["label"] if files else None, } - def _build_recursive_delete_plan(self, root: Path) -> tuple[list[dict[str, str]], list[str]]: + def _build_recursive_delete_plan(self, root: Path, include_root_prefix: bool = False) -> tuple[list[dict[str, str]], list[str]]: files: list[dict[str, str]] = [] directories: list[str] = [] + start_prefix = Path(root.name) if include_root_prefix else Path() def walk(path: Path, relative_prefix: Path) -> None: for entry in sorted(path.iterdir(), key=lambda child: child.name.lower()): @@ -127,7 +208,7 @@ class DeleteTaskService: continue files.append({"path": str(entry), "label": relative_path.as_posix()}) - walk(root, Path()) + walk(root, start_prefix) directories.append(str(root)) return files, directories diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index a5f7f86..c7ea4f4 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -89,6 +89,14 @@ class TaskRunner: ) thread.start() + def enqueue_delete_batch(self, task_id: str, items: list[dict[str, object]]) -> None: + thread = threading.Thread( + target=self._run_delete_batch, + args=(task_id, items), + daemon=True, + ) + thread.start() + def enqueue_archive_prepare(self, worker) -> None: thread = threading.Thread( target=worker, @@ -490,6 +498,66 @@ class TaskRunner: ) self._update_history_failed(task_id, str(exc)) + def _run_delete_batch(self, task_id: str, items: list[dict[str, object]]) -> None: + total_items = self._total_delete_work_count(items) + current_item = self._first_delete_item_label(items) + if not self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=current_item, + ): + self._finalize_if_already_cancelled(task_id, done_items=0, total_items=total_items) + return + + completed_items = 0 + current_target = str(items[0]["target"]) if items else "" + try: + for item in items: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + target = str(item["target"]) + current_target = target + kind = str(item["kind"]) + recursive = bool(item["recursive"]) + files = list(item.get("files", [])) # type: ignore[arg-type] + directories = list(item.get("directories", [])) # type: ignore[arg-type] + if kind == "file": + completed_items = self._delete_planned_file(task_id, files[0], completed_items, total_items) + elif recursive: + for file_entry in files: + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + completed_items = self._delete_planned_file(task_id, file_entry, completed_items, total_items) + if self._is_cancel_requested(task_id): + self._finalize_cancelled(task_id, done_items=completed_items, total_items=total_items) + return + for directory in directories: + self._filesystem.delete_empty_directory(Path(directory)) + else: + self._filesystem.delete_empty_directory(Path(target)) + self._complete_or_cancel_item_task( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + ) + except OSError as exc: + task = self._repository.get_task(task_id) + failed_item = (task.get("current_item") if task else None) or current_target + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=failed_item, + done_bytes=None, + total_bytes=None, + done_items=completed_items, + total_items=total_items, + ) + self._update_history_failed(task_id, str(exc)) + def _cleanup_partial_duplicate(self, path: Path) -> None: if not path.exists(): return @@ -556,6 +624,26 @@ class TaskRunner: return files[0]["label"] return None + def _total_delete_work_count(self, items: list[dict[str, object]]) -> int: + total = 0 + for item in items: + progress_total_items = item.get("progress_total_items") + if progress_total_items is not None: + total += int(progress_total_items) + continue + total += len(self._file_entries(item)) + return total + + def _first_delete_item_label(self, items: list[dict[str, object]]) -> str | None: + for item in items: + progress_label = item.get("progress_label") + if isinstance(progress_label, str) and progress_label: + return progress_label + files = self._file_entries(item) + if files: + return files[0]["label"] + return None + def _copy_single_planned_file( self, task_id: str, diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 8e6eeeed29fd569741a5c58ebcd3eb98cd370a91..e74a73fc29fef13b424c3cf2b7e4549e3dcbe964 100644 GIT binary patch delta 3252 zcmbVOeQ*=U72n<4J)LC9G8u3&CdLB-WZ=Z*YR5oA!bf7J*aqPS%aYurgTXG} zpSB~60moo)%O-6~7@9U8Ey2b!+_b|VBvU#x?NCA{C8eY>O+q_E+CbWrfWh?LISwE8 zAD-xs+u8TtzV~}?-|wv&Ph>atWVh$Cxd@@9d-IF&+^(u8rt=AHcp?SSXuf4FE;y>t zI%&Lj1_;p74)?*C#5<$AB#t6lxa*w5cvgUhXyH-W)A{>SUsZ=PO@L(T@A zjmprcNT?K4K_VZLW2DV{&U@IK_Ac=%UNQ4R=F!ZZnaeVbOt&ZS`iv(tCb@39&bnT6HM%NXvWq&;J3F1*os~}2>6AW{ zj!A7&LW)XW$0f&c$3Dl?GtsAxBF9MaviPRR#TDXgF_(SLPO*b*9eaXJq`mYkeU&!Q zaypF?;V(j`u$@Fn7QTd!;|}}`F2>oA(G=OLax@r8YQbtvPKJ$YsB(3+9Es|N9Ia6k zHPz9S5{^gEmk41VY$s#8OS1ylF0xUb(u2vUA;Z}SoYQLLNGhSp)saLv6xNk!$_Sww z9p|POP`^-yJAw;*{5xy$6kfZH`1z65SmYPGsE?qNe8~>tC+JoFdMl`xwqTieETBHT zhWEAN06+UIo@Be=Zw9cB*R_FB{80)g{>OjZh6{Mh9O@TQmmQ^cvyWf4EM{y6&8XdQ ziCde%$%I1YN&wH$J7k0wOwP(P-f|mL`zP=XRtcltQFetVsOg z2iOO`cW=k#{0GfIb6Z+e3FSV~j2roXx8aF=OfzKi;x-6(8E%lEU-E-#*c3GbzI+Sc z)@SjQ{a;_!$_ntA;u$Sg$e-CEdnzF1z%DRt%VQAye1DoZeE8vG?U+`%E6{0-*W&qj zyyGy>yn;32Nb{LjaJ^|>!S6DHdZBHvEwY>qhvWFRWjUNNf^YHoD(p9JU&G`vFED-| zo`#&Ko&R(Z&rDN#n2g2z$~=-|K2kznkczZIT`ANeF?>5m(21(3hOC6-puQ+*Yg8?U z`vz+?qcEt)Ae1!{O?@u$2xEq{Qlc?@)2v)TenH&(Q9D499+LvnNK+~$V=%>k$ESGJ zLJQ48@+Yx>!Zz3o|HtD*y1MWk04D|}mcic6j3E%R(ADImO|n>^59mnaZa zRK-wZs5f7jHB_YnK}`!Op%}U`*uXAWz%ZhEFc?GEhcr;40WGSAqIwK{d5-}<-AyF^ z#`DZ&9_S`D$E7`ZHgb-Z`b?=#$i;l`3HqR27;|C0Q02g-&|35FRLWu9WDH&4or_5h zKeCvN zAE$mc-){Auav#gGsn9gbniiM#Hvy-5U{v)_lMB-Ykhbx0^Q`f<9a@h1@gm*@1CF5A zc+Py-CU|2jp3Wb5p25(#W;@$*4M@MW6+8IJ`PL};ksWMArH^@R{l!(xXO8x*iz>g(k90))=b|L z-XWim@pu{f9jN<%O{3CVGRj7c%Z@QI<0<*8?vlwo;!pYu0dPR5GN~J52 zNa%h{a4KX3B3dLEj>PVoqq70uhC%qgNlTXj)cda3?(TtMHwbD_4;T?mF^v1lXZL?V z(V%{znAyu2libjPhTM?=LS zj?F=*hfasG(-�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 delta 726 zcmZ|MKWGzC90&0GXhrndT5BLww30R$c!}Dk zh1kVG2bD*`-NjAxfs3so!O=kq3LWg=po=1i;!^eZduHh34d45G-+T9a!#!U2eEtR2 zchd}N_-u)1mCaL6tan!HC)N$Eq;X&x_b}i(i!o1JTQskxTD5;{J;B)HX(-DZS%IOni-<*#c3x5s-!1 zq6gBVGPeO4@n^2>b3xPUj<~xJmAe<(It+=2{iIo6l^3iR>iji!u|2V zr+sbmuJ)@2>eGA4nD09EKr-GIZW!Q?^3C2VDlt(>p4dzZ~0a z7OFS_|I=S!GW)0`o!5e;1FM}@Z=z3MtoPcycIB7kbs#rT3N)S=sIWNK=^0&@&Yg@i z0|hC`>|VAbBC^_HYDBh2ke!240=1Er&m5%9m^FIP5aRp+Y#L?gju7kT*??Dxa|a19 zwvA@u>CrORedhUDA-0o*waJQihunB|Xf2xdEjN(hqbJX`rH~2xd)W0%mX?kzW~k80 zJn*zADr_&~WySoaEJ5UWe-~R?EN~INgDs9(wYc%X7=wQ8 zTBVm~Xep6`6r7%MY6HV;Y>+?eR8?wcn~B7Q@6DdZ-_E*l=$jgx`zA$Wrp(pgmFugp zc`g@E&hcpe(<(XrMi5j@=_akx%Qqj@v@PGN+qvZ8Z#y-Wv$t~b?yZ=(`gSg+D2(9k zMb$1k-;6EuKSlRnFQWMlk0l?k!u~t0s?532jDNUOE{`mfB;qHt={WgSw&8K`K8I7Y zIqTgf9U-v)TIBV)S*NNuB?~ zXC@Thkftd`?WE7DvQPOEReIWIQ|X&NyQ+3ZdPNV#_Or_F=al!C>!`3pI-`edm32PG zqVKWZim{whn*o*?9m@2AG-!Z_jkGeHB*UD{x671jA`iJi^JUQK>iB+T$`242j7}M# z&;&lL=eI04u=1TGZ(*8>#4Y9XxH6YJ5OTy*t1mSv?B;vjH4YWqQmYoo)js{eufX z`NF*6E3@rObNXk>Kbp4_&f5}SBmQXlk)~NlTXye=4a8%8%wx%>ls^d4@lFtnJ|spT z5FjJBfv;T}tK4gbIxft*DNW@gsj3Th(j+AOs|4O7@V<1m3o_t}^ida7~b+t3Gu?$HlZ#nnPdZZhkhYzL8-LTpA8)cPih8}+~%wLflK}d(M grTieYq_JsG@}l|6boXcJ?nND$&3g)@mx7S-UteNk&Hw-a delta 1444 zcmYL|4Qvx-7{~9q>&LC_wH>3~aKPHIuQG6pj4uVUmCp?}GRk2VzE+`V7245tGXq&c z{1}qK?BjSrg9wV?RJP?+D}E5jKruS9>2QP)gE1Nnm^8F=FkyJ_O~PI7_kaG+^W44f zbGd7~sr~dPtvMk{22MHX7oYj}PwO)KXKK2P9EW}GG`er*3?p80`!%)WS!V87x_o|| zNAJRM^zfoykC}CErx7=KRCWW@~BBockJjBb*Ct+w`S|8yfhL)^2z6MmD6t zagb;SjQe(DNmD6bsKw(=1$w>An>mk&15HzHDIS9?6bc@r$E2K+$2?mb&|p#0j)jqQ zuwd16J1&&cur@L_#X4JXa8GltRBVY9&>tcPbo`J=H@;#O@%a`T{kiQh=r-{jSN9t> zl5*@P_E|=qWKIa8&Cr~Z9K(%ygk(jt;(S4>6ki3LIlXyloO3FpJ_*Z!jamunsOxxq z*|(B59L#aKBFY@0oeaAeUS!zKu#aIsLkmMIJ%7#(aFOb}zc=vlQfxVxO**#199n#7 z$O!_*`))7ItJLi{ zkT|J$JGLduur{8JzbN;xyne6@!-Ki_$)DNy=3s~x{o`i~*|B5Ds_NyT8LSZ|U~Mvo zF222KwA_6s7f;{L#lP;TWt={oi@S!CcuMAV%-4#|;z~fMJC9B*GDC)O6+SRD63CRq>&jC6o4+8 zoFp8PRe49&Gj~)LnH-Z-6oGe1s~K{H_hchW9+yo_-j^j-J3;a-P?&OZF?c>uu69ym zfjrfIsM^*C_9NAvwLlhpOs-p$O`T%moRq2*I;JaM4?Earmfr~+R~c$lY-k(9_E@;MsW+t4pz&!G6G1$oDd@zyD0?3M0P5~)Tw}ai>M7$ob zxuPs?V?c&C7!EKHhUXZ93=QOn2PVRy*clJ(1N|Q5PpbPpGv<+@QbSIA!5ZtThLxaS z-Me-}zqpYEo`P|)DXSr%CDp6JO4@6{LatORs9vX_qy{FE!S&4gK#I-tLcYe?Yk#bB zQNQ@a716X`+9UPar**FC7w0H;K+Nb9GkRmM`rwqwc$95u^bL@b5ZKZm4pF6)TtX<7 zbsQp_Lx50mpbo;1USOH?3@mbKLKP2UHG5T484UPb{AkNJ4N6>m8`K)dQ&-58h4KQ6GM0oAYq+93KYZt=M67pIYqHuvssE0+SZ None: + first = self.scope / "batch-a.txt" + second_dir = self.scope / "batch-dir" + second_nested = second_dir / "nested.txt" + first.write_text("a", encoding="utf-8") + second_dir.mkdir() + second_nested.write_text("b", encoding="utf-8") + + response = self._post( + "/api/files/delete", + { + "paths": ["storage1/scope/batch-a.txt", "storage1/scope/batch-dir"], + "recursive_paths": ["storage1/scope/batch-dir"], + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["operation"], "delete") + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["source"], "2 items") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertFalse(first.exists()) + self.assertFalse(second_dir.exists()) + + def test_delete_batch_cancelled_after_current_delete_finishes(self) -> None: + blocking_fs = BlockingDeleteFilesystemAdapter() + path_guard = PathGuard({"storage1": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=blocking_fs) + delete_service = DeleteTaskService( + path_guard=path_guard, + repository=self.repo, + runner=TaskRunner(repository=self.repo, filesystem=blocking_fs), + ) + task_service = TaskService(repository=self.repo) + + async def _override_file_ops_service() -> FileOpsService: + return service + + async def _override_delete_task_service() -> DeleteTaskService: + return delete_service + + async def _override_task_service() -> TaskService: + return task_service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + app.dependency_overrides[get_delete_task_service] = _override_delete_task_service + app.dependency_overrides[get_task_service] = _override_task_service + + first = self.scope / "cancel-a.txt" + second = self.scope / "cancel-b.txt" + first.write_text("a", encoding="utf-8") + second.write_text("b", encoding="utf-8") + + response = self._post( + "/api/files/delete", + { + "paths": ["storage1/scope/cancel-a.txt", "storage1/scope/cancel-b.txt"], + }, + ) + + task_id = response.json()["task_id"] + self.assertTrue(blocking_fs.entered.wait(timeout=2.0)) + running = self._wait_for_status(task_id, {"running"}) + self.assertEqual(running["done_items"], 0) + self.assertEqual(running["total_items"], 2) + + cancel_response = self._post(f"/api/tasks/{task_id}/cancel", {}) + self.assertEqual(cancel_response.status_code, 200) + self.assertEqual(cancel_response.json()["status"], "cancelling") + + blocking_fs.release.set() + detail = self._wait_task(task_id) + self.assertEqual(detail["status"], "cancelled") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 2) + self.assertFalse(first.exists()) + self.assertTrue(second.exists()) + + def test_delete_batch_directory_only_empty_dirs_remains_honestly_coarse(self) -> None: + first = self.scope / "empty-a" + second = self.scope / "empty-b" + first.mkdir() + second.mkdir() + + response = self._post( + "/api/files/delete", + { + "paths": ["storage1/scope/empty-a", "storage1/scope/empty-b"], + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 0) + self.assertEqual(detail["total_items"], 0) + self.assertIsNone(detail["current_item"]) + self.assertFalse(first.exists()) + self.assertFalse(second.exists()) + def test_delete_invalid_path(self) -> None: response = self._post( "/api/files/delete", diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 1670cee..7c8a553 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -299,6 +299,20 @@ class HistoryApiGoldenTest(unittest.TestCase): self.assertEqual(history[0]['status'], 'completed') self.assertEqual(history[0]['path'], 'storage1/trash.txt') + def test_delete_batch_completed_history_item(self) -> None: + (self.root1 / 'trash-a.txt').write_text('a', encoding='utf-8') + (self.root1 / 'trash-b.txt').write_text('b', encoding='utf-8') + + response = self._request('POST', '/api/files/delete', {'paths': ['storage1/trash-a.txt', 'storage1/trash-b.txt']}) + + self.assertEqual(response.status_code, 202) + self._wait_task(response.json()['task_id']) + + history = self._request('GET', '/api/history').json()['items'] + self.assertEqual(history[0]['operation'], 'delete') + self.assertEqual(history[0]['status'], 'completed') + self.assertEqual(history[0]['path'], '2 items') + def test_single_file_download_writes_ready_history_item(self) -> None: (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index c7b24df..29c703d 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -368,7 +368,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -379,7 +379,7 @@ class UiSmokeGoldenTest(unittest.TestCase): {{ id: "c", operation: "download", status: "requested", source: "/src/c", destination: "kodidownload-20260315-120000.zip" }}, {{ id: "d", operation: "download", status: "preparing", source: "/src/d", destination: "folder.zip" }}, {{ id: "dup", operation: "duplicate", status: "queued", source: "/src/dup", destination: "/dst/dup" }}, - {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "" }}, + {{ id: "del", operation: "delete", status: "running", source: "/src/del", destination: "", done_items: 2, total_items: 5, current_item: "folder/delete-me.txt" }}, {{ id: "stop", operation: "copy", status: "cancelling", source: "/src/stop", destination: "/dst/stop", done_items: 1, total_items: 4, current_item: "nested/final-file.txt" }}, {{ id: "e", operation: "copy", status: "completed", source: "/src/e", destination: "/dst/e" }}, {{ id: "f", operation: "move", status: "failed", source: "/src/f", destination: "/dst/f" }}, @@ -388,28 +388,33 @@ class UiSmokeGoldenTest(unittest.TestCase): ]; const activeTasks = activeTasksFromItems(mixedTasks); - assert(activeTasks.length === 4, "Only active user-visible operations should count as active"); + assert(activeTasks.length === 5, "Only active user-visible operations should count as active"); assert(activeTasks.every((task) => isActiveTask(task)), "All filtered tasks should be active"); - assert(!activeTasks.some((task) => task.operation === "delete"), "Delete should stay out of operation UI until it maps cleanly to one user-visible operation"); + assert(activeTasks.some((task) => task.operation === "delete"), "Delete should be included once it maps cleanly to one user-visible operation"); assert(activeTasks.some((task) => task.status === "cancelling"), "Cancelling tasks should remain visible while stopping"); - assert(activeTaskChipLabel(activeTasks) === "4 active operations", "Chip label should reflect active operation count"); + assert(activeTaskChipLabel(activeTasks) === "5 active operations", "Chip label should reflect active operation count"); updateHeaderTaskState(mixedTasks); assert(!elements["header-task-chip-container"].classList.contains("hidden"), "Chip should be visible with active tasks"); - assert(elements["header-task-chip-label"].textContent === "4 active operations", "Chip label should render active operation count"); + assert(elements["header-task-chip-label"].textContent === "5 active operations", "Chip label should render active operation count"); assert(shouldPollHeaderTasks(), "Active tasks should enable header polling"); setHeaderTaskPopoverOpen(true); assert(headerTaskState.popoverOpen, "Popover should open when active tasks exist"); assert(!elements["header-task-popover"].classList.contains("hidden"), "Popover should be visible when open"); assert(elements["header-task-chip-btn"].attributes["aria-expanded"] === "true", "Chip button should expose expanded state"); - assert(elements["header-task-popover-list"].children.length === 4, "Popover should render only active operations"); + assert(elements["header-task-popover-list"].children.length === 5, "Popover should render only active operations"); const moveRow = elements["header-task-popover-list"].children[1]; const moveProgress = moveRow.children[3]; const moveCurrent = moveRow.children[4]; assert(moveProgress.textContent === "1/3", "Popover should show done/total progress when available"); assert(moveCurrent.textContent === "b.mkv", "Popover should show compact current item"); - const cancellingRow = elements["header-task-popover-list"].children[3]; + const deleteRow = elements["header-task-popover-list"].children[3]; + const deleteProgress = deleteRow.children[3]; + const deleteCurrent = deleteRow.children[4]; + assert(deleteProgress.textContent === "2/5", "Delete operations should show done/total progress when available"); + assert(deleteCurrent.textContent === "folder/delete-me.txt", "Delete operations should show compact current item"); + const cancellingRow = elements["header-task-popover-list"].children[4]; const cancellingProgress = cancellingRow.children[3]; const cancellingCurrent = cancellingRow.children[4]; const cancellingSubtext = cancellingRow.children[5]; @@ -417,7 +422,7 @@ class UiSmokeGoldenTest(unittest.TestCase): assert(cancellingCurrent.textContent === "nested/final-file.txt", "Cancelling tasks should show current item"); assert(cancellingSubtext.textContent === "Stopping after current item...", "Cancelling tasks should explain stop semantics"); const firstActionButton = elements["header-task-popover-list"].children[0].children[3].children[0]; - const cancellingActionButton = elements["header-task-popover-list"].children[3].children[6].children[0]; + const cancellingActionButton = elements["header-task-popover-list"].children[4].children[6].children[0]; assert(firstActionButton.textContent === "Stop", "Queued/running tasks should expose a Stop action"); assert(!firstActionButton.disabled, "Queued/running tasks should be cancellable"); assert(cancellingActionButton.textContent === "Stopping...", "Cancelling tasks should show stopping state"); @@ -433,6 +438,11 @@ class UiSmokeGoldenTest(unittest.TestCase): ]); assert(elements["header-task-chip-label"].textContent === "Duplicate 3/12", "Single duplicate task should show compact item progress in chip"); + updateHeaderTaskState([ + {{ id: "single-del", operation: "delete", status: "running", source: "/src/a", destination: "", done_items: 2, total_items: 5, current_item: "nested/file.txt" }}, + ]); + assert(elements["header-task-chip-label"].textContent === "Delete 2/5", "Single delete task should show compact item progress in chip"); + updateHeaderTaskState([ {{ id: "single-move", operation: "move", status: "running", source: "/src/dir", destination: "/dst/dir", done_items: 0, total_items: 1, current_item: "Folder" }}, ]); @@ -582,7 +592,7 @@ class UiSmokeGoldenTest(unittest.TestCase): pollTimer: null, lastRenderKey: "", }}; - const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); + const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); {functions} @@ -617,6 +627,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self._extract_js_function(app_js, "paneState"), self._extract_js_function(app_js, "otherPane"), self._extract_js_function(app_js, "defaultDestination"), + self._extract_async_js_function(app_js, "executeDeleteItems"), self._extract_async_js_function(app_js, "startCopySelected"), self._extract_async_js_function(app_js, "executeMoveSelection"), ] @@ -693,6 +704,30 @@ class UiSmokeGoldenTest(unittest.TestCase): assert(refreshCalls.length === 1, "Move should refresh task snapshot once"); assert(statusMessages.includes("Move: operation started"), "Move should report operation start"); assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Move batch should clear source selection once"); + + apiCalls.length = 0; + refreshCalls.length = 0; + statusMessages.length = 0; + loadCalls.length = 0; + clearedSelection.length = 0; + state.selectedTaskId = null; + + await executeDeleteItems("left", [ + {{ path: "storage1/source/a.txt", kind: "file", name: "a.txt" }}, + {{ path: "storage1/source/folder", kind: "directory", name: "folder" }}, + ], new Set(["storage1/source/folder"])); + assert(apiCalls.length === 1, "Multi-select delete should issue one request"); + assert(apiCalls[0].url === "/api/files/delete", "Delete should use delete endpoint"); + assert(Array.isArray(apiCalls[0].body.paths), "Delete should send batch paths"); + assert(apiCalls[0].body.paths.length === 2, "Delete batch should include all selected items"); + assert(Array.isArray(apiCalls[0].body.recursive_paths), "Delete should send recursive path list"); + assert(apiCalls[0].body.recursive_paths.length === 1, "Delete batch should include recursive selection paths"); + assert(apiCalls[0].body.recursive_paths[0] === "storage1/source/folder", "Delete batch should preserve recursive path selection"); + assert(state.selectedTaskId === "task-123", "Delete should store the created task id"); + assert(refreshCalls.length === 1, "Delete should refresh task snapshot once"); + assert(statusMessages.includes("Delete: operation started"), "Delete should report operation start"); + assert(clearedSelection.length === 1 && clearedSelection[0].pane === "left", "Delete batch should clear source selection once"); + assert(loadCalls.length === 1 && loadCalls[0] === "left", "Delete batch should reload the source pane once"); assert(errorCalls.length === 0, "Batch operation start should not emit action errors"); }})().catch((error) => {{ console.error(error); @@ -965,7 +1000,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function inferDownloadTaskContext(task)', app_js) self.assertIn('function formatTaskLine(task)', app_js) self.assertIn('let headerTaskState = {', app_js) - self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]);', app_js) + self.assertIn('const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]);', app_js) self.assertIn('const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]);', app_js) self.assertIn("The header chip/popover reflects user-visible file operations, not every task-backed file action.", app_js) self.assertIn('function headerTaskElements()', app_js) @@ -988,7 +1023,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function updateHeaderTaskState(taskItems)', app_js) self.assertIn('function applyTaskSnapshot(taskItems)', app_js) self.assertIn('return `${count} active operation${count === 1 ? "" : "s"}`;', app_js) - self.assertIn('return task.operation === "copy" || task.operation === "duplicate";', app_js) + self.assertIn('return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete";', app_js) self.assertIn('return `${action} ${task.done_items}/${task.total_items}`;', app_js) self.assertIn('return `${action} running`;', app_js) self.assertIn('return "Stopping after current item...";', app_js) @@ -1081,7 +1116,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startDuplicateSelected()', app_js) self.assertIn('async function deleteSelected()', app_js) - self.assertIn('const result = await apiRequest("POST", "/api/files/delete", {', app_js) + self.assertIn('result = await apiRequest("POST", "/api/files/delete", {', app_js) + self.assertIn('paths: items.map((item) => item.path),', app_js) + self.assertIn('recursive_paths: Array.from(recursivePaths),', app_js) + self.assertIn('setStatus("Delete: operation started");', app_js) self.assertIn('state.selectedTaskId = result.task_id;', app_js) self.assertIn('await refreshTasksSnapshot();', app_js) self.assertIn('function startContextMenuDuplicate()', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 0a36ff3..76f803e 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -121,7 +121,7 @@ let headerTaskState = { lastRenderKey: "", }; // The header chip/popover reflects user-visible file operations, not every task-backed file action. -const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate"]); +const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); const VALID_THEME_FAMILIES = [ "default", @@ -2761,28 +2761,30 @@ function openConfirmModal({ title, message, path, applyText = "Confirm" }) { } async function executeDeleteItems(pane, items, recursivePaths) { - let successes = 0; - let failures = 0; - let firstError = null; - for (const item of items) { - try { - const result = await apiRequest("POST", "/api/files/delete", { + try { + let result; + if (items.length > 1) { + result = await apiRequest("POST", "/api/files/delete", { + paths: items.map((item) => item.path), + recursive_paths: Array.from(recursivePaths), + }); + setStatus("Delete: operation started"); + } else { + const item = items[0]; + result = await apiRequest("POST", "/api/files/delete", { path: item.path, recursive: recursivePaths.has(item.path), }); - state.selectedTaskId = result.task_id; - await refreshTasksSnapshot(); - successes += 1; - } catch (err) { - failures += 1; - if (!firstError) { - firstError = `${item.path}: ${err.message}`; - } + setStatus("Delete: started"); } + state.selectedTaskId = result.task_id; + await refreshTasksSnapshot(); + } catch (err) { + setActionError("Delete", err); + return; } setSelectedItem(pane, null); await loadBrowsePane(pane); - showActionSummary("Delete", successes, failures, firstError); } async function submitDeleteConfirmModal() { @@ -3901,7 +3903,7 @@ function canShowChipItemProgress(task) { if (!hasMeaningfulItemProgress(task)) { return false; } - return task.operation === "copy" || task.operation === "duplicate"; + return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete"; } function compactTaskCurrentItem(task) {