diff --git a/datalab/config.py b/datalab/config.py
index 67d123b2..c754cd6b 100644
--- a/datalab/config.py
+++ b/datalab/config.py
@@ -206,7 +206,9 @@ class MainSection(conf.Section, metaclass=conf.SectionMeta):
current_tab = conf.Option()
plugins_enabled = conf.Option()
plugins_enabled_list = conf.Option() # List of enabled plugin names
- plugins_path = conf.Option()
+ plugins_path = conf.Option() # Deprecated: single-directory string, kept for
+ # backward compatibility. Use plugins_path_list instead.
+ plugins_path_list = conf.Option() # List of extra plugin directories
tour_enabled = conf.Option()
v020_plugins_warning_ignore = conf.Option() # True: do not warn, False: warn
@@ -525,6 +527,59 @@ class Conf(conf.Configuration, metaclass=conf.ConfMeta):
ai = AISection()
+def normalize_plugin_paths(paths: list[str] | tuple[str, ...] | None) -> list[str]:
+ """Normalize a list of plugin directories and drop duplicates/empties."""
+ normalized: list[str] = []
+ seen: set[str] = set()
+ for raw_path in paths or []:
+ if not raw_path:
+ continue
+ path = osp.normpath(osp.abspath(osp.expanduser(raw_path)))
+ if path in seen:
+ continue
+ seen.add(path)
+ normalized.append(path)
+ return normalized
+
+
+def get_user_plugin_paths() -> list[str]:
+ """Return user-configured extra plugin directories.
+
+ Reads from ``plugins_path_list`` (list of directories). For backward
+ compatibility, the deprecated ``plugins_path`` single-directory string is
+ also merged into ``plugins_path_list`` if this is empty.
+ """
+ fixed_default = osp.normpath(Conf.get_path("plugins"))
+
+ # New list-based option (primary)
+ path_list = Conf.main.plugins_path_list.get([])
+ if path_list is None:
+ path_list = []
+ candidates = list(path_list)
+
+ # Migrate deprecated single-directory option into the list
+ legacy_path = Conf.main.plugins_path.get("")
+ if legacy_path and isinstance(legacy_path, str):
+ norm_legacy = osp.normpath(osp.abspath(osp.expanduser(legacy_path)))
+ if not candidates and norm_legacy != fixed_default:
+ candidates.append(legacy_path)
+ Conf.main.plugins_path_list.set(candidates)
+
+ normalized = normalize_plugin_paths(candidates)
+ return [path for path in normalized if path != fixed_default]
+
+
+def set_user_plugin_paths(paths: list[str] | tuple[str, ...]) -> None:
+ """Persist user-configured extra plugin directories.
+
+ Writes to ``plugins_path_list``. The deprecated ``plugins_path`` is left
+ untouched so that older DataLab versions can still find at least one
+ user-configured directory.
+ """
+ normalized = normalize_plugin_paths(list(paths))
+ Conf.main.plugins_path_list.set(normalized)
+
+
def get_old_log_fname(fname):
"""Return old log fname from current log fname"""
return osp.splitext(fname)[0] + ".1.log"
@@ -555,7 +610,8 @@ def initialize():
Conf.main.plugins_enabled_list.get(
None
) # None = all enabled, [] = none, list = specific
- Conf.main.plugins_path.get(Conf.get_path("plugins"))
+ Conf.main.plugins_path.get("") # Deprecated: kept for backward compat
+ Conf.main.plugins_path_list.get([])
Conf.main.tour_enabled.get(True)
Conf.main.v020_plugins_warning_ignore.get(False)
# Console section
diff --git a/datalab/data/icons/edit/open_file_source.svg b/datalab/data/icons/edit/open_file_source.svg
new file mode 100644
index 00000000..f81d24fd
--- /dev/null
+++ b/datalab/data/icons/edit/open_file_source.svg
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/datalab/data/icons/io/show_in_folder.svg b/datalab/data/icons/io/show_in_folder.svg
new file mode 100644
index 00000000..f92989ff
--- /dev/null
+++ b/datalab/data/icons/io/show_in_folder.svg
@@ -0,0 +1,8 @@
+
+
\ No newline at end of file
diff --git a/datalab/gui/main.py b/datalab/gui/main.py
index f1556a84..83980abd 100644
--- a/datalab/gui/main.py
+++ b/datalab/gui/main.py
@@ -28,6 +28,7 @@
import time
import traceback
import webbrowser
+from datetime import datetime
from typing import TYPE_CHECKING
import guidata.dataset as gds
@@ -158,6 +159,8 @@ def __init__(self, console=None, hide_on_close=False): # pylint: disable=too-ma
execenv.log(self, "Starting initialization")
self.ready_flag = True
+ self.started_at = datetime.now().astimezone()
+ self.plugins_last_load_at = self.started_at
self.hide_on_close = hide_on_close
self.__old_size: tuple[int, int] | None = None
@@ -1051,6 +1054,10 @@ def __register_plugins(self) -> None:
# None = all plugins enabled (default), [] = no plugins, list = specific plugins
enabled_list = Conf.main.plugins_enabled_list.get(None)
+ if not Conf.main.plugins_enabled.get():
+ self.plugins_last_load_at = datetime.now().astimezone()
+ return
+
for plugin_class in PluginRegistry.get_plugin_classes():
try:
# Check if plugin is enabled before instantiation
@@ -1092,6 +1099,8 @@ def __register_plugins(self) -> None:
plugin_class.__name__, filepath or "", tb_text
)
+ self.plugins_last_load_at = datetime.now().astimezone()
+
def __flush_startup_errors(self) -> None:
"""Write any buffered startup errors to the internal console.
@@ -1129,6 +1138,11 @@ def __configure_plugins(self) -> None:
dialog = PluginConfigDialog(self)
dialog.exec()
+ def set_plugins_enabled(self, enabled: bool) -> None:
+ """Apply the global third-party plugin enabled state."""
+ Conf.main.plugins_enabled.set(enabled)
+ self.__apply_plugins_enabled_setting()
+
def reload_plugins(self) -> None:
"""Reload third-party plugins at runtime.
@@ -1143,8 +1157,9 @@ def reload_plugins(self) -> None:
self,
_("Plugins"),
_(
- "Third-party plugins are disabled. Enable them in the "
- "Settings dialog to use this feature."
+ "Third-party plugins are disabled. Enable them again "
+ "from the plugin configuration dialog to use this "
+ "feature."
),
)
return
@@ -1220,6 +1235,7 @@ def reload_plugins(self) -> None:
# Update plugin status in the status bar
self.pluginstatus.update_status()
self.__update_plugins_availability()
+ self.plugins_last_load_at = datetime.now().astimezone()
def __configure_statusbar(self, console: bool) -> None:
"""Configure status bar
@@ -1255,12 +1271,11 @@ def __update_plugins_availability(self) -> None:
"""Update plugin-related UI according to third-party plugin setting."""
plugins_enabled = Conf.main.plugins_enabled.get()
- if self.plugins_menu is not None:
- self.plugins_menu.setEnabled(plugins_enabled)
+ if self.reload_plugins_action is not None:
+ self.reload_plugins_action.setEnabled(plugins_enabled)
- for action in (self.reload_plugins_action, self.configure_plugins_action):
- if action is not None:
- action.setEnabled(plugins_enabled)
+ if self.configure_plugins_action is not None:
+ self.configure_plugins_action.setEnabled(True)
if hasattr(self, "pluginstatus") and self.pluginstatus is not None:
self.pluginstatus.update_status()
@@ -1277,11 +1292,6 @@ def __apply_plugins_enabled_setting(self) -> None:
for panel in (self.signalpanel, self.imagepanel):
panel.acthandler.clear_plugin_actions()
- PluginRegistry.clear_plugin_classes()
- PluginRegistry.clear_failed_plugins()
- PluginRegistry.clear_discovery_errors()
- self._startup_errors.clear()
-
self.__update_actions(update_other_data_panel=True)
self.__update_plugins_availability()
@@ -1867,8 +1877,6 @@ def __update_actions(self, update_other_data_panel: bool = False) -> None:
panel.selection_changed()
self.signalpanel_toolbar.setVisible(is_signal)
self.imagepanel_toolbar.setVisible(not is_signal)
- if self.plugins_menu is not None:
- self.plugins_menu.setEnabled(Conf.main.plugins_enabled.get())
def __tab_index_changed(self, index: int) -> None:
"""Switch from signal to image mode, or vice-versa"""
@@ -1898,12 +1906,11 @@ def __update_generic_menu(self, menu: QW.QMenu | None = None) -> None:
# no plugin has registered actions yet (so that new plugins can be
# discovered after they are added on disk).
if menu is self.plugins_menu:
- if Conf.main.plugins_enabled.get():
- actions = list(actions) + [
- None,
- self.configure_plugins_action,
- self.reload_plugins_action,
- ]
+ actions = list(actions) + [
+ None,
+ self.configure_plugins_action,
+ self.reload_plugins_action,
+ ]
add_actions(menu, actions)
def __update_file_menu(self) -> None:
@@ -2513,8 +2520,6 @@ def __edit_settings(self) -> None: # pylint: disable=too-many-branches,too-many
self.__update_color_mode()
if option == "show_console_on_error":
self.__update_console_show_mode()
- if option == "plugins_enabled":
- self.__apply_plugins_enabled_setting()
if option == "plot_toolbar_position":
for dock in self.docks.values():
widget = dock.widget()
diff --git a/datalab/gui/pluginconfig.py b/datalab/gui/pluginconfig.py
index 76d2c8ac..455c4db2 100644
--- a/datalab/gui/pluginconfig.py
+++ b/datalab/gui/pluginconfig.py
@@ -9,14 +9,19 @@
from __future__ import annotations
+import inspect
import os
import os.path as osp
+from datetime import datetime, timedelta
+from html import escape
from typing import TYPE_CHECKING
+from guidata.configtools import get_icon
from guidata.qthelpers import win32_fix_title_bar_background
from qtpy import QtCore as QC
from qtpy import QtGui as QG
from qtpy import QtWidgets as QW
+from qtpy.compat import getexistingdirectory
from datalab.config import (
DATALAB_PLUGINS_ENV_PATHS,
@@ -26,8 +31,17 @@
PLUGIN_OK_COLOR,
Conf,
_,
+ get_user_plugin_paths,
+ normalize_plugin_paths,
+ set_user_plugin_paths,
)
from datalab.plugins import PLUGINS_DEFAULT_PATH, PluginRegistry
+from datalab.utils.qthelpers import (
+ open_local_path as _open_local_path,
+)
+from datalab.utils.qthelpers import (
+ show_in_folder as _show_in_folder,
+)
from datalab.widgets.expandabletext import (
ExpandableTextWidget,
apply_palette_color,
@@ -86,6 +100,49 @@ def _create_status_label(text: str, color: QG.QColor | None = None) -> QW.QLabel
return status_label
+def _get_latest_plugin_load_at(main: DLMainWindow) -> datetime:
+ """Return the most recent relevant plugin load timestamp."""
+ timestamps = [
+ value
+ for value in (
+ getattr(main, "started_at", None),
+ getattr(main, "plugins_last_load_at", None),
+ )
+ if isinstance(value, datetime)
+ ]
+ if timestamps:
+ return max(timestamps)
+ return datetime.now().astimezone()
+
+
+def format_last_load_text(
+ timestamp: datetime,
+ now: datetime | None = None,
+ locale: QC.QLocale | None = None,
+) -> str:
+ """Format the last load text with today/yesterday/date semantics."""
+ if now is None:
+ now = datetime.now(timestamp.tzinfo) if timestamp.tzinfo else datetime.now()
+ if locale is None:
+ locale = QC.QLocale.system()
+
+ if timestamp.date() == now.date():
+ day_text = _("today")
+ elif timestamp.date() == (now - timedelta(days=1)).date():
+ day_text = _("yesterday")
+ else:
+ day_text = locale.toString(
+ QC.QDate(timestamp.year, timestamp.month, timestamp.day),
+ QC.QLocale.ShortFormat,
+ )
+
+ time_text = locale.toString(
+ QC.QTime(timestamp.hour, timestamp.minute),
+ "HH:mm",
+ )
+ return _("Last loaded: %s at %s") % (day_text, time_text)
+
+
class PluginState:
"""Plugin state enumeration"""
@@ -117,6 +174,9 @@ def __init__(
self.initial_enabled = enabled
self.state = state
self._expanded = False
+ self.plugin_filepath = self._get_plugin_filepath()
+ self.open_file_button: QW.QPushButton | None = None
+ self.show_in_folder_button: QW.QPushButton | None = None
self.setSizePolicy(QW.QSizePolicy.Preferred, QW.QSizePolicy.Maximum)
# Main layout
@@ -126,6 +186,9 @@ def __init__(
layout.addLayout(self._create_top_row(enabled, state))
layout.addWidget(self._create_description_widget())
+ actions_layout = self._create_actions_layout()
+ if actions_layout is not None:
+ layout.addLayout(actions_layout)
layout.addWidget(self._create_separator())
def _create_top_row(self, enabled: bool, state: str) -> QW.QHBoxLayout:
@@ -180,6 +243,50 @@ def _sync_description_expanded_state(self, expanded: bool) -> None:
"""Keep the legacy expanded state in sync with the description widget."""
self._expanded = expanded
+ def _get_plugin_filepath(self) -> str | None:
+ """Return the Python file defining the plugin class, when available."""
+ filepath = getattr(self.plugin_class, "__plugin_filepath__", None)
+ if filepath:
+ return osp.abspath(filepath)
+ try:
+ filepath = inspect.getsourcefile(self.plugin_class) or inspect.getfile(
+ self.plugin_class
+ )
+ except (OSError, TypeError):
+ return None
+ return osp.abspath(filepath) if filepath else None
+
+ def _create_actions_layout(self) -> QW.QHBoxLayout | None:
+ """Create file/folder actions for the plugin source file."""
+ if not self.plugin_filepath:
+ return None
+
+ actions_layout = QW.QHBoxLayout()
+ actions_layout.addStretch()
+
+ self.open_file_button = QW.QPushButton(
+ get_icon("open_file_source.svg"), _("Open file")
+ )
+ self.open_file_button.clicked.connect(self._open_plugin_file)
+ actions_layout.addWidget(self.open_file_button)
+
+ self.show_in_folder_button = QW.QPushButton(
+ get_icon("show_in_folder.svg"), _("Show in folder")
+ )
+ self.show_in_folder_button.clicked.connect(self._show_plugin_in_folder)
+ actions_layout.addWidget(self.show_in_folder_button)
+ return actions_layout
+
+ def _open_plugin_file(self) -> None:
+ """Open the plugin file with the desktop handler."""
+ if self.plugin_filepath:
+ _open_local_path(self.plugin_filepath)
+
+ def _show_plugin_in_folder(self) -> None:
+ """Show the plugin file in its containing folder."""
+ if self.plugin_filepath:
+ _show_in_folder(self.plugin_filepath)
+
@staticmethod
def _create_separator() -> QW.QFrame:
"""Create the row separator."""
@@ -236,6 +343,11 @@ def __init__(
"""
super().__init__(parent)
self._expanded = False
+ self.plugin_filepath = (
+ osp.abspath(failed_info.filepath) if failed_info.filepath else None
+ )
+ self.open_file_button: QW.QPushButton | None = None
+ self.show_in_folder_button: QW.QPushButton | None = None
self.setSizePolicy(QW.QSizePolicy.Preferred, QW.QSizePolicy.Maximum)
# Main layout
@@ -245,6 +357,9 @@ def __init__(
layout.addLayout(self._create_top_row(failed_info))
layout.addWidget(self._create_description_widget(failed_info))
+ actions_layout = self._create_actions_layout()
+ if actions_layout is not None:
+ layout.addLayout(actions_layout)
layout.addWidget(PluginInfoWidget._create_separator())
def _create_top_row(self, failed_info: FailedPluginInfo) -> QW.QHBoxLayout:
@@ -286,6 +401,37 @@ def _create_description_widget(self, failed_info: FailedPluginInfo) -> QW.QWidge
self._toggle_btn = self.description_widget.toggle_button
return self.description_widget
+ def _create_actions_layout(self) -> QW.QHBoxLayout | None:
+ """Create file/folder actions for the failed plugin path."""
+ if not self.plugin_filepath:
+ return None
+
+ actions_layout = QW.QHBoxLayout()
+ actions_layout.addStretch()
+
+ self.open_file_button = QW.QPushButton(
+ get_icon("open_file_source.svg"), _("Open file")
+ )
+ self.open_file_button.clicked.connect(self._open_plugin_file)
+ actions_layout.addWidget(self.open_file_button)
+
+ self.show_in_folder_button = QW.QPushButton(
+ get_icon("show_in_folder.svg"), _("Show in folder")
+ )
+ self.show_in_folder_button.clicked.connect(self._show_plugin_in_folder)
+ actions_layout.addWidget(self.show_in_folder_button)
+ return actions_layout
+
+ def _open_plugin_file(self) -> None:
+ """Open the failed plugin file with the desktop handler."""
+ if self.plugin_filepath:
+ _open_local_path(self.plugin_filepath)
+
+ def _show_plugin_in_folder(self) -> None:
+ """Show the failed plugin file in its containing folder."""
+ if self.plugin_filepath:
+ _show_in_folder(self.plugin_filepath)
+
def _toggle_description(self):
"""Toggle between collapsed and expanded description"""
self.description_widget.set_expanded(not self.description_widget.is_expanded())
@@ -297,6 +443,87 @@ def matches_filter(filter_mode: str) -> bool:
return filter_mode in (FILTER_ALL, FILTER_ERRORS)
+class SearchPathItemWidget(QW.QWidget):
+ """Widget representing one plugin search path."""
+
+ def __init__(
+ self,
+ path: str,
+ *,
+ editable: bool,
+ from_env: bool = False,
+ parent: QW.QWidget | None = None,
+ ) -> None:
+ super().__init__(parent)
+ self.path = path
+ self.from_env = from_env
+ self.path_label: QW.QLabel | None = None
+ self.edit_button: QW.QToolButton | None = None
+ self.delete_button: QW.QToolButton | None = None
+
+ layout = QW.QHBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(6)
+ self.setLayout(layout)
+
+ path_label = QW.QLabel()
+ path_label.setWordWrap(True)
+ self.path_label = path_label
+ self.set_links_enabled(True)
+ layout.addWidget(path_label, 1)
+
+ if editable:
+ self.edit_button = QW.QToolButton()
+ self.edit_button.setIcon(get_icon("annotations_edit.svg"))
+ self.edit_button.setToolTip(_("Edit directory"))
+ self.edit_button.setAutoRaise(True)
+ layout.addWidget(self.edit_button)
+
+ self.delete_button = QW.QToolButton()
+ self.delete_button.setIcon(get_icon("annotations_delete.svg"))
+ self.delete_button.setToolTip(_("Remove directory"))
+ self.delete_button.setAutoRaise(True)
+ layout.addWidget(self.delete_button)
+
+ def set_links_enabled(self, enabled: bool) -> None:
+ """Update link interactivity and appearance for enabled/disabled states."""
+ if self.path_label is None:
+ return
+
+ self.path_label.setText(self._build_path_label_text(enabled))
+ if enabled:
+ self.path_label.setTextInteractionFlags(QC.Qt.TextBrowserInteraction)
+ self.path_label.setOpenExternalLinks(True)
+ self.path_label.setStyleSheet("")
+ return
+
+ disabled_color = QW.QApplication.palette().color(
+ QG.QPalette.Disabled, QG.QPalette.WindowText
+ )
+ disabled_color_name = disabled_color.name()
+ self.path_label.setTextInteractionFlags(QC.Qt.TextSelectableByMouse)
+ self.path_label.setOpenExternalLinks(False)
+ self.path_label.setStyleSheet(f"QLabel {{ color: {disabled_color_name}; }}")
+
+ def _build_path_label_text(self, enabled: bool) -> str:
+ """Return rich text for the path label according to enabled state."""
+ url = escape(QC.QUrl.fromLocalFile(self.path).toString(), quote=True)
+ path = escape(self.path)
+ if enabled:
+ link_text = f'{path}'
+ else:
+ disabled_color = (
+ QW.QApplication.palette()
+ .color(QG.QPalette.Disabled, QG.QPalette.WindowText)
+ .name()
+ )
+ link_text = f'{path}'
+ if self.from_env:
+ env_var = escape(DATALAB_PLUGINS_ENV_VAR)
+ link_text += f" ({_('from')} {env_var})"
+ return link_text
+
+
class PluginConfigDialog(QW.QDialog):
"""Dialog for configuring plugins"""
@@ -311,9 +538,31 @@ def __init__(self, parent: DLMainWindow):
self.main = parent
self.plugin_widgets: list[PluginInfoWidget] = []
self.failed_plugin_widgets: list[FailedPluginInfoWidget] = []
+ self.fixed_path_widgets: list[SearchPathItemWidget] = []
+ self.extra_path_widgets: list[SearchPathItemWidget] = []
+ self.original_plugins_enabled = Conf.main.plugins_enabled.get(True)
+ self.plugins_enabled = self.original_plugins_enabled
+ self.original_v020_plugins_warning_ignore = (
+ Conf.main.v020_plugins_warning_ignore.get(False)
+ )
+ self.v020_plugins_warning_ignore = self.original_v020_plugins_warning_ignore
+ self.original_extra_plugin_paths = get_user_plugin_paths()
+ self.extra_plugin_paths = list(self.original_extra_plugin_paths)
+ self.tabs: QW.QTabWidget | None = None
self.toggle_all_checkbox: QW.QCheckBox | None = None
self.filter_combo: QW.QComboBox | None = None
+ self.load_info_label: QW.QLabel | None = None
self.plugins_layout: QW.QVBoxLayout | None = None
+ self.plugins_content: QW.QWidget | None = None
+ self.plugins_disabled_label: QW.QLabel | None = None
+ self.extra_paths_layout: QW.QVBoxLayout | None = None
+ self.extra_paths_placeholder: QW.QLabel | None = None
+ self.settings_scroll: QW.QScrollArea | None = None
+ self.settings_disabled_label: QW.QLabel | None = None
+ self.add_path_button: QW.QPushButton | None = None
+ self.reload_button: QW.QPushButton | None = None
+ self.global_toggle_button: QW.QPushButton | None = None
+ self.v020_warning_checkbox: QW.QCheckBox | None = None
self.setWindowTitle(_("Plugin Configuration"))
self.setMinimumWidth(DIALOG_MIN_WIDTH)
@@ -323,12 +572,11 @@ def __init__(self, parent: DLMainWindow):
layout = QW.QVBoxLayout()
self.setLayout(layout)
- layout.addWidget(self._create_title_label())
-
- tabs = QW.QTabWidget()
- tabs.addTab(self._create_plugins_tab(), _("Enable/disable plugins"))
- tabs.addTab(self._create_search_paths_tab(), _("Plugin search paths"))
- layout.addWidget(tabs, 1)
+ self.tabs = QW.QTabWidget()
+ self.tabs.addTab(self._create_plugins_tab(), _("Enable/disable plugins"))
+ self.tabs.addTab(self._create_search_paths_tab(), _("Plugin settings"))
+ self.tabs.setCornerWidget(self._create_corner_widget(), QC.Qt.TopRightCorner)
+ layout.addWidget(self.tabs, 1)
# Populate plugins
self.populate_plugins()
@@ -337,23 +585,37 @@ def __init__(self, parent: DLMainWindow):
button_box = QW.QDialogButtonBox(
QW.QDialogButtonBox.Ok | QW.QDialogButtonBox.Cancel
)
+ self.reload_button = QW.QPushButton(
+ get_icon("refresh-auto.svg"), _("Apply and reload plugins")
+ )
+ button_box.addButton(self.reload_button, QW.QDialogButtonBox.ActionRole)
+ self.reload_button.clicked.connect(self._apply_and_reload_plugins)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
- layout.addWidget(button_box)
+ layout.addLayout(self._create_footer_layout(button_box))
+ self._update_load_info_label()
+ self._update_global_plugins_ui()
- @staticmethod
- def _create_title_label() -> QW.QLabel:
- """Create the dialog title label."""
- title_label = QW.QLabel(_("Manage Plugins"))
- title_font = title_label.font()
- title_font.setPointSize(title_font.pointSize() + TITLE_FONT_SIZE_DELTA)
- title_font.setBold(True)
- title_label.setFont(title_font)
- apply_palette_color(
- title_label,
- QW.QApplication.instance().palette().color(QG.QPalette.WindowText),
+ def _create_corner_widget(self) -> QW.QWidget:
+ """Create the low-impact global plugin toggle shown near the tab titles."""
+ container = QW.QWidget()
+ layout = QW.QHBoxLayout()
+ layout.setContentsMargins(6, 2, 0, 2)
+ container.setLayout(layout)
+
+ self.global_toggle_button = QW.QPushButton()
+ self.global_toggle_button.setAutoDefault(False)
+ self.global_toggle_button.setDefault(False)
+ self.global_toggle_button.setCursor(QG.QCursor(QC.Qt.PointingHandCursor))
+ self.global_toggle_button.setSizePolicy(
+ QW.QSizePolicy.Fixed, QW.QSizePolicy.Fixed
)
- return title_label
+ self.global_toggle_button.setIconSize(QC.QSize(14, 14))
+ self.global_toggle_button.setMinimumHeight(24)
+ self.global_toggle_button.setStyleSheet("QPushButton { padding: 2px 8px; }")
+ self.global_toggle_button.clicked.connect(self._toggle_global_plugins_enabled)
+ layout.addWidget(self.global_toggle_button)
+ return container
def _create_plugins_tab(self) -> QW.QWidget:
"""Create the 'Enable/disable plugins' tab content."""
@@ -361,27 +623,51 @@ def _create_plugins_tab(self) -> QW.QWidget:
tab_layout = QW.QVBoxLayout()
tab.setLayout(tab_layout)
tab_layout.addWidget(self._create_info_label())
- tab_layout.addLayout(self._create_controls_layout())
- tab_layout.addWidget(self._create_scroll_area(), 1)
+
+ self.plugins_disabled_label = QW.QLabel(
+ _("Third-party plugins are globally disabled.")
+ )
+ self.plugins_disabled_label.setWordWrap(True)
+ apply_subdued_color(self.plugins_disabled_label)
+ self.plugins_disabled_label.hide()
+ tab_layout.addWidget(self.plugins_disabled_label)
+
+ self.plugins_content = QW.QWidget()
+ content_layout = QW.QVBoxLayout()
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ self.plugins_content.setLayout(content_layout)
+ content_layout.addLayout(self._create_controls_layout())
+ content_layout.addWidget(self._create_scroll_area(), 1)
+ tab_layout.addWidget(self.plugins_content, 1)
return tab
def _create_search_paths_tab(self) -> QW.QWidget:
- """Create the 'Plugin search paths' tab content (scrollable)."""
+ """Create the 'Plugin settings' tab content (scrollable)."""
tab = QW.QWidget()
tab_layout = QW.QVBoxLayout()
tab_layout.setContentsMargins(0, 0, 0, 0)
tab.setLayout(tab_layout)
- scroll = QW.QScrollArea()
- scroll.setWidgetResizable(True)
- scroll.setHorizontalScrollBarPolicy(QC.Qt.ScrollBarAlwaysOff)
+ self.settings_disabled_label = QW.QLabel(
+ _("Third-party plugins are globally disabled.")
+ )
+ self.settings_disabled_label.setWordWrap(True)
+ apply_subdued_color(self.settings_disabled_label)
+ self.settings_disabled_label.hide()
+ tab_layout.addWidget(self.settings_disabled_label)
+
+ self.settings_scroll = QW.QScrollArea()
+ self.settings_scroll.setWidgetResizable(True)
+ self.settings_scroll.setHorizontalScrollBarPolicy(QC.Qt.ScrollBarAlwaysOff)
container = QW.QWidget()
container_layout = QW.QVBoxLayout()
container.setLayout(container_layout)
container_layout.addLayout(self._create_search_paths_layout())
+ container_layout.addSpacing(18)
+ container_layout.addLayout(self._create_warning_settings_layout())
container_layout.addStretch()
- scroll.setWidget(container)
- tab_layout.addWidget(scroll)
+ self.settings_scroll.setWidget(container)
+ tab_layout.addWidget(self.settings_scroll)
return tab
@staticmethod
@@ -395,8 +681,8 @@ def _create_info_label() -> QW.QLabel:
return info_label
@staticmethod
- def _collect_search_paths() -> list[tuple[str, bool]]:
- """Return active plugin search paths with their env-var origin flag.
+ def _collect_fixed_search_paths() -> list[tuple[str, bool]]:
+ """Return fixed plugin search paths with their env-var origin flag.
Returns:
List of ``(path, from_env_var)`` tuples in discovery order.
@@ -404,11 +690,7 @@ def _collect_search_paths() -> list[tuple[str, bool]]:
seen: set[str] = set()
entries: list[tuple[str, bool]] = []
env_paths_norm = {osp.normpath(p) for p in DATALAB_PLUGINS_ENV_PATHS}
- candidates: list[str] = []
- custom = Conf.main.plugins_path.get()
- if custom:
- candidates.append(custom)
- candidates.append(PLUGINS_DEFAULT_PATH)
+ candidates = [PLUGINS_DEFAULT_PATH]
candidates.extend(OTHER_PLUGINS_PATHLIST)
for raw in candidates:
if not raw:
@@ -421,7 +703,7 @@ def _collect_search_paths() -> list[tuple[str, bool]]:
return entries
def _create_search_paths_layout(self) -> QW.QVBoxLayout:
- """Create the layout listing active plugin search paths."""
+ """Create the layout listing plugin search paths."""
paths_layout = QW.QVBoxLayout()
intro = QW.QLabel(
@@ -430,31 +712,61 @@ def _create_search_paths_layout(self) -> QW.QVBoxLayout:
intro.setWordWrap(True)
paths_layout.addWidget(intro)
- items: list[str] = []
- for path, from_env in self._collect_search_paths():
- url = QC.QUrl.fromLocalFile(path).toString()
- link = f'{path}'
- if from_env:
- link += f" ({_('from')} {DATALAB_PLUGINS_ENV_VAR})"
- items.append(f"
%s environment variable "
- "(multiple paths separated by '%s'). "
+ "(multiple paths separated by '%s') "
+ "also appear above as read-only entries. "
"Changes take effect at DataLab startup."
)
% (DATALAB_PLUGINS_ENV_VAR, os.pathsep)
@@ -464,6 +776,102 @@ def _create_search_paths_layout(self) -> QW.QVBoxLayout:
paths_layout.addWidget(hint)
return paths_layout
+ def _create_warning_settings_layout(self) -> QW.QVBoxLayout:
+ """Create the layout for plugin compatibility warning options."""
+ warnings_layout = QW.QVBoxLayout()
+
+ title = QW.QLabel(_("Compatibility warnings"))
+ title_font = title.font()
+ title_font.setBold(True)
+ title.setFont(title_font)
+ warnings_layout.addWidget(title)
+
+ self.v020_warning_checkbox = QW.QCheckBox(
+ _("Hide warnings for incompatible DataLab v0.20 plugins")
+ )
+ self.v020_warning_checkbox.setChecked(self.v020_plugins_warning_ignore)
+ self.v020_warning_checkbox.toggled.connect(
+ self._set_v020_plugins_warning_ignore
+ )
+ warnings_layout.addWidget(self.v020_warning_checkbox)
+
+ hint = QW.QLabel(
+ _(
+ "If enabled, DataLab will not warn you about v0.20 plugins "
+ "that are no longer compatible with v1.0."
+ )
+ )
+ hint.setWordWrap(True)
+ apply_subdued_color(hint)
+ warnings_layout.addWidget(hint)
+ return warnings_layout
+
+ def _refresh_extra_path_widgets(self) -> None:
+ """Rebuild the editable extra-path widgets."""
+ if self.extra_paths_layout is None:
+ return
+
+ for widget in self.extra_path_widgets:
+ self.extra_paths_layout.removeWidget(widget)
+ widget.deleteLater()
+ self.extra_path_widgets.clear()
+
+ if self.extra_paths_placeholder is not None:
+ self.extra_paths_placeholder.setVisible(not self.extra_plugin_paths)
+
+ for path in self.extra_plugin_paths:
+ widget = SearchPathItemWidget(path, editable=True)
+ assert widget.edit_button is not None
+ assert widget.delete_button is not None
+ widget.edit_button.clicked.connect(
+ lambda _checked=False, item=widget: self._edit_search_path(item)
+ )
+ widget.delete_button.clicked.connect(
+ lambda _checked=False, item=widget: self._remove_search_path(item)
+ )
+ self.extra_path_widgets.append(widget)
+ self.extra_paths_layout.addWidget(widget)
+
+ def _browse_plugin_directory(self, initial_path: str | None = None) -> str | None:
+ """Open a directory chooser for a plugin search path."""
+ basedir = initial_path or Conf.main.base_dir.get(osp.expanduser("~"))
+ directory = getexistingdirectory(self, _("Select plugin directory"), basedir)
+ normalized = normalize_plugin_paths([directory])
+ return normalized[0] if normalized else None
+
+ def _get_fixed_search_path_set(self) -> set[str]:
+ """Return the normalized set of fixed plugin search paths."""
+ return {path for path, _from_env in self._collect_fixed_search_paths()}
+
+ def _add_search_path(self) -> None:
+ """Append a new extra plugin search path."""
+ path = self._browse_plugin_directory()
+ if not path:
+ return
+ if path in self._get_fixed_search_path_set() or path in self.extra_plugin_paths:
+ return
+ self.extra_plugin_paths.append(path)
+ self._refresh_extra_path_widgets()
+
+ def _edit_search_path(self, item: SearchPathItemWidget) -> None:
+ """Edit an existing extra plugin search path."""
+ path = self._browse_plugin_directory(item.path)
+ if not path:
+ return
+ other_paths = [entry for entry in self.extra_plugin_paths if entry != item.path]
+ if path in self._get_fixed_search_path_set() or path in other_paths:
+ return
+ index = self.extra_plugin_paths.index(item.path)
+ self.extra_plugin_paths[index] = path
+ self._refresh_extra_path_widgets()
+
+ def _remove_search_path(self, item: SearchPathItemWidget) -> None:
+ """Remove an extra plugin search path."""
+ self.extra_plugin_paths = [
+ path for path in self.extra_plugin_paths if path != item.path
+ ]
+ self._refresh_extra_path_widgets()
+
def _create_controls_layout(self) -> QW.QHBoxLayout:
"""Create the row with master controls."""
controls_layout = QW.QHBoxLayout()
@@ -498,6 +906,136 @@ def _create_scroll_area(self) -> QW.QScrollArea:
scroll.setWidget(container)
return scroll
+ def _create_footer_layout(self, button_box: QW.QDialogButtonBox) -> QW.QHBoxLayout:
+ """Create the footer with the last-load label and dialog buttons."""
+ footer_layout = QW.QHBoxLayout()
+ self.load_info_label = QW.QLabel()
+ apply_subdued_color(self.load_info_label)
+ footer_layout.addWidget(self.load_info_label)
+ footer_layout.addStretch()
+ footer_layout.addWidget(button_box)
+ return footer_layout
+
+ def _update_load_info_label(self) -> None:
+ """Update the text describing the latest plugin load time."""
+ if self.load_info_label is None:
+ return
+ latest_load = _get_latest_plugin_load_at(self.main)
+ self.load_info_label.setText(format_last_load_text(latest_load))
+
+ def _update_global_plugins_ui(self) -> None:
+ """Refresh button texts and enabled state for global plugin controls."""
+ enabled = self.plugins_enabled
+ if self.global_toggle_button is not None:
+ self.global_toggle_button.setText(
+ _("Disable plugins globally")
+ if enabled
+ else _("Enable plugins globally")
+ )
+ self.global_toggle_button.setIcon(
+ get_icon("uncheck_all.svg") if enabled else get_icon("check_all.svg")
+ )
+ if self.reload_button is not None:
+ self.reload_button.setEnabled(enabled)
+ if self.plugins_content is not None:
+ self.plugins_content.setEnabled(enabled)
+ if self.plugins_disabled_label is not None:
+ self.plugins_disabled_label.setVisible(not enabled)
+ if self.settings_scroll is not None:
+ self.settings_scroll.setEnabled(enabled)
+ if self.settings_disabled_label is not None:
+ self.settings_disabled_label.setVisible(not enabled)
+ for widget in self.fixed_path_widgets + self.extra_path_widgets:
+ widget.set_links_enabled(enabled)
+
+ def _toggle_global_plugins_enabled(self) -> None:
+ """Toggle the local global third-party plugin enabled state."""
+ self.plugins_enabled = not self.plugins_enabled
+ self._update_global_plugins_ui()
+
+ def _set_v020_plugins_warning_ignore(self, state: bool) -> None:
+ """Store the local compatibility-warning preference."""
+ self.v020_plugins_warning_ignore = state
+
+ def _has_changes(self) -> bool:
+ """Return whether plugin enablement or search paths changed."""
+ plugin_changes_made = any(
+ widget.has_changed() for widget in self.plugin_widgets
+ )
+ path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths
+ plugins_enabled_changed = self.plugins_enabled != self.original_plugins_enabled
+ warning_changed = (
+ self.v020_plugins_warning_ignore
+ != self.original_v020_plugins_warning_ignore
+ )
+ return (
+ plugin_changes_made
+ or path_changes_made
+ or plugins_enabled_changed
+ or warning_changed
+ )
+
+ def _has_reloadable_changes(self) -> bool:
+ """Return whether changes require plugin reload while plugins are enabled."""
+ plugin_changes_made = any(
+ widget.has_changed() for widget in self.plugin_widgets
+ )
+ path_changes_made = self.extra_plugin_paths != self.original_extra_plugin_paths
+ return plugin_changes_made or path_changes_made
+
+ def _has_global_plugins_enabled_change(self) -> bool:
+ """Return whether the global third-party plugin state changed."""
+ return self.plugins_enabled != self.original_plugins_enabled
+
+ def _save_configuration(self) -> None:
+ """Persist current plugin enablement and search path settings."""
+ Conf.main.plugins_enabled.set(self.plugins_enabled)
+ Conf.main.v020_plugins_warning_ignore.set(self.v020_plugins_warning_ignore)
+ enabled_plugins = [
+ widget.plugin_class.PLUGIN_INFO.name
+ for widget in self.plugin_widgets
+ if widget.is_enabled()
+ ]
+ Conf.main.plugins_enabled_list.set(enabled_plugins)
+ set_user_plugin_paths(self.extra_plugin_paths)
+
+ def _mark_current_state_as_saved(self) -> None:
+ """Synchronize original values with the current dialog state."""
+ self.original_plugins_enabled = self.plugins_enabled
+ self.original_v020_plugins_warning_ignore = self.v020_plugins_warning_ignore
+ self.original_extra_plugin_paths = list(self.extra_plugin_paths)
+
+ def _refresh_plugin_list(self) -> None:
+ """Rebuild the plugin list from the current registry state."""
+ if self.plugins_layout is None:
+ return
+
+ while self.plugins_layout.count():
+ item = self.plugins_layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.deleteLater()
+
+ self.plugin_widgets.clear()
+ self.failed_plugin_widgets.clear()
+ self.populate_plugins()
+
+ def _apply_and_reload_plugins(self) -> None:
+ """Save configuration, reload plugins, and keep dialog open."""
+ if not self.plugins_enabled:
+ return
+ global_plugins_enabled_changed = self._has_global_plugins_enabled_change()
+ if self._has_changes():
+ self._save_configuration()
+ if global_plugins_enabled_changed:
+ self.main.set_plugins_enabled(self.plugins_enabled)
+ else:
+ self.main.reload_plugins()
+ self._mark_current_state_as_saved()
+ self._refresh_plugin_list()
+ self._update_load_info_label()
+ self._update_global_plugins_ui()
+
def set_all_enabled(self, enabled: bool) -> None:
"""Set all plugin checkboxes to the same state."""
for widget in self.plugin_widgets:
@@ -540,6 +1078,7 @@ def _apply_filter(self) -> None:
def populate_plugins(self):
"""Populate the dialog with all discovered plugins"""
+ self._update_load_info_label()
registered_names = {p.info.name for p in PluginRegistry.get_plugins()}
enabled_plugins = Conf.main.plugins_enabled_list.get(None)
@@ -577,22 +1116,22 @@ def _add_plugin_widgets(
def accept(self):
"""Apply changes and close dialog"""
- # Check if any changes were made
- changes_made = any(widget.has_changed() for widget in self.plugin_widgets)
-
- if not changes_made:
+ if not self._has_changes():
super().accept()
return
- # Collect enabled plugin names
- enabled_plugins = [
- widget.plugin_class.PLUGIN_INFO.name
- for widget in self.plugin_widgets
- if widget.is_enabled()
- ]
+ global_plugins_enabled_changed = self._has_global_plugins_enabled_change()
+ reloadable_changes = self._has_reloadable_changes()
+ self._save_configuration()
- # Save to configuration
- Conf.main.plugins_enabled_list.set(enabled_plugins)
+ if global_plugins_enabled_changed:
+ self.main.set_plugins_enabled(self.plugins_enabled)
+ super().accept()
+ return
+
+ if not reloadable_changes:
+ super().accept()
+ return
# Inform user that reload is needed
reply = QW.QMessageBox.question(
diff --git a/datalab/gui/settings.py b/datalab/gui/settings.py
index 660caa91..19f4cb58 100644
--- a/datalab/gui/settings.py
+++ b/datalab/gui/settings.py
@@ -66,31 +66,6 @@ class MainSettings(gds.DataSet):
"before loading any new data"
),
)
- plugins_enabled = gds.BoolItem(
- "",
- _("Third-party plugins"),
- help=_(
- "Enable or disable third-party plugins immediately. "
- "Changes are applied without restarting DataLab"
- ),
- )
- plugins_path = gds.DirectoryItem(
- _("Plugins path"),
- allow_none=True,
- help=_(
- "Path to third-party plugins.%s environment variable (multiple paths separated by '%s'). Changes take effect at DataLab startup."
-msgstr "Des répertoires supplémentaires peuvent être ajoutés via la variable d'environnement %s (plusieurs chemins séparés par '%s'). Les modifications prennent effet au démarrage de DataLab."
+msgid "Directories provided via the %s environment variable (multiple paths separated by '%s') also appear above as read-only entries. Changes take effect at DataLab startup."
+msgstr "Les répertoires fournis via la variable d'environnement %s (plusieurs chemins séparés par '%s') apparaissent également ci-dessus en lecture seule. Les modifications prennent effet au démarrage de DataLab."
+
+msgid "Select plugin directory"
+msgstr "Sélectionner un répertoire de plugins"
msgid "Enable all plugins"
msgstr "Activer tous les plugins"
@@ -1936,6 +1970,27 @@ msgstr "Recharger les plugins"
msgid "Plugin configuration has been saved. Do you want to reload plugins now to apply changes?"
msgstr "La configuration des plugins a été enregistrée. Voulez-vous recharger les plugins maintenant pour appliquer les modifications ?"
+msgid "Plugin settings"
+msgstr "Paramètres des plugins"
+
+msgid "Third-party plugins are globally disabled."
+msgstr "Les plugins tiers sont désactivés globalement."
+
+msgid "Compatibility warnings"
+msgstr "Avertissements de compatibilité"
+
+msgid "Hide warnings for incompatible DataLab v0.20 plugins"
+msgstr "Masquer les avertissements pour les plugins DataLab v0.20 incompatibles"
+
+msgid "Disable plugins globally"
+msgstr "Désactiver globalement les plugins"
+
+msgid "Enable plugins globally"
+msgstr "Activer globalement les plugins"
+
+msgid "Enable them again from the plugin configuration dialog to use this feature."
+msgstr "Réactivez-les depuis la boîte de dialogue de configuration des plugins pour utiliser cette fonctionnalité."
+
msgid "Failed to deserialize processing parameters from JSON."
msgstr "Échec de la désérialisation des paramètres de traitement depuis le format JSON."
@@ -2796,12 +2851,6 @@ msgstr "Plugins tiers"
msgid "Enable or disable third-party plugins immediately. Changes are applied without restarting DataLab"
msgstr "Activer ou désactiver immédiatement les plugins tiers. Les changements sont appliqués sans redémarrer DataLab"
-msgid "Plugins path"
-msgstr "Chemin des plugins"
-
-msgid "Path to third-party plugins.