diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 82cfada8..8367674c 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -214,6 +214,46 @@ class AdvancedDockArea(RPCBase): None """ + @rpc_call + def save_profile( + self, + name: "str | None" = None, + *, + show_dialog: "bool" = False, + quick_select: "bool | None" = None, + ): + """ + Save the current workspace profile. + + On first save of a given name: + - writes a default copy to states/default/.ini with tag=default and created_at + - writes a user copy to states/user/.ini with tag=user and created_at + On subsequent saves of user-owned profiles: + - updates both the default and user copies so restore uses the latest snapshot. + Read-only bundled profiles cannot be overwritten. + + Args: + name (str | None): The name of the profile to save. If None and show_dialog is True, + prompts the user. + show_dialog (bool): If True, shows the SaveProfileDialog for user interaction. + If False (default), saves directly without user interaction (useful for CLI usage). + quick_select (bool | None): Whether to include the profile in quick selection. + If None (default), uses the existing value or True for new profiles. + Only used when show_dialog is False; otherwise the dialog provides the value. + """ + + @rpc_call + def load_profile(self, name: "str | None" = None): + """ + Load a workspace profile. + + Before switching, persist the current profile to the user copy. + Prefer loading the user copy; fall back to the default copy. + + Args: + name (str | None): The name of the profile to load. If None, prompts the user. + """ + class AutoUpdates(RPCBase): @property 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 ccd01985..83f91e24 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 @@ -201,18 +201,7 @@ class AdvancedDockArea(DockAreaWidget): f"No profiles found for namespace '{namespace}'. Bootstrapping '{name}' workspace." ) - default_settings = open_default_settings(name, namespace=namespace) - self._write_snapshot_to_settings(default_settings, save_preview=False) - if not default_settings.value(SETTINGS_KEYS["created_at"], ""): - default_settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - default_settings.setValue(SETTINGS_KEYS["is_quick_select"], True) - - user_settings = open_user_settings(name, namespace=namespace) - self._write_snapshot_to_settings(user_settings, save_preview=False) - if not user_settings.value(SETTINGS_KEYS["created_at"], ""): - user_settings.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - user_settings.setValue(SETTINGS_KEYS["is_quick_select"], True) - + self._write_profile_settings(name, namespace, save_preview=False) set_quick_select(name, True, namespace=namespace) set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) return True @@ -608,8 +597,63 @@ class AdvancedDockArea(DockAreaWidget): logger.info(f"Workspace snapshot written to settings: {settings.fileName()}") + def _write_profile_settings( + self, + name: str, + namespace: str | None, + *, + write_default: bool = True, + write_user: bool = True, + save_preview: bool = True, + ) -> None: + """ + Write profile settings to default and/or user settings files. + + Args: + name: The profile name. + namespace: The profile namespace. + write_default: Whether to write to the default settings file. + write_user: Whether to write to the user settings file. + save_preview: Whether to save a screenshot preview. + """ + if write_default: + ds = open_default_settings(name, namespace=namespace) + self._write_snapshot_to_settings(ds, save_preview=save_preview) + if not ds.value(SETTINGS_KEYS["created_at"], ""): + ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if not ds.value(SETTINGS_KEYS["is_quick_select"], None): + ds.setValue(SETTINGS_KEYS["is_quick_select"], True) + + if write_user: + us = open_user_settings(name, namespace=namespace) + self._write_snapshot_to_settings(us, save_preview=save_preview) + if not us.value(SETTINGS_KEYS["created_at"], ""): + us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) + if not us.value(SETTINGS_KEYS["is_quick_select"], None): + us.setValue(SETTINGS_KEYS["is_quick_select"], True) + + def _finalize_profile_change(self, name: str, namespace: str | None) -> None: + """ + Finalize a profile change by updating state and refreshing the UI. + + Args: + name: The profile name. + namespace: The profile namespace. + """ + self._current_profile_name = name + self.profile_changed.emit(name) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) + combo = self.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles(active_profile=name) + @SafeSlot(str) - def save_profile(self, name: str | None = None): + def save_profile( + self, + name: str | None = None, + *, + show_dialog: bool = False, + quick_select: bool | None = None, + ): """ Save the current workspace profile. @@ -621,76 +665,106 @@ class AdvancedDockArea(DockAreaWidget): Read-only bundled profiles cannot be overwritten. Args: - name (str | None): The name of the profile to save. If None, prompts the user. + name (str | None): The name of the profile to save. If None and show_dialog is True, + prompts the user. + show_dialog (bool): If True, shows the SaveProfileDialog for user interaction. + If False (default), saves directly without user interaction (useful for CLI usage). + quick_select (bool | None): Whether to include the profile in quick selection. + If None (default), uses the existing value or True for new profiles. + Only used when show_dialog is False; otherwise the dialog provides the value. """ namespace = self.profile_namespace + current_profile = getattr(self, "_current_profile_name", "") or "" def _profile_exists(profile_name: str) -> bool: return profile_origin(profile_name, namespace=namespace) != "unknown" - initial_name = name or "" - quickselect_default = is_quick_select(name, namespace=namespace) if name else True + # Determine final values either from dialog or directly + if show_dialog: + initial_name = name or "" + quickselect_default = is_quick_select(name, namespace=namespace) if name else True - current_profile = getattr(self, "_current_profile_name", "") or "" - dialog = SaveProfileDialog( - self, - current_name=initial_name, - current_profile_name=current_profile, - name_exists=_profile_exists, - profile_origin=lambda n: profile_origin(n, namespace=namespace), - origin_label=lambda n: profile_origin_display(n, namespace=namespace), - quick_select_checked=quickselect_default, - ) - if dialog.exec() != QDialog.DialogCode.Accepted: - return + dialog = SaveProfileDialog( + self, + current_name=initial_name, + current_profile_name=current_profile, + name_exists=_profile_exists, + profile_origin=lambda n: profile_origin(n, namespace=namespace), + origin_label=lambda n: profile_origin_display(n, namespace=namespace), + quick_select_checked=quickselect_default, + ) + if dialog.exec() != QDialog.DialogCode.Accepted: + return + + name = dialog.get_profile_name() + quickselect = dialog.is_quick_select() + overwrite_existing = dialog.overwrite_existing + else: + # CLI / programmatic usage - no dialog + if not name: + logger.warning("save_profile called without name and show_dialog=False") + return + + # Determine quick_select value + if quick_select is None: + # Use existing value if profile exists, otherwise default to True + quickselect = ( + is_quick_select(name, namespace=namespace) if _profile_exists(name) else True + ) + else: + quickselect = quick_select + + # For programmatic saves, check if profile is read-only + origin = profile_origin(name, namespace=namespace) + if origin in {"module", "plugin"}: + logger.warning(f"Cannot save to read-only profile '{name}' (origin: {origin})") + return + + # Overwrite existing settings profile when saving programmatically + overwrite_existing = origin == "settings" - name = dialog.get_profile_name() - quickselect = dialog.is_quick_select() origin_before_save = profile_origin(name, namespace=namespace) - overwrite_default = dialog.overwrite_existing and origin_before_save == "settings" - # Display saving placeholder + overwrite_default = overwrite_existing and origin_before_save == "settings" + + # Display saving placeholder in toolbar workspace_combo = self.toolbar.components.get_action("workspace_combo").widget workspace_combo.blockSignals(True) workspace_combo.insertItem(0, f"{name}-saving") workspace_combo.setCurrentIndex(0) workspace_combo.blockSignals(False) - # Create or update default copy controlled by overwrite flag + # Write to default and/or user settings should_write_default = overwrite_default or not any( os.path.exists(path) for path in default_profile_candidates(name, namespace) ) - if should_write_default: - ds = open_default_settings(name, namespace=namespace) - self._write_snapshot_to_settings(ds) - if not ds.value(SETTINGS_KEYS["created_at"], ""): - ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - # Ensure new profiles are quick-select by default - if not ds.value(SETTINGS_KEYS["is_quick_select"], None): - ds.setValue(SETTINGS_KEYS["is_quick_select"], True) - - # Always (over)write the user copy - us = open_user_settings(name, namespace=namespace) - self._write_snapshot_to_settings(us) - if not us.value(SETTINGS_KEYS["created_at"], ""): - us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc()) - # Ensure new profiles are quick-select by default (only if missing) - if not us.value(SETTINGS_KEYS["is_quick_select"], None): - us.setValue(SETTINGS_KEYS["is_quick_select"], True) + self._write_profile_settings( + name, namespace, write_default=should_write_default, write_user=True + ) set_quick_select(name, quickselect, namespace=namespace) self._refresh_workspace_list() - if current_profile and current_profile != name and not dialog.overwrite_existing: + if current_profile and current_profile != name and not overwrite_existing: self._pending_autosave_skip = (current_profile, name) else: self._pending_autosave_skip = None workspace_combo.setCurrentText(name) - self._current_profile_name = name - self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) - combo = self.toolbar.components.get_action("workspace_combo").widget - combo.refresh_profiles(active_profile=name) + self._finalize_profile_change(name, namespace) + + @SafeSlot() + def save_profile_dialog(self, name: str | None = None): + """ + Save the current workspace profile with a dialog prompt. + + This is a convenience method for UI usage (toolbar, dialogs) that + always shows the SaveProfileDialog. For programmatic/CLI usage, + use save_profile() directly. + + Args: + name (str | None): Optional initial name to populate in the dialog. + """ + self.save_profile(name, show_dialog=True) def load_profile(self, name: str | None = None): """ @@ -725,7 +799,9 @@ class AdvancedDockArea(DockAreaWidget): elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)): settings = open_default_settings(name, namespace=namespace) if settings is None: - QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.") + logger.warning(f"Profile '{name}' not found in namespace '{namespace}'. Creating new.") + self.delete_all() + self.save_profile(name, show_dialog=False, quick_select=True) return # Clear existing docks and remove all widgets @@ -759,11 +835,7 @@ class AdvancedDockArea(DockAreaWidget): self.state_manager.load_state(settings=settings) self._set_editable(self._editable) - self._current_profile_name = name - self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) - combo = self.toolbar.components.get_action("workspace_combo").widget - combo.refresh_profiles(active_profile=name) + self._finalize_profile_change(name, namespace) @SafeSlot() @SafeSlot(str) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 406e5037..ded47f32 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -20,7 +20,7 @@ from bec_lib import bec_logger from bec_lib.client import BECClient from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path from pydantic import BaseModel, Field -from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt +from qtpy.QtCore import QByteArray, QDateTime, QSettings, QTimeZone from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QApplication @@ -627,7 +627,7 @@ def now_iso_utc() -> str: Returns: str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``). """ - return QDateTime.currentDateTimeUtc().toString(Qt.ISODate) + return QDateTime.currentDateTimeUtc().toString("yyyy-MM-ddTHH:mm:ssZ") def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: @@ -843,7 +843,9 @@ def _file_modified_iso(path: str) -> str: """ try: mtime = os.path.getmtime(path) - return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate) + return QDateTime.fromSecsSinceEpoch(int(mtime), QTimeZone.utc()).toString( + "yyyy-MM-ddTHH:mm:ssZ" + ) except Exception: return now_iso_utc() 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 36357a17..c5949f60 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 @@ -330,7 +330,7 @@ class WorkSpaceManager(BECWidget, QWidget): ) return - self.target_widget.save_profile() + self.target_widget.save_profile_dialog() # AdvancedDockArea will emit profile_changed which will trigger table refresh, # but ensure the UI stays in sync even if the signal is delayed. self.render_table() @@ -402,7 +402,7 @@ class WorkSpaceManager(BECWidget, QWidget): scaled = pm.scaled( self.screenshot_label.width() or 800, self.screenshot_label.height() or 450, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, ) self.screenshot_label.setPixmap(scaled) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index 58bb8cbe..88940c0e 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -156,7 +156,7 @@ class WorkspaceConnection(BundleConnection): # Connect the action to the target widget's method save_action = self.components.get_action("save_workspace").action if save_action.isVisible(): - save_action.triggered.connect(self.target_widget.save_profile) + save_action.triggered.connect(self.target_widget.save_profile_dialog) self.components.get_action("workspace_combo").widget.currentTextChanged.connect( self.target_widget.load_profile @@ -176,7 +176,7 @@ class WorkspaceConnection(BundleConnection): # Disconnect the action from the target widget's method save_action = self.components.get_action("save_workspace").action if save_action.isVisible(): - save_action.triggered.disconnect(self.target_widget.save_profile) + save_action.triggered.disconnect(self.target_widget.save_profile_dialog) self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect( self.target_widget.load_profile )