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
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:
@@ -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."""
|
||||
|
||||
@@ -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 レポートツールを開発中。命名規則に従うこと …」",
|
||||
|
||||
@@ -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 -------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
+6
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user