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."""
+11
View File
@@ -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()
+223
View File
@@ -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")
+3
View File
@@ -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
View File
@@ -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()