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 5334158..2900a88 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() @@ -520,6 +502,7 @@ class WorkspaceTab(QWidget): self._cloud_sync_btn.setText(tr("workspace.cloud_sync")) self._refresh_cloud_badge() 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")) @@ -637,7 +620,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: @@ -652,11 +635,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) @@ -685,7 +668,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 @@ -699,7 +682,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: @@ -710,7 +693,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() @@ -849,7 +832,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 @@ -902,51 +885,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 @@ -966,7 +904,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: