"""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"") 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]