diff --git a/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py b/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py index 6bff6813..db18a0b8 100644 --- a/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py +++ b/bec_widgets/widgets/containers/explorer/file_browser_tree_widget.py @@ -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) diff --git a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py index 79f71c68..de13ac53 100644 --- a/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py +++ b/bec_widgets/widgets/utility/ide_explorer/ide_explorer.py @@ -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. diff --git a/tests/unit_tests/test_file_browser_tree_widget.py b/tests/unit_tests/test_file_browser_tree_widget.py index d887603f..b10b4d68 100644 --- a/tests/unit_tests/test_file_browser_tree_widget.py +++ b/tests/unit_tests/test_file_browser_tree_widget.py @@ -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.""" diff --git a/tests/unit_tests/test_ide_explorer.py b/tests/unit_tests/test_ide_explorer.py index aee2536b..29a64db7 100644 --- a/tests/unit_tests/test_ide_explorer.py +++ b/tests/unit_tests/test_ide_explorer.py @@ -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", [