diff --git a/presentation/graph/graph_project_lock.py b/presentation/graph/graph_project_lock.py new file mode 100644 index 0000000..1480379 --- /dev/null +++ b/presentation/graph/graph_project_lock.py @@ -0,0 +1,83 @@ +"""Khoá phạm vi quét của màn GraphRAG vào một project. + +Tách khỏi ``graph_renderer.py``: file đó đã ở 399/400 dòng — đúng một dòng +trước trần của ``scripts/check_loc.py``, và cổng ấy nói rõ cách duy nhất đúng +khi chạm trần là tách file, không phải nới con số. Khối này là chỗ tự nhiên +để cắt: ba phương thức dưới đây chỉ nói về một việc — project nào đang khoá, +và thư mục nào đi theo nó — còn phần còn lại của renderer lo việc vẽ. + +Là mixin chứ không phải đối tượng rời, cùng lý do như ``NavRailMixin``: ba +phương thức này đọc/ghi state của chính renderer (``project_combo``, +``path_edit``, ``_needs_scan``…). Biến thành đối tượng cộng tác thì phải viết +lại từng chỗ ``self.X`` thành ``self.renderer.X`` mà không đổi hành vi gì. +""" +from __future__ import annotations + +from cowork_local.i18n import tr + + +class GraphProjectLockMixin: + """Ba phương thức khoá-theo-project. Trộn vào ``GraphRenderer``.""" + + def _refresh_project_combo(self) -> None: + """Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn.""" + from cowork_local.core.projects import list_projects + + keep = self._active_project_id + self.project_combo.blockSignals(True) + self.project_combo.clear() + self.project_combo.addItem(tr("structure.project_none"), "") + row_to_select = 0 + for i, p in enumerate(list_projects(), start=1): + self.project_combo.addItem(p.name, p.project_id) + if p.project_id == keep: + row_to_select = i + self.project_combo.setCurrentIndex(row_to_select) + self.project_combo.blockSignals(False) + + def set_project(self, project_id: str) -> None: + """Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá).""" + pid = project_id or "" + self._refresh_project_combo() + target = self.project_combo.findData(pid) + if target < 0: + target = 0 + if self.project_combo.currentIndex() == target: + self._on_project_changed(target) + else: + self.project_combo.setCurrentIndex(target) + + def _on_project_changed(self, _idx: int) -> None: + """Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục""" + from cowork_local.core.projects import load_project + + pid = self.project_combo.currentData() or "" + project_changed = pid != self._active_project_id + self._active_project_id = pid + locked = bool(pid) + self.path_edit.setReadOnly(locked) + self._pick_btn.setEnabled(not locked) + if locked: + project = load_project(pid) + if project is not None: + self.path_edit.setText(str(project.workspace_dir())) + # Changing the FOLDER changes what we scan just as much as changing the + # project does. Keying this off the id alone left the path in the bar + # updated while the graph in the middle still showed the old folder's + # nodes: "Đổi" in the Project screen moves the folder, never the id. + duong_dan = self.path_edit.text().strip() + doi_muc_tieu = project_changed or duong_dan != self._active_path + self._active_path = duong_dan + if doi_muc_tieu: + self.project_changed.emit() # GraphQaWidget drops its temp extraction cache + # Mark it and scan on the next visit rather than now — see + # auto_scan_and_fit()'s docstring for why. + self._needs_scan = True + # ...except when this screen is the one on show. The picker lives HERE, + # so a user changing project is already looking at the graph: there is + # no "next visit" to defer to, and they had to press Scan by hand. + # Deferring still applies when the change came from the Workspace + # screen while this one is hidden, which is what it was for. + if self.isVisible() and duong_dan: + self._needs_scan = False + self._scan() diff --git a/presentation/graph/graph_renderer.py b/presentation/graph/graph_renderer.py index 2d8a771..1fdf243 100644 --- a/presentation/graph/graph_renderer.py +++ b/presentation/graph/graph_renderer.py @@ -27,6 +27,7 @@ from cowork_local.core.worker import AgentWorker from cowork_local.i18n import on_language_changed, tr from cowork_local.presentation.graph import graph_export from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView +from cowork_local.presentation.graph.graph_project_lock import GraphProjectLockMixin from cowork_local.presentation.graph.graph_scene_builder import build_scene from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node from cowork_local.presentation.shared import HAS_WEB_ENGINE @@ -35,7 +36,7 @@ from cowork_local.theme import current_palette from cowork_local.ui.icons import icon -class GraphRenderer(QWidget): +class GraphRenderer(GraphProjectLockMixin, QWidget): """Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét.""" status_message = Signal(str) node_selected = Signal(object) # a node's .data, whenever the scene selection changes @@ -60,6 +61,8 @@ class GraphRenderer(QWidget): self._needs_scan = False self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox + # The folder last scanned — see graph_project_lock.py. + self._active_path = "" self._rescan_timer = QTimer(self) self._rescan_timer.setSingleShot(True) @@ -154,63 +157,6 @@ class GraphRenderer(QWidget): """ return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - # ---- project sandbox lock ------------------------------------------------- # - def _refresh_project_combo(self) -> None: - """Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn.""" - from cowork_local.core.projects import list_projects - - keep = self._active_project_id - self.project_combo.blockSignals(True) - self.project_combo.clear() - self.project_combo.addItem(tr("structure.project_none"), "") - row_to_select = 0 - for i, p in enumerate(list_projects(), start=1): - self.project_combo.addItem(p.name, p.project_id) - if p.project_id == keep: - row_to_select = i - self.project_combo.setCurrentIndex(row_to_select) - self.project_combo.blockSignals(False) - - def set_project(self, project_id: str) -> None: - """Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá).""" - pid = project_id or "" - self._refresh_project_combo() - target = self.project_combo.findData(pid) - if target < 0: - target = 0 - if self.project_combo.currentIndex() == target: - self._on_project_changed(target) - else: - self.project_combo.setCurrentIndex(target) - - def _on_project_changed(self, _idx: int) -> None: - """Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục""" - from cowork_local.core.projects import load_project - - pid = self.project_combo.currentData() or "" - project_changed = pid != self._active_project_id - self._active_project_id = pid - locked = bool(pid) - self.path_edit.setReadOnly(locked) - self._pick_btn.setEnabled(not locked) - if locked: - project = load_project(pid) - if project is not None: - self.path_edit.setText(str(project.workspace_dir())) - if project_changed: - self.project_changed.emit() # GraphQaWidget drops its temp extraction cache - # Mark it and scan on the next visit rather than now — see - # auto_scan_and_fit()'s docstring for why. - self._needs_scan = True - # ...except when this screen is the one on show. The picker lives HERE, - # so a user changing project is already looking at the graph: there is - # no "next visit" to defer to, and they had to press Scan by hand. - # Deferring still applies when the change came from the Workspace - # screen while this one is hidden, which is what it was for. - if self.isVisible() and self.path_edit.text().strip(): - self._needs_scan = False - self._scan() - # ---- helpers ---------------------------------------------------------------- # def _pick(self) -> None: """Mở hộp thoại chọn thư mục gốc để quét.""" diff --git a/tests/ui/test_graphrag_rescan_on_folder_change.py b/tests/ui/test_graphrag_rescan_on_folder_change.py new file mode 100644 index 0000000..69cff5c --- /dev/null +++ b/tests/ui/test_graphrag_rescan_on_folder_change.py @@ -0,0 +1,167 @@ +"""Đổi thư mục project thì đồ thị giữa màn GraphRAG phải quét lại. + +Nối tiếp ``test_graphrag_follows_folder_change.py``. Sau khi đường dẫn trên +thanh đã trỏ đúng thư mục mới, các node ở giữa màn vẫn là của thư mục cũ: không +có lệnh quét lại nào được phát ra. + +Nguyên nhân cùng một họ với hai mảnh trước — câu hỏi "có gì đổi không" được trả +lời bằng project id chứ không bằng thứ thật sự quyết định kết quả quét: + + project_changed = pid != self._active_project_id + +Đổi thư mục làm việc ở màn Project giữ nguyên id, nên ``project_changed`` là +False và cả khối phát tín hiệu lẫn khối gọi ``_scan()`` đều bị bỏ qua. + +Cố ý KHÔNG dựng ``GraphRenderer`` thật: nó kéo theo QtWebEngine, dựng trong bộ +``tests/ui`` làm cả bộ chết giữa chừng (xem docstring của +``test_graphrag_follows_folder_change.py``). ``_on_project_changed`` là Python +thuần trên các thuộc tính của chính nó, nên gọi thẳng với một ``self`` giả là đủ +và đúng hơn — bài test chốt luồng quyết định, không chốt phần vẽ. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để nạp module renderer") + +from cowork_local.core import projects as projects_mod +from cowork_local.core.projects import Project +from cowork_local.presentation.graph.graph_renderer import GraphRenderer + + +class _O: + """Vật thể ghi lại lời gọi, thay cho một widget Qt.""" + + def __init__(self, **thuoc_tinh): + self.__dict__.update(thuoc_tinh) + self.da_goi = [] + + def __getattr__(self, ten): + def ghi(*args, **kwargs): + self.da_goi.append((ten, args)) + return ghi + + +class _ComboGia: + def __init__(self, pid): + self._pid = pid + + def currentData(self): + return self._pid + + +class _OGia: + """Ô nhập đường dẫn: giữ được chữ, và ghi lại việc bị khoá.""" + + def __init__(self, text=""): + self._text = text + self.read_only = False + + def text(self): + return self._text + + def setText(self, value): + self._text = value + + def setReadOnly(self, value): + self.read_only = value + + +class _Renderer: + """``self`` giả cho ``GraphRenderer._on_project_changed``.""" + + def __init__(self, pid, active_id="", active_path="", hien=True): + self.project_combo = _ComboGia(pid) + self.path_edit = _OGia() + self._pick_btn = _O() + self.project_changed = _O() + self._active_project_id = active_id + self._active_path = active_path + self._needs_scan = False + self._hien = hien + self.lan_quet = 0 + + def isVisible(self): + return self._hien + + def _scan(self): + self.lan_quet += 1 + + # -- tiện cho khẳng định --------------------------------------------- # + @property + def so_lan_bao_doi(self): + """Số lần phát tín hiệu "đã đổi mục tiêu".""" + return sum(1 for ten, _ in self.project_changed.da_goi if ten == "emit") + + +@pytest.fixture +def du_an(monkeypatch, tmp_path): + """Một project duy nhất trong kho giả, trỏ vào ``tmp_path/cu``.""" + p = Project(project_id="p1", name="test", output_dir=str(tmp_path / "cu")) + monkeypatch.setattr(projects_mod, "load_project", + lambda pid, directory=None: p if pid == "p1" else None) + return p + + +def _chay(renderer): + """Gọi đúng hàm thật với ``self`` giả.""" + GraphRenderer._on_project_changed(renderer, 0) + + +def test_cung_project_thu_muc_moi_thi_quet_lai(du_an, tmp_path): + """Đây là chỗ hỏng người dùng báo: đường dẫn đổi mà node giữa màn thì không.""" + r = _Renderer("p1") + _chay(r) # lần đầu: khoá vào project + assert r.lan_quet == 1 + + du_an.output_dir = str(tmp_path / "moi") # người dùng bấm "Đổi" ở tab Project + _chay(r) + + assert r.path_edit.text() == str(tmp_path / "moi") + assert r.lan_quet == 2, "đổi thư mục xong nhưng không quét lại — node vẫn của thư mục cũ" + assert r.so_lan_bao_doi == 2, ( + "phải báo đổi để khung hỏi-đáp bỏ phần trích xuất của thư mục cũ") + + +def test_cung_project_cung_thu_muc_thi_khong_quet_lai(du_an): + """Bảo vệ sẵn có: ``_bind_project`` chạy lại mỗi lần vào lại màn Workspace, + quét lại vô cớ là vừa giật vừa tốn.""" + r = _Renderer("p1") + _chay(r) + assert r.lan_quet == 1 + + _chay(r) + _chay(r) + + assert r.lan_quet == 1 + + +def test_doi_sang_project_khac_van_quet_lai(du_an, tmp_path, monkeypatch): + """Hành vi vốn có, không được mất.""" + khac = Project(project_id="p2", name="khac", output_dir=str(tmp_path / "cua-p2")) + monkeypatch.setattr(projects_mod, "load_project", + lambda pid, directory=None: du_an if pid == "p1" else khac) + r = _Renderer("p1") + _chay(r) + + r.project_combo._pid = "p2" + _chay(r) + + assert r.lan_quet == 2 + assert r.path_edit.text() == str(tmp_path / "cua-p2") + + +def test_man_dang_an_thi_hoan_quet_chu_khong_quet_ngay(du_an, tmp_path): + """Đổi thư mục từ màn Project trong khi GraphRAG đang ẩn: đánh dấu để quét + ở lần vào sau, đúng luật hoãn mà ``auto_scan_and_fit`` dựa vào.""" + r = _Renderer("p1", hien=False) + _chay(r) + # Lần khoá đầu tiên đã đặt cờ rồi; xoá đi để bài này thật sự kiểm được + # lần ĐỔI THƯ MỤC, chứ không xanh nhờ cờ còn sót của lần trước. + r._needs_scan = False + + du_an.output_dir = str(tmp_path / "moi") + _chay(r) + + assert r.lan_quet == 0 + assert r._needs_scan is True