fix: replace custom tree with builtin filebrowser

This commit is contained in:
2026-07-14 16:59:40 +02:00
committed by wakonig_k
parent e8902d1ea2
commit cfa38e549f
15 changed files with 1072 additions and 1492 deletions
@@ -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