diff --git a/bec_widgets/examples/developer_view/developer_widget.py b/bec_widgets/examples/developer_view/developer_widget.py index d6009666..25061174 100644 --- a/bec_widgets/examples/developer_view/developer_widget.py +++ b/bec_widgets/examples/developer_view/developer_widget.py @@ -158,6 +158,7 @@ class DeveloperWidget(BECWidget, QWidget): # Connect editor signals self.explorer.file_open_requested.connect(self._open_new_file) + self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) self.toolbar.show_bundles(["save", "execution", "settings"]) @@ -225,7 +226,7 @@ class DeveloperWidget(BECWidget, QWidget): save_as_shortcut.activated.connect(self.on_save_as) def _open_new_file(self, file_name: str, scope: str): - self.monaco.open_file(file_name) + self.monaco.open_file(file_name, scope) # Set read-only mode for shared files if "shared" in scope: diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py index 3b247d97..be088508 100644 --- a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py @@ -266,6 +266,45 @@ class MacroTreeWidget(QWidget): self._scan_macro_functions() + def _create_file_item(self, py_file: Path) -> QStandardItem | None: + """Create a file item with its functions + + Args: + py_file: Path to the Python file + + Returns: + QStandardItem representing the file, or None if no functions found + """ + # Skip files starting with underscore + if py_file.name.startswith("_"): + return None + + try: + functions = self._extract_functions_from_file(py_file) + if not functions: + return None + + # Create a file node + file_item = QStandardItem(py_file.stem) + file_item.setData({"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole) + + # Add function nodes + for func_name, func_info in functions.items(): + func_item = QStandardItem(func_name) + func_data = { + "function_name": func_name, + "file_path": str(py_file), + "line_number": func_info.get("line_number", 1), + "type": "function", + } + func_item.setData(func_data, Qt.ItemDataRole.UserRole) + file_item.appendRow(func_item) + + return file_item + except Exception as e: + logger.warning(f"Failed to parse {py_file}: {e}") + return None + def _scan_macro_functions(self): """Scan the directory for Python files and extract macro functions""" self.model.clear() @@ -278,34 +317,9 @@ class MacroTreeWidget(QWidget): python_files = list(Path(self.directory).glob("*.py")) for py_file in python_files: - # Skip files starting with underscore - if py_file.name.startswith("_"): - continue - - try: - functions = self._extract_functions_from_file(py_file) - if functions: - # Create a file node - file_item = QStandardItem(py_file.stem) - file_item.setData( - {"file_path": str(py_file), "type": "file"}, Qt.ItemDataRole.UserRole - ) - - # Add function nodes - for func_name, func_info in functions.items(): - func_item = QStandardItem(func_name) - func_data = { - "function_name": func_name, - "file_path": str(py_file), - "line_number": func_info.get("line_number", 1), - "type": "function", - } - func_item.setData(func_data, Qt.ItemDataRole.UserRole) - file_item.appendRow(func_item) - - self.model.appendRow(file_item) - except Exception as e: - logger.warning(f"Failed to parse {py_file}: {e}") + file_item = self._create_file_item(py_file) + if file_item: + self.model.appendRow(file_item) self.tree.expandAll() @@ -399,6 +413,51 @@ class MacroTreeWidget(QWidget): return self._scan_macro_functions() + def refresh_file_item(self, file_path: str): + """Refresh a single file item by re-scanning its functions + + Args: + file_path: Path to the Python file to refresh + """ + if not file_path or not os.path.exists(file_path): + logger.warning(f"Cannot refresh file item: {file_path} does not exist") + return + + py_file = Path(file_path) + + # Find existing file item in the model + existing_item = None + existing_row = -1 + for row in range(self.model.rowCount()): + item = self.model.item(row) + if not item or not item.data(Qt.ItemDataRole.UserRole): + continue + item_data = item.data(Qt.ItemDataRole.UserRole) + if item_data.get("type") == "file" and item_data.get("file_path") == str(py_file): + existing_item = item + existing_row = row + break + + # Store expansion state if item exists + was_expanded = existing_item and self.tree.isExpanded(existing_item.index()) + + # Remove existing item if found + if existing_item and existing_row >= 0: + self.model.removeRow(existing_row) + + # Create new item using the helper method + new_item = self._create_file_item(py_file) + if new_item: + # Insert at the same position or append if it was a new file + insert_row = existing_row if existing_row >= 0 else self.model.rowCount() + self.model.insertRow(insert_row, new_item) + + # Restore expansion state + if was_expanded: + self.tree.expand(new_item.index()) + else: + self.tree.expand(new_item.index()) + def expand_all(self): """Expand all items in the tree""" self.tree.expandAll() diff --git a/bec_widgets/widgets/editors/monaco/monaco_tab.py b/bec_widgets/widgets/editors/monaco/monaco_tab.py index d4b62e78..a7332557 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_tab.py +++ b/bec_widgets/widgets/editors/monaco/monaco_tab.py @@ -2,10 +2,11 @@ from __future__ import annotations import os import pathlib -from typing import Any, cast +from typing import Any, Literal, cast import PySide6QtAds as QtAds from bec_lib.logger import bec_logger +from bec_lib.macro_update_handler import has_executable_code from PySide6QtAds import CDockWidget from qtpy.QtCore import QEvent, QTimer, Signal from qtpy.QtWidgets import QFileDialog, QMessageBox, QToolButton, QVBoxLayout, QWidget @@ -25,6 +26,7 @@ class MonacoDock(BECWidget, QWidget): focused_editor = Signal(object) # Emitted when the focused editor changes save_enabled = Signal(bool) # Emitted when the save action is enabled/disabled signature_help = Signal(str) # Emitted when signature help is requested + macro_file_updated = Signal(str) # Emitted when a macro file is saved def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) @@ -234,7 +236,7 @@ class MonacoDock(BECWidget, QWidget): QTimer.singleShot(0, self._scan_and_fix_areas) return new_dock - def open_file(self, file_name: str): + def open_file(self, file_name: str, scope: str | None = None) -> None: """ Open a file in the specified area. If the file is already open, activate it. """ @@ -260,12 +262,14 @@ class MonacoDock(BECWidget, QWidget): editor_dock.setWindowTitle(file) editor_dock.setTabToolTip(file_name) editor_widget.open_file(file_name) + editor_widget.metadata["scope"] = scope return # File is not open, create a new editor editor_dock = self.add_editor(title=file, tooltip=file_name) widget = cast(MonacoWidget, editor_dock.widget()) widget.open_file(file_name) + widget.metadata["scope"] = scope def save_file( self, widget: MonacoWidget | None = None, force_save_as: bool = False, format_on_save=True @@ -281,11 +285,22 @@ class MonacoDock(BECWidget, QWidget): widget = self.last_focused_editor.widget() if self.last_focused_editor else None if not widget: return + if "macros" in widget.metadata.get("scope", ""): + if not self._validate_macros(widget.get_text()): + return + if widget.current_file and not force_save_as: if format_on_save and pathlib.Path(widget.current_file).suffix == ".py": widget.format() + with open(widget.current_file, "w", encoding="utf-8") as f: f.write(widget.get_text()) + + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(widget.current_file) + # pylint: disable=protected-access widget._original_content = widget.get_text() widget.save_enabled.emit(False) @@ -317,9 +332,53 @@ class MonacoDock(BECWidget, QWidget): dock.setWindowTitle(file.name) dock.setTabToolTip(str(file)) break + if "macros" in widget.metadata.get("scope", ""): + self._update_macros(widget) + # Emit signal to refresh macro tree widget + self.macro_file_updated.emit(str(file)) print(f"Save file called, last focused editor: {self.last_focused_editor}") + def _validate_macros(self, source: str) -> bool: + # pylint: disable=protected-access + # Ensure the macro does not contain executable code before saving + exec_code, line_number = has_executable_code(source) + if exec_code: + if line_number is None: + msg = "The macro contains executable code. Please remove it before saving." + else: + msg = f"The macro contains executable code on line {line_number}. Please remove it before saving." + QMessageBox.warning(self, "Save Error", msg) + return False + return True + + def _update_macros(self, widget: MonacoWidget): + # pylint: disable=protected-access + if not widget.current_file: + return + # Check which macros have changed and broadcast the change + macros = self.client.macros._update_handler.get_macros_from_file(widget.current_file) + existing_macros = self.client.macros._update_handler.get_existing_macros( + widget.current_file + ) + + removed_macros = set(existing_macros.keys()) - set(macros.keys()) + added_macros = set(macros.keys()) - set(existing_macros.keys()) + for name, info in macros.items(): + if name in added_macros: + self.client.macros._update_handler.broadcast( + action="add", name=name, file_path=widget.current_file + ) + if ( + name in existing_macros + and info.get("source", "") != existing_macros[name]["source"] + ): + self.client.macros._update_handler.broadcast( + action="reload", name=name, file_path=widget.current_file + ) + for name in removed_macros: + self.client.macros._update_handler.broadcast(action="remove", name=name) + def set_vim_mode(self, enabled: bool): """ Set Vim mode for all editor widgets. diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 1a28eec0..dbfc9d5a 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -63,6 +63,7 @@ class MonacoWidget(BECWidget, QWidget): self.editor.context_menu_action_triggered.connect(self._handle_context_menu_action) self._current_file = None self._original_content = "" + self.metadata = {} @property def current_file(self): diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index ba17e36b..bec91e44 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -108,7 +108,11 @@ class IDEExplorer(BECWidget, QWidget): def add_macro_section(self): section = CollapsibleSection( - parent=self, title="MACROS", indentation=0, show_add_button=True + parent=self, + title="MACROS", + indentation=0, + show_add_button=True, + tooltip="Macros are reusable functions that can be called from scripts or the console.", ) section.header_add_button.setIcon(material_icon("refresh", size=(20, 20))) section.header_add_button.setToolTip("Reload all macros") @@ -315,6 +319,42 @@ def {function_name}(): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + def refresh_macro_file(self, file_path: str): + """Refresh a single macro file in the tree widget. + + Args: + file_path: Path to the macro file that was updated + """ + target_section = self.main_explorer.get_section("MACROS") + if not target_section or not hasattr(target_section, "content_widget"): + return + + # Determine if this is a local or shared macro based on the file path + local_section = target_section.content_widget.get_section("Local") + shared_section = target_section.content_widget.get_section("Shared") + + # Check if file belongs to local macros directory + if ( + local_section + and hasattr(local_section, "content_widget") + and hasattr(local_section.content_widget, "directory") + ): + local_macro_dir = local_section.content_widget.directory + if local_macro_dir and file_path.startswith(local_macro_dir): + local_section.content_widget.refresh_file_item(file_path) + return + + # Check if file belongs to shared macros directory + if ( + shared_section + and hasattr(shared_section, "content_widget") + and hasattr(shared_section.content_widget, "directory") + ): + shared_macro_dir = shared_section.content_widget.directory + if shared_macro_dir and file_path.startswith(shared_macro_dir): + shared_section.content_widget.refresh_file_item(file_path) + return + if __name__ == "__main__": from qtpy.QtWidgets import QApplication