diff --git a/core/projects.py b/core/projects.py index f6bef6b..98a90a8 100644 --- a/core/projects.py +++ b/core/projects.py @@ -24,6 +24,7 @@ project — nothing about it is special-cased in the UI. from __future__ import annotations import json +import os import re from dataclasses import asdict, dataclass, field from datetime import datetime @@ -82,6 +83,53 @@ class Project: return (base or WORKSPACES_DIR) / self.project_id +def _norm_dir(path) -> str: + """Đường dẫn đã chuẩn hoá để đem ra so sánh. + + Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì + ``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ + cho hai project chiếm chung một chỗ mà không ai biết. + """ + return os.path.normcase(os.path.abspath(os.path.expanduser(str(path)))) + + +def _cham_nhau(a: str, b: str) -> bool: + """Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau. + + Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này + không bao giờ chạm được file của project kia" (xem docstring đầu module). + Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là + chạm nhau dù hai đường dẫn không giống nhau. + """ + return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep) + + +def folder_conflict(path, *, ignore_id: str = "", + directory: Path = None) -> Optional[Project]: + """Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm. + + Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox + vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một + thư mục là đọc lẫn dữ liệu của nhau. + + So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải 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 của nó, và chính thư mục đó là thứ hay bị chọn nhầm. + + ``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì + không phải là trùng. + """ + if not str(path).strip(): + return None + muon = _norm_dir(path) + for project in list_projects(directory): + if project.project_id == ignore_id: + continue + if _cham_nhau(muon, _norm_dir(project.workspace_dir())): + return project + return None + + def _starter_project() -> Project: """An ordinary (deletable, renamable) project seeded when the projects folder is empty, so the app always opens with somewhere to chat.""" diff --git a/i18n/sidebar.py b/i18n/sidebar.py index de94a5c..49d26d5 100644 --- a/i18n/sidebar.py +++ b/i18n/sidebar.py @@ -57,6 +57,17 @@ STRINGS: Dict[str, Dict[str, str]] = { "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.folder_taken_title": { + "en": "Folder already used", "ja": "フォルダーが重複しています", + "vi": "Thư mục đã được dùng"}, + "workspace.folder_taken_body": { + "en": "Project \"{name}\" already works in {folder}. One folder belongs to one project only — the folder is that project's sandbox and shared knowledge, so sharing it lets two projects read and overwrite each other's files. Pick another folder.", + "ja": "プロジェクト「{name}」が既に {folder} を使用しています。フォルダーは 1 つのプロジェクト専用です — フォルダーはそのプロジェクトのサンドボックス兼共有ナレッジなので、共有すると互いのファイルを読み書きしてしまいます。別のフォルダーを選んでください。", + "vi": "Project \"{name}\" đang làm việc trong {folder}. Mỗi thư mục chỉ thuộc về một project — thư mục vừa là sandbox vừa là kho kiến thức chung của project đó, dùng chung là hai project đọc và ghi đè file của nhau. Hãy chọn thư mục khác."}, + "workspace.folder_shared_warning": { + "en": "⚠ This folder is also used by project \"{name}\". One folder belongs to one project only — pick another folder for one of them.", + "ja": "⚠ このフォルダーはプロジェクト「{name}」でも使われています。フォルダーは 1 つのプロジェクト専用です — どちらかに別のフォルダーを指定してください。", + "vi": "⚠ Thư mục này đang được project \"{name}\" dùng chung. Mỗi thư mục chỉ thuộc về một project — hãy đổi thư mục cho một trong hai."}, "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/project_editing.py b/presentation/workspace/project_editing.py index 77b90c0..0fe468d 100644 --- a/presentation/workspace/project_editing.py +++ b/presentation/workspace/project_editing.py @@ -141,6 +141,10 @@ class ProjectEditingMixin: self.project_list.setContextMenuPolicy(Qt.CustomContextMenu) self.project_list.customContextMenuRequested.connect(self._show_project_menu) + # Luật "mỗi thư mục một project" sống ở module riêng — xem + # ``project_folder_rules.py`` về lý do nó không nằm trong file này. + self.install_project_folder_rule() + self.set_project_editable(False) # ---- chế độ chỉ-xem / sửa ------------------------------------------- diff --git a/presentation/workspace/project_folder_rules.py b/presentation/workspace/project_folder_rules.py new file mode 100644 index 0000000..f9d2e94 --- /dev/null +++ b/presentation/workspace/project_folder_rules.py @@ -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() diff --git a/tests/ui/test_project_folder_unique.py b/tests/ui/test_project_folder_unique.py new file mode 100644 index 0000000..f1e304a --- /dev/null +++ b/tests/ui/test_project_folder_unique.py @@ -0,0 +1,223 @@ +"""Mỗi thư mục làm việc chỉ được thuộc về MỘT project. + +Thư mục làm việc vừa là sandbox (agent chỉ được đọc/ghi bên trong nó) vừa là +kho kiến thức chung của project (file ở gốc thư mụ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 mà docstring đầu ``core/projects.py`` nói sandbox sinh ra để +ngăn, nhưng trước đây không có gì chặn. + +Hai nhóm bài: + +* **Luật** — ``folder_conflict`` nhận diện trùng, kể cả khác hoa thường, khác + kiểu dấu phân cách, và LỒNG NHAU (đứng ở thư mục cha thì vẫn với tới được + file của project con). +* **Giao diện** — nhãn cảnh báo dưới ô "Thư mục làm việc" hiện đúng lúc, vì dữ + liệu cũ có thể đã trùng sẵn và luật mới chỉ chặn từ lúc chọn trở đi. +""" +from __future__ import annotations + +import os + +import pytest + +from cowork_local.core import projects as projects_mod +from cowork_local.core.projects import Project, WORKSPACES_DIR, folder_conflict + + +def _kho(monkeypatch, *ds: Project) -> None: + """Giả lập kho project, không đụng ``~/.cowork_local`` thật.""" + monkeypatch.setattr(projects_mod, "list_projects", lambda directory=None: list(ds)) + + +# ---- luật: nhận diện trùng ---------------------------------------------- + +def test_trung_y_het_thi_bi_bat(monkeypatch, tmp_path): + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + khac = folder_conflict(str(tmp_path), ignore_id="b") + + assert khac is not None and khac.project_id == "a" + + +def test_khac_hoa_thuong_va_dau_phan_cach_van_la_trung(monkeypatch, tmp_path): + """Trên Windows ``D:/Work`` và ``d:/work`` là cùng một thư mục.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + lech = str(tmp_path).replace(os.sep, "/") + if os.name == "nt": + lech = lech.upper() + + assert folder_conflict(lech, ignore_id="b") is not None + + +def test_thu_muc_con_nam_trong_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path): + """Project kia đứng ở thư mục cha thì vẫn đọc/ghi được thư mục con này.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + assert folder_conflict(str(tmp_path / "con"), ignore_id="b") is not None + + +def test_thu_muc_cha_chua_thu_muc_cua_project_khac_la_trung(monkeypatch, tmp_path): + """Chiều ngược lại cũng phải bắt: chọn thư mục cha là ôm trọn project kia.""" + _kho(monkeypatch, Project(project_id="a", name="A", + output_dir=str(tmp_path / "con"))) + + assert folder_conflict(str(tmp_path), ignore_id="b") is not None + + +def test_ten_na_na_nhung_khong_long_nhau_thi_khong_trung(monkeypatch, tmp_path): + """Bẫy của so sánh tiền tố: ``work2`` KHÔNG nằm trong ``work``.""" + _kho(monkeypatch, Project(project_id="a", name="A", + output_dir=str(tmp_path / "work"))) + + assert folder_conflict(str(tmp_path / "work2"), ignore_id="b") is None + + +def test_project_chua_dat_thu_muc_rieng_van_dang_chiem_thu_muc_quan_ly(monkeypatch): + """``output_dir`` rỗng không có nghĩa là "chưa chiếm chỗ nào": project vẫn + đang dùng thư mục quản lý sẵn, và chính nó hay bị chọn nhầm.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir="")) + + assert folder_conflict(str(WORKSPACES_DIR / "a"), ignore_id="b") is not None + + +def test_giu_nguyen_thu_muc_cua_chinh_no_thi_khong_phai_trung(monkeypatch, tmp_path): + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + assert folder_conflict(str(tmp_path), ignore_id="a") is None + + +def test_thu_muc_chua_ai_dung_thi_di_qua(monkeypatch, tmp_path): + _kho(monkeypatch, Project(project_id="a", name="A", + output_dir=str(tmp_path / "cua-a"))) + + assert folder_conflict(str(tmp_path / "cua-b"), ignore_id="b") is None + + +def test_duong_dan_rong_khong_bi_coi_la_trung(monkeypatch, tmp_path): + """Ô trống là "chưa chọn", không phải "trùng" — khác hẳn nhau.""" + _kho(monkeypatch, Project(project_id="a", name="A", output_dir=str(tmp_path))) + + assert folder_conflict("", ignore_id="b") is None + assert folder_conflict(" ", ignore_id="b") is None + + +# ---- i18n: ba key mới phải đủ ba ngôn ngữ ------------------------------- + +@pytest.mark.parametrize("key", [ + "workspace.folder_taken_title", "workspace.folder_taken_body", + "workspace.folder_shared_warning", +]) +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}" + + +# ---- giao diện: cảnh báo cho dữ liệu đã trùng sẵn ----------------------- + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật") + + +@pytest.fixture(scope="module") +def ws(qapp, tmp_path_factory): + """Một cửa sổ cho cả module — dựng nhiều MainWindow làm Qt chết giữa chừng.""" + 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) + window = MainWindow(build_context(config_path)) + yield window.workspace + window.close() + + +def _mo_project(qapp, ws, monkeypatch, dang_mo: Project, *nhung_cai_khac: Project): + """Mở ``dang_mo`` trên biểu mẫu, với kho chứa cả các project còn lại.""" + _kho(monkeypatch, dang_mo, *nhung_cai_khac) + monkeypatch.setattr(projects_mod, "load_project", + lambda pid, directory=None: dang_mo if pid == dang_mo.project_id else None) + ws._current_id = dang_mo.project_id + ws.project_selected.emit(dang_mo.project_id) + qapp.processEvents() + + +def test_canh_bao_hien_khi_project_dang_dung_chung_thu_muc(qapp, ws, monkeypatch, tmp_path): + """Đúng trạng thái trong ảnh người dùng gửi: hai project cùng một thư mục.""" + _mo_project(qapp, ws, monkeypatch, + Project(project_id="b", name="test3", output_dir=str(tmp_path)), + Project(project_id="a", name="test2", output_dir=str(tmp_path))) + + assert ws._folder_warn_lbl.isHidden() is False + assert "test2" in ws._folder_warn_lbl.text() + + +def test_khong_canh_bao_khi_thu_muc_rieng(qapp, ws, monkeypatch, tmp_path): + _mo_project(qapp, ws, monkeypatch, + Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")), + Project(project_id="a", name="test2", output_dir=str(tmp_path / "a"))) + + assert ws._folder_warn_lbl.isHidden() is True + + +def test_nhan_canh_bao_nam_ngay_duoi_o_thu_muc_lam_viec(ws): + """Cảnh báo phải ở cạnh thứ nó nói tới, không rơi xuống cuối biểu mẫu.""" + from cowork_local.presentation.workspace.project_editing import _row_layout_of + from cowork_local.presentation.workspace.project_folder_rules import _layout_chua + + hang = _row_layout_of(ws.folder_lbl) + layout, vi_tri = _layout_chua(ws.folder_lbl.parentWidget().layout(), hang) + + assert layout.itemAt(vi_tri + 1).widget() is ws._folder_warn_lbl + +# ---- hành vi: chọn thư mục đã thuộc project khác thì KHÔNG được ghi ------ + +def test_chon_thu_muc_trung_thi_khong_ghi_gi(qapp, ws, monkeypatch, tmp_path): + """Đây là cổng chặn thật, ở đúng nút "Đổi" mà người dùng bấm.""" + from PySide6.QtWidgets import QFileDialog, QMessageBox + + from cowork_local.ui import workspace_tab as wt + + cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")) + cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")) + _mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac) + + da_ghi = [] + monkeypatch.setattr(projects_mod, "save_project", + lambda project, directory=None: da_ghi.append(project)) + da_bao = [] + monkeypatch.setattr(QMessageBox, "warning", + staticmethod(lambda *a, **k: da_bao.append(a))) + # Người dùng chọn đúng thư mục của project kia. + monkeypatch.setattr(QFileDialog, "getExistingDirectory", + staticmethod(lambda *a, **k: str(tmp_path / "a"))) + + wt.WorkspaceTab._pick_folder(ws) + + assert da_ghi == [], "đã ghi đè output_dir dù thư mục thuộc project khác" + assert cua_toi.output_dir == str(tmp_path / "b"), "thư mục cũ bị đổi mất" + assert da_bao, "chặn im lặng — người dùng không biết vì sao không đổi được" + + +def test_chon_thu_muc_tu_do_thi_van_doi_duoc(qapp, ws, monkeypatch, tmp_path): + """Chặn một chiều là hỏng tính năng — thư mục chưa ai dùng phải đổi được.""" + from PySide6.QtWidgets import QFileDialog + + from cowork_local.ui import workspace_tab as wt + + cua_toi = Project(project_id="b", name="test3", output_dir=str(tmp_path / "b")) + cua_nguoi_khac = Project(project_id="a", name="test2", output_dir=str(tmp_path / "a")) + _mo_project(qapp, ws, monkeypatch, cua_toi, cua_nguoi_khac) + + da_ghi = [] + monkeypatch.setattr(projects_mod, "save_project", + lambda project, directory=None: da_ghi.append(project)) + monkeypatch.setattr(QFileDialog, "getExistingDirectory", + staticmethod(lambda *a, **k: str(tmp_path / "hoan-toan-moi"))) + + wt.WorkspaceTab._pick_folder(ws) + + assert len(da_ghi) == 1 + assert cua_toi.output_dir == str(tmp_path / "hoan-toan-moi") diff --git a/ui/cowork_tab.py b/ui/cowork_tab.py index 7efe955..5b00879 100644 --- a/ui/cowork_tab.py +++ b/ui/cowork_tab.py @@ -123,9 +123,12 @@ class CoworkTab(ChatPanel): # workspace (its sandbox + shared-knowledge root) — not the # global default-output setting. from ..core.projects import save_project + from ..presentation.workspace.project_folder_rules import folder_taken_blocked project = self._project() if project is not None: + if folder_taken_blocked(self, chosen, self.project_id): + return project.output_dir = chosen save_project(project) else: diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 41a22d1..47df595 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -27,13 +27,14 @@ from PySide6.QtWidgets import ( from ..i18n import on_language_changed, tr from ..presentation.workspace.project_editing import ProjectEditingMixin, ProjectRow +from ..presentation.workspace.project_folder_rules import ProjectFolderRuleMixin, folder_taken_blocked from ..state import AppContext from .icons import collapse_left_icon, icon from .osutil import open_folder from .widgets import CollapseStrip -class WorkspaceTab(ProjectEditingMixin, QWidget): +class WorkspaceTab(ProjectEditingMixin, ProjectFolderRuleMixin, QWidget): """Trang chủ Workspace: cột project, cột lịch sử, và 5 sub-tab (Dự án · Cowork · Co4E · Thư mục · GraphRAG). @@ -941,6 +942,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): self, tr("workspace.browse_tooltip"), str(project.workspace_dir())) if not chosen: return + if folder_taken_blocked(self, chosen, pid): + return project.output_dir = chosen save_project(project) self.folder_lbl.setText(chosen) @@ -984,6 +987,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): if not cloud_source: return local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror" + if folder_taken_blocked(self, str(local_dir), pid): + return self._cloud_pick_btn.setEnabled(False) try: token = self._cloud_token()