mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-24 04:03:05 +02:00
fix: replace custom tree with builtin filebrowser
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}/<folder>):"
|
||||
)
|
||||
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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user