feat(workspace): mỗi thư mục làm việc chỉ thuộc về một project
CI / test (pull_request) Canceled after 0s

Thư mục làm việc vừa là sandbox (agent chỉ đọc/ghi bên trong nó) vừa là kho
kiến thức chung của project (file ở gốc được mọi đoạn chat tự đọc). Hai project
trỏ vào cùng một thư mục là đọc lẫn dữ liệu của nhau và ghi đè lên nhau — đúng
điều docstring đầu core/projects.py nói sandbox sinh ra để ngăn, nhưng không có
chỗ nào thực thi: cả ba nơi đặt thư mục đều gán thẳng output_dir rồi lưu.

Thêm core.projects.folder_conflict: so theo workspace_dir() chứ không theo
output_dir (project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản lý sẵn),
chuẩn hoá đường dẫn bằng expanduser + abspath + normcase, và coi thư mục lồng
nhau là trùng — đứng ở thư mục cha vẫn với tới được file của project con.

Chặn tại cả ba nơi ghi output_dir: nút Đổi ở màn Project, thư mục mirror cloud,
và nút chọn thư mục trong tab Cowork. Chặn lúc CHỌN chứ không lúc Lưu, vì nút
Lưu chỉ ghi tên/mô tả/chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project
lỡ đang trùng thư mục.

Dữ liệu đã trùng sẵn không bị tự sửa: có nhãn cảnh báo ngay dưới ô Thư mục làm
việc, nói rõ trùng với project nào, để người dùng tự quyết.

Luật ở module riêng (presentation/workspace/project_folder_rules.py) vì nhét
vào project_editing.py sẽ đẩy file đó lên 435 dòng, vượt trần 400 của
scripts/check_loc.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 15:36:16 +09:00
co-authored by Claude Opus 5
parent bbdf146d2c
commit b7a41b3658
7 changed files with 408 additions and 1 deletions
@@ -0,0 +1,113 @@
"""Luật "mỗi thư mục làm việc chỉ thuộc về MỘT project".
Tách khỏi ``project_editing.py`` chứ không nhét thêm vào đó: file kia đã gom
bốn tính năng và thêm luật này là chạm trần 400 dòng của
``scripts/check_loc.py``. Đây cũng là một mối quan tâm riêng — nó không nói về
việc *sửa* một project mà về việc hai project không được giẫm lên nhau.
Luật có hai nửa, cố ý không đối xứng:
* **Chặn lúc CHỌN.** Ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn Project,
thư mục cloud, nút chọn thư mục trong tab Cowork) đều đi qua
:func:`folder_taken_blocked`, để cả ba chặn giống hệt nhau. Không chặn ở
"Lưu project": nút đó chỉ ghi tên/mô tả/chỉ dẫn, chặn ở đó sẽ khoá luôn việc
đổi tên một project lỡ đang trùng thư mục.
* **Cảnh báo cho cái ĐANG sai.** Dữ liệu cũ có thể đã có hai project trỏ vào
cùng một thư mục, mà nửa trên chỉ chặn từ nay trở đi. Nhãn dưới ô "Thư mục
làm việc" nói ra điều đó và để người dùng tự đổi — sửa hộ là tự ý đụng vào
dữ liệu của họ.
Phép so trùng nằm ở ``core/projects.py::folder_conflict`` (thuần, không Qt).
"""
from __future__ import annotations
from PySide6.QtWidgets import QLabel, QLayout, QMessageBox, QWidget
from ...i18n import tr
from .project_editing import _row_layout_of
def _layout_chua(layout: QLayout, con: QLayout) -> "tuple | None":
"""``(layout_cha, vị_trí)`` của ``con`` bên trong ``layout``, duyệt đệ quy."""
for i in range(layout.count()):
item = layout.itemAt(i)
ben_trong = item.layout()
if ben_trong is con:
return layout, i
if ben_trong is not None:
tim = _layout_chua(ben_trong, con)
if tim is not None:
return tim
return None
def folder_taken_blocked(parent: QWidget, path: str, ignore_id: str) -> bool:
"""``True`` nếu ``path`` đã thuộc project khác — và đã báo cho người dùng.
Dùng chung cho cả ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn
Project, thư mục cloud, và nút chọn thư mục trong tab Cowork), để cả ba
chặn giống hệt nhau thay vì mỗi nơi tự nghĩ ra một luật.
Chặn ở lúc CHỌN chứ không ở lúc Lưu: "Lưu project" chỉ ghi tên, mô tả và
chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng
thư mục, tức phạt người dùng vì một trạng thái họ chưa kịp sửa.
"""
from ...core.projects import folder_conflict
khac = folder_conflict(path, ignore_id=ignore_id)
if khac is None:
return False
QMessageBox.warning(parent, tr("workspace.folder_taken_title"),
tr("workspace.folder_taken_body", name=khac.name,
folder=str(khac.workspace_dir())))
return True
class ProjectFolderRuleMixin:
"""Nửa giao diện của luật. Trộn vào ``WorkspaceTab``."""
def install_project_folder_rule(self) -> None:
"""Dựng nhãn cảnh báo và nối nó vào việc đổi project.
Gọi từ ``install_project_editing``, tức sau khi form đã dựng xong.
"""
self._folder_warn_lbl = QLabel()
self._folder_warn_lbl.setObjectName("warning") # màu lấy từ theme/
self._folder_warn_lbl.setWordWrap(True)
self._folder_warn_lbl.hide()
self._gan_nhan_canh_bao_thu_muc()
self.project_selected.connect(self._sync_folder_warning)
def _gan_nhan_canh_bao_thu_muc(self) -> None:
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
Chèn từ đây thay vì thêm dòng vào ``_build_project_tab``: file
``ui/workspace_tab.py`` đang vượt trần của ``scripts/check_loc.py``,
nên mọi dòng mới đều phải tránh nó (cùng lý do nút "Sửa project" được
chèn bằng ``_row_layout_of``).
"""
hang = _row_layout_of(self.folder_lbl)
cha = self.folder_lbl.parentWidget()
if hang is None or cha is None or cha.layout() is None:
return
tim = _layout_chua(cha.layout(), hang)
if tim is None:
return
layout, vi_tri = tim
layout.insertWidget(vi_tri + 1, self._folder_warn_lbl)
def _sync_folder_warning(self, *_a) -> None:
"""Hiện/ẩn cảnh báo "thư mục đang dùng chung" theo project đang mở."""
from ...core.projects import folder_conflict, load_project
pid = getattr(self, "_current_id", "")
project = load_project(pid) if pid else None
khac = (folder_conflict(project.workspace_dir(), ignore_id=pid)
if project is not None else None)
if khac is None:
self._folder_warn_lbl.hide()
return
self._folder_warn_lbl.setText(
tr("workspace.folder_shared_warning", name=khac.name))
self._folder_warn_lbl.show()