Files
cowork-local/presentation/graph/structure_graph_view.py
T

230 lines
10 KiB
Python

"""StructureGraphView shell (R08-T14) — assembles
``graph_renderer.py::GraphRenderer`` and
``graph_qa_widget.py::GraphQaWidget`` behind the splitter that used to be
inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines
188-343 of the original 1035-line file), and forwards the public methods
``app.py``/``ui/workspace_tab.py`` call: ``schedule_rescan``,
``auto_scan_and_fit``, ``set_project``, ``prewarm``.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QProgressBar, QSplitter, QVBoxLayout, QWidget,
)
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
from cowork_local.ui.widgets import CollapseStrip
_COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default
class StructureGraphView(QWidget):
"""Màn GraphRAG: ghép khung đồ thị (:class:`GraphRenderer`) và khung hỏi-đáp
(:class:`GraphQaWidget`) vào một splitter.
Vỏ này chỉ lắp ráp và chuyển tiếp bốn phương thức mà ``app.py`` và
``ui/workspace_tab.py`` gọi tới; toàn bộ phần việc thật nằm ở hai widget con.
"""
status_message = Signal(str)
def __init__(self, ctx: AppContext):
"""Ghép hai nửa của màn GraphRAG: bên trái là đồ thị, bên phải là panel Hỏi
đáp.
"""
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)
self.renderer.status_message.connect(self.status_message.emit)
self.qa = GraphQaWidget(ctx, self.renderer)
self.qa.status_message.connect(self.status_message.emit)
self.qa.collapse_changed.connect(self._on_qa_collapse_changed)
self._split = QSplitter(Qt.Horizontal)
self._split.addWidget(self.renderer)
self._split.addWidget(self.qa)
self._split.setChildrenCollapsible(False)
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:
"""Chuyển lệnh dịch lại xuống cho cả hai widget con."""
self.renderer._retranslate()
self.qa.retranslate()
def _on_qa_collapse_changed(self, collapsed: bool) -> None:
"""Gập/mở khung hỏi-đáp: gập thì thu về đúng bề rộng dải nắm, mở thì trả
splitter về tỉ lệ mặc định.
"""
strip_w = CollapseStrip.WIDTH + 2
if collapsed:
self.qa.setMaximumWidth(strip_w)
sizes = self._split.sizes()
if len(sizes) == 2:
self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
else:
self.qa.setMaximumWidth(16777215)
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 _ensure_scan_path(self) -> None:
"""Điền ô đường dẫn từ project đang hoạt động nếu nó đang rỗng.
``GraphRenderer.auto_scan_and_fit`` thoát sớm khi ô đường dẫn rỗng,
trong khi chính nút Scan lại có đường lùi (``path_edit.text() or
Path.cwd()``). Bất đối xứng đó nghĩa là: vào màn thì không làm gì, bấm
Scan thì chạy — đúng thứ người dùng phàn nàn.
Điền từ project đang chọn chứ KHÔNG lấy ``cwd()`` làm đường lùi như nút
Scan: quét thư mục làm việc của tiến trình là quét một cây không liên
quan gì tới project, và nó có thể rất lớn.
"""
if self.renderer.path_edit.text().strip():
return
ctx = getattr(self, "ctx", None)
pid = (getattr(ctx, "active_project_id", "") or "").strip()
if not pid or pid == "default":
return
try:
from ...core.projects import load_project
project = load_project(pid)
except Exception: # noqa: BLE001
return
if project is not None:
self.renderer.path_edit.setText(str(project.workspace_dir()))
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.
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ề.
"""
self._ensure_scan_path()
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()
def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info.
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
self.qa.clear_extracts()
super().hideEvent(e)
__all__ = ["StructureGraphView"]