"""Các thao tác trên một task: thêm, sửa, chạy ngay, xoá, xem log — R08-T11. Tách khỏi ``ScheduleTaskTab`` để phần dựng bảng và phần hành động không nằm lẫn nhau. ``_context_menu`` là chỗ tập trung: nó quyết định mục nào hiện ra tuỳ theo đang chọn một hay nhiều thẻ. Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. """ from __future__ import annotations # Import muộn trong hàm ở chỗ dùng: ba lớp này nằm cùng gói và một trong số # chúng trộn ngược mixin này vào, nên import ở mức module là vòng. import copy from pathlib import Path from typing import Dict, List, Optional from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from ...core import tasks as taskrepo from ...core.projects import list_projects from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task from ...core.worker import AgentWorker from ...i18n import on_language_changed, tr from ...state import AppContext from ...theme import current_palette from ...ui.calendar_view import CalendarView from ...ui.icons import icon from ...ui.osutil import open_path class TaskActionsMixin: """Thao tác trên task. Trộn vào ScheduleTaskTab.""" def _add_task_on_date(self, date_str: str) -> None: """Create a task pre-filled with the clicked calendar date (default 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" from ...ui.task_editor_dialog import TaskEditorDialog t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) if dlg.exec() and dlg.edited_task: self._save_and_refresh(dlg.edited_task) self.status_message.emit(tr("schedtask.msg_created")) def _save_and_refresh(self, task: dict) -> None: taskrepo.save_task(task, self._tasks_dir) self.refresh() def _add_task(self) -> None: from ...ui.task_editor_dialog import TaskEditorDialog dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) if dlg.exec() and dlg.edited_task: self._save_and_refresh(dlg.edited_task) self.status_message.emit(tr("schedtask.msg_created")) def _edit_task(self, task_id: str) -> None: from ...ui.task_editor_dialog import TaskEditorDialog task = taskrepo.load_task(task_id, self._tasks_dir) if not task: return dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) if dlg.exec() and dlg.edited_task: self._save_and_refresh(dlg.edited_task) def _on_double_click(self, item: QListWidgetItem) -> None: tid = item.data(Qt.UserRole) if tid: self._edit_task(tid) def _is_multi_selection(item, selected) -> bool: """True when the right-clicked card is part of an existing multi-item selection — pure boolean, kept separate from _context_menu so it's testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" return len(selected) > 1 and item in selected def _context_menu(self, col: _KanbanColumn, pos) -> None: item = col.itemAt(pos) if item is None or not item.data(Qt.UserRole): return selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] if self._is_multi_selection(item, selected): self._bulk_delete_menu(col, pos, selected) return tid = item.data(Qt.UserRole) task = taskrepo.load_task(tid, self._tasks_dir) if not task: return menu = QMenu(col) run_act = menu.addAction(tr("schedtask.menu_run")) edit_act = menu.addAction(tr("schedtask.menu_edit")) dup_act = menu.addAction(tr("schedtask.menu_duplicate")) paused = task.get("status") == "paused" pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) logs_act = menu.addAction(tr("schedtask.menu_logs")) hist_act = menu.addAction(tr("schedtask.menu_history")) next_act = menu.addAction(tr("schedtask.menu_create_next")) menu.addSeparator() del_act = menu.addAction(tr("schedtask.menu_delete")) chosen = menu.exec(col.viewport().mapToGlobal(pos)) if chosen == run_act: self._run_now(task) elif chosen == edit_act: self._edit_task(tid) elif chosen == dup_act: self._save_and_refresh(duplicate_task(task)) elif chosen == pause_act: task["status"] = "backlog" if paused else "paused" self._save_and_refresh(task) elif chosen == logs_act: self._view_logs(task) elif chosen == hist_act: from .run_history_dialog import _RunHistoryDialog _RunHistoryDialog(task, self).exec() elif chosen == next_act: self._create_next_from_output(task) elif chosen == del_act: if QMessageBox.question(self, tr("schedtask.menu_delete"), tr("schedtask.delete_confirm", title=task.get("title", "")) ) == QMessageBox.Yes: taskrepo.delete_task(tid, self._tasks_dir) self.refresh() def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: """Right-click on a multi-selection within one column (Shift/Ctrl-click several cards first): one action deletes every selected task. The popup itself is a thin wrapper — see _confirm_and_delete_selected for the actual (independently testable) confirm+delete logic.""" menu = QMenu(col) del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) chosen = menu.exec(col.viewport().mapToGlobal(pos)) if chosen == del_act: self._confirm_and_delete_selected(selected) def _confirm_and_delete_selected(self, selected) -> bool: """Confirm, then delete every task in ``selected``. Split out of _bulk_delete_menu so tests can drive it directly without having to fake a real (modal, event-loop-blocking) QMenu popup.""" if QMessageBox.question( self, tr("schedtask.menu_delete"), tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: return False for item in selected: tid = item.data(Qt.UserRole) if tid: taskrepo.delete_task(tid, self._tasks_dir) self.refresh() return True def _run_now(self, task: dict) -> None: if task.get("task_type") == "manual": self.status_message.emit(tr("schedtask.msg_manual_norun")) return if self.scheduler is None: self.status_message.emit(tr("schedtask.msg_no_scheduler")) return if self.scheduler.run_now(task["task_id"]): self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) self.refresh() def _view_logs(self, task: dict) -> None: run_id = task.get("logs", {}).get("last_run_id") if not run_id: QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) return folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id if folder.exists(): open_path(str(folder)) else: QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) def _create_next_from_output(self, task: dict) -> None: """Scaffold a follow-up task pre-wired to consume this task's output.""" nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) nxt["task_type"] = "cowork" nxt["input"]["mode"] = "previous_task_output" nxt["input"]["previous_task_id"] = task["task_id"] nxt["dependency"]["previous_task_id"] = task["task_id"] err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], task["task_id"], nxt["task_id"]) if err: QMessageBox.warning(self, tr("schedtask.g_dependency"), err) return taskrepo.save_task(nxt, self._tasks_dir) task["dependency"]["next_task_id"] = nxt["task_id"] task["dependency"]["pass_output_to_next"] = True if task["dependency"].get("run_next_mode", "none") == "none": task["dependency"]["run_next_mode"] = "run_after_success" taskrepo.save_task(task, self._tasks_dir) self.refresh() self._edit_task(nxt["task_id"]) def _ai_create(self) -> None: from .ai_task_creator_dialog import _AiCreateDialog dlg = _AiCreateDialog(self.ctx, self) if dlg.exec() and dlg.created_tasks: for t in dlg.created_tasks: taskrepo.save_task(t, self._tasks_dir) self.refresh() self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks)))