feat(ui): GraphRAG giữ project đã chọn, tự quét, và báo khi đang tải

Cùng lớp lỗi với tab Thư mục: _bind_project gọi _structure.set_project(pid) vô
điều kiện, và chuỗi _goto -> refresh -> _load_current -> _bind_project chạy lại
mỗi lần vào lại màn Workspace, 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. set_workspace_project() bỏ
qua khi project không đổi (dùng None làm giá trị khởi tạo, không phải "", để
lần gọi đầu không bị bỏ qua khi chưa chọn project nào).

Chọn project xong phải tự quét: _on_project_changed cố ý hoãn lượt quét sang
"lần ghé tiếp", nhưng combo nằm ngay trên màn đó nên người dùng đang đứng ở đấy
và không có lần ghé tiếp nào — họ phải tự bấm Scan. Giờ quét ngay khi màn đang
mở; hoãn vẫn giữ cho trường hợp đổi project từ màn Workspace khi GraphRAG ẩn.

Panel "đang tải": prewarm() chỉ chạy 3 giây sau khi cửa sổ hiện, nên bấm
GraphRAG trong 3 giây đầu gặp _ensure_web() dựng QWebEngineView ĐỒNG BỘ trên
GUI thread — đóng băng 1-2 giây mà không có gì báo. Vì GUI thread bị chặn, panel
phải repaint() ngay 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, nê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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-10 01:34:36 +09:00
committed by thanhnv
co-authored by Claude Opus 5
parent 6f122575b0
commit 33876f96f6
5 changed files with 407 additions and 4 deletions
+3
View File
@@ -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)"},
+8
View File
@@ -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:
+108 -4
View File
@@ -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()
+201
View File
@@ -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()
@@ -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ũ")