feat(ui): màn quản lý project — chế độ chỉ-xem, chặn trùng tên, menu chuột phải

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 01:34:36 +09:00
committed by thanhnv
co-authored by Claude Opus 5
parent e601225b0f
commit 69ffe8954a
7 changed files with 636 additions and 78 deletions
+11 -1
View File
@@ -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ở."}, "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.name": {"en": "Name", "ja": "名前", "vi": "Tên"},
"workspace.description": {"en": "Description", "ja": "説明", "vi": "Mô tả"}, "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": { "workspace.instructions_placeholder": {
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"", "en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」", "ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
+1
View File
@@ -0,0 +1 @@
"""Màn Workspace — phần đã bóc khỏi ``ui/workspace_tab.py``."""
+343
View File
@@ -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"))
+2 -2
View File
@@ -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 #: 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. #: đúng là tách file.
LEGACY_ALLOWANCE = { LEGACY_ALLOWANCE = {
"ui/workspace_tab.py": 566, "ui/workspace_tab.py": 523,
"ui/widgets.py": 466, "ui/widgets.py": 466,
"ui/task_editor_dialog.py": 627, "ui/task_editor_dialog.py": 627,
"ui/accounts_tab.py": 559, "ui/accounts_tab.py": 559,
@@ -75,7 +75,7 @@ LEGACY_ALLOWANCE = {
"core/task_executors.py": 347, "core/task_executors.py": 347,
"ui/help_agent_widget.py": 313, "ui/help_agent_widget.py": 313,
"core/structure_graph.py": 346, "core/structure_graph.py": 346,
"ui/cowork_tab.py": 255, "ui/cowork_tab.py": 248,
"providers/base.py": 224, "providers/base.py": 224,
"ui/co4e_tab.py": 180, "ui/co4e_tab.py": 180,
"providers/openai_compat.py": 279, "providers/openai_compat.py": 279,
+240
View File
@@ -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
+26
View File
@@ -141,6 +141,25 @@ QPushButton#danger {
QPushButton#danger:hover { background: $danger_solid_hover; } QPushButton#danger:hover { background: $danger_solid_hover; }
QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; } 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. */ /* Ghost buttons: nav section headers and icon-only chrome. */
QPushButton#navMenuBtn { QPushButton#navMenuBtn {
background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px; 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 { background: transparent; }
QLabel#hint { color: $text_muted; } 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#faint { color: $text_faint; }
QLabel#warning { color: $warning; font-weight: 600; } QLabel#warning { color: $warning; font-weight: 600; }
QLabel#error { color: $danger; font-weight: 600; } QLabel#error { color: $danger; font-weight: 600; }
+13 -75
View File
@@ -21,38 +21,19 @@ from __future__ import annotations
from PySide6.QtCore import Qt, Signal from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, QTabWidget, QPlainTextEdit, QPushButton, QSplitter, QTabWidget,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
) )
from ..i18n import on_language_changed, tr from ..i18n import on_language_changed, tr
from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow
from ..state import AppContext from ..state import AppContext
from .icons import collapse_left_icon, icon from .icons import collapse_left_icon, icon
from .osutil import open_folder from .osutil import open_folder
from .widgets import CollapseStrip from .widgets import CollapseStrip
class _ProjectRow(QWidget): class WorkspaceTab(ProjectEditingMixin, 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):
"""Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab """Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab
(Dự án · Cowork · Co4E · Thư mục · GraphRAG). (Dự án · Cowork · Co4E · Thư mục · GraphRAG).
@@ -265,6 +246,7 @@ class WorkspaceTab(QWidget):
self._apply_pane_visibility() self._apply_pane_visibility()
self.refresh() self.refresh()
self.install_project_editing()
on_language_changed(self._retranslate) on_language_changed(self._retranslate)
self._retranslate() self._retranslate()
@@ -520,6 +502,7 @@ class WorkspaceTab(QWidget):
self._cloud_sync_btn.setText(tr("workspace.cloud_sync")) self._cloud_sync_btn.setText(tr("workspace.cloud_sync"))
self._refresh_cloud_badge() self._refresh_cloud_badge()
self._save_btn.setText(tr("workspace.save")) self._save_btn.setText(tr("workspace.save"))
self.retranslate_project_rows()
self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip")) self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip"))
self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip")) self._projects_strip.setToolTip(tr("workspace.expand_projects_tooltip"))
self.tabs.setTabText(self._project_tab_idx, tr("workspace.tab_project")) self.tabs.setTabText(self._project_tab_idx, tr("workspace.tab_project"))
@@ -637,7 +620,7 @@ class WorkspaceTab(QWidget):
if p.description: if p.description:
item.setToolTip(p.description) item.setToolTip(p.description)
self.project_list.addItem(item) 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()) item.setSizeHint(row.sizeHint())
self.project_list.setItemWidget(item, row) self.project_list.setItemWidget(item, row)
if p.project_id == keep: if p.project_id == keep:
@@ -652,11 +635,11 @@ class WorkspaceTab(QWidget):
@staticmethod @staticmethod
def _project_counts(): def _project_counts():
"""{project_id: (chats, tasks)} — read once per refresh, not per row.""" """{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 from ..core.tasks import list_tasks
out: dict = {} out: dict = {}
for conv in list_conversations(): for conv in list_conversations_by_project(history_dirs()):
pid = conv.get("project_id") or "default" pid = conv.get("project_id") or "default"
chats, tasks = out.get(pid, (0, 0)) chats, tasks = out.get(pid, (0, 0))
out[pid] = (chats + 1, tasks) out[pid] = (chats + 1, tasks)
@@ -685,7 +668,7 @@ class WorkspaceTab(QWidget):
def _load_current(self) -> None: def _load_current(self) -> None:
"""Nạp project đang chọn lên form và nối mọi sub-tab vào nó.""" """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() pid = self._selected_id()
self._current_id = pid self._current_id = pid
@@ -699,7 +682,7 @@ class WorkspaceTab(QWidget):
# sharing that folder shares the history (another machine can view + # sharing that folder shares the history (another machine can view +
# continue). No project → global history dir (attribute cleared). # continue). No project → global history dir (attribute cleared).
if project is not None: 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: else:
self.ctx.config._project_history_dir = None self.ctx.config._project_history_dir = None
if project is not None: if project is not None:
@@ -710,7 +693,7 @@ class WorkspaceTab(QWidget):
self._del_btn.setEnabled(True) self._del_btn.setEnabled(True)
self._reload_threads() self._reload_threads()
if getattr(self, "_folder", None) is not None: 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: else:
self.name_edit.clear() self.name_edit.clear()
self.desc_edit.clear() self.desc_edit.clear()
@@ -849,7 +832,7 @@ class WorkspaceTab(QWidget):
self.ctx.active_project_id = pid or "default" self.ctx.active_project_id = pid or "default"
self._refresh_mode_toggles() self._refresh_mode_toggles()
if self._structure is not None: if self._structure is not None:
self._structure.set_project(pid) self._structure.set_workspace_project(pid)
if self._sidebar is not None: if self._sidebar is not None:
self._sidebar.set_project_filter(pid) # "" → show all (no project selected) self._sidebar.set_project_filter(pid) # "" → show all (no project selected)
# Route Co4E flow output into THIS project's workspace folder (so flow # Route Co4E flow output into THIS project's workspace folder (so flow
@@ -902,51 +885,6 @@ class WorkspaceTab(QWidget):
self.threads.addTopLevelItem(item) self.threads.addTopLevelItem(item)
# ---- actions ----------------------------------------------------------- # ---- 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: def _pick_folder(self) -> None:
"""Chọn thư mục sandbox cho project đang mở.""" """Chọn thư mục sandbox cho project đang mở."""
from ..core.projects import load_project, save_project from ..core.projects import load_project, save_project
@@ -966,7 +904,7 @@ class WorkspaceTab(QWidget):
def _open_workspace(self) -> None: 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.""" """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 project = load_project(self._current_id) if self._current_id else None
if project is None: if project is None: