diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 021cfc32..b887ee1a 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -39,7 +39,7 @@ def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates: object_name(str): The name of the dock area. Returns: - BECDockArea: The created dock area. + AdvancedDockArea: The created dock area. """ _auto_update = AutoUpdates(object_name=object_name) return _auto_update diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index f49d3a54..71c2c35e 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -397,12 +397,10 @@ class LaunchWindow(BECMainWindow): with RPCRegister.delayed_broadcast() as rpc_register: existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(AdvancedDockArea) if name is not None: - if name in existing_dock_areas: - raise ValueError( - f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}." - ) WidgetContainerUtils.raise_for_invalid_name(name) - + # If name already exists, generate a unique one with counter suffix + if name in existing_dock_areas: + name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) else: name = "dock_area" name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas) diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 0320c613..e130919c 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -274,6 +274,24 @@ class AdvancedDockArea(RPCBase): name (str | None): The name of the profile to load. If None, prompts the user. """ + @rpc_call + def delete_profile(self, name: "str | None" = None, show_dialog: "bool" = False) -> "bool": + """ + Delete a workspace profile. + + Args: + name: The name of the profile to delete. If None, uses the currently + selected profile from the toolbar combo box (for UI usage). + show_dialog: If True, show confirmation dialog before deletion. + Defaults to False for CLI/programmatic usage. + + Returns: + bool: True if the profile was deleted, False otherwise. + + Raises: + ValueError: If the profile is read-only or doesn't exist (when show_dialog=False). + """ + class AutoUpdates(RPCBase): @property diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 622becc6..6602271b 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -303,27 +303,55 @@ class BECGuiClient(RPCBase): wait: bool = True, geometry: tuple[int, int, int, int] | None = None, launch_script: str = "dock_area", + profile: str | None = None, + empty: bool = False, **kwargs, - ) -> client.BECDockArea: + ) -> client.AdvancedDockArea: """Create a new top-level dock area. Args: name(str, optional): The name of the dock area. Defaults to None. wait(bool, optional): Whether to wait for the server to start. Defaults to True. - geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h) + geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h). + launch_script(str): The launch script to use. Defaults to "dock_area". + profile(str | None): The profile name to load. If None, restores the last used profile. + Use a profile name to load a specific saved profile. + empty(bool): If True, start with an empty dock area without loading any profile. + This takes precedence over the profile argument. Defaults to False. + **kwargs: Additional keyword arguments passed to the dock area. + Returns: - client.BECDockArea: The new dock area. + client.AdvancedDockArea: The new dock area. + + Examples: + >>> gui.new() # Restore last used profile + >>> gui.new(profile="my_profile") # Load specific profile, if profile do not exist, the new profile is created empty with specified name + >>> gui.new(empty=True) # Start with empty dock area + >>> gui.new(name="custom_dock", empty=True) # Named empty dock area """ + if empty: + # Use a unique non-existent profile name to ensure empty start + profile = "__empty__" if not self._check_if_server_is_alive(): self.start(wait=True) if wait: with wait_for_server(self): widget = self.launcher._run_rpc( - "launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs + "launch", + launch_script=launch_script, + name=name, + geometry=geometry, + profile=profile, + **kwargs, ) # pylint: disable=protected-access return widget widget = self.launcher._run_rpc( - "launch", launch_script=launch_script, name=name, geometry=geometry, **kwargs + "launch", + launch_script=launch_script, + name=name, + geometry=geometry, + profile=profile, + **kwargs, ) # pylint: disable=protected-access return widget diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index cfc06da0..7c0ef7c5 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -107,6 +107,7 @@ class AdvancedDockArea(DockAreaWidget): "mode.setter", "save_profile", "load_profile", + "delete_profile", ] # Define a signal for mode changes @@ -183,6 +184,7 @@ class AdvancedDockArea(DockAreaWidget): def _ensure_initial_profile(self) -> bool: """ Ensure at least one workspace profile exists for the current namespace. + If list_profile fails due to file permission or corrupted profiles, no action taken. Returns: bool: True if a profile was created, False otherwise. @@ -870,37 +872,82 @@ class AdvancedDockArea(DockAreaWidget): self.load_profile(target) @SafeSlot() - def delete_profile(self): + def delete_profile(self, name: str | None = None, show_dialog: bool = False) -> bool: """ - Delete the currently selected workspace profile file and refresh the combo list. + Delete a workspace profile. + + Args: + name: The name of the profile to delete. If None, uses the currently + selected profile from the toolbar combo box (for UI usage). + show_dialog: If True, show confirmation dialog before deletion. + Defaults to False for CLI/programmatic usage. + + Returns: + bool: True if the profile was deleted, False otherwise. + + Raises: + ValueError: If the profile is read-only or doesn't exist (when show_dialog=False). + """ - combo = self.toolbar.components.get_action("workspace_combo").widget - name = combo.currentText() + # Resolve profile name + if name is None: + combo = self.toolbar.components.get_action("workspace_combo").widget + name = combo.currentText() if not name: - return - - # Protect bundled/module/plugin profiles from deletion - if is_profile_read_only(name, namespace=self.profile_namespace): - QMessageBox.information( - self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted." - ) - return - - # Confirm deletion for regular profiles - reply = QMessageBox.question( - self, - "Delete Profile", - f"Are you sure you want to delete the profile '{name}'?\n\n" - f"This action cannot be undone.", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return + if show_dialog: + return False + raise ValueError("No profile name provided.") namespace = self.profile_namespace - delete_profile_files(name, namespace=namespace) + + # Check if profile is read-only + if is_profile_read_only(name, namespace=namespace): + if show_dialog: + QMessageBox.information( + self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted." + ) + return False + raise ValueError(f"Profile '{name}' is read-only and cannot be deleted.") + + # Confirm deletion if dialog is enabled + if show_dialog: + reply = QMessageBox.question( + self, + "Delete Profile", + f"Are you sure you want to delete the profile '{name}'?\n\n" + f"This action cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return False + + # Perform deletion + try: + removed = delete_profile_files(name, namespace=namespace) + except OSError as exc: + if show_dialog: + QMessageBox.warning( + self, "Delete Profile", f"Failed to delete profile '{name}': {exc}" + ) + return False + raise ValueError(f"Failed to delete profile '{name}': {exc}") from exc + + if not removed: + if show_dialog: + QMessageBox.information( + self, "Delete Profile", "No writable profile files were found to delete." + ) + return False + raise ValueError(f"No writable profile files found for '{name}'.") + + # Clear current profile if it was deleted + if getattr(self, "_current_profile_name", None) == name: + self._current_profile_name = None + + # Refresh the workspace list self._refresh_workspace_list() + return True def _refresh_workspace_list(self): """ diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py index c5949f60..be9e5542 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -29,7 +29,6 @@ from qtpy.QtWidgets import ( from bec_widgets import BECWidget, SafeSlot from bec_widgets.utils.colors import get_accent_colors from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( - delete_profile_files, get_profile_info, is_quick_select, list_profiles, @@ -341,55 +340,35 @@ class WorkSpaceManager(BECWidget, QWidget): @SafeSlot(str) def delete_profile(self, profile_name: str): - info = get_profile_info(profile_name, namespace=self.profile_namespace) - if info.is_read_only: - QMessageBox.information( - self, "Delete Profile", "This profile is read-only and cannot be deleted." - ) - return + """ + Delete a profile by delegating to the target widget's delete_profile method. - reply = QMessageBox.question( - self, - "Delete Profile", - ( - f"Delete the profile '{profile_name}'?\n\n" - "This will remove both the user and default copies." - ), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if reply != QMessageBox.Yes: + Args: + profile_name: The name of the profile to delete. + """ + if self.target_widget is None or not hasattr(self.target_widget, "delete_profile"): + QMessageBox.warning( + self, "Delete Profile", "No target widget available for profile deletion." + ) return try: - removed = delete_profile_files(profile_name, namespace=self.profile_namespace) - except OSError as exc: - QMessageBox.warning( - self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}" - ) - return + result = self.target_widget.delete_profile(profile_name, show_dialog=True) + except ValueError: + # Error was already handled by target widget's dialog + result = False - if not removed: - QMessageBox.information( - self, "Delete Profile", "No writable profile files were found to delete." - ) - return - - if self.target_widget is not None: - if getattr(self.target_widget, "_current_profile_name", None) == profile_name: - self.target_widget._current_profile_name = None - if hasattr(self.target_widget, "_refresh_workspace_list"): - self.target_widget._refresh_workspace_list() - - self.render_table() - remaining_profiles = list_profiles(namespace=self.profile_namespace) - if remaining_profiles: - next_profile = remaining_profiles[0] - self._select_by_name(next_profile) - self._show_profile_details(next_profile) - else: - self.profile_details_tree.clear() - self.screenshot_label.setPixmap(QPixmap()) + if result: + # Refresh our table and select next profile + self.render_table() + remaining_profiles = list_profiles(namespace=self.profile_namespace) + if remaining_profiles: + next_profile = remaining_profiles[0] + self._select_by_name(next_profile) + self._show_profile_details(next_profile) + else: + self.profile_details_tree.clear() + self.screenshot_label.setPixmap(QPixmap()) def resizeEvent(self, event): super().resizeEvent(event) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index d18a5a8a..26f3a8e2 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -141,9 +141,37 @@ def workspace_manager_target(): def save_profile(self): self.save_called = True + def save_profile_dialog(self, name: str | None = None): + """Mock save_profile_dialog that sets save_called flag.""" + self.save_called = True + def _refresh_workspace_list(self): self.refresh_calls += 1 + def delete_profile(self, name: str, show_dialog: bool = False) -> bool: + """Mock delete_profile that performs actual file deletion.""" + from qtpy.QtWidgets import QMessageBox + + from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + delete_profile_files, + is_profile_read_only, + ) + + if is_profile_read_only(name): + if show_dialog: + QMessageBox.information( + None, + "Delete Profile", + f"Profile '{name}' is read-only and cannot be deleted.", + ) + return False + raise ValueError(f"Profile '{name}' is read-only.") + delete_profile_files(name) + if self._current_profile_name == name: + self._current_profile_name = None + self._refresh_workspace_list() + return True + def _factory(): return _Target() @@ -273,11 +301,6 @@ class TestBasicDockArea: assert "panel_one" not in basic_dock_area.dock_map() assert "panel_two" in basic_dock_area.dock_map() - def test_remove_widget_raises_for_unknown_name(self, basic_dock_area): - """Test remove_widget raises ValueError for non-existent widget.""" - with pytest.raises(ValueError, match="No widget found with object name 'nonexistent'"): - basic_dock_area.remove_widget("nonexistent") - def test_manifest_serialization_includes_floating_geometry( self, basic_dock_area, qtbot, tmp_path ): @@ -1703,7 +1726,7 @@ class TestWorkspaceProfileOperations: "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", StubDialog, ): - advanced_dock_area.save_profile(profile_name) + advanced_dock_area.save_profile(profile_name, show_dialog=True) assert os.path.exists(target_path) @@ -1775,7 +1798,7 @@ class TestWorkspaceProfileOperations: "bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog", StubDialog, ): - advanced_dock_area.save_profile() + advanced_dock_area.save_profile(show_dialog=True) qtbot.wait(500) source_manifest = read_manifest(helper.open_user(source_profile)) @@ -1844,7 +1867,7 @@ class TestWorkspaceProfileOperations: return_value=None, ) as mock_info, ): - advanced_dock_area.delete_profile() + advanced_dock_area.delete_profile(show_dialog=True) mock_question.assert_not_called() mock_info.assert_called_once() @@ -1875,13 +1898,31 @@ class TestWorkspaceProfileOperations: mock_question.return_value = QMessageBox.Yes with patch.object(advanced_dock_area, "_refresh_workspace_list") as mock_refresh: - advanced_dock_area.delete_profile() + advanced_dock_area.delete_profile(show_dialog=True) mock_question.assert_called_once() mock_refresh.assert_called_once() # Profile should be deleted assert not os.path.exists(user_path) + def test_delete_profile_cli_usage(self, advanced_dock_area, temp_profile_dir): + """Test delete_profile with explicit name (CLI usage - no dialog by default).""" + profile_name = "cli_deletable_profile" + helper = profile_helper(advanced_dock_area) + + # Create regular profile + settings = helper.open_user(profile_name) + settings.setValue("test", "value") + settings.sync() + user_path = helper.user_path(profile_name) + assert os.path.exists(user_path) + + # Delete without dialog (CLI usage - default behavior) + result = advanced_dock_area.delete_profile(profile_name) + + assert result is True + assert not os.path.exists(user_path) + def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir): """Test refreshing workspace list.""" # Create some profiles