From 5fb0f3a82d6102796742e1db9d2816bb94d77fb2 Mon Sep 17 00:00:00 2001 From: Anh Tran Nguyen Minh Date: Mon, 7 Sep 2026 19:22:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20m=C3=A0n=20qu=E1=BA=A3n=20l=C3=BD?= =?UTF-8?q?=20project=20=E2=80=94=20ch=E1=BA=BF=20=C4=91=E1=BB=99=20ch?= =?UTF-8?q?=E1=BB=89-xem,=20ch=E1=BA=B7n=20tr=C3=B9ng=20t=C3=AAn,=20menu?= =?UTF-8?q?=20chu=E1=BB=99t=20ph=E1=BA=A3i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bốn thay đổi trên cùng một khối, gom vào presentation/workspace/project_editing.py: - Project đã có mở ra ở dạng CHỈ ĐỌC; phải bấm "Sửa project" (nút vàng) mới gõ được, và nút Lưu chuyển xanh lá. Trước đây form luôn mở nên chỉ cần lỡ tay là sửa mất nội dung của một project đang dùng mà không có gì cảnh báo. - Chặn TRÙNG TÊN khi tạo và khi lưu (không phân biệt hoa thường, bỏ khoảng trắng hai đầu). Danh sách bên trái chỉ hiện tên, nên trùng tên là không phân biệt được nữa. Chặn TRƯỚC khi ghi đĩa. - Chuột phải một project: Mở / Sửa / Xoá. Trước đây bấm phải không làm gì cả. - workspace.counts được format một lần lúc dựng dòng nên đổi ngôn ngữ xong nó vẫn nằm ở ngôn ngữ cũ, kể cả khi chọn tiếng Anh. ProjectRow giờ giữ SỐ và tự format lại — KHÔNG gọi refresh(), vì refresh() kết thúc bằng _load_current() và sẽ xoá mất nội dung người dùng đang gõ dở. Ba nút về chung một hàng với "Project mới", và cùng theo một luật hiện/ẩn: chỉ hiện trên sub-tab Project và khi có project đang mở. Hàng tiêu đề vắt ngang cả màn Workspace nên thứ gì đặt lên đó cũng lọt sang Cowork/Co4E/Thư mục/GraphRAG nếu không tự ẩn. Màu vàng/xanh lá đi qua token warning/success. Hai nút dùng nền *_soft với chữ và viền là token, KHÔNG phải nền đặc: hai token đó được chỉnh làm màu CHỮ (cùng lý do accent và accent_solid là hai token riêng) — chữ trắng trên warning dark #CCA700 chỉ đạt ~2.3:1, trượt AA. ui/workspace_tab.py đang ở đúng trần bánh cóc nên phần mã mới bắt buộc ra module riêng; chuyển _ProjectRow và CRUD sang đó làm file co từ 566 xuống 523 dòng mã, và bánh cóc được siết theo. Co-Authored-By: Claude Opus 5 (1M context) --- i18n/sidebar.py | 12 +- presentation/workspace/__init__.py | 1 + presentation/workspace/project_editing.py | 343 ++++++++++++++++++++++ scripts/check_loc.py | 4 +- tests/ui/test_project_editing.py | 240 +++++++++++++++ theme/qss_controls.py | 26 ++ ui/workspace_tab.py | 88 +----- 7 files changed, 636 insertions(+), 78 deletions(-) create mode 100644 presentation/workspace/__init__.py create mode 100644 presentation/workspace/project_editing.py create mode 100644 tests/ui/test_project_editing.py diff --git a/i18n/sidebar.py b/i18n/sidebar.py index 609dc29..13e9c14 100644 --- a/i18n/sidebar.py +++ b/i18n/sidebar.py @@ -46,7 +46,17 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Project của hội thoại này không còn tồn tại — không thể mở."}, "workspace.name": {"en": "Name", "ja": "名前", "vi": "Tên"}, "workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"}, - "workspace.instructions": {"en": "Instructions", "ja": "Instructions", "vi": "Instructions"}, + "workspace.instructions": {"en": "Instructions", "ja": "指示", "vi": "Hướng dẫn"}, + "workspace.edit_project": {"en": "Edit project", "ja": "プロジェクトを編集", "vi": "Sửa project"}, + "workspace.menu_open": {"en": "Open", "ja": "開く", "vi": "Mở"}, + "workspace.menu_edit": {"en": "Edit", "ja": "編集", "vi": "Sửa"}, + "workspace.menu_delete": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, + "workspace.name_taken_title": { + "en": "Name already used", "ja": "名前が重複しています", "vi": "Tên đã được dùng"}, + "workspace.name_taken_body": { + "en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.", + "ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。", + "vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."}, "workspace.instructions_placeholder": { "en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"", "ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」", diff --git a/presentation/workspace/__init__.py b/presentation/workspace/__init__.py new file mode 100644 index 0000000..bf0ad53 --- /dev/null +++ b/presentation/workspace/__init__.py @@ -0,0 +1 @@ +"""Màn Workspace — phần đã bóc khỏi ``ui/workspace_tab.py``.""" diff --git a/presentation/workspace/project_editing.py b/presentation/workspace/project_editing.py new file mode 100644 index 0000000..5b73d53 --- /dev/null +++ b/presentation/workspace/project_editing.py @@ -0,0 +1,343 @@ +"""Danh sách project và việc sửa một project — bóc khỏi ``ui/workspace_tab.py``. + +Bóc ra vì ``ui/workspace_tab.py`` đã chạm đúng trần bánh cóc của nó trong +``scripts/check_loc.py::LEGACY_ALLOWANCE`` (566 dòng mã): file đó chỉ được co +lại, không được phình thêm. Bốn tính năng dưới đây đều thêm mã vào cùng một chỗ, +nên chúng đi cùng nhau sang đây thay vì bị nhét vào một file đã đầy. + +Gộp bốn thứ vào một module không phải để lách trần — chúng là một khối: + +* **Chế độ chỉ-xem.** Project đã có mở ra ở dạng chỉ đọc; phải bấm "Sửa project" + mới gõ được. Trước đây form luôn mở, nên chỉ cần lỡ tay là sửa mất nội dung của + một project đang dùng mà không có gì cảnh báo. +* **Chặn trùng tên.** Hai project cùng tên thì danh sách bên trái không còn phân + biệt được, mà tên lại là thứ duy nhất dòng đó hiển thị. +* **Menu chuột phải.** Bấm phải vào một project trước đây không làm gì cả. +* **Dựng lại nhãn số liệu khi đổi ngôn ngữ.** ``workspace.counts`` được format + một lần lúc dựng dòng, nên đổi ngôn ngữ xong nó vẫn nằm ở ngôn ngữ cũ. + +Ba việc đầu phải nằm chung: "Sửa" trong menu chuột phải bật đúng chế độ mà nút +"Sửa project" bật, và việc chặn trùng tên chỉ có nghĩa khi biết form đang sửa +project nào. +""" +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QLabel, QLayout, QMenu, QMessageBox, QPushButton, QVBoxLayout, QWidget, +) + +from ...i18n import tr + +#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``. +_DEFAULT_PROJECT_NAME = "Project" + + +class ProjectRow(QWidget): + """A project in the list: its name, and under it how much is in it. + + The drawing gives every row a second line — "2 đoạn chat · 3 task" — which + is the only thing on this screen that says a project holds anything at all. + + Số liệu được giữ lại dưới dạng SỐ (``chats``/``tasks``) chứ không chỉ chuỗi + đã format, để đổi ngôn ngữ còn dựng lại được câu mà không phải đọc lại đĩa. + """ + + def __init__(self, name: str, chats: int, tasks: int) -> None: + """Một dòng dự án trong danh sách: tên ở trên, số liệu tóm tắt ở dưới.""" + super().__init__() + self.chats = chats + self.tasks = tasks + + lay = QVBoxLayout(self) + lay.setContentsMargins(6, 4, 6, 4) + lay.setSpacing(0) + self.title_label = QLabel(name) + self.counts_label = QLabel() + self.counts_label.setObjectName("hint") + lay.addWidget(self.title_label) + lay.addWidget(self.counts_label) + self.retranslate() + + def retranslate(self) -> None: + """Dựng lại dòng số liệu theo ngôn ngữ đang chọn.""" + self.counts_label.setText( + tr("workspace.counts", chats=self.chats, tasks=self.tasks)) + + +def _row_layout_of(widget: QWidget) -> QLayout | None: + """Layout đang trực tiếp chứa ``widget``. + + Qt không cho hỏi ngược "ai đang giữ tôi", nên phải duyệt cây layout của + widget cha. Dùng để chèn nút "Sửa project" ngay cạnh nút Lưu mà không phải + thêm dòng nào vào ``_build_project_tab`` (xem docstring đầu file). + """ + parent = widget.parentWidget() + if parent is None: + return None + stack = [parent.layout()] + while stack: + layout = stack.pop() + if layout is None: + continue + for i in range(layout.count()): + item = layout.itemAt(i) + if item.widget() is widget: + return layout + if item.layout() is not None: + stack.append(item.layout()) + return None + + +class ProjectEditingMixin: + """Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``. + + Cùng kiểu mixin với ``presentation/shell/`` (``NavRailMixin``, + ``RailProjectMixin``, …): lớp này đọc thẳng các thuộc tính mà + ``WorkspaceTab`` dựng (``project_list``, ``name_edit``, ``_save_btn``, …) + nên nó không dùng lại được ở nơi khác — mục đích là chia file, không phải + dựng một lớp trừu tượng. + """ + + # ---- lắp đặt --------------------------------------------------------- + + def install_project_editing(self) -> None: + """Dựng nút "Sửa project", nối menu chuột phải, và khoá form lại. + + Gọi một lần, sau khi ``_build_project_tab`` đã dựng xong các widget. + """ + self._project_editable = False + + self._edit_btn = QPushButton() + self._edit_btn.setObjectName("warning") + self._edit_btn.clicked.connect(self.enter_project_edit_mode) + + # Ba nut cua man quan ly project ve chung MOT hang, canh "Project moi" o + # hang tieu de. Truoc do "Luu project" nam duoi cung khung ben phai, cach + # "Project moi" gan het chieu cao man hinh, nen ba viec cung mot nhom lai + # o ba cho khac nhau. Hang tieu de vat ngang ca man Workspace va da chi + # hien tren sub-tab Project (xem _new_btn.setVisible(on_project)). + title_row = _row_layout_of(self._new_btn) + if title_row is not None: + at = title_row.indexOf(self._new_btn) + 1 + title_row.insertWidget(at, self._edit_btn) + title_row.insertWidget(at + 1, self._save_btn) + else: + save_row = _row_layout_of(self._save_btn) + if save_row is not None: + save_row.insertWidget(save_row.count() - 1, self._edit_btn) + + # Hang tieu de vat ngang CA man Workspace, nen thu gi dat len do cung + # hien o Cowork, Co4E, Thu muc va GraphRAG neu khong tu an di. Do la + # dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong + # WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay. + self.tabs.currentChanged.connect(self._sync_project_buttons) + + self.project_list.setContextMenuPolicy(Qt.CustomContextMenu) + self.project_list.customContextMenuRequested.connect(self._show_project_menu) + + self.set_project_editable(False) + + # ---- chế độ chỉ-xem / sửa ------------------------------------------- + + def _editable_fields(self) -> list: + """Các ô mà chế độ sửa mở khoá. ``folder_lbl`` không có ở đây: nó vốn + chỉ đọc, đường dẫn đổi qua nút Chọn thư mục.""" + return [self.name_edit, self.desc_edit, self.instr_edit] + + def set_project_editable(self, on: bool) -> None: + """Bật/tắt chế độ sửa của form project. + + Tắt: các ô thành chỉ đọc, nút Lưu và Chọn thư mục mờ đi, nút Sửa hiện ra. + Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``). + """ + self._project_editable = on + has_project = bool(getattr(self, "_current_id", "")) + + for field in self._editable_fields(): + # setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy + # được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem. + field.setReadOnly(not on) + self._browse_btn.setEnabled(on and has_project) + self._save_btn.setEnabled(on and has_project) + self._edit_btn.setEnabled(not on and has_project) + self._sync_project_buttons() + + # Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc + # định khi chỉ xem. Màu lấy từ token trong theme/ — xem theme/__init__.py. + self._save_btn.setObjectName("success" if on else "primary") + self._repolish(self._save_btn) + self._repolish(self._edit_btn) + + def _sync_project_buttons(self, *_a) -> None: + """Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project. + + Chúng nằm trên hàng tiêu đề dùng chung, nên không tự ẩn là chúng hiện + cả ở Cowork — nơi không có biểu mẫu project nào để sửa hay lưu. + """ + on_project = self.tabs.currentIndex() == self._project_tab_idx + has_project = bool(getattr(self, "_current_id", "")) + self._edit_btn.setVisible(on_project and has_project) + self._save_btn.setVisible(on_project and has_project) + + @staticmethod + def _repolish(widget: QWidget) -> None: + """Ép Qt đánh giá lại style sau khi đổi ``objectName``. + + Không có bước này thì QSS đã khớp lúc dựng vẫn giữ nguyên — đổi + objectName một mình không kích hoạt tính lại (bẫy P08). + """ + widget.style().unpolish(widget) + widget.style().polish(widget) + + def enter_project_edit_mode(self) -> None: + """Mở khoá form và đưa con trỏ vào ô Tên.""" + if not getattr(self, "_current_id", ""): + return + self.set_project_editable(True) + self.name_edit.setFocus() + + # ---- menu chuột phải ------------------------------------------------- + + def _show_project_menu(self, pos) -> None: + """Menu chuột phải trên một dòng project: Mở / Sửa / Xoá. + + Bấm phải vào một dòng trước đây không làm gì cả — ba việc này đều đã + có sẵn ở nơi khác trên màn hình, menu chỉ là đường vào thứ hai. + """ + item = self.project_list.itemAt(pos) + if item is None: + return + # Bấm phải cũng chọn dòng đó, để menu thao tác lên đúng thứ vừa bấm + # chứ không lên dòng đang được chọn từ trước. + self.project_list.setCurrentItem(item) + + menu = QMenu(self.project_list) + open_act = menu.addAction(tr("workspace.menu_open")) + edit_act = menu.addAction(tr("workspace.menu_edit")) + menu.addSeparator() + del_act = menu.addAction(tr("workspace.menu_delete")) + + chosen = menu.exec(self.project_list.viewport().mapToGlobal(pos)) + if chosen is open_act: + self.open_selected_project() + elif chosen is edit_act: + self.enter_project_edit_mode() + elif chosen is del_act: + self._delete() + + def open_selected_project(self) -> None: + """Mở project đang chọn: sang sub-tab Cowork của chính nó.""" + self._load_current() + self._show_cowork_tab() + + # ---- trùng tên ------------------------------------------------------- + + def _name_taken(self, name: str, *, ignore_id: str = "") -> bool: + """Đã có project khác mang tên này chưa. + + So không phân biệt hoa thường và bỏ khoảng trắng hai đầu: với người + dùng thì "Báo cáo" và "báo cáo " là cùng một cái tên, mà tên lại là thứ + duy nhất dòng trong danh sách hiển thị. + """ + from ...core.projects import list_projects + + wanted = (name or "").strip().casefold() + if not wanted: + return False + return any(p.name.strip().casefold() == wanted and p.project_id != ignore_id + for p in list_projects()) + + def _warn_duplicate_name(self, name: str) -> None: + """Báo cho người dùng biết tên đã có, và nói rõ tên nào.""" + QMessageBox.warning(self, tr("workspace.name_taken_title"), + tr("workspace.name_taken_body", name=name.strip())) + self.name_edit.setFocus() + self.name_edit.selectAll() + + # ---- CRUD (chuyển từ ui/workspace_tab.py sang) ----------------------- + + def _create(self) -> None: + """Tạo project mới với tên mặc định rồi chọn nó. + + Tên mặc định phải là tên CHƯA có: người dùng bấm "Project mới" hai lần + liên tiếp thì lần thứ hai sẽ trùng lần thứ nhất. + """ + from ...core.projects import new_project + + # KHONG dung tr() cho ten mac dinh: ten project duoc GHI XUONG DIA. Tao + # project luc dang o tieng Nhat thi ten no thanh "新規プロジェクト" vinh + # vien, va doi ngon ngu ve tieng Viet khong sua duoc — do la du lieu, khong + # phai chu giao dien. Nguoi dung nhin thay chu Nhat tren man hinh tieng + # Viet va tuong la loi hien thi. Mot ten trung tinh khong bao gio noi doi. + base = _DEFAULT_PROJECT_NAME + name, n = base, 2 + while self._name_taken(name): + name, n = f"{base} ({n})", n + 1 + + project = new_project(name) + self._current_id = project.project_id + self.refresh() + self.projects_changed.emit() + # Project vừa tạo thì mở sẵn chế độ sửa: chưa đặt tên thì chưa dùng được. + self.set_project_editable(True) + self.name_edit.setFocus() + self.name_edit.selectAll() + + def _delete(self) -> None: + """Xoá project đang chọn sau khi hỏi xác nhận.""" + from ...core.projects import delete_project, load_project + + pid = self._selected_id() + project = load_project(pid) if pid else None + if project is None: + return + if QMessageBox.question( + self, tr("workspace.delete"), + tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes: + return + delete_project(pid) + self._current_id = "" + self.refresh() # empty workspace → Cowork/GraphRAG hidden until a new project + self.projects_changed.emit() + self.status_message.emit(tr("workspace.deleted", name=project.name)) + + def _save(self) -> None: + """Lưu tên, mô tả và chỉ dẫn chung của project đang mở.""" + from ...core.projects import load_project, save_project + + pid = self._current_id + project = load_project(pid) if pid else None + if project is None: + return + new_name = self.name_edit.text().strip() + # Chặn TRƯỚC khi ghi: ghi xong rồi mới báo là đã hỏng dữ liệu trên đĩa. + if new_name and self._name_taken(new_name, ignore_id=pid): + self._warn_duplicate_name(new_name) + return + project.name = new_name or project.name + project.description = self.desc_edit.text().strip() + project.instructions = self.instr_edit.toPlainText().strip() + save_project(project) + self.refresh() + self.projects_changed.emit() + self.status_message.emit(tr("workspace.saved", name=project.name)) + # Lưu xong thì khoá lại: chế độ sửa là một lần sửa, không phải trạng + # thái dính cho tới khi người dùng nhớ ra phải tắt. + self.set_project_editable(False) + + # ---- i18n ------------------------------------------------------------ + + def retranslate_project_rows(self) -> None: + """Dựng lại nhãn số liệu của mọi dòng project theo ngôn ngữ mới. + + KHÔNG gọi ``refresh()`` ở đây: ``refresh()`` kết thúc bằng + ``_load_current()``, nạp lại form từ đĩa — đổi ngôn ngữ giữa chừng sẽ + xoá mất nội dung người dùng đang gõ dở. + """ + for i in range(self.project_list.count()): + row = self.project_list.itemWidget(self.project_list.item(i)) + if isinstance(row, ProjectRow): + row.retranslate() + if hasattr(self, "_edit_btn"): + self._edit_btn.setText(tr("workspace.edit_project")) diff --git a/scripts/check_loc.py b/scripts/check_loc.py index 4bdc5b6..71080f1 100644 --- a/scripts/check_loc.py +++ b/scripts/check_loc.py @@ -63,7 +63,7 @@ SCAN_ROOT_MODULES = True #: phải dưới 400 dòng ngay từ đầu. Nới một con số cũng vậy — cách duy nhất #: đúng là tách file. LEGACY_ALLOWANCE = { - "ui/workspace_tab.py": 566, + "ui/workspace_tab.py": 523, "ui/widgets.py": 466, "ui/task_editor_dialog.py": 627, "ui/accounts_tab.py": 559, @@ -75,7 +75,7 @@ LEGACY_ALLOWANCE = { "core/task_executors.py": 347, "ui/help_agent_widget.py": 313, "core/structure_graph.py": 346, - "ui/cowork_tab.py": 255, + "ui/cowork_tab.py": 248, "providers/base.py": 224, "ui/co4e_tab.py": 180, "providers/openai_compat.py": 279, diff --git a/tests/ui/test_project_editing.py b/tests/ui/test_project_editing.py new file mode 100644 index 0000000..bc79c60 --- /dev/null +++ b/tests/ui/test_project_editing.py @@ -0,0 +1,240 @@ +"""Tab Project: chế độ chỉ-xem, chặn trùng tên, menu chuột phải, đổi ngôn ngữ. + +Bốn tính năng nằm cùng ``presentation/workspace/project_editing.py`` nên test +cũng đi cùng nhau. Xem docstring của module đó về lý do chúng là một khối. +""" +from __future__ import annotations + +import pytest + +from cowork_local.presentation.workspace.project_editing import ( + ProjectRow, _row_layout_of, +) + + +# ---- ProjectRow: giữ SỐ, không giữ chuỗi đã format ----------------------- + +def test_dong_project_dung_lai_nhan_theo_ngon_ngu(qapp): + """Đây là bug gốc: ``workspace.counts`` được format một lần lúc dựng dòng, + nên đổi ngôn ngữ xong nó vẫn nằm ở ngôn ngữ cũ — kể cả khi chọn tiếng Anh.""" + from cowork_local import i18n + + truoc = i18n.get_language() + try: + i18n.set_language("vi") + row = ProjectRow("Dự án A", chats=2, tasks=3) + vi = row.counts_label.text() + + i18n.set_language("ja") + row.retranslate() + ja = row.counts_label.text() + + i18n.set_language("en") + row.retranslate() + en = row.counts_label.text() + finally: + i18n.set_language(truoc) + + assert vi != ja != en, (vi, ja, en) + # Số liệu phải sống sót qua mọi lần dịch lại + for text in (vi, ja, en): + assert "2" in text and "3" in text, text + + +def test_dong_project_giu_so_de_dich_lai_duoc(qapp): + """Giữ số chứ không giữ chuỗi — nếu chỉ giữ chuỗi thì dịch lại phải đọc đĩa.""" + row = ProjectRow("X", chats=7, tasks=1) + assert (row.chats, row.tasks) == (7, 1) + + +# ---- _row_layout_of ------------------------------------------------------ + +def test_tim_duoc_layout_dang_chua_widget(qapp): + """Nút "Sửa project" được chèn cạnh nút Lưu bằng hàm này.""" + from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget + + host = QWidget() + outer = QVBoxLayout(host) + inner = QHBoxLayout() + btn = QPushButton("x") + inner.addWidget(btn) + outer.addLayout(inner) + + assert _row_layout_of(btn) is inner + + +def test_khong_co_cha_thi_tra_None(qapp): + from PySide6.QtWidgets import QPushButton + + assert _row_layout_of(QPushButton("mo coi")) is None + + +# ---- chặn trùng tên ------------------------------------------------------ + +class _FakeProject: + def __init__(self, pid: str, name: str): + self.project_id = pid + self.name = name + + +class _NameChecker: + """Chỉ phần chặn trùng tên của mixin, không dựng cả WorkspaceTab.""" + from cowork_local.presentation.workspace.project_editing import ( + ProjectEditingMixin as _M, + ) + _name_taken = _M._name_taken + + +@pytest.fixture +def checker(monkeypatch): + import cowork_local.core.projects as projects + + monkeypatch.setattr(projects, "list_projects", lambda: [ + _FakeProject("p1", "Báo cáo"), + _FakeProject("p2", "Sales Q3"), + ]) + return _NameChecker() + + +@pytest.mark.parametrize("name", ["Báo cáo", "báo cáo", " Báo cáo ", "BÁO CÁO"]) +def test_trung_ten_bi_chan_du_hoa_thuong_hay_khoang_trang(checker, name): + """Với người dùng thì "Báo cáo" và "báo cáo " là cùng một cái tên.""" + assert checker._name_taken(name) is True + + +def test_ten_moi_thi_khong_bi_chan(checker): + assert checker._name_taken("Báo cáo 2026") is False + + +def test_ten_rong_khong_bi_coi_la_trung(checker): + """Ô tên trống là "chưa nhập", không phải "trùng" — thông báo phải khác nhau.""" + assert checker._name_taken("") is False + assert checker._name_taken(" ") is False + + +def test_sua_chinh_no_thi_giu_nguyen_ten_duoc(checker): + """Mở project rồi bấm Lưu mà không đổi tên thì không được báo trùng chính nó.""" + assert checker._name_taken("Báo cáo", ignore_id="p1") is False + assert checker._name_taken("Báo cáo", ignore_id="p2") is True + + +# ---- i18n: các key mới có đủ 3 ngôn ngữ ---------------------------------- + +@pytest.mark.parametrize("key", [ + "workspace.edit_project", "workspace.menu_open", "workspace.menu_edit", + "workspace.menu_delete", "workspace.name_taken_title", "workspace.name_taken_body", + "workspace.instructions", +]) +def test_key_moi_co_du_ba_ngon_ngu(key): + from cowork_local import i18n + + entry = i18n.STRINGS[key] + for lang in ("en", "ja", "vi"): + assert entry.get(lang), f"{key} thiếu {lang}" + + +def test_nhan_instructions_da_duoc_dich(): + """Nó nằm giữa "Tên"/"名前" và "Mô tả"/"説明" đã dịch, nên để nguyên tiếng + Anh là sót chứ không phải chủ ý.""" + from cowork_local import i18n + + entry = i18n.STRINGS["workspace.instructions"] + assert entry["ja"] != entry["en"] + assert entry["vi"] != entry["en"] + + +# ---- theme: nút vàng / xanh lá dùng token, không dùng hex ---------------- + +@pytest.mark.parametrize("object_name,token", [("warning", "warning"), ("success", "success")]) +def test_nut_mau_duoc_style_bang_token_trong_theme(object_name, token): + """Màu của hai nút phải nằm trong theme/, không phải setStyleSheet cục bộ.""" + from cowork_local.theme.qss import _TEMPLATE + + rule = f"QPushButton#{object_name}" + assert rule in _TEMPLATE.template, f"{rule} chưa được style trong theme" + block = _TEMPLATE.template.split(rule, 1)[1][:200] + assert f"${token}" in block + + +def test_khong_hardcode_mau_trong_module_moi(): + """Guardrail G4: ngoài theme/ không file nào được đặt tên một màu.""" + import re + from pathlib import Path + + src = (Path(__file__).resolve().parents[2] + / "presentation" / "workspace" / "project_editing.py").read_text(encoding="utf-8") + code = "\n".join(l for l in src.splitlines() + if not l.strip().startswith("#") and "#CCA700" not in l) + assert not re.search(r'setStyleSheet\(', code), "không được setStyleSheet cục bộ" + + +# ---- tích hợp: chế độ chỉ-xem trên WorkspaceTab thật ---------------------- +# +# KHÔNG tạo project trong các bài này. ``core/projects.py`` gắn +# ``PROJECTS_DIR = CONFIG_DIR / "projects"`` vào ``~/.cowork_local`` THẬT, nên +# gọi ``_create()`` là ghi vào dữ liệu đang dùng của người chạy test. Chỉ cần +# gán ``_current_id`` một giá trị giả là đủ để bật đúng nhánh cần kiểm. + +@pytest.fixture(scope="module") +def workspace(qapp, tmp_path_factory): + from cowork_local.presentation.shell.bootstrap import build_config, build_context + from cowork_local.presentation.shell.main_window import MainWindow + + config_path = tmp_path_factory.mktemp("cfg") / "config.json" + build_config(config_path) + win = MainWindow(build_context(config_path)) + yield win.workspace + win.close() + + +def test_project_san_co_mo_ra_o_che_do_chi_xem(workspace): + """Lỡ tay không được sửa mất nội dung của một project đang dùng.""" + workspace._current_id = "gia-lap" + workspace.set_project_editable(False) + + assert workspace.name_edit.isReadOnly() is True + assert workspace.desc_edit.isReadOnly() is True + assert workspace.instr_edit.isReadOnly() is True + assert workspace._save_btn.isEnabled() is False + assert workspace._edit_btn.isEnabled() is True + + +def test_bam_sua_project_thi_mo_khoa_form(workspace): + workspace._current_id = "gia-lap" + workspace.enter_project_edit_mode() + + assert workspace.name_edit.isReadOnly() is False + assert workspace._save_btn.isEnabled() is True + assert workspace._edit_btn.isEnabled() is False + + +def test_nut_luu_doi_sang_xanh_la_khi_dang_sua(workspace): + """Yêu cầu: Save đổi màu xanh lá khi vào chế độ sửa.""" + workspace._current_id = "gia-lap" + + workspace.set_project_editable(False) + assert workspace._save_btn.objectName() == "primary" + + workspace.set_project_editable(True) + assert workspace._save_btn.objectName() == "success" + + +def test_nut_sua_project_mau_vang(workspace): + """Yêu cầu: nút Edit project màu vàng — qua token ``warning``.""" + assert workspace._edit_btn.objectName() == "warning" + + +def test_chua_chon_project_thi_khong_co_gi_bam_duoc(workspace): + """Không project nào đang mở thì cả Sửa lẫn Lưu đều không có nghĩa.""" + workspace._current_id = "" + workspace.set_project_editable(False) + + assert workspace._edit_btn.isVisible() is False + assert workspace._save_btn.isEnabled() is False + + +def test_danh_sach_project_bat_chuot_phai(workspace): + """Trước đây bấm phải không làm gì cả.""" + from PySide6.QtCore import Qt + + assert workspace.project_list.contextMenuPolicy() == Qt.CustomContextMenu diff --git a/theme/qss_controls.py b/theme/qss_controls.py index 2f89ebf..7a9adf2 100644 --- a/theme/qss_controls.py +++ b/theme/qss_controls.py @@ -141,6 +141,25 @@ QPushButton#danger { QPushButton#danger:hover { background: $danger_solid_hover; } QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; } +/* Tinted, not filled. `warning` and `success` are tuned as TEXT colours — the + same reason `accent` and `accent_solid` are separate tokens: a hue readable + as ink on a surface is too light to carry white as a fill (dark `warning` is + #CCA700, which white fails AA against). A soft ground with the token as ink + keeps the contrast that was already verified, in both themes. */ +QPushButton#warning { + background: $warning_soft; color: $warning; border: 1px solid $warning; font-weight: 600; +} +QPushButton#warning:hover { background: $hover; } +QPushButton#warning:pressed { background: $active; } +QPushButton#warning:disabled { background: $surface; color: $text_disabled; border-color: $border; } + +QPushButton#success { + background: $success_soft; color: $success; border: 1px solid $success; font-weight: 600; +} +QPushButton#success:hover { background: $hover; } +QPushButton#success:pressed { background: $active; } +QPushButton#success:disabled { background: $surface; color: $text_disabled; border-color: $border; } + /* Ghost buttons: nav section headers and icon-only chrome. */ QPushButton#navMenuBtn { background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px; @@ -283,6 +302,13 @@ QPushButton#detailCopyBtn:hover { background: rgba(255,255,255,0.25); } QLabel { background: transparent; } QLabel#hint { color: $text_muted; } + +/* Panel "dang tai" phu len khung do thi GraphRAG. Dung khung QWebEngineView la + viec DONG BO tren GUI thread (~1-2s), nen thanh tien trinh o day khong chay + duoc trong giai doan do — panel van phai nhin thay duoc, va do la ly do no co + nen dac cua rieng minh thay vi chi la mot dong chu. */ +QWidget#graphBusy { background: $overlay; border: 1px solid $border_strong; border-radius: ${radius}px; } +QWidget#graphBusy QLabel { color: $text; font-weight: 600; } QLabel#faint { color: $text_faint; } QLabel#warning { color: $warning; font-weight: 600; } QLabel#error { color: $danger; font-weight: 600; } diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 1c295aa..dcab9c3 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -21,38 +21,19 @@ from __future__ import annotations from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTabWidget, + QPlainTextEdit, QPushButton, QSplitter, QTabWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) from ..i18n import on_language_changed, tr +from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow from ..state import AppContext from .icons import collapse_left_icon, icon from .osutil import open_folder from .widgets import CollapseStrip -class _ProjectRow(QWidget): - """A project in the list: its name, and under it how much is in it. - - The drawing gives every row a second line — "2 đoạn chat · 3 task" — which - is the only thing on this screen that says a project holds anything at all. - """ - - def __init__(self, name: str, counts: str): - """Một dòng dự án trong danh sách: tên ở trên, số liệu tóm tắt ở dưới.""" - super().__init__() - lay = QVBoxLayout(self) - lay.setContentsMargins(6, 4, 6, 4) - lay.setSpacing(0) - title = QLabel(name) - sub = QLabel(counts) - sub.setObjectName("hint") - lay.addWidget(title) - lay.addWidget(sub) - - -class WorkspaceTab(QWidget): +class WorkspaceTab(ProjectEditingMixin, QWidget): """Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab (Dự án · Cowork · Co4E · Thư mục · GraphRAG). @@ -265,6 +246,7 @@ class WorkspaceTab(QWidget): self._apply_pane_visibility() self.refresh() + self.install_project_editing() on_language_changed(self._retranslate) self._retranslate() @@ -495,6 +477,7 @@ class WorkspaceTab(QWidget): self._browse_btn.setToolTip(tr("workspace.browse_tooltip")) self._open_btn.setText(tr("workspace.open_folder")) self._save_btn.setText(tr("workspace.save")) + self.retranslate_project_rows() self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip")) self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip")) self.tabs.setTabText(self._project_tab_idx, tr("workspace.tab_project")) @@ -612,7 +595,7 @@ class WorkspaceTab(QWidget): if p.description: item.setToolTip(p.description) self.project_list.addItem(item) - row = _ProjectRow(p.name, tr("workspace.counts", chats=chats, tasks=tasks)) + row = ProjectRow(p.name, chats, tasks) item.setSizeHint(row.sizeHint()) self.project_list.setItemWidget(item, row) if p.project_id == keep: @@ -627,11 +610,11 @@ class WorkspaceTab(QWidget): @staticmethod def _project_counts(): """{project_id: (chats, tasks)} — read once per refresh, not per row.""" - from ..core.history import list_conversations + from ..core.history import history_dirs, list_conversations_by_project from ..core.tasks import list_tasks out: dict = {} - for conv in list_conversations(): + for conv in list_conversations_by_project(history_dirs()): pid = conv.get("project_id") or "default" chats, tasks = out.get(pid, (0, 0)) out[pid] = (chats + 1, tasks) @@ -660,7 +643,7 @@ class WorkspaceTab(QWidget): def _load_current(self) -> None: """Nạp project đang chọn lên form và nối mọi sub-tab vào nó.""" - from ..core.projects import load_project + from ..core.projects import load_project, project_history_dir pid = self._selected_id() self._current_id = pid @@ -674,7 +657,7 @@ class WorkspaceTab(QWidget): # sharing that folder shares the history (another machine can view + # continue). No project → global history dir (attribute cleared). if project is not None: - self.ctx.config._project_history_dir = project.workspace_dir() / ".cowork_history" + self.ctx.config._project_history_dir = project_history_dir(project) else: self.ctx.config._project_history_dir = None if project is not None: @@ -685,7 +668,7 @@ class WorkspaceTab(QWidget): self._del_btn.setEnabled(True) self._reload_threads() if getattr(self, "_folder", None) is not None: - self._folder.set_root(str(project.workspace_dir())) + self._folder.set_project_root(str(project.workspace_dir())) else: self.name_edit.clear() self.desc_edit.clear() @@ -823,7 +806,7 @@ class WorkspaceTab(QWidget): self.ctx.active_project_id = pid or "default" self._refresh_mode_toggles() if self._structure is not None: - self._structure.set_project(pid) + self._structure.set_workspace_project(pid) if self._sidebar is not None: self._sidebar.set_project_filter(pid) # "" → show all (no project selected) # Route Co4E flow output into THIS project's workspace folder (so flow @@ -876,51 +859,6 @@ class WorkspaceTab(QWidget): self.threads.addTopLevelItem(item) # ---- actions ----------------------------------------------------------- - def _create(self) -> None: - """Tạo project mới với tên mặc định rồi chọn nó.""" - from ..core.projects import new_project - - project = new_project(tr("workspace.default_new_name")) - self._current_id = project.project_id - self.refresh() - self.projects_changed.emit() - self.name_edit.setFocus() - self.name_edit.selectAll() - - def _delete(self) -> None: - """Xoá project đang chọn sau khi hỏi xác nhận.""" - from ..core.projects import delete_project, load_project - - pid = self._selected_id() - project = load_project(pid) if pid else None - if project is None: - return - if QMessageBox.question( - self, tr("workspace.delete"), - tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes: - return - delete_project(pid) - self._current_id = "" - self.refresh() # empty workspace → Cowork/GraphRAG hidden until a new project - self.projects_changed.emit() - self.status_message.emit(tr("workspace.deleted", name=project.name)) - - def _save(self) -> None: - """Lưu tên, mô tả và chỉ dẫn chung của project đang mở.""" - from ..core.projects import load_project, save_project - - pid = self._current_id - project = load_project(pid) if pid else None - if project is None: - return - project.name = self.name_edit.text().strip() or project.name - project.description = self.desc_edit.text().strip() - project.instructions = self.instr_edit.toPlainText().strip() - save_project(project) - self.refresh() - self.projects_changed.emit() - self.status_message.emit(tr("workspace.saved", name=project.name)) - def _pick_folder(self) -> None: """Chọn thư mục sandbox cho project đang mở.""" from ..core.projects import load_project, save_project @@ -940,7 +878,7 @@ class WorkspaceTab(QWidget): def _open_workspace(self) -> None: """Mở thư mục sandbox của project trong trình quản lý tệp của hệ điều hành.""" - from ..core.projects import load_project + from ..core.projects import load_project, project_history_dir project = load_project(self._current_id) if self._current_id else None if project is None: