Files
cowork-local/presentation/graph/graph_render.py
T
Nam Pham Dinh ThanhandClaude Opus 5 4fef41481b refactor(graph): R08-T14 — structure_graph_view.py 1034 -> 11, tách 6 file
presentation/graph/
      structure_graph_view.py  325  lớp chính + dựng giao diện
      graph_qa_widget.py       322  hỏi-đáp trên đồ thị (_ask 119 dòng)
      graph_render.py          226  quét, vẽ Qt + D3, xuất ảnh
      graph_scene.py           138  node, cạnh, khung nhìn — thuần đồ hoạ
      graph_project.py         109  chọn project, đổi tab xem
      graph_web.py              38  cờ có dùng được QtWebEngine không
    ui/structure_graph_view.py  11  vỏ chuyển tiếp, giữ đường import cũ

BA LẦN CẮT HỎNG, ĐỀU LÀ TÊN CẤP MODULE BỊ BỎ LẠI
------------------------------------------------
_HAS_WEB, QWebEngineView, QWebChannel, _Bridge, _Edge, _Node — tất cả định
nghĩa ở file gốc, dùng ở file mới, nên NameError ngay lúc chạy. Bộ test đơn
vị KHÔNG bắt được cái nào: 756 bài vẫn xanh suốt ba lần. Chỉ
check_graphrag_rescan bắt, vì nó gọi prewarm() thật rồi chờ đồ thị dựng xong.

Sau lần thứ ba tôi bỏ cách đuổi từng lỗi và viết bộ dò tên chưa định nghĩa có
tính đến phạm vi hàm (tham số, biến cục bộ, except-as, comprehension). Nó
tìm ra nốt _fmt_plan và _qcolor còn thiếu ở hai file Co4E đã tách hôm trước —
hai quả mìn chưa nổ.

_HAS_WEB tách hẳn ra graph_web.py: cả structure_graph_view.py lẫn
graph_render.py đều phải hỏi, để ở một trong hai là vòng import.

756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 22:45:25 +09:00

228 lines
9.5 KiB
Python

