"""Chọn project và đổi chế độ xem cho GraphRAG — R08-T14. Đồ thị luôn thuộc về một project. Đổi project là phải quét lại từ đầu, nên phần này giữ luôn việc dọn kết quả cũ trước khi nạp cái mới. Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. """ from __future__ import annotations from .graph_qa_widget import GraphQaMixin from .graph_render import GraphRenderMixin from .graph_scene import _Edge, _GraphView, _Node import re import sys from pathlib import Path from PySide6.QtCore import QPointF, Qt, QTimer, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import QComboBox, QFileDialog, QGraphicsScene, QGraphicsView, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, QTextBrowser, QVBoxLayout, QWidget from ...theme import current_palette from ...core.worker import AgentWorker from ...i18n import on_language_changed, tr from ...state import AppContext from ...ui.icons import collapse_right_icon, icon from ...ui.widgets import CollapseStrip class GraphProjectMixin: """Chọn project + đổi tab xem. Trộn vào StructureGraphView.""" def _retranslate(self) -> None: self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) self._pick_btn.setText(tr("structure.browse")) self._scan_btn.setText(tr("structure.scan")) self._export_btn.setText(tr("structure.export_png")) # Both views are named at once now, so neither label depends on state. self.view_tabs.setTabText(0, tr("structure.graph_btn")) self.view_tabs.setTabText(1, tr("structure.msgs_btn")) self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) self._ag_label.setText(tr("structure.agent_header")) self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) self._ask_btn.setText(tr("structure.ask")) if self._detail_mode == "idle": self.detail.setPlaceholderText(tr("structure.detail_placeholder")) self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) self.project_combo.setToolTip(tr("structure.project_tooltip")) self._refresh_project_combo() def _refresh_project_combo(self) -> None: from ...core.projects import list_projects keep = self._active_project_id self.project_combo.blockSignals(True) self.project_combo.clear() self.project_combo.addItem(tr("structure.project_none"), "") row_to_select = 0 for i, p in enumerate(list_projects(), start=1): self.project_combo.addItem(p.name, p.project_id) if p.project_id == keep: row_to_select = i self.project_combo.setCurrentIndex(row_to_select) self.project_combo.blockSignals(False) def set_project(self, project_id: str) -> None: pid = project_id or "" self._refresh_project_combo() target = self.project_combo.findData(pid) if target < 0: target = 0 if self.project_combo.currentIndex() == target: self._on_project_changed(target) else: self.project_combo.setCurrentIndex(target) def _on_project_changed(self, _idx: int) -> None: from ...core.projects import load_project pid = self.project_combo.currentData() or "" project_changed = pid != self._active_project_id if project_changed: self._clear_extracts() # different workspace → drop temp extraction self._active_project_id = pid locked = bool(pid) self.path_edit.setReadOnly(locked) # Also disable the folder-pick button — otherwise the scan path is only # "locked" against typing, but the picker could still repoint it outside # the selected project's sandbox, breaking GraphRAG scope isolation. self._pick_btn.setEnabled(not locked) if locked: project = load_project(pid) if project is not None: self.path_edit.setText(str(project.workspace_dir())) if project_changed: # Mark it and scan on the next visit rather than now. The rail's # project picker made switching a one-click thing from any screen, # and each switch rebuilt this graph — a folder walk plus a force # layout plus a full setHtml of the D3 page — for a tab that was # usually not even on screen. auto_scan_and_fit() picks the flag up # when GraphRAG is actually opened. self._needs_scan = True def _pick(self) -> None: chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) if chosen: self.path_edit.setText(chosen) def _on_view_tab(self, index: int) -> None: """Tab 0 = graph, tab 1 = messages. Same two views as before, now named on screen instead of hidden behind one button's changing label.""" if index == 1: self._reload_messages() self._stack.setCurrentWidget(self._msgs_view) else: self._stack.setCurrentWidget(self.web if self.web is not None else self.view)