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
+48
View File
@@ -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."""