"""Quét mã nguồn, dựng đồ thị, và xuất ảnh — R08-T14.
Hai đường vẽ song song: khung nhìn Qt (``_render``) và bản D3 chạy trong
QtWebEngine (``_render_d3``). WebEngine nặng nên chỉ dựng ở lần hiện đầu tiên
(``_ensure_web``), và ``prewarm`` hâm nóng nó lúc rảnh để bấm vào GraphRAG
không phải ngồi nhìn khung trắng.
Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
"""
from __future__ import annotations
from .graph_scene import _Bridge, _Edge, _Node
from .graph_web import _HAS_WEB, QWebChannel, QWebEngineView
import math
import re
from pathlib import Path
from PySide6.QtCore import QPointF, Qt, QUrl
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QFileDialog
from ...theme import current_palette
from ...core.worker import AgentWorker
from ...i18n import tr
class GraphRenderMixin:
"""Quét, vẽ, xuất. Trộn vào StructureGraphView."""
def schedule_rescan(self, path: str = "") -> None:
if self._graph is None:
self._needs_scan = True
return
self._rescan_timer.start()
def prewarm(self) -> None:
"""Pay for the graph view before it is clicked on, not during.
Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project
(~485ms) while an empty browser sat on screen — long enough, and white
enough, to read as the app restarting itself. Called from an idle timer
after the window is up, so startup itself is unaffected; the memory the
lazy construction was saving is spent a few seconds later instead.
"""
if not _HAS_WEB or self.web is not None:
return
self._ensure_web()
if self._graph is None and self.path_edit.text().strip():
self._needs_scan = False
self._scan() # runs on a worker thread
def _ensure_web(self) -> None:
if self.web is not None or not _HAS_WEB:
return
self.web = QWebEngineView()
# Blank the page in the app's own background first. A fresh
# QWebEngineView paints white, and on a dark theme that white rectangle
# WAS the flash — it showed for as long as the first scan took.
self.web.setHtml(
f"<body style='margin:0;background:{current_palette().bg}'></body>")
self._bridge = _Bridge()
self._channel = QWebChannel()
self._channel.registerObject("py", self._bridge)
self.web.page().setWebChannel(self._channel)
self._stack.addWidget(self.web)
self._stack.setCurrentWidget(self.web)
if self._graph is not None:
self._render_d3()
def auto_scan_and_fit(self) -> None:
self._ensure_web()
if not self.path_edit.text().strip():
return
if getattr(self, "_worker", None) is not None and self._worker.isRunning():
self._fit()
self._preserve_answer()
return
if self._graph is not None and not self._needs_scan:
self._fit()
self._preserve_answer()
return
self._needs_scan = False
self._scan()
def _scan(self) -> None:
path = self.path_edit.text().strip() or str(Path.cwd())
mode = "files" # default: scan all files (filter removed)
use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
st = self.ctx.config.structure
max_nodes = int(st.get("max_nodes", 500) or 0)
max_edges = int(st.get("max_edges", 500) or 0)
self._scan_seq += 1
seq = self._scan_seq
self.status_message.emit(tr("structure.scanning"))
def job(worker: AgentWorker):
from ...core.structure_graph import (
build_from_codebase_memory, build_from_directory, force_layout,
)
if use_cmem:
from ...core.codebase_memory import CodebaseMemory
mem = CodebaseMemory(cmem_bin)
graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
else:
graph = build_from_directory(path, mode, max_nodes, max_edges)
pos = force_layout(graph)
return {"graph": graph, "pos": pos, "seq": seq}
w = AgentWorker(job)
w.finished_ok.connect(self._render)
w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
self._worker = w
w.start()
def _render(self, result: dict) -> None:
if result.get("seq") is not None and result["seq"] != self._scan_seq:
return
graph = result.get("graph")
pos = result.get("pos", {})
if graph is None:
return
self._graph = graph
self.scene.clear()
self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear
self._node_items = []
self._edge_items = []
degree = {n.id: 0 for n in graph.nodes}
for e in graph.edges:
if e.source in degree:
degree[e.source] += 1
if e.target in degree:
degree[e.target] += 1
items = {}
sx = sy = 0.0
for node in graph.nodes:
radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
item = _Node(node, radius)
x, y = pos.get(node.id, (0, 0))
item.setPos(x, y)
self.scene.addItem(item)
items[node.id] = item
self._node_items.append(item)
sx += x
sy += y
for edge in graph.edges:
a, b = items.get(edge.source), items.get(edge.target)
if a and b:
e = _Edge(a, b, getattr(edge, "type", ""))
self.scene.addItem(e)
self._edge_items.append(e)
n = max(1, len(self._node_items))
self._centroid = QPointF(sx / n, sy / n)
self._fit()
if self.web is not None:
self._render_d3()
note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
self.status_message.emit(tr(
"structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
self._preserve_answer()
def _render_d3(self) -> None:
if self.web is None or self._graph is None:
return
from ...core.d3_graph import build_html
try:
self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
except Exception as exc:
self.status_message.emit(f"D3 view error: {exc}")
def _on_selection(self) -> None:
for item in self.scene.selectedItems():
if isinstance(item, _Node):
d = item.data
self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}")
self._detail_mode = "node"
return
def _fit(self) -> None:
if self.web is not None and self._stack.currentWidget() is self.web:
self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
return
rect = self.scene.itemsBoundingRect()
if not rect.isNull():
self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
def _export(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
if not path:
return
showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
if showing_d3:
self._export_d3_png(path)
else:
self._export_widget_grab(path)
def _export_d3_png(self, path: str) -> None:
def on_result(data_url) -> None:
if not isinstance(data_url, str) or "," not in data_url:
self._export_widget_grab(path)
return
import base64
try:
with open(path, "wb") as f:
f.write(base64.b64decode(data_url.split(",", 1)[1]))
self.status_message.emit(tr("structure.export_done", path=path))
except (OSError, ValueError) as exc:
self.status_message.emit(tr("structure.export_failed", err=str(exc)))
self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
def _export_widget_grab(self, path: str) -> None:
ok = self._stack.currentWidget().grab().save(path, "PNG")
if ok:
self.status_message.emit(tr("structure.export_done", path=path))
else:
self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
@staticmethod
def _graph_context(graph) -> str:
from collections import defaultdict
by_kind = defaultdict(list)
for n in graph.nodes:
by_kind[n.kind].append(n.label)
lines = []
for kind in ("file", "class", "function", "method", "module", "section"):
items = by_kind.get(kind, [])
if items:
lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
id2label = {n.id: n.label for n in graph.nodes}
rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
for e in graph.edges[:140]]
if rels:
lines.append("Relationships (sample):\n" + "\n".join(rels))
return "\n".join(lines)[:7000]