diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 4def0502..f6e2bf20 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -132,7 +132,7 @@ class DeveloperWidget(DockAreaWidget): self.monaco.macro_file_updated.connect(self.explorer.refresh_macro_file) self.monaco.focused_editor.connect(self._on_focused_editor_changed) - self.toolbar.show_bundles(["save", "execution", "settings"]) + self.toolbar.show_bundles(["new", "save", "execution", "settings"]) def _initialize_layout(self) -> None: """Create the default dock arrangement for the developer workspace.""" @@ -207,6 +207,23 @@ class DeveloperWidget(DockAreaWidget): def init_developer_toolbar(self): """Initialize the developer toolbar with necessary actions and widgets.""" + new_script_button = MaterialIconAction( + icon_name="note_add", tooltip="New Script", label_text="New Script", parent=self + ) + new_script_button.action.triggered.connect(self.on_new_script) + self.toolbar.components.add_safe("new_script", new_script_button) + + new_macro_button = MaterialIconAction( + icon_name="add_circle", tooltip="New Macro", label_text="New Macro", parent=self + ) + new_macro_button.action.triggered.connect(self.on_new_macro) + self.toolbar.components.add_safe("new_macro", new_macro_button) + + new_bundle = ToolbarBundle("new", self.toolbar.components) + new_bundle.add_action("new_script") + new_bundle.add_action("new_macro") + self.toolbar.add_bundle(new_bundle) + save_button = MaterialIconAction( icon_name="save", tooltip="Save", label_text="Save", filled=True, parent=self ) @@ -296,6 +313,16 @@ class DeveloperWidget(DockAreaWidget): """Save the currently focused file in the Monaco editor with a 'Save As' dialog.""" self.monaco.save_file(force_save_as=True) + @SafeSlot() + def on_new_script(self): + """Open the new script dialog from the explorer.""" + self.explorer._add_local_script() + + @SafeSlot() + def on_new_macro(self): + """Open the new macro dialog from the explorer.""" + self.explorer._add_local_macro() + @SafeSlot() def on_vim_triggered(self): """Toggle Vim mode in the Monaco editor.""" diff --git a/bec_widgets/widgets/containers/explorer/explorer.py b/bec_widgets/widgets/containers/explorer/explorer.py index 997648ef..4d6edd8f 100644 --- a/bec_widgets/widgets/containers/explorer/explorer.py +++ b/bec_widgets/widgets/containers/explorer/explorer.py @@ -150,14 +150,16 @@ if __name__ == "__main__": from qtpy.QtWidgets import QApplication, QLabel - from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget + from bec_widgets.widgets.containers.explorer.file_browser_tree_widget import ( + FileBrowserTreeWidget, + ) app = QApplication([]) explorer = Explorer() section = CollapsibleSection(title="SCRIPTS", indentation=0) script_explorer = Explorer() - script_widget = ScriptTreeWidget() + script_widget = FileBrowserTreeWidget() local_scripts_section = CollapsibleSection(title="Local") local_scripts_section.set_widget(script_widget) script_widget.set_directory(os.path.abspath("./")) @@ -166,7 +168,7 @@ if __name__ == "__main__": section.set_widget(script_explorer) explorer.add_section(section) shared_script_section = CollapsibleSection(title="Shared") - shared_script_widget = ScriptTreeWidget() + shared_script_widget = FileBrowserTreeWidget() shared_script_widget.set_directory(os.path.abspath("./")) shared_script_section.set_widget(shared_script_widget) script_explorer.add_section(shared_script_section) diff --git a/bec_widgets/widgets/containers/explorer/explorer_delegate.py b/bec_widgets/widgets/containers/explorer/explorer_delegate.py deleted file mode 100644 index 7a8b41cb..00000000 --- a/bec_widgets/widgets/containers/explorer/explorer_delegate.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from qtpy.QtCore import QModelIndex, QRect, QSortFilterProxyModel, Qt -from qtpy.QtGui import QPainter -from qtpy.QtWidgets import QAction, QStyledItemDelegate, QTreeView - -from bec_widgets.utils.colors import get_theme_palette - - -class ExplorerDelegate(QStyledItemDelegate): - """Custom delegate to show action buttons on hover for the explorer""" - - def __init__(self, parent=None): - super().__init__(parent) - self.hovered_index = QModelIndex() - self.button_rects: list[QRect] = [] - self.current_macro_info = {} - self.target_model = QSortFilterProxyModel - - def paint(self, painter, option, index): - """Paint the item with action buttons on hover""" - # Paint the default item - super().paint(painter, option, index) - - # Early return if not hovering over this item - if index != self.hovered_index: - return - - tree_view = self.parent() - if not isinstance(tree_view, QTreeView): - return - - proxy_model = tree_view.model() - if not isinstance(proxy_model, self.target_model): - return - - actions = self.get_actions_for_current_item(proxy_model, index) - if actions: - self._draw_action_buttons(painter, option, actions) - - def _draw_action_buttons(self, painter, option, actions: list[Any]): - """Draw action buttons on the right side""" - button_size = 18 - margin = 4 - spacing = 2 - - # Calculate total width needed for all buttons - total_width = len(actions) * button_size + (len(actions) - 1) * spacing - - # Clear previous button rects and create new ones - self.button_rects.clear() - - # Calculate starting position (right side of the item) - start_x = option.rect.right() - total_width - margin - current_x = start_x - - painter.save() - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # Get theme colors for better integration - palette = get_theme_palette() - button_bg = palette.button().color() - button_bg.setAlpha(150) # Semi-transparent - - for action in actions: - if not action.isVisible(): - continue - - # Calculate button position - button_rect = QRect( - current_x, - option.rect.top() + (option.rect.height() - button_size) // 2, - button_size, - button_size, - ) - self.button_rects.append(button_rect) - - # Draw button background - painter.setBrush(button_bg) - painter.setPen(palette.mid().color()) - painter.drawRoundedRect(button_rect, 3, 3) - - # Draw action icon - icon = action.icon() - if not icon.isNull(): - icon_rect = button_rect.adjusted(2, 2, -2, -2) - icon.paint(painter, icon_rect) - - # Move to next button position - current_x += button_size + spacing - - painter.restore() - - def get_actions_for_current_item(self, model, index) -> list[QAction] | None: - """Get actions for the current item based on its type""" - return None - - def editorEvent(self, event, model, option, index): - """Handle mouse events for action buttons""" - # Early return if not a left click - if not ( - event.type() == event.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton - ): - return super().editorEvent(event, model, option, index) - - actions = self.get_actions_for_current_item(model, index) - if not actions: - return super().editorEvent(event, model, option, index) - - # Check which button was clicked - visible_actions = [action for action in actions if action.isVisible()] - for i, button_rect in enumerate(self.button_rects): - if button_rect.contains(event.pos()) and i < len(visible_actions): - # Trigger the action - visible_actions[i].trigger() - return True - - return super().editorEvent(event, model, option, index) - - def set_hovered_index(self, index): - """Set the currently hovered index""" - self.hovered_index = index diff --git a/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py b/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py new file mode 100644 index 00000000..6bff6813 --- /dev/null +++ b/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py @@ -0,0 +1,304 @@ +import os +from pathlib import Path + +from bec_lib.logger import bec_logger +from qtpy.QtCore import QModelIndex, QPoint, QSortFilterProxyModel, Qt, Signal +from qtpy.QtWidgets import ( + QAbstractItemView, + QAction, + QFileSystemModel, + QInputDialog, + QMenu, + QTreeView, + QVBoxLayout, + QWidget, +) + +logger = bec_logger.logger + + +class _FileBrowserFilterProxyModel(QSortFilterProxyModel): + """Filter out cache directories while keeping the filesystem model behavior.""" + + def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: + source_model = self.sourceModel() + if source_model is None: + return False + + index = source_model.index(source_row, 0, source_parent) + if not index.isValid(): + return False + + file_name = source_model.fileName(index) + if file_name in {"__pycache__", "__init__.py"}: + return False + return super().filterAcceptsRow(source_row, source_parent) + + +class FileBrowserTreeWidget(QWidget): + """A plain QFileSystemModel browser pinned to a single directory.""" + + file_selected = Signal(str) + file_open_requested = Signal(str) + file_delete_requested = Signal(str) + file_renamed = Signal(str, str) + + def __init__(self, parent=None, directory: str | None = None, read_only: bool = False): + super().__init__(parent) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.tree = QTreeView(parent=self) + self.tree.setHeaderHidden(True) + self.tree.setRootIsDecorated(True) + self.tree.setSortingEnabled(True) + self.tree.setAnimated(True) + self.tree.setAlternatingRowColors(False) + self.tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.tree.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.tree.setDefaultDropAction(Qt.DropAction.MoveAction) + self.tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + self.model = QFileSystemModel(parent=self) + self.model.setNameFilters(["*.py"]) + self.model.setNameFilterDisables(False) + self.proxy_model = _FileBrowserFilterProxyModel(parent=self) + self.proxy_model.setRecursiveFilteringEnabled(True) + self.proxy_model.setSourceModel(self.model) + self.tree.setModel(self.proxy_model) + + self.tree.setColumnHidden(1, True) + self.tree.setColumnHidden(2, True) + self.tree.setColumnHidden(3, True) + + self._apply_styling() + + self.directory: str | None = None + + self.tree.clicked.connect(self._on_item_clicked) + self.tree.doubleClicked.connect(self._on_item_double_clicked) + self.tree.customContextMenuRequested.connect(self._show_context_menu) + self.model.fileRenamed.connect(self._on_model_file_renamed) + + layout.addWidget(self.tree) + + self.set_readonly(read_only) + if directory: + self.set_directory(directory) + + def _apply_styling(self): + """Apply styling to the tree widget.""" + tree_style = """ + QTreeView { + border: none; + outline: 0; + show-decoration-selected: 0; + } + QTreeView::branch { + border-image: none; + background: transparent; + } + QTreeView::item { + border: none; + padding: 0px; + margin: 0px; + } + """ + + self.tree.setStyleSheet(tree_style) + + def set_directory(self, directory: str) -> None: + """Pin the browser to a directory.""" + if not directory or not isinstance(directory, str) or not os.path.exists(directory): + return + + self.directory = directory + root_index = self.model.setRootPath(directory) + self.tree.setRootIndex(self.proxy_model.mapFromSource(root_index)) + + def set_readonly(self, read_only: bool) -> None: + """Toggle read-only mode for the file browser.""" + self.model.setReadOnly(read_only) + self.tree.setDragEnabled(not read_only) + self.tree.setAcceptDrops(not read_only) + self.tree.setDropIndicatorShown(not read_only) + self.tree.setDragDropMode( + QAbstractItemView.DragDropMode.NoDragDrop + if read_only + else QAbstractItemView.DragDropMode.InternalMove + ) + + def _on_item_clicked(self, index: QModelIndex): + """Emit a selection signal for Python files.""" + source_index = self._map_to_source(index) + if not source_index.isValid() or self.model.isDir(source_index): + return + + file_path = self.model.filePath(source_index) + if not file_path or not os.path.isfile(file_path): + return + + if Path(file_path).suffix.lower() == ".py": + logger.info(f"File selected: {file_path}") + self.file_selected.emit(file_path) + + def _on_item_double_clicked(self, index: QModelIndex): + """Emit an open signal for Python files.""" + source_index = self._map_to_source(index) + if not source_index.isValid() or self.model.isDir(source_index): + return + + file_path = self.model.filePath(source_index) + if not file_path or not os.path.isfile(file_path): + return + + logger.info(f"File open requested via double-click: {file_path}") + self.file_open_requested.emit(file_path) + + def _on_file_open_requested(self): + """Emit open for the currently selected file.""" + file_path = self._get_selected_file_path() + if file_path: + self.file_open_requested.emit(file_path) + + def _on_file_delete_requested(self): + """Emit delete for the currently selected file.""" + index = self.tree.currentIndex() + if not index.isValid(): + return + + source_index = self._map_to_source(index) + file_path = self.model.filePath(source_index) + if not file_path or self._is_root_path(file_path): + return + + self.file_delete_requested.emit(file_path) + + def _get_selected_file_path(self) -> str | None: + """Return the currently selected file path.""" + index = self.tree.currentIndex() + if not index.isValid(): + return None + + source_index = self._map_to_source(index) + file_path = self.model.filePath(source_index) + if not file_path or not os.path.isfile(file_path): + return None + return file_path + + def _show_context_menu(self, position: QPoint) -> None: + """Show a right-click context menu for editable items.""" + if self.model.isReadOnly(): + return + + index = self.tree.indexAt(position) + if index.isValid(): + self.tree.setCurrentIndex(index) + + menu = self._build_context_menu(index) + if menu is None: + return + menu.exec(self.tree.viewport().mapToGlobal(position)) + + def _build_context_menu(self, index: QModelIndex) -> QMenu | None: + """Build the context menu for a specific model index.""" + if self.model.isReadOnly(): + return None + + menu = QMenu(self) + target_directory = self._get_target_directory(index) + if target_directory is not None: + new_folder_action = QAction("New Folder", self) + new_folder_action.triggered.connect(lambda: self._create_subdirectory(target_directory)) + menu.addAction(new_folder_action) + + source_index = self._map_to_source(index) if index.isValid() else QModelIndex() + + if source_index.isValid() and not self.model.isDir(source_index): + open_action = QAction("Open", self) + open_action.triggered.connect(self._on_file_open_requested) + menu.addAction(open_action) + + file_path = self.model.filePath(source_index) if source_index.isValid() else None + if index.isValid() and file_path and not self._is_root_path(file_path): + rename_action = QAction("Rename", self) + rename_action.triggered.connect(lambda: self.tree.edit(index)) + menu.addAction(rename_action) + + delete_label = "Delete Folder" if self.model.isDir(source_index) else "Delete File" + delete_action = QAction(delete_label, self) + delete_action.triggered.connect(self._on_file_delete_requested) + menu.addAction(delete_action) + + if not menu.actions(): + return None + return menu + + def _on_model_file_renamed(self, directory: str, old_name: str, new_name: str) -> None: + """Emit full rename paths when QFileSystemModel completes a rename.""" + old_path = os.path.join(directory, old_name) + new_path = os.path.join(directory, new_name) + self.file_renamed.emit(old_path, new_path) + + def _get_target_directory(self, index: QModelIndex) -> str | None: + """Return the directory where a new subdirectory should be created.""" + if self.directory is None: + return None + + if not index.isValid(): + return self.directory + + source_index = self._map_to_source(index) + file_path = self.model.filePath(source_index) + if not file_path: + return self.directory + + if self.model.isDir(source_index): + return file_path + return os.path.dirname(file_path) + + def _create_subdirectory(self, parent_directory: str) -> None: + """Prompt for and create a subdirectory inside parent_directory.""" + folder_name, ok = QInputDialog.getText( + self, "New Folder", f"Enter folder name ({parent_directory}/):" + ) + if not ok or not folder_name: + return + + folder_name = folder_name.strip() + if not folder_name or os.path.sep in folder_name: + return + + os.makedirs(os.path.join(parent_directory, folder_name), exist_ok=True) + self.refresh() + + def _is_root_path(self, file_path: str) -> bool: + """Return True when a path is the pinned root directory itself.""" + if self.directory is None: + return False + return os.path.abspath(file_path) == os.path.abspath(self.directory) + + def refresh(self): + """Refresh the tree view.""" + if self.directory is None: + return + self.model.setRootPath("") + root_index = self.model.setRootPath(self.directory) + self.tree.setRootIndex(self.proxy_model.mapFromSource(root_index)) + + def expand_all(self): + """Expand all items in the tree.""" + self.tree.expandAll() + + def collapse_all(self): + """Collapse all items in the tree.""" + self.tree.collapseAll() + + def _map_to_source(self, index: QModelIndex) -> QModelIndex: + """Map a tree index back to the QFileSystemModel index.""" + if not index.isValid(): + return QModelIndex() + return self.proxy_model.mapToSource(index) diff --git a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py b/bec_widgets/widgets/containers/explorer/macro_tree_widget.py deleted file mode 100644 index 2546eb35..00000000 --- a/bec_widgets/widgets/containers/explorer/macro_tree_widget.py +++ /dev/null @@ -1,382 +0,0 @@ -import ast -import os -from pathlib import Path -from typing import Any - -from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRect, Qt, Signal -from qtpy.QtGui import QStandardItem, QStandardItemModel -from qtpy.QtWidgets import QAction, QTreeView, QVBoxLayout, QWidget - -from bec_widgets.utils.colors import get_theme_palette -from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate - -logger = bec_logger.logger - - -class MacroItemDelegate(ExplorerDelegate): - """Custom delegate to show action buttons on hover for macro functions""" - - def __init__(self, parent=None): - super().__init__(parent) - self.macro_actions: list[Any] = [] - self.button_rects: list[QRect] = [] - self.current_macro_info = {} - self.target_model = QStandardItemModel - - def add_macro_action(self, action: Any) -> None: - """Add an action for macro functions""" - self.macro_actions.append(action) - - def clear_actions(self) -> None: - """Remove all actions""" - self.macro_actions.clear() - - def get_actions_for_current_item(self, model, index) -> list[QAction] | None: - # Only show actions for macro functions (not directories) - item = index.model().itemFromIndex(index) - if not item or not item.data(Qt.ItemDataRole.UserRole): - return - - macro_info = item.data(Qt.ItemDataRole.UserRole) - if not isinstance(macro_info, dict) or "function_name" not in macro_info: - return - - self.current_macro_info = macro_info - return self.macro_actions - - -class MacroTreeWidget(QWidget): - """A tree widget that displays macro functions from Python files""" - - macro_selected = Signal(str, str) # Function name, file path - macro_open_requested = Signal(str, str) # Function name, file path - - def __init__(self, parent=None): - super().__init__(parent) - - # Create layout - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Create tree view - self.tree = QTreeView() - self.tree.setHeaderHidden(True) - self.tree.setRootIsDecorated(True) - - # Disable editing to prevent renaming on double-click - self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers) - - # Enable mouse tracking for hover effects - self.tree.setMouseTracking(True) - - # Create model for macro functions - self.model = QStandardItemModel() - self.tree.setModel(self.model) - - # Create and set custom delegate - self.delegate = MacroItemDelegate(self.tree) - self.tree.setItemDelegate(self.delegate) - - # Add default open button for macros - action = MaterialIconAction(icon_name="file_open", tooltip="Open macro file", parent=self) - action.action.triggered.connect(self._on_macro_open_requested) - self.delegate.add_macro_action(action.action) - - # Apply BEC styling - self._apply_styling() - - # Macro specific properties - self.directory = None - - # Connect signals - self.tree.clicked.connect(self._on_item_clicked) - self.tree.doubleClicked.connect(self._on_item_double_clicked) - - # Install event filter for hover tracking - self.tree.viewport().installEventFilter(self) - - # Add to layout - layout.addWidget(self.tree) - - def _apply_styling(self): - """Apply styling to the tree widget""" - # Get theme colors for subtle tree lines - palette = get_theme_palette() - subtle_line_color = palette.mid().color() - subtle_line_color.setAlpha(80) - - # Standard editable styling - opacity_modifier = "" - cursor_style = "" - - # pylint: disable=f-string-without-interpolation - tree_style = f""" - QTreeView {{ - border: none; - outline: 0; - show-decoration-selected: 0; - {opacity_modifier} - {cursor_style} - }} - QTreeView::branch {{ - border-image: none; - background: transparent; - }} - - QTreeView::item {{ - border: none; - padding: 0px; - margin: 0px; - }} - QTreeView::item:hover {{ - background: palette(midlight); - border: none; - padding: 0px; - margin: 0px; - text-decoration: none; - }} - QTreeView::item:selected {{ - background: palette(highlight); - color: palette(highlighted-text); - }} - QTreeView::item:selected:hover {{ - background: palette(highlight); - }} - """ - - self.tree.setStyleSheet(tree_style) - - def eventFilter(self, obj, event): - """Handle mouse move events for hover tracking""" - # Early return if not the tree viewport - if obj != self.tree.viewport(): - return super().eventFilter(obj, event) - - if event.type() == event.Type.MouseMove: - index = self.tree.indexAt(event.pos()) - if index.isValid(): - self.delegate.set_hovered_index(index) - else: - self.delegate.set_hovered_index(QModelIndex()) - self.tree.viewport().update() - return super().eventFilter(obj, event) - - if event.type() == event.Type.Leave: - self.delegate.set_hovered_index(QModelIndex()) - self.tree.viewport().update() - return super().eventFilter(obj, event) - - return super().eventFilter(obj, event) - - def set_directory(self, directory): - """Set the macros directory and scan for macro functions""" - self.directory = directory - - # Early return if directory doesn't exist - if not directory or not os.path.exists(directory): - return - - 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() - self.model.setHorizontalHeaderLabels(["Macros"]) - - if not self.directory or not os.path.exists(self.directory): - return - - # Get all Python files in the directory - python_files = list(Path(self.directory).glob("*.py")) - - for py_file in python_files: - file_item = self._create_file_item(py_file) - if file_item: - self.model.appendRow(file_item) - - self.tree.expandAll() - - def _extract_functions_from_file(self, file_path: Path) -> dict: - """Extract function definitions from a Python file""" - functions = {} - - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - # Parse the AST - tree = ast.parse(content) - - # Only get top-level function definitions - for node in tree.body: - if isinstance(node, ast.FunctionDef): - functions[node.name] = { - "line_number": node.lineno, - "docstring": ast.get_docstring(node) or "", - } - - except Exception as e: - logger.warning(f"Failed to parse {file_path}: {e}") - - return functions - - def _on_item_clicked(self, index: QModelIndex): - """Handle item clicks""" - item = self.model.itemFromIndex(index) - if not item: - return - - data = item.data(Qt.ItemDataRole.UserRole) - if not data: - return - - if data.get("type") == "function": - function_name = data.get("function_name") - file_path = data.get("file_path") - if function_name and file_path: - logger.info(f"Macro function selected: {function_name} in {file_path}") - self.macro_selected.emit(function_name, file_path) - - def _on_item_double_clicked(self, index: QModelIndex): - """Handle item double-clicks""" - item = self.model.itemFromIndex(index) - if not item: - return - - data = item.data(Qt.ItemDataRole.UserRole) - if not data: - return - - if data.get("type") == "function": - function_name = data.get("function_name") - file_path = data.get("file_path") - if function_name and file_path: - logger.info( - f"Macro open requested via double-click: {function_name} in {file_path}" - ) - self.macro_open_requested.emit(function_name, file_path) - - def _on_macro_open_requested(self): - """Handle macro open action triggered""" - logger.info("Macro open requested") - # Early return if no hovered item - if not self.delegate.hovered_index.isValid(): - return - - macro_info = self.delegate.current_macro_info - if not macro_info or macro_info.get("type") != "function": - return - - function_name = macro_info.get("function_name") - file_path = macro_info.get("file_path") - if function_name and file_path: - self.macro_open_requested.emit(function_name, file_path) - - def add_macro_action(self, action: Any) -> None: - """Add an action for macro items""" - self.delegate.add_macro_action(action) - - def clear_actions(self) -> None: - """Remove all actions from items""" - self.delegate.clear_actions() - - def refresh(self): - """Refresh the tree view""" - if self.directory is None: - 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() - - def collapse_all(self): - """Collapse all items in the tree""" - self.tree.collapseAll() diff --git a/bec_widgets/widgets/containers/explorer/script_tree_widget.py b/bec_widgets/widgets/containers/explorer/script_tree_widget.py deleted file mode 100644 index d7195228..00000000 --- a/bec_widgets/widgets/containers/explorer/script_tree_widget.py +++ /dev/null @@ -1,282 +0,0 @@ -import os -from pathlib import Path - -from bec_lib.logger import bec_logger -from qtpy.QtCore import QModelIndex, QRegularExpression, QSortFilterProxyModel, Signal -from qtpy.QtWidgets import QFileSystemModel, QTreeView, QVBoxLayout, QWidget - -from bec_widgets.utils.colors import get_theme_palette -from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.widgets.containers.explorer.explorer_delegate import ExplorerDelegate - -logger = bec_logger.logger - - -class FileItemDelegate(ExplorerDelegate): - """Custom delegate to show action buttons on hover""" - - def __init__(self, tree_widget): - super().__init__(tree_widget) - self.file_actions = [] - self.dir_actions = [] - - def add_file_action(self, action) -> None: - """Add an action for files""" - self.file_actions.append(action) - - def add_dir_action(self, action) -> None: - """Add an action for directories""" - self.dir_actions.append(action) - - def clear_actions(self) -> None: - """Remove all actions""" - self.file_actions.clear() - self.dir_actions.clear() - - def get_actions_for_current_item(self, model, index) -> list[MaterialIconAction] | None: - """Get actions for the current item based on its type""" - if not isinstance(model, QSortFilterProxyModel): - return None - - source_index = model.mapToSource(index) - source_model = model.sourceModel() - if not isinstance(source_model, QFileSystemModel): - return None - - is_dir = source_model.isDir(source_index) - return self.dir_actions if is_dir else self.file_actions - - -class ScriptTreeWidget(QWidget): - """A simple tree widget for scripts using QFileSystemModel - designed to be injected into CollapsibleSection""" - - file_selected = Signal(str) # Script file path selected - file_open_requested = Signal(str) # File open button clicked - file_renamed = Signal(str, str) # Old path, new path - - def __init__(self, parent=None): - super().__init__(parent) - - # Create layout - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Create tree view - self.tree = QTreeView(parent=self) - self.tree.setHeaderHidden(True) - self.tree.setRootIsDecorated(True) - - # Enable mouse tracking for hover effects - self.tree.setMouseTracking(True) - - # Create file system model - self.model = QFileSystemModel(parent=self) - self.model.setNameFilters(["*.py"]) - self.model.setNameFilterDisables(False) - - # Create proxy model to filter out underscore directories - self.proxy_model = QSortFilterProxyModel(parent=self) - self.proxy_model.setFilterRegularExpression(QRegularExpression("^[^_].*")) - self.proxy_model.setSourceModel(self.model) - self.tree.setModel(self.proxy_model) - - # Create and set custom delegate - self.delegate = FileItemDelegate(self.tree) - self.tree.setItemDelegate(self.delegate) - - # Add default open button for files - action = MaterialIconAction(icon_name="file_open", tooltip="Open file", parent=self) - action.action.triggered.connect(self._on_file_open_requested) - self.delegate.add_file_action(action.action) - - # Remove unnecessary columns - self.tree.setColumnHidden(1, True) # Hide size column - self.tree.setColumnHidden(2, True) # Hide type column - self.tree.setColumnHidden(3, True) # Hide date modified column - - # Apply BEC styling - self._apply_styling() - - # Script specific properties - self.directory = None - - # Connect signals - self.tree.clicked.connect(self._on_item_clicked) - self.tree.doubleClicked.connect(self._on_item_double_clicked) - - # Install event filter for hover tracking - self.tree.viewport().installEventFilter(self) - - # Add to layout - layout.addWidget(self.tree) - - def _apply_styling(self): - """Apply styling to the tree widget""" - # Get theme colors for subtle tree lines - palette = get_theme_palette() - subtle_line_color = palette.mid().color() - subtle_line_color.setAlpha(80) - - # Standard editable styling - opacity_modifier = "" - cursor_style = "" - - # pylint: disable=f-string-without-interpolation - tree_style = f""" - QTreeView {{ - border: none; - outline: 0; - show-decoration-selected: 0; - {opacity_modifier} - {cursor_style} - }} - QTreeView::branch {{ - border-image: none; - background: transparent; - }} - - QTreeView::item {{ - border: none; - padding: 0px; - margin: 0px; - }} - QTreeView::item:hover {{ - background: palette(midlight); - border: none; - padding: 0px; - margin: 0px; - text-decoration: none; - }} - QTreeView::item:selected {{ - background: palette(highlight); - color: palette(highlighted-text); - }} - QTreeView::item:selected:hover {{ - background: palette(highlight); - }} - """ - - self.tree.setStyleSheet(tree_style) - - def eventFilter(self, obj, event): - """Handle mouse move events for hover tracking""" - # Early return if not the tree viewport - if obj != self.tree.viewport(): - return super().eventFilter(obj, event) - - if event.type() == event.Type.MouseMove: - index = self.tree.indexAt(event.pos()) - if index.isValid(): - self.delegate.set_hovered_index(index) - else: - self.delegate.set_hovered_index(QModelIndex()) - self.tree.viewport().update() - return super().eventFilter(obj, event) - - if event.type() == event.Type.Leave: - self.delegate.set_hovered_index(QModelIndex()) - self.tree.viewport().update() - return super().eventFilter(obj, event) - - return super().eventFilter(obj, event) - - def set_directory(self, directory: str) -> None: - """Set the scripts directory""" - # Early return if directory doesn't exist - if not directory or not isinstance(directory, str) or not os.path.exists(directory): - return - - self.directory = directory - - root_index = self.model.setRootPath(directory) - # Map the source model index to proxy model index - proxy_root_index = self.proxy_model.mapFromSource(root_index) - self.tree.setRootIndex(proxy_root_index) - self.tree.expandAll() - - def _on_item_clicked(self, index: QModelIndex): - """Handle item clicks""" - # Map proxy index back to source index - source_index = self.proxy_model.mapToSource(index) - - # Early return for directories - if self.model.isDir(source_index): - return - - file_path = self.model.filePath(source_index) - - # Early return if not a valid file - if not file_path or not os.path.isfile(file_path): - return - - path_obj = Path(file_path) - - # Only emit signal for Python files - if path_obj.suffix.lower() == ".py": - logger.info(f"Script selected: {file_path}") - self.file_selected.emit(file_path) - - def _on_item_double_clicked(self, index: QModelIndex): - """Handle item double-clicks""" - # Map proxy index back to source index - source_index = self.proxy_model.mapToSource(index) - - # Early return for directories - if self.model.isDir(source_index): - return - - file_path = self.model.filePath(source_index) - - # Early return if not a valid file - if not file_path or not os.path.isfile(file_path): - return - - # Emit signal to open the file - logger.info(f"File open requested via double-click: {file_path}") - self.file_open_requested.emit(file_path) - - def _on_file_open_requested(self): - """Handle file open action triggered""" - logger.info("File open requested") - # Early return if no hovered item - if not self.delegate.hovered_index.isValid(): - return - - source_index = self.proxy_model.mapToSource(self.delegate.hovered_index) - file_path = self.model.filePath(source_index) - - # Early return if not a valid file - if not file_path or not os.path.isfile(file_path): - return - - self.file_open_requested.emit(file_path) - - def add_file_action(self, action) -> None: - """Add an action for file items""" - self.delegate.add_file_action(action) - - def add_dir_action(self, action) -> None: - """Add an action for directory items""" - self.delegate.add_dir_action(action) - - def clear_actions(self) -> None: - """Remove all actions from items""" - self.delegate.clear_actions() - - def refresh(self): - """Refresh the tree view""" - if self.directory is None: - return - self.model.setRootPath("") # Reset - root_index = self.model.setRootPath(self.directory) - proxy_root_index = self.proxy_model.mapFromSource(root_index) - self.tree.setRootIndex(proxy_root_index) - - def expand_all(self): - """Expand all items in the tree""" - self.tree.expandAll() - - def collapse_all(self): - """Collapse all items in the tree""" - self.tree.collapseAll() diff --git a/bec_widgets/widgets/editors/monaco/monaco_dock.py b/bec_widgets/widgets/editors/monaco/monaco_dock.py index c977dd29..f05729d4 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_dock.py +++ b/bec_widgets/widgets/editors/monaco/monaco_dock.py @@ -115,11 +115,15 @@ class MonacoDock(DockAreaWidget): self.last_focused_editor = new_widget def _on_editor_close_requested(self, dock: CDockWidget, widget: QWidget): + self._close_editor_dock(dock, prompt_to_save=True) + + def _close_editor_dock(self, dock: CDockWidget, prompt_to_save: bool) -> bool: + """Close a docked editor, optionally prompting to save unsaved changes.""" # Cast widget to MonacoWidget since we know that's what it is - monaco_widget = cast(MonacoWidget, widget) + monaco_widget = cast(MonacoWidget, dock.widget()) # Check if we have unsaved changes - if monaco_widget.modified: + if prompt_to_save and monaco_widget.modified: # Prompt the user to save changes response = QMessageBox.question( self, @@ -131,8 +135,10 @@ class MonacoDock(DockAreaWidget): ) if response == QMessageBox.StandardButton.Yes: self.save_file(monaco_widget) + if monaco_widget.modified: + return False elif response == QMessageBox.StandardButton.Cancel: - return + return False # Count all editor docks managed by this dock manager total = len(self.dock_manager.dockWidgets()) @@ -146,7 +152,7 @@ class MonacoDock(DockAreaWidget): icon = self._resolve_dock_icon(monaco_widget, dock_icon=None, apply_widget_icon=True) dock.setIcon(icon) self.last_focused_editor = dock - return + return True # Otherwise, proceed to close and delete the dock monaco_widget.close() @@ -156,6 +162,7 @@ class MonacoDock(DockAreaWidget): self.last_focused_editor = None # After topology changes, make sure single-tab areas get a plus button self._scan_and_fix_areas() + return True @staticmethod def reset_widget(widget: MonacoWidget): @@ -447,6 +454,55 @@ class MonacoDock(DockAreaWidget): return widget return None + def close_file(self, file_name: str, force: bool = False) -> bool: + """ + Close an open file by path. + + Args: + file_name (str): The file path to close. + force (bool): If True, discard unsaved changes without prompting. + + Returns: + bool: True if the file was closed or not open, False if closing was cancelled. + """ + editor_dock = self._get_editor_dock(file_name) + if editor_dock is None: + return True + return self._close_editor_dock(editor_dock, prompt_to_save=not force) + + def rename_open_path(self, old_path: str, new_path: str) -> None: + """ + Update open editor paths after a file or directory rename. + + Args: + old_path: Original file or directory path. + new_path: Replacement file or directory path. + """ + old_path = os.path.abspath(old_path) + new_path = os.path.abspath(new_path) + + for dock in self.dock_manager.dockWidgets(): + editor_widget = cast(MonacoWidget, dock.widget()) + current_file = editor_widget.current_file + if current_file is None: + continue + + current_file = os.path.abspath(current_file) + if current_file == old_path: + updated_path = new_path + else: + try: + if os.path.commonpath([current_file, old_path]) != old_path: + continue + except ValueError: + continue + updated_path = os.path.join(new_path, os.path.relpath(current_file, old_path)) + + editor_widget._current_file = updated_path + dock.setWindowTitle(os.path.basename(updated_path)) + dock.setTabToolTip(updated_path) + self._update_tab_title_for_modification(dock, editor_widget.modified) + def set_file_readonly(self, file_name: str, read_only: bool = True) -> bool: """ Set a specific file's editor to read-only mode. diff --git a/bec_widgets/widgets/editors/monaco/monaco_widget.py b/bec_widgets/widgets/editors/monaco/monaco_widget.py index 3b2a9652..6d70244a 100644 --- a/bec_widgets/widgets/editors/monaco/monaco_widget.py +++ b/bec_widgets/widgets/editors/monaco/monaco_widget.py @@ -72,11 +72,15 @@ class MonacoWidget(BECWidget, QWidget): self._original_content = "" self.metadata = {} if init_lsp: + app_id = self.bec_dispatcher.cli_server.gui_id self.editor.update_workspace_configuration( { "pylsp": { "plugins": { - "pylsp-bec": {"service_config": self.client._service_config.config} + "pylsp-bec": { + "service_config": self.client._service_config.config, + "gui_app_id": app_id, + } } } } diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index ac345ed4..7a48b0f1 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -3,6 +3,7 @@ import importlib import importlib.metadata import os import re +import shutil from typing import Literal from bec_qthemes import material_icon @@ -13,8 +14,7 @@ from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty from bec_widgets.widgets.containers.explorer.collapsible_tree_section import CollapsibleSection from bec_widgets.widgets.containers.explorer.explorer import Explorer -from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget -from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget +from bec_widgets.widgets.containers.explorer.file_browser_tree_widget import FileBrowserTreeWidget class IDEExplorer(BECWidget, QWidget): @@ -81,17 +81,20 @@ class IDEExplorer(BECWidget, QWidget): section = CollapsibleSection(parent=self, title="SCRIPTS", indentation=0) script_explorer = Explorer(parent=self) - script_widget = ScriptTreeWidget(parent=self) - script_widget.file_open_requested.connect(self._emit_file_open_scripts_local) - script_widget.file_selected.connect(self._emit_file_preview_scripts_local) - local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) - local_scripts_section.header_add_button.clicked.connect(self._add_local_script) - local_scripts_section.set_widget(script_widget) local_script_dir = os.path.expanduser( self.client._service_config.model.user_scripts.base_path ) os.makedirs(local_script_dir, exist_ok=True) - script_widget.set_directory(local_script_dir) + script_widget = FileBrowserTreeWidget( + parent=self, directory=local_script_dir, read_only=False + ) + script_widget.file_open_requested.connect(self._emit_file_open_scripts_local) + script_widget.file_selected.connect(self._emit_file_preview_scripts_local) + script_widget.file_delete_requested.connect(self._delete_local_script) + script_widget.file_renamed.connect(self._rename_local_script_path) + local_scripts_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) + local_scripts_section.header_add_button.clicked.connect(self._add_local_script) + local_scripts_section.set_widget(script_widget) script_explorer.add_section(local_scripts_section) section.set_widget(script_explorer) @@ -103,9 +106,10 @@ class IDEExplorer(BECWidget, QWidget): return shared_script_section = CollapsibleSection(title="Shared (Read-only)", parent=self) shared_script_section.setToolTip("Shared scripts (read-only)") - shared_script_widget = ScriptTreeWidget(parent=self) + shared_script_widget = FileBrowserTreeWidget( + parent=self, directory=plugin_scripts_dir, read_only=True + ) shared_script_section.set_widget(shared_script_widget) - shared_script_widget.set_directory(plugin_scripts_dir) script_explorer.add_section(shared_script_section) shared_script_widget.file_open_requested.connect(self._emit_file_open_scripts_shared) shared_script_widget.file_selected.connect(self._emit_file_preview_scripts_shared) @@ -125,17 +129,20 @@ class IDEExplorer(BECWidget, QWidget): section.header_add_button.clicked.connect(self._reload_macros) macro_explorer = Explorer(parent=self) - macro_widget = MacroTreeWidget(parent=self) - macro_widget.macro_open_requested.connect(self._emit_file_open_macros_local) - macro_widget.macro_selected.connect(self._emit_file_preview_macros_local) - local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) - local_macros_section.header_add_button.clicked.connect(self._add_local_macro) - local_macros_section.set_widget(macro_widget) local_macro_dir = os.path.expanduser( self.client._service_config.model.user_macros.base_path ) os.makedirs(local_macro_dir, exist_ok=True) - macro_widget.set_directory(local_macro_dir) + macro_widget = FileBrowserTreeWidget( + parent=self, directory=local_macro_dir, read_only=False + ) + macro_widget.file_open_requested.connect(self._emit_file_open_macros_local) + macro_widget.file_selected.connect(self._emit_file_preview_macros_local) + macro_widget.file_delete_requested.connect(self._delete_local_macro) + macro_widget.file_renamed.connect(self._rename_local_macro_path) + local_macros_section = CollapsibleSection(title="Local", show_add_button=True, parent=self) + local_macros_section.header_add_button.clicked.connect(self._add_local_macro) + local_macros_section.set_widget(macro_widget) macro_explorer.add_section(local_macros_section) section.set_widget(macro_explorer) @@ -147,12 +154,13 @@ class IDEExplorer(BECWidget, QWidget): return shared_macro_section = CollapsibleSection(title="Shared (Read-only)", parent=self) shared_macro_section.setToolTip("Shared macros (read-only)") - shared_macro_widget = MacroTreeWidget(parent=self) + shared_macro_widget = FileBrowserTreeWidget( + parent=self, directory=plugin_macros_dir, read_only=True + ) shared_macro_section.set_widget(shared_macro_widget) - shared_macro_widget.set_directory(plugin_macros_dir) macro_explorer.add_section(shared_macro_section) - shared_macro_widget.macro_open_requested.connect(self._emit_file_open_macros_shared) - shared_macro_widget.macro_selected.connect(self._emit_file_preview_macros_shared) + shared_macro_widget.file_open_requested.connect(self._emit_file_open_macros_shared) + shared_macro_widget.file_selected.connect(self._emit_file_preview_macros_shared) def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None: """Get the path to the specified directory within the BEC plugin. @@ -179,16 +187,20 @@ class IDEExplorer(BECWidget, QWidget): def _emit_file_preview_scripts_shared(self, file_name: str): self.file_preview_requested.emit(file_name, "scripts/shared") - def _emit_file_open_macros_local(self, function_name: str, file_path: str): + def _emit_file_open_macros_local(self, *args): + file_path = args[-1] self.file_open_requested.emit(file_path, "macros/local") - def _emit_file_preview_macros_local(self, function_name: str, file_path: str): + def _emit_file_preview_macros_local(self, *args): + file_path = args[-1] self.file_preview_requested.emit(file_path, "macros/local") - def _emit_file_open_macros_shared(self, function_name: str, file_path: str): + def _emit_file_open_macros_shared(self, *args): + file_path = args[-1] self.file_open_requested.emit(file_path, "macros/shared") - def _emit_file_preview_macros_shared(self, function_name: str, file_path: str): + def _emit_file_preview_macros_shared(self, *args): + file_path = args[-1] self.file_preview_requested.emit(file_path, "macros/shared") def _add_local_script(self): @@ -294,7 +306,6 @@ def {function_name}(): """ print("Executing macro: {function_name}") # TODO: Add your macro code here - pass ''') # Refresh the macro tree to show the new function @@ -310,14 +321,14 @@ def {function_name}(): if hasattr(self.client, "macros"): self.client.macros.load_all_user_macros() - # Refresh the macro tree widgets to show updated functions + # Refresh the macro tree widgets to show updated files target_section = self.main_explorer.get_section("MACROS") if target_section and hasattr(target_section, "content_widget"): local_section = target_section.content_widget.get_section("Local") if local_section and hasattr(local_section, "content_widget"): local_section.content_widget.refresh() - shared_section = target_section.content_widget.get_section("Shared") + shared_section = target_section.content_widget.get_section("Shared (Read-only)") if shared_section and hasattr(shared_section, "content_widget"): shared_section.content_widget.refresh() @@ -329,8 +340,185 @@ def {function_name}(): except Exception as e: QMessageBox.critical(self, "Error", f"Failed to reload macros: {str(e)}") + def _delete_local_script(self, file_path: str): + """Delete a local script file or directory from disk.""" + target_section = self.main_explorer.get_section("SCRIPTS") + script_dir_section = target_section.content_widget.get_section("Local") + local_script_dir = script_dir_section.content_widget.directory + + if not self._is_local_path(file_path, local_script_dir): + return + + item_name = os.path.basename(file_path) + is_directory = os.path.isdir(file_path) + title = "Delete Folder" if is_directory else "Delete Script" + message = ( + f"Delete folder '{item_name}'?\nThis will permanently remove the folder and its contents." + if is_directory + else f"Delete script '{item_name}'?\nThis will permanently remove the file from disk." + ) + response = QMessageBox.question( + self, + title, + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if response != QMessageBox.StandardButton.Yes: + return + + try: + self._close_open_editors_for_path(file_path) + self._remove_path(file_path) + script_dir_section.content_widget.refresh() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete script: {str(e)}") + + def _delete_local_macro(self, file_path: str): + """Delete a local macro file or directory from disk and unload its macros.""" + target_section = self.main_explorer.get_section("MACROS") + macro_dir_section = target_section.content_widget.get_section("Local") + local_macro_dir = macro_dir_section.content_widget.directory + + if not self._is_local_path(file_path, local_macro_dir): + return + + item_name = os.path.basename(file_path) + is_directory = os.path.isdir(file_path) + title = "Delete Folder" if is_directory else "Delete Macro" + message = ( + f"Delete folder '{item_name}'?\nThis removes all macro files inside that folder." + if is_directory + else ( + f"Delete macro file '{item_name}'?\n" + "This removes all macros defined in that file." + ) + ) + response = QMessageBox.question( + self, + title, + message, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if response != QMessageBox.StandardButton.Yes: + return + + try: + self._close_open_editors_for_path(file_path) + for macro_file in self._iter_python_files(file_path): + self._broadcast_removed_macros(macro_file) + self._remove_path(file_path) + macro_dir_section.content_widget.refresh() + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to delete macro: {str(e)}") + + def _rename_local_script_path(self, old_path: str, new_path: str) -> None: + """Update open editor state after a local script path rename.""" + target_section = self.main_explorer.get_section("SCRIPTS") + script_dir_section = target_section.content_widget.get_section("Local") + local_script_dir = script_dir_section.content_widget.directory + + if not self._is_local_path(new_path, local_script_dir): + return + + self._rename_open_editor_path(old_path, new_path) + script_dir_section.content_widget.refresh() + + def _rename_local_macro_path(self, old_path: str, new_path: str) -> None: + """Update macro/editor state after a local macro path rename.""" + target_section = self.main_explorer.get_section("MACROS") + macro_dir_section = target_section.content_widget.get_section("Local") + local_macro_dir = macro_dir_section.content_widget.directory + + if not self._is_local_path(new_path, local_macro_dir): + return + + if old_path.endswith(".py"): + self._broadcast_removed_macros(old_path) + + self._rename_open_editor_path(old_path, new_path) + if hasattr(self.client, "macros"): + self.client.macros.load_all_user_macros() + macro_dir_section.content_widget.refresh() + + @staticmethod + def _is_local_path(file_path: str, base_dir: str | None) -> bool: + """Return True when file_path is a file inside base_dir.""" + if not file_path or not base_dir: + return False + + try: + file_path = os.path.abspath(file_path) + base_dir = os.path.abspath(base_dir) + return os.path.commonpath([file_path, base_dir]) == base_dir + except ValueError: + return False + + def _close_open_editors_for_path(self, file_path: str) -> None: + """Close editor tabs for a file or for every file under a directory.""" + parent = self.parent() + while parent is not None and not hasattr(parent, "monaco"): + parent = parent.parent() + + monaco = getattr(parent, "monaco", None) + if monaco is None or not hasattr(monaco, "close_file"): + return + + if os.path.isdir(file_path) and hasattr(monaco, "_get_open_files"): + for open_file in list(monaco._get_open_files()): + if self._is_local_path(open_file, file_path): + monaco.close_file(open_file, force=True) + return + + monaco.close_file(file_path, force=True) + + def _rename_open_editor_path(self, old_path: str, new_path: str) -> None: + """Update Monaco editor paths after a file or directory rename.""" + parent = self.parent() + while parent is not None and not hasattr(parent, "monaco"): + parent = parent.parent() + + monaco = getattr(parent, "monaco", None) + if monaco is not None and hasattr(monaco, "rename_open_path"): + monaco.rename_open_path(old_path, new_path) + + def _broadcast_removed_macros(self, file_path: str) -> None: + """Broadcast removal of macros currently loaded from a deleted file.""" + if not hasattr(self.client, "macros") or not hasattr(self.client.macros, "_update_handler"): + return + + handler = self.client.macros._update_handler + existing_macros = handler.get_existing_macros(file_path) + for macro_name in existing_macros: + handler.broadcast(action="remove", name=macro_name) + + @staticmethod + def _iter_python_files(file_path: str): + """Yield Python files under a file or directory path.""" + if os.path.isfile(file_path): + if file_path.endswith(".py"): + yield file_path + return + + if not os.path.isdir(file_path): + return + + for root, _dirs, files in os.walk(file_path): + for name in files: + if name.endswith(".py"): + yield os.path.join(root, name) + + @staticmethod + def _remove_path(file_path: str) -> None: + """Delete a file or directory from disk.""" + if os.path.isdir(file_path): + shutil.rmtree(file_path) + return + os.remove(file_path) + def refresh_macro_file(self, file_path: str): - """Refresh a single macro file in the tree widget. + """Refresh the macro file browser when a macro file changes. Args: file_path: Path to the macro file that was updated @@ -341,7 +529,7 @@ def {function_name}(): # 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") + shared_section = target_section.content_widget.get_section("Shared (Read-only)") # Check if file belongs to local macros directory if ( @@ -351,7 +539,7 @@ def {function_name}(): ): 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) + local_section.content_widget.refresh() return # Check if file belongs to shared macros directory @@ -362,7 +550,7 @@ def {function_name}(): ): 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) + shared_section.content_widget.refresh() return diff --git a/tests/unit_tests/test_developer_view.py b/tests/unit_tests/test_developer_view.py index 803858c6..2d819bb7 100644 --- a/tests/unit_tests/test_developer_view.py +++ b/tests/unit_tests/test_developer_view.py @@ -104,7 +104,7 @@ class TestDeveloperViewInitialization: # Check for expected toolbar actions toolbar_components = developer_view.toolbar.components - expected_actions = ["save", "save_as", "run", "stop", "vim"] + expected_actions = ["new_script", "new_macro", "save", "save_as", "run", "stop", "vim"] for action_name in expected_actions: assert toolbar_components.exists(action_name) @@ -208,6 +208,18 @@ class TestFileOperations: developer_view.on_save_as() mock_save.assert_called_once_with(force_save_as=True) + def test_new_script_functionality(self, developer_view): + """Test the new script toolbar action handler.""" + with mock.patch.object(developer_view.explorer, "_add_local_script") as mock_new_script: + developer_view.on_new_script() + mock_new_script.assert_called_once() + + def test_new_macro_functionality(self, developer_view): + """Test the new macro toolbar action handler.""" + with mock.patch.object(developer_view.explorer, "_add_local_macro") as mock_new_macro: + developer_view.on_new_macro() + mock_new_macro.assert_called_once() + class TestMonacoEditorIntegration: """Test Monaco editor specific functionality.""" diff --git a/tests/unit_tests/test_file_browser_tree_widget.py b/tests/unit_tests/test_file_browser_tree_widget.py new file mode 100644 index 00000000..d887603f --- /dev/null +++ b/tests/unit_tests/test_file_browser_tree_widget.py @@ -0,0 +1,270 @@ +from pathlib import Path + +import pytest +from qtpy.QtCore import QModelIndex, QPoint, Qt +from qtpy.QtWidgets import QAbstractItemView, QInputDialog, QMenu + +from bec_widgets.widgets.containers.explorer.file_browser_tree_widget import FileBrowserTreeWidget + + +@pytest.fixture +def file_browser_tree(qtbot, tmpdir): + """Create a FileBrowserTreeWidget rooted at tmpdir.""" + (Path(tmpdir) / "test_file.py").touch() + (Path(tmpdir) / "__init__.py").touch() + (Path(tmpdir) / "test_dir").mkdir() + (Path(tmpdir) / "test_dir" / "nested_file.py").touch() + (Path(tmpdir) / "__pycache__").mkdir() + (Path(tmpdir) / "__pycache__" / "ignored.py").touch() + + widget = FileBrowserTreeWidget(directory=str(tmpdir)) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_file_browser_tree_set_directory(file_browser_tree, tmpdir): + """Test setting the directory.""" + assert file_browser_tree.directory == str(tmpdir) + + +def test_file_browser_tree_hides_filtered_entries(file_browser_tree, qtbot): + """Test that filtered cache/init entries are hidden from the browser.""" + + def visible_names(): + root_index = file_browser_tree.tree.rootIndex() + return [ + file_browser_tree.proxy_model.data( + file_browser_tree.proxy_model.index(i, 0, root_index) + ) + for i in range(file_browser_tree.proxy_model.rowCount(root_index)) + ] + + qtbot.waitUntil(lambda: "test_file.py" in visible_names(), timeout=5000) + assert "__pycache__" not in visible_names() + assert "__init__.py" not in visible_names() + + +def test_file_browser_tree_native_browser_configuration(file_browser_tree): + """Test that the tree is configured like a plain native file browser.""" + assert file_browser_tree.model.isReadOnly() is False + assert file_browser_tree.tree.alternatingRowColors() is False + assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.InternalMove + assert file_browser_tree.tree.defaultDropAction() == Qt.DropAction.MoveAction + assert ( + file_browser_tree.tree.selectionBehavior() == QAbstractItemView.SelectionBehavior.SelectRows + ) + + +def test_file_browser_tree_set_readonly(file_browser_tree): + """Test toggling read-only mode on the file browser.""" + file_browser_tree.set_readonly(True) + assert file_browser_tree.model.isReadOnly() is True + assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.NoDragDrop + + file_browser_tree.set_readonly(False) + assert file_browser_tree.model.isReadOnly() is False + assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.InternalMove + + +@pytest.mark.timeout(10) +def test_file_browser_tree_on_item_clicked(file_browser_tree, qtbot): + """Test that clicks and double-clicks emit the expected file signals.""" + file_selected_signals = [] + file_open_requested_signals = [] + + def on_file_selected(file_path): + file_selected_signals.append(file_path) + + def on_file_open_requested(file_path): + file_open_requested_signals.append(file_path) + + file_browser_tree.file_selected.connect(on_file_selected) + file_browser_tree.file_open_requested.connect(on_file_open_requested) + + def has_py_file(): + nonlocal py_file_index + root_index = file_browser_tree.tree.rootIndex() + for i in range(file_browser_tree.proxy_model.rowCount(root_index)): + index = file_browser_tree.proxy_model.index(i, 0, root_index) + if file_browser_tree.proxy_model.data(index) == "test_file.py": + py_file_index = index + return True + return False + + py_file_index = None + qtbot.waitUntil(has_py_file) + + file_browser_tree._on_item_clicked(py_file_index) + qtbot.wait(100) + + py_file_index = None + qtbot.waitUntil(has_py_file) + + file_browser_tree._on_item_double_clicked(py_file_index) + qtbot.wait(100) + + assert len(file_selected_signals) == 1 + assert Path(file_selected_signals[0]).name == "test_file.py" + assert len(file_open_requested_signals) == 1 + assert Path(file_open_requested_signals[0]).name == "test_file.py" + + +def test_file_browser_tree_delete_request_emits_selected_file(file_browser_tree, qtbot): + """Test that delete requests emit the currently selected file path.""" + delete_requests = [] + + def on_delete_requested(file_path): + delete_requests.append(file_path) + + file_browser_tree.file_delete_requested.connect(on_delete_requested) + + def get_test_file_index(): + root_index = file_browser_tree.tree.rootIndex() + for i in range(file_browser_tree.proxy_model.rowCount(root_index)): + index = file_browser_tree.proxy_model.index(i, 0, root_index) + if file_browser_tree.proxy_model.data(index) == "test_file.py": + return index + return None + + qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000) + file_index = get_test_file_index() + assert file_index is not None + file_browser_tree.tree.setCurrentIndex(file_index) + file_browser_tree._on_file_delete_requested() + qtbot.wait(100) + + assert len(delete_requests) == 1 + assert Path(delete_requests[0]).name == "test_file.py" + + +def test_file_browser_tree_context_menu_rename_action(file_browser_tree, qtbot, monkeypatch): + """Test that the context menu exposes a rename action for editable items.""" + + def get_test_file_index(): + root_index = file_browser_tree.tree.rootIndex() + for i in range(file_browser_tree.proxy_model.rowCount(root_index)): + index = file_browser_tree.proxy_model.index(i, 0, root_index) + if file_browser_tree.proxy_model.data(index) == "test_file.py": + return index + return None + + qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000) + file_index = get_test_file_index() + assert file_index is not None + + called = {} + + def fake_edit(index): + called["name"] = file_browser_tree.proxy_model.data(index) + return True + + monkeypatch.setattr(file_browser_tree.tree, "edit", fake_edit) + menu = file_browser_tree._build_context_menu(file_index) + assert menu is not None + assert [action.text() for action in menu.actions()] == [ + "New Folder", + "Open", + "Rename", + "Delete File", + ] + menu.actions()[2].trigger() + + assert called["name"] == "test_file.py" + + +def test_file_browser_tree_context_menu_open_action(file_browser_tree, qtbot): + """Test that the context menu exposes an open action for files.""" + opened = [] + file_browser_tree.file_open_requested.connect(opened.append) + + def get_test_file_index(): + root_index = file_browser_tree.tree.rootIndex() + for i in range(file_browser_tree.proxy_model.rowCount(root_index)): + index = file_browser_tree.proxy_model.index(i, 0, root_index) + if file_browser_tree.proxy_model.data(index) == "test_file.py": + return index + return None + + qtbot.waitUntil(lambda: get_test_file_index() is not None, timeout=5000) + file_index = get_test_file_index() + assert file_index is not None + + file_browser_tree.tree.setCurrentIndex(file_index) + menu = file_browser_tree._build_context_menu(file_index) + assert menu is not None + menu.actions()[1].trigger() + + assert len(opened) == 1 + assert Path(opened[0]).name == "test_file.py" + + +def test_file_browser_tree_context_menu_root_allows_new_folder(file_browser_tree): + """Test that right-clicking the root area offers folder creation.""" + menu = file_browser_tree._build_context_menu(QModelIndex()) + assert menu is not None + assert [action.text() for action in menu.actions()] == ["New Folder"] + + +def test_file_browser_tree_context_menu_hidden_when_read_only(file_browser_tree, monkeypatch): + """Test that no context menu is shown in read-only mode.""" + file_browser_tree.set_readonly(True) + + called = {"shown": False} + + def fake_exec(*_args, **_kwargs): + called["shown"] = True + return None + + monkeypatch.setattr(QMenu, "exec", fake_exec) + file_browser_tree._show_context_menu(QPoint(0, 0)) + + assert called["shown"] is False + + +def test_file_browser_tree_emits_file_renamed_signal(file_browser_tree): + """Test that model rename notifications are emitted as full paths.""" + renamed = [] + file_browser_tree.file_renamed.connect(lambda old, new: renamed.append((old, new))) + + file_browser_tree._on_model_file_renamed(str(file_browser_tree.directory), "old.py", "new.py") + + assert renamed == [ + ( + str(Path(file_browser_tree.directory) / "old.py"), + str(Path(file_browser_tree.directory) / "new.py"), + ) + ] + + +def test_file_browser_tree_create_subdirectory_at_root(file_browser_tree, monkeypatch): + """Test creating a subdirectory at the root level.""" + monkeypatch.setattr(QInputDialog, "getText", lambda *_args, **_kwargs: ("new_root_dir", True)) + + file_browser_tree._create_subdirectory(str(file_browser_tree.directory)) + + assert (Path(file_browser_tree.directory) / "new_root_dir").is_dir() + + +def test_file_browser_tree_create_subdirectory_in_selected_directory( + file_browser_tree, qtbot, monkeypatch +): + """Test creating a subdirectory inside the selected directory level.""" + + def get_test_dir_index(): + root_index = file_browser_tree.tree.rootIndex() + for i in range(file_browser_tree.proxy_model.rowCount(root_index)): + index = file_browser_tree.proxy_model.index(i, 0, root_index) + if file_browser_tree.proxy_model.data(index) == "test_dir": + return index + return None + + qtbot.waitUntil(lambda: get_test_dir_index() is not None, timeout=5000) + dir_index = get_test_dir_index() + assert dir_index is not None + assert file_browser_tree._get_target_directory(dir_index).endswith("test_dir") + + monkeypatch.setattr(QInputDialog, "getText", lambda *_args, **_kwargs: ("child_dir", True)) + file_browser_tree._create_subdirectory(file_browser_tree._get_target_directory(dir_index)) + + assert (Path(file_browser_tree.directory) / "test_dir" / "child_dir").is_dir() diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index 3f415edf..6a1f6b6a 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -258,12 +258,131 @@ def test_ide_explorer_add_local_macro(ide_explorer, qtbot, tmpdir): assert os.path.exists(expected_file) # Check that the file contains the expected function - with open(expected_file, "r") as f: + with open(expected_file, "r", encoding="utf-8") as f: content = f.read() assert "def test_macro_function():" in content assert "test_macro_function macro" in content +def test_ide_explorer_delete_local_script(ide_explorer, tmpdir): + """Test deleting a local script file.""" + local_script_section = ide_explorer.main_explorer.get_section( + "SCRIPTS" + ).content_widget.get_section("Local") + local_script_section.content_widget.set_directory(str(tmpdir)) + + file_path = os.path.join(tmpdir, "delete_me.py") + Path(file_path).write_text("print('delete me')", encoding="utf-8") + + with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes): + ide_explorer._delete_local_script(file_path) + + assert not os.path.exists(file_path) + + +def test_ide_explorer_delete_local_script_directory(ide_explorer, tmpdir): + """Test deleting a local script directory.""" + local_script_section = ide_explorer.main_explorer.get_section( + "SCRIPTS" + ).content_widget.get_section("Local") + local_script_section.content_widget.set_directory(str(tmpdir)) + + script_dir = Path(tmpdir) / "subdir" + script_dir.mkdir() + nested_file = script_dir / "nested.py" + nested_file.write_text("print('nested')", encoding="utf-8") + + with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes): + ide_explorer._delete_local_script(str(script_dir)) + + assert not script_dir.exists() + + +def test_ide_explorer_delete_local_macro_broadcasts_removals(ide_explorer, tmpdir): + """Test deleting a local macro unloads loaded macros and removes the file.""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + file_path = os.path.join(tmpdir, "delete_macro.py") + Path(file_path).write_text("def delete_macro():\n pass\n", encoding="utf-8") + + ide_explorer.client.macros = mock.MagicMock() + ide_explorer.client.macros._update_handler = mock.MagicMock() + ide_explorer.client.macros._update_handler.get_existing_macros.return_value = { + "delete_macro": {"fname": file_path} + } + + with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes): + ide_explorer._delete_local_macro(file_path) + + ide_explorer.client.macros._update_handler.broadcast.assert_called_once_with( + action="remove", name="delete_macro" + ) + assert not os.path.exists(file_path) + + +def test_ide_explorer_delete_local_macro_directory_broadcasts_removals(ide_explorer, tmpdir): + """Test deleting a local macro directory unloads macros from contained files.""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + macro_dir = Path(tmpdir) / "subdir" + macro_dir.mkdir() + file_path = macro_dir / "delete_macro.py" + file_path.write_text("def delete_macro():\n pass\n", encoding="utf-8") + + ide_explorer.client.macros = mock.MagicMock() + ide_explorer.client.macros._update_handler = mock.MagicMock() + ide_explorer.client.macros._update_handler.get_existing_macros.return_value = { + "delete_macro": {"fname": str(file_path)} + } + + with mock.patch.object(QMessageBox, "question", return_value=QMessageBox.StandardButton.Yes): + ide_explorer._delete_local_macro(str(macro_dir)) + + ide_explorer.client.macros._update_handler.broadcast.assert_called_once_with( + action="remove", name="delete_macro" + ) + assert not macro_dir.exists() + + +def test_ide_explorer_rename_local_macro_path_reloads_macros(ide_explorer, tmpdir): + """Test local macro renames refresh open-editor and macro state.""" + ide_explorer.clear() + ide_explorer.sections = ["macros"] + + local_macros_section = ide_explorer.main_explorer.get_section( + "MACROS" + ).content_widget.get_section("Local") + local_macros_section.content_widget.set_directory(str(tmpdir)) + + old_path = os.path.join(tmpdir, "old_name.py") + new_path = os.path.join(tmpdir, "new_name.py") + + ide_explorer.client.macros = mock.MagicMock() + ide_explorer.client.macros._update_handler = mock.MagicMock() + + with ( + mock.patch.object(ide_explorer, "_rename_open_editor_path") as mock_rename_open_editor, + mock.patch.object(ide_explorer, "_broadcast_removed_macros") as mock_broadcast_removed, + ): + ide_explorer._rename_local_macro_path(old_path, new_path) + + mock_broadcast_removed.assert_called_once_with(old_path) + mock_rename_open_editor.assert_called_once_with(old_path, new_path) + ide_explorer.client.macros.load_all_user_macros.assert_called_once() + + def test_ide_explorer_add_local_macro_invalid_name(ide_explorer, qtbot, tmpdir): """Test adding a local macro with invalid function name""" ide_explorer.clear() @@ -410,14 +529,12 @@ def test_ide_explorer_refresh_macro_file_local(ide_explorer, qtbot, tmpdir): macro_file = Path(tmpdir) / "test_macro.py" macro_file.write_text("def test_function(): pass") - # Mock the refresh_file_item method - with mock.patch.object( - local_macros_section.content_widget, "refresh_file_item" - ) as mock_refresh: + # Mock the refresh method + with mock.patch.object(local_macros_section.content_widget, "refresh") as mock_refresh: ide_explorer.refresh_macro_file(str(macro_file)) - # Should call refresh_file_item with the file path - mock_refresh.assert_called_once_with(str(macro_file)) + # Should refresh the file browser + mock_refresh.assert_called_once_with() def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir): @@ -434,13 +551,11 @@ def test_ide_explorer_refresh_macro_file_no_match(ide_explorer, qtbot, tmpdir): # Try to refresh a file that's not in any macro directory unrelated_file = "/some/other/path/unrelated.py" - # Mock the refresh_file_item method - with mock.patch.object( - local_macros_section.content_widget, "refresh_file_item" - ) as mock_refresh: + # Mock the refresh method + with mock.patch.object(local_macros_section.content_widget, "refresh") as mock_refresh: ide_explorer.refresh_macro_file(unrelated_file) - # Should not call refresh_file_item + # Should not refresh for unrelated files mock_refresh.assert_not_called() diff --git a/tests/unit_tests/test_macro_tree_widget.py b/tests/unit_tests/test_macro_tree_widget.py deleted file mode 100644 index f29934e2..00000000 --- a/tests/unit_tests/test_macro_tree_widget.py +++ /dev/null @@ -1,534 +0,0 @@ -""" -Unit tests for the MacroTreeWidget. -""" - -from pathlib import Path - -import pytest -from qtpy.QtCore import QEvent, QModelIndex, Qt -from qtpy.QtGui import QMouseEvent - -from bec_widgets.utils.toolbars.actions import MaterialIconAction -from bec_widgets.widgets.containers.explorer.macro_tree_widget import MacroTreeWidget - - -@pytest.fixture -def temp_macro_files(tmpdir): - """Create temporary macro files for testing.""" - macro_dir = Path(tmpdir) / "macros" - macro_dir.mkdir() - - # Create a simple macro file with functions - macro_file1 = macro_dir / "test_macros.py" - macro_file1.write_text(''' -def test_macro_function(): - """A test macro function.""" - return "test" - -def another_function(param1, param2): - """Another function with parameters.""" - return param1 + param2 - -class TestClass: - """This class should be ignored.""" - def method(self): - pass -''') - - # Create another macro file - macro_file2 = macro_dir / "utils_macros.py" - macro_file2.write_text(''' -def utility_function(): - """A utility function.""" - pass - -def deprecated_function(): - """Old function.""" - return None -''') - - # Create a file with no functions (should be ignored) - empty_file = macro_dir / "empty.py" - empty_file.write_text(""" -# Just a comment -x = 1 -y = 2 -""") - - # Create a file starting with underscore (should be ignored) - private_file = macro_dir / "_private.py" - private_file.write_text(""" -def private_function(): - return "private" -""") - - # Create a file with syntax errors - error_file = macro_dir / "error_file.py" - error_file.write_text(""" -def broken_function( - # Missing closing parenthesis and colon - pass -""") - - return macro_dir - - -@pytest.fixture -def macro_tree(qtbot, temp_macro_files): - """Create a MacroTreeWidget with test macro files.""" - widget = MacroTreeWidget() - widget.set_directory(str(temp_macro_files)) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -class TestMacroTreeWidgetInitialization: - """Test macro tree widget initialization and basic functionality.""" - - def test_initialization(self, qtbot): - """Test that the macro tree widget initializes correctly.""" - widget = MacroTreeWidget() - qtbot.addWidget(widget) - - # Check basic properties - assert widget.tree is not None - assert widget.model is not None - assert widget.delegate is not None - assert widget.directory is None - - # Check that tree is configured properly - assert widget.tree.isHeaderHidden() - assert widget.tree.rootIsDecorated() - assert not widget.tree.editTriggers() - - def test_set_directory_with_valid_path(self, macro_tree, temp_macro_files): - """Test setting a valid directory path.""" - assert macro_tree.directory == str(temp_macro_files) - - # Check that files were loaded - assert macro_tree.model.rowCount() > 0 - - # Should have 2 files (test_macros.py and utils_macros.py) - # empty.py and _private.py should be filtered out - expected_files = ["test_macros", "utils_macros"] - actual_files = [] - - for row in range(macro_tree.model.rowCount()): - item = macro_tree.model.item(row) - if item: - actual_files.append(item.text()) - - # Sort for consistent comparison - actual_files.sort() - expected_files.sort() - - for expected in expected_files: - assert expected in actual_files - - def test_set_directory_with_invalid_path(self, qtbot): - """Test setting an invalid directory path.""" - widget = MacroTreeWidget() - qtbot.addWidget(widget) - - widget.set_directory("/nonexistent/path") - - # Should handle gracefully - assert widget.directory == "/nonexistent/path" - assert widget.model.rowCount() == 0 - - def test_set_directory_with_none(self, qtbot): - """Test setting directory to None.""" - widget = MacroTreeWidget() - qtbot.addWidget(widget) - - widget.set_directory(None) - - # Should handle gracefully - assert widget.directory is None - assert widget.model.rowCount() == 0 - - -class TestMacroFunctionParsing: - """Test macro function parsing and AST functionality.""" - - def test_extract_functions_from_file(self, macro_tree, temp_macro_files): - """Test extracting functions from a Python file.""" - test_file = temp_macro_files / "test_macros.py" - functions = macro_tree._extract_functions_from_file(test_file) - - # Should extract 2 functions, not the class method - assert len(functions) == 2 - assert "test_macro_function" in functions - assert "another_function" in functions - assert "method" not in functions # Class methods should be excluded - - # Check function details - test_func = functions["test_macro_function"] - assert test_func["line_number"] == 2 # First function starts at line 2 - assert "A test macro function" in test_func["docstring"] - - def test_extract_functions_from_empty_file(self, macro_tree, temp_macro_files): - """Test extracting functions from a file with no functions.""" - empty_file = temp_macro_files / "empty.py" - functions = macro_tree._extract_functions_from_file(empty_file) - - assert len(functions) == 0 - - def test_extract_functions_from_invalid_file(self, macro_tree): - """Test extracting functions from a non-existent file.""" - nonexistent_file = Path("/nonexistent/file.py") - functions = macro_tree._extract_functions_from_file(nonexistent_file) - - assert len(functions) == 0 - - def test_extract_functions_from_syntax_error_file(self, macro_tree, temp_macro_files): - """Test extracting functions from a file with syntax errors.""" - error_file = temp_macro_files / "error_file.py" - functions = macro_tree._extract_functions_from_file(error_file) - - # Should return empty dict on syntax error - assert len(functions) == 0 - - def test_create_file_item(self, macro_tree, temp_macro_files): - """Test creating a file item from a Python file.""" - test_file = temp_macro_files / "test_macros.py" - file_item = macro_tree._create_file_item(test_file) - - assert file_item is not None - assert file_item.text() == "test_macros" - assert file_item.rowCount() == 2 # Should have 2 function children - - # Check file data - file_data = file_item.data(Qt.ItemDataRole.UserRole) - assert file_data["type"] == "file" - assert file_data["file_path"] == str(test_file) - - # Check function children - func_names = [] - for row in range(file_item.rowCount()): - child = file_item.child(row) - func_names.append(child.text()) - - # Check function data - func_data = child.data(Qt.ItemDataRole.UserRole) - assert func_data["type"] == "function" - assert func_data["file_path"] == str(test_file) - assert "function_name" in func_data - assert "line_number" in func_data - - assert "test_macro_function" in func_names - assert "another_function" in func_names - - def test_create_file_item_with_private_file(self, macro_tree, temp_macro_files): - """Test that files starting with underscore are ignored.""" - private_file = temp_macro_files / "_private.py" - file_item = macro_tree._create_file_item(private_file) - - assert file_item is None - - def test_create_file_item_with_no_functions(self, macro_tree, temp_macro_files): - """Test that files with no functions return None.""" - empty_file = temp_macro_files / "empty.py" - file_item = macro_tree._create_file_item(empty_file) - - assert file_item is None - - -class TestMacroTreeInteractions: - """Test macro tree widget interactions and signals.""" - - def test_item_click_on_function(self, macro_tree, qtbot): - """Test clicking on a function item.""" - # Set up signal spy - macro_selected_signals = [] - - def on_macro_selected(function_name, file_path): - macro_selected_signals.append((function_name, file_path)) - - macro_tree.macro_selected.connect(on_macro_selected) - - # Find a function item - file_item = macro_tree.model.item(0) # First file - if file_item and file_item.rowCount() > 0: - func_item = file_item.child(0) # First function - func_index = func_item.index() - - # Simulate click - macro_tree._on_item_clicked(func_index) - - # Check signal was emitted - assert len(macro_selected_signals) == 1 - function_name, file_path = macro_selected_signals[0] - assert function_name is not None - assert file_path is not None - assert file_path.endswith(".py") - - def test_item_click_on_file(self, macro_tree, qtbot): - """Test clicking on a file item (should not emit signal).""" - # Set up signal spy - macro_selected_signals = [] - - def on_macro_selected(function_name, file_path): - macro_selected_signals.append((function_name, file_path)) - - macro_tree.macro_selected.connect(on_macro_selected) - - # Find a file item - file_item = macro_tree.model.item(0) - if file_item: - file_index = file_item.index() - - # Simulate click - macro_tree._on_item_clicked(file_index) - - # Should not emit signal for file items - assert len(macro_selected_signals) == 0 - - def test_item_double_click_on_function(self, macro_tree, qtbot): - """Test double-clicking on a function item.""" - # Set up signal spy - open_requested_signals = [] - - def on_macro_open_requested(function_name, file_path): - open_requested_signals.append((function_name, file_path)) - - macro_tree.macro_open_requested.connect(on_macro_open_requested) - - # Find a function item - file_item = macro_tree.model.item(0) - if file_item and file_item.rowCount() > 0: - func_item = file_item.child(0) - func_index = func_item.index() - - # Simulate double-click - macro_tree._on_item_double_clicked(func_index) - - # Check signal was emitted - assert len(open_requested_signals) == 1 - function_name, file_path = open_requested_signals[0] - assert function_name is not None - assert file_path is not None - - def test_hover_events(self, macro_tree, qtbot): - """Test mouse hover events and action button visibility.""" - # Get the tree view and its viewport - tree_view = macro_tree.tree - viewport = tree_view.viewport() - - # Initially, no item should be hovered - assert not macro_tree.delegate.hovered_index.isValid() - - # Find a function item to hover over - file_item = macro_tree.model.item(0) - if file_item and file_item.rowCount() > 0: - func_item = file_item.child(0) - func_index = func_item.index() - - # Get the position of the function item - rect = tree_view.visualRect(func_index) - pos = rect.center() - - # Simulate a mouse move event over the item - mouse_event = QMouseEvent( - QEvent.Type.MouseMove, - pos, - tree_view.mapToGlobal(pos), - Qt.MouseButton.NoButton, - Qt.MouseButton.NoButton, - Qt.KeyboardModifier.NoModifier, - ) - - # Send the event to the viewport - macro_tree.eventFilter(viewport, mouse_event) - qtbot.wait(100) - - # Now, the hover index should be set - assert macro_tree.delegate.hovered_index.isValid() - assert macro_tree.delegate.hovered_index == func_index - - # Simulate mouse leaving the viewport - leave_event = QEvent(QEvent.Type.Leave) - macro_tree.eventFilter(viewport, leave_event) - qtbot.wait(100) - - # After leaving, no item should be hovered - assert not macro_tree.delegate.hovered_index.isValid() - - def test_macro_open_action(self, macro_tree, qtbot): - """Test the macro open action functionality.""" - # Set up signal spy - open_requested_signals = [] - - def on_macro_open_requested(function_name, file_path): - open_requested_signals.append((function_name, file_path)) - - macro_tree.macro_open_requested.connect(on_macro_open_requested) - - # Find a function item and set it as hovered - file_item = macro_tree.model.item(0) - if file_item and file_item.rowCount() > 0: - func_item = file_item.child(0) - func_index = func_item.index() - - # Set the delegate's hovered index and current macro info - macro_tree.delegate.set_hovered_index(func_index) - func_data = func_item.data(Qt.ItemDataRole.UserRole) - macro_tree.delegate.current_macro_info = func_data - - # Trigger the open action - macro_tree._on_macro_open_requested() - - # Check signal was emitted - assert len(open_requested_signals) == 1 - function_name, file_path = open_requested_signals[0] - assert function_name is not None - assert file_path is not None - - -class TestMacroTreeRefresh: - """Test macro tree refresh functionality.""" - - def test_refresh(self, macro_tree, temp_macro_files): - """Test refreshing the entire tree.""" - # Get initial count - initial_count = macro_tree.model.rowCount() - - # Add a new macro file - new_file = temp_macro_files / "new_macros.py" - new_file.write_text(''' -def new_function(): - """A new function.""" - return "new" -''') - - # Refresh the tree - macro_tree.refresh() - - # Should have one more file - assert macro_tree.model.rowCount() == initial_count + 1 - - def test_refresh_file_item(self, macro_tree, temp_macro_files): - """Test refreshing a single file item.""" - # Find the test_macros.py file - test_file_path = str(temp_macro_files / "test_macros.py") - - # Get initial function count - initial_functions = [] - for row in range(macro_tree.model.rowCount()): - item = macro_tree.model.item(row) - if item: - item_data = item.data(Qt.ItemDataRole.UserRole) - if item_data and item_data.get("file_path") == test_file_path: - for child_row in range(item.rowCount()): - child = item.child(child_row) - initial_functions.append(child.text()) - break - - # Modify the file to add a new function - with open(test_file_path, "a") as f: - f.write(''' - -def newly_added_function(): - """A newly added function.""" - return "added" -''') - - # Refresh just this file - macro_tree.refresh_file_item(test_file_path) - - # Check that the new function was added - updated_functions = [] - for row in range(macro_tree.model.rowCount()): - item = macro_tree.model.item(row) - if item: - item_data = item.data(Qt.ItemDataRole.UserRole) - if item_data and item_data.get("file_path") == test_file_path: - for child_row in range(item.rowCount()): - child = item.child(child_row) - updated_functions.append(child.text()) - break - - # Should have the new function - assert len(updated_functions) == len(initial_functions) + 1 - assert "newly_added_function" in updated_functions - - def test_refresh_nonexistent_file(self, macro_tree): - """Test refreshing a non-existent file.""" - # Should handle gracefully without crashing - macro_tree.refresh_file_item("/nonexistent/file.py") - - # Tree should remain unchanged - assert macro_tree.model.rowCount() >= 0 # Just ensure it doesn't crash - - def test_expand_collapse_all(self, macro_tree, qtbot): - """Test expand/collapse all functionality.""" - # Initially should be expanded - for row in range(macro_tree.model.rowCount()): - item = macro_tree.model.item(row) - if item: - # Items with children should be expanded after initial load - if item.rowCount() > 0: - assert macro_tree.tree.isExpanded(item.index()) - - # Collapse all - macro_tree.collapse_all() - qtbot.wait(50) - - for row in range(macro_tree.model.rowCount()): - item = macro_tree.model.item(row) - if item and item.rowCount() > 0: - assert not macro_tree.tree.isExpanded(item.index()) - - # Expand all - macro_tree.expand_all() - qtbot.wait(50) - - for row in range(macro_tree.model.rowCount()): - item = macro_tree.model.item(row) - if item and item.rowCount() > 0: - assert macro_tree.tree.isExpanded(item.index()) - - -class TestMacroItemDelegate: - """Test the custom macro item delegate functionality.""" - - def test_delegate_action_management(self, qtbot): - """Test adding and clearing delegate actions.""" - widget = MacroTreeWidget() - qtbot.addWidget(widget) - - # Should have at least one default action (open) - assert len(widget.delegate.macro_actions) >= 1 - - # Add a custom action - custom_action = MaterialIconAction(icon_name="edit", tooltip="Edit", parent=widget) - widget.add_macro_action(custom_action.action) - - # Should have the additional action - assert len(widget.delegate.macro_actions) >= 2 - - # Clear actions - widget.clear_actions() - - # Should be empty - assert len(widget.delegate.macro_actions) == 0 - - def test_delegate_hover_index_management(self, qtbot): - """Test hover index management in the delegate.""" - widget = MacroTreeWidget() - qtbot.addWidget(widget) - - # Initially no hover - assert not widget.delegate.hovered_index.isValid() - - # Create a fake index - fake_index = widget.model.createIndex(0, 0) - - # Set hover - widget.delegate.set_hovered_index(fake_index) - assert widget.delegate.hovered_index == fake_index - - # Clear hover - widget.delegate.set_hovered_index(QModelIndex()) - assert not widget.delegate.hovered_index.isValid() diff --git a/tests/unit_tests/test_monaco_dock.py b/tests/unit_tests/test_monaco_dock.py index 0a1d6b88..659c0c56 100644 --- a/tests/unit_tests/test_monaco_dock.py +++ b/tests/unit_tests/test_monaco_dock.py @@ -85,6 +85,45 @@ class TestFocusEditor: assert editor1 != editor2 assert editor2 is not None + def test_close_file_force_discards_unsaved_changes( + self, qtbot, monaco_dock: MonacoDock, tmpdir + ): + """Test force-closing an open file bypasses the save prompt.""" + file_path = tmpdir.join("force_close.py") + file_path.write("print('before')") + + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + editor_widget.set_text("print('after')") + qtbot.wait(100) + assert editor_widget.modified + + with mock.patch.object(QMessageBox, "question") as mock_question: + assert monaco_dock.close_file(str(file_path), force=True) + + assert not mock_question.called + assert editor_widget.current_file is None + assert editor_widget.get_text() == "" + + def test_rename_open_path_updates_open_editor(self, qtbot, monaco_dock: MonacoDock, tmpdir): + """Test renaming an open file updates the tracked editor path and tooltip.""" + file_path = tmpdir.join("rename_me.py") + file_path.write("print('before')") + + monaco_dock.open_file(str(file_path)) + qtbot.wait(300) + + new_file_path = str(tmpdir.join("renamed.py")) + monaco_dock.rename_open_path(str(file_path), new_file_path) + + editor_widget = monaco_dock.last_focused_editor.widget() + assert isinstance(editor_widget, MonacoWidget) + assert editor_widget.current_file == new_file_path + assert monaco_dock._get_open_files() == [new_file_path] + class TestSaveFiles: def test_save_file_existing_file_no_macros(self, qtbot, monaco_dock: MonacoDock, tmpdir): diff --git a/tests/unit_tests/test_script_tree_widget.py b/tests/unit_tests/test_script_tree_widget.py deleted file mode 100644 index 96421976..00000000 --- a/tests/unit_tests/test_script_tree_widget.py +++ /dev/null @@ -1,114 +0,0 @@ -from pathlib import Path - -import pytest -from qtpy.QtCore import QEvent, Qt -from qtpy.QtGui import QMouseEvent - -from bec_widgets.widgets.containers.explorer.script_tree_widget import ScriptTreeWidget - - -@pytest.fixture -def script_tree(qtbot, tmpdir): - """Create a ScriptTreeWidget with the tmpdir directory""" - # Create test files and directories - (Path(tmpdir) / "test_file.py").touch() - (Path(tmpdir) / "test_dir").mkdir() - (Path(tmpdir) / "test_dir" / "nested_file.py").touch() - - widget = ScriptTreeWidget() - widget.set_directory(str(tmpdir)) - qtbot.addWidget(widget) - qtbot.waitExposed(widget) - yield widget - - -def test_script_tree_set_directory(script_tree, tmpdir): - """Test setting the directory""" - assert script_tree.directory == str(tmpdir) - - -def test_script_tree_hover_events(script_tree, qtbot): - """Test mouse hover events and actions button visibility""" - - # Get the tree view and its viewport - tree_view = script_tree.tree - viewport = tree_view.viewport() - - # Find the position of the first item (test_file.py) - index = script_tree.proxy_model.index(0, 0) # first item - rect = tree_view.visualRect(index) - pos = rect.center() - - # Initially, no item should be hovered - assert script_tree.delegate.hovered_index.isValid() == False - - # Simulate a mouse move event over the item - mouse_event = QMouseEvent( - QEvent.Type.MouseMove, - pos, - tree_view.mapToGlobal(pos), - Qt.MouseButton.NoButton, - Qt.MouseButton.NoButton, - Qt.KeyboardModifier.NoModifier, - ) - - # Send the event to the viewport (the event filter is installed on the viewport) - script_tree.eventFilter(viewport, mouse_event) - - # Now, the hover index should be set to the first item - qtbot.waitUntil(lambda: script_tree.delegate.hovered_index.isValid(), timeout=5000) - assert script_tree.delegate.hovered_index.row() == index.row() - - # Simulate mouse leaving the viewport - leave_event = QEvent(QEvent.Type.Leave) - script_tree.eventFilter(viewport, leave_event) - - # After leaving, no item should be hovered - qtbot.waitUntil(lambda: not script_tree.delegate.hovered_index.isValid(), timeout=5000) - - -@pytest.mark.timeout(10) -def test_script_tree_on_item_clicked(script_tree, qtbot, tmpdir): - """Test that _on_item_clicked emits file_selected signal only for Python files""" - - file_selected_signals = [] - file_open_requested_signals = [] - - def on_file_selected(file_path): - file_selected_signals.append(file_path) - - def on_file_open_requested(file_path): - file_open_requested_signals.append(file_path) - - # Connect to the signal - script_tree.file_selected.connect(on_file_selected) - script_tree.file_open_requested.connect(on_file_open_requested) - - # Wait until the model sees test_file.py - def has_py_file(): - nonlocal py_file_index - root_index = script_tree.tree.rootIndex() - for i in range(script_tree.proxy_model.rowCount(root_index)): - index = script_tree.proxy_model.index(i, 0, root_index) - source_index = script_tree.proxy_model.mapToSource(index) - if script_tree.model.fileName(source_index) == "test_file.py": - py_file_index = index - return True - return False - - py_file_index = None - qtbot.waitUntil(has_py_file) - - # Simulate clicking on the center of the item - script_tree._on_item_clicked(py_file_index) - qtbot.wait(100) # Allow time for the click to be processed - - py_file_index = None - qtbot.waitUntil(has_py_file) - - script_tree._on_item_double_clicked(py_file_index) - qtbot.wait(100) - - # Verify the signal was emitted with the correct path - assert len(file_selected_signals) == 1 - assert Path(file_selected_signals[0]).name == "test_file.py"