fix(ide-explorer): enhance file selection behavior across scripts and macros

This commit is contained in:
2026-07-14 16:59:40 +02:00
committed by wakonig_k
parent e4159b9787
commit 70b8022648
4 changed files with 274 additions and 9 deletions
@@ -1,4 +1,5 @@
import os
from fnmatch import fnmatch
from pathlib import Path
from bec_lib.logger import bec_logger
@@ -6,6 +7,7 @@ from qtpy.QtCore import QModelIndex, QPoint, QSortFilterProxyModel, Qt, Signal
from qtpy.QtWidgets import (
QAbstractItemView,
QAction,
QApplication,
QFileSystemModel,
QInputDialog,
QMenu,
@@ -43,8 +45,24 @@ class FileBrowserTreeWidget(QWidget):
file_delete_requested = Signal(str)
file_renamed = Signal(str, str)
def __init__(self, parent=None, directory: str | None = None, read_only: bool = False):
def __init__(
self,
parent=None,
directory: str | None = None,
read_only: bool = False,
name_filters: list[str] | None = None,
):
"""
A file browser tree widget that displays files and directories in a tree view.
Args:
parent: The parent widget.
directory: The initial directory to display. If None, the browser will be empty.
read_only: If True, the browser will be in read-only mode (no drag-and-drop, no renaming, no deletion).
name_filters: A list of name filters (e.g., ["*.py", "*.txt"]) to filter displayed files. If None, all files are displayed.
"""
super().__init__(parent)
self.name_filters = name_filters or ["*.py"]
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -56,14 +74,14 @@ class FileBrowserTreeWidget(QWidget):
self.tree.setSortingEnabled(True)
self.tree.setAnimated(True)
self.tree.setAlternatingRowColors(False)
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
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.setNameFilters(self.name_filters)
self.model.setNameFilterDisables(False)
self.proxy_model = _FileBrowserFilterProxyModel(parent=self)
self.proxy_model.setRecursiveFilteringEnabled(True)
@@ -77,6 +95,7 @@ class FileBrowserTreeWidget(QWidget):
self._apply_styling()
self.directory: str | None = None
self._selection_extending = False
self.tree.clicked.connect(self._on_item_clicked)
self.tree.doubleClicked.connect(self._on_item_double_clicked)
@@ -133,18 +152,34 @@ class FileBrowserTreeWidget(QWidget):
def _on_item_clicked(self, index: QModelIndex):
"""Emit a selection signal for Python files."""
self._selection_extending = self._is_multi_selection_modifier_pressed()
source_index = self._map_to_source(index)
if not source_index.isValid() or self.model.isDir(source_index):
return
selection_model = self.tree.selectionModel()
if selection_model is not None and not selection_model.isSelected(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":
if self._matches_name_filters(file_path):
logger.info(f"File selected: {file_path}")
self.file_selected.emit(file_path)
def is_selection_extending(self) -> bool:
"""Return whether the current click is extending an existing selection."""
return self._selection_extending
@staticmethod
def _is_multi_selection_modifier_pressed() -> bool:
modifiers = QApplication.keyboardModifiers()
return bool(
modifiers & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.MetaModifier)
)
def _on_item_double_clicked(self, index: QModelIndex):
"""Emit an open signal for Python files."""
source_index = self._map_to_source(index)
@@ -189,6 +224,13 @@ class FileBrowserTreeWidget(QWidget):
return None
return file_path
def clear_selection(self) -> None:
"""Clear the highlighted selection in the tree view."""
selection_model = self.tree.selectionModel()
if selection_model is not None:
selection_model.clear()
self.tree.setCurrentIndex(QModelIndex())
def _show_context_menu(self, position: QPoint) -> None:
"""Show a right-click context menu for editable items."""
if self.model.isReadOnly():
@@ -302,3 +344,11 @@ class FileBrowserTreeWidget(QWidget):
if not index.isValid():
return QModelIndex()
return self.proxy_model.mapToSource(index)
def _matches_name_filters(self, file_path: str) -> bool:
"""Return whether file_path matches the configured name filters."""
if not self.name_filters:
return True
file_name = Path(file_path).name.lower()
return any(fnmatch(file_name, name_filter.lower()) for name_filter in self.name_filters)
@@ -29,6 +29,7 @@ class IDEExplorer(BECWidget, QWidget):
def __init__(self, parent=None, **kwargs):
super().__init__(parent=parent, **kwargs)
self._sections = [] # Use list to maintain order instead of set
self._file_browsers: list[FileBrowserTreeWidget] = []
self.main_explorer = Explorer(parent=self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
@@ -76,6 +77,7 @@ class IDEExplorer(BECWidget, QWidget):
"""Clear all sections from the explorer."""
for section in reversed(self._sections):
self._remove_section(section)
self._file_browsers.clear()
def add_script_section(self):
section = CollapsibleSection(
@@ -97,7 +99,7 @@ class IDEExplorer(BECWidget, QWidget):
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)
self._register_file_browser(script_widget, "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)
@@ -120,7 +122,7 @@ class IDEExplorer(BECWidget, QWidget):
shared_script_section.set_widget(shared_script_widget)
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)
self._register_file_browser(shared_script_widget, "scripts/shared")
def add_macro_section(self):
section = CollapsibleSection(
@@ -148,7 +150,7 @@ class IDEExplorer(BECWidget, QWidget):
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)
self._register_file_browser(macro_widget, "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)
@@ -171,7 +173,26 @@ class IDEExplorer(BECWidget, QWidget):
shared_macro_section.set_widget(shared_macro_widget)
macro_explorer.add_section(shared_macro_section)
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)
self._register_file_browser(shared_macro_widget, "macros/shared")
def _register_file_browser(self, widget: FileBrowserTreeWidget, scope: str) -> None:
"""Track file browsers so only one file remains highlighted in the IDE explorer."""
self._file_browsers.append(widget)
widget.file_selected.connect(
lambda file_name, source=widget, source_scope=scope: self._on_file_selected(
source, file_name, source_scope
)
)
def _on_file_selected(
self, source_widget: FileBrowserTreeWidget, file_name: str, scope: str
) -> None:
"""Clear other file browser selections and emit the preview request."""
if not source_widget.is_selection_extending():
for widget in self._file_browsers:
if widget is not source_widget:
widget.clear_selection()
self.file_preview_requested.emit(file_name, scope)
def _get_plugin_dir(self, dir_name: Literal["scripts", "macros"]) -> str | None:
"""Get the path to the specified directory within the BEC plugin.
@@ -1,7 +1,7 @@
from pathlib import Path
import pytest
from qtpy.QtCore import QModelIndex, QPoint, Qt
from qtpy.QtCore import QItemSelectionModel, QModelIndex, QPoint, Qt
from qtpy.QtWidgets import QAbstractItemView, QInputDialog, QMenu
from bec_widgets.widgets.containers.explorer.file_browser_tree_widget import FileBrowserTreeWidget
@@ -48,7 +48,11 @@ def test_file_browser_tree_hides_filtered_entries(file_browser_tree, qtbot):
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.model.nameFilters() == ["*.py"]
assert file_browser_tree.tree.alternatingRowColors() is False
assert (
file_browser_tree.tree.selectionMode() == QAbstractItemView.SelectionMode.ExtendedSelection
)
assert file_browser_tree.tree.dragDropMode() == QAbstractItemView.DragDropMode.InternalMove
assert file_browser_tree.tree.defaultDropAction() == Qt.DropAction.MoveAction
assert (
@@ -95,6 +99,10 @@ def test_file_browser_tree_on_item_clicked(file_browser_tree, qtbot):
py_file_index = None
qtbot.waitUntil(has_py_file)
file_browser_tree.tree.selectionModel().select(
py_file_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
file_browser_tree._on_item_clicked(py_file_index)
qtbot.wait(100)
@@ -110,6 +118,50 @@ def test_file_browser_tree_on_item_clicked(file_browser_tree, qtbot):
assert Path(file_open_requested_signals[0]).name == "test_file.py"
@pytest.mark.timeout(10)
def test_file_browser_tree_accepts_custom_name_filters(qtbot, tmpdir):
"""Test that the file browser can be configured for non-Python files."""
(Path(tmpdir) / "config.yaml").touch()
(Path(tmpdir) / "settings.yml").touch()
(Path(tmpdir) / "script.py").touch()
widget = FileBrowserTreeWidget(directory=str(tmpdir), name_filters=["*.yaml", "*.yml"])
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
selected = []
widget.file_selected.connect(selected.append)
def visible_names():
root_index = widget.tree.rootIndex()
return [
widget.proxy_model.data(widget.proxy_model.index(i, 0, root_index))
for i in range(widget.proxy_model.rowCount(root_index))
]
qtbot.waitUntil(lambda: "config.yaml" in visible_names(), timeout=5000)
assert "settings.yml" in visible_names()
assert "script.py" not in visible_names()
root_index = widget.tree.rootIndex()
yaml_index = None
for i in range(widget.proxy_model.rowCount(root_index)):
index = widget.proxy_model.index(i, 0, root_index)
if widget.proxy_model.data(index) == "config.yaml":
yaml_index = index
break
assert yaml_index is not None
widget.tree.selectionModel().select(
yaml_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
widget._on_item_clicked(yaml_index)
assert len(selected) == 1
assert Path(selected[0]).name == "config.yaml"
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 = []
@@ -138,6 +190,30 @@ def test_file_browser_tree_delete_request_emits_selected_file(file_browser_tree,
assert Path(delete_requests[0]).name == "test_file.py"
def test_file_browser_tree_clear_selection(file_browser_tree, qtbot):
"""Test clearing the current highlighted file selection."""
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)
assert file_browser_tree.tree.selectionModel().hasSelection()
file_browser_tree.clear_selection()
assert not file_browser_tree.tree.selectionModel().hasSelection()
assert not file_browser_tree.tree.currentIndex().isValid()
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."""
+118
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from unittest import mock
import pytest
from qtpy.QtCore import QItemSelectionModel
from qtpy.QtWidgets import QMessageBox
from bec_widgets.widgets.utility.ide_explorer.ide_explorer import IDEExplorer
@@ -174,6 +175,123 @@ def test_shared_sections_not_added_when_directory_empty(ide_explorer, tmpdir):
assert shared_section is None
def test_file_selection_highlight_is_global_across_scripts_and_macros(ide_explorer, qtbot, tmpdir):
"""Selecting a script file clears the previous macro file highlight."""
scripts_dir = tmpdir.mkdir("scripts")
macros_dir = tmpdir.mkdir("macros")
script_file = scripts_dir.join("script.py")
macro_file = macros_dir.join("macro.py")
script_file.write("print('script')")
macro_file.write("def macro():\n pass\n")
scripts_browser = (
ide_explorer.main_explorer.get_section("SCRIPTS")
.content_widget.get_section("Local")
.content_widget
)
macros_browser = (
ide_explorer.main_explorer.get_section("MACROS")
.content_widget.get_section("Local")
.content_widget
)
scripts_browser.set_directory(str(scripts_dir))
macros_browser.set_directory(str(macros_dir))
def get_index(browser, file_name):
root_index = browser.tree.rootIndex()
for i in range(browser.proxy_model.rowCount(root_index)):
index = browser.proxy_model.index(i, 0, root_index)
if browser.proxy_model.data(index) == file_name:
return index
return None
qtbot.waitUntil(
lambda: get_index(scripts_browser, "script.py") is not None
and get_index(macros_browser, "macro.py") is not None,
timeout=5000,
)
macro_index = get_index(macros_browser, "macro.py")
assert macro_index is not None
macros_browser.tree.setCurrentIndex(macro_index)
macros_browser.tree.selectionModel().select(
macro_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
macros_browser.file_selected.emit(str(macro_file))
assert macros_browser.tree.selectionModel().hasSelection()
script_index = get_index(scripts_browser, "script.py")
assert script_index is not None
scripts_browser.tree.setCurrentIndex(script_index)
scripts_browser.tree.selectionModel().select(
script_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
scripts_browser.file_selected.emit(str(script_file))
assert scripts_browser.tree.selectionModel().hasSelection()
assert not macros_browser.tree.selectionModel().hasSelection()
def test_modifier_file_selection_keeps_existing_highlights(ide_explorer, qtbot, tmpdir):
"""Modifier-click selection keeps earlier highlights across scripts and macros."""
scripts_dir = tmpdir.mkdir("scripts")
macros_dir = tmpdir.mkdir("macros")
script_file = scripts_dir.join("script.py")
macro_file = macros_dir.join("macro.py")
script_file.write("print('script')")
macro_file.write("def macro():\n pass\n")
scripts_browser = (
ide_explorer.main_explorer.get_section("SCRIPTS")
.content_widget.get_section("Local")
.content_widget
)
macros_browser = (
ide_explorer.main_explorer.get_section("MACROS")
.content_widget.get_section("Local")
.content_widget
)
scripts_browser.set_directory(str(scripts_dir))
macros_browser.set_directory(str(macros_dir))
def get_index(browser, file_name):
root_index = browser.tree.rootIndex()
for i in range(browser.proxy_model.rowCount(root_index)):
index = browser.proxy_model.index(i, 0, root_index)
if browser.proxy_model.data(index) == file_name:
return index
return None
qtbot.waitUntil(
lambda: get_index(scripts_browser, "script.py") is not None
and get_index(macros_browser, "macro.py") is not None,
timeout=5000,
)
macro_index = get_index(macros_browser, "macro.py")
assert macro_index is not None
macros_browser.tree.selectionModel().select(
macro_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
macros_browser.file_selected.emit(str(macro_file))
script_index = get_index(scripts_browser, "script.py")
assert script_index is not None
scripts_browser.tree.selectionModel().select(
script_index,
QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows,
)
scripts_browser._selection_extending = True
scripts_browser.file_selected.emit(str(script_file))
assert scripts_browser.tree.selectionModel().hasSelection()
assert macros_browser.tree.selectionModel().hasSelection()
@pytest.mark.parametrize(
"slot, signal, file_name,scope",
[