diff --git a/i18n/skills_dialog.py b/i18n/skills_dialog.py index a1d67fa..7690c63 100644 --- a/i18n/skills_dialog.py +++ b/i18n/skills_dialog.py @@ -336,6 +336,9 @@ STRINGS: Dict[str, Dict[str, str]] = { "vi": "Nhấp node để mở thư mục, hoặc hỏi agent về đồ thị."}, "structure.pick_folder_title": {"en": "Choose folder", "ja": "フォルダを選択", "vi": "Chọn thư mục"}, "structure.scanning": {"en": "Scanning structure…", "ja": "構造をスキャン中…", "vi": "Đang quét cấu trúc…"}, + "structure.loading_view": { + "en": "Loading the graph view…", "ja": "グラフビューを読み込み中…", + "vi": "Đang tải khung đồ thị…"}, "structure.scan_error": {"en": "Scan error: {err}", "ja": "スキャンエラー: {err}", "vi": "Lỗi khi quét: {err}"}, "structure.graph_summary": {"en": "Graph: {nodes} nodes, {edges} edges.{note}", "ja": "グラフ: ノード {nodes} 個、エッジ {edges} 個。{note}", "vi": "Đồ thị: {nodes} node, {edges} cạnh.{note}"}, "structure.truncated_note": {"en": " (truncated — too many nodes)", "ja": " (切り捨て:ノードが多すぎます)", "vi": " (đã cắt bớt — quá nhiều node)"}, diff --git a/presentation/graph/graph_renderer.py b/presentation/graph/graph_renderer.py index 5b1dcb6..6a8493f 100644 --- a/presentation/graph/graph_renderer.py +++ b/presentation/graph/graph_renderer.py @@ -202,6 +202,14 @@ class GraphRenderer(QWidget): # 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: diff --git a/presentation/graph/structure_graph_view.py b/presentation/graph/structure_graph_view.py index b92f83a..9c5856d 100644 --- a/presentation/graph/structure_graph_view.py +++ b/presentation/graph/structure_graph_view.py @@ -9,9 +9,11 @@ inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines from __future__ import annotations from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget +from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QProgressBar, QSplitter, QVBoxLayout, QWidget, +) -from cowork_local.i18n import on_language_changed +from cowork_local.i18n import on_language_changed, tr from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget from cowork_local.presentation.graph.graph_renderer import GraphRenderer from cowork_local.state import AppContext @@ -35,6 +37,9 @@ class StructureGraphView(QWidget): """ super().__init__() self.ctx = ctx + # Project ma man Workspace da ap xuong lan gan nhat. None = chua ap lan + # nao, de lan goi dau tien khong bi bo qua ke ca khi pid la chuoi rong. + self._workspace_project = None root = QVBoxLayout(self) self.renderer = GraphRenderer(ctx) @@ -50,6 +55,7 @@ class StructureGraphView(QWidget): self._split.setSizes(list(_COLLAPSED_SIZES_HINT)) root.addWidget(self._split, 1) + self._build_busy_panel() on_language_changed(self._retranslate) def _retranslate(self) -> None: @@ -72,18 +78,116 @@ class StructureGraphView(QWidget): self._split.setSizes(list(_COLLAPSED_SIZES_HINT)) # ---- public API (app.py / ui/workspace_tab.py) --------------------------- # + # ---- panel "đang tải" --------------------------------------------------- + def _build_busy_panel(self) -> None: + """Panel phủ lên khung đồ thị trong lúc nó đang được dựng. + + Vì sao cần: ``prewarm()`` chỉ chạy 3 giây sau khi cửa sổ hiện + (``main_window.py``), nên người dùng bấm GraphRAG trong 3 giây đầu sẽ + gặp ``_ensure_web()`` dựng ``QWebEngineView`` ĐỒNG BỘ trên GUI thread — + đóng băng 1-2 giây mà trước đây không có gì báo. + """ + self._busy = QWidget(self) + self._busy.setObjectName("graphBusy") + lay = QHBoxLayout(self._busy) + lay.setContentsMargins(18, 14, 18, 14) + lay.setSpacing(12) + self._busy_label = QLabel() + bar = QProgressBar() + bar.setRange(0, 0) # chế độ vô định + bar.setTextVisible(False) + bar.setFixedWidth(120) + lay.addWidget(self._busy_label) + lay.addWidget(bar) + self._busy.hide() + self.renderer.graph_rendered.connect(self._hide_busy) + # Đổi project ngay trên màn này thì renderer quét luôn, không chờ lần ghé + # sau — nên panel phải bật theo, không thì lượt quét đó lại im lặng. + self.renderer.project_changed.connect(self._on_project_scan_started) + + def _on_project_scan_started(self) -> None: + """Renderer vừa đổi project. Nó chỉ quét ngay khi màn này đang mở.""" + if self.renderer.isVisible(): + self._show_busy("structure.scanning") + + def _show_busy(self, key: str) -> None: + """Hiện panel và ÉP VẼ NGAY. + + ``repaint()`` chứ không ``update()``: ``update()`` chỉ xếp hàng một lượt + vẽ cho vòng lặp sự kiện, mà vòng lặp đó sắp bị chặn — panel sẽ chỉ hiện + ra SAU khi hết đóng băng, đúng lúc không còn cần tới nó nữa. + """ + self._busy_label.setText(tr(key)) + self._busy.adjustSize() + self._center_busy() + self._busy.show() + self._busy.raise_() + self._busy.repaint() + + def _hide_busy(self) -> None: + """Ẩn panel khi đồ thị đã vẽ xong.""" + if getattr(self, "_busy", None) is not None: + self._busy.hide() + + def _center_busy(self) -> None: + """Giữ panel ở giữa khung, kể cả khi cửa sổ đổi kích thước.""" + busy = getattr(self, "_busy", None) + if busy is None: + return + size = busy.sizeHint() + busy.setGeometry((self.width() - size.width()) // 2, + (self.height() - size.height()) // 2, + size.width(), size.height()) + + def resizeEvent(self, e): # noqa: N802 + """Đổi kích thước cửa sổ thì panel phải theo.""" + super().resizeEvent(e) + self._center_busy() + def schedule_rescan(self, path: str = "") -> None: """Hẹn quét lại đồ thị sau khi thư mục có thay đổi.""" self.renderer.schedule_rescan(path) def auto_scan_and_fit(self) -> None: - """Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần.""" - self.renderer.auto_scan_and_fit() + """Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần. + + Panel "đang tải" bật TRƯỚC khi gọi xuống renderer: phần dựng khung xem + chặn GUI thread, nên phải vẽ panel ra trước lúc đó. Nó tự ẩn khi + ``graph_rendered`` phát — bao trọn cả lượt quét chạy ở luồng nền phía + sau, chứ không tắt ngay khi hàm này trả về. + """ + da_dung_khung = self.renderer.web is not None + self._show_busy("structure.scanning" if da_dung_khung else "structure.loading_view") + try: + self.renderer.auto_scan_and_fit() + finally: + # Khong co gi de ve (chua chon thu muc) thi graph_rendered khong phat + # — panel se treo lai mai mai neu khong tu don o day. + if not self.renderer.path_edit.text().strip(): + self._hide_busy() 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á).""" self.renderer.set_project(project_id) + def set_workspace_project(self, project_id: str) -> None: + """Áp project theo màn Workspace — bỏ qua nếu project KHÔNG đổi. + + ``WorkspaceTab._bind_project`` gọi xuống đây, và nó chạy lại mỗi lần + người dùng vào lại màn Workspace (``_goto`` -> ``refresh`` -> + ``_load_current`` -> ``_bind_project``). Áp vô điều kiện thì bộ chọn + project của chính màn GraphRAG bị kéo về giá trị của Workspace: chọn một + project ở đây, sang tab khác rồi quay lại là mất. + + Đổi sang project khác ở màn Workspace thì vẫn áp — cùng luật với tab Thư + mục (``FolderTab.set_project_root``). Chỉ lần refresh trong CÙNG một + project là không được đụng. + """ + if project_id == self._workspace_project: + return + self._workspace_project = project_id + self.set_project(project_id) + def prewarm(self) -> None: """Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật.""" self.renderer.prewarm() diff --git a/tests/ui/test_graphrag_busy_panel.py b/tests/ui/test_graphrag_busy_panel.py new file mode 100644 index 0000000..234a39e --- /dev/null +++ b/tests/ui/test_graphrag_busy_panel.py @@ -0,0 +1,201 @@ +"""Vào màn GraphRAG phải có báo "đang tải". + +``prewarm()`` chỉ chạy 3 giây sau khi cửa sổ hiện (``main_window.py``), nên bấm +GraphRAG trong 3 giây đầu sẽ gặp ``_ensure_web()`` dựng ``QWebEngineView`` ĐỒNG BỘ +trên GUI thread — đóng băng 1-2 giây mà trước đây không có gì báo. + +Vì GUI thread bị chặn, panel phải được ``repaint()`` NGAY, không phải ``update()``: +``update()`` chỉ xếp hàng một lượt vẽ cho vòng lặp sự kiện, mà vòng lặp đó sắp bị +chặn — panel sẽ chỉ hiện ra sau khi hết đóng băng, đúng lúc không còn cần nữa. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật") + + +class _Renderer: + """Thay GraphRenderer. Ghi lại panel có đang hiện lúc nó bị gọi hay không.""" + + def __init__(self, qapp, web=None, path=""): + from PySide6.QtCore import QObject, Signal + + class _Sig(QObject): + graph_rendered = Signal() + project_changed = Signal() + self._sig = _Sig() + self.graph_rendered = self._sig.graph_rendered + self.project_changed = self._sig.project_changed + self._hien = False + self.web = web + self.path_edit = type("E", (), {"text": lambda _s: path})() + self.busy_luc_duoc_goi = None + self._view = None + + def isVisible(self): # noqa: N802 — khớp API của QWidget + return self._hien + + def auto_scan_and_fit(self): + # Chốt điều quan trọng nhất: panel phải hiện TRƯỚC khi việc chặn bắt đầu. + self.busy_luc_duoc_goi = not self._view._busy.isHidden() + + +@pytest.fixture +def view(qapp): + from cowork_local.presentation.graph.structure_graph_view import StructureGraphView + return StructureGraphView + + +def _dung(qapp, ViewCls, web=None, path="C:/tmp"): + """StructureGraphView với renderer bị thay — renderer thật kéo theo QtWebEngine.""" + v = ViewCls.__new__(ViewCls) + from PySide6.QtWidgets import QWidget + QWidget.__init__(v) + v.renderer = _Renderer(qapp, web=web, path=path) + v.renderer._view = v + v._workspace_project = None + v.resize(800, 600) + v._build_busy_panel() + return v + + +# ---- panel bật trước khi việc chặn bắt đầu ------------------------------- + +def test_panel_hien_TRUOC_khi_dung_khung_xem(qapp, view): + v = _dung(qapp, view) + try: + v.auto_scan_and_fit() + + assert v.renderer.busy_luc_duoc_goi is True, ( + "panel bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì") + finally: + v.deleteLater() + + +def test_chua_dung_khung_xem_thi_bao_dang_tai_khung(qapp, view): + """Hai giai đoạn, hai thông báo khác nhau.""" + from cowork_local.i18n import tr + + v = _dung(qapp, view, web=None) + try: + v.auto_scan_and_fit() + assert v._busy_label.text() == tr("structure.loading_view") + finally: + v.deleteLater() + + +def test_da_co_khung_xem_thi_bao_dang_quet(qapp, view): + from cowork_local.i18n import tr + + v = _dung(qapp, view, web=object()) + try: + v.auto_scan_and_fit() + assert v._busy_label.text() == tr("structure.scanning") + finally: + v.deleteLater() + + +# ---- panel tắt đúng lúc -------------------------------------------------- + +def test_panel_tat_khi_do_thi_ve_xong(qapp, view): + """Tắt theo ``graph_rendered``, không tắt ngay khi hàm trả về — lượt quét + còn chạy ở luồng nền phía sau.""" + v = _dung(qapp, view) + try: + v.auto_scan_and_fit() + assert v._busy.isHidden() is False + + v.renderer._sig.graph_rendered.emit() + + assert v._busy.isHidden() is True + finally: + v.deleteLater() + + +def test_chua_chon_thu_muc_thi_panel_khong_treo_lai(qapp, view): + """Không có gì để vẽ thì ``graph_rendered`` không phát — panel phải tự dọn.""" + v = _dung(qapp, view, path="") + try: + v.auto_scan_and_fit() + + assert v._busy.isHidden() is True, "panel treo lại mãi mãi" + finally: + v.deleteLater() + + +# ---- panel nằm giữa và theo kích thước ---------------------------------- + +def test_panel_nam_giua_khung(qapp, view): + v = _dung(qapp, view) + try: + v.auto_scan_and_fit() + g = v._busy.geometry() + + assert abs(g.center().x() - v.rect().center().x()) <= 1 + assert abs(g.center().y() - v.rect().center().y()) <= 1 + finally: + v.deleteLater() + + +def test_doi_kich_thuoc_thi_panel_theo(qapp, view): + """Gửi ``QResizeEvent`` thật thay vì chỉ gọi ``resize()``. + + Widget chưa ``show()`` thì Qt hoãn việc phát ``resizeEvent`` — mà việc Qt + phát event là phần của Qt, không phải phần cần kiểm. Cái cần kiểm là hàm + xử lý event có căn lại panel hay không. + """ + from PySide6.QtCore import QSize + from PySide6.QtGui import QResizeEvent + + v = _dung(qapp, view) + try: + v.auto_scan_and_fit() + cu = v.size() + v.resize(1200, 900) + v.resizeEvent(QResizeEvent(QSize(1200, 900), cu)) + + g = v._busy.geometry() + assert abs(g.center().x() - v.rect().center().x()) <= 1 + assert abs(g.center().y() - v.rect().center().y()) <= 1 + finally: + v.deleteLater() + + +# ---- màu lấy từ theme, không hardcode ------------------------------------ + +def test_panel_duoc_style_bang_token_trong_theme(): + """Guardrail G4: ngoài theme/ không file nào được đặt tên một màu.""" + from pathlib import Path + from cowork_local.theme.qss import _TEMPLATE + + assert "QWidget#graphBusy" in _TEMPLATE.template + src = (Path(__file__).resolve().parents[2] + / "presentation" / "graph" / "structure_graph_view.py").read_text(encoding="utf-8") + assert "setStyleSheet" not in src + + +# ---- đổi project ngay trên màn này thì quét luôn, có panel --------------- + +def test_doi_project_khi_man_dang_mo_thi_panel_bat(qapp, view): + """Renderer quét ngay (không chờ lần ghé sau) nên panel phải bật theo.""" + v = _dung(qapp, view) + try: + v.renderer._hien = True + v.renderer.project_changed.emit() + + assert v._busy.isHidden() is False + finally: + v.deleteLater() + + +def test_doi_project_khi_man_dang_an_thi_khong_bat_panel(qapp, view): + """Renderer hoãn lượt quét sang lần ghé sau — bật panel lúc này là treo lại.""" + v = _dung(qapp, view) + try: + v.renderer._hien = False + v.renderer.project_changed.emit() + + assert v._busy.isHidden() is True + finally: + v.deleteLater() diff --git a/tests/ui/test_graphrag_project_persists.py b/tests/ui/test_graphrag_project_persists.py new file mode 100644 index 0000000..7af9954 --- /dev/null +++ b/tests/ui/test_graphrag_project_persists.py @@ -0,0 +1,87 @@ +"""Project chọn ở tab GraphRAG không được biến mất khi chuyển tab. + +Cùng khuôn với ``test_folder_root_persists.py``: ``WorkspaceTab._bind_project`` +gọi ``self._structure.set_project(pid)`` vô điều kiện, và nó chạy lại mỗi lần +người dùng vào lại màn Workspace (``_goto`` → ``refresh`` → ``_load_current`` → +``_bind_project``). Nên bộ chọn project của chính màn GraphRAG bị kéo về giá trị +của Workspace mỗi cú chuyển tab. +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật") + + +class _Ghi: + """Thay GraphRenderer — chỉ ghi lại nó bị áp project mấy lần, với giá trị gì.""" + + def __init__(self): + self.lan_ap = [] + + def set_project(self, project_id): + self.lan_ap.append(project_id) + + +@pytest.fixture +def view(qapp, monkeypatch): + """StructureGraphView với renderer bị thay — dựng renderer thật kéo theo + QtWebEngine, quá đắt cho một khẳng định về luồng điều khiển.""" + from cowork_local.presentation.graph.structure_graph_view import StructureGraphView + + v = StructureGraphView.__new__(StructureGraphView) + v._workspace_project = None + v.renderer = _Ghi() + return v + + +def test_lan_dau_van_ap_du_project_la_rong(view): + """``None`` khởi tạo để lần gọi đầu không bị bỏ qua khi pid là chuỗi rỗng.""" + view.set_workspace_project("") + + assert view.renderer.lan_ap == [""] + + +def test_refresh_cung_project_khong_ap_lai(view): + """Đây chính là triệu chứng: chuyển tab rồi quay lại là mất lựa chọn.""" + view.set_workspace_project("p-a") + view.set_workspace_project("p-a") # chuyển tab rồi quay lại -> refresh + view.set_workspace_project("p-a") + + assert view.renderer.lan_ap == ["p-a"], "áp lại là kéo bộ chọn về, xoá lựa chọn tay" + + +def test_doi_project_o_workspace_thi_van_ap(view): + """Đổi project thật thì GraphRAG phải theo.""" + view.set_workspace_project("p-a") + view.set_workspace_project("p-b") + + assert view.renderer.lan_ap == ["p-a", "p-b"] + + +def test_quay_lai_project_cu_thi_ap_lai(view): + view.set_workspace_project("p-a") + view.set_workspace_project("p-b") + view.set_workspace_project("p-a") + + assert view.renderer.lan_ap == ["p-a", "p-b", "p-a"] + + +def test_bo_khoa_van_di_qua_duoc(view): + """Bỏ chọn project ở Workspace là một thay đổi thật, không phải refresh.""" + view.set_workspace_project("p-a") + view.set_workspace_project("") + + assert view.renderer.lan_ap == ["p-a", ""] + + +# ---- chỗ gọi phải dùng hàm mới ------------------------------------------- + +def test_workspace_tab_goi_set_workspace_project(): + from pathlib import Path + + src = (Path(__file__).resolve().parents[2] / "ui" / "workspace_tab.py").read_text(encoding="utf-8") + + assert "_structure.set_workspace_project(pid)" in src + assert "_structure.set_project(pid)" not in src, ( + "gọi set_project() vô điều kiện là quay lại đúng lỗi cũ")