Delta team/fix comment ui #10
+11
-1
@@ -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 レポートツールを開発中。命名規則に従うこと …」",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Màn Workspace — phần đã bóc khỏi ``ui/workspace_tab.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"))
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
|
||||
+13
-75
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